Compare commits
46 Commits
refactor/p
...
v3.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d77866160 | ||
|
|
7305b1124b | ||
|
|
dc79c31148 | ||
|
|
03e15916dd | ||
|
|
69c0a09604 | ||
|
|
4e23250755 | ||
|
|
5f78e58ffc | ||
|
|
e4416c9da8 | ||
|
|
6f05a91226 | ||
|
|
db80e96786 | ||
|
|
d6fa0060fb | ||
|
|
4f4c1e4ed7 | ||
|
|
ce24b37b39 | ||
|
|
a428e618d2 | ||
|
|
21d29b9c2d | ||
|
|
254896e5eb | ||
|
|
dbf220d85f | ||
|
|
b498f0fe91 | ||
|
|
f92dd4cc5a | ||
|
|
720c4d9774 | ||
|
|
bafddb8e52 | ||
|
|
05e58e9e14 | ||
|
|
802c3bffd9 | ||
|
|
7be74806e8 | ||
|
|
a6b6c199b4 | ||
|
|
0778347f84 | ||
|
|
49c2855b10 | ||
|
|
ccb011fba1 | ||
|
|
0f62829599 | ||
|
|
cc5d59ce56 | ||
|
|
4afa68eac6 | ||
|
|
36fd61b2a2 | ||
|
|
85334d8dce | ||
|
|
ab2833e626 | ||
|
|
b4f10d8316 | ||
|
|
50eb4538ca | ||
|
|
c56866f48c | ||
|
|
972650377d | ||
|
|
faeca6b6ce | ||
|
|
cb83089866 | ||
|
|
ebb7106102 | ||
|
|
4811aa2dcd | ||
|
|
2ebe34810c | ||
|
|
87f408c163 | ||
|
|
b1f7840e45 | ||
|
|
c168873c1e |
1
.gitignore
vendored
@@ -11,3 +11,4 @@ CLAUDE.md
|
||||
AGENTS.md
|
||||
/.claude
|
||||
/.vscode
|
||||
vitest-report.json
|
||||
|
||||
138
CHANGELOG.md
@@ -5,6 +5,114 @@ All notable changes to CC Switch will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [3.6.0] - 2025-11-07
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **Provider Duplicate** - Quick duplicate existing provider configurations for easy variant creation
|
||||
- **Edit Mode Toggle** - Show/hide drag handles to optimize editing experience
|
||||
- **Custom Endpoint Management** - Support multi-endpoint configuration for aggregator providers
|
||||
- **Usage Query Enhancements**
|
||||
- Auto-refresh interval: Support periodic automatic usage query
|
||||
- Test Script API: Validate JavaScript scripts before execution
|
||||
- Template system expansion: Custom blank template, support for access token and user ID parameters
|
||||
- **Configuration Editor Improvements**
|
||||
- Add JSON format button
|
||||
- Real-time TOML syntax validation for Codex configuration
|
||||
- **Auto-sync on Directory Change** - When switching Claude/Codex config directories (e.g., WSL environment), automatically sync current provider to new directory without manual operation
|
||||
- **Load Live Config When Editing Active Provider** - When editing the currently active provider, prioritize displaying the actual effective configuration to protect user manual modifications
|
||||
- **New Provider Presets** - DMXAPI, Azure Codex, AnyRouter, AiHubMix, MiniMax
|
||||
- **Partner Promotion Mechanism** - Support ecosystem partner promotion (e.g., Zhipu GLM Z.ai)
|
||||
|
||||
### 🔧 Improvements
|
||||
|
||||
- **Configuration Directory Switching**
|
||||
- Introduced unified post-change sync utility (`postChangeSync.ts`)
|
||||
- Auto-sync current providers to new directory when changing Claude/Codex config directories
|
||||
- Perfect support for WSL environment switching
|
||||
- Auto-sync after config import to ensure immediate effectiveness
|
||||
- Use Result pattern for graceful error handling without blocking main flow
|
||||
- Distinguish "fully successful" and "partially successful" states for precise user feedback
|
||||
- **UI/UX Enhancements**
|
||||
- Provider cards: Unique icons and color identification
|
||||
- Unified border design system across all components
|
||||
- Drag interaction optimization: Push effect animation, improved handle icons
|
||||
- Enhanced current provider visual feedback
|
||||
- Dialog size standardization and layout consistency
|
||||
- Form experience: Optimized model placeholders, simplified provider hints, category-specific hints
|
||||
- **Complete Internationalization Coverage**
|
||||
- Error messages internationalization
|
||||
- Tray menu internationalization
|
||||
- All UI components internationalization
|
||||
- **Usage Display Moved Inline** - Usage display moved next to enable button
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **Configuration Sync**
|
||||
- Fixed `apiKeyUrl` priority issue
|
||||
- Fixed MCP sync-to-other-side functionality failure
|
||||
- Fixed sync issues after config import
|
||||
- Prevent silent fallback and data loss on config error
|
||||
- **Usage Query**
|
||||
- Fixed auto-query interval timing issue
|
||||
- Ensure refresh button shows loading animation on click
|
||||
- **UI Issues**
|
||||
- Fixed name collision error (`get_init_error` command)
|
||||
- Fixed language setting rollback after successful save
|
||||
- Fixed language switch state reset (dependency cycle)
|
||||
- Fixed edit mode button alignment
|
||||
- **Configuration Management**
|
||||
- Fixed Codex API Key auto-sync
|
||||
- Fixed endpoint speed test functionality
|
||||
- Fixed provider duplicate insertion position (next to original provider)
|
||||
- Fixed custom endpoint preservation in edit mode
|
||||
- **Startup Issues**
|
||||
- Force exit on config error (no silent fallback)
|
||||
- Eliminate code duplication causing initialization errors
|
||||
|
||||
### 🏗️ Technical Improvements (For Developers)
|
||||
|
||||
**Backend Refactoring (Rust)** - Completed 5-phase refactoring:
|
||||
- **Phase 1**: Unified error handling (`AppError` + i18n error messages)
|
||||
- **Phase 2**: Command layer split by domain (`commands/{provider,mcp,config,settings,plugin,misc}.rs`)
|
||||
- **Phase 3**: Integration tests and transaction mechanism (config snapshot + failure rollback)
|
||||
- **Phase 4**: Extracted Service layer (`services/{provider,mcp,config,speedtest}.rs`)
|
||||
- **Phase 5**: Concurrency optimization (`RwLock` instead of `Mutex`, scoped guard to avoid deadlock)
|
||||
|
||||
**Frontend Refactoring (React + TypeScript)** - Completed 4-stage refactoring:
|
||||
- **Stage 1**: Test infrastructure (vitest + MSW + @testing-library/react)
|
||||
- **Stage 2**: Extracted custom hooks (`useProviderActions`, `useMcpActions`, `useSettings`, `useImportExport`, etc.)
|
||||
- **Stage 3**: Component splitting and business logic extraction
|
||||
- **Stage 4**: Code cleanup and formatting unification
|
||||
|
||||
**Testing System**:
|
||||
- Hooks unit tests 100% coverage
|
||||
- Integration tests covering key processes (App, SettingsDialog, MCP Panel)
|
||||
- MSW mocking backend API to ensure test independence
|
||||
|
||||
**Code Quality**:
|
||||
- Unified parameter format: All Tauri commands migrated to camelCase (Tauri 2 specification)
|
||||
- `AppType` renamed to `AppId`: Semantically clearer
|
||||
- Unified parsing with `FromStr` trait: Centralized `app` parameter parsing
|
||||
- Eliminate code duplication: DRY violations cleanup
|
||||
- Remove unused code: `missing_param` helper function, deprecated `tauri-api.ts`, redundant `KimiModelSelector` component
|
||||
|
||||
**Internal Optimizations**:
|
||||
- **Removed Legacy Migration Logic**: v3.6 removed v1 config auto-migration and copy file scanning logic
|
||||
- ✅ **Impact**: Improved startup performance, cleaner code
|
||||
- ✅ **Compatibility**: v2 format configs fully compatible, no action required
|
||||
- ⚠️ **Note**: Users upgrading from v3.1.0 or earlier should first upgrade to v3.2.x or v3.5.x for one-time migration, then upgrade to v3.6
|
||||
- **Command Parameter Standardization**: Backend unified to use `app` parameter (values: `claude` or `codex`)
|
||||
- ✅ **Impact**: More standardized code, friendlier error prompts
|
||||
- ✅ **Compatibility**: Frontend fully adapted, users don't need to care about this change
|
||||
|
||||
### 📦 Dependencies
|
||||
|
||||
- Updated to Tauri 2.8.x
|
||||
- Updated to TailwindCSS 4.x
|
||||
- Updated to TanStack Query v5.90.x
|
||||
- Maintained React 18.2.x and TypeScript 5.3.x
|
||||
|
||||
## [3.5.0] - 2025-01-15
|
||||
|
||||
### ⚠ Breaking Changes
|
||||
@@ -257,13 +365,35 @@ For users upgrading from v2.x (Electron version):
|
||||
|
||||
### ⚠️ Breaking Changes
|
||||
|
||||
- Tauri 命令统一仅接受 `app` 参数,移除历史 `app_type`/`appType` 兼容路径;传入未知 `app` 时会明确报错,并提示可选值。
|
||||
- **Runtime auto-migration from v1 to v2 config format has been removed**
|
||||
- `MultiAppConfig::load()` no longer automatically migrates v1 configs
|
||||
- When a v1 config is detected, the app now returns a clear error with migration instructions
|
||||
- **Migration path**: Install v3.2.x to perform one-time auto-migration, OR manually edit `~/.cc-switch/config.json` to v2 format
|
||||
- **Rationale**: Separates concerns (load() should be read-only), fail-fast principle, simplifies maintenance
|
||||
- Related: `app_config.rs` (v1 detection improved with structural analysis), `app_config_load.rs` (comprehensive test coverage added)
|
||||
|
||||
- **Legacy v1 copy file migration logic has been removed**
|
||||
- Removed entire `migration.rs` module (435 lines) that handled one-time migration from v3.1.0 to v3.2.0
|
||||
- No longer scans/merges legacy copy files (`settings-*.json`, `auth-*.json`, `config-*.toml`)
|
||||
- No longer archives copy files or performs automatic deduplication
|
||||
- **Migration path**: Users upgrading from v3.1.0 must first upgrade to v3.2.x to automatically migrate their configurations
|
||||
- **Benefits**: Improved startup performance (no file scanning), reduced code complexity, cleaner codebase
|
||||
|
||||
- **Tauri commands now only accept `app` parameter**
|
||||
- Removed legacy `app_type`/`appType` compatibility paths
|
||||
- Explicit error with available values when unknown `app` is provided
|
||||
|
||||
### 🔧 Improvements
|
||||
|
||||
- 统一 `AppType` 解析:集中到 `FromStr` 实现,命令层不再各自实现 `parse_app()`,减少重复与漂移。
|
||||
- 错误消息本地化与更友好:对不支持的 `app` 返回中英双语提示,并包含可选值清单。
|
||||
- Unified `AppType` parsing: centralized to `FromStr` implementation, command layer no longer implements separate `parse_app()`, reducing code duplication and drift
|
||||
- Localized and user-friendly error messages: returns bilingual (Chinese/English) hints for unsupported `app` values with a list of available options
|
||||
- Simplified startup logic: Only ensures config structure exists, no migration overhead
|
||||
|
||||
### 🧪 Tests
|
||||
|
||||
- 新增单元测试覆盖 `AppType::from_str`:大小写、裁剪空白、未知值错误消息。
|
||||
- Added unit tests covering `AppType::from_str`: case sensitivity, whitespace trimming, unknown value error messages
|
||||
- Added comprehensive config loading tests:
|
||||
- `load_v1_config_returns_error_and_does_not_write`
|
||||
- `load_v1_with_extra_version_still_treated_as_v1`
|
||||
- `load_invalid_json_returns_parse_error_and_does_not_write`
|
||||
- `load_valid_v2_config_succeeds`
|
||||
|
||||
574
README.md
@@ -1,284 +1,512 @@
|
||||
# Claude Code & Codex 供应商切换器
|
||||
# Claude Code & Codex Provider Switcher
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://tauri.app/)
|
||||
|
||||
一个用于管理和切换 Claude Code 与 Codex 不同供应商配置的桌面应用。
|
||||
English | [中文](README_ZH.md) | [Changelog](CHANGELOG.md)
|
||||
|
||||
> v3.5.0 :新增 **MCP 管理**、**配置导入/导出**、**端点速度测试**功能,完善国际化覆盖,新增 Longcat、kat-coder 预设,标准化发布文件命名规范。
|
||||
A desktop application for managing and switching between different provider configurations & MCP for Claude Code and Codex.
|
||||
|
||||
> v3.4.0 :新增 i18next 国际化(还有部分未完成)、对新模型(qwen-3-max, GLM-4.6, DeepSeek-V3.2-Exp)的支持、Claude 插件、单实例守护、托盘最小化及安装器优化等。
|
||||
</div>
|
||||
|
||||
> v3.3.0 :VS Code Codex 插件一键配置/移除(默认自动同步)、Codex 通用配置片段与自定义向导增强、WSL 环境支持、跨平台托盘与 UI 优化。(该 VS Code 写入功能已在 v3.4.x 停用)
|
||||
## ❤️ Sponsor
|
||||
|
||||
> v3.2.0 :全新 UI、macOS系统托盘、内置更新器、原子写入与回滚、改进暗色样式、单一事实源(SSOT)与一次性迁移/归档。
|
||||

|
||||
|
||||
> v3.1.0 :新增 Codex 供应商管理与一键切换,支持导入当前 Codex 配置为默认供应商,并在内部配置从 v1 → v2 迁移前自动备份(详见下文“迁移与归档”)。
|
||||
This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.
|
||||
|
||||
> v3.0.0 重大更新:从 Electron 完全迁移到 Tauri 2.0,应用体积显著降低、启动性能大幅提升。
|
||||
GLM CODING PLAN is a subscription service designed for AI coding, starting at just $3/month. It provides access to their flagship GLM-4.6 model across 10+ popular AI coding tools (Claude Code, Cline, Roo Code, etc.), offering developers top-tier, fast, and stable coding experiences.
|
||||
|
||||
## 功能特性(v3.5.0)
|
||||
Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJQFSKB)!
|
||||
|
||||
- **MCP (Model Context Protocol) 管理**:完整的 MCP 服务器配置管理系统
|
||||
- 支持 stdio 和 http 服务器类型,并提供命令校验
|
||||
- 内置常用 MCP 服务器模板(如 mcp-fetch 等)
|
||||
- 实时启用/禁用 MCP 服务器,原子文件写入防止配置损坏
|
||||
- **配置导入/导出**:备份和恢复你的供应商配置
|
||||
- 一键导出所有配置到 JSON 文件
|
||||
- 导入配置时自动验证并备份,自动轮换备份(保留最近 10 个)
|
||||
- 带有详细状态反馈的进度模态框
|
||||
- **端点速度测试**:测试 API 端点响应时间
|
||||
- 测量不同供应商端点的延迟,可视化连接质量指示器
|
||||
- 帮助用户选择最快的供应商
|
||||
- **国际化与语言切换**:完整的 i18next 国际化覆盖,默认显示中文,可在设置中快速切换到英文,界面文案自动实时刷新。
|
||||
- **Claude 插件同步**:内置按钮可一键应用或恢复 Claude 插件配置,切换供应商后立即生效。
|
||||
- **供应商预设扩展**:新增 Longcat、kat-coder 等预设,更新 GLM 供应商配置至最新模型。
|
||||
- **系统托盘与窗口行为**:窗口关闭可最小化到托盘,macOS 支持托盘模式下隐藏/显示 Dock,托盘切换时同步 Claude/Codex/插件状态。
|
||||
- **单实例**:保证同一时间仅运行一个实例,避免多开冲突。
|
||||
- **标准化发布命名**:所有平台发布文件使用一致的版本标签命名(macOS: `.tar.gz` / `.zip`,Windows: `.msi` / `-Portable.zip`,Linux: `.AppImage` / `.deb`)。
|
||||
## Release Notes
|
||||
|
||||
## 界面预览
|
||||
> **v3.6.0**: Added edit mode (provider duplication, manual sorting), custom endpoint management, usage query features. Optimized config directory switching experience (perfect WSL environment support). Added multiple provider presets (DMXAPI, Azure Codex, AnyRouter, AiHubMix, MiniMax). Completed full-stack architecture refactoring and testing infrastructure.
|
||||
|
||||
### 主界面
|
||||
> v3.5.0: Added MCP management, config import/export, endpoint speed testing. Complete i18n coverage. Added Longcat and kat-coder presets. Standardized release file naming conventions.
|
||||
|
||||

|
||||
> v3.4.0: Added i18next internationalization, support for new models (qwen-3-max, GLM-4.6, DeepSeek-V3.2-Exp), Claude plugin, single-instance daemon, tray minimize, and installer optimizations.
|
||||
|
||||
### 添加供应商
|
||||
> v3.3.0: One-click VS Code Codex plugin configuration/removal (auto-sync by default), Codex common config snippets, enhanced custom wizard, WSL environment support, cross-platform tray and UI optimizations. (VS Code write feature deprecated in v3.4.x)
|
||||
|
||||

|
||||
> v3.2.0: Brand new UI, macOS system tray, built-in updater, atomic write with rollback, improved dark mode, Single Source of Truth (SSOT) with one-time migration/archival.
|
||||
|
||||
## 下载安装
|
||||
> v3.1.0: Added Codex provider management with one-click switching. Import current Codex config as default provider. Auto-backup before internal config v1 → v2 migration (see "Migration & Archival" below).
|
||||
|
||||
### 系统要求
|
||||
> v3.0.0 Major Update: Complete migration from Electron to Tauri 2.0. Significantly reduced app size and greatly improved startup performance.
|
||||
|
||||
- **Windows**: Windows 10 及以上
|
||||
- **macOS**: macOS 10.15 (Catalina) 及以上
|
||||
- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+ 等主流发行版
|
||||
## Features (v3.6.0)
|
||||
|
||||
### Windows 用户
|
||||
### Core Features
|
||||
|
||||
从 [Releases](../../releases) 页面下载最新版本的 `CC-Switch-v{版本号}-Windows.msi` 安装包或者 `CC-Switch-v{版本号}-Windows-Portable.zip` 绿色版。
|
||||
- **MCP (Model Context Protocol) Management**: Complete MCP server configuration management system
|
||||
- Support for stdio and http server types with command validation
|
||||
- Built-in templates for popular MCP servers (e.g., mcp-fetch)
|
||||
- Real-time enable/disable MCP servers with atomic file writes to prevent configuration corruption
|
||||
- **Config Import/Export**: Backup and restore your provider configurations
|
||||
- One-click export all configurations to JSON file
|
||||
- Import configs with automatic validation and backup, auto-rotate backups (keep 10 most recent)
|
||||
- Auto-sync to live config files after import to ensure immediate effect
|
||||
- **Endpoint Speed Testing**: Test API endpoint response times
|
||||
- Measure latency to different provider endpoints with visual connection quality indicators
|
||||
- Help users choose the fastest provider
|
||||
- **Internationalization & Language Switching**: Complete i18next i18n coverage (including error messages, tray menu, all UI components)
|
||||
- **Claude Plugin Sync**: Built-in button to apply or restore Claude plugin configurations with one click. Takes effect immediately after switching providers.
|
||||
|
||||
### macOS 用户
|
||||
### v3.6 New Features
|
||||
|
||||
**方式一:通过 Homebrew 安装(推荐)**
|
||||
- **Provider Duplication**: Quickly duplicate existing provider configs to easily create variants
|
||||
- **Manual Sorting**: Drag and drop to manually reorder providers
|
||||
- **Custom Endpoint Management**: Support multi-endpoint configuration for aggregator providers
|
||||
- **Usage Query Features**
|
||||
- Auto-refresh interval: Supports periodic automatic usage queries
|
||||
- Test Script API: Validate JavaScript scripts before execution
|
||||
- Template system expansion: Custom blank templates, support for access token and user ID parameters
|
||||
- **Config Editor Improvements**
|
||||
- Added JSON format button
|
||||
- Real-time TOML syntax validation (for Codex configs)
|
||||
- **Auto-sync on Directory Change**: When switching Claude/Codex config directories (e.g., switching to WSL environment), automatically sync current provider to new directory to avoid config file conflicts
|
||||
- **Load Live Config When Editing Active Provider**: When editing the currently active provider, prioritize displaying the actual effective configuration to protect user manual modifications
|
||||
- **New Provider Presets**: DMXAPI, Azure Codex, AnyRouter, AiHubMix, MiniMax
|
||||
- **Partner Promotion Mechanism**: Support ecosystem partner promotion (e.g., Zhipu GLM Z.ai)
|
||||
|
||||
### v3.6 Architecture Improvements
|
||||
|
||||
- **Backend Refactoring**: Completed 5-phase refactoring (unified error handling → command layer split → integration tests → Service layer extraction → concurrency optimization)
|
||||
- **Frontend Refactoring**: Completed 4-stage refactoring (test infrastructure → Hooks extraction → component splitting → code cleanup)
|
||||
- **Testing System**: 100% Hooks unit test coverage, integration tests covering critical flows (vitest + MSW + @testing-library/react)
|
||||
|
||||
### System Features
|
||||
|
||||
- **System Tray & Window Behavior**: Window can minimize to tray, macOS supports hide/show Dock in tray mode, tray switching syncs Claude/Codex/plugin status.
|
||||
- **Single Instance**: Ensures only one instance runs at a time to avoid multi-instance conflicts.
|
||||
- **Standardized Release Naming**: All platform release files use consistent version-tagged naming (macOS: `.tar.gz` / `.zip`, Windows: `.msi` / `-Portable.zip`, Linux: `.AppImage` / `.deb`).
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Main Interface
|
||||
|
||||

|
||||
|
||||
### Add Provider
|
||||
|
||||

