Compare commits
75 Commits
v2.0.3
...
codex-adap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b00e8de26f | ||
|
|
47b06b7773 | ||
|
|
4e66f0c105 | ||
|
|
84c7726940 | ||
|
|
b8f59a4740 | ||
|
|
06a19519c5 | ||
|
|
b4ebb7c9e5 | ||
|
|
5edc3e07a4 | ||
|
|
417dcc1d37 | ||
|
|
72f6068e86 | ||
|
|
97e7f34260 | ||
|
|
74babf9730 | ||
|
|
30fe800ebe | ||
|
|
c98a724935 | ||
|
|
0cb89c8f67 | ||
|
|
7b5d5c6ce1 | ||
|
|
eea5e4123b | ||
|
|
c10ace7a84 | ||
|
|
0e803b53d8 | ||
|
|
49d8787ab9 | ||
|
|
a05fefb54c | ||
|
|
3574fa07cb | ||
|
|
b64b86f3ca | ||
|
|
2cf116280f | ||
|
|
73cf337c42 | ||
|
|
fa2d64b692 | ||
|
|
9c17be1b59 | ||
|
|
fe1574a026 | ||
|
|
9254c5d291 | ||
|
|
642e7a3817 | ||
|
|
5e2e80b00d | ||
|
|
2a43f1f54d | ||
|
|
7e6ce83158 | ||
|
|
6932e89ea8 | ||
|
|
d144d5c2fc | ||
|
|
adee37ab66 | ||
|
|
dcf49cc094 | ||
|
|
f8e39594fa | ||
|
|
374649750b | ||
|
|
6d26115368 | ||
|
|
606ee67778 | ||
|
|
57d21fabcf | ||
|
|
001664c67d | ||
|
|
616e230218 | ||
|
|
70f9a68e5c | ||
|
|
78bc0a1a31 | ||
|
|
dac8ebe03b | ||
|
|
9f370bf429 | ||
|
|
bac2c3db36 | ||
|
|
326e975748 | ||
|
|
b5696b4511 | ||
|
|
ef7e9d2f73 | ||
|
|
d78013562c | ||
|
|
d3adfc480d | ||
|
|
731cfc47be | ||
|
|
95b3746e49 | ||
|
|
c8670aede6 | ||
|
|
95549473bd | ||
|
|
f3f484a04b | ||
|
|
1458f1e45d | ||
|
|
0301d1aee7 | ||
|
|
224d7a8be0 | ||
|
|
c4791ff523 | ||
|
|
55c62a3753 | ||
|
|
12fa80e002 | ||
|
|
29581b85d9 | ||
|
|
88e69e844a | ||
|
|
2a658af5b9 | ||
|
|
1402fd0cc5 | ||
|
|
8a3133be43 | ||
|
|
f64320fbd6 | ||
|
|
3479780639 | ||
|
|
1b0ab269fb | ||
|
|
6706889387 | ||
|
|
093e54f23c |
225
.github/workflows/release.yml
vendored
@@ -11,97 +11,200 @@ permissions:
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: windows-latest
|
||||
platform: win32
|
||||
- os: ubuntu-latest
|
||||
platform: linux
|
||||
- os: ubuntu-latest
|
||||
- os: macos-latest
|
||||
platform: darwin
|
||||
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Add macOS targets
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
rustup target add aarch64-apple-darwin x86_64-apple-darwin
|
||||
|
||||
- name: Install Linux system deps
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
sudo apt-get update
|
||||
# Core build tools and pkg-config
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
curl \
|
||||
wget \
|
||||
file \
|
||||
patchelf \
|
||||
libssl-dev
|
||||
# GTK/GLib stack for gdk-3.0, glib-2.0, gio-2.0
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libgtk-3-dev \
|
||||
librsvg2-dev \
|
||||
libayatana-appindicator3-dev
|
||||
# WebKit2GTK (version differs across Ubuntu images; try 4.1 then 4.0)
|
||||
sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev \
|
||||
|| sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.0-dev
|
||||
# libsoup also changed major version; prefer 3.0 with fallback to 2.4
|
||||
sudo apt-get install -y --no-install-recommends libsoup-3.0-dev \
|
||||
|| sudo apt-get install -y --no-install-recommends libsoup2.4-dev
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 10.12.3
|
||||
run_install: false
|
||||
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-store
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
run: echo "path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
restore-keys: ${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install frontend deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build application
|
||||
run: |
|
||||
pnpm run build
|
||||
pnpm run dist
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: false
|
||||
|
||||
- name: List build files (debug)
|
||||
|
||||
- name: Build Tauri App (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
run: pnpm tauri build --target universal-apple-darwin
|
||||
|
||||
- name: Build Tauri App (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
run: pnpm tauri build
|
||||
|
||||
- name: Build Tauri App (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: pnpm tauri build
|
||||
|
||||
- name: Prepare macOS Assets
|
||||
if: runner.os == 'macOS'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Listing release directory:"
|
||||
ls -la release/ || echo "No release directory found"
|
||||
find . -name "*.exe" -o -name "*.dmg" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.rpm" || echo "No build files found"
|
||||
|
||||
set -euxo pipefail
|
||||
mkdir -p release-assets
|
||||
echo "Looking for .app bundle..."
|
||||
APP_PATH=""
|
||||
for path in \
|
||||
"src-tauri/target/release/bundle/macos" \
|
||||
"src-tauri/target/universal-apple-darwin/release/bundle/macos" \
|
||||
"src-tauri/target/aarch64-apple-darwin/release/bundle/macos" \
|
||||
"src-tauri/target/x86_64-apple-darwin/release/bundle/macos"; do
|
||||
if [ -d "$path" ]; then
|
||||
APP_PATH=$(find "$path" -name "*.app" -type d | head -1)
|
||||
[ -n "$APP_PATH" ] && break
|
||||
fi
|
||||
done
|
||||
if [ -z "$APP_PATH" ]; then
|
||||
echo "No .app found" >&2
|
||||
exit 1
|
||||
fi
|
||||
APP_DIR=$(dirname "$APP_PATH")
|
||||
APP_NAME=$(basename "$APP_PATH")
|
||||
cd "$APP_DIR"
|
||||
# 使用 ditto 打包更兼容资源分叉
|
||||
ditto -c -k --sequesterRsrc --keepParent "$APP_NAME" "CC-Switch-macOS.zip"
|
||||
mv "CC-Switch-macOS.zip" "$GITHUB_WORKSPACE/release-assets/"
|
||||
echo "macOS zip ready"
|
||||
|
||||
- name: Prepare Windows Assets
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
New-Item -ItemType Directory -Force -Path release-assets | Out-Null
|
||||
# 安装器(优先 NSIS,其次 MSI)
|
||||
$installer = Get-ChildItem -Path 'src-tauri/target/release/bundle' -Recurse -Include *.exe,*.msi -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.FullName -match '\\bundle\\(nsis|msi)\\' } |
|
||||
Select-Object -First 1
|
||||
if ($null -ne $installer) {
|
||||
$dest = if ($installer.Extension -ieq '.msi') { 'CC-Switch-Setup.msi' } else { 'CC-Switch-Setup.exe' }
|
||||
Copy-Item $installer.FullName (Join-Path release-assets $dest)
|
||||
Write-Host "Installer copied: $dest"
|
||||
} else {
|
||||
Write-Warning 'No Windows installer found'
|
||||
}
|
||||
# 绿色版(portable):仅可执行文件
|
||||
$exeCandidates = @(
|
||||
'src-tauri/target/release/cc-switch.exe',
|
||||
'src-tauri/target/x86_64-pc-windows-msvc/release/cc-switch.exe'
|
||||
)
|
||||
$exePath = $exeCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
|
||||
if ($null -ne $exePath) {
|
||||
$portableDir = 'release-assets/CC-Switch-Portable'
|
||||
New-Item -ItemType Directory -Force -Path $portableDir | Out-Null
|
||||
Copy-Item $exePath $portableDir
|
||||
Compress-Archive -Path "$portableDir/*" -DestinationPath 'release-assets/CC-Switch-Windows-Portable.zip' -Force
|
||||
Remove-Item -Recurse -Force $portableDir
|
||||
Write-Host 'Windows portable zip created'
|
||||
} else {
|
||||
Write-Warning 'Portable exe not found'
|
||||
}
|
||||
|
||||
- name: Prepare Linux Assets
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
mkdir -p release-assets
|
||||
# 仅上传安装包(deb)
|
||||
DEB=$(find src-tauri/target/release/bundle -name "*.deb" | head -1 || true)
|
||||
if [ -n "$DEB" ]; then
|
||||
cp "$DEB" release-assets/
|
||||
echo "Deb package copied"
|
||||
else
|
||||
echo "No .deb found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: List prepared assets
|
||||
shell: bash
|
||||
run: |
|
||||
ls -la release-assets || true
|
||||
|
||||
- name: Upload Release Assets
|
||||
uses: softprops/action-gh-release@v1
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
files: |
|
||||
release/*.exe
|
||||
release/*.zip
|
||||
release/*.AppImage
|
||||
name: "CC Switch ${{ github.ref_name }}"
|
||||
tag_name: ${{ github.ref_name }}
|
||||
name: CC Switch ${{ github.ref_name }}
|
||||
body: |
|
||||
## CC Switch ${{ github.ref_name }}
|
||||
|
||||
|
||||
Claude Code 供应商切换工具
|
||||
|
||||
|
||||
### 下载
|
||||
|
||||
#### Windows 用户
|
||||
- **安装版 (推荐)**: `CC Switch Setup ${{ github.ref_name }}.exe`
|
||||
- **便携版**: `CC Switch ${{ github.ref_name }}.exe`
|
||||
|
||||
#### macOS 用户(推荐使用通用版本)
|
||||
- **通用版本**: `CC Switch-${{ github.ref_name }}-mac.zip` - 兼容所有Mac(Intel + M系列)
|
||||
|
||||
#### Linux 用户
|
||||
- **AppImage**: `CC Switch-${{ github.ref_name }}.AppImage`
|
||||
|
||||
### macOS 安装说明
|
||||
1. 下载 ZIP 文件后解压
|
||||
2. 首次打开可能出现"未知开发者"警告
|
||||
3. 前往"系统设置" → "隐私与安全性" → 点击"仍要打开"
|
||||
4. 或者使用命令: `xattr -cr "/path/to/CC Switch.app"`
|
||||
|
||||
### 注意事项
|
||||
- macOS 版本使用 Intel 架构,通过 Rosetta 2 在 M 系列芯片上运行
|
||||
- 兼容性和稳定性最佳,性能损失minimal
|
||||
|
||||
- macOS: `CC-Switch-macOS.zip`(解压即用)
|
||||
- Windows: `CC-Switch-Setup.exe` 或 `CC-Switch-Setup.msi`(安装版);`CC-Switch-Windows-Portable.zip`(绿色版)
|
||||
- Linux: `*.deb`(Debian/Ubuntu 安装包)
|
||||
|
||||
---
|
||||
提示:macOS 如遇“已损坏”提示,可在终端执行:`xattr -cr "/Applications/CC Switch.app"`
|
||||
files: release-assets/*
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: List generated bundles (debug)
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Listing bundles in src-tauri/target..."
|
||||
find src-tauri/target -maxdepth 4 -type f -name "*.*" 2>/dev/null || true
|
||||
|
||||
4
.gitignore
vendored
@@ -6,4 +6,6 @@ release/
|
||||
.env
|
||||
.env.local
|
||||
*.tsbuildinfo
|
||||
.npmrc
|
||||
.npmrc
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
|
||||
72
CHANGELOG.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Changelog
|
||||
|
||||
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.0.0] - 2025-08-27
|
||||
|
||||
### 🚀 Major Changes
|
||||
- **Complete migration from Electron to Tauri 2.0** - The application has been completely rewritten using Tauri, resulting in:
|
||||
- **90% reduction in bundle size** (from ~150MB to ~15MB)
|
||||
- **Significantly improved startup performance**
|
||||
- **Native system integration** without Chromium overhead
|
||||
- **Enhanced security** with Rust backend
|
||||
|
||||
### ✨ New Features
|
||||
- **Native window controls** with transparent title bar on macOS
|
||||
- **Improved file system operations** using Rust for better performance
|
||||
- **Enhanced security model** with explicit permission declarations
|
||||
- **Better platform detection** using Tauri's native APIs
|
||||
|
||||
### 🔧 Technical Improvements
|
||||
- Migrated from Electron IPC to Tauri command system
|
||||
- Replaced Node.js file operations with Rust implementations
|
||||
- Implemented proper CSP (Content Security Policy) for enhanced security
|
||||
- Added TypeScript strict mode for better type safety
|
||||
- Integrated Rust cargo fmt and clippy for code quality
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
- Fixed bundle identifier conflict on macOS (changed from .app to .desktop)
|
||||
- Resolved platform detection issues
|
||||
- Improved error handling in configuration management
|
||||
|
||||
### 📦 Dependencies
|
||||
- **Tauri**: 2.8.2
|
||||
- **React**: 18.2.0
|
||||
- **TypeScript**: 5.3.0
|
||||
- **Vite**: 5.0.0
|
||||
|
||||
### 🔄 Migration Notes
|
||||
For users upgrading from v2.x (Electron version):
|
||||
- Configuration files remain compatible - no action required
|
||||
- The app will automatically migrate your existing provider configurations
|
||||
- Window position and size preferences have been reset to defaults
|
||||
|
||||
#### Backup on v1→v2 Migration (cc-switch internal config)
|
||||
- When the app detects an old v1 config structure at `~/.cc-switch/config.json`, it now creates a timestamped backup before writing the new v2 structure.
|
||||
- Backup location: `~/.cc-switch/config.v1.backup.<timestamp>.json`
|
||||
- This only concerns cc-switch's own metadata file; your actual provider files under `~/.claude/` and `~/.codex/` are untouched.
|
||||
|
||||
### 🛠️ Development
|
||||
- Added `pnpm typecheck` command for TypeScript validation
|
||||
- Added `pnpm format` and `pnpm format:check` for code formatting
|
||||
- Rust code now uses cargo fmt for consistent formatting
|
||||
|
||||
## [2.0.0] - Previous Electron Release
|
||||
|
||||
### Features
|
||||
- Multi-provider configuration management
|
||||
- Quick provider switching
|
||||
- Import/export configurations
|
||||
- Preset provider templates
|
||||
|
||||
---
|
||||
|
||||
## [1.0.0] - Initial Release
|
||||
|
||||
### Features
|
||||
- Basic provider management
|
||||
- Claude Code integration
|
||||
- Configuration file handling
|
||||
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Jason Young
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
177
README.md
@@ -1,14 +1,23 @@
|
||||
# Claude Code 供应商切换器
|
||||
|
||||
一个用于管理和切换 Claude Code 不同供应商配置的桌面应用。
|
||||
[](https://github.com/jasonyoung/cc-switch/releases)
|
||||
[](https://github.com/jasonyoung/cc-switch/releases)
|
||||
[](https://tauri.app/)
|
||||
|
||||
一个用于管理和切换 Claude Code 与 Codex 不同供应商配置的桌面应用。
|
||||
|
||||
> **v3.0.0 重大更新**:从 Electron 完全迁移到 Tauri 2.0,应用体积减少 85%(从 ~80MB 降至 ~12MB),启动速度提升 10 倍!
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **极速启动** - 基于 Tauri 2.0,原生性能,秒开应用
|
||||
- 一键切换不同供应商
|
||||
- 智谱 GLM、Qwen coder、DeepSeek v3.1、packycode 等预设供应商只需要填写 key 即可一键配置
|
||||
- Qwen coder、kimi k2、智谱 GLM、DeepSeek v3.1、packycode 等预设供应商只需要填写 key 即可一键配置
|
||||
- 支持添加自定义供应商
|
||||
- 随时切换官方登录
|
||||
- 简洁美观的图形界面
|
||||
- 信息存储在本地 ~/.cc-switch/config.json,无隐私风险
|
||||
- 超小体积 - 仅 ~5MB 安装包
|
||||
|
||||
## 界面预览
|
||||
|
||||
@@ -22,109 +31,133 @@
|
||||
|
||||
## 下载安装
|
||||
|
||||
### 系统要求
|
||||
|
||||
- **Windows**: Windows 10 及以上
|
||||
- **macOS**: macOS 10.15 (Catalina) 及以上
|
||||
- **Linux**: Ubuntu 20.04+ / Debian 11+ / Fedora 34+ 等主流发行版
|
||||
|
||||
### Windows 用户
|
||||
|
||||
从 [Releases](../../releases) 页面下载:
|
||||
|
||||
- **安装版 (推荐)**: `CC-Switch-Setup-x.x.x.exe`
|
||||
- 完整系统集成,正确显示应用图标
|
||||
- 自动创建桌面快捷方式和开始菜单项
|
||||
- **便携版**: `CC-Switch-Portable-x.x.x.exe`
|
||||
- 无需安装,直接运行
|
||||
- 适合需要绿色软件的用户
|
||||
从 [Releases](../../releases) 页面下载最新版本的 `CC-Switch-Setup.msi` 安装包或者 `CC-Switch-Windows-Portable.zip` 绿色版。
|
||||
|
||||
### macOS 用户
|
||||
|
||||
从 [Releases](../../releases) 页面下载:
|
||||
从 [Releases](../../releases) 页面下载 `CC-Switch-macOS.zip` 解压使用。
|
||||
|
||||
- **通用版本(推荐)**: `CC Switch-x.x.x-mac.zip` - Intel 版本,兼容所有 Mac(包括 M 系列芯片)
|
||||
|
||||
#### macOS 安装说明
|
||||
|
||||
**推荐使用通用版本**,它通过 Rosetta 2 在 M 系列 Mac 上运行良好,兼容性最佳。
|
||||
|
||||
由于作者没有苹果开发者账号,应用使用 ad-hoc 签名(未经苹果官方认证),首次打开时可能出现"未知开发者"警告。这是正常的安全提示,处理方法:
|
||||
|
||||
**方法 1 - 系统设置**:
|
||||
|
||||
1. 双击应用时选择"取消"
|
||||
2. 打开"系统设置" → "隐私与安全性"
|
||||
3. 在底部找到被阻止的应用,点击"仍要打开"
|
||||
4. 确认后即可正常使用
|
||||
|
||||
**方法 2 - 自行编译**:
|
||||
|
||||
1. Clone 代码到本地:`git clone https://github.com/farion1231/cc-switch.git`
|
||||
2. 安装依赖:`pnpm install`
|
||||
3. 编译代码:`pnpm run build`
|
||||
4. 打包应用:`pnpm run dist`
|
||||
5. 在项目 release 目录找到编译好的应用包
|
||||
|
||||
**安全保障**:
|
||||
|
||||
- 应用已通过 ad-hoc 代码签名,确保文件完整性
|
||||
- 源代码完全开源,可在 GitHub 审查
|
||||
- 本地存储配置,无网络传输风险
|
||||
|
||||
**技术说明**:
|
||||
|
||||
- 使用 Intel x64 架构,通过 Rosetta 2 在 M 系列芯片上运行
|
||||
- 兼容性和稳定性最佳,性能损失 minimal
|
||||
- 避免了 ARM64 原生版本的签名复杂性问题
|
||||
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开
|
||||
|
||||
### Linux 用户
|
||||
|
||||
- **AppImage**: `CC Switch-x.x.x.AppImage`
|
||||
|
||||
下载后添加执行权限:
|
||||
|
||||
```bash
|
||||
chmod +x CC-Switch-x.x.x.AppImage
|
||||
```
|
||||
从 [Releases](../../releases) 页面下载最新版本的 `.deb` 包。
|
||||
|
||||
## 使用说明
|
||||
|
||||
1. 点击"添加供应商"添加你的 API 配置
|
||||
2. 选择要使用的供应商,点击单选按钮切换
|
||||
3. 配置会自动保存到 Claude Code 的配置文件中
|
||||
4. 重启或者新打开 Claude Code 终端以生效
|
||||
3. 配置会自动保存到对应应用的配置文件中
|
||||
4. 重启或者新打开终端以生效
|
||||
5. 如果需要切回 Claude 官方登录,可以添加预设供应商里的“Claude 官方登录”并切换,重启终端后即可进行正常的 /login 登录
|
||||
|
||||
### Codex 说明
|
||||
|
||||
- 配置目录:`~/.codex/`
|
||||
- 主配置文件:`auth.json`(必需)、`config.toml`(可为空)
|
||||
- 供应商副本:`auth-<name>.json`、`config-<name>.toml`
|
||||
- API Key 字段:`auth.json` 中使用 `OPENAI_API_KEY`
|
||||
- 切换策略:将选中供应商的副本覆盖到主配置(`auth.json`、`config.toml`)。若供应商没有 `config-*.toml`,会创建空的 `config.toml`。
|
||||
- 导入默认:若 `~/.codex/auth.json` 存在,会将当前主配置导入为 `default` 供应商;`config.toml` 不存在时按空处理。
|
||||
|
||||
## 开发
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Node.js 18+
|
||||
- pnpm 8+
|
||||
- Rust 1.75+
|
||||
- Tauri CLI 2.0+
|
||||
|
||||
### 开发命令
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
pnpm install
|
||||
# 或
|
||||
npm install
|
||||
|
||||
# 开发模式
|
||||
pnpm run dev
|
||||
# 开发模式(热重载)
|
||||
pnpm dev
|
||||
|
||||
# 类型检查
|
||||
pnpm typecheck
|
||||
|
||||
# 代码格式化
|
||||
pnpm format
|
||||
|
||||
# 检查代码格式
|
||||
pnpm format:check
|
||||
|
||||
# 构建应用
|
||||
pnpm run build
|
||||
pnpm build
|
||||
|
||||
# 打包发布
|
||||
pnpm run dist
|
||||
# 构建调试版本
|
||||
pnpm tauri build --debug
|
||||
```
|
||||
|
||||
### Rust 后端开发
|
||||
|
||||
```bash
|
||||
cd src-tauri
|
||||
|
||||
# 格式化 Rust 代码
|
||||
cargo fmt
|
||||
|
||||
# 运行 clippy 检查
|
||||
cargo clippy
|
||||
|
||||
# 运行测试
|
||||
cargo test
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Electron
|
||||
- React
|
||||
- TypeScript
|
||||
- Vite
|
||||
- **[Tauri 2.0](https://tauri.app/)** - 跨平台桌面应用框架
|
||||
- **[React 18](https://react.dev/)** - 用户界面库
|
||||
- **[TypeScript](https://www.typescriptlang.org/)** - 类型安全的 JavaScript
|
||||
- **[Vite](https://vitejs.dev/)** - 极速的前端构建工具
|
||||
- **[Rust](https://www.rust-lang.org/)** - 系统级编程语言(后端)
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
├── src/
|
||||
│ ├── main/ # 主进程代码
|
||||
│ ├── renderer/ # 渲染进程代码
|
||||
│ └── shared/ # 共享类型和工具
|
||||
├── build/ # 应用图标资源
|
||||
└── dist/ # 构建输出目录
|
||||
├── src/ # 前端代码 (React + TypeScript)
|
||||
│ ├── components/ # React 组件
|
||||
│ ├── config/ # 预设供应商配置
|
||||
│ ├── lib/ # Tauri API 封装
|
||||
│ └── utils/ # 工具函数
|
||||
├── src-tauri/ # 后端代码 (Rust)
|
||||
│ ├── src/ # Rust 源代码
|
||||
│ │ ├── commands.rs # Tauri 命令定义
|
||||
│ │ ├── config.rs # 配置文件管理
|
||||
│ │ ├── provider.rs # 供应商管理逻辑
|
||||
│ │ └── store.rs # 状态管理
|
||||
│ ├── capabilities/ # 权限配置
|
||||
│ └── icons/ # 应用图标资源
|
||||
└── screenshots/ # 界面截图
|
||||
```
|
||||
|
||||
## 更新日志
|
||||
|
||||
查看 [CHANGELOG.md](CHANGELOG.md) 了解版本更新详情。
|
||||
|
||||
## Electron 旧版
|
||||
|
||||
[Releases](../../releases) 里保留 v2.0.3 Electron 旧版
|
||||
|
||||
如果需要旧版 Electron 代码,可以拉取 electron-legacy 分支
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎提交 Issue 和 Pull Request!
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
MIT © Jason Young
|
||||
|
||||
BIN
build/icon.icns
BIN
build/icon.ico
|
Before Width: | Height: | Size: 161 KiB |
BIN
build/icon.png
|
Before Width: | Height: | Size: 312 KiB |
78
package.json
@@ -1,91 +1,33 @@
|
||||
{
|
||||
"name": "cc-switch",
|
||||
"version": "2.0.3",
|
||||
"version": "3.0.0",
|
||||
"description": "Claude Code 供应商切换工具",
|
||||
"main": "dist/main/index.js",
|
||||
"scripts": {
|
||||
"dev": "concurrently -k \"npm:dev:renderer\" \"npm:dev:electron:watch\"",
|
||||
"dev:electron": "tsc -p tsconfig.main.json && electron .",
|
||||
"dev:electron:watch": "tsc -p tsconfig.main.json && concurrently -k \"tsc -w -p tsconfig.main.json\" \"npm:electron\"",
|
||||
"electron": "electron .",
|
||||
"dev": "pnpm tauri dev",
|
||||
"build": "pnpm tauri build",
|
||||
"tauri": "tauri",
|
||||
"dev:renderer": "vite",
|
||||
"build": "npm run build:renderer && npm run build:main",
|
||||
"build:main": "tsc -p tsconfig.main.json",
|
||||
"build:renderer": "vite build",
|
||||
"start": "electron .",
|
||||
"dist": "electron-builder"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,css,json}\"",
|
||||
"format:check": "prettier --check \"src/**/*.{js,jsx,ts,tsx,css,json}\""
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Jason Young",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.8.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"concurrently": "^8.2.0",
|
||||
"electron": "^32.3.3",
|
||||
"electron-builder": "^24.0.0",
|
||||
"prettier": "^3.6.2",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.ccswitch.app",
|
||||
"productName": "CC Switch",
|
||||
"compression": "maximum",
|
||||
"publish": null,
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"icon": "build/icon.ico",
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"node_modules/**/*"
|
||||
],
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true
|
||||
},
|
||||
"mac": {
|
||||
"category": "public.app-category.developer-tools",
|
||||
"icon": "build/icon.icns",
|
||||
"identity": "-",
|
||||
"hardenedRuntime": false,
|
||||
"entitlements": null,
|
||||
"entitlementsInherit": null,
|
||||
"target": [
|
||||
{
|
||||
"target": "zip",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "portable",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"icon": "build/icon.ico"
|
||||
},
|
||||
"linux": {
|
||||
"target": "AppImage",
|
||||
"icon": "build/icon.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
2386
pnpm-lock.yaml
generated
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 194 KiB |
4
src-tauri/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
/gen/schemas
|
||||
5773
src-tauri/Cargo.lock
generated
Normal file
32
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "cc-switch"
|
||||
version = "3.0.0"
|
||||
description = "Claude Code MCP 服务器配置管理工具"
|
||||
authors = ["Jason Young"]
|
||||
license = "MIT"
|
||||
repository = "https://github.com/jasonyoung/cc-switch"
|
||||
edition = "2024"
|
||||
rust-version = "1.85.0"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
name = "cc_switch_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.4.0", features = [] }
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
log = "0.4"
|
||||
tauri = { version = "2.8.2", features = [] }
|
||||
tauri-plugin-log = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
dirs = "5.0"
|
||||
toml = "0.8"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.5"
|
||||
objc2-app-kit = { version = "0.2", features = ["NSColor"] }
|
||||
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
12
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "enables the default permissions",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default"
|
||||
]
|
||||
}
|
||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 109 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 152 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 982 B |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 523 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
130
src-tauri/src/app_config.rs
Normal file
@@ -0,0 +1,130 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
|
||||
use crate::provider::ProviderManager;
|
||||
|
||||
/// 应用类型
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AppType {
|
||||
Claude,
|
||||
Codex,
|
||||
}
|
||||
|
||||
impl AppType {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
AppType::Claude => "claude",
|
||||
AppType::Codex => "codex",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for AppType {
|
||||
fn from(s: &str) -> Self {
|
||||
match s.to_lowercase().as_str() {
|
||||
"codex" => AppType::Codex,
|
||||
_ => AppType::Claude, // 默认为 Claude
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 多应用配置结构(向后兼容)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MultiAppConfig {
|
||||
#[serde(default = "default_version")]
|
||||
pub version: u32,
|
||||
#[serde(flatten)]
|
||||
pub apps: HashMap<String, ProviderManager>,
|
||||
}
|
||||
|
||||
fn default_version() -> u32 {
|
||||
2
|
||||
}
|
||||
|
||||
impl Default for MultiAppConfig {
|
||||
fn default() -> Self {
|
||||
let mut apps = HashMap::new();
|
||||
apps.insert("claude".to_string(), ProviderManager::default());
|
||||
apps.insert("codex".to_string(), ProviderManager::default());
|
||||
|
||||
Self { version: 2, apps }
|
||||
}
|
||||
}
|
||||
|
||||
impl MultiAppConfig {
|
||||
/// 从文件加载配置(处理v1到v2的迁移)
|
||||
pub fn load() -> Result<Self, String> {
|
||||
let config_path = get_app_config_path();
|
||||
|
||||
if !config_path.exists() {
|
||||
log::info!("配置文件不存在,创建新的多应用配置");
|
||||
return Ok(Self::default());
|
||||
}
|
||||
|
||||
// 尝试读取文件
|
||||
let content = std::fs::read_to_string(&config_path)
|
||||
.map_err(|e| format!("读取配置文件失败: {}", 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 };
|
||||
|
||||
// 迁移前备份旧版(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);
|
||||
}
|
||||
|
||||
// 尝试读取v2格式
|
||||
serde_json::from_str::<Self>(&content).map_err(|e| format!("解析配置文件失败: {}", e))
|
||||
}
|
||||
|
||||
/// 保存配置到文件
|
||||
pub fn save(&self) -> Result<(), String> {
|
||||
let config_path = get_app_config_path();
|
||||
write_json_file(&config_path, self)
|
||||
}
|
||||
|
||||
/// 获取指定应用的管理器
|
||||
pub fn get_manager(&self, app: &AppType) -> Option<&ProviderManager> {
|
||||
self.apps.get(app.as_str())
|
||||
}
|
||||
|
||||
/// 获取指定应用的管理器(可变引用)
|
||||
pub fn get_manager_mut(&mut self, app: &AppType) -> Option<&mut ProviderManager> {
|
||||
self.apps.get_mut(app.as_str())
|
||||
}
|
||||
|
||||
/// 确保应用存在
|
||||
pub fn ensure_app(&mut self, app: &AppType) {
|
||||
if !self.apps.contains_key(app.as_str()) {
|
||||
self.apps
|
||||
.insert(app.as_str().to_string(), ProviderManager::default());
|
||||
}
|
||||
}
|
||||
}
|
||||
172
src-tauri/src/codex_config.rs
Normal file
@@ -0,0 +1,172 @@
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::config::{
|
||||
copy_file, delete_file, read_json_file, sanitize_provider_name, write_json_file,
|
||||
};
|
||||
|
||||
/// 获取 Codex 配置目录路径
|
||||
pub fn get_codex_config_dir() -> PathBuf {
|
||||
dirs::home_dir().expect("无法获取用户主目录").join(".codex")
|
||||
}
|
||||
|
||||
/// 获取 Codex auth.json 路径
|
||||
pub fn get_codex_auth_path() -> PathBuf {
|
||||
get_codex_config_dir().join("auth.json")
|
||||
}
|
||||
|
||||
/// 获取 Codex config.toml 路径
|
||||
pub fn get_codex_config_path() -> PathBuf {
|
||||
get_codex_config_dir().join("config.toml")
|
||||
}
|
||||
|
||||
/// 获取 Codex 供应商配置文件路径
|
||||
pub fn get_codex_provider_paths(
|
||||
provider_id: &str,
|
||||
provider_name: Option<&str>,
|
||||
) -> (PathBuf, PathBuf) {
|
||||
let base_name = provider_name
|
||||
.map(|name| sanitize_provider_name(name))
|
||||
.unwrap_or_else(|| sanitize_provider_name(provider_id));
|
||||
|
||||
let auth_path = get_codex_config_dir().join(format!("auth-{}.json", base_name));
|
||||
let config_path = get_codex_config_dir().join(format!("config-{}.toml", base_name));
|
||||
|
||||
(auth_path, config_path)
|
||||
}
|
||||
|
||||
/// 备份 Codex 当前配置
|
||||
pub fn backup_codex_config(provider_id: &str, provider_name: &str) -> Result<(), String> {
|
||||
let auth_path = get_codex_auth_path();
|
||||
let config_path = get_codex_config_path();
|
||||
let (backup_auth_path, backup_config_path) =
|
||||
get_codex_provider_paths(provider_id, Some(provider_name));
|
||||
|
||||
// 备份 auth.json
|
||||
if auth_path.exists() {
|
||||
copy_file(&auth_path, &backup_auth_path)?;
|
||||
log::info!("已备份 Codex auth.json: {}", backup_auth_path.display());
|
||||
}
|
||||
|
||||
// 备份 config.toml
|
||||
if config_path.exists() {
|
||||
copy_file(&config_path, &backup_config_path)?;
|
||||
log::info!("已备份 Codex config.toml: {}", backup_config_path.display());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 保存 Codex 供应商配置副本
|
||||
pub fn save_codex_provider_config(
|
||||
provider_id: &str,
|
||||
provider_name: &str,
|
||||
settings_config: &Value,
|
||||
) -> Result<(), String> {
|
||||
let (auth_path, config_path) = get_codex_provider_paths(provider_id, Some(provider_name));
|
||||
|
||||
// 保存 auth.json
|
||||
if let Some(auth) = settings_config.get("auth") {
|
||||
write_json_file(&auth_path, auth)?;
|
||||
}
|
||||
|
||||
// 保存 config.toml
|
||||
if let Some(config) = settings_config.get("config") {
|
||||
if let Some(config_str) = config.as_str() {
|
||||
// 若非空则进行 TOML 语法校验
|
||||
if !config_str.trim().is_empty() {
|
||||
toml::from_str::<toml::Table>(config_str)
|
||||
.map_err(|e| format!("config.toml 格式错误: {}", e))?;
|
||||
}
|
||||
fs::write(&config_path, config_str)
|
||||
.map_err(|e| format!("写入供应商 config.toml 失败: {}", e))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除 Codex 供应商配置文件
|
||||
pub fn delete_codex_provider_config(provider_id: &str, provider_name: &str) -> Result<(), String> {
|
||||
let (auth_path, config_path) = get_codex_provider_paths(provider_id, Some(provider_name));
|
||||
|
||||
delete_file(&auth_path).ok();
|
||||
delete_file(&config_path).ok();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 从 Codex 供应商配置副本恢复到主配置
|
||||
pub fn restore_codex_provider_config(provider_id: &str, provider_name: &str) -> Result<(), String> {
|
||||
let (provider_auth_path, provider_config_path) =
|
||||
get_codex_provider_paths(provider_id, Some(provider_name));
|
||||
let auth_path = get_codex_auth_path();
|
||||
let config_path = get_codex_config_path();
|
||||
|
||||
// 确保目录存在
|
||||
if let Some(parent) = auth_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| format!("创建 Codex 目录失败: {}", e))?;
|
||||
}
|
||||
|
||||
// 复制 auth.json(必需)
|
||||
if provider_auth_path.exists() {
|
||||
copy_file(&provider_auth_path, &auth_path)?;
|
||||
log::info!("已恢复 Codex auth.json");
|
||||
} else {
|
||||
return Err(format!(
|
||||
"供应商 auth.json 不存在: {}",
|
||||
provider_auth_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
// 复制 config.toml(可选,允许为空;不存在则创建空文件以保持一致性)
|
||||
if provider_config_path.exists() {
|
||||
copy_file(&provider_config_path, &config_path)?;
|
||||
log::info!("已恢复 Codex config.toml");
|
||||
} else {
|
||||
// 写入空文件
|
||||
fs::write(&config_path, "").map_err(|e| format!("创建空的 config.toml 失败: {}", e))?;
|
||||
log::info!("供应商 config.toml 缺失,已创建空文件");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 导入当前 Codex 配置为默认供应商
|
||||
pub fn import_current_codex_config() -> Result<Value, String> {
|
||||
let auth_path = get_codex_auth_path();
|
||||
let config_path = get_codex_config_path();
|
||||
|
||||
// 行为放宽:仅要求 auth.json 存在;config.toml 可缺失
|
||||
if !auth_path.exists() {
|
||||
return Err("Codex 配置文件不存在".to_string());
|
||||
}
|
||||
|
||||
// 读取 auth.json
|
||||
let auth = read_json_file::<Value>(&auth_path)?;
|
||||
|
||||
// 读取 config.toml(允许不存在或读取失败时为空)
|
||||
let config_str = if config_path.exists() {
|
||||
let s = fs::read_to_string(&config_path)
|
||||
.map_err(|e| format!("读取 config.toml 失败: {}", e))?;
|
||||
if !s.trim().is_empty() {
|
||||
toml::from_str::<toml::Table>(&s)
|
||||
.map_err(|e| format!("config.toml 语法错误: {}", e))?;
|
||||
}
|
||||
s
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
// 组合成完整配置
|
||||
let settings_config = serde_json::json!({
|
||||
"auth": auth,
|
||||
"config": config_str
|
||||
});
|
||||
|
||||
// 保存为默认供应商副本
|
||||
save_codex_provider_config("default", "default", &settings_config)?;
|
||||
|
||||
Ok(settings_config)
|
||||
}
|
||||
504
src-tauri/src/commands.rs
Normal file
@@ -0,0 +1,504 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use tauri::State;
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::codex_config;
|
||||
use crate::config::{ConfigStatus, get_claude_settings_path, import_current_config_as_default};
|
||||
use crate::provider::Provider;
|
||||
use crate::store::AppState;
|
||||
|
||||
/// 获取所有供应商
|
||||
#[tauri::command]
|
||||
pub async fn get_providers(
|
||||
state: State<'_, AppState>,
|
||||
app_type: Option<AppType>,
|
||||
app: Option<String>,
|
||||
appType: Option<String>,
|
||||
) -> Result<HashMap<String, Provider>, String> {
|
||||
let app_type = app_type
|
||||
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
|
||||
let config = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
|
||||
let manager = config
|
||||
.get_manager(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
|
||||
Ok(manager.get_all_providers().clone())
|
||||
}
|
||||
|
||||
/// 获取当前供应商ID
|
||||
#[tauri::command]
|
||||
pub async fn get_current_provider(
|
||||
state: State<'_, AppState>,
|
||||
app_type: Option<AppType>,
|
||||
app: Option<String>,
|
||||
appType: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
let app_type = app_type
|
||||
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
|
||||
let config = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
|
||||
let manager = config
|
||||
.get_manager(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
|
||||
Ok(manager.current.clone())
|
||||
}
|
||||
|
||||
/// 添加供应商
|
||||
#[tauri::command]
|
||||
pub async fn add_provider(
|
||||
state: State<'_, AppState>,
|
||||
app_type: Option<AppType>,
|
||||
app: Option<String>,
|
||||
appType: Option<String>,
|
||||
provider: Provider,
|
||||
) -> Result<bool, String> {
|
||||
let app_type = app_type
|
||||
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
|
||||
let mut config = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
|
||||
let manager = config
|
||||
.get_manager_mut(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
|
||||
// 根据应用类型保存配置文件
|
||||
match app_type {
|
||||
AppType::Codex => {
|
||||
// Codex: 保存两个文件
|
||||
codex_config::save_codex_provider_config(
|
||||
&provider.id,
|
||||
&provider.name,
|
||||
&provider.settings_config,
|
||||
)?;
|
||||
}
|
||||
AppType::Claude => {
|
||||
// Claude: 使用原有逻辑
|
||||
use crate::config::{get_provider_config_path, write_json_file};
|
||||
let config_path = get_provider_config_path(&provider.id, Some(&provider.name));
|
||||
write_json_file(&config_path, &provider.settings_config)?;
|
||||
}
|
||||
}
|
||||
|
||||
manager.providers.insert(provider.id.clone(), provider);
|
||||
|
||||
// 保存配置
|
||||
drop(config); // 释放锁
|
||||
state.save()?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 更新供应商
|
||||
#[tauri::command]
|
||||
pub async fn update_provider(
|
||||
state: State<'_, AppState>,
|
||||
app_type: Option<AppType>,
|
||||
app: Option<String>,
|
||||
appType: Option<String>,
|
||||
provider: Provider,
|
||||
) -> Result<bool, String> {
|
||||
let app_type = app_type
|
||||
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
|
||||
let mut config = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
|
||||
let manager = config
|
||||
.get_manager_mut(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
|
||||
// 检查供应商是否存在
|
||||
if !manager.providers.contains_key(&provider.id) {
|
||||
return Err(format!("供应商不存在: {}", provider.id));
|
||||
}
|
||||
|
||||
// 如果名称改变了,需要处理配置文件
|
||||
if let Some(old_provider) = manager.providers.get(&provider.id) {
|
||||
if old_provider.name != provider.name {
|
||||
// 删除旧配置文件
|
||||
match app_type {
|
||||
AppType::Codex => {
|
||||
codex_config::delete_codex_provider_config(&provider.id, &old_provider.name)
|
||||
.ok();
|
||||
}
|
||||
AppType::Claude => {
|
||||
use crate::config::{delete_file, get_provider_config_path};
|
||||
let old_config_path =
|
||||
get_provider_config_path(&provider.id, Some(&old_provider.name));
|
||||
delete_file(&old_config_path).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保存新配置文件
|
||||
match app_type {
|
||||
AppType::Codex => {
|
||||
codex_config::save_codex_provider_config(
|
||||
&provider.id,
|
||||
&provider.name,
|
||||
&provider.settings_config,
|
||||
)?;
|
||||
}
|
||||
AppType::Claude => {
|
||||
use crate::config::{get_provider_config_path, write_json_file};
|
||||
let config_path = get_provider_config_path(&provider.id, Some(&provider.name));
|
||||
write_json_file(&config_path, &provider.settings_config)?;
|
||||
}
|
||||
}
|
||||
|
||||
manager.providers.insert(provider.id.clone(), provider);
|
||||
|
||||
// 保存配置
|
||||
drop(config); // 释放锁
|
||||
state.save()?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 删除供应商
|
||||
#[tauri::command]
|
||||
pub async fn delete_provider(
|
||||
state: State<'_, AppState>,
|
||||
app_type: Option<AppType>,
|
||||
app: Option<String>,
|
||||
appType: Option<String>,
|
||||
id: String,
|
||||
) -> Result<bool, String> {
|
||||
let app_type = app_type
|
||||
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
|
||||
let mut config = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
|
||||
let manager = config
|
||||
.get_manager_mut(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
|
||||
// 检查是否为当前供应商
|
||||
if manager.current == id {
|
||||
return Err("不能删除当前正在使用的供应商".to_string());
|
||||
}
|
||||
|
||||
// 获取供应商信息
|
||||
let provider = manager
|
||||
.providers
|
||||
.get(&id)
|
||||
.ok_or_else(|| format!("供应商不存在: {}", id))?
|
||||
.clone();
|
||||
|
||||
// 删除配置文件
|
||||
match app_type {
|
||||
AppType::Codex => {
|
||||
codex_config::delete_codex_provider_config(&id, &provider.name)?;
|
||||
}
|
||||
AppType::Claude => {
|
||||
use crate::config::{delete_file, get_provider_config_path};
|
||||
let config_path = get_provider_config_path(&id, Some(&provider.name));
|
||||
delete_file(&config_path)?;
|
||||
}
|
||||
}
|
||||
|
||||
// 从管理器删除
|
||||
manager.providers.remove(&id);
|
||||
|
||||
// 保存配置
|
||||
drop(config); // 释放锁
|
||||
state.save()?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 切换供应商
|
||||
#[tauri::command]
|
||||
pub async fn switch_provider(
|
||||
state: State<'_, AppState>,
|
||||
app_type: Option<AppType>,
|
||||
app: Option<String>,
|
||||
appType: Option<String>,
|
||||
id: String,
|
||||
) -> Result<bool, String> {
|
||||
let app_type = app_type
|
||||
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
|
||||
let mut config = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
|
||||
let manager = config
|
||||
.get_manager_mut(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
|
||||
// 检查供应商是否存在
|
||||
let provider = manager
|
||||
.providers
|
||||
.get(&id)
|
||||
.ok_or_else(|| format!("供应商不存在: {}", id))?
|
||||
.clone();
|
||||
|
||||
// 根据应用类型执行切换
|
||||
match app_type {
|
||||
AppType::Codex => {
|
||||
// 备份当前配置(如果存在)
|
||||
if !manager.current.is_empty() {
|
||||
if let Some(current_provider) = manager.providers.get(&manager.current) {
|
||||
codex_config::backup_codex_config(&manager.current, ¤t_provider.name)?;
|
||||
log::info!("已备份当前 Codex 供应商配置: {}", current_provider.name);
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复目标供应商配置
|
||||
codex_config::restore_codex_provider_config(&id, &provider.name)?;
|
||||
}
|
||||
AppType::Claude => {
|
||||
// 使用原有的 Claude 切换逻辑
|
||||
use crate::config::{
|
||||
backup_config, copy_file, get_claude_settings_path, get_provider_config_path,
|
||||
};
|
||||
|
||||
let settings_path = get_claude_settings_path();
|
||||
let provider_config_path = get_provider_config_path(&id, Some(&provider.name));
|
||||
|
||||
// 检查供应商配置文件是否存在
|
||||
if !provider_config_path.exists() {
|
||||
return Err(format!(
|
||||
"供应商配置文件不存在: {}",
|
||||
provider_config_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
// 如果当前有配置,先备份到当前供应商
|
||||
if settings_path.exists() && !manager.current.is_empty() {
|
||||
if let Some(current_provider) = manager.providers.get(&manager.current) {
|
||||
let current_provider_path =
|
||||
get_provider_config_path(&manager.current, Some(¤t_provider.name));
|
||||
backup_config(&settings_path, ¤t_provider_path)?;
|
||||
log::info!("已备份当前供应商配置: {}", current_provider.name);
|
||||
}
|
||||
}
|
||||
|
||||
// 确保主配置父目录存在
|
||||
if let Some(parent) = settings_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?;
|
||||
}
|
||||
|
||||
// 复制新供应商配置到主配置
|
||||
copy_file(&provider_config_path, &settings_path)?;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新当前供应商
|
||||
manager.current = id;
|
||||
|
||||
log::info!("成功切换到供应商: {}", provider.name);
|
||||
|
||||
// 保存配置
|
||||
drop(config); // 释放锁
|
||||
state.save()?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 导入当前配置为默认供应商
|
||||
#[tauri::command]
|
||||
pub async fn import_default_config(
|
||||
state: State<'_, AppState>,
|
||||
app_type: Option<AppType>,
|
||||
app: Option<String>,
|
||||
appType: Option<String>,
|
||||
) -> Result<bool, String> {
|
||||
let app_type = app_type
|
||||
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
|
||||
// 若已存在 default 供应商,则直接返回,避免重复导入
|
||||
{
|
||||
let config = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
|
||||
if let Some(manager) = config.get_manager(&app_type) {
|
||||
if manager.get_all_providers().contains_key("default") {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 根据应用类型导入配置
|
||||
let settings_config = match app_type {
|
||||
AppType::Codex => codex_config::import_current_codex_config()?,
|
||||
AppType::Claude => import_current_config_as_default()?,
|
||||
};
|
||||
|
||||
// 创建默认供应商
|
||||
let provider = Provider::with_id(
|
||||
"default".to_string(),
|
||||
"default".to_string(),
|
||||
settings_config,
|
||||
None,
|
||||
);
|
||||
|
||||
// 添加到管理器
|
||||
let mut config = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
|
||||
let manager = config
|
||||
.get_manager_mut(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
|
||||
// 根据应用类型保存配置文件
|
||||
match app_type {
|
||||
AppType::Codex => {
|
||||
codex_config::save_codex_provider_config(
|
||||
&provider.id,
|
||||
&provider.name,
|
||||
&provider.settings_config,
|
||||
)?;
|
||||
}
|
||||
AppType::Claude => {
|
||||
use crate::config::{get_provider_config_path, write_json_file};
|
||||
let config_path = get_provider_config_path(&provider.id, Some(&provider.name));
|
||||
write_json_file(&config_path, &provider.settings_config)?;
|
||||
}
|
||||
}
|
||||
|
||||
manager.providers.insert(provider.id.clone(), provider);
|
||||
|
||||
// 如果没有当前供应商,设置为 default
|
||||
if manager.current.is_empty() {
|
||||
manager.current = "default".to_string();
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
drop(config); // 释放锁
|
||||
state.save()?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 获取 Claude Code 配置状态
|
||||
#[tauri::command]
|
||||
pub async fn get_claude_config_status() -> Result<ConfigStatus, String> {
|
||||
Ok(crate::config::get_claude_config_status())
|
||||
}
|
||||
|
||||
/// 获取应用配置状态(通用)
|
||||
/// 兼容两种参数:`app_type`(推荐)或 `app`(字符串)
|
||||
#[tauri::command]
|
||||
pub async fn get_config_status(
|
||||
app_type: Option<AppType>,
|
||||
app: Option<String>,
|
||||
appType: Option<String>,
|
||||
) -> Result<ConfigStatus, String> {
|
||||
let app = app_type
|
||||
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
|
||||
match app {
|
||||
AppType::Claude => Ok(crate::config::get_claude_config_status()),
|
||||
AppType::Codex => {
|
||||
use crate::codex_config::{get_codex_auth_path, get_codex_config_dir};
|
||||
let auth_path = get_codex_auth_path();
|
||||
|
||||
// 放宽:只要 auth.json 存在即可认为已配置;config.toml 允许为空
|
||||
let exists = auth_path.exists();
|
||||
let path = get_codex_config_dir().to_string_lossy().to_string();
|
||||
|
||||
Ok(ConfigStatus { exists, path })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取 Claude Code 配置文件路径
|
||||
#[tauri::command]
|
||||
pub async fn get_claude_code_config_path() -> Result<String, String> {
|
||||
Ok(get_claude_settings_path().to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// 打开配置文件夹
|
||||
/// 兼容两种参数:`app_type`(推荐)或 `app`(字符串)
|
||||
#[tauri::command]
|
||||
pub async fn open_config_folder(
|
||||
handle: tauri::AppHandle,
|
||||
app_type: Option<AppType>,
|
||||
app: Option<String>,
|
||||
appType: Option<String>,
|
||||
) -> Result<bool, String> {
|
||||
let app_type = app_type
|
||||
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
|
||||
let config_dir = match app_type {
|
||||
AppType::Claude => crate::config::get_claude_config_dir(),
|
||||
AppType::Codex => crate::codex_config::get_codex_config_dir(),
|
||||
};
|
||||
|
||||
// 确保目录存在
|
||||
if !config_dir.exists() {
|
||||
std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {}", e))?;
|
||||
}
|
||||
|
||||
// 使用 opener 插件打开文件夹
|
||||
handle.opener()
|
||||
.open_path(config_dir.to_string_lossy().to_string(), None::<String>)
|
||||
.map_err(|e| format!("打开文件夹失败: {}", e))?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 打开外部链接
|
||||
#[tauri::command]
|
||||
pub async fn open_external(app: tauri::AppHandle, url: String) -> Result<bool, String> {
|
||||
// 规范化 URL,缺少协议时默认加 https://
|
||||
let url = if url.starts_with("http://") || url.starts_with("https://") {
|
||||
url
|
||||
} else {
|
||||
format!("https://{}", url)
|
||||
};
|
||||
|
||||
// 使用 opener 插件打开链接
|
||||
app.opener()
|
||||
.open_url(&url, None::<String>)
|
||||
.map_err(|e| format!("打开链接失败: {}", e))?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
141
src-tauri/src/config.rs
Normal file
@@ -0,0 +1,141 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// 获取 Claude Code 配置目录路径
|
||||
pub fn get_claude_config_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.expect("无法获取用户主目录")
|
||||
.join(".claude")
|
||||
}
|
||||
|
||||
/// 获取 Claude Code 主配置文件路径
|
||||
pub fn get_claude_settings_path() -> PathBuf {
|
||||
let dir = get_claude_config_dir();
|
||||
let settings = dir.join("settings.json");
|
||||
if settings.exists() {
|
||||
return settings;
|
||||
}
|
||||
// 兼容旧版命名:若存在旧文件则继续使用
|
||||
let legacy = dir.join("claude.json");
|
||||
if legacy.exists() {
|
||||
return legacy;
|
||||
}
|
||||
// 默认新建:回落到标准文件名 settings.json(不再生成 claude.json)
|
||||
settings
|
||||
}
|
||||
|
||||
/// 获取应用配置目录路径 (~/.cc-switch)
|
||||
pub fn get_app_config_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.expect("无法获取用户主目录")
|
||||
.join(".cc-switch")
|
||||
}
|
||||
|
||||
/// 获取应用配置文件路径
|
||||
pub fn get_app_config_path() -> PathBuf {
|
||||
get_app_config_dir().join("config.json")
|
||||
}
|
||||
|
||||
/// 清理供应商名称,确保文件名安全
|
||||
pub fn sanitize_provider_name(name: &str) -> String {
|
||||
name.chars()
|
||||
.map(|c| match c {
|
||||
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '-',
|
||||
_ => c,
|
||||
})
|
||||
.collect::<String>()
|
||||
.to_lowercase()
|
||||
}
|
||||
|
||||
/// 获取供应商配置文件路径
|
||||
pub fn get_provider_config_path(provider_id: &str, provider_name: Option<&str>) -> PathBuf {
|
||||
let base_name = provider_name
|
||||
.map(|name| sanitize_provider_name(name))
|
||||
.unwrap_or_else(|| sanitize_provider_name(provider_id));
|
||||
|
||||
get_claude_config_dir().join(format!("settings-{}.json", base_name))
|
||||
}
|
||||
|
||||
/// 读取 JSON 配置文件
|
||||
pub fn read_json_file<T: for<'a> Deserialize<'a>>(path: &Path) -> Result<T, String> {
|
||||
if !path.exists() {
|
||||
return Err(format!("文件不存在: {}", path.display()));
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(path).map_err(|e| format!("读取文件失败: {}", e))?;
|
||||
|
||||
serde_json::from_str(&content).map_err(|e| format!("解析 JSON 失败: {}", e))
|
||||
}
|
||||
|
||||
/// 写入 JSON 配置文件
|
||||
pub fn write_json_file<T: Serialize>(path: &Path, data: &T) -> Result<(), String> {
|
||||
// 确保目录存在
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?;
|
||||
}
|
||||
|
||||
let json =
|
||||
serde_json::to_string_pretty(data).map_err(|e| format!("序列化 JSON 失败: {}", e))?;
|
||||
|
||||
fs::write(path, json).map_err(|e| format!("写入文件失败: {}", e))
|
||||
}
|
||||
|
||||
/// 复制文件
|
||||
pub fn copy_file(from: &Path, to: &Path) -> Result<(), String> {
|
||||
fs::copy(from, to).map_err(|e| format!("复制文件失败: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除文件
|
||||
pub fn delete_file(path: &Path) -> Result<(), String> {
|
||||
if path.exists() {
|
||||
fs::remove_file(path).map_err(|e| format!("删除文件失败: {}", e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 检查 Claude Code 配置状态
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ConfigStatus {
|
||||
pub exists: bool,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// 获取 Claude Code 配置状态
|
||||
pub fn get_claude_config_status() -> ConfigStatus {
|
||||
let path = get_claude_settings_path();
|
||||
ConfigStatus {
|
||||
exists: path.exists(),
|
||||
path: path.to_string_lossy().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 备份配置文件
|
||||
pub fn backup_config(from: &Path, to: &Path) -> Result<(), String> {
|
||||
if from.exists() {
|
||||
copy_file(from, to)?;
|
||||
log::info!("已备份配置文件: {} -> {}", from.display(), to.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 导入当前 Claude Code 配置为默认供应商
|
||||
pub fn import_current_config_as_default() -> Result<Value, String> {
|
||||
let settings_path = get_claude_settings_path();
|
||||
|
||||
if !settings_path.exists() {
|
||||
return Err("Claude Code 配置文件不存在".to_string());
|
||||
}
|
||||
|
||||
// 读取当前配置
|
||||
let settings_config: Value = read_json_file(&settings_path)?;
|
||||
|
||||
// 保存为 default 供应商
|
||||
let default_provider_path = get_provider_config_path("default", Some("default"));
|
||||
write_json_file(&default_provider_path, &settings_config)?;
|
||||
|
||||
log::info!("已导入当前配置为默认供应商");
|
||||
Ok(settings_config)
|
||||
}
|
||||
125
src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,125 @@
|
||||
mod app_config;
|
||||
mod codex_config;
|
||||
mod commands;
|
||||
mod config;
|
||||
mod provider;
|
||||
mod store;
|
||||
|
||||
use store::AppState;
|
||||
use tauri::Manager;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.setup(|app| {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// 设置 macOS 标题栏背景色为主界面蓝色
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
use objc2::rc::Retained;
|
||||
use objc2::runtime::AnyObject;
|
||||
use objc2_app_kit::NSColor;
|
||||
|
||||
let ns_window_ptr = window.ns_window().unwrap();
|
||||
let ns_window: Retained<AnyObject> =
|
||||
unsafe { Retained::retain(ns_window_ptr as *mut AnyObject).unwrap() };
|
||||
|
||||
// 使用与主界面 banner 相同的蓝色 #3498db
|
||||
// #3498db = RGB(52, 152, 219)
|
||||
let bg_color = unsafe {
|
||||
NSColor::colorWithRed_green_blue_alpha(
|
||||
52.0 / 255.0, // R: 52
|
||||
152.0 / 255.0, // G: 152
|
||||
219.0 / 255.0, // B: 219
|
||||
1.0, // Alpha: 1.0
|
||||
)
|
||||
};
|
||||
|
||||
unsafe {
|
||||
use objc2::msg_send;
|
||||
let _: () = msg_send![&*ns_window, setBackgroundColor: &*bg_color];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化日志
|
||||
if cfg!(debug_assertions) {
|
||||
app.handle().plugin(
|
||||
tauri_plugin_log::Builder::default()
|
||||
.level(log::LevelFilter::Info)
|
||||
.build(),
|
||||
)?;
|
||||
}
|
||||
|
||||
// 初始化应用状态(仅创建一次,并在本函数末尾注入 manage)
|
||||
let app_state = AppState::new();
|
||||
|
||||
// 如果没有供应商且存在 Claude Code 配置,自动导入
|
||||
{
|
||||
let mut config = app_state.config.lock().unwrap();
|
||||
|
||||
// 检查 Claude 供应商
|
||||
let need_import = if let Some(claude_manager) =
|
||||
config.get_manager(&app_config::AppType::Claude)
|
||||
{
|
||||
claude_manager.providers.is_empty()
|
||||
} else {
|
||||
// 确保 Claude 应用存在
|
||||
config.ensure_app(&app_config::AppType::Claude);
|
||||
true
|
||||
};
|
||||
|
||||
if need_import {
|
||||
let settings_path = config::get_claude_settings_path();
|
||||
if settings_path.exists() {
|
||||
log::info!("检测到 Claude Code 配置,自动导入为默认供应商");
|
||||
|
||||
if let Ok(settings_config) = config::import_current_config_as_default() {
|
||||
if let Some(manager) =
|
||||
config.get_manager_mut(&app_config::AppType::Claude)
|
||||
{
|
||||
let provider = provider::Provider::with_id(
|
||||
"default".to_string(),
|
||||
"default".to_string(),
|
||||
settings_config,
|
||||
None,
|
||||
);
|
||||
|
||||
if manager.add_provider(provider).is_ok() {
|
||||
manager.current = "default".to_string();
|
||||
log::info!("成功导入默认供应商");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 确保 Codex 应用存在
|
||||
config.ensure_app(&app_config::AppType::Codex);
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
let _ = app_state.save();
|
||||
|
||||
// 将同一个实例注入到全局状态,避免重复创建导致的不一致
|
||||
app.manage(app_state);
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::get_providers,
|
||||
commands::get_current_provider,
|
||||
commands::add_provider,
|
||||
commands::update_provider,
|
||||
commands::delete_provider,
|
||||
commands::switch_provider,
|
||||
commands::import_default_config,
|
||||
commands::get_claude_config_status,
|
||||
commands::get_config_status,
|
||||
commands::get_claude_code_config_path,
|
||||
commands::open_config_folder,
|
||||
commands::open_external,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
6
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
cc_switch_lib::run();
|
||||
}
|
||||
68
src-tauri/src/provider.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::config::{get_provider_config_path, write_json_file};
|
||||
|
||||
/// 供应商结构体
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Provider {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(rename = "settingsConfig")]
|
||||
pub settings_config: Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "websiteUrl")]
|
||||
pub website_url: Option<String>,
|
||||
}
|
||||
|
||||
impl Provider {
|
||||
/// 从现有ID创建供应商
|
||||
pub fn with_id(
|
||||
id: String,
|
||||
name: String,
|
||||
settings_config: Value,
|
||||
website_url: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
name,
|
||||
settings_config,
|
||||
website_url,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 供应商管理器
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderManager {
|
||||
pub providers: HashMap<String, Provider>,
|
||||
pub current: String,
|
||||
}
|
||||
|
||||
impl Default for ProviderManager {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
providers: HashMap::new(),
|
||||
current: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderManager {
|
||||
/// 添加供应商
|
||||
pub fn add_provider(&mut self, provider: Provider) -> Result<(), String> {
|
||||
// 保存供应商配置到独立文件
|
||||
let config_path = get_provider_config_path(&provider.id, Some(&provider.name));
|
||||
write_json_file(&config_path, &provider.settings_config)?;
|
||||
|
||||
// 添加到管理器
|
||||
self.providers.insert(provider.id.clone(), provider);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取所有供应商
|
||||
pub fn get_all_providers(&self) -> &HashMap<String, Provider> {
|
||||
&self.providers
|
||||
}
|
||||
}
|
||||
31
src-tauri/src/store.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use crate::app_config::MultiAppConfig;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// 全局应用状态
|
||||
pub struct AppState {
|
||||
pub config: Mutex<MultiAppConfig>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// 创建新的应用状态
|
||||
pub fn new() -> Self {
|
||||
let config = MultiAppConfig::load().unwrap_or_else(|e| {
|
||||
log::warn!("加载配置失败: {}, 使用默认配置", e);
|
||||
MultiAppConfig::default()
|
||||
});
|
||||
|
||||
Self {
|
||||
config: Mutex::new(config),
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存配置到文件
|
||||
pub fn save(&self) -> Result<(), String> {
|
||||
let config = self
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
|
||||
config.save()
|
||||
}
|
||||
}
|
||||
40
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CC Switch",
|
||||
"version": "3.0.0",
|
||||
"identifier": "com.ccswitch.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:3000",
|
||||
"beforeDevCommand": "pnpm run dev:renderer",
|
||||
"beforeBuildCommand": "pnpm run build:renderer"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "",
|
||||
"width": 900,
|
||||
"height": 650,
|
||||
"minWidth": 800,
|
||||
"minHeight": 600,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"titleBarStyle": "Transparent"
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' https: http:"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -5,39 +5,104 @@
|
||||
}
|
||||
|
||||
.app-header {
|
||||
background: #3498db;
|
||||
background: linear-gradient(180deg, #3498db 0%, #2d89c7 100%);
|
||||
color: white;
|
||||
padding: 1rem 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.35rem 2rem 0.45rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
grid-template-areas:
|
||||
". title ."
|
||||
"tabs . actions";
|
||||
align-items: center;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
/* 允许作为 Electron 的拖拽区域(macOS 隐藏标题栏时生效) */
|
||||
-webkit-app-region: drag;
|
||||
row-gap: 0.6rem;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.app-tabs {
|
||||
grid-area: tabs;
|
||||
}
|
||||
|
||||
/* Segmented control */
|
||||
.segmented {
|
||||
--seg-bg: rgba(255, 255, 255, 0.16);
|
||||
--seg-thumb: #ffffff;
|
||||
--seg-color: rgba(255, 255, 255, 0.85);
|
||||
--seg-active: #2d89c7;
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
width: 280px;
|
||||
background: var(--seg-bg);
|
||||
border-radius: 999px;
|
||||
padding: 4px;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: saturate(140%) blur(2px);
|
||||
}
|
||||
|
||||
.segmented-thumb {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
width: calc(50% - 4px);
|
||||
height: calc(100% - 8px);
|
||||
background: var(--seg-thumb);
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
|
||||
transition:
|
||||
transform 220ms ease,
|
||||
width 220ms ease;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.segmented-item {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 6px 16px; /* 更紧凑的高度 */
|
||||
color: var(--seg-color);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.2px;
|
||||
cursor: pointer;
|
||||
transition: color 200ms ease;
|
||||
}
|
||||
|
||||
.segmented-item.active {
|
||||
color: var(--seg-active);
|
||||
}
|
||||
|
||||
.segmented-item:focus-visible {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.8);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
grid-area: title;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
/* header 内的交互元素需要排除拖拽,否则无法点击 */
|
||||
-webkit-app-region: no-drag;
|
||||
grid-area: actions;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.refresh-btn, .add-btn {
|
||||
.refresh-btn,
|
||||
.add-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
/* 明确按钮不可拖拽,确保可点击 */
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
@@ -128,12 +193,12 @@
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 100;
|
||||
|
||||
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
|
||||
|
||||
width: fit-content;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -1,18 +1,24 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Provider } from "../shared/types";
|
||||
import { Provider } from "./types";
|
||||
import { AppType } from "./lib/tauri-api";
|
||||
import ProviderList from "./components/ProviderList";
|
||||
import AddProviderModal from "./components/AddProviderModal";
|
||||
import EditProviderModal from "./components/EditProviderModal";
|
||||
import { ConfirmDialog } from "./components/ConfirmDialog";
|
||||
import { AppSwitcher } from "./components/AppSwitcher";
|
||||
import "./App.css";
|
||||
|
||||
function App() {
|
||||
const [activeApp, setActiveApp] = useState<AppType>("claude");
|
||||
const [providers, setProviders] = useState<Record<string, Provider>>({});
|
||||
const [currentProviderId, setCurrentProviderId] = useState<string>("");
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
const [configPath, setConfigPath] = useState<string>("");
|
||||
const [configStatus, setConfigStatus] = useState<{
|
||||
exists: boolean;
|
||||
path: string;
|
||||
} | null>(null);
|
||||
const [editingProviderId, setEditingProviderId] = useState<string | null>(
|
||||
null
|
||||
null,
|
||||
);
|
||||
const [notification, setNotification] = useState<{
|
||||
message: string;
|
||||
@@ -25,13 +31,13 @@ function App() {
|
||||
message: string;
|
||||
onConfirm: () => void;
|
||||
} | null>(null);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// 设置通知的辅助函数
|
||||
const showNotification = (
|
||||
message: string,
|
||||
type: "success" | "error",
|
||||
duration = 3000
|
||||
duration = 3000,
|
||||
) => {
|
||||
// 清除之前的定时器
|
||||
if (timeoutRef.current) {
|
||||
@@ -56,8 +62,8 @@ function App() {
|
||||
// 加载供应商列表
|
||||
useEffect(() => {
|
||||
loadProviders();
|
||||
loadConfigPath();
|
||||
}, []);
|
||||
loadConfigStatus();
|
||||
}, [activeApp]); // 当切换应用时重新加载
|
||||
|
||||
// 清理定时器
|
||||
useEffect(() => {
|
||||
@@ -69,20 +75,23 @@ function App() {
|
||||
}, []);
|
||||
|
||||
const loadProviders = async () => {
|
||||
const loadedProviders = await window.electronAPI.getProviders();
|
||||
const currentId = await window.electronAPI.getCurrentProvider();
|
||||
const loadedProviders = await window.api.getProviders(activeApp);
|
||||
const currentId = await window.api.getCurrentProvider(activeApp);
|
||||
setProviders(loadedProviders);
|
||||
setCurrentProviderId(currentId);
|
||||
|
||||
|
||||
// 如果供应商列表为空,尝试自动导入现有配置为"default"供应商
|
||||
if (Object.keys(loadedProviders).length === 0) {
|
||||
await handleAutoImportDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const loadConfigPath = async () => {
|
||||
const path = await window.electronAPI.getClaudeCodeConfigPath();
|
||||
setConfigPath(path);
|
||||
const loadConfigStatus = async () => {
|
||||
const status = await window.api.getConfigStatus(activeApp);
|
||||
setConfigStatus({
|
||||
exists: Boolean(status?.exists),
|
||||
path: String(status?.path || ""),
|
||||
});
|
||||
};
|
||||
|
||||
// 生成唯一ID
|
||||
@@ -95,14 +104,14 @@ function App() {
|
||||
...provider,
|
||||
id: generateId(),
|
||||
};
|
||||
await window.electronAPI.addProvider(newProvider);
|
||||
await window.api.addProvider(newProvider, activeApp);
|
||||
await loadProviders();
|
||||
setIsAddModalOpen(false);
|
||||
};
|
||||
|
||||
const handleEditProvider = async (provider: Provider) => {
|
||||
try {
|
||||
await window.electronAPI.updateProvider(provider);
|
||||
await window.api.updateProvider(provider, activeApp);
|
||||
await loadProviders();
|
||||
setEditingProviderId(null);
|
||||
// 显示编辑成功提示
|
||||
@@ -121,7 +130,7 @@ function App() {
|
||||
title: "删除供应商",
|
||||
message: `确定要删除供应商 "${provider?.name}" 吗?此操作无法撤销。`,
|
||||
onConfirm: async () => {
|
||||
await window.electronAPI.deleteProvider(id);
|
||||
await window.api.deleteProvider(id, activeApp);
|
||||
await loadProviders();
|
||||
setConfirmDialog(null);
|
||||
showNotification("供应商删除成功", "success");
|
||||
@@ -130,14 +139,15 @@ function App() {
|
||||
};
|
||||
|
||||
const handleSwitchProvider = async (id: string) => {
|
||||
const success = await window.electronAPI.switchProvider(id);
|
||||
const success = await window.api.switchProvider(id, activeApp);
|
||||
if (success) {
|
||||
setCurrentProviderId(id);
|
||||
// 显示重启提示
|
||||
const appName = activeApp === "claude" ? "Claude Code" : "Codex";
|
||||
showNotification(
|
||||
"切换成功!请重启 Claude Code 终端以生效",
|
||||
`切换成功!请重启 ${appName} 终端以生效`,
|
||||
"success",
|
||||
2000
|
||||
2000,
|
||||
);
|
||||
} else {
|
||||
showNotification("切换失败,请检查配置", "error");
|
||||
@@ -147,27 +157,37 @@ function App() {
|
||||
// 自动导入现有配置为"default"供应商
|
||||
const handleAutoImportDefault = async () => {
|
||||
try {
|
||||
const result = await window.electronAPI.importCurrentConfigAsDefault()
|
||||
|
||||
const result = await window.api.importCurrentConfigAsDefault(activeApp);
|
||||
|
||||
if (result.success) {
|
||||
await loadProviders()
|
||||
showNotification("已自动导入现有配置为 default 供应商", "success", 3000)
|
||||
await loadProviders();
|
||||
showNotification(
|
||||
"已自动导入现有配置为 default 供应商",
|
||||
"success",
|
||||
3000,
|
||||
);
|
||||
}
|
||||
// 如果导入失败(比如没有现有配置),静默处理,不显示错误
|
||||
} catch (error) {
|
||||
console.error('自动导入默认配置失败:', error)
|
||||
console.error("自动导入默认配置失败:", error);
|
||||
// 静默处理,不影响用户体验
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenConfigFolder = async () => {
|
||||
await window.electronAPI.openConfigFolder();
|
||||
await window.api.openConfigFolder(activeApp);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<h1>Claude Code 供应商切换器</h1>
|
||||
<h1>CC Switch</h1>
|
||||
<div className="app-tabs">
|
||||
<AppSwitcher
|
||||
activeApp={activeApp}
|
||||
onSwitch={setActiveApp}
|
||||
/>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<button className="add-btn" onClick={() => setIsAddModalOpen(true)}>
|
||||
添加供应商
|
||||
@@ -199,9 +219,12 @@ function App() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{configPath && (
|
||||
{configStatus && (
|
||||
<div className="config-path">
|
||||
<span>配置文件位置: {configPath}</span>
|
||||
<span>
|
||||
配置文件位置: {configStatus.path}
|
||||
{!configStatus.exists ? "(未创建,切换或保存时会自动创建)" : ""}
|
||||
</span>
|
||||
<button
|
||||
className="browse-btn"
|
||||
onClick={handleOpenConfigFolder}
|
||||
@@ -215,6 +238,7 @@ function App() {
|
||||
|
||||
{isAddModalOpen && (
|
||||
<AddProviderModal
|
||||
appType={activeApp}
|
||||
onAdd={handleAddProvider}
|
||||
onClose={() => setIsAddModalOpen(false)}
|
||||
/>
|
||||
@@ -222,6 +246,7 @@ function App() {
|
||||
|
||||
{editingProviderId && providers[editingProviderId] && (
|
||||
<EditProviderModal
|
||||
appType={activeApp}
|
||||
provider={providers[editingProviderId]}
|
||||
onSave={handleEditProvider}
|
||||
onClose={() => setEditingProviderId(null)}
|
||||
@@ -13,20 +13,78 @@
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
border-radius: 10px;
|
||||
padding: 0;
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
max-width: 640px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||
overflow: hidden; /* 由 body 滚动,标题栏固定 */
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
|
||||
position: relative;
|
||||
z-index: 1001;
|
||||
display: flex; /* 纵向布局,便于底栏固定 */
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-content h2 {
|
||||
margin-bottom: 1.5rem;
|
||||
color: #2c3e50;
|
||||
/* 模拟窗口标题栏 */
|
||||
.modal-titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 3rem; /* 与主窗口标题栏一致 */
|
||||
padding: 0 12px; /* 接近主头部的水平留白 */
|
||||
background: #3498db; /* 与 .app-header 相同 */
|
||||
color: #fff;
|
||||
border-top-left-radius: 10px;
|
||||
border-top-right-radius: 10px;
|
||||
}
|
||||
|
||||
/* 左侧占位以保证标题居中(与右侧关闭按钮宽度相当) */
|
||||
.modal-spacer {
|
||||
width: 32px;
|
||||
flex: 0 0 32px;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.modal-close-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-close-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.modal-form {
|
||||
/* 表单外层包裹 body + footer */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0; /* 允许子元素正确计算高度 */
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.25rem 1.5rem 1.5rem;
|
||||
overflow: auto; /* 仅内容区滚动 */
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
@@ -75,6 +133,18 @@
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 官方按钮橙色主题(Anthropic 风格) */
|
||||
.preset-btn.official {
|
||||
border: 1px solid #d97706;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.preset-btn.official:hover,
|
||||
.preset-btn.official.selected {
|
||||
background: #d97706;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
@@ -86,6 +156,18 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* API Key 输入框容器 - 预留空间避免抖动 */
|
||||
.form-group.api-key-group {
|
||||
min-height: 88px; /* 固定高度:label + input + 间距 */
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.form-group.api-key-group.hidden {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
@@ -109,11 +191,14 @@
|
||||
border-color: #3498db;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
.modal-footer {
|
||||
/* 固定在弹窗底部(非滚动区) */
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 2rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-top: 1px solid #ecf0f1;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.cancel-btn,
|
||||
@@ -180,4 +265,4 @@
|
||||
margin: 2px;
|
||||
cursor: pointer;
|
||||
transform: translateY(2px);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,22 @@
|
||||
import React from "react";
|
||||
import { Provider } from "../../shared/types";
|
||||
import { Provider } from "../types";
|
||||
import { AppType } from "../lib/tauri-api";
|
||||
import ProviderForm from "./ProviderForm";
|
||||
|
||||
interface AddProviderModalProps {
|
||||
appType: AppType;
|
||||
onAdd: (provider: Omit<Provider, "id">) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const AddProviderModal: React.FC<AddProviderModalProps> = ({
|
||||
appType,
|
||||
onAdd,
|
||||
onClose,
|
||||
}) => {
|
||||
return (
|
||||
<ProviderForm
|
||||
appType={appType}
|
||||
title="添加新供应商"
|
||||
submitText="添加"
|
||||
showPresets={true}
|
||||
66
src/components/AppSwitcher.css
Normal file
@@ -0,0 +1,66 @@
|
||||
/* 药丸式切换按钮 */
|
||||
.switcher-pills {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
padding: 6px 8px;
|
||||
border-radius: 50px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.switcher-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 50px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 200ms ease;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.switcher-pill:hover:not(.active) {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.switcher-pill.active {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: white;
|
||||
box-shadow:
|
||||
inset 0 1px 3px rgba(0, 0, 0, 0.1),
|
||||
0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.pill-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
opacity: 0.4;
|
||||
transition: all 200ms ease;
|
||||
}
|
||||
|
||||
.switcher-pill.active .pill-dot {
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 8px currentColor;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.pills-divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.2); opacity: 0.8; }
|
||||
}
|
||||
36
src/components/AppSwitcher.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { AppType } from "../lib/tauri-api";
|
||||
import "./AppSwitcher.css";
|
||||
|
||||
interface AppSwitcherProps {
|
||||
activeApp: AppType;
|
||||
onSwitch: (app: AppType) => void;
|
||||
}
|
||||
|
||||
export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
|
||||
const handleSwitch = (app: AppType) => {
|
||||
if (app === activeApp) return;
|
||||
onSwitch(app);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="switcher-pills">
|
||||
<button
|
||||
type="button"
|
||||
className={`switcher-pill ${activeApp === "claude" ? "active" : ""}`}
|
||||
onClick={() => handleSwitch("claude")}
|
||||
>
|
||||
<span className="pill-dot" />
|
||||
<span>Claude Code</span>
|
||||
</button>
|
||||
<div className="pills-divider" />
|
||||
<button
|
||||
type="button"
|
||||
className={`switcher-pill ${activeApp === "codex" ? "active" : ""}`}
|
||||
onClick={() => handleSwitch("codex")}
|
||||
>
|
||||
<span className="pill-dot" />
|
||||
<span>Codex</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -68,7 +68,9 @@
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s, transform 0.1s;
|
||||
transition:
|
||||
background-color 0.2s,
|
||||
transform 0.1s;
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
@@ -102,4 +104,4 @@
|
||||
.confirm-btn:focus {
|
||||
outline: 2px solid #007bff;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import './ConfirmDialog.css';
|
||||
import React from "react";
|
||||
import "./ConfirmDialog.css";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean;
|
||||
@@ -15,10 +15,10 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
||||
isOpen,
|
||||
title,
|
||||
message,
|
||||
confirmText = '确定',
|
||||
cancelText = '取消',
|
||||
confirmText = "确定",
|
||||
cancelText = "取消",
|
||||
onConfirm,
|
||||
onCancel
|
||||
onCancel,
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
@@ -32,15 +32,15 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
<div className="confirm-actions">
|
||||
<button
|
||||
className="confirm-btn cancel-btn"
|
||||
<button
|
||||
className="confirm-btn cancel-btn"
|
||||
onClick={onCancel}
|
||||
autoFocus
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
className="confirm-btn confirm-btn-primary"
|
||||
<button
|
||||
className="confirm-btn confirm-btn-primary"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmText}
|
||||
@@ -49,4 +49,4 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
39
src/components/EditProviderModal.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from "react";
|
||||
import { Provider } from "../types";
|
||||
import { AppType } from "../lib/tauri-api";
|
||||
import ProviderForm from "./ProviderForm";
|
||||
|
||||
interface EditProviderModalProps {
|
||||
appType: AppType;
|
||||
provider: Provider;
|
||||
onSave: (provider: Provider) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const EditProviderModal: React.FC<EditProviderModalProps> = ({
|
||||
appType,
|
||||
provider,
|
||||
onSave,
|
||||
onClose,
|
||||
}) => {
|
||||
const handleSubmit = (data: Omit<Provider, "id">) => {
|
||||
onSave({
|
||||
...provider,
|
||||
...data,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ProviderForm
|
||||
appType={appType}
|
||||
title="编辑供应商"
|
||||
submitText="保存"
|
||||
initialData={provider}
|
||||
showPresets={false}
|
||||
onSubmit={handleSubmit}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditProviderModal;
|
||||
580
src/components/ProviderForm.tsx
Normal file
@@ -0,0 +1,580 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Provider } from "../types";
|
||||
import { AppType } from "../lib/tauri-api";
|
||||
import {
|
||||
updateCoAuthoredSetting,
|
||||
checkCoAuthoredSetting,
|
||||
extractWebsiteUrl,
|
||||
getApiKeyFromConfig,
|
||||
hasApiKeyField,
|
||||
setApiKeyInConfig,
|
||||
} from "../utils/providerConfigUtils";
|
||||
import { providerPresets } from "../config/providerPresets";
|
||||
import { codexProviderPresets } from "../config/codexProviderPresets";
|
||||
import "./AddProviderModal.css";
|
||||
|
||||
interface ProviderFormProps {
|
||||
appType?: AppType;
|
||||
title: string;
|
||||
submitText: string;
|
||||
initialData?: Provider;
|
||||
showPresets?: boolean;
|
||||
onSubmit: (data: Omit<Provider, "id">) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ProviderForm: React.FC<ProviderFormProps> = ({
|
||||
appType = "claude",
|
||||
title,
|
||||
submitText,
|
||||
initialData,
|
||||
showPresets = false,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}) => {
|
||||
// 对于 Codex,需要分离 auth 和 config
|
||||
const isCodex = appType === "codex";
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: initialData?.name || "",
|
||||
websiteUrl: initialData?.websiteUrl || "",
|
||||
settingsConfig: initialData
|
||||
? JSON.stringify(initialData.settingsConfig, null, 2)
|
||||
: "",
|
||||
});
|
||||
|
||||
// Codex 特有的状态
|
||||
const [codexAuth, setCodexAuth] = useState("");
|
||||
const [codexConfig, setCodexConfig] = useState("");
|
||||
const [codexApiKey, setCodexApiKey] = useState("");
|
||||
const [selectedCodexPreset, setSelectedCodexPreset] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// 初始化 Codex 配置
|
||||
useEffect(() => {
|
||||
if (isCodex && initialData) {
|
||||
const config = initialData.settingsConfig;
|
||||
if (typeof config === "object" && config !== null) {
|
||||
setCodexAuth(JSON.stringify(config.auth || {}, null, 2));
|
||||
setCodexConfig(config.config || "");
|
||||
try {
|
||||
const auth = config.auth || {};
|
||||
if (auth && typeof auth.OPENAI_API_KEY === "string") {
|
||||
setCodexApiKey(auth.OPENAI_API_KEY);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isCodex, initialData]);
|
||||
const [error, setError] = useState("");
|
||||
const [disableCoAuthored, setDisableCoAuthored] = useState(false);
|
||||
const [selectedPreset, setSelectedPreset] = useState<number | null>(null);
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
|
||||
// 初始化时检查禁用签名状态
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
const configString = JSON.stringify(initialData.settingsConfig, null, 2);
|
||||
const hasCoAuthoredDisabled = checkCoAuthoredSetting(configString);
|
||||
setDisableCoAuthored(hasCoAuthoredDisabled);
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (!formData.name) {
|
||||
setError("请填写供应商名称");
|
||||
return;
|
||||
}
|
||||
|
||||
let settingsConfig: Record<string, any>;
|
||||
|
||||
if (isCodex) {
|
||||
// Codex: 仅要求 auth.json 必填;config.toml 可为空
|
||||
if (!codexAuth.trim()) {
|
||||
setError("请填写 auth.json 配置");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const authJson = JSON.parse(codexAuth);
|
||||
|
||||
// 非官方预设强制要求 OPENAI_API_KEY
|
||||
if (selectedCodexPreset !== null) {
|
||||
const preset = codexProviderPresets[selectedCodexPreset];
|
||||
const isOfficial = Boolean(preset?.isOfficial);
|
||||
if (!isOfficial) {
|
||||
const key =
|
||||
typeof authJson.OPENAI_API_KEY === "string"
|
||||
? authJson.OPENAI_API_KEY.trim()
|
||||
: "";
|
||||
if (!key) {
|
||||
setError("请填写 OPENAI_API_KEY");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
settingsConfig = {
|
||||
auth: authJson,
|
||||
config: codexConfig ?? "",
|
||||
};
|
||||
} catch (err) {
|
||||
setError("auth.json 格式错误,请检查JSON语法");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Claude: 原有逻辑
|
||||
if (!formData.settingsConfig.trim()) {
|
||||
setError("请填写配置内容");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
settingsConfig = JSON.parse(formData.settingsConfig);
|
||||
} catch (err) {
|
||||
setError("配置JSON格式错误,请检查语法");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
onSubmit({
|
||||
name: formData.name,
|
||||
websiteUrl: formData.websiteUrl,
|
||||
settingsConfig,
|
||||
});
|
||||
};
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
) => {
|
||||
const { name, value } = e.target;
|
||||
|
||||
if (name === "settingsConfig") {
|
||||
// 当用户修改配置时,尝试自动提取官网地址
|
||||
const extractedWebsiteUrl = extractWebsiteUrl(value);
|
||||
|
||||
// 同时检查并同步选择框状态
|
||||
const hasCoAuthoredDisabled = checkCoAuthoredSetting(value);
|
||||
setDisableCoAuthored(hasCoAuthoredDisabled);
|
||||
|
||||
// 同步 API Key 输入框显示与值
|
||||
const parsedKey = getApiKeyFromConfig(value);
|
||||
setApiKey(parsedKey);
|
||||
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: value,
|
||||
// 只有在官网地址为空时才自动填入
|
||||
websiteUrl: formData.websiteUrl || extractedWebsiteUrl,
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 处理选择框变化
|
||||
const handleCoAuthoredToggle = (checked: boolean) => {
|
||||
setDisableCoAuthored(checked);
|
||||
|
||||
// 更新JSON配置
|
||||
const updatedConfig = updateCoAuthoredSetting(
|
||||
formData.settingsConfig,
|
||||
checked,
|
||||
);
|
||||
setFormData({
|
||||
...formData,
|
||||
settingsConfig: updatedConfig,
|
||||
});
|
||||
};
|
||||
|
||||
const applyPreset = (preset: (typeof providerPresets)[0], index: number) => {
|
||||
const configString = JSON.stringify(preset.settingsConfig, null, 2);
|
||||
|
||||
setFormData({
|
||||
name: preset.name,
|
||||
websiteUrl: preset.websiteUrl,
|
||||
settingsConfig: configString,
|
||||
});
|
||||
|
||||
// 设置选中的预设
|
||||
setSelectedPreset(index);
|
||||
|
||||
// 清空 API Key 输入框,让用户重新输入
|
||||
setApiKey("");
|
||||
|
||||
// 同步选择框状态
|
||||
const hasCoAuthoredDisabled = checkCoAuthoredSetting(configString);
|
||||
setDisableCoAuthored(hasCoAuthoredDisabled);
|
||||
};
|
||||
|
||||
// Codex: 应用预设
|
||||
const applyCodexPreset = (
|
||||
preset: (typeof codexProviderPresets)[0],
|
||||
index: number,
|
||||
) => {
|
||||
const authString = JSON.stringify(preset.auth || {}, null, 2);
|
||||
setCodexAuth(authString);
|
||||
setCodexConfig(preset.config || "");
|
||||
|
||||
setFormData({
|
||||
name: preset.name,
|
||||
websiteUrl: preset.websiteUrl,
|
||||
settingsConfig: formData.settingsConfig,
|
||||
});
|
||||
|
||||
setSelectedCodexPreset(index);
|
||||
|
||||
// 清空 API Key,让用户重新输入
|
||||
setCodexApiKey("");
|
||||
};
|
||||
|
||||
// 处理 API Key 输入并自动更新配置
|
||||
const handleApiKeyChange = (key: string) => {
|
||||
setApiKey(key);
|
||||
|
||||
const configString = setApiKeyInConfig(
|
||||
formData.settingsConfig,
|
||||
key.trim(),
|
||||
{ createIfMissing: selectedPreset !== null },
|
||||
);
|
||||
|
||||
// 更新表单配置
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
settingsConfig: configString,
|
||||
}));
|
||||
|
||||
// 同步选择框状态
|
||||
const hasCoAuthoredDisabled = checkCoAuthoredSetting(configString);
|
||||
setDisableCoAuthored(hasCoAuthoredDisabled);
|
||||
};
|
||||
|
||||
// Codex: 处理 API Key 输入并写回 auth.json
|
||||
const handleCodexApiKeyChange = (key: string) => {
|
||||
setCodexApiKey(key);
|
||||
try {
|
||||
const auth = JSON.parse(codexAuth || "{}");
|
||||
auth.OPENAI_API_KEY = key.trim();
|
||||
setCodexAuth(JSON.stringify(auth, null, 2));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
// 根据当前配置决定是否展示 API Key 输入框
|
||||
const showApiKey =
|
||||
selectedPreset !== null || hasApiKeyField(formData.settingsConfig);
|
||||
|
||||
// 判断当前选中的预设是否是官方
|
||||
const isOfficialPreset =
|
||||
selectedPreset !== null &&
|
||||
providerPresets[selectedPreset]?.isOfficial === true;
|
||||
|
||||
// Codex: 控制显示 API Key 与官方标记
|
||||
const getCodexAuthApiKey = (authString: string): string => {
|
||||
try {
|
||||
const auth = JSON.parse(authString || "{}");
|
||||
return typeof auth.OPENAI_API_KEY === "string" ? auth.OPENAI_API_KEY : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
const showCodexApiKey =
|
||||
selectedCodexPreset !== null || getCodexAuthApiKey(codexAuth) !== "";
|
||||
const isCodexOfficialPreset =
|
||||
selectedCodexPreset !== null &&
|
||||
codexProviderPresets[selectedCodexPreset]?.isOfficial === true;
|
||||
|
||||
// 初始时从配置中同步 API Key(编辑模式)
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
const parsedKey = getApiKeyFromConfig(
|
||||
JSON.stringify(initialData.settingsConfig),
|
||||
);
|
||||
if (parsedKey) setApiKey(parsedKey);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 支持按下 ESC 关闭弹窗
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="modal-content">
|
||||
<div className="modal-titlebar">
|
||||
<div className="modal-spacer" />
|
||||
<div className="modal-title" title={title}>
|
||||
{title}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="modal-close-btn"
|
||||
aria-label="关闭"
|
||||
onClick={onClose}
|
||||
title="关闭"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="modal-form">
|
||||
<div className="modal-body">
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
{showPresets && !isCodex && (
|
||||
<div className="presets">
|
||||
<label>一键导入(只需要填写 key)</label>
|
||||
<div className="preset-buttons">
|
||||
{providerPresets.map((preset, index) => {
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
className={`preset-btn ${
|
||||
selectedPreset === index ? "selected" : ""
|
||||
} ${preset.isOfficial ? "official" : ""}`}
|
||||
onClick={() => applyPreset(preset, index)}
|
||||
>
|
||||
{preset.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPresets && isCodex && (
|
||||
<div className="presets">
|
||||
<label>一键导入(只需要填写 key)</label>
|
||||
<div className="preset-buttons">
|
||||
{codexProviderPresets.map((preset, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
className={`preset-btn ${
|
||||
selectedCodexPreset === index ? "selected" : ""
|
||||
} ${preset.isOfficial ? "official" : ""}`}
|
||||
onClick={() => applyCodexPreset(preset, index)}
|
||||
>
|
||||
{preset.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="name">供应商名称 *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
placeholder="例如:Anthropic 官方"
|
||||
required
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isCodex && (
|
||||
<div
|
||||
className={`form-group api-key-group ${!showApiKey ? "hidden" : ""}`}
|
||||
>
|
||||
<label htmlFor="apiKey">API Key *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="apiKey"
|
||||
value={apiKey}
|
||||
onChange={(e) => handleApiKeyChange(e.target.value)}
|
||||
placeholder={
|
||||
isOfficialPreset
|
||||
? "官方登录无需填写 API Key,直接保存即可"
|
||||
: "只需要填这里,下方配置会自动填充"
|
||||
}
|
||||
disabled={isOfficialPreset}
|
||||
autoComplete="off"
|
||||
style={
|
||||
isOfficialPreset
|
||||
? {
|
||||
backgroundColor: "#f5f5f5",
|
||||
cursor: "not-allowed",
|
||||
color: "#999",
|
||||
}
|
||||
: {}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCodex && (
|
||||
<div
|
||||
className={`form-group api-key-group ${!showCodexApiKey ? "hidden" : ""}`}
|
||||
>
|
||||
<label htmlFor="codexApiKey">API Key *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="codexApiKey"
|
||||
value={codexApiKey}
|
||||
onChange={(e) => handleCodexApiKeyChange(e.target.value)}
|
||||
placeholder={
|
||||
isCodexOfficialPreset
|
||||
? "官方无需填写 API Key,直接保存即可"
|
||||
: "只需要填这里,下方 auth.json 会自动填充"
|
||||
}
|
||||
disabled={isCodexOfficialPreset}
|
||||
required={
|
||||
selectedCodexPreset !== null && !isCodexOfficialPreset
|
||||
}
|
||||
autoComplete="off"
|
||||
style={
|
||||
isCodexOfficialPreset
|
||||
? {
|
||||
backgroundColor: "#f5f5f5",
|
||||
cursor: "not-allowed",
|
||||
color: "#999",
|
||||
}
|
||||
: {}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="websiteUrl">官网地址</label>
|
||||
<input
|
||||
type="url"
|
||||
id="websiteUrl"
|
||||
name="websiteUrl"
|
||||
value={formData.websiteUrl}
|
||||
onChange={handleChange}
|
||||
placeholder="https://example.com(可选)"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Claude 或 Codex 的配置部分 */}
|
||||
{isCodex ? (
|
||||
// Codex: 双编辑器
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label htmlFor="codexAuth">auth.json (JSON) *</label>
|
||||
<textarea
|
||||
id="codexAuth"
|
||||
value={codexAuth}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setCodexAuth(value);
|
||||
try {
|
||||
const auth = JSON.parse(value || "{}");
|
||||
const key =
|
||||
typeof auth.OPENAI_API_KEY === "string"
|
||||
? auth.OPENAI_API_KEY
|
||||
: "";
|
||||
setCodexApiKey(key);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}}
|
||||
placeholder={`{
|
||||
"OPENAI_API_KEY": "sk-your-api-key-here"
|
||||
}`}
|
||||
rows={6}
|
||||
style={{ fontFamily: "monospace", fontSize: "14px" }}
|
||||
required
|
||||
/>
|
||||
<small className="field-hint">Codex auth.json 配置内容</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="codexConfig">config.toml (TOML)</label>
|
||||
<textarea
|
||||
id="codexConfig"
|
||||
value={codexConfig}
|
||||
onChange={(e) => setCodexConfig(e.target.value)}
|
||||
placeholder={``}
|
||||
rows={8}
|
||||
style={{ fontFamily: "monospace", fontSize: "14px" }}
|
||||
/>
|
||||
<small className="field-hint">
|
||||
Codex config.toml 配置内容(可留空)
|
||||
</small>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
// Claude: 原有的单编辑器
|
||||
<div className="form-group">
|
||||
<div className="label-with-checkbox">
|
||||
<label htmlFor="settingsConfig">
|
||||
Claude Code 配置 (JSON) *
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={disableCoAuthored}
|
||||
onChange={(e) => handleCoAuthoredToggle(e.target.checked)}
|
||||
/>
|
||||
禁止 Claude Code 签名
|
||||
</label>
|
||||
</div>
|
||||
<textarea
|
||||
id="settingsConfig"
|
||||
name="settingsConfig"
|
||||
value={formData.settingsConfig}
|
||||
onChange={handleChange}
|
||||
placeholder={`{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-your-api-key-here"
|
||||
}
|
||||
}`}
|
||||
rows={12}
|
||||
style={{ fontFamily: "monospace", fontSize: "14px" }}
|
||||
required
|
||||
/>
|
||||
<small className="field-hint">
|
||||
完整的 Claude Code settings.json 配置内容
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="cancel-btn" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" className="submit-btn">
|
||||
{submitText}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProviderForm;
|
||||
@@ -203,4 +203,4 @@
|
||||
.delete-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import React from 'react'
|
||||
import { Provider } from '../../shared/types'
|
||||
import './ProviderList.css'
|
||||
import React from "react";
|
||||
import { Provider } from "../types";
|
||||
import "./ProviderList.css";
|
||||
|
||||
interface ProviderListProps {
|
||||
providers: Record<string, Provider>
|
||||
currentProviderId: string
|
||||
onSwitch: (id: string) => void
|
||||
onDelete: (id: string) => void
|
||||
onEdit: (id: string) => void
|
||||
providers: Record<string, Provider>;
|
||||
currentProviderId: string;
|
||||
onSwitch: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onEdit: (id: string) => void;
|
||||
}
|
||||
|
||||
const ProviderList: React.FC<ProviderListProps> = ({
|
||||
@@ -15,28 +15,28 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
||||
currentProviderId,
|
||||
onSwitch,
|
||||
onDelete,
|
||||
onEdit
|
||||
onEdit,
|
||||
}) => {
|
||||
// 提取API地址
|
||||
const getApiUrl = (provider: Provider): string => {
|
||||
try {
|
||||
const config = provider.settingsConfig
|
||||
const config = provider.settingsConfig;
|
||||
if (config?.env?.ANTHROPIC_BASE_URL) {
|
||||
return config.env.ANTHROPIC_BASE_URL
|
||||
return config.env.ANTHROPIC_BASE_URL;
|
||||
}
|
||||
return '未设置'
|
||||
return "未设置";
|
||||
} catch {
|
||||
return '配置错误'
|
||||
return "配置错误";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleUrlClick = async (url: string) => {
|
||||
try {
|
||||
await window.electronAPI.openExternal(url)
|
||||
await window.api.openExternal(url);
|
||||
} catch (error) {
|
||||
console.error('打开链接失败:', error)
|
||||
console.error("打开链接失败:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="provider-list">
|
||||
@@ -48,25 +48,27 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
||||
) : (
|
||||
<div className="provider-items">
|
||||
{Object.values(providers).map((provider) => {
|
||||
const isCurrent = provider.id === currentProviderId
|
||||
|
||||
const isCurrent = provider.id === currentProviderId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={provider.id}
|
||||
className={`provider-item ${isCurrent ? 'current' : ''}`}
|
||||
<div
|
||||
key={provider.id}
|
||||
className={`provider-item ${isCurrent ? "current" : ""}`}
|
||||
>
|
||||
<div className="provider-info">
|
||||
<div className="provider-name">
|
||||
<span>{provider.name}</span>
|
||||
{isCurrent && <span className="current-badge">当前使用</span>}
|
||||
{isCurrent && (
|
||||
<span className="current-badge">当前使用</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="provider-url">
|
||||
{provider.websiteUrl ? (
|
||||
<a
|
||||
href="#"
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleUrlClick(provider.websiteUrl!)
|
||||
e.preventDefault();
|
||||
handleUrlClick(provider.websiteUrl!);
|
||||
}}
|
||||
className="url-link"
|
||||
title={`访问 ${provider.websiteUrl}`}
|
||||
@@ -80,23 +82,23 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="provider-actions">
|
||||
<button
|
||||
<button
|
||||
className="enable-btn"
|
||||
onClick={() => onSwitch(provider.id)}
|
||||
disabled={isCurrent}
|
||||
>
|
||||
启用
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
className="edit-btn"
|
||||
onClick={() => onEdit(provider.id)}
|
||||
disabled={isCurrent}
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
className="delete-btn"
|
||||
onClick={() => onDelete(provider.id)}
|
||||
disabled={isCurrent}
|
||||
@@ -105,12 +107,12 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default ProviderList
|
||||
export default ProviderList;
|
||||
40
src/config/codexProviderPresets.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Codex 预设供应商配置模板
|
||||
*/
|
||||
export interface CodexProviderPreset {
|
||||
name: string;
|
||||
websiteUrl: string;
|
||||
auth: Record<string, any>; // 将写入 ~/.codex/auth.json
|
||||
config: string; // 将写入 ~/.codex/config.toml(TOML 字符串)
|
||||
isOfficial?: boolean; // 标识是否为官方预设
|
||||
}
|
||||
|
||||
export const codexProviderPresets: CodexProviderPreset[] = [
|
||||
{
|
||||
name: "Codex官方",
|
||||
websiteUrl: "https://chatgpt.com/codex",
|
||||
isOfficial: true,
|
||||
// 官方的 key 为null
|
||||
auth: {
|
||||
OPENAI_API_KEY: null,
|
||||
},
|
||||
config: ``,
|
||||
},
|
||||
{
|
||||
name: "PackyCode",
|
||||
websiteUrl: "https://codex.packycode.com/",
|
||||
// PackyCode 一般通过 API Key;请将占位符替换为你的实际 key
|
||||
auth: {
|
||||
OPENAI_API_KEY: "sk-your-api-key-here",
|
||||
},
|
||||
config: `model_provider = "packycode"
|
||||
model = "gpt-5"
|
||||
model_reasoning_effort = "high"
|
||||
|
||||
[model_providers.packycode]
|
||||
name = "packycode"
|
||||
base_url = "https://codex-api.packycode.com/v1"
|
||||
wire_api = "responses"
|
||||
env_key = "packycode"`,
|
||||
},
|
||||
];
|
||||
@@ -5,12 +5,21 @@ export interface ProviderPreset {
|
||||
name: string;
|
||||
websiteUrl: string;
|
||||
settingsConfig: object;
|
||||
isOfficial?: boolean; // 标识是否为官方预设
|
||||
}
|
||||
|
||||
export const providerPresets: ProviderPreset[] = [
|
||||
{
|
||||
name: "Claude官方登录",
|
||||
websiteUrl: "https://www.anthropic.com/claude-code",
|
||||
settingsConfig: {
|
||||
env: {},
|
||||
},
|
||||
isOfficial: true, // 明确标识为官方预设
|
||||
},
|
||||
{
|
||||
name: "DeepSeek v3.1",
|
||||
websiteUrl: "https://platform.deepseek.com/",
|
||||
websiteUrl: "https://platform.deepseek.com",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.deepseek.com/anthropic",
|
||||
@@ -41,6 +50,18 @@ export const providerPresets: ProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Kimi k2",
|
||||
websiteUrl: "https://platform.moonshot.cn/console",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.moonshot.cn/anthropic",
|
||||
ANTHROPIC_AUTH_TOKEN: "sk-your-api-key-here",
|
||||
ANTHROPIC_MODEL: "kimi-k2-turbo-preview",
|
||||
ANTHROPIC_SMALL_FAST_MODEL: "kimi-k2-turbo-preview",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "PackyCode",
|
||||
websiteUrl: "https://www.packycode.com",
|
||||
@@ -51,14 +72,4 @@ export const providerPresets: ProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "AnyRouter",
|
||||
websiteUrl: "https://anyrouter.top",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://anyrouter.top",
|
||||
ANTHROPIC_AUTH_TOKEN: "sk-your-api-key-here",
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -5,9 +5,9 @@
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu",
|
||||
"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: #f5f5f5;
|
||||
186
src/lib/tauri-api.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { Provider } from "../types";
|
||||
|
||||
// 应用类型
|
||||
export type AppType = "claude" | "codex";
|
||||
|
||||
// 定义配置状态类型
|
||||
interface ConfigStatus {
|
||||
exists: boolean;
|
||||
path: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// 定义导入结果类型
|
||||
interface ImportResult {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// Tauri API 封装,提供统一的全局 API 接口
|
||||
export const tauriAPI = {
|
||||
// 获取所有供应商
|
||||
getProviders: async (app?: AppType): Promise<Record<string, Provider>> => {
|
||||
try {
|
||||
return await invoke("get_providers", { app_type: app, app });
|
||||
} catch (error) {
|
||||
console.error("获取供应商列表失败:", error);
|
||||
return {};
|
||||
}
|
||||
},
|
||||
|
||||
// 获取当前供应商ID
|
||||
getCurrentProvider: async (app?: AppType): Promise<string> => {
|
||||
try {
|
||||
return await invoke("get_current_provider", { app_type: app, app });
|
||||
} catch (error) {
|
||||
console.error("获取当前供应商失败:", error);
|
||||
return "";
|
||||
}
|
||||
},
|
||||
|
||||
// 添加供应商
|
||||
addProvider: async (provider: Provider, app?: AppType): Promise<boolean> => {
|
||||
try {
|
||||
return await invoke("add_provider", { provider, app_type: app, app });
|
||||
} catch (error) {
|
||||
console.error("添加供应商失败:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 更新供应商
|
||||
updateProvider: async (
|
||||
provider: Provider,
|
||||
app?: AppType,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
return await invoke("update_provider", { provider, app_type: app, app });
|
||||
} catch (error) {
|
||||
console.error("更新供应商失败:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 删除供应商
|
||||
deleteProvider: async (id: string, app?: AppType): Promise<boolean> => {
|
||||
try {
|
||||
return await invoke("delete_provider", { id, app_type: app, app });
|
||||
} catch (error) {
|
||||
console.error("删除供应商失败:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 切换供应商
|
||||
switchProvider: async (
|
||||
providerId: string,
|
||||
app?: AppType,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
return await invoke("switch_provider", {
|
||||
id: providerId,
|
||||
app_type: app,
|
||||
app,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("切换供应商失败:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// 导入当前配置为默认供应商
|
||||
importCurrentConfigAsDefault: async (
|
||||
app?: AppType,
|
||||
): Promise<ImportResult> => {
|
||||
try {
|
||||
const success = await invoke<boolean>("import_default_config", {
|
||||
app_type: app,
|
||||
app,
|
||||
});
|
||||
return {
|
||||
success,
|
||||
message: success ? "成功导入默认配置" : "导入失败",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("导入默认配置失败:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: String(error),
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
// 获取 Claude Code 配置文件路径
|
||||
getClaudeCodeConfigPath: async (): Promise<string> => {
|
||||
try {
|
||||
return await invoke("get_claude_code_config_path");
|
||||
} catch (error) {
|
||||
console.error("获取配置路径失败:", error);
|
||||
return "";
|
||||
}
|
||||
},
|
||||
|
||||
// 获取 Claude Code 配置状态
|
||||
getClaudeConfigStatus: async (): Promise<ConfigStatus> => {
|
||||
try {
|
||||
return await invoke("get_claude_config_status");
|
||||
} catch (error) {
|
||||
console.error("获取配置状态失败:", error);
|
||||
return {
|
||||
exists: false,
|
||||
path: "",
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
// 获取应用配置状态(通用)
|
||||
getConfigStatus: async (app?: AppType): Promise<ConfigStatus> => {
|
||||
try {
|
||||
return await invoke("get_config_status", { app_type: app, app });
|
||||
} catch (error) {
|
||||
console.error("获取配置状态失败:", error);
|
||||
return {
|
||||
exists: false,
|
||||
path: "",
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
// 打开配置文件夹
|
||||
openConfigFolder: async (app?: AppType): Promise<void> => {
|
||||
try {
|
||||
await invoke("open_config_folder", { app_type: app, app });
|
||||
} catch (error) {
|
||||
console.error("打开配置文件夹失败:", error);
|
||||
}
|
||||
},
|
||||
|
||||
// 打开外部链接
|
||||
openExternal: async (url: string): Promise<void> => {
|
||||
try {
|
||||
await invoke("open_external", { url });
|
||||
} catch (error) {
|
||||
console.error("打开外部链接失败:", error);
|
||||
}
|
||||
},
|
||||
|
||||
// (保留空位,取消迁移提示)
|
||||
|
||||
// 选择配置文件(Tauri 暂不实现,保留接口兼容性)
|
||||
selectConfigFile: async (): Promise<string | null> => {
|
||||
console.warn("selectConfigFile 在 Tauri 版本中暂不支持");
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
// 创建全局 API 对象,兼容现有代码
|
||||
if (typeof window !== "undefined") {
|
||||
// 绑定到 window.api,避免 Electron 命名造成误解
|
||||
// API 内部已做 try/catch,非 Tauri 环境下也会安全返回默认值
|
||||
(window as any).api = tauriAPI;
|
||||
}
|
||||
|
||||
export default tauriAPI;
|
||||
24
src/main.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
// 导入 Tauri API(自动绑定到 window.api)
|
||||
import "./lib/tauri-api";
|
||||
|
||||
// 根据平台添加 body class,便于平台特定样式
|
||||
try {
|
||||
const ua = navigator.userAgent || "";
|
||||
const plat = (navigator.platform || "").toLowerCase();
|
||||
const isMac = /mac/i.test(ua) || plat.includes("mac");
|
||||
if (isMac) {
|
||||
document.body.classList.add("is-mac");
|
||||
}
|
||||
} catch {
|
||||
// 忽略平台检测失败
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -1,277 +0,0 @@
|
||||
import { app, BrowserWindow, ipcMain, dialog, shell } from "electron";
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
import { Provider } from "../shared/types";
|
||||
import {
|
||||
switchProvider,
|
||||
getClaudeCodeConfig,
|
||||
saveProviderConfig,
|
||||
deleteProviderConfig,
|
||||
sanitizeProviderName,
|
||||
importCurrentConfigAsDefault,
|
||||
getProviderConfigPath,
|
||||
fileExists,
|
||||
} from "./services";
|
||||
import { store } from "./store";
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
const isMac = process.platform === "darwin";
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 800,
|
||||
height: 600,
|
||||
minWidth: 600,
|
||||
minHeight: 400,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "../main/preload.js"),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
// 使用 macOS 隐藏式标题栏,仅在 macOS 启用
|
||||
...(isMac ? { titleBarStyle: "hiddenInset" as const } : {}),
|
||||
autoHideMenuBar: true,
|
||||
});
|
||||
|
||||
if (app.isPackaged) {
|
||||
mainWindow.loadFile(path.join(__dirname, "../renderer/index.html"));
|
||||
} else {
|
||||
mainWindow.loadURL("http://localhost:3000");
|
||||
mainWindow.webContents.openDevTools();
|
||||
}
|
||||
|
||||
mainWindow.on("closed", () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
createWindow();
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
// IPC handlers
|
||||
ipcMain.handle("getProviders", async () => {
|
||||
return await store.get("providers", {} as Record<string, Provider>);
|
||||
});
|
||||
|
||||
ipcMain.handle("getCurrentProvider", async () => {
|
||||
return await store.get("current", "");
|
||||
});
|
||||
|
||||
ipcMain.handle("addProvider", async (_, provider: Provider) => {
|
||||
try {
|
||||
// 1. 保存供应商配置到独立文件
|
||||
const saveSuccess = await saveProviderConfig(provider);
|
||||
if (!saveSuccess) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 更新应用配置
|
||||
const providers = await store.get("providers", {} as Record<string, Provider>);
|
||||
providers[provider.id] = provider;
|
||||
await store.set("providers", providers);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("添加供应商失败:", error);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("deleteProvider", async (_, id: string) => {
|
||||
try {
|
||||
const providers = await store.get("providers", {} as Record<string, Provider>);
|
||||
const provider = providers[id];
|
||||
|
||||
// 1. 删除供应商配置文件
|
||||
const deleteSuccess = await deleteProviderConfig(id, provider?.name);
|
||||
if (!deleteSuccess) {
|
||||
console.error("删除供应商配置文件失败");
|
||||
// 仍然继续删除应用配置,避免配置不同步
|
||||
}
|
||||
|
||||
// 2. 更新应用配置
|
||||
delete providers[id];
|
||||
await store.set("providers", providers);
|
||||
|
||||
// 3. 如果删除的是当前供应商,清空当前选择
|
||||
const currentProviderId = await store.get("current", "");
|
||||
if (currentProviderId === id) {
|
||||
await store.set("current", "");
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("删除供应商失败:", error);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("updateProvider", async (_, provider: Provider) => {
|
||||
try {
|
||||
const providers = await store.get("providers", {} as Record<string, Provider>);
|
||||
const currentProviderId = await store.get("current", "");
|
||||
const oldProvider = providers[provider.id];
|
||||
|
||||
// 1. 如果名字发生变化,需要重命名配置文件
|
||||
if (oldProvider && oldProvider.name !== provider.name) {
|
||||
const oldConfigPath = getProviderConfigPath(
|
||||
provider.id,
|
||||
oldProvider.name
|
||||
);
|
||||
const newConfigPath = getProviderConfigPath(provider.id, provider.name);
|
||||
|
||||
// 如果旧配置文件存在且路径不同,需要重命名
|
||||
if (
|
||||
(await fileExists(oldConfigPath)) &&
|
||||
oldConfigPath !== newConfigPath
|
||||
) {
|
||||
// 如果新路径已存在文件,先删除避免冲突
|
||||
if (await fileExists(newConfigPath)) {
|
||||
await fs.unlink(newConfigPath);
|
||||
}
|
||||
await fs.rename(oldConfigPath, newConfigPath);
|
||||
console.log(
|
||||
`已重命名配置文件: ${oldProvider.name} -> ${provider.name}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 保存更新后的配置到文件
|
||||
const saveSuccess = await saveProviderConfig(provider);
|
||||
if (!saveSuccess) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 更新应用配置
|
||||
providers[provider.id] = provider;
|
||||
await store.set("providers", providers);
|
||||
|
||||
// 4. 如果编辑的是当前激活的供应商,需要重新切换以应用更改
|
||||
if (provider.id === currentProviderId) {
|
||||
const switchSuccess = await switchProvider(
|
||||
provider,
|
||||
currentProviderId,
|
||||
providers
|
||||
);
|
||||
if (!switchSuccess) {
|
||||
console.error("更新当前供应商的Claude Code配置失败");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("更新供应商失败:", error);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("switchProvider", async (_, providerId: string) => {
|
||||
try {
|
||||
const providers = await store.get("providers", {} as Record<string, Provider>);
|
||||
const provider = providers[providerId];
|
||||
const currentProviderId = await store.get("current", "");
|
||||
|
||||
if (!provider) {
|
||||
console.error(`供应商不存在: ${providerId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 执行切换
|
||||
const success = await switchProvider(
|
||||
provider,
|
||||
currentProviderId,
|
||||
providers
|
||||
);
|
||||
if (success) {
|
||||
await store.set("current", providerId);
|
||||
console.log(`成功切换到供应商: ${provider.name}`);
|
||||
}
|
||||
|
||||
return success;
|
||||
} catch (error) {
|
||||
console.error("切换供应商失败:", error);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("importCurrentConfigAsDefault", async () => {
|
||||
try {
|
||||
const result = await importCurrentConfigAsDefault();
|
||||
|
||||
if (result.success && result.provider) {
|
||||
// 将默认供应商添加到store中
|
||||
const providers = await store.get("providers", {} as Record<string, Provider>);
|
||||
providers[result.provider.id] = result.provider;
|
||||
await store.set("providers", providers);
|
||||
|
||||
// 设置为当前选中的供应商
|
||||
await store.set("current", result.provider.id);
|
||||
|
||||
return { success: true, providerId: result.provider.id };
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("导入默认配置失败:", error);
|
||||
return { success: false };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("getClaudeCodeConfigPath", () => {
|
||||
return getClaudeCodeConfig().path;
|
||||
});
|
||||
|
||||
ipcMain.handle("selectConfigFile", async () => {
|
||||
if (!mainWindow) return null;
|
||||
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
properties: ["openFile"],
|
||||
title: "选择 Claude Code 配置文件",
|
||||
filters: [
|
||||
{ name: "JSON 文件", extensions: ["json"] },
|
||||
{ name: "所有文件", extensions: ["*"] },
|
||||
],
|
||||
defaultPath: "settings.json",
|
||||
});
|
||||
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.filePaths[0];
|
||||
});
|
||||
|
||||
ipcMain.handle("openConfigFolder", async () => {
|
||||
try {
|
||||
const { dir } = getClaudeCodeConfig();
|
||||
await shell.openPath(dir);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("打开配置文件夹失败:", error);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("openExternal", async (_, url: string) => {
|
||||
try {
|
||||
await shell.openExternal(url);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("打开外部链接失败:", error);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import { Provider } from '../shared/types'
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getProviders: () => ipcRenderer.invoke('getProviders'),
|
||||
getCurrentProvider: () => ipcRenderer.invoke('getCurrentProvider'),
|
||||
addProvider: (provider: Provider) => ipcRenderer.invoke('addProvider', provider),
|
||||
deleteProvider: (id: string) => ipcRenderer.invoke('deleteProvider', id),
|
||||
updateProvider: (provider: Provider) => ipcRenderer.invoke('updateProvider', provider),
|
||||
switchProvider: (providerId: string) => ipcRenderer.invoke('switchProvider', providerId),
|
||||
importCurrentConfigAsDefault: () => ipcRenderer.invoke('importCurrentConfigAsDefault'),
|
||||
getClaudeCodeConfigPath: () => ipcRenderer.invoke('getClaudeCodeConfigPath'),
|
||||
selectConfigFile: () => ipcRenderer.invoke('selectConfigFile'),
|
||||
openConfigFolder: () => ipcRenderer.invoke('openConfigFolder'),
|
||||
openExternal: (url: string) => ipcRenderer.invoke('openExternal', url)
|
||||
})
|
||||
|
||||
// 暴露平台信息给渲染进程,用于平台特定样式控制
|
||||
contextBridge.exposeInMainWorld('platform', {
|
||||
isMac: process.platform === 'darwin'
|
||||
})
|
||||
@@ -1,181 +0,0 @@
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import { Provider } from "../shared/types";
|
||||
|
||||
/**
|
||||
* 清理供应商名称,确保文件名安全
|
||||
*/
|
||||
export function sanitizeProviderName(name: string): string {
|
||||
return name.replace(/[<>:"/\\|?*]/g, "-").toLowerCase();
|
||||
}
|
||||
|
||||
export function getClaudeCodeConfig() {
|
||||
// Claude Code 配置文件路径
|
||||
const configDir = path.join(os.homedir(), ".claude");
|
||||
const configPath = path.join(configDir, "settings.json");
|
||||
|
||||
return { path: configPath, dir: configDir };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取供应商配置文件路径(基于供应商名称)
|
||||
*/
|
||||
export function getProviderConfigPath(
|
||||
providerId: string,
|
||||
providerName?: string
|
||||
): string {
|
||||
const { dir } = getClaudeCodeConfig();
|
||||
|
||||
// 如果提供了名称,使用名称;否则使用ID(向后兼容)
|
||||
const baseName = providerName
|
||||
? sanitizeProviderName(providerName)
|
||||
: sanitizeProviderName(providerId);
|
||||
return path.join(dir, `settings-${baseName}.json`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存供应商配置到独立文件
|
||||
*/
|
||||
export async function saveProviderConfig(provider: Provider): Promise<boolean> {
|
||||
try {
|
||||
const { dir } = getClaudeCodeConfig();
|
||||
const providerConfigPath = getProviderConfigPath(
|
||||
provider.id,
|
||||
provider.name
|
||||
);
|
||||
|
||||
// 确保目录存在
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
|
||||
// 保存配置到供应商专用文件
|
||||
await fs.writeFile(
|
||||
providerConfigPath,
|
||||
JSON.stringify(provider.settingsConfig, null, 2),
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("保存供应商配置失败:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查文件是否存在
|
||||
*/
|
||||
export async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换供应商配置(基于文件复制)
|
||||
*/
|
||||
export async function switchProvider(
|
||||
provider: Provider,
|
||||
currentProviderId?: string,
|
||||
providers?: Record<string, Provider>
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const { path: settingsPath, dir: configDir } = getClaudeCodeConfig();
|
||||
|
||||
// 确保目录存在
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
|
||||
const newSettingsPath = getProviderConfigPath(provider.id, provider.name);
|
||||
|
||||
// 检查目标配置文件是否存在
|
||||
if (!(await fileExists(newSettingsPath))) {
|
||||
console.error(`供应商配置文件不存在: ${newSettingsPath}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 1. 如果当前存在settings.json,先备份到当前供应商的配置文件
|
||||
if (await fileExists(settingsPath)) {
|
||||
if (currentProviderId && providers && providers[currentProviderId]) {
|
||||
const currentProvider = providers[currentProviderId];
|
||||
const currentProviderPath = getProviderConfigPath(
|
||||
currentProviderId,
|
||||
currentProvider.name
|
||||
);
|
||||
// 使用复制而不是重命名,避免删除原文件
|
||||
await fs.copyFile(settingsPath, currentProviderPath);
|
||||
console.log(`已备份当前供应商配置: ${currentProvider.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 复制新配置到settings.json(保留原文件)
|
||||
await fs.copyFile(newSettingsPath, settingsPath);
|
||||
|
||||
console.log(`成功切换到供应商: ${provider.name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("切换供应商失败:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入当前配置为默认供应商
|
||||
*/
|
||||
export async function importCurrentConfigAsDefault(): Promise<{ success: boolean; provider?: Provider }> {
|
||||
try {
|
||||
const { path: settingsPath } = getClaudeCodeConfig();
|
||||
|
||||
// 检查当前配置是否存在
|
||||
if (!(await fileExists(settingsPath))) {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
// 读取当前配置
|
||||
const configContent = await fs.readFile(settingsPath, "utf-8");
|
||||
const settingsConfig = JSON.parse(configContent);
|
||||
|
||||
// 创建默认供应商对象
|
||||
const provider: Provider = {
|
||||
id: "default",
|
||||
name: "default",
|
||||
settingsConfig: settingsConfig,
|
||||
};
|
||||
|
||||
// 保存默认供应商的配置到独立文件
|
||||
const saveSuccess = await saveProviderConfig(provider);
|
||||
if (!saveSuccess) {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
console.log(`已导入当前配置为默认供应商,配置文件:settings-default.json`);
|
||||
return { success: true, provider };
|
||||
} catch (error) {
|
||||
console.error("导入默认配置失败:", error);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除供应商配置文件
|
||||
*/
|
||||
export async function deleteProviderConfig(
|
||||
providerId: string,
|
||||
providerName?: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const providerConfigPath = getProviderConfigPath(providerId, providerName);
|
||||
|
||||
if (await fileExists(providerConfigPath)) {
|
||||
await fs.unlink(providerConfigPath);
|
||||
console.log(`已删除供应商配置文件: ${providerConfigPath}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("删除供应商配置失败:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
import { AppConfig } from '../shared/types'
|
||||
|
||||
export class SimpleStore {
|
||||
private configPath: string
|
||||
private configDir: string
|
||||
private data: AppConfig = { providers: {}, current: '' }
|
||||
private initPromise: Promise<void>
|
||||
|
||||
constructor() {
|
||||
this.configDir = path.join(os.homedir(), '.cc-switch')
|
||||
this.configPath = path.join(this.configDir, 'config.json')
|
||||
// 立即开始加载,但不阻塞构造函数
|
||||
this.initPromise = this.loadData()
|
||||
}
|
||||
|
||||
private async loadData(): Promise<void> {
|
||||
try {
|
||||
const content = await fs.readFile(this.configPath, 'utf-8')
|
||||
this.data = JSON.parse(content)
|
||||
} catch (error) {
|
||||
// 文件不存在或格式错误,使用默认数据
|
||||
this.data = { providers: {}, current: '' }
|
||||
await this.saveData()
|
||||
}
|
||||
}
|
||||
|
||||
private async saveData(): Promise<void> {
|
||||
try {
|
||||
// 确保目录存在
|
||||
await fs.mkdir(this.configDir, { recursive: true })
|
||||
// 写入配置文件
|
||||
await fs.writeFile(this.configPath, JSON.stringify(this.data, null, 2), 'utf-8')
|
||||
} catch (error) {
|
||||
console.error('保存配置失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async get<T>(key: keyof AppConfig, defaultValue?: T): Promise<T> {
|
||||
await this.initPromise // 等待初始化完成
|
||||
const value = this.data[key] as T
|
||||
return value !== undefined ? value : (defaultValue as T)
|
||||
}
|
||||
|
||||
async set<K extends keyof AppConfig>(key: K, value: AppConfig[K]): Promise<void> {
|
||||
await this.initPromise // 等待初始化完成
|
||||
this.data[key] = value
|
||||
await this.saveData()
|
||||
}
|
||||
|
||||
// 获取配置文件路径,用于调试
|
||||
getConfigPath(): string {
|
||||
return this.configPath
|
||||
}
|
||||
}
|
||||
|
||||
// 创建单例
|
||||
export const store = new SimpleStore()
|
||||
@@ -1,31 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Provider } from '../../shared/types'
|
||||
import ProviderForm from './ProviderForm'
|
||||
|
||||
interface EditProviderModalProps {
|
||||
provider: Provider
|
||||
onSave: (provider: Provider) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const EditProviderModal: React.FC<EditProviderModalProps> = ({ provider, onSave, onClose }) => {
|
||||
const handleSubmit = (data: Omit<Provider, 'id'>) => {
|
||||
onSave({
|
||||
...provider,
|
||||
...data
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<ProviderForm
|
||||
title="编辑供应商"
|
||||
submitText="保存"
|
||||
initialData={provider}
|
||||
showPresets={false}
|
||||
onSubmit={handleSubmit}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditProviderModal
|
||||
@@ -1,284 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Provider } from "../../shared/types";
|
||||
import {
|
||||
updateCoAuthoredSetting,
|
||||
checkCoAuthoredSetting,
|
||||
extractWebsiteUrl,
|
||||
} from "../utils/providerConfigUtils";
|
||||
import { providerPresets } from "../config/providerPresets";
|
||||
import "./AddProviderModal.css";
|
||||
|
||||
interface ProviderFormProps {
|
||||
title: string;
|
||||
submitText: string;
|
||||
initialData?: Provider;
|
||||
showPresets?: boolean;
|
||||
onSubmit: (data: Omit<Provider, "id">) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ProviderForm: React.FC<ProviderFormProps> = ({
|
||||
title,
|
||||
submitText,
|
||||
initialData,
|
||||
showPresets = false,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}) => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: initialData?.name || "",
|
||||
websiteUrl: initialData?.websiteUrl || "",
|
||||
settingsConfig: initialData
|
||||
? JSON.stringify(initialData.settingsConfig, null, 2)
|
||||
: "",
|
||||
});
|
||||
const [error, setError] = useState("");
|
||||
const [disableCoAuthored, setDisableCoAuthored] = useState(false);
|
||||
const [selectedPreset, setSelectedPreset] = useState<number | null>(null);
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
|
||||
// 初始化时检查禁用签名状态
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
const configString = JSON.stringify(initialData.settingsConfig, null, 2);
|
||||
const hasCoAuthoredDisabled = checkCoAuthoredSetting(configString);
|
||||
setDisableCoAuthored(hasCoAuthoredDisabled);
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (!formData.name) {
|
||||
setError("请填写供应商名称");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.settingsConfig.trim()) {
|
||||
setError("请填写配置内容");
|
||||
return;
|
||||
}
|
||||
|
||||
let settingsConfig: Record<string, any>;
|
||||
|
||||
try {
|
||||
settingsConfig = JSON.parse(formData.settingsConfig);
|
||||
} catch (err) {
|
||||
setError("配置JSON格式错误,请检查语法");
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit({
|
||||
name: formData.name,
|
||||
websiteUrl: formData.websiteUrl,
|
||||
settingsConfig,
|
||||
});
|
||||
};
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => {
|
||||
const { name, value } = e.target;
|
||||
|
||||
if (name === "settingsConfig") {
|
||||
// 当用户修改配置时,尝试自动提取官网地址
|
||||
const extractedWebsiteUrl = extractWebsiteUrl(value);
|
||||
|
||||
// 同时检查并同步选择框状态
|
||||
const hasCoAuthoredDisabled = checkCoAuthoredSetting(value);
|
||||
setDisableCoAuthored(hasCoAuthoredDisabled);
|
||||
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: value,
|
||||
// 只有在官网地址为空时才自动填入
|
||||
websiteUrl: formData.websiteUrl || extractedWebsiteUrl,
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 处理选择框变化
|
||||
const handleCoAuthoredToggle = (checked: boolean) => {
|
||||
setDisableCoAuthored(checked);
|
||||
|
||||
// 更新JSON配置
|
||||
const updatedConfig = updateCoAuthoredSetting(
|
||||
formData.settingsConfig,
|
||||
checked
|
||||
);
|
||||
setFormData({
|
||||
...formData,
|
||||
settingsConfig: updatedConfig,
|
||||
});
|
||||
};
|
||||
|
||||
const applyPreset = (preset: (typeof providerPresets)[0], index: number) => {
|
||||
const configString = JSON.stringify(preset.settingsConfig, null, 2);
|
||||
|
||||
setFormData({
|
||||
name: preset.name,
|
||||
websiteUrl: preset.websiteUrl,
|
||||
settingsConfig: configString,
|
||||
});
|
||||
|
||||
// 设置选中的预设
|
||||
setSelectedPreset(index);
|
||||
|
||||
// 清空 API Key 输入框,让用户重新输入
|
||||
setApiKey("");
|
||||
|
||||
// 同步选择框状态
|
||||
const hasCoAuthoredDisabled = checkCoAuthoredSetting(configString);
|
||||
setDisableCoAuthored(hasCoAuthoredDisabled);
|
||||
};
|
||||
|
||||
// 处理 API Key 输入并自动更新配置
|
||||
const handleApiKeyChange = (key: string) => {
|
||||
setApiKey(key);
|
||||
|
||||
if (selectedPreset !== null && key.trim()) {
|
||||
// 获取当前选中的预设配置
|
||||
const preset = providerPresets[selectedPreset];
|
||||
const updatedConfig = JSON.parse(JSON.stringify(preset.settingsConfig));
|
||||
|
||||
// 替换配置中的 API Key
|
||||
if (updatedConfig.env && updatedConfig.env.ANTHROPIC_AUTH_TOKEN) {
|
||||
updatedConfig.env.ANTHROPIC_AUTH_TOKEN = key.trim();
|
||||
}
|
||||
|
||||
const configString = JSON.stringify(updatedConfig, null, 2);
|
||||
|
||||
// 更新表单配置
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
settingsConfig: configString,
|
||||
}));
|
||||
|
||||
// 同步选择框状态
|
||||
const hasCoAuthoredDisabled = checkCoAuthoredSetting(configString);
|
||||
setDisableCoAuthored(hasCoAuthoredDisabled);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-content">
|
||||
<h2>{title}</h2>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
{showPresets && (
|
||||
<div className="presets">
|
||||
<label>快速选择模板:</label>
|
||||
<div className="preset-buttons">
|
||||
{providerPresets.map((preset, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
className={`preset-btn ${
|
||||
selectedPreset === index ? "selected" : ""
|
||||
}`}
|
||||
onClick={() => applyPreset(preset, index)}
|
||||
>
|
||||
{preset.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="name">供应商名称 *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
placeholder="例如:Anthropic 官方"
|
||||
required
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedPreset !== null && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="apiKey">API Key *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="apiKey"
|
||||
value={apiKey}
|
||||
onChange={(e) => handleApiKeyChange(e.target.value)}
|
||||
placeholder="只需要填这里,下方配置会自动填充"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="websiteUrl">官网地址</label>
|
||||
<input
|
||||
type="url"
|
||||
id="websiteUrl"
|
||||
name="websiteUrl"
|
||||
value={formData.websiteUrl}
|
||||
onChange={handleChange}
|
||||
placeholder="https://example.com(可选)"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="label-with-checkbox">
|
||||
<label htmlFor="settingsConfig">Claude Code 配置 (JSON) *</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={disableCoAuthored}
|
||||
onChange={(e) => handleCoAuthoredToggle(e.target.checked)}
|
||||
/>
|
||||
禁止 Claude Code 签名
|
||||
</label>
|
||||
</div>
|
||||
<textarea
|
||||
id="settingsConfig"
|
||||
name="settingsConfig"
|
||||
value={formData.settingsConfig}
|
||||
onChange={handleChange}
|
||||
placeholder={`{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-your-api-key-here"
|
||||
}
|
||||
}`}
|
||||
rows={12}
|
||||
style={{ fontFamily: "monospace", fontSize: "14px" }}
|
||||
required
|
||||
/>
|
||||
<small className="field-hint">
|
||||
完整的 Claude Code settings.json 配置内容
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" className="cancel-btn" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" className="submit-btn">
|
||||
{submitText}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProviderForm;
|
||||