57 Commits

Author SHA1 Message Date
Jason
077d491720 release: bump version to 3.3.0
Update version across all package files and add comprehensive changelog for v3.3.0 release featuring VS Code integration, shared config snippets, enhanced Codex wizard, and cross-platform improvements.
2025-09-22 22:50:07 +08:00
Jason
7e9930fe50 fix: stabilize provider action button width and improve visual consistency
- Set fixed width (76px) for enable/active buttons to prevent layout shift
- Hide play icon in active state to optimize space usage
- Add center alignment and nowrap to ensure consistent appearance
2025-09-22 22:16:47 +08:00
Jason
b17d915086 refactor: optimize React state updates and improve UI text clarity
- Use functional setState to ensure proper state updates in ProviderForm
- Improve Chinese UI text consistency in CodexConfigEditor:
  - Change "API 基础地址" to "API 请求地址" for clarity
  - Simplify "供应商官网" to "官网地址"
  - Update placeholder text for consistency
- Move requires_openai_auth to model_providers section in Codex config template
2025-09-22 16:25:58 +08:00
Jason
3e834e2c38 fix: correct HTML pattern attribute regex escape in Codex config wizard
Fixed validation pattern in provider name input field by removing incorrect double backslash escape (\S to \S) to properly validate non-whitespace input
2025-09-22 15:50:33 +08:00
Jason
cae625dab1 refactor: update VSCode apply button style to match Linear design theme
- Changed from solid emerald button to bordered style for better visual hierarchy
- Apply action: gray border, blue on hover (consistent with theme color)
- Remove action: gray border, red on hover (indicates destructive action)
- Better distinction between apply/remove states while maintaining Linear's minimalist aesthetic
2025-09-22 15:35:46 +08:00
Jason
122d7f1ad6 feat: add created_at timestamp field to Provider struct
- Add optional created_at field to track provider creation time
- Serialize field as camelCase (createdAt) for JSON compatibility
- Skip serialization when field is None to maintain backward compatibility
2025-09-22 10:46:18 +08:00
Jason
7eaf284400 feat: add dedicated API key URL support for third-party providers
- Add optional apiKeyUrl field to ProviderPreset interface for third-party providers
- Update ProviderForm to prioritize apiKeyUrl over websiteUrl for third-party category
- Make provider display name required in CodexConfigEditor with validation
- Configure PackyCode preset with affiliate API key URL

This allows third-party providers to have separate URLs for their service homepage
and API key acquisition, improving user experience when obtaining API keys.
2025-09-21 23:09:53 +08:00
TinsFox
86ef7afbdf fix: add @codemirror/lint (#45)
LGTM!
2025-09-21 20:54:58 +08:00
farion1231
615c431875 feat: enhance Codex provider configuration wizard with display name field
- Add separate display name field for provider (supports Chinese)
- Keep provider code field for internal identifier (English only)
- Add onNameChange callback to update provider name from wizard
- Improve code formatting consistency in ProviderForm
2025-09-21 20:37:01 +08:00
farion1231
d041ea7a56 refactor: improve form validation in CodexConfigEditor using HTML5 validation API
- Replace custom error state with native HTML5 form validation
- Add useRef hooks for input field validation management
- Add pattern attributes to enforce non-empty input validation
- Leverage browser's built-in validation UI for better UX
- Extract closeTemplateModal function for consistent modal closing
2025-09-21 20:04:50 +08:00
farion1231
c4c1747563 feat: add configuration wizard for custom Codex providers
- Add quick configuration wizard modal for custom providers
- Generate auth.json and config.toml from simple inputs (API key, base URL, model name)
- Extract generation logic into reusable functions (generateThirdPartyAuth, generateThirdPartyConfig)
- Pre-populate custom template when selecting custom option
- Add wizard button link in PresetSelector for custom mode
- Update PackyCode preset to use the new generation functions
2025-09-21 19:04:56 +08:00
farion1231
c284fe8348 fix: prevent text wrapping in VSCode apply button on Windows
Add whitespace-nowrap class to ensure button text stays on single line
across different font rendering systems
2025-09-21 10:50:08 +08:00
Jason Young
8f932b7358 Merge pull request #44 from farion1231/feature/wsl-support
feat: improve WSL config directory support
2025-09-21 10:24:49 +08:00
farion1231
d9e940e7a7 fix: update config directory placeholders for WSL environment
- Change placeholders from Windows-style WSL mount paths (/mnt/c/Users/...) to native WSL paths (/home/...)
- Better reflects the intended use case for WSL users configuring their native Linux home directories
2025-09-21 10:20:47 +08:00
farion1231
2147db6707 fix: update WSL config directory override description and fix formatting
- Clarify that WSL config directory is for WSL environments specifically
- Explain that vendor data stays consistent with main environment
- Add missing parentheses for proper conditional evaluation
- Remove trailing commas from GitHub release URLs
2025-09-21 10:16:20 +08:00
Jason
8c826b3073 fix(codex): improve config snippet handling with consistent trimming
- Trim config snippets during initialization from localStorage and defaults
- Trim snippets before comparison in toggle and change handlers
- Store trimmed values to localStorage for consistency
- Prevents configuration matching issues caused by accidental whitespace
2025-09-20 23:00:53 +08:00
Jason
54f1357bcc feat: add config directory override support for WSL
- Add persistent app settings with custom Claude Code and Codex config directories
- Add config directory override UI in settings modal with manual input, browse, and reset options
- Integrate tauri-plugin-dialog for native directory picker
- Support WSL and other special environments where config paths need manual specification

Changes:
- settings.rs: Implement settings load/save and directory override logic
- SettingsModal: Add config directory override UI components
- API: Add get_config_dir and pick_directory commands
2025-09-20 21:20:07 +08:00
Jason
b8d2daccde - fix(linux): disable modal backdrop blur on Linux (WebKitGTK/Wayland) to prevent freeze when opening Add Provider panel
- refactor(ui): add runtime platform detector and conditionally apply blur only on non-Linux platforms
- chore: verify typecheck and renderer build succeed; no functional regression expected
2025-09-19 16:17:14 +08:00
Jason
21205272a5 codex settings model update 2025-09-19 16:02:44 +08:00
Jason
ef067a6968 codex settings update 2025-09-19 16:00:31 +08:00
Jason Young
84204889f0 Merge pull request #37 from farion1231/feature/vscode-improvements
Feature/VS Code Integration Enhancements
2025-09-19 15:56:55 +08:00
Jason
31cdc2a5cf - feat(vscode-sync): restore auto-sync logic and enable by default
- refactor(settings): remove the VS Code auto-sync toggle from Settings UI
- feat(provider-list): enable auto-sync after "Apply to VS Code"; disable after "Remove"
- chore(prettier): run Prettier on changed files
- verify: typecheck and renderer build pass

- Files
  - added: src/hooks/useVSCodeAutoSync.ts
  - modified: src/App.tsx
  - modified: src/components/ProviderList.tsx
  - modified: src/components/SettingsModal.tsx

- Notes
  - Auto-sync now defaults to enabled for new users (stored in localStorage; existing saved state is respected).
  - No settings toggle is shown; manual Apply/Remove in the list still works as before.
2025-09-19 15:48:35 +08:00
Jason
7522ba3e03 feat: improve VS Code integration button and notifications
- Add fixed width and center alignment for VS Code button to ensure consistent width between 'Apply' and 'Remove' states
- Update notification messages to remind users to restart Codex plugin for changes to take effect
- Restore distinct color scheme: green for 'Apply to VS Code', gray for 'Remove from VS Code'
2025-09-19 15:10:11 +08:00
Jason
3ac3f122eb - refactor(utils): extract Codex base_url parsing into shared helpers
- refactor(ProviderList): use shared base_url helpers
- refactor(App): reuse shared base_url helpers for VS Code sync
- fix(auto-sync): global shared VS Code auto-apply state (localStorage + event broadcast)
- feat(tray): auto-apply to VS Code on Codex provider-switched when enabled
- behavior: manual Apply enables auto-sync; manual Remove disables; official providers clear managed keys only
- chore(typecheck): pass pnpm typecheck
2025-09-19 14:22:39 +08:00
Jason
67db492330 fix: prevent layout shift when switching providers
- Keep 'Current' badge always rendered with invisible class when not active
- Keep 'Apply to VS Code' button always rendered with invisible class when not active
- Use consistent border width (1px) for both selected and unselected cards
- Remove ring utility that was causing extra space outside elements

This ensures all provider cards maintain the same height regardless of selection state
2025-09-19 11:26:51 +08:00
Jason
358d6e001e refactor: extract VS Code sync logic to separate function
- Extract syncCodexToVSCode as a standalone function for better code organization
- Fix VS Code button state not updating after provider switch
- Add loadProviders() call after sync to trigger UI state refresh
- Improve error handling and variable naming
2025-09-19 11:06:26 +08:00
Jason
8a26cb51d8 fix: improve VS Code config sync reliability and TOML parsing
- Support both single and double quotes in TOML base_url parsing
- Fix regex capture group index for consistent parsing
- Improve "applied" status detection by comparing normalized URLs
- Add validation to prevent empty base_url writes for non-official providers
- Enhance error handling with user-friendly notifications
2025-09-19 09:41:08 +08:00
Jason
9f8c745f8c fix: hide 'Apply to VS Code' button for official Codex providers
Official Codex providers don't need VS Code integration since they use the default API endpoint
2025-09-19 09:19:53 +08:00
Jason
3a9a8036d2 - feat(codex): Add “Apply to VS Code/Remove from VS Code” button on current Codex provider card
- feat(tauri): Add commands to read/write VS Code settings.json with cross-variant detection (Code/Insiders/VSCodium/OSS)
- fix(vscode): Use top-level keys “chatgpt.apiBase” and “chatgpt.config.preferred_auth_method”
- fix(vscode): Handle empty settings.json (skip deletes, direct write) to avoid “Can not delete in empty document”
- fix(windows): Make atomic writes robust by removing target before rename
- ui(provider-list): Improve error surfacing when applying/removing
- chore(types): Extend window.api typings and tauri-api wrappers for VS Code commands
- deps: Add jsonc-parser
2025-09-19 08:30:29 +08:00
Jason
04e81ebbe3 refactor: simplify TOML common config handling by removing markers
- Remove COMMON_CONFIG_MARKER_START/END constants
- Simplify config snippet addition/removal logic
- Use natural append/replace approach instead of markers
- Fix unused variable warning
- Improve user experience with cleaner config output
2025-09-18 22:33:55 +08:00
Jason
c6e4f3599e Revert "feat: add VS Code ChatGPT plugin config sync functionality"
This reverts commit 9bf216b102.
2025-09-18 17:57:32 +08:00
Jason
60eb9ce2a4 Revert "refactor: improve UI layout for VS Code and common config options"
This reverts commit 2a9f093210.
2025-09-18 17:57:32 +08:00
Jason
50244f0055 Revert "fix: improve VS Code config toggle handling with async state management"
This reverts commit 32e66e054b.
2025-09-18 17:57:32 +08:00
Jason
eca14db58c Revert "fix: improve VS Code config synchronization and code formatting"
This reverts commit 463e430a3d.
2025-09-18 17:57:32 +08:00
Jason
463e430a3d fix: improve VS Code config synchronization and code formatting
- Add automatic VS Code config sync when base_url changes in TOML
- Improve error handling for VS Code configuration writes
- Enhance state management with ref tracking to prevent duplicate API calls
- Fix code formatting issues and improve readability across components
- Optimize common configuration handling for both Claude and Codex providers
2025-09-18 15:25:10 +08:00
Jason
32e66e054b fix: improve VS Code config toggle handling with async state management
- Add loading state to prevent race conditions during config operations
- Convert effect-based approach to direct async handler for better control
- Support both writing and removing VS Code config through toggle
- Disable checkbox during async operations to prevent multiple requests
2025-09-18 14:46:23 +08:00
Jason
2a9f093210 refactor: improve UI layout for VS Code and common config options
- Implement horizontal two-column layout for better space utilization
- Convert VS Code config from button to checkbox pattern for consistency
- Add automatic VS Code settings write on checkbox toggle
- Fix layout stability issues with fixed height containers
- Remove unnecessary wrapper functions for cleaner code
- Adjust spacing and alignment for more compact design
2025-09-18 12:07:36 +08:00
Jason
9bf216b102 feat: add VS Code ChatGPT plugin config sync functionality 2025-09-18 10:58:03 +08:00
Jason
b69d7f7979 feat: add TOML validation for Codex config and improve code formatting
- Add real-time TOML syntax validation for Codex config field
- Validate config TOML when saving provider settings
- Format code to improve readability with proper error handling blocks
- Reorganize imports for better consistency

This ensures Codex config is valid TOML before saving, preventing runtime errors.
2025-09-18 09:33:58 +08:00
Jason
efff780eea fix: improve JSON validation with unified validation function
- Extract common validateJsonConfig function for reuse
- Apply unified validation to both main config and common config snippets
- Add real-time JSON validation to JsonEditor component using CodeMirror linter
- Simplify error handling without over-engineering error position extraction
2025-09-18 08:35:09 +08:00
Jason
19dcc84c83 refactor: extract error message handling to utils module
- Move extractErrorMessage function from App.tsx to utils/errorUtils.ts
- Improve code organization and reusability
- Enhance error notification with dynamic message extraction and timeout
2025-09-17 23:47:17 +08:00
Jason
4e9e63f524 fix: prevent automatic quote correction in TOML input fields
- Remove unnecessary normalizeSmartQuotes function and its usage
- Add input attributes to prevent automatic text correction (inputMode, data-gramm, etc.)
- Delete unused textNormalization.ts utility file
- Keep user's original input without any quote transformation
2025-09-17 22:39:45 +08:00
Jason
1d1440f52f Revert "feat: add common config snippet management system"
This reverts commit 36b78d1b4b.
2025-09-17 16:14:43 +08:00
Jason
36b78d1b4b feat: add common config snippet management system
- Add settings module for managing common configuration snippets
- Implement UI for creating, editing, and deleting snippets
- Add tauri-plugin-fs for file operations
- Replace co-authored setting with flexible snippet system
- Enable users to define custom config snippets for frequently used settings
2025-09-17 12:25:05 +08:00
Jason
2b59a5d51b feat: add common config support for Codex with TOML format
- Added separate common config state and storage for Codex
- Implemented TOML-based common config merging with markers
- Created UI components for Codex common config editor
- Added toggle and edit functionality similar to Claude config
- Store Codex common config in localStorage separately
- Support appending/removing common TOML snippets to config.toml
2025-09-17 10:44:30 +08:00
Jason
15c12c8e65 fix: resolve checkbox sync issue when editing common config snippet
The checkbox state was becoming out of sync when users edited the common config
snippet. Added a ref flag to track when updates are coming from common config
changes to prevent the handleChange function from incorrectly resetting the
checkbox state during these updates.
2025-09-17 10:36:28 +08:00
Jason
3256b2f842 feat(ui): unify modal window styles for common config editor
- Unified close button with X icon (size 18) matching main modal style
- Added Save icon to save button with consistent blue theme styling
- Aligned window structure with main modal (padding, borders, backdrop)
- Added ESC key support to close modal
- Enabled click-outside-to-close functionality
- Standardized text sizes (xl for title, sm for buttons and errors)
- Consistent hover effects and transitions across all buttons
- Matched footer background color with other modals (gray-100/gray-800)
2025-09-17 09:47:44 +08:00
Jason
7374b934c7 refactor: replace co-authored setting with flexible common config snippet feature
- Replace single "disable co-authored" checkbox with universal "common config snippet" functionality
- Add localStorage persistence for common config snippets
- Implement deep merge/remove operations for complex JSON structures
- Add modal editor for managing common config snippets
- Optimize performance with custom deepClone function instead of JSON.parse/stringify
- Fix deep remove logic to only delete matching values
- Improve error handling and validation for JSON snippets
2025-09-16 22:59:00 +08:00
Jason
d9d7c5c342 feat(ui): add Claude icon hover effect
Add orange color transition when hovering over Claude button icon for better visual feedback
2025-09-16 20:16:55 +08:00
Jason
f4f7e10953 fix(ui): prevent update button jitter when checking for updates
- Add min-width to update button to maintain consistent width across states
- Ensure all button states have consistent border styling with transparent borders
2025-09-16 16:23:47 +08:00
Jason
6ad7e04a95 feat(ui): add click-outside to close functionality for settings modal
- Restructure modal overlay to separate backdrop and content layers
- Add onMouseDown handler to close modal when clicking outside
- Improve backdrop styling with blur effect
2025-09-16 15:50:16 +08:00
Jason
7122e10646 feat(macos): handle dock icon click to restore window
Add macOS-specific handling for app.run() to catch Reopen events when
the dock icon is clicked, automatically unminimizing and showing the
main window with focus.
2025-09-16 10:42:59 +08:00
Jason Young
bb685be43d add star history 2025-09-15 21:49:32 +08:00
Jason
c5b3b4027f feat(ui): convert title to GitHub link
- Make "CC Switch" title clickable link to GitHub repository
- Remove separate GitHub icon for cleaner design
- Add hover effects for better user interaction
- Update repository URL to farion1231/cc-switch
2025-09-15 10:24:41 +08:00
Jason
daba6b094b fix(ui): refactor layout with fixed header and prevent layout shift
- Convert layout to flexbox with fixed header and scrollable content area
- Use overflow-y-scroll to always show scrollbar track
- Prevents content width changes when switching between apps
- Ensures consistent 12px spacing between header and content
2025-09-14 23:22:57 +08:00
Jason
711ad843ce feat(tray): hide window on close and refine tray UX; simplify icon handling
- Intercept CloseRequested to hide instead of exit (keep tray resident)
- Add 'Open Main' menu item; left-click shows menu; reduce tray click logs
- Use default app icon for tray for now; remove runtime PNG decoding
- Drop unused image dependency and tray resources entry
- Prepare path for future macOS template icon under icons/tray/macos
2025-09-14 21:55:41 +08:00
Jason
189a70280f feat(tray): hide window on close and refine tray UX
- Intercept CloseRequested to prevent close and hide the window so the process and tray remain running
- Add 'Open Main Window' item at the top of the tray menu and keep left-click to only show the menu (no auto window focus)
- Downgrade tray left-click log from info to debug to reduce console noise in dev
- Keep native behavior: the tray menu closes after selection; removed any transient title/tooltip feedback attempts

Files:
- src-tauri/src/lib.rs
2025-09-14 16:07:51 +08:00
39 changed files with 3300 additions and 297 deletions

View File

@@ -5,6 +5,25 @@ 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.3.0] - 2025-09-22
### ✨ Features
- Add “Apply to VS Code / Remove from VS Code” actions on provider cards, writing settings for Code/Insiders/VSCodium variants
- Enable VS Code auto-sync by default with window broadcast and tray hooks so Codex switches sync silently
- Extend the Codex provider wizard with display name, dedicated API key URL, and clearer guidance
- Introduce shared common config snippets with JSON/TOML reuse, validation, and consistent error surfaces
### 🔧 Improvements
- Keep the tray menu responsive when the window is hidden and standardize button styling and copy
- Disable modal backdrop blur on Linux (WebKitGTK/Wayland) to avoid freezes; restore the window when clicking the macOS Dock icon
- Support overriding config directories on WSL, refine placeholders/descriptions, and fix VS Code button wrapping on Windows
- Add a `created_at` timestamp to provider records for future sorting and analytics
### 🐛 Fixes
- Correct regex escapes and common snippet trimming in the Codex wizard to prevent validation issues
- Harden the VS Code sync flow with more reliable TOML/JSON parsing while reducing layout jank
- Bundle `@codemirror/lint` to reinstate live linting in config editors
## [3.2.0] - 2025-09-13
### ✨ New Features