|
||||
|
||||
## Download & Installation
|
||||
|
||||
### System Requirements
|
||||
|
||||
- **Windows**: Windows 10 and above
|
||||
- **macOS**: macOS 10.15 (Catalina) and above
|
||||
- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+ and other mainstream distributions
|
||||
|
||||
### Windows Users
|
||||
|
||||
Download the latest `CC-Switch-v{version}-Windows.msi` installer or `CC-Switch-v{version}-Windows-Portable.zip` portable version from the [Releases](../../releases) page.
|
||||
|
||||
### macOS Users
|
||||
|
||||
**Method 1: Install via Homebrew (Recommended)**
|
||||
|
||||
```bash
|
||||
brew tap farion1231/ccswitch
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
更新:
|
||||
Update:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
**方式二:手动下载**
|
||||
**Method 2: Manual Download**
|
||||
|
||||
从 [Releases](../../releases) 页面下载 `CC-Switch-v{版本号}-macOS.zip` 解压使用。
|
||||
Download `CC-Switch-v{version}-macOS.zip` from the [Releases](../../releases) page and extract to use.
|
||||
|
||||
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开
|
||||
> **Note**: Since the author doesn't have an Apple Developer account, you may see an "unidentified developer" warning on first launch. Please close it first, then go to "System Settings" → "Privacy & Security" → click "Open Anyway", and you'll be able to open it normally afterwards.
|
||||
|
||||
### Linux 用户
|
||||
### Linux Users
|
||||
|
||||
从 [Releases](../../releases) 页面下载最新版本的 `CC-Switch-v{版本号}-Linux.deb` 包或者 `CC-Switch-v{版本号}-Linux.AppImage` 安装包。
|
||||
Download the latest `CC-Switch-v{version}-Linux.deb` package or `CC-Switch-v{version}-Linux.AppImage` from the [Releases](../../releases) page.
|
||||
|
||||
## 使用说明
|
||||
## Usage Guide
|
||||
|
||||
1. 点击"添加供应商"添加你的 API 配置
|
||||
2. 切换方式:
|
||||
- 在主界面选择供应商后点击切换
|
||||
- 或通过“系统托盘(菜单栏)”直接选择目标供应商,立即生效
|
||||
3. 切换会写入对应应用的“live 配置文件”(Claude:`settings.json`;Codex:`auth.json` + `config.toml`)
|
||||
4. 重启或新开终端以确保生效
|
||||
5. 若需切回官方登录,在预设中选择“官方登录”并切换即可;重启终端后按官方流程登录
|
||||
1. Click "Add Provider" to add your API configuration
|
||||
2. Switching methods:
|
||||
- Select a provider on the main interface and click switch
|
||||
- Or directly select target provider from "System Tray (Menu Bar)" for immediate effect
|
||||
3. Switching will write to the corresponding app's "live config file" (Claude: `settings.json`; Codex: `auth.json` + `config.toml`)
|
||||
4. Restart or open new terminal to ensure it takes effect
|
||||
5. To switch back to official login, select "Official Login" from presets and switch; after restarting terminal, follow the official login process
|
||||
|
||||
### MCP 配置说明(v3.5.x)
|
||||
### MCP Configuration Guide (v3.5.x)
|
||||
|
||||
- 管理位置:所有 MCP 服务器定义集中保存在 `~/.cc-switch/config.json`(按客户端 `claude` / `codex` 分类)
|
||||
- 同步机制:
|
||||
- 启用的 Claude MCP 会投影到 `~/.claude.json`(路径可随覆盖目录而变化)
|
||||
- 启用的 Codex MCP 会投影到 `~/.codex/config.toml`
|
||||
- 校验与归一化:新增/导入时自动校验字段合法性(stdio/http),并自动修复/填充 `id` 等键名
|
||||
- 导入来源:支持从 `~/.claude.json` 与 `~/.codex/config.toml` 导入;已存在条目只强制 `enabled=true`,不覆盖其他字段
|
||||
- Management Location: All MCP server definitions are centrally saved in `~/.cc-switch/config.json` (categorized by client `claude` / `codex`)
|
||||
- Sync Mechanism:
|
||||
- Enabled Claude MCP servers are projected to `~/.claude.json` (path may vary with override directory)
|
||||
- Enabled Codex MCP servers are projected to `~/.codex/config.toml`
|
||||
- Validation & Normalization: Auto-validate field legality (stdio/http) when adding/importing, and auto-fix/populate keys like `id`
|
||||
- Import Sources: Support importing from `~/.claude.json` and `~/.codex/config.toml`; existing entries only force `enabled=true`, don't override other fields
|
||||
|
||||
### 检查更新
|
||||
### Check for Updates
|
||||
|
||||
- 在“设置”中点击“检查更新”,若内置 Updater 配置可用将直接检测与下载;否则会回退打开 Releases 页面
|
||||
- Click "Check for Updates" in Settings. If built-in Updater config is available, it will detect and download directly; otherwise, it will fall back to opening the Releases page
|
||||
|
||||
### Codex 说明(SSOT)
|
||||
### Codex Guide (SSOT)
|
||||
|
||||
- 配置目录:`~/.codex/`
|
||||
- live 主配置:`auth.json`(必需)、`config.toml`(可为空)
|
||||
- API Key 字段:`auth.json` 中使用 `OPENAI_API_KEY`
|
||||
- 切换行为(不再写“副本文件”):
|
||||
- 供应商配置统一保存在 `~/.cc-switch/config.json`
|
||||
- 切换时将目标供应商写回 live 文件(`auth.json` + `config.toml`)
|
||||
- 采用“原子写入 + 失败回滚”,避免半写状态;`config.toml` 可为空
|
||||
- 导入默认:当该应用无任何供应商时,从现有 live 主配置创建一条默认项并设为当前
|
||||
- 官方登录:可切换到预设“Codex 官方登录”,重启终端后按官方流程登录
|
||||
- Config Directory: `~/.codex/`
|
||||
- Live main config: `auth.json` (required), `config.toml` (can be empty)
|
||||
- API Key Field: Uses `OPENAI_API_KEY` in `auth.json`
|
||||
- Switching Behavior (no longer writes "copy files"):
|
||||
- Provider configs are uniformly saved in `~/.cc-switch/config.json`
|
||||
- When switching, writes target provider back to live files (`auth.json` + `config.toml`)
|
||||
- Uses "atomic write + rollback on failure" to avoid half-written state; `config.toml` can be empty
|
||||
- Import Default: When the app has no providers, creates a default entry from existing live main config and sets it as current
|
||||
- Official Login: Can switch to preset "Codex Official Login", restart terminal and follow official login process
|
||||
|
||||
### Claude Code 说明(SSOT)
|
||||
### Claude Code Guide (SSOT)
|
||||
|
||||
- 配置目录:`~/.claude/`
|
||||
- live 主配置:`settings.json`(优先)或历史兼容 `claude.json`
|
||||
- API Key 字段:`env.ANTHROPIC_AUTH_TOKEN`
|
||||
- 切换行为(不再写“副本文件”):
|
||||
- 供应商配置统一保存在 `~/.cc-switch/config.json`
|
||||
- 切换时将目标供应商 JSON 直接写入 live 文件(优先 `settings.json`)
|
||||
- 编辑当前供应商时,先写 live 成功,再更新应用主配置,保证一致性
|
||||
- 导入默认:当该应用无任何供应商时,从现有 live 主配置创建一条默认项并设为当前
|
||||
- 官方登录:可切换到预设“Claude 官方登录”,重启终端后可使用 `/login` 完成登录
|
||||
- Config Directory: `~/.claude/`
|
||||
- Live main config: `settings.json` (preferred) or legacy-compatible `claude.json`
|
||||
- API Key Field: `env.ANTHROPIC_AUTH_TOKEN`
|
||||
- Switching Behavior (no longer writes "copy files"):
|
||||
- Provider configs are uniformly saved in `~/.cc-switch/config.json`
|
||||
- When switching, writes target provider JSON directly to live file (preferring `settings.json`)
|
||||
- When editing current provider, writes live first successfully, then updates app main config to ensure consistency
|
||||
- Import Default: When the app has no providers, creates a default entry from existing live main config and sets it as current
|
||||
- Official Login: Can switch to preset "Claude Official Login", restart terminal and use `/login` to complete login
|
||||
|
||||
### 迁移与归档(自 v3.2.0 起)
|
||||
### Migration & Archival
|
||||
|
||||
- 一次性迁移:首次启动 3.2.0 及以上版本会扫描旧的“副本文件”并合并到 `~/.cc-switch/config.json`
|
||||
- Claude:`~/.claude/settings-*.json`(排除 `settings.json` / 历史 `claude.json`)
|
||||
- Codex:`~/.codex/auth-*.json` 与 `config-*.toml`(按名称成对合并)
|
||||
- 去重与当前项:按“名称(忽略大小写)+ API Key”去重;若当前为空,将 live 合并项设为当前
|
||||
- 归档与清理:
|
||||
- 归档目录:`~/.cc-switch/archive/<timestamp>/<category>/...`
|
||||
- 归档成功后删除原副本;失败则保留原文件(保守策略)
|
||||
- v1 → v2 结构升级:会额外生成 `~/.cc-switch/config.v1.backup.<timestamp>.json` 以便回滚
|
||||
- 注意:迁移后不再持续归档日常切换/编辑操作,如需长期审计请自备备份方案
|
||||
#### v3.6 Technical Improvements
|
||||
|
||||
## 架构总览(v3.5.x)
|
||||
**Internal Optimizations (User Transparent)**:
|
||||
|
||||
- 前端(Renderer)
|
||||
- 技术栈:TypeScript + React 18 + Vite + TailwindCSS
|
||||
- 数据层:TanStack React Query 统一查询与变更(`@/lib/query`),Tauri API 统一封装(`@/lib/api`)
|
||||
- 事件流:监听后端 `provider-switched` 事件,驱动 UI 刷新与托盘状态一致
|
||||
- 组织结构:按领域拆分组件(providers/settings/mcp),动作逻辑下沉至 Hooks(如 `useProviderActions`)
|
||||
- **Removed Legacy Migration Logic**: v3.6 removed v1 config auto-migration and copy file scanning logic
|
||||
- ✅ **Impact**: Improved startup performance, cleaner code
|
||||
- ✅ **Compatibility**: v2 format configs are fully compatible, no action required
|
||||
- ⚠️ **Note**: Users upgrading from v3.1.0 or earlier should first upgrade to v3.2.x or v3.5.x for one-time migration, then upgrade to v3.6
|
||||
|
||||
- 后端(Tauri + Rust)
|
||||
- Commands(接口层):`src-tauri/src/commands/*` 按领域拆分(provider/config/mcp 等)
|
||||
- Services(业务层):`src-tauri/src/services/*` 承载核心逻辑(Provider/MCP/Config/Speedtest)
|
||||
- 模型与状态:`provider.rs`(领域模型)+ `app_config.rs`(多应用配置)+ `store.rs`(全局 RwLock)
|
||||
- 可靠性:
|
||||
- 统一错误类型 `AppError`(包含本地化消息)
|
||||
- 事务式变更(配置快照 + 失败回滚)与原子写入(避免半写入)
|
||||
- 托盘菜单与事件:切换后重建菜单并向前端发射 `provider-switched` 事件
|
||||
- **Command Parameter Standardization**: Backend unified to use `app` parameter (values: `claude` or `codex`)
|
||||
- ✅ **Impact**: More standardized code, friendlier error messages
|
||||
- ✅ **Compatibility**: Frontend fully adapted, users don't need to care about this change
|
||||
|
||||
- 设计要点(SSOT)
|
||||
- 单一事实源:供应商配置集中存放于 `~/.cc-switch/config.json`
|
||||
- 切换时仅写 live 配置(Claude: `settings.json`;Codex: `auth.json` + `config.toml`)
|
||||
- 首次缺省导入:当某应用无任何供应商时,会从已有 live 配置生成默认项
|
||||
#### Startup Failure & Recovery
|
||||
|
||||
- 兼容性与变更
|
||||
- 命令参数统一:Tauri 命令仅接受 `app`(值为 `claude` / `codex`)
|
||||
- 前端类型统一:使用 `AppId` 表达应用标识(替代历史 `AppType` 导出)
|
||||
- Trigger Conditions: Triggered when `~/.cc-switch/config.json` doesn't exist, is corrupted, or fails to parse.
|
||||
- User Action: Check JSON syntax according to popup prompt, or restore from backup files.
|
||||
- Backup Location & Rotation: `~/.cc-switch/backups/backup_YYYYMMDD_HHMMSS.json` (keep up to 10, see `src-tauri/src/services/config.rs`).
|
||||
- Exit Strategy: To protect data safety, the app will show a popup and force exit when the above errors occur; restart after fixing.
|
||||
|
||||
## 开发
|
||||
#### Migration Mechanism (v3.2.0+)
|
||||
|
||||
### 环境要求
|
||||
- One-time Migration: First launch of v3.2.0+ will scan old "copy files" and merge into `~/.cc-switch/config.json`
|
||||
- Claude: `~/.claude/settings-*.json` (excluding `settings.json` / legacy `claude.json`)
|
||||
- Codex: `~/.codex/auth-*.json` and `config-*.toml` (merged in pairs by name)
|
||||
- Deduplication & Current Item: Deduplicate by "name (case-insensitive) + API Key"; if current is empty, set live merged item as current
|
||||
- Archival & Cleanup:
|
||||
- Archive directory: `~/.cc-switch/archive/<timestamp>/<category>/...`
|
||||
- Delete original copies after successful archival; keep original files on failure (conservative strategy)
|
||||
- v1 → v2 Structure Upgrade: Additionally generates `~/.cc-switch/config.v1.backup.<timestamp>.json` for rollback
|
||||
- Note: After migration, daily switch/edit operations are no longer archived; prepare your own backup solution if long-term auditing is needed
|
||||
|
||||
## Architecture Overview (v3.6)
|
||||
|
||||
### Architecture Refactoring Highlights (v3.6)
|
||||
|
||||
**Backend Refactoring (Rust)**: Completed 5-phase refactoring
|
||||
|
||||
- **Phase 1**: Unified error handling (`AppError` + i18n error messages)
|
||||
- **Phase 2**: Command layer split by domain (`commands/{provider,mcp,config,settings,plugin,misc}.rs`)
|
||||
- **Phase 3**: Introduced integration tests and transaction mechanism (config snapshot + failure rollback)
|
||||
- **Phase 4**: Extracted Service layer (`services/{provider,mcp,config,speedtest}.rs`)
|
||||
- **Phase 5**: Concurrency optimization (`RwLock` instead of `Mutex`, scoped guard to avoid deadlock)
|
||||
|
||||
**Frontend Refactoring (React + TypeScript)**: Completed 4-stage refactoring
|
||||
|
||||
- **Stage 1**: Established test infrastructure (vitest + MSW + @testing-library/react)
|
||||
- **Stage 2**: Extracted custom hooks (`useProviderActions`, `useMcpActions`, `useSettings`, `useImportExport`, etc.)
|
||||
- **Stage 3**: Component splitting and business logic extraction
|
||||
- **Stage 4**: Code cleanup and formatting unification
|
||||
|
||||
**Test Coverage**:
|
||||
|
||||
- 100% Hooks unit test coverage
|
||||
- Integration tests covering critical flows (App, SettingsDialog, MCP Panel)
|
||||
- MSW mocking backend API to ensure test independence
|
||||
|
||||
### Layered Architecture
|
||||
|
||||
- **Frontend (Renderer)**
|
||||
- Tech Stack: TypeScript + React 18 + Vite + TailwindCSS 4
|
||||
- Data Layer: TanStack React Query unified queries and mutations (`@/lib/query`), Tauri API unified wrapper (`@/lib/api`)
|
||||
- Business Logic Layer: Custom Hooks (`@/hooks`) carry domain logic, components stay simple
|
||||
- Event Flow: Listen to backend `provider-switched` events, drive UI refresh and tray state consistency
|
||||
- Organization: Components split by domain (`providers/settings/mcp/ui`)
|
||||
|
||||
- **Backend (Tauri + Rust)**
|
||||
- **Commands Layer** (Interface Layer): `src-tauri/src/commands/*` split by domain, only responsible for parameter parsing and permission validation
|
||||
- **Services Layer** (Business Layer): `src-tauri/src/services/*` carry core logic, reusable and testable
|
||||
- `ProviderService`: Provider CRUD, switch, backfill, sorting
|
||||
- `McpService`: MCP server management, import/export, sync
|
||||
- `ConfigService`: Config file import/export, backup/restore
|
||||
- `SpeedtestService`: API endpoint latency testing
|
||||
- **Models & State**:
|
||||
- `provider.rs`: Domain models (`Provider`, `ProviderManager`, `ProviderMeta`)
|
||||
- `app_config.rs`: Multi-app config (`MultiAppConfig`, `AppId`, `McpRoot`)
|
||||
- `store.rs`: Global state (`AppState` + `RwLock<MultiAppConfig>`)
|
||||
- **Reliability**:
|
||||
- Unified error type `AppError` (with localized messages)
|
||||
- Transactional changes (config snapshot + failure rollback)
|
||||
- Atomic writes (temp file + rename, avoid half-writes)
|
||||
- Tray menu & events: Rebuild menu after switch and emit `provider-switched` event to frontend
|
||||
|
||||
- **Design Points (SSOT + Dual-way Sync)**
|
||||
- **Single Source of Truth**: Provider configs centrally stored in `~/.cc-switch/config.json`
|
||||
- **Write on Switch**: Write target provider config to live files (Claude: `settings.json`; Codex: `auth.json` + `config.toml`)
|
||||
- **Backfill Mechanism**: Immediately read back live files after switch, update SSOT to protect user manual modifications
|
||||
- **Directory Switch Sync**: Auto-sync current provider to new directory when changing config directories (perfect WSL environment support)
|
||||
- **Prioritize Live When Editing**: When editing current provider, prioritize loading live config to ensure display of actually effective configuration
|
||||
|
||||
- **Compatibility & Changes**
|
||||
- Command Parameters Unified: Tauri commands only accept `app` (values: `claude` / `codex`)
|
||||
- Frontend Types Unified: Use `AppId` to express app identifiers (replacing legacy `AppType` export)
|
||||
|
||||
## Development
|
||||
|
||||
### Environment Requirements
|
||||
|
||||
- Node.js 18+
|
||||
- pnpm 8+
|
||||
- Rust 1.75+
|
||||
- Tauri CLI 2.0+
|
||||
- Rust 1.85+
|
||||
- Tauri CLI 2.8+
|
||||
|
||||
### 开发命令
|
||||
### Development Commands
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# 开发模式(热重载)
|
||||
# Dev mode (hot reload)
|
||||
pnpm dev
|
||||
|
||||
# 类型检查
|
||||
# Type check
|
||||
pnpm typecheck
|
||||
|
||||
# 代码格式化
|
||||
# Format code
|
||||
pnpm format
|
||||
|
||||
# 检查代码格式
|
||||
# Check code format
|
||||
pnpm format:check
|
||||
|
||||
# 运行前端单元测试
|
||||
# Run frontend unit tests
|
||||
pnpm test:unit
|
||||
|
||||
# 监听模式运行测试
|
||||
# Run tests in watch mode (recommended for development)
|
||||
pnpm test:unit:watch
|
||||
|
||||
# 构建应用
|
||||
# Build application
|
||||
pnpm build
|
||||
|
||||
# 构建调试版本
|
||||
# Build debug version
|
||||
pnpm tauri build --debug
|
||||
```
|
||||
|
||||
### Rust 后端开发
|
||||
### Rust Backend Development
|
||||
|
||||
```bash
|
||||
cd src-tauri
|
||||
|
||||
# 格式化 Rust 代码
|
||||
# Format Rust code
|
||||
cargo fmt
|
||||
|
||||
# 运行 clippy 检查
|
||||
# Run clippy checks
|
||||
cargo clippy
|
||||
|
||||
# 运行测试
|
||||
# Run backend tests
|
||||
cargo test
|
||||
|
||||
# Run specific tests
|
||||
cargo test test_name
|
||||
|
||||
# Run tests with test-hooks feature
|
||||
cargo test --features test-hooks
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
### Testing Guide (v3.6 New)
|
||||
|
||||
- **[Tauri 2](https://tauri.app/)** - 跨平台桌面应用框架(集成 updater/process/opener/log/tray-icon)
|
||||
- **[React 18](https://react.dev/)** - 用户界面库
|
||||
- **[TypeScript](https://www.typescriptlang.org/)** - 类型安全的 JavaScript
|
||||
- **[Vite](https://vitejs.dev/)** - 极速的前端构建工具
|
||||
- **[Rust](https://www.rust-lang.org/)** - 系统级编程语言(后端)
|
||||
- **[TanStack Query](https://tanstack.com/query/latest)** - 前端数据获取与缓存
|
||||
- **[i18next](https://www.i18next.com/)** - 国际化框架
|
||||
**Frontend Testing**:
|
||||
|
||||
## 项目结构
|
||||
- Uses **vitest** as test framework
|
||||
- Uses **MSW (Mock Service Worker)** to mock Tauri API calls
|
||||
- Uses **@testing-library/react** for component testing
|
||||
|
||||
**Test Coverage**:
|
||||
|
||||
- ✅ Hooks unit tests (100% coverage)
|
||||
- `useProviderActions` - Provider operations
|
||||
- `useMcpActions` - MCP management
|
||||
- `useSettings` series - Settings management
|
||||
- `useImportExport` - Import/export
|
||||
- ✅ Integration tests
|
||||
- App main application flow
|
||||
- SettingsDialog complete interaction
|
||||
- MCP panel functionality
|
||||
|
||||
**Running Tests**:
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pnpm test:unit
|
||||
|
||||
# Watch mode (auto re-run)
|
||||
pnpm test:unit:watch
|
||||
|
||||
# With coverage report
|
||||
pnpm test:unit --coverage
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
### Frontend
|
||||
|
||||
- **[React 18](https://react.dev/)** - User interface library
|
||||
- **[TypeScript](https://www.typescriptlang.org/)** - Type-safe JavaScript
|
||||
- **[Vite](https://vitejs.dev/)** - Lightning fast frontend build tool
|
||||
- **[TailwindCSS 4](https://tailwindcss.com/)** - Utility-first CSS framework
|
||||
- **[TanStack Query v5](https://tanstack.com/query/latest)** - Powerful data fetching and caching
|
||||
- **[react-i18next](https://react.i18next.com/)** - React internationalization framework
|
||||
- **[react-hook-form](https://react-hook-form.com/)** - High-performance forms library
|
||||
- **[zod](https://zod.dev/)** - TypeScript-first schema validation
|
||||
- **[shadcn/ui](https://ui.shadcn.com/)** - Reusable React components
|
||||
- **[@dnd-kit](https://dndkit.com/)** - Modern drag and drop toolkit
|
||||
|
||||
### Backend
|
||||
|
||||
- **[Tauri 2.8](https://tauri.app/)** - Cross-platform desktop app framework
|
||||
- tauri-plugin-updater - Auto update
|
||||
- tauri-plugin-process - Process management
|
||||
- tauri-plugin-dialog - File dialogs
|
||||
- tauri-plugin-store - Persistent storage
|
||||
- tauri-plugin-log - Logging
|
||||
- **[Rust](https://www.rust-lang.org/)** - Systems programming language
|
||||
- **[serde](https://serde.rs/)** - Serialization/deserialization framework
|
||||
- **[tokio](https://tokio.rs/)** - Async runtime
|
||||
- **[thiserror](https://github.com/dtolnay/thiserror)** - Error handling derive macro
|
||||
|
||||
### Testing Tools
|
||||
|
||||
- **[vitest](https://vitest.dev/)** - Fast unit testing framework
|
||||
- **[MSW](https://mswjs.io/)** - API mocking tool
|
||||
- **[@testing-library/react](https://testing-library.com/react)** - React testing utilities
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
├── src/ # 前端代码 (React + TypeScript)
|
||||
│ ├── components/ # React 组件(providers/settings/mcp/ui 等)
|
||||
│ ├── hooks/ # 领域动作与状态(如 useProviderActions)
|
||||
├── src/ # Frontend code (React + TypeScript)
|
||||
│ ├── components/ # React components
|
||||
│ │ ├── providers/ # Provider management components
|
||||
│ │ │ ├── forms/ # Form sub-components (Claude/Codex fields)
|
||||
│ │ │ ├── ProviderList.tsx
|
||||
│ │ │ ├── ProviderForm.tsx
|
||||
│ │ │ ├── AddProviderDialog.tsx
|
||||
│ │ │ └── EditProviderDialog.tsx
|
||||
│ │ ├── settings/ # Settings related components
|
||||
│ │ │ ├── SettingsDialog.tsx
|
||||
│ │ │ ├── DirectorySettings.tsx
|
||||
│ │ │ └── ImportExportSection.tsx
|
||||
│ │ ├── mcp/ # MCP management components
|
||||
│ │ │ ├── McpPanel.tsx
|
||||
│ │ │ ├── McpFormModal.tsx
|
||||
│ │ │ └── McpWizard.tsx
|
||||
│ │ └── ui/ # shadcn/ui base components
|
||||
│ ├── hooks/ # Custom Hooks (business logic layer)
|
||||
│ │ ├── useProviderActions.ts # Provider operations
|
||||
│ │ ├── useMcpActions.ts # MCP operations
|
||||
│ │ ├── useSettings.ts # Settings management
|
||||
│ │ ├── useImportExport.ts # Import/export
|
||||
│ │ └── useDirectorySettings.ts # Directory config
|
||||
│ ├── lib/
|
||||
│ │ ├── api/ # Tauri API 封装(providers/settings/mcp 等)
|
||||
│ │ └── query/ # TanStack Query 查询/变更与 client
|
||||
│ ├── i18n/ # 国际化资源
|
||||
│ ├── config/ # 供应商/MCP 预设
|
||||
│ └── utils/ # 工具函数
|
||||
├── src-tauri/ # 后端代码 (Rust)
|
||||
│ ├── src/ # Rust 源代码
|
||||
│ │ ├── commands/ # Tauri 命令定义(按域拆分)
|
||||
│ │ ├── services/ # 领域服务(Provider/MCP/Speedtest 等)
|
||||
│ │ ├── mcp.rs # MCP 同步与规范化
|
||||
│ │ ├── migration.rs # 配置迁移逻辑
|
||||
│ │ ├── config.rs # 配置文件管理
|
||||
│ │ ├── provider.rs # 供应商管理逻辑
|
||||
│ │ └── store.rs # 状态管理
|
||||
│ ├── capabilities/ # 权限配置
|
||||
│ └── icons/ # 应用图标资源
|
||||
└── screenshots/ # 界面截图
|
||||
│ │ ├── api/ # Tauri API wrapper (type-safe)
|
||||
│ │ │ ├── providers.ts # Provider API
|
||||
│ │ │ ├── settings.ts # Settings API
|
||||
│ │ │ ├── mcp.ts # MCP API
|
||||
│ │ │ └── usage.ts # Usage query API
|
||||
│ │ └── query/ # TanStack Query config
|
||||
│ │ ├── queries.ts # Query definitions
|
||||
│ │ ├── mutations.ts # Mutation definitions
|
||||
│ │ └── queryClient.ts
|
||||
│ ├── i18n/ # Internationalization resources
|
||||
│ │ └── locales/
|
||||
│ │ ├── zh/ # Chinese translations
|
||||
│ │ └── en/ # English translations
|
||||
│ ├── config/ # Config & presets
|
||||
│ │ ├── claudeProviderPresets.ts # Claude provider presets
|
||||
│ │ ├── codexProviderPresets.ts # Codex provider presets
|
||||
│ │ └── mcpPresets.ts # MCP server templates
|
||||
│ ├── utils/ # Utility functions
|
||||
│ │ ├── postChangeSync.ts # Config sync utility
|
||||
│ │ └── ...
|
||||
│ └── types/ # TypeScript type definitions
|
||||
├── src-tauri/ # Backend code (Rust)
|
||||
│ ├── src/
|
||||
│ │ ├── commands/ # Tauri command layer (split by domain)
|
||||
│ │ │ ├── provider.rs # Provider commands
|
||||
│ │ │ ├── mcp.rs # MCP commands
|
||||
│ │ │ ├── config.rs # Config query commands
|
||||
│ │ │ ├── settings.rs # Settings commands
|
||||
│ │ │ ├── plugin.rs # Plugin commands
|
||||
│ │ │ ├── import_export.rs # Import/export commands
|
||||
│ │ │ └── misc.rs # Misc commands
|
||||
│ │ ├── services/ # Service layer (business logic)
|
||||
│ │ │ ├── provider.rs # ProviderService
|
||||
│ │ │ ├── mcp.rs # McpService
|
||||
│ │ │ ├── config.rs # ConfigService
|
||||
│ │ │ └── speedtest.rs # SpeedtestService
|
||||
│ │ ├── app_config.rs # Config data models
|
||||
│ │ ├── provider.rs # Provider domain models
|
||||
│ │ ├── store.rs # Global state management
|
||||
│ │ ├── mcp.rs # MCP sync & validation
|
||||
│ │ ├── error.rs # Unified error type
|
||||
│ │ ├── usage_script.rs # Usage script execution
|
||||
│ │ ├── claude_plugin.rs # Claude plugin management
|
||||
│ │ └── lib.rs # App entry point
|
||||
│ ├── capabilities/ # Tauri permission config
|
||||
│ └── icons/ # App icons
|
||||
├── tests/ # Frontend tests (v3.6 new)
|
||||
│ ├── hooks/ # Hooks unit tests
|
||||
│ ├── components/ # Component integration tests
|
||||
│ └── setup.ts # Test config
|
||||
└── assets/ # Static resources
|
||||
├── screenshots/ # Interface screenshots
|
||||
└── partners/ # Partner resources
|
||||
├── logos/ # Partner logos
|
||||
└── banners/ # Partner banners/promotional images
|
||||
```
|
||||
|
||||
## 更新日志
|
||||
## Changelog
|
||||
|
||||
查看 [CHANGELOG.md](CHANGELOG.md) 了解版本更新详情。
|
||||
See [CHANGELOG.md](CHANGELOG.md) for version update details.
|
||||
|
||||
## Electron 旧版
|
||||
## Legacy Electron Version
|
||||
|
||||
[Releases](../../releases) 里保留 v2.0.3 Electron 旧版
|
||||
[Releases](../../releases) retains v2.0.3 legacy Electron version
|
||||
|
||||
如果需要旧版 Electron 代码,可以拉取 electron-legacy 分支
|
||||
If you need legacy Electron code, you can pull the electron-legacy branch
|
||||
|
||||
## 贡献
|
||||
## Contributing
|
||||
|
||||
欢迎提交 Issue 反馈问题和建议!
|
||||
Issues and suggestions are welcome!
|
||||
|
||||
提交 PR 前请确保:
|
||||
- 通过类型检查:`pnpm typecheck`
|
||||
- 通过格式检查:`pnpm format:check`
|
||||
- 通过单元测试:`pnpm test:unit`
|
||||
Before submitting PRs, please ensure:
|
||||
|
||||
- Pass type check: `pnpm typecheck`
|
||||
- Pass format check: `pnpm format:check`
|
||||
- Pass unit tests: `pnpm test:unit`
|
||||
- Functional PRs should be discussed in the issue area first
|
||||
|
||||
## Star History
|
||||
|
||||
|
||||
517
README_ZH.md
Normal file
@@ -0,0 +1,517 @@
|
||||
# Claude Code & Codex 供应商管理器
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://tauri.app/)
|
||||
|
||||
[English](README.md) | 中文 | [更新日志](CHANGELOG.md)
|
||||
|
||||
一个用于管理和切换 Claude Code 与 Codex 不同供应商配置、MCP的桌面应用。
|
||||
|
||||
</div>
|
||||
|
||||
## ❤️ 赞助商
|
||||
|
||||

|
||||
|
||||
感谢智谱AI的 GLM CODING PLAN 赞助了本项目!
|
||||
|
||||
GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元,即可在十余款主流AI编码工具如 Claude Code、Cline 中畅享智谱旗舰模型 GLM-4.6,为开发者提供顶尖、高速、稳定的编码体验。
|
||||
|
||||
CC Switch 已经预设了智谱GLM,只需要填写 key 即可一键导入编程工具。智谱AI为本软件的用户提供了特别优惠,使用[此链接](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)购买可以享受九折优惠。
|
||||
|
||||
## 更新记录
|
||||
|
||||
> **v3.6.0** :新增编辑模式(供应商复制、手动排序)、自定义端点管理、使用量查询等功能,优化配置目录切换体验(WSL 环境完美支持),新增多个供应商预设(DMXAPI、Azure Codex、AnyRouter、AiHubMix、MiniMax),完成全栈架构重构和测试体系建设。
|
||||
|
||||
> v3.5.0 :新增 MCP 管理、配置导入/导出、端点速度测试功能,完善国际化覆盖,新增 Longcat、kat-coder 预设,标准化发布文件命名规范。
|
||||
|
||||
> v3.4.0 :新增 i18next 国际化、对新模型(qwen-3-max, GLM-4.6, DeepSeek-V3.2-Exp)的支持、Claude 插件、单实例守护、托盘最小化及安装器优化等。
|
||||
|
||||
> v3.3.0 :VS Code Codex 插件一键配置/移除(默认自动同步)、Codex 通用配置片段与自定义向导增强、WSL 环境支持、跨平台托盘与 UI 优化。(该 VS Code 写入功能已在 v3.4.x 停用)
|
||||
|
||||
> v3.2.0 :全新 UI、macOS系统托盘、内置更新器、原子写入与回滚、改进暗色样式、单一事实源(SSOT)与一次性迁移/归档。
|
||||
|
||||
> v3.1.0 :新增 Codex 供应商管理与一键切换,支持导入当前 Codex 配置为默认供应商,并在内部配置从 v1 → v2 迁移前自动备份(详见下文“迁移与归档”)。
|
||||
|
||||
> v3.0.0 重大更新:从 Electron 完全迁移到 Tauri 2.0,应用体积显著降低、启动性能大幅提升。
|
||||
|
||||
## 功能特性(v3.6.0)
|
||||
|
||||
### 核心功能
|
||||
|
||||
- **MCP (Model Context Protocol) 管理**:完整的 MCP 服务器配置管理系统
|
||||
- 支持 stdio 和 http 服务器类型,并提供命令校验
|
||||
- 内置常用 MCP 服务器模板(如 mcp-fetch 等)
|
||||
- 实时启用/禁用 MCP 服务器,原子文件写入防止配置损坏
|
||||
- **配置导入/导出**:备份和恢复你的供应商配置
|
||||
- 一键导出所有配置到 JSON 文件
|
||||
- 导入配置时自动验证并备份,自动轮换备份(保留最近 10 个)
|
||||
- 导入后自动同步到 live 配置文件,确保立即生效
|
||||
- **端点速度测试**:测试 API 端点响应时间
|
||||
- 测量不同供应商端点的延迟,可视化连接质量指示器
|
||||
- 帮助用户选择最快的供应商
|
||||
- **国际化与语言切换**:完整的 i18next 国际化覆盖(包含错误消息、托盘菜单、所有 UI 组件)
|
||||
- **Claude 插件同步**:内置按钮可一键应用或恢复 Claude 插件配置,切换供应商后立即生效。
|
||||
|
||||
### v3.6 新增功能
|
||||
|
||||
- **供应商复制功能**:快速复制现有供应商配置,轻松创建变体配置
|
||||
- **手动排序功能**:通过拖拽来对供应商进行手动排序
|
||||
- **自定义端点管理**:支持聚合类供应商的多端点配置
|
||||
- **使用量查询功能**
|
||||
- 自动刷新间隔:支持定时自动查询使用量
|
||||
- 测试脚本 API:测试 JavaScript 脚本是否正确
|
||||
- 模板系统扩展:自定义空白模板、支持 access token 和 user ID 参数
|
||||
- **配置编辑器改进**
|
||||
- 新增 JSON 格式化按钮
|
||||
- 实时 TOML 语法验证(Codex 配置)
|
||||
- **配置目录切换自动同步**:切换 Claude/Codex 配置目录(如切换到 WSL 环境)时,自动同步当前供应商到新目录,避免冲突导致配置文件混乱
|
||||
- **编辑当前供应商时加载 live 配置**:编辑正在使用的供应商时,优先显示实际生效的配置,保护用户手动修改
|
||||
- **新增供应商预设**:DMXAPI、Azure Codex、AnyRouter、AiHubMix、MiniMax
|
||||
- **合作伙伴推广机制**:支持生态合作伙伴推广(如智谱 GLM Z.ai)
|
||||
|
||||
### v3.6 架构改进
|
||||
|
||||
- **后端重构**:完成 5 阶段重构(统一错误处理 → 命令层拆分 → 集成测试 → Service 层提取 → 并发优化)
|
||||
- **前端重构**:完成 4 阶段重构(测试基础设施 → Hooks 提取 → 组件拆分 → 代码清理)
|
||||
- **测试体系**:Hooks 单元测试 100% 覆盖,集成测试覆盖关键流程(vitest + MSW + @testing-library/react)
|
||||
|
||||
### 系统功能
|
||||
|
||||
- **系统托盘与窗口行为**:窗口关闭可最小化到托盘,macOS 支持托盘模式下隐藏/显示 Dock,托盘切换时同步 Claude/Codex/插件状态。
|
||||
- **单实例**:保证同一时间仅运行一个实例,避免多开冲突。
|
||||
- **标准化发布命名**:所有平台发布文件使用一致的版本标签命名(macOS: `.tar.gz` / `.zip`,Windows: `.msi` / `-Portable.zip`,Linux: `.AppImage` / `.deb`)。
|
||||
|
||||
## 界面预览
|
||||
|
||||
### 主界面
|
||||
|
||||

|
||||
|
||||
### 添加供应商
|
||||
|
||||

|
||||
|
||||
## 下载安装
|
||||
|
||||
### 系统要求
|
||||
|
||||
- **Windows**: Windows 10 及以上
|
||||
- **macOS**: macOS 10.15 (Catalina) 及以上
|
||||
- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+ 等主流发行版
|
||||
|
||||
### Windows 用户
|
||||
|
||||
从 [Releases](../../releases) 页面下载最新版本的 `CC-Switch-v{版本号}-Windows.msi` 安装包或者 `CC-Switch-v{版本号}-Windows-Portable.zip` 绿色版。
|
||||
|
||||
### macOS 用户
|
||||
|
||||
**方式一:通过 Homebrew 安装(推荐)**
|
||||
|
||||
```bash
|
||||
brew tap farion1231/ccswitch
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
更新:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
**方式二:手动下载**
|
||||
|
||||
从 [Releases](../../releases) 页面下载 `CC-Switch-v{版本号}-macOS.zip` 解压使用。
|
||||
|
||||
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开
|
||||
|
||||
### Linux 用户
|
||||
|
||||
从 [Releases](../../releases) 页面下载最新版本的 `CC-Switch-v{版本号}-Linux.deb` 包或者 `CC-Switch-v{版本号}-Linux.AppImage` 安装包。
|
||||
|
||||
## 使用说明
|
||||
|
||||
1. 点击"添加供应商"添加你的 API 配置
|
||||
2. 切换方式:
|
||||
- 在主界面选择供应商后点击切换
|
||||
- 或通过“系统托盘(菜单栏)”直接选择目标供应商,立即生效
|
||||
3. 切换会写入对应应用的“live 配置文件”(Claude:`settings.json`;Codex:`auth.json` + `config.toml`)
|
||||
4. 重启或新开终端以确保生效
|
||||
5. 若需切回官方登录,在预设中选择“官方登录”并切换即可;重启终端后按官方流程登录
|
||||
|
||||
### MCP 配置说明(v3.5.x)
|
||||
|
||||
- 管理位置:所有 MCP 服务器定义集中保存在 `~/.cc-switch/config.json`(按客户端 `claude` / `codex` 分类)
|
||||
- 同步机制:
|
||||
- 启用的 Claude MCP 会投影到 `~/.claude.json`(路径可随覆盖目录而变化)
|
||||
- 启用的 Codex MCP 会投影到 `~/.codex/config.toml`
|
||||
- 校验与归一化:新增/导入时自动校验字段合法性(stdio/http),并自动修复/填充 `id` 等键名
|
||||
- 导入来源:支持从 `~/.claude.json` 与 `~/.codex/config.toml` 导入;已存在条目只强制 `enabled=true`,不覆盖其他字段
|
||||
|
||||
### 检查更新
|
||||
|
||||
- 在“设置”中点击“检查更新”,若内置 Updater 配置可用将直接检测与下载;否则会回退打开 Releases 页面
|
||||
|
||||
### Codex 说明(SSOT)
|
||||
|
||||
- 配置目录:`~/.codex/`
|
||||
- live 主配置:`auth.json`(必需)、`config.toml`(可为空)
|
||||
- API Key 字段:`auth.json` 中使用 `OPENAI_API_KEY`
|
||||
- 切换行为(不再写“副本文件”):
|
||||
- 供应商配置统一保存在 `~/.cc-switch/config.json`
|
||||
- 切换时将目标供应商写回 live 文件(`auth.json` + `config.toml`)
|
||||
- 采用“原子写入 + 失败回滚”,避免半写状态;`config.toml` 可为空
|
||||
- 导入默认:当该应用无任何供应商时,从现有 live 主配置创建一条默认项并设为当前
|
||||
- 官方登录:可切换到预设“Codex 官方登录”,重启终端后按官方流程登录
|
||||
|
||||
### Claude Code 说明(SSOT)
|
||||
|
||||
- 配置目录:`~/.claude/`
|
||||
- live 主配置:`settings.json`(优先)或历史兼容 `claude.json`
|
||||
- API Key 字段:`env.ANTHROPIC_AUTH_TOKEN`
|
||||
- 切换行为(不再写“副本文件”):
|
||||
- 供应商配置统一保存在 `~/.cc-switch/config.json`
|
||||
- 切换时将目标供应商 JSON 直接写入 live 文件(优先 `settings.json`)
|
||||
- 编辑当前供应商时,先写 live 成功,再更新应用主配置,保证一致性
|
||||
- 导入默认:当该应用无任何供应商时,从现有 live 主配置创建一条默认项并设为当前
|
||||
- 官方登录:可切换到预设“Claude 官方登录”,重启终端后可使用 `/login` 完成登录
|
||||
|
||||
### 迁移与归档
|
||||
|
||||
#### v3.6 技术改进
|
||||
|
||||
**内部优化(用户无感知)**:
|
||||
|
||||
- **移除遗留迁移逻辑**:v3.6 移除了 v1 配置自动迁移和副本文件扫描逻辑
|
||||
- ✅ **影响**:启动性能提升,代码更简洁
|
||||
- ✅ **兼容性**:v2 格式配置完全兼容,无需任何操作
|
||||
- ⚠️ **注意**:从 v3.1.0 或更早版本升级的用户,请先升级到 v3.2.x 或 v3.5.x 完成一次性迁移,再升级到 v3.6
|
||||
|
||||
- **命令参数标准化**:后端统一使用 `app` 参数(取值:`claude` 或 `codex`)
|
||||
- ✅ **影响**:代码更规范,错误提示更友好
|
||||
- ✅ **兼容性**:前端已完全适配,用户无需关心此变更
|
||||
|
||||
#### 启动失败与恢复
|
||||
|
||||
- 触发条件:`~/.cc-switch/config.json` 不存在、损坏或解析失败时触发。
|
||||
- 用户动作:根据弹窗提示检查 JSON 语法,或从备份文件恢复。
|
||||
- 备份位置与轮换:`~/.cc-switch/backups/backup_YYYYMMDD_HHMMSS.json`(最多保留 10 个,参见 `src-tauri/src/services/config.rs`)。
|
||||
- 退出策略:为保护数据安全,出现上述错误时应用会弹窗提示并强制退出;修复后重新启动即可。
|
||||
|
||||
#### v3.2.0 起的迁移机制
|
||||
|
||||
- 一次性迁移:首次启动 3.2.0 及以上版本会扫描旧的"副本文件"并合并到 `~/.cc-switch/config.json`
|
||||
- Claude:`~/.claude/settings-*.json`(排除 `settings.json` / 历史 `claude.json`)
|
||||
- Codex:`~/.codex/auth-*.json` 与 `config-*.toml`(按名称成对合并)
|
||||
- 去重与当前项:按"名称(忽略大小写)+ API Key"去重;若当前为空,将 live 合并项设为当前
|
||||
- 归档与清理:
|
||||
- 归档目录:`~/.cc-switch/archive/<timestamp>/<category>/...`
|
||||
- 归档成功后删除原副本;失败则保留原文件(保守策略)
|
||||
- v1 → v2 结构升级:会额外生成 `~/.cc-switch/config.v1.backup.<timestamp>.json` 以便回滚
|
||||
- 注意:迁移后不再持续归档日常切换/编辑操作,如需长期审计请自备备份方案
|
||||
|
||||
## 架构总览(v3.6)
|
||||
|
||||
### 架构重构亮点(v3.6)
|
||||
|
||||
**后端重构(Rust)**:完成 5 阶段重构
|
||||
|
||||
- **Phase 1**:统一错误处理(`AppError` + 国际化错误消息)
|
||||
- **Phase 2**:命令层按领域拆分(`commands/{provider,mcp,config,settings,plugin,misc}.rs`)
|
||||
- **Phase 3**:引入集成测试和事务机制(配置快照 + 失败回滚)
|
||||
- **Phase 4**:提取 Service 层(`services/{provider,mcp,config,speedtest}.rs`)
|
||||
- **Phase 5**:并发优化(`RwLock` 替代 `Mutex`,作用域 guard 避免死锁)
|
||||
|
||||
**前端重构(React + TypeScript)**:完成 4 阶段重构
|
||||
|
||||
- **Stage 1**:建立测试基础设施(vitest + MSW + @testing-library/react)
|
||||
- **Stage 2**:提取自定义 hooks(`useProviderActions`, `useMcpActions`, `useSettings`, `useImportExport` 等)
|
||||
- **Stage 3**:组件拆分和业务逻辑提取
|
||||
- **Stage 4**:代码清理和格式化统一
|
||||
|
||||
**测试覆盖**:
|
||||
|
||||
- Hooks 单元测试 100% 覆盖
|
||||
- 集成测试覆盖关键流程(App、SettingsDialog、MCP 面板)
|
||||
- MSW 模拟后端 API,确保测试独立性
|
||||
|
||||
### 分层架构
|
||||
|
||||
- **前端(Renderer)**
|
||||
- 技术栈:TypeScript + React 18 + Vite + TailwindCSS 4
|
||||
- 数据层:TanStack React Query 统一查询与变更(`@/lib/query`),Tauri API 统一封装(`@/lib/api`)
|
||||
- 业务逻辑层:自定义 Hooks(`@/hooks`)承载领域逻辑,组件保持简洁
|
||||
- 事件流:监听后端 `provider-switched` 事件,驱动 UI 刷新与托盘状态一致
|
||||
- 组织结构:按领域拆分组件(`providers/settings/mcp/ui`)
|
||||
|
||||
- **后端(Tauri + Rust)**
|
||||
- **Commands 层**(接口层):`src-tauri/src/commands/*` 按领域拆分,仅负责参数解析和权限校验
|
||||
- **Services 层**(业务层):`src-tauri/src/services/*` 承载核心逻辑,可复用和测试
|
||||
- `ProviderService`:供应商增删改查、切换、回填、排序
|
||||
- `McpService`:MCP 服务器管理、导入导出、同步
|
||||
- `ConfigService`:配置文件导入导出、备份恢复
|
||||
- `SpeedtestService`:API 端点延迟测试
|
||||
- **模型与状态**:
|
||||
- `provider.rs`:领域模型(`Provider`, `ProviderManager`, `ProviderMeta`)
|
||||
- `app_config.rs`:多应用配置(`MultiAppConfig`, `AppId`, `McpRoot`)
|
||||
- `store.rs`:全局状态(`AppState` + `RwLock<MultiAppConfig>`)
|
||||
- **可靠性**:
|
||||
- 统一错误类型 `AppError`(包含本地化消息)
|
||||
- 事务式变更(配置快照 + 失败回滚)
|
||||
- 原子写入(临时文件 + 重命名,避免半写入)
|
||||
- 托盘菜单与事件:切换后重建菜单并向前端发射 `provider-switched` 事件
|
||||
|
||||
- **设计要点(SSOT + 双向同步)**
|
||||
- **单一事实源**:供应商配置集中存放于 `~/.cc-switch/config.json`
|
||||
- **切换时写入**:将目标供应商配置写入 live 文件(Claude: `settings.json`;Codex: `auth.json` + `config.toml`)
|
||||
- **回填机制**:切换后立即读回 live 文件,更新 SSOT,保护用户手动修改
|
||||
- **目录切换同步**:修改配置目录时自动同步当前供应商到新目录(WSL 环境完美支持)
|
||||
- **编辑时优先 live**:编辑当前供应商时,优先加载 live 配置,确保显示实际生效的配置
|
||||
|
||||
- **兼容性与变更**
|
||||
- 命令参数统一:Tauri 命令仅接受 `app`(值为 `claude` / `codex`)
|
||||
- 前端类型统一:使用 `AppId` 表达应用标识(替代历史 `AppType` 导出)
|
||||
|
||||
## 开发
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Node.js 18+
|
||||
- pnpm 8+
|
||||
- Rust 1.85+
|
||||
- Tauri CLI 2.8+
|
||||
|
||||
### 开发命令
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
pnpm install
|
||||
|
||||
# 开发模式(热重载)
|
||||
pnpm dev
|
||||
|
||||
# 类型检查
|
||||
pnpm typecheck
|
||||
|
||||
# 代码格式化
|
||||
pnpm format
|
||||
|
||||
# 检查代码格式
|
||||
pnpm format:check
|
||||
|
||||
# 运行前端单元测试
|
||||
pnpm test:unit
|
||||
|
||||
# 监听模式运行测试(推荐开发时使用)
|
||||
pnpm test:unit:watch
|
||||
|
||||
# 构建应用
|
||||
pnpm build
|
||||
|
||||
# 构建调试版本
|
||||
pnpm tauri build --debug
|
||||
```
|
||||
|
||||
### Rust 后端开发
|
||||
|
||||
```bash
|
||||
cd src-tauri
|
||||
|
||||
# 格式化 Rust 代码
|
||||
cargo fmt
|
||||
|
||||
# 运行 clippy 检查
|
||||
cargo clippy
|
||||
|
||||
# 运行后端测试
|
||||
cargo test
|
||||
|
||||
# 运行特定测试
|
||||
cargo test test_name
|
||||
|
||||
# 运行带测试 hooks 的测试
|
||||
cargo test --features test-hooks
|
||||
```
|
||||
|
||||
### 测试说明(v3.6 新增)
|
||||
|
||||
**前端测试**:
|
||||
|
||||
- 使用 **vitest** 作为测试框架
|
||||
- 使用 **MSW (Mock Service Worker)** 模拟 Tauri API 调用
|
||||
- 使用 **@testing-library/react** 进行组件测试
|
||||
|
||||
**测试覆盖**:
|
||||
|
||||
- ✅ Hooks 单元测试(100% 覆盖)
|
||||
- `useProviderActions` - 供应商操作
|
||||
- `useMcpActions` - MCP 管理
|
||||
- `useSettings` 系列 - 设置管理
|
||||
- `useImportExport` - 导入导出
|
||||
- ✅ 集成测试
|
||||
- App 主应用流程
|
||||
- SettingsDialog 完整交互
|
||||
- MCP 面板功能
|
||||
|
||||
**运行测试**:
|
||||
|
||||
```bash
|
||||
# 运行所有测试
|
||||
pnpm test:unit
|
||||
|
||||
# 监听模式(自动重跑)
|
||||
pnpm test:unit:watch
|
||||
|
||||
# 带覆盖率报告
|
||||
pnpm test:unit --coverage
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
|
||||
### 前端
|
||||
|
||||
- **[React 18](https://react.dev/)** - 用户界面库
|
||||
- **[TypeScript](https://www.typescriptlang.org/)** - 类型安全的 JavaScript
|
||||
- **[Vite](https://vitejs.dev/)** - 极速的前端构建工具
|
||||
- **[TailwindCSS 4](https://tailwindcss.com/)** - 实用优先的 CSS 框架
|
||||
- **[TanStack Query v5](https://tanstack.com/query/latest)** - 强大的数据获取与缓存
|
||||
- **[react-i18next](https://react.i18next.com/)** - React 国际化框架
|
||||
- **[react-hook-form](https://react-hook-form.com/)** - 高性能表单库
|
||||
- **[zod](https://zod.dev/)** - TypeScript 优先的模式验证
|
||||
- **[shadcn/ui](https://ui.shadcn.com/)** - 可复用的 React 组件
|
||||
- **[@dnd-kit](https://dndkit.com/)** - 现代拖拽工具包
|
||||
|
||||
### 后端
|
||||
|
||||
- **[Tauri 2.8](https://tauri.app/)** - 跨平台桌面应用框架
|
||||
- tauri-plugin-updater - 自动更新
|
||||
- tauri-plugin-process - 进程管理
|
||||
- tauri-plugin-dialog - 文件对话框
|
||||
- tauri-plugin-store - 持久化存储
|
||||
- tauri-plugin-log - 日志记录
|
||||
- **[Rust](https://www.rust-lang.org/)** - 系统级编程语言
|
||||
- **[serde](https://serde.rs/)** - 序列化/反序列化框架
|
||||
- **[tokio](https://tokio.rs/)** - 异步运行时
|
||||
- **[thiserror](https://github.com/dtolnay/thiserror)** - 错误处理派生宏
|
||||
|
||||
### 测试工具
|
||||
|
||||
- **[vitest](https://vitest.dev/)** - 快速的单元测试框架
|
||||
- **[MSW](https://mswjs.io/)** - API mock 工具
|
||||
- **[@testing-library/react](https://testing-library.com/react)** - React 测试工具
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
├── src/ # 前端代码 (React + TypeScript)
|
||||
│ ├── components/ # React 组件
|
||||
│ │ ├── providers/ # 供应商管理组件
|
||||
│ │ │ ├── forms/ # 表单子组件(Claude/Codex 字段)
|
||||
│ │ │ ├── ProviderList.tsx
|
||||
│ │ │ ├── ProviderForm.tsx
|
||||
│ │ │ ├── AddProviderDialog.tsx
|
||||
│ │ │ └── EditProviderDialog.tsx
|
||||
│ │ ├── settings/ # 设置相关组件
|
||||
│ │ │ ├── SettingsDialog.tsx
|
||||
│ │ │ ├── DirectorySettings.tsx
|
||||
│ │ │ └── ImportExportSection.tsx
|
||||
│ │ ├── mcp/ # MCP 管理组件
|
||||
│ │ │ ├── McpPanel.tsx
|
||||
│ │ │ ├── McpFormModal.tsx
|
||||
│ │ │ └── McpWizard.tsx
|
||||
│ │ └── ui/ # shadcn/ui 基础组件
|
||||
│ ├── hooks/ # 自定义 Hooks(业务逻辑层)
|
||||
│ │ ├── useProviderActions.ts # 供应商操作
|
||||
│ │ ├── useMcpActions.ts # MCP 操作
|
||||
│ │ ├── useSettings.ts # 设置管理
|
||||
│ │ ├── useImportExport.ts # 导入导出
|
||||
│ │ └── useDirectorySettings.ts # 目录配置
|
||||
│ ├── lib/
|
||||
│ │ ├── api/ # Tauri API 封装(类型安全)
|
||||
│ │ │ ├── providers.ts # 供应商 API
|
||||
│ │ │ ├── settings.ts # 设置 API
|
||||
│ │ │ ├── mcp.ts # MCP API
|
||||
│ │ │ └── usage.ts # 用量查询 API
|
||||
│ │ └── query/ # TanStack Query 配置
|
||||
│ │ ├── queries.ts # 查询定义
|
||||
│ │ ├── mutations.ts # 变更定义
|
||||
│ │ └── queryClient.ts
|
||||
│ ├── i18n/ # 国际化资源
|
||||
│ │ └── locales/
|
||||
│ │ ├── zh/ # 中文翻译
|
||||
│ │ └── en/ # 英文翻译
|
||||
│ ├── config/ # 配置与预设
|
||||
│ │ ├── claudeProviderPresets.ts # Claude 供应商预设
|
||||
│ │ ├── codexProviderPresets.ts # Codex 供应商预设
|
||||
│ │ └── mcpPresets.ts # MCP 服务器模板
|
||||
│ ├── utils/ # 工具函数
|
||||
│ │ ├── postChangeSync.ts # 配置同步工具
|
||||
│ │ └── ...
|
||||
│ └── types/ # TypeScript 类型定义
|
||||
├── src-tauri/ # 后端代码 (Rust)
|
||||
│ ├── src/
|
||||
│ │ ├── commands/ # Tauri 命令层(按领域拆分)
|
||||
│ │ │ ├── provider.rs # 供应商命令
|
||||
│ │ │ ├── mcp.rs # MCP 命令
|
||||
│ │ │ ├── config.rs # 配置查询命令
|
||||
│ │ │ ├── settings.rs # 设置命令
|
||||
│ │ │ ├── plugin.rs # 插件命令
|
||||
│ │ │ ├── import_export.rs # 导入导出命令
|
||||
│ │ │ └── misc.rs # 杂项命令
|
||||
│ │ ├── services/ # Service 层(业务逻辑)
|
||||
│ │ │ ├── provider.rs # ProviderService
|
||||
│ │ │ ├── mcp.rs # McpService
|
||||
│ │ │ ├── config.rs # ConfigService
|
||||
│ │ │ └── speedtest.rs # SpeedtestService
|
||||
│ │ ├── app_config.rs # 配置数据模型
|
||||
│ │ ├── provider.rs # 供应商领域模型
|
||||
│ │ ├── store.rs # 全局状态管理
|
||||
│ │ ├── mcp.rs # MCP 同步与校验
|
||||
│ │ ├── error.rs # 统一错误类型
|
||||
│ │ ├── usage_script.rs # 用量脚本执行
|
||||
│ │ ├── claude_plugin.rs # Claude 插件管理
|
||||
│ │ └── lib.rs # 应用入口
|
||||
│ ├── capabilities/ # Tauri 权限配置
|
||||
│ └── icons/ # 应用图标
|
||||
├── tests/ # 前端测试(v3.6 新增)
|
||||
│ ├── hooks/ # Hooks 单元测试
|
||||
│ ├── components/ # 组件集成测试
|
||||
│ └── setup.ts # 测试配置
|
||||
└── assets/ # 静态资源
|
||||
├── screenshots/ # 界面截图
|
||||
└── partners/ # 合作商资源
|
||||
├── logos/ # 合作商 Logo
|
||||
└── banners/ # 合作商横幅/宣传图
|
||||
```
|
||||
|
||||
## 更新日志
|
||||
|
||||
查看 [CHANGELOG.md](CHANGELOG.md) 了解版本更新详情。
|
||||
|
||||
## Electron 旧版
|
||||
|
||||
[Releases](../../releases) 里保留 v2.0.3 Electron 旧版
|
||||
|
||||
如果需要旧版 Electron 代码,可以拉取 electron-legacy 分支
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎提交 Issue 反馈问题和建议!
|
||||
|
||||
提交 PR 前请确保:
|
||||
|
||||
- 通过类型检查:`pnpm typecheck`
|
||||
- 通过格式检查:`pnpm format:check`
|
||||
- 通过单元测试:`pnpm test:unit`
|
||||
- 功能性 PR 请先经过 issue 区讨论
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#farion1231/cc-switch&Date)
|
||||
|
||||
## License
|
||||
|
||||
MIT © Jason Young
|
||||
BIN
assets/partners/banners/glm-en.jpg
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
assets/partners/banners/glm-zh.jpg
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
assets/screenshots/add-en.png
Normal file
|
After Width: | Height: | Size: 185 KiB |
BIN
assets/screenshots/add-zh.png
Normal file
|
After Width: | Height: | Size: 203 KiB |
BIN
assets/screenshots/main-en.png
Normal file
|
After Width: | Height: | Size: 204 KiB |
BIN
assets/screenshots/main-zh.png
Normal file
|
After Width: | Height: | Size: 205 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cc-switch",
|
||||
"version": "3.5.1",
|
||||
"version": "3.6.0",
|
||||
"description": "Claude Code & Codex 供应商切换工具",
|
||||
"scripts": {
|
||||
"dev": "pnpm tauri dev",
|
||||
|
||||
|
Before Width: | Height: | Size: 203 KiB |
|
Before Width: | Height: | Size: 200 KiB |
2
src-tauri/Cargo.lock
generated
@@ -563,7 +563,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc-switch"
|
||||
version = "3.5.1"
|
||||
version = "3.6.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cc-switch"
|
||||
version = "3.5.1"
|
||||
version = "3.6.0"
|
||||
description = "Claude Code & Codex 供应商配置管理工具"
|
||||
authors = ["Jason Young"]
|
||||
license = "MIT"
|
||||
|
||||
@@ -89,7 +89,7 @@ impl Default for MultiAppConfig {
|
||||
}
|
||||
|
||||
impl MultiAppConfig {
|
||||
/// 从文件加载配置(处理v1到v2的迁移)
|
||||
/// 从文件加载配置(仅支持 v2 结构)
|
||||
pub fn load() -> Result<Self, AppError> {
|
||||
let config_path = get_app_config_path();
|
||||
|
||||
@@ -102,45 +102,27 @@ impl MultiAppConfig {
|
||||
let content =
|
||||
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
||||
|
||||
// 检查是否是旧版本格式(v1)
|
||||
if let Ok(v1_config) = serde_json::from_str::<ProviderManager>(&content) {
|
||||
log::info!("检测到v1配置,自动迁移到v2");
|
||||
|
||||
// 迁移到新格式
|
||||
let mut apps = HashMap::new();
|
||||
apps.insert("claude".to_string(), v1_config);
|
||||
apps.insert("codex".to_string(), ProviderManager::default());
|
||||
|
||||
let config = Self {
|
||||
version: 2,
|
||||
apps,
|
||||
mcp: McpRoot::default(),
|
||||
};
|
||||
|
||||
// 迁移前备份旧版(v1)配置文件
|
||||
let backup_dir = get_app_config_dir();
|
||||
let ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let backup_path = backup_dir.join(format!("config.v1.backup.{}.json", ts));
|
||||
|
||||
match copy_file(&config_path, &backup_path) {
|
||||
Ok(()) => log::info!(
|
||||
"已备份旧版配置文件: {} -> {}",
|
||||
config_path.display(),
|
||||
backup_path.display()
|
||||
),
|
||||
Err(e) => log::warn!("备份旧版配置文件失败: {}", e),
|
||||
}
|
||||
|
||||
// 保存迁移后的配置
|
||||
config.save()?;
|
||||
return Ok(config);
|
||||
// 先解析为 Value,以便严格判定是否为 v1 结构;
|
||||
// 满足:顶层同时包含 providers(object) + current(string),且不包含 version/apps/mcp 关键键,即视为 v1
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(&content).map_err(|e| AppError::json(&config_path, e))?;
|
||||
let is_v1 = value.as_object().is_some_and(|map| {
|
||||
let has_providers = map.get("providers").map(|v| v.is_object()).unwrap_or(false);
|
||||
let has_current = map.get("current").map(|v| v.is_string()).unwrap_or(false);
|
||||
// v1 的充分必要条件:有 providers 和 current,且 apps 不存在(version/mcp 可能存在但不作为 v2 判据)
|
||||
let has_apps = map.contains_key("apps");
|
||||
has_providers && has_current && !has_apps
|
||||
});
|
||||
if is_v1 {
|
||||
return Err(AppError::localized(
|
||||
"config.unsupported_v1",
|
||||
"检测到旧版 v1 配置格式。当前版本已不再支持运行时自动迁移。\n\n解决方案:\n1. 安装 v3.2.x 版本进行一次性自动迁移\n2. 或手动编辑 ~/.cc-switch/config.json,将顶层结构调整为:\n {\"version\": 2, \"claude\": {...}, \"codex\": {...}, \"mcp\": {...}}\n\n",
|
||||
"Detected legacy v1 config. Runtime auto-migration is no longer supported.\n\nSolutions:\n1. Install v3.2.x for one-time auto-migration\n2. Or manually edit ~/.cc-switch/config.json to adjust the top-level structure:\n {\"version\": 2, \"claude\": {...}, \"codex\": {...}, \"mcp\": {...}}\n\n",
|
||||
));
|
||||
}
|
||||
|
||||
// 尝试读取v2格式
|
||||
serde_json::from_str::<Self>(&content).map_err(|e| AppError::json(&config_path, e))
|
||||
// 解析 v2 结构
|
||||
serde_json::from_value::<Self>(value).map_err(|e| AppError::json(&config_path, e))
|
||||
}
|
||||
|
||||
/// 保存配置到文件
|
||||
|
||||
@@ -56,8 +56,6 @@ pub fn delete_codex_provider_config(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
//(移除未使用的备份/保存/恢复/导入函数,避免 dead_code 告警)
|
||||
|
||||
/// 原子写 Codex 的 `auth.json` 与 `config.toml`,在第二步失败时回滚第一步
|
||||
pub fn write_codex_live_atomic(
|
||||
auth: &Value,
|
||||
@@ -118,15 +116,6 @@ pub fn read_codex_config_text() -> Result<String, AppError> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 从给定路径读取 config.toml 文本(路径存在时);路径不存在则返回空字符串
|
||||
pub fn read_config_text_from_path(path: &Path) -> Result<String, AppError> {
|
||||
if path.exists() {
|
||||
std::fs::read_to_string(path).map_err(|e| AppError::io(path, e))
|
||||
} else {
|
||||
Ok(String::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// 对非空的 TOML 文本进行语法校验
|
||||
pub fn validate_config_toml(text: &str) -> Result<(), AppError> {
|
||||
if text.trim().is_empty() {
|
||||
@@ -143,10 +132,3 @@ pub fn read_and_validate_codex_config_text() -> Result<String, AppError> {
|
||||
validate_config_toml(&s)?;
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// 从指定路径读取并校验 config.toml,返回文本(可能为空)
|
||||
pub fn read_and_validate_config_from_path(path: &Path) -> Result<String, AppError> {
|
||||
let s = read_config_text_from_path(path)?;
|
||||
validate_config_toml(&s)?;
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
@@ -73,9 +73,9 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool,
|
||||
#[tauri::command]
|
||||
pub async fn pick_directory(
|
||||
app: AppHandle,
|
||||
default_path: Option<String>,
|
||||
#[allow(non_snake_case)] defaultPath: Option<String>,
|
||||
) -> Result<Option<String>, String> {
|
||||
let initial = default_path
|
||||
let initial = defaultPath
|
||||
.map(|p| p.trim().to_string())
|
||||
.filter(|p| !p.is_empty());
|
||||
|
||||
|
||||
@@ -11,14 +11,16 @@ use crate::store::AppState;
|
||||
|
||||
/// 导出配置文件
|
||||
#[tauri::command]
|
||||
pub async fn export_config_to_file(file_path: String) -> Result<Value, String> {
|
||||
pub async fn export_config_to_file(
|
||||
#[allow(non_snake_case)] filePath: String,
|
||||
) -> Result<Value, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let target_path = PathBuf::from(&file_path);
|
||||
let target_path = PathBuf::from(&filePath);
|
||||
ConfigService::export_config_to_path(&target_path)?;
|
||||
Ok::<_, AppError>(json!({
|
||||
"success": true,
|
||||
"message": "Configuration exported successfully",
|
||||
"filePath": file_path
|
||||
"filePath": filePath
|
||||
}))
|
||||
})
|
||||
.await
|
||||
@@ -29,11 +31,11 @@ pub async fn export_config_to_file(file_path: String) -> Result<Value, String> {
|
||||
/// 从文件导入配置
|
||||
#[tauri::command]
|
||||
pub async fn import_config_from_file(
|
||||
file_path: String,
|
||||
#[allow(non_snake_case)] filePath: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Value, String> {
|
||||
let (new_config, backup_id) = tauri::async_runtime::spawn_blocking(move || {
|
||||
let path_buf = PathBuf::from(&file_path);
|
||||
let path_buf = PathBuf::from(&filePath);
|
||||
ConfigService::load_config_for_import(&path_buf)
|
||||
})
|
||||
.await
|
||||
@@ -77,13 +79,13 @@ pub async fn sync_current_providers_live(state: State<'_, AppState>) -> Result<V
|
||||
#[tauri::command]
|
||||
pub async fn save_file_dialog<R: tauri::Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
default_name: String,
|
||||
#[allow(non_snake_case)] defaultName: String,
|
||||
) -> Result<Option<String>, String> {
|
||||
let dialog = app.dialog();
|
||||
let result = dialog
|
||||
.file()
|
||||
.add_filter("JSON", &["json"])
|
||||
.set_file_name(&default_name)
|
||||
.set_file_name(&defaultName)
|
||||
.blocking_save_file();
|
||||
|
||||
Ok(result.map(|p| p.to_string()))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use crate::init_status::InitErrorPayload;
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
@@ -43,3 +44,10 @@ pub async fn is_portable_mode() -> Result<bool, String> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取应用启动阶段的初始化错误(若有)。
|
||||
/// 用于前端在早期主动拉取,避免事件订阅竞态导致的提示缺失。
|
||||
#[tauri::command]
|
||||
pub async fn get_init_error() -> Result<Option<InitErrorPayload>, String> {
|
||||
Ok(crate::init_status::get_init_error())
|
||||
}
|
||||
|
||||
@@ -6,11 +6,6 @@ use crate::error::AppError;
|
||||
use crate::provider::Provider;
|
||||
use crate::services::{EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService};
|
||||
use crate::store::AppState;
|
||||
|
||||
fn missing_param(param: &str) -> String {
|
||||
format!("缺少 {} 参数 (Missing {} parameter)", param, param)
|
||||
}
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
/// 获取所有供应商
|
||||
@@ -113,19 +108,45 @@ pub fn import_default_config(state: State<'_, AppState>, app: String) -> Result<
|
||||
}
|
||||
|
||||
/// 查询供应商用量
|
||||
#[allow(non_snake_case)]
|
||||
#[tauri::command]
|
||||
pub async fn query_provider_usage(
|
||||
pub async fn queryProviderUsage(
|
||||
state: State<'_, AppState>,
|
||||
provider_id: Option<String>,
|
||||
#[allow(non_snake_case)] providerId: String, // 使用 camelCase 匹配前端
|
||||
app: String,
|
||||
) -> Result<crate::provider::UsageResult, String> {
|
||||
let provider_id = provider_id.ok_or_else(|| missing_param("providerId"))?;
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
ProviderService::query_usage(state.inner(), app_type, &provider_id)
|
||||
ProviderService::query_usage(state.inner(), app_type, &providerId)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 测试用量脚本(使用当前编辑器中的脚本,不保存)
|
||||
#[allow(non_snake_case)]
|
||||
#[tauri::command]
|
||||
pub async fn testUsageScript(
|
||||
state: State<'_, AppState>,
|
||||
#[allow(non_snake_case)] providerId: String,
|
||||
app: String,
|
||||
#[allow(non_snake_case)] scriptCode: String,
|
||||
timeout: Option<u64>,
|
||||
#[allow(non_snake_case)] accessToken: Option<String>,
|
||||
#[allow(non_snake_case)] userId: Option<String>,
|
||||
) -> Result<crate::provider::UsageResult, String> {
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
ProviderService::test_usage_script(
|
||||
state.inner(),
|
||||
app_type,
|
||||
&providerId,
|
||||
&scriptCode,
|
||||
timeout.unwrap_or(10),
|
||||
accessToken.as_deref(),
|
||||
userId.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 读取当前生效的配置内容
|
||||
#[tauri::command]
|
||||
pub fn read_live_provider_settings(app: String) -> Result<serde_json::Value, String> {
|
||||
@@ -137,9 +158,9 @@ pub fn read_live_provider_settings(app: String) -> Result<serde_json::Value, Str
|
||||
#[tauri::command]
|
||||
pub async fn test_api_endpoints(
|
||||
urls: Vec<String>,
|
||||
timeout_secs: Option<u64>,
|
||||
#[allow(non_snake_case)] timeoutSecs: Option<u64>,
|
||||
) -> Result<Vec<EndpointLatency>, String> {
|
||||
SpeedtestService::test_endpoints(urls, timeout_secs)
|
||||
SpeedtestService::test_endpoints(urls, timeoutSecs)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -149,11 +170,10 @@ pub async fn test_api_endpoints(
|
||||
pub fn get_custom_endpoints(
|
||||
state: State<'_, AppState>,
|
||||
app: String,
|
||||
provider_id: Option<String>,
|
||||
#[allow(non_snake_case)] providerId: String,
|
||||
) -> Result<Vec<crate::settings::CustomEndpoint>, String> {
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
let provider_id = provider_id.ok_or_else(|| missing_param("providerId"))?;
|
||||
ProviderService::get_custom_endpoints(state.inner(), app_type, &provider_id)
|
||||
ProviderService::get_custom_endpoints(state.inner(), app_type, &providerId)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -162,12 +182,11 @@ pub fn get_custom_endpoints(
|
||||
pub fn add_custom_endpoint(
|
||||
state: State<'_, AppState>,
|
||||
app: String,
|
||||
provider_id: Option<String>,
|
||||
#[allow(non_snake_case)] providerId: String,
|
||||
url: String,
|
||||
) -> Result<(), String> {
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
let provider_id = provider_id.ok_or_else(|| missing_param("providerId"))?;
|
||||
ProviderService::add_custom_endpoint(state.inner(), app_type, &provider_id, url)
|
||||
ProviderService::add_custom_endpoint(state.inner(), app_type, &providerId, url)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -176,12 +195,11 @@ pub fn add_custom_endpoint(
|
||||
pub fn remove_custom_endpoint(
|
||||
state: State<'_, AppState>,
|
||||
app: String,
|
||||
provider_id: Option<String>,
|
||||
#[allow(non_snake_case)] providerId: String,
|
||||
url: String,
|
||||
) -> Result<(), String> {
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
let provider_id = provider_id.ok_or_else(|| missing_param("providerId"))?;
|
||||
ProviderService::remove_custom_endpoint(state.inner(), app_type, &provider_id, url)
|
||||
ProviderService::remove_custom_endpoint(state.inner(), app_type, &providerId, url)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -190,12 +208,11 @@ pub fn remove_custom_endpoint(
|
||||
pub fn update_endpoint_last_used(
|
||||
state: State<'_, AppState>,
|
||||
app: String,
|
||||
provider_id: Option<String>,
|
||||
#[allow(non_snake_case)] providerId: String,
|
||||
url: String,
|
||||
) -> Result<(), String> {
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
let provider_id = provider_id.ok_or_else(|| missing_param("providerId"))?;
|
||||
ProviderService::update_endpoint_last_used(state.inner(), app_type, &provider_id, url)
|
||||
ProviderService::update_endpoint_last_used(state.inner(), app_type, &providerId, url)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
|
||||
@@ -78,55 +78,6 @@ pub fn get_app_config_path() -> PathBuf {
|
||||
get_app_config_dir().join("config.json")
|
||||
}
|
||||
|
||||
/// 归档根目录 ~/.cc-switch/archive
|
||||
pub fn get_archive_root() -> PathBuf {
|
||||
get_app_config_dir().join("archive")
|
||||
}
|
||||
|
||||
fn ensure_unique_path(dest: PathBuf) -> PathBuf {
|
||||
if !dest.exists() {
|
||||
return dest;
|
||||
}
|
||||
let file_name = dest
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "file".into());
|
||||
let ext = dest
|
||||
.extension()
|
||||
.map(|s| format!(".{}", s.to_string_lossy()))
|
||||
.unwrap_or_default();
|
||||
let parent = dest.parent().map(|p| p.to_path_buf()).unwrap_or_default();
|
||||
for i in 2..1000 {
|
||||
let mut candidate = parent.clone();
|
||||
candidate.push(format!("{}-{}{}", file_name, i, ext));
|
||||
if !candidate.exists() {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
dest
|
||||
}
|
||||
|
||||
/// 将现有文件归档到 `~/.cc-switch/archive/<ts>/<category>/` 下,返回归档路径
|
||||
pub fn archive_file(ts: u64, category: &str, src: &Path) -> Result<Option<PathBuf>, AppError> {
|
||||
if !src.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut dest_dir = get_archive_root();
|
||||
dest_dir.push(ts.to_string());
|
||||
dest_dir.push(category);
|
||||
fs::create_dir_all(&dest_dir).map_err(|e| AppError::io(&dest_dir, e))?;
|
||||
|
||||
let file_name = src
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "file".into());
|
||||
let mut dest = dest_dir.join(file_name);
|
||||
dest = ensure_unique_path(dest);
|
||||
|
||||
copy_file(src, &dest)?;
|
||||
Ok(Some(dest))
|
||||
}
|
||||
|
||||
/// 清理供应商名称,确保文件名安全
|
||||
pub fn sanitize_provider_name(name: &str) -> String {
|
||||
name.chars()
|
||||
@@ -304,5 +255,3 @@ pub fn get_claude_config_status() -> ConfigStatus {
|
||||
path: path.to_string_lossy().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
//(移除未使用的备份/导入函数,避免 dead_code 告警)
|
||||
|
||||
@@ -40,8 +40,6 @@ pub enum AppError {
|
||||
},
|
||||
#[error("锁获取失败: {0}")]
|
||||
Lock(String),
|
||||
#[error("供应商不存在: {0}")]
|
||||
ProviderNotFound(String),
|
||||
#[error("MCP 校验失败: {0}")]
|
||||
McpValidation(String),
|
||||
#[error("{0}")]
|
||||
|
||||
41
src-tauri/src/init_status.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use serde::Serialize;
|
||||
use std::sync::{OnceLock, RwLock};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct InitErrorPayload {
|
||||
pub path: String,
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
static INIT_ERROR: OnceLock<RwLock<Option<InitErrorPayload>>> = OnceLock::new();
|
||||
|
||||
fn cell() -> &'static RwLock<Option<InitErrorPayload>> {
|
||||
INIT_ERROR.get_or_init(|| RwLock::new(None))
|
||||
}
|
||||
|
||||
pub fn set_init_error(payload: InitErrorPayload) {
|
||||
if let Ok(mut guard) = cell().write() {
|
||||
*guard = Some(payload);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_init_error() -> Option<InitErrorPayload> {
|
||||
cell().read().ok()?.clone()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn init_error_roundtrip() {
|
||||
let payload = InitErrorPayload {
|
||||
path: "/tmp/config.json".into(),
|
||||
error: "broken json".into(),
|
||||
};
|
||||
set_init_error(payload.clone());
|
||||
let got = get_init_error().expect("should get payload back");
|
||||
assert_eq!(got.path, payload.path);
|
||||
assert_eq!(got.error, payload.error);
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,8 @@ mod codex_config;
|
||||
mod commands;
|
||||
mod config;
|
||||
mod error;
|
||||
mod init_status;
|
||||
mod mcp;
|
||||
mod migration;
|
||||
mod provider;
|
||||
mod services;
|
||||
mod settings;
|
||||
@@ -437,27 +437,42 @@ pub fn run() {
|
||||
app_store::refresh_app_config_dir_override(app.handle());
|
||||
|
||||
// 初始化应用状态(仅创建一次,并在本函数末尾注入 manage)
|
||||
let app_state = AppState::new();
|
||||
// 如果配置解析失败,则向前端发送错误事件并提前结束 setup(不落盘、不覆盖配置)。
|
||||
let app_state = match AppState::try_new() {
|
||||
Ok(state) => state,
|
||||
Err(err) => {
|
||||
let path = crate::config::get_app_config_path();
|
||||
let payload_json = serde_json::json!({
|
||||
"path": path.display().to_string(),
|
||||
"error": err.to_string(),
|
||||
});
|
||||
// 事件通知(可能早于前端订阅,不保证送达)
|
||||
if let Err(e) = app.emit("configLoadError", payload_json) {
|
||||
log::error!("发射配置加载错误事件失败: {}", e);
|
||||
}
|
||||
// 同时缓存错误,供前端启动阶段主动拉取
|
||||
crate::init_status::set_init_error(crate::init_status::InitErrorPayload {
|
||||
path: path.display().to_string(),
|
||||
error: err.to_string(),
|
||||
});
|
||||
// 不再继续构建托盘/命令依赖的状态,交由前端提示后退出。
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
// 迁移旧的 app_config_dir 配置到 Store
|
||||
if let Err(e) = app_store::migrate_app_config_dir_from_settings(app.handle()) {
|
||||
log::warn!("迁移 app_config_dir 失败: {}", e);
|
||||
}
|
||||
|
||||
// 首次启动迁移:扫描副本文件,合并到 config.json,并归档副本;旧 config.json 先归档
|
||||
// 确保配置结构就绪(已移除旧版本的副本迁移逻辑)
|
||||
{
|
||||
let mut config_guard = app_state.config.write().unwrap();
|
||||
let migrated = migration::migrate_copies_into_config(&mut config_guard)?;
|
||||
if migrated {
|
||||
log::info!("已将副本文件导入到 config.json,并完成归档");
|
||||
}
|
||||
// 确保两个 App 条目存在
|
||||
config_guard.ensure_app(&app_config::AppType::Claude);
|
||||
config_guard.ensure_app(&app_config::AppType::Codex);
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
let _ = app_state.save();
|
||||
// 启动阶段不再无条件保存,避免意外覆盖用户配置。
|
||||
|
||||
// 创建动态托盘菜单
|
||||
let menu = create_tray_menu(app.handle(), &app_state)?;
|
||||
@@ -498,6 +513,7 @@ pub fn run() {
|
||||
commands::open_config_folder,
|
||||
commands::pick_directory,
|
||||
commands::open_external,
|
||||
commands::get_init_error,
|
||||
commands::get_app_config_path,
|
||||
commands::open_app_config_folder,
|
||||
commands::read_live_provider_settings,
|
||||
@@ -517,7 +533,8 @@ pub fn run() {
|
||||
commands::delete_claude_mcp_server,
|
||||
commands::validate_mcp_command,
|
||||
// usage query
|
||||
commands::query_provider_usage,
|
||||
commands::queryProviderUsage,
|
||||
commands::testUsageScript,
|
||||
// New MCP via config.json (SSOT)
|
||||
commands::get_mcp_config,
|
||||
commands::upsert_mcp_server_in_config,
|
||||
|
||||
@@ -1,432 +0,0 @@
|
||||
use crate::app_config::{AppType, MultiAppConfig};
|
||||
use crate::config::{
|
||||
archive_file, delete_file, get_app_config_dir, get_app_config_path, get_claude_config_dir,
|
||||
};
|
||||
use crate::error::AppError;
|
||||
use serde_json::Value;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn now_ts() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn get_marker_path() -> PathBuf {
|
||||
get_app_config_dir().join("migrated.copies.v1")
|
||||
}
|
||||
|
||||
fn sanitized_id(base: &str) -> String {
|
||||
crate::config::sanitize_provider_name(base)
|
||||
}
|
||||
|
||||
fn next_unique_id(existing: &HashSet<String>, base: &str) -> String {
|
||||
let base = sanitized_id(base);
|
||||
if !existing.contains(&base) {
|
||||
return base;
|
||||
}
|
||||
for i in 2..1000 {
|
||||
let candidate = format!("{}-{}", base, i);
|
||||
if !existing.contains(&candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
format!("{}-dup", base)
|
||||
}
|
||||
|
||||
fn extract_claude_api_key(value: &Value) -> Option<String> {
|
||||
value
|
||||
.get("env")
|
||||
.and_then(|env| env.get("ANTHROPIC_AUTH_TOKEN"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn extract_codex_api_key(value: &Value) -> Option<String> {
|
||||
value
|
||||
.get("auth")
|
||||
.and_then(|auth| {
|
||||
auth.get("OPENAI_API_KEY")
|
||||
.or_else(|| auth.get("openai_api_key"))
|
||||
})
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn norm_name(s: &str) -> String {
|
||||
s.trim().to_lowercase()
|
||||
}
|
||||
|
||||
// 去重策略:name + 原始 key 直接比较(不做哈希)
|
||||
|
||||
fn scan_claude_copies() -> Vec<(String, PathBuf, Value)> {
|
||||
let mut items = Vec::new();
|
||||
let dir = get_claude_config_dir();
|
||||
if !dir.exists() {
|
||||
return items;
|
||||
}
|
||||
if let Ok(rd) = fs::read_dir(&dir) {
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
let fname = match p.file_name().and_then(|s| s.to_str()) {
|
||||
Some(s) => s,
|
||||
None => continue,
|
||||
};
|
||||
if fname == "settings.json" || fname == "claude.json" {
|
||||
continue;
|
||||
}
|
||||
if !fname.starts_with("settings-") || !fname.ends_with(".json") {
|
||||
continue;
|
||||
}
|
||||
let name = fname
|
||||
.trim_start_matches("settings-")
|
||||
.trim_end_matches(".json");
|
||||
if let Ok(val) = crate::config::read_json_file::<Value>(&p) {
|
||||
items.push((name.to_string(), p, val));
|
||||
}
|
||||
}
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
fn scan_codex_copies() -> Vec<(String, Option<PathBuf>, Option<PathBuf>, Value)> {
|
||||
let mut by_name: HashMap<String, (Option<PathBuf>, Option<PathBuf>)> = HashMap::new();
|
||||
let dir = crate::codex_config::get_codex_config_dir();
|
||||
if !dir.exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
if let Ok(rd) = fs::read_dir(&dir) {
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
let fname = match p.file_name().and_then(|s| s.to_str()) {
|
||||
Some(s) => s,
|
||||
None => continue,
|
||||
};
|
||||
if fname.starts_with("auth-") && fname.ends_with(".json") {
|
||||
let name = fname.trim_start_matches("auth-").trim_end_matches(".json");
|
||||
let entry = by_name.entry(name.to_string()).or_default();
|
||||
entry.0 = Some(p);
|
||||
} else if fname.starts_with("config-") && fname.ends_with(".toml") {
|
||||
let name = fname
|
||||
.trim_start_matches("config-")
|
||||
.trim_end_matches(".toml");
|
||||
let entry = by_name.entry(name.to_string()).or_default();
|
||||
entry.1 = Some(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut items = Vec::new();
|
||||
for (name, (auth_path, config_path)) in by_name {
|
||||
if let Some(authp) = auth_path {
|
||||
if let Ok(auth) = crate::config::read_json_file::<Value>(&authp) {
|
||||
let config_str = if let Some(cfgp) = &config_path {
|
||||
match crate::codex_config::read_and_validate_config_from_path(cfgp) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
log::warn!("跳过无效 Codex config-{}.toml: {}", name, e);
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let settings = serde_json::json!({
|
||||
"auth": auth,
|
||||
"config": config_str,
|
||||
});
|
||||
items.push((name, Some(authp), config_path, settings));
|
||||
}
|
||||
}
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result<bool, AppError> {
|
||||
// 如果已迁移过则跳过;若目录不存在则先创建,避免新装用户写入标记时失败
|
||||
let marker = get_marker_path();
|
||||
if let Some(parent) = marker.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||
}
|
||||
if marker.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let claude_items = scan_claude_copies();
|
||||
let codex_items = scan_codex_copies();
|
||||
if claude_items.is_empty() && codex_items.is_empty() {
|
||||
// 即便没有可迁移项,也写入标记避免每次扫描
|
||||
fs::write(&marker, b"no-copies").map_err(|e| AppError::io(&marker, e))?;
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// 备份旧的 config.json
|
||||
let ts = now_ts();
|
||||
let app_cfg_path = get_app_config_path();
|
||||
if app_cfg_path.exists() {
|
||||
let _ = archive_file(ts, "cc-switch", &app_cfg_path);
|
||||
}
|
||||
|
||||
// 读取 live:Claude(settings.json / claude.json)
|
||||
let live_claude: Option<(String, Value)> = {
|
||||
let settings_path = crate::config::get_claude_settings_path();
|
||||
if settings_path.exists() {
|
||||
match crate::config::read_json_file::<Value>(&settings_path) {
|
||||
Ok(val) => Some(("default".to_string(), val)),
|
||||
Err(e) => {
|
||||
log::warn!("读取 Claude live 配置失败: {}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// 合并:Claude(优先 live,然后副本) - 去重键: name + apiKey(直接比较)
|
||||
config.ensure_app(&AppType::Claude);
|
||||
let manager = config.get_manager_mut(&AppType::Claude).unwrap();
|
||||
let mut ids: HashSet<String> = manager.providers.keys().cloned().collect();
|
||||
let mut live_claude_id: Option<String> = None;
|
||||
|
||||
if let Some((name, value)) = &live_claude {
|
||||
let cand_key = extract_claude_api_key(value);
|
||||
let exist_id = manager.providers.iter().find_map(|(id, p)| {
|
||||
let pk = extract_claude_api_key(&p.settings_config);
|
||||
if norm_name(&p.name) == norm_name(name) && pk == cand_key {
|
||||
Some(id.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
if let Some(exist_id) = exist_id {
|
||||
if let Some(prov) = manager.providers.get_mut(&exist_id) {
|
||||
log::info!("合并到已存在 Claude 供应商 '{}' (by name+key)", name);
|
||||
prov.settings_config = value.clone();
|
||||
live_claude_id = Some(exist_id);
|
||||
}
|
||||
} else {
|
||||
let id = next_unique_id(&ids, name);
|
||||
ids.insert(id.clone());
|
||||
let provider =
|
||||
crate::provider::Provider::with_id(id.clone(), name.clone(), value.clone(), None);
|
||||
manager.providers.insert(provider.id.clone(), provider);
|
||||
live_claude_id = Some(id);
|
||||
}
|
||||
}
|
||||
for (name, path, value) in claude_items.iter() {
|
||||
let cand_key = extract_claude_api_key(value);
|
||||
let exist_id = manager.providers.iter().find_map(|(id, p)| {
|
||||
let pk = extract_claude_api_key(&p.settings_config);
|
||||
if norm_name(&p.name) == norm_name(name) && pk == cand_key {
|
||||
Some(id.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
if let Some(exist_id) = exist_id {
|
||||
if let Some(prov) = manager.providers.get_mut(&exist_id) {
|
||||
log::info!(
|
||||
"覆盖 Claude 供应商 '{}' 来自 {} (by name+key)",
|
||||
name,
|
||||
path.display()
|
||||
);
|
||||
prov.settings_config = value.clone();
|
||||
}
|
||||
} else {
|
||||
let id = next_unique_id(&ids, name);
|
||||
ids.insert(id.clone());
|
||||
let provider =
|
||||
crate::provider::Provider::with_id(id.clone(), name.clone(), value.clone(), None);
|
||||
manager.providers.insert(provider.id.clone(), provider);
|
||||
}
|
||||
}
|
||||
|
||||
// 读取 live:Codex(auth.json 必需,config.toml 可空)
|
||||
let live_codex: Option<(String, Value)> = {
|
||||
let auth_path = crate::codex_config::get_codex_auth_path();
|
||||
if auth_path.exists() {
|
||||
match crate::config::read_json_file::<Value>(&auth_path) {
|
||||
Ok(auth) => {
|
||||
let cfg = match crate::codex_config::read_and_validate_codex_config_text() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
log::warn!("读取/校验 Codex live config.toml 失败: {}", e);
|
||||
String::new()
|
||||
}
|
||||
};
|
||||
Some((
|
||||
"default".to_string(),
|
||||
serde_json::json!({"auth": auth, "config": cfg}),
|
||||
))
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("读取 Codex live auth.json 失败: {}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// 合并:Codex(优先 live,然后副本) - 去重键: name + OPENAI_API_KEY(直接比较)
|
||||
config.ensure_app(&AppType::Codex);
|
||||
let manager = config.get_manager_mut(&AppType::Codex).unwrap();
|
||||
let mut ids: HashSet<String> = manager.providers.keys().cloned().collect();
|
||||
let mut live_codex_id: Option<String> = None;
|
||||
|
||||
if let Some((name, value)) = &live_codex {
|
||||
let cand_key = extract_codex_api_key(value);
|
||||
let exist_id = manager.providers.iter().find_map(|(id, p)| {
|
||||
let pk = extract_codex_api_key(&p.settings_config);
|
||||
if norm_name(&p.name) == norm_name(name) && pk == cand_key {
|
||||
Some(id.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
if let Some(exist_id) = exist_id {
|
||||
if let Some(prov) = manager.providers.get_mut(&exist_id) {
|
||||
log::info!("合并到已存在 Codex 供应商 '{}' (by name+key)", name);
|
||||
prov.settings_config = value.clone();
|
||||
live_codex_id = Some(exist_id);
|
||||
}
|
||||
} else {
|
||||
let id = next_unique_id(&ids, name);
|
||||
ids.insert(id.clone());
|
||||
let provider =
|
||||
crate::provider::Provider::with_id(id.clone(), name.clone(), value.clone(), None);
|
||||
manager.providers.insert(provider.id.clone(), provider);
|
||||
live_codex_id = Some(id);
|
||||
}
|
||||
}
|
||||
for (name, authp, cfgp, value) in codex_items.iter() {
|
||||
let cand_key = extract_codex_api_key(value);
|
||||
let exist_id = manager.providers.iter().find_map(|(id, p)| {
|
||||
let pk = extract_codex_api_key(&p.settings_config);
|
||||
if norm_name(&p.name) == norm_name(name) && pk == cand_key {
|
||||
Some(id.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
if let Some(exist_id) = exist_id {
|
||||
if let Some(prov) = manager.providers.get_mut(&exist_id) {
|
||||
log::info!(
|
||||
"覆盖 Codex 供应商 '{}' 来自 {:?}/{:?} (by name+key)",
|
||||
name,
|
||||
authp,
|
||||
cfgp
|
||||
);
|
||||
prov.settings_config = value.clone();
|
||||
}
|
||||
} else {
|
||||
let id = next_unique_id(&ids, name);
|
||||
ids.insert(id.clone());
|
||||
let provider =
|
||||
crate::provider::Provider::with_id(id.clone(), name.clone(), value.clone(), None);
|
||||
manager.providers.insert(provider.id.clone(), provider);
|
||||
}
|
||||
}
|
||||
|
||||
// 若当前为空,将 live 导入项设为当前
|
||||
{
|
||||
let manager = config.get_manager_mut(&AppType::Claude).unwrap();
|
||||
if manager.current.is_empty() {
|
||||
if let Some(id) = live_claude_id {
|
||||
manager.current = id;
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
let manager = config.get_manager_mut(&AppType::Codex).unwrap();
|
||||
if manager.current.is_empty() {
|
||||
if let Some(id) = live_codex_id {
|
||||
manager.current = id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 归档副本文件
|
||||
for (_, p, _) in claude_items.into_iter() {
|
||||
match archive_file(ts, "claude", &p) {
|
||||
Ok(Some(_)) => {
|
||||
let _ = delete_file(&p);
|
||||
}
|
||||
_ => {
|
||||
// 归档失败则不要删除原文件,保守处理
|
||||
}
|
||||
}
|
||||
}
|
||||
for (_, ap, cp, _) in codex_items.into_iter() {
|
||||
if let Some(ap) = ap {
|
||||
if let Ok(Some(_)) = archive_file(ts, "codex", &ap) {
|
||||
let _ = delete_file(&ap);
|
||||
}
|
||||
}
|
||||
if let Some(cp) = cp {
|
||||
if let Ok(Some(_)) = archive_file(ts, "codex", &cp) {
|
||||
let _ = delete_file(&cp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 标记完成
|
||||
// 仅在迁移阶段执行一次全量去重(忽略大小写的名称 + API Key)
|
||||
let removed = dedupe_config(config);
|
||||
if removed > 0 {
|
||||
log::info!("迁移阶段已去重重复供应商 {} 个", removed);
|
||||
}
|
||||
|
||||
fs::write(&marker, b"done").map_err(|e| AppError::io(&marker, e))?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 启动时对现有配置做一次去重:按名称(忽略大小写)+API Key
|
||||
pub fn dedupe_config(config: &mut MultiAppConfig) -> usize {
|
||||
use std::collections::HashMap as Map;
|
||||
|
||||
fn dedupe_one(
|
||||
mgr: &mut crate::provider::ProviderManager,
|
||||
extract_key: &dyn Fn(&Value) -> Option<String>,
|
||||
) -> usize {
|
||||
let mut keep: Map<String, String> = Map::new(); // key -> id 保留
|
||||
let mut remove: Vec<String> = Vec::new();
|
||||
for (id, p) in mgr.providers.iter() {
|
||||
let k = format!(
|
||||
"{}|{}",
|
||||
norm_name(&p.name),
|
||||
extract_key(&p.settings_config).unwrap_or_default()
|
||||
);
|
||||
if let Some(exist_id) = keep.get(&k) {
|
||||
// 若当前是正在使用的,则用当前替换之前的,反之丢弃当前
|
||||
if *id == mgr.current {
|
||||
// 替换:把原先的标记为删除,改保留为当前
|
||||
remove.push(exist_id.clone());
|
||||
keep.insert(k, id.clone());
|
||||
} else {
|
||||
remove.push(id.clone());
|
||||
}
|
||||
} else {
|
||||
keep.insert(k, id.clone());
|
||||
}
|
||||
}
|
||||
for id in remove.iter() {
|
||||
mgr.providers.remove(id);
|
||||
}
|
||||
remove.len()
|
||||
}
|
||||
|
||||
let mut removed = 0;
|
||||
if let Some(mgr) = config.get_manager_mut(&crate::app_config::AppType::Claude) {
|
||||
removed += dedupe_one(mgr, &extract_claude_api_key);
|
||||
}
|
||||
if let Some(mgr) = config.get_manager_mut(&crate::app_config::AppType::Codex) {
|
||||
removed += dedupe_one(mgr, &extract_codex_api_key);
|
||||
}
|
||||
removed
|
||||
}
|
||||
@@ -63,6 +63,18 @@ pub struct UsageScript {
|
||||
pub code: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub timeout: Option<u64>,
|
||||
/// 访问令牌(用于需要登录的接口)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "accessToken")]
|
||||
pub access_token: Option<String>,
|
||||
/// 用户ID(用于需要用户标识的接口)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "userId")]
|
||||
pub user_id: Option<String>,
|
||||
/// 自动查询间隔(单位:分钟,0 表示禁用自动查询)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "autoQueryInterval")]
|
||||
pub auto_query_interval: Option<u64>,
|
||||
}
|
||||
|
||||
/// 用量数据
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::config::{
|
||||
use crate::error::AppError;
|
||||
use crate::mcp;
|
||||
use crate::provider::{Provider, ProviderMeta, UsageData, UsageResult};
|
||||
use crate::settings::CustomEndpoint;
|
||||
use crate::settings::{self, CustomEndpoint};
|
||||
use crate::store::AppState;
|
||||
use crate::usage_script;
|
||||
|
||||
@@ -112,6 +112,86 @@ mod tests {
|
||||
}
|
||||
|
||||
impl ProviderService {
|
||||
/// 归一化 Claude 模型键:读旧键(ANTHROPIC_SMALL_FAST_MODEL),写新键(DEFAULT_*), 并删除旧键
|
||||
fn normalize_claude_models_in_value(settings: &mut Value) -> bool {
|
||||
let mut changed = false;
|
||||
let env = match settings.get_mut("env") {
|
||||
Some(v) if v.is_object() => v.as_object_mut().unwrap(),
|
||||
_ => return changed,
|
||||
};
|
||||
|
||||
let model = env
|
||||
.get("ANTHROPIC_MODEL")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let small_fast = env
|
||||
.get("ANTHROPIC_SMALL_FAST_MODEL")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let current_haiku = env
|
||||
.get("ANTHROPIC_DEFAULT_HAIKU_MODEL")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let current_sonnet = env
|
||||
.get("ANTHROPIC_DEFAULT_SONNET_MODEL")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let current_opus = env
|
||||
.get("ANTHROPIC_DEFAULT_OPUS_MODEL")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let target_haiku = current_haiku
|
||||
.or_else(|| small_fast.clone())
|
||||
.or_else(|| model.clone());
|
||||
let target_sonnet = current_sonnet
|
||||
.or_else(|| model.clone())
|
||||
.or_else(|| small_fast.clone());
|
||||
let target_opus = current_opus
|
||||
.or_else(|| model.clone())
|
||||
.or_else(|| small_fast.clone());
|
||||
|
||||
if env.get("ANTHROPIC_DEFAULT_HAIKU_MODEL").is_none() {
|
||||
if let Some(v) = target_haiku {
|
||||
env.insert(
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL".to_string(),
|
||||
Value::String(v),
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if env.get("ANTHROPIC_DEFAULT_SONNET_MODEL").is_none() {
|
||||
if let Some(v) = target_sonnet {
|
||||
env.insert(
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL".to_string(),
|
||||
Value::String(v),
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if env.get("ANTHROPIC_DEFAULT_OPUS_MODEL").is_none() {
|
||||
if let Some(v) = target_opus {
|
||||
env.insert("ANTHROPIC_DEFAULT_OPUS_MODEL".to_string(), Value::String(v));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if env.remove("ANTHROPIC_SMALL_FAST_MODEL").is_some() {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
changed
|
||||
}
|
||||
|
||||
fn normalize_provider_if_claude(app_type: &AppType, provider: &mut Provider) {
|
||||
if matches!(app_type, AppType::Claude) {
|
||||
let mut v = provider.settings_config.clone();
|
||||
if Self::normalize_claude_models_in_value(&mut v) {
|
||||
provider.settings_config = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
fn run_transaction<R, F>(state: &AppState, f: F) -> Result<R, AppError>
|
||||
where
|
||||
F: FnOnce(&mut MultiAppConfig) -> Result<(R, Option<PostCommitAction>), AppError>,
|
||||
@@ -209,7 +289,8 @@ impl ProviderService {
|
||||
"Claude settings file missing; cannot refresh snapshot",
|
||||
));
|
||||
}
|
||||
let live_after = read_json_file::<Value>(&settings_path)?;
|
||||
let mut live_after = read_json_file::<Value>(&settings_path)?;
|
||||
let _ = Self::normalize_claude_models_in_value(&mut live_after);
|
||||
{
|
||||
let mut guard = state.config.write().map_err(AppError::from)?;
|
||||
if let Some(manager) = guard.get_manager_mut(app_type) {
|
||||
@@ -308,6 +389,9 @@ impl ProviderService {
|
||||
|
||||
/// 新增供应商
|
||||
pub fn add(state: &AppState, app_type: AppType, provider: Provider) -> Result<bool, AppError> {
|
||||
let mut provider = provider;
|
||||
// 归一化 Claude 模型键
|
||||
Self::normalize_provider_if_claude(&app_type, &mut provider);
|
||||
Self::validate_provider_settings(&app_type, &provider)?;
|
||||
|
||||
let app_type_clone = app_type.clone();
|
||||
@@ -347,6 +431,9 @@ impl ProviderService {
|
||||
app_type: AppType,
|
||||
provider: Provider,
|
||||
) -> Result<bool, AppError> {
|
||||
let mut provider = provider;
|
||||
// 归一化 Claude 模型键
|
||||
Self::normalize_provider_if_claude(&app_type, &mut provider);
|
||||
Self::validate_provider_settings(&app_type, &provider)?;
|
||||
let provider_id = provider.id.clone();
|
||||
let app_type_clone = app_type.clone();
|
||||
@@ -358,28 +445,27 @@ impl ProviderService {
|
||||
.ok_or_else(|| Self::app_not_found(&app_type_clone))?;
|
||||
|
||||
if !manager.providers.contains_key(&provider_id) {
|
||||
return Err(AppError::ProviderNotFound(provider_id.clone()));
|
||||
return Err(AppError::localized(
|
||||
"provider.not_found",
|
||||
format!("供应商不存在: {}", provider_id),
|
||||
format!("Provider not found: {}", provider_id),
|
||||
));
|
||||
}
|
||||
|
||||
let is_current = manager.current == provider_id;
|
||||
let merged = if let Some(existing) = manager.providers.get(&provider_id) {
|
||||
let mut updated = provider_clone.clone();
|
||||
match (existing.meta.as_ref(), updated.meta.take()) {
|
||||
// 前端未提供 meta,表示不修改,沿用旧值
|
||||
(Some(old_meta), None) => {
|
||||
updated.meta = Some(old_meta.clone());
|
||||
}
|
||||
(Some(old_meta), Some(mut new_meta)) => {
|
||||
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(ProviderMeta {
|
||||
custom_endpoints: merged_map,
|
||||
usage_script: new_meta.usage_script.clone(),
|
||||
});
|
||||
(None, None) => {
|
||||
updated.meta = None;
|
||||
}
|
||||
(None, maybe_new) => {
|
||||
updated.meta = maybe_new;
|
||||
// 前端提供的 meta 视为权威,直接覆盖(其中 custom_endpoints 允许是空,表示删除所有自定义端点)
|
||||
(_old, Some(new_meta)) => {
|
||||
updated.meta = Some(new_meta);
|
||||
}
|
||||
}
|
||||
updated
|
||||
@@ -440,7 +526,9 @@ impl ProviderService {
|
||||
"Claude settings file is missing",
|
||||
));
|
||||
}
|
||||
read_json_file(&settings_path)?
|
||||
let mut v = read_json_file::<Value>(&settings_path)?;
|
||||
let _ = Self::normalize_claude_models_in_value(&mut v);
|
||||
v
|
||||
}
|
||||
};
|
||||
|
||||
@@ -543,10 +631,13 @@ impl ProviderService {
|
||||
let manager = cfg
|
||||
.get_manager_mut(&app_type)
|
||||
.ok_or_else(|| Self::app_not_found(&app_type))?;
|
||||
let provider = manager
|
||||
.providers
|
||||
.get_mut(provider_id)
|
||||
.ok_or_else(|| AppError::ProviderNotFound(provider_id.to_string()))?;
|
||||
let provider = manager.providers.get_mut(provider_id).ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.not_found",
|
||||
format!("供应商不存在: {}", provider_id),
|
||||
format!("Provider not found: {}", provider_id),
|
||||
)
|
||||
})?;
|
||||
let meta = provider.meta.get_or_insert_with(ProviderMeta::default);
|
||||
|
||||
let endpoint = CustomEndpoint {
|
||||
@@ -634,23 +725,95 @@ impl ProviderService {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 查询供应商用量
|
||||
/// 执行用量脚本并格式化结果(私有辅助方法)
|
||||
async fn execute_and_format_usage_result(
|
||||
script_code: &str,
|
||||
api_key: &str,
|
||||
base_url: &str,
|
||||
timeout: u64,
|
||||
access_token: Option<&str>,
|
||||
user_id: Option<&str>,
|
||||
) -> Result<UsageResult, AppError> {
|
||||
match usage_script::execute_usage_script(
|
||||
script_code,
|
||||
api_key,
|
||||
base_url,
|
||||
timeout,
|
||||
access_token,
|
||||
user_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(data) => {
|
||||
let usage_list: Vec<UsageData> = if data.is_array() {
|
||||
serde_json::from_value(data).map_err(|e| {
|
||||
AppError::localized(
|
||||
"usage_script.data_format_error",
|
||||
format!("数据格式错误: {}", e),
|
||||
format!("Data format error: {}", e),
|
||||
)
|
||||
})?
|
||||
} else {
|
||||
let single: UsageData = serde_json::from_value(data).map_err(|e| {
|
||||
AppError::localized(
|
||||
"usage_script.data_format_error",
|
||||
format!("数据格式错误: {}", e),
|
||||
format!("Data format error: {}", e),
|
||||
)
|
||||
})?;
|
||||
vec![single]
|
||||
};
|
||||
|
||||
Ok(UsageResult {
|
||||
success: true,
|
||||
data: Some(usage_list),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
Err(err) => {
|
||||
let lang = settings::get_settings()
|
||||
.language
|
||||
.unwrap_or_else(|| "zh".to_string());
|
||||
|
||||
let msg = match err {
|
||||
AppError::Localized { zh, en, .. } => {
|
||||
if lang == "en" {
|
||||
en
|
||||
} else {
|
||||
zh
|
||||
}
|
||||
}
|
||||
other => other.to_string(),
|
||||
};
|
||||
|
||||
Ok(UsageResult {
|
||||
success: false,
|
||||
data: None,
|
||||
error: Some(msg),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 查询供应商用量(使用已保存的脚本配置)
|
||||
pub async fn query_usage(
|
||||
state: &AppState,
|
||||
app_type: AppType,
|
||||
provider_id: &str,
|
||||
) -> Result<UsageResult, AppError> {
|
||||
let (provider, script_code, timeout) = {
|
||||
let (provider, script_code, timeout, access_token, user_id) = {
|
||||
let config = state.config.read().map_err(AppError::from)?;
|
||||
let manager = config
|
||||
.get_manager(&app_type)
|
||||
.ok_or_else(|| Self::app_not_found(&app_type))?;
|
||||
let provider = manager
|
||||
.providers
|
||||
.get(provider_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| AppError::ProviderNotFound(provider_id.to_string()))?;
|
||||
let (script_code, timeout) = {
|
||||
let provider = manager.providers.get(provider_id).cloned().ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.not_found",
|
||||
format!("供应商不存在: {}", provider_id),
|
||||
format!("Provider not found: {}", provider_id),
|
||||
)
|
||||
})?;
|
||||
let (script_code, timeout, access_token, user_id) = {
|
||||
let usage_script = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
@@ -672,37 +835,63 @@ impl ProviderService {
|
||||
(
|
||||
usage_script.code.clone(),
|
||||
usage_script.timeout.unwrap_or(10),
|
||||
usage_script.access_token.clone(),
|
||||
usage_script.user_id.clone(),
|
||||
)
|
||||
};
|
||||
|
||||
(provider, script_code, timeout)
|
||||
(provider, script_code, timeout, access_token, user_id)
|
||||
};
|
||||
|
||||
let (api_key, base_url) = Self::extract_credentials(&provider, &app_type)?;
|
||||
|
||||
match usage_script::execute_usage_script(&script_code, &api_key, &base_url, timeout).await {
|
||||
Ok(data) => {
|
||||
let usage_list: Vec<UsageData> = if data.is_array() {
|
||||
serde_json::from_value(data)
|
||||
.map_err(|e| AppError::Message(format!("数据格式错误: {}", e)))?
|
||||
} else {
|
||||
let single: UsageData = serde_json::from_value(data)
|
||||
.map_err(|e| AppError::Message(format!("数据格式错误: {}", e)))?;
|
||||
vec![single]
|
||||
};
|
||||
Self::execute_and_format_usage_result(
|
||||
&script_code,
|
||||
&api_key,
|
||||
&base_url,
|
||||
timeout,
|
||||
access_token.as_deref(),
|
||||
user_id.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
Ok(UsageResult {
|
||||
success: true,
|
||||
data: Some(usage_list),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
Err(err) => Ok(UsageResult {
|
||||
success: false,
|
||||
data: None,
|
||||
error: Some(err.to_string()),
|
||||
}),
|
||||
}
|
||||
/// 测试用量脚本(使用临时脚本内容,不保存)
|
||||
pub async fn test_usage_script(
|
||||
state: &AppState,
|
||||
app_type: AppType,
|
||||
provider_id: &str,
|
||||
script_code: &str,
|
||||
timeout: u64,
|
||||
access_token: Option<&str>,
|
||||
user_id: Option<&str>,
|
||||
) -> Result<UsageResult, AppError> {
|
||||
// 获取 provider 的 API 凭证
|
||||
let provider = {
|
||||
let config = state.config.read().map_err(AppError::from)?;
|
||||
let manager = config
|
||||
.get_manager(&app_type)
|
||||
.ok_or_else(|| Self::app_not_found(&app_type))?;
|
||||
manager.providers.get(provider_id).cloned().ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.not_found",
|
||||
format!("供应商不存在: {}", provider_id),
|
||||
format!("Provider not found: {}", provider_id),
|
||||
)
|
||||
})?
|
||||
};
|
||||
|
||||
let (api_key, base_url) = Self::extract_credentials(&provider, &app_type)?;
|
||||
|
||||
Self::execute_and_format_usage_result(
|
||||
script_code,
|
||||
&api_key,
|
||||
&base_url,
|
||||
timeout,
|
||||
access_token,
|
||||
user_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 切换指定应用的供应商
|
||||
@@ -739,7 +928,13 @@ impl ProviderService {
|
||||
.providers
|
||||
.get(provider_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| AppError::ProviderNotFound(provider_id.to_string()))?;
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.not_found",
|
||||
format!("供应商不存在: {}", provider_id),
|
||||
format!("Provider not found: {}", provider_id),
|
||||
)
|
||||
})?;
|
||||
|
||||
Self::backfill_codex_current(config, provider_id)?;
|
||||
|
||||
@@ -820,7 +1015,13 @@ impl ProviderService {
|
||||
.providers
|
||||
.get(provider_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| AppError::ProviderNotFound(provider_id.to_string()))?;
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.not_found",
|
||||
format!("供应商不存在: {}", provider_id),
|
||||
format!("Provider not found: {}", provider_id),
|
||||
)
|
||||
})?;
|
||||
|
||||
Self::backfill_claude_current(config, provider_id)?;
|
||||
|
||||
@@ -848,7 +1049,8 @@ impl ProviderService {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let live = read_json_file::<Value>(&settings_path)?;
|
||||
let mut live = read_json_file::<Value>(&settings_path)?;
|
||||
let _ = Self::normalize_claude_models_in_value(&mut live);
|
||||
if let Some(manager) = config.get_manager_mut(&AppType::Claude) {
|
||||
if let Some(current) = manager.providers.get_mut(¤t_id) {
|
||||
current.settings_config = live;
|
||||
@@ -864,7 +1066,10 @@ impl ProviderService {
|
||||
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||
}
|
||||
|
||||
write_json_file(&settings_path, &provider.settings_config)?;
|
||||
// 归一化后再写入
|
||||
let mut content = provider.settings_config.clone();
|
||||
let _ = Self::normalize_claude_models_in_value(&mut content);
|
||||
write_json_file(&settings_path, &content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -951,6 +1156,7 @@ impl ProviderService {
|
||||
|
||||
let api_key = env
|
||||
.get("ANTHROPIC_AUTH_TOKEN")
|
||||
.or_else(|| env.get("ANTHROPIC_API_KEY"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
@@ -1007,8 +1213,13 @@ impl ProviderService {
|
||||
.unwrap_or("");
|
||||
|
||||
let base_url = if config_toml.contains("base_url") {
|
||||
let re = Regex::new(r#"base_url\s*=\s*["']([^"']+)["']"#)
|
||||
.map_err(|e| AppError::Message(format!("正则初始化失败: {}", e)))?;
|
||||
let re = Regex::new(r#"base_url\s*=\s*["']([^"']+)["']"#).map_err(|e| {
|
||||
AppError::localized(
|
||||
"provider.regex_init_failed",
|
||||
format!("正则初始化失败: {}", e),
|
||||
format!("Failed to initialize regex: {}", e),
|
||||
)
|
||||
})?;
|
||||
re.captures(config_toml)
|
||||
.and_then(|caps| caps.get(1))
|
||||
.map(|m| m.as_str().to_string())
|
||||
@@ -1033,7 +1244,11 @@ impl ProviderService {
|
||||
}
|
||||
|
||||
fn app_not_found(app_type: &AppType) -> AppError {
|
||||
AppError::Message(format!("应用类型不存在: {:?}", app_type))
|
||||
AppError::localized(
|
||||
"provider.app_not_found",
|
||||
format!("应用类型不存在: {:?}", app_type),
|
||||
format!("App type not found: {:?}", app_type),
|
||||
)
|
||||
}
|
||||
|
||||
fn now_millis() -> i64 {
|
||||
@@ -1058,11 +1273,13 @@ impl ProviderService {
|
||||
));
|
||||
}
|
||||
|
||||
manager
|
||||
.providers
|
||||
.get(provider_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| AppError::ProviderNotFound(provider_id.to_string()))?
|
||||
manager.providers.get(provider_id).cloned().ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.not_found",
|
||||
format!("供应商不存在: {}", provider_id),
|
||||
format!("Provider not found: {}", provider_id),
|
||||
)
|
||||
})?
|
||||
};
|
||||
|
||||
match app_type {
|
||||
|
||||
@@ -101,7 +101,13 @@ impl SpeedtestService {
|
||||
.redirect(reqwest::redirect::Policy::limited(5))
|
||||
.user_agent("cc-switch-speedtest/1.0")
|
||||
.build()
|
||||
.map_err(|e| AppError::Message(format!("创建 HTTP 客户端失败: {e}")))
|
||||
.map_err(|e| {
|
||||
AppError::localized(
|
||||
"speedtest.client_create_failed",
|
||||
format!("创建 HTTP 客户端失败: {e}"),
|
||||
format!("Failed to create HTTP client: {e}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn sanitize_timeout(timeout_secs: Option<u64>) -> u64 {
|
||||
|
||||
@@ -7,23 +7,14 @@ pub struct AppState {
|
||||
pub config: RwLock<MultiAppConfig>,
|
||||
}
|
||||
|
||||
impl Default for AppState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// 创建新的应用状态
|
||||
pub fn new() -> Self {
|
||||
let config = MultiAppConfig::load().unwrap_or_else(|e| {
|
||||
log::warn!("加载配置失败: {}, 使用默认配置", e);
|
||||
MultiAppConfig::default()
|
||||
});
|
||||
|
||||
Self {
|
||||
/// 注意:仅在配置成功加载时返回;不会在失败时回退默认值。
|
||||
pub fn try_new() -> Result<Self, AppError> {
|
||||
let config = MultiAppConfig::load()?;
|
||||
Ok(Self {
|
||||
config: RwLock::new(config),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 保存配置到文件
|
||||
|
||||
@@ -12,88 +12,189 @@ pub async fn execute_usage_script(
|
||||
api_key: &str,
|
||||
base_url: &str,
|
||||
timeout_secs: u64,
|
||||
access_token: Option<&str>,
|
||||
user_id: Option<&str>,
|
||||
) -> Result<Value, AppError> {
|
||||
// 1. 替换变量
|
||||
let replaced = script_code
|
||||
let mut replaced = script_code
|
||||
.replace("{{apiKey}}", api_key)
|
||||
.replace("{{baseUrl}}", base_url);
|
||||
|
||||
// 替换 accessToken 和 userId
|
||||
if let Some(token) = access_token {
|
||||
replaced = replaced.replace("{{accessToken}}", token);
|
||||
}
|
||||
if let Some(uid) = user_id {
|
||||
replaced = replaced.replace("{{userId}}", uid);
|
||||
}
|
||||
|
||||
// 2. 在独立作用域中提取 request 配置(确保 Runtime/Context 在 await 前释放)
|
||||
let request_config = {
|
||||
let runtime =
|
||||
Runtime::new().map_err(|e| AppError::Message(format!("创建 JS 运行时失败: {}", e)))?;
|
||||
let context = Context::full(&runtime)
|
||||
.map_err(|e| AppError::Message(format!("创建 JS 上下文失败: {}", e)))?;
|
||||
let runtime = Runtime::new().map_err(|e| {
|
||||
AppError::localized(
|
||||
"usage_script.runtime_create_failed",
|
||||
format!("创建 JS 运行时失败: {}", e),
|
||||
format!("Failed to create JS runtime: {}", e),
|
||||
)
|
||||
})?;
|
||||
let context = Context::full(&runtime).map_err(|e| {
|
||||
AppError::localized(
|
||||
"usage_script.context_create_failed",
|
||||
format!("创建 JS 上下文失败: {}", e),
|
||||
format!("Failed to create JS context: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
context.with(|ctx| {
|
||||
// 执行用户代码,获取配置对象
|
||||
let config: rquickjs::Object = ctx
|
||||
.eval(replaced.clone())
|
||||
.map_err(|e| AppError::Message(format!("解析配置失败: {}", e)))?;
|
||||
let config: rquickjs::Object = ctx.eval(replaced.clone()).map_err(|e| {
|
||||
AppError::localized(
|
||||
"usage_script.config_parse_failed",
|
||||
format!("解析配置失败: {}", e),
|
||||
format!("Failed to parse config: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
// 提取 request 配置
|
||||
let request: rquickjs::Object = config
|
||||
.get("request")
|
||||
.map_err(|e| AppError::Message(format!("缺少 request 配置: {}", e)))?;
|
||||
let request: rquickjs::Object = config.get("request").map_err(|e| {
|
||||
AppError::localized(
|
||||
"usage_script.request_missing",
|
||||
format!("缺少 request 配置: {}", e),
|
||||
format!("Missing request config: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
// 将 request 转换为 JSON 字符串
|
||||
let request_json: String = ctx
|
||||
.json_stringify(request)
|
||||
.map_err(|e| AppError::Message(format!("序列化 request 失败: {}", e)))?
|
||||
.ok_or_else(|| AppError::Message("序列化返回 None".into()))?
|
||||
.map_err(|e| {
|
||||
AppError::localized(
|
||||
"usage_script.request_serialize_failed",
|
||||
format!("序列化 request 失败: {}", e),
|
||||
format!("Failed to serialize request: {}", e),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"usage_script.serialize_none",
|
||||
"序列化返回 None",
|
||||
"Serialization returned None",
|
||||
)
|
||||
})?
|
||||
.get()
|
||||
.map_err(|e| AppError::Message(format!("获取字符串失败: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
AppError::localized(
|
||||
"usage_script.get_string_failed",
|
||||
format!("获取字符串失败: {}", e),
|
||||
format!("Failed to get string: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok::<_, AppError>(request_json)
|
||||
})?
|
||||
}; // Runtime 和 Context 在这里被 drop
|
||||
|
||||
// 3. 解析 request 配置
|
||||
let request: RequestConfig = serde_json::from_str(&request_config)
|
||||
.map_err(|e| AppError::Message(format!("request 配置格式错误: {}", e)))?;
|
||||
let request: RequestConfig = serde_json::from_str(&request_config).map_err(|e| {
|
||||
AppError::localized(
|
||||
"usage_script.request_format_invalid",
|
||||
format!("request 配置格式错误: {}", e),
|
||||
format!("Invalid request config format: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
// 4. 发送 HTTP 请求
|
||||
let response_data = send_http_request(&request, timeout_secs).await?;
|
||||
|
||||
// 5. 在独立作用域中执行 extractor(确保 Runtime/Context 在函数结束前释放)
|
||||
let result: Value = {
|
||||
let runtime =
|
||||
Runtime::new().map_err(|e| AppError::Message(format!("创建 JS 运行时失败: {}", e)))?;
|
||||
let context = Context::full(&runtime)
|
||||
.map_err(|e| AppError::Message(format!("创建 JS 上下文失败: {}", e)))?;
|
||||
let runtime = Runtime::new().map_err(|e| {
|
||||
AppError::localized(
|
||||
"usage_script.runtime_create_failed",
|
||||
format!("创建 JS 运行时失败: {}", e),
|
||||
format!("Failed to create JS runtime: {}", e),
|
||||
)
|
||||
})?;
|
||||
let context = Context::full(&runtime).map_err(|e| {
|
||||
AppError::localized(
|
||||
"usage_script.context_create_failed",
|
||||
format!("创建 JS 上下文失败: {}", e),
|
||||
format!("Failed to create JS context: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
context.with(|ctx| {
|
||||
// 重新 eval 获取配置对象
|
||||
let config: rquickjs::Object = ctx
|
||||
.eval(replaced.clone())
|
||||
.map_err(|e| AppError::Message(format!("重新解析配置失败: {}", e)))?;
|
||||
let config: rquickjs::Object = ctx.eval(replaced.clone()).map_err(|e| {
|
||||
AppError::localized(
|
||||
"usage_script.config_reparse_failed",
|
||||
format!("重新解析配置失败: {}", e),
|
||||
format!("Failed to re-parse config: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
// 提取 extractor 函数
|
||||
let extractor: Function = config
|
||||
.get("extractor")
|
||||
.map_err(|e| AppError::Message(format!("缺少 extractor 函数: {}", e)))?;
|
||||
let extractor: Function = config.get("extractor").map_err(|e| {
|
||||
AppError::localized(
|
||||
"usage_script.extractor_missing",
|
||||
format!("缺少 extractor 函数: {}", e),
|
||||
format!("Missing extractor function: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
// 将响应数据转换为 JS 值
|
||||
let response_js: rquickjs::Value = ctx
|
||||
.json_parse(response_data.as_str())
|
||||
.map_err(|e| AppError::Message(format!("解析响应 JSON 失败: {}", e)))?;
|
||||
let response_js: rquickjs::Value =
|
||||
ctx.json_parse(response_data.as_str()).map_err(|e| {
|
||||
AppError::localized(
|
||||
"usage_script.response_parse_failed",
|
||||
format!("解析响应 JSON 失败: {}", e),
|
||||
format!("Failed to parse response JSON: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
// 调用 extractor(response)
|
||||
let result_js: rquickjs::Value = extractor
|
||||
.call((response_js,))
|
||||
.map_err(|e| AppError::Message(format!("执行 extractor 失败: {}", e)))?;
|
||||
let result_js: rquickjs::Value = extractor.call((response_js,)).map_err(|e| {
|
||||
AppError::localized(
|
||||
"usage_script.extractor_exec_failed",
|
||||
format!("执行 extractor 失败: {}", e),
|
||||
format!("Failed to execute extractor: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
// 转换为 JSON 字符串
|
||||
let result_json: String = ctx
|
||||
.json_stringify(result_js)
|
||||
.map_err(|e| AppError::Message(format!("序列化结果失败: {}", e)))?
|
||||
.ok_or_else(|| AppError::Message("序列化返回 None".into()))?
|
||||
.map_err(|e| {
|
||||
AppError::localized(
|
||||
"usage_script.result_serialize_failed",
|
||||
format!("序列化结果失败: {}", e),
|
||||
format!("Failed to serialize result: {}", e),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"usage_script.serialize_none",
|
||||
"序列化返回 None",
|
||||
"Serialization returned None",
|
||||
)
|
||||
})?
|
||||
.get()
|
||||
.map_err(|e| AppError::Message(format!("获取字符串失败: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
AppError::localized(
|
||||
"usage_script.get_string_failed",
|
||||
format!("获取字符串失败: {}", e),
|
||||
format!("Failed to get string: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
// 解析为 serde_json::Value
|
||||
serde_json::from_str(&result_json)
|
||||
.map_err(|e| AppError::Message(format!("JSON 解析失败: {}", e)))
|
||||
serde_json::from_str(&result_json).map_err(|e| {
|
||||
AppError::localized(
|
||||
"usage_script.json_parse_failed",
|
||||
format!("JSON 解析失败: {}", e),
|
||||
format!("JSON parse failed: {}", e),
|
||||
)
|
||||
})
|
||||
})?
|
||||
}; // Runtime 和 Context 在这里被 drop
|
||||
|
||||
@@ -116,12 +217,27 @@ struct RequestConfig {
|
||||
|
||||
/// 发送 HTTP 请求
|
||||
async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<String, AppError> {
|
||||
// 约束超时范围,防止异常配置导致长时间阻塞
|
||||
let timeout = timeout_secs.clamp(2, 30);
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.timeout(Duration::from_secs(timeout))
|
||||
.build()
|
||||
.map_err(|e| AppError::Message(format!("创建客户端失败: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
AppError::localized(
|
||||
"usage_script.client_create_failed",
|
||||
format!("创建客户端失败: {}", e),
|
||||
format!("Failed to create client: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
let method = config.method.parse().unwrap_or(reqwest::Method::GET);
|
||||
// 严格校验 HTTP 方法,非法值不回退为 GET
|
||||
let method: reqwest::Method = config.method.parse().map_err(|_| {
|
||||
AppError::localized(
|
||||
"usage_script.invalid_http_method",
|
||||
format!("不支持的 HTTP 方法: {}", config.method),
|
||||
format!("Unsupported HTTP method: {}", config.method),
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut req = client.request(method.clone(), &config.url);
|
||||
|
||||
@@ -136,16 +252,22 @@ async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
let resp = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::Message(format!("请求失败: {}", e)))?;
|
||||
let resp = req.send().await.map_err(|e| {
|
||||
AppError::localized(
|
||||
"usage_script.request_failed",
|
||||
format!("请求失败: {}", e),
|
||||
format!("Request failed: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
let status = resp.status();
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| AppError::Message(format!("读取响应失败: {}", e)))?;
|
||||
let text = resp.text().await.map_err(|e| {
|
||||
AppError::localized(
|
||||
"usage_script.read_response_failed",
|
||||
format!("读取响应失败: {}", e),
|
||||
format!("Failed to read response: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
if !status.is_success() {
|
||||
let preview = if text.len() > 200 {
|
||||
@@ -153,7 +275,11 @@ async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<
|
||||
} else {
|
||||
text.clone()
|
||||
};
|
||||
return Err(AppError::Message(format!("HTTP {} : {}", status, preview)));
|
||||
return Err(AppError::localized(
|
||||
"usage_script.http_error",
|
||||
format!("HTTP {} : {}", status, preview),
|
||||
format!("HTTP {} : {}", status, preview),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(text)
|
||||
@@ -164,11 +290,20 @@ fn validate_result(result: &Value) -> Result<(), AppError> {
|
||||
// 如果是数组,验证每个元素
|
||||
if let Some(arr) = result.as_array() {
|
||||
if arr.is_empty() {
|
||||
return Err(AppError::InvalidInput("脚本返回的数组不能为空".into()));
|
||||
return Err(AppError::localized(
|
||||
"usage_script.empty_array",
|
||||
"脚本返回的数组不能为空",
|
||||
"Script returned empty array",
|
||||
));
|
||||
}
|
||||
for (idx, item) in arr.iter().enumerate() {
|
||||
validate_single_usage(item)
|
||||
.map_err(|e| AppError::InvalidInput(format!("数组索引[{}]验证失败: {}", idx, e)))?;
|
||||
validate_single_usage(item).map_err(|e| {
|
||||
AppError::localized(
|
||||
"usage_script.array_validation_failed",
|
||||
format!("数组索引[{}]验证失败: {}", idx, e),
|
||||
format!("Validation failed at index [{}]: {}", idx, e),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
@@ -179,50 +314,82 @@ fn validate_result(result: &Value) -> Result<(), AppError> {
|
||||
|
||||
/// 验证单个用量数据对象
|
||||
fn validate_single_usage(result: &Value) -> Result<(), AppError> {
|
||||
let obj = result
|
||||
.as_object()
|
||||
.ok_or_else(|| AppError::InvalidInput("脚本必须返回对象或对象数组".into()))?;
|
||||
let obj = result.as_object().ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"usage_script.must_return_object",
|
||||
"脚本必须返回对象或对象数组",
|
||||
"Script must return object or array of objects",
|
||||
)
|
||||
})?;
|
||||
|
||||
// 所有字段均为可选,只进行类型检查
|
||||
if obj.contains_key("isValid")
|
||||
&& !result["isValid"].is_null()
|
||||
&& !result["isValid"].is_boolean()
|
||||
{
|
||||
return Err(AppError::InvalidInput("isValid 必须是布尔值或 null".into()));
|
||||
return Err(AppError::localized(
|
||||
"usage_script.isvalid_type_error",
|
||||
"isValid 必须是布尔值或 null",
|
||||
"isValid must be boolean or null",
|
||||
));
|
||||
}
|
||||
if obj.contains_key("invalidMessage")
|
||||
&& !result["invalidMessage"].is_null()
|
||||
&& !result["invalidMessage"].is_string()
|
||||
{
|
||||
return Err(AppError::InvalidInput(
|
||||
"invalidMessage 必须是字符串或 null".into(),
|
||||
return Err(AppError::localized(
|
||||
"usage_script.invalidmessage_type_error",
|
||||
"invalidMessage 必须是字符串或 null",
|
||||
"invalidMessage must be string or null",
|
||||
));
|
||||
}
|
||||
if obj.contains_key("remaining")
|
||||
&& !result["remaining"].is_null()
|
||||
&& !result["remaining"].is_number()
|
||||
{
|
||||
return Err(AppError::InvalidInput("remaining 必须是数字或 null".into()));
|
||||
return Err(AppError::localized(
|
||||
"usage_script.remaining_type_error",
|
||||
"remaining 必须是数字或 null",
|
||||
"remaining must be number or null",
|
||||
));
|
||||
}
|
||||
if obj.contains_key("unit") && !result["unit"].is_null() && !result["unit"].is_string() {
|
||||
return Err(AppError::InvalidInput("unit 必须是字符串或 null".into()));
|
||||
return Err(AppError::localized(
|
||||
"usage_script.unit_type_error",
|
||||
"unit 必须是字符串或 null",
|
||||
"unit must be string or null",
|
||||
));
|
||||
}
|
||||
if obj.contains_key("total") && !result["total"].is_null() && !result["total"].is_number() {
|
||||
return Err(AppError::InvalidInput("total 必须是数字或 null".into()));
|
||||
return Err(AppError::localized(
|
||||
"usage_script.total_type_error",
|
||||
"total 必须是数字或 null",
|
||||
"total must be number or null",
|
||||
));
|
||||
}
|
||||
if obj.contains_key("used") && !result["used"].is_null() && !result["used"].is_number() {
|
||||
return Err(AppError::InvalidInput("used 必须是数字或 null".into()));
|
||||
return Err(AppError::localized(
|
||||
"usage_script.used_type_error",
|
||||
"used 必须是数字或 null",
|
||||
"used must be number or null",
|
||||
));
|
||||
}
|
||||
if obj.contains_key("planName")
|
||||
&& !result["planName"].is_null()
|
||||
&& !result["planName"].is_string()
|
||||
{
|
||||
return Err(AppError::InvalidInput(
|
||||
"planName 必须是字符串或 null".into(),
|
||||
return Err(AppError::localized(
|
||||
"usage_script.planname_type_error",
|
||||
"planName 必须是字符串或 null",
|
||||
"planName must be string or null",
|
||||
));
|
||||
}
|
||||
if obj.contains_key("extra") && !result["extra"].is_null() && !result["extra"].is_string() {
|
||||
return Err(AppError::InvalidInput("extra 必须是字符串或 null".into()));
|
||||
return Err(AppError::localized(
|
||||
"usage_script.extra_type_error",
|
||||
"extra 必须是字符串或 null",
|
||||
"extra must be string or null",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CC Switch",
|
||||
"version": "3.5.1",
|
||||
"version": "3.6.0",
|
||||
"identifier": "com.ccswitch.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
107
src-tauri/tests/app_config_load.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use cc_switch_lib::{AppError, MultiAppConfig};
|
||||
|
||||
mod support;
|
||||
use support::{ensure_test_home, reset_test_fs, test_mutex};
|
||||
|
||||
fn cfg_path() -> PathBuf {
|
||||
let home = std::env::var("HOME").expect("HOME should be set by ensure_test_home");
|
||||
PathBuf::from(home).join(".cc-switch").join("config.json")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_v1_config_returns_error_and_does_not_write() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let home = ensure_test_home();
|
||||
let path = cfg_path();
|
||||
fs::create_dir_all(path.parent().unwrap()).expect("create cfg dir");
|
||||
|
||||
// 最小 v1 形状:providers + current,且不含 version/apps/mcp
|
||||
let v1_json = r#"{"providers":{},"current":""}"#;
|
||||
fs::write(&path, v1_json).expect("seed v1 json");
|
||||
let before = fs::read_to_string(&path).expect("read before");
|
||||
|
||||
let err = MultiAppConfig::load().expect_err("v1 should not be auto-migrated");
|
||||
match err {
|
||||
AppError::Localized { key, .. } => assert_eq!(key, "config.unsupported_v1"),
|
||||
other => panic!("expected Localized v1 error, got {other:?}"),
|
||||
}
|
||||
|
||||
// 文件不应有任何变化,且不应生成 .bak
|
||||
let after = fs::read_to_string(&path).expect("read after");
|
||||
assert_eq!(before, after, "config.json should not be modified");
|
||||
let bak = home.join(".cc-switch").join("config.json.bak");
|
||||
assert!(!bak.exists(), ".bak should not be created on load error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_v1_with_extra_version_still_treated_as_v1() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let home = ensure_test_home();
|
||||
let path = cfg_path();
|
||||
std::fs::create_dir_all(path.parent().unwrap()).expect("create cfg dir");
|
||||
|
||||
// 畸形:包含 providers + current + version,但没有 apps,应按 v1 处理
|
||||
let v1_like = r#"{"providers":{},"current":"","version":2}"#;
|
||||
std::fs::write(&path, v1_like).expect("seed v1-like json");
|
||||
let before = std::fs::read_to_string(&path).expect("read before");
|
||||
|
||||
let err = MultiAppConfig::load().expect_err("v1-like should not be parsed as v2");
|
||||
match err {
|
||||
AppError::Localized { key, .. } => assert_eq!(key, "config.unsupported_v1"),
|
||||
other => panic!("expected Localized v1 error, got {other:?}"),
|
||||
}
|
||||
|
||||
let after = std::fs::read_to_string(&path).expect("read after");
|
||||
assert_eq!(before, after, "config.json should not be modified");
|
||||
let bak = home.join(".cc-switch").join("config.json.bak");
|
||||
assert!(!bak.exists(), ".bak should not be created on v1-like error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_invalid_json_returns_parse_error_and_does_not_write() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let home = ensure_test_home();
|
||||
let path = cfg_path();
|
||||
fs::create_dir_all(path.parent().unwrap()).expect("create cfg dir");
|
||||
|
||||
fs::write(&path, "{not json").expect("seed invalid json");
|
||||
let before = fs::read_to_string(&path).expect("read before");
|
||||
|
||||
let err = MultiAppConfig::load().expect_err("invalid json should error");
|
||||
match err {
|
||||
AppError::Json { .. } => {}
|
||||
other => panic!("expected Json error, got {other:?}"),
|
||||
}
|
||||
|
||||
let after = fs::read_to_string(&path).expect("read after");
|
||||
assert_eq!(before, after, "config.json should remain unchanged");
|
||||
let bak = home.join(".cc-switch").join("config.json.bak");
|
||||
assert!(!bak.exists(), ".bak should not be created on parse error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_valid_v2_config_succeeds() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let _home = ensure_test_home();
|
||||
let path = cfg_path();
|
||||
fs::create_dir_all(path.parent().unwrap()).expect("create cfg dir");
|
||||
|
||||
// 使用默认结构序列化为 v2
|
||||
let default_cfg = MultiAppConfig::default();
|
||||
let json = serde_json::to_string_pretty(&default_cfg).expect("serialize default cfg");
|
||||
fs::write(&path, json).expect("write v2 json");
|
||||
|
||||
let loaded = MultiAppConfig::load().expect("v2 should load successfully");
|
||||
assert_eq!(loaded.version, 2);
|
||||
assert!(loaded
|
||||
.get_manager(&cc_switch_lib::AppType::Claude)
|
||||
.is_some());
|
||||
assert!(loaded.get_manager(&cc_switch_lib::AppType::Codex).is_some());
|
||||
}
|
||||
@@ -240,8 +240,8 @@ fn provider_service_switch_missing_provider_returns_error() {
|
||||
let err = ProviderService::switch(&state, AppType::Claude, "missing")
|
||||
.expect_err("switching missing provider should fail");
|
||||
match err {
|
||||
AppError::ProviderNotFound(id) => assert_eq!(id, "missing"),
|
||||
other => panic!("expected ProviderNotFound, got {other:?}"),
|
||||
AppError::Localized { key, .. } => assert_eq!(key, "provider.not_found"),
|
||||
other => panic!("expected Localized error for provider not found, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ import { EditorState } from "@codemirror/state";
|
||||
import { placeholder } from "@codemirror/view";
|
||||
import { linter, Diagnostic } from "@codemirror/lint";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Wand2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { formatJSON } from "@/utils/formatters";
|
||||
|
||||
interface JsonEditorProps {
|
||||
value: string;
|
||||
@@ -170,7 +173,44 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return <div ref={editorRef} style={{ width: "100%" }} />;
|
||||
// 格式化处理函数
|
||||
const handleFormat = () => {
|
||||
if (!viewRef.current) return;
|
||||
|
||||
const currentValue = viewRef.current.state.doc.toString();
|
||||
if (!currentValue.trim()) return;
|
||||
|
||||
try {
|
||||
const formatted = formatJSON(currentValue);
|
||||
onChange(formatted);
|
||||
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(
|
||||
t("common.formatError", {
|
||||
defaultValue: "格式化失败:{{error}}",
|
||||
error: errorMessage,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ width: "100%" }}>
|
||||
<div ref={editorRef} style={{ width: "100%" }} />
|
||||
{language === "json" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFormat}
|
||||
className="mt-2 inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
>
|
||||
<Wand2 className="w-3.5 h-3.5" />
|
||||
{t("common.format", { defaultValue: "格式化" })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default JsonEditor;
|
||||
|
||||
@@ -1,33 +1,82 @@
|
||||
import React from "react";
|
||||
import { RefreshCw, AlertCircle } from "lucide-react";
|
||||
import { RefreshCw, AlertCircle, Clock } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type AppId } from "@/lib/api";
|
||||
import { useUsageQuery } from "@/lib/query/queries";
|
||||
import { UsageData } from "../types";
|
||||
import { UsageData, Provider } from "@/types";
|
||||
|
||||
interface UsageFooterProps {
|
||||
provider: Provider;
|
||||
providerId: string;
|
||||
appId: AppId;
|
||||
usageEnabled: boolean; // 是否启用了用量查询
|
||||
isCurrent: boolean; // 是否为当前激活的供应商
|
||||
inline?: boolean; // 是否内联显示(在按钮左侧)
|
||||
}
|
||||
|
||||
const UsageFooter: React.FC<UsageFooterProps> = ({
|
||||
provider,
|
||||
providerId,
|
||||
appId,
|
||||
usageEnabled,
|
||||
isCurrent,
|
||||
inline = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 统一的用量查询(自动查询仅对当前激活的供应商启用)
|
||||
const autoQueryInterval = isCurrent
|
||||
? provider.meta?.usage_script?.autoQueryInterval || 0
|
||||
: 0;
|
||||
|
||||
const {
|
||||
data: usage,
|
||||
isLoading: loading,
|
||||
isFetching: loading,
|
||||
lastQueriedAt,
|
||||
refetch,
|
||||
} = useUsageQuery(providerId, appId, usageEnabled);
|
||||
} = useUsageQuery(providerId, appId, {
|
||||
enabled: usageEnabled,
|
||||
autoQueryInterval,
|
||||
});
|
||||
|
||||
// 🆕 定期更新当前时间,用于刷新相对时间显示
|
||||
const [now, setNow] = React.useState(Date.now());
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!lastQueriedAt) return;
|
||||
|
||||
// 每30秒更新一次当前时间,触发相对时间显示的刷新
|
||||
const interval = setInterval(() => {
|
||||
setNow(Date.now());
|
||||
}, 30000); // 30秒
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [lastQueriedAt]);
|
||||
|
||||
// 只在启用用量查询且有数据时显示
|
||||
if (!usageEnabled || !usage) return null;
|
||||
|
||||
// 错误状态
|
||||
if (!usage.success) {
|
||||
if (inline) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<div className="flex items-center gap-1.5 text-red-500 dark:text-red-400">
|
||||
<AlertCircle size={12} />
|
||||
<span>{t("usage.queryFailed")}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
disabled={loading}
|
||||
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50 flex-shrink-0"
|
||||
title={t("usage.refreshUsage")}
|
||||
>
|
||||
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t border-border-default ">
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
@@ -55,21 +104,104 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
|
||||
// 无数据时不显示
|
||||
if (usageDataList.length === 0) return null;
|
||||
|
||||
// 内联模式:仅显示第一个套餐的核心数据(分上下两行)
|
||||
if (inline) {
|
||||
const firstUsage = usageDataList[0];
|
||||
const isExpired = firstUsage.isValid === false;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 text-xs flex-shrink-0">
|
||||
{/* 第一行:刷新时间 + 刷新按钮 */}
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
{/* 上次查询时间 */}
|
||||
{lastQueriedAt && (
|
||||
<span className="text-[10px] text-gray-400 dark:text-gray-500 flex items-center gap-1">
|
||||
<Clock size={10} />
|
||||
{formatRelativeTime(lastQueriedAt, now, t)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 刷新按钮 */}
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
disabled={loading}
|
||||
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50 flex-shrink-0"
|
||||
title={t("usage.refreshUsage")}
|
||||
>
|
||||
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 第二行:已用 + 剩余 + 单位 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 已用 */}
|
||||
{firstUsage.used !== undefined && (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
{t("usage.used")}
|
||||
</span>
|
||||
<span className="tabular-nums text-gray-600 dark:text-gray-400 font-medium">
|
||||
{firstUsage.used.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 剩余 */}
|
||||
{firstUsage.remaining !== undefined && (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
{t("usage.remaining")}
|
||||
</span>
|
||||
<span
|
||||
className={`font-semibold tabular-nums ${
|
||||
isExpired
|
||||
? "text-red-500 dark:text-red-400"
|
||||
: firstUsage.remaining <
|
||||
(firstUsage.total || firstUsage.remaining) * 0.1
|
||||
? "text-orange-500 dark:text-orange-400"
|
||||
: "text-green-600 dark:text-green-400"
|
||||
}`}
|
||||
>
|
||||
{firstUsage.remaining.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 单位 */}
|
||||
{firstUsage.unit && (
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
{firstUsage.unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t border-border-default ">
|
||||
{/* 标题行:包含刷新按钮 */}
|
||||
{/* 标题行:包含刷新按钮和自动查询时间 */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
|
||||
{t("usage.planUsage")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
disabled={loading}
|
||||
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50"
|
||||
title={t("usage.refreshUsage")}
|
||||
>
|
||||
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 自动查询时间提示 */}
|
||||
{lastQueriedAt && (
|
||||
<span className="text-[10px] text-gray-400 dark:text-gray-500 flex items-center gap-1">
|
||||
<Clock size={10} />
|
||||
{formatRelativeTime(lastQueriedAt, now, t)}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
disabled={loading}
|
||||
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50"
|
||||
title={t("usage.refreshUsage")}
|
||||
>
|
||||
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 套餐列表 */}
|
||||
@@ -197,4 +329,26 @@ const UsagePlanItem: React.FC<{ data: UsageData }> = ({ data }) => {
|
||||
);
|
||||
};
|
||||
|
||||
// 格式化相对时间
|
||||
function formatRelativeTime(
|
||||
timestamp: number,
|
||||
now: number,
|
||||
t: (key: string, options?: { count?: number }) => string,
|
||||
): string {
|
||||
const diff = Math.floor((now - timestamp) / 1000); // 秒
|
||||
|
||||
if (diff < 60) {
|
||||
return t("usage.justNow");
|
||||
} else if (diff < 3600) {
|
||||
const minutes = Math.floor(diff / 60);
|
||||
return t("usage.minutesAgo", { count: minutes });
|
||||
} else if (diff < 86400) {
|
||||
const hours = Math.floor(diff / 3600);
|
||||
return t("usage.hoursAgo", { count: hours });
|
||||
} else {
|
||||
const days = Math.floor(diff / 86400);
|
||||
return t("usage.daysAgo", { count: days });
|
||||
}
|
||||
}
|
||||
|
||||
export default UsageFooter;
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from "react";
|
||||
import { Play, Wand2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Provider, UsageScript } from "../types";
|
||||
import { Provider, UsageScript } from "@/types";
|
||||
import { usageApi, type AppId } from "@/lib/api";
|
||||
import JsonEditor from "./JsonEditor";
|
||||
import * as prettier from "prettier/standalone";
|
||||
@@ -25,9 +25,32 @@ interface UsageScriptModalProps {
|
||||
onSave: (script: UsageScript) => void;
|
||||
}
|
||||
|
||||
// 预设模板(JS 对象字面量格式)
|
||||
const PRESET_TEMPLATES: Record<string, string> = {
|
||||
通用模板: `({
|
||||
// 预设模板键名(用于国际化)
|
||||
const TEMPLATE_KEYS = {
|
||||
CUSTOM: "custom",
|
||||
GENERAL: "general",
|
||||
NEW_API: "newapi",
|
||||
} as const;
|
||||
|
||||
// 生成预设模板的函数(支持国际化)
|
||||
const generatePresetTemplates = (
|
||||
t: (key: string) => string,
|
||||
): Record<string, string> => ({
|
||||
[TEMPLATE_KEYS.CUSTOM]: `({
|
||||
request: {
|
||||
url: "",
|
||||
method: "GET",
|
||||
headers: {}
|
||||
},
|
||||
extractor: function(response) {
|
||||
return {
|
||||
remaining: 0,
|
||||
unit: "USD"
|
||||
};
|
||||
}
|
||||
})`,
|
||||
|
||||
[TEMPLATE_KEYS.GENERAL]: `({
|
||||
request: {
|
||||
url: "{{baseUrl}}/user/balance",
|
||||
method: "GET",
|
||||
@@ -45,41 +68,39 @@ const PRESET_TEMPLATES: Record<string, string> = {
|
||||
}
|
||||
})`,
|
||||
|
||||
NewAPI: `({
|
||||
[TEMPLATE_KEYS.NEW_API]: `({
|
||||
request: {
|
||||
url: "{{baseUrl}}/api/usage/token",
|
||||
url: "{{baseUrl}}/api/user/self",
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: "Bearer {{apiKey}}",
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer {{accessToken}}",
|
||||
"New-Api-User": "{{userId}}"
|
||||
},
|
||||
},
|
||||
extractor: function (response) {
|
||||
if (response.code) {
|
||||
if (response.data.unlimited_quota) {
|
||||
return {
|
||||
planName: response.data.name,
|
||||
total: -1,
|
||||
used: response.data.total_used / 500000,
|
||||
unit: "USD",
|
||||
};
|
||||
}
|
||||
if (response.success && response.data) {
|
||||
return {
|
||||
isValid: true,
|
||||
planName: response.data.name,
|
||||
total: response.data.total_granted / 500000,
|
||||
used: response.data.total_used / 500000,
|
||||
remaining: response.data.total_available / 500000,
|
||||
planName: response.data.group || "${t("usageScript.defaultPlan")}",
|
||||
remaining: response.data.quota / 500000,
|
||||
used: response.data.used_quota / 500000,
|
||||
total: (response.data.quota + response.data.used_quota) / 500000,
|
||||
unit: "USD",
|
||||
};
|
||||
}
|
||||
if (response.error) {
|
||||
return {
|
||||
isValid: false,
|
||||
invalidMessage: response.error.message,
|
||||
};
|
||||
}
|
||||
return {
|
||||
isValid: false,
|
||||
invalidMessage: response.message || "${t("usageScript.queryFailedMessage")}"
|
||||
};
|
||||
},
|
||||
})`,
|
||||
});
|
||||
|
||||
// 模板名称国际化键映射
|
||||
const TEMPLATE_NAME_KEYS: Record<string, string> = {
|
||||
[TEMPLATE_KEYS.CUSTOM]: "usageScript.templateCustom",
|
||||
[TEMPLATE_KEYS.GENERAL]: "usageScript.templateGeneral",
|
||||
[TEMPLATE_KEYS.NEW_API]: "usageScript.templateNewAPI",
|
||||
};
|
||||
|
||||
const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
@@ -90,16 +111,16 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
onSave,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 生成带国际化的预设模板
|
||||
const PRESET_TEMPLATES = generatePresetTemplates(t);
|
||||
|
||||
const [script, setScript] = useState<UsageScript>(() => {
|
||||
return (
|
||||
provider.meta?.usage_script || {
|
||||
enabled: false,
|
||||
language: "javascript",
|
||||
code: PRESET_TEMPLATES[
|
||||
t("usageScript.presetTemplate") === "预设模板"
|
||||
? "通用模板"
|
||||
: "General"
|
||||
],
|
||||
code: PRESET_TEMPLATES[TEMPLATE_KEYS.GENERAL],
|
||||
timeout: 10,
|
||||
}
|
||||
);
|
||||
@@ -107,6 +128,18 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
|
||||
const [testing, setTesting] = useState(false);
|
||||
|
||||
// 跟踪当前选择的模板类型(用于控制高级配置的显示)
|
||||
// 初始化:如果已有 accessToken 或 userId,说明是 NewAPI 模板
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(
|
||||
() => {
|
||||
const existingScript = provider.meta?.usage_script;
|
||||
if (existingScript?.accessToken || existingScript?.userId) {
|
||||
return TEMPLATE_KEYS.NEW_API;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
const handleSave = () => {
|
||||
// 验证脚本格式
|
||||
if (script.enabled && !script.code.trim()) {
|
||||
@@ -127,7 +160,15 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
const handleTest = async () => {
|
||||
setTesting(true);
|
||||
try {
|
||||
const result = await usageApi.query(provider.id, appId);
|
||||
// 使用当前编辑器中的脚本内容进行测试
|
||||
const result = await usageApi.testScript(
|
||||
provider.id,
|
||||
appId,
|
||||
script.code,
|
||||
script.timeout,
|
||||
script.accessToken,
|
||||
script.userId,
|
||||
);
|
||||
if (result.success && result.data && result.data.length > 0) {
|
||||
// 显示所有套餐数据
|
||||
const summary = result.data
|
||||
@@ -184,10 +225,24 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
const handleUsePreset = (presetName: string) => {
|
||||
const preset = PRESET_TEMPLATES[presetName];
|
||||
if (preset) {
|
||||
setScript({ ...script, code: preset });
|
||||
// 如果选择的不是 NewAPI 模板,清空高级配置字段
|
||||
if (presetName !== TEMPLATE_KEYS.NEW_API) {
|
||||
setScript({
|
||||
...script,
|
||||
code: preset,
|
||||
accessToken: undefined,
|
||||
userId: undefined,
|
||||
});
|
||||
} else {
|
||||
setScript({ ...script, code: preset });
|
||||
}
|
||||
setSelectedTemplate(presetName); // 记录选择的模板
|
||||
}
|
||||
};
|
||||
|
||||
// 判断是否应该显示高级配置(仅 NewAPI 模板需要)
|
||||
const shouldShowAdvancedConfig = selectedTemplate === TEMPLATE_KEYS.NEW_API;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col">
|
||||
@@ -222,18 +277,60 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
{t("usageScript.presetTemplate")}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{Object.keys(PRESET_TEMPLATES).map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
onClick={() => handleUsePreset(name)}
|
||||
className="px-3 py-1.5 text-xs bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
{Object.keys(PRESET_TEMPLATES).map((name) => {
|
||||
const isSelected = selectedTemplate === name;
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
onClick={() => handleUsePreset(name)}
|
||||
className={`px-3 py-1.5 text-xs rounded transition-colors ${
|
||||
isSelected
|
||||
? "bg-blue-500 text-white dark:bg-blue-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(TEMPLATE_NAME_KEYS[name])}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 高级配置:Access Token 和 User ID(仅 NewAPI 模板显示) */}
|
||||
{shouldShowAdvancedConfig && (
|
||||
<div className="space-y-3 p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
|
||||
<label className="block">
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{t("usageScript.accessToken")}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={script.accessToken || ""}
|
||||
onChange={(e) =>
|
||||
setScript({ ...script, accessToken: e.target.value })
|
||||
}
|
||||
placeholder={t("usageScript.accessTokenPlaceholder")}
|
||||
className="mt-1 w-full px-3 py-2 border border-border-default dark:border-border-default rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{t("usageScript.userId")}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={script.userId || ""}
|
||||
onChange={(e) =>
|
||||
setScript({ ...script, userId: e.target.value })
|
||||
}
|
||||
placeholder={t("usageScript.userIdPlaceholder")}
|
||||
className="mt-1 w-full px-3 py-2 border border-border-default dark:border-border-default rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 脚本编辑器 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-gray-900 dark:text-gray-100">
|
||||
@@ -273,6 +370,30 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
className="mt-1 w-full px-3 py-2 border border-border-default dark:border-border-default rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* 🆕 自动查询间隔 */}
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{t("usageScript.autoQueryInterval")}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="1440"
|
||||
step="1"
|
||||
value={script.autoQueryInterval || 0}
|
||||
onChange={(e) =>
|
||||
setScript({
|
||||
...script,
|
||||
autoQueryInterval: parseInt(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
className="mt-1 w-full px-3 py-2 border border-border-default dark:border-border-default rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{t("usageScript.autoQueryIntervalHint")}
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 脚本说明 */}
|
||||
@@ -292,10 +413,10 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
"Authorization": "Bearer {{apiKey}}",
|
||||
"User-Agent": "cc-switch/1.0"
|
||||
},
|
||||
body: JSON.stringify({ key: "value" }) // 可选
|
||||
body: JSON.stringify({ key: "value" }) // ${t("usageScript.commentOptional")}
|
||||
},
|
||||
extractor: function(response) {
|
||||
// response 是 API 返回的 JSON 数据
|
||||
// ${t("usageScript.commentResponseIsJson")}
|
||||
return {
|
||||
isValid: !response.error,
|
||||
remaining: response.balance,
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
ProviderForm,
|
||||
type ProviderFormValues,
|
||||
} from "@/components/providers/forms/ProviderForm";
|
||||
import { providerPresets } from "@/config/providerPresets";
|
||||
import { providerPresets } from "@/config/claudeProviderPresets";
|
||||
import { codexProviderPresets } from "@/config/codexProviderPresets";
|
||||
|
||||
interface AddProviderDialogProps {
|
||||
|
||||
@@ -123,6 +123,7 @@ export function EditProviderDialog({
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<ProviderForm
|
||||
appId={appId}
|
||||
providerId={provider.id}
|
||||
submitLabel={t("common.save")}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
|
||||
@@ -170,20 +170,25 @@ export function ProviderCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProviderActions
|
||||
isCurrent={isCurrent}
|
||||
onSwitch={() => onSwitch(provider)}
|
||||
onEdit={() => onEdit(provider)}
|
||||
onConfigureUsage={() => onConfigureUsage(provider)}
|
||||
onDelete={() => onDelete(provider)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<UsageFooter
|
||||
provider={provider}
|
||||
providerId={provider.id}
|
||||
appId={appId}
|
||||
usageEnabled={usageEnabled}
|
||||
isCurrent={isCurrent}
|
||||
inline={true}
|
||||
/>
|
||||
|
||||
<UsageFooter
|
||||
providerId={provider.id}
|
||||
appId={appId}
|
||||
usageEnabled={usageEnabled}
|
||||
/>
|
||||
<ProviderActions
|
||||
isCurrent={isCurrent}
|
||||
onSwitch={() => onSwitch(provider)}
|
||||
onEdit={() => onEdit(provider)}
|
||||
onConfigureUsage={() => onConfigureUsage(provider)}
|
||||
onDelete={() => onDelete(provider)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,16 +2,16 @@ import { useTranslation } from "react-i18next";
|
||||
import { FormLabel } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import EndpointSpeedTest from "./EndpointSpeedTest";
|
||||
import KimiModelSelector from "./KimiModelSelector";
|
||||
import { ApiKeySection, EndpointField } from "./shared";
|
||||
import type { ProviderCategory } from "@/types";
|
||||
import type { TemplateValueConfig } from "@/config/providerPresets";
|
||||
import type { TemplateValueConfig } from "@/config/claudeProviderPresets";
|
||||
|
||||
interface EndpointCandidate {
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface ClaudeFormFieldsProps {
|
||||
providerId?: string;
|
||||
// API Key
|
||||
shouldShowApiKey: boolean;
|
||||
apiKey: string;
|
||||
@@ -19,6 +19,8 @@ interface ClaudeFormFieldsProps {
|
||||
category?: ProviderCategory;
|
||||
shouldShowApiKeyLink: boolean;
|
||||
websiteUrl: string;
|
||||
isPartner?: boolean;
|
||||
partnerPromotionKey?: string;
|
||||
|
||||
// Template Values
|
||||
templateValueEntries: Array<[string, TemplateValueConfig]>;
|
||||
@@ -35,20 +37,17 @@ interface ClaudeFormFieldsProps {
|
||||
onCustomEndpointsChange: (endpoints: string[]) => void;
|
||||
|
||||
// Model Selector
|
||||
shouldShowKimiSelector: boolean;
|
||||
shouldShowModelSelector: boolean;
|
||||
claudeModel: string;
|
||||
claudeSmallFastModel: string;
|
||||
defaultHaikuModel: string;
|
||||
defaultSonnetModel: string;
|
||||
defaultOpusModel: string;
|
||||
onModelChange: (
|
||||
field: "ANTHROPIC_MODEL" | "ANTHROPIC_SMALL_FAST_MODEL",
|
||||
value: string,
|
||||
) => void;
|
||||
|
||||
// Kimi Model Selector
|
||||
kimiAnthropicModel: string;
|
||||
kimiAnthropicSmallFastModel: string;
|
||||
onKimiModelChange: (
|
||||
field: "ANTHROPIC_MODEL" | "ANTHROPIC_SMALL_FAST_MODEL",
|
||||
field:
|
||||
| "ANTHROPIC_MODEL"
|
||||
| "ANTHROPIC_DEFAULT_HAIKU_MODEL"
|
||||
| "ANTHROPIC_DEFAULT_SONNET_MODEL"
|
||||
| "ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
value: string,
|
||||
) => void;
|
||||
|
||||
@@ -57,12 +56,15 @@ interface ClaudeFormFieldsProps {
|
||||
}
|
||||
|
||||
export function ClaudeFormFields({
|
||||
providerId,
|
||||
shouldShowApiKey,
|
||||
apiKey,
|
||||
onApiKeyChange,
|
||||
category,
|
||||
shouldShowApiKeyLink,
|
||||
websiteUrl,
|
||||
isPartner,
|
||||
partnerPromotionKey,
|
||||
templateValueEntries,
|
||||
templateValues,
|
||||
templatePresetName,
|
||||
@@ -73,14 +75,12 @@ export function ClaudeFormFields({
|
||||
isEndpointModalOpen,
|
||||
onEndpointModalToggle,
|
||||
onCustomEndpointsChange,
|
||||
shouldShowKimiSelector,
|
||||
shouldShowModelSelector,
|
||||
claudeModel,
|
||||
claudeSmallFastModel,
|
||||
defaultHaikuModel,
|
||||
defaultSonnetModel,
|
||||
defaultOpusModel,
|
||||
onModelChange,
|
||||
kimiAnthropicModel,
|
||||
kimiAnthropicSmallFastModel,
|
||||
onKimiModelChange,
|
||||
speedTestEndpoints,
|
||||
}: ClaudeFormFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -95,6 +95,8 @@ export function ClaudeFormFields({
|
||||
category={category}
|
||||
shouldShowLink={shouldShowApiKeyLink}
|
||||
websiteUrl={websiteUrl}
|
||||
isPartner={isPartner}
|
||||
partnerPromotionKey={partnerPromotionKey}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -150,6 +152,7 @@ export function ClaudeFormFields({
|
||||
{shouldShowSpeedTest && isEndpointModalOpen && (
|
||||
<EndpointSpeedTest
|
||||
appId="claude"
|
||||
providerId={providerId}
|
||||
value={baseUrl}
|
||||
onChange={onBaseUrlChange}
|
||||
initialEndpoints={speedTestEndpoints}
|
||||
@@ -163,12 +166,10 @@ export function ClaudeFormFields({
|
||||
{shouldShowModelSelector && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* ANTHROPIC_MODEL */}
|
||||
{/* 主模型 */}
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="claudeModel">
|
||||
{t("providerForm.anthropicModel", {
|
||||
defaultValue: "主模型",
|
||||
})}
|
||||
{t("providerForm.anthropicModel", { defaultValue: "主模型" })}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="claudeModel"
|
||||
@@ -184,22 +185,67 @@ export function ClaudeFormFields({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ANTHROPIC_SMALL_FAST_MODEL */}
|
||||
{/* 默认 Haiku */}
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="claudeSmallFastModel">
|
||||
{t("providerForm.anthropicSmallFastModel", {
|
||||
defaultValue: "快速模型",
|
||||
<FormLabel htmlFor="claudeDefaultHaikuModel">
|
||||
{t("providerForm.anthropicDefaultHaikuModel", {
|
||||
defaultValue: "Haiku 默认模型",
|
||||
})}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="claudeSmallFastModel"
|
||||
id="claudeDefaultHaikuModel"
|
||||
type="text"
|
||||
value={claudeSmallFastModel}
|
||||
value={defaultHaikuModel}
|
||||
onChange={(e) =>
|
||||
onModelChange("ANTHROPIC_SMALL_FAST_MODEL", e.target.value)
|
||||
onModelChange("ANTHROPIC_DEFAULT_HAIKU_MODEL", e.target.value)
|
||||
}
|
||||
placeholder={t("providerForm.smallModelPlaceholder", {
|
||||
defaultValue: "claude-3-5-haiku-20241022",
|
||||
placeholder={t("providerForm.haikuModelPlaceholder", {
|
||||
defaultValue: "GLM-4.5-Air",
|
||||
})}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 默认 Sonnet */}
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="claudeDefaultSonnetModel">
|
||||
{t("providerForm.anthropicDefaultSonnetModel", {
|
||||
defaultValue: "Sonnet 默认模型",
|
||||
})}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="claudeDefaultSonnetModel"
|
||||
type="text"
|
||||
value={defaultSonnetModel}
|
||||
onChange={(e) =>
|
||||
onModelChange(
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
placeholder={t("providerForm.modelPlaceholder", {
|
||||
defaultValue: "claude-3-7-sonnet-20250219",
|
||||
})}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 默认 Opus */}
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="claudeDefaultOpusModel">
|
||||
{t("providerForm.anthropicDefaultOpusModel", {
|
||||
defaultValue: "Opus 默认模型",
|
||||
})}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="claudeDefaultOpusModel"
|
||||
type="text"
|
||||
value={defaultOpusModel}
|
||||
onChange={(e) =>
|
||||
onModelChange("ANTHROPIC_DEFAULT_OPUS_MODEL", e.target.value)
|
||||
}
|
||||
placeholder={t("providerForm.modelPlaceholder", {
|
||||
defaultValue: "claude-3-7-opus-20250219",
|
||||
})}
|
||||
autoComplete="off"
|
||||
/>
|
||||
@@ -213,17 +259,6 @@ export function ClaudeFormFields({
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Kimi 模型选择器 */}
|
||||
{shouldShowKimiSelector && (
|
||||
<KimiModelSelector
|
||||
apiKey={apiKey}
|
||||
anthropicModel={kimiAnthropicModel}
|
||||
anthropicSmallFastModel={kimiAnthropicSmallFastModel}
|
||||
onModelChange={onKimiModelChange}
|
||||
disabled={category === "official"}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,8 +28,6 @@ interface CodexConfigEditorProps {
|
||||
|
||||
configError: string; // config.toml 错误提示
|
||||
|
||||
isCustomMode?: boolean; // 是否为自定义模式
|
||||
|
||||
onWebsiteUrlChange?: (url: string) => void; // 更新网址回调
|
||||
|
||||
isTemplateModalOpen?: boolean; // 模态框状态
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Wand2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { formatJSON } from "@/utils/formatters";
|
||||
|
||||
interface CodexAuthSectionProps {
|
||||
value: string;
|
||||
@@ -19,6 +22,25 @@ export const CodexAuthSection: React.FC<CodexAuthSectionProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleFormat = () => {
|
||||
if (!value.trim()) return;
|
||||
|
||||
try {
|
||||
const formatted = formatJSON(value);
|
||||
onChange(formatted);
|
||||
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(
|
||||
t("common.formatError", {
|
||||
defaultValue: "格式化失败:{{error}}",
|
||||
error: errorMessage,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
@@ -47,13 +69,26 @@ export const CodexAuthSection: React.FC<CodexAuthSectionProps> = ({
|
||||
data-enable-grammarly="false"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-500 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFormat}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
>
|
||||
<Wand2 className="w-3.5 h-3.5" />
|
||||
{t("common.format", { defaultValue: "格式化" })}
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t("codexConfig.authJsonHint")}
|
||||
</p>
|
||||
{error && (
|
||||
<p className="text-xs text-red-500 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!error && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t("codexConfig.authJsonHint")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -141,9 +176,11 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
|
||||
<p className="text-xs text-red-500 dark:text-red-400">{configError}</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t("codexConfig.configTomlHint")}
|
||||
</p>
|
||||
{!configError && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t("codexConfig.configTomlHint")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,12 +8,15 @@ interface EndpointCandidate {
|
||||
}
|
||||
|
||||
interface CodexFormFieldsProps {
|
||||
providerId?: string;
|
||||
// API Key
|
||||
codexApiKey: string;
|
||||
onApiKeyChange: (key: string) => void;
|
||||
category?: ProviderCategory;
|
||||
shouldShowApiKeyLink: boolean;
|
||||
websiteUrl: string;
|
||||
isPartner?: boolean;
|
||||
partnerPromotionKey?: string;
|
||||
|
||||
// Base URL
|
||||
shouldShowSpeedTest: boolean;
|
||||
@@ -28,11 +31,14 @@ interface CodexFormFieldsProps {
|
||||
}
|
||||
|
||||
export function CodexFormFields({
|
||||
providerId,
|
||||
codexApiKey,
|
||||
onApiKeyChange,
|
||||
category,
|
||||
shouldShowApiKeyLink,
|
||||
websiteUrl,
|
||||
isPartner,
|
||||
partnerPromotionKey,
|
||||
shouldShowSpeedTest,
|
||||
codexBaseUrl,
|
||||
onBaseUrlChange,
|
||||
@@ -54,6 +60,8 @@ export function CodexFormFields({
|
||||
category={category}
|
||||
shouldShowLink={shouldShowApiKeyLink}
|
||||
websiteUrl={websiteUrl}
|
||||
isPartner={isPartner}
|
||||
partnerPromotionKey={partnerPromotionKey}
|
||||
placeholder={{
|
||||
official: t("providerForm.codexOfficialNoApiKey", {
|
||||
defaultValue: "官方供应商无需 API Key",
|
||||
@@ -81,6 +89,7 @@ export function CodexFormFields({
|
||||
{shouldShowSpeedTest && isEndpointModalOpen && (
|
||||
<EndpointSpeedTest
|
||||
appId="codex"
|
||||
providerId={providerId}
|
||||
value={codexBaseUrl}
|
||||
onChange={onBaseUrlChange}
|
||||
initialEndpoints={speedTestEndpoints}
|
||||
|
||||
@@ -4,8 +4,13 @@ import {
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Save, Wand2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { formatJSON } from "@/utils/formatters";
|
||||
|
||||
interface CommonConfigEditorProps {
|
||||
value: string;
|
||||
@@ -34,6 +39,44 @@ export function CommonConfigEditor({
|
||||
}: CommonConfigEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleFormatMain = () => {
|
||||
if (!value.trim()) return;
|
||||
|
||||
try {
|
||||
const formatted = formatJSON(value);
|
||||
onChange(formatted);
|
||||
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(
|
||||
t("common.formatError", {
|
||||
defaultValue: "格式化失败:{{error}}",
|
||||
error: errorMessage,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormatModal = () => {
|
||||
if (!commonConfigSnippet.trim()) return;
|
||||
|
||||
try {
|
||||
const formatted = formatJSON(commonConfigSnippet);
|
||||
onCommonConfigSnippetChange(formatted);
|
||||
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(
|
||||
t("common.formatError", {
|
||||
defaultValue: "格式化失败:{{error}}",
|
||||
error: errorMessage,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
@@ -94,26 +137,36 @@ export function CommonConfigEditor({
|
||||
data-gramm_editor="false"
|
||||
data-enable-grammarly="false"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("claudeConfig.fullSettingsHint", {
|
||||
defaultValue: "请填写完整的 Claude Code 配置",
|
||||
})}
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFormatMain}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
>
|
||||
<Wand2 className="w-3.5 h-3.5" />
|
||||
{t("common.format", { defaultValue: "格式化" })}
|
||||
</button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("claudeConfig.fullSettingsHint", {
|
||||
defaultValue: "请填写完整的 Claude Code 配置",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={isModalOpen}
|
||||
onOpenChange={(open) => !open && onModalClose()}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogContent className="sm:max-w-[600px] max-h-[90vh] flex flex-col p-0">
|
||||
<DialogHeader className="px-6 pt-6 pb-0">
|
||||
<DialogTitle>
|
||||
{t("claudeConfig.editCommonConfigTitle", {
|
||||
defaultValue: "编辑通用配置片段",
|
||||
})}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex-1 overflow-auto px-6 py-4 space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("claudeConfig.commonConfigHint", {
|
||||
defaultValue: "通用配置片段将合并到所有启用它的供应商配置中",
|
||||
@@ -134,12 +187,31 @@ export function CommonConfigEditor({
|
||||
data-gramm_editor="false"
|
||||
data-enable-grammarly="false"
|
||||
/>
|
||||
{commonConfigError && (
|
||||
<p className="text-sm text-red-500 dark:text-red-400">
|
||||
{commonConfigError}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFormatModal}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
>
|
||||
<Wand2 className="w-3.5 h-3.5" />
|
||||
{t("common.format", { defaultValue: "格式化" })}
|
||||
</button>
|
||||
{commonConfigError && (
|
||||
<p className="text-sm text-red-500 dark:text-red-400">
|
||||
{commonConfigError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onModalClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="button" onClick={onModalClose} className="gap-2">
|
||||
<Save className="w-4 h-4" />
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
|
||||
@@ -281,70 +281,35 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
||||
|
||||
setAddError(null);
|
||||
|
||||
// 保存到后端
|
||||
try {
|
||||
if (providerId) {
|
||||
await vscodeApi.addCustomEndpoint(appId, 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,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
// 更新本地状态
|
||||
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);
|
||||
if (!normalizedSelected) {
|
||||
onChange(sanitized);
|
||||
}
|
||||
}, [customUrl, entries, normalizedSelected, onChange, appId, providerId, t]);
|
||||
|
||||
setCustomUrl("");
|
||||
}, [customUrl, entries, normalizedSelected, onChange]);
|
||||
|
||||
const handleRemoveEndpoint = useCallback(
|
||||
async (entry: EndpointEntry) => {
|
||||
(entry: EndpointEntry) => {
|
||||
// 清空之前的错误提示
|
||||
setLastError(null);
|
||||
|
||||
// 如果有 providerId,尝试从后端删除
|
||||
if (entry.isCustom && providerId) {
|
||||
try {
|
||||
await vscodeApi.removeCustomEndpoint(appId, providerId, entry.url);
|
||||
} catch (error) {
|
||||
const errorMsg =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
// 只有"端点不存在"时才允许删除本地条目
|
||||
if (
|
||||
errorMsg.includes("not found") ||
|
||||
errorMsg.includes("does not exist") ||
|
||||
errorMsg.includes("不存在")
|
||||
) {
|
||||
console.warn(t("endpointTest.removeEndpointFailed"), errorMsg);
|
||||
// 继续删除本地条目
|
||||
} else {
|
||||
// 其他错误:显示错误提示,阻止删除
|
||||
setLastError(t("endpointTest.removeFailed", { error: errorMsg }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新本地状态(删除成功)
|
||||
// 更新本地状态(延迟提交,不立即从后端删除)
|
||||
setEntries((prev) => {
|
||||
const next = prev.filter((item) => item.id !== entry.id);
|
||||
if (entry.url === normalizedSelected) {
|
||||
@@ -354,7 +319,7 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[normalizedSelected, onChange, appId, providerId, t],
|
||||
[normalizedSelected, onChange],
|
||||
);
|
||||
|
||||
const runSpeedTest = useCallback(async () => {
|
||||
@@ -432,22 +397,11 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
||||
}, [entries, autoSelect, appId, normalizedSelected, onChange, t]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (url: string) => {
|
||||
(url: string) => {
|
||||
if (!url || url === normalizedSelected) return;
|
||||
|
||||
// 更新最后使用时间(对自定义端点)
|
||||
const entry = entries.find((e) => e.url === url);
|
||||
if (entry?.isCustom && providerId) {
|
||||
try {
|
||||
await vscodeApi.updateEndpointLastUsed(appId, providerId, url);
|
||||
} catch (error) {
|
||||
console.error(t("endpointTest.updateLastUsedFailed"), error);
|
||||
}
|
||||
}
|
||||
|
||||
onChange(url);
|
||||
},
|
||||
[normalizedSelected, onChange, appId, entries, providerId, t],
|
||||
[normalizedSelected, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, RefreshCw, AlertCircle } from "lucide-react";
|
||||
|
||||
interface KimiModel {
|
||||
id: string;
|
||||
object: string;
|
||||
created: number;
|
||||
owned_by: string;
|
||||
}
|
||||
|
||||
interface KimiModelSelectorProps {
|
||||
apiKey: string;
|
||||
anthropicModel: string;
|
||||
anthropicSmallFastModel: string;
|
||||
onModelChange: (
|
||||
field: "ANTHROPIC_MODEL" | "ANTHROPIC_SMALL_FAST_MODEL",
|
||||
value: string,
|
||||
) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
|
||||
apiKey,
|
||||
anthropicModel,
|
||||
anthropicSmallFastModel,
|
||||
onModelChange,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [models, setModels] = useState<KimiModel[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [debouncedKey, setDebouncedKey] = useState("");
|
||||
|
||||
// 获取模型列表
|
||||
const fetchModelsWithKey = async (key: string) => {
|
||||
if (!key) {
|
||||
setError(t("kimiSelector.fillApiKeyFirst"));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const response = await fetch("https://api.moonshot.cn/v1/models", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
t("kimiSelector.requestFailed", {
|
||||
error: `${response.status} ${response.statusText}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.data && Array.isArray(data.data)) {
|
||||
setModels(data.data);
|
||||
} else {
|
||||
throw new Error(t("kimiSelector.invalidData"));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(t("kimiSelector.fetchModelsFailed") + ":", err);
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t("kimiSelector.fetchModelsFailed"),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 500ms 防抖 API Key
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedKey(apiKey.trim());
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [apiKey]);
|
||||
|
||||
// 当防抖后的 Key 改变时自动获取模型列表
|
||||
useEffect(() => {
|
||||
if (debouncedKey) {
|
||||
fetchModelsWithKey(debouncedKey);
|
||||
} else {
|
||||
setModels([]);
|
||||
setError("");
|
||||
}
|
||||
}, [debouncedKey]);
|
||||
|
||||
const selectClass = `w-full px-3 py-2 border rounded-lg text-sm transition-colors appearance-none bg-white dark:bg-gray-800 ${
|
||||
disabled
|
||||
? "bg-gray-100 dark:bg-gray-800 border-border-default text-gray-400 dark:text-gray-500 cursor-not-allowed"
|
||||
: "border-border-default dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-border-active "
|
||||
}`;
|
||||
|
||||
const ModelSelect: React.FC<{
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}> = ({ label, value, onChange }) => (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{label}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled || loading || models.length === 0}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="">
|
||||
{loading
|
||||
? t("common.loading")
|
||||
: models.length === 0
|
||||
? t("kimiSelector.noModels")
|
||||
: t("kimiSelector.pleaseSelectModel")}
|
||||
</option>
|
||||
{models.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{model.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500 dark:text-gray-400 pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{t("kimiSelector.modelConfig")}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => debouncedKey && fetchModelsWithKey(debouncedKey)}
|
||||
disabled={disabled || loading || !debouncedKey}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs text-gray-500 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
|
||||
{t("kimiSelector.refreshModels")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-100 dark:bg-red-900/20 border border-red-500/20 dark:border-red-500/30 rounded-lg">
|
||||
<AlertCircle
|
||||
size={16}
|
||||
className="text-red-500 dark:text-red-400 flex-shrink-0"
|
||||
/>
|
||||
<p className="text-red-500 dark:text-red-400 text-xs">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<ModelSelect
|
||||
label={t("kimiSelector.mainModel")}
|
||||
value={anthropicModel}
|
||||
onChange={(value) => onModelChange("ANTHROPIC_MODEL", value)}
|
||||
/>
|
||||
<ModelSelect
|
||||
label={t("kimiSelector.fastModel")}
|
||||
value={anthropicSmallFastModel}
|
||||
onChange={(value) =>
|
||||
onModelChange("ANTHROPIC_SMALL_FAST_MODEL", value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!apiKey.trim() && (
|
||||
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg">
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
{t("kimiSelector.apiKeyHint")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KimiModelSelector;
|
||||
@@ -7,7 +7,10 @@ import { Form } from "@/components/ui/form";
|
||||
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
|
||||
import type { AppId } from "@/lib/api";
|
||||
import type { ProviderCategory, ProviderMeta } from "@/types";
|
||||
import { providerPresets, type ProviderPreset } from "@/config/providerPresets";
|
||||
import {
|
||||
providerPresets,
|
||||
type ProviderPreset,
|
||||
} from "@/config/claudeProviderPresets";
|
||||
import {
|
||||
codexProviderPresets,
|
||||
type CodexProviderPreset,
|
||||
@@ -27,8 +30,6 @@ import {
|
||||
useModelState,
|
||||
useCodexConfigState,
|
||||
useApiKeyLink,
|
||||
useCustomEndpoints,
|
||||
useKimiModelSelector,
|
||||
useTemplateValues,
|
||||
useCommonConfigSnippet,
|
||||
useCodexCommonConfig,
|
||||
@@ -46,6 +47,7 @@ type PresetEntry = {
|
||||
|
||||
interface ProviderFormProps {
|
||||
appId: AppId;
|
||||
providerId?: string;
|
||||
submitLabel: string;
|
||||
onSubmit: (values: ProviderFormValues) => void;
|
||||
onCancel: () => void;
|
||||
@@ -61,6 +63,7 @@ interface ProviderFormProps {
|
||||
|
||||
export function ProviderForm({
|
||||
appId,
|
||||
providerId,
|
||||
submitLabel,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
@@ -76,12 +79,20 @@ export function ProviderForm({
|
||||
const [activePreset, setActivePreset] = useState<{
|
||||
id: string;
|
||||
category?: ProviderCategory;
|
||||
isPartner?: boolean;
|
||||
} | null>(null);
|
||||
const [isEndpointModalOpen, setIsEndpointModalOpen] = useState(false);
|
||||
|
||||
// 新建供应商:收集端点测速弹窗中的"自定义端点",提交时一次性落盘到 meta.custom_endpoints
|
||||
// 编辑供应商:从 initialData.meta.custom_endpoints 恢复端点列表
|
||||
const [draftCustomEndpoints, setDraftCustomEndpoints] = useState<string[]>(
|
||||
[],
|
||||
() => {
|
||||
if (!initialData?.meta?.custom_endpoints) {
|
||||
return [];
|
||||
}
|
||||
// 从 Record<string, CustomEndpoint> 中提取 URL 列表
|
||||
return Object.keys(initialData.meta.custom_endpoints);
|
||||
},
|
||||
);
|
||||
|
||||
// 使用 category hook
|
||||
@@ -95,6 +106,13 @@ export function ProviderForm({
|
||||
useEffect(() => {
|
||||
setSelectedPresetId(initialData ? null : "custom");
|
||||
setActivePreset(null);
|
||||
|
||||
// 重新初始化 draftCustomEndpoints(编辑模式时从 meta 恢复)
|
||||
if (initialData?.meta?.custom_endpoints) {
|
||||
setDraftCustomEndpoints(Object.keys(initialData.meta.custom_endpoints));
|
||||
} else {
|
||||
setDraftCustomEndpoints([]);
|
||||
}
|
||||
}, [appId, initialData]);
|
||||
|
||||
const defaultValues: ProviderFormData = useMemo(
|
||||
@@ -140,12 +158,17 @@ export function ProviderForm({
|
||||
},
|
||||
});
|
||||
|
||||
// 使用 Model hook
|
||||
const { claudeModel, claudeSmallFastModel, handleModelChange } =
|
||||
useModelState({
|
||||
settingsConfig: form.watch("settingsConfig"),
|
||||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||
});
|
||||
// 使用 Model hook(新:主模型 + Haiku/Sonnet/Opus 默认模型)
|
||||
const {
|
||||
claudeModel,
|
||||
defaultHaikuModel,
|
||||
defaultSonnetModel,
|
||||
defaultOpusModel,
|
||||
handleModelChange,
|
||||
} = useModelState({
|
||||
settingsConfig: form.watch("settingsConfig"),
|
||||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||
});
|
||||
|
||||
// 使用 Codex 配置 hook (仅 Codex 模式)
|
||||
const {
|
||||
@@ -214,24 +237,6 @@ export function ProviderForm({
|
||||
}));
|
||||
}, [appId]);
|
||||
|
||||
// 使用 Kimi 模型选择器 hook
|
||||
const {
|
||||
shouldShow: shouldShowKimiSelector,
|
||||
kimiAnthropicModel,
|
||||
kimiAnthropicSmallFastModel,
|
||||
handleKimiModelChange,
|
||||
} = useKimiModelSelector({
|
||||
initialData,
|
||||
settingsConfig: form.watch("settingsConfig"),
|
||||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||
selectedPresetId,
|
||||
presetName:
|
||||
selectedPresetId && selectedPresetId !== "custom"
|
||||
? presetEntries.find((item) => item.id === selectedPresetId)?.preset
|
||||
.name || ""
|
||||
: "",
|
||||
});
|
||||
|
||||
// 使用模板变量 hook (仅 Claude 模式)
|
||||
const {
|
||||
templateValues,
|
||||
@@ -283,7 +288,7 @@ export function ProviderForm({
|
||||
type: "manual",
|
||||
message: t("providerForm.fillParameter", {
|
||||
label: validation.missingField.label,
|
||||
defaultValue: `请填写 ${validation.missingField.label}`,
|
||||
defaultValue: `<EFBFBD><EFBFBD><EFBFBD>填写 ${validation.missingField.label}`,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
@@ -322,10 +327,49 @@ export function ProviderForm({
|
||||
if (activePreset.category) {
|
||||
payload.presetCategory = activePreset.category;
|
||||
}
|
||||
// 继承合作伙伴标识
|
||||
if (activePreset.isPartner) {
|
||||
payload.isPartner = activePreset.isPartner;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 meta 字段(新建与编辑使用不同策略)
|
||||
const mergedMeta = mergeProviderMeta(initialData?.meta, customEndpointsMap);
|
||||
// 处理 meta 字段:基于 draftCustomEndpoints 生成 custom_endpoints
|
||||
// 注意:不使用 customEndpointsMap,因为它包含了候选端点(预设、Base URL 等)
|
||||
// 而我们只需要保存用户真正添加的自定义端点
|
||||
const customEndpointsToSave: Record<
|
||||
string,
|
||||
import("@/types").CustomEndpoint
|
||||
> | null =
|
||||
draftCustomEndpoints.length > 0
|
||||
? draftCustomEndpoints.reduce(
|
||||
(acc, url) => {
|
||||
// 尝试从 initialData.meta 中获取原有的端点元数据(保留 addedAt 和 lastUsed)
|
||||
const existing = initialData?.meta?.custom_endpoints?.[url];
|
||||
if (existing) {
|
||||
acc[url] = existing;
|
||||
} else {
|
||||
// 新端点:使用当前时间戳
|
||||
const now = Date.now();
|
||||
acc[url] = { url, addedAt: now, lastUsed: undefined };
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, import("@/types").CustomEndpoint>,
|
||||
)
|
||||
: null;
|
||||
|
||||
// 检测是否需要清空端点(重要:区分"用户清空端点"和"用户没有修改端点")
|
||||
const hadEndpoints =
|
||||
initialData?.meta?.custom_endpoints &&
|
||||
Object.keys(initialData.meta.custom_endpoints).length > 0;
|
||||
const needsClearEndpoints =
|
||||
hadEndpoints && draftCustomEndpoints.length === 0;
|
||||
|
||||
// 如果用户明确清空了端点,传递空对象(而不是 null)让后端知道要删除
|
||||
const mergedMeta = needsClearEndpoints
|
||||
? mergeProviderMeta(initialData?.meta, {})
|
||||
: mergeProviderMeta(initialData?.meta, customEndpointsToSave);
|
||||
|
||||
if (mergedMeta) {
|
||||
payload.meta = mergedMeta;
|
||||
}
|
||||
@@ -350,14 +394,15 @@ export function ProviderForm({
|
||||
);
|
||||
}, [groupedPresets]);
|
||||
|
||||
// 判断是否显示端点测速(仅第三方和自定义类别)
|
||||
const shouldShowSpeedTest =
|
||||
category === "third_party" || category === "custom";
|
||||
// 判断是否显示端点测速(仅官方类别不显示)
|
||||
const shouldShowSpeedTest = category !== "official";
|
||||
|
||||
// 使用 API Key 链接 hook (Claude)
|
||||
const {
|
||||
shouldShowApiKeyLink: shouldShowClaudeApiKeyLink,
|
||||
websiteUrl: claudeWebsiteUrl,
|
||||
isPartner: isClaudePartner,
|
||||
partnerPromotionKey: claudePartnerPromotionKey,
|
||||
} = useApiKeyLink({
|
||||
appId: "claude",
|
||||
category,
|
||||
@@ -370,6 +415,8 @@ export function ProviderForm({
|
||||
const {
|
||||
shouldShowApiKeyLink: shouldShowCodexApiKeyLink,
|
||||
websiteUrl: codexWebsiteUrl,
|
||||
isPartner: isCodexPartner,
|
||||
partnerPromotionKey: codexPartnerPromotionKey,
|
||||
} = useApiKeyLink({
|
||||
appId: "codex",
|
||||
category,
|
||||
@@ -378,16 +425,6 @@ export function ProviderForm({
|
||||
formWebsiteUrl: form.watch("websiteUrl") || "",
|
||||
});
|
||||
|
||||
// 使用自定义端点 hook
|
||||
const customEndpointsMap = useCustomEndpoints({
|
||||
appId,
|
||||
selectedPresetId,
|
||||
presetEntries,
|
||||
draftCustomEndpoints,
|
||||
baseUrl,
|
||||
codexBaseUrl,
|
||||
});
|
||||
|
||||
// 使用端点测速候选 hook
|
||||
const speedTestEndpoints = useSpeedTestEndpoints({
|
||||
appId,
|
||||
@@ -419,6 +456,7 @@ export function ProviderForm({
|
||||
setActivePreset({
|
||||
id: value,
|
||||
category: entry.preset.category,
|
||||
isPartner: entry.preset.isPartner,
|
||||
});
|
||||
|
||||
if (appId === "codex") {
|
||||
@@ -467,6 +505,12 @@ export function ProviderForm({
|
||||
presetCategoryLabels={presetCategoryLabels}
|
||||
onPresetChange={handlePresetChange}
|
||||
category={category}
|
||||
appId={appId}
|
||||
onOpenWizard={
|
||||
appId === "codex"
|
||||
? () => setIsCodexTemplateModalOpen(true)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -476,6 +520,7 @@ export function ProviderForm({
|
||||
{/* Claude 专属字段 */}
|
||||
{appId === "claude" && (
|
||||
<ClaudeFormFields
|
||||
providerId={providerId}
|
||||
shouldShowApiKey={shouldShowApiKey(
|
||||
form.watch("settingsConfig"),
|
||||
isEditMode,
|
||||
@@ -485,6 +530,8 @@ export function ProviderForm({
|
||||
category={category}
|
||||
shouldShowApiKeyLink={shouldShowClaudeApiKeyLink}
|
||||
websiteUrl={claudeWebsiteUrl}
|
||||
isPartner={isClaudePartner}
|
||||
partnerPromotionKey={claudePartnerPromotionKey}
|
||||
templateValueEntries={templateValueEntries}
|
||||
templateValues={templateValues}
|
||||
templatePresetName={templatePreset?.name || ""}
|
||||
@@ -495,16 +542,12 @@ export function ProviderForm({
|
||||
isEndpointModalOpen={isEndpointModalOpen}
|
||||
onEndpointModalToggle={setIsEndpointModalOpen}
|
||||
onCustomEndpointsChange={setDraftCustomEndpoints}
|
||||
shouldShowKimiSelector={shouldShowKimiSelector}
|
||||
shouldShowModelSelector={
|
||||
category !== "official" && !shouldShowKimiSelector
|
||||
}
|
||||
shouldShowModelSelector={category !== "official"}
|
||||
claudeModel={claudeModel}
|
||||
claudeSmallFastModel={claudeSmallFastModel}
|
||||
defaultHaikuModel={defaultHaikuModel}
|
||||
defaultSonnetModel={defaultSonnetModel}
|
||||
defaultOpusModel={defaultOpusModel}
|
||||
onModelChange={handleModelChange}
|
||||
kimiAnthropicModel={kimiAnthropicModel}
|
||||
kimiAnthropicSmallFastModel={kimiAnthropicSmallFastModel}
|
||||
onKimiModelChange={handleKimiModelChange}
|
||||
speedTestEndpoints={speedTestEndpoints}
|
||||
/>
|
||||
)}
|
||||
@@ -512,11 +555,14 @@ export function ProviderForm({
|
||||
{/* Codex 专属字段 */}
|
||||
{appId === "codex" && (
|
||||
<CodexFormFields
|
||||
providerId={providerId}
|
||||
codexApiKey={codexApiKey}
|
||||
onApiKeyChange={handleCodexApiKeyChange}
|
||||
category={category}
|
||||
shouldShowApiKeyLink={shouldShowCodexApiKeyLink}
|
||||
websiteUrl={codexWebsiteUrl}
|
||||
isPartner={isCodexPartner}
|
||||
partnerPromotionKey={codexPartnerPromotionKey}
|
||||
shouldShowSpeedTest={shouldShowSpeedTest}
|
||||
codexBaseUrl={codexBaseUrl}
|
||||
onBaseUrlChange={handleCodexBaseUrlChange}
|
||||
@@ -541,7 +587,6 @@ export function ProviderForm({
|
||||
commonConfigError={codexCommonConfigError}
|
||||
authError={codexAuthError}
|
||||
configError={codexConfigError}
|
||||
isCustomMode={selectedPresetId === "custom"}
|
||||
onWebsiteUrlChange={(url) => form.setValue("websiteUrl", url)}
|
||||
onNameChange={(name) => form.setValue("name", name)}
|
||||
isTemplateModalOpen={isCodexTemplateModalOpen}
|
||||
@@ -578,5 +623,6 @@ export function ProviderForm({
|
||||
export type ProviderFormValues = ProviderFormData & {
|
||||
presetId?: string;
|
||||
presetCategory?: ProviderCategory;
|
||||
isPartner?: boolean;
|
||||
meta?: ProviderMeta;
|
||||
};
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FormLabel } from "@/components/ui/form";
|
||||
import { ClaudeIcon, CodexIcon } from "@/components/BrandIcons";
|
||||
import { Zap } from "lucide-react";
|
||||
import type { ProviderPreset } from "@/config/providerPresets";
|
||||
import { Zap, Star } from "lucide-react";
|
||||
import type { ProviderPreset } from "@/config/claudeProviderPresets";
|
||||
import type { CodexProviderPreset } from "@/config/codexProviderPresets";
|
||||
import type { ProviderCategory } from "@/types";
|
||||
import type { AppId } from "@/lib/api";
|
||||
|
||||
type PresetEntry = {
|
||||
id: string;
|
||||
@@ -18,6 +19,8 @@ interface ProviderPresetSelectorProps {
|
||||
presetCategoryLabels: Record<string, string>;
|
||||
onPresetChange: (value: string) => void;
|
||||
category?: ProviderCategory; // 新增:当前选中的分类
|
||||
appId?: AppId;
|
||||
onOpenWizard?: () => void; // Codex 专用:打开配置向导
|
||||
}
|
||||
|
||||
export function ProviderPresetSelector({
|
||||
@@ -27,11 +30,13 @@ export function ProviderPresetSelector({
|
||||
presetCategoryLabels,
|
||||
onPresetChange,
|
||||
category,
|
||||
appId,
|
||||
onOpenWizard,
|
||||
}: ProviderPresetSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 根据分类获取提示文字
|
||||
const getCategoryHint = () => {
|
||||
const getCategoryHint = (): React.ReactNode => {
|
||||
switch (category) {
|
||||
case "official":
|
||||
return t("providerForm.officialHint", {
|
||||
@@ -50,6 +55,23 @@ export function ProviderPresetSelector({
|
||||
defaultValue: "💡 第三方供应商需要填写 API Key 和请求地址",
|
||||
});
|
||||
case "custom":
|
||||
// Codex 自定义:在此位置显示"手动配置…或者 使用配置向导"
|
||||
if (appId === "codex" && onOpenWizard) {
|
||||
return (
|
||||
<>
|
||||
{t("providerForm.manualConfig")}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenWizard}
|
||||
className="ml-1 text-blue-500 dark:text-blue-400 hover:text-blue-600 dark:hover:text-blue-300 underline-offset-2 hover:underline"
|
||||
aria-label={t("providerForm.openConfigWizard")}
|
||||
>
|
||||
{t("providerForm.useConfigWizard")}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
// 其他情况沿用原提示
|
||||
return t("providerForm.customApiKeyHint", {
|
||||
defaultValue: "💡 自定义配置需手动填写所有必要字段",
|
||||
});
|
||||
@@ -135,12 +157,13 @@ export function ProviderPresetSelector({
|
||||
if (!entries || entries.length === 0) return null;
|
||||
return entries.map((entry) => {
|
||||
const isSelected = selectedPresetId === entry.id;
|
||||
const isPartner = entry.preset.isPartner;
|
||||
return (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
onClick={() => onPresetChange(entry.id)}
|
||||
className={getPresetButtonClass(isSelected, entry.preset)}
|
||||
className={`${getPresetButtonClass(isSelected, entry.preset)} relative`}
|
||||
style={getPresetButtonStyle(isSelected, entry.preset)}
|
||||
title={
|
||||
presetCategoryLabels[category] ??
|
||||
@@ -151,6 +174,11 @@ export function ProviderPresetSelector({
|
||||
>
|
||||
{renderPresetIcon(entry.preset)}
|
||||
{entry.preset.name}
|
||||
{isPartner && (
|
||||
<span className="absolute -top-1 -right-1 flex items-center gap-0.5 rounded-full bg-gradient-to-r from-amber-500 to-yellow-500 px-1.5 py-0.5 text-[10px] font-bold text-white shadow-md">
|
||||
<Star className="h-2.5 w-2.5 fill-current" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ export { useModelState } from "./useModelState";
|
||||
export { useCodexConfigState } from "./useCodexConfigState";
|
||||
export { useApiKeyLink } from "./useApiKeyLink";
|
||||
export { useCustomEndpoints } from "./useCustomEndpoints";
|
||||
export { useKimiModelSelector } from "./useKimiModelSelector";
|
||||
export { useTemplateValues } from "./useTemplateValues";
|
||||
export { useCommonConfigSnippet } from "./useCommonConfigSnippet";
|
||||
export { useCodexCommonConfig } from "./useCodexCommonConfig";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
import type { AppId } from "@/lib/api";
|
||||
import type { ProviderCategory } from "@/types";
|
||||
import type { ProviderPreset } from "@/config/providerPresets";
|
||||
import type { ProviderPreset } from "@/config/claudeProviderPresets";
|
||||
import type { CodexProviderPreset } from "@/config/codexProviderPresets";
|
||||
|
||||
type PresetEntry = {
|
||||
@@ -37,20 +37,39 @@ export function useApiKeyLink({
|
||||
);
|
||||
}, [category]);
|
||||
|
||||
// 获取当前预设条目
|
||||
const currentPresetEntry = useMemo(() => {
|
||||
if (selectedPresetId && selectedPresetId !== "custom") {
|
||||
return presetEntries.find((item) => item.id === selectedPresetId);
|
||||
}
|
||||
return undefined;
|
||||
}, [selectedPresetId, presetEntries]);
|
||||
|
||||
// 获取当前供应商的网址(用于 API Key 链接)
|
||||
const getWebsiteUrl = useMemo(() => {
|
||||
if (selectedPresetId && selectedPresetId !== "custom") {
|
||||
const entry = presetEntries.find((item) => item.id === selectedPresetId);
|
||||
if (entry) {
|
||||
const preset = entry.preset;
|
||||
// 第三方供应商优先使用 apiKeyUrl
|
||||
return preset.category === "third_party"
|
||||
? preset.apiKeyUrl || preset.websiteUrl || ""
|
||||
: preset.websiteUrl || "";
|
||||
if (currentPresetEntry) {
|
||||
const preset = currentPresetEntry.preset;
|
||||
// 对于 cn_official、aggregator、third_party,优先使用 apiKeyUrl(可能包含推广参数)
|
||||
if (
|
||||
preset.category === "cn_official" ||
|
||||
preset.category === "aggregator" ||
|
||||
preset.category === "third_party"
|
||||
) {
|
||||
return preset.apiKeyUrl || preset.websiteUrl || "";
|
||||
}
|
||||
return preset.websiteUrl || "";
|
||||
}
|
||||
return formWebsiteUrl || "";
|
||||
}, [selectedPresetId, presetEntries, formWebsiteUrl]);
|
||||
}, [currentPresetEntry, formWebsiteUrl]);
|
||||
|
||||
// 提取合作伙伴信息
|
||||
const isPartner = useMemo(() => {
|
||||
return currentPresetEntry?.preset.isPartner ?? false;
|
||||
}, [currentPresetEntry]);
|
||||
|
||||
const partnerPromotionKey = useMemo(() => {
|
||||
return currentPresetEntry?.preset.partnerPromotionKey;
|
||||
}, [currentPresetEntry]);
|
||||
|
||||
return {
|
||||
shouldShowApiKeyLink:
|
||||
@@ -60,5 +79,7 @@ export function useApiKeyLink({
|
||||
? shouldShowApiKeyLink
|
||||
: false,
|
||||
websiteUrl: getWebsiteUrl,
|
||||
isPartner,
|
||||
partnerPromotionKey,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,6 +68,24 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
||||
}
|
||||
}, [codexConfig, codexBaseUrl]);
|
||||
|
||||
// 获取 API Key(从 auth JSON)
|
||||
const getCodexAuthApiKey = useCallback((authString: string): string => {
|
||||
try {
|
||||
const auth = JSON.parse(authString || "{}");
|
||||
return typeof auth.OPENAI_API_KEY === "string" ? auth.OPENAI_API_KEY : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 从 codexAuth 中提取并同步 API Key
|
||||
useEffect(() => {
|
||||
const extractedKey = getCodexAuthApiKey(codexAuth);
|
||||
if (extractedKey !== codexApiKey) {
|
||||
setCodexApiKey(extractedKey);
|
||||
}
|
||||
}, [codexAuth, codexApiKey]);
|
||||
|
||||
// 验证 Codex Auth JSON
|
||||
const validateCodexAuth = useCallback((value: string): string => {
|
||||
if (!value.trim()) return "";
|
||||
@@ -106,10 +124,11 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
||||
// 处理 Codex API Key 输入并写回 auth.json
|
||||
const handleCodexApiKeyChange = useCallback(
|
||||
(key: string) => {
|
||||
setCodexApiKey(key);
|
||||
const trimmed = key.trim();
|
||||
setCodexApiKey(trimmed);
|
||||
try {
|
||||
const auth = JSON.parse(codexAuth || "{}");
|
||||
auth.OPENAI_API_KEY = key.trim();
|
||||
auth.OPENAI_API_KEY = trimmed;
|
||||
setCodexAuth(JSON.stringify(auth, null, 2));
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -178,16 +197,6 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
||||
[setCodexAuth, setCodexConfig],
|
||||
);
|
||||
|
||||
// 获取 API Key(从 auth JSON)
|
||||
const getCodexAuthApiKey = useCallback((authString: string): string => {
|
||||
try {
|
||||
const auth = JSON.parse(authString || "{}");
|
||||
return typeof auth.OPENAI_API_KEY === "string" ? auth.OPENAI_API_KEY : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
codexAuth,
|
||||
codexConfig,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
import type { AppId } from "@/lib/api";
|
||||
import type { CustomEndpoint } from "@/types";
|
||||
import type { ProviderPreset } from "@/config/providerPresets";
|
||||
import type { ProviderPreset } from "@/config/claudeProviderPresets";
|
||||
import type { CodexProviderPreset } from "@/config/codexProviderPresets";
|
||||
|
||||
type PresetEntry = {
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
interface UseKimiModelSelectorProps {
|
||||
initialData?: {
|
||||
settingsConfig?: Record<string, unknown>;
|
||||
};
|
||||
settingsConfig: string;
|
||||
onConfigChange: (config: string) => void;
|
||||
selectedPresetId: string | null;
|
||||
presetName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理 Kimi 模型选择器的状态和逻辑
|
||||
*/
|
||||
export function useKimiModelSelector({
|
||||
initialData,
|
||||
settingsConfig,
|
||||
onConfigChange,
|
||||
selectedPresetId,
|
||||
presetName = "",
|
||||
}: UseKimiModelSelectorProps) {
|
||||
const [kimiAnthropicModel, setKimiAnthropicModel] = useState("");
|
||||
const [kimiAnthropicSmallFastModel, setKimiAnthropicSmallFastModel] =
|
||||
useState("");
|
||||
|
||||
// 判断是否显示 Kimi 模型选择器
|
||||
const shouldShowKimiSelector =
|
||||
selectedPresetId !== null &&
|
||||
selectedPresetId !== "custom" &&
|
||||
presetName.includes("Kimi");
|
||||
|
||||
// 判断是否正在编辑 Kimi 供应商
|
||||
const isEditingKimi = Boolean(
|
||||
initialData &&
|
||||
settingsConfig.includes("api.moonshot.cn") &&
|
||||
settingsConfig.includes("ANTHROPIC_MODEL"),
|
||||
);
|
||||
|
||||
const shouldShow = shouldShowKimiSelector || isEditingKimi;
|
||||
|
||||
// 初始化 Kimi 模型选择(编辑模式)
|
||||
useEffect(() => {
|
||||
if (
|
||||
initialData?.settingsConfig &&
|
||||
typeof initialData.settingsConfig === "object"
|
||||
) {
|
||||
const config = initialData.settingsConfig as {
|
||||
env?: Record<string, unknown>;
|
||||
};
|
||||
if (config.env) {
|
||||
const model =
|
||||
typeof config.env.ANTHROPIC_MODEL === "string"
|
||||
? config.env.ANTHROPIC_MODEL
|
||||
: "";
|
||||
const smallFastModel =
|
||||
typeof config.env.ANTHROPIC_SMALL_FAST_MODEL === "string"
|
||||
? config.env.ANTHROPIC_SMALL_FAST_MODEL
|
||||
: "";
|
||||
setKimiAnthropicModel(model);
|
||||
setKimiAnthropicSmallFastModel(smallFastModel);
|
||||
}
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
// 处理 Kimi 模型变化
|
||||
const handleKimiModelChange = useCallback(
|
||||
(
|
||||
field: "ANTHROPIC_MODEL" | "ANTHROPIC_SMALL_FAST_MODEL",
|
||||
value: string,
|
||||
) => {
|
||||
if (field === "ANTHROPIC_MODEL") {
|
||||
setKimiAnthropicModel(value);
|
||||
} else {
|
||||
setKimiAnthropicSmallFastModel(value);
|
||||
}
|
||||
|
||||
// 更新配置 JSON
|
||||
try {
|
||||
const currentConfig = JSON.parse(settingsConfig || "{}");
|
||||
if (!currentConfig.env) currentConfig.env = {};
|
||||
currentConfig.env[field] = value;
|
||||
|
||||
const updatedConfigString = JSON.stringify(currentConfig, null, 2);
|
||||
onConfigChange(updatedConfigString);
|
||||
} catch (err) {
|
||||
console.error("更新 Kimi 模型配置失败:", err);
|
||||
}
|
||||
},
|
||||
[settingsConfig, onConfigChange],
|
||||
);
|
||||
|
||||
// 当选择 Kimi 预设时,同步模型值
|
||||
useEffect(() => {
|
||||
if (shouldShowKimiSelector && settingsConfig) {
|
||||
try {
|
||||
const config = JSON.parse(settingsConfig);
|
||||
if (config.env) {
|
||||
const model = config.env.ANTHROPIC_MODEL || "";
|
||||
const smallFastModel = config.env.ANTHROPIC_SMALL_FAST_MODEL || "";
|
||||
setKimiAnthropicModel(model);
|
||||
setKimiAnthropicSmallFastModel(smallFastModel);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}, [shouldShowKimiSelector, settingsConfig]);
|
||||
|
||||
return {
|
||||
shouldShow,
|
||||
kimiAnthropicModel,
|
||||
kimiAnthropicSmallFastModel,
|
||||
handleKimiModelChange,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
|
||||
interface UseModelStateProps {
|
||||
settingsConfig: string;
|
||||
@@ -14,18 +14,62 @@ export function useModelState({
|
||||
onConfigChange,
|
||||
}: UseModelStateProps) {
|
||||
const [claudeModel, setClaudeModel] = useState("");
|
||||
const [claudeSmallFastModel, setClaudeSmallFastModel] = useState("");
|
||||
const [defaultHaikuModel, setDefaultHaikuModel] = useState("");
|
||||
const [defaultSonnetModel, setDefaultSonnetModel] = useState("");
|
||||
const [defaultOpusModel, setDefaultOpusModel] = useState("");
|
||||
|
||||
// 初始化读取:读新键;若缺失,按兼容优先级回退
|
||||
// Haiku: DEFAULT_HAIKU || SMALL_FAST || MODEL
|
||||
// Sonnet: DEFAULT_SONNET || MODEL || SMALL_FAST
|
||||
// Opus: DEFAULT_OPUS || MODEL || SMALL_FAST
|
||||
// 仅在 settingsConfig 变化时同步一次(表单加载/切换预设时)
|
||||
useEffect(() => {
|
||||
try {
|
||||
const cfg = settingsConfig ? JSON.parse(settingsConfig) : {};
|
||||
const env = cfg?.env || {};
|
||||
const model =
|
||||
typeof env.ANTHROPIC_MODEL === "string" ? env.ANTHROPIC_MODEL : "";
|
||||
const small =
|
||||
typeof env.ANTHROPIC_SMALL_FAST_MODEL === "string"
|
||||
? env.ANTHROPIC_SMALL_FAST_MODEL
|
||||
: "";
|
||||
const haiku =
|
||||
typeof env.ANTHROPIC_DEFAULT_HAIKU_MODEL === "string"
|
||||
? env.ANTHROPIC_DEFAULT_HAIKU_MODEL
|
||||
: small || model;
|
||||
const sonnet =
|
||||
typeof env.ANTHROPIC_DEFAULT_SONNET_MODEL === "string"
|
||||
? env.ANTHROPIC_DEFAULT_SONNET_MODEL
|
||||
: model || small;
|
||||
const opus =
|
||||
typeof env.ANTHROPIC_DEFAULT_OPUS_MODEL === "string"
|
||||
? env.ANTHROPIC_DEFAULT_OPUS_MODEL
|
||||
: model || small;
|
||||
|
||||
setClaudeModel(model || "");
|
||||
setDefaultHaikuModel(haiku || "");
|
||||
setDefaultSonnetModel(sonnet || "");
|
||||
setDefaultOpusModel(opus || "");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [settingsConfig]);
|
||||
|
||||
const handleModelChange = useCallback(
|
||||
(
|
||||
field: "ANTHROPIC_MODEL" | "ANTHROPIC_SMALL_FAST_MODEL",
|
||||
field:
|
||||
| "ANTHROPIC_MODEL"
|
||||
| "ANTHROPIC_DEFAULT_HAIKU_MODEL"
|
||||
| "ANTHROPIC_DEFAULT_SONNET_MODEL"
|
||||
| "ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
value: string,
|
||||
) => {
|
||||
if (field === "ANTHROPIC_MODEL") {
|
||||
setClaudeModel(value);
|
||||
} else {
|
||||
setClaudeSmallFastModel(value);
|
||||
}
|
||||
if (field === "ANTHROPIC_MODEL") setClaudeModel(value);
|
||||
if (field === "ANTHROPIC_DEFAULT_HAIKU_MODEL")
|
||||
setDefaultHaikuModel(value);
|
||||
if (field === "ANTHROPIC_DEFAULT_SONNET_MODEL")
|
||||
setDefaultSonnetModel(value);
|
||||
if (field === "ANTHROPIC_DEFAULT_OPUS_MODEL") setDefaultOpusModel(value);
|
||||
|
||||
try {
|
||||
const currentConfig = settingsConfig
|
||||
@@ -33,15 +77,18 @@ export function useModelState({
|
||||
: { env: {} };
|
||||
if (!currentConfig.env) currentConfig.env = {};
|
||||
|
||||
if (value.trim()) {
|
||||
currentConfig.env[field] = value.trim();
|
||||
// 新键仅写入;旧键不再写入
|
||||
const trimmed = value.trim();
|
||||
if (trimmed) {
|
||||
currentConfig.env[field] = trimmed;
|
||||
} else {
|
||||
delete currentConfig.env[field];
|
||||
}
|
||||
// 删除旧键
|
||||
delete currentConfig.env["ANTHROPIC_SMALL_FAST_MODEL"];
|
||||
|
||||
onConfigChange(JSON.stringify(currentConfig, null, 2));
|
||||
} catch (err) {
|
||||
// 如果 JSON 解析失败,不做处理
|
||||
console.error("Failed to update model config:", err);
|
||||
}
|
||||
},
|
||||
@@ -51,8 +98,12 @@ export function useModelState({
|
||||
return {
|
||||
claudeModel,
|
||||
setClaudeModel,
|
||||
claudeSmallFastModel,
|
||||
setClaudeSmallFastModel,
|
||||
defaultHaikuModel,
|
||||
setDefaultHaikuModel,
|
||||
defaultSonnetModel,
|
||||
setDefaultSonnetModel,
|
||||
defaultOpusModel,
|
||||
setDefaultOpusModel,
|
||||
handleModelChange,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import type { ProviderCategory } from "@/types";
|
||||
import type { AppId } from "@/lib/api";
|
||||
import { providerPresets } from "@/config/providerPresets";
|
||||
import { providerPresets } from "@/config/claudeProviderPresets";
|
||||
import { codexProviderPresets } from "@/config/codexProviderPresets";
|
||||
|
||||
interface UseProviderCategoryProps {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import type { AppId } from "@/lib/api";
|
||||
import type { ProviderPreset } from "@/config/providerPresets";
|
||||
import type { ProviderPreset } from "@/config/claudeProviderPresets";
|
||||
import type { CodexProviderPreset } from "@/config/codexProviderPresets";
|
||||
import type { ProviderMeta, EndpointCandidate } from "@/types";
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import type {
|
||||
ProviderPreset,
|
||||
TemplateValueConfig,
|
||||
} from "@/config/providerPresets";
|
||||
} from "@/config/claudeProviderPresets";
|
||||
import type { CodexProviderPreset } from "@/config/codexProviderPresets";
|
||||
import { applyTemplateValues } from "@/utils/providerConfigUtils";
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ interface ApiKeySectionProps {
|
||||
thirdParty: string;
|
||||
};
|
||||
disabled?: boolean;
|
||||
isPartner?: boolean;
|
||||
partnerPromotionKey?: string;
|
||||
}
|
||||
|
||||
export function ApiKeySection({
|
||||
@@ -27,6 +29,8 @@ export function ApiKeySection({
|
||||
websiteUrl,
|
||||
placeholder,
|
||||
disabled,
|
||||
isPartner,
|
||||
partnerPromotionKey,
|
||||
}: ApiKeySectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -57,7 +61,7 @@ export function ApiKeySection({
|
||||
/>
|
||||
{/* API Key 获取链接 */}
|
||||
{shouldShowLink && websiteUrl && (
|
||||
<div className="-mt-1 pl-1">
|
||||
<div className="space-y-2 -mt-1 pl-1">
|
||||
<a
|
||||
href={websiteUrl}
|
||||
target="_blank"
|
||||
@@ -68,6 +72,18 @@ export function ApiKeySection({
|
||||
defaultValue: "获取 API Key",
|
||||
})}
|
||||
</a>
|
||||
|
||||
{/* 合作伙伴促销信息 */}
|
||||
{isPartner && partnerPromotionKey && (
|
||||
<div className="rounded-md bg-blue-50 dark:bg-blue-950/30 p-2.5 border border-blue-200 dark:border-blue-800">
|
||||
<p className="text-xs leading-relaxed text-blue-700 dark:text-blue-300">
|
||||
💡{" "}
|
||||
{t(`providerForm.partnerPromotion.${partnerPromotionKey}`, {
|
||||
defaultValue: "",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -34,49 +34,78 @@ export function DirectorySettings({
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<header className="space-y-1">
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("settings.configDirectoryOverride")}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.configDirectoryDescription")}
|
||||
</p>
|
||||
</header>
|
||||
<>
|
||||
{/* CC Switch 配置目录 - 独立区块 */}
|
||||
<section className="space-y-4">
|
||||
<header className="space-y-1">
|
||||
<h3 className="text-sm font-medium">{t("settings.appConfigDir")}</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.appConfigDirDescription")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<DirectoryInput
|
||||
label={t("settings.appConfigDir")}
|
||||
description={t("settings.appConfigDirDescription")}
|
||||
value={appConfigDir}
|
||||
resolvedValue={resolvedDirs.appConfig}
|
||||
placeholder={t("settings.browsePlaceholderApp")}
|
||||
onChange={onAppConfigChange}
|
||||
onBrowse={onBrowseAppConfig}
|
||||
onReset={onResetAppConfig}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={appConfigDir ?? resolvedDirs.appConfig ?? ""}
|
||||
placeholder={t("settings.browsePlaceholderApp")}
|
||||
className="font-mono text-xs"
|
||||
onChange={(event) => onAppConfigChange(event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onBrowseAppConfig}
|
||||
title={t("settings.browseDirectory")}
|
||||
>
|
||||
<FolderSearch className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onResetAppConfig}
|
||||
title={t("settings.resetDefault")}
|
||||
>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DirectoryInput
|
||||
label={t("settings.claudeConfigDir")}
|
||||
description={t("settings.claudeConfigDirDescription")}
|
||||
value={claudeDir}
|
||||
resolvedValue={resolvedDirs.claude}
|
||||
placeholder={t("settings.browsePlaceholderClaude")}
|
||||
onChange={(val) => onDirectoryChange("claude", val)}
|
||||
onBrowse={() => onBrowseDirectory("claude")}
|
||||
onReset={() => onResetDirectory("claude")}
|
||||
/>
|
||||
{/* Claude/Codex 配置目录 - 独立区块 */}
|
||||
<section className="space-y-4">
|
||||
<header className="space-y-1">
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("settings.configDirectoryOverride")}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.configDirectoryDescription")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<DirectoryInput
|
||||
label={t("settings.codexConfigDir")}
|
||||
description={t("settings.codexConfigDirDescription")}
|
||||
value={codexDir}
|
||||
resolvedValue={resolvedDirs.codex}
|
||||
placeholder={t("settings.browsePlaceholderCodex")}
|
||||
onChange={(val) => onDirectoryChange("codex", val)}
|
||||
onBrowse={() => onBrowseDirectory("codex")}
|
||||
onReset={() => onResetDirectory("codex")}
|
||||
/>
|
||||
</section>
|
||||
<DirectoryInput
|
||||
label={t("settings.claudeConfigDir")}
|
||||
description={undefined}
|
||||
value={claudeDir}
|
||||
resolvedValue={resolvedDirs.claude}
|
||||
placeholder={t("settings.browsePlaceholderClaude")}
|
||||
onChange={(val) => onDirectoryChange("claude", val)}
|
||||
onBrowse={() => onBrowseDirectory("claude")}
|
||||
onReset={() => onResetDirectory("claude")}
|
||||
/>
|
||||
|
||||
<DirectoryInput
|
||||
label={t("settings.codexConfigDir")}
|
||||
description={undefined}
|
||||
value={codexDir}
|
||||
resolvedValue={resolvedDirs.codex}
|
||||
placeholder={t("settings.browsePlaceholderCodex")}
|
||||
onChange={(val) => onDirectoryChange("codex", val)}
|
||||
onBrowse={() => onBrowseDirectory("codex")}
|
||||
onReset={() => onResetDirectory("codex")}
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,11 @@ export interface ProviderPreset {
|
||||
apiKeyUrl?: string;
|
||||
settingsConfig: object;
|
||||
isOfficial?: boolean; // 标识是否为官方预设
|
||||
isPartner?: boolean; // 标识是否为商业合作伙伴
|
||||
partnerPromotionKey?: string; // 合作伙伴促销信息的 i18n key
|
||||
category?: ProviderCategory; // 新增:分类
|
||||
// 新增:指定该预设所使用的 API Key 字段名(默认 ANTHROPIC_AUTH_TOKEN)
|
||||
apiKeyField?: "ANTHROPIC_AUTH_TOKEN" | "ANTHROPIC_API_KEY";
|
||||
// 新增:模板变量定义,用于动态替换配置中的值
|
||||
templateValues?: Record<string, TemplateValueConfig>; // editorValue 存储编辑器中的实时输入值
|
||||
// 新增:请求地址候选列表(用于地址管理/测速)
|
||||
@@ -61,7 +65,9 @@ export const providerPresets: ProviderPreset[] = [
|
||||
ANTHROPIC_BASE_URL: "https://api.deepseek.com/anthropic",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_MODEL: "DeepSeek-V3.2-Exp",
|
||||
ANTHROPIC_SMALL_FAST_MODEL: "DeepSeek-V3.2-Exp",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "DeepSeek-V3.2-Exp",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "DeepSeek-V3.2-Exp",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "DeepSeek-V3.2-Exp",
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
@@ -69,19 +75,38 @@ export const providerPresets: ProviderPreset[] = [
|
||||
{
|
||||
name: "Zhipu GLM",
|
||||
websiteUrl: "https://open.bigmodel.cn",
|
||||
apiKeyUrl: "https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://open.bigmodel.cn/api/anthropic",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
// 兼容旧键名,保持前端读取一致
|
||||
ANTHROPIC_MODEL: "GLM-4.6",
|
||||
ANTHROPIC_SMALL_FAST_MODEL: "glm-4.5-air",
|
||||
ANTHROPIC_MODEL: "glm-4.6",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-4.5-air",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-4.6",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-4.6",
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
isPartner: true, // 合作伙伴
|
||||
partnerPromotionKey: "zhipu", // 促销信息 i18n key
|
||||
},
|
||||
{
|
||||
name: "Z.ai GLM",
|
||||
websiteUrl: "https://z.ai",
|
||||
apiKeyUrl: "https://z.ai/subscribe?ic=8JVLJQFSKB",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.z.ai/api/anthropic",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_MODEL: "glm-4.6",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-4.5-air",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-4.6",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-4.6",
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
isPartner: true, // 合作伙伴
|
||||
partnerPromotionKey: "zhipu", // 促销信息 i18n key
|
||||
},
|
||||
{
|
||||
name: "Qwen Coder",
|
||||
@@ -92,7 +117,9 @@ export const providerPresets: ProviderPreset[] = [
|
||||
"https://dashscope.aliyuncs.com/api/v2/apps/claude-code-proxy",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_MODEL: "qwen3-max",
|
||||
ANTHROPIC_SMALL_FAST_MODEL: "qwen3-max",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "qwen3-max",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "qwen3-max",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "qwen3-max",
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
@@ -104,8 +131,10 @@ export const providerPresets: ProviderPreset[] = [
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.moonshot.cn/anthropic",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_MODEL: "kimi-k2-turbo-preview",
|
||||
ANTHROPIC_SMALL_FAST_MODEL: "kimi-k2-turbo-preview",
|
||||
ANTHROPIC_MODEL: "kimi-k2-thinking",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "kimi-k2-thinking",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "kimi-k2-thinking",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "kimi-k2-thinking",
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
@@ -118,22 +147,26 @@ export const providerPresets: ProviderPreset[] = [
|
||||
ANTHROPIC_BASE_URL: "https://api-inference.modelscope.cn",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_MODEL: "ZhipuAI/GLM-4.6",
|
||||
ANTHROPIC_SMALL_FAST_MODEL: "ZhipuAI/GLM-4.6",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "ZhipuAI/GLM-4.6",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "ZhipuAI/GLM-4.6",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "ZhipuAI/GLM-4.6",
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
},
|
||||
{
|
||||
name: "KAT-Coder",
|
||||
websiteUrl: "https://console.streamlake.ai/wanqing/",
|
||||
apiKeyUrl: "https://console.streamlake.ai/console/wanqing/api-key",
|
||||
websiteUrl: "https://console.streamlake.ai",
|
||||
apiKeyUrl: "https://console.streamlake.ai/console/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",
|
||||
ANTHROPIC_MODEL: "KAT-Coder-Pro V1",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "KAT-Coder-Air V1",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "KAT-Coder-Pro V1",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "KAT-Coder-Pro V1",
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
@@ -155,7 +188,7 @@ export const providerPresets: ProviderPreset[] = [
|
||||
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_HAIKU_MODEL: "LongCat-Flash-Chat",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "LongCat-Flash-Chat",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "LongCat-Flash-Chat",
|
||||
CLAUDE_CODE_MAX_OUTPUT_TOKENS: "6000",
|
||||
@@ -164,6 +197,54 @@ export const providerPresets: ProviderPreset[] = [
|
||||
},
|
||||
category: "cn_official",
|
||||
},
|
||||
{
|
||||
name: "MiniMax",
|
||||
websiteUrl: "https://platform.minimaxi.com",
|
||||
apiKeyUrl: "https://platform.minimaxi.com/user-center/basic-information",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.minimaxi.com/anthropic",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
API_TIMEOUT_MS: "3000000",
|
||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: 1,
|
||||
ANTHROPIC_MODEL: "MiniMax-M2",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMax-M2",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMax-M2",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMax-M2",
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
},
|
||||
{
|
||||
name: "AiHubMix",
|
||||
websiteUrl: "https://aihubmix.com",
|
||||
apiKeyUrl: "https://aihubmix.com",
|
||||
// 说明:该供应商使用 ANTHROPIC_API_KEY(而非 ANTHROPIC_AUTH_TOKEN)
|
||||
apiKeyField: "ANTHROPIC_API_KEY",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://aihubmix.com",
|
||||
ANTHROPIC_API_KEY: "",
|
||||
},
|
||||
},
|
||||
// 请求地址候选(用于地址管理/测速),用户可自行选择/覆盖
|
||||
endpointCandidates: ["https://aihubmix.com", "https://api.aihubmix.com"],
|
||||
category: "aggregator",
|
||||
},
|
||||
{
|
||||
name: "DMXAPI",
|
||||
websiteUrl: "https://www.dmxapi.cn",
|
||||
apiKeyUrl: "https://www.dmxapi.cn",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://www.dmxapi.cn",
|
||||
ANTHROPIC_API_KEY: "",
|
||||
},
|
||||
},
|
||||
// 请求地址候选(用于地址管理/测速),用户可自行选择/覆盖
|
||||
endpointCandidates: ["https://aihubmix.com", "https://api.aihubmix.com"],
|
||||
category: "aggregator",
|
||||
},
|
||||
{
|
||||
name: "PackyCode",
|
||||
websiteUrl: "https://www.packyapi.com",
|
||||
@@ -2,7 +2,7 @@
|
||||
* Codex 预设供应商配置模板
|
||||
*/
|
||||
import { ProviderCategory } from "../types";
|
||||
import type { PresetTheme } from "./providerPresets";
|
||||
import type { PresetTheme } from "./claudeProviderPresets";
|
||||
|
||||
export interface CodexProviderPreset {
|
||||
name: string;
|
||||
@@ -12,6 +12,8 @@ export interface CodexProviderPreset {
|
||||
auth: Record<string, any>; // 将写入 ~/.codex/auth.json
|
||||
config: string; // 将写入 ~/.codex/config.toml(TOML 字符串)
|
||||
isOfficial?: boolean; // 标识是否为官方预设
|
||||
isPartner?: boolean; // 标识是否为商业合作伙伴
|
||||
partnerPromotionKey?: string; // 合作伙伴促销信息的 i18n key
|
||||
category?: ProviderCategory; // 新增:分类
|
||||
isCustomTemplate?: boolean; // 标识是否为自定义模板
|
||||
// 新增:请求地址候选列表(用于地址管理/测速)
|
||||
@@ -58,7 +60,7 @@ requires_openai_auth = true`;
|
||||
|
||||
export const codexProviderPresets: CodexProviderPreset[] = [
|
||||
{
|
||||
name: "Codex Official",
|
||||
name: "OpenAI Official",
|
||||
websiteUrl: "https://chatgpt.com/codex",
|
||||
isOfficial: true,
|
||||
category: "official",
|
||||
@@ -70,20 +72,72 @@ export const codexProviderPresets: CodexProviderPreset[] = [
|
||||
textColor: "#FFFFFF",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Azure OpenAI",
|
||||
websiteUrl:
|
||||
"https://learn.microsoft.com/azure/ai-services/openai/how-to/overview",
|
||||
category: "third_party",
|
||||
isOfficial: true,
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: `model_provider = "azure"
|
||||
model = "gpt-5-codex"
|
||||
model_reasoning_effort = "high"
|
||||
disable_response_storage = true
|
||||
|
||||
[model_providers.azure]
|
||||
name = "Azure OpenAI"
|
||||
base_url = "https://YOUR_RESOURCE_NAME.openai.azure.com/openai"
|
||||
env_key = "OPENAI_API_KEY"
|
||||
query_params = { "api-version" = "2025-04-01-preview" }
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = true`,
|
||||
endpointCandidates: ["https://YOUR_RESOURCE_NAME.openai.azure.com/openai"],
|
||||
theme: {
|
||||
icon: "codex",
|
||||
backgroundColor: "#0078D4",
|
||||
textColor: "#FFFFFF",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "AiHubMix",
|
||||
websiteUrl: "https://aihubmix.com",
|
||||
category: "aggregator",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: generateThirdPartyConfig(
|
||||
"aihubmix",
|
||||
"https://aihubmix.com/v1",
|
||||
"gpt-5-codex",
|
||||
),
|
||||
endpointCandidates: [
|
||||
"https://aihubmix.com/v1",
|
||||
"https://api.aihubmix.com/v1",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "DMXAPI",
|
||||
websiteUrl: "https://www.dmxapi.cn",
|
||||
category: "aggregator",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: generateThirdPartyConfig(
|
||||
"dmxapi",
|
||||
"https://www.dmxapi.cn/v1",
|
||||
"gpt-5-codex",
|
||||
),
|
||||
endpointCandidates: ["https://www.dmxapi.cn/v1"],
|
||||
},
|
||||
{
|
||||
name: "PackyCode",
|
||||
websiteUrl: "https://codex.packycode.com/",
|
||||
websiteUrl: "https://www.packyapi.com",
|
||||
category: "third_party",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: generateThirdPartyConfig(
|
||||
"packycode",
|
||||
"https://codex-api.packycode.com/v1",
|
||||
"https://www.packyapi.com/v1",
|
||||
"gpt-5-codex",
|
||||
),
|
||||
// Codex 请求地址候选(用于地址管理/测速)
|
||||
endpointCandidates: [
|
||||
"https://codex-api.packycode.com/v1",
|
||||
"https://codex-api-slb.packycode.com/v1",
|
||||
"https://www.packyapi.com/v1",
|
||||
"https://api-slb.packyapi.com/v1",
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -93,14 +147,13 @@ export const codexProviderPresets: CodexProviderPreset[] = [
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: generateThirdPartyConfig(
|
||||
"anyrouter",
|
||||
"https://anyrouter.top",
|
||||
"https://anyrouter.top/v1",
|
||||
"gpt-5-codex",
|
||||
),
|
||||
// Codex 请求地址候选(用于地址管理/测速)
|
||||
endpointCandidates: [
|
||||
"https://anyrouter.top",
|
||||
"https://q.quuvv.cn",
|
||||
"https://pmpjfbhq.cn-nb1.rainapp.top",
|
||||
"https://anyrouter.top/v1",
|
||||
"https://q.quuvv.cn/v1",
|
||||
"https://pmpjfbhq.cn-nb1.rainapp.top/v1",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { settingsApi } from "@/lib/api";
|
||||
import { syncCurrentProvidersLiveSafe } from "@/utils/postChangeSync";
|
||||
|
||||
export type ImportStatus =
|
||||
| "idle"
|
||||
@@ -105,8 +106,8 @@ export function useImportExport(
|
||||
|
||||
setBackupId(result.backupId ?? null);
|
||||
|
||||
try {
|
||||
await settingsApi.syncCurrentProvidersLive();
|
||||
const syncResult = await syncCurrentProvidersLiveSafe();
|
||||
if (syncResult.ok) {
|
||||
setStatus("success");
|
||||
toast.success(
|
||||
t("settings.importSuccess", {
|
||||
@@ -117,8 +118,11 @@ export function useImportExport(
|
||||
successTimerRef.current = window.setTimeout(() => {
|
||||
void onImportSuccess?.();
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
console.error("[useImportExport] Failed to sync live config", error);
|
||||
} else {
|
||||
console.error(
|
||||
"[useImportExport] Failed to sync live config",
|
||||
syncResult.error,
|
||||
);
|
||||
setStatus("partial-success");
|
||||
toast.warning(
|
||||
t("settings.importPartialSuccess", {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { providersApi, settingsApi, type AppId } from "@/lib/api";
|
||||
import { syncCurrentProvidersLiveSafe } from "@/utils/postChangeSync";
|
||||
import { useSettingsQuery, useSaveSettingsMutation } from "@/lib/query";
|
||||
import type { Settings } from "@/types";
|
||||
import { useSettingsForm, type SettingsFormState } from "./useSettingsForm";
|
||||
@@ -120,6 +121,8 @@ export function useSettings(): UseSettingsResult {
|
||||
const sanitizedClaudeDir = sanitizeDir(settings.claudeConfigDir);
|
||||
const sanitizedCodexDir = sanitizeDir(settings.codexConfigDir);
|
||||
const previousAppDir = initialAppConfigDir;
|
||||
const previousClaudeDir = sanitizeDir(data?.claudeConfigDir);
|
||||
const previousCodexDir = sanitizeDir(data?.codexConfigDir);
|
||||
|
||||
const payload: Settings = {
|
||||
...settings,
|
||||
@@ -167,6 +170,19 @@ export function useSettings(): UseSettingsResult {
|
||||
console.warn("[useSettings] Failed to refresh tray menu", error);
|
||||
}
|
||||
|
||||
// 如果 Claude/Codex 的目录覆盖发生变化,则立即将“当前使用的供应商”写回对应应用的 live 配置
|
||||
const claudeDirChanged = sanitizedClaudeDir !== previousClaudeDir;
|
||||
const codexDirChanged = sanitizedCodexDir !== previousCodexDir;
|
||||
if (claudeDirChanged || codexDirChanged) {
|
||||
const syncResult = await syncCurrentProvidersLiveSafe();
|
||||
if (!syncResult.ok) {
|
||||
console.warn(
|
||||
"[useSettings] Failed to sync current providers after directory change",
|
||||
syncResult.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const appDirChanged = sanitizedAppDir !== (previousAppDir ?? undefined);
|
||||
setRequiresRestart(appDirChanged);
|
||||
|
||||
@@ -177,6 +193,7 @@ export function useSettings(): UseSettingsResult {
|
||||
}
|
||||
}, [
|
||||
appConfigDir,
|
||||
data,
|
||||
initialAppConfigDir,
|
||||
saveMutation,
|
||||
settings,
|
||||
|
||||
@@ -22,7 +22,10 @@
|
||||
"unknown": "Unknown",
|
||||
"enterValidValue": "Please enter a valid value",
|
||||
"clear": "Clear",
|
||||
"toggleTheme": "Toggle theme"
|
||||
"toggleTheme": "Toggle theme",
|
||||
"format": "Format",
|
||||
"formatSuccess": "Formatted successfully",
|
||||
"formatError": "Format failed: {{error}}"
|
||||
},
|
||||
"apiKeyInput": {
|
||||
"placeholder": "Enter API Key",
|
||||
@@ -126,7 +129,7 @@
|
||||
"themeDark": "Dark",
|
||||
"themeSystem": "System",
|
||||
"importExport": "Import/Export Config",
|
||||
"importExportHint": "Import or export cc-switch configuration for backup or migration.",
|
||||
"importExportHint": "Import or export CC Switch configuration for backup or migration.",
|
||||
"exportConfig": "Export Config to File",
|
||||
"selectConfigFile": "Select Config File",
|
||||
"noFileSelected": "No configuration file selected.",
|
||||
@@ -152,9 +155,9 @@
|
||||
"enableClaudePluginIntegration": "Apply to Claude Code extension",
|
||||
"enableClaudePluginIntegrationDescription": "When enabled, the VS Code Claude Code extension provider will switch with this app",
|
||||
"configDirectoryOverride": "Configuration Directory Override (Advanced)",
|
||||
"configDirectoryDescription": "When using Claude Code or Codex in environments like WSL, you can manually specify the configuration directory in WSL to keep provider data consistent with the main environment.",
|
||||
"appConfigDir": "CC-Switch Configuration Directory",
|
||||
"appConfigDirDescription": "Customize the storage location for CC-Switch configuration files (config.json, etc.)",
|
||||
"configDirectoryDescription": "When using Claude Code or Codex in environments like WSL, you can manually specify the configuration directory to the one in WSL to keep provider data consistent with the main environment.",
|
||||
"appConfigDir": "CC Switch Configuration Directory",
|
||||
"appConfigDirDescription": "Customize the storage location for CC Switch configuration (point to cloud sync folder to enable config sync)",
|
||||
"browsePlaceholderApp": "e.g., C:\\Users\\Administrator\\.cc-switch",
|
||||
"claudeConfigDir": "Claude Code Configuration Directory",
|
||||
"claudeConfigDirDescription": "Override Claude configuration directory (settings.json) and keep claude.json (MCP) alongside it.",
|
||||
@@ -181,7 +184,7 @@
|
||||
"importFailedError": "Import config failed: {{message}}",
|
||||
"exportFailedError": "Export config failed:",
|
||||
"restartRequired": "Restart Required",
|
||||
"restartRequiredMessage": "Modifying the CC-Switch configuration directory requires restarting the application to take effect. Restart now?",
|
||||
"restartRequiredMessage": "Modifying the CC Switch configuration directory requires restarting the application to take effect. Restart now?",
|
||||
"restartNow": "Restart Now",
|
||||
"restartLater": "Restart Later",
|
||||
"restartFailed": "Application restart failed, please manually close and reopen.",
|
||||
@@ -223,18 +226,21 @@
|
||||
"manageAndTest": "Manage & Test",
|
||||
"configContent": "Config Content",
|
||||
"useConfigWizard": "Use Configuration Wizard",
|
||||
"openConfigWizard": "Open 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",
|
||||
"codexApiKeyAutoFill": "Just fill in here, auth.json below will be auto-filled",
|
||||
"kimiApiKeyHint": "Fill in to get model list",
|
||||
"apiKeyAutoFill": "Just fill in here, config below will be auto-filled",
|
||||
"cnOfficialApiKeyHint": "💡 Opensource official providers only need API Key, endpoint is preset",
|
||||
"aggregatorApiKeyHint": "💡 Aggregator providers only need API Key to use",
|
||||
"thirdPartyApiKeyHint": "💡 Third-party providers require both API Key and endpoint",
|
||||
"cnOfficialApiKeyHint": "💡 Only need to fill in API Key, endpoint is preset",
|
||||
"aggregatorApiKeyHint": "💡 Only need to fill in API Key, endpoint is preset",
|
||||
"thirdPartyApiKeyHint": "💡 Only need to fill in API Key, endpoint is preset",
|
||||
"customApiKeyHint": "💡 Custom configuration requires manually filling all necessary fields",
|
||||
"officialHint": "💡 Official provider uses browser login, no API Key needed",
|
||||
"getApiKey": "Get API Key",
|
||||
"partnerPromotion": {
|
||||
"zhipu": "Zhipu GLM is an official partner of CC Switch. Use this link to top up and get a 10% discount"
|
||||
},
|
||||
"parameterConfig": "Parameter Config - {{name}} *",
|
||||
"mainModel": "Main Model (optional)",
|
||||
"mainModelPlaceholder": "e.g., GLM-4.6",
|
||||
@@ -255,8 +261,12 @@
|
||||
"visitWebsite": "Visit {{url}}",
|
||||
"anthropicModel": "Main Model",
|
||||
"anthropicSmallFastModel": "Fast Model",
|
||||
"anthropicDefaultHaikuModel": "Default Haiku Model",
|
||||
"anthropicDefaultSonnetModel": "Default Sonnet Model",
|
||||
"anthropicDefaultOpusModel": "Default Opus Model",
|
||||
"modelPlaceholder": "GLM-4.6",
|
||||
"smallModelPlaceholder": "GLM-4.5-Air",
|
||||
"haikuModelPlaceholder": "GLM-4.5-Air",
|
||||
"modelHelper": "Optional: Specify default Claude model to use, leave blank to use system default.",
|
||||
"categoryOfficial": "Official",
|
||||
"categoryCnOfficial": "Opensource Official",
|
||||
@@ -331,16 +341,33 @@
|
||||
"invalid": "Expired",
|
||||
"total": "Total:",
|
||||
"used": "Used:",
|
||||
"remaining": "Remaining:"
|
||||
"remaining": "Remaining:",
|
||||
"justNow": "Just now",
|
||||
"minutesAgo": "{{count}} min ago",
|
||||
"hoursAgo": "{{count}} hr ago",
|
||||
"daysAgo": "{{count}} day ago"
|
||||
},
|
||||
"usageScript": {
|
||||
"title": "Configure Usage Query",
|
||||
"enableUsageQuery": "Enable usage query",
|
||||
"presetTemplate": "Preset template",
|
||||
"templateCustom": "Custom",
|
||||
"templateGeneral": "General",
|
||||
"templateNewAPI": "NewAPI",
|
||||
"accessToken": "Access Token",
|
||||
"accessTokenPlaceholder": "Generate in 'Security Settings'",
|
||||
"userId": "User ID",
|
||||
"userIdPlaceholder": "e.g., 114514",
|
||||
"defaultPlan": "Default Plan",
|
||||
"queryFailedMessage": "Query failed",
|
||||
"queryScript": "Query script (JavaScript)",
|
||||
"timeoutSeconds": "Timeout (seconds)",
|
||||
"autoQueryInterval": "Auto Query Interval (minutes)",
|
||||
"autoQueryIntervalHint": "0 to disable, recommend 5-60 minutes",
|
||||
"scriptHelp": "Script writing instructions:",
|
||||
"configFormat": "Configuration format:",
|
||||
"commentOptional": "optional",
|
||||
"commentResponseIsJson": "response is the JSON data returned by the API",
|
||||
"extractorFormat": "Extractor return format (all fields optional):",
|
||||
"tips": "💡 Tips:",
|
||||
"testing": "Testing...",
|
||||
@@ -366,19 +393,10 @@
|
||||
"tip2": "• Extractor function runs in sandbox environment, supports ES2020+ syntax",
|
||||
"tip3": "• Entire config must be wrapped in () to form object literal expression"
|
||||
},
|
||||
"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"
|
||||
"errors": {
|
||||
"usage_query_failed": "Usage query failed"
|
||||
},
|
||||
|
||||
"presetSelector": {
|
||||
"title": "Select Configuration Type",
|
||||
"custom": "Custom",
|
||||
|
||||
@@ -22,7 +22,10 @@
|
||||
"unknown": "未知",
|
||||
"enterValidValue": "请输入有效的内容",
|
||||
"clear": "清除",
|
||||
"toggleTheme": "切换主题"
|
||||
"toggleTheme": "切换主题",
|
||||
"format": "格式化",
|
||||
"formatSuccess": "格式化成功",
|
||||
"formatError": "格式化失败:{{error}}"
|
||||
},
|
||||
"apiKeyInput": {
|
||||
"placeholder": "请输入API Key",
|
||||
@@ -126,7 +129,7 @@
|
||||
"themeDark": "深色",
|
||||
"themeSystem": "跟随系统",
|
||||
"importExport": "导入导出配置",
|
||||
"importExportHint": "导入导出 cc-switch 配置,便于备份或迁移。",
|
||||
"importExportHint": "导入导出 CC Switch 配置,便于备份或迁移。",
|
||||
"exportConfig": "导出配置到文件",
|
||||
"selectConfigFile": "选择配置文件",
|
||||
"noFileSelected": "尚未选择配置文件。",
|
||||
@@ -152,9 +155,9 @@
|
||||
"enableClaudePluginIntegration": "应用到 Claude Code 插件",
|
||||
"enableClaudePluginIntegrationDescription": "开启后 Vscode Claude Code 插件的供应商将随本软件切换",
|
||||
"configDirectoryOverride": "配置目录覆盖(高级)",
|
||||
"configDirectoryDescription": "在 WSL 等环境使用 Claude Code 或 Codex 的时候,可手动指定 WSL 里的配置目录,供应商数据与主环境保持一致。",
|
||||
"appConfigDir": "CC-Switch 配置目录",
|
||||
"appConfigDirDescription": "自定义 CC-Switch 的配置存储位置(config.json 等文件)",
|
||||
"configDirectoryDescription": "在 WSL 等环境使用 Claude Code 或 Codex 的时候,可手动指定为 WSL 里的配置目录,供应商数据与主环境保持一致。",
|
||||
"appConfigDir": "CC Switch 配置目录",
|
||||
"appConfigDirDescription": "自定义 CC Switch 的配置存储位置(指定到云同步文件夹即可云同步配置)",
|
||||
"browsePlaceholderApp": "例如:C:\\Users\\Administrator\\.cc-switch",
|
||||
"claudeConfigDir": "Claude Code 配置目录",
|
||||
"claudeConfigDirDescription": "覆盖 Claude 配置目录 (settings.json),同时会在同级存放 Claude MCP 的 claude.json。",
|
||||
@@ -181,7 +184,7 @@
|
||||
"importFailedError": "导入配置失败:{{message}}",
|
||||
"exportFailedError": "导出配置失败:",
|
||||
"restartRequired": "需要重启应用",
|
||||
"restartRequiredMessage": "修改 CC-Switch 配置目录后需要重启应用才能生效,是否立即重启?",
|
||||
"restartRequiredMessage": "修改 CC Switch 配置目录后需要重启应用才能生效,是否立即重启?",
|
||||
"restartNow": "立即重启",
|
||||
"restartLater": "稍后重启",
|
||||
"restartFailed": "应用重启失败,请手动关闭后重新打开。",
|
||||
@@ -223,18 +226,21 @@
|
||||
"manageAndTest": "管理与测速",
|
||||
"configContent": "配置内容",
|
||||
"useConfigWizard": "使用配置向导",
|
||||
"openConfigWizard": "打开配置向导",
|
||||
"manualConfig": "手动配置供应商,需要填写完整的配置信息,或者",
|
||||
"officialNoApiKey": "官方登录无需填写 API Key,直接保存即可",
|
||||
"codexOfficialNoApiKey": "官方无需填写 API Key,直接保存即可",
|
||||
"codexApiKeyAutoFill": "只需要填这里,下方 auth.json 会自动填充",
|
||||
"kimiApiKeyHint": "填写后可获取模型列表",
|
||||
"apiKeyAutoFill": "只需要填这里,下方配置会自动填充",
|
||||
"cnOfficialApiKeyHint": "💡 开源官方供应商只需填写 API Key,请求地址已预设",
|
||||
"aggregatorApiKeyHint": "💡 聚合服务供应商只需填写 API Key 即可使用",
|
||||
"thirdPartyApiKeyHint": "💡 第三方供应商需要填写 API Key 和请求地址",
|
||||
"cnOfficialApiKeyHint": "💡 只需填写 API Key,请求地址已预设",
|
||||
"aggregatorApiKeyHint": "💡 只需填写 API Key,请求地址已预设",
|
||||
"thirdPartyApiKeyHint": "💡 只需填写 API Key,请求地址已预设",
|
||||
"customApiKeyHint": "💡 自定义配置需手动填写所有必要字段",
|
||||
"officialHint": "💡 官方供应商使用浏览器登录,无需配置 API Key",
|
||||
"getApiKey": "获取 API Key",
|
||||
"partnerPromotion": {
|
||||
"zhipu": "智谱 GLM 是 CC Switch 的官方合作伙伴,使用此链接充值可以获得9折优惠"
|
||||
},
|
||||
"parameterConfig": "参数配置 - {{name}} *",
|
||||
"mainModel": "主模型 (可选)",
|
||||
"mainModelPlaceholder": "例如: GLM-4.6",
|
||||
@@ -255,8 +261,12 @@
|
||||
"visitWebsite": "访问 {{url}}",
|
||||
"anthropicModel": "主模型",
|
||||
"anthropicSmallFastModel": "快速模型",
|
||||
"anthropicDefaultHaikuModel": "Haiku 默认模型",
|
||||
"anthropicDefaultSonnetModel": "Sonnet 默认模型",
|
||||
"anthropicDefaultOpusModel": "Opus 默认模型",
|
||||
"modelPlaceholder": "GLM-4.6",
|
||||
"smallModelPlaceholder": "GLM-4.5-Air",
|
||||
"haikuModelPlaceholder": "GLM-4.5-Air",
|
||||
"modelHelper": "可选:指定默认使用的 Claude 模型,留空则使用系统默认。",
|
||||
"categoryOfficial": "官方",
|
||||
"categoryCnOfficial": "开源官方",
|
||||
@@ -330,17 +340,34 @@
|
||||
"planUsage": "套餐用量",
|
||||
"invalid": "已失效",
|
||||
"total": "总:",
|
||||
"used": "使用:",
|
||||
"remaining": "剩余:"
|
||||
"used": "已使用:",
|
||||
"remaining": "剩余:",
|
||||
"justNow": "刚刚",
|
||||
"minutesAgo": "{{count}} 分钟前",
|
||||
"hoursAgo": "{{count}} 小时前",
|
||||
"daysAgo": "{{count}} 天前"
|
||||
},
|
||||
"usageScript": {
|
||||
"title": "配置用量查询",
|
||||
"enableUsageQuery": "启用用量查询",
|
||||
"presetTemplate": "预设模板",
|
||||
"templateCustom": "自定义",
|
||||
"templateGeneral": "通用模板",
|
||||
"templateNewAPI": "NewAPI",
|
||||
"accessToken": "访问令牌",
|
||||
"accessTokenPlaceholder": "在'安全设置'里生成",
|
||||
"userId": "用户 ID",
|
||||
"userIdPlaceholder": "例如:114514",
|
||||
"defaultPlan": "默认套餐",
|
||||
"queryFailedMessage": "查询失败",
|
||||
"queryScript": "查询脚本(JavaScript)",
|
||||
"timeoutSeconds": "超时时间(秒)",
|
||||
"autoQueryInterval": "自动查询间隔(分钟)",
|
||||
"autoQueryIntervalHint": "0 表示不自动查询,建议设置 5-60 分钟",
|
||||
"scriptHelp": "脚本编写说明:",
|
||||
"configFormat": "配置格式:",
|
||||
"commentOptional": "可选",
|
||||
"commentResponseIsJson": "response 是 API 返回的 JSON 数据",
|
||||
"extractorFormat": "extractor 返回格式(所有字段均为可选):",
|
||||
"tips": "💡 提示:",
|
||||
"testing": "测试中...",
|
||||
@@ -366,19 +393,10 @@
|
||||
"tip2": "• extractor 函数在沙箱环境中执行,支持 ES2020+ 语法",
|
||||
"tip3": "• 整个配置必须用 () 包裹,形成对象字面量表达式"
|
||||
},
|
||||
"kimiSelector": {
|
||||
"modelConfig": "模型配置",
|
||||
"mainModel": "主模型",
|
||||
"fastModel": "快速模型",
|
||||
"refreshModels": "刷新模型列表",
|
||||
"pleaseSelectModel": "请选择模型",
|
||||
"noModels": "暂无模型",
|
||||
"fillApiKeyFirst": "请先填写 API Key",
|
||||
"requestFailed": "请求失败: {{error}}",
|
||||
"invalidData": "返回数据格式错误",
|
||||
"fetchModelsFailed": "获取模型列表失败",
|
||||
"apiKeyHint": "💡 填写 API Key 后将自动获取可用模型列表"
|
||||
"errors": {
|
||||
"usage_query_failed": "用量查询失败"
|
||||
},
|
||||
|
||||
"presetSelector": {
|
||||
"title": "选择配置类型",
|
||||
"custom": "自定义",
|
||||
|
||||
@@ -39,7 +39,7 @@ export const settingsApi = {
|
||||
},
|
||||
|
||||
async selectConfigDirectory(defaultPath?: string): Promise<string | null> {
|
||||
return await invoke("pick_directory", { default_path: defaultPath });
|
||||
return await invoke("pick_directory", { defaultPath });
|
||||
},
|
||||
|
||||
async getClaudeCodeConfigPath(): Promise<string> {
|
||||
@@ -70,10 +70,7 @@ export const settingsApi = {
|
||||
},
|
||||
|
||||
async saveFileDialog(defaultName: string): Promise<string | null> {
|
||||
return await invoke("save_file_dialog", {
|
||||
default_name: defaultName,
|
||||
defaultName,
|
||||
});
|
||||
return await invoke("save_file_dialog", { defaultName });
|
||||
},
|
||||
|
||||
async openFileDialog(): Promise<string | null> {
|
||||
@@ -81,17 +78,11 @@ export const settingsApi = {
|
||||
},
|
||||
|
||||
async exportConfigToFile(filePath: string): Promise<ConfigTransferResult> {
|
||||
return await invoke("export_config_to_file", {
|
||||
file_path: filePath,
|
||||
filePath,
|
||||
});
|
||||
return await invoke("export_config_to_file", { filePath });
|
||||
},
|
||||
|
||||
async importConfigFromFile(filePath: string): Promise<ConfigTransferResult> {
|
||||
return await invoke("import_config_from_file", {
|
||||
file_path: filePath,
|
||||
filePath,
|
||||
});
|
||||
return await invoke("import_config_from_file", { filePath });
|
||||
},
|
||||
|
||||
async syncCurrentProvidersLive(): Promise<void> {
|
||||
|
||||
@@ -1,12 +1,61 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { UsageResult } from "@/types";
|
||||
import type { AppId } from "./types";
|
||||
import i18n from "@/i18n";
|
||||
|
||||
export const usageApi = {
|
||||
async query(providerId: string, appId: AppId): Promise<UsageResult> {
|
||||
return await invoke("query_provider_usage", {
|
||||
provider_id: providerId,
|
||||
app: appId,
|
||||
});
|
||||
try {
|
||||
return await invoke("queryProviderUsage", {
|
||||
providerId: providerId,
|
||||
app: appId,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
// 提取错误消息:优先使用后端返回的错误信息
|
||||
const message =
|
||||
typeof error === "string"
|
||||
? error
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: "";
|
||||
|
||||
// 如果没有错误消息,使用国际化的默认提示
|
||||
return {
|
||||
success: false,
|
||||
error: message || i18n.t("errors.usage_query_failed"),
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
async testScript(
|
||||
providerId: string,
|
||||
appId: AppId,
|
||||
scriptCode: string,
|
||||
timeout?: number,
|
||||
accessToken?: string,
|
||||
userId?: string,
|
||||
): Promise<UsageResult> {
|
||||
try {
|
||||
return await invoke("testUsageScript", {
|
||||
providerId: providerId,
|
||||
app: appId,
|
||||
scriptCode: scriptCode,
|
||||
timeout: timeout,
|
||||
accessToken: accessToken,
|
||||
userId: userId,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
typeof error === "string"
|
||||
? error
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: "";
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: message || i18n.t("errors.usage_query_failed"),
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ export const vscodeApi = {
|
||||
): Promise<EndpointLatencyResult[]> {
|
||||
return await invoke("test_api_endpoints", {
|
||||
urls,
|
||||
timeout_secs: options?.timeoutSecs,
|
||||
timeoutSecs: options?.timeoutSecs,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -30,7 +30,7 @@ export const vscodeApi = {
|
||||
): Promise<CustomEndpoint[]> {
|
||||
return await invoke("get_custom_endpoints", {
|
||||
app: appId,
|
||||
provider_id: providerId,
|
||||
providerId: providerId,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -41,7 +41,7 @@ export const vscodeApi = {
|
||||
): Promise<void> {
|
||||
await invoke("add_custom_endpoint", {
|
||||
app: appId,
|
||||
provider_id: providerId,
|
||||
providerId: providerId,
|
||||
url,
|
||||
});
|
||||
},
|
||||
@@ -53,7 +53,7 @@ export const vscodeApi = {
|
||||
): Promise<void> {
|
||||
await invoke("remove_custom_endpoint", {
|
||||
app: appId,
|
||||
provider_id: providerId,
|
||||
providerId: providerId,
|
||||
url,
|
||||
});
|
||||
},
|
||||
@@ -65,28 +65,25 @@ export const vscodeApi = {
|
||||
): Promise<void> {
|
||||
await invoke("update_endpoint_last_used", {
|
||||
app: appId,
|
||||
provider_id: providerId,
|
||||
providerId: providerId,
|
||||
url,
|
||||
});
|
||||
},
|
||||
|
||||
async exportConfigToFile(filePath: string) {
|
||||
return await invoke("export_config_to_file", {
|
||||
file_path: filePath,
|
||||
filePath,
|
||||
});
|
||||
},
|
||||
|
||||
async importConfigFromFile(filePath: string) {
|
||||
return await invoke("import_config_from_file", {
|
||||
file_path: filePath,
|
||||
filePath,
|
||||
});
|
||||
},
|
||||
|
||||
async saveFileDialog(defaultName: string): Promise<string | null> {
|
||||
return await invoke("save_file_dialog", {
|
||||
default_name: defaultName,
|
||||
defaultName,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -83,16 +83,33 @@ export const useSettingsQuery = (): UseQueryResult<Settings> => {
|
||||
});
|
||||
};
|
||||
|
||||
export interface UseUsageQueryOptions {
|
||||
enabled?: boolean;
|
||||
autoQueryInterval?: number; // 自动查询间隔(分钟),0 表示禁用
|
||||
}
|
||||
|
||||
export const useUsageQuery = (
|
||||
providerId: string,
|
||||
appId: AppId,
|
||||
enabled: boolean = true,
|
||||
): UseQueryResult<UsageResult> => {
|
||||
return useQuery({
|
||||
options?: UseUsageQueryOptions,
|
||||
) => {
|
||||
const { enabled = true, autoQueryInterval = 0 } = options || {};
|
||||
|
||||
const query = useQuery<UsageResult>({
|
||||
queryKey: ["usage", providerId, appId],
|
||||
queryFn: async () => usageApi.query(providerId, appId),
|
||||
enabled: enabled && !!providerId,
|
||||
refetchInterval:
|
||||
autoQueryInterval > 0
|
||||
? Math.max(autoQueryInterval, 1) * 60 * 1000 // 最小1分钟
|
||||
: false,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 5 * 60 * 1000, // 5分钟
|
||||
retry: false,
|
||||
staleTime: 0, // 不使用缓存策略,确保 refetchInterval 准确执行
|
||||
});
|
||||
|
||||
return {
|
||||
...query,
|
||||
lastQueriedAt: query.dataUpdatedAt || null,
|
||||
};
|
||||
};
|
||||
|
||||
81
src/main.tsx
@@ -9,6 +9,10 @@ import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { queryClient } from "@/lib/query";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { message } from "@tauri-apps/plugin-dialog";
|
||||
import { exit } from "@tauri-apps/plugin-process";
|
||||
|
||||
// 根据平台添加 body class,便于平台特定样式
|
||||
try {
|
||||
@@ -22,15 +26,68 @@ try {
|
||||
// 忽略平台检测失败
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider defaultTheme="system" storageKey="cc-switch-theme">
|
||||
<UpdateProvider>
|
||||
<App />
|
||||
<Toaster />
|
||||
</UpdateProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
// 配置加载错误payload类型
|
||||
interface ConfigLoadErrorPayload {
|
||||
path?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理配置加载失败:显示错误消息并强制退出应用
|
||||
* 不给用户"取消"选项,因为配置损坏时应用无法正常运行
|
||||
*/
|
||||
async function handleConfigLoadError(
|
||||
payload: ConfigLoadErrorPayload | null,
|
||||
): Promise<void> {
|
||||
const path = payload?.path ?? "~/.cc-switch/config.json";
|
||||
const detail = payload?.error ?? "Unknown error";
|
||||
|
||||
await message(
|
||||
`无法读取配置文件:\n${path}\n\n错误详情:\n${detail}\n\n请手动检查 JSON 是否有效,或从同目录的备份文件(如 config.json.bak)恢复。\n\n应用将退出以便您进行修复。`,
|
||||
{ title: "配置加载失败", kind: "error" },
|
||||
);
|
||||
|
||||
await exit(1);
|
||||
}
|
||||
|
||||
// 监听后端的配置加载错误事件:仅提醒用户并强制退出,不修改任何配置文件
|
||||
try {
|
||||
void listen("configLoadError", async (evt) => {
|
||||
await handleConfigLoadError(evt.payload as ConfigLoadErrorPayload | null);
|
||||
});
|
||||
} catch (e) {
|
||||
// 忽略事件订阅异常(例如在非 Tauri 环境下)
|
||||
console.error("订阅 configLoadError 事件失败", e);
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
// 启动早期主动查询后端初始化错误,避免事件竞态
|
||||
try {
|
||||
const initError = (await invoke(
|
||||
"get_init_error",
|
||||
)) as ConfigLoadErrorPayload | null;
|
||||
if (initError && (initError.path || initError.error)) {
|
||||
await handleConfigLoadError(initError);
|
||||
// 注意:不会执行到这里,因为 exit(1) 会终止进程
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略拉取错误,继续渲染
|
||||
console.error("拉取初始化错误失败", e);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider defaultTheme="system" storageKey="cc-switch-theme">
|
||||
<UpdateProvider>
|
||||
<App />
|
||||
<Toaster />
|
||||
</UpdateProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface Provider {
|
||||
category?: ProviderCategory;
|
||||
createdAt?: number; // 添加时间戳(毫秒)
|
||||
sortIndex?: number; // 排序索引(用于自定义拖拽排序)
|
||||
// 新增:是否为商业合作伙伴
|
||||
isPartner?: boolean;
|
||||
// 可选:供应商元数据(仅存于 ~/.cc-switch/config.json,不写入 live 配置)
|
||||
meta?: ProviderMeta;
|
||||
}
|
||||
@@ -43,6 +45,9 @@ export interface UsageScript {
|
||||
language: "javascript"; // 脚本语言
|
||||
code: string; // 脚本代码(JSON 格式配置)
|
||||
timeout?: number; // 超时时间(秒,默认 10)
|
||||
accessToken?: string; // 访问令牌(用于需要登录的接口)
|
||||
userId?: string; // 用户ID(用于需要用户标识的接口)
|
||||
autoQueryInterval?: number; // 自动查询间隔(单位:分钟,0 表示禁用)
|
||||
}
|
||||
|
||||
// 单个套餐用量数据
|
||||
|
||||
28
src/utils/formatters.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 格式化 JSON 字符串
|
||||
* @param value - 原始 JSON 字符串
|
||||
* @returns 格式化后的 JSON 字符串(2 空格缩进)
|
||||
* @throws 如果 JSON 格式无效
|
||||
*/
|
||||
export function formatJSON(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
const parsed = JSON.parse(trimmed);
|
||||
return JSON.stringify(parsed, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* TOML 格式化功能已禁用
|
||||
*
|
||||
* 原因:smol-toml 的 parse/stringify 会丢失所有注释和原有排版。
|
||||
* 由于 TOML 常用于配置文件,注释是重要的文档说明,丢失注释会造成严重的用户体验问题。
|
||||
*
|
||||
* 未来可选方案:
|
||||
* - 使用 @ltd/j-toml(支持注释保留,但需额外依赖和复杂的 API)
|
||||
* - 实现仅格式化缩进/空白的轻量级方案
|
||||
* - 使用 toml-eslint-parser + 自定义生成器
|
||||
*
|
||||
* 暂时建议:依赖现有的 TOML 语法校验(useCodexTomlValidation),不提供格式化功能。
|
||||
*/
|
||||
18
src/utils/postChangeSync.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { settingsApi } from "@/lib/api";
|
||||
|
||||
/**
|
||||
* 统一的“后置同步”工具:将当前使用的供应商写回对应应用的 live 配置。
|
||||
* 不抛出异常,由调用方根据返回值决定提示策略。
|
||||
*/
|
||||
export async function syncCurrentProvidersLiveSafe(): Promise<{
|
||||
ok: boolean;
|
||||
error?: Error;
|
||||
}> {
|
||||
try {
|
||||
await settingsApi.syncCurrentProvidersLive();
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err ?? ""));
|
||||
return { ok: false, error };
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// 供应商配置处理工具函数
|
||||
|
||||
import type { TemplateValueConfig } from "../config/providerPresets";
|
||||
import type { TemplateValueConfig } from "../config/claudeProviderPresets";
|
||||
|
||||
const isPlainObject = (value: unknown): value is Record<string, any> => {
|
||||
return Object.prototype.toString.call(value) === "[object Object]";
|
||||
@@ -164,12 +164,19 @@ export const hasCommonConfigSnippet = (
|
||||
}
|
||||
};
|
||||
|
||||
// 读取配置中的 API Key(env.ANTHROPIC_AUTH_TOKEN)
|
||||
// 读取配置中的 API Key(优先 ANTHROPIC_AUTH_TOKEN,其次 ANTHROPIC_API_KEY)
|
||||
export const getApiKeyFromConfig = (jsonString: string): string => {
|
||||
try {
|
||||
const config = JSON.parse(jsonString);
|
||||
const key = config?.env?.ANTHROPIC_AUTH_TOKEN;
|
||||
return typeof key === "string" ? key : "";
|
||||
const token = config?.env?.ANTHROPIC_AUTH_TOKEN;
|
||||
const apiKey = config?.env?.ANTHROPIC_API_KEY;
|
||||
const value =
|
||||
typeof token === "string"
|
||||
? token
|
||||
: typeof apiKey === "string"
|
||||
? apiKey
|
||||
: "";
|
||||
return value;
|
||||
} catch (err) {
|
||||
return "";
|
||||
}
|
||||
@@ -224,9 +231,10 @@ export const applyTemplateValues = (
|
||||
export const hasApiKeyField = (jsonString: string): boolean => {
|
||||
try {
|
||||
const config = JSON.parse(jsonString);
|
||||
return Object.prototype.hasOwnProperty.call(
|
||||
config?.env ?? {},
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
const env = config?.env ?? {};
|
||||
return (
|
||||
Object.prototype.hasOwnProperty.call(env, "ANTHROPIC_AUTH_TOKEN") ||
|
||||
Object.prototype.hasOwnProperty.call(env, "ANTHROPIC_API_KEY")
|
||||
);
|
||||
} catch (err) {
|
||||
return false;
|
||||
@@ -246,10 +254,17 @@ export const setApiKeyInConfig = (
|
||||
if (!createIfMissing) return jsonString;
|
||||
config.env = {};
|
||||
}
|
||||
if (!("ANTHROPIC_AUTH_TOKEN" in config.env) && !createIfMissing) {
|
||||
const env = config.env as Record<string, any>;
|
||||
// 优先写入已存在的字段;若两者均不存在且允许创建,则默认创建 AUTH_TOKEN 字段
|
||||
if ("ANTHROPIC_AUTH_TOKEN" in env) {
|
||||
env.ANTHROPIC_AUTH_TOKEN = apiKey;
|
||||
} else if ("ANTHROPIC_API_KEY" in env) {
|
||||
env.ANTHROPIC_API_KEY = apiKey;
|
||||
} else if (createIfMissing) {
|
||||
env.ANTHROPIC_AUTH_TOKEN = apiKey;
|
||||
} else {
|
||||
return jsonString;
|
||||
}
|
||||
config.env.ANTHROPIC_AUTH_TOKEN = apiKey;
|
||||
return JSON.stringify(config, null, 2);
|
||||
} catch (err) {
|
||||
return jsonString;
|
||||
|
||||
@@ -2,9 +2,10 @@ import type { CustomEndpoint, ProviderMeta } from "@/types";
|
||||
|
||||
/**
|
||||
* 合并供应商元数据中的自定义端点。
|
||||
* - 当 customEndpoints 为空对象或 null 时,移除自定义端点但保留其它元数据。
|
||||
* - 当 customEndpoints 为空对象时,明确删除自定义端点但保留其它元数据。
|
||||
* - 当 customEndpoints 为 null/undefined 时,不修改端点(保留原有端点)。
|
||||
* - 当 customEndpoints 存在时,覆盖原有自定义端点。
|
||||
* - 若结果为空对象则返回 undefined,避免写入空 meta。
|
||||
* - 若结果为空对象且非明确清空场景则返回 undefined,避免写入空 meta。
|
||||
*/
|
||||
export function mergeProviderMeta(
|
||||
initialMeta: ProviderMeta | undefined,
|
||||
@@ -13,6 +14,12 @@ export function mergeProviderMeta(
|
||||
const hasCustomEndpoints =
|
||||
!!customEndpoints && Object.keys(customEndpoints).length > 0;
|
||||
|
||||
// 明确清空:传入空对象(非 null/undefined)表示用户想要删除所有端点
|
||||
const isExplicitClear =
|
||||
customEndpoints !== null &&
|
||||
customEndpoints !== undefined &&
|
||||
Object.keys(customEndpoints).length === 0;
|
||||
|
||||
if (hasCustomEndpoints) {
|
||||
return {
|
||||
...(initialMeta ? { ...initialMeta } : {}),
|
||||
@@ -20,6 +27,25 @@ export function mergeProviderMeta(
|
||||
};
|
||||
}
|
||||
|
||||
// 明确清空端点
|
||||
if (isExplicitClear) {
|
||||
if (!initialMeta) {
|
||||
// 新供应商且用户没有添加端点(理论上不会到这里)
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if ("custom_endpoints" in initialMeta) {
|
||||
const { custom_endpoints, ...rest } = initialMeta;
|
||||
// 保留其他字段(如 usage_script)
|
||||
// 即使 rest 为空,也要返回空对象(让后端知道要清空 meta)
|
||||
return Object.keys(rest).length > 0 ? rest : {};
|
||||
}
|
||||
|
||||
// initialMeta 中本来就没有 custom_endpoints
|
||||
return { ...initialMeta };
|
||||
}
|
||||
|
||||
// null/undefined:用户没有修改端点,保持不变
|
||||
if (!initialMeta) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ vi.mock("react-i18next", () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) =>
|
||||
params ? `${key}:${JSON.stringify(params)}` : key,
|
||||
}),
|
||||
// 提供 initReactI18next 以兼容 i18n 初始化路径
|
||||
initReactI18next: { type: "3rdParty", init: () => {} },
|
||||
}));
|
||||
|
||||
vi.mock("@/config/mcpPresets", () => ({
|
||||
|
||||
@@ -7,6 +7,7 @@ const mutateAsyncMock = vi.fn();
|
||||
const useSettingsQueryMock = vi.fn();
|
||||
const setAppConfigDirOverrideMock = vi.fn();
|
||||
const applyClaudePluginConfigMock = vi.fn();
|
||||
const syncCurrentProvidersLiveMock = vi.fn();
|
||||
const toastErrorMock = vi.fn();
|
||||
const toastSuccessMock = vi.fn();
|
||||
|
||||
@@ -48,6 +49,8 @@ vi.mock("@/lib/api", () => ({
|
||||
setAppConfigDirOverrideMock(...args),
|
||||
applyClaudePluginConfig: (...args: unknown[]) =>
|
||||
applyClaudePluginConfigMock(...args),
|
||||
syncCurrentProvidersLive: (...args: unknown[]) =>
|
||||
syncCurrentProvidersLiveMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -102,6 +105,7 @@ describe("useSettings hook", () => {
|
||||
useSettingsQueryMock.mockReset();
|
||||
setAppConfigDirOverrideMock.mockReset();
|
||||
applyClaudePluginConfigMock.mockReset();
|
||||
syncCurrentProvidersLiveMock.mockReset();
|
||||
toastErrorMock.mockReset();
|
||||
toastSuccessMock.mockReset();
|
||||
window.localStorage.clear();
|
||||
@@ -181,6 +185,8 @@ describe("useSettings hook", () => {
|
||||
expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(true);
|
||||
expect(window.localStorage.getItem("language")).toBe("en");
|
||||
expect(toastErrorMock).not.toHaveBeenCalled();
|
||||
// 目录有变化,应触发一次同步当前供应商到 live
|
||||
expect(syncCurrentProvidersLiveMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("saves settings without restart when directory unchanged", async () => {
|
||||
@@ -209,6 +215,8 @@ describe("useSettings hook", () => {
|
||||
expect(setAppConfigDirOverrideMock).toHaveBeenCalledWith(null);
|
||||
expect(applyClaudePluginConfigMock).toHaveBeenCalledWith({ official: true });
|
||||
expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(false);
|
||||
// 目录未变化,不应触发同步
|
||||
expect(syncCurrentProvidersLiveMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows toast when Claude plugin sync fails but continues flow", async () => {
|
||||
|
||||
@@ -169,13 +169,21 @@ export const handlers = [
|
||||
http.post(`${TAURI_ENDPOINT}/is_portable_mode`, () => success(false)),
|
||||
|
||||
http.post(`${TAURI_ENDPOINT}/select_config_directory`, async ({ request }) => {
|
||||
const { default_path } = await withJson<{ default_path?: string }>(request);
|
||||
return success(default_path ? `${default_path}/picked` : "/mock/selected-dir");
|
||||
const { defaultPath, default_path } = await withJson<{
|
||||
defaultPath?: string;
|
||||
default_path?: string;
|
||||
}>(request);
|
||||
const initial = defaultPath ?? default_path;
|
||||
return success(initial ? `${initial}/picked` : "/mock/selected-dir");
|
||||
}),
|
||||
|
||||
http.post(`${TAURI_ENDPOINT}/pick_directory`, async ({ request }) => {
|
||||
const { default_path } = await withJson<{ default_path?: string }>(request);
|
||||
return success(default_path ? `${default_path}/picked` : "/mock/selected-dir");
|
||||
const { defaultPath, default_path } = await withJson<{
|
||||
defaultPath?: string;
|
||||
default_path?: string;
|
||||
}>(request);
|
||||
const initial = defaultPath ?? default_path;
|
||||
return success(initial ? `${initial}/picked` : "/mock/selected-dir");
|
||||
}),
|
||||
|
||||
http.post(`${TAURI_ENDPOINT}/open_file_dialog`, () =>
|
||||
|
||||