82 Commits

Author SHA1 Message Date
Jason
14964667ac chore(ci): Linux runner 切换至 ubuntu-22.04(遵循 GitHub 支持范围) 2025-09-11 08:37:01 +08:00
Jason
e143ef30e3 - chore(ci): 固定 runner 版本,移除 macOS-13;升级 actions/cache@v4 与 action-gh-release@v2
- fix(linux): 使用 ubuntu-20.04 构建并可选上传 AppImage,降低 glibc/内核基线
- fix(windows): 安装器启用 WebView2 下载引导(silent),默认不开启 --disable-gpu
- fix(macos): ARM64 构建通用包(zip),覆盖 Intel 与 Apple Silicon
- chore(version): bump to v3.1.2(兼容性热修复)
2025-09-10 22:53:09 +08:00
farion1231
3665a79e50 chore: bump version to v3.1.1
- Update version in package.json, Cargo.toml, and tauri.conf.json
- Add CHANGELOG entries for v3.1.0 and v3.1.1
2025-09-03 16:43:29 +08:00
farion1231
4dce31aff7 Fix the default codex config.toml to match the latest modifications. 2025-09-03 16:33:12 +08:00
Jason
451ca949ec feat(ui): improve provider configuration UX with custom option
- Add explicit "Custom" button in preset selection
- Set "Custom" as default selection when adding new provider
- Update label from "One-click import" to "Choose configuration type"
- Add contextual hints for different configuration modes:
  - Custom mode: "Manually configure provider, complete configuration required"
  - Official preset: "Official login, no API Key required"
  - Other presets: "Use preset configuration, only API Key required"
- Remove redundant "(optional)" text from Codex config.toml hint
- Improve clarity for users who were confused about adding custom providers
2025-09-03 15:58:02 +08:00
Jason
a9ff8ce01c update readme 2025-09-01 15:33:24 +08:00
Jason Young
7848248df7 Merge pull request #3 from farion1231/codex-adaptation
feat(codex): 支持 Codex 供应商管理与一键切换;迁移前自动备份
2025-09-01 11:41:31 +08:00
Jason
b00e8de26f feat(config): backup v1 file before v2 migration
- Add timestamped backup at `~/.cc-switch/config.v1.backup.<ts>.json`
- Keep provider files untouched; only cc-switch metadata is backed up
- Remove UI notification plan; backup only as requested
- Update CHANGELOG with migration backup notes
2025-09-01 10:49:31 +08:00
Jason
47b06b7773 feat(ui): elevate title above controls for better visual hierarchy
Move title to separate row above switcher and action buttons for cleaner layout.
2025-08-31 23:13:27 +08:00
Jason
4e66f0c105 feat(ui): center title and balance header layout
Unify title to "CC Switch" to prevent text length jumping during app switching.
Reorganize header as three-column grid with centered title.
2025-08-31 21:49:28 +08:00
Jason
84c7726940 feat(ui): implement pills-style AppSwitcher with consistent button widths
Replace segmented control with pills-style switcher for better visual consistency.
2025-08-31 21:27:58 +08:00
Jason
b8f59a4740 chore: silence non_snake_case warnings in commands.rs for legacy app/appType compatibility
- Add crate-level allow(non_snake_case) in src-tauri/src/commands.rs
- Keeps compatibility while avoiding compiler warnings
2025-08-31 19:00:09 +08:00
Jason
06a19519c5 revert: restore app/appType param compatibility and revert segmented-thumb pointer-events change
- Restore backend commands to accept app_type/app/appType with priority app_type
- Frontend invoke() now passes both { app_type, app } again
- Revert CSS change that set pointer-events: none on segmented-thumb
- Keep minor fix: open_config_folder signature uses handle + respects both names