View File

@@ -1,28 +1,27 @@
# Claude Code & Codex 供应商切换器
[![Version](https://img.shields.io/badge/version-3.2.0-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.3.0-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
一个用于管理和切换 Claude Code 与 Codex 不同供应商配置的桌面应用。
> v3.2.0 重点:全新 UI、macOS系统托盘、内置更新器、原子写入与回滚、改进暗色样式、单一事实源SSOT与一次性迁移/归档(详见下文“迁移与归档 v3.2.0”)
> v3.3.0 VS Code Codex 插件一键配置/移除默认自动同步、Codex 通用配置片段与自定义向导增强、WSL 环境支持、跨平台托盘与 UI 优化
> v3.2.0 :全新 UI、macOS系统托盘、内置更新器、原子写入与回滚、改进暗色样式、单一事实源SSOT与一次性迁移/归档。
> v3.1.0 :新增 Codex 供应商管理与一键切换,支持导入当前 Codex 配置为默认供应商,并在内部配置从 v1 → v2 迁移前自动备份(详见下文“迁移与归档”)。
> v3.0.0 重大更新:从 Electron 完全迁移到 Tauri 2.0,应用体积显著降低、启动性能大幅提升。
## 功能特性v3.2.0
## 功能特性v3.3.0
- **全新 UI**:感谢 [TinsFox](https://github.com/TinsFox) 大佬设计的全新 UI
- **系统托盘(菜单栏)快速切换**:按应用分组(Claude / Codex),勾选态展示当前供应商
- **内置更新器**:集成 Tauri Updater支持检测/下载/安装与一键重启
- **单一事实源SSOT**:不再写每个供应商的“副本文件”,统一存于 `~/.cc-switch/config.json`
- **一次性迁移/归档**:首次升级自动导入旧副本并归档原文件,之后不再持续归档
- **原子写入与回滚**:写入 `auth.json`/`config.toml`/`settings.json` 时避免半写状态
- **深色模式优化**Tailwind v4 适配与选择器修正
- **丰富预设与自定义**Qwen coder、Kimi、GLM、DeepSeek、PackyCode 等;可自定义 Base URL
- **本地优先与隐私**:全部信息存储在本地 `~/.cc-switch/config.json`
- **VS Code Codex 插件一键配置**:供应商卡片支持「应用到 VS Code / 从 VS Code 移除」,默认开启自动同步,并可跨 Code / Insiders / VSCodium 写入 `settings.json`
- **通用配置片段**Claude Codex 共用 JSON/TOML 片段,提供编辑器 lint、内容校验、统一错误提示与本地持久化
- **Codex 配置向导**:新增显示名称、专用 API Key URL、HTML5 校验与预设模板,方便快速配置第三方服务
- **系统托盘与快捷操作**:窗口隐藏时仍可通过托盘切换供应商,并在自动同步开启时触发 VS Code 写入
- **平台适配**:新增 Windows WSL 环境支持、Linux 自动禁用模态背景模糊解决白屏问题、macOS Dock 点击即可恢复窗口
- **UI优化**:多处 UI 和使用体验优化
## 界面预览
@@ -54,7 +53,7 @@
### Linux 用户
从 [Releases](../../releases) 页面下载最新版本的 `.deb` 包。
从 [Releases](../../releases) 页面下载最新版本的 `.deb`或者 `AppImage`安装包
## 使用说明
@@ -70,7 +69,7 @@
- 在“设置”中点击“检查更新”,若内置 Updater 配置可用将直接检测与下载;否则会回退打开 Releases 页面
### Codex 说明(v3.2.0 SSOT
### Codex 说明SSOT
- 配置目录:`~/.codex/`
- live 主配置:`auth.json`(必需)、`config.toml`(可为空)
@@ -82,7 +81,7 @@
- 导入默认:当该应用无任何供应商时,从现有 live 主配置创建一条默认项并设为当前
- 官方登录可切换到预设“Codex 官方登录”,重启终端后按官方流程登录
### Claude Code 说明(v3.2.0 SSOT
### Claude Code 说明SSOT
- 配置目录:`~/.claude/`
- live 主配置:`settings.json`(优先)或历史兼容 `claude.json`
@@ -94,9 +93,9 @@
- 导入默认:当该应用无任何供应商时,从现有 live 主配置创建一条默认项并设为当前
- 官方登录可切换到预设“Claude 官方登录”,重启终端后可使用 `/login` 完成登录
### 迁移与归档v3.2.0
### 迁移与归档(v3.2.0
- 一次性迁移:首次启动 3.2.0 会扫描旧的“副本文件”并合并到 `~/.cc-switch/config.json`
- 一次性迁移:首次启动 3.2.0 及以上版本会扫描旧的“副本文件”并合并到 `~/.cc-switch/config.json`
- Claude`~/.claude/settings-*.json`(排除 `settings.json` / 历史 `claude.json`
- Codex`~/.codex/auth-*.json``config-*.toml`(按名称成对合并)
- 去重与当前项:按“名称(忽略大小写)+ API Key”去重若当前为空将 live 合并项设为当前
@@ -196,6 +195,10 @@ cargo test
欢迎提交 Issue 和 Pull Request
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=farion1231/cc-switch&type=Date)](https://www.star-history.com/#farion1231/cc-switch&Date)
## License
MIT © Jason Young

View File

@@ -1,6 +1,6 @@
{
"name": "cc-switch",
"version": "3.2.0",
"version": "3.3.0",
"description": "Claude Code & Codex 供应商切换工具",
"scripts": {
"dev": "pnpm tauri dev",
@@ -27,14 +27,17 @@
},
"dependencies": {
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lint": "^6.8.5",
"@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.38.2",
"@tailwindcss/vite": "^4.1.13",
"@tauri-apps/api": "^2.8.0",
"@tauri-apps/plugin-dialog": "^2.4.0",
"@tauri-apps/plugin-process": "^2.0.0",
"@tauri-apps/plugin-updater": "^2.0.0",
"codemirror": "^6.0.2",
"jsonc-parser": "^3.2.1",
"lucide-react": "^0.542.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",

21
pnpm-lock.yaml generated
View File

@@ -11,6 +11,9 @@ importers:
'@codemirror/lang-json':
specifier: ^6.0.2
version: 6.0.2
'@codemirror/lint':
specifier: ^6.8.5
version: 6.8.5
'@codemirror/state':
specifier: ^6.5.2
version: 6.5.2
@@ -26,6 +29,9 @@ importers:
'@tauri-apps/api':
specifier: ^2.8.0
version: 2.8.0
'@tauri-apps/plugin-dialog':
specifier: ^2.4.0
version: 2.4.0
'@tauri-apps/plugin-process':
specifier: ^2.0.0
version: 2.3.0
@@ -35,6 +41,9 @@ importers:
codemirror:
specifier: ^6.0.2
version: 6.0.2
jsonc-parser:
specifier: ^3.2.1
version: 3.3.1
lucide-react:
specifier: ^0.542.0
version: 0.542.0(react@18.3.1)
@@ -632,6 +641,9 @@ packages:
engines: {node: '>= 10'}
hasBin: true
'@tauri-apps/plugin-dialog@2.4.0':
resolution: {integrity: sha512-OvXkrEBfWwtd8tzVCEXIvRfNEX87qs2jv6SqmVPiHcJjBhSF/GUvjqUNIDmKByb5N8nvDqVUM7+g1sXwdC/S9w==}
'@tauri-apps/plugin-process@2.3.0':
resolution: {integrity: sha512-0DNj6u+9csODiV4seSxxRbnLpeGYdojlcctCuLOCgpH9X3+ckVZIEj6H7tRQ7zqWr7kSTEWnrxtAdBb0FbtrmQ==}
@@ -755,6 +767,9 @@ packages:
engines: {node: '>=6'}
hasBin: true
jsonc-parser@3.3.1:
resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==}
lightningcss-darwin-arm64@1.30.1:
resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==}
engines: {node: '>= 12.0.0'}
@@ -1439,6 +1454,10 @@ snapshots:
'@tauri-apps/cli-win32-ia32-msvc': 2.8.1
'@tauri-apps/cli-win32-x64-msvc': 2.8.1
'@tauri-apps/plugin-dialog@2.4.0':
dependencies:
'@tauri-apps/api': 2.8.0
'@tauri-apps/plugin-process@2.3.0':
dependencies:
'@tauri-apps/api': 2.8.0
@@ -1580,6 +1599,8 @@ snapshots:
json5@2.2.3: {}
jsonc-parser@3.3.1: {}
lightningcss-darwin-arm64@1.30.1:
optional: true

336
src-tauri/Cargo.lock generated
View File

@@ -105,6 +105,27 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "ashpd"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cbdf310d77fd3aaee6ea2093db7011dc2d35d2eb3481e5607f1f8d942ed99df"
dependencies = [
"enumflags2",
"futures-channel",
"futures-util",
"rand 0.9.2",
"raw-window-handle",
"serde",
"serde_repr",
"tokio",
"url",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"zbus 5.11.0",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -559,7 +580,7 @@ dependencies = [
[[package]]
name = "cc-switch"
version = "3.2.0"
version = "3.3.0"
dependencies = [
"dirs 5.0.1",
"log",
@@ -569,6 +590,7 @@ dependencies = [
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-log",
"tauri-plugin-opener",
"tauri-plugin-process",
@@ -934,6 +956,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
dependencies = [
"bitflags 2.9.3",
"block2 0.6.1",
"libc",
"objc2 0.6.2",
]
@@ -948,6 +972,15 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "dlib"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412"
dependencies = [
"libloading",
]
[[package]]
name = "dlopen2"
version = "0.8.0"
@@ -971,6 +1004,12 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "dpi"
version = "0.1.2"
@@ -2351,6 +2390,19 @@ dependencies = [
"memoffset",
]
[[package]]
name = "nix"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
dependencies = [
"bitflags 2.9.3",
"cfg-if",
"cfg_aliases 0.2.1",
"libc",
"memoffset",
]
[[package]]
name = "nodrop"
version = "0.1.14"
@@ -2986,7 +3038,7 @@ checksum = "3af6b589e163c5a788fab00ce0c0366f6efbb9959c2f9874b224936af7fce7e1"
dependencies = [
"base64 0.22.1",
"indexmap 2.11.0",
"quick-xml",
"quick-xml 0.38.2",
"serde",
"time",
]
@@ -3068,6 +3120,15 @@ dependencies = [
"toml_edit 0.20.2",
]
[[package]]
name = "proc-macro-crate"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983"
dependencies = [
"toml_edit 0.23.4",
]
[[package]]
name = "proc-macro-error"
version = "1.0.4"
@@ -3127,6 +3188,15 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "quick-xml"
version = "0.37.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.38.2"
@@ -3458,6 +3528,31 @@ dependencies = [
"webpki-roots",
]
[[package]]
name = "rfd"
version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed"
dependencies = [
"ashpd",
"block2 0.6.1",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2 0.6.2",
"objc2-app-kit 0.3.1",
"objc2-core-foundation",
"objc2-foundation 0.3.1",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.59.0",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -3658,6 +3753,12 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "scoped-tls"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -4320,6 +4421,46 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0beee42a4002bc695550599b011728d9dfabf82f767f134754ed6655e434824e"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.16",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "315784ec4be45e90a987687bae7235e6be3d6e9e350d2b75c16b8a4bf22c1db7"
dependencies = [
"anyhow",
"dunce",
"glob",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.16",
"toml 0.9.5",
"url",
]
[[package]]
name = "tauri-plugin-log"
version = "2.6.0"
@@ -4361,7 +4502,7 @@ dependencies = [
"thiserror 2.0.16",
"url",
"windows 0.58.0",
"zbus",
"zbus 4.0.1",
]
[[package]]
@@ -4640,8 +4781,10 @@ dependencies = [
"libc",
"mio",
"pin-project-lite",
"signal-hook-registry",
"slab",
"socket2",
"tracing",
"windows-sys 0.59.0",
]
@@ -4737,6 +4880,18 @@ dependencies = [
"winnow 0.5.40",
]
[[package]]
name = "toml_edit"
version = "0.23.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7211ff1b8f0d3adae1663b7da9ffe396eabe1ca25f0b0bee42b0da29a9ddce93"
dependencies = [
"indexmap 2.11.0",
"toml_datetime 0.7.0",
"toml_parser",
"winnow 0.7.13",
]
[[package]]
name = "toml_parser"
version = "1.0.2"
@@ -5154,6 +5309,66 @@ dependencies = [
"web-sys",
]
[[package]]
name = "wayland-backend"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35"
dependencies = [
"cc",
"downcast-rs",
"rustix",
"scoped-tls",
"smallvec",
"wayland-sys",
]
[[package]]
name = "wayland-client"
version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d"
dependencies = [
"bitflags 2.9.3",
"rustix",
"wayland-backend",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols"
version = "0.32.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901"
dependencies = [
"bitflags 2.9.3",
"wayland-backend",
"wayland-client",
"wayland-scanner",
]
[[package]]
name = "wayland-scanner"
version = "0.31.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3"
dependencies = [
"proc-macro2",
"quick-xml 0.37.5",
"quote",
]
[[package]]
name = "wayland-sys"
version = "0.31.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142"
dependencies = [
"dlib",
"log",
"pkg-config",
]
[[package]]
name = "web-sys"
version = "0.3.77"
@@ -5795,6 +6010,9 @@ name = "winnow"
version = "0.7.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf"
dependencies = [
"memchr",
]
[[package]]
name = "winreg"
@@ -5963,7 +6181,7 @@ dependencies = [
"futures-sink",
"futures-util",
"hex",
"nix",
"nix 0.27.1",
"ordered-stream",
"rand 0.8.5",
"serde",
@@ -5974,9 +6192,37 @@ dependencies = [
"uds_windows",
"windows-sys 0.52.0",
"xdg-home",
"zbus_macros",
"zbus_names",
"zvariant",
"zbus_macros 4.0.1",
"zbus_names 3.0.0",
"zvariant 4.0.0",
]
[[package]]
name = "zbus"
version = "5.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d07e46d035fb8e375b2ce63ba4e4ff90a7f73cf2ffb0138b29e1158d2eaadf7"
dependencies = [
"async-broadcast",
"async-recursion",
"async-trait",
"enumflags2",
"event-listener",
"futures-core",
"futures-lite",
"hex",
"nix 0.30.1",
"ordered-stream",
"serde",
"serde_repr",
"tokio",
"tracing",
"uds_windows",
"windows-sys 0.60.2",
"winnow 0.7.13",
"zbus_macros 5.11.0",
"zbus_names 4.2.0",
"zvariant 5.7.0",
]
[[package]]
@@ -5990,7 +6236,22 @@ dependencies = [
"quote",
"regex",
"syn 1.0.109",
"zvariant_utils",
"zvariant_utils 1.1.0",
]
[[package]]
name = "zbus_macros"
version = "5.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57e797a9c847ed3ccc5b6254e8bcce056494b375b511b3d6edcec0aeb4defaca"
dependencies = [
"proc-macro-crate 3.4.0",
"proc-macro2",
"quote",
"syn 2.0.106",
"zbus_names 4.2.0",
"zvariant 5.7.0",
"zvariant_utils 3.2.1",
]
[[package]]
@@ -6001,7 +6262,19 @@ checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
dependencies = [
"serde",
"static_assertions",
"zvariant",
"zvariant 4.0.0",
]
[[package]]
name = "zbus_names"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97"
dependencies = [
"serde",
"static_assertions",
"winnow 0.7.13",
"zvariant 5.7.0",
]
[[package]]
@@ -6106,7 +6379,22 @@ dependencies = [
"enumflags2",
"serde",
"static_assertions",
"zvariant_derive",
"zvariant_derive 4.0.0",
]
[[package]]
name = "zvariant"
version = "5.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "999dd3be73c52b1fccd109a4a81e4fcd20fab1d3599c8121b38d04e1419498db"
dependencies = [
"endi",
"enumflags2",
"serde",
"url",
"winnow 0.7.13",
"zvariant_derive 5.7.0",
"zvariant_utils 3.2.1",
]
[[package]]
@@ -6119,7 +6407,20 @@ dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
"zvariant_utils",
"zvariant_utils 1.1.0",
]
[[package]]
name = "zvariant_derive"
version = "5.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6643fd0b26a46d226bd90d3f07c1b5321fe9bb7f04673cb37ac6d6883885b68e"
dependencies = [
"proc-macro-crate 3.4.0",
"proc-macro2",
"quote",
"syn 2.0.106",
"zvariant_utils 3.2.1",
]
[[package]]
@@ -6132,3 +6433,16 @@ dependencies = [
"quote",
"syn 1.0.109",
]
[[package]]
name = "zvariant_utils"
version = "3.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6949d142f89f6916deca2232cf26a8afacf2b9fdc35ce766105e104478be599"
dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 2.0.106",
"winnow 0.7.13",
]

View File

@@ -1,6 +1,6 @@
[package]
name = "cc-switch"
version = "3.2.0"
version = "3.3.0"
description = "Claude Code & Codex 供应商配置管理工具"
authors = ["Jason Young"]
license = "MIT"
@@ -26,6 +26,7 @@ tauri-plugin-log = "2"
tauri-plugin-opener = "2"
tauri-plugin-process = "2"
tauri-plugin-updater = "2"
tauri-plugin-dialog = "2"
dirs = "5.0"
toml = "0.8"

View File

@@ -9,6 +9,7 @@
"core:default",
"opener:default",
"updater:default",
"process:allow-restart"
"process:allow-restart",
"dialog:default"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 564 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 572 KiB

View File

@@ -10,6 +10,10 @@ use std::path::Path;
/// 获取 Codex 配置目录路径
pub fn get_codex_config_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_codex_override_dir() {
return custom;
}
dirs::home_dir().expect("无法获取用户主目录").join(".codex")
}

View File

@@ -3,12 +3,45 @@
use std::collections::HashMap;
use tauri::State;
use tauri_plugin_opener::OpenerExt;
use tauri_plugin_dialog::DialogExt;
use crate::app_config::AppType;
use crate::codex_config;
use crate::config::{get_claude_settings_path, ConfigStatus};
use crate::config::{self, get_claude_settings_path, ConfigStatus};
use crate::provider::Provider;
use crate::store::AppState;
use crate::vscode;
fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), String> {
match app_type {
AppType::Claude => {
if !provider.settings_config.is_object() {
return Err("Claude 配置必须是 JSON 对象".to_string());
}
}
AppType::Codex => {
let settings = provider
.settings_config
.as_object()
.ok_or_else(|| "Codex 配置必须是 JSON 对象".to_string())?;
let auth = settings
.get("auth")
.ok_or_else(|| "Codex 配置缺少 auth 字段".to_string())?;
if !auth.is_object() {
return Err("Codex auth 配置必须是 JSON 对象".to_string());
}
if let Some(config_value) = settings.get("config") {
if !(config_value.is_string() || config_value.is_null()) {
return Err("Codex config 字段必须是字符串".to_string());
}
if let Some(cfg_text) = config_value.as_str() {
codex_config::validate_config_toml(cfg_text)?;
}
}
}
}
Ok(())
}
/// 获取所有供应商
#[tauri::command]
@@ -74,6 +107,8 @@ pub async fn add_provider(
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
validate_provider_settings(&app_type, &provider)?;
// 读取当前是否是激活供应商(短锁)
let is_current = {
let config = state
@@ -139,6 +174,8 @@ pub async fn update_provider(
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
validate_provider_settings(&app_type, &provider)?;
// 读取校验 & 是否当前(短锁)
let (exists, is_current) = {
let config = state
@@ -483,6 +520,26 @@ pub async fn get_claude_code_config_path() -> Result<String, String> {
Ok(get_claude_settings_path().to_string_lossy().to_string())
}
/// 获取当前生效的配置目录
#[tauri::command]
pub async fn get_config_dir(
app_type: Option<AppType>,
app: Option<String>,
appType: Option<String>,
) -> Result<String, 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);
let dir = match app {
AppType::Claude => config::get_claude_config_dir(),
AppType::Codex => codex_config::get_codex_config_dir(),
};
Ok(dir.to_string_lossy().to_string())
}
/// 打开配置文件夹
/// 兼容两种参数:`app_type`(推荐)或 `app`(字符串)
#[tauri::command]
@@ -516,6 +573,38 @@ pub async fn open_config_folder(
Ok(true)
}
/// 弹出系统目录选择器并返回用户选择的路径
#[tauri::command]
pub async fn pick_directory(
app: tauri::AppHandle,
default_path: Option<String>,
) -> Result<Option<String>, String> {
let initial = default_path
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty());
let result = tauri::async_runtime::spawn_blocking(move || {
let mut builder = app.dialog().file();
if let Some(path) = initial {
builder = builder.set_directory(path);
}
builder.blocking_pick_folder()
})
.await
.map_err(|e| format!("弹出目录选择器失败: {}", e))?;
match result {
Some(file_path) => {
let resolved = file_path
.simplified()
.into_path()
.map_err(|e| format!("解析选择的目录失败: {}", e))?;
Ok(Some(resolved.to_string_lossy().to_string()))
}
None => Ok(None),
}
}
/// 打开外部链接
#[tauri::command]
pub async fn open_external(app: tauri::AppHandle, url: String) -> Result<bool, String> {
@@ -566,21 +655,15 @@ pub async fn open_app_config_folder(handle: tauri::AppHandle) -> Result<bool, St
/// 获取设置
#[tauri::command]
pub async fn get_settings(_state: State<'_, AppState>) -> Result<serde_json::Value, String> {
// 暂时返回默认设置:系统托盘(菜单栏)显示开关
Ok(serde_json::json!({
"showInTray": true
}))
pub async fn get_settings() -> Result<serde_json::Value, String> {
serde_json::to_value(crate::settings::get_settings())
.map_err(|e| format!("序列化设置失败: {}", e))
}
/// 保存设置
#[tauri::command]
pub async fn save_settings(
_state: State<'_, AppState>,
settings: serde_json::Value,
) -> Result<bool, String> {
// TODO: 实现系统托盘显示开关的保存与应用(显示/隐藏菜单栏托盘图标)
log::info!("保存设置: {:?}", settings);
pub async fn save_settings(settings: crate::settings::AppSettings) -> Result<bool, String> {
crate::settings::update_settings(settings)?;
Ok(true)
}
@@ -598,3 +681,36 @@ pub async fn check_for_updates(handle: tauri::AppHandle) -> Result<bool, String>
Ok(true)
}
/// VS Code: 获取用户 settings.json 状态
#[tauri::command]
pub async fn get_vscode_settings_status() -> Result<ConfigStatus, String> {
if let Some(p) = vscode::find_existing_settings() {
Ok(ConfigStatus { exists: true, path: p.to_string_lossy().to_string() })
} else {
// 默认返回 macOS 稳定版路径(或其他平台首选项的第一个候选),但标记不存在
let preferred = vscode::candidate_settings_paths().into_iter().next();
Ok(ConfigStatus { exists: false, path: preferred.unwrap_or_default().to_string_lossy().to_string() })
}
}
/// VS Code: 读取 settings.json 文本(仅当文件存在)
#[tauri::command]
pub async fn read_vscode_settings() -> Result<String, String> {
if let Some(p) = vscode::find_existing_settings() {
std::fs::read_to_string(&p).map_err(|e| format!("读取 VS Code 设置失败: {}", e))
} else {
Err("未找到 VS Code 用户设置文件".to_string())
}
}
/// VS Code: 写入 settings.json 文本(仅当文件存在;不自动创建)
#[tauri::command]
pub async fn write_vscode_settings(content: String) -> Result<bool, String> {
if let Some(p) = vscode::find_existing_settings() {
config::write_text_file(&p, &content)?;
Ok(true)
} else {
Err("未找到 VS Code 用户设置文件".to_string())
}
}

View File

@@ -6,6 +6,10 @@ use std::path::{Path, PathBuf};
/// 获取 Claude Code 配置目录路径
pub fn get_claude_config_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_claude_override_dir() {
return custom;
}
dirs::home_dir()
.expect("无法获取用户主目录")
.join(".claude")
@@ -175,7 +179,19 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> {
}
}
fs::rename(&tmp, path).map_err(|e| format!("原子替换失败: {}", e))?;
#[cfg(windows)]
{
// Windows 上 rename 目标存在会失败,先移除再重命名(尽量接近原子性)
if path.exists() {
let _ = fs::remove_file(path);
}
fs::rename(&tmp, path).map_err(|e| format!("原子替换失败: {}", e))?;
}
#[cfg(not(windows))]
{
fs::rename(&tmp, path).map_err(|e| format!("原子替换失败: {}", e))?;
}
Ok(())
}

View File

@@ -4,12 +4,16 @@ mod commands;
mod config;
mod migration;
mod provider;
mod settings;
mod store;
mod vscode;
use store::AppState;
#[cfg(target_os = "macos")]
use tauri::RunEvent;
use tauri::{
menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
tray::{TrayIconBuilder, TrayIconEvent},
};
use tauri::{Emitter, Manager};
@@ -25,6 +29,11 @@ fn create_tray_menu(
let mut menu_builder = MenuBuilder::new(app);
// 顶部:打开主界面
let show_main_item = MenuItem::with_id(app, "show_main", "打开主界面", true, None::<&str>)
.map_err(|e| format!("创建打开主界面菜单失败: {}", e))?;
menu_builder = menu_builder.item(&show_main_item).separator();
// 直接添加所有供应商到主菜单(扁平化结构,更简单可靠)
if let Some(claude_manager) = config.get_manager(&crate::app_config::AppType::Claude) {
// 添加Claude标题禁用状态仅作为分组标识
@@ -112,6 +121,13 @@ fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
log::info!("处理托盘菜单事件: {}", event_id);
match event_id {
"show_main" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}
"quit" => {
log::info!("退出应用");
app.exit(0);
@@ -130,7 +146,9 @@ fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
provider_id,
)
.await
{ log::error!("切换Claude供应商失败: {}", e); }
{
log::error!("切换Claude供应商失败: {}", e);
}
});
}
id if id.starts_with("codex_") => {
@@ -147,7 +165,9 @@ fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
provider_id,
)
.await
{ log::error!("切换Codex供应商失败: {}", e); }
{
log::error!("切换Codex供应商失败: {}", e);
}
});
}
_ => {
@@ -156,6 +176,8 @@ fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
}
}
//
/// 内部切换供应商函数
async fn switch_provider_internal(
app: &tauri::AppHandle,
@@ -215,8 +237,17 @@ async fn update_tray_menu(
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
let builder = tauri::Builder::default()
// 拦截窗口关闭:仅隐藏窗口,保持进程与托盘常驻
.on_window_event(|window, event| match event {
tauri::WindowEvent::CloseRequested { api, .. } => {
api.prevent_close();
let _ = window.hide();
}
_ => {}
})
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.setup(|app| {
// 注册 Updater 插件(桌面端)
@@ -290,33 +321,23 @@ pub fn run() {
// 创建动态托盘菜单
let menu = create_tray_menu(&app.handle(), &app_state)?;
let _tray = TrayIconBuilder::with_id("main")
.on_tray_icon_event(|tray, event| match event {
TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} => {
log::info!("left click pressed and released");
// 在这个例子中,当点击托盘图标时,将展示并聚焦于主窗口
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}
_ => {
log::debug!("unhandled event {event:?}");
}
// 构建托盘
let mut tray_builder = TrayIconBuilder::with_id("main")
.on_tray_icon_event(|_tray, event| match event {
// 左键点击已通过 show_menu_on_left_click(true) 打开菜单,这里不再额外处理
TrayIconEvent::Click { .. } => {}
_ => log::debug!("unhandled event {event:?}"),
})
.menu(&menu)
.on_menu_event(|app, event| {
handle_tray_menu_event(app, &event.id.0);
})
.icon(app.default_window_icon().unwrap().clone())
.show_menu_on_left_click(true)
.build(app)?;
.show_menu_on_left_click(true);
// 统一使用应用默认图标;待托盘模板图标就绪后再启用
tray_builder = tray_builder.icon(app.default_window_icon().unwrap().clone());
let _tray = tray_builder.build(app)?;
// 将同一个实例注入到全局状态,避免重复创建导致的不一致
app.manage(app_state);
Ok(())
@@ -332,15 +353,42 @@ pub fn run() {
commands::get_claude_config_status,
commands::get_config_status,
commands::get_claude_code_config_path,
commands::get_config_dir,
commands::open_config_folder,
commands::pick_directory,
commands::open_external,
commands::get_app_config_path,
commands::open_app_config_folder,
commands::get_settings,
commands::save_settings,
commands::check_for_updates,
commands::get_vscode_settings_status,
commands::read_vscode_settings,
commands::write_vscode_settings,
update_tray_menu,
])
.run(tauri::generate_context!())
]);
let app = builder
.build(tauri::generate_context!())
.expect("error while running tauri application");
app.run(|app_handle, event| {
#[cfg(target_os = "macos")]
// macOS 在 Dock 图标被点击并重新激活应用时会触发 Reopen 事件,这里手动恢复主窗口
match event {
RunEvent::Reopen { .. } => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}
_ => {}
}
#[cfg(not(target_os = "macos"))]
{
let _ = (app_handle, event);
}
});
}

View File

@@ -148,8 +148,7 @@ pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result<bool, S
// 如果已迁移过则跳过;若目录不存在则先创建,避免新装用户写入标记时失败
let marker = get_marker_path();
if let Some(parent) = marker.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("创建迁移标记目录失败: {}", e))?;
std::fs::create_dir_all(parent).map_err(|e| format!("创建迁移标记目录失败: {}", e))?;
}
if marker.exists() {
return Ok(false);

View File

@@ -16,6 +16,9 @@ pub struct Provider {
pub website_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "createdAt")]
pub created_at: Option<i64>,
}
impl Provider {
@@ -32,6 +35,7 @@ impl Provider {
settings_config,
website_url,
category: None,
created_at: None,
}
}
}