Note: warnings for non_snake_case (appType) are expected for compatibility.
2025-08-31 18:14:31 +08:00
Jason
b4ebb7c9e5 docs(codex): document Codex config directory, fields (OPENAI_API_KEY), empty config.toml behavior, and switching strategy in README 2025-08-31 17:17:22 +08:00
Jason
5edc3e07a4 feat(codex): validate non-empty config.toml with toml crate (syntax check in save/import) 2025-08-31 17:13:25 +08:00
Jason
417dcc1d37 feat(codex): require OPENAI_API_KEY when non-official preset selected; keep config.toml optional 2025-08-31 17:07:35 +08:00
Jason
72f6068e86 Revert "feat(ui): enhance Codex provider list display by extracting base_url/model_provider from config.toml; plumb appType into ProviderList"
This reverts commit 97e7f34260.
2025-08-31 17:02:15 +08:00
Jason
97e7f34260 feat(ui): enhance Codex provider list display by extracting base_url/model_provider from config.toml; plumb appType into ProviderList 2025-08-31 16:55:55 +08:00
Jason
74babf9730 refactor(api): unify Tauri command app param as app_type with backward-compatible app/appType; update front-end invocations accordingly 2025-08-31 16:43:33 +08:00
Jason
30fe800ebe fix(codex): correct config path reporting and folder opening; allow empty config.toml; unify API key field as OPENAI_API_KEY; front-end invoke uses app_type/app fallback for Tauri commands 2025-08-31 16:39:38 +08:00
Jason
c98a724935 feat(ui): 优化首页切换为分段控件;精简 Banner 间距;标题在上切换在下 2025-08-31 00:03:22 +08:00
Jason
0cb89c8f67 chore(codex): 调整 Codex 预设模板与占位符(auth.json/config.toml 与表单占位) 2025-08-30 23:02:49 +08:00
Jason
7b5d5c6ce1 refactor(codex): 选择 Codex 预设时清空 API Key 输入,避免误保存占位符 2025-08-30 22:09:19 +08:00
Jason
eea5e4123b feat(codex): 增加 Codex 预设供应商(官方、PackyCode);在添加供应商时支持一键预设与 API Key 自动写入 auth.json;UI 同步 Codex 预设按钮与字段 2025-08-30 22:08:41 +08:00
Jason
c10ace7a84 - feat(codex): 引入 Codex 应用与供应商切换(管理 auth.json/config.toml,支持备份与恢复)
- feat(core): 多应用配置 v2(claude/codex)与 ProviderManager;支持 v1→v2 自动迁移
- feat(ui): 新增 Codex 页签与双编辑器表单;统一 window.api 支持 app 参数
- feat(tauri): 新增 get_config_status/open_config_folder/open_external 命令并适配 Codex
- fix(codex): 主配置缺失时不执行默认导入(对齐 Claude 行为)
- chore: 配置目录展示与重启提示等细节优化
2025-08-30 21:54:52 +08:00
farion1231
0e803b53d8 update readme 2025-08-29 15:37:26 +08:00
Jason
49d8787ab9 ci(release): generate macOS zip, Windows installer + portable, Linux deb; split per-OS build and asset steps 2025-08-29 14:57:33 +08:00
Jason
a05fefb54c feat: optimize release workflow for better distribution
- Configure GitHub Actions to generate platform-specific releases:
  - macOS: zip package only (avoids signing issues)
  - Windows: installer (NSIS) and portable version
  - Linux: AppImage and deb packages