147
src-tauri/src/settings.rs Normal file
View File

@@ -0,0 +1,147 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::sync::{OnceLock, RwLock};
/// 应用设置结构,允许覆盖默认配置目录
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppSettings {
#[serde(default = "default_show_in_tray")]
pub show_in_tray: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claude_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codex_config_dir: Option<String>,
}
fn default_show_in_tray() -> bool {
true
}
impl Default for AppSettings {
fn default() -> Self {
Self {
show_in_tray: true,
claude_config_dir: None,
codex_config_dir: None,
}
}
}
impl AppSettings {
fn settings_path() -> PathBuf {
crate::config::get_app_config_dir().join("settings.json")
}
fn normalize_paths(&mut self) {
self.claude_config_dir = self
.claude_config_dir
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
self.codex_config_dir = self
.codex_config_dir
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
}
pub fn load() -> Self {
let path = Self::settings_path();
if let Ok(content) = fs::read_to_string(&path) {
match serde_json::from_str::<AppSettings>(&content) {
Ok(mut settings) => {
settings.normalize_paths();
settings
}
Err(err) => {
log::warn!(
"解析设置文件失败,将使用默认设置。路径: {}, 错误: {}",
path.display(),
err
);
Self::default()
}
}
} else {
Self::default()
}
}
pub fn save(&self) -> Result<(), String> {
let mut normalized = self.clone();
normalized.normalize_paths();
let path = Self::settings_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("创建设置目录失败: {}", e))?;
}
let json = serde_json::to_string_pretty(&normalized)
.map_err(|e| format!("序列化设置失败: {}", e))?;
fs::write(&path, json).map_err(|e| format!("写入设置失败: {}", e))?;
Ok(())
}
}
fn settings_store() -> &'static RwLock<AppSettings> {
static STORE: OnceLock<RwLock<AppSettings>> = OnceLock::new();
STORE.get_or_init(|| RwLock::new(AppSettings::load()))
}
fn resolve_override_path(raw: &str) -> PathBuf {
if raw == "~" {
if let Some(home) = dirs::home_dir() {
return home;
}
} else if let Some(stripped) = raw.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(stripped);
}
} else if let Some(stripped) = raw.strip_prefix("~\\") {
if let Some(home) = dirs::home_dir() {
return home.join(stripped);
}
}
PathBuf::from(raw)
}
pub fn get_settings() -> AppSettings {
settings_store()
.read()
.expect("读取设置锁失败")
.clone()
}
pub fn update_settings(mut new_settings: AppSettings) -> Result<(), String> {
new_settings.normalize_paths();
new_settings.save()?;
let mut guard = settings_store()
.write()
.expect("写入设置锁失败");
*guard = new_settings;
Ok(())
}
pub fn get_claude_override_dir() -> Option<PathBuf> {
let settings = settings_store().read().ok()?;
settings
.claude_config_dir
.as_ref()
.map(|p| resolve_override_path(p))
}
pub fn get_codex_override_dir() -> Option<PathBuf> {
let settings = settings_store().read().ok()?;
settings
.codex_config_dir
.as_ref()
.map(|p| resolve_override_path(p))
}

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

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

View File

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

View File

@@ -11,9 +11,14 @@ import { UpdateBadge } from "./components/UpdateBadge";
import { Plus, Settings, Moon, Sun } from "lucide-react";
import { buttonStyles } from "./lib/styles";
import { useDarkMode } from "./hooks/useDarkMode";
import { extractErrorMessage } from "./utils/errorUtils";
import { applyProviderToVSCode } from "./utils/vscodeSettings";
import { getCodexBaseUrl } from "./utils/providerConfigUtils";
import { useVSCodeAutoSync } from "./hooks/useVSCodeAutoSync";
function App() {
const { isDarkMode, toggleDarkMode } = useDarkMode();
const { isAutoSyncEnabled } = useVSCodeAutoSync();
const [activeApp, setActiveApp] = useState<AppType>("claude");
const [providers, setProviders] = useState<Record<string, Provider>>({});
const [currentProviderId, setCurrentProviderId] = useState<string>("");
@@ -75,7 +80,7 @@ function App() {
};
}, []);
// 监听托盘切换事件
// 监听托盘切换事件(包括菜单切换)
useEffect(() => {
let unlisten: (() => void) | null = null;
@@ -90,6 +95,11 @@ function App() {
if (data.appType === activeApp) {
await loadProviders();
}
// 若为 Codex 且开启自动同步,则静默同步到 VS Code覆盖
if (data.appType === "codex" && isAutoSyncEnabled) {
await syncCodexToVSCode(data.providerId, true);
}
});
} catch (error) {
console.error("设置供应商切换监听器失败:", error);
@@ -104,7 +114,7 @@ function App() {
unlisten();
}
};
}, [activeApp]); // 依赖activeApp切换应用时重新设置监听器
}, [activeApp, isAutoSyncEnabled]);
const loadProviders = async () => {
const loadedProviders = await window.api.getProviders(activeApp);
@@ -148,7 +158,11 @@ function App() {
} catch (error) {
console.error("更新供应商失败:", error);
setEditingProviderId(null);
showNotification("保存失败,请重试", "error");
const errorMessage = extractErrorMessage(error);
const message = errorMessage
? `保存失败:${errorMessage}`
: "保存失败,请重试";
showNotification(message, "error", errorMessage ? 6000 : 3000);
}
};
@@ -169,6 +183,64 @@ function App() {
});
};
// 同步Codex供应商到VS Code设置静默覆盖
const syncCodexToVSCode = async (providerId: string, silent = false) => {
try {
const status = await window.api.getVSCodeSettingsStatus();
if (!status.exists) {
if (!silent) {
showNotification(
"未找到 VS Code 用户设置文件 (settings.json)",
"error",
3000,
);
}
return;
}
const raw = await window.api.readVSCodeSettings();
const provider = providers[providerId];
const isOfficial = provider?.category === "official";
// 非官方供应商需要解析 base_url使用公共工具函数
let baseUrl: string | undefined = undefined;
if (!isOfficial) {
const parsed = getCodexBaseUrl(provider);
if (!parsed) {
if (!silent) {
showNotification(
"当前配置缺少 base_url无法写入 VS Code",
"error",
4000,
);
}
return;
}
baseUrl = parsed;
}
const updatedSettings = applyProviderToVSCode(raw, {
baseUrl,
isOfficial,
});
if (updatedSettings !== raw) {
await window.api.writeVSCodeSettings(updatedSettings);
if (!silent) {
showNotification("已同步到 VS Code", "success", 1500);
}
}
// 触发providers重新加载以更新VS Code按钮状态
await loadProviders();
} catch (error: any) {
console.error("同步到VS Code失败:", error);
if (!silent) {
const errorMessage = error?.message || "同步 VS Code 失败";
showNotification(errorMessage, "error", 5000);
}
}
};
const handleSwitchProvider = async (id: string) => {
const success = await window.api.switchProvider(id, activeApp);
if (success) {
@@ -182,6 +254,11 @@ function App() {
);
// 更新托盘菜单
await window.api.updateTrayMenu();
// Codex: 切换供应商后,只在自动同步启用时同步到 VS Code
if (activeApp === "codex" && isAutoSyncEnabled) {
await syncCodexToVSCode(id, true); // silent模式不显示通知
}
} else {
showNotification("切换失败,请检查配置", "error");
}
@@ -206,14 +283,20 @@ function App() {
};
return (
<div className="min-h-screen flex flex-col bg-gray-50 dark:bg-gray-950">
{/* Linear 风格的顶部导航 */}
<header className="bg-white border-b border-gray-200 dark:bg-gray-900 dark:border-gray-800 px-6 py-4">
<div className="h-screen flex flex-col bg-gray-50 dark:bg-gray-950">
{/* 顶部导航区域 - 固定高度 */}
<header className="flex-shrink-0 bg-white border-b border-gray-200 dark:bg-gray-900 dark:border-gray-800 px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h1 className="text-xl font-semibold text-blue-500 dark:text-blue-400">
<a
href="https://github.com/farion1231/cc-switch"
target="_blank"
rel="noopener noreferrer"
className="text-xl font-semibold text-blue-500 dark:text-blue-400 hover:text-blue-600 dark:hover:text-blue-300 transition-colors"
title="在 GitHub 上查看"
>
CC Switch
</h1>
</a>
<button
onClick={toggleDarkMode}
className={buttonStyles.icon}
@@ -247,29 +330,33 @@ function App() {
</div>
</header>
{/* 主内容区域 */}
<main className="flex-1 p-6">
<div className="max-w-4xl mx-auto">
{/* 通知组件 */}
{notification && (
<div
className={`fixed top-6 left-1/2 transform -translate-x-1/2 z-50 px-4 py-3 rounded-lg shadow-lg transition-all duration-300 ${
notification.type === "error"
? "bg-red-500 text-white"
: "bg-green-500 text-white"
} ${isNotificationVisible ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-2"}`}
>
{notification.message}
</div>
)}
{/* 主内容区域 - 独立滚动 */}
<main className="flex-1 overflow-y-scroll">
<div className="pt-3 px-6 pb-6">
<div className="max-w-4xl mx-auto">
{/* 通知组件 - 相对于视窗定位 */}
{notification && (
<div
className={`fixed top-20 left-1/2 transform -translate-x-1/2 z-50 px-4 py-3 rounded-lg shadow-lg transition-all duration-300 ${
notification.type === "error"
? "bg-red-500 text-white"
: "bg-green-500 text-white"
} ${isNotificationVisible ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-2"}`}
>
{notification.message}
</div>
)}
<ProviderList
providers={providers}
currentProviderId={currentProviderId}
onSwitch={handleSwitchProvider}
onDelete={handleDeleteProvider}
onEdit={setEditingProviderId}
/>
<ProviderList
providers={providers}
currentProviderId={currentProviderId}
onSwitch={handleSwitchProvider}
onDelete={handleDeleteProvider}
onEdit={setEditingProviderId}
appType={activeApp}
onNotify={showNotification}
/>
</div>
</div>
</main>

View File

@@ -17,7 +17,7 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
<button
type="button"
onClick={() => handleSwitch("claude")}
className={`inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
className={`group inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
activeApp === "claude"
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100 dark:shadow-none"
: "text-gray-500 hover:text-gray-900 hover:bg-white/50 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800/60"
@@ -26,7 +26,9 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
<ClaudeIcon
size={16}
className={
activeApp === "claude" ? "text-[#D97757] dark:text-[#D97757]" : ""
activeApp === "claude"
? "text-[#D97757] dark:text-[#D97757] transition-colors duration-200"
: "text-gray-500 dark:text-gray-400 group-hover:text-[#D97757] dark:group-hover:text-[#D97757] transition-colors duration-200"
}
/>
<span>Claude</span>

View File

@@ -1,5 +1,6 @@
import React from "react";
import { AlertTriangle, X } from "lucide-react";
import { isLinux } from "../lib/platform";
interface ConfirmDialogProps {
isOpen: boolean;
@@ -26,7 +27,7 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
className={`absolute inset-0 bg-black/50${isLinux() ? "" : " backdrop-blur-sm"}`}
onClick={onCancel}
/>

View File

@@ -1,9 +1,10 @@
import React, { useRef, useEffect } from "react";
import React, { useRef, useEffect, useMemo } from "react";
import { EditorView, basicSetup } from "codemirror";
import { json } from "@codemirror/lang-json";
import { oneDark } from "@codemirror/theme-one-dark";
import { EditorState } from "@codemirror/state";
import { placeholder } from "@codemirror/view";
import { linter, Diagnostic } from "@codemirror/lint";
interface JsonEditorProps {
value: string;
@@ -11,6 +12,7 @@ interface JsonEditorProps {
placeholder?: string;
darkMode?: boolean;
rows?: number;
showValidation?: boolean;
}
const JsonEditor: React.FC<JsonEditorProps> = ({
@@ -19,10 +21,50 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
placeholder: placeholderText = "",
darkMode = false,
rows = 12,
showValidation = true,
}) => {
const editorRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
// JSON linter 函数
const jsonLinter = useMemo(
() =>
linter((view) => {
const diagnostics: Diagnostic[] = [];
if (!showValidation) return diagnostics;
const doc = view.state.doc.toString();
if (!doc.trim()) return diagnostics;
try {
const parsed = JSON.parse(doc);
// 检查是否是JSON对象
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
// 格式正确
} else {
diagnostics.push({
from: 0,
to: doc.length,
severity: "error",
message: "配置必须是JSON对象不能是数组或其他类型",
});
}
} catch (e) {
// 简单处理JSON解析错误
const message = e instanceof SyntaxError ? e.message : "JSON格式错误";
diagnostics.push({
from: 0,
to: doc.length,
severity: "error",
message,
});
}
return diagnostics;
}),
[showValidation],
);
useEffect(() => {
if (!editorRef.current) return;
@@ -43,6 +85,7 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
json(),
placeholder(placeholderText || ""),
sizingTheme,
jsonLinter,
EditorView.updateListener.of((update) => {
if (update.docChanged) {
const newValue = update.state.doc.toString();
@@ -75,7 +118,7 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
view.destroy();
viewRef.current = null;
};
}, [darkMode, rows]); // 依赖项中不包含 onChange 和 placeholder避免不必要的重建
}, [darkMode, rows, jsonLinter]); // 依赖项中不包含 onChange 和 placeholder避免不必要的重建
// 当 value 从外部改变时更新编辑器内容
useEffect(() => {

View File

@@ -1,23 +1,39 @@
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useRef } from "react";
import { Provider, ProviderCategory } from "../types";
import { AppType } from "../lib/tauri-api";
import {
updateCoAuthoredSetting,
checkCoAuthoredSetting,
updateCommonConfigSnippet,
hasCommonConfigSnippet,
getApiKeyFromConfig,
hasApiKeyField,
setApiKeyInConfig,
updateTomlCommonConfigSnippet,
hasTomlCommonConfigSnippet,
validateJsonConfig,
} from "../utils/providerConfigUtils";
import { providerPresets } from "../config/providerPresets";
import { codexProviderPresets } from "../config/codexProviderPresets";
import {
codexProviderPresets,
generateThirdPartyAuth,
generateThirdPartyConfig,
} from "../config/codexProviderPresets";
import PresetSelector from "./ProviderForm/PresetSelector";
import ApiKeyInput from "./ProviderForm/ApiKeyInput";
import ClaudeConfigEditor from "./ProviderForm/ClaudeConfigEditor";
import CodexConfigEditor from "./ProviderForm/CodexConfigEditor";
import KimiModelSelector from "./ProviderForm/KimiModelSelector";
import { X, AlertCircle, Save } from "lucide-react";
import { isLinux } from "../lib/platform";
// 分类仅用于控制少量交互(如官方禁用 API Key不显示介绍组件
const COMMON_CONFIG_STORAGE_KEY = "cc-switch:common-config-snippet";
const CODEX_COMMON_CONFIG_STORAGE_KEY = "cc-switch:codex-common-config-snippet";
const DEFAULT_COMMON_CONFIG_SNIPPET = `{
"includeCoAuthoredBy": false
}`;
const DEFAULT_CODEX_COMMON_CONFIG_SNIPPET = `# Common Codex config
# Add your common TOML configuration here`;
interface ProviderFormProps {
appType?: AppType;
title: string;
@@ -48,7 +64,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
: "",
});
const [category, setCategory] = useState<ProviderCategory | undefined>(
initialData?.category,
initialData?.category
);
// Claude 模型配置状态
@@ -57,14 +73,29 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
const [baseUrl, setBaseUrl] = useState(""); // 新增:基础 URL 状态
// Codex 特有的状态
const [codexAuth, setCodexAuth] = useState("");
const [codexConfig, setCodexConfig] = useState("");
const [codexAuth, setCodexAuthState] = useState("");
const [codexConfig, setCodexConfigState] = useState("");
const [codexApiKey, setCodexApiKey] = useState("");
const [isCodexTemplateModalOpen, setIsCodexTemplateModalOpen] =
useState(false);
// -1 表示自定义null 表示未选择,>= 0 表示预设索引
const [selectedCodexPreset, setSelectedCodexPreset] = useState<number | null>(
showPresets && isCodex ? -1 : null,
showPresets && isCodex ? -1 : null
);
const setCodexAuth = (value: string) => {
setCodexAuthState(value);
setCodexAuthError(validateCodexAuth(value));
};
const setCodexConfig = (value: string) => {
setCodexConfigState(value);
};
const setCodexCommonConfigSnippet = (value: string) => {
setCodexCommonConfigSnippetState(value);
};
// 初始化 Codex 配置
useEffect(() => {
if (isCodex && initialData) {
@@ -85,18 +116,86 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
}, [isCodex, initialData]);
const [error, setError] = useState("");
const [disableCoAuthored, setDisableCoAuthored] = useState(false);
const [useCommonConfig, setUseCommonConfig] = useState(false);
const [commonConfigSnippet, setCommonConfigSnippet] = useState<string>(() => {
if (typeof window === "undefined") {
return DEFAULT_COMMON_CONFIG_SNIPPET;
}
try {
const stored = window.localStorage.getItem(COMMON_CONFIG_STORAGE_KEY);
if (stored && stored.trim()) {
return stored;
}
} catch {
// ignore localStorage 读取失败
}
return DEFAULT_COMMON_CONFIG_SNIPPET;
});
const [commonConfigError, setCommonConfigError] = useState("");
const [settingsConfigError, setSettingsConfigError] = useState("");
// 用于跟踪是否正在通过通用配置更新
const isUpdatingFromCommonConfig = useRef(false);
// Codex 通用配置状态
const [useCodexCommonConfig, setUseCodexCommonConfig] = useState(false);
const [codexCommonConfigSnippet, setCodexCommonConfigSnippetState] =
useState<string>(() => {
if (typeof window === "undefined") {
return DEFAULT_CODEX_COMMON_CONFIG_SNIPPET.trim();
}
try {
const stored = window.localStorage.getItem(
CODEX_COMMON_CONFIG_STORAGE_KEY
);
if (stored && stored.trim()) {
return stored.trim();
}
} catch {
// ignore localStorage 读取失败
}
return DEFAULT_CODEX_COMMON_CONFIG_SNIPPET.trim();
});
const [codexCommonConfigError, setCodexCommonConfigError] = useState("");
const isUpdatingFromCodexCommonConfig = useRef(false);
// -1 表示自定义null 表示未选择,>= 0 表示预设索引
const [selectedPreset, setSelectedPreset] = useState<number | null>(
showPresets ? -1 : null,
showPresets ? -1 : null
);
const [apiKey, setApiKey] = useState("");
const [codexAuthError, setCodexAuthError] = useState("");
// Kimi 模型选择状态
const [kimiAnthropicModel, setKimiAnthropicModel] = useState("");
const [kimiAnthropicSmallFastModel, setKimiAnthropicSmallFastModel] =
useState("");
const validateSettingsConfig = (value: string): string => {
return validateJsonConfig(value, "配置内容");
};
const validateCodexAuth = (value: string): string => {
if (!value.trim()) {
return "";
}
try {
const parsed = JSON.parse(value);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return "auth.json 必须是 JSON 对象";
}
return "";
} catch {
return "auth.json 格式错误请检查JSON语法";
}
};
const updateSettingsConfigValue = (value: string) => {
setFormData((prev) => ({
...prev,
settingsConfig: value,
}));
setSettingsConfigError(validateSettingsConfig(value));
};
// 初始化自定义模式的默认配置
useEffect(() => {
if (
@@ -115,44 +214,67 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
// ANTHROPIC_SMALL_FAST_MODEL: "your-fast-model-name"
},
};
const templateString = JSON.stringify(customTemplate, null, 2);
setFormData((prev) => ({
...prev,
settingsConfig: JSON.stringify(customTemplate, null, 2),
}));
updateSettingsConfigValue(templateString);
setApiKey("");
}
}, []); // 只在组件挂载时执行一次
// 初始化时检查禁用签名状态
// 初始化时检查通用配置片段
useEffect(() => {
if (initialData) {
const configString = JSON.stringify(initialData.settingsConfig, null, 2);
const hasCoAuthoredDisabled = checkCoAuthoredSetting(configString);
setDisableCoAuthored(hasCoAuthoredDisabled);
if (!isCodex) {
const configString = JSON.stringify(
initialData.settingsConfig,
null,
2
);
const hasCommon = hasCommonConfigSnippet(
configString,
commonConfigSnippet
);
setUseCommonConfig(hasCommon);
setSettingsConfigError(validateSettingsConfig(configString));
// 初始化模型配置(编辑模式)
if (
initialData.settingsConfig &&
typeof initialData.settingsConfig === "object"
) {
const config = initialData.settingsConfig as {
env?: Record<string, any>;
};
if (config.env) {
setClaudeModel(config.env.ANTHROPIC_MODEL || "");
setClaudeSmallFastModel(config.env.ANTHROPIC_SMALL_FAST_MODEL || "");
setBaseUrl(config.env.ANTHROPIC_BASE_URL || ""); // 初始化基础 URL
// 初始化模型配置(编辑模式)
if (
initialData.settingsConfig &&
typeof initialData.settingsConfig === "object"
) {
const config = initialData.settingsConfig as {
env?: Record<string, any>;
};
if (config.env) {
setClaudeModel(config.env.ANTHROPIC_MODEL || "");
setClaudeSmallFastModel(
config.env.ANTHROPIC_SMALL_FAST_MODEL || ""
);
setBaseUrl(config.env.ANTHROPIC_BASE_URL || ""); // 初始化基础 URL
// 初始化 Kimi 模型选择
setKimiAnthropicModel(config.env.ANTHROPIC_MODEL || "");
setKimiAnthropicSmallFastModel(
config.env.ANTHROPIC_SMALL_FAST_MODEL || "",
);
// 初始化 Kimi 模型选择
setKimiAnthropicModel(config.env.ANTHROPIC_MODEL || "");
setKimiAnthropicSmallFastModel(
config.env.ANTHROPIC_SMALL_FAST_MODEL || ""
);
}
}
} else {
// Codex 初始化时检查 TOML 通用配置
const hasCommon = hasTomlCommonConfigSnippet(
codexConfig,
codexCommonConfigSnippet
);
setUseCodexCommonConfig(hasCommon);
}
}
}, [initialData]);
}, [
initialData,
commonConfigSnippet,
codexCommonConfigSnippet,
isCodex,
codexConfig,
]);
// 当选择预设变化时,同步类别
useEffect(() => {
@@ -161,7 +283,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
if (selectedPreset !== null && selectedPreset >= 0) {
const preset = providerPresets[selectedPreset];
setCategory(
preset?.category || (preset?.isOfficial ? "official" : undefined),
preset?.category || (preset?.isOfficial ? "official" : undefined)
);
} else if (selectedPreset === -1) {
setCategory("custom");
@@ -170,7 +292,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
if (selectedCodexPreset !== null && selectedCodexPreset >= 0) {
const preset = codexProviderPresets[selectedCodexPreset];
setCategory(
preset?.category || (preset?.isOfficial ? "official" : undefined),
preset?.category || (preset?.isOfficial ? "official" : undefined)
);
} else if (selectedCodexPreset === -1) {
setCategory("custom");
@@ -178,6 +300,23 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
}
}, [showPresets, isCodex, selectedPreset, selectedCodexPreset]);
// 同步本地存储的通用配置片段
useEffect(() => {
if (typeof window === "undefined") return;
try {
if (commonConfigSnippet.trim()) {
window.localStorage.setItem(
COMMON_CONFIG_STORAGE_KEY,
commonConfigSnippet
);
} else {
window.localStorage.removeItem(COMMON_CONFIG_STORAGE_KEY);
}
} catch {
// ignore
}
}, [commonConfigSnippet]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError("");
@@ -190,6 +329,12 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
let settingsConfig: Record<string, any>;
if (isCodex) {
const currentAuthError = validateCodexAuth(codexAuth);
setCodexAuthError(currentAuthError);
if (currentAuthError) {
setError(currentAuthError);
return;
}
// Codex: 仅要求 auth.json 必填config.toml 可为空
if (!codexAuth.trim()) {
setError("请填写 auth.json 配置");
@@ -224,6 +369,14 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
return;
}
} else {
const currentSettingsError = validateSettingsConfig(
formData.settingsConfig
);
setSettingsConfigError(currentSettingsError);
if (currentSettingsError) {
setError(currentSettingsError);
return;
}
// Claude: 原有逻辑
if (!formData.settingsConfig.trim()) {
setError("请填写配置内容");
@@ -248,45 +401,131 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
};
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target;
if (name === "settingsConfig") {
// 同时检查并同步选择框状态
const hasCoAuthoredDisabled = checkCoAuthoredSetting(value);
setDisableCoAuthored(hasCoAuthoredDisabled);
// 只有在不是通过通用配置更新时,才检查并同步选择框状态
if (!isUpdatingFromCommonConfig.current) {
const hasCommon = hasCommonConfigSnippet(value, commonConfigSnippet);
setUseCommonConfig(hasCommon);
}
// 同步 API Key 输入框显示与值
const parsedKey = getApiKeyFromConfig(value);
setApiKey(parsedKey);
// 不再从 JSON 自动提取或覆盖官网地址,只更新配置内容
updateSettingsConfigValue(value);
} else {
setFormData((prev) => ({
...prev,
[name]: value,
}));
} else {
setFormData({
...formData,
[name]: value,
});
}
};
// 处理选择框变化
const handleCoAuthoredToggle = (checked: boolean) => {
setDisableCoAuthored(checked);
// 更新JSON配置
const updatedConfig = updateCoAuthoredSetting(
// 处理通用配置开关
const handleCommonConfigToggle = (checked: boolean) => {
const { updatedConfig, error: snippetError } = updateCommonConfigSnippet(
formData.settingsConfig,
checked,
commonConfigSnippet,
checked
);
setFormData({
...formData,
settingsConfig: updatedConfig,
});
if (snippetError) {
setCommonConfigError(snippetError);
if (snippetError.includes("配置 JSON 解析失败")) {
setSettingsConfigError("配置JSON格式错误请检查语法");
}
setUseCommonConfig(false);
return;
}
setCommonConfigError("");
setUseCommonConfig(checked);
// 标记正在通过通用配置更新
isUpdatingFromCommonConfig.current = true;
updateSettingsConfigValue(updatedConfig);
// 在下一个事件循环中重置标记
setTimeout(() => {
isUpdatingFromCommonConfig.current = false;
}, 0);
};
const handleCommonConfigSnippetChange = (value: string) => {
const previousSnippet = commonConfigSnippet;
setCommonConfigSnippet(value);
if (!value.trim()) {
setCommonConfigError("");
if (useCommonConfig) {
const { updatedConfig } = updateCommonConfigSnippet(
formData.settingsConfig,
previousSnippet,
false
);
// 直接更新 formData不通过 handleChange
updateSettingsConfigValue(updatedConfig);
setUseCommonConfig(false);
}
return;
}
// 验证JSON格式
const validationError = validateJsonConfig(value, "通用配置片段");
if (validationError) {
setCommonConfigError(validationError);
} else {
setCommonConfigError("");
}
// 若当前启用通用配置且格式正确,需要替换为最新片段
if (useCommonConfig && !validationError) {
const removeResult = updateCommonConfigSnippet(
formData.settingsConfig,
previousSnippet,
false
);
if (removeResult.error) {
setCommonConfigError(removeResult.error);
if (removeResult.error.includes("配置 JSON 解析失败")) {
setSettingsConfigError("配置JSON格式错误请检查语法");
}
return;
}
const addResult = updateCommonConfigSnippet(
removeResult.updatedConfig,
value,
true
);
if (addResult.error) {
setCommonConfigError(addResult.error);
if (addResult.error.includes("配置 JSON 解析失败")) {
setSettingsConfigError("配置JSON格式错误请检查语法");
}
return;
}
// 标记正在通过通用配置更新,避免触发状态检查
isUpdatingFromCommonConfig.current = true;
updateSettingsConfigValue(addResult.updatedConfig);
// 在下一个事件循环中重置标记
setTimeout(() => {
isUpdatingFromCommonConfig.current = false;
}, 0);
}
// 保存通用配置到 localStorage
if (!validationError && typeof window !== "undefined") {
try {
window.localStorage.setItem(COMMON_CONFIG_STORAGE_KEY, value);
} catch {
// ignore localStorage 写入失败
}
}
};
const applyPreset = (preset: (typeof providerPresets)[0], index: number) => {
@@ -297,8 +536,9 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
websiteUrl: preset.websiteUrl,
settingsConfig: configString,
});
setSettingsConfigError(validateSettingsConfig(configString));
setCategory(
preset.category || (preset.isOfficial ? "official" : undefined),
preset.category || (preset.isOfficial ? "official" : undefined)
);
// 设置选中的预设
@@ -308,9 +548,10 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
setApiKey("");
setBaseUrl(""); // 清空基础 URL
// 同步选择框状态
const hasCoAuthoredDisabled = checkCoAuthoredSetting(configString);
setDisableCoAuthored(hasCoAuthoredDisabled);
// 同步通用配置状态
const hasCommon = hasCommonConfigSnippet(configString, commonConfigSnippet);
setUseCommonConfig(hasCommon);
setCommonConfigError("");
// 如果预设包含模型配置,初始化模型输入框
if (preset.settingsConfig && typeof preset.settingsConfig === "object") {
@@ -323,7 +564,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
if (preset.name?.includes("Kimi")) {
setKimiAnthropicModel(config.env.ANTHROPIC_MODEL || "");
setKimiAnthropicSmallFastModel(
config.env.ANTHROPIC_SMALL_FAST_MODEL || "",
config.env.ANTHROPIC_SMALL_FAST_MODEL || ""
);
}
} else {
@@ -347,15 +588,18 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
// ANTHROPIC_SMALL_FAST_MODEL: "your-fast-model-name"
},
};
const templateString = JSON.stringify(customTemplate, null, 2);
setFormData({
name: "",
websiteUrl: "",
settingsConfig: JSON.stringify(customTemplate, null, 2),
settingsConfig: templateString,
});
setSettingsConfigError(validateSettingsConfig(templateString));
setApiKey("");
setBaseUrl("https://your-api-endpoint.com"); // 设置默认的基础 URL
setDisableCoAuthored(false);
setUseCommonConfig(false);
setCommonConfigError("");
setClaudeModel("");
setClaudeSmallFastModel("");
setKimiAnthropicModel("");
@@ -366,7 +610,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
// Codex: 应用预设
const applyCodexPreset = (
preset: (typeof codexProviderPresets)[0],
index: number,
index: number
) => {
const authString = JSON.stringify(preset.auth || {}, null, 2);
setCodexAuth(authString);
@@ -380,7 +624,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
setSelectedCodexPreset(index);
setCategory(
preset.category || (preset.isOfficial ? "official" : undefined),
preset.category || (preset.isOfficial ? "official" : undefined)
);
// 清空 API Key让用户重新输入
@@ -390,13 +634,23 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
// Codex: 处理点击自定义按钮
const handleCodexCustomClick = () => {
setSelectedCodexPreset(-1);
// 设置自定义模板
const customAuth = generateThirdPartyAuth("");
const customConfig = generateThirdPartyConfig(
"custom",
"https://your-api-endpoint.com/v1",
"gpt-5-codex"
);
setFormData({
name: "",
websiteUrl: "",
settingsConfig: "",
});
setCodexAuth("");
setCodexConfig("");
setSettingsConfigError(validateSettingsConfig(""));
setCodexAuth(JSON.stringify(customAuth, null, 2));
setCodexConfig(customConfig);
setCodexApiKey("");
setCategory("custom");
};
@@ -408,18 +662,15 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
const configString = setApiKeyInConfig(
formData.settingsConfig,
key.trim(),
{ createIfMissing: selectedPreset !== null && selectedPreset !== -1 },
{ createIfMissing: selectedPreset !== null && selectedPreset !== -1 }
);
// 更新表单配置
setFormData((prev) => ({
...prev,
settingsConfig: configString,
}));
updateSettingsConfigValue(configString);
// 同步选择框状态
const hasCoAuthoredDisabled = checkCoAuthoredSetting(configString);
setDisableCoAuthored(hasCoAuthoredDisabled);
// 同步通用配置开关
const hasCommon = hasCommonConfigSnippet(configString, commonConfigSnippet);
setUseCommonConfig(hasCommon);
};
// 处理基础 URL 变化
@@ -433,10 +684,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
}
config.env.ANTHROPIC_BASE_URL = url.trim();
setFormData((prev) => ({
...prev,
settingsConfig: JSON.stringify(config, null, 2),
}));
updateSettingsConfigValue(JSON.stringify(config, null, 2));
} catch {
// ignore
}
@@ -454,6 +702,101 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
}
};
// Codex: 处理通用配置开关
const handleCodexCommonConfigToggle = (checked: boolean) => {
const snippet = codexCommonConfigSnippet.trim();
const { updatedConfig, error: snippetError } =
updateTomlCommonConfigSnippet(codexConfig, snippet, checked);
if (snippetError) {
setCodexCommonConfigError(snippetError);
setUseCodexCommonConfig(false);
return;
}
setCodexCommonConfigError("");
setUseCodexCommonConfig(checked);
// 标记正在通过通用配置更新
isUpdatingFromCodexCommonConfig.current = true;
setCodexConfig(updatedConfig);
// 在下一个事件循环中重置标记
setTimeout(() => {
isUpdatingFromCodexCommonConfig.current = false;
}, 0);
};
// Codex: 处理通用配置片段变化
const handleCodexCommonConfigSnippetChange = (value: string) => {
const previousSnippet = codexCommonConfigSnippet.trim();
const sanitizedValue = value.trim();
setCodexCommonConfigSnippet(value);
if (!sanitizedValue) {
setCodexCommonConfigError("");
if (useCodexCommonConfig) {
const { updatedConfig } = updateTomlCommonConfigSnippet(
codexConfig,
previousSnippet,
false
);
setCodexConfig(updatedConfig);
setUseCodexCommonConfig(false);
}
return;
}
// TOML 不需要验证 JSON 格式,直接更新
if (useCodexCommonConfig) {
const removeResult = updateTomlCommonConfigSnippet(
codexConfig,
previousSnippet,
false
);
const addResult = updateTomlCommonConfigSnippet(
removeResult.updatedConfig,
sanitizedValue,
true
);
if (addResult.error) {
setCodexCommonConfigError(addResult.error);
return;
}
// 标记正在通过通用配置更新
isUpdatingFromCodexCommonConfig.current = true;
setCodexConfig(addResult.updatedConfig);
// 在下一个事件循环中重置标记
setTimeout(() => {
isUpdatingFromCodexCommonConfig.current = false;
}, 0);
}
// 保存 Codex 通用配置到 localStorage
if (typeof window !== "undefined") {
try {
window.localStorage.setItem(
CODEX_COMMON_CONFIG_STORAGE_KEY,
sanitizedValue
);
} catch {
// ignore localStorage 写入失败
}
}
};
// Codex: 处理 config 变化
const handleCodexConfigChange = (value: string) => {
if (!isUpdatingFromCodexCommonConfig.current) {
const hasCommon = hasTomlCommonConfigSnippet(
value,
codexCommonConfigSnippet
);
setUseCodexCommonConfig(hasCommon);
}
setCodexConfig(value);
};
// 根据当前配置决定是否展示 API Key 输入框
// 自定义模式(-1)也需要显示 API Key 输入框
const showApiKey =
@@ -504,7 +847,12 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
// 获取当前供应商的网址
const getCurrentWebsiteUrl = () => {
if (selectedPreset !== null && selectedPreset >= 0) {
return providerPresets[selectedPreset]?.websiteUrl || "";
const preset = providerPresets[selectedPreset];
if (!preset) return "";
// 仅第三方供应商使用专用 apiKeyUrl其余使用官网地址
return preset.category === "third_party"
? preset.apiKeyUrl || preset.websiteUrl || ""
: preset.websiteUrl || "";
}
return formData.websiteUrl || "";
};
@@ -512,7 +860,12 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
// 获取 Codex 当前供应商的网址
const getCurrentCodexWebsiteUrl = () => {
if (selectedCodexPreset !== null && selectedCodexPreset >= 0) {
return codexProviderPresets[selectedCodexPreset]?.websiteUrl || "";
const preset = codexProviderPresets[selectedCodexPreset];
if (!preset) return "";
// 仅第三方供应商使用专用 apiKeyUrl其余使用官网地址
return preset.category === "third_party"
? preset.apiKeyUrl || preset.websiteUrl || ""
: preset.websiteUrl || "";
}
return formData.websiteUrl || "";
};
@@ -541,7 +894,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
codexProviderPresets[selectedCodexPreset]?.category === "official")) ||
category === "official";
// 判断是否显示 Codex 的"获取 API Key"链接
// 判断是否显示 Codex 的"获取 API Key"链接(国产官方、聚合站和第三方显示)
const shouldShowCodexApiKeyLink =
isCodex &&
!isCodexOfficialPreset &&
@@ -560,7 +913,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
// 处理模型输入变化,自动更新 JSON 配置
const handleModelChange = (
field: "ANTHROPIC_MODEL" | "ANTHROPIC_SMALL_FAST_MODEL",
value: string,
value: string
) => {
if (field === "ANTHROPIC_MODEL") {
setClaudeModel(value);
@@ -581,10 +934,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
delete currentConfig.env[field];
}
setFormData((prev) => ({
...prev,
settingsConfig: JSON.stringify(currentConfig, null, 2),
}));
updateSettingsConfigValue(JSON.stringify(currentConfig, null, 2));
} catch (err) {
// 如果 JSON 解析失败,不做处理
}
@@ -593,7 +943,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
// Kimi 模型选择处理函数
const handleKimiModelChange = (
field: "ANTHROPIC_MODEL" | "ANTHROPIC_SMALL_FAST_MODEL",
value: string,
value: string
) => {
if (field === "ANTHROPIC_MODEL") {
setKimiAnthropicModel(value);
@@ -608,10 +958,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
currentConfig.env[field] = value;
const updatedConfigString = JSON.stringify(currentConfig, null, 2);
setFormData((prev) => ({
...prev,
settingsConfig: updatedConfigString,
}));
updateSettingsConfigValue(updatedConfigString);
} catch (err) {
console.error("更新 Kimi 模型配置失败:", err);
}
@@ -621,7 +968,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
useEffect(() => {
if (!initialData) return;
const parsedKey = getApiKeyFromConfig(
JSON.stringify(initialData.settingsConfig),
JSON.stringify(initialData.settingsConfig)
);
if (parsedKey) setApiKey(parsedKey);
}, [initialData]);
@@ -646,7 +993,11 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
}}
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/50 dark:bg-black/70 backdrop-blur-sm" />
<div
className={`absolute inset-0 bg-black/50 dark:bg-black/70${
isLinux() ? "" : " backdrop-blur-sm"
}`}
/>
{/* Modal */}
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg max-w-3xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col">
@@ -698,6 +1049,18 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
applyCodexPreset(codexProviderPresets[index], index)
}
onCustomClick={handleCodexCustomClick}
renderCustomDescription={() => (
<>
<button
type="button"
onClick={() => setIsCodexTemplateModalOpen(true)}
className="text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors ml-1"
>
使
</button>
</>
)}
/>
)}
@@ -846,7 +1209,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
authValue={codexAuth}
configValue={codexConfig}
onAuthChange={setCodexAuth}
onConfigChange={setCodexConfig}
onConfigChange={handleCodexConfigChange}
onAuthBlur={() => {
try {
const auth = JSON.parse(codexAuth || "{}");
@@ -859,6 +1222,29 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
// ignore
}
}}
useCommonConfig={useCodexCommonConfig}
onCommonConfigToggle={handleCodexCommonConfigToggle}
commonConfigSnippet={codexCommonConfigSnippet}
onCommonConfigSnippetChange={
handleCodexCommonConfigSnippetChange
}
commonConfigError={codexCommonConfigError}
authError={codexAuthError}
isCustomMode={selectedCodexPreset === -1}
onWebsiteUrlChange={(url) => {
setFormData((prev) => ({
...prev,
websiteUrl: url,
}));
}}
onNameChange={(name) => {
setFormData((prev) => ({
...prev,
name,
}));
}}
isTemplateModalOpen={isCodexTemplateModalOpen}
setIsTemplateModalOpen={setIsCodexTemplateModalOpen}
/>
) : (
<>
@@ -900,7 +1286,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
onChange={(e) =>
handleModelChange(
"ANTHROPIC_SMALL_FAST_MODEL",
e.target.value,
e.target.value
)
}
placeholder="例如: GLM-4.5-Air"
@@ -925,8 +1311,12 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
target: { name: "settingsConfig", value },
} as React.ChangeEvent<HTMLTextAreaElement>)
}
disableCoAuthored={disableCoAuthored}
onCoAuthoredToggle={handleCoAuthoredToggle}
useCommonConfig={useCommonConfig}
onCommonConfigToggle={handleCommonConfigToggle}
commonConfigSnippet={commonConfigSnippet}
onCommonConfigSnippetChange={handleCommonConfigSnippetChange}
commonConfigError={commonConfigError}
configError={settingsConfigError}
/>
</>
)}

View File

@@ -1,20 +1,31 @@
import React, { useEffect, useState } from "react";
import JsonEditor from "../JsonEditor";
import { X, Save } from "lucide-react";
import { isLinux } from "../../lib/platform";
interface ClaudeConfigEditorProps {
value: string;
onChange: (value: string) => void;
disableCoAuthored: boolean;
onCoAuthoredToggle: (checked: boolean) => void;
useCommonConfig: boolean;
onCommonConfigToggle: (checked: boolean) => void;
commonConfigSnippet: string;
onCommonConfigSnippetChange: (value: string) => void;
commonConfigError: string;
configError: string;
}
const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
value,
onChange,
disableCoAuthored,
onCoAuthoredToggle,
useCommonConfig,
onCommonConfigToggle,
commonConfigSnippet,
onCommonConfigSnippetChange,
commonConfigError,
configError,
}) => {
const [isDarkMode, setIsDarkMode] = useState(false);
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
useEffect(() => {
// 检测暗色模式
@@ -40,6 +51,30 @@ const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
return () => observer.disconnect();
}, []);
useEffect(() => {
if (commonConfigError && !isCommonConfigModalOpen) {
setIsCommonConfigModalOpen(true);
}
}, [commonConfigError, isCommonConfigModalOpen]);
// 支持按下 ESC 关闭弹窗
useEffect(() => {
if (!isCommonConfigModalOpen) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
closeModal();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [isCommonConfigModalOpen]);
const closeModal = () => {
setIsCommonConfigModalOpen(false);
};
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
@@ -52,13 +87,27 @@ const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
<label className="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 cursor-pointer">
<input
type="checkbox"
checked={disableCoAuthored}
onChange={(e) => onCoAuthoredToggle(e.target.checked)}
checked={useCommonConfig}
onChange={(e) => onCommonConfigToggle(e.target.checked)}
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
/>
Claude Code
</label>
</div>
<div className="flex items-center justify-end">
<button
type="button"
onClick={() => setIsCommonConfigModalOpen(true)}
className="text-xs text-blue-500 dark:text-blue-400 hover:underline"
>
</button>
</div>
{commonConfigError && !isCommonConfigModalOpen && (
<p className="text-xs text-red-500 dark:text-red-400 text-right">
{commonConfigError}
</p>
)}
<JsonEditor
value={value}
onChange={onChange}
@@ -71,9 +120,82 @@ const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
}`}
rows={12}
/>
{configError && (
<p className="text-xs text-red-500 dark:text-red-400">{configError}</p>
)}
<p className="text-xs text-gray-500 dark:text-gray-400">
Claude Code settings.json
</p>
{isCommonConfigModalOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onMouseDown={(e) => {
if (e.target === e.currentTarget) closeModal();
}}
>
{/* Backdrop - 统一背景样式 */}
<div
className={`absolute inset-0 bg-black/50 dark:bg-black/70${
isLinux() ? "" : " backdrop-blur-sm"
}`}
/>
{/* Modal - 统一窗口样式 */}
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg max-w-2xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col">
{/* Header - 统一标题栏样式 */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
</h2>
<button
type="button"
onClick={closeModal}
className="p-1 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
aria-label="关闭"
>
<X size={18} />
</button>
</div>
{/* Content - 统一内容区域样式 */}
<div className="flex-1 overflow-auto p-6 space-y-4">
<p className="text-sm text-gray-500 dark:text-gray-400">
"写入通用配置" settings.json
</p>
<JsonEditor
value={commonConfigSnippet}
onChange={onCommonConfigSnippetChange}
darkMode={isDarkMode}
rows={12}
/>
{commonConfigError && (
<p className="text-sm text-red-500 dark:text-red-400">
{commonConfigError}
</p>
)}
</div>
{/* Footer - 统一底部按钮样式 */}
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-800">
<button
type="button"
onClick={closeModal}
className="px-4 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-white dark:hover:bg-gray-700 rounded-lg transition-colors"
>
</button>
<button
type="button"
onClick={closeModal}
className="px-4 py-2 bg-blue-500 dark:bg-blue-600 text-white rounded-lg hover:bg-blue-600 dark:hover:bg-blue-700 transition-colors text-sm font-medium flex items-center gap-2"
>
<Save className="w-4 h-4" />
</button>
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -1,20 +1,234 @@
import React from "react";
import React, { useState, useEffect, useRef } from "react";
import { X, Save } from "lucide-react";
import { isLinux } from "../../lib/platform";
import {
generateThirdPartyAuth,
generateThirdPartyConfig,
} from "../../config/codexProviderPresets";
interface CodexConfigEditorProps {
authValue: string;
configValue: string;
onAuthChange: (value: string) => void;
onConfigChange: (value: string) => void;
onAuthBlur?: () => void;
useCommonConfig: boolean;
onCommonConfigToggle: (checked: boolean) => void;
commonConfigSnippet: string;
onCommonConfigSnippetChange: (value: string) => void;
commonConfigError: string;
authError: string;
isCustomMode?: boolean; // 新增:是否为自定义模式
onWebsiteUrlChange?: (url: string) => void; // 新增:更新网址回调
isTemplateModalOpen?: boolean; // 新增:模态框状态
setIsTemplateModalOpen?: (open: boolean) => void; // 新增:设置模态框状态
onNameChange?: (name: string) => void; // 新增:更新供应商名称回调
}
const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
authValue,
configValue,
onAuthChange,
onConfigChange,
onAuthBlur,
useCommonConfig,
onCommonConfigToggle,
commonConfigSnippet,
onCommonConfigSnippetChange,
commonConfigError,
authError,
onWebsiteUrlChange,
onNameChange,
isTemplateModalOpen: externalTemplateModalOpen,
setIsTemplateModalOpen: externalSetTemplateModalOpen,
}) => {
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
// 使用内部状态或外部状态
const [internalTemplateModalOpen, setInternalTemplateModalOpen] =
useState(false);
const isTemplateModalOpen =
externalTemplateModalOpen ?? internalTemplateModalOpen;
const setIsTemplateModalOpen =
externalSetTemplateModalOpen ?? setInternalTemplateModalOpen;
const [templateApiKey, setTemplateApiKey] = useState("");
const [templateProviderName, setTemplateProviderName] = useState("");
const [templateBaseUrl, setTemplateBaseUrl] = useState("");
const [templateWebsiteUrl, setTemplateWebsiteUrl] = useState("");
const [templateModelName, setTemplateModelName] = useState("gpt-5-codex");
const apiKeyInputRef = useRef<HTMLInputElement>(null);
const baseUrlInputRef = useRef<HTMLInputElement>(null);
const modelNameInputRef = useRef<HTMLInputElement>(null);
const displayNameInputRef = useRef<HTMLInputElement>(null);
// 移除自动填充逻辑,因为现在在点击自定义按钮时就已经填充
const [templateDisplayName, setTemplateDisplayName] = useState("");
useEffect(() => {
if (commonConfigError && !isCommonConfigModalOpen) {
setIsCommonConfigModalOpen(true);
}
}, [commonConfigError, isCommonConfigModalOpen]);
// 支持按下 ESC 关闭弹窗
useEffect(() => {
if (!isCommonConfigModalOpen) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
closeModal();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [isCommonConfigModalOpen]);
const closeModal = () => {
setIsCommonConfigModalOpen(false);
};
const closeTemplateModal = () => {
setIsTemplateModalOpen(false);
};
const applyTemplate = () => {
const requiredInputs = [
displayNameInputRef.current,
apiKeyInputRef.current,
baseUrlInputRef.current,
modelNameInputRef.current,
];
for (const input of requiredInputs) {
if (input && !input.checkValidity()) {
input.reportValidity();
input.focus();
return;
}
}
const trimmedKey = templateApiKey.trim();
const trimmedBaseUrl = templateBaseUrl.trim();
const trimmedModel = templateModelName.trim();
const auth = generateThirdPartyAuth(trimmedKey);
const config = generateThirdPartyConfig(
templateProviderName || "custom",
trimmedBaseUrl,
trimmedModel
);
onAuthChange(JSON.stringify(auth, null, 2));
onConfigChange(config);
if (onWebsiteUrlChange) {
const trimmedWebsite = templateWebsiteUrl.trim();
if (trimmedWebsite) {
onWebsiteUrlChange(trimmedWebsite);
}
}
if (onNameChange) {
const trimmedName = templateDisplayName.trim();
if (trimmedName) {
onNameChange(trimmedName);
}
}
setTemplateApiKey("");
setTemplateProviderName("");
setTemplateBaseUrl("");
setTemplateWebsiteUrl("");
setTemplateModelName("gpt-5-codex");
setTemplateDisplayName("");
closeTemplateModal();
};
const handleTemplateInputKeyDown = (
e: React.KeyboardEvent<HTMLInputElement>
) => {
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
applyTemplate();
}
};
const handleAuthChange = (value: string) => {
onAuthChange(value);
};
const handleConfigChange = (value: string) => {
onConfigChange(value);
};
const handleCommonConfigSnippetChange = (value: string) => {
onCommonConfigSnippetChange(value);
};
return (
<div className="space-y-6">
<div className="space-y-2">
@@ -24,10 +238,11 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
>
auth.json (JSON) *
</label>
<textarea
id="codexAuth"
value={authValue}
onChange={(e) => onAuthChange(e.target.value)}
onChange={(e) => handleAuthChange(e.target.value)}
onBlur={onAuthBlur}
placeholder={`{
"OPENAI_API_KEY": "sk-your-api-key-here"
@@ -35,31 +250,417 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
rows={6}
required
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400 transition-colors resize-y min-h-[8rem]"
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
lang="en"
inputMode="text"
data-gramm="false"
data-gramm_editor="false"
data-enable-grammarly="false"
/>
{authError && (
<p className="text-xs text-red-500 dark:text-red-400">{authError}</p>
)}
<p className="text-xs text-gray-500 dark:text-gray-400">
Codex auth.json
</p>
</div>
<div className="space-y-2">
<label
htmlFor="codexConfig"
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
config.toml (TOML)
</label>
<div className="flex items-center justify-between">
<label
htmlFor="codexConfig"
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
config.toml (TOML)
</label>
<label className="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 cursor-pointer">
<input
type="checkbox"
checked={useCommonConfig}
onChange={(e) => onCommonConfigToggle(e.target.checked)}
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
/>
</label>
</div>
<div className="flex items-center justify-end">
<button
type="button"
onClick={() => setIsCommonConfigModalOpen(true)}
className="text-xs text-blue-500 dark:text-blue-400 hover:underline"
>
</button>
</div>
{commonConfigError && !isCommonConfigModalOpen && (
<p className="text-xs text-red-500 dark:text-red-400 text-right">
{commonConfigError}
</p>
)}
<textarea
id="codexConfig"
value={configValue}
onChange={(e) => onConfigChange(e.target.value)}
onChange={(e) => handleConfigChange(e.target.value)}
placeholder=""
rows={8}
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400 transition-colors resize-y min-h-[10rem]"
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
lang="en"
inputMode="text"
data-gramm="false"
data-gramm_editor="false"
data-enable-grammarly="false"
/>
<p className="text-xs text-gray-500 dark:text-gray-400">
Codex config.toml
</p>
</div>
{isTemplateModalOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onMouseDown={(e) => {
if (e.target === e.currentTarget) {
closeTemplateModal();
}
}}
>
<div
className={`absolute inset-0 bg-black/50 dark:bg-black/70${
isLinux() ? "" : " backdrop-blur-sm"
}`}
/>
<div className="relative mx-4 flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-white shadow-lg dark:bg-gray-900">
<div className="flex h-full min-h-0 flex-col" role="form">
<div className="flex items-center justify-between border-b border-gray-200 p-6 dark:border-gray-800">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
</h2>
<button
type="button"
onClick={closeTemplateModal}
className="rounded-md p-1 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100"
aria-label="关闭"
>
<X size={18} />
</button>
</div>
<div className="flex-1 min-h-0 space-y-4 overflow-auto p-6">
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-800 dark:bg-blue-900/20">
<p className="text-sm text-blue-800 dark:text-blue-200">
auth.json config.toml
</p>
</div>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
API *
</label>
<input
type="text"
value={templateApiKey}
ref={apiKeyInputRef}
onChange={(e) => setTemplateApiKey(e.target.value)}
onKeyDown={handleTemplateInputKeyDown}
pattern=".*\S.*"
title="请输入有效的内容"
placeholder="sk-your-api-key-here"
required
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
*
</label>
<input
type="text"
value={templateDisplayName}
ref={displayNameInputRef}
onChange={(e) => {
setTemplateDisplayName(e.target.value);
if (onNameChange) {
onNameChange(e.target.value);
}
}}
onKeyDown={handleTemplateInputKeyDown}
placeholder="例如Codex 官方"
required
pattern=".*\S.*"
title="请输入有效的内容"
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
使
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
</label>
<input
type="text"
value={templateProviderName}
onChange={(e) => setTemplateProviderName(e.target.value)}
onKeyDown={handleTemplateInputKeyDown}
placeholder="custom可选"
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
custom
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
API *
</label>
<input
type="url"
value={templateBaseUrl}
ref={baseUrlInputRef}
onChange={(e) => setTemplateBaseUrl(e.target.value)}
onKeyDown={handleTemplateInputKeyDown}
placeholder="https://your-api-endpoint.com/v1"
required
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
</label>
<input
type="url"
value={templateWebsiteUrl}
onChange={(e) => setTemplateWebsiteUrl(e.target.value)}
onKeyDown={handleTemplateInputKeyDown}
placeholder="https://example.com"
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
*
</label>
<input
type="text"
value={templateModelName}
ref={modelNameInputRef}
onChange={(e) => setTemplateModelName(e.target.value)}
onKeyDown={handleTemplateInputKeyDown}
pattern=".*\S.*"
title="请输入有效的内容"
placeholder="gpt-5-codex"
required
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
</div>
{(templateApiKey ||
templateProviderName ||
templateBaseUrl) && (
<div className="space-y-2 border-t border-gray-200 pt-4 dark:border-gray-700">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
</h3>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div>
<label className="mb-1 block text-xs font-medium text-gray-500 dark:text-gray-400">
auth.json
</label>
<pre className="overflow-x-auto rounded-lg bg-gray-50 p-3 text-xs font-mono text-gray-700 dark:bg-gray-800 dark:text-gray-300">
{JSON.stringify(
generateThirdPartyAuth(templateApiKey),
null,
2
)}
</pre>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-500 dark:text-gray-400">
config.toml
</label>
<pre className="whitespace-pre-wrap rounded-lg bg-gray-50 p-3 text-xs font-mono text-gray-700 dark:bg-gray-800 dark:text-gray-300">
{templateProviderName && templateBaseUrl
? generateThirdPartyConfig(
templateProviderName,
templateBaseUrl,
templateModelName
)
: ""}
</pre>
</div>
</div>
</div>
)}
</div>
<div className="flex items-center justify-end gap-3 border-t border-gray-200 bg-gray-100 p-6 dark:border-gray-800 dark:bg-gray-800">
<button
type="button"
onClick={closeTemplateModal}
className="rounded-lg px-4 py-2 text-sm font-medium text-gray-500 transition-colors hover:bg-white hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-100"
>
</button>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
applyTemplate();
}}
className="flex items-center gap-2 rounded-lg bg-blue-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700"
>
<Save className="h-4 w-4" />
</button>
</div>
</div>
</div>
</div>
)}
{isCommonConfigModalOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onMouseDown={(e) => {
if (e.target === e.currentTarget) closeModal();
}}
>
{/* Backdrop - 统一背景样式 */}
<div
className={`absolute inset-0 bg-black/50 dark:bg-black/70${
isLinux() ? "" : " backdrop-blur-sm"
}`}
/>
{/* Modal - 统一窗口样式 */}
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg max-w-2xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col">
{/* Header - 统一标题栏样式 */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
Codex
</h2>
<button
type="button"
onClick={closeModal}
className="p-1 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
aria-label="关闭"
>
<X size={18} />
</button>
</div>
{/* Content - 统一内容区域样式 */}
<div className="flex-1 overflow-auto p-6 space-y-4">
<p className="text-sm text-gray-500 dark:text-gray-400">
"写入通用配置" config.toml
</p>
<textarea
value={commonConfigSnippet}
onChange={(e) =>
handleCommonConfigSnippetChange(e.target.value)
}
placeholder={`# Common Codex config
# Add your common TOML configuration here`}
rows={12}
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400 transition-colors resize-y"
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
lang="en"
inputMode="text"
data-gramm="false"
data-gramm_editor="false"
data-enable-grammarly="false"
/>
{commonConfigError && (
<p className="text-sm text-red-500 dark:text-red-400">
{commonConfigError}
</p>
)}
</div>
{/* Footer - 统一底部按钮样式 */}
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-800">
<button
type="button"
onClick={closeModal}
className="px-4 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-white dark:hover:bg-gray-700 rounded-lg transition-colors"
>
</button>
<button
type="button"
onClick={closeModal}
className="px-4 py-2 bg-blue-500 dark:bg-blue-600 text-white rounded-lg hover:bg-blue-600 dark:hover:bg-blue-700 transition-colors text-sm font-medium flex items-center gap-2"
>
<Save className="w-4 h-4" />
</button>
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -16,6 +16,7 @@ interface PresetSelectorProps {
onSelectPreset: (index: number) => void;
onCustomClick: () => void;
customLabel?: string;
renderCustomDescription?: () => React.ReactNode; // 新增:自定义描述渲染
}
const PresetSelector: React.FC<PresetSelectorProps> = ({
@@ -25,6 +26,7 @@ const PresetSelector: React.FC<PresetSelectorProps> = ({
onSelectPreset,
onCustomClick,
customLabel = "自定义",
renderCustomDescription,
}) => {
const getButtonClass = (index: number, preset?: Preset) => {
const isSelected = selectedIndex === index;
@@ -48,6 +50,10 @@ const PresetSelector: React.FC<PresetSelectorProps> = ({
const getDescription = () => {
if (selectedIndex === -1) {
// 如果提供了自定义描述渲染函数,使用它
if (renderCustomDescription) {
return renderCustomDescription();
}
return "手动配置供应商,需要填写完整的配置信息";
}
@@ -99,9 +105,9 @@ const PresetSelector: React.FC<PresetSelectorProps> = ({
</div>
</div>
{getDescription() && (
<p className="text-sm text-gray-500 dark:text-gray-400">
<div className="text-sm text-gray-500 dark:text-gray-400">
{getDescription()}
</p>
</div>
)}
</div>
);

View File

@@ -1,7 +1,15 @@
import React from "react";
import React, { useEffect, useState } from "react";
import { Provider } from "../types";
import { Play, Edit3, Trash2, CheckCircle2, Users } from "lucide-react";
import { buttonStyles, cardStyles, badgeStyles, cn } from "../lib/styles";
import { AppType } from "../lib/tauri-api";
import {
applyProviderToVSCode,
detectApplied,
normalizeBaseUrl,
} from "../utils/vscodeSettings";
import { getCodexBaseUrl } from "../utils/providerConfigUtils";
import { useVSCodeAutoSync } from "../hooks/useVSCodeAutoSync";
// 不再在列表中显示分类徽章,避免造成困惑
interface ProviderListProps {
@@ -10,6 +18,12 @@ interface ProviderListProps {
onSwitch: (id: string) => void;
onDelete: (id: string) => void;
onEdit: (id: string) => void;
appType?: AppType;
onNotify?: (
message: string,
type: "success" | "error",
duration?: number
) => void;
}
const ProviderList: React.FC<ProviderListProps> = ({
@@ -18,6 +32,8 @@ const ProviderList: React.FC<ProviderListProps> = ({
onSwitch,
onDelete,
onEdit,
appType,
onNotify,
}) => {
// 提取API地址兼容不同供应商配置Claude env / Codex TOML
const getApiUrl = (provider: Provider): string => {
@@ -29,8 +45,9 @@ const ProviderList: React.FC<ProviderListProps> = ({
}
// Codex: 从 TOML 配置中解析 base_url
if (typeof cfg?.config === "string" && cfg.config.includes("base_url")) {
const match = cfg.config.match(/base_url\s*=\s*"([^"]+)"/);
if (match && match[1]) return match[1];
// 支持单/双引号
const match = cfg.config.match(/base_url\s*=\s*(['"])([^'\"]+)\1/);
if (match && match[2]) return match[2];
}
return "未配置官网地址";
} catch {
@@ -46,6 +63,128 @@ const ProviderList: React.FC<ProviderListProps> = ({
}
};
// 解析 Codex 配置中的 base_url已提取到公共工具
// VS Code 按钮:仅在 Codex + 当前供应商显示;按钮文案根据是否"已应用"变化
const [vscodeAppliedFor, setVscodeAppliedFor] = useState<string | null>(null);
const { enableAutoSync, disableAutoSync } = useVSCodeAutoSync();
// 当当前供应商或 appType 变化时,尝试读取 VS Code settings 并检测状态
useEffect(() => {
const check = async () => {
if (appType !== "codex" || !currentProviderId) {
setVscodeAppliedFor(null);
return;
}
const status = await window.api.getVSCodeSettingsStatus();
if (!status.exists) {
setVscodeAppliedFor(null);
return;
}
try {
const content = await window.api.readVSCodeSettings();
const detected = detectApplied(content);
// 认为“已应用”的条件非官方供应商VS Code 中的 apiBase 与当前供应商的 base_url 完全一致
const current = providers[currentProviderId];
let applied = false;
if (current && current.category !== "official") {
const base = getCodexBaseUrl(current);
if (detected.apiBase && base) {
applied =
normalizeBaseUrl(detected.apiBase) === normalizeBaseUrl(base);
}
}
setVscodeAppliedFor(applied ? currentProviderId : null);
} catch {
setVscodeAppliedFor(null);
}
};
check();
}, [appType, currentProviderId, providers]);
const handleApplyToVSCode = async (provider: Provider) => {
try {
const status = await window.api.getVSCodeSettingsStatus();
if (!status.exists) {
onNotify?.(
"未找到 VS Code 用户设置文件 (settings.json)",
"error",
3000
);
return;
}
const raw = await window.api.readVSCodeSettings();
const isOfficial = provider.category === "official";
// 非官方且缺少 base_url 时直接报错并返回,避免“空写入”假成功
if (!isOfficial) {
const parsed = getCodexBaseUrl(provider);
if (!parsed) {
onNotify?.("当前配置缺少 base_url无法写入 VS Code", "error", 4000);
return;
}
}
const baseUrl = isOfficial ? undefined : getCodexBaseUrl(provider);
const next = applyProviderToVSCode(raw, { baseUrl, isOfficial });
if (next === raw) {
// 幂等:没有变化也提示成功
onNotify?.("已应用到 VS Code重启 Codex 插件以生效", "success", 3000);
setVscodeAppliedFor(provider.id);
// 用户手动应用时,启用自动同步
enableAutoSync();
return;
}
await window.api.writeVSCodeSettings(next);
onNotify?.("已应用到 VS Code重启 Codex 插件以生效", "success", 3000);
setVscodeAppliedFor(provider.id);
// 用户手动应用时,启用自动同步
enableAutoSync();
} catch (e: any) {
console.error(e);
const msg = e && e.message ? e.message : "应用到 VS Code 失败";
onNotify?.(msg, "error", 5000);
}
};
const handleRemoveFromVSCode = async () => {
try {
const status = await window.api.getVSCodeSettingsStatus();
if (!status.exists) {
onNotify?.(
"未找到 VS Code 用户设置文件 (settings.json)",
"error",
3000
);
return;
}
const raw = await window.api.readVSCodeSettings();
const next = applyProviderToVSCode(raw, {
baseUrl: undefined,
isOfficial: true,
});
if (next === raw) {
onNotify?.("已从 VS Code 移除,重启 Codex 插件以生效", "success", 3000);
setVscodeAppliedFor(null);
// 用户手动移除时,禁用自动同步
disableAutoSync();
return;
}
await window.api.writeVSCodeSettings(next);
onNotify?.("已从 VS Code 移除,重启 Codex 插件以生效", "success", 3000);
setVscodeAppliedFor(null);
// 用户手动移除时,禁用自动同步
disableAutoSync();
} catch (e: any) {
console.error(e);
const msg = e && e.message ? e.message : "移除失败";
onNotify?.(msg, "error", 5000);
}
};
// 对供应商列表进行排序
const sortedProviders = Object.values(providers).sort((a, b) => {
// 按添加时间排序
@@ -91,7 +230,7 @@ const ProviderList: React.FC<ProviderListProps> = ({
<div
key={provider.id}
className={cn(
isCurrent ? cardStyles.selected : cardStyles.interactive,
isCurrent ? cardStyles.selected : cardStyles.interactive
)}
>
<div className="flex items-start justify-between">
@@ -101,12 +240,15 @@ const ProviderList: React.FC<ProviderListProps> = ({
{provider.name}
</h3>
{/* 分类徽章已移除 */}
{isCurrent && (
<div className={badgeStyles.success}>
<CheckCircle2 size={12} />
使
</div>
)}
<div
className={cn(
badgeStyles.success,
!isCurrent && "invisible"
)}
>
<CheckCircle2 size={12} />
使
</div>
</div>
<div className="flex items-center gap-2 text-sm">
@@ -133,17 +275,43 @@ const ProviderList: React.FC<ProviderListProps> = ({
</div>
<div className="flex items-center gap-2 ml-4">
{appType === "codex" &&
provider.category !== "official" && (
<button
onClick={() =>
vscodeAppliedFor === provider.id
? handleRemoveFromVSCode()
: handleApplyToVSCode(provider)
}
className={cn(
"inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors w-[130px] whitespace-nowrap justify-center",
!isCurrent && "invisible",
vscodeAppliedFor === provider.id
? "border border-gray-300 text-gray-600 hover:border-red-300 hover:text-red-600 hover:bg-red-50 dark:border-gray-600 dark:text-gray-400 dark:hover:border-red-800 dark:hover:text-red-400 dark:hover:bg-red-900/20"
: "border border-gray-300 text-gray-700 hover:border-blue-300 hover:text-blue-600 hover:bg-blue-50 dark:border-gray-600 dark:text-gray-300 dark:hover:border-blue-700 dark:hover:text-blue-400 dark:hover:bg-blue-900/20"
)}
title={
vscodeAppliedFor === provider.id
? "从 VS Code 移除我们写入的配置"
: "将当前供应商应用到 VS Code"
}
>
{vscodeAppliedFor === provider.id
? "从 VS Code 移除"
: "应用到 VS Code"}
</button>
)}
<button
onClick={() => onSwitch(provider.id)}
disabled={isCurrent}
className={cn(
"inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors",
"inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors w-[76px] justify-center whitespace-nowrap",
isCurrent
? "bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-500 cursor-not-allowed"
: "bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700",
: "bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700"
)}
>
<Play size={14} />
{!isCurrent && <Play size={14} />}
{isCurrent ? "使用中" : "启用"}
</button>
@@ -162,7 +330,7 @@ const ProviderList: React.FC<ProviderListProps> = ({
buttonStyles.icon,
isCurrent
? "text-gray-400 cursor-not-allowed"
: "text-gray-500 hover:text-red-500 hover:bg-red-100 dark:text-gray-400 dark:hover:text-red-400 dark:hover:bg-red-500/10",
: "text-gray-500 hover:text-red-500 hover:bg-red-100 dark:text-gray-400 dark:hover:text-red-400 dark:hover:bg-red-500/10"
)}
title="删除供应商"
>

View File

@@ -6,12 +6,17 @@ import {
Download,
ExternalLink,
Check,
Undo2,
FolderSearch,
} from "lucide-react";
import { getVersion } from "@tauri-apps/api/app";
import { homeDir, join } from "@tauri-apps/api/path";
import "../lib/tauri-api";
import { relaunchApp } from "../lib/updater";
import { useUpdate } from "../contexts/UpdateContext";
import type { Settings } from "../types";
import type { AppType } from "../lib/tauri-api";
import { isLinux } from "../lib/platform";
interface SettingsModalProps {
onClose: () => void;
@@ -20,12 +25,16 @@ interface SettingsModalProps {
export default function SettingsModal({ onClose }: SettingsModalProps) {
const [settings, setSettings] = useState<Settings>({
showInTray: true,
claudeConfigDir: undefined,
codexConfigDir: undefined,
});
const [configPath, setConfigPath] = useState<string>("");
const [version, setVersion] = useState<string>("");
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const [showUpToDate, setShowUpToDate] = useState(false);
const [resolvedClaudeDir, setResolvedClaudeDir] = useState<string>("");
const [resolvedCodexDir, setResolvedCodexDir] = useState<string>("");
const { hasUpdate, updateInfo, updateHandle, checkUpdate, resetDismiss } =
useUpdate();
@@ -33,6 +42,7 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
loadSettings();
loadConfigPath();
loadVersion();
loadResolvedDirs();
}, []);
const loadVersion = async () => {
@@ -49,12 +59,21 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
const loadSettings = async () => {
try {
const loadedSettings = await window.api.getSettings();
if ((loadedSettings as any)?.showInTray !== undefined) {
setSettings({ showInTray: (loadedSettings as any).showInTray });
} else if ((loadedSettings as any)?.showInDock !== undefined) {
// 向后兼容:若历史上有 showInDock则映射为 showInTray
setSettings({ showInTray: (loadedSettings as any).showInDock });
}
const showInTray =
(loadedSettings as any)?.showInTray ??
(loadedSettings as any)?.showInDock ??
true;
setSettings({
showInTray,
claudeConfigDir:
typeof (loadedSettings as any)?.claudeConfigDir === "string"
? (loadedSettings as any).claudeConfigDir
: undefined,
codexConfigDir:
typeof (loadedSettings as any)?.codexConfigDir === "string"
? (loadedSettings as any).codexConfigDir
: undefined,
});
} catch (error) {
console.error("加载设置失败:", error);
}
@@ -71,9 +90,34 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
}
};
const loadResolvedDirs = async () => {
try {
const [claudeDir, codexDir] = await Promise.all([
window.api.getConfigDir("claude"),
window.api.getConfigDir("codex"),
]);
setResolvedClaudeDir(claudeDir || "");
setResolvedCodexDir(codexDir || "");
} catch (error) {
console.error("获取配置目录失败:", error);
}
};
const saveSettings = async () => {
try {
await window.api.saveSettings(settings);
const payload: Settings = {
...settings,
claudeConfigDir:
settings.claudeConfigDir && settings.claudeConfigDir.trim() !== ""
? settings.claudeConfigDir.trim()
: undefined,
codexConfigDir:
settings.codexConfigDir && settings.codexConfigDir.trim() !== ""
? settings.codexConfigDir.trim()
: undefined,
};
await window.api.saveSettings(payload);
setSettings(payload);
onClose();
} catch (error) {
console.error("保存设置失败:", error);
@@ -135,13 +179,75 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
}
};
const handleBrowseConfigDir = async (app: AppType) => {
try {
const currentResolved =
app === "claude"
? (settings.claudeConfigDir ?? resolvedClaudeDir)
: (settings.codexConfigDir ?? resolvedCodexDir);
const selected = await window.api.selectConfigDirectory(currentResolved);
if (!selected) {
return;
}
const sanitized = selected.trim();
if (sanitized === "") {
return;
}
if (app === "claude") {
setSettings((prev) => ({ ...prev, claudeConfigDir: sanitized }));
setResolvedClaudeDir(sanitized);
} else {
setSettings((prev) => ({ ...prev, codexConfigDir: sanitized }));
setResolvedCodexDir(sanitized);
}
} catch (error) {
console.error("选择配置目录失败:", error);
}
};
const computeDefaultConfigDir = async (app: AppType) => {
try {
const home = await homeDir();
const folder = app === "claude" ? ".claude" : ".codex";
return await join(home, folder);
} catch (error) {
console.error("获取默认配置目录失败:", error);
return "";
}
};
const handleResetConfigDir = async (app: AppType) => {
setSettings((prev) => ({
...prev,
...(app === "claude"
? { claudeConfigDir: undefined }
: { codexConfigDir: undefined }),
}));
const defaultDir = await computeDefaultConfigDir(app);
if (!defaultDir) {
return;
}
if (app === "claude") {
setResolvedClaudeDir(defaultDir);
} else {
setResolvedCodexDir(defaultDir);
}
};
const handleOpenReleaseNotes = async () => {
try {
const targetVersion = updateInfo?.availableVersion || version;
// 如果未知或为空,回退到 releases 首页
if (!targetVersion || targetVersion === "未知") {
await window.api.openExternal(
"https://github.com/farion1231/cc-switch/releases",
"https://github.com/farion1231/cc-switch/releases"
);
return;
}
@@ -149,7 +255,7 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
? targetVersion
: `v${targetVersion}`;
await window.api.openExternal(
`https://github.com/farion1231/cc-switch/releases/tag/${tag}`,
`https://github.com/farion1231/cc-switch/releases/tag/${tag}`
);
} catch (error) {
console.error("打开更新日志失败:", error);
@@ -157,8 +263,18 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
};
return (
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-[500px] overflow-hidden">
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
className={`absolute inset-0 bg-black/50 dark:bg-black/70${
isLinux() ? "" : " backdrop-blur-sm"
}`}
/>
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-[500px] overflow-hidden">
{/* 标题栏 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-800">
<h2 className="text-lg font-semibold text-blue-500 dark:text-blue-400">
@@ -195,6 +311,8 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
</label>
</div> */}
{/* VS Code 自动同步设置已移除 */}
{/* 配置文件位置 */}
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
@@ -219,6 +337,90 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
</div>
</div>
{/* 配置目录覆盖 */}
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3 leading-relaxed">
WSL 使 Claude Code Codex WSL
</p>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
Claude Code
</label>
<div className="flex gap-2">
<input
type="text"
value={settings.claudeConfigDir ?? resolvedClaudeDir ?? ""}
onChange={(e) =>
setSettings({
...settings,
claudeConfigDir: e.target.value,
})
}
placeholder="例如:/home/<你的用户名>/.claude"
className="flex-1 px-3 py-2 text-xs font-mono bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/40"
/>
<button
type="button"
onClick={() => handleBrowseConfigDir("claude")}
className="px-2 py-2 text-xs text-gray-500 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
title="浏览目录"
>
<FolderSearch size={16} />
</button>
<button
type="button"
onClick={() => handleResetConfigDir("claude")}
className="px-2 py-2 text-xs text-gray-500 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
title="恢复默认目录(需保存后生效)"
>
<Undo2 size={16} />
</button>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
Codex
</label>
<div className="flex gap-2">
<input
type="text"
value={settings.codexConfigDir ?? resolvedCodexDir ?? ""}
onChange={(e) =>
setSettings({
...settings,
codexConfigDir: e.target.value,
})
}
placeholder="例如:/home/<你的用户名>/.codex"
className="flex-1 px-3 py-2 text-xs font-mono bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/40"
/>
<button
type="button"
onClick={() => handleBrowseConfigDir("codex")}
className="px-2 py-2 text-xs text-gray-500 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
title="浏览目录"
>
<FolderSearch size={16} />
</button>
<button
type="button"
onClick={() => handleResetConfigDir("codex")}
className="px-2 py-2 text-xs text-gray-500 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
title="恢复默认目录(需保存后生效)"
>
<Undo2 size={16} />
</button>
</div>
</div>
</div>
</div>
{/* 关于 */}
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
@@ -252,11 +454,11 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
<button
onClick={handleCheckUpdate}
disabled={isCheckingUpdate || isDownloading}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-all ${
className={`min-w-[88px] px-3 py-1.5 text-xs font-medium rounded-lg transition-all ${
isCheckingUpdate || isDownloading
? "bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed"
? "bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed border border-transparent"
: hasUpdate
? "bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 text-white"
? "bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 text-white border border-transparent"
: showUpToDate
? "bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 border border-green-200 dark:border-green-800"
: "bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 text-blue-500 dark:text-blue-400 border border-gray-200 dark:border-gray-600"

View File

@@ -10,6 +10,43 @@ export interface CodexProviderPreset {
config: string; // 将写入 ~/.codex/config.tomlTOML 字符串)
isOfficial?: boolean; // 标识是否为官方预设
category?: ProviderCategory; // 新增:分类
isCustomTemplate?: boolean; // 标识是否为自定义模板
}
/**
* 生成第三方供应商的 auth.json
*/
export function generateThirdPartyAuth(apiKey: string): Record<string, any> {
return {
OPENAI_API_KEY: apiKey || "sk-your-api-key-here"
};
}
/**
* 生成第三方供应商的 config.toml
*/
export function generateThirdPartyConfig(
providerName: string,
baseUrl: string,
modelName = "gpt-5-codex"
): string {
// 清理供应商名称确保符合TOML键名规范
const cleanProviderName = providerName
.toLowerCase()
.replace(/[^a-z0-9_]/g, '_')
.replace(/^_+|_+$/g, '') || 'custom';
return `model_provider = "${cleanProviderName}"
model = "${modelName}"
model_reasoning_effort = "high"
disable_response_storage = true
[model_providers.${cleanProviderName}]
name = "${cleanProviderName}"
base_url = "${baseUrl}"
wire_api = "responses"
env_key = "${cleanProviderName}"
requires_openai_auth = true`;
}
export const codexProviderPresets: CodexProviderPreset[] = [
@@ -18,7 +55,6 @@ export const codexProviderPresets: CodexProviderPreset[] = [
websiteUrl: "https://chatgpt.com/codex",
isOfficial: true,
category: "official",
// 官方的 key 为null
auth: {
OPENAI_API_KEY: null,
},
@@ -28,19 +64,11 @@ export const codexProviderPresets: CodexProviderPreset[] = [
name: "PackyCode",
websiteUrl: "https://codex.packycode.com/",
category: "third_party",
// 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"`,
auth: generateThirdPartyAuth("sk-your-api-key-here"),
config: generateThirdPartyConfig(
"packycode",
"https://codex-api.packycode.com/v1",
"gpt-5-codex"
),
},
];

View File

@@ -6,6 +6,8 @@ import { ProviderCategory } from "../types";
export interface ProviderPreset {
name: string;
websiteUrl: string;
// 新增:第三方/聚合等可单独配置获取 API Key 的链接
apiKeyUrl?: string;
settingsConfig: object;
isOfficial?: boolean; // 标识是否为官方预设
category?: ProviderCategory; // 新增:分类
@@ -90,6 +92,7 @@ export const providerPresets: ProviderPreset[] = [
{
name: "PackyCode",
websiteUrl: "https://www.packycode.com",
apiKeyUrl: "https://www.packycode.com/?aff=rlo54mgz",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://api.packycode.com",

View File

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

30
src/lib/platform.ts Normal file
View File

@@ -0,0 +1,30 @@
// 轻量平台检测,避免在 SSR 或无 navigator 的环境报错
export const isMac = (): boolean => {
try {
const ua = navigator.userAgent || "";
const plat = (navigator.platform || "").toLowerCase();
return /mac/i.test(ua) || plat.includes("mac");
} catch {
return false;
}
};
export const isWindows = (): boolean => {
try {
const ua = navigator.userAgent || "";
return /windows|win32|win64/i.test(ua);
} catch {
return false;
}
};
export const isLinux = (): boolean => {
try {
const ua = navigator.userAgent || "";
// WebKitGTK/Chromium 在 Linux/Wayland/X11 下 UA 通常包含 Linux 或 X11
return /linux|x11/i.test(ua) && !/android/i.test(ua) && !isMac() && !isWindows();
} catch {
return false;
}
};

View File

@@ -38,7 +38,7 @@ export const cardStyles = {
// 选中/激活态卡片
selected:
"bg-white rounded-lg border border-blue-500 ring-1 ring-blue-500/20 bg-blue-500/5 p-4 dark:bg-gray-900 dark:border-blue-400 dark:ring-blue-400/20 dark:bg-blue-400/10",
"bg-white rounded-lg border border-blue-500 shadow-sm bg-blue-50 p-4 dark:bg-gray-900 dark:border-blue-400 dark:bg-blue-400/10",
} as const;
// 输入控件样式

View File

@@ -122,6 +122,16 @@ export const tauriAPI = {
}
},
// 获取当前生效的配置目录
getConfigDir: async (app?: AppType): Promise<string> => {
try {
return await invoke("get_config_dir", { app_type: app, app });
} catch (error) {
console.error("获取配置目录失败:", error);
return "";
}
},
// 获取 Claude Code 配置状态
getClaudeConfigStatus: async (): Promise<ConfigStatus> => {
try {
@@ -189,10 +199,22 @@ export const tauriAPI = {
// (保留空位,取消迁移提示)
// 选择配置文件Tauri 暂不实现,保留接口兼容性)
selectConfigFile: async (): Promise<string | null> => {
console.warn("selectConfigFile 在 Tauri 版本中暂不支持");
return null;
// 选择配置目录
selectConfigDirectory: async (
defaultPath?: string,
): Promise<string | null> => {
try {
const sanitized =
defaultPath && defaultPath.trim() !== ""
? defaultPath
: undefined;
return await invoke<string | null>("pick_directory", {
defaultPath: sanitized,
});
} catch (error) {
console.error("选择配置目录失败:", error);
return null;
}
},
// 获取设置
@@ -242,6 +264,38 @@ export const tauriAPI = {
console.error("打开应用配置文件夹失败:", error);
}
},
// VS Code: 获取 settings.json 状态
getVSCodeSettingsStatus: async (): Promise<{
exists: boolean;
path: string;
error?: string;
}> => {
try {
return await invoke("get_vscode_settings_status");
} catch (error) {
console.error("获取 VS Code 设置状态失败:", error);
return { exists: false, path: "", error: String(error) };
}
},
// VS Code: 读取 settings.json 文本
readVSCodeSettings: async (): Promise<string> => {
try {
return await invoke("read_vscode_settings");
} catch (error) {
throw new Error(`读取 VS Code 设置失败: ${String(error)}`);
}
},
// VS Code: 写回 settings.json 文本(不自动创建)
writeVSCodeSettings: async (content: string): Promise<boolean> => {
try {
return await invoke("write_vscode_settings", { content });
} catch (error) {
throw new Error(`写入 VS Code 设置失败: ${String(error)}`);
}
},
};
// 创建全局 API 对象,兼容现有代码

View File

@@ -24,4 +24,8 @@ export interface AppConfig {
export interface Settings {
// 是否在系统托盘macOS 菜单栏)显示图标
showInTray: boolean;
// 覆盖 Claude Code 配置目录(可选)
claudeConfigDir?: string;
// 覆盖 Codex 配置目录(可选)
codexConfigDir?: string;
}

38
src/utils/errorUtils.ts Normal file
View File

@@ -0,0 +1,38 @@
/**
* 从各种错误对象中提取错误信息
* @param error 错误对象
* @returns 提取的错误信息字符串
*/
export const extractErrorMessage = (error: unknown): string => {
if (!error) return "";
if (typeof error === "string") {
return error;
}
if (error instanceof Error && error.message.trim()) {
return error.message;
}
if (typeof error === "object") {
const errObject = error as Record<string, unknown>;
const candidate = errObject.message ?? errObject.error ?? errObject.detail;
if (typeof candidate === "string" && candidate.trim()) {
return candidate;
}
const payload = errObject.payload;
if (typeof payload === "string" && payload.trim()) {
return payload;
}
if (payload && typeof payload === "object") {
const payloadObj = payload as Record<string, unknown>;
const payloadCandidate =
payloadObj.message ?? payloadObj.error ?? payloadObj.detail;
if (typeof payloadCandidate === "string" && payloadCandidate.trim()) {
return payloadCandidate;
}
}
}
return "";
};

View File

@@ -1,33 +1,162 @@
// 供应商配置处理工具函数
// 处理includeCoAuthoredBy字段的添加/删除
export const updateCoAuthoredSetting = (
jsonString: string,
disable: boolean,
): string => {
try {
const config = JSON.parse(jsonString);
const isPlainObject = (value: unknown): value is Record<string, any> => {
return Object.prototype.toString.call(value) === "[object Object]";
};
if (disable) {
// 添加或更新includeCoAuthoredBy字段
config.includeCoAuthoredBy = false;
const deepMerge = (
target: Record<string, any>,
source: Record<string, any>,
): Record<string, any> => {
Object.entries(source).forEach(([key, value]) => {
if (isPlainObject(value)) {
if (!isPlainObject(target[key])) {
target[key] = {};
}
deepMerge(target[key], value);
} else {
// 删除includeCoAuthoredBy字段
delete config.includeCoAuthoredBy;
// 直接覆盖非对象字段(数组/基础类型)
target[key] = value;
}
});
return target;
};
return JSON.stringify(config, null, 2);
} catch (err) {
// 如果JSON解析失败返回原始字符串
return jsonString;
const deepRemove = (
target: Record<string, any>,
source: Record<string, any>,
) => {
Object.entries(source).forEach(([key, value]) => {
if (!(key in target)) return;
if (isPlainObject(value) && isPlainObject(target[key])) {
// 只移除完全匹配的嵌套属性
deepRemove(target[key], value);
if (Object.keys(target[key]).length === 0) {
delete target[key];
}
} else if (isSubset(target[key], value)) {
// 只有当值完全匹配时才删除
delete target[key];
}
});
};
const isSubset = (target: any, source: any): boolean => {
if (isPlainObject(source)) {
if (!isPlainObject(target)) return false;
return Object.entries(source).every(([key, value]) =>
isSubset(target[key], value),
);
}
if (Array.isArray(source)) {
if (!Array.isArray(target) || target.length !== source.length) return false;
return source.every((item, index) => isSubset(target[index], item));
}
return target === source;
};
// 深拷贝函数
const deepClone = <T>(obj: T): T => {
if (obj === null || typeof obj !== "object") return obj;
if (obj instanceof Date) return new Date(obj.getTime()) as T;
if (obj instanceof Array) return obj.map((item) => deepClone(item)) as T;
if (obj instanceof Object) {
const clonedObj = {} as T;
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
clonedObj[key] = deepClone(obj[key]);
}
}
return clonedObj;
}
return obj;
};
export interface UpdateCommonConfigResult {
updatedConfig: string;
error?: string;
}
// 验证JSON配置格式
export const validateJsonConfig = (
value: string,
fieldName: string = "配置",
): string => {
if (!value.trim()) {
return "";
}
try {
const parsed = JSON.parse(value);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return `${fieldName}必须是 JSON 对象`;
}
return "";
} catch {
return `${fieldName}JSON格式错误请检查语法`;
}
};
// 从JSON配置中检查是否包含includeCoAuthoredBy设置
export const checkCoAuthoredSetting = (jsonString: string): boolean => {
// 将通用配置片段写入/移除 settingsConfig
export const updateCommonConfigSnippet = (
jsonString: string,
snippetString: string,
enabled: boolean,
): UpdateCommonConfigResult => {
let config: Record<string, any>;
try {
const config = JSON.parse(jsonString);
return config.includeCoAuthoredBy === false;
config = jsonString ? JSON.parse(jsonString) : {};
} catch (err) {
return {
updatedConfig: jsonString,
error: "配置 JSON 解析失败,无法写入通用配置",
};
}
if (!snippetString.trim()) {
return {
updatedConfig: JSON.stringify(config, null, 2),
};
}
// 使用统一的验证函数
const snippetError = validateJsonConfig(snippetString, "通用配置片段");
if (snippetError) {
return {
updatedConfig: JSON.stringify(config, null, 2),
error: snippetError,
};
}
const snippet = JSON.parse(snippetString) as Record<string, any>;
if (enabled) {
const merged = deepMerge(deepClone(config), snippet);
return {
updatedConfig: JSON.stringify(merged, null, 2),
};
}
const cloned = deepClone(config);
deepRemove(cloned, snippet);
return {
updatedConfig: JSON.stringify(cloned, null, 2),
};
};
// 检查当前配置是否已包含通用配置片段
export const hasCommonConfigSnippet = (
jsonString: string,
snippetString: string,
): boolean => {
try {
if (!snippetString.trim()) return false;
const config = jsonString ? JSON.parse(jsonString) : {};
const snippet = JSON.parse(snippetString);
if (!isPlainObject(snippet)) return false;
return isSubset(config, snippet);
} catch (err) {
return false;
}
@@ -79,3 +208,113 @@ export const setApiKeyInConfig = (
return jsonString;
}
};
// ========== TOML Config Utilities ==========
export interface UpdateTomlCommonConfigResult {
updatedConfig: string;
error?: string;
}
// 保存之前的通用配置片段,用于替换操作
let previousCommonSnippet = "";
// 将通用配置片段写入/移除 TOML 配置
export const updateTomlCommonConfigSnippet = (
tomlString: string,
snippetString: string,
enabled: boolean,
): UpdateTomlCommonConfigResult => {
if (!snippetString.trim()) {
// 如果片段为空,直接返回原始配置
return {
updatedConfig: tomlString,
};
}
if (enabled) {
// 添加通用配置
// 先移除旧的通用配置(如果有)
let updatedConfig = tomlString;
if (previousCommonSnippet && tomlString.includes(previousCommonSnippet)) {
updatedConfig = tomlString.replace(previousCommonSnippet, "");
}
// 在文件末尾添加新的通用配置
// 确保有适当的换行
const needsNewline = updatedConfig && !updatedConfig.endsWith("\n");
updatedConfig =
updatedConfig + (needsNewline ? "\n\n" : "\n") + snippetString;
// 保存当前通用配置片段
previousCommonSnippet = snippetString;
return {
updatedConfig: updatedConfig.trim() + "\n",
};
} else {
// 移除通用配置
if (tomlString.includes(snippetString)) {
const updatedConfig = tomlString.replace(snippetString, "");
// 清理多余的空行
const cleaned = updatedConfig.replace(/\n{3,}/g, "\n\n").trim();
// 清空保存的状态
previousCommonSnippet = "";
return {
updatedConfig: cleaned ? cleaned + "\n" : "",
};
}
return {
updatedConfig: tomlString,
};
}
};
// 检查 TOML 配置是否已包含通用配置片段
export const hasTomlCommonConfigSnippet = (
tomlString: string,
snippetString: string,
): boolean => {
if (!snippetString.trim()) return false;
// 简单检查配置是否包含片段内容
// 去除空白字符后比较,避免格式差异影响
const normalizeWhitespace = (str: string) => str.replace(/\s+/g, " ").trim();
return normalizeWhitespace(tomlString).includes(
normalizeWhitespace(snippetString),
);
};
// ========== Codex base_url utils ==========
// 从 Codex 的 TOML 配置文本中提取 base_url支持单/双引号)
export const extractCodexBaseUrl = (
configText: string | undefined | null,
): string | undefined => {
try {
const text = typeof configText === "string" ? configText : "";
if (!text) return undefined;
const m = text.match(/base_url\s*=\s*(['"])([^'\"]+)\1/);
return m && m[2] ? m[2] : undefined;
} catch {
return undefined;
}
};
// 从 Provider 对象中提取 Codex base_url当 settingsConfig.config 为 TOML 字符串时)
export const getCodexBaseUrl = (
provider: { settingsConfig?: Record<string, any> } | undefined | null,
): string | undefined => {
try {
const text =
typeof provider?.settingsConfig?.config === "string"
? (provider as any).settingsConfig.config
: "";
return extractCodexBaseUrl(text);
} catch {
return undefined;
}
};

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

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

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

@@ -28,7 +28,8 @@ declare global {
getClaudeCodeConfigPath: () => Promise<string>;
getClaudeConfigStatus: () => Promise<ConfigStatus>;
getConfigStatus: (app?: AppType) => Promise<ConfigStatus>;
selectConfigFile: () => Promise<string | null>;
getConfigDir: (app?: AppType) => Promise<string>;
selectConfigDirectory: (defaultPath?: string) => Promise<string | null>;
openConfigFolder: (app?: AppType) => Promise<void>;
openExternal: (url: string) => Promise<void>;
updateTrayMenu: () => Promise<boolean>;
@@ -40,6 +41,10 @@ declare global {
checkForUpdates: () => Promise<void>;
getAppConfigPath: () => Promise<string>;
openAppConfigFolder: () => Promise<void>;
// VS Code settings.json 能力
getVSCodeSettingsStatus: () => Promise<ConfigStatus>;
readVSCodeSettings: () => Promise<string>;
writeVSCodeSettings: (content: string) => Promise<boolean>;
};
platform: {
isMac: boolean;