- Update Tauri config to build all available targets
- Add documentation for macOS signature workarounds
2025-08-29 14:40:40 +08:00
Jason
3574fa07cb ci(workflows): restore tag-only release; keep Linux deps 2025-08-29 12:11:16 +08:00
Jason Young
b64b86f3ca Merge pull request #2 from farion1231/tauri-migration
feat: migrate from Electron to Tauri 2.0 (v3.0.0)
2025-08-29 11:56:31 +08:00
Jason
2cf116280f feat: add prettier formatter and MIT license
- Add prettier dev dependency for code formatting
- Create MIT LICENSE file
- Format TypeScript files with prettier
- Update provider order in README (Qwen coder first)
- Update add provider screenshot with new UI
2025-08-29 11:35:17 +08:00
Jason
73cf337c42 fix(config): create settings.json on first run; keep legacy claude.json read compatibility 2025-08-29 10:50:10 +08:00
Jason
fa2d64b692 feat: add official preset orange theme and disabled API input
- Add Anthropic orange theme styling for official preset buttons
- Auto-disable API Key input field when official preset is selected
- Add isOfficial field for precise official preset identification
- Enhance UX: official login requires no manual API Key input
2025-08-29 09:03:11 +08:00
Jason
9c17be1b59 security(tauri): remove unused shell plugin and capability
- Remove tauri-plugin-shell from Cargo.toml
- Drop tauri_plugin_shell::init() from src-tauri/src/lib.rs
- Delete "shell:allow-open" from src-tauri/capabilities/default.json
- No runtime behavior change; opener plugin still handles links/paths
- Motivation: reduce permissions surface and slightly shrink bundle
2025-08-28 22:26:02 +08:00
Jason
fe1574a026 docs: update README for v3.0.0 Tauri release
- Add version badges and Tauri branding
- Update performance metrics (85% size reduction, 10x startup speed)
- Add detailed system requirements for all platforms
- Update installation instructions with specific file names
- Add comprehensive development setup guide
- Include new npm scripts (typecheck, format)
- Add Rust development commands
- Enhance project structure documentation
- Link to CHANGELOG for version details
- Update screenshots for new UI
2025-08-27 22:26:07 +08:00
Jason
9254c5d291 feat: add development scripts and CHANGELOG for v3.0.0
- Add typecheck script for TypeScript validation
- Add format and format:check scripts for code formatting
- Create comprehensive CHANGELOG documenting migration from Electron to Tauri
- Document all major changes, improvements, and migration notes for v3.0.0
2025-08-27 11:15:29 +08:00
Jason
642e7a3817 chore: format code and fix bundle identifier for v3.0.0 release
- Format all TypeScript/React code with Prettier
- Format all Rust code with cargo fmt
- Fix bundle identifier from .app to .desktop to avoid macOS conflicts
- Prepare codebase for v3.0.0 Tauri release
2025-08-27 11:00:53 +08:00
Jason
5e2e80b00d fix: prevent modal header jumping when toggling API key field
Reserve fixed height for API key input container and use visibility/opacity
for show/hide instead of conditional rendering to maintain consistent modal
height when selecting presets
2025-08-27 10:39:39 +08:00
Jason
2a43f1f54d update: regenerate all platform icons 2025-08-27 10:30:29 +08:00
Jason
7e6ce83158 update: regenerate all platform icons 2025-08-27 09:01:36 +08:00
Jason
6932e89ea8 update: regenerate all platform icons from unified source
- Regenerated all desktop platform icons (Windows .ico, macOS .icns, Linux PNG)
- Added mobile platform icons for future cross-platform support
- Ensures consistent icon appearance across all platforms
2025-08-27 08:43:41 +08:00
Jason
d144d5c2fc ci(workflow): fix pnpm cache path context by using step outputs
- Replace env var STORE_PATH with step output\n- Add id to pnpm-store step and write to \n- Reference cache path via steps.pnpm-store.outputs.path\n- Resolves linter warning: Context access might be invalid: STORE_PATH\n- No behavior change; caching remains the same
2025-08-26 23:32:13 +08:00
Jason
adee37ab66 feat(icons): update application icon with new colorful radial design
Replace all platform-specific icon files with new radial burst design featuring teal, orange, and yellow gradients. Updated icons include:
- PNG variants for all required sizes (32x32 to 1024x1024)
- macOS ICNS bundle with all resolutions
- Windows Square logo variants for modern app packaging
2025-08-26 22:57:57 +08:00
Jason
dcf49cc094 fix(tauri): correct bundle.targets schema to array
- Replace per-OS map with array: ["app","dmg","nsis","appimage"].

- Align with Tauri v2 config; resolves schema validation error.

- Keeps Windows portable ("app") target enabled.
2025-08-26 22:51:22 +08:00
Jason
f8e39594fa chore: prepare v3.0.0 release
- Bump version to 3.0.0 across all files
- Update Cargo.toml with proper package metadata
- Rename crate from 'app' to 'cc-switch'
- Use Rust edition 2024 with minimum rust-version 1.85.0
- Update library name to cc_switch_lib
2025-08-26 15:45:42 +08:00
Jason
374649750b feat(ui): update preset template description for clarity 2025-08-26 15:12:27 +08:00
Jason
6d26115368 update gitignore 2025-08-26 12:39:01 +08:00
Jason
606ee67778 fix(ui): Pin modal action bar; prevent bottom content overflow\n\n- Move action buttons to fixed .modal-footer at the bottom\n- Make modal a column flex container; scroll only body\n- Ensure buttons remain visible on small viewports\n- Remove sticky edge cases causing leaked content 2025-08-26 12:34:47 +08:00
Jason
57d21fabcf feat(providers): add Kimi K2 support and optimize provider configurations
- Add Kimi K2 (Moonshot AI) preset with k2-turbo-preview model support
- Set specific models for ANTHROPIC_MODEL and ANTHROPIC_SMALL_FAST_MODEL
- Fix DeepSeek platform URL (remove trailing slash)
- Reorganize provider order for better categorization
2025-08-26 11:28:10 +08:00
Jason
001664c67d - feat(form): Support API Key ⇄ JSON two-way binding in edit modal
- feat(utils): Add helpers to read/write/detect API Key in config
- refactor(form): Reuse unified linking logic for preset and edit flows
- chore: Preserve website URL extraction and signature-disable behaviors
- build: Verify renderer build locally
2025-08-26 10:41:16 +08:00
Jason
616e230218 style: remove window title and adjust header padding for cleaner UI 2025-08-25 23:30:25 +08:00
Jason
70f9a68e5c feat(macos): implement transparent titlebar with custom background color
- Add transparent titlebar configuration in tauri.conf.json
- Implement macOS titlebar background color matching main UI banner (#3498db)
- Replace deprecated cocoa crate with modern objc2-app-kit
- Preserve native window functionality (drag, traffic lights)
- Remove all deprecation warnings from build process

The titlebar now seamlessly matches the application's blue theme while
maintaining all native macOS window management features.
2025-08-25 23:06:54 +08:00
Jason
78bc0a1a31 chore(tauri): remove dead code warnings and drop unused uuid dep
- Delete unused Provider::new, ProviderManager::get_current_provider
- Delete unused AppState::reload
- Remove uuid crate and related imports
- Keep functionality unchanged; frontend uses ID string for current provider
2025-08-25 21:41:35 +08:00
Jason
dac8ebe03b feat: upgrade Rust code to full Tauri 2.0 compatibility
- Add tauri-plugin-shell and tauri-plugin-opener dependencies
- Update permissions configuration to support shell and opener operations
- Refactor open_config_folder and open_external commands to use secure plugin APIs
- Remove unsafe direct std::process::Command usage
- Initialize necessary Tauri plugins
- Ensure all external operations comply with Tauri 2.0 security standards
2025-08-25 20:16:29 +08:00
Jason
9f370bf429 fix: remove @tauri-apps/api/os import and use local UA/platform detection for mac class 2025-08-25 10:37:19 +08:00
Jason
bac2c3db36 refactor: remove window.platform shim; platform detection handled via @tauri-apps/api/os 2025-08-25 10:33:54 +08:00
Jason
326e975748 feat: use @tauri-apps/api/os for reliable platform detection (mac body class) 2025-08-25 10:33:19 +08:00
Jason
b5696b4511 security: add restrictive default CSP for Tauri app 2025-08-25 10:32:47 +08:00
Jason
ef7e9d2f73 style: remove Electron-specific drag region styles for bordered Tauri window 2025-08-25 10:31:09 +08:00
Jason
d78013562c refactor: rename global API from electronAPI to api and update references 2025-08-25 10:30:45 +08:00
Jason
d3adfc480d ci: migrate release workflow to Tauri action and correct bundle handling 2025-08-25 10:29:58 +08:00
Jason
731cfc47be chore(deps): remove unused @tauri-apps/plugin-shell dependency 2025-08-24 23:40:32 +08:00
Jason
95b3746e49 chore(version): align package.json version to 3.0.0-beta.1 to match Tauri app version 2025-08-24 23:39:41 +08:00
Jason
c8670aede6 feat(ui): drive config path UI from getClaudeConfigStatus (show path + existence hint) and remove direct getClaudeCodeConfigPath usage 2025-08-24 23:36:09 +08:00
Jason
95549473bd fix(tauri): ensure ~/.claude directory exists before copying provider settings into main settings file 2025-08-24 23:35:44 +08:00
Jason
f3f484a04b fix(tauri): normalize external URLs by auto-prepending https:// when protocol is missing 2025-08-24 23:35:07 +08:00
Jason
1458f1e45d fix(ui): use browser-safe timeout ref type (ReturnType<typeof setTimeout>) to avoid NodeJS.Timeout mismatch 2025-08-24 23:31:56 +08:00
Jason
0301d1aee7 fix(tauri): avoid duplicate import of default provider in import_default_config by early-exit when default exists 2025-08-24 23:30:35 +08:00
Jason
224d7a8be0 fix: 修复 Tauri 重构导致的配置读取与渲染问题
- 前端:始终绑定 ,避免环境判断失误造成白屏
- 后端: 仅初始化一次,并通过  注入,避免双实例不一致
- 配置: 兼容  回退,提高旧配置兼容性
- 结果:主页面数据正常加载,底部配置路径组件恢复显示
2025-08-24 23:04:55 +08:00
Jason
c4791ff523 - chore: 添加 Tauri CLI 开发依赖
- 在 `package.json` 新增 `@tauri-apps/cli`
- 更新 `pnpm-lock.yaml` 锁定文件
- 仅依赖变更,无业务代码改动
2025-08-24 22:38:13 +08:00
Jason
55c62a3753 - fix(types): 统一导入到 src/types.ts,移除 shared/types 残留路径
- chore(tsconfig): 将 include 扩展为 src/**/* 覆盖迁移后的源文件
- feat(build): Vite 设置 root 为 src,并将 build.outDir 设为 ../dist 以匹配 Tauri frontendDist
- refactor(api): 去除未使用的 plugin-shell import;统一 electronAPI 类型定义至 vite-env.d.ts
- build: 验证 renderer 构建通过,产物输出至 dist/
2025-08-23 23:11:39 +08:00
farion1231
12fa80e002 refactor: 清理 Electron 遗留代码并优化项目结构
- 删除 Electron 主进程代码 (src/main/)
- 删除构建产物文件夹 (build/, dist/, release/)
- 清理 package.json 中的 Electron 依赖和脚本
- 删除 TypeScript 配置中的 Electron 相关文件
- 优化前端代码结构至 Tauri 标准结构 (src/renderer → src/)
- 删除移动端图标和不必要文件
- 更新文档说明技术栈变更为 Tauri
2025-08-23 21:13:25 +08:00
farion1231
29581b85d9 fix: 修复 Rust 编译错误并成功启动 Tauri 应用
- 修复 commands.rs 中的重复导入问题
- 清理未使用的导入
- 统一 Vite 和 Tauri 配置的端口为 3000
- 添加 Tauri 前端依赖包
- 应用已成功编译并运行
2025-08-23 21:00:50 +08:00
farion1231
88e69e844a docs: 更新迁移计划 - 标记 Phase 4 已完成 2025-08-23 20:52:01 +08:00
farion1231
2a658af5b9 feat: 完成前端窗口控制和配置适配
- 更新 tauri.conf.json 配置正确的前端构建路径
- 调整开发服务器端口为 Vite 默认端口 5173
- 添加 Tauri 前端依赖包
- 窗口拖拽样式已兼容
2025-08-23 20:41:14 +08:00
farion1231
1402fd0cc5 feat: 创建 Tauri API 层替换 Electron IPC 调用
- 创建 tauri-api.ts 封装所有 Tauri invoke 调用
- 保持与 Electron API 相同的接口,确保代码兼容性
- 添加类型声明文件 vite-env.d.ts
- 在 main.tsx 中导入 Tauri API
2025-08-23 20:38:57 +08:00
farion1231
8a3133be43 feat: 实现 Tauri Commands - 完成所有供应商和配置管理命令 2025-08-23 20:15:10 +08:00
farion1231
f64320fbd6 feat: 实现 Rust 后端核心模块 - 配置管理、供应商管理和数据存储 2025-08-23 20:12:35 +08:00
farion1231
3479780639 feat: configure Tauri build system and app metadata
- Update Vite config for Tauri integration
- Configure package.json scripts for Tauri commands
- Generate multi-platform app icons
- Update app metadata and window configuration
2025-08-23 20:05:50 +08:00
farion1231
1b0ab269fb feat: initialize Tauri project structure
- Add @tauri-apps/api dependency
- Create src-tauri directory with base configuration
- Generate Tauri project Rust code framework
- Add application icon resources
2025-08-23 19:59:29 +08:00
farion1231
6706889387 删除多余文件 2025-08-22 20:54:45 +08:00
farion1231
093e54f23c 更新文档 2025-08-22 15:50:25 +08:00
110 changed files with 9147 additions and 3565 deletions

View File

@@ -3,7 +3,7 @@ name: Release
on:
push:
tags:
- 'v*'
- "v*"
permissions:
contents: write
@@ -11,25 +11,57 @@ permissions:
jobs:
release:
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: windows-latest
platform: win32
- os: ubuntu-latest
platform: linux
- os: macos-latest
platform: darwin
# 固定 runner 版本,避免 latest 漂移导致 ABI 升级
- os: windows-2022
- os: ubuntu-22.04
- os: macos-14
steps:
- name: Checkout code
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
node-version: "20"
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Add macOS targets (ARM64 only for universal)
if: runner.os == 'macOS' && runner.arch == 'ARM64'
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
@@ -38,45 +70,131 @@ jobs:
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
uses: actions/cache@v4
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-
restore-keys: ${{ runner.os }}-pnpm-store-
- name: Install dependencies
- 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: Build Tauri App (macOS, universal on ARM64)
if: runner.os == 'macOS' && runner.arch == 'ARM64'
run: pnpm tauri build --target universal-apple-darwin
- name: List build files (debug)
# macOS 仅保留通用包(在 macOS-14 / ARM64 运行器上构建)
- 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同时若存在 AppImage 一并上传
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
APPIMAGE=$(find src-tauri/target/release/bundle -name "*.AppImage" | head -1 || true)
if [ -n "$APPIMAGE" ]; then
cp "$APPIMAGE" release-assets/
echo "AppImage copied"
else
echo "No AppImage found (optional)"
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/')
uses: softprops/action-gh-release@v2
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 }}
@@ -84,24 +202,19 @@ jobs:
### 下载
#### Windows 用户
- **安装版 (推荐)**: `CC Switch Setup ${{ github.ref_name }}.exe`
- **便携版**: `CC Switch ${{ github.ref_name }}.exe`
- macOS: `CC-Switch-macOS.zip`(解压即用)
- Windows: `CC-Switch-Setup.exe` 或 `CC-Switch-Setup.msi`(安装版);`CC-Switch-Windows-Portable.zip`(绿色版)
- Linux: `*.deb`Debian/Ubuntu 安装包)
#### macOS 用户(推荐使用通用版本)
- **通用版本**: `CC Switch-${{ github.ref_name }}-mac.zip` - 兼容所有MacIntel + 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 如遇“已损坏”提示,可在终端执行:`xattr -cr "/Applications/CC Switch.app"`
files: release-assets/*
env:
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

2
.gitignore vendored
View File

@@ -7,3 +7,5 @@ release/
.env.local
*.tsbuildinfo
.npmrc
CLAUDE.md
AGENTS.md

110
CHANGELOG.md Normal file
View File

@@ -0,0 +1,110 @@
# 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.1.1] - 2025-09-03
### 🐛 Bug Fixes
- Fixed the default codex config.toml to match the latest modifications
- Improved provider configuration UX with custom option
### 📝 Documentation
- Updated README with latest information
## [3.1.0] - 2025-09-01
### ✨ New Features
- **Added Codex application support** - Now supports both Claude Code and Codex configuration management
- Manage auth.json and config.toml for Codex
- Support for backup and restore operations
- Preset providers for Codex (Official, PackyCode)
- API Key auto-write to auth.json when using presets
- **New UI components**
- App switcher with segmented control design
- Dual editor form for Codex configuration
- Pills-style app switcher with consistent button widths
- **Enhanced configuration management**
- Multi-app config v2 structure (claude/codex)
- Automatic v1→v2 migration with backup
- OPENAI_API_KEY validation for non-official presets
- TOML syntax validation for config.toml
### 🔧 Technical Improvements
- Unified Tauri command API with app_type parameter
- Backward compatibility for app/appType parameters
- Added get_config_status/open_config_folder/open_external commands
- Improved error handling for empty config.toml
### 🐛 Bug Fixes
- Fixed config path reporting and folder opening for Codex
- Corrected default import behavior when main config is missing
- Fixed non_snake_case warnings in commands.rs
## [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
View 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.

198
README.md
View File

@@ -1,14 +1,26 @@
# Claude Code 供应商切换器
# Claude Code & Codex 供应商切换器
一个用于管理和切换 Claude Code 不同供应商配置的桌面应用。
[![Version](https://img.shields.io/badge/version-3.0.0-blue.svg)](https://github.com/jasonyoung/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/jasonyoung/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202.0-orange.svg)](https://tauri.app/)
一个用于管理和切换 Claude Code 与 Codex 不同供应商配置的桌面应用。
> v3.1.0 :新增 Codex 供应商管理与一键切换,支持导入当前 Codex 配置为默认供应商,并在内部配置从 v1 → v2 迁移前自动备份(详见下文““迁移与备份”)。
> v3.0.0 重大更新:从 Electron 完全迁移到 Tauri 2.0,应用体积减少 85%(从 ~80MB 降至 ~12MB启动速度提升 10 倍!
## 功能特性
- **极速启动** - 基于 Tauri 2.0,原生性能,秒开应用
- 一键切换不同供应商
- 智谱 GLM、Qwen coder、DeepSeek v3.1、packycode 等预设供应商只需要填写 key 即可一键配置
- 同时支持 Claude Code 与 Codex 的供应商切换与导入
- Qwen coder、kimi k2、智谱 GLM、DeepSeek v3.1、packycode 等预设供应商只需要填写 key 即可一键配置
- 支持添加自定义供应商
- 随时切换官方登录
- 简洁美观的图形界面
- 信息存储在本地 ~/.cc-switch/config.json无隐私风险
- 超小体积 - 仅 ~5MB 安装包
## 界面预览
@@ -22,109 +34,149 @@
## 下载安装
### 系统要求
- **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` 不存在时按空处理。
- 官方登录可切换到预设“Codex 官方登录”,重启终端后可选择使用 ChatGPT 账号完成登录。
### Claude Code 说明
- 配置目录:`~/.claude/`
- 主配置文件:`settings.json`(推荐)或 `claude.json`(旧版兼容,若存在则继续使用)
- 供应商副本:`settings-<name>.json`
- API Key 字段:`env.ANTHROPIC_AUTH_TOKEN`
- 切换策略:将选中供应商的副本覆盖到主配置(`settings.json`/`claude.json`)。如当前有配置且存在“当前供应商”,会先将主配置备份回该供应商的副本文件。
- 导入默认:若 `~/.claude/settings.json``~/.claude/claude.json` 存在,会将当前主配置导入为 `default` 供应商副本。
- 官方登录可切换到预设“Claude 官方登录”,重启终端后可使用 `/login` 完成登录。
### 迁移与备份
- cc-switch 自身配置从 v1 → v2 迁移时,将在 `~/.cc-switch/` 目录自动创建时间戳备份:`config.v1.backup.<timestamp>.json`
- 实际生效的应用配置文件(如 `~/.claude/settings.json``~/.codex/auth.json`/`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

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 312 KiB

View File

@@ -1,91 +1,33 @@
{
"name": "cc-switch",
"version": "2.0.3",
"description": "Claude Code 供应商切换工具",
"main": "dist/main/index.js",
"version": "3.1.2",
"description": "Claude Code & Codex 供应商切换工具",
"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"
}
}
}

2382
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 247 KiB

4
src-tauri/.gitignore vendored Normal file
View 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

File diff suppressed because it is too large Load Diff

32
src-tauri/Cargo.toml Normal file
View File

@@ -0,0 +1,32 @@
[package]
name = "cc-switch"
version = "3.1.2"
description = "Claude Code & Codex 供应商配置管理工具"
authors = ["Jason Young"]
license = "MIT"
repository = "https://github.com/farion1231/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
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
src-tauri/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

130
src-tauri/src/app_config.rs Normal file
View 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());
}
}
}

View 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
View 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, &current_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(&current_provider.name));
backup_config(&settings_path, &current_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
View 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
View 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
View 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
View 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
View 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()
}
}

46
src-tauri/tauri.conf.json Normal file
View File

@@ -0,0 +1,46 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch",
"version": "3.1.2",
"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"
],
"windows": {
"webviewInstallMode": {
"type": "downloadBootstrapper",
"silent": true
}
}
}
}

View File

@@ -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 {

View File

@@ -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,8 +75,8 @@ 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);
@@ -80,9 +86,12 @@ function App() {
}
};
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)}

View File

@@ -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,

View File

@@ -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}

View 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; }
}

View 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>
);
}

View File

@@ -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;
}

View File

@@ -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;

View 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;

View File

@@ -0,0 +1,657 @@
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("");
// -1 表示自定义null 表示未选择,>= 0 表示预设索引
const [selectedCodexPreset, setSelectedCodexPreset] = useState<number | null>(
showPresets && isCodex ? -1 : 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);
// -1 表示自定义null 表示未选择,>= 0 表示预设索引
const [selectedPreset, setSelectedPreset] = useState<number | null>(
showPresets ? -1 : 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);
};
// 处理点击自定义按钮
const handleCustomClick = () => {
setSelectedPreset(-1);
setFormData({
name: "",
websiteUrl: "",
settingsConfig: "",
});
setApiKey("");
setDisableCoAuthored(false);
};
// 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("");
};
// Codex: 处理点击自定义按钮
const handleCodexCustomClick = () => {
setSelectedCodexPreset(-1);
setFormData({
name: "",
websiteUrl: "",
settingsConfig: "",
});
setCodexAuth("");
setCodexConfig("");
setCodexApiKey("");
};
// 处理 API Key 输入并自动更新配置
const handleApiKeyChange = (key: string) => {
setApiKey(key);
const configString = setApiKeyInConfig(
formData.settingsConfig,
key.trim(),
{ createIfMissing: selectedPreset !== null && selectedPreset !== -1 },
);
// 更新表单配置
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 输入框
// 自定义模式(-1)不显示独立的 API Key 输入框
const showApiKey =
(selectedPreset !== null && selectedPreset !== -1) ||
(!showPresets && hasApiKeyField(formData.settingsConfig));
// 判断当前选中的预设是否是官方
const isOfficialPreset =
selectedPreset !== null &&
selectedPreset >= 0 &&
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 "";
}
};
// 自定义模式(-1)不显示独立的 API Key 输入框
const showCodexApiKey =
(selectedCodexPreset !== null && selectedCodexPreset !== -1) ||
(!showPresets && getCodexAuthApiKey(codexAuth) !== "");
const isCodexOfficialPreset =
selectedCodexPreset !== null &&
selectedCodexPreset >= 0 &&
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></label>
<div className="preset-buttons">
<button
type="button"
className={`preset-btn ${
selectedPreset === -1 ? "selected" : ""
}`}
onClick={handleCustomClick}
>
</button>
{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>
{selectedPreset === -1 && (
<small className="field-hint" style={{ marginTop: "8px", display: "block" }}>
</small>
)}
{selectedPreset !== -1 && selectedPreset !== null && (
<small className="field-hint" style={{ marginTop: "8px", display: "block" }}>
{isOfficialPreset
? "Claude 官方登录,不需要填写 API Key"
: "使用预设配置,只需填写 API Key"}
</small>
)}
</div>
)}
{showPresets && isCodex && (
<div className="presets">
<label></label>
<div className="preset-buttons">
<button
type="button"
className={`preset-btn ${
selectedCodexPreset === -1 ? "selected" : ""
}`}
onClick={handleCodexCustomClick}
>
</button>
{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>
{selectedCodexPreset === -1 && (
<small className="field-hint" style={{ marginTop: "8px", display: "block" }}>
</small>
)}
{selectedCodexPreset !== -1 && selectedCodexPreset !== null && (
<small className="field-hint" style={{ marginTop: "8px", display: "block" }}>
{isCodexOfficialPreset
? "Codex 官方登录,不需要填写 API Key"
: "使用预设配置,只需填写 API Key"}
</small>
)}
</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 && selectedCodexPreset >= 0 && !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;

View File

@@ -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' : ''}`}
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="#"
onClick={(e) => {
e.preventDefault()
handleUrlClick(provider.websiteUrl!)
e.preventDefault();
handleUrlClick(provider.websiteUrl!);
}}
className="url-link"
title={`访问 ${provider.websiteUrl}`}
@@ -105,12 +107,12 @@ const ProviderList: React.FC<ProviderListProps> = ({
</button>
</div>
</div>
)
);
})}
</div>
)}
</div>
)
}
);
};
export default ProviderList
export default ProviderList;

View File

@@ -0,0 +1,41 @@
/**
* Codex 预设供应商配置模板
*/
export interface CodexProviderPreset {
name: string;
websiteUrl: string;
auth: Record<string, any>; // 将写入 ~/.codex/auth.json
config: string; // 将写入 ~/.codex/config.tomlTOML 字符串)
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"
disable_response_storage = true
[model_providers.packycode]
name = "packycode"
base_url = "https://codex-api.packycode.com/v1"
wire_api = "responses"
env_key = "packycode"`,
},
];

View File

@@ -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",
},
},
},
];

View File

@@ -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
View 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
View 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>,
);

View File

@@ -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;
}
});

View File

@@ -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'
})

View File

@@ -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;
}
}

View File

@@ -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()

View File

@@ -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

View File

@@ -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;

Some files were not shown because too many files have changed in this diff Show More