208 Commits

Author SHA1 Message Date
Jason
f55c6d3d91 chore: standardize release artifact naming with version tags
- Update GitHub Actions to generate version-tagged filenames
- macOS: CC-Switch-v{version}-macOS.tar.gz / .zip
- Windows: CC-Switch-v{version}-Windows.msi / -Portable.zip
- Linux: CC-Switch-v{version}-Linux.AppImage / .deb
- Update README installation instructions with new filename format
- Add naming standardization note to CHANGELOG v3.5.0
2025-10-12 23:17:05 +08:00
Jason
ec83e2ca44 docs: add Homebrew installation instructions 2025-10-12 22:38:47 +08:00
Jason
60e8351f60 chore: bump version to v3.5.0 and update roadmap
Version Changes:
- Update version to 3.5.0 in package.json, Cargo.toml, and tauri.conf.json

Changelog Updates:
- Add v3.5.0 release notes with comprehensive feature list
- Document MCP management system implementation
- Document configuration import/export functionality
- Document endpoint speed testing feature
- List all improvements, bug fixes, and technical enhancements

Roadmap Updates:
- Mark MCP manager as completed 
- Mark i18n (internationalization) as completed 
- Add new planned features: memory management, cloud sync
- Reorganize feature priorities
2025-10-12 22:27:02 +08:00
Jason
4a9eb64f76 feat: add Longcat provider and update GLM model version
- Add Longcat provider preset with Flash-Chat model configuration
- Update mainModelPlaceholder from GLM-4.5 to GLM-4.6 in i18n files
- Configure Longcat with max output tokens (6000) and disabled non-essential traffic
2025-10-12 21:31:38 +08:00
Jason
66bbf63300 feat(ui): add endpoint format hint for Codex providers
Add informational hint below Codex API endpoint input field to guide users
to fill in OpenAI Response compatible service endpoints, similar to the
existing hint for Claude providers.
2025-10-12 19:58:40 +08:00
Jason
6e2c80531d refactor: improve code quality and fix linting issues
- Derive Default trait instead of manual implementation for McpRoot and ProviderManager
- Remove redundant closures in codex_config.rs and config.rs
- Simplify match statements to if let patterns in migration.rs and lib.rs
- Remove unnecessary type conversions and borrows in lib.rs
- Fix i18n key inconsistency: sequentialThinking → sequential-thinking
- Format TypeScript files to match Prettier style

All clippy warnings resolved, code passes all quality checks.
2025-10-12 16:52:32 +08:00
Jason
2ec0a10a2c feat(i18n): complete internationalization for MCP preset descriptions
- Add i18n keys for all 5 MCP preset descriptions (fetch, time, memory, sequential-thinking, context7)
- Refactor mcpPresets.ts to use i18n keys instead of hardcoded Chinese descriptions
- Add getMcpPresetWithDescription() helper function for dynamic translation
- Update McpFormModal to use translated descriptions when applying presets
- Fix inconsistent key naming: sequentialThinking -> sequential-thinking

This ensures MCP preset descriptions automatically switch language based on user's locale setting.
2025-10-12 16:34:32 +08:00
Jason
e92d99b758 feat(mcp): add automatic key normalization for server entries
- Add normalize_server_keys() to ensure MCP server map keys match internal id fields
- Auto-normalize on all read/write operations (get, upsert, delete, import, sync)
- Handle edge cases: empty/whitespace ids, key renaming, conflict resolution
- Auto-save config when normalization detects changes
- Apply cargo fmt for code formatting consistency

This enhancement improves data integrity by automatically fixing inconsistencies
between server entry keys and their id fields, especially after manual config edits.
2025-10-12 16:21:32 +08:00
Jason
036d41b774 fix(ui): vertically center action buttons in provider list
Change the flex alignment from items-start to items-center in the provider card layout to ensure the action buttons (Enable, Edit, Delete) are vertically centered relative to the provider information.
2025-10-12 13:17:38 +08:00
Jason
3bd70b9508 feat(mcp): add enabled count to panel info section
- Add enabledCount calculation using useMemo for performance
- Display format: "X MCP server(s) configured · Y enabled"
- Add i18n keys: mcp.enabledCount for zh and en
- Real-time updates when toggling server enabled status
2025-10-12 12:16:15 +08:00
Jason
664efc7456 chore(mcp): update presets for time and context7 servers
Updates MCP preset configurations with accurate information:

- time preset: Remove "completely no config needed" claim for clarity
- context7 preset: Update package name from @context7/mcp-server to @upstash/context7-mcp
- context7 preset: Update description to mention API key provides higher quota
- context7 preset: Update docs URL to new GitHub repository location

These changes reflect the latest upstream changes and package migrations.
2025-10-12 11:31:36 +08:00
Jason
1415ef4d78 feat(mcp): add collapsible additional info section in form modal
Improves UX by collapsing optional metadata fields (description, tags, homepage, docs) behind an expandable "Additional Info" section.

Changes:
- Add collapsible section with ChevronUp/ChevronDown icons
- Smart default state: collapsed for new entries, expanded for existing entries with metadata
- Add i18n keys: mcp.form.additionalInfo (zh: "附加信息", en: "Additional Info")
- Keep core fields (ID, name, config) always visible for better focus

Benefits:
- Cleaner, less cluttered form interface
- Users can focus on essential configuration first
- Optional metadata easily accessible when needed
2025-10-12 11:01:49 +08:00
Jason
fb137c4a78 refactor(mcp): improve data structure with metadata/spec separation
- Separate MCP server metadata from connection spec for cleaner architecture
- Add comprehensive server entry fields: name, description, tags, homepage, docs
- Remove legacy format compatibility logic from extract_server_spec
- Implement data validation and filtering in get_servers_snapshot_for
- Add strict id consistency check in upsert_in_config_for
- Enhance import logic with defensive programming for corrupted data
- Simplify frontend by removing normalization logic (moved to backend)
- Improve error messages with contextual information
- Add comprehensive i18n support for new metadata fields
2025-10-12 00:08:37 +08:00
Jason
668ab710c6 fix: preserve codex mcp metadata 2025-10-11 16:35:51 +08:00
Jason
ea7080a42e fix(mcp): improve error message internationalization
- Add translateMcpBackendError utility to map backend errors to i18n keys
- Update error handling in McpPanel, McpFormModal, and McpWizardModal
- Internationalize stdio/http type selectors in Wizard
- Implement three-tier fallback strategy: translation → raw error → default message
- No backend changes required, fully frontend-based i18n implementation
2025-10-11 16:20:12 +08:00
Jason
c2b27a4949 fix(app): eliminate startup white screen by replacing @iarna/toml with browser-friendly smol-toml
- chore(deps): switch TOML dependency from @iarna/toml to smol-toml and update lockfile
- feat(mcp): add TOML editing/validation for Codex while keeping JSON for Claude; support auto ID extraction from TOML and JSON->TOML conversion for wizard output; add pre-submit required checks (stdio.command / http.url)
- refactor(mcp): unify JSON/TOML validation errors via i18n; add formatTomlError for consistent, localized messages; consolidate state into formConfig/configError
- feat(i18n): add TOML labels/placeholders and error keys (tomlConfig, tomlPlaceholder, tomlInvalid)
- feat(utils): introduce tomlUtils with parse/stringify/validate/convert helpers using smol-toml; provide tomlToMcpServer, mcpServerToToml, extractIdFromToml, validateToml
- build: confirm Vite no longer externalizes Node builtins during build; renderer builds without 'Module 'stream' has been externalized' warning
2025-10-11 15:34:58 +08:00
Jason
a6ee3ba35f feat(mcp): enhance wizard with title field and optimize placeholders
- Add MCP title field to wizard (required)
- Remove working directory (cwd) field and related logic
- Update wizard callback to return both title and JSON
- Optimize placeholder text for better user guidance:
  - Command: "npx or uvx"
  - Args: "arg1\narg2"
  - Env: "KEY1=value1\nKEY2=value2"
  - Headers: "Authorization: Bearer your_token_here\n..."
- Simplify args label by removing "(one per line)" hint
- Update parent component to handle title from wizard
2025-10-11 11:43:32 +08:00
Jason
2a60d20841 fix: align Tauri arg names and improve export UX
- Match frontend camelCase keys to backend snake_case params
- Show error toast when save dialog is cancelled
2025-10-11 11:10:03 +08:00
Jason
42329d4dce fix(mcp): prevent wizard height jump when switching types
- Add min-h-[400px] to form fields container
- Ensures consistent height between stdio and http modes
- Eliminates visual jump when toggling between types
2025-10-11 10:15:40 +08:00
Jason
5013d3b4c9 feat(mcp): show wizard button only in custom mode
- Hide 'Use Configuration Wizard' button when preset MCP is selected
- Show wizard button only in custom mode or edit mode
- Prevents user confusion by avoiding wizard access when preset configs are loaded
2025-10-11 09:55:54 +08:00
Jason
9ba9cddf18 feat(mcp): unify preset button styles with emerald theme
- Update MCP preset selector to match provider form layout
- Change button size from xs to sm (px-4 py-2) for consistency
- Use emerald-500/600 for selected state (MCP brand color)
- Add preset description text below buttons
- Fix cancel button hover effect in footer (gray-200/gray-700)
- Fix missing space in zh i18n: "应用到 Claude Code 插件"
2025-10-11 09:22:33 +08:00
Jason
81356cacee feat(mcp): unify modal styles and add footer with done button
- Unify McpFormModal width to max-w-3xl (consistent with provider form)
- Add scrollable container with max-h-[90vh] to McpFormModal
- Add footer section to both McpFormModal and McpPanel
- Add "Done" button with emerald theme and check icon to McpPanel footer
- Add i18n keys for "common.done" (zh: "完成", en: "Done")
- Apply consistent footer styling across all modals
2025-10-10 23:57:38 +08:00
Jason
3b142155c3 fix(mcp): eliminate panel flicker on toggle with optimistic updates
- Add fixed height (h-16) to MCP list items for visual consistency
- Implement optimistic UI updates in handleToggle to prevent flicker
- Remove reload() call that caused loading state transitions
- Add automatic rollback on API failure with error notifications
2025-10-10 23:23:40 +08:00
Jason
4543664ba2 refactor(mcp): remove installed preset badge and env-related preset logic
- Move MCP presets into Add modal, no auto-seeding into list
- Replace env-required presets with context7 (no env needed)
- Remove requiresEnv checks/prompts from list and form
- Keep Docs button; maintain clean list UI
2025-10-10 22:34:38 +08:00
Jason
e88562be98 - feat(mcp): unify notifications via onNotify in form and wizard
- refactor(mcp): remove HTML5 required to avoid native popups

- refactor(ui): propagate onNotify from App → McpPanel → McpFormModal → McpWizardModal

- feat(settings): use onNotify for export and file-selection feedback

- fix(ui): notify link-open failures via onNotify; remove unused appType prop from ProviderList

- chore: format codebase and ensure typecheck passes
2025-10-10 20:52:16 +08:00
Jason
bfdf7d4ad5 fix: preserve meta.custom_endpoints on update and persist preset candidates on create
- Preserve and merge meta.custom_endpoints in update_provider to avoid losing custom endpoints added via Tauri commands during edit/save. Merge old and incoming meta; keep existing entries and timestamps, add new URLs only.
- Persist endpoint candidates when creating a provider: union of user-added custom endpoints, selected base URL (Claude/Codex), and preset.endpointCandidates; normalize and de-duplicate. Ensures PackyCode keeps all 5 nodes after saving.

Files:
- src-tauri/src/commands.rs
- src/components/ProviderForm.tsx

Validation:
- cargo check passes
- Manual: create from PackyCode preset -> save -> reopen edit -> Manage & Test lists all preset nodes; edit existing provider -> add endpoint -> save -> reopen -> endpoint persists.
2025-10-10 20:20:08 +08:00
Jason
c350e64687 feat(settings): add 'Apply to Claude Code extension' toggle
- Apply immediately on save (write or remove primaryApiKey)
- Honor setting on provider switch (enabled: write for non-official, remove for official; disabled: no auto writes)
- Remove per-provider Claude plugin buttons from ProviderList
- Upsert primaryApiKey=any preserving other fields; respect override dir
- Add zh/en i18n for the new setting
2025-10-10 16:35:21 +08:00
Jason
70d8d2cc43 feat(codex): add requires_openai_auth flag to provider config template
Add requires_openai_auth = true to the generated config.toml template
for custom Codex providers to ensure proper authentication handling.
2025-10-10 16:00:12 +08:00
Jason
56b2681a6f feat(provider): use live config for edit and backfill SSOT after switch
- Edit modal (Claude+Codex): when editing the current provider, initialize form from live files (Claude: ~/.claude/settings.json; Codex: ~/.codex/auth.json + ~/.codex/config.toml) instead of SSOT.
- Switch (Claude): after writing live settings.json for the target provider, read it back and update the provider’s SSOT to match live.
- Switch (Codex): keep MCP sync to config.toml, then read back TOML and update the target provider’s SSOT (preserves mcp.servers/mcp_servers schema).
- Add Tauri command read_live_provider_settings for both apps, register handler, and expose window.api.getLiveProviderSettings.
- Types updated accordingly; cargo check and pnpm typecheck pass.
2025-10-10 15:47:57 +08:00
Jason
6cf7dacd0e feat(mcp): import Codex MCP from ~/.codex/config.toml
- Support both TOML schemas: [mcp.servers.<id>] and [mcp_servers.<id>]
- Non-destructive merge of imported servers (enabled=true only)
- Preserve existing TOML schema when syncing (prefer mcp_servers)
- Remove both mcp and mcp_servers when no enabled items

feat(ui): auto-import Codex MCP on panel init (app=codex)

chore(tauri): add import_mcp_from_codex command and register

chore(types): expose window.api.importMcpFromCodex and typings

fix(ui): remove unused variable for typecheck
2025-10-10 14:59:02 +08:00
Jason
428369cae0 feat(mcp): app-aware MCP panel and Codex MCP sync to config.toml
- Make MCP panel app-aware; pass appType from App and call APIs with current app
- Show active app in title: “MCP Management · Claude Code/Codex”
- Add sync_enabled_to_codex: project enabled servers from SSOT to ~/.codex/config.toml as [mcp.servers.*]
- Sync on enable/disable, delete, and provider switch (post live write)
- Add Tauri command sync_enabled_mcp_to_codex and expose window.api.syncEnabledMcpToCodex()
- Fix Rust borrow scopes in switch_provider to avoid E0502
- Add TS declarations for new Codex sync API
2025-10-10 12:35:02 +08:00
Jason
7f1131dfae refactor(mcp): improve UI consistency and i18n
- Add MCP-specific green button style (buttonStyles.mcp)
- Unify MCP panel and form buttons with emerald theme
- Adjust MCP entry button width to match AppSwitcher (px-3)
- Reduce JSON editor height from h-64 to h-48
- Update translations: "Add/Edit Server" → "Add/Edit MCP"
- Change form label to "MCP Title (Unique)" for clarity
- Move config wizard button to right side of JSON label
- Fix McpListItem enabled state check (explicit true check)
2025-10-10 11:58:40 +08:00
Jason
7493f3f9dd feat(mcp): show inline duplicate ID error and block submit in add mode
- Display red hint next to title when ID exists

- Disable Add/Save button and prevent submit on duplicate

- Accept existing IDs via prop for real-time validation

- Remove overwrite confirmation dialog on add

- i18n: add duplicate-ID error strings and remove unused overwrite prompt

- files:

  - src/components/mcp/McpFormModal.tsx

  - src/components/mcp/McpPanel.tsx

  - src/i18n/locales/en.json

  - src/i18n/locales/zh.json
2025-10-10 11:17:40 +08:00
Jason
eb8d9352c8 fix(mcp): properly save and display description field
- Initialize formDescription from initialData.description when editing
- Save formDescription to server object before submitting
- Display only description in list items, hide technical details
- Show empty space when description is not available
2025-10-09 23:13:33 +08:00
Jason
29b8d5edde refactor(mcp): optimize panel UI dimensions and simplify state
- Reduce max width from 4xl (896px) to 3xl (768px)
- Adjust max height from 90vh to 85vh with min-height 600px
- Remove config path display, keep only server count
- Remove McpStatus state, calculate count directly from servers
- Simplify reload function by removing redundant status updates
2025-10-09 22:56:03 +08:00
Jason
97d81c13b7 chore(mcp): clear built-in MCP presets; keep logic intact
- Remove sample presets from  while preserving seeding/display logic
- Add inline notes for future preset/hidden strategy and per-client (claude/codex) split
- Make preset seeding a no-op with empty list; no behavior change elsewhere
- Verified
> cc-switch@3.4.0 typecheck /Users/jasonyoung/Code/cc-switch
> tsc --noEmit passes; UI reads/writes continue to work
2025-10-09 22:20:15 +08:00
Jason
511980e3ea fix(mcp): remove SSE support; keep stdio default when type is omitted
- Backend: reject "sse" in validators; accept missing type as stdio; require url only for http (mcp.rs, claude_mcp.rs)
- Frontend: McpServer.type narrowed to "stdio" | "http" (optional) (src/types.ts)
- UI: avoid undefined in list item details (McpListItem)
- Claude-only sync after delete to update ~/.claude.json (commands.rs)

Notes:
- Ran typecheck and cargo check: both pass
- Clippy shows advisory warnings unrelated to this change
- Prettier check warns on a few files; limited scope changes kept minimal
2025-10-09 22:02:56 +08:00
Jason
f6bf8611cd feat(mcp): use project config as SSOT and sync enabled servers to ~/.claude.json
- Add McpConfig to MultiAppConfig and persist MCP servers in ~/.cc-switch/config.json
- Add Tauri commands: get_mcp_config, upsert_mcp_server_in_config, delete_mcp_server_in_config, set_mcp_enabled, sync_enabled_mcp_to_claude, import_mcp_from_claude
- Only write enabled MCPs to ~/.claude.json (mcpServers) and strip UI-only fields (enabled/source)
- Frontend: update API wrappers and MCP panel to read/write via config.json; seed presets on first open; import from ~/.claude.json
- Fix warnings (remove unused mut, dead code)
- Verified with cargo check and pnpm typecheck
2025-10-09 21:08:42 +08:00
Jason
0be596afb5 feat(mcp): inline presets in panel with one-click enable
- Show not-installed MCP presets directly in the list, consistent with existing UI (no modal)
- Toggle now supports enabling presets by writing to ~/.claude.json (mcpServers) and refreshing list
- Keep installed MCP entries unchanged (edit/delete/toggle)

fix(mcp): robust error handling and pre-submit validation

- Use extractErrorMessage in MCP panel and form to surface backend details
- Prevent pasting full config (with mcpServers) into single-server JSON field
- Add required-field checks: stdio requires non-empty command; http requires non-empty url

i18n: add messages for single-server validation and preset labels

chore: add data-only MCP presets file (no new dependencies)
2025-10-09 17:21:03 +08:00
Jason
2bb847cb3d fix(mcp): improve error handling and notification visibility
- Increase notification z-index to z-[80] to prevent overlay issues
- Make MCP save operation async with proper error propagation
- Display specific backend error messages in form validation
- Ensure errors are visible in both form and panel layers
2025-10-09 16:44:28 +08:00
Jason
9471cb0d19 fix(mcp): update MCP wizard to support http type and improve args input
- Replace deprecated 'sse' type with 'http' (as per Claude Code official docs)
- Add HTTP-specific fields: url (required) and headers (optional)
- Implement dynamic UI: show different fields based on selected type
- Improve args input: support multi-line input (one argument per line)
- Add headers parsing supporting both 'KEY: VALUE' and 'KEY=VALUE' formats
- Update backend validation to enforce type-specific required fields
- Update i18n translations (zh/en) with new HTTP-related labels
2025-10-09 12:04:37 +08:00
Jason
d0fe9d7533 feat(mcp): add configuration wizard and simplify form modal
- Simplify McpFormModal to 3 inputs: title (required), description (optional), and JSON config (optional)
- Add JSON validation similar to ProviderForm (must be object, real-time error display)
- Create McpWizardModal component for quick configuration:
  - 5 input fields: type (stdio/sse), command (required), args, cwd, env
  - Real-time JSON preview
  - Emerald theme color (consistent with MCP button)
  - Z-index 70 (above McpFormModal's 60)
- Add "or use configuration wizard" link next to JSON config label
- Update i18n translations (zh/en) for form and wizard
- All changes pass TypeScript typecheck and Prettier formatting
2025-10-09 11:30:28 +08:00
Jason
59c13c3366 refactor(mcp): redesign MCP management panel UI
- Redesign MCP panel to match main interface style
- Add toggle switch for each MCP server to enable/disable
- Use emerald theme color consistent with MCP button
- Create card-based layout with one MCP per row
- Add dedicated form modal for add/edit operations
- Implement proper empty state with friendly prompts
- Add comprehensive i18n support (zh/en)
- Extend McpServer type to support enabled field
- Backend already supports enabled field via serde_json::Value

Components:
- McpPanel: Main panel container with header and list
- McpListItem: Card-based list item with toggle and actions
- McpFormModal: Independent modal for add/edit forms
- McpToggle: Emerald-themed toggle switch component

All changes passed TypeScript type checking and production build.
2025-10-09 11:04:36 +08:00
Jason
96a4b4fe95 refactor(mcp): switch to user-level config ~/.claude.json and remove project-level logic
- Read/write ~/.claude.json (preserve unknown fields) for mcpServers
- Remove settings.local.json and mcp.json handling
- Drop enableAllProjectMcpServers command and UI toggle
- Update types, Tauri APIs, and MCP panel to reflect new status fields
- Keep atomic write and command validation behaviors
2025-10-08 23:22:19 +08:00
Jason
e0e84ca58a i18n: add translations for MCP UI (zh/en)
- Add mcp.* keys for panel, actions, validation, messages
2025-10-08 22:35:10 +08:00
Jason
c6a062f64a feat(mcp): implement MCP management panel (list, form, templates)
- Add McpPanel with enable toggle, server list and add/edit form
- Quick template: mcp-fetch (uvx mcp-server-fetch)
- Command validation UI and open ~/.claude shortcut
- Wire MCP button in App to open panel
2025-10-08 22:35:07 +08:00
Jason
94192a3720 feat(mcp): add front-end API wrappers and types
- Add McpServer and McpStatus types
- Add window.api wrappers for MCP commands
- Extend vite-env.d.ts global typings for MCP APIs
2025-10-08 22:35:02 +08:00
Jason
e7a584c5ba feat(mcp): add Rust module and Tauri commands for Claude MCP
- Manage ~/.claude/settings.local.json and ~/.claude/mcp.json
- Atomic JSON read/write with preservation of unknown fields
- Commands: get_claude_mcp_status, read_claude_mcp_config, set_claude_mcp_enable_all_projects, upsert_claude_mcp_server, delete_claude_mcp_server, validate_mcp_command
- Register commands in Tauri invoke handler
- PATH-based command availability check (non-executing)
2025-10-08 22:34:58 +08:00
Jason
e9833e9a57 refactor: improve error handling and code formatting
- Enhanced error messages in Rust backend to include file paths
- Improved provider switching error handling with detailed messages
- Added MCP button placeholder in UI (functionality TODO)
- Applied code formatting across frontend components
- Extended error notification duration to 6s for better readability
2025-10-08 21:22:56 +08:00
Jason
6afc436946 fix(frontend): align Tauri invoke param names to backend (snake_case)
- pick_directory: pass `default_path` instead of `defaultPath` (src/lib/tauri-api.ts:156)
- export_config_to_file/import_config_from_file: pass `file_path` instead of `filePath` (src/lib/tauri-api.ts:394, 408)
- save_file_dialog: pass `default_name` instead of `defaultName` (src/lib/tauri-api.ts:418)

- reason: Tauri commands match params by exact name; camelCase caused missing args and runtime failures in directory picker, import/export, and save dialog
- verify: pnpm typecheck OK; cargo check OK; command signatures confirmed (pick_directory at src-tauri/src/commands.rs:584, export/import/save dialog at src-tauri/src/import_export.rs:84, 102, 140)
- follow-ups: run `pnpm format`; consider Clippy cleanups; short-circuit same provider switch
2025-10-08 21:11:00 +08:00
Jason
c89bf0c6f0 fix: correct i18n key references in ProviderForm
- Fix providerForm.websiteLabel -> providerForm.websiteUrl (line 1498)
- Fix codexConfig namespace to providerForm for API key placeholders (lines 1681-1682)
  - codexConfig.codexOfficialNoApiKey -> providerForm.codexOfficialNoApiKey
  - codexConfig.codexApiKeyAutoFill -> providerForm.codexApiKeyAutoFill

All i18n keys now properly reference existing locale definitions.
2025-10-08 18:12:03 +08:00
Jason
a6d461282d fix: custom endpoints not saved when creating new provider
When creating a new provider, custom endpoints added in the speed test
modal were not being saved properly. The issue was in ProviderForm.tsx
where the CustomEndpoint object was constructed without the optional
lastUsed field.

This caused inconsistency between:
- Edit mode: uses backend API (addCustomEndpoint) which correctly
  constructs complete CustomEndpoint objects
- Create mode: directly constructs objects in frontend, missing the
  lastUsed field

Fixed by explicitly setting lastUsed to undefined when constructing
custom endpoints in create mode, ensuring structural consistency with
the TypeScript CustomEndpoint interface.
2025-10-08 17:32:20 +08:00
Jason
75ce5a0723 refactor: replace 'Done' button with standard 'Save' button in endpoint speed test
- Import Save icon from lucide-react
- Replace endpointTest.done translation key with common.save for consistency
- Add Save icon to button with flex layout matching other save buttons in the app
2025-10-08 11:29:34 +08:00
Jason
3f3905fda0 i18n: localize provider preset names and sorting logic
- Replace hardcoded Chinese preset names with English versions (Claude Official, Zhipu GLM, Qwen Coder, ModelScope, Codex Official, KAT-Coder)
- Update ProviderList sorting to use locale-aware comparison based on current language (zh-CN for Chinese, en-US for English)
- Reorder presets: move KAT-Coder before PackyCode for better grouping
2025-10-08 11:02:09 +08:00
Jason
01da9a1eac - i18n: complete remaining internationalization across the UI
- Locales: add and align keys (common.enterValidValue, apiKeyInput.*, jsonEditor.*, claudeConfig.*); fix zh common.unknown mapping
- ProviderForm: localize labels/placeholders/hints/errors; unify JSON/auth validation to providerForm.*; add wizard CTA for Codex custom with i18n; cancel uses common.cancel
- CodexConfigEditor: i18n for quick wizard, labels/placeholders/hints, common config modal (title/help/buttons)
- ClaudeConfigEditor: i18n for main label, common-config toggle/button, modal title/help, footer buttons
- EndpointSpeedTest: localize failed/noEndpoints/done and aria labels
- ApiKeyInput: i18n for placeholder and show/hide aria
- JsonEditor: i18n linter messages
- PresetSelector: remove hardcoded defaults, use i18n keys
- UpdateBadge: i18n close aria
- Build/typecheck: pass; scan shows no visible hardcoded Chinese strings outside locales
2025-10-07 23:31:00 +08:00
Jason
420a4234de feat: improve endpoint speed test UI and accuracy
- Add color-coded latency indicators (green <300ms, yellow <500ms, orange <800ms, red >=800ms)
- Fix speed test button width to prevent layout shift during testing
- Clear latency data before each test to properly show loading state
- Add warmup request in speed test to avoid first packet penalty and improve accuracy
2025-10-07 20:51:12 +08:00
YoVinchen
ca488cf076 feat: Implement Speed Test Function
* feat: add unified endpoint speed test for API providers

Add a comprehensive endpoint latency testing system that allows users to:
- Test multiple API endpoints concurrently
- Auto-select the fastest endpoint based on latency
- Add/remove custom endpoints dynamically
- View latency results with color-coded indicators

Backend (Rust):
- Implement parallel HTTP HEAD requests with configurable timeout
- Handle various error scenarios (timeout, connection failure, invalid URL)
- Return structured latency data with status codes

Frontend (React):
- Create interactive speed test UI component with auto-sort by latency
- Support endpoint management (add/remove custom endpoints)
- Extract and update Codex base_url from TOML configuration
- Integrate with provider presets for default endpoint candidates

This feature improves user experience when selecting optimal API endpoints,
especially useful for users with multiple provider options or proxy setups.

* refactor: convert endpoint speed test to modal dialog

- Transform EndpointSpeedTest component into a modal dialog
- Add "Advanced" button next to base URL input to open modal
- Support ESC key and backdrop click to close modal
- Apply Linear design principles: minimal styling, clean layout
- Remove unused showBaseUrlInput variable
- Implement same modal pattern for both Claude and Codex

* fix: prevent modal cascade closing when ESC is pressed

- Add state checks to prevent parent modal from closing when child modals (endpoint speed test or template wizard) are open
- Update ESC key handler dependencies to track all modal states
- Ensures only the topmost modal responds to ESC key

* refactor: unify speed test panel UI with project design system

UI improvements:
- Update modal border radius from rounded-lg to rounded-xl
- Unify header padding from px-6 py-4 to p-6
- Change speed test button color to blue theme (bg-blue-500) for consistency
- Update footer background from bg-gray-50 to bg-gray-100
- Style "Done" button as primary action button with blue theme
- Adjust footer button spacing and hover states

Simplify endpoint display:
- Remove endpoint labels (e.g., "Current Address", "Custom 1")
- Display only URL for cleaner interface
- Clean up all label-related logic:
  * Remove label field from EndpointCandidate interface
  * Remove label generation in buildInitialEntries function
  * Remove label handling in useEffect merge logic
  * Remove label generation in handleAddEndpoint
  * Remove label parameters from claudeSpeedTestEndpoints
  * Remove label parameters from codexSpeedTestEndpoints

* refactor: improve endpoint list UI consistency

- Show delete button for all endpoints on hover for uniform UI
- Change selected state to use blue theme matching main interface:
  * Blue border (border-blue-500) for selected items
  * Light blue background (bg-blue-50/dark:bg-blue-900/20)
  * Blue indicator dot (bg-blue-500/dark:bg-blue-400)
- Switch from compact list (space-y-px) to card-based layout (space-y-2)
- Add rounded corners to each endpoint item for better visual separation

* feat: persist custom endpoints to settings.json

- Extend AppSettings to store custom endpoints for Claude and Codex
- Add Tauri commands: get/add/remove/update custom endpoints
- Update frontend API with endpoint persistence methods
- Modify EndpointSpeedTest to load/save custom endpoints via API
- Track endpoint last used time for future sorting/cleanup
- Store endpoints per app type in settings.json instead of localStorage

* - feat(types): add Provider.meta and ProviderMeta (snake_case) with custom_endpoints map

- feat(provider-form): persist custom endpoints on provider create by merging EndpointSpeedTest’s custom URLs into meta.custom_endpoints on submit

- feat(endpoint-speed-test): add onCustomEndpointsChange callback emitting normalized custom URLs; wire it for both Claude/Codex modals

- fix(api): send alias param names (app/appType/app_type and provider_id/providerId) in Tauri invokes to avoid “missing providerId” with older backends

- storage: custom endpoints are stored in ~/.cc-switch/config.json under providers[<id>].meta.custom_endpoints (not in settings.json)

- behavior: edit flow remains immediate writes; create flow now writes once via addProvider, removing the providerId dependency during creation

* feat: add endpoint candidates support and code formatting improvements

- Add endpointCandidates field to ProviderPreset and CodexProviderPreset interfaces
- Integrate preset endpoint candidates into speed test endpoint selection
- Add multiple endpoint options for PackyCode providers (Claude & Codex)
- Apply consistent code formatting (trailing commas, line breaks)
- Improve template value type safety and readability

* refactor: improve endpoint management button UX

Replace ambiguous "Advanced" text with intuitive "Manage & Test" label accompanied by Zap icon, making the endpoint management panel entry point more discoverable and self-explanatory for both Claude and Codex configurations.

* - merge: merge origin/main, resolve conflicts and preserve both feature sets
- feat(tauri): register import/export and file dialogs; keep endpoint speed test and custom endpoints
- feat(api): add updateTrayMenu and onProviderSwitched; wire import/export APIs
- feat(types): extend global API declarations (import/export)
- chore(presets): GLM preset supports both new and legacy model keys
- chore(rust): add chrono dependency; refresh lockfile

---------

Co-authored-by: Jason <farion1231@gmail.com>
2025-10-07 19:14:32 +08:00
WormW
3ad11acdb2 add: local config import and export (#84)
* add: local config import and export

* Fix import refresh flow and typings

* Clarify import refresh messaging

* Limit stored import backups

---------

Co-authored-by: Jason <farion1231@gmail.com>
2025-10-05 23:33:07 +08:00
zhangweiii
f8c40d591f feat: update GLM provider model configuration (#90)
- Update GLM-4.5 to GLM-4.6 as default Sonnet and Opus models
- Use new model naming convention (glm-4.5-air, glm-4.6)
- Align with latest GLM API model naming standards
2025-10-05 20:41:39 +08:00
QuentinHsu
ce593248fc fix: update layout for Claude app type provider display (#87)
* feat: add .node-version file with Node.js version 22.4.1

* fix: update layout for Claude app type provider display
2025-10-05 11:09:03 +08:00
Jason
e0908701b4 Remove deprecated VS Code Codex integration 2025-10-03 20:03:55 +08:00
Lakr
d86994eb7e feat: support kat-coder & template value (#77) 2025-10-02 22:14:35 +08:00
Jason
94e93137a2 chore: bump version to 3.4.0
- Add i18next internationalization with Chinese/English support
- Add Claude plugin sync alongside VS Code integration
- Extend provider presets with new models (DeepSeek-V3.2-Exp, Qwen3-Max, GLM-4.6)
- Support portable mode and single instance enforcement
- Add tray minimize and macOS Dock visibility management
- Improve Settings UI with scrollable layout and save icon
- Fix layout shifts and provider toggle consistency
- Remove unnecessary OpenAI auth requirement
- Update Windows MSI installer to target per-user LocalAppData
2025-10-02 09:59:38 +08:00
Jason
db832a9654 fix: eliminate layout shift when switching app types with Claude plugin sync
- Separate sync button containers for Codex and Claude modes
- Only render the container in corresponding app type to prevent layout jumping
- Apply same fix pattern as commit 0bcc04a for VS Code sync button
2025-10-01 21:33:29 +08:00
Jason
45a639e73f feat: add optional apiKeyUrl field to provider presets
Allow third-party providers to specify a dedicated API key URL separate from the main website URL for easier key acquisition.
2025-10-01 21:28:09 +08:00
Jason
f74d641f86 Add Claude plugin sync alongside VS Code integration 2025-10-01 21:23:55 +08:00
Jason
fcfa9574e8 Update AI model versions in provider presets
- Update DeepSeek model from V3.1-Terminus to V3.2-Exp
- Update ModelScope model from GLM-4.5 to GLM-4.6
2025-09-30 22:19:20 +08:00
Jason
d739bb36e5 feat: add macOS Dock visibility management for tray mode
- Hide Dock icon when minimizing to tray
- Show Dock icon when restoring window from tray
- Apply appropriate activation policy (Accessory/Regular) based on window state
- Add error handling with logging for Dock operations
2025-09-29 17:03:13 +08:00
Jason
0bcc04adce fix: eliminate layout shift when switching between Claude and Codex
- Wrap VS Code sync button in fixed-width container to maintain stable layout
- Only render the container in Codex mode to avoid unnecessary space in Claude mode
- Change card transition from 'all' to specific properties (border-color, box-shadow) to prevent layout animations
- These changes prevent the horizontal position jumping of provider cards during app switching
2025-09-28 23:23:43 +08:00
Jason
fee0762e3e fix: improve Enable/In Use button consistency with fixed width and icons 2025-09-28 23:00:43 +08:00
Jason
1a8ae85e55 refactor: simplify language settings UI by removing description text and general section 2025-09-28 22:40:14 +08:00
Jason
c5aa244d65 feat: integrate language switcher into settings with modern segment control UI
- Move language switcher from header to settings modal for better organization
- Implement modern segment control UI instead of radio buttons for language selection
- Add language preference persistence in localStorage and backend settings
- Support instant language preview with cancel/revert functionality
- Remove standalone LanguageSwitcher component
- Improve initial language detection logic (localStorage -> browser -> default)
- Add proper i18n keys for language settings UI text
2025-09-28 22:23:49 +08:00
Jason
0bedbb2663 feat: change default language to Chinese with English fallback 2025-09-28 21:11:22 +08:00
TinsFox
5f3caa1484 feat: integrate i18next for internationalization support (#65)
* feat: integrate i18next for internationalization support

- Added i18next and react-i18next dependencies for localization.
- Updated various components to utilize translation functions for user-facing text.
- Enhanced user experience by providing multilingual support across the application.

* feat: improve i18n implementation with better translations and accessibility

- Add proper i18n keys for language switcher tooltips and aria-labels
- Replace hardcoded Chinese console error messages with i18n keys
- Add missing translation keys for new UI elements
- Improve accessibility with proper aria-label attributes

---------

Co-authored-by: Jason <farion1231@gmail.com>
2025-09-28 20:47:44 +08:00
farion1231
fd0e83ebd5 fix: remove unnecessary openai auth requirement from third-party config
Remove the env_key and requires_openai_auth fields from third-party provider config generation as they are not needed for custom API endpoints.
2025-09-28 09:53:55 +08:00
farion1231
e969bdbd73 feat: improve SettingsModal UX with scrolling and save icon
- Add scrollable content area with max height constraint
- Add Save icon to save button for better visual clarity
2025-09-27 11:20:37 +08:00
Jason
7435a34c66 update roadmap 2025-09-26 21:57:38 +08:00
ShaSan
5d2d15690c feat: support minimizing window to tray on close (#41)
fix: grant set_skip_taskbar permission through default capability

chore: align settings casing and defaults between Rust and frontend

Co-authored-by: Jason <farion1231@gmail.com>
2025-09-26 20:18:11 +08:00
farion1231
11ee8bddf7 refactor: consolidate chatgpt.config cleanup logic
Merged duplicate try-catch blocks that handle invalid chatgpt.config values.
Now handles all edge cases (scalar values, arrays, empty objects) in a single
unified check, reducing code duplication and improving performance by parsing
JSON only once.
2025-09-26 09:27:56 +08:00
Jason
186c361a79 refactor: reorganize documentation structure
- Remove docs/ from .gitignore to track documentation files
- Delete completed plan documents (encrypted-config-plan.md, updater-plan.md)
- Add roadmap.md with project milestones and future features
2025-09-25 23:51:48 +08:00
Jason
cc1caea36d Add support for qwen3-max 2025-09-24 22:28:32 +08:00
Jason
9ede0ad27d feat: add portable mode support and improve update handling
- Add portable.ini marker file creation in GitHub Actions for portable builds
- Implement is_portable_mode() command to detect portable execution
- Redirect portable users to GitHub releases page for manual updates
- Change update URL to point to latest releases page
- Integrate portable mode detection in Settings UI
2025-09-24 11:25:33 +08:00
farion1231
20f0dd7e1c fix: improve per-user MSI installer component tracking
- Change File KeyPath to "no" and use registry value as KeyPath instead
- Add registry entry for better component state tracking
- Enhance uninstall cleanup to remove LocalAppData files and folders
2025-09-23 20:55:30 +08:00
Jason
4dd07dfd85 Switch MSI installer to per-user LocalAppData target 2025-09-23 14:41:28 +08:00
Jason
8c01be42fa feat: add single instance plugin to prevent multiple app instances
Integrates tauri_plugin_single_instance to ensure only one instance of the application
runs at a time. When attempting to launch a second instance, it will focus the existing
window instead.
2025-09-23 10:02:23 +08:00
Jason
aaf1af0743 release: bump version to v3.3.1
Add support for DeepSeek-V3.1-Terminus model with emergency hotfix release
2025-09-22 23:31:59 +08:00
Jason
aeb0007957 feat: add support for DeepSeek-V3.1-Terminus 2025-09-22 23:20:50 +08:00
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
Jason
7ccef5f385 fix(ui): add dark mode support for Kimi model selector
- Update select dropdown with dark mode background and text colors
- Add dark mode styles to labels and refresh button
- Apply dark mode colors to error messages (red theme)
- Ensure hint box uses consistent amber theme in dark mode
- Fix chevron icon visibility in dark mode

This ensures the Kimi provider configuration UI is properly visible
and maintains consistency with other provider modals in dark mode.
2025-09-13 21:48:18 +08:00
Jason
85ba24f1c3 fix(ui): add dark mode support for provider modals
- Add dark mode styles to ProviderForm modal (background, borders, text)
- Update ApiKeyInput component with dark mode colors
- Add dark mode detection to ClaudeConfigEditor for JSON editor
- Apply dark mode styles to CodexConfigEditor textareas
- Update PresetSelector buttons for dark mode
- Ensure consistent amber color scheme for all hint boxes in dark mode

This ensures proper visibility and readability of add/edit provider dialogs
when dark mode is enabled.
2025-09-13 21:45:34 +08:00
Jason
0d2dedbb6d fix(migration): ensure config directory exists before writing marker
- Create parent directory before checking/writing migration marker
- Prevents runtime error for fresh installations without config directory
- Defensive fix for edge case where ~/.cc-switch/ doesn't exist yet
2025-09-13 21:03:48 +08:00
Jason
d76c675feb docs: update README and screenshots for v3.2.0 release
- Update README with v3.2.0 features and UI improvements
- Replace screenshots with new UI design
- Clarify SSOT architecture and one-time migration details
- Update version badge to 3.2.0
2025-09-13 17:54:21 +08:00
Jason
9372ecd3c6 feat(ui): enhance provider form with brand icons and colors
- Add Save icon to submit buttons in provider forms
- Replace generic Zap icon with brand-specific icons (ClaudeIcon, CodexIcon)
- Update selected state colors: Claude uses brand color #D97757, Codex uses black
- Maintain visual consistency with AppSwitcher component
2025-09-13 17:04:46 +08:00
Jason
d0b654f63e feat(ui): replace generic icons with official brand icons
- Add Claude and ChatGPT/Codex official brand SVG icons
- Create BrandIcons component with proper currentColor support
- Update AppSwitcher to use brand icons with Claude's official color (#D97757)
- Icons now dynamically change color based on active state
- Improve brand recognition and visual consistency
2025-09-13 16:21:15 +08:00
Jason
f035796654 chore(release): polish 3.2.0 changes\n\n- Docs: add 3.2.0 release notes to CHANGELOG\n- UI: silence debug logs in production via import.meta.env.DEV\n- CSS: replace pseudo-element Tailwind @apply with explicit selectors to fix minifier warnings 2025-09-13 15:48:14 +08:00
Jason
160da2729e fix(css): eliminate minifier warnings for scrollbar styles\n\n- Replace Tailwind @apply + custom dark variant on pseudo-elements\n- Use explicit selectors (html.dark) and hex colors for track/thumb\n- Prevent :where() empty selector warnings during production build 2025-09-13 15:38:23 +08:00
Jason
14db6b8a8f refactor(tauri): replace println/eprintln with structured logging\n\n- Use log::info/warn/error instead of println!/eprintln!\n- Keep behavior identical; improve log integration with tauri-plugin-log\n- Reduce stdout noise in production builds 2025-09-13 15:38:01 +08:00
Jason
d91bbb122c refactor(ui): silence debug logs in production\n\n- Wrap dark mode and event logs with import.meta.env.DEV\n- Keep error logging for failures intact\n- Reduce console noise in release builds 2025-09-13 15:37:39 +08:00
Jason
6df5dfc123 style: format codebase with Prettier\n\n- Apply Prettier across src to ensure consistent styling\n- No functional changes; whitespace and ordering only\n- Unblocks format:check for release pipeline 2025-09-13 15:36:43 +08:00
Jason
c8327f7632 feat: add API key links for third-party providers and simplify Kimi model labels
- Add "Get API Key" link support for third-party providers (e.g., PackyCode)
- Simplify Kimi model selector labels by removing technical field names
  - Changed "主模型 (ANTHROPIC_MODEL)" to "主模型"
  - Changed "快速模型 (ANTHROPIC_SMALL_FAST_MODEL)" to "快速模型"
- Improve user experience with cleaner, more intuitive interface labels
2025-09-13 13:23:32 +08:00
Jason
4a0e63d0b7 style: improve "Get API Key" link styling
- Change link color to light blue (text-blue-400)
- Remove arrow symbol from link text
- Apply to domestic official and aggregator provider API key links
2025-09-12 21:36:32 +08:00
Jason
e63b4e069b feat: enhance provider configuration UX with custom URL support and API key links
- Add custom base URL input for custom providers
  - New "Request URL" field appears only in custom mode
  - Automatically syncs with ANTHROPIC_BASE_URL in config
  - Includes helpful amber-styled hint about Claude API compatibility

- Add "Get API Key" links for non-official providers
  - Shows for cn_official, aggregator, and third_party categories
  - Links point to provider's official website
  - Styled as subtle helper text (text-xs, gray-500)
  - Positioned closely under API key input for better visual grouping

- Improve UI consistency and hints
  - Unify all hint boxes to use amber color scheme (amber-50/amber-200/amber-600)
  - Update model placeholders to latest versions (GLM-4.5, GLM-4.5-Air)
  - Simplify provider names (remove version numbers and redundant text)

- Update provider presets
  - GLM models: glm-4-plus → GLM-4.5, glm-4-flash → GLM-4.5-Air
  - Qwen models: qwen-coder-turbo → qwen3-coder-plus
  - Cleaner naming: "Claude官方登录" → "Claude官方", "DeepSeek v3.1" → "DeepSeek"

- Fix Kimi model selector behavior
  - Remove API key requirement for displaying selector
  - Avoid showing duplicate model input fields for Kimi preset
  - Improve hint message clarity
2025-09-12 20:14:59 +08:00
Jason
687c7de111 feat: improve custom provider configuration UX
- Show API key input field for custom mode
- Initialize default custom mode with JSON template on modal open
- Change default API key from placeholder to empty string
- Remove Save icon from submit button for cleaner UI
- Ensure consistent behavior between default and manually selected custom mode
2025-09-12 15:20:49 +08:00
Jason
876605e983 feat: require API key for non-official Claude providers
- Add required asterisk to API key input label for non-official providers
- Pre-fill API key placeholder when switching to custom mode
- Ensure consistent validation across Claude and Codex providers
2025-09-12 12:15:09 +08:00
Jason
442b05507c feat: simplify Claude provider configuration form
- Add optional model input fields (ANTHROPIC_MODEL, ANTHROPIC_SMALL_FAST_MODEL)
- Place model inputs in a single row for better space utilization
- Move website URL field above API configuration section
- Add JSON template for custom mode to guide users
- Simplify field labels and remove redundant descriptions
- Keep JSON editor for advanced configuration flexibility
2025-09-12 12:04:19 +08:00
Jason
eca9c02147 feat(providers): add provider categorization system
- Add ProviderCategory type with official, cn_official, aggregator, third_party, and custom categories
- Update Provider interface and Rust struct to include optional category field
- Enhance ProviderForm to automatically sync category when selecting presets
- Improve PresetSelector to show category-based styling and hints
- Add category classification to all provider presets
- Support differentiated interactions (e.g., hide API key input for official providers)
- Maintain backward compatibility with existing configurations
2025-09-11 22:33:55 +08:00
Jason
9fbce5d0cf refactor(settings): rename Dock setting to system tray (showInDock → showInTray)
- compat: map legacy showInDock to showInTray when loading settings
- ui(copy): clarify “system tray (menu bar)” vs Dock in SettingsModal
- tauri(settings): return showInTray in get_settings; adjust default fallback
- docs(comment): align comments to “system tray” terminology across code
- note: no functional change yet; tray visibility toggle remains unimplemented
2025-09-11 20:20:27 +08:00
farion1231
c597b9b122 feat(ui): add "up-to-date" feedback for update check button
- Show green "Already up-to-date" state with check icon when no updates available
- Button changes color and text temporarily (3 seconds) to provide clear feedback
- Fix TypeScript type for checkUpdate to return Promise<boolean>
- Handle dev mode gracefully - show up-to-date instead of opening release page
- Simplify previous complex notification UI to inline button state change
2025-09-11 15:13:33 +08:00
Jason
54b88d9c89 refactor(ui): redesign update notification to match Linear design system
- Replace gradient background with solid colors and subtle borders
- Remove decorative Info icon from settings panel that had no functionality
- Change update badge icon from Sparkles to Download for better clarity
- Simplify update button states with consistent blue primary color
- Remove all gradient effects in favor of flat design
- Unify hover states and transitions across all update-related UI

The update notification now seamlessly integrates with the app's Linear-inspired
aesthetic, providing a clean and non-intrusive user experience.
2025-09-11 12:06:49 +08:00
Jason
319e5fa61a build(rust): optimize release binary size (thin LTO, strip symbols, s-level, codegen-units=1, panic=abort)\n\n- Helps reduce final AppImage size by trimming Rust binary 2025-09-11 10:17:35 +08:00
Jason
310086d5c9 ci(release): refine artifacts\n\n- macOS: add .app zip (plus tar.gz + .sig for updater)\n- Windows: only MSI + .sig; add portable zip\n- Linux: always include .deb; keep AppImage + .sig for updater 2025-09-11 10:17:35 +08:00
Jason
4297703ebe ci(release): pin runners for compatibility and update cache action\n\n- matrix: windows-2022 / ubuntu-22.04 / macos-14\n- assemble-latest-json runs on ubuntu-22.04\n- actions/cache bumped to v4 2025-09-11 09:33:49 +08:00
Jason
ca7ce99702 chore(version): bump app version to 3.2.0\n\n- package.json → 3.2.0\n- src-tauri/Cargo.toml → 3.2.0\n- src-tauri/tauri.conf.json → 3.2.0\n- src-tauri/Cargo.lock → 3.2.0 2025-09-11 09:33:49 +08:00
Jason
af8b9289fe feat(updater): 优化更新体验与 UI
- ui: UpdateBadge 使用 Tailwind 内置过渡,支持点击打开设置,保留图标动画

- updater: 新增 UpdateContext 首启延迟检查,忽略版本键名命名空间化(含旧键迁移),并发保护

- settings: 去除版本硬编码回退;检测到更新时复用 updateHandle 下载并安装,并新增常显“更新日志”入口

- a11y: 更新徽标支持键盘触达(Enter/Space)

- refactor: 移除未使用的 runUpdateFlow 导出

- chore: 类型检查通过,整体行为与权限边界未改变
2025-09-10 19:46:38 +08:00
Jason
bf7e13d4e9 chore: update repository URLs from jasonyoung to farion1231
- Update GitHub repository URLs in README.md badges
- Update repository URL in Cargo.toml metadata
- Update updater endpoint URL in tauri.conf.json for auto-updates
2025-09-10 15:26:21 +08:00
Jason
b015af173a feat(updater): refactor release workflow for proper Tauri updater support
- Switch macOS packaging from .app to .tar.gz updater artifacts
- Add automatic latest.json generation workflow
- Include signature file handling for all platforms
- Remove manual latest.json in favor of automated generation
- Improve updater artifact detection and path handling
2025-09-10 09:20:14 +08:00
Jason
4a4779a7e7 enhance(ci): improve release workflow with concurrency control and updated dependencies
- Add concurrency control to prevent multiple simultaneous releases
- Upgrade softprops/action-gh-release from v1 to v2 for better reliability
- Add docs/ directory to .gitignore to exclude documentation build artifacts
2025-09-10 08:10:01 +08:00
Jason
92a39a1a34 enhance(ci): implement cross-platform base64 encoding for private key
- Add support for multiple base64 encoders (base64, openssl, node.js)
- Encode complete private key file content as single-line base64
- Implement fallback chain for maximum platform compatibility
- Simplify environment variable handling with encoded content
2025-09-10 07:05:02 +08:00
Jason
ea56794a37 refactor(ci): use complete private key content with heredoc syntax
- Switch to passing complete two-line private key content instead of base64 only
- Use GitHub Actions heredoc syntax (<<'EOF') for proper multiline handling
- Preserve original minisign private key format with comment and base64 lines
- Improve compatibility with Tauri CLI's private key parsing
2025-09-09 22:52:34 +08:00
Jason
fd4864115c enhance(ci): improve Tauri signing key handling with direct base64 content
- Switch from file path to direct base64 content for better compatibility
- Extract private key base64 from second line for stable parsing
- Enhance error handling for key extraction process
- Improve cross-version compatibility for different Tauri CLI versions
2025-09-09 22:40:26 +08:00
Jason
74d4b42936 refactor(ci): standardize Tauri signing key variable and update pubkey
- Update CI workflow to use TAURI_SIGNING_PRIVATE_KEY consistently
- Simplify key handling logic and add password support
- Update pubkey in tauri.conf.json to match new signing key
2025-09-09 22:26:37 +08:00
Jason
a95f974787 refactor(ci): simplify Tauri signing key handling to use file path only
- Remove redundant environment variables for key content export
- Focus on providing proper key file path to Tauri CLI to avoid decoding ambiguity
- Maintain support for all three key formats (two-line, base64-wrapped, single base64)
- Improve reliability by standardizing on file-based key passing approach
2025-09-09 21:50:37 +08:00
Jason
29057c1fe0 refactor: optimize key handling to export base64 line only with multiple env var formats 2025-09-09 21:38:24 +08:00
Jason
63285acba8 enhance: improve private key handling with better base64 compatibility and single-line support 2025-09-09 21:21:30 +08:00
Jason
f99b614888 enhance: improve updater workflow with latest.json collection and prerelease flag 2025-09-09 21:14:44 +08:00
Jason
41f3aa7d76 fix: correct Tauri signing key environment variable usage 2025-09-09 16:38:59 +08:00
Jason
f23898a5c9 fix: correct GitHub Secret name to TAURI_PRIVATE_KEY 2025-09-09 16:29:42 +08:00
Jason
664391568c fix: add debug output for GitHub Secret issues 2025-09-09 16:28:12 +08:00
Jason
081aabe10f fix: add Tauri signing key decoding in GitHub Actions 2025-09-09 16:25:49 +08:00
Jason
036069a5c1 update pub key again 2025-09-09 16:06:14 +08:00
Jason
9b7091ba88 update pub key 2025-09-09 15:36:04 +08:00
Jason
2357d976dc chore: bump version to 3.1.2 for auto-updater testing 2025-09-09 15:21:49 +08:00
Jason Young
df43692bb9 Merge pull request #16 from farion1231/feat/auto-update
feat: Add auto-updater support with GitHub releases
2025-09-09 15:16:19 +08:00
Jason
6ed9cf47df feat: add auto-updater support with GitHub releases
- Configure Tauri updater plugin with Ed25519 signing
- Add GitHub Actions support for signed builds
- Set up GitHub releases as update endpoint
- Enable update checking in Settings modal
2025-09-09 15:13:06 +08:00
Jason
a3582f54e9 fix(updater): 避免因 Updater 配置不完整导致应用启动中止\n\n- Updater 插件注册改为容错(失败仅告警,不影响应用启动) 2025-09-09 10:28:34 +08:00
Jason
9ff7516c51 feat(updater): 集成 Rust 侧 Updater/Process 插件与权限配置
- Cargo 依赖:tauri-plugin-updater、tauri-plugin-process
- 插件注册:process 顶层 init,updater 于 setup 中注册
- 权限配置:capabilities 增加 updater:default、process:allow-restart
- 配置:tauri.conf.json 启用 createUpdaterArtifacts 与 updater 占位(pubkey/endpoints)
2025-09-08 16:58:41 +08:00
Jason
a1a16be2aa feat(updater): 前端 Updater 封装与设置页接入\n\n- 新增 src/lib/updater.ts(check→download→install→relaunch 封装与类型)\n- SettingsModal 按钮优先走 Updater,失败回退打开 Releases\n- 依赖:@tauri-apps/plugin-updater、@tauri-apps/plugin-process\n- 文档:精简并重写 docs/updater-plan.md(明确接口/流程/配置) 2025-09-08 16:48:24 +08:00
Jason
df7d818514 feat(ui): enhance dark mode styling support
- AppSwitcher: add dark mode backgrounds, borders and hover effects
- ConfirmDialog: apply dark theme colors to dialog elements
- ProviderList: update list items and buttons for dark mode
- Global styles: use color-scheme property for native controls theming

Ensures all interactive components have proper visual feedback and consistent UX in dark mode.
2025-09-08 15:57:29 +08:00
Jason
c0d9d0296d feat(ui): implement dark mode with system preference support
- Add useDarkMode hook for managing theme state and persistence
- Integrate dark mode toggle button in app header
- Update all components with dark variant styles using Tailwind v4
- Create centralized style utilities for consistent theming
- Support system color scheme preference as fallback
- Store user preference in localStorage for persistence
2025-09-08 15:38:06 +08:00
Jason
77a65aaad8 refactor(styles): migrate from CSS variables to Tailwind classes
- Replace all CSS custom properties with Tailwind utility classes
- Add tailwind.config.js with custom color palette matching Linear design
- Reduce index.css from 89 to 37 lines (58% reduction)
- Maintain consistent visual appearance with semantic color usage
- Update all components to use Tailwind classes instead of CSS variables
2025-09-08 11:48:05 +08:00
Jason
3ce847d2e0 refactor(ui): remove config file path display module
Remove the configuration file path information section from the main page footer, including:
- configStatus state and related functions
- loadConfigStatus function
- handleOpenConfigFolder function
- UI component showing config file path and folder open button

This simplifies the interface while preserving all core functionality.
2025-09-07 22:41:55 +08:00
Jason
1482dc9e66 feat(providers): add timestamp-based sorting for provider list
- Add createdAt timestamp field to Provider interface
- Implement sorting logic: old providers without timestamp first, then by creation time
- Providers without timestamps are sorted alphabetically by name
- New providers are automatically timestamped on creation
2025-09-07 22:29:08 +08:00
Jason
fa2b11fcc2 fix(settings): update version display and comment out incomplete dock settings
- Fix version display to use actual app version (3.1.1) from Tauri API
- Comment out dock display settings as feature is not yet implemented
- Update GitHub releases URL from yungookim to farion1231
2025-09-07 22:14:17 +08:00
Jason
02bfc97ee6 refactor(types): introduce Settings and apply in API
- style(prettier): format src files
- style(rustfmt): format Rust sources
- refactor(tauri-api): type-safe getSettings/saveSettings
- refactor(d.ts): declare window.api with Settings

[skip ci]
2025-09-07 11:36:09 +08:00
Jason
77bdeb02fb feat(settings): add minimal settings panel
- Add settings icon button next to app title
- Create SettingsModal component with:
  - Show in Dock option (macOS)
  - Version info and check for updates button
  - Config file location with open folder button
- Add settings-related APIs in tauri-api
- Update type definitions for new API methods
2025-09-07 10:48:27 +08:00
Jason
48bd37a74b feat(ui): unify link/address styles and set primary header color
- Links use primary color; removed leading icon
- Show API address when website is missing; use secondary text color, non-monospace
- Parse base_url from Codex TOML; change fallback copy to "未配置官网地址"
- Use primary color for the top-left header title
- Clean up unused imports

Affected files:
- src/components/ProviderList.tsx
- src/App.tsx
2025-09-06 23:57:10 +08:00
TinsFox
7346fcde2c feat: refactor ProviderForm component with new subcomponents (#13)
* feat: refactor ProviderForm component with new subcomponents

- Introduced PresetSelector, ApiKeyInput, ClaudeConfigEditor, and CodexConfigEditor for improved modularity and readability.
- Simplified preset selection logic for both Claude and Codex configurations.
- Enhanced API Key input handling with dedicated components for better user experience.
- Removed redundant code and improved state management in the ProviderForm component.

* feat: add Kimi model selection to ProviderForm component

- Introduced KimiModelSelector for enhanced model configuration options.
- Implemented state management for Kimi model selection, including initialization and updates based on preset selection.
- Improved user experience by conditionally displaying the Kimi model selector based on the selected preset.
- Refactored related logic to ensure proper handling of Kimi-specific settings in the ProviderForm.

* feat: enhance API Key input and model selection in ProviderForm

- Added toggle functionality to show/hide API Key in ApiKeyInput component for improved user experience.
- Updated placeholder text in ProviderForm to provide clearer instructions based on the selected preset.
- Enhanced KimiModelSelector to display a more informative message when API Key is not provided.
- Refactored provider presets to remove hardcoded API Key values for better security practices.

* fix(kimi): optimize debounce implementation in model selector

- Fix initial state: use empty string instead of apiKey.trim()
- Refactor fetchModels to fetchModelsWithKey with explicit key parameter
- Ensure consistent behavior between auto-fetch and manual refresh
- Eliminate mental overhead from optional parameter fallback logic

* fix(api-key): remove custom masking logic, use native password input

- Remove getDisplayValue function with custom star masking
- Use native browser password input behavior for better UX consistency
- Simplify component logic while maintaining show/hide toggle functionality

* chore: format code with prettier

- Apply consistent code formatting across all TypeScript files
- Fix indentation and spacing according to project style guide

---------

Co-authored-by: Jason <farion1231@gmail.com>
2025-09-06 23:13:01 +08:00
TinsFox
5af476d376 feat: 系统托盘 (#12)
* feat: 系统托盘

1. 添加系统托盘
2. 托盘添加切换供应商功能
3. 整理组件目录

* feat: 优化系统托盘菜单结构

- 扁平化Claude和Codex的菜单结构,直接将所有供应商添加到主菜单,简化用户交互。
- 添加无供应商时的提示信息,提升用户体验。
- 更新分隔符文本以增强可读性。

* feat: integrate Tailwind CSS and Lucide icons

- Added Tailwind CSS for styling and layout improvements.
- Integrated Lucide icons for enhanced UI elements.
- Updated project structure by removing unused CSS files and components.
- Refactored configuration files to support new styling and component structure.
- Introduced new components for managing providers with improved UI interactions.   

* fix: 修复类型声明和分隔符实现问题

- 修复 updateTrayMenu 返回类型不一致(Promise<void> -> Promise<boolean>)
- 添加缺失的 UnlistenFn 类型导入
- 使用 MenuBuilder.separator() 替代文本分隔符

---------

Co-authored-by: farion1231 <farion1231@gmail.c
2025-09-06 16:21:21 +08:00
farion1231
07b870488d fix: add .gitattributes to resolve line ending inconsistencies
- Configure unified line ending rules (LF)
- Fix issue where Cargo.toml always shows as modified on Windows
2025-09-06 11:28:58 +08:00
Jason
74ab14f572 feat: add ModelScope provider preset with GLM-4.5 model configuration 2025-09-06 11:01:09 +08:00
QuentinHsu
a14f7ef9b2 feat: JsonEditor for inputting JSON content (#4)
* feat(editor): add JsonEditor component for JSON configuration editing

* fix(provider): update API Key visibility logic in ProviderForm component

* fix(editor): stabilize JsonEditor height and restore API Key logic

- Revert API Key visibility to preset-based rule and injection to non-custom preset only.
- Reduce JsonEditor min-height from rows*22px to rows*18px to lessen layout jitter when switching presets.
- Keep fonts/size consistent with the previous textarea for visual parity.

* - fix(form): remove websiteUrl auto-extraction from JSON to prevent incorrect overrides

---------

Co-authored-by: Jason <farion1231@gmail.com>
2025-09-06 09:30:09 +08:00
Jason Young
07dd70570e Merge pull request #10 from farion1231/feat/ssot
feat(ssot): 首次迁移副本入库 + 切换回填不丢数据 + Codex 原子写入回滚
2025-09-05 21:42:11 +08:00
87 changed files with 15394 additions and 2417 deletions

38
.gitattributes vendored Normal file
View File

@@ -0,0 +1,38 @@
# Auto detect text files and perform LF normalization
* text=auto
# Explicitly declare text files you want to always be normalized and converted
# to native line endings on checkout.
*.rs text eol=lf
*.toml text eol=lf
*.json text eol=lf
*.md text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.txt text eol=lf
# TypeScript/JavaScript files
*.ts text eol=lf
*.tsx text eol=lf
*.js text eol=lf
*.jsx text eol=lf
# HTML/CSS files
*.html text eol=lf
*.css text eol=lf
*.scss text eol=lf
# Shell scripts
*.sh text eol=lf
# Denote all files that are truly binary and should not be modified.
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.woff binary
*.woff2 binary
*.ttf binary
*.exe binary
*.dll binary

View File

@@ -8,15 +8,19 @@ on:
permissions:
contents: write
concurrency:
group: release-${{ github.ref_name }}
cancel-in-progress: true
jobs:
release:
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: windows-latest
- os: ubuntu-latest
- os: macos-latest
- os: windows-2022
- os: ubuntu-22.04
- os: macos-14
steps:
- name: Checkout
@@ -74,7 +78,7 @@ jobs:
run: echo "path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Setup pnpm cache
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
@@ -83,6 +87,62 @@ jobs:
- name: Install frontend deps
run: pnpm install --frozen-lockfile
- name: Prepare Tauri signing key
shell: bash
run: |
# 调试:检查 Secret 是否存在
if [ -z "${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}" ]; then
echo "❌ TAURI_SIGNING_PRIVATE_KEY Secret 为空或不存在" >&2
echo "请检查 GitHub 仓库 Settings > Secrets and variables > Actions" >&2
exit 1
fi
RAW="${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}"
# 目标:提供正确的私钥“文件路径”给 Tauri CLI避免内容解码歧义
KEY_PATH="$RUNNER_TEMP/tauri_signing.key"
# 情况 1原始两行文本第一行以 "untrusted comment:" 开头)
if echo "$RAW" | head -n1 | grep -q '^untrusted comment:'; then
printf '%s\n' "$RAW" > "$KEY_PATH"
echo "✅ 使用原始两行密钥文件格式"
else
# 情况 2整体被 base64 包裹(解包后应当是两行)
if DECODED=$(printf '%s' "$RAW" | (base64 --decode 2>/dev/null || base64 -D 2>/dev/null)) \
&& echo "$DECODED" | head -n1 | grep -q '^untrusted comment:'; then
printf '%s\n' "$DECODED" > "$KEY_PATH"
echo "✅ 成功解码 base64 包裹密钥,已还原为两行文件"
else
# 情况 3已是第二行纯 Base64 一行)→ 构造两行文件
if echo "$RAW" | grep -Eq '^[A-Za-z0-9+/=]+$'; then
ONE=$(printf '%s' "$RAW" | tr -d '\r\n')
printf '%s\n%s\n' "untrusted comment: tauri signing key" "$ONE" > "$KEY_PATH"
echo "✅ 使用一行 Base64 私钥,已构造两行文件"
else
echo "❌ TAURI_SIGNING_PRIVATE_KEY 格式无法识别:既不是两行原文,也不是其 base64亦非一行 base64" >&2
echo "密钥前10个字符: $(echo "$RAW" | head -c 10)..." >&2
exit 1
fi
fi
fi
# 将“完整两行内容”作为环境变量注入Tauri 支持传入完整私钥文本或文件路径)
# 使用多行写入语法,保持换行以便解析
# 将完整两行私钥内容进行 base64 编码,作为单行内容注入环境变量
if command -v base64 >/dev/null 2>&1; then
KEY_B64=$(base64 < "$KEY_PATH" | tr -d '\r\n')
elif command -v openssl >/dev/null 2>&1; then
KEY_B64=$(openssl base64 -A -in "$KEY_PATH")
else
KEY_B64=$(KEY_PATH="$KEY_PATH" node -e "process.stdout.write(require('fs').readFileSync(process.env.KEY_PATH).toString('base64'))")
fi
if [ -z "$KEY_B64" ]; then
echo "❌ 无法生成私钥 base64 内容" >&2
exit 1
fi
echo "TAURI_SIGNING_PRIVATE_KEY=$KEY_B64" >> "$GITHUB_ENV"
if [ -n "${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}" ]; then
echo "TAURI_SIGNING_PRIVATE_KEY_PASSWORD=${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}" >> $GITHUB_ENV
fi
echo "✅ Tauri signing key prepared"
- name: Build Tauri App (macOS)
if: runner.os == 'macOS'
run: pnpm tauri build --target universal-apple-darwin
@@ -101,29 +161,38 @@ jobs:
run: |
set -euxo pipefail
mkdir -p release-assets
echo "Looking for .app bundle..."
APP_PATH=""
VERSION="${GITHUB_REF_NAME}" # e.g., v3.5.0
echo "Looking for updater artifact (.tar.gz) and .app for zip..."
TAR_GZ=""; APP_PATH=""
for path in \
"src-tauri/target/release/bundle/macos" \
"src-tauri/target/universal-apple-darwin/release/bundle/macos" \
"src-tauri/target/aarch64-apple-darwin/release/bundle/macos" \
"src-tauri/target/x86_64-apple-darwin/release/bundle/macos"; do
"src-tauri/target/x86_64-apple-darwin/release/bundle/macos" \
"src-tauri/target/release/bundle/macos"; do
if [ -d "$path" ]; then
APP_PATH=$(find "$path" -name "*.app" -type d | head -1)
[ -n "$APP_PATH" ] && break
[ -z "$TAR_GZ" ] && TAR_GZ=$(find "$path" -maxdepth 1 -name "*.tar.gz" -type f | head -1 || true)
[ -z "$APP_PATH" ] && APP_PATH=$(find "$path" -maxdepth 1 -name "*.app" -type d | head -1 || true)
fi
done
if [ -z "$APP_PATH" ]; then
echo "No .app found" >&2
if [ -z "$TAR_GZ" ]; then
echo "No macOS .tar.gz updater artifact found" >&2
exit 1
fi
APP_DIR=$(dirname "$APP_PATH")
APP_NAME=$(basename "$APP_PATH")
cd "$APP_DIR"
# 使用 ditto 打包更兼容资源分叉
ditto -c -k --sequesterRsrc --keepParent "$APP_NAME" "CC-Switch-macOS.zip"
mv "CC-Switch-macOS.zip" "$GITHUB_WORKSPACE/release-assets/"
echo "macOS zip ready"
# 重命名 tar.gz 为统一格式
NEW_TAR_GZ="CC-Switch-${VERSION}-macOS.tar.gz"
cp "$TAR_GZ" "release-assets/$NEW_TAR_GZ"
[ -f "$TAR_GZ.sig" ] && cp "$TAR_GZ.sig" "release-assets/$NEW_TAR_GZ.sig" || echo ".sig for macOS not found yet"
echo "macOS updater artifact copied: $NEW_TAR_GZ"
if [ -n "$APP_PATH" ]; then
APP_DIR=$(dirname "$APP_PATH"); APP_NAME=$(basename "$APP_PATH")
NEW_ZIP="CC-Switch-${VERSION}-macOS.zip"
cd "$APP_DIR"
ditto -c -k --sequesterRsrc --keepParent "$APP_NAME" "$NEW_ZIP"
mv "$NEW_ZIP" "$GITHUB_WORKSPACE/release-assets/"
echo "macOS zip ready: $NEW_ZIP"
else
echo "No .app found to zip (optional)" >&2
fi
- name: Prepare Windows Assets
if: runner.os == 'Windows'
@@ -131,18 +200,28 @@ jobs:
run: |
$ErrorActionPreference = 'Stop'
New-Item -ItemType Directory -Force -Path release-assets | Out-Null
# 安装器(优先 NSIS其次 MSI
$installer = Get-ChildItem -Path 'src-tauri/target/release/bundle' -Recurse -Include *.exe,*.msi -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -match '\\bundle\\(nsis|msi)\\' } |
Select-Object -First 1
if ($null -ne $installer) {
$dest = if ($installer.Extension -ieq '.msi') { 'CC-Switch-Setup.msi' } else { 'CC-Switch-Setup.exe' }
Copy-Item $installer.FullName (Join-Path release-assets $dest)
Write-Host "Installer copied: $dest"
} else {
Write-Warning 'No Windows installer found'
$VERSION = $env:GITHUB_REF_NAME # e.g., v3.5.0
# 仅打包 MSI 安装器 + .sig用于 Updater
$msi = Get-ChildItem -Path 'src-tauri/target/release/bundle/msi' -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1
if ($null -eq $msi) {
# 兜底:全局搜索 .msi
$msi = Get-ChildItem -Path 'src-tauri/target/release/bundle' -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1
}
# 绿色版portable仅可执行文件
if ($null -ne $msi) {
$dest = "CC-Switch-$VERSION-Windows.msi"
Copy-Item $msi.FullName (Join-Path release-assets $dest)
Write-Host "Installer copied: $dest"
$sigPath = "$($msi.FullName).sig"
if (Test-Path $sigPath) {
Copy-Item $sigPath (Join-Path release-assets ("$dest.sig"))
Write-Host "Signature copied: $dest.sig"
} else {
Write-Warning "Signature not found for $($msi.Name)"
}
} else {
Write-Warning 'No Windows MSI installer found'
}
# 绿色版portable仅可执行文件打 zip不参与 Updater
$exeCandidates = @(
'src-tauri/target/release/cc-switch.exe',
'src-tauri/target/x86_64-pc-windows-msvc/release/cc-switch.exe'
@@ -152,9 +231,16 @@ jobs:
$portableDir = 'release-assets/CC-Switch-Portable'
New-Item -ItemType Directory -Force -Path $portableDir | Out-Null
Copy-Item $exePath $portableDir
Compress-Archive -Path "$portableDir/*" -DestinationPath 'release-assets/CC-Switch-Windows-Portable.zip' -Force
$portableIniPath = Join-Path $portableDir 'portable.ini'
$portableContent = @(
'# CC Switch portable build marker',
'portable=true'
)
$portableContent | Set-Content -Path $portableIniPath -Encoding UTF8
$portableZip = "release-assets/CC-Switch-$VERSION-Windows-Portable.zip"
Compress-Archive -Path "$portableDir/*" -DestinationPath $portableZip -Force
Remove-Item -Recurse -Force $portableDir
Write-Host 'Windows portable zip created'
Write-Host "Windows portable zip created: CC-Switch-$VERSION-Windows-Portable.zip"
} else {
Write-Warning 'Portable exe not found'
}
@@ -165,14 +251,25 @@ jobs:
run: |
set -euxo pipefail
mkdir -p release-assets
# 仅上传安装包deb
VERSION="${GITHUB_REF_NAME}" # e.g., v3.5.0
# Updater artifact: AppImage含对应 .sig
APPIMAGE=$(find src-tauri/target/release/bundle -name "*.AppImage" | head -1 || true)
if [ -n "$APPIMAGE" ]; then
NEW_APPIMAGE="CC-Switch-${VERSION}-Linux.AppImage"
cp "$APPIMAGE" "release-assets/$NEW_APPIMAGE"
[ -f "$APPIMAGE.sig" ] && cp "$APPIMAGE.sig" "release-assets/$NEW_APPIMAGE.sig" || echo ".sig for AppImage not found"
echo "AppImage copied: $NEW_APPIMAGE"
else
echo "No AppImage found under target/release/bundle" >&2
fi
# 额外上传 .deb用于手动安装不参与 Updater
DEB=$(find src-tauri/target/release/bundle -name "*.deb" | head -1 || true)
if [ -n "$DEB" ]; then
cp "$DEB" release-assets/
echo "Deb package copied"
NEW_DEB="CC-Switch-${VERSION}-Linux.deb"
cp "$DEB" "release-assets/$NEW_DEB"
echo "Deb package copied: $NEW_DEB"
else
echo "No .deb found" >&2
exit 1
echo "No .deb found (optional)"
fi
- name: List prepared assets
@@ -180,11 +277,19 @@ jobs:
run: |
ls -la release-assets || true
- name: Collect Signatures
shell: bash
run: |
set -euo pipefail
echo "Collected signatures (if any alongside artifacts):"
ls -la release-assets/*.sig || echo "No signatures found"
- name: Upload Release Assets
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: CC Switch ${{ github.ref_name }}
prerelease: true
body: |
## CC Switch ${{ github.ref_name }}
@@ -192,12 +297,12 @@ jobs:
### 下载
- macOS: `CC-Switch-macOS.zip`(解压即用
- Windows: `CC-Switch-Setup.exe` 或 `CC-Switch-Setup.msi`(安装版)`CC-Switch-Windows-Portable.zip`(绿色版)
- Linux: `*.deb`Debian/Ubuntu 安装包
- **macOS**: `CC-Switch-${{ github.ref_name }}-macOS.zip`(解压即用)或 `CC-Switch-${{ github.ref_name }}-macOS.tar.gz`Homebrew
- **Windows**: `CC-Switch-${{ github.ref_name }}-Windows.msi`(安装版)`CC-Switch-${{ github.ref_name }}-Windows-Portable.zip`(绿色版)
- **Linux**: `CC-Switch-${{ github.ref_name }}-Linux.AppImage`AppImage或 `CC-Switch-${{ github.ref_name }}-Linux.deb`Debian/Ubuntu
---
提示macOS 如遇已损坏提示,可在终端执行:`xattr -cr "/Applications/CC Switch.app"`
提示macOS 如遇"已损坏"提示,可在终端执行:`xattr -cr "/Applications/CC Switch.app"`
files: release-assets/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -208,3 +313,92 @@ jobs:
run: |
echo "Listing bundles in src-tauri/target..."
find src-tauri/target -maxdepth 4 -type f -name "*.*" 2>/dev/null || true
assemble-latest-json:
name: Assemble latest.json
runs-on: ubuntu-22.04
needs: release
permissions:
contents: write
steps:
- name: Prepare GH
run: |
gh --version || (type -p curl >/dev/null && sudo apt-get update && sudo apt-get install -y gh || true)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Download all release assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euxo pipefail
TAG="${GITHUB_REF_NAME}"
mkdir -p dl
gh release download "$TAG" --dir dl --repo "$GITHUB_REPOSITORY"
ls -la dl || true
- name: Generate latest.json
env:
REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
VERSION="${TAG#v}"
PUB_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
base_url="https://github.com/$REPO/releases/download/$TAG"
# 初始化空平台映射
mac_url=""; mac_sig=""
win_url=""; win_sig=""
linux_url=""; linux_sig=""
shopt -s nullglob
for sig in dl/*.sig; do
base=${sig%.sig}
fname=$(basename "$base")
url="$base_url/$fname"
sig_content=$(cat "$sig")
case "$fname" in
*.tar.gz)
# 视为 macOS updater artifact
mac_url="$url"; mac_sig="$sig_content";;
*.AppImage|*.appimage)
linux_url="$url"; linux_sig="$sig_content";;
*.msi|*.exe)
win_url="$url"; win_sig="$sig_content";;
esac
done
# 构造 JSON仅包含存在的目标
tmp_json=$(mktemp)
{
echo '{'
echo " \"version\": \"$VERSION\",";
echo " \"notes\": \"Release $TAG\",";
echo " \"pub_date\": \"$PUB_DATE\",";
echo ' "platforms": {'
first=1
if [ -n "$mac_url" ] && [ -n "$mac_sig" ]; then
# 为兼容 arm64 / x64重复写入两个键指向同一 universal 包
for key in darwin-aarch64 darwin-x86_64; do
[ $first -eq 0 ] && echo ','
echo " \"$key\": {\"signature\": \"$mac_sig\", \"url\": \"$mac_url\"}"
first=0
done
fi
if [ -n "$win_url" ] && [ -n "$win_sig" ]; then
[ $first -eq 0 ] && echo ','
echo " \"windows-x86_64\": {\"signature\": \"$win_sig\", \"url\": \"$win_url\"}"
first=0
fi
if [ -n "$linux_url" ] && [ -n "$linux_sig" ]; then
[ $first -eq 0 ] && echo ','
echo " \"linux-x86_64\": {\"signature\": \"$linux_sig\", \"url\": \"$linux_url\"}"
first=0
fi
echo ' }'
echo '}'
} > "$tmp_json"
echo "Generated latest.json:" && cat "$tmp_json"
mv "$tmp_json" latest.json
- name: Upload latest.json to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euxo pipefail
gh release upload "$GITHUB_REF_NAME" latest.json --clobber --repo "$GITHUB_REPOSITORY"

1
.gitignore vendored
View File

@@ -9,3 +9,4 @@ release/
.npmrc
CLAUDE.md
AGENTS.md
/.claude

1
.node-version Normal file
View File

@@ -0,0 +1 @@
v22.4.1

View File

@@ -5,18 +5,145 @@ 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.5.0] - 2025-01-15
### ✨ New Features
- **MCP (Model Context Protocol) Management** - Complete MCP server configuration management system
- Add, edit, delete, and toggle MCP servers in `~/.claude.json`
- Support for stdio and http server types with command validation
- Built-in templates for popular MCP servers (mcp-fetch, etc.)
- Real-time enable/disable toggle for MCP servers
- Atomic file writing to prevent configuration corruption
- **Configuration Import/Export** - Backup and restore your provider configurations
- Export all configurations to JSON file with one click
- Import configurations with validation and automatic backup
- Automatic backup rotation (keeps 10 most recent backups)
- Progress modal with detailed status feedback
- **Endpoint Speed Testing** - Test API endpoint response times
- Measure latency to different provider endpoints
- Visual indicators for connection quality
- Help users choose the fastest provider
### 🔧 Improvements
- Complete internationalization (i18n) coverage for all UI components
- Enhanced error handling and user feedback throughout the application
- Improved configuration file management with better validation
- Added new provider presets: Longcat, kat-coder
- Updated GLM provider configurations with latest models
- Refined UI/UX with better spacing, icons, and visual feedback
- Enhanced tray menu functionality and responsiveness
- **Standardized release artifact naming** - All platform releases now use consistent version-tagged filenames:
- macOS: `CC-Switch-v{version}-macOS.tar.gz` / `.zip`
- Windows: `CC-Switch-v{version}-Windows.msi` / `-Portable.zip`
- Linux: `CC-Switch-v{version}-Linux.AppImage` / `.deb`
### 🐛 Bug Fixes
- Fixed layout shifts during provider switching
- Improved config file path handling across different platforms
- Better error messages for configuration validation failures
- Fixed various edge cases in configuration import/export
### 📦 Technical Details
- Enhanced `import_export.rs` module with backup management
- New `claude_mcp.rs` module for MCP configuration handling
- Improved state management and lock handling in Rust backend
- Better TypeScript type safety across the codebase
## [3.4.0] - 2025-10-01
### ✨ Features
- Enable internationalization via i18next with a Chinese default and English fallback, plus an in-app language switcher
- Add Claude plugin sync while retiring the legacy VS Code integration controls (Codex no longer requires settings.json edits)
- Extend provider presets with optional API key URLs and updated models, including DeepSeek-V3.1-Terminus and Qwen3-Max
- Support portable mode launches and enforce a single running instance to avoid conflicts
### 🔧 Improvements
- Allow minimizing the window to the system tray and add macOS Dock visibility management for tray workflows
- Refresh the Settings modal with a scrollable layout, save icon, and cleaner language section
- Smooth provider toggle states with consistent button widths/icons and prevent layout shifts when switching between Claude and Codex
- Adjust the Windows MSI installer to target per-user LocalAppData and improve component tracking reliability
### 🐛 Fixes
- Remove the unnecessary OpenAI auth requirement from third-party provider configurations
- Fix layout shifts while switching app types with Claude plugin sync enabled
- Align Enable/In Use button states to avoid visual jank across app views
## [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 _(Removed in 3.4.x)_
- Enable VS Code auto-sync by default with window broadcast and tray hooks so Codex switches sync silently _(Removed in 3.4.x)_
- Extend the Codex provider wizard with display name, dedicated API key URL, and clearer guidance
- 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
- System tray provider switching with dynamic menu for Claude/Codex
- Frontend receives `provider-switched` events and refreshes active app
- Built-in update flow via Tauri Updater plugin with dismissible UpdateBadge
### 🔧 Improvements
- Single source of truth for provider configs; no duplicate copy files
- One-time migration imports existing copies into `config.json` and archives originals
- Duplicate provider de-duplication by name + API key at startup
- Atomic writes for Codex `auth.json` + `config.toml` with rollback on failure
- Logging standardized (Rust): use `log::{info,warn,error}` instead of stdout prints
- Tailwind v4 integration and refined dark mode handling
### 🐛 Fixes
- Remove/minimize debug console logs in production builds
- Fix CSS minifier warnings for scrollbar pseudo-elements
- Prettier formatting across codebase for consistent style
### 📦 Dependencies
- Tauri: 2.8.x (core, updater, process, opener, log plugins)
- React: 18.2.x · TypeScript: 5.3.x · Vite: 5.x
### 🔄 Notes
- `connect-src` CSP remains permissive for compatibility; can be tightened later as needed
## [3.1.1] - 2025-09-03
### 🐛 Bug Fixes
- Fixed the default codex config.toml to match the latest modifications
- Improved provider configuration UX with custom option
### 📝 Documentation
- Updated README with latest information
## [3.1.0] - 2025-09-01
### ✨ New Features
- **Added Codex application support** - Now supports both Claude Code and Codex configuration management
- Manage auth.json and config.toml for Codex
- Support for backup and restore operations
@@ -33,12 +160,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- TOML syntax validation for config.toml
### 🔧 Technical Improvements
- Unified Tauri command API with app_type parameter
- Backward compatibility for app/appType parameters
- Added get_config_status/open_config_folder/open_external commands
- Improved error handling for empty config.toml
### 🐛 Bug Fixes
- Fixed config path reporting and folder opening for Codex
- Corrected default import behavior when main config is missing
- Fixed non_snake_case warnings in commands.rs
@@ -46,6 +175,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [3.0.0] - 2025-08-27
### 🚀 Major Changes
- **Complete migration from Electron to Tauri 2.0** - The application has been completely rewritten using Tauri, resulting in:
- **90% reduction in bundle size** (from ~150MB to ~15MB)
- **Significantly improved startup performance**
@@ -53,12 +183,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Enhanced security** with Rust backend
### ✨ New Features
- **Native window controls** with transparent title bar on macOS
- **Improved file system operations** using Rust for better performance
- **Enhanced security model** with explicit permission declarations
- **Better platform detection** using Tauri's native APIs
### 🔧 Technical Improvements
- Migrated from Electron IPC to Tauri command system
- Replaced Node.js file operations with Rust implementations
- Implemented proper CSP (Content Security Policy) for enhanced security
@@ -66,28 +198,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Integrated Rust cargo fmt and clippy for code quality
### 🐛 Bug Fixes
- Fixed bundle identifier conflict on macOS (changed from .app to .desktop)
- Resolved platform detection issues
- Improved error handling in configuration management
### 📦 Dependencies
- **Tauri**: 2.8.2
- **React**: 18.2.0
- **TypeScript**: 5.3.0
- **Vite**: 5.0.0
### 🔄 Migration Notes
For users upgrading from v2.x (Electron version):
- Configuration files remain compatible - no action required
- The app will automatically migrate your existing provider configurations
- Window position and size preferences have been reset to defaults
#### Backup on v1→v2 Migration (cc-switch internal config)
- When the app detects an old v1 config structure at `~/.cc-switch/config.json`, it now creates a timestamped backup before writing the new v2 structure.
- Backup location: `~/.cc-switch/config.v1.backup.<timestamp>.json`
- This only concerns cc-switch's own metadata file; your actual provider files under `~/.claude/` and `~/.codex/` are untouched.
### 🛠️ Development
- Added `pnpm typecheck` command for TypeScript validation
- Added `pnpm format` and `pnpm format:check` for code formatting
- Rust code now uses cargo fmt for consistent formatting
@@ -95,6 +233,7 @@ For users upgrading from v2.x (Electron version):
## [2.0.0] - Previous Electron Release
### Features
- Multi-provider configuration management
- Quick provider switching
- Import/export configurations
@@ -105,6 +244,7 @@ For users upgrading from v2.x (Electron version):
## [1.0.0] - Initial Release
### Features
- Basic provider management
- Claude Code integration
- Configuration file handling

118
README.md
View File

@@ -1,26 +1,30 @@
# Claude Code & Codex 供应商切换器
[![Version](https://img.shields.io/badge/version-3.0.0-blue.svg)](https://github.com/jasonyoung/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/jasonyoung/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202.0-orange.svg)](https://tauri.app/)
[![Version](https://img.shields.io/badge/version-3.5.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.1.0 :新增 Codex 供应商管理与一键切换,支持导入当前 Codex 配置为默认供应商,并在内部配置从 v1 → v2 迁移前自动备份(详见下文““迁移与备份”)
> v3.4.0 :新增 i18next 国际化还有部分未完成、对新模型qwen-3-max, GLM-4.6, DeepSeek-V3.2-Exp的支持、Claude 插件、单实例守护、托盘最小化及安装器优化等
> v3.0.0 重大更新:从 Electron 完全迁移到 Tauri 2.0,应用体积减少 85%(从 ~80MB 降至 ~12MB启动速度提升 10 倍!
> v3.3.0 VS Code Codex 插件一键配置/移除默认自动同步、Codex 通用配置片段与自定义向导增强、WSL 环境支持、跨平台托盘与 UI 优化。(该 VS Code 写入功能已在 v3.4.x 停用)
## 功能特性
> v3.2.0 :全新 UI、macOS系统托盘、内置更新器、原子写入与回滚、改进暗色样式、单一事实源SSOT与一次性迁移/归档。
- **极速启动** - 基于 Tauri 2.0,原生性能,秒开应用
- 一键切换不同供应商
- 同时支持 Claude Code 与 Codex 的供应商切换与导入
- Qwen coder、kimi k2、智谱 GLM、DeepSeek v3.1、packycode 等预设供应商只需要填写 key 即可一键配置
- 支持添加自定义供应商
- 随时切换官方登录
- 简洁美观的图形界面
- 信息存储在本地 ~/.cc-switch/config.json无隐私风险
- 超小体积 - 仅 ~5MB 安装包
> v3.1.0 :新增 Codex 供应商管理与一键切换,支持导入当前 Codex 配置为默认供应商,并在内部配置从 v1 → v2 迁移前自动备份(详见下文“迁移与归档”)。
> v3.0.0 重大更新:从 Electron 完全迁移到 Tauri 2.0,应用体积显著降低、启动性能大幅提升。
## 功能特性v3.4.0
- **国际化与语言切换**:内置 i18next默认显示中文可在设置中快速切换到英文界面文文案自动实时刷新。
- **Claude 插件同步**:内置按钮可一键应用或恢复 Claude 插件配置,切换供应商后立即生效。
- **VS Code Codex 设置停用**:由于新版 Codex 插件无需修改 `settings.json`,应用不再写入 VS Code 设置,避免潜在冲突。
- **供应商预设扩展**:新增 DeepSeek--V3.2-Exp、Qwen3-Max、GLM-4.6 等最新模型。
- **系统托盘与窗口行为**窗口关闭可最小化到托盘macOS 支持托盘模式下隐藏/显示 Dock托盘切换时同步 Claude/Codex/插件状态。
- **单实例**:保证同一时间仅运行一个实例,避免多开冲突。
- **UI 与安装体验优化**设置面板改为可滚动布局并加入保存图标按钮宽度与状态一致性加强Windows MSI 安装默认写入 per-user LocalAppData 并改进组件跟踪Windows 便携版现在指向最新 release 页面,不再自动更为为安装版。
## 界面预览
@@ -38,54 +42,86 @@
- **Windows**: Windows 10 及以上
- **macOS**: macOS 10.15 (Catalina) 及以上
- **Linux**: Ubuntu 20.04+ / Debian 11+ / Fedora 34+ 等主流发行版
- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+ 等主流发行版
### Windows 用户
从 [Releases](../../releases) 页面下载最新版本的 `CC-Switch-Setup.msi` 安装包或者 `CC-Switch-Windows-Portable.zip` 绿色版。
从 [Releases](../../releases) 页面下载最新版本的 `CC-Switch-v{版本号}-Windows.msi` 安装包或者 `CC-Switch-v{版本号}-Windows-Portable.zip` 绿色版。
### macOS 用户
从 [Releases](../../releases) 页面下载 `CC-Switch-macOS.zip` 解压使用。
**方式一:通过 Homebrew 安装(推荐)**
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
更新:
```bash
brew upgrade --cask cc-switch
```
**方式二:手动下载**
从 [Releases](../../releases) 页面下载 `CC-Switch-v{版本号}-macOS.zip` 解压使用。
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开
### Linux 用户
从 [Releases](../../releases) 页面下载最新版本的 `.deb` 包。
从 [Releases](../../releases) 页面下载最新版本的 `CC-Switch-v{版本号}-Linux.deb` 包或者 `CC-Switch-v{版本号}-Linux.AppImage` 安装包。
## 使用说明
1. 点击"添加供应商"添加你的 API 配置
2. 选择要使用的供应商,点击单选按钮切换
3. 配置会自动保存到对应应用的配置文件中
4. 重启或者新打开终端以生效
5. 如果需要切回 Claude 官方登录可以添加预设供应商里的“Claude 官方登录”并切换,重启终端后即可进行正常的 /login 登录
2. 切换方式:
- 在主界面选择供应商后点击切换
- 或通过“系统托盘(菜单栏)”直接选择目标供应商,立即生效
3. 切换会写入对应应用的“live 配置文件”Claude`settings.json`Codex`auth.json` + `config.toml`
4. 重启或新开终端以确保生效
5. 若需切回官方登录,在预设中选择“官方登录”并切换即可;重启终端后按官方流程登录
### Codex 说明
### 检查更新
- 在“设置”中点击“检查更新”,若内置 Updater 配置可用将直接检测与下载;否则会回退打开 Releases 页面
### Codex 说明SSOT
- 配置目录:`~/.codex/`
- 主配置文件`auth.json`(必需)、`config.toml`(可为空)
- 供应商副本:`auth-<name>.json``config-<name>.toml`
- live 主配置:`auth.json`(必需)、`config.toml`(可为空)
- API Key 字段:`auth.json` 中使用 `OPENAI_API_KEY`
- 切换策略:将选中供应商的副本覆盖到主配置(`auth.json``config.toml`)。若供应商没有 `config-*.toml`,会创建空的 `config.toml`
- 导入默认:仅当该应用无任何供应商时,从现有主配置创建一条默认项并设为当前;`config.toml` 不存在时按空处理。
- 官方登录可切换到预设“Codex 官方登录”,重启终端后可选择使用 ChatGPT 账号完成登录。
- 切换行为(不再写“副本文件”):
- 供应商配置统一保存在 `~/.cc-switch/config.json`
- 切换时将目标供应商写回 live 文件(`auth.json` + `config.toml`
- 采用“原子写入 + 失败回滚”,避免半写状态;`config.toml` 可为空
- 导入默认:当该应用无任何供应商时,从现有 live 主配置创建一条默认项并设为当前
- 官方登录可切换到预设“Codex 官方登录”,重启终端后按官方流程登录
### Claude Code 说明
### Claude Code 说明SSOT
- 配置目录:`~/.claude/`
- 主配置文件`settings.json`推荐)或 `claude.json`(旧版兼容,若存在则继续使用)
- 供应商副本:`settings-<name>.json`
- live 主配置:`settings.json`优先)或历史兼容 `claude.json`
- API Key 字段:`env.ANTHROPIC_AUTH_TOKEN`
- 切换策略:将选中供应商的副本覆盖到主配置(`settings.json`/`claude.json`)。如当前有配置且存在“当前供应商”,会先将主配置备份回该供应商的副本文件
- 导入默认:仅当该应用无任何供应商时,从现有主配置创建一条默认项并设为当前。
- 官方登录可切换到预设“Claude 官方登录”,重启终端后可使用 `/login` 完成登录。
- 切换行为(不再写“副本文件”):
- 供应商配置统一保存在 `~/.cc-switch/config.json`
- 切换时将目标供应商 JSON 直接写入 live 文件(优先 `settings.json`
- 编辑当前供应商时,先写 live 成功,再更新应用主配置,保证一致性
- 导入默认:当该应用无任何供应商时,从现有 live 主配置创建一条默认项并设为当前
- 官方登录可切换到预设“Claude 官方登录”,重启终端后可使用 `/login` 完成登录
### 迁移与备份
### 迁移与归档(自 v3.2.0 起)
- cc-switch 自身配置从 v1 → v2 迁移时,将在 `~/.cc-switch/` 目录自动创建时间戳备份:`config.v1.backup.<timestamp>.json`
- 实际生效的应用配置文件(如 `~/.claude/settings.json``~/.codex/auth.json`/`config.toml`)不会被修改,切换仅在用户点击“切换”时按副本覆盖到主配置。
- 一次性迁移:首次启动 3.2.0 及以上版本会扫描旧的“副本文件”并合并到 `~/.cc-switch/config.json`
- Claude`~/.claude/settings-*.json`(排除 `settings.json` / 历史 `claude.json`
- Codex`~/.codex/auth-*.json``config-*.toml`(按名称成对合并)
- 去重与当前项:按“名称(忽略大小写)+ API Key”去重若当前为空将 live 合并项设为当前
- 归档与清理:
- 归档目录:`~/.cc-switch/archive/<timestamp>/<category>/...`
- 归档成功后删除原副本;失败则保留原文件(保守策略)
- v1 → v2 结构升级:会额外生成 `~/.cc-switch/config.v1.backup.<timestamp>.json` 以便回滚
- 注意:迁移后不再持续归档日常切换/编辑操作,如需长期审计请自备备份方案
## 开发
@@ -138,7 +174,7 @@ cargo test
## 技术栈
- **[Tauri 2.0](https://tauri.app/)** - 跨平台桌面应用框架
- **[Tauri 2](https://tauri.app/)** - 跨平台桌面应用框架(集成 updater/process/opener/log/tray-icon
- **[React 18](https://react.dev/)** - 用户界面库
- **[TypeScript](https://www.typescriptlang.org/)** - 类型安全的 JavaScript
- **[Vite](https://vitejs.dev/)** - 极速的前端构建工具
@@ -177,6 +213,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

76
README_i18n.md Normal file
View File

@@ -0,0 +1,76 @@
# CC Switch 国际化功能说明
## 已完成的工作
1. **安装依赖**:添加了 `react-i18next``i18next`
2. **配置国际化**:在 `src/i18n/` 目录下创建了配置文件
3. **翻译文件**:创建了英文和中文翻译文件
4. **组件更新**:替换了主要组件中的硬编码文案
5. **语言切换器**:添加了语言切换按钮
## 文件结构
```
src/
├── i18n/
│ ├── index.ts # 国际化配置文件
│ └── locales/
│ ├── en.json # 英文翻译
│ └── zh.json # 中文翻译
├── components/
│ └── LanguageSwitcher.tsx # 语言切换组件
└── main.tsx # 导入国际化配置
```
## 默认语言设置
- **默认语言**:英文 (en)
- **回退语言**:英文 (en)
## 使用方式
1. 在组件中导入 `useTranslation`
```tsx
import { useTranslation } from 'react-i18next';
function MyComponent() {
const { t } = useTranslation();
return <div>{t('common.save')}</div>;
}
```
2. 切换语言:
```tsx
const { i18n } = useTranslation();
i18n.changeLanguage('zh'); // 切换到中文
```
## 翻译键结构
- `common.*` - 通用文案(保存、取消、设置等)
- `header.*` - 头部相关文案
- `provider.*` - 供应商相关文案
- `notifications.*` - 通知消息
- `settings.*` - 设置页面文案
- `apps.*` - 应用名称
- `console.*` - 控制台日志信息
## 测试功能
应用已添加了语言切换按钮(地球图标),点击可以在中英文之间切换,验证国际化功能是否正常工作。
## 已更新的组件
- ✅ App.tsx - 主应用组件
- ✅ ConfirmDialog.tsx - 确认对话框
- ✅ AddProviderModal.tsx - 添加供应商弹窗
- ✅ EditProviderModal.tsx - 编辑供应商弹窗
- ✅ ProviderList.tsx - 供应商列表
- ✅ LanguageSwitcher.tsx - 语言切换器
- 🔄 SettingsModal.tsx - 设置弹窗(部分完成)
## 注意事项
1. 所有新的文案都应该添加到翻译文件中,而不是硬编码
2. 翻译键名应该有意义且结构化
3. 可以通过修改 `src/i18n/index.ts` 中的 `lng` 配置来更改默认语言

View File

@@ -1,193 +0,0 @@
# CC Switch 加密配置与切换重构方案V1
## 1. 目标与范围
- 目标:将 `~/.cc-switch/config.json` 作为单一真实来源SSOT改为“加密落盘”切换时从解密后的内存配置写入目标应用主配置Claude/Codex
- 范围:
- 后端Rust/Tauri新增加密模块与读写改造。
- 调整切换逻辑为“内存 → 主配置”,切换前回填 live 配置到当前供应商,避免用户外部手改丢失。
- 新增“旧文件清理与归档”能力:默认仅归档不删除,并在迁移成功后提醒用户执行;可在设置页手动触发。
- 兼容旧明文配置v1/v2首次保存迁移为加密文件。
## 2. 背景现状(简述)
- 当前:
- 全局配置:`~/.cc-switch/config.json`v2`MultiAppConfig`,含多个 `ProviderManager`)。
- 切换依赖“供应商副本文件”Claude`~/.claude/settings-<name>.json`Codex`~/.codex/auth-<name>.json``config-<name>.toml`)→ 恢复到主配置。
- 启动:若对应 App 的供应商列表为空,可从现有主配置自动创建一条“默认项”并设为当前。
- 问题:存在“副本 ↔ 总配置”双来源,可能不一致;明文落盘有泄露风险。
## 3. 总体方案
- 以加密文件 `~/.cc-switch/config.enc.json` 替代明文存储;进程启动时解密一次加载到内存,后续以内存为准;保存时加密写盘。
- 切换时:直接从内存 `Provider.settings_config` 写入目标应用主配置;切换前回填当前 live 配置到当前选中供应商(由 `manager.current` 指向),保留外部修改。
- 明文兼容:若无加密文件,读取旧 `config.json`(含 v1→v2 迁移),首次保存写加密文件,并备份旧明文。
- 旧文件清理:提供“可回滚归档”而非删除。扫描 `~/.cc-switch/config.json`v1/v2与 Claude/Codex 的历史副本文件,用户确认后移动到 `~/.cc-switch/archive/<ts>/`,生成 `manifest.json` 以便恢复;默认不做静默清理。
## 4. 密钥管理
- 存储系统级凭据管家keyring crate
- Service`cc-switch`Account`config-key-v1`内容Base64 编码的 32 字节随机密钥AES-256
- 首次运行:生成随机密钥,写入 Keychain。
- 进程内缓存:启动加载后缓存密钥,避免重复 IO。
- 轮换(后续):支持命令触发“旧密钥解密 → 新密钥加密”的原子迁移。
- 回退策略Keychain 不可用时进入“只读模式”并提示用户(不建议将密钥落盘)。
## 5. 加密封装格式
- 文件:`~/.cc-switch/config.enc.json`
- 结构JSON 封装,便于演进):
```json
{
"v": 1,
"alg": "AES-256-GCM",
"nonce": "<base64-nonce>",
"ct": "<base64-ciphertext>"
}
```
- 明文:`serde_json::to_vec(MultiAppConfig)`加密AES-GCM12 字节随机 nonce每次保存生成新 nonce。
## 6. 模块与改造点
- 新增 `src-tauri/src/secure_store.rs`
- `get_or_create_key() -> Result<[u8;32], String>`:从 Keychain 获取/生成密钥。
- `encrypt_bytes(key, plaintext) -> (nonce, ciphertext)``decrypt_bytes(key, nonce, ciphertext)`。
- `read_encrypted_config() -> Result<MultiAppConfig, String>`:读取 `config.enc.json`、解析封装、解密、反序列化。
- `write_encrypted_config(cfg: &MultiAppConfig) -> Result<(), String>`:序列化→加密→原子写入。
- 新增 `src-tauri/src/legacy_cleanup.rs`(旧文件清理/归档):
- `scan_legacy_files() -> LegacyScanReport`:扫描旧 `config.json`v1/v2与 Claude/Codex 副本文件(`settings-*.json`、`auth-*.json`、`config-*.toml`返回分组清单、大小、mtime永不将 live 文件(`settings.json`、`auth.json`、`config.toml`、`config.enc.json`)列为可归档。
- `archive_legacy_files(selection) -> ArchiveResult`:将选中文件移动到 `~/.cc-switch/archive/<ts>/` 下对应子目录(`cc-switch/`、`claude/`、`codex/`),生成 `manifest.json`记录原路径、归档路径、大小、mtime、sha256、类别同分区 `rename`跨分区“copy + fsync + remove”。
- `restore_from_archive(manifest_path, items?) -> RestoreResult`:从归档恢复选中文件;若原路径已有同名文件则中止并提示冲突。
- 可选:`purge_archived(before_days)` 仅删除 `archive/` 内的过期归档;默认关闭。
- 安全护栏:操作前后做 mtime/hash 复核CAS发生变化中止并提示“外部已修改”。
- 调整 `src-tauri/src/app_config.rs`
- `MultiAppConfig::load()`:优先 `read_encrypted_config()`;若无则读旧明文:
- 若检测到 v1`ProviderManager`)→ 迁移到 v2原有逻辑保留
- `MultiAppConfig::save()`:统一调用 `write_encrypted_config()`;若检测到旧 `config.json`,首次保存时备份为 `config.v1.backup.<ts>.json`(或保留为只读,视实现选择)。
- 调整 `src-tauri/src/commands.rs::switch_provider`
- Claude
1. 回填:若 `~/.claude/settings.json` 存在且存在当前指针 → 读取 JSON写回 `manager.providers[manager.current].settings_config`。
2. 切换:从目标 `provider.settings_config` 直接写 `~/.claude/settings.json`(确保父目录存在)。
- Codex
1. 回填:读取 `~/.codex/auth.json`JSON与 `~/.codex/config.toml`(字符串;非空做 TOML 校验)→ 合成为 `{auth, config}` → 写回 `manager.providers[manager.current].settings_config`。
2. 切换:从目标 `provider.settings_config` 中取 `auth`(必需)与 `config`(可空)写入对应主配置(非空 `config` 校验 TOML
- 更新 `manager.current = id``state.save()` → 触发加密保存。
- 保留/清理:
- 阶段一保留 `codex_config.rs` 与 `config.rs` 的副本读写函数(减少改动面),但切换不再依赖“副本恢复”。
- 阶段二可移除 add/update 时的“副本写入”,转为仅更新内存并保存加密配置。
## 7. 数据流与时序
- 启动:`AppState::new()` → `MultiAppConfig::load()`(优先加密)→ 进程内持有解密后的配置。
- 添加/编辑/删除:更新内存中的 `ProviderManager` → `state.save()`(加密写盘)。
- 切换:回填 live → 以目标供应商内存配置写入主配置 → 更新当前指针(`manager.current`)→ `state.save()`。
- 迁移后提醒:若首次从旧明文迁移成功,弹出“发现旧配置,可归档”提示;用户可进入“存储与清理”页面查看并执行归档。
## 8. 迁移策略
- 读取顺序:`config.enc.json`(新)→ `config.json`(旧)。
- 旧版支持:
- v1 明文(单 `ProviderManager`)→ 自动迁移为 v2已有逻辑
- v2 明文 → 直接加载。
- 首次保存:写 `config.enc.json`;若存在旧 `config.json`,备份为 `config.v1.backup.<ts>.json`(或保留为只读)。
- 失败处理:解密失败/破损 → 明确提示并拒绝覆盖;允许用户手动回滚备份。
- 旧文件处理:默认不自动删除。提供“扫描→归档”的可选流程,将旧 `config.json` 与历史副本文件移动到 `~/.cc-switch/archive/<ts>/`,保留 `manifest.json` 以支持恢复。
## 9. 回滚策略
- 加密回滚:保留 `config.v1.backup.<ts>.json` 作为明文快照;必要时让 `load()` 回退到该备份(手动步骤)。
- 切换回退:临时切换回“副本恢复”路径(现有代码仍在,快速恢复可用)。
## 10. 安全与性能
- 算法AES-256-GCMAEAD随机 12 字节 nonce每次保存新 nonce。
- 性能:对几十 KB 级别文件,加解密开销远低于磁盘 IO 和 JSON 处理;冷启动 Keychain 取密钥 120ms可缓存。
- 可靠性:原子写入(临时文件 + rename写入失败不破坏现有文件。
- 可选增强:`zeroize` 清理密钥与明文Claude 配置 JSON Schema 校验。
- 清理安全:归档而非删除;不触及 live 文件;归档/恢复采用 CAS 校验与错误回滚;归档路径冲突加后缀去重(如 `-2`、`-3`)。
## 11. API 与 UX 影响
- 前端 API现有行为不变新增清理相关命令Tauri供 UI 调用:`scan_legacy_files`、`archive_legacy_files`、`restore_from_archive``purge_archived` 可选)。
- UI 提示:在“配置文件位置”旁提示“已加密存储”。
- 清理入口:设置页新增“存储与清理”面板,展示扫描结果、支持归档与从归档恢复;首次迁移成功后弹出提醒(可稍后再说)。
- 文案约定:明确“仅归档、不删除;删除需二次确认且默认关闭自动删除”。
## 12. 开发任务拆解(阶段一为本次交付)
- 阶段一(核心改造 + 清理能力最小闭环)
- 新增模块 `secure_store.rs`Keychain 与加解密工具函数。
- 改造 `app_config.rs``load()/save()` 支持加密文件与旧明文迁移、原子写入、备份。
- 改造 `commands.rs::switch_provider`
- 回填 live 配置 → 写入目标主配置Claude/Codex
- 去除对“副本恢复”的依赖(保留函数以便回退)。
- 旧文件清理:新增 `legacy_cleanup.rs` 与对应 Tauri 命令,完成“扫描→归档→恢复”;首次迁移成功后在 UI 弹提醒,指向“设置 > 存储与清理”。
- 保持 `import_default_config`、`get_config_status` 行为不变。
- 阶段二(清理与增强)
- 移除 add/update 对“副本文件”的写入,完全以内存+加密文件为中心。
- Claude settings 的 JSON Schema 校验;导出明文快照;只读模式显式开关。
- 阶段三(安全升级)
- 密钥轮换;可选 passphraseKDF: Argon2id + salt
## 14. 验收标准
- 功能:
- 无加密明文文件也能启动并正确读写;
- 切换成功将内存配置写入主配置;
- 外部手改在下一次切换前被回填保存;
- 旧配置自动迁移并生成加密文件;
- Keychain/解密异常时不损坏已有文件,给出可理解错误。
- 清理:扫描能准确识别旧明文与副本文件;执行归档后原路径不再存在文件、归档目录生成 `manifest.json`;从归档恢复可还原到原路径(不覆盖已存在文件)。
- 质量:
- 关键路径加错误处理与日志;
- 写入采用原子替换;
- 代码变更集中、最小侵入,与现有风格一致。
- 清理操作具备 CAS 校验、错误回滚、绝不触及 live 文件与 `config.enc.json`。
## 15. 风险与对策
- Keychain 不可用或权限受限:
- 对策:只读模式 + 明确提示;不覆盖落盘;允许手动恢复明文备份。
- 加密文件损坏:
- 对策:严格校验与错误分支;保留旧文件;不做“盲目重置”。
- 与“副本文件”并存导致混淆:
- 对策:阶段一保留但不依赖;阶段二移除写入,文档化行为变更。
- 清理误删或不可逆:
- 对策:默认仅归档不删除;删除需二次确认且仅作用于 `archive/`;提供 `manifest.json` 恢复;归档/恢复全程 CAS 校验与回滚。
## 16. 发布与回退
- 发布:随 Tauri 应用正常发布,无需前端变更。
- 回退:保留旧明文备份;将切换逻辑临时改回“副本恢复”路径可快速回退。
## 17. 旧文件清理与归档(新增)
- 归档对象:
- `~/.cc-switch/config.json`v1/v2迁移成功后
- `~/.claude/settings-*.json`(保留 `settings.json`
- `~/.codex/auth-*.json`、`~/.codex/config-*.toml`(保留 `auth.json`、`config.toml`
- 归档位置与结构:`~/.cc-switch/archive/<timestamp>/{cc-switch,claude,codex}/...`
- `manifest.json`记录原路径、归档路径、大小、mtime、sha256、类别v1/v2/claude/codex用于恢复与可视化。
- 提醒策略:首次迁移成功后弹窗提醒;设置页“存储与清理”提供扫描、归档、恢复操作;默认不自动删除,可选“删除归档 >N 天”开关(默认关闭)。
- 护栏:永不移动/删除 live 文件与 `config.enc.json`;执行前后 CAS 校验跨分区采用“copy+fsync+remove”失败即时回滚并提示。
## 18. 变更点清单(代码)
- 新增:`src-tauri/src/secure_store.rs`
- 修改:
- `src-tauri/src/app_config.rs`load/save 加密化、迁移与原子写入)
- `src-tauri/src/commands.rs`switch_provider 改为内存 → 主配置,并回填 live
- `src-tauri/src/legacy_cleanup.rs`(扫描/归档/恢复旧文件)
- 保持:
- `src-tauri/src/config.rs`、`src-tauri/src/codex_config.rs`(读写工具与校验,阶段一不大动)
- 前端 `src/lib/tauri-api.ts` 与 UI 逻辑
## 19. 开放问题(待确认)
- Keychain 失败时是否提供“本地明文密钥文件600 权限)”的应急模式(当前建议:不提供,保持只读)。
- 加密文件名固定为 `config.enc.json` 是否满足预期,或需隐藏(如 `.config.enc`)。
- 是否需要提供“自动删除归档 >N 天”的开关(默认关闭,建议 N=30
---
以上方案为“阶段一”可落地版本,能在保持前端无感的前提下完成“加密存储 + 内存驱动切换”的核心目标。如需我可以继续补充任务看板Issue 列表)与实施顺序的 PR 规划。

9
docs/roadmap.md Normal file
View File

@@ -0,0 +1,9 @@
- 自动升级自定义路径 ✅
- win 绿色版报毒问题 ✅
- mcp 管理器 ✅
- i18n ✅
- gemini cli
- homebrew 支持
- memory 管理
- codex 更多预设供应商
- 云同步

View File

@@ -1,6 +1,6 @@
{
"name": "cc-switch",
"version": "3.1.1",
"version": "3.5.0",
"description": "Claude Code & Codex 供应商切换工具",
"scripts": {
"dev": "pnpm tauri dev",
@@ -26,8 +26,24 @@
"vite": "^5.0.0"
},
"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",
"smol-toml": "^1.4.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",
"i18next": "^25.5.2",
"jsonc-parser": "^3.2.1",
"lucide-react": "^0.542.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
"react-dom": "^18.2.0",
"react-i18next": "^16.0.0",
"tailwindcss": "^4.1.13"
}
}

710
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
onlyBuiltDependencies:
- '@tailwindcss/oxide'

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

After

Width:  |  Height:  |  Size: 162 KiB

1475
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,10 @@
[package]
name = "cc-switch"
version = "3.1.1"
version = "3.5.0"
description = "Claude Code & Codex 供应商配置管理工具"
authors = ["Jason Young"]
license = "MIT"
repository = "https://github.com/jasonyoung/cc-switch"
repository = "https://github.com/farion1231/cc-switch"
edition = "2021"
rust-version = "1.85.0"
@@ -21,12 +21,30 @@ tauri-build = { version = "2.4.0", features = [] }
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.8.2", features = [] }
chrono = "0.4"
tauri = { version = "2.8.2", features = ["tray-icon"] }
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"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
futures = "0.3"
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
tauri-plugin-single-instance = "2"
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.5"
objc2-app-kit = { version = "0.2", features = ["NSColor"] }
# Optimize release binary size to help reduce AppImage footprint
[profile.release]
codegen-units = 1
lto = "thin"
opt-level = "s"
panic = "abort"
strip = "symbols"

View File

@@ -7,6 +7,10 @@
],
"permissions": [
"core:default",
"opener:default"
"opener:default",
"updater:default",
"core:window:allow-set-skip-taskbar",
"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

@@ -1,6 +1,23 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// MCP 配置单客户端维度claude 或 codex 下的一组服务器)
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct McpConfig {
/// 以 id 为键的服务器定义(宽松 JSON 对象,包含 enabled/source 等 UI 辅助字段)
#[serde(default)]
pub servers: HashMap<String, serde_json::Value>,
}
/// MCP 根:按客户端分开维护(无历史兼容压力,直接以 v2 结构落地)
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct McpRoot {
#[serde(default)]
pub claude: McpConfig,
#[serde(default)]
pub codex: McpConfig,
}
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
use crate::provider::ProviderManager;
@@ -35,8 +52,12 @@ impl From<&str> for AppType {
pub struct MultiAppConfig {
#[serde(default = "default_version")]
pub version: u32,
/// 应用管理器claude/codex
#[serde(flatten)]
pub apps: HashMap<String, ProviderManager>,
/// MCP 配置(按客户端分治)
#[serde(default)]
pub mcp: McpRoot,
}
fn default_version() -> u32 {
@@ -49,7 +70,11 @@ impl Default for MultiAppConfig {
apps.insert("claude".to_string(), ProviderManager::default());
apps.insert("codex".to_string(), ProviderManager::default());
Self { version: 2, apps }
Self {
version: 2,
apps,
mcp: McpRoot::default(),
}
}
}
@@ -76,7 +101,11 @@ impl MultiAppConfig {
apps.insert("claude".to_string(), v1_config);
apps.insert("codex".to_string(), ProviderManager::default());
let config = Self { version: 2, apps };
let config = Self {
version: 2,
apps,
mcp: McpRoot::default(),
};
// 迁移前备份旧版(v1)配置文件
let backup_dir = get_app_config_dir();
@@ -136,4 +165,20 @@ impl MultiAppConfig {
.insert(app.as_str().to_string(), ProviderManager::default());
}
}
/// 获取指定客户端的 MCP 配置(不可变引用)
pub fn mcp_for(&self, app: &AppType) -> &McpConfig {
match app {
AppType::Claude => &self.mcp.claude,
AppType::Codex => &self.mcp.codex,
}
}
/// 获取指定客户端的 MCP 配置(可变引用)
pub fn mcp_for_mut(&mut self, app: &AppType) -> &mut McpConfig {
match app {
AppType::Claude => &mut self.mcp.claude,
AppType::Codex => &mut self.mcp.codex,
}
}
}

239
src-tauri/src/claude_mcp.rs Normal file
View File

@@ -0,0 +1,239 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use crate::config::atomic_write;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpStatus {
pub user_config_path: String,
pub user_config_exists: bool,
pub server_count: usize,
}
fn user_config_path() -> PathBuf {
// 用户级 MCP 配置文件:~/.claude.json
dirs::home_dir()
.expect("无法获取用户主目录")
.join(".claude.json")
}
fn read_json_value(path: &Path) -> Result<Value, String> {
if !path.exists() {
return Ok(serde_json::json!({}));
}
let content =
fs::read_to_string(path).map_err(|e| format!("读取文件失败: {}: {}", path.display(), e))?;
let value: Value = serde_json::from_str(&content)
.map_err(|e| format!("解析 JSON 失败: {}: {}", path.display(), e))?;
Ok(value)
}
fn write_json_value(path: &Path, value: &Value) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("创建目录失败: {}: {}", parent.display(), e))?;
}
let json =
serde_json::to_string_pretty(value).map_err(|e| format!("序列化 JSON 失败: {}", e))?;
atomic_write(path, json.as_bytes())
}
pub fn get_mcp_status() -> Result<McpStatus, String> {
let path = user_config_path();
let (exists, count) = if path.exists() {
let v = read_json_value(&path)?;
let servers = v.get("mcpServers").and_then(|x| x.as_object());
(true, servers.map(|m| m.len()).unwrap_or(0))
} else {
(false, 0)
};
Ok(McpStatus {
user_config_path: path.to_string_lossy().to_string(),
user_config_exists: exists,
server_count: count,
})
}
pub fn read_mcp_json() -> Result<Option<String>, String> {
let path = user_config_path();
if !path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&path).map_err(|e| format!("读取 MCP 配置失败: {}", e))?;
Ok(Some(content))
}
pub fn upsert_mcp_server(id: &str, spec: Value) -> Result<bool, String> {
if id.trim().is_empty() {
return Err("MCP 服务器 ID 不能为空".into());
}
// 基础字段校验(尽量宽松)
if !spec.is_object() {
return Err("MCP 服务器定义必须为 JSON 对象".into());
}
let t_opt = spec.get("type").and_then(|x| x.as_str());
let is_stdio = t_opt.map(|t| t == "stdio").unwrap_or(true); // 兼容缺省(按 stdio 处理)
let is_http = t_opt.map(|t| t == "http").unwrap_or(false);
if !(is_stdio || is_http) {
return Err("MCP 服务器 type 必须是 'stdio' 或 'http'(或省略表示 stdio".into());
}
// stdio 类型必须有 command
if is_stdio {
let cmd = spec.get("command").and_then(|x| x.as_str()).unwrap_or("");
if cmd.is_empty() {
return Err("stdio 类型的 MCP 服务器缺少 command 字段".into());
}
}
// http 类型必须有 url
if is_http {
let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
if url.is_empty() {
return Err("http 类型的 MCP 服务器缺少 url 字段".into());
}
}
let path = user_config_path();
let mut root = if path.exists() {
read_json_value(&path)?
} else {
serde_json::json!({})
};
// 确保 mcpServers 对象存在
{
let obj = root
.as_object_mut()
.ok_or_else(|| "mcp.json 根必须是对象".to_string())?;
if !obj.contains_key("mcpServers") {
obj.insert("mcpServers".into(), serde_json::json!({}));
}
}
let before = root.clone();
if let Some(servers) = root.get_mut("mcpServers").and_then(|v| v.as_object_mut()) {
servers.insert(id.to_string(), spec);
}
if before == root && path.exists() {
return Ok(false);
}
write_json_value(&path, &root)?;
Ok(true)
}
pub fn delete_mcp_server(id: &str) -> Result<bool, String> {
if id.trim().is_empty() {
return Err("MCP 服务器 ID 不能为空".into());
}
let path = user_config_path();
if !path.exists() {
return Ok(false);
}
let mut root = read_json_value(&path)?;
let Some(servers) = root.get_mut("mcpServers").and_then(|v| v.as_object_mut()) else {
return Ok(false);
};
let existed = servers.remove(id).is_some();
if !existed {
return Ok(false);
}
write_json_value(&path, &root)?;
Ok(true)
}
pub fn validate_command_in_path(cmd: &str) -> Result<bool, String> {
if cmd.trim().is_empty() {
return Ok(false);
}
// 如果包含路径分隔符,直接判断是否存在可执行文件
if cmd.contains('/') || cmd.contains('\\') {
return Ok(Path::new(cmd).exists());
}
let path_var = env::var_os("PATH").unwrap_or_default();
let paths = env::split_paths(&path_var);
#[cfg(windows)]
let exts: Vec<String> = env::var("PATHEXT")
.unwrap_or(".COM;.EXE;.BAT;.CMD".into())
.split(';')
.map(|s| s.trim().to_uppercase())
.collect();
for p in paths {
let candidate = p.join(cmd);
if candidate.is_file() {
return Ok(true);
}
#[cfg(windows)]
{
for ext in &exts {
let cand = p.join(format!("{}{}", cmd, ext));
if cand.is_file() {
return Ok(true);
}
}
}
}
Ok(false)
}
/// 将给定的启用 MCP 服务器映射写入到用户级 ~/.claude.json 的 mcpServers 字段
/// 仅覆盖 mcpServers其他字段保持不变
pub fn set_mcp_servers_map(
servers: &std::collections::HashMap<String, Value>,
) -> Result<(), String> {
let path = user_config_path();
let mut root = if path.exists() {
read_json_value(&path)?
} else {
serde_json::json!({})
};
// 构建 mcpServers 对象:移除 UI 辅助字段enabled/source仅保留实际 MCP 规范
let mut out: Map<String, Value> = Map::new();
for (id, spec) in servers.iter() {
let mut obj = if let Some(map) = spec.as_object() {
map.clone()
} else {
return Err(format!("MCP 服务器 '{}' 不是对象", id));
};
if let Some(server_val) = obj.remove("server") {
let server_obj = server_val
.as_object()
.cloned()
.ok_or_else(|| format!("MCP 服务器 '{}' server 字段不是对象", id))?;
obj = server_obj;
}
obj.remove("enabled");
obj.remove("source");
obj.remove("id");
obj.remove("name");
obj.remove("description");
obj.remove("tags");
obj.remove("homepage");
obj.remove("docs");
out.insert(id.clone(), Value::Object(obj));
}
{
let obj = root
.as_object_mut()
.ok_or_else(|| "~/.claude.json 根必须是对象".to_string())?;
obj.insert("mcpServers".into(), Value::Object(out));
}
write_json_value(&path, &root)?;
Ok(())
}

View File

@@ -0,0 +1,132 @@
use std::fs;
use std::path::PathBuf;
const CLAUDE_DIR: &str = ".claude";
const CLAUDE_CONFIG_FILE: &str = "config.json";
fn claude_dir() -> Result<PathBuf, String> {
// 优先使用设置中的覆盖目录
if let Some(dir) = crate::settings::get_claude_override_dir() {
return Ok(dir);
}
let home = dirs::home_dir().ok_or_else(|| "无法获取用户主目录".to_string())?;
Ok(home.join(CLAUDE_DIR))
}
pub fn claude_config_path() -> Result<PathBuf, String> {
Ok(claude_dir()?.join(CLAUDE_CONFIG_FILE))
}
pub fn ensure_claude_dir_exists() -> Result<PathBuf, String> {
let dir = claude_dir()?;
if !dir.exists() {
fs::create_dir_all(&dir).map_err(|e| format!("创建 Claude 配置目录失败: {}", e))?;
}
Ok(dir)
}
pub fn read_claude_config() -> Result<Option<String>, String> {
let path = claude_config_path()?;
if path.exists() {
let content =
fs::read_to_string(&path).map_err(|e| format!("读取 Claude 配置失败: {}", e))?;
Ok(Some(content))
} else {
Ok(None)
}
}
fn is_managed_config(content: &str) -> bool {
match serde_json::from_str::<serde_json::Value>(content) {
Ok(value) => value
.get("primaryApiKey")
.and_then(|v| v.as_str())
.map(|val| val == "any")
.unwrap_or(false),
Err(_) => false,
}
}
pub fn write_claude_config() -> Result<bool, String> {
// 增量写入:仅设置 primaryApiKey = "any",保留其它字段
let path = claude_config_path()?;
ensure_claude_dir_exists()?;
// 尝试读取并解析为对象
let mut obj = match read_claude_config()? {
Some(existing) => match serde_json::from_str::<serde_json::Value>(&existing) {
Ok(serde_json::Value::Object(map)) => serde_json::Value::Object(map),
_ => serde_json::json!({}),
},
None => serde_json::json!({}),
};
let mut changed = false;
if let Some(map) = obj.as_object_mut() {
let cur = map
.get("primaryApiKey")
.and_then(|v| v.as_str())
.unwrap_or("");
if cur != "any" {
map.insert(
"primaryApiKey".to_string(),
serde_json::Value::String("any".to_string()),
);
changed = true;
}
}
if changed || !path.exists() {
let serialized = serde_json::to_string_pretty(&obj)
.map_err(|e| format!("序列化 Claude 配置失败: {}", e))?;
fs::write(&path, format!("{}\n", serialized))
.map_err(|e| format!("写入 Claude 配置失败: {}", e))?;
Ok(true)
} else {
Ok(false)
}
}
pub fn clear_claude_config() -> Result<bool, String> {
let path = claude_config_path()?;
if !path.exists() {
return Ok(false);
}
let content = match read_claude_config()? {
Some(content) => content,
None => return Ok(false),
};
let mut value = match serde_json::from_str::<serde_json::Value>(&content) {
Ok(value) => value,
Err(_) => return Ok(false),
};
let obj = match value.as_object_mut() {
Some(obj) => obj,
None => return Ok(false),
};
if obj.remove("primaryApiKey").is_none() {
return Ok(false);
}
let serialized = serde_json::to_string_pretty(&value)
.map_err(|e| format!("序列化 Claude 配置失败: {}", e))?;
fs::write(&path, format!("{}\n", serialized))
.map_err(|e| format!("写入 Claude 配置失败: {}", e))?;
Ok(true)
}
pub fn claude_config_status() -> Result<(bool, PathBuf), String> {
let path = claude_config_path()?;
Ok((path.exists(), path))
}
pub fn is_claude_config_applied() -> Result<bool, String> {
match read_claude_config()? {
Some(content) => Ok(is_managed_config(&content)),
None => Ok(false),
}
}

View File

@@ -4,12 +4,16 @@ use std::path::PathBuf;
use crate::config::{
atomic_write, delete_file, sanitize_provider_name, write_json_file, write_text_file,
};
use serde_json::Value;
use std::fs;
use std::path::Path;
use serde_json::Value;
/// 获取 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")
}
@@ -29,7 +33,7 @@ pub fn get_codex_provider_paths(
provider_name: Option<&str>,
) -> (PathBuf, PathBuf) {
let base_name = provider_name
.map(|name| sanitize_provider_name(name))
.map(sanitize_provider_name)
.unwrap_or_else(|| sanitize_provider_name(provider_id));
let auth_path = get_codex_config_dir().join(format!("auth-{}.json", base_name));
@@ -56,20 +60,27 @@ pub fn write_codex_live_atomic(auth: &Value, config_text_opt: Option<&str>) -> R
let config_path = get_codex_config_path();
if let Some(parent) = auth_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("创建 Codex 目录失败: {}", e))?;
std::fs::create_dir_all(parent)
.map_err(|e| format!("创建 Codex 目录失败: {}: {}", parent.display(), e))?;
}
// 读取旧内容用于回滚
let old_auth = if auth_path.exists() {
Some(fs::read(&auth_path).map_err(|e| format!("读取旧 auth.json 失败: {}", e))?)
} else {
None
};
let _old_config = if config_path.exists() {
Some(fs::read(&config_path).map_err(|e| format!("读取旧 config.toml 失败: {}", e))?)
Some(
fs::read(&auth_path)
.map_err(|e| format!("读取旧 auth.json 失败: {}: {}", auth_path.display(), e))?,
)
} else {
None
};
let _old_config =
if config_path.exists() {
Some(fs::read(&config_path).map_err(|e| {
format!("读取旧 config.toml 失败: {}: {}", config_path.display(), e)
})?)
} else {
None
};
// 准备写入内容
let cfg_text = match config_text_opt {
@@ -77,7 +88,13 @@ pub fn write_codex_live_atomic(auth: &Value, config_text_opt: Option<&str>) -> R
None => String::new(),
};
if !cfg_text.trim().is_empty() {
toml::from_str::<toml::Table>(&cfg_text).map_err(|e| format!("config.toml 格式错误: {}", e))?;
toml::from_str::<toml::Table>(&cfg_text).map_err(|e| {
format!(
"config.toml 语法错误: {} (路径: {})",
e,
config_path.display()
)
})?;
}
// 第一步:写 auth.json
@@ -121,7 +138,9 @@ pub fn validate_config_toml(text: &str) -> Result<(), String> {
if text.trim().is_empty() {
return Ok(());
}
toml::from_str::<toml::Table>(text).map(|_| ()).map_err(|e| format!("config.toml 语法错误: {}", e))
toml::from_str::<toml::Table>(text)
.map(|_| ())
.map_err(|e| format!("config.toml 语法错误: {}", e))
}
/// 读取并校验 `~/.codex/config.toml`,返回文本(可能为空)

View File

@@ -2,14 +2,49 @@
use std::collections::HashMap;
use tauri::State;
use tauri_plugin_dialog::DialogExt;
use tauri_plugin_opener::OpenerExt;
use crate::app_config::AppType;
use crate::claude_mcp;
use crate::claude_plugin;
use crate::codex_config;
use crate::config::{ConfigStatus, get_claude_settings_path};
use crate::provider::Provider;
use crate::config::{self, get_claude_settings_path, ConfigStatus};
use crate::provider::{Provider, ProviderMeta};
use crate::speedtest;
use crate::store::AppState;
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]
pub async fn get_providers(
@@ -74,6 +109,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
@@ -116,7 +153,9 @@ pub async fn add_provider(
let manager = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
manager.providers.insert(provider.id.clone(), provider.clone());
manager
.providers
.insert(provider.id.clone(), provider.clone());
}
state.save()?;
@@ -137,6 +176,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
@@ -146,7 +187,10 @@ pub async fn update_provider(
let manager = config
.get_manager(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
(manager.providers.contains_key(&provider.id), manager.current == provider.id)
(
manager.providers.contains_key(&provider.id),
manager.current == provider.id,
)
};
if !exists {
return Err(format!("供应商不存在: {}", provider.id));
@@ -173,7 +217,7 @@ pub async fn update_provider(
}
}
// 更新内存并保存
// 更新内存并保存(保留/合并已有的 meta.custom_endpoints避免丢失在编辑流程中新增的自定义端点
{
let mut config = state
.config
@@ -182,7 +226,43 @@ pub async fn update_provider(
let manager = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
manager.providers.insert(provider.id.clone(), provider.clone());
// 若已存在旧供应商,合并其 meta尤其是 custom_endpoints到新对象
let merged_provider = if let Some(existing) = manager.providers.get(&provider.id) {
// 克隆入参作为基准
let mut updated = provider.clone();
match (existing.meta.as_ref(), updated.meta.take()) {
// 入参未携带 meta直接沿用旧 meta
(Some(old_meta), None) => {
updated.meta = Some(old_meta.clone());
}
// 入参携带 meta与旧 meta 合并(以旧值为准,保留新增项)
(Some(old_meta), Some(mut new_meta)) => {
// 合并 custom_endpointsURL 去重,保留旧端点的时间信息,补充新增端点)
let mut merged_map = old_meta.custom_endpoints.clone();
for (url, ep) in new_meta.custom_endpoints.drain() {
merged_map.entry(url).or_insert(ep);
}
updated.meta = Some(crate::provider::ProviderMeta {
custom_endpoints: merged_map,
});
}
// 旧 meta 不存在:使用入参(可能为 None
(None, maybe_new) => {
updated.meta = maybe_new;
}
}
updated
} else {
// 不存在旧供应商(理论上不应发生,因为前面已校验 exists
provider.clone()
};
manager
.providers
.insert(merged_provider.id.clone(), merged_provider);
}
state.save()?;
@@ -268,16 +348,20 @@ pub async fn switch_provider(
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let manager = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
// 为避免长期可变借用,尽快获取必要数据并缩小借用范围
let provider = {
let manager = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
// 检查供应商是否存在
let provider = manager
.providers
.get(&id)
.ok_or_else(|| format!("供应商不存在: {}", id))?
.clone();
// 检查供应商是否存在
let provider = manager
.providers
.get(&id)
.ok_or_else(|| format!("供应商不存在: {}", id))?
.clone();
provider
};
// SSOT 切换:先回填 live 配置到当前供应商,然后从内存写入目标主配置
match app_type {
@@ -285,14 +369,20 @@ pub async fn switch_provider(
use serde_json::Value;
// 回填:读取 liveauth.json + config.toml写回当前供应商 settings_config
if !manager.current.is_empty() {
if !{
let cur = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
cur.current.is_empty()
} {
let auth_path = codex_config::get_codex_auth_path();
let config_path = codex_config::get_codex_config_path();
if auth_path.exists() {
let auth: Value = crate::config::read_json_file(&auth_path)?;
let config_str = if config_path.exists() {
std::fs::read_to_string(&config_path)
.map_err(|e| format!("读取 config.toml 失败: {}", e))?
std::fs::read_to_string(&config_path).map_err(|e| {
format!("读取 config.toml 失败: {}: {}", config_path.display(), e)
})?
} else {
String::new()
};
@@ -302,7 +392,16 @@ pub async fn switch_provider(
"config": config_str,
});
if let Some(cur) = manager.providers.get_mut(&manager.current) {
let cur_id2 = {
let m = config
.get_manager(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
m.current.clone()
};
let m = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
if let Some(cur) = m.providers.get_mut(&cur_id2) {
cur.settings_config = live;
}
}
@@ -325,10 +424,21 @@ pub async fn switch_provider(
let settings_path = get_claude_settings_path();
// 回填:读取 live settings.json 写回当前供应商 settings_config
if settings_path.exists() && !manager.current.is_empty() {
if let Ok(live) = read_json_file::<serde_json::Value>(&settings_path) {
if let Some(cur) = manager.providers.get_mut(&manager.current) {
cur.settings_config = live;
if settings_path.exists() {
let cur_id = {
let m = config
.get_manager(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
m.current.clone()
};
if !cur_id.is_empty() {
if let Ok(live) = read_json_file::<serde_json::Value>(&settings_path) {
let m = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
if let Some(cur) = m.providers.get_mut(&cur_id) {
cur.settings_config = live;
}
}
}
}
@@ -340,11 +450,56 @@ pub async fn switch_provider(
// 不做归档,直接写入
write_json_file(&settings_path, &provider.settings_config)?;
// 写入后回读 live并回填到目标供应商的 SSOT保证一致
if settings_path.exists() {
if let Ok(live_after) = read_json_file::<serde_json::Value>(&settings_path) {
let m = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
if let Some(target) = m.providers.get_mut(&id) {
target.settings_config = live_after;
}
}
}
}
}
// 更新当前供应商
manager.current = id;
// 更新当前供应商(短借用范围)
{
let manager = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
manager.current = id;
}
// 对 Codex切换完成后同步 MCP 到 config.toml并将最新的 config.toml 回填到当前供应商 settings_config.config
if let AppType::Codex = app_type {
// 1) 依据 SSOT 将启用的 MCP 投影到 ~/.codex/config.toml
crate::mcp::sync_enabled_to_codex(&config)?;
// 2) 读取投影后的 live config.toml 文本
let cfg_text_after = crate::codex_config::read_and_validate_codex_config_text()?;
// 3) 回填到当前(目标)供应商的 settings_config.config确保编辑面板读取到最新 MCP
let cur_id = {
let m = config
.get_manager(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
m.current.clone()
};
let m = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
if let Some(p) = m.providers.get_mut(&cur_id) {
if let Some(obj) = p.settings_config.as_object_mut() {
obj.insert(
"config".to_string(),
serde_json::Value::String(cfg_text_after),
);
}
}
}
log::info!("成功切换到供应商: {}", provider.name);
@@ -390,7 +545,8 @@ pub async fn import_default_config(
if !auth_path.exists() {
return Err("Codex 配置文件不存在".to_string());
}
let auth: serde_json::Value = crate::config::read_json_file::<serde_json::Value>(&auth_path)?;
let auth: serde_json::Value =
crate::config::read_json_file::<serde_json::Value>(&auth_path)?;
let config_str = match crate::codex_config::read_and_validate_codex_config_text() {
Ok(s) => s,
Err(e) => return Err(e),
@@ -475,6 +631,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]
@@ -488,7 +664,7 @@ pub async fn open_config_folder(
.or_else(|| app.as_deref().map(|s| s.into()))
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
let config_dir = match app_type {
AppType::Claude => crate::config::get_claude_config_dir(),
AppType::Codex => crate::codex_config::get_codex_config_dir(),
@@ -500,13 +676,46 @@ pub async fn open_config_folder(
}
// 使用 opener 插件打开文件夹
handle.opener()
handle
.opener()
.open_path(config_dir.to_string_lossy().to_string(), None::<String>)
.map_err(|e| format!("打开文件夹失败: {}", e))?;
Ok(true)
}
/// 弹出系统目录选择器并返回用户选择的路径
#[tauri::command]
pub async fn 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> {
@@ -524,3 +733,528 @@ pub async fn open_external(app: tauri::AppHandle, url: String) -> Result<bool, S
Ok(true)
}
/// 获取应用配置文件路径
#[tauri::command]
pub async fn get_app_config_path() -> Result<String, String> {
use crate::config::get_app_config_path;
let config_path = get_app_config_path();
Ok(config_path.to_string_lossy().to_string())
}
/// 打开应用配置文件夹
#[tauri::command]
pub async fn open_app_config_folder(handle: tauri::AppHandle) -> Result<bool, String> {
use crate::config::get_app_config_dir;
let config_dir = get_app_config_dir();
// 确保目录存在
if !config_dir.exists() {
std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {}", e))?;
}
// 使用 opener 插件打开文件夹
handle
.opener()
.open_path(config_dir.to_string_lossy().to_string(), None::<String>)
.map_err(|e| format!("打开文件夹失败: {}", e))?;
Ok(true)
}
// =====================
// Claude MCP 管理命令
// =====================
/// 获取 Claude MCP 状态settings.local.json 与 mcp.json
#[tauri::command]
pub async fn get_claude_mcp_status() -> Result<crate::claude_mcp::McpStatus, String> {
claude_mcp::get_mcp_status()
}
/// 读取 mcp.json 文本内容(不存在则返回 Ok(None)
#[tauri::command]
pub async fn read_claude_mcp_config() -> Result<Option<String>, String> {
claude_mcp::read_mcp_json()
}
/// 新增或更新一个 MCP 服务器条目
#[tauri::command]
pub async fn upsert_claude_mcp_server(id: String, spec: serde_json::Value) -> Result<bool, String> {
claude_mcp::upsert_mcp_server(&id, spec)
}
/// 删除一个 MCP 服务器条目
#[tauri::command]
pub async fn delete_claude_mcp_server(id: String) -> Result<bool, String> {
claude_mcp::delete_mcp_server(&id)
}
/// 校验命令是否在 PATH 中可用(不执行)
#[tauri::command]
pub async fn validate_mcp_command(cmd: String) -> Result<bool, String> {
claude_mcp::validate_command_in_path(&cmd)
}
// =====================
// 新:集中以 config.json 为 SSOT 的 MCP 配置命令
// =====================
#[derive(serde::Serialize)]
pub struct McpConfigResponse {
pub config_path: String,
pub servers: std::collections::HashMap<String, serde_json::Value>,
}
/// 获取 MCP 配置(来自 ~/.cc-switch/config.json
#[tauri::command]
pub async fn get_mcp_config(
state: State<'_, AppState>,
app: Option<String>,
) -> Result<McpConfigResponse, String> {
let config_path = crate::config::get_app_config_path()
.to_string_lossy()
.to_string();
let mut cfg = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let app_ty = crate::app_config::AppType::from(app.as_deref().unwrap_or("claude"));
let (servers, normalized) = crate::mcp::get_servers_snapshot_for(&mut cfg, &app_ty);
let need_save = normalized > 0;
drop(cfg);
if need_save {
state.save()?;
}
Ok(McpConfigResponse {
config_path,
servers,
})
}
/// 在 config.json 中新增或更新一个 MCP 服务器定义
#[tauri::command]
pub async fn upsert_mcp_server_in_config(
state: State<'_, AppState>,
app: Option<String>,
id: String,
spec: serde_json::Value,
) -> Result<bool, String> {
let mut cfg = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let app_ty = crate::app_config::AppType::from(app.as_deref().unwrap_or("claude"));
let changed = crate::mcp::upsert_in_config_for(&mut cfg, &app_ty, &id, spec)?;
drop(cfg);
state.save()?;
Ok(changed)
}
/// 在 config.json 中删除一个 MCP 服务器定义
#[tauri::command]
pub async fn delete_mcp_server_in_config(
state: State<'_, AppState>,
app: Option<String>,
id: String,
) -> Result<bool, String> {
let mut cfg = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let app_ty = crate::app_config::AppType::from(app.as_deref().unwrap_or("claude"));
let existed = crate::mcp::delete_in_config_for(&mut cfg, &app_ty, &id)?;
drop(cfg);
state.save()?;
// 若删除的是 Claude/Codex 客户端的条目,则同步一次,确保启用项从对应 live 配置中移除
let cfg2 = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
match app_ty {
crate::app_config::AppType::Claude => crate::mcp::sync_enabled_to_claude(&cfg2)?,
crate::app_config::AppType::Codex => crate::mcp::sync_enabled_to_codex(&cfg2)?,
}
Ok(existed)
}
/// 设置启用状态并同步到 ~/.claude.json
#[tauri::command]
pub async fn set_mcp_enabled(
state: State<'_, AppState>,
app: Option<String>,
id: String,
enabled: bool,
) -> Result<bool, String> {
let mut cfg = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let app_ty = crate::app_config::AppType::from(app.as_deref().unwrap_or("claude"));
let changed = crate::mcp::set_enabled_and_sync_for(&mut cfg, &app_ty, &id, enabled)?;
drop(cfg);
state.save()?;
Ok(changed)
}
/// 手动同步:将启用的 MCP 投影到 ~/.claude.json不更改 config.json
#[tauri::command]
pub async fn sync_enabled_mcp_to_claude(state: State<'_, AppState>) -> Result<bool, String> {
let mut cfg = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let normalized = crate::mcp::normalize_servers_for(&mut cfg, &AppType::Claude);
crate::mcp::sync_enabled_to_claude(&cfg)?;
let need_save = normalized > 0;
drop(cfg);
if need_save {
state.save()?;
}
Ok(true)
}
/// 手动同步:将启用的 MCP 投影到 ~/.codex/config.toml不更改 config.json
#[tauri::command]
pub async fn sync_enabled_mcp_to_codex(state: State<'_, AppState>) -> Result<bool, String> {
let mut cfg = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let normalized = crate::mcp::normalize_servers_for(&mut cfg, &AppType::Codex);
crate::mcp::sync_enabled_to_codex(&cfg)?;
let need_save = normalized > 0;
drop(cfg);
if need_save {
state.save()?;
}
Ok(true)
}
/// 从 ~/.claude.json 导入 MCP 定义到 config.json返回变更数量
#[tauri::command]
pub async fn import_mcp_from_claude(state: State<'_, AppState>) -> Result<usize, String> {
let mut cfg = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let changed = crate::mcp::import_from_claude(&mut cfg)?;
drop(cfg);
if changed > 0 {
state.save()?;
}
Ok(changed)
}
/// 从 ~/.codex/config.toml 导入 MCP 定义到 config.jsonCodex 作用域),返回变更数量
#[tauri::command]
pub async fn import_mcp_from_codex(state: State<'_, AppState>) -> Result<usize, String> {
let mut cfg = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let changed = crate::mcp::import_from_codex(&mut cfg)?;
drop(cfg);
if changed > 0 {
state.save()?;
}
Ok(changed)
}
/// 读取当前生效live的配置内容返回可直接作为 provider.settings_config 的对象
/// - Codex: 返回 { auth: JSON, config: string }
/// - Claude: 返回 settings.json 的 JSON 内容
#[tauri::command]
pub async fn read_live_provider_settings(
app_type: Option<AppType>,
app: Option<String>,
appType: Option<String>,
) -> Result<serde_json::Value, String> {
let app_type = app_type
.or_else(|| app.as_deref().map(|s| s.into()))
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
match app_type {
AppType::Codex => {
let auth_path = crate::codex_config::get_codex_auth_path();
if !auth_path.exists() {
return Err("Codex 配置文件不存在:缺少 auth.json".to_string());
}
let auth: serde_json::Value = crate::config::read_json_file(&auth_path)?;
let cfg_text = crate::codex_config::read_and_validate_codex_config_text()?;
Ok(serde_json::json!({ "auth": auth, "config": cfg_text }))
}
AppType::Claude => {
let path = crate::config::get_claude_settings_path();
if !path.exists() {
return Err("Claude Code 配置文件不存在".to_string());
}
let v: serde_json::Value = crate::config::read_json_file(&path)?;
Ok(v)
}
}
}
/// 获取设置
#[tauri::command]
pub async fn get_settings() -> Result<crate::settings::AppSettings, String> {
Ok(crate::settings::get_settings())
}
/// 保存设置
#[tauri::command]
pub async fn save_settings(settings: crate::settings::AppSettings) -> Result<bool, String> {
crate::settings::update_settings(settings)?;
Ok(true)
}
/// 检查更新
#[tauri::command]
pub async fn check_for_updates(handle: tauri::AppHandle) -> Result<bool, String> {
// 打开 GitHub releases 页面
handle
.opener()
.open_url(
"https://github.com/farion1231/cc-switch/releases/latest",
None::<String>,
)
.map_err(|e| format!("打开更新页面失败: {}", e))?;
Ok(true)
}
/// 判断是否为便携版(绿色版)运行
#[tauri::command]
pub async fn is_portable_mode() -> Result<bool, String> {
let exe_path = std::env::current_exe().map_err(|e| format!("获取可执行路径失败: {}", e))?;
if let Some(dir) = exe_path.parent() {
Ok(dir.join("portable.ini").is_file())
} else {
Ok(false)
}
}
/// Claude 插件:获取 ~/.claude/config.json 状态
#[tauri::command]
pub async fn get_claude_plugin_status() -> Result<ConfigStatus, String> {
match claude_plugin::claude_config_status() {
Ok((exists, path)) => Ok(ConfigStatus {
exists,
path: path.to_string_lossy().to_string(),
}),
Err(err) => Err(err),
}
}
/// Claude 插件:读取配置内容(若不存在返回 Ok(None)
#[tauri::command]
pub async fn read_claude_plugin_config() -> Result<Option<String>, String> {
claude_plugin::read_claude_config()
}
/// Claude 插件:写入/清除固定配置
#[tauri::command]
pub async fn apply_claude_plugin_config(official: bool) -> Result<bool, String> {
if official {
claude_plugin::clear_claude_config()
} else {
claude_plugin::write_claude_config()
}
}
/// Claude 插件:检测是否已写入目标配置
#[tauri::command]
pub async fn is_claude_plugin_applied() -> Result<bool, String> {
claude_plugin::is_claude_config_applied()
}
/// 测试第三方/自定义供应商端点的网络延迟
#[tauri::command]
pub async fn test_api_endpoints(
urls: Vec<String>,
timeout_secs: Option<u64>,
) -> Result<Vec<speedtest::EndpointLatency>, String> {
let filtered: Vec<String> = urls
.into_iter()
.filter(|url| !url.trim().is_empty())
.collect();
speedtest::test_endpoints(filtered, timeout_secs).await
}
/// 获取自定义端点列表
#[tauri::command]
pub async fn get_custom_endpoints(
state: State<'_, crate::store::AppState>,
app_type: Option<AppType>,
app: Option<String>,
appType: Option<String>,
provider_id: Option<String>,
providerId: Option<String>,
) -> Result<Vec<crate::settings::CustomEndpoint>, String> {
let app_type = app_type
.or_else(|| app.as_deref().map(|s| s.into()))
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
let provider_id = provider_id
.or(providerId)
.ok_or_else(|| "缺少 providerId".to_string())?;
let mut cfg_guard = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let manager = cfg_guard
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
let Some(provider) = manager.providers.get_mut(&provider_id) else {
return Ok(vec![]);
};
// 首选从 provider.meta 读取
let meta = provider.meta.get_or_insert_with(ProviderMeta::default);
if !meta.custom_endpoints.is_empty() {
let mut result: Vec<_> = meta.custom_endpoints.values().cloned().collect();
result.sort_by(|a, b| b.added_at.cmp(&a.added_at));
return Ok(result);
}
Ok(vec![])
}
/// 添加自定义端点
#[tauri::command]
pub async fn add_custom_endpoint(
state: State<'_, crate::store::AppState>,
app_type: Option<AppType>,
app: Option<String>,
appType: Option<String>,
provider_id: Option<String>,
providerId: Option<String>,
url: String,
) -> Result<(), String> {
let app_type = app_type
.or_else(|| app.as_deref().map(|s| s.into()))
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
let provider_id = provider_id
.or(providerId)
.ok_or_else(|| "缺少 providerId".to_string())?;
let normalized = url.trim().trim_end_matches('/').to_string();
if normalized.is_empty() {
return Err("URL 不能为空".to_string());
}
let mut cfg_guard = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let manager = cfg_guard
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
let Some(provider) = manager.providers.get_mut(&provider_id) else {
return Err("供应商不存在或未选择".to_string());
};
let meta = provider.meta.get_or_insert_with(ProviderMeta::default);
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
let endpoint = crate::settings::CustomEndpoint {
url: normalized.clone(),
added_at: timestamp,
last_used: None,
};
meta.custom_endpoints.insert(normalized, endpoint);
drop(cfg_guard);
state.save()?;
Ok(())
}
/// 删除自定义端点
#[tauri::command]
pub async fn remove_custom_endpoint(
state: State<'_, crate::store::AppState>,
app_type: Option<AppType>,
app: Option<String>,
appType: Option<String>,
provider_id: Option<String>,
providerId: Option<String>,
url: String,
) -> Result<(), String> {
let app_type = app_type
.or_else(|| app.as_deref().map(|s| s.into()))
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
let provider_id = provider_id
.or(providerId)
.ok_or_else(|| "缺少 providerId".to_string())?;
let normalized = url.trim().trim_end_matches('/').to_string();
let mut cfg_guard = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let manager = cfg_guard
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
if let Some(provider) = manager.providers.get_mut(&provider_id) {
if let Some(meta) = provider.meta.as_mut() {
meta.custom_endpoints.remove(&normalized);
}
}
drop(cfg_guard);
state.save()?;
Ok(())
}
/// 更新端点最后使用时间
#[tauri::command]
pub async fn update_endpoint_last_used(
state: State<'_, crate::store::AppState>,
app_type: Option<AppType>,
app: Option<String>,
appType: Option<String>,
provider_id: Option<String>,
providerId: Option<String>,
url: String,
) -> Result<(), String> {
let app_type = app_type
.or_else(|| app.as_deref().map(|s| s.into()))
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
let provider_id = provider_id
.or(providerId)
.ok_or_else(|| "缺少 providerId".to_string())?;
let normalized = url.trim().trim_end_matches('/').to_string();
let mut cfg_guard = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let manager = cfg_guard
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
if let Some(provider) = manager.providers.get_mut(&provider_id) {
if let Some(meta) = provider.meta.as_mut() {
if let Some(endpoint) = meta.custom_endpoints.get_mut(&normalized) {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
endpoint.last_used = Some(timestamp);
}
}
}
drop(cfg_guard);
state.save()?;
Ok(())
}

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")
@@ -88,7 +92,6 @@ pub fn archive_file(ts: u64, category: &str, src: &Path) -> Result<Option<PathBu
Ok(Some(dest))
}
/// 清理供应商名称,确保文件名安全
pub fn sanitize_provider_name(name: &str) -> String {
name.chars()
@@ -103,7 +106,7 @@ pub fn sanitize_provider_name(name: &str) -> String {
/// 获取供应商配置文件路径
pub fn get_provider_config_path(provider_id: &str, provider_name: Option<&str>) -> PathBuf {
let base_name = provider_name
.map(|name| sanitize_provider_name(name))
.map(sanitize_provider_name)
.unwrap_or_else(|| sanitize_provider_name(provider_id));
get_claude_config_dir().join(format!("settings-{}.json", base_name))
@@ -115,16 +118,18 @@ pub fn read_json_file<T: for<'a> Deserialize<'a>>(path: &Path) -> Result<T, Stri
return Err(format!("文件不存在: {}", path.display()));
}
let content = fs::read_to_string(path).map_err(|e| format!("读取文件失败: {}", e))?;
let content =
fs::read_to_string(path).map_err(|e| format!("读取文件失败: {}: {}", path.display(), e))?;
serde_json::from_str(&content).map_err(|e| format!("解析 JSON 失败: {}", e))
serde_json::from_str(&content).map_err(|e| format!("解析 JSON 失败: {}: {}", path.display(), e))
}
/// 写入 JSON 配置文件
pub fn write_json_file<T: Serialize>(path: &Path, data: &T) -> Result<(), String> {
// 确保目录存在
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?;
fs::create_dir_all(parent)
.map_err(|e| format!("创建目录失败: {}: {}", parent.display(), e))?;
}
let json =
@@ -136,7 +141,8 @@ pub fn write_json_file<T: Serialize>(path: &Path, data: &T) -> Result<(), String
/// 原子写入文本文件(用于 TOML/纯文本)
pub fn write_text_file(path: &Path, data: &str) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?;
fs::create_dir_all(parent)
.map_err(|e| format!("创建目录失败: {}: {}", parent.display(), e))?;
}
atomic_write(path, data.as_bytes())
}
@@ -144,7 +150,8 @@ pub fn write_text_file(path: &Path, data: &str) -> Result<(), String> {
/// 原子写入:写入临时文件后 rename 替换,避免半写状态
pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?;
fs::create_dir_all(parent)
.map_err(|e| format!("创建目录失败: {}: {}", parent.display(), e))?;
}
let parent = path.parent().ok_or_else(|| "无效的路径".to_string())?;
@@ -161,10 +168,12 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> {
tmp.push(format!("{}.tmp.{}", file_name, ts));
{
let mut f = fs::File::create(&tmp).map_err(|e| format!("创建临时文件失败: {}", e))?;
let mut f = fs::File::create(&tmp)
.map_err(|e| format!("创建临时文件失败: {}: {}", tmp.display(), e))?;
f.write_all(data)
.map_err(|e| format!("写入临时文件失败: {}", e))?;
f.flush().map_err(|e| format!("刷新临时文件失败: {}", e))?;
.map_err(|e| format!("写入临时文件失败: {}: {}", tmp.display(), e))?;
f.flush()
.map_err(|e| format!("刷新临时文件失败: {}: {}", tmp.display(), e))?;
}
#[cfg(unix)]
@@ -176,7 +185,33 @@ 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!(
"原子替换失败: {} -> {}: {}",
tmp.display(),
path.display(),
e
)
})?;
}
#[cfg(not(windows))]
{
fs::rename(&tmp, path).map_err(|e| {
format!(
"原子替换失败: {} -> {}: {}",
tmp.display(),
path.display(),
e
)
})?;
}
Ok(())
}

View File

@@ -0,0 +1,170 @@
use chrono::Utc;
use serde_json::{json, Value};
use std::fs;
use std::path::PathBuf;
// 默认仅保留最近 10 份备份,避免目录无限膨胀
const MAX_BACKUPS: usize = 10;
/// 创建配置文件备份
pub fn create_backup(config_path: &PathBuf) -> Result<String, String> {
if !config_path.exists() {
return Ok(String::new());
}
let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
let backup_id = format!("backup_{}", timestamp);
let backup_dir = config_path
.parent()
.ok_or("Invalid config path")?
.join("backups");
// 创建备份目录
fs::create_dir_all(&backup_dir)
.map_err(|e| format!("Failed to create backup directory: {}", e))?;
let backup_path = backup_dir.join(format!("{}.json", backup_id));
// 复制配置文件到备份
fs::copy(config_path, backup_path).map_err(|e| format!("Failed to create backup: {}", e))?;
// 备份完成后清理旧的备份文件(仅保留最近 MAX_BACKUPS 份)
cleanup_old_backups(&backup_dir, MAX_BACKUPS)?;
Ok(backup_id)
}
fn cleanup_old_backups(backup_dir: &PathBuf, retain: usize) -> Result<(), String> {
if retain == 0 {
return Ok(());
}
let mut entries: Vec<_> = match fs::read_dir(backup_dir) {
Ok(iter) => iter
.filter_map(|entry| entry.ok())
.filter(|entry| {
entry
.path()
.extension()
.map(|ext| ext == "json")
.unwrap_or(false)
})
.collect(),
Err(_) => return Ok(()),
};
if entries.len() <= retain {
return Ok(());
}
let remove_count = entries.len().saturating_sub(retain);
entries.sort_by(|a, b| {
let a_time = a.metadata().and_then(|m| m.modified()).ok();
let b_time = b.metadata().and_then(|m| m.modified()).ok();
a_time.cmp(&b_time)
});
for entry in entries.into_iter().take(remove_count) {
if let Err(err) = fs::remove_file(entry.path()) {
log::warn!(
"Failed to remove old backup {}: {}",
entry.path().display(),
err
);
}
}
Ok(())
}
/// 导出配置文件
#[tauri::command]
pub async fn export_config_to_file(file_path: String) -> Result<Value, String> {
// 读取当前配置文件
let config_path = crate::config::get_app_config_path();
let config_content = fs::read_to_string(&config_path)
.map_err(|e| format!("Failed to read configuration: {}", e))?;
// 写入到指定文件
fs::write(&file_path, &config_content).map_err(|e| format!("Failed to write file: {}", e))?;
Ok(json!({
"success": true,
"message": "Configuration exported successfully",
"filePath": file_path
}))
}
/// 从文件导入配置
#[tauri::command]
pub async fn import_config_from_file(
file_path: String,
state: tauri::State<'_, crate::store::AppState>,
) -> Result<Value, String> {
// 读取导入的文件
let import_content =
fs::read_to_string(&file_path).map_err(|e| format!("Failed to read import file: {}", e))?;
// 验证并解析为配置对象
let new_config: crate::app_config::MultiAppConfig = serde_json::from_str(&import_content)
.map_err(|e| format!("Invalid configuration file: {}", e))?;
// 备份当前配置
let config_path = crate::config::get_app_config_path();
let backup_id = create_backup(&config_path)?;
// 写入新配置到磁盘
fs::write(&config_path, &import_content)
.map_err(|e| format!("Failed to write configuration: {}", e))?;
// 更新内存中的状态
{
let mut config_state = state
.config
.lock()
.map_err(|e| format!("Failed to lock config: {}", e))?;
*config_state = new_config;
}
Ok(json!({
"success": true,
"message": "Configuration imported successfully",
"backupId": backup_id
}))
}
/// 保存文件对话框
#[tauri::command]
pub async fn save_file_dialog<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
default_name: String,
) -> Result<Option<String>, String> {
use tauri_plugin_dialog::DialogExt;
let dialog = app.dialog();
let result = dialog
.file()
.add_filter("JSON", &["json"])
.set_file_name(&default_name)
.blocking_save_file();
Ok(result.map(|p| p.to_string()))
}
/// 打开文件对话框
#[tauri::command]
pub async fn open_file_dialog<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
) -> Result<Option<String>, String> {
use tauri_plugin_dialog::DialogExt;
let dialog = app.dialog();
let result = dialog
.file()
.add_filter("JSON", &["json"])
.blocking_pick_file();
Ok(result.map(|p| p.to_string()))
}

View File

@@ -1,19 +1,321 @@
mod app_config;
mod claude_mcp;
mod claude_plugin;
mod codex_config;
mod commands;
mod config;
mod provider;
mod store;
mod import_export;
mod mcp;
mod migration;
mod provider;
mod settings;
mod speedtest;
mod store;
use store::AppState;
use tauri::Manager;
use tauri::{
menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem},
tray::{TrayIconBuilder, TrayIconEvent},
};
#[cfg(target_os = "macos")]
use tauri::{ActivationPolicy, RunEvent};
use tauri::{Emitter, Manager};
/// 创建动态托盘菜单
fn create_tray_menu(
app: &tauri::AppHandle,
app_state: &AppState,
) -> Result<Menu<tauri::Wry>, String> {
let config = app_state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
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标题禁用状态仅作为分组标识
let claude_header =
MenuItem::with_id(app, "claude_header", "─── Claude ───", false, None::<&str>)
.map_err(|e| format!("创建Claude标题失败: {}", e))?;
menu_builder = menu_builder.item(&claude_header);
if !claude_manager.providers.is_empty() {
for (id, provider) in &claude_manager.providers {
let is_current = claude_manager.current == *id;
let item = CheckMenuItem::with_id(
app,
format!("claude_{}", id),
&provider.name,
true,
is_current,
None::<&str>,
)
.map_err(|e| format!("创建菜单项失败: {}", e))?;
menu_builder = menu_builder.item(&item);
}
} else {
// 没有供应商时显示提示
let empty_hint = MenuItem::with_id(
app,
"claude_empty",
" (无供应商,请在主界面添加)",
false,
None::<&str>,
)
.map_err(|e| format!("创建Claude空提示失败: {}", e))?;
menu_builder = menu_builder.item(&empty_hint);
}
}
if let Some(codex_manager) = config.get_manager(&crate::app_config::AppType::Codex) {
// 添加Codex标题禁用状态仅作为分组标识
let codex_header =
MenuItem::with_id(app, "codex_header", "─── Codex ───", false, None::<&str>)
.map_err(|e| format!("创建Codex标题失败: {}", e))?;
menu_builder = menu_builder.item(&codex_header);
if !codex_manager.providers.is_empty() {
for (id, provider) in &codex_manager.providers {
let is_current = codex_manager.current == *id;
let item = CheckMenuItem::with_id(
app,
format!("codex_{}", id),
&provider.name,
true,
is_current,
None::<&str>,
)
.map_err(|e| format!("创建菜单项失败: {}", e))?;
menu_builder = menu_builder.item(&item);
}
} else {
// 没有供应商时显示提示
let empty_hint = MenuItem::with_id(
app,
"codex_empty",
" (无供应商,请在主界面添加)",
false,
None::<&str>,
)
.map_err(|e| format!("创建Codex空提示失败: {}", e))?;
menu_builder = menu_builder.item(&empty_hint);
}
}
// 分隔符和退出菜单
let quit_item = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)
.map_err(|e| format!("创建退出菜单失败: {}", e))?;
menu_builder = menu_builder.separator().item(&quit_item);
menu_builder
.build()
.map_err(|e| format!("构建菜单失败: {}", e))
}
#[cfg(target_os = "macos")]
fn apply_tray_policy(app: &tauri::AppHandle, dock_visible: bool) {
let desired_policy = if dock_visible {
ActivationPolicy::Regular
} else {
ActivationPolicy::Accessory
};
if let Err(err) = app.set_dock_visibility(dock_visible) {
log::warn!("设置 Dock 显示状态失败: {}", err);
}
if let Err(err) = app.set_activation_policy(desired_policy) {
log::warn!("设置激活策略失败: {}", err);
}
}
/// 处理托盘菜单事件
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") {
#[cfg(target_os = "windows")]
{
let _ = window.set_skip_taskbar(false);
}
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
#[cfg(target_os = "macos")]
{
apply_tray_policy(app, true);
}
}
}
"quit" => {
log::info!("退出应用");
app.exit(0);
}
id if id.starts_with("claude_") => {
let provider_id = id.strip_prefix("claude_").unwrap();
log::info!("切换到Claude供应商: {}", provider_id);
// 执行切换
let app_handle = app.clone();
let provider_id = provider_id.to_string();
tauri::async_runtime::spawn(async move {
if let Err(e) = switch_provider_internal(
&app_handle,
crate::app_config::AppType::Claude,
provider_id,
)
.await
{
log::error!("切换Claude供应商失败: {}", e);
}
});
}
id if id.starts_with("codex_") => {
let provider_id = id.strip_prefix("codex_").unwrap();
log::info!("切换到Codex供应商: {}", provider_id);
// 执行切换
let app_handle = app.clone();
let provider_id = provider_id.to_string();
tauri::async_runtime::spawn(async move {
if let Err(e) = switch_provider_internal(
&app_handle,
crate::app_config::AppType::Codex,
provider_id,
)
.await
{
log::error!("切换Codex供应商失败: {}", e);
}
});
}
_ => {
log::warn!("未处理的菜单事件: {}", event_id);
}
}
}
//
/// 内部切换供应商函数
async fn switch_provider_internal(
app: &tauri::AppHandle,
app_type: crate::app_config::AppType,
provider_id: String,
) -> Result<(), String> {
if let Some(app_state) = app.try_state::<AppState>() {
// 在使用前先保存需要的值
let app_type_str = app_type.as_str().to_string();
let provider_id_clone = provider_id.clone();
crate::commands::switch_provider(
app_state.clone(),
Some(app_type),
None,
None,
provider_id,
)
.await?;
// 切换成功后重新创建托盘菜单
if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) {
if let Some(tray) = app.tray_by_id("main") {
if let Err(e) = tray.set_menu(Some(new_menu)) {
log::error!("更新托盘菜单失败: {}", e);
}
}
}
// 发射事件到前端,通知供应商已切换
let event_data = serde_json::json!({
"appType": app_type_str,
"providerId": provider_id_clone
});
if let Err(e) = app.emit("provider-switched", event_data) {
log::error!("发射供应商切换事件失败: {}", e);
}
}
Ok(())
}
/// 更新托盘菜单的Tauri命令
#[tauri::command]
async fn update_tray_menu(
app: tauri::AppHandle,
state: tauri::State<'_, AppState>,
) -> Result<bool, String> {
if let Ok(new_menu) = create_tray_menu(&app, state.inner()) {
if let Some(tray) = app.tray_by_id("main") {
tray.set_menu(Some(new_menu))
.map_err(|e| format!("更新托盘菜单失败: {}", e))?;
return Ok(true);
}
}
Ok(false)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
let mut builder = tauri::Builder::default();
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
{
builder = builder.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}));
}
let builder = builder
// 拦截窗口关闭:根据设置决定是否最小化到托盘
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
let settings = crate::settings::get_settings();
if settings.minimize_to_tray_on_close {
api.prevent_close();
let _ = window.hide();
#[cfg(target_os = "windows")]
{
let _ = window.set_skip_taskbar(true);
}
#[cfg(target_os = "macos")]
{
apply_tray_policy(window.app_handle(), false);
}
} else {
window.app_handle().exit(0);
}
}
})
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.setup(|app| {
// 注册 Updater 插件(桌面端)
#[cfg(desktop)]
{
if let Err(e) = app
.handle()
.plugin(tauri_plugin_updater::Builder::new().build())
{
// 若配置不完整(如缺少 pubkey跳过 Updater 而不中断应用
log::warn!("初始化 Updater 插件失败,已跳过:{}", e);
}
}
#[cfg(target_os = "macos")]
{
// 设置 macOS 标题栏背景色为主界面蓝色
@@ -59,7 +361,7 @@ pub fn run() {
// 首次启动迁移:扫描副本文件,合并到 config.json并归档副本旧 config.json 先归档
{
let mut config_guard = app_state.config.lock().unwrap();
let migrated = migration::migrate_copies_into_config(&mut *config_guard)?;
let migrated = migration::migrate_copies_into_config(&mut config_guard)?;
if migrated {
log::info!("已将副本文件导入到 config.json并完成归档");
}
@@ -71,6 +373,26 @@ pub fn run() {
// 保存配置
let _ = app_state.save();
// 创建动态托盘菜单
let menu = create_tray_menu(app.handle(), &app_state)?;
// 构建托盘
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);
})
.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(())
@@ -86,9 +408,73 @@ 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,
])
.run(tauri::generate_context!())
commands::get_app_config_path,
commands::open_app_config_folder,
commands::read_live_provider_settings,
commands::get_settings,
commands::save_settings,
commands::check_for_updates,
commands::is_portable_mode,
commands::get_claude_plugin_status,
commands::read_claude_plugin_config,
commands::apply_claude_plugin_config,
commands::is_claude_plugin_applied,
// Claude MCP management
commands::get_claude_mcp_status,
commands::read_claude_mcp_config,
commands::upsert_claude_mcp_server,
commands::delete_claude_mcp_server,
commands::validate_mcp_command,
// New MCP via config.json (SSOT)
commands::get_mcp_config,
commands::upsert_mcp_server_in_config,
commands::delete_mcp_server_in_config,
commands::set_mcp_enabled,
commands::sync_enabled_mcp_to_claude,
commands::sync_enabled_mcp_to_codex,
commands::import_mcp_from_claude,
commands::import_mcp_from_codex,
// ours: endpoint speed test + custom endpoint management
commands::test_api_endpoints,
commands::get_custom_endpoints,
commands::add_custom_endpoint,
commands::remove_custom_endpoint,
commands::update_endpoint_last_used,
// theirs: config import/export and dialogs
import_export::export_config_to_file,
import_export::import_config_from_file,
import_export::save_file_dialog,
import_export::open_file_dialog,
update_tray_menu,
]);
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 事件,这里手动恢复主窗口
if let RunEvent::Reopen { .. } = event {
if let Some(window) = app_handle.get_webview_window("main") {
#[cfg(target_os = "windows")]
{
let _ = window.set_skip_taskbar(false);
}
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
apply_tray_policy(app_handle, true);
}
}
#[cfg(not(target_os = "macos"))]
{
let _ = (app_handle, event);
}
});
}

732
src-tauri/src/mcp.rs Normal file
View File

@@ -0,0 +1,732 @@
use serde_json::{json, Value};
use std::collections::HashMap;
use crate::app_config::{AppType, McpConfig, MultiAppConfig};
/// 基础校验:允许 stdio/http或省略 type视为 stdio。对应必填字段存在
fn validate_server_spec(spec: &Value) -> Result<(), String> {
if !spec.is_object() {
return Err("MCP 服务器连接定义必须为 JSON 对象".into());
}
let t_opt = spec.get("type").and_then(|x| x.as_str());
// 支持两种stdio/http若缺省 type 则按 stdio 处理(与社区常见 .mcp.json 一致)
let is_stdio = t_opt.map(|t| t == "stdio").unwrap_or(true);
let is_http = t_opt.map(|t| t == "http").unwrap_or(false);
if !(is_stdio || is_http) {
return Err("MCP 服务器 type 必须是 'stdio' 或 'http'(或省略表示 stdio".into());
}
if is_stdio {
let cmd = spec.get("command").and_then(|x| x.as_str()).unwrap_or("");
if cmd.trim().is_empty() {
return Err("stdio 类型的 MCP 服务器缺少 command 字段".into());
}
}
if is_http {
let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
if url.trim().is_empty() {
return Err("http 类型的 MCP 服务器缺少 url 字段".into());
}
}
Ok(())
}
fn validate_mcp_entry(entry: &Value) -> Result<(), String> {
let obj = entry
.as_object()
.ok_or_else(|| "MCP 服务器条目必须为 JSON 对象".to_string())?;
let server = obj
.get("server")
.ok_or_else(|| "MCP 服务器条目缺少 server 字段".to_string())?;
validate_server_spec(server)?;
for key in ["name", "description", "homepage", "docs"] {
if let Some(val) = obj.get(key) {
if !val.is_string() {
return Err(format!("MCP 服务器 {} 必须为字符串", key));
}
}
}
if let Some(tags) = obj.get("tags") {
let arr = tags
.as_array()
.ok_or_else(|| "MCP 服务器 tags 必须为字符串数组".to_string())?;
if !arr.iter().all(|item| item.is_string()) {
return Err("MCP 服务器 tags 必须为字符串数组".into());
}
}
if let Some(enabled) = obj.get("enabled") {
if !enabled.is_boolean() {
return Err("MCP 服务器 enabled 必须为布尔值".into());
}
}
Ok(())
}
fn normalize_server_keys(map: &mut HashMap<String, Value>) -> usize {
let mut change_count = 0usize;
let mut renames: Vec<(String, String)> = Vec::new();
for (key_ref, value) in map.iter_mut() {
let key = key_ref.clone();
let Some(obj) = value.as_object_mut() else {
continue;
};
let id_value = obj.get("id").cloned();
let target_id: String;
match id_value {
Some(id_val) => match id_val.as_str() {
Some(id_str) => {
let trimmed = id_str.trim();
if trimmed.is_empty() {
obj.insert("id".into(), json!(key.clone()));
change_count += 1;
target_id = key.clone();
} else {
if trimmed != id_str {
obj.insert("id".into(), json!(trimmed));
change_count += 1;
}
target_id = trimmed.to_string();
}
}
None => {
obj.insert("id".into(), json!(key.clone()));
change_count += 1;
target_id = key.clone();
}
},
None => {
obj.insert("id".into(), json!(key.clone()));
change_count += 1;
target_id = key.clone();
}
}
if target_id != key {
renames.push((key, target_id));
}
}
for (old_key, new_key) in renames {
if old_key == new_key {
continue;
}
if map.contains_key(&new_key) {
log::warn!(
"MCP 条目 '{}' 的内部 id '{}' 与现有键冲突,回退为原键",
old_key,
new_key
);
if let Some(value) = map.get_mut(&old_key) {
if let Some(obj) = value.as_object_mut() {
if obj
.get("id")
.and_then(|v| v.as_str())
.map(|s| s != old_key)
.unwrap_or(true)
{
obj.insert("id".into(), json!(old_key.clone()));
change_count += 1;
}
}
}
continue;
}
if let Some(mut value) = map.remove(&old_key) {
if let Some(obj) = value.as_object_mut() {
obj.insert("id".into(), json!(new_key.clone()));
}
log::info!("MCP 条目键名已自动修复: '{}' -> '{}'", old_key, new_key);
map.insert(new_key, value);
change_count += 1;
}
}
change_count
}
pub fn normalize_servers_for(config: &mut MultiAppConfig, app: &AppType) -> usize {
let servers = &mut config.mcp_for_mut(app).servers;
normalize_server_keys(servers)
}
fn extract_server_spec(entry: &Value) -> Result<Value, String> {
let obj = entry
.as_object()
.ok_or_else(|| "MCP 服务器条目必须为 JSON 对象".to_string())?;
let server = obj
.get("server")
.ok_or_else(|| "MCP 服务器条目缺少 server 字段".to_string())?;
if !server.is_object() {
return Err("MCP 服务器 server 字段必须为 JSON 对象".into());
}
Ok(server.clone())
}
/// 返回已启用的 MCP 服务器(过滤 enabled==true
fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
let mut out = HashMap::new();
for (id, entry) in cfg.servers.iter() {
let enabled = entry
.get("enabled")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if !enabled {
continue;
}
match extract_server_spec(entry) {
Ok(spec) => {
out.insert(id.clone(), spec);
}
Err(err) => {
log::warn!("跳过无效的 MCP 条目 '{}': {}", id, err);
}
}
}
out
}
pub fn get_servers_snapshot_for(
config: &mut MultiAppConfig,
app: &AppType,
) -> (HashMap<String, Value>, usize) {
let normalized = normalize_servers_for(config, app);
let mut snapshot = config.mcp_for(app).servers.clone();
snapshot.retain(|id, value| {
let Some(obj) = value.as_object_mut() else {
log::warn!("跳过无效的 MCP 条目 '{}': 必须为 JSON 对象", id);
return false;
};
obj.entry(String::from("id")).or_insert(json!(id));
match validate_mcp_entry(value) {
Ok(()) => true,
Err(err) => {
log::error!("config.json 中存在无效的 MCP 条目 '{}': {}", id, err);
false
}
}
});
(snapshot, normalized)
}
pub fn upsert_in_config_for(
config: &mut MultiAppConfig,
app: &AppType,
id: &str,
spec: Value,
) -> Result<bool, String> {
if id.trim().is_empty() {
return Err("MCP 服务器 ID 不能为空".into());
}
normalize_servers_for(config, app);
validate_mcp_entry(&spec)?;
let mut entry_obj = spec
.as_object()
.cloned()
.ok_or_else(|| "MCP 服务器条目必须为 JSON 对象".to_string())?;
if let Some(existing_id) = entry_obj.get("id") {
let Some(existing_id_str) = existing_id.as_str() else {
return Err("MCP 服务器 id 必须为字符串".into());
};
if existing_id_str != id {
return Err(format!(
"MCP 服务器条目中的 id '{}' 与参数 id '{}' 不一致",
existing_id_str, id
));
}
} else {
entry_obj.insert(String::from("id"), json!(id));
}
let value = Value::Object(entry_obj);
let servers = &mut config.mcp_for_mut(app).servers;
let before = servers.get(id).cloned();
servers.insert(id.to_string(), value);
Ok(before.is_none())
}
pub fn delete_in_config_for(
config: &mut MultiAppConfig,
app: &AppType,
id: &str,
) -> Result<bool, String> {
if id.trim().is_empty() {
return Err("MCP 服务器 ID 不能为空".into());
}
normalize_servers_for(config, app);
let existed = config.mcp_for_mut(app).servers.remove(id).is_some();
Ok(existed)
}
/// 设置启用状态并同步到 ~/.claude.json
pub fn set_enabled_and_sync_for(
config: &mut MultiAppConfig,
app: &AppType,
id: &str,
enabled: bool,
) -> Result<bool, String> {
if id.trim().is_empty() {
return Err("MCP 服务器 ID 不能为空".into());
}
normalize_servers_for(config, app);
if let Some(spec) = config.mcp_for_mut(app).servers.get_mut(id) {
// 写入 enabled 字段
let mut obj = spec
.as_object()
.cloned()
.ok_or_else(|| "MCP 服务器定义必须为 JSON 对象".to_string())?;
obj.insert("enabled".into(), json!(enabled));
*spec = Value::Object(obj);
} else {
// 若不存在则直接返回 false
return Ok(false);
}
// 同步启用项
match app {
AppType::Claude => {
// 将启用项投影到 ~/.claude.json
sync_enabled_to_claude(config)?;
}
AppType::Codex => {
// 将启用项投影到 ~/.codex/config.toml
sync_enabled_to_codex(config)?;
}
}
Ok(true)
}
/// 将 config.json 中 enabled==true 的项投影写入 ~/.claude.json
pub fn sync_enabled_to_claude(config: &MultiAppConfig) -> Result<(), String> {
let enabled = collect_enabled_servers(&config.mcp.claude);
crate::claude_mcp::set_mcp_servers_map(&enabled)
}
/// 从 ~/.claude.json 导入 mcpServers 到 config.json设为 enabled=true
/// 已存在的项仅强制 enabled=true不覆盖其他字段。
pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, String> {
let text_opt = crate::claude_mcp::read_mcp_json()?;
let Some(text) = text_opt else { return Ok(0) };
let mut changed = normalize_servers_for(config, &AppType::Claude);
let v: Value =
serde_json::from_str(&text).map_err(|e| format!("解析 ~/.claude.json 失败: {}", e))?;
let Some(map) = v.get("mcpServers").and_then(|x| x.as_object()) else {
return Ok(changed);
};
for (id, spec) in map.iter() {
// 校验目标 spec
validate_server_spec(spec)?;
let entry = config
.mcp_for_mut(&AppType::Claude)
.servers
.entry(id.clone());
use std::collections::hash_map::Entry;
match entry {
Entry::Vacant(vac) => {
let mut obj = serde_json::Map::new();
obj.insert(String::from("id"), json!(id));
obj.insert(String::from("name"), json!(id));
obj.insert(String::from("server"), spec.clone());
obj.insert(String::from("enabled"), json!(true));
vac.insert(Value::Object(obj));
changed += 1;
}
Entry::Occupied(mut occ) => {
let value = occ.get_mut();
let Some(existing) = value.as_object_mut() else {
log::warn!("MCP 条目 '{}' 不是 JSON 对象,覆盖为导入数据", id);
let mut obj = serde_json::Map::new();
obj.insert(String::from("id"), json!(id));
obj.insert(String::from("name"), json!(id));
obj.insert(String::from("server"), spec.clone());
obj.insert(String::from("enabled"), json!(true));
occ.insert(Value::Object(obj));
changed += 1;
continue;
};
let mut modified = false;
let prev_enabled = existing
.get("enabled")
.and_then(|b| b.as_bool())
.unwrap_or(false);
if !prev_enabled {
existing.insert(String::from("enabled"), json!(true));
modified = true;
}
if existing.get("server").is_none() {
log::warn!("MCP 条目 '{}' 缺少 server 字段,覆盖为导入数据", id);
existing.insert(String::from("server"), spec.clone());
modified = true;
}
if existing.get("id").is_none() {
log::warn!("MCP 条目 '{}' 缺少 id 字段,自动填充", id);
existing.insert(String::from("id"), json!(id));
modified = true;
}
if modified {
changed += 1;
}
}
}
}
Ok(changed)
}
/// 从 ~/.codex/config.toml 导入 MCP 到 config.jsonCodex 作用域),并将导入项设为 enabled=true。
/// 支持两种 schema[mcp.servers.<id>] 与 [mcp_servers.<id>]。
/// 已存在的项仅强制 enabled=true不覆盖其他字段。
pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, String> {
let text = crate::codex_config::read_and_validate_codex_config_text()?;
if text.trim().is_empty() {
return Ok(0);
}
let mut changed_total = normalize_servers_for(config, &AppType::Codex);
let root: toml::Table =
toml::from_str(&text).map_err(|e| format!("解析 ~/.codex/config.toml 失败: {}", e))?;
// helper处理一组 servers 表
let mut import_servers_tbl = |servers_tbl: &toml::value::Table| {
let mut changed = 0usize;
for (id, entry_val) in servers_tbl.iter() {
let Some(entry_tbl) = entry_val.as_table() else {
continue;
};
// type 缺省为 stdio
let typ = entry_tbl
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("stdio");
// 构建 JSON 规范
let mut spec = serde_json::Map::new();
spec.insert("type".into(), json!(typ));
match typ {
"stdio" => {
if let Some(cmd) = entry_tbl.get("command").and_then(|v| v.as_str()) {
spec.insert("command".into(), json!(cmd));
}
if let Some(args) = entry_tbl.get("args").and_then(|v| v.as_array()) {
let arr = args
.iter()
.filter_map(|x| x.as_str())
.map(|s| json!(s))
.collect::<Vec<_>>();
if !arr.is_empty() {
spec.insert("args".into(), serde_json::Value::Array(arr));
}
}
if let Some(cwd) = entry_tbl.get("cwd").and_then(|v| v.as_str()) {
if !cwd.trim().is_empty() {
spec.insert("cwd".into(), json!(cwd));
}
}
if let Some(env_tbl) = entry_tbl.get("env").and_then(|v| v.as_table()) {
let mut env_json = serde_json::Map::new();
for (k, v) in env_tbl.iter() {
if let Some(sv) = v.as_str() {
env_json.insert(k.clone(), json!(sv));
}
}
if !env_json.is_empty() {
spec.insert("env".into(), serde_json::Value::Object(env_json));
}
}
}
"http" => {
if let Some(url) = entry_tbl.get("url").and_then(|v| v.as_str()) {
spec.insert("url".into(), json!(url));
}
if let Some(headers_tbl) = entry_tbl.get("headers").and_then(|v| v.as_table()) {
let mut headers_json = serde_json::Map::new();
for (k, v) in headers_tbl.iter() {
if let Some(sv) = v.as_str() {
headers_json.insert(k.clone(), json!(sv));
}
}
if !headers_json.is_empty() {
spec.insert("headers".into(), serde_json::Value::Object(headers_json));
}
}
}
_ => {}
}
let spec_v = serde_json::Value::Object(spec);
// 校验
if let Err(e) = validate_server_spec(&spec_v) {
log::warn!("跳过无效 Codex MCP 项 '{}': {}", id, e);
continue;
}
// 合并:仅强制 enabled=true
use std::collections::hash_map::Entry;
let entry = config
.mcp_for_mut(&AppType::Codex)
.servers
.entry(id.clone());
match entry {
Entry::Vacant(vac) => {
let mut obj = serde_json::Map::new();
obj.insert(String::from("id"), json!(id));
obj.insert(String::from("name"), json!(id));
obj.insert(String::from("server"), spec_v.clone());
obj.insert(String::from("enabled"), json!(true));
vac.insert(serde_json::Value::Object(obj));
changed += 1;
}
Entry::Occupied(mut occ) => {
let value = occ.get_mut();
let Some(existing) = value.as_object_mut() else {
log::warn!("MCP 条目 '{}' 不是 JSON 对象,覆盖为导入数据", id);
let mut obj = serde_json::Map::new();
obj.insert(String::from("id"), json!(id));
obj.insert(String::from("name"), json!(id));
obj.insert(String::from("server"), spec_v.clone());
obj.insert(String::from("enabled"), json!(true));
occ.insert(serde_json::Value::Object(obj));
changed += 1;
continue;
};
let mut modified = false;
let prev = existing
.get("enabled")
.and_then(|b| b.as_bool())
.unwrap_or(false);
if !prev {
existing.insert(String::from("enabled"), json!(true));
modified = true;
}
if existing.get("server").is_none() {
log::warn!("MCP 条目 '{}' 缺少 server 字段,覆盖为导入数据", id);
existing.insert(String::from("server"), spec_v.clone());
modified = true;
}
if existing.get("id").is_none() {
log::warn!("MCP 条目 '{}' 缺少 id 字段,自动填充", id);
existing.insert(String::from("id"), json!(id));
modified = true;
}
if modified {
changed += 1;
}
}
}
}
changed
};
// 1) 处理 mcp.servers
if let Some(mcp_val) = root.get("mcp") {
if let Some(mcp_tbl) = mcp_val.as_table() {
if let Some(servers_val) = mcp_tbl.get("servers") {
if let Some(servers_tbl) = servers_val.as_table() {
changed_total += import_servers_tbl(servers_tbl);
}
}
}
}
// 2) 处理 mcp_servers
if let Some(servers_val) = root.get("mcp_servers") {
if let Some(servers_tbl) = servers_val.as_table() {
changed_total += import_servers_tbl(servers_tbl);
}
}
Ok(changed_total)
}
/// 将 config.json 中 Codex 的 enabled==true 项以 TOML 形式写入 ~/.codex/config.toml 的 [mcp.servers]
/// 策略:
/// - 读取现有 config.toml若语法无效则报错不尝试覆盖
/// - 仅更新 `mcp.servers` 或 `mcp_servers` 子表,保留 `mcp` 其它键
/// - 仅写入启用项;无启用项时清理对应子表
pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), String> {
use toml::{value::Value as TomlValue, Table as TomlTable};
// 1) 收集启用项Codex 维度)
let enabled = collect_enabled_servers(&config.mcp.codex);
// 2) 读取现有 config.toml 并解析为 Table允许空文件
let base_text = crate::codex_config::read_and_validate_codex_config_text()?;
let mut root: TomlTable = if base_text.trim().is_empty() {
TomlTable::new()
} else {
toml::from_str::<TomlTable>(&base_text)
.map_err(|e| format!("解析 config.toml 失败: {}", e))?
};
// 3) 写入 servers 表(支持 mcp.servers 与 mcp_servers优先沿用已有风格默认 mcp_servers
let prefer_mcp_servers = root.get("mcp_servers").is_some() || root.get("mcp").is_none();
if enabled.is_empty() {
// 无启用项:移除两种节点
// 清除 mcp.servers但保留其他 mcp 字段
let mut should_drop_mcp = false;
if let Some(mcp_val) = root.get_mut("mcp") {
match mcp_val {
TomlValue::Table(tbl) => {
tbl.remove("servers");
should_drop_mcp = tbl.is_empty();
}
_ => should_drop_mcp = true,
}
}
if should_drop_mcp {
root.remove("mcp");
}
// 清除顶层 mcp_servers
root.remove("mcp_servers");
} else {
let mut servers_tbl = TomlTable::new();
for (id, spec) in enabled.iter() {
let mut s = TomlTable::new();
// 类型(缺省视为 stdio
let typ = spec.get("type").and_then(|v| v.as_str()).unwrap_or("stdio");
s.insert("type".into(), TomlValue::String(typ.to_string()));
match typ {
"stdio" => {
let cmd = spec
.get("command")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
s.insert("command".into(), TomlValue::String(cmd));
if let Some(args) = spec.get("args").and_then(|v| v.as_array()) {
let arr = args
.iter()
.filter_map(|x| x.as_str())
.map(|x| TomlValue::String(x.to_string()))
.collect::<Vec<_>>();
if !arr.is_empty() {
s.insert("args".into(), TomlValue::Array(arr));
}
}
if let Some(cwd) = spec.get("cwd").and_then(|v| v.as_str()) {
if !cwd.trim().is_empty() {
s.insert("cwd".into(), TomlValue::String(cwd.to_string()));
}
}
if let Some(env) = spec.get("env").and_then(|v| v.as_object()) {
let mut env_tbl = TomlTable::new();
for (k, v) in env.iter() {
if let Some(sv) = v.as_str() {
env_tbl.insert(k.clone(), TomlValue::String(sv.to_string()));
}
}
if !env_tbl.is_empty() {
s.insert("env".into(), TomlValue::Table(env_tbl));
}
}
}
"http" => {
let url = spec
.get("url")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
s.insert("url".into(), TomlValue::String(url));
if let Some(headers) = spec.get("headers").and_then(|v| v.as_object()) {
let mut h_tbl = TomlTable::new();
for (k, v) in headers.iter() {
if let Some(sv) = v.as_str() {
h_tbl.insert(k.clone(), TomlValue::String(sv.to_string()));
}
}
if !h_tbl.is_empty() {
s.insert("headers".into(), TomlValue::Table(h_tbl));
}
}
}
_ => {}
}
servers_tbl.insert(id.clone(), TomlValue::Table(s));
}
let servers_value = TomlValue::Table(servers_tbl.clone());
if prefer_mcp_servers {
root.insert("mcp_servers".into(), servers_value);
// 若存在 mcp则仅移除 servers 字段,保留其他键
let mut should_drop_mcp = false;
if let Some(mcp_val) = root.get_mut("mcp") {
match mcp_val {
TomlValue::Table(tbl) => {
tbl.remove("servers");
should_drop_mcp = tbl.is_empty();
}
_ => should_drop_mcp = true,
}
}
if should_drop_mcp {
root.remove("mcp");
}
} else {
let mut inserted = false;
if let Some(mcp_val) = root.get_mut("mcp") {
match mcp_val {
TomlValue::Table(tbl) => {
tbl.insert("servers".into(), TomlValue::Table(servers_tbl.clone()));
inserted = true;
}
_ => {
let mut mcp_tbl = TomlTable::new();
mcp_tbl.insert("servers".into(), TomlValue::Table(servers_tbl.clone()));
*mcp_val = TomlValue::Table(mcp_tbl);
inserted = true;
}
}
}
if !inserted {
let mut mcp_tbl = TomlTable::new();
mcp_tbl.insert("servers".into(), TomlValue::Table(servers_tbl));
root.insert("mcp".into(), TomlValue::Table(mcp_tbl));
}
root.remove("mcp_servers");
}
}
// 4) 序列化并写回 config.toml仅改 TOML不触碰 auth.json
let new_text = toml::to_string(&TomlValue::Table(root))
.map_err(|e| format!("序列化 config.toml 失败: {}", e))?;
let path = crate::codex_config::get_codex_config_path();
crate::config::write_text_file(&path, &new_text)?;
Ok(())
}

View File

@@ -47,7 +47,10 @@ fn extract_claude_api_key(value: &Value) -> Option<String> {
fn extract_codex_api_key(value: &Value) -> Option<String> {
value
.get("auth")
.and_then(|auth| auth.get("OPENAI_API_KEY").or_else(|| auth.get("openai_api_key")))
.and_then(|auth| {
auth.get("OPENAI_API_KEY")
.or_else(|| auth.get("openai_api_key"))
})
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
@@ -77,7 +80,9 @@ fn scan_claude_copies() -> Vec<(String, PathBuf, Value)> {
if !fname.starts_with("settings-") || !fname.ends_with(".json") {
continue;
}
let name = fname.trim_start_matches("settings-").trim_end_matches(".json");
let name = fname
.trim_start_matches("settings-")
.trim_end_matches(".json");
if let Ok(val) = crate::config::read_json_file::<Value>(&p) {
items.push((name.to_string(), p, val));
}
@@ -104,7 +109,9 @@ fn scan_codex_copies() -> Vec<(String, Option<PathBuf>, Option<PathBuf>, Value)>
let entry = by_name.entry(name.to_string()).or_default();
entry.0 = Some(p);
} else if fname.starts_with("config-") && fname.ends_with(".toml") {
let name = fname.trim_start_matches("config-").trim_end_matches(".toml");
let name = fname
.trim_start_matches("config-")
.trim_end_matches(".toml");
let entry = by_name.entry(name.to_string()).or_default();
entry.1 = Some(p);
}
@@ -138,8 +145,11 @@ fn scan_codex_copies() -> Vec<(String, Option<PathBuf>, Option<PathBuf>, Value)>
}
pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result<bool, String> {
// 如果已迁移过则跳过
// 如果已迁移过则跳过;若目录不存在则先创建,避免新装用户写入标记时失败
let marker = get_marker_path();
if let Some(parent) = marker.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("创建迁移标记目录失败: {}", e))?;
}
if marker.exists() {
return Ok(false);
}
@@ -183,17 +193,14 @@ pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result<bool, S
if let Some((name, value)) = &live_claude {
let cand_key = extract_claude_api_key(value);
let exist_id = manager
.providers
.iter()
.find_map(|(id, p)| {
let pk = extract_claude_api_key(&p.settings_config);
if norm_name(&p.name) == norm_name(name) && pk == cand_key {
Some(id.clone())
} else {
None
}
});
let exist_id = manager.providers.iter().find_map(|(id, p)| {
let pk = extract_claude_api_key(&p.settings_config);
if norm_name(&p.name) == norm_name(name) && pk == cand_key {
Some(id.clone())
} else {
None
}
});
if let Some(exist_id) = exist_id {
if let Some(prov) = manager.providers.get_mut(&exist_id) {
log::info!("合并到已存在 Claude 供应商 '{}' (by name+key)", name);
@@ -203,43 +210,36 @@ pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result<bool, S
} else {
let id = next_unique_id(&ids, name);
ids.insert(id.clone());
let provider = crate::provider::Provider::with_id(
id.clone(),
name.clone(),
value.clone(),
None,
);
let provider =
crate::provider::Provider::with_id(id.clone(), name.clone(), value.clone(), None);
manager.providers.insert(provider.id.clone(), provider);
live_claude_id = Some(id);
}
}
for (name, path, value) in claude_items.iter() {
let cand_key = extract_claude_api_key(value);
let exist_id = manager
.providers
.iter()
.find_map(|(id, p)| {
let pk = extract_claude_api_key(&p.settings_config);
if norm_name(&p.name) == norm_name(name) && pk == cand_key {
Some(id.clone())
} else {
None
}
});
let exist_id = manager.providers.iter().find_map(|(id, p)| {
let pk = extract_claude_api_key(&p.settings_config);
if norm_name(&p.name) == norm_name(name) && pk == cand_key {
Some(id.clone())
} else {
None
}
});
if let Some(exist_id) = exist_id {
if let Some(prov) = manager.providers.get_mut(&exist_id) {
log::info!("覆盖 Claude 供应商 '{}' 来自 {} (by name+key)", name, path.display());
log::info!(
"覆盖 Claude 供应商 '{}' 来自 {} (by name+key)",
name,
path.display()
);
prov.settings_config = value.clone();
}
} else {
let id = next_unique_id(&ids, name);
ids.insert(id.clone());
let provider = crate::provider::Provider::with_id(
id.clone(),
name.clone(),
value.clone(),
None,
);
let provider =
crate::provider::Provider::with_id(id.clone(), name.clone(), value.clone(), None);
manager.providers.insert(provider.id.clone(), provider);
}
}
@@ -257,7 +257,10 @@ pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result<bool, S
String::new()
}
};
Some(("default".to_string(), serde_json::json!({"auth": auth, "config": cfg})))
Some((
"default".to_string(),
serde_json::json!({"auth": auth, "config": cfg}),
))
}
Err(e) => {
log::warn!("读取 Codex live auth.json 失败: {}", e);
@@ -277,17 +280,14 @@ pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result<bool, S
if let Some((name, value)) = &live_codex {
let cand_key = extract_codex_api_key(value);
let exist_id = manager
.providers
.iter()
.find_map(|(id, p)| {
let pk = extract_codex_api_key(&p.settings_config);
if norm_name(&p.name) == norm_name(name) && pk == cand_key {
Some(id.clone())
} else {
None
}
});
let exist_id = manager.providers.iter().find_map(|(id, p)| {
let pk = extract_codex_api_key(&p.settings_config);
if norm_name(&p.name) == norm_name(name) && pk == cand_key {
Some(id.clone())
} else {
None
}
});
if let Some(exist_id) = exist_id {
if let Some(prov) = manager.providers.get_mut(&exist_id) {
log::info!("合并到已存在 Codex 供应商 '{}' (by name+key)", name);
@@ -297,43 +297,37 @@ pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result<bool, S
} else {
let id = next_unique_id(&ids, name);
ids.insert(id.clone());
let provider = crate::provider::Provider::with_id(
id.clone(),
name.clone(),
value.clone(),
None,
);
let provider =
crate::provider::Provider::with_id(id.clone(), name.clone(), value.clone(), None);
manager.providers.insert(provider.id.clone(), provider);
live_codex_id = Some(id);
}
}
for (name, authp, cfgp, value) in codex_items.iter() {
let cand_key = extract_codex_api_key(value);
let exist_id = manager
.providers
.iter()
.find_map(|(id, p)| {
let pk = extract_codex_api_key(&p.settings_config);
if norm_name(&p.name) == norm_name(name) && pk == cand_key {
Some(id.clone())
} else {
None
}
});
let exist_id = manager.providers.iter().find_map(|(id, p)| {
let pk = extract_codex_api_key(&p.settings_config);
if norm_name(&p.name) == norm_name(name) && pk == cand_key {
Some(id.clone())
} else {
None
}
});
if let Some(exist_id) = exist_id {
if let Some(prov) = manager.providers.get_mut(&exist_id) {
log::info!("覆盖 Codex 供应商 '{}' 来自 {:?}/{:?} (by name+key)", name, authp, cfgp);
log::info!(
"覆盖 Codex 供应商 '{}' 来自 {:?}/{:?} (by name+key)",
name,
authp,
cfgp
);
prov.settings_config = value.clone();
}
} else {
let id = next_unique_id(&ids, name);
ids.insert(id.clone());
let provider = crate::provider::Provider::with_id(
id.clone(),
name.clone(),
value.clone(),
None,
);
let provider =
crate::provider::Provider::with_id(id.clone(), name.clone(), value.clone(), None);
manager.providers.insert(provider.id.clone(), provider);
}
}
@@ -369,15 +363,13 @@ pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result<bool, S
}
for (_, ap, cp, _) in codex_items.into_iter() {
if let Some(ap) = ap {
match archive_file(ts, "codex", &ap) {
Ok(Some(_)) => { let _ = delete_file(&ap); }
_ => {}
if let Ok(Some(_)) = archive_file(ts, "codex", &ap) {
let _ = delete_file(&ap);
}
}
if let Some(cp) = cp {
match archive_file(ts, "codex", &cp) {
Ok(Some(_)) => { let _ = delete_file(&cp); }
_ => {}
if let Ok(Some(_)) = archive_file(ts, "codex", &cp) {
let _ = delete_file(&cp);
}
}
}
@@ -404,7 +396,11 @@ pub fn dedupe_config(config: &mut MultiAppConfig) -> usize {
let mut keep: Map<String, String> = Map::new(); // key -> id 保留
let mut remove: Vec<String> = Vec::new();
for (id, p) in mgr.providers.iter() {
let k = format!("{}|{}", norm_name(&p.name), extract_key(&p.settings_config).unwrap_or_default());
let k = format!(
"{}|{}",
norm_name(&p.name),
extract_key(&p.settings_config).unwrap_or_default()
);
if let Some(exist_id) = keep.get(&k) {
// 若当前是正在使用的,则用当前替换之前的,反之丢弃当前
if *id == mgr.current {

View File

@@ -14,6 +14,14 @@ pub struct Provider {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "websiteUrl")]
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>,
/// 供应商元数据(不写入 live 配置,仅存于 ~/.cc-switch/config.json
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<ProviderMeta>,
}
impl Provider {
@@ -29,24 +37,26 @@ impl Provider {
name,
settings_config,
website_url,
category: None,
created_at: None,
meta: None,
}
}
}
/// 供应商管理器
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProviderManager {
pub providers: HashMap<String, Provider>,
pub current: String,
}
impl Default for ProviderManager {
fn default() -> Self {
Self {
providers: HashMap::new(),
current: String::new(),
}
}
/// 供应商元数据
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProviderMeta {
/// 自定义端点列表(按 URL 去重存储)
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub custom_endpoints: HashMap<String, crate::settings::CustomEndpoint>,
}
impl ProviderManager {

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

@@ -0,0 +1,181 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::{OnceLock, RwLock};
/// 自定义端点配置
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CustomEndpoint {
pub url: String,
pub added_at: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_used: Option<i64>,
}
/// 应用设置结构,允许覆盖默认配置目录
#[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 = "default_minimize_to_tray_on_close")]
pub minimize_to_tray_on_close: bool,
/// 是否启用 Claude 插件联动
#[serde(default)]
pub enable_claude_plugin_integration: 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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
/// Claude 自定义端点列表
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub custom_endpoints_claude: HashMap<String, CustomEndpoint>,
/// Codex 自定义端点列表
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub custom_endpoints_codex: HashMap<String, CustomEndpoint>,
}
fn default_show_in_tray() -> bool {
true
}
fn default_minimize_to_tray_on_close() -> bool {
true
}
impl Default for AppSettings {
fn default() -> Self {
Self {
show_in_tray: true,
minimize_to_tray_on_close: true,
enable_claude_plugin_integration: false,
claude_config_dir: None,
codex_config_dir: None,
language: None,
custom_endpoints_claude: HashMap::new(),
custom_endpoints_codex: HashMap::new(),
}
}
}
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());
self.language = self
.language
.as_ref()
.map(|s| s.trim())
.filter(|s| matches!(*s, "en" | "zh"))
.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))
}

106
src-tauri/src/speedtest.rs Normal file
View File

@@ -0,0 +1,106 @@
use futures::future::join_all;
use reqwest::{Client, Url};
use serde::Serialize;
use std::time::{Duration, Instant};
const DEFAULT_TIMEOUT_SECS: u64 = 8;
const MAX_TIMEOUT_SECS: u64 = 30;
const MIN_TIMEOUT_SECS: u64 = 2;
#[derive(Debug, Clone, Serialize)]
pub struct EndpointLatency {
pub url: String,
pub latency: Option<u128>,
pub status: Option<u16>,
pub error: Option<String>,
}
fn build_client(timeout_secs: u64) -> Result<Client, String> {
Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.redirect(reqwest::redirect::Policy::limited(5))
.user_agent("cc-switch-speedtest/1.0")
.build()
.map_err(|e| format!("创建 HTTP 客户端失败: {e}"))
}
fn sanitize_timeout(timeout_secs: Option<u64>) -> u64 {
let secs = timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS);
secs.clamp(MIN_TIMEOUT_SECS, MAX_TIMEOUT_SECS)
}
pub async fn test_endpoints(
urls: Vec<String>,
timeout_secs: Option<u64>,
) -> Result<Vec<EndpointLatency>, String> {
if urls.is_empty() {
return Ok(vec![]);
}
let timeout = sanitize_timeout(timeout_secs);
let client = build_client(timeout)?;
let tasks = urls.into_iter().map(|raw_url| {
let client = client.clone();
async move {
let trimmed = raw_url.trim().to_string();
if trimmed.is_empty() {
return EndpointLatency {
url: raw_url,
latency: None,
status: None,
error: Some("URL 不能为空".to_string()),
};
}
let parsed_url = match Url::parse(&trimmed) {
Ok(url) => url,
Err(err) => {
return EndpointLatency {
url: trimmed,
latency: None,
status: None,
error: Some(format!("URL 无效: {err}")),
};
}
};
// 先进行一次“热身”请求,忽略其结果,仅用于复用连接/绕过首包惩罚
let _ = client.get(parsed_url.clone()).send().await;
// 第二次请求开始计时,并将其作为结果返回
let start = Instant::now();
match client.get(parsed_url).send().await {
Ok(resp) => {
let latency = start.elapsed().as_millis();
EndpointLatency {
url: trimmed,
latency: Some(latency),
status: Some(resp.status().as_u16()),
error: None,
}
}
Err(err) => {
let status = err.status().map(|s| s.as_u16());
let error_message = if err.is_timeout() {
"请求超时".to_string()
} else if err.is_connect() {
"连接失败".to_string()
} else {
err.to_string()
};
EndpointLatency {
url: trimmed,
latency: None,
status,
error: Some(error_message),
}
}
}
}
});
let results = join_all(tasks).await;
Ok(results)
}

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch",
"version": "3.1.1",
"version": "3.5.0",
"identifier": "com.ccswitch.desktop",
"build": {
"frontendDist": "../dist",
@@ -30,12 +30,26 @@
"bundle": {
"active": true,
"targets": "all",
"createUpdaterArtifacts": true,
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
],
"windows": {
"wix": {
"template": "wix/per-user-main.wxs"
}
}
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEM4MDI4QzlBNTczOTI4RTMKUldUaktEbFhtb3dDeUM5US9kT0FmdGR5Ti9vQzcwa2dTMlpibDVDUmQ2M0VGTzVOWnd0SGpFVlEK",
"endpoints": [
"https://github.com/farion1231/cc-switch/releases/latest/download/latest.json"
]
}
}
}

View File

@@ -0,0 +1,360 @@
<?if $(sys.BUILDARCH)="x86"?>
<?define Win64 = "no" ?>
<?define PlatformProgramFilesFolder = "ProgramFilesFolder" ?>
<?elseif $(sys.BUILDARCH)="x64"?>
<?define Win64 = "yes" ?>
<?define PlatformProgramFilesFolder = "ProgramFiles64Folder" ?>
<?elseif $(sys.BUILDARCH)="arm64"?>
<?define Win64 = "yes" ?>
<?define PlatformProgramFilesFolder = "ProgramFiles64Folder" ?>
<?else?>
<?error Unsupported value of sys.BUILDARCH=$(sys.BUILDARCH)?>
<?endif?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
<Product
Id="*"
Name="{{product_name}}"
UpgradeCode="{{upgrade_code}}"
Language="!(loc.TauriLanguage)"
Manufacturer="{{manufacturer}}"
Version="{{version}}">
<Package Id="*"
Keywords="Installer"
InstallerVersion="450"
Languages="0"
Compressed="yes"
InstallScope="perUser"
InstallPrivileges="limited"
SummaryCodepage="!(loc.TauriCodepage)"/>
<!-- https://docs.microsoft.com/en-us/windows/win32/msi/reinstallmode -->
<!-- reinstall all files; rewrite all registry entries; reinstall all shortcuts -->
<Property Id="REINSTALLMODE" Value="amus" />
<!-- Auto launch app after installation, useful for passive mode which usually used in updates -->
<Property Id="AUTOLAUNCHAPP" Secure="yes" />
<!-- Property to forward cli args to the launched app to not lose those of the pre-update instance -->
<Property Id="LAUNCHAPPARGS" Secure="yes" />
{{#if allow_downgrades}}
<MajorUpgrade Schedule="afterInstallInitialize" AllowDowngrades="yes" />
{{else}}
<MajorUpgrade Schedule="afterInstallInitialize" DowngradeErrorMessage="!(loc.DowngradeErrorMessage)" AllowSameVersionUpgrades="yes" />
{{/if}}
<InstallExecuteSequence>
<RemoveShortcuts>Installed AND NOT UPGRADINGPRODUCTCODE</RemoveShortcuts>
</InstallExecuteSequence>
<Media Id="1" Cabinet="app.cab" EmbedCab="yes" />
{{#if banner_path}}
<WixVariable Id="WixUIBannerBmp" Value="{{banner_path}}" />
{{/if}}
{{#if dialog_image_path}}
<WixVariable Id="WixUIDialogBmp" Value="{{dialog_image_path}}" />
{{/if}}
{{#if license}}
<WixVariable Id="WixUILicenseRtf" Value="{{license}}" />
{{/if}}
<Icon Id="ProductIcon" SourceFile="{{icon_path}}"/>
<Property Id="ARPPRODUCTICON" Value="ProductIcon" />
<Property Id="ARPNOREPAIR" Value="yes" Secure="yes" /> <!-- Remove repair -->
<SetProperty Id="ARPNOMODIFY" Value="1" After="InstallValidate" Sequence="execute"/>
{{#if homepage}}
<Property Id="ARPURLINFOABOUT" Value="{{homepage}}"/>
<Property Id="ARPHELPLINK" Value="{{homepage}}"/>
<Property Id="ARPURLUPDATEINFO" Value="{{homepage}}"/>
{{/if}}
<Property Id="INSTALLDIR">
<!-- First attempt: Search for "InstallDir" -->
<RegistrySearch Id="PrevInstallDirWithName" Root="HKCU" Key="Software\\{{manufacturer}}\\{{product_name}}" Name="InstallDir" Type="raw" />
<!-- Second attempt: If the first fails, search for the default key value (this is how the nsis installer currently stores the path) -->
<RegistrySearch Id="PrevInstallDirNoName" Root="HKCU" Key="Software\\{{manufacturer}}\\{{product_name}}" Type="raw" />
</Property>
<!-- launch app checkbox -->
<Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOXTEXT" Value="!(loc.LaunchApp)" />
<Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOX" Value="1"/>
<CustomAction Id="LaunchApplication" Impersonate="yes" FileKey="Path" ExeCommand="[LAUNCHAPPARGS]" Return="asyncNoWait" />
<UI>
<!-- launch app checkbox -->
<Publish Dialog="ExitDialog" Control="Finish" Event="DoAction" Value="LaunchApplication">WIXUI_EXITDIALOGOPTIONALCHECKBOX = 1 and NOT Installed</Publish>
<Property Id="WIXUI_INSTALLDIR" Value="INSTALLDIR" />
{{#unless license}}
<!-- Skip license dialog -->
<Publish Dialog="WelcomeDlg"
Control="Next"
Event="NewDialog"
Value="InstallDirDlg"
Order="2">1</Publish>
<Publish Dialog="InstallDirDlg"
Control="Back"
Event="NewDialog"
Value="WelcomeDlg"
Order="2">1</Publish>
{{/unless}}
</UI>
<UIRef Id="WixUI_InstallDir" />
<Directory Id="TARGETDIR" Name="SourceDir">
<Directory Id="DesktopFolder" Name="Desktop">
<Component Id="ApplicationShortcutDesktop" Guid="*">
<Shortcut Id="ApplicationDesktopShortcut" Name="{{product_name}}" Description="Runs {{product_name}}" Target="[!Path]" WorkingDirectory="INSTALLDIR" />
<RemoveFolder Id="DesktopFolder" On="uninstall" />
<RegistryValue Root="HKCU" Key="Software\\{{manufacturer}}\\{{product_name}}" Name="Desktop Shortcut" Type="integer" Value="1" KeyPath="yes" />
</Component>
</Directory>
<Directory Id="LocalAppDataFolder">
<Directory Id="TauriLocalAppDataPrograms" Name="Programs">
<Directory Id="INSTALLDIR" Name="{{product_name}}"/>
</Directory>
</Directory>
<Directory Id="ProgramMenuFolder">
<Directory Id="ApplicationProgramsFolder" Name="{{product_name}}"/>
</Directory>
</Directory>
<DirectoryRef Id="INSTALLDIR">
<Component Id="RegistryEntries" Guid="*">
<RegistryKey Root="HKCU" Key="Software\\{{manufacturer}}\\{{product_name}}">
<RegistryValue Name="InstallDir" Type="string" Value="[INSTALLDIR]" KeyPath="yes" />
</RegistryKey>
<!-- Change the Root to HKCU for perUser installations -->
{{#each deep_link_protocols as |protocol| ~}}
<RegistryKey Root="HKCU" Key="Software\Classes\\{{protocol}}">
<RegistryValue Type="string" Name="URL Protocol" Value=""/>
<RegistryValue Type="string" Value="URL:{{bundle_id}} protocol"/>
<RegistryKey Key="DefaultIcon">
<RegistryValue Type="string" Value="&quot;[!Path]&quot;,0" />
</RegistryKey>
<RegistryKey Key="shell\open\command">
<RegistryValue Type="string" Value="&quot;[!Path]&quot; &quot;%1&quot;" />
</RegistryKey>
</RegistryKey>
{{/each~}}
</Component>
<Component Id="Path" Guid="{{path_component_guid}}" Win64="$(var.Win64)">
<File Id="Path" Source="{{main_binary_path}}" KeyPath="no" Checksum="yes"/>
<RegistryValue Root="HKCU" Key="Software\{{manufacturer}}\{{product_name}}" Name="PathComponent" Type="integer" Value="1" KeyPath="yes" />
{{#each file_associations as |association| ~}}
{{#each association.ext as |ext| ~}}
<ProgId Id="{{../../product_name}}.{{ext}}" Advertise="yes" Description="{{association.description}}">
<Extension Id="{{ext}}" Advertise="yes">
<Verb Id="open" Command="Open with {{../../product_name}}" Argument="&quot;%1&quot;" />
</Extension>
</ProgId>
{{/each~}}
{{/each~}}
</Component>
{{#each binaries as |bin| ~}}
<Component Id="{{ bin.id }}" Guid="{{bin.guid}}" Win64="$(var.Win64)">
<File Id="Bin_{{ bin.id }}" Source="{{bin.path}}" KeyPath="yes"/>
</Component>
{{/each~}}
{{#if enable_elevated_update_task}}
<Component Id="UpdateTask" Guid="C492327D-9720-4CD5-8DB8-F09082AF44BE" Win64="$(var.Win64)">
<File Id="UpdateTask" Source="update.xml" KeyPath="yes" Checksum="yes"/>
</Component>
<Component Id="UpdateTaskInstaller" Guid="011F25ED-9BE3-50A7-9E9B-3519ED2B9932" Win64="$(var.Win64)">
<File Id="UpdateTaskInstaller" Source="install-task.ps1" KeyPath="yes" Checksum="yes"/>
</Component>
<Component Id="UpdateTaskUninstaller" Guid="D4F6CC3F-32DC-5FD0-95E8-782FFD7BBCE1" Win64="$(var.Win64)">
<File Id="UpdateTaskUninstaller" Source="uninstall-task.ps1" KeyPath="yes" Checksum="yes"/>
</Component>
{{/if}}
{{resources}}
<Component Id="CMP_UninstallShortcut" Guid="*">
<Shortcut Id="UninstallShortcut"
Name="Uninstall {{product_name}}"
Description="Uninstalls {{product_name}}"
Target="[System64Folder]msiexec.exe"
Arguments="/x [ProductCode]" />
<RemoveFile Id="RemoveUserProgramsFiles" Directory="TauriLocalAppDataPrograms" Name="*" On="uninstall" />
<RemoveFolder Id="RemoveUserProgramsFolder" Directory="TauriLocalAppDataPrograms" On="uninstall" />
<RemoveFolder Id="INSTALLDIR"
On="uninstall" />
<RegistryValue Root="HKCU"
Key="Software\\{{manufacturer}}\\{{product_name}}"
Name="Uninstaller Shortcut"
Type="integer"
Value="1"
KeyPath="yes" />
</Component>
</DirectoryRef>
<DirectoryRef Id="ApplicationProgramsFolder">
<Component Id="ApplicationShortcut" Guid="*">
<Shortcut Id="ApplicationStartMenuShortcut"
Name="{{product_name}}"
Description="Runs {{product_name}}"
Target="[!Path]"
Icon="ProductIcon"
WorkingDirectory="INSTALLDIR">
<ShortcutProperty Key="System.AppUserModel.ID" Value="{{bundle_id}}"/>
</Shortcut>
<RemoveFolder Id="ApplicationProgramsFolder" On="uninstall"/>
<RegistryValue Root="HKCU" Key="Software\\{{manufacturer}}\\{{product_name}}" Name="Start Menu Shortcut" Type="integer" Value="1" KeyPath="yes"/>
</Component>
</DirectoryRef>
{{#each merge_modules as |msm| ~}}
<DirectoryRef Id="TARGETDIR">
<Merge Id="{{ msm.name }}" SourceFile="{{ msm.path }}" DiskId="1" Language="!(loc.TauriLanguage)" />
</DirectoryRef>
<Feature Id="{{ msm.name }}" Title="{{ msm.name }}" AllowAdvertise="no" Display="hidden" Level="1">
<MergeRef Id="{{ msm.name }}"/>
</Feature>
{{/each~}}
<Feature
Id="MainProgram"
Title="Application"
Description="!(loc.InstallAppFeature)"
Level="1"
ConfigurableDirectory="INSTALLDIR"
AllowAdvertise="no"
Display="expand"
Absent="disallow">
<ComponentRef Id="RegistryEntries"/>
{{#each resource_file_ids as |resource_file_id| ~}}
<ComponentRef Id="{{ resource_file_id }}"/>
{{/each~}}
{{#if enable_elevated_update_task}}
<ComponentRef Id="UpdateTask" />
<ComponentRef Id="UpdateTaskInstaller" />
<ComponentRef Id="UpdateTaskUninstaller" />
{{/if}}
<Feature Id="ShortcutsFeature"
Title="Shortcuts"
Level="1">
<ComponentRef Id="Path"/>
<ComponentRef Id="CMP_UninstallShortcut" />
<ComponentRef Id="ApplicationShortcut" />
<ComponentRef Id="ApplicationShortcutDesktop" />
</Feature>
<Feature
Id="Environment"
Title="PATH Environment Variable"
Description="!(loc.PathEnvVarFeature)"
Level="1"
Absent="allow">
<ComponentRef Id="Path"/>
{{#each binaries as |bin| ~}}
<ComponentRef Id="{{ bin.id }}"/>
{{/each~}}
</Feature>
</Feature>
<Feature Id="External" AllowAdvertise="no" Absent="disallow">
{{#each component_group_refs as |id| ~}}
<ComponentGroupRef Id="{{ id }}"/>
{{/each~}}
{{#each component_refs as |id| ~}}
<ComponentRef Id="{{ id }}"/>
{{/each~}}
{{#each feature_group_refs as |id| ~}}
<FeatureGroupRef Id="{{ id }}"/>
{{/each~}}
{{#each feature_refs as |id| ~}}
<FeatureRef Id="{{ id }}"/>
{{/each~}}
{{#each merge_refs as |id| ~}}
<MergeRef Id="{{ id }}"/>
{{/each~}}
</Feature>
{{#if install_webview}}
<!-- WebView2 -->
<Property Id="WVRTINSTALLED">
<RegistrySearch Id="WVRTInstalledSystem" Root="HKLM" Key="SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" Name="pv" Type="raw" Win64="no" />
<RegistrySearch Id="WVRTInstalledUser" Root="HKCU" Key="SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" Name="pv" Type="raw"/>
</Property>
{{#if download_bootstrapper}}
<CustomAction Id='DownloadAndInvokeBootstrapper' Directory="INSTALLDIR" Execute="deferred" ExeCommand='powershell.exe -NoProfile -windowstyle hidden try [\{] [\[]Net.ServicePointManager[\]]::SecurityProtocol = [\[]Net.SecurityProtocolType[\]]::Tls12 [\}] catch [\{][\}]; Invoke-WebRequest -Uri "https://go.microsoft.com/fwlink/p/?LinkId=2124703" -OutFile "$env:TEMP\MicrosoftEdgeWebview2Setup.exe" ; Start-Process -FilePath "$env:TEMP\MicrosoftEdgeWebview2Setup.exe" -ArgumentList ({{webview_installer_args}} &apos;/install&apos;) -Wait' Return='check'/>
<InstallExecuteSequence>
<Custom Action='DownloadAndInvokeBootstrapper' Before='InstallFinalize'>
<![CDATA[NOT(REMOVE OR WVRTINSTALLED)]]>
</Custom>
</InstallExecuteSequence>
{{/if}}
<!-- Embedded webview bootstrapper mode -->
{{#if webview2_bootstrapper_path}}
<Binary Id="MicrosoftEdgeWebview2Setup.exe" SourceFile="{{webview2_bootstrapper_path}}"/>
<CustomAction Id='InvokeBootstrapper' BinaryKey='MicrosoftEdgeWebview2Setup.exe' Execute="deferred" ExeCommand='{{webview_installer_args}} /install' Return='check' />
<InstallExecuteSequence>
<Custom Action='InvokeBootstrapper' Before='InstallFinalize'>
<![CDATA[NOT(REMOVE OR WVRTINSTALLED)]]>
</Custom>
</InstallExecuteSequence>
{{/if}}
<!-- Embedded offline installer -->
{{#if webview2_installer_path}}
<Binary Id="MicrosoftEdgeWebView2RuntimeInstaller.exe" SourceFile="{{webview2_installer_path}}"/>
<CustomAction Id='InvokeStandalone' BinaryKey='MicrosoftEdgeWebView2RuntimeInstaller.exe' Execute="deferred" ExeCommand='{{webview_installer_args}} /install' Return='check' />
<InstallExecuteSequence>
<Custom Action='InvokeStandalone' Before='InstallFinalize'>
<![CDATA[NOT(REMOVE OR WVRTINSTALLED)]]>
</Custom>
</InstallExecuteSequence>
{{/if}}
{{/if}}
{{#if enable_elevated_update_task}}
<!-- Install an elevated update task within Windows Task Scheduler -->
<CustomAction
Id="CreateUpdateTask"
Return="check"
Directory="INSTALLDIR"
Execute="commit"
Impersonate="yes"
ExeCommand="powershell.exe -WindowStyle hidden .\install-task.ps1" />
<InstallExecuteSequence>
<Custom Action='CreateUpdateTask' Before='InstallFinalize'>
NOT(REMOVE)
</Custom>
</InstallExecuteSequence>
<!-- Remove elevated update task during uninstall -->
<CustomAction
Id="DeleteUpdateTask"
Return="check"
Directory="INSTALLDIR"
ExeCommand="powershell.exe -WindowStyle hidden .\uninstall-task.ps1" />
<InstallExecuteSequence>
<Custom Action="DeleteUpdateTask" Before='InstallFinalize'>
(REMOVE = "ALL") AND NOT UPGRADINGPRODUCTCODE
</Custom>
</InstallExecuteSequence>
{{/if}}
<InstallExecuteSequence>
<Custom Action="LaunchApplication" After="InstallFinalize">AUTOLAUNCHAPP AND NOT Installed</Custom>
</InstallExecuteSequence>
<SetProperty Id="ARPINSTALLLOCATION" Value="[INSTALLDIR]" After="CostFinalize"/>
</Product>
</Wix>

View File

@@ -1,242 +0,0 @@
.app {
height: 100vh;
display: flex;
flex-direction: column;
}
.app-header {
background: linear-gradient(180deg, #3498db 0%, #2d89c7 100%);
color: white;
padding: 0.35rem 2rem 0.45rem;
display: grid;
grid-template-columns: 1fr auto 1fr;
grid-template-rows: auto auto;
grid-template-areas:
". title ."
"tabs . actions";
align-items: center;
row-gap: 0.6rem;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
user-select: none;
}
.app-tabs {
grid-area: tabs;
}
/* Segmented control */
.segmented {
--seg-bg: rgba(255, 255, 255, 0.16);
--seg-thumb: #ffffff;
--seg-color: rgba(255, 255, 255, 0.85);
--seg-active: #2d89c7;
position: relative;
display: grid;
grid-template-columns: 1fr 1fr;
width: 280px;
background: var(--seg-bg);
border-radius: 999px;
padding: 4px;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.15);
backdrop-filter: saturate(140%) blur(2px);
}
.segmented-thumb {
position: absolute;
top: 4px;
left: 4px;
width: calc(50% - 4px);
height: calc(100% - 8px);
background: var(--seg-thumb);
border-radius: 999px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
transition:
transform 220ms ease,
width 220ms ease;
will-change: transform;
}
.segmented-item {
position: relative;
z-index: 1;
background: transparent;
border: none;
border-radius: 999px;
padding: 6px 16px; /* 更紧凑的高度 */
color: var(--seg-color);
font-size: 0.95rem;
font-weight: 600;
letter-spacing: 0.2px;
cursor: pointer;
transition: color 200ms ease;
}
.segmented-item.active {
color: var(--seg-active);
}
.segmented-item:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.8);
outline-offset: 2px;
}
.app-header h1 {
font-size: 1.5rem;
font-weight: 500;
margin: 0;
grid-area: title;
text-align: center;
}
.header-actions {
display: flex;
gap: 1rem;
grid-area: actions;
justify-self: end;
}
.refresh-btn,
.add-btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}
.refresh-btn {
background: #3498db;
color: white;
}
.refresh-btn:hover:not(:disabled) {
background: #2980b9;
}
.refresh-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.import-btn {
background: rgba(255, 255, 255, 0.2);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
}
.import-btn:hover {
background: rgba(255, 255, 255, 0.3);
}
.import-btn:focus {
outline: none;
}
.add-btn {
background: #27ae60;
color: white;
border: none;
}
.add-btn:hover {
background: #229954;
}
.add-btn:focus {
outline: none;
}
.app-main {
flex: 1;
padding: 2rem;
overflow-y: auto;
}
.config-path {
margin-top: 2rem;
padding: 1rem;
background: #ecf0f1;
border-radius: 4px;
font-size: 0.9rem;
color: #7f8c8d;
display: flex;
justify-content: space-between;
align-items: center;
}
.browse-btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
background: #3498db;
color: white;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
margin-left: 1rem;
}
.browse-btn:hover {
background: #2980b9;
}
/* 供应商列表区域 - 相对定位容器 */
.provider-section {
position: relative;
}
/* 浮动通知 - 绝对定位,不占据空间 */
.notification-floating {
position: absolute;
top: -10px;
left: 50%;
transform: translateX(-50%);
z-index: 100;
padding: 0.75rem 1.25rem;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
width: fit-content;
white-space: nowrap;
}
.fade-in {
animation: fadeIn 0.3s ease-out;
}
.fade-out {
animation: fadeOut 0.3s ease-out;
}
.notification-success {
background: linear-gradient(135deg, #27ae60 0%, #2ecc71 100%);
color: white;
box-shadow: 0 4px 12px rgba(39, 174, 96, 0.3);
}
.notification-error {
background: linear-gradient(135deg, #e74c3c 0%, #ec7063 100%);
color: white;
box-shadow: 0 4px 12px rgba(231, 76, 60, 0.3);
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Provider } from "./types";
import { AppType } from "./lib/tauri-api";
import ProviderList from "./components/ProviderList";
@@ -6,17 +7,21 @@ import AddProviderModal from "./components/AddProviderModal";
import EditProviderModal from "./components/EditProviderModal";
import { ConfirmDialog } from "./components/ConfirmDialog";
import { AppSwitcher } from "./components/AppSwitcher";
import "./App.css";
import SettingsModal from "./components/SettingsModal";
import { UpdateBadge } from "./components/UpdateBadge";
import { Plus, Settings, Moon, Sun } from "lucide-react";
import McpPanel from "./components/mcp/McpPanel";
import { buttonStyles } from "./lib/styles";
import { useDarkMode } from "./hooks/useDarkMode";
import { extractErrorMessage } from "./utils/errorUtils";
function App() {
const { t } = useTranslation();
const { isDarkMode, toggleDarkMode } = useDarkMode();
const [activeApp, setActiveApp] = useState<AppType>("claude");
const [providers, setProviders] = useState<Record<string, Provider>>({});
const [currentProviderId, setCurrentProviderId] = useState<string>("");
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [configStatus, setConfigStatus] = useState<{
exists: boolean;
path: string;
} | null>(null);
const [editingProviderId, setEditingProviderId] = useState<string | null>(
null,
);
@@ -31,6 +36,8 @@ function App() {
message: string;
onConfirm: () => void;
} | null>(null);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isMcpOpen, setIsMcpOpen] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 设置通知的辅助函数
@@ -62,7 +69,6 @@ function App() {
// 加载供应商列表
useEffect(() => {
loadProviders();
loadConfigStatus();
}, [activeApp]); // 当切换应用时重新加载
// 清理定时器
@@ -74,6 +80,42 @@ function App() {
};
}, []);
// 监听托盘切换事件(包括菜单切换)
useEffect(() => {
let unlisten: (() => void) | null = null;
const setupListener = async () => {
try {
unlisten = await window.api.onProviderSwitched(async (data) => {
if (import.meta.env.DEV) {
console.log(t("console.providerSwitchReceived"), data);
}
// 如果当前应用类型匹配,则重新加载数据
if (data.appType === activeApp) {
await loadProviders();
}
// 若为 Claude则同步插件配置
if (data.appType === "claude") {
await syncClaudePlugin(data.providerId, true);
}
});
} catch (error) {
console.error(t("console.setupListenerFailed"), error);
}
};
setupListener();
// 清理监听器
return () => {
if (unlisten) {
unlisten();
}
};
}, [activeApp]);
const loadProviders = async () => {
const loadedProviders = await window.api.getProviders(activeApp);
const currentId = await window.api.getCurrentProvider(activeApp);
@@ -86,14 +128,6 @@ function App() {
}
};
const loadConfigStatus = async () => {
const status = await window.api.getConfigStatus(activeApp);
setConfigStatus({
exists: Boolean(status?.exists),
path: String(status?.path || ""),
});
};
// 生成唯一ID
const generateId = () => {
return crypto.randomUUID();
@@ -103,10 +137,13 @@ function App() {
const newProvider: Provider = {
...provider,
id: generateId(),
createdAt: Date.now(), // 添加创建时间戳
};
await window.api.addProvider(newProvider, activeApp);
await loadProviders();
setIsAddModalOpen(false);
// 更新托盘菜单
await window.api.updateTrayMenu();
};
const handleEditProvider = async (provider: Provider) => {
@@ -115,11 +152,17 @@ function App() {
await loadProviders();
setEditingProviderId(null);
// 显示编辑成功提示
showNotification("供应商配置已保存", "success", 2000);
showNotification(t("notifications.providerSaved"), "success", 2000);
// 更新托盘菜单
await window.api.updateTrayMenu();
} catch (error) {
console.error("更新供应商失败:", error);
console.error(t("console.updateProviderFailed"), error);
setEditingProviderId(null);
showNotification("保存失败,请重试", "error");
const errorMessage = extractErrorMessage(error);
const message = errorMessage
? t("notifications.saveFailed", { error: errorMessage })
: t("notifications.saveFailedGeneric");
showNotification(message, "error", errorMessage ? 6000 : 3000);
}
};
@@ -127,30 +170,87 @@ function App() {
const provider = providers[id];
setConfirmDialog({
isOpen: true,
title: "删除供应商",
message: `确定要删除供应商 "${provider?.name}" 吗?此操作无法撤销。`,
title: t("confirm.deleteProvider"),
message: t("confirm.deleteProviderMessage", { name: provider?.name }),
onConfirm: async () => {
await window.api.deleteProvider(id, activeApp);
await loadProviders();
setConfirmDialog(null);
showNotification("供应商删除成功", "success");
showNotification(t("notifications.providerDeleted"), "success");
// 更新托盘菜单
await window.api.updateTrayMenu();
},
});
};
// 同步 Claude 插件配置(按设置决定是否联动;开启时:非官方写入,官方移除)
const syncClaudePlugin = async (providerId: string, silent = false) => {
try {
const settings = await window.api.getSettings();
if (!(settings as any)?.enableClaudePluginIntegration) {
// 未开启联动:不执行写入/移除
return;
}
const provider = providers[providerId];
if (!provider) return;
const isOfficial = provider.category === "official";
await window.api.applyClaudePluginConfig({ official: isOfficial });
if (!silent) {
showNotification(
isOfficial
? t("notifications.removedFromClaudePlugin")
: t("notifications.appliedToClaudePlugin"),
"success",
2000,
);
}
} catch (error: any) {
console.error("同步 Claude 插件失败:", error);
if (!silent) {
const message =
error?.message || t("notifications.syncClaudePluginFailed");
showNotification(message, "error", 5000);
}
}
};
const handleSwitchProvider = async (id: string) => {
const success = await window.api.switchProvider(id, activeApp);
if (success) {
setCurrentProviderId(id);
// 显示重启提示
const appName = activeApp === "claude" ? "Claude Code" : "Codex";
showNotification(
`切换成功!请重启 ${appName} 终端以生效`,
"success",
2000,
);
} else {
showNotification("切换失败,请检查配置", "error");
try {
const success = await window.api.switchProvider(id, activeApp);
if (success) {
setCurrentProviderId(id);
// 显示重启提示
const appName = t(`apps.${activeApp}`);
showNotification(
t("notifications.switchSuccess", { appName }),
"success",
2000,
);
// 更新托盘菜单
await window.api.updateTrayMenu();
if (activeApp === "claude") {
await syncClaudePlugin(id, true);
}
} else {
showNotification(t("notifications.switchFailed"), "error");
}
} catch (error) {
const detail = extractErrorMessage(error);
const msg = detail
? `${t("notifications.switchFailed")}: ${detail}`
: t("notifications.switchFailed");
// 详细错误展示稍长时间,便于用户阅读
showNotification(msg, "error", detail ? 6000 : 3000);
}
};
const handleImportSuccess = async () => {
await loadProviders();
try {
await window.api.updateTrayMenu();
} catch (error) {
console.error("[App] Failed to refresh tray menu after import", error);
}
};
@@ -161,72 +261,103 @@ function App() {
if (result.success) {
await loadProviders();
showNotification("已从现有配置创建默认供应商", "success", 3000);
showNotification(t("notifications.autoImported"), "success", 3000);
// 更新托盘菜单
await window.api.updateTrayMenu();
}
// 如果导入失败(比如没有现有配置),静默处理,不显示错误
} catch (error) {
console.error("自动导入默认配置失败:", error);
console.error(t("console.autoImportFailed"), error);
// 静默处理,不影响用户体验
}
};
const handleOpenConfigFolder = async () => {
await window.api.openConfigFolder(activeApp);
};
return (
<div className="app">
<header className="app-header">
<h1>CC Switch</h1>
<div className="app-tabs">
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
</div>
<div className="header-actions">
<button className="add-btn" onClick={() => setIsAddModalOpen(true)}>
</button>
<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">
<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={t("header.viewOnGithub")}
>
CC Switch
</a>
<button
onClick={toggleDarkMode}
className={buttonStyles.icon}
title={
isDarkMode
? t("header.toggleLightMode")
: t("header.toggleDarkMode")
}
>
{isDarkMode ? <Sun size={18} /> : <Moon size={18} />}
</button>
<div className="flex items-center gap-2">
<button
onClick={() => setIsSettingsOpen(true)}
className={buttonStyles.icon}
title={t("common.settings")}
>
<Settings size={18} />
</button>
<UpdateBadge onClick={() => setIsSettingsOpen(true)} />
</div>
</div>
<div className="flex items-center gap-4">
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
<button
onClick={() => setIsMcpOpen(true)}
className="inline-flex items-center gap-2 px-7 py-2 text-sm font-medium rounded-lg transition-colors bg-emerald-500 text-white hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700"
>
MCP
</button>
<button
onClick={() => setIsAddModalOpen(true)}
className={`inline-flex items-center gap-2 ${buttonStyles.primary}`}
>
<Plus size={16} />
{t("header.addProvider")}
</button>
</div>
</div>
</header>
<main className="app-main">
<div className="provider-section">
{/* 浮动通知组件 */}
{notification && (
<div
className={`notification-floating ${
notification.type === "error"
? "notification-error"
: "notification-success"
} ${isNotificationVisible ? "fade-in" : "fade-out"}`}
>
{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-[80] 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}
/>
</div>
{configStatus && (
<div className="config-path">
<span>
: {configStatus.path}
{!configStatus.exists ? "(未创建,切换或保存时会自动创建)" : ""}
</span>
<button
className="browse-btn"
onClick={handleOpenConfigFolder}
title="打开配置文件夹"
>
</button>
<ProviderList
providers={providers}
currentProviderId={currentProviderId}
onSwitch={handleSwitchProvider}
onDelete={handleDeleteProvider}
onEdit={setEditingProviderId}
onNotify={showNotification}
/>
</div>
)}
</div>
</main>
{isAddModalOpen && (
@@ -255,6 +386,22 @@ function App() {
onCancel={() => setConfirmDialog(null)}
/>
)}
{isSettingsOpen && (
<SettingsModal
onClose={() => setIsSettingsOpen(false)}
onImportSuccess={handleImportSuccess}
onNotify={showNotification}
/>
)}
{isMcpOpen && (
<McpPanel
appType={activeApp}
onClose={() => setIsMcpOpen(false)}
onNotify={showNotification}
/>
)}
</div>
);
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1757750114641" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1475" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M202.112 678.656l200.64-112.64 3.392-9.792-3.392-5.44h-9.792l-33.6-2.048-114.624-3.072-99.456-4.224-96.384-5.12-24.192-5.12-22.72-29.952 2.304-14.976 20.48-13.696 29.12 2.56 64.576 4.416 96.832 6.72 70.208 4.096 104.064 10.88h16.576l2.304-6.72-5.696-4.16-4.352-4.096-100.224-67.968-108.48-71.744-56.768-41.344-30.72-20.928-15.488-19.584-6.72-42.88 27.84-30.72 37.504 2.56 9.536 2.56 37.952 29.184 81.088 62.784 105.856 77.952 15.488 12.928 6.208-4.352 0.768-3.136L395.264 360l-57.6-104.064-61.44-105.92-27.392-43.904-7.168-26.304c-2.56-10.88-4.48-19.904-4.48-30.976l31.808-43.136L286.592 0l42.304 5.696 17.856 15.488 26.304 60.16 42.624 94.72 66.112 128.896 19.392 38.208 10.24 35.392 3.904 10.88h6.72v-6.208l5.44-72.576 10.048-89.088 9.856-114.688 3.328-32.256 16-38.72 31.808-20.928 24.768 11.904 20.416 29.184-2.88 18.816-12.16 78.72-23.68 123.52-15.552 82.56h9.088l10.304-10.24 41.856-55.552 70.208-87.808 30.976-34.88 36.16-38.464 23.232-18.368h43.904l32.32 48.064-14.464 49.6-45.184 57.28-37.44 48.576-53.76 72.32-33.536 57.856 3.072 4.608 8-0.768 121.408-25.792 65.6-11.904 78.208-13.44 35.392 16.512 3.84 16.832-13.952 34.304-83.648 20.672-98.112 19.648-146.176 34.56-1.792 1.28 2.048 2.56 65.92 6.272 28.096 1.536h68.928l128.384 9.6 33.536 22.144 20.16 27.136-3.392 20.672-51.648 26.304-69.696-16.512-162.688-38.72-55.744-13.952h-7.744v4.672l46.464 45.44 85.184 76.928 106.688 99.2 5.376 24.512-13.632 19.328-14.464-2.048-93.76-70.464-36.16-31.808-81.856-68.928h-5.44v7.232l18.88 27.648 99.648 149.76 5.184 45.952-7.232 14.976-25.856 9.024-28.352-5.12L673.408 856l-60.16-92.16-48.576-82.624-5.952 3.392-28.672 308.544-13.44 15.744-30.976 11.904-25.792-19.648-13.696-31.744 13.696-62.72 16.512-81.92 13.44-65.024 12.16-80.832 7.232-26.88-0.512-1.792-5.952 0.768-60.928 83.648-92.736 125.248-73.344 78.528-17.536 6.976-30.464-15.808 2.816-28.16 17.024-24.96 101.504-129.152 61.184-80 39.552-46.272-0.256-6.72h-2.368L177.6 789.44l-48 6.144-20.736-19.328 2.56-31.744 9.856-10.368 81.088-55.744-0.256 0.256z" p-id="1476" fill="#bfbfbf"></path></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -1,268 +0,0 @@
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: white;
border-radius: 10px;
padding: 0;
width: 90%;
max-width: 640px;
max-height: 90vh;
overflow: hidden; /* 由 body 滚动,标题栏固定 */
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
position: relative;
z-index: 1001;
display: flex; /* 纵向布局,便于底栏固定 */
flex-direction: column;
}
/* 模拟窗口标题栏 */
.modal-titlebar {
display: flex;
align-items: center;
justify-content: space-between;
height: 3rem; /* 与主窗口标题栏一致 */
padding: 0 12px; /* 接近主头部的水平留白 */
background: #3498db; /* 与 .app-header 相同 */
color: #fff;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
}
/* 左侧占位以保证标题居中(与右侧关闭按钮宽度相当) */
.modal-spacer {
width: 32px;
flex: 0 0 32px;
}
.modal-title {
flex: 1;
text-align: center;
color: #fff;
font-weight: 600;
font-size: 1rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.modal-close-btn {
background: transparent;
border: none;
color: #fff;
font-size: 20px;
line-height: 1;
padding: 2px 6px;
border-radius: 6px;
cursor: pointer;
}
.modal-close-btn:hover {
background: rgba(255, 255, 255, 0.18);
color: #fff;
}
.modal-form {
/* 表单外层包裹 body + footer */
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0; /* 允许子元素正确计算高度 */
}
.modal-body {
padding: 1.25rem 1.5rem 1.5rem;
overflow: auto; /* 仅内容区滚动 */
flex: 1 1 auto;
min-height: 0;
}
.error-message {
background: #fee;
color: #c33;
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 1rem;
border: 1px solid #fcc;
font-size: 0.9rem;
}
.presets {
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid #ecf0f1;
}
.presets label {
display: block;
margin-bottom: 0.5rem;
color: #555;
font-size: 0.9rem;
}
.preset-buttons {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.preset-btn {
padding: 0.375rem 0.75rem;
border: 1px solid #3498db;
background: white;
color: #3498db;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.preset-btn:hover,
.preset-btn.selected {
background: #3498db;
color: white;
}
/* 官方按钮橙色主题Anthropic 风格) */
.preset-btn.official {
border: 1px solid #d97706;
color: #d97706;
}
.preset-btn.official:hover,
.preset-btn.official.selected {
background: #d97706;
color: white;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #555;
font-weight: 500;
}
/* API Key 输入框容器 - 预留空间避免抖动 */
.form-group.api-key-group {
min-height: 88px; /* 固定高度label + input + 间距 */
transition: opacity 0.2s ease;
}
.form-group.api-key-group.hidden {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 0.625rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.95rem;
transition: border-color 0.2s;
background: white;
box-sizing: border-box;
}
.form-group textarea {
resize: vertical;
min-height: 200px;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: #3498db;
}
.modal-footer {
/* 固定在弹窗底部(非滚动区) */
display: flex;
gap: 1rem;
justify-content: flex-end;
padding: 0.75rem 1.5rem;
border-top: 1px solid #ecf0f1;
background: #fff;
}
.cancel-btn,
.submit-btn {
padding: 0.625rem 1.25rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.95rem;
transition: all 0.2s;
}
.cancel-btn {
background: #ecf0f1;
color: #555;
}
.cancel-btn:hover {
background: #bdc3c7;
}
.submit-btn {
background: #27ae60;
color: white;
}
.submit-btn:hover {
background: #229954;
}
.field-hint {
display: block;
margin-top: 0.25rem;
color: #7f8c8d;
font-size: 0.8rem;
line-height: 1.3;
}
/* 添加标签和选择框的样式 */
.label-with-checkbox {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.5rem;
}
.label-with-checkbox label:first-child {
margin-bottom: 0;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.85rem;
color: #666;
font-weight: normal;
margin-bottom: 0;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
width: auto;
margin: 2px;
cursor: pointer;
transform: translateY(2px);
}

View File

@@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Provider } from "../types";
import { AppType } from "../lib/tauri-api";
import ProviderForm from "./ProviderForm";
@@ -14,11 +15,18 @@ const AddProviderModal: React.FC<AddProviderModalProps> = ({
onAdd,
onClose,
}) => {
const { t } = useTranslation();
const title =
appType === "claude"
? t("provider.addClaudeProvider")
: t("provider.addCodexProvider");
return (
<ProviderForm
appType={appType}
title="添加新供应商"
submitText="添加"
title={title}
submitText={t("common.add")}
showPresets={true}
onSubmit={onAdd}
onClose={onClose}

View File

@@ -1,73 +0,0 @@
/* 药丸式切换按钮 */
.switcher-pills {
display: inline-flex;
align-items: center;
gap: 12px;
background: rgba(255, 255, 255, 0.08);
padding: 6px 8px;
border-radius: 50px;
backdrop-filter: blur(10px);
}
.switcher-pill {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 16px;
background: transparent;
border: none;
border-radius: 50px;
color: rgba(255, 255, 255, 0.6);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 200ms ease;
min-width: 120px;
}
.switcher-pill:hover:not(.active) {
color: rgba(255, 255, 255, 0.8);
background: rgba(255, 255, 255, 0.05);
}
.switcher-pill.active {
background: rgba(255, 255, 255, 0.15);
color: white;
box-shadow:
inset 0 1px 3px rgba(0, 0, 0, 0.1),
0 1px 0 rgba(255, 255, 255, 0.1);
}
.pill-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
opacity: 0.4;
transition: all 200ms ease;
}
.switcher-pill.active .pill-dot {
opacity: 1;
box-shadow: 0 0 8px currentColor;
animation: pulse 2s infinite;
}
.pills-divider {
width: 1px;
height: 20px;
background: rgba(255, 255, 255, 0.2);
}
@keyframes pulse {
0%,
100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.2);
opacity: 0.8;
}
}

View File

@@ -1,5 +1,5 @@
import { AppType } from "../lib/tauri-api";
import "./AppSwitcher.css";
import { ClaudeIcon, CodexIcon } from "./BrandIcons";
interface AppSwitcherProps {
activeApp: AppType;
@@ -13,22 +13,37 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
};
return (
<div className="switcher-pills">
<div className="inline-flex bg-gray-100 dark:bg-gray-800 rounded-lg p-1 gap-1 border border-transparent dark:border-gray-700">
<button
type="button"
className={`switcher-pill ${activeApp === "claude" ? "active" : ""}`}
onClick={() => handleSwitch("claude")}
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"
}`}
>
<span className="pill-dot" />
<span>Claude Code</span>
<ClaudeIcon
size={16}
className={
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>
</button>
<div className="pills-divider" />
<button
type="button"
className={`switcher-pill ${activeApp === "codex" ? "active" : ""}`}
onClick={() => handleSwitch("codex")}
className={`inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
activeApp === "codex"
? "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"
}`}
>
<span className="pill-dot" />
<CodexIcon size={16} />
<span>Codex</span>
</button>
</div>

File diff suppressed because one or more lines are too long

View File

@@ -1,107 +0,0 @@
.confirm-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(2px);
}
.confirm-dialog {
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
min-width: 300px;
max-width: 400px;
animation: confirmSlideIn 0.2s ease-out;
}
@keyframes confirmSlideIn {
from {
opacity: 0;
transform: scale(0.9) translateY(-20px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.confirm-header {
padding: 1.5rem 1.5rem 1rem;
border-bottom: 1px solid #eee;
}
.confirm-header h3 {
margin: 0;
font-size: 1.1rem;
color: #333;
font-weight: 600;
}
.confirm-content {
padding: 1rem 1.5rem;
}
.confirm-content p {
margin: 0;
color: #666;
line-height: 1.5;
}
.confirm-actions {
display: flex;
gap: 0.75rem;
padding: 1rem 1.5rem 1.5rem;
justify-content: flex-end;
}
.confirm-btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
transition:
background-color 0.2s,
transform 0.1s;
min-width: 70px;
}
.confirm-btn:hover {
transform: translateY(-1px);
}
.confirm-btn:active {
transform: translateY(0);
}
.cancel-btn {
background: #f8f9fa;
color: #6c757d;
border: 1px solid #dee2e6;
}
.cancel-btn:hover {
background: #e9ecef;
}
.confirm-btn-primary {
background: #dc3545;
color: white;
}
.confirm-btn-primary:hover {
background: #c82333;
}
.confirm-btn:focus {
outline: 2px solid #007bff;
outline-offset: 2px;
}

View File

@@ -1,5 +1,7 @@
import React from "react";
import "./ConfirmDialog.css";
import { useTranslation } from "react-i18next";
import { AlertTriangle, X } from "lucide-react";
import { isLinux } from "../lib/platform";
interface ConfirmDialogProps {
isOpen: boolean;
@@ -15,35 +17,64 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
isOpen,
title,
message,
confirmText = "确定",
cancelText = "取消",
confirmText,
cancelText,
onConfirm,
onCancel,
}) => {
const { t } = useTranslation();
if (!isOpen) return null;
return (
<div className="confirm-overlay">
<div className="confirm-dialog">
<div className="confirm-header">
<h3>{title}</h3>
</div>
<div className="confirm-content">
<p>{message}</p>
</div>
<div className="confirm-actions">
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className={`absolute inset-0 bg-black/50${isLinux() ? "" : " backdrop-blur-sm"}`}
onClick={onCancel}
/>
{/* Dialog */}
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg max-w-md w-full mx-4 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-red-100 dark:bg-red-500/10 rounded-full flex items-center justify-center">
<AlertTriangle size={20} className="text-red-500" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{title}
</h3>
</div>
<button
className="confirm-btn cancel-btn"
onClick={onCancel}
className="p-1 text-gray-500 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
>
<X size={18} />
</button>
</div>
{/* Content */}
<div className="p-6">
<p className="text-gray-500 dark:text-gray-400 leading-relaxed">
{message}
</p>
</div>
{/* Actions */}
<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-900">
<button
onClick={onCancel}
className="px-4 py-2 text-sm font-medium text-gray-500 hover:text-gray-900 hover:bg-white dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
autoFocus
>
{cancelText}
{cancelText || t("common.cancel")}
</button>
<button
className="confirm-btn confirm-btn-primary"
onClick={onConfirm}
className="px-4 py-2 text-sm font-medium bg-red-500 text-white hover:bg-red-500/90 rounded-md transition-colors"
>
{confirmText}
{confirmText || t("common.confirm")}
</button>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import React from "react";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Provider } from "../types";
import { AppType } from "../lib/tauri-api";
import ProviderForm from "./ProviderForm";
@@ -16,6 +17,34 @@ const EditProviderModal: React.FC<EditProviderModalProps> = ({
onSave,
onClose,
}) => {
const { t } = useTranslation();
const [effectiveProvider, setEffectiveProvider] =
useState<Provider>(provider);
// 若为当前应用且正在编辑“当前供应商”,则优先读取 live 配置作为初始值Claude/Codex 均适用)
useEffect(() => {
let mounted = true;
const maybeLoadLive = async () => {
try {
const currentId = await window.api.getCurrentProvider(appType);
if (currentId && currentId === provider.id) {
const live = await window.api.getLiveProviderSettings(appType);
if (!mounted) return;
setEffectiveProvider({ ...provider, settingsConfig: live });
} else {
setEffectiveProvider(provider);
}
} catch (e) {
// 读取失败则回退到原 provider
setEffectiveProvider(provider);
}
};
maybeLoadLive();
return () => {
mounted = false;
};
}, [appType, provider]);
const handleSubmit = (data: Omit<Provider, "id">) => {
onSave({
...provider,
@@ -23,12 +52,17 @@ const EditProviderModal: React.FC<EditProviderModalProps> = ({
});
};
const title =
appType === "claude"
? t("provider.editClaudeProvider")
: t("provider.editCodexProvider");
return (
<ProviderForm
appType={appType}
title="编辑供应商"
submitText="保存"
initialData={provider}
title={title}
submitText={t("common.save")}
initialData={effectiveProvider}
showPresets={false}
onSubmit={handleSubmit}
onClose={onClose}

View File

@@ -0,0 +1,107 @@
import { useEffect } from "react";
import { CheckCircle, Loader2, AlertCircle } from "lucide-react";
import { useTranslation } from "react-i18next";
interface ImportProgressModalProps {
status: "importing" | "success" | "error";
message?: string;
backupId?: string;
onComplete?: () => void;
onSuccess?: () => void;
}
export function ImportProgressModal({
status,
message,
backupId,
onComplete,
onSuccess,
}: ImportProgressModalProps) {
const { t } = useTranslation();
useEffect(() => {
if (status === "success") {
console.log(
"[ImportProgressModal] Success detected, starting 2 second countdown",
);
// 成功后等待2秒自动关闭并刷新数据
const timer = setTimeout(() => {
console.log(
"[ImportProgressModal] 2 seconds elapsed, calling callbacks...",
);
if (onSuccess) {
onSuccess();
}
if (onComplete) {
onComplete();
}
}, 2000);
return () => {
console.log("[ImportProgressModal] Cleanup timer");
clearTimeout(timer);
};
}
}, [status, onComplete, onSuccess]);
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center">
<div className="absolute inset-0 bg-black/50 dark:bg-black/70 backdrop-blur-sm" />
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-2xl p-8 max-w-md w-full mx-4">
<div className="flex flex-col items-center text-center">
{status === "importing" && (
<>
<Loader2 className="w-12 h-12 text-blue-500 animate-spin mb-4" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
{t("settings.importing")}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
{t("common.loading")}
</p>
</>
)}
{status === "success" && (
<>
<CheckCircle className="w-12 h-12 text-green-500 mb-4" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
{t("settings.importSuccess")}
</h3>
{backupId && (
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
{t("settings.backupId")}: {backupId}
</p>
)}
<p className="text-sm text-gray-600 dark:text-gray-400">
{t("settings.autoReload")}
</p>
</>
)}
{status === "error" && (
<>
<AlertCircle className="w-12 h-12 text-red-500 mb-4" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
{t("settings.importFailed")}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
{message || t("settings.configCorrupted")}
</p>
<button
onClick={() => {
if (onComplete) {
onComplete();
}
}}
className="mt-4 px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-colors"
>
{t("common.close")}
</button>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,143 @@
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";
import { useTranslation } from "react-i18next";
interface JsonEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
darkMode?: boolean;
rows?: number;
showValidation?: boolean;
}
const JsonEditor: React.FC<JsonEditorProps> = ({
value,
onChange,
placeholder: placeholderText = "",
darkMode = false,
rows = 12,
showValidation = true,
}) => {
const { t } = useTranslation();
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: t("jsonEditor.mustBeObject"),
});
}
} catch (e) {
// 简单处理JSON解析错误
const message =
e instanceof SyntaxError ? e.message : t("jsonEditor.invalidJson");
diagnostics.push({
from: 0,
to: doc.length,
severity: "error",
message,
});
}
return diagnostics;
}),
[showValidation, t],
);
useEffect(() => {
if (!editorRef.current) return;
// 创建编辑器扩展
const minHeightPx = Math.max(1, rows) * 18; // 降低最小高度以减少抖动
const sizingTheme = EditorView.theme({
"&": { minHeight: `${minHeightPx}px` },
".cm-scroller": { overflow: "auto" },
".cm-content": {
fontFamily:
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
fontSize: "14px",
},
});
const extensions = [
basicSetup,
json(),
placeholder(placeholderText || ""),
sizingTheme,
jsonLinter,
EditorView.updateListener.of((update) => {
if (update.docChanged) {
const newValue = update.state.doc.toString();
onChange(newValue);
}
}),
];
// 如果启用深色模式,添加深色主题
if (darkMode) {
extensions.push(oneDark);
}
// 创建初始状态
const state = EditorState.create({
doc: value,
extensions,
});
// 创建编辑器视图
const view = new EditorView({
state,
parent: editorRef.current,
});
viewRef.current = view;
// 清理函数
return () => {
view.destroy();
viewRef.current = null;
};
}, [darkMode, rows, jsonLinter]); // 依赖项中不包含 onChange 和 placeholder避免不必要的重建
// 当 value 从外部改变时更新编辑器内容
useEffect(() => {
if (viewRef.current && viewRef.current.state.doc.toString() !== value) {
const transaction = viewRef.current.state.update({
changes: {
from: 0,
to: viewRef.current.state.doc.length,
insert: value,
},
});
viewRef.current.dispatch(transaction);
}
}, [value]);
return <div ref={editorRef} style={{ width: "100%" }} />;
};
export default JsonEditor;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,72 @@
import React, { useState } from "react";
import { Eye, EyeOff } from "lucide-react";
import { useTranslation } from "react-i18next";
interface ApiKeyInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
required?: boolean;
label?: string;
id?: string;
}
const ApiKeyInput: React.FC<ApiKeyInputProps> = ({
value,
onChange,
placeholder,
disabled = false,
required = false,
label = "API Key",
id = "apiKey",
}) => {
const { t } = useTranslation();
const [showKey, setShowKey] = useState(false);
const toggleShowKey = () => {
setShowKey(!showKey);
};
const inputClass = `w-full px-3 py-2 pr-10 border rounded-lg text-sm transition-colors ${
disabled
? "bg-gray-100 dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed"
: "border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 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"
}`;
return (
<div className="space-y-2">
<label
htmlFor={id}
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
{label} {required && "*"}
</label>
<div className="relative">
<input
type={showKey ? "text" : "password"}
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder ?? t("apiKeyInput.placeholder")}
disabled={disabled}
required={required}
autoComplete="off"
className={inputClass}
/>
{!disabled && value && (
<button
type="button"
onClick={toggleShowKey}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
aria-label={showKey ? t("apiKeyInput.hide") : t("apiKeyInput.show")}
>
{showKey ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
)}
</div>
</div>
);
};
export default ApiKeyInput;

View File

@@ -0,0 +1,205 @@
import React, { useEffect, useState } from "react";
import JsonEditor from "../JsonEditor";
import { X, Save } from "lucide-react";
import { isLinux } from "../../lib/platform";
import { useTranslation } from "react-i18next";
interface ClaudeConfigEditorProps {
value: string;
onChange: (value: string) => void;
useCommonConfig: boolean;
onCommonConfigToggle: (checked: boolean) => void;
commonConfigSnippet: string;
onCommonConfigSnippetChange: (value: string) => void;
commonConfigError: string;
configError: string;
}
const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
value,
onChange,
useCommonConfig,
onCommonConfigToggle,
commonConfigSnippet,
onCommonConfigSnippetChange,
commonConfigError,
configError,
}) => {
const { t } = useTranslation();
const [isDarkMode, setIsDarkMode] = useState(false);
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
useEffect(() => {
// 检测暗色模式
const checkDarkMode = () => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
};
checkDarkMode();
// 监听暗色模式变化
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === "class") {
checkDarkMode();
}
});
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
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">
<label
htmlFor="settingsConfig"
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
{t("claudeConfig.configLabel")}
</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"
/>
{t("claudeConfig.writeCommonConfig")}
</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"
>
{t("claudeConfig.editCommonConfig")}
</button>
</div>
{commonConfigError && !isCommonConfigModalOpen && (
<p className="text-xs text-red-500 dark:text-red-400 text-right">
{commonConfigError}
</p>
)}
<JsonEditor
value={value}
onChange={onChange}
darkMode={isDarkMode}
placeholder={`{
"env": {
"ANTHROPIC_BASE_URL": "https://your-api-endpoint.com",
"ANTHROPIC_AUTH_TOKEN": "your-api-key-here"
}
}`}
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">
{t("claudeConfig.fullSettingsHint")}
</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">
{t("claudeConfig.editCommonConfigTitle")}
</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={t("common.close")}
>
<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">
{t("claudeConfig.commonConfigHint")}
</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"
>
{t("common.cancel")}
</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" />
{t("common.save")}
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default ClaudeConfigEditor;

View File

@@ -0,0 +1,667 @@
import React, { useState, useEffect, useRef } from "react";
import { X, Save } from "lucide-react";
import { isLinux } from "../../lib/platform";
import { useTranslation } from "react-i18next";
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 { t } = useTranslation();
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">
<label
htmlFor="codexAuth"
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
{t("codexConfig.authJson")}
</label>
<textarea
id="codexAuth"
value={authValue}
onChange={(e) => handleAuthChange(e.target.value)}
onBlur={onAuthBlur}
placeholder={t("codexConfig.authJsonPlaceholder")}
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">
{t("codexConfig.authJsonHint")}
</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label
htmlFor="codexConfig"
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
{t("codexConfig.configToml")}
</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"
/>
{t("codexConfig.writeCommonConfig")}
</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"
>
{t("codexConfig.editCommonConfig")}
</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) => 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">
{t("codexConfig.configTomlHint")}
</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">
{t("codexConfig.quickWizard")}
</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={t("common.close")}
>
<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">
{t("codexConfig.wizardHint")}
</p>
</div>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
{t("codexConfig.apiKeyLabel")}
</label>
<input
type="text"
value={templateApiKey}
ref={apiKeyInputRef}
onChange={(e) => setTemplateApiKey(e.target.value)}
onKeyDown={handleTemplateInputKeyDown}
pattern=".*\S.*"
title={t("common.enterValidValue")}
placeholder={t("codexConfig.apiKeyPlaceholder")}
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">
{t("codexConfig.supplierNameLabel")}
</label>
<input
type="text"
value={templateDisplayName}
ref={displayNameInputRef}
onChange={(e) => {
setTemplateDisplayName(e.target.value);
if (onNameChange) {
onNameChange(e.target.value);
}
}}
onKeyDown={handleTemplateInputKeyDown}
placeholder={t("codexConfig.supplierNamePlaceholder")}
required
pattern=".*\S.*"
title={t("common.enterValidValue")}
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{t("codexConfig.supplierNameHint")}
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
{t("codexConfig.supplierCodeLabel")}
</label>
<input
type="text"
value={templateProviderName}
onChange={(e) => setTemplateProviderName(e.target.value)}
onKeyDown={handleTemplateInputKeyDown}
placeholder={t("codexConfig.supplierCodePlaceholder")}
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{t("codexConfig.supplierCodeHint")}
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
{t("codexConfig.apiUrlLabel")}
</label>
<input
type="url"
value={templateBaseUrl}
ref={baseUrlInputRef}
onChange={(e) => setTemplateBaseUrl(e.target.value)}
onKeyDown={handleTemplateInputKeyDown}
placeholder={t("codexConfig.apiUrlPlaceholder")}
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">
{t("codexConfig.websiteLabel")}
</label>
<input
type="url"
value={templateWebsiteUrl}
onChange={(e) => setTemplateWebsiteUrl(e.target.value)}
onKeyDown={handleTemplateInputKeyDown}
placeholder={t("codexConfig.websitePlaceholder")}
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{t("codexConfig.websiteHint")}
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
{t("codexConfig.modelNameLabel")}
</label>
<input
type="text"
value={templateModelName}
ref={modelNameInputRef}
onChange={(e) => setTemplateModelName(e.target.value)}
onKeyDown={handleTemplateInputKeyDown}
pattern=".*\S.*"
title={t("common.enterValidValue")}
placeholder={t("codexConfig.modelNamePlaceholder")}
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">
{t("codexConfig.configPreview")}
</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"
>
{t("common.cancel")}
</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" />
{t("codexConfig.applyConfig")}
</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">
{t("codexConfig.editCommonConfigTitle")}
</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={t("common.close")}
>
<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">
{t("codexConfig.commonConfigHint")}
</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"
>
{t("common.cancel")}
</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" />
{t("common.save")}
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default CodexConfigEditor;

View File

@@ -0,0 +1,637 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Zap, Loader2, Plus, X, AlertCircle, Save } from "lucide-react";
import { isLinux } from "../../lib/platform";
import type { AppType } from "../../lib/tauri-api";
export interface EndpointCandidate {
id?: string;
url: string;
isCustom?: boolean;
}
interface EndpointSpeedTestProps {
appType: AppType;
providerId?: string;
value: string;
onChange: (url: string) => void;
initialEndpoints: EndpointCandidate[];
visible?: boolean;
onClose: () => void;
// 当自定义端点列表变化时回传(仅包含 isCustom 的条目)
onCustomEndpointsChange?: (urls: string[]) => void;
}
interface EndpointEntry extends EndpointCandidate {
id: string;
latency: number | null;
status?: number;
error?: string | null;
}
const randomId = () => `ep_${Math.random().toString(36).slice(2, 9)}`;
const normalizeEndpointUrl = (url: string): string =>
url.trim().replace(/\/+$/, "");
const buildInitialEntries = (
candidates: EndpointCandidate[],
selected: string,
): EndpointEntry[] => {
const map = new Map<string, EndpointEntry>();
const addCandidate = (candidate: EndpointCandidate) => {
const sanitized = candidate.url ? normalizeEndpointUrl(candidate.url) : "";
if (!sanitized) return;
if (map.has(sanitized)) return;
map.set(sanitized, {
id: candidate.id ?? randomId(),
url: sanitized,
isCustom: candidate.isCustom ?? false,
latency: null,
status: undefined,
error: null,
});
};
candidates.forEach(addCandidate);
const selectedUrl = normalizeEndpointUrl(selected);
if (selectedUrl && !map.has(selectedUrl)) {
addCandidate({ url: selectedUrl, isCustom: true });
}
return Array.from(map.values());
};
const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
appType,
providerId,
value,
onChange,
initialEndpoints,
visible = true,
onClose,
onCustomEndpointsChange,
}) => {
const { t } = useTranslation();
const [entries, setEntries] = useState<EndpointEntry[]>(() =>
buildInitialEntries(initialEndpoints, value),
);
const [customUrl, setCustomUrl] = useState("");
const [addError, setAddError] = useState<string | null>(null);
const [autoSelect, setAutoSelect] = useState(true);
const [isTesting, setIsTesting] = useState(false);
const [lastError, setLastError] = useState<string | null>(null);
const normalizedSelected = normalizeEndpointUrl(value);
const hasEndpoints = entries.length > 0;
// 加载保存的自定义端点(按正在编辑的供应商)
useEffect(() => {
const loadCustomEndpoints = async () => {
try {
if (!providerId) return;
const customEndpoints = await window.api.getCustomEndpoints(
appType,
providerId,
);
const candidates: EndpointCandidate[] = customEndpoints.map((ep) => ({
url: ep.url,
isCustom: true,
}));
setEntries((prev) => {
const map = new Map<string, EndpointEntry>();
// 先添加现有端点
prev.forEach((entry) => {
map.set(entry.url, entry);
});
// 合并自定义端点
candidates.forEach((candidate) => {
const sanitized = normalizeEndpointUrl(candidate.url);
if (sanitized && !map.has(sanitized)) {
map.set(sanitized, {
id: randomId(),
url: sanitized,
isCustom: true,
latency: null,
status: undefined,
error: null,
});
}
});
return Array.from(map.values());
});
} catch (error) {
console.error(t("endpointTest.loadEndpointsFailed"), error);
}
};
if (visible) {
loadCustomEndpoints();
}
}, [appType, visible, providerId, t]);
useEffect(() => {
setEntries((prev) => {
const map = new Map<string, EndpointEntry>();
prev.forEach((entry) => {
map.set(entry.url, entry);
});
let changed = false;
const mergeCandidate = (candidate: EndpointCandidate) => {
const sanitized = candidate.url
? normalizeEndpointUrl(candidate.url)
: "";
if (!sanitized) return;
const existing = map.get(sanitized);
if (existing) return;
map.set(sanitized, {
id: candidate.id ?? randomId(),
url: sanitized,
isCustom: candidate.isCustom ?? false,
latency: null,
status: undefined,
error: null,
});
changed = true;
};
initialEndpoints.forEach(mergeCandidate);
if (normalizedSelected && !map.has(normalizedSelected)) {
mergeCandidate({ url: normalizedSelected, isCustom: true });
}
if (!changed) {
return prev;
}
return Array.from(map.values());
});
}, [initialEndpoints, normalizedSelected]);
// 将自定义端点变化透传给父组件(仅限 isCustom
useEffect(() => {
if (!onCustomEndpointsChange) return;
try {
const customUrls = Array.from(
new Set(
entries
.filter((e) => e.isCustom)
.map((e) => (e.url ? normalizeEndpointUrl(e.url) : ""))
.filter(Boolean),
),
);
onCustomEndpointsChange(customUrls);
} catch (err) {
// ignore
}
// 仅在 entries 变化时同步
}, [entries, onCustomEndpointsChange]);
const sortedEntries = useMemo(() => {
return entries.slice().sort((a, b) => {
const aLatency = a.latency ?? Number.POSITIVE_INFINITY;
const bLatency = b.latency ?? Number.POSITIVE_INFINITY;
if (aLatency === bLatency) {
return a.url.localeCompare(b.url);
}
return aLatency - bLatency;
});
}, [entries]);
const handleAddEndpoint = useCallback(async () => {
const candidate = customUrl.trim();
let errorMsg: string | null = null;
if (!candidate) {
errorMsg = t("endpointTest.enterValidUrl");
}
let parsed: URL | null = null;
if (!errorMsg) {
try {
parsed = new URL(candidate);
} catch {
errorMsg = t("endpointTest.invalidUrlFormat");
}
}
if (!errorMsg && parsed && !parsed.protocol.startsWith("http")) {
errorMsg = t("endpointTest.onlyHttps");
}
let sanitized = "";
if (!errorMsg && parsed) {
sanitized = normalizeEndpointUrl(parsed.toString());
// 使用当前 entries 做去重校验,避免依赖可能过期的 addError
const isDuplicate = entries.some((entry) => entry.url === sanitized);
if (isDuplicate) {
errorMsg = t("endpointTest.urlExists");
}
}
if (errorMsg) {
setAddError(errorMsg);
return;
}
setAddError(null);
// 保存到后端
try {
if (providerId) {
await window.api.addCustomEndpoint(appType, providerId, sanitized);
}
// 更新本地状态
setEntries((prev) => {
if (prev.some((e) => e.url === sanitized)) return prev;
return [
...prev,
{
id: randomId(),
url: sanitized,
isCustom: true,
latency: null,
status: undefined,
error: null,
},
];
});
if (!normalizedSelected) {
onChange(sanitized);
}
setCustomUrl("");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setAddError(message || t("endpointTest.saveFailed"));
console.error(t("endpointTest.addEndpointFailed"), error);
}
}, [
customUrl,
entries,
normalizedSelected,
onChange,
appType,
providerId,
t,
]);
const handleRemoveEndpoint = useCallback(
async (entry: EndpointEntry) => {
// 如果是自定义端点,尝试从后端删除(无 providerId 则仅本地删除)
if (entry.isCustom && providerId) {
try {
await window.api.removeCustomEndpoint(appType, providerId, entry.url);
} catch (error) {
console.error(t("endpointTest.removeEndpointFailed"), error);
return;
}
}
// 更新本地状态
setEntries((prev) => {
const next = prev.filter((item) => item.id !== entry.id);
if (entry.url === normalizedSelected) {
const fallback = next[0];
onChange(fallback ? fallback.url : "");
}
return next;
});
},
[normalizedSelected, onChange, appType, providerId, t],
);
const runSpeedTest = useCallback(async () => {
const urls = entries.map((entry) => entry.url);
if (urls.length === 0) {
setLastError(t("endpointTest.pleaseAddEndpoint"));
return;
}
if (typeof window === "undefined" || !window.api?.testApiEndpoints) {
setLastError(t("endpointTest.testUnavailable"));
return;
}
setIsTesting(true);
setLastError(null);
// 清空所有延迟数据,显示 loading 状态
setEntries((prev) =>
prev.map((entry) => ({
...entry,
latency: null,
status: undefined,
error: null,
})),
);
try {
const results = await window.api.testApiEndpoints(urls, {
timeoutSecs: appType === "codex" ? 12 : 8,
});
const resultMap = new Map(
results.map((item) => [normalizeEndpointUrl(item.url), item]),
);
setEntries((prev) =>
prev.map((entry) => {
const match = resultMap.get(entry.url);
if (!match) {
return {
...entry,
latency: null,
status: undefined,
error: t("endpointTest.noResult"),
};
}
return {
...entry,
latency:
typeof match.latency === "number"
? Math.round(match.latency)
: null,
status: match.status,
error: match.error ?? null,
};
}),
);
if (autoSelect) {
const successful = results
.filter(
(item) => typeof item.latency === "number" && item.latency !== null,
)
.sort((a, b) => (a.latency! || 0) - (b.latency! || 0));
const best = successful[0];
if (best && best.url && best.url !== normalizedSelected) {
onChange(best.url);
}
}
} catch (error) {
const message =
error instanceof Error
? error.message
: `${t("endpointTest.testFailed", { error: String(error) })}`;
setLastError(message);
} finally {
setIsTesting(false);
}
}, [entries, autoSelect, appType, normalizedSelected, onChange, t]);
const handleSelect = useCallback(
async (url: string) => {
if (!url || url === normalizedSelected) return;
// 更新最后使用时间(对自定义端点)
const entry = entries.find((e) => e.url === url);
if (entry?.isCustom && providerId) {
await window.api.updateEndpointLastUsed(appType, providerId, url);
}
onChange(url);
},
[normalizedSelected, onChange, appType, entries, providerId],
);
// 支持按下 ESC 关闭弹窗
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [onClose]);
if (!visible) {
return null;
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
{/* 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 w-full max-w-2xl mx-4 max-h-[80vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
<h3 className="text-base font-medium text-gray-900 dark:text-gray-100">
{t("endpointTest.title")}
</h3>
<button
type="button"
onClick={onClose}
className="p-1 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
aria-label={t("common.close")}
>
<X size={16} />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-auto px-6 py-4 space-y-4">
{/* 测速控制栏 */}
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600 dark:text-gray-400">
{entries.length} {t("endpointTest.endpoints")}
</div>
<div className="flex items-center gap-3">
<label className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
<input
type="checkbox"
checked={autoSelect}
onChange={(event) => setAutoSelect(event.target.checked)}
className="h-3.5 w-3.5 rounded border-gray-300 dark:border-gray-600"
/>
{t("endpointTest.autoSelect")}
</label>
<button
type="button"
onClick={runSpeedTest}
disabled={isTesting || !hasEndpoints}
className="flex h-7 w-20 items-center justify-center gap-1.5 rounded-md bg-blue-500 px-2.5 text-xs font-medium text-white transition hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-blue-600 dark:hover:bg-blue-700"
>
{isTesting ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t("endpointTest.testing")}
</>
) : (
<>
<Zap className="h-3.5 w-3.5" />
{t("endpointTest.testSpeed")}
</>
)}
</button>
</div>
</div>
{/* 添加输入 */}
<div className="space-y-1.5">
<div className="flex gap-2">
<input
type="url"
value={customUrl}
placeholder={t("endpointTest.addEndpointPlaceholder")}
onChange={(event) => setCustomUrl(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
handleAddEndpoint();
}
}}
className="flex-1 rounded-md border border-gray-200 bg-white px-3 py-1.5 text-sm text-gray-900 placeholder-gray-400 transition focus:border-gray-400 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:placeholder-gray-500 dark:focus:border-gray-600"
/>
<button
type="button"
onClick={handleAddEndpoint}
className="flex h-8 w-8 items-center justify-center rounded-md border border-gray-200 transition hover:border-gray-300 hover:bg-gray-50 dark:border-gray-700 dark:hover:border-gray-600 dark:hover:bg-gray-800"
>
<Plus className="h-4 w-4 text-gray-600 dark:text-gray-400" />
</button>
</div>
{addError && (
<div className="flex items-center gap-1.5 text-xs text-red-600 dark:text-red-400">
<AlertCircle className="h-3 w-3" />
{addError}
</div>
)}
</div>
{/* 端点列表 */}
{hasEndpoints ? (
<div className="space-y-2">
{sortedEntries.map((entry) => {
const isSelected = normalizedSelected === entry.url;
const latency = entry.latency;
return (
<div
key={entry.id}
onClick={() => handleSelect(entry.url)}
className={`group flex cursor-pointer items-center justify-between px-3 py-2.5 rounded-lg border transition ${
isSelected
? "border-blue-500 bg-blue-50 dark:border-blue-500 dark:bg-blue-900/20"
: "border-gray-200 bg-white hover:border-gray-300 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-900 dark:hover:border-gray-600 dark:hover:bg-gray-850"
}`}
>
<div className="flex min-w-0 flex-1 items-center gap-3">
{/* 选择指示器 */}
<div
className={`h-1.5 w-1.5 flex-shrink-0 rounded-full transition ${
isSelected
? "bg-blue-500 dark:bg-blue-400"
: "bg-gray-300 dark:bg-gray-700"
}`}
/>
{/* 内容 */}
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-gray-900 dark:text-gray-100">
{entry.url}
</div>
</div>
</div>
{/* 右侧信息 */}
<div className="flex items-center gap-2">
{latency !== null ? (
<div className="text-right">
<div
className={`font-mono text-sm font-medium ${
latency < 300
? "text-green-600 dark:text-green-400"
: latency < 500
? "text-yellow-600 dark:text-yellow-400"
: latency < 800
? "text-orange-600 dark:text-orange-400"
: "text-red-600 dark:text-red-400"
}`}
>
{latency}ms
</div>
</div>
) : isTesting ? (
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
) : entry.error ? (
<div className="text-xs text-gray-400">
{t("endpointTest.failed")}
</div>
) : (
<div className="text-xs text-gray-400"></div>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleRemoveEndpoint(entry);
}}
className="opacity-0 transition hover:text-red-600 group-hover:opacity-100 dark:hover:text-red-400"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
);
})}
</div>
) : (
<div className="rounded-md border border-dashed border-gray-200 bg-gray-50 py-8 text-center text-xs text-gray-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-400">
{t("endpointTest.noEndpoints")}
</div>
)}
{/* 错误提示 */}
{lastError && (
<div className="flex items-center gap-1.5 text-xs text-red-600 dark:text-red-400">
<AlertCircle className="h-3 w-3" />
{lastError}
</div>
)}
</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={onClose}
className="px-4 py-2 bg-blue-500 dark:bg-blue-600 text-white rounded-lg hover:bg-blue-600 dark:hover:bg-blue-700 transition-colors text-sm font-medium flex items-center gap-2"
>
<Save className="w-4 h-4" />
{t("common.save")}
</button>
</div>
</div>
</div>
);
};
export default EndpointSpeedTest;

View File

@@ -0,0 +1,195 @@
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, RefreshCw, AlertCircle } from "lucide-react";
interface KimiModel {
id: string;
object: string;
created: number;
owned_by: string;
}
interface KimiModelSelectorProps {
apiKey: string;
anthropicModel: string;
anthropicSmallFastModel: string;
onModelChange: (
field: "ANTHROPIC_MODEL" | "ANTHROPIC_SMALL_FAST_MODEL",
value: string,
) => void;
disabled?: boolean;
}
const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
apiKey,
anthropicModel,
anthropicSmallFastModel,
onModelChange,
disabled = false,
}) => {
const { t } = useTranslation();
const [models, setModels] = useState<KimiModel[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [debouncedKey, setDebouncedKey] = useState("");
// 获取模型列表
const fetchModelsWithKey = async (key: string) => {
if (!key) {
setError(t("kimiSelector.fillApiKeyFirst"));
return;
}
setLoading(true);
setError("");
try {
const response = await fetch("https://api.moonshot.cn/v1/models", {
headers: {
Authorization: `Bearer ${key}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(
t("kimiSelector.requestFailed", {
error: `${response.status} ${response.statusText}`,
}),
);
}
const data = await response.json();
if (data.data && Array.isArray(data.data)) {
setModels(data.data);
} else {
throw new Error(t("kimiSelector.invalidData"));
}
} catch (err) {
console.error(t("kimiSelector.fetchModelsFailed") + ":", err);
setError(
err instanceof Error
? err.message
: t("kimiSelector.fetchModelsFailed"),
);
} finally {
setLoading(false);
}
};
// 500ms 防抖 API Key
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedKey(apiKey.trim());
}, 500);
return () => clearTimeout(timer);
}, [apiKey]);
// 当防抖后的 Key 改变时自动获取模型列表
useEffect(() => {
if (debouncedKey) {
fetchModelsWithKey(debouncedKey);
} else {
setModels([]);
setError("");
}
}, [debouncedKey]);
const selectClass = `w-full px-3 py-2 border rounded-lg text-sm transition-colors appearance-none bg-white dark:bg-gray-800 ${
disabled
? "bg-gray-100 dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed"
: "border-gray-200 dark:border-gray-700 dark:text-gray-100 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"
}`;
const ModelSelect: React.FC<{
label: string;
value: string;
onChange: (value: string) => void;
}> = ({ label, value, onChange }) => (
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100">
{label}
</label>
<div className="relative">
<select
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled || loading || models.length === 0}
className={selectClass}
>
<option value="">
{loading
? t("common.loading")
: models.length === 0
? t("kimiSelector.noModels")
: t("kimiSelector.pleaseSelectModel")}
</option>
{models.map((model) => (
<option key={model.id} value={model.id}>
{model.id}
</option>
))}
</select>
<ChevronDown
size={16}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500 dark:text-gray-400 pointer-events-none"
/>
</div>
</div>
);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
{t("kimiSelector.modelConfig")}
</h3>
<button
type="button"
onClick={() => debouncedKey && fetchModelsWithKey(debouncedKey)}
disabled={disabled || loading || !debouncedKey}
className="inline-flex items-center gap-1 px-2 py-1 text-xs text-gray-500 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
{t("kimiSelector.refreshModels")}
</button>
</div>
{error && (
<div className="flex items-center gap-2 p-3 bg-red-100 dark:bg-red-900/20 border border-red-500/20 dark:border-red-500/30 rounded-lg">
<AlertCircle
size={16}
className="text-red-500 dark:text-red-400 flex-shrink-0"
/>
<p className="text-red-500 dark:text-red-400 text-xs">{error}</p>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<ModelSelect
label={t("kimiSelector.mainModel")}
value={anthropicModel}
onChange={(value) => onModelChange("ANTHROPIC_MODEL", value)}
/>
<ModelSelect
label={t("kimiSelector.fastModel")}
value={anthropicSmallFastModel}
onChange={(value) =>
onModelChange("ANTHROPIC_SMALL_FAST_MODEL", value)
}
/>
</div>
{!apiKey.trim() && (
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg">
<p className="text-xs text-amber-600 dark:text-amber-400">
{t("kimiSelector.apiKeyHint")}
</p>
</div>
)}
</div>
);
};
export default KimiModelSelector;

View File

@@ -0,0 +1,119 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Zap } from "lucide-react";
import { ProviderCategory } from "../../types";
import { ClaudeIcon, CodexIcon } from "../BrandIcons";
interface Preset {
name: string;
isOfficial?: boolean;
category?: ProviderCategory;
}
interface PresetSelectorProps {
title?: string;
presets: Preset[];
selectedIndex: number | null;
onSelectPreset: (index: number) => void;
onCustomClick: () => void;
customLabel?: string;
renderCustomDescription?: () => React.ReactNode; // 新增:自定义描述渲染
}
const PresetSelector: React.FC<PresetSelectorProps> = ({
title,
presets,
selectedIndex,
onSelectPreset,
onCustomClick,
customLabel,
renderCustomDescription,
}) => {
const { t } = useTranslation();
const getButtonClass = (index: number, preset?: Preset) => {
const isSelected = selectedIndex === index;
const baseClass =
"inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors";
if (isSelected) {
if (preset?.isOfficial || preset?.category === "official") {
// Codex 官方使用黑色背景
if (preset?.name.includes("Codex")) {
return `${baseClass} bg-gray-900 text-white`;
}
// Claude 官方使用品牌色背景
return `${baseClass} bg-[#D97757] text-white`;
}
return `${baseClass} bg-blue-500 text-white`;
}
return `${baseClass} bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700`;
};
const getDescription = () => {
if (selectedIndex === -1) {
// 如果提供了自定义描述渲染函数,使用它
if (renderCustomDescription) {
return renderCustomDescription();
}
return t("presetSelector.customDescription");
}
if (selectedIndex !== null && selectedIndex >= 0) {
const preset = presets[selectedIndex];
return preset?.isOfficial || preset?.category === "official"
? t("presetSelector.officialDescription")
: t("presetSelector.presetDescription");
}
return null;
};
return (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
{title || t("presetSelector.title")}
</label>
<div className="flex flex-wrap gap-2">
<button
type="button"
className={`${getButtonClass(-1)} ${selectedIndex === -1 ? "" : ""}`}
onClick={onCustomClick}
>
{customLabel || t("presetSelector.custom")}
</button>
{presets.map((preset, index) => (
<button
key={index}
type="button"
className={getButtonClass(index, preset)}
onClick={() => onSelectPreset(index)}
>
{(preset.isOfficial || preset.category === "official") && (
<>
{preset.name.includes("Claude") ? (
<ClaudeIcon size={14} />
) : preset.name.includes("Codex") ? (
<CodexIcon size={14} />
) : (
<Zap size={14} />
)}
</>
)}
{preset.name}
</button>
))}
</div>
</div>
{getDescription() && (
<div className="text-sm text-gray-500 dark:text-gray-400">
{getDescription()}
</div>
)}
</div>
);
};
export default PresetSelector;

View File

@@ -1,206 +0,0 @@
.provider-list {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 1.5rem;
}
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: #7f8c8d;
}
.empty-state p:first-child {
font-size: 1.1rem;
margin-bottom: 0.5rem;
}
.provider-items {
display: flex;
flex-direction: column;
gap: 1rem;
}
.provider-item {
display: flex;
align-items: center;
padding: 1rem;
border: 2px solid #ecf0f1;
border-radius: 6px;
transition: all 0.2s;
}
.provider-item:hover {
border-color: #3498db;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.provider-item.current {
border-color: #27ae60;
background: #f0fdf4;
}
.provider-info {
flex: 1;
min-width: 0;
}
.provider-name {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 500;
margin-bottom: 0.25rem;
}
.provider-name input[type="radio"] {
cursor: pointer;
}
.provider-name input[type="radio"]:disabled {
cursor: not-allowed;
}
.current-badge {
background: #27ae60;
color: white;
padding: 0.125rem 0.5rem;
border-radius: 3px;
font-size: 0.75rem;
}
.provider-url {
color: #7f8c8d;
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.url-link {
color: #3498db;
text-decoration: none;
cursor: pointer;
transition: color 0.2s;
}
.url-link:hover {
color: #2980b9;
text-decoration: underline;
}
.api-url {
color: #7f8c8d;
}
.provider-status {
display: flex;
align-items: center;
gap: 0.5rem;
margin-right: 2rem;
}
.status-icon {
font-size: 1.1rem;
}
.status-text {
color: #555;
font-size: 0.9rem;
}
.response-time {
color: #3498db;
font-size: 0.85rem;
font-family: monospace;
}
.provider-actions {
display: flex;
gap: 0.5rem;
}
.check-btn {
padding: 0.375rem 0.75rem;
border: 1px solid #f39c12;
background: white;
color: #f39c12;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.check-btn:hover:not(:disabled) {
background: #f39c12;
color: white;
}
.check-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.enable-btn {
padding: 0.375rem 0.75rem;
border: 1px solid #27ae60;
background: white;
color: #27ae60;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.enable-btn:hover:not(:disabled) {
background: #27ae60;
color: white;
}
.enable-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.edit-btn {
padding: 0.375rem 0.75rem;
border: 1px solid #3498db;
background: white;
color: #3498db;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.edit-btn:hover:not(:disabled) {
background: #3498db;
color: white;
}
.edit-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.delete-btn {
padding: 0.375rem 0.75rem;
border: 1px solid #e74c3c;
background: white;
color: #e74c3c;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.delete-btn:hover:not(:disabled) {
background: #e74c3c;
color: white;
}
.delete-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}

View File

@@ -1,6 +1,9 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Provider } from "../types";
import "./ProviderList.css";
import { Play, Edit3, Trash2, CheckCircle2, Users, Check } from "lucide-react";
import { buttonStyles, cardStyles, badgeStyles, cn } from "../lib/styles";
// 不再在列表中显示分类徽章,避免造成困惑
interface ProviderListProps {
providers: Record<string, Provider>;
@@ -8,6 +11,11 @@ interface ProviderListProps {
onSwitch: (id: string) => void;
onDelete: (id: string) => void;
onEdit: (id: string) => void;
onNotify?: (
message: string,
type: "success" | "error",
duration?: number,
) => void;
}
const ProviderList: React.FC<ProviderListProps> = ({
@@ -16,17 +24,26 @@ const ProviderList: React.FC<ProviderListProps> = ({
onSwitch,
onDelete,
onEdit,
onNotify,
}) => {
// 提取API地址
const { t, i18n } = useTranslation();
// 提取API地址兼容不同供应商配置Claude env / Codex TOML
const getApiUrl = (provider: Provider): string => {
try {
const config = provider.settingsConfig;
if (config?.env?.ANTHROPIC_BASE_URL) {
return config.env.ANTHROPIC_BASE_URL;
const cfg = provider.settingsConfig;
// Claude/Anthropic: 从 env 中读取
if (cfg?.env?.ANTHROPIC_BASE_URL) {
return cfg.env.ANTHROPIC_BASE_URL;
}
return "未设置";
// Codex: 从 TOML 配置中解析 base_url
if (typeof cfg?.config === "string" && cfg.config.includes("base_url")) {
// 支持单/双引号
const match = cfg.config.match(/base_url\s*=\s*(['"])([^'\"]+)\1/);
if (match && match[2]) return match[2];
}
return t("provider.notConfigured");
} catch {
return "配置错误";
return t("provider.configError");
}
};
@@ -34,76 +51,146 @@ const ProviderList: React.FC<ProviderListProps> = ({
try {
await window.api.openExternal(url);
} catch (error) {
console.error("打开链接失败:", error);
console.error(t("console.openLinkFailed"), error);
onNotify?.(
`${t("console.openLinkFailed")}: ${String(error)}`,
"error",
4000,
);
}
};
// 列表页不再提供 Claude 插件按钮,统一在“设置”中控制
// 对供应商列表进行排序
const sortedProviders = Object.values(providers).sort((a, b) => {
// 按添加时间排序
// 没有时间戳的视为最早添加的(排在最前面)
// 有时间戳的按时间升序排列
const timeA = a.createdAt || 0;
const timeB = b.createdAt || 0;
// 如果都没有时间戳,按名称排序
if (timeA === 0 && timeB === 0) {
const locale = i18n.language === "zh" ? "zh-CN" : "en-US";
return a.name.localeCompare(b.name, locale);
}
// 如果只有一个没有时间戳,没有时间戳的排在前面
if (timeA === 0) return -1;
if (timeB === 0) return 1;
// 都有时间戳,按时间升序
return timeA - timeB;
});
return (
<div className="provider-list">
{Object.values(providers).length === 0 ? (
<div className="empty-state">
<p></p>
<p>"添加供应商"</p>
<div className="space-y-4">
{sortedProviders.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 rounded-full flex items-center justify-center">
<Users size={24} className="text-gray-400" />
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
{t("provider.noProviders")}
</h3>
<p className="text-gray-500 dark:text-gray-400 text-sm">
{t("provider.noProvidersDescription")}
</p>
</div>
) : (
<div className="provider-items">
{Object.values(providers).map((provider) => {
<div className="space-y-3">
{sortedProviders.map((provider) => {
const isCurrent = provider.id === currentProviderId;
const apiUrl = getApiUrl(provider);
return (
<div
key={provider.id}
className={`provider-item ${isCurrent ? "current" : ""}`}
className={cn(
isCurrent ? cardStyles.selected : cardStyles.interactive,
)}
>
<div className="provider-info">
<div className="provider-name">
<span>{provider.name}</span>
{isCurrent && (
<span className="current-badge">使</span>
)}
</div>
<div className="provider-url">
{provider.websiteUrl ? (
<a
href="#"
onClick={(e) => {
e.preventDefault();
handleUrlClick(provider.websiteUrl!);
}}
className="url-link"
title={`访问 ${provider.websiteUrl}`}
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="font-medium text-gray-900 dark:text-gray-100">
{provider.name}
</h3>
{/* 分类徽章已移除 */}
<div
className={cn(
badgeStyles.success,
!isCurrent && "invisible",
)}
>
{provider.websiteUrl}
</a>
) : (
<span className="api-url" title={getApiUrl(provider)}>
{getApiUrl(provider)}
</span>
)}
</div>
</div>
<CheckCircle2 size={12} />
{t("provider.currentlyUsing")}
</div>
</div>
<div className="provider-actions">
<button
className="enable-btn"
onClick={() => onSwitch(provider.id)}
disabled={isCurrent}
>
</button>
<button
className="edit-btn"
onClick={() => onEdit(provider.id)}
>
</button>
<button
className="delete-btn"
onClick={() => onDelete(provider.id)}
disabled={isCurrent}
>
</button>
<div className="flex items-center gap-2 text-sm">
{provider.websiteUrl ? (
<button
onClick={(e) => {
e.preventDefault();
handleUrlClick(provider.websiteUrl!);
}}
className="inline-flex items-center gap-1 text-blue-500 dark:text-blue-400 hover:opacity-90 transition-colors"
title={t("providerForm.visitWebsite", {
url: provider.websiteUrl,
})}
>
{provider.websiteUrl}
</button>
) : (
<span
className="text-gray-500 dark:text-gray-400"
title={apiUrl}
>
{apiUrl}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 ml-4">
<button
onClick={() => onSwitch(provider.id)}
disabled={isCurrent}
className={cn(
"inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md transition-colors w-[90px] justify-center whitespace-nowrap",
isCurrent
? "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",
)}
>
{isCurrent ? <Check size={14} /> : <Play size={14} />}
{isCurrent ? t("provider.inUse") : t("provider.enable")}
</button>
<button
onClick={() => onEdit(provider.id)}
className={buttonStyles.icon}
title={t("provider.editProvider")}
>
<Edit3 size={16} />
</button>
<button
onClick={() => onDelete(provider.id)}
disabled={isCurrent}
className={cn(
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",
)}
title={t("provider.deleteProvider")}
>
<Trash2 size={16} />
</button>
</div>
</div>
</div>
);

View File

@@ -0,0 +1,859 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
X,
RefreshCw,
FolderOpen,
Download,
ExternalLink,
Check,
Undo2,
FolderSearch,
Save,
} from "lucide-react";
import { getVersion } from "@tauri-apps/api/app";
import { ImportProgressModal } from "./ImportProgressModal";
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;
onImportSuccess?: () => void | Promise<void>;
onNotify?: (
message: string,
type: "success" | "error",
duration?: number,
) => void;
}
export default function SettingsModal({
onClose,
onImportSuccess,
onNotify,
}: SettingsModalProps) {
const { t, i18n } = useTranslation();
const normalizeLanguage = (lang?: string | null): "zh" | "en" =>
lang === "en" ? "en" : "zh";
const readPersistedLanguage = (): "zh" | "en" => {
if (typeof window !== "undefined") {
const stored = window.localStorage.getItem("language");
if (stored === "en" || stored === "zh") {
return stored;
}
}
return normalizeLanguage(i18n.language);
};
const persistedLanguage = readPersistedLanguage();
const [settings, setSettings] = useState<Settings>({
showInTray: true,
minimizeToTrayOnClose: true,
enableClaudePluginIntegration: false,
claudeConfigDir: undefined,
codexConfigDir: undefined,
language: persistedLanguage,
});
const [initialLanguage, setInitialLanguage] = useState<"zh" | "en">(
persistedLanguage,
);
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 [isPortable, setIsPortable] = useState(false);
const { hasUpdate, updateInfo, updateHandle, checkUpdate, resetDismiss } =
useUpdate();
// 导入/导出相关状态
const [isImporting, setIsImporting] = useState(false);
const [importStatus, setImportStatus] = useState<
"idle" | "importing" | "success" | "error"
>("idle");
const [importError, setImportError] = useState<string>("");
const [importBackupId, setImportBackupId] = useState<string>("");
const [selectedImportFile, setSelectedImportFile] = useState<string>("");
useEffect(() => {
loadSettings();
loadConfigPath();
loadVersion();
loadResolvedDirs();
loadPortableFlag();
}, []);
const loadVersion = async () => {
try {
const appVersion = await getVersion();
setVersion(appVersion);
} catch (error) {
console.error(t("console.getVersionFailed"), error);
// 失败时不硬编码版本号,显示为未知
setVersion(t("common.unknown"));
}
};
const loadSettings = async () => {
try {
const loadedSettings = await window.api.getSettings();
const showInTray =
(loadedSettings as any)?.showInTray ??
(loadedSettings as any)?.showInDock ??
true;
const minimizeToTrayOnClose =
(loadedSettings as any)?.minimizeToTrayOnClose ??
(loadedSettings as any)?.minimize_to_tray_on_close ??
true;
const storedLanguage = normalizeLanguage(
typeof (loadedSettings as any)?.language === "string"
? (loadedSettings as any).language
: persistedLanguage,
);
setSettings({
showInTray,
minimizeToTrayOnClose,
enableClaudePluginIntegration:
typeof (loadedSettings as any)?.enableClaudePluginIntegration ===
"boolean"
? (loadedSettings as any).enableClaudePluginIntegration
: false,
claudeConfigDir:
typeof (loadedSettings as any)?.claudeConfigDir === "string"
? (loadedSettings as any).claudeConfigDir
: undefined,
codexConfigDir:
typeof (loadedSettings as any)?.codexConfigDir === "string"
? (loadedSettings as any).codexConfigDir
: undefined,
language: storedLanguage,
});
setInitialLanguage(storedLanguage);
if (i18n.language !== storedLanguage) {
void i18n.changeLanguage(storedLanguage);
}
} catch (error) {
console.error(t("console.loadSettingsFailed"), error);
}
};
const loadConfigPath = async () => {
try {
const path = await window.api.getAppConfigPath();
if (path) {
setConfigPath(path);
}
} catch (error) {
console.error(t("console.getConfigPathFailed"), error);
}
};
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(t("console.getConfigDirFailed"), error);
}
};
const loadPortableFlag = async () => {
try {
const portable = await window.api.isPortable();
setIsPortable(portable);
} catch (error) {
console.error(t("console.detectPortableFailed"), error);
}
};
const saveSettings = async () => {
try {
const selectedLanguage = settings.language === "en" ? "en" : "zh";
const payload: Settings = {
...settings,
claudeConfigDir:
settings.claudeConfigDir && settings.claudeConfigDir.trim() !== ""
? settings.claudeConfigDir.trim()
: undefined,
codexConfigDir:
settings.codexConfigDir && settings.codexConfigDir.trim() !== ""
? settings.codexConfigDir.trim()
: undefined,
language: selectedLanguage,
};
await window.api.saveSettings(payload);
// 立即生效:根据开关无条件写入/移除 ~/.claude/config.json
try {
if (payload.enableClaudePluginIntegration) {
await window.api.applyClaudePluginConfig({ official: false });
} else {
await window.api.applyClaudePluginConfig({ official: true });
}
} catch (e) {
console.warn("[Settings] Apply Claude plugin config on save failed", e);
}
setSettings(payload);
try {
window.localStorage.setItem("language", selectedLanguage);
} catch (error) {
console.warn("[Settings] Failed to persist language preference", error);
}
setInitialLanguage(selectedLanguage);
if (i18n.language !== selectedLanguage) {
void i18n.changeLanguage(selectedLanguage);
}
onClose();
} catch (error) {
console.error(t("console.saveSettingsFailed"), error);
}
};
const handleLanguageChange = (lang: "zh" | "en") => {
setSettings((prev) => ({ ...prev, language: lang }));
if (i18n.language !== lang) {
void i18n.changeLanguage(lang);
}
};
const handleCancel = () => {
if (settings.language !== initialLanguage) {
setSettings((prev) => ({ ...prev, language: initialLanguage }));
if (i18n.language !== initialLanguage) {
void i18n.changeLanguage(initialLanguage);
}
}
onClose();
};
const handleCheckUpdate = async () => {
if (hasUpdate && updateHandle) {
if (isPortable) {
await window.api.checkForUpdates();
return;
}
// 已检测到更新:直接复用 updateHandle 下载并安装,避免重复检查
setIsDownloading(true);
try {
resetDismiss();
await updateHandle.downloadAndInstall();
await relaunchApp();
} catch (error) {
console.error(t("console.updateFailed"), error);
// 更新失败时回退到打开 Releases 页面
await window.api.checkForUpdates();
} finally {
setIsDownloading(false);
}
} else {
// 尚未检测到更新:先检查
setIsCheckingUpdate(true);
setShowUpToDate(false);
try {
const hasNewUpdate = await checkUpdate();
// 检查完成后,如果没有更新,显示"已是最新"
if (!hasNewUpdate) {
setShowUpToDate(true);
// 3秒后恢复按钮文字
setTimeout(() => {
setShowUpToDate(false);
}, 3000);
}
} catch (error) {
console.error(t("console.checkUpdateFailed"), error);
// 在开发模式下,模拟已是最新版本的响应
if (import.meta.env.DEV) {
setShowUpToDate(true);
setTimeout(() => {
setShowUpToDate(false);
}, 3000);
} else {
// 生产环境下如果更新插件不可用,回退到打开 Releases 页面
await window.api.checkForUpdates();
}
} finally {
setIsCheckingUpdate(false);
}
}
};
const handleOpenConfigFolder = async () => {
try {
await window.api.openAppConfigFolder();
} catch (error) {
console.error(t("console.openConfigFolderFailed"), error);
}
};
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(t("console.selectConfigDirFailed"), 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(t("console.getDefaultConfigDirFailed"), 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;
const unknownLabel = t("common.unknown");
// 如果未知或为空,回退到 releases 首页
if (!targetVersion || targetVersion === unknownLabel) {
await window.api.openExternal(
"https://github.com/farion1231/cc-switch/releases",
);
return;
}
const tag = targetVersion.startsWith("v")
? targetVersion
: `v${targetVersion}`;
await window.api.openExternal(
`https://github.com/farion1231/cc-switch/releases/tag/${tag}`,
);
} catch (error) {
console.error(t("console.openReleaseNotesFailed"), error);
}
};
// 导出配置处理函数
const handleExportConfig = async () => {
try {
const defaultName = `cc-switch-config-${new Date().toISOString().split("T")[0]}.json`;
const filePath = await window.api.saveFileDialog(defaultName);
if (!filePath) {
onNotify?.(
`${t("settings.exportFailed")}: ${t("settings.selectFileFailed")}`,
"error",
4000,
);
return;
}
const result = await window.api.exportConfigToFile(filePath);
if (result.success) {
onNotify?.(
`${t("settings.configExported")}\n${result.filePath}`,
"success",
4000,
);
}
} catch (error) {
console.error(t("settings.exportFailedError"), error);
onNotify?.(
`${t("settings.exportFailed")}: ${String(error)}`,
"error",
5000,
);
}
};
// 选择要导入的文件
const handleSelectImportFile = async () => {
try {
const filePath = await window.api.openFileDialog();
if (filePath) {
setSelectedImportFile(filePath);
setImportStatus("idle"); // 重置状态
setImportError("");
}
} catch (error) {
console.error(t("settings.selectFileFailed") + ":", error);
onNotify?.(
`${t("settings.selectFileFailed")}: ${String(error)}`,
"error",
5000,
);
}
};
// 执行导入
const handleExecuteImport = async () => {
if (!selectedImportFile || isImporting) return;
setIsImporting(true);
setImportStatus("importing");
try {
const result = await window.api.importConfigFromFile(selectedImportFile);
if (result.success) {
setImportBackupId(result.backupId || "");
setImportStatus("success");
// ImportProgressModal 会在2秒后触发数据刷新回调
} else {
setImportError(result.message || t("settings.configCorrupted"));
setImportStatus("error");
}
} catch (error) {
setImportError(String(error));
setImportStatus("error");
} finally {
setIsImporting(false);
}
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onMouseDown={(e) => {
if (e.target === e.currentTarget) handleCancel();
}}
>
<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] max-h-[90vh] flex flex-col 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">
{t("settings.title")}
</h2>
<button
onClick={handleCancel}
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
>
<X size={20} className="text-gray-500 dark:text-gray-400" />
</button>
</div>
{/* 设置内容 */}
<div className="px-6 py-4 space-y-6 overflow-y-auto flex-1">
{/* 语言设置 */}
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
{t("settings.language")}
</h3>
<div className="inline-flex p-0.5 bg-gray-100 dark:bg-gray-800 rounded-lg">
<button
type="button"
onClick={() => handleLanguageChange("zh")}
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-all min-w-[80px] ${
(settings.language ?? "zh") === "zh"
? "bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm"
: "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
}`}
>
{t("settings.languageOptionChinese")}
</button>
<button
type="button"
onClick={() => handleLanguageChange("en")}
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-all min-w-[80px] ${
settings.language === "en"
? "bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm"
: "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
}`}
>
{t("settings.languageOptionEnglish")}
</button>
</div>
</div>
{/* 窗口行为设置 */}
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
{t("settings.windowBehavior")}
</h3>
<div className="space-y-3">
<label className="flex items-center justify-between">
<div>
<span className="text-sm text-gray-900 dark:text-gray-100">
{t("settings.minimizeToTray")}
</span>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t("settings.minimizeToTrayDescription")}
</p>
</div>
<input
type="checkbox"
checked={settings.minimizeToTrayOnClose}
onChange={(e) =>
setSettings((prev) => ({
...prev,
minimizeToTrayOnClose: e.target.checked,
}))
}
className="w-4 h-4 text-blue-500 rounded focus:ring-blue-500/20"
/>
</label>
{/* Claude 插件联动开关 */}
<label className="flex items-center justify-between">
<div>
<span className="text-sm text-gray-900 dark:text-gray-100">
{t("settings.enableClaudePluginIntegration")}
</span>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1 max-w-[34rem]">
{t("settings.enableClaudePluginIntegrationDescription")}
</p>
</div>
<input
type="checkbox"
checked={!!settings.enableClaudePluginIntegration}
onChange={(e) =>
setSettings((prev) => ({
...prev,
enableClaudePluginIntegration: e.target.checked,
}))
}
className="w-4 h-4 text-blue-500 rounded focus:ring-blue-500/20"
/>
</label>
</div>
</div>
{/* 配置文件位置 */}
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
{t("settings.configFileLocation")}
</h3>
<div className="flex items-center gap-2">
<div className="flex-1 px-3 py-2 bg-gray-100 dark:bg-gray-800 rounded-lg">
<span className="text-xs font-mono text-gray-500 dark:text-gray-400">
{configPath || t("common.loading")}
</span>
</div>
<button
onClick={handleOpenConfigFolder}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
title={t("settings.openFolder")}
>
<FolderOpen
size={18}
className="text-gray-500 dark:text-gray-400"
/>
</button>
</div>
</div>
{/* 配置目录覆盖 */}
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">
{t("settings.configDirectoryOverride")}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3 leading-relaxed">
{t("settings.configDirectoryDescription")}
</p>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
{t("settings.claudeConfigDir")}
</label>
<div className="flex gap-2">
<input
type="text"
value={settings.claudeConfigDir ?? resolvedClaudeDir ?? ""}
onChange={(e) =>
setSettings({
...settings,
claudeConfigDir: e.target.value,
})
}
placeholder={t("settings.browsePlaceholderClaude")}
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={t("settings.browseDirectory")}
>
<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={t("settings.resetDefault")}
>
<Undo2 size={16} />
</button>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
{t("settings.codexConfigDir")}
</label>
<div className="flex gap-2">
<input
type="text"
value={settings.codexConfigDir ?? resolvedCodexDir ?? ""}
onChange={(e) =>
setSettings({
...settings,
codexConfigDir: e.target.value,
})
}
placeholder={t("settings.browsePlaceholderCodex")}
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={t("settings.browseDirectory")}
>
<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={t("settings.resetDefault")}
>
<Undo2 size={16} />
</button>
</div>
</div>
</div>
</div>
{/* 导入导出 */}
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
{t("settings.importExport")}
</h3>
<div className="p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
<div className="space-y-3">
{/* 导出按钮 */}
<button
onClick={handleExportConfig}
className="w-full flex items-center justify-center gap-2 px-3 py-2 text-xs font-medium rounded-lg transition-colors bg-gray-500 hover:bg-gray-600 dark:bg-gray-600 dark:hover:bg-gray-700 text-white"
>
<Save size={12} />
{t("settings.exportConfig")}
</button>
{/* 导入区域 */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<button
onClick={handleSelectImportFile}
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 text-xs font-medium rounded-lg transition-colors bg-gray-500 hover:bg-gray-600 dark:bg-gray-600 dark:hover:bg-gray-700 text-white"
>
<FolderOpen size={12} />
{t("settings.selectConfigFile")}
</button>
<button
onClick={handleExecuteImport}
disabled={!selectedImportFile || isImporting}
className={`px-3 py-2 text-xs font-medium rounded-lg transition-colors text-white ${
!selectedImportFile || isImporting
? "bg-gray-400 cursor-not-allowed"
: "bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700"
}`}
>
{isImporting
? t("settings.importing")
: t("settings.import")}
</button>
</div>
{/* 显示选择的文件 */}
{selectedImportFile && (
<div className="text-xs text-gray-600 dark:text-gray-400 px-2 py-1 bg-gray-50 dark:bg-gray-900 rounded break-all">
{selectedImportFile.split("/").pop() ||
selectedImportFile.split("\\").pop() ||
selectedImportFile}
</div>
)}
</div>
</div>
</div>
</div>
{/* 关于 */}
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
{t("common.about")}
</h3>
<div className="p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
<div className="flex items-start justify-between">
<div>
<div className="text-sm">
<p className="font-medium text-gray-900 dark:text-gray-100">
CC Switch
</p>
<p className="mt-1 text-gray-500 dark:text-gray-400">
{t("common.version")} {version}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleOpenReleaseNotes}
className="px-2 py-1 text-xs font-medium text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 rounded-lg hover:bg-blue-500/10 transition-colors"
title={
hasUpdate
? t("settings.viewReleaseNotes")
: t("settings.viewCurrentReleaseNotes")
}
>
<span className="inline-flex items-center gap-1">
<ExternalLink size={12} />
{t("settings.releaseNotes")}
</span>
</button>
<button
onClick={handleCheckUpdate}
disabled={isCheckingUpdate || isDownloading}
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 border border-transparent"
: hasUpdate
? "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"
}`}
>
{isDownloading ? (
<span className="flex items-center gap-1">
<Download size={12} className="animate-pulse" />
{t("settings.updating")}
</span>
) : isCheckingUpdate ? (
<span className="flex items-center gap-1">
<RefreshCw size={12} className="animate-spin" />
{t("settings.checking")}
</span>
) : hasUpdate ? (
<span className="flex items-center gap-1">
<Download size={12} />
{t("settings.updateTo", {
version: updateInfo?.availableVersion ?? "",
})}
</span>
) : showUpToDate ? (
<span className="flex items-center gap-1">
<Check size={12} />
{t("settings.upToDate")}
</span>
) : (
t("settings.checkForUpdates")
)}
</button>
</div>
</div>
</div>
</div>
</div>
{/* 底部按钮 */}
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-800">
<button
onClick={handleCancel}
className="px-4 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
>
{t("common.cancel")}
</button>
<button
onClick={saveSettings}
className="px-4 py-2 text-sm font-medium text-white bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 rounded-lg transition-colors flex items-center gap-2"
>
<Save size={16} />
{t("common.save")}
</button>
</div>
</div>
{/* Import Progress Modal */}
{importStatus !== "idle" && (
<ImportProgressModal
status={importStatus}
message={importError}
backupId={importBackupId}
onComplete={() => {
setImportStatus("idle");
setImportError("");
setSelectedImportFile("");
}}
onSuccess={() => {
if (onImportSuccess) {
void onImportSuccess();
}
void window.api
.updateTrayMenu()
.catch((error) =>
console.error(
"[SettingsModal] Failed to refresh tray menu",
error,
),
);
}}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,63 @@
import { X, Download } from "lucide-react";
import { useUpdate } from "../contexts/UpdateContext";
import { useTranslation } from "react-i18next";
interface UpdateBadgeProps {
className?: string;
onClick?: () => void;
}
export function UpdateBadge({ className = "", onClick }: UpdateBadgeProps) {
const { hasUpdate, updateInfo, isDismissed, dismissUpdate } = useUpdate();
const { t } = useTranslation();
// 如果没有更新或已关闭,不显示
if (!hasUpdate || isDismissed || !updateInfo) {
return null;
}
return (
<div
className={`
flex items-center gap-1.5 px-2.5 py-1
bg-white dark:bg-gray-800
border border-gray-200 dark:border-gray-700
rounded-lg text-xs
shadow-sm
transition-all duration-200
${onClick ? "cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-750" : ""}
${className}
`}
role={onClick ? "button" : undefined}
tabIndex={onClick ? 0 : -1}
onClick={onClick}
onKeyDown={(e) => {
if (!onClick) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
}
}}
>
<Download className="w-3 h-3 text-blue-500 dark:text-blue-400" />
<span className="text-gray-700 dark:text-gray-300 font-medium">
v{updateInfo.availableVersion}
</span>
<button
onClick={(e) => {
e.stopPropagation();
dismissUpdate();
}}
className="
ml-1 -mr-0.5 p-0.5 rounded
hover:bg-gray-100 dark:hover:bg-gray-700
transition-colors
focus:outline-none focus:ring-2 focus:ring-blue-500/20
"
aria-label={t("common.close")}
>
<X className="w-3 h-3 text-gray-400 dark:text-gray-500" />
</button>
</div>
);
}

View File

@@ -0,0 +1,669 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { X, Save, AlertCircle, ChevronDown, ChevronUp } from "lucide-react";
import { McpServer, McpServerSpec } from "../../types";
import {
mcpPresets,
getMcpPresetWithDescription,
} from "../../config/mcpPresets";
import { buttonStyles, inputStyles } from "../../lib/styles";
import McpWizardModal from "./McpWizardModal";
import {
extractErrorMessage,
translateMcpBackendError,
} from "../../utils/errorUtils";
import { AppType } from "../../lib/tauri-api";
import {
validateToml,
tomlToMcpServer,
extractIdFromToml,
mcpServerToToml,
} from "../../utils/tomlUtils";
interface McpFormModalProps {
appType: AppType;
editingId?: string;
initialData?: McpServer;
onSave: (id: string, server: McpServer) => Promise<void>;
onClose: () => void;
existingIds?: string[];
onNotify?: (
message: string,
type: "success" | "error",
duration?: number,
) => void;
}
/**
* MCP 表单模态框组件(简化版)
* Claude: 使用 JSON 格式
* Codex: 使用 TOML 格式
*/
const McpFormModal: React.FC<McpFormModalProps> = ({
appType,
editingId,
initialData,
onSave,
onClose,
existingIds = [],
onNotify,
}) => {
const { t } = useTranslation();
// JSON 基本校验(返回 i18n 文案)
const validateJson = (text: string): string => {
if (!text.trim()) return "";
try {
const parsed = JSON.parse(text);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return t("mcp.error.jsonInvalid");
}
return "";
} catch {
return t("mcp.error.jsonInvalid");
}
};
// 统一格式化 TOML 错误(本地化 + 详情)
const formatTomlError = (err: string): string => {
if (!err) return "";
if (err === "mustBeObject" || err === "parseError") {
return t("mcp.error.tomlInvalid");
}
return `${t("mcp.error.tomlInvalid")}: ${err}`;
};
const [formId, setFormId] = useState(
() => editingId || initialData?.id || "",
);
const [formName, setFormName] = useState(initialData?.name || "");
const [formDescription, setFormDescription] = useState(
initialData?.description || "",
);
const [formHomepage, setFormHomepage] = useState(initialData?.homepage || "");
const [formDocs, setFormDocs] = useState(initialData?.docs || "");
const [formTags, setFormTags] = useState(initialData?.tags?.join(", ") || "");
// 编辑模式下禁止修改 ID
const isEditing = !!editingId;
// 判断是否在编辑模式下有附加信息
const hasAdditionalInfo = !!(
initialData?.description ||
initialData?.tags?.length ||
initialData?.homepage ||
initialData?.docs
);
// 附加信息展开状态(编辑模式下有值时默认展开)
const [showMetadata, setShowMetadata] = useState(
isEditing ? hasAdditionalInfo : false,
);
// 根据 appType 决定初始格式
const [formConfig, setFormConfig] = useState(() => {
const spec = initialData?.server;
if (!spec) return "";
if (appType === "codex") {
return mcpServerToToml(spec);
}
return JSON.stringify(spec, null, 2);
});
const [configError, setConfigError] = useState("");
const [saving, setSaving] = useState(false);
const [isWizardOpen, setIsWizardOpen] = useState(false);
const [idError, setIdError] = useState("");
// 判断是否使用 TOML 格式
const useToml = appType === "codex";
// 预设选择状态(仅新增模式显示;-1 表示自定义)
const [selectedPreset, setSelectedPreset] = useState<number | null>(
isEditing ? null : -1,
);
const handleIdChange = (value: string) => {
setFormId(value);
if (!isEditing) {
const exists = existingIds.includes(value.trim());
setIdError(exists ? t("mcp.error.idExists") : "");
}
};
const ensureUniqueId = (base: string): string => {
let candidate = base.trim();
if (!candidate) candidate = "mcp-server";
if (!existingIds.includes(candidate)) return candidate;
let i = 1;
while (existingIds.includes(`${candidate}-${i}`)) i++;
return `${candidate}-${i}`;
};
// 应用预设(写入表单但不落库)
const applyPreset = (index: number) => {
if (index < 0 || index >= mcpPresets.length) return;
const preset = mcpPresets[index];
const presetWithDesc = getMcpPresetWithDescription(preset, t);
const id = ensureUniqueId(presetWithDesc.id);
setFormId(id);
setFormName(presetWithDesc.name || presetWithDesc.id);
setFormDescription(presetWithDesc.description || "");
setFormHomepage(presetWithDesc.homepage || "");
setFormDocs(presetWithDesc.docs || "");
setFormTags(presetWithDesc.tags?.join(", ") || "");
// 根据格式转换配置
if (useToml) {
const toml = mcpServerToToml(presetWithDesc.server);
setFormConfig(toml);
{
const err = validateToml(toml);
setConfigError(formatTomlError(err));
}
} else {
const json = JSON.stringify(presetWithDesc.server, null, 2);
setFormConfig(json);
setConfigError(validateJson(json));
}
setSelectedPreset(index);
};
// 切回自定义
const applyCustom = () => {
setSelectedPreset(-1);
// 恢复到空白模板
setFormId("");
setFormName("");
setFormDescription("");
setFormHomepage("");
setFormDocs("");
setFormTags("");
setFormConfig("");
setConfigError("");
};
const handleConfigChange = (value: string) => {
setFormConfig(value);
if (useToml) {
// TOML 校验
const err = validateToml(value);
if (err) {
setConfigError(formatTomlError(err));
return;
}
// 尝试解析并做必填字段提示
if (value.trim()) {
try {
const server = tomlToMcpServer(value);
if (server.type === "stdio" && !server.command?.trim()) {
setConfigError(t("mcp.error.commandRequired"));
return;
}
if (server.type === "http" && !server.url?.trim()) {
setConfigError(t("mcp.wizard.urlRequired"));
return;
}
// 尝试提取 ID如果用户还没有填写
if (!formId.trim()) {
const extractedId = extractIdFromToml(value);
if (extractedId) {
setFormId(extractedId);
}
}
} catch (e: any) {
const msg = e?.message || String(e);
setConfigError(formatTomlError(msg));
return;
}
}
} else {
// JSON 校验
const baseErr = validateJson(value);
if (baseErr) {
setConfigError(baseErr);
return;
}
// 进一步结构校验
if (value.trim()) {
try {
const obj = JSON.parse(value);
if (obj && typeof obj === "object") {
if (Object.prototype.hasOwnProperty.call(obj, "mcpServers")) {
setConfigError(t("mcp.error.singleServerObjectRequired"));
return;
}
const typ = (obj as any)?.type;
if (typ === "stdio" && !(obj as any)?.command?.trim()) {
setConfigError(t("mcp.error.commandRequired"));
return;
}
if (typ === "http" && !(obj as any)?.url?.trim()) {
setConfigError(t("mcp.wizard.urlRequired"));
return;
}
}
} catch {
// 解析异常已在基础校验覆盖
}
}
}
setConfigError("");
};
const handleWizardApply = (title: string, json: string) => {
setFormId(title);
if (!formName.trim()) {
setFormName(title);
}
// Wizard 返回的是 JSON根据格式决定是否需要转换
if (useToml) {
try {
const server = JSON.parse(json) as McpServerSpec;
const toml = mcpServerToToml(server);
setFormConfig(toml);
const err = validateToml(toml);
setConfigError(formatTomlError(err));
} catch (e: any) {
setConfigError(t("mcp.error.jsonInvalid"));
}
} else {
setFormConfig(json);
setConfigError(validateJson(json));
}
};
const handleSubmit = async () => {
const trimmedId = formId.trim();
if (!trimmedId) {
onNotify?.(t("mcp.error.idRequired"), "error", 3000);
return;
}
// 新增模式:阻止提交重名 ID
if (!isEditing && existingIds.includes(trimmedId)) {
setIdError(t("mcp.error.idExists"));
return;
}
// 验证配置格式
let serverSpec: McpServerSpec;
if (useToml) {
// TOML 模式
const tomlError = validateToml(formConfig);
setConfigError(formatTomlError(tomlError));
if (tomlError) {
onNotify?.(t("mcp.error.tomlInvalid"), "error", 3000);
return;
}
if (!formConfig.trim()) {
// 空配置
serverSpec = {
type: "stdio",
command: "",
args: [],
};
} else {
try {
serverSpec = tomlToMcpServer(formConfig);
} catch (e: any) {
const msg = e?.message || String(e);
setConfigError(formatTomlError(msg));
onNotify?.(t("mcp.error.tomlInvalid"), "error", 4000);
return;
}
}
} else {
// JSON 模式
const jsonError = validateJson(formConfig);
setConfigError(jsonError);
if (jsonError) {
onNotify?.(t("mcp.error.jsonInvalid"), "error", 3000);
return;
}
if (!formConfig.trim()) {
// 空配置
serverSpec = {
type: "stdio",
command: "",
args: [],
};
} else {
try {
serverSpec = JSON.parse(formConfig) as McpServerSpec;
} catch (e: any) {
setConfigError(t("mcp.error.jsonInvalid"));
onNotify?.(t("mcp.error.jsonInvalid"), "error", 4000);
return;
}
}
}
// 前置必填校验
if (serverSpec?.type === "stdio" && !serverSpec?.command?.trim()) {
onNotify?.(t("mcp.error.commandRequired"), "error", 3000);
return;
}
if (serverSpec?.type === "http" && !serverSpec?.url?.trim()) {
onNotify?.(t("mcp.wizard.urlRequired"), "error", 3000);
return;
}
setSaving(true);
try {
const entry: McpServer = {
...(initialData ? { ...initialData } : {}),
id: trimmedId,
server: serverSpec,
};
if (initialData?.enabled !== undefined) {
entry.enabled = initialData.enabled;
} else if (!initialData) {
delete entry.enabled;
}
const nameTrimmed = (formName || trimmedId).trim();
entry.name = nameTrimmed || trimmedId;
const descriptionTrimmed = formDescription.trim();
if (descriptionTrimmed) {
entry.description = descriptionTrimmed;
} else {
delete entry.description;
}
const homepageTrimmed = formHomepage.trim();
if (homepageTrimmed) {
entry.homepage = homepageTrimmed;
} else {
delete entry.homepage;
}
const docsTrimmed = formDocs.trim();
if (docsTrimmed) {
entry.docs = docsTrimmed;
} else {
delete entry.docs;
}
const parsedTags = formTags
.split(",")
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0);
if (parsedTags.length > 0) {
entry.tags = parsedTags;
} else {
delete entry.tags;
}
// 显式等待父组件保存流程
await onSave(trimmedId, entry);
} catch (error: any) {
const detail = extractErrorMessage(error);
const mapped = translateMcpBackendError(detail, t);
const msg = mapped || detail || t("mcp.error.saveFailed");
onNotify?.(msg, "error", mapped || detail ? 6000 : 4000);
} finally {
setSaving(false);
}
};
const getFormTitle = () => {
if (appType === "claude") {
return isEditing ? t("mcp.editClaudeServer") : t("mcp.addClaudeServer");
} else {
return isEditing ? t("mcp.editCodexServer") : t("mcp.addCodexServer");
}
};
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg max-w-3xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex-shrink-0 flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{getFormTitle()}
</h3>
<button
onClick={onClose}
className="p-1 text-gray-500 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
>
<X size={18} />
</button>
</div>
{/* Content - Scrollable */}
<div className="flex-1 overflow-y-auto p-6 space-y-4">
{/* 预设选择(仅新增时展示) */}
{!isEditing && (
<div>
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
{t("mcp.presets.title")}
</label>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={applyCustom}
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
selectedPreset === -1
? "bg-emerald-500 text-white dark:bg-emerald-600"
: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
}`}
>
{t("presetSelector.custom")}
</button>
{mcpPresets.map((preset, idx) => {
const descriptionKey = `mcp.presets.${preset.id}.description`;
return (
<button
key={preset.id}
type="button"
onClick={() => applyPreset(idx)}
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
selectedPreset === idx
? "bg-emerald-500 text-white dark:bg-emerald-600"
: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
}`}
title={t(descriptionKey)}
>
{preset.id}
</button>
);
})}
</div>
</div>
)}
{/* ID (标题) */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
{t("mcp.form.title")} <span className="text-red-500">*</span>
</label>
{!isEditing && idError && (
<span className="text-xs text-red-500 dark:text-red-400">
{idError}
</span>
)}
</div>
<input
className={inputStyles.text}
placeholder={t("mcp.form.titlePlaceholder")}
value={formId}
onChange={(e) => handleIdChange(e.target.value)}
disabled={isEditing}
/>
</div>
{/* Name */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t("mcp.form.name")}
</label>
<input
className={inputStyles.text}
placeholder={t("mcp.form.namePlaceholder")}
value={formName}
onChange={(e) => setFormName(e.target.value)}
/>
</div>
{/* 可折叠的附加信息按钮 */}
<div>
<button
type="button"
onClick={() => setShowMetadata(!showMetadata)}
className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
>
{showMetadata ? (
<ChevronUp size={16} />
) : (
<ChevronDown size={16} />
)}
{t("mcp.form.additionalInfo")}
</button>
</div>
{/* 附加信息区域(可折叠) */}
{showMetadata && (
<>
{/* Description (描述) */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t("mcp.form.description")}
</label>
<input
className={inputStyles.text}
placeholder={t("mcp.form.descriptionPlaceholder")}
value={formDescription}
onChange={(e) => setFormDescription(e.target.value)}
/>
</div>
{/* Tags */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t("mcp.form.tags")}
</label>
<input
className={inputStyles.text}
placeholder={t("mcp.form.tagsPlaceholder")}
value={formTags}
onChange={(e) => setFormTags(e.target.value)}
/>
</div>
{/* Homepage */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t("mcp.form.homepage")}
</label>
<input
className={inputStyles.text}
placeholder={t("mcp.form.homepagePlaceholder")}
value={formHomepage}
onChange={(e) => setFormHomepage(e.target.value)}
/>
</div>
{/* Docs */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t("mcp.form.docs")}
</label>
<input
className={inputStyles.text}
placeholder={t("mcp.form.docsPlaceholder")}
value={formDocs}
onChange={(e) => setFormDocs(e.target.value)}
/>
</div>
</>
)}
{/* 配置输入框(根据格式显示 JSON 或 TOML */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
{useToml ? t("mcp.form.tomlConfig") : t("mcp.form.jsonConfig")}
</label>
{(isEditing || selectedPreset === -1) && (
<button
type="button"
onClick={() => setIsWizardOpen(true)}
className="text-sm text-blue-500 dark:text-blue-400 hover:text-blue-600 dark:hover:text-blue-300 transition-colors"
>
{t("mcp.form.useWizard")}
</button>
)}
</div>
<textarea
className={`${inputStyles.text} h-48 resize-none font-mono text-xs`}
placeholder={
useToml
? t("mcp.form.tomlPlaceholder")
: t("mcp.form.jsonPlaceholder")
}
value={formConfig}
onChange={(e) => handleConfigChange(e.target.value)}
/>
{configError && (
<div className="flex items-center gap-2 mt-2 text-red-500 dark:text-red-400 text-sm">
<AlertCircle size={16} />
<span>{configError}</span>
</div>
)}
</div>
</div>
{/* Footer */}
<div className="flex-shrink-0 flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-800">
<button
onClick={onClose}
className="px-4 py-2 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-gray-200 rounded-lg transition-colors text-sm font-medium"
>
{t("common.cancel")}
</button>
<button
onClick={handleSubmit}
disabled={saving || (!isEditing && !!idError)}
className={`inline-flex items-center gap-2 ${buttonStyles.mcp}`}
>
<Save size={16} />
{saving
? t("common.saving")
: isEditing
? t("common.save")
: t("common.add")}
</button>
</div>
</div>
{/* Wizard Modal */}
<McpWizardModal
isOpen={isWizardOpen}
onClose={() => setIsWizardOpen(false)}
onApply={handleWizardApply}
onNotify={onNotify}
/>
</div>
);
};
export default McpFormModal;

View File

@@ -0,0 +1,117 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Edit3, Trash2 } from "lucide-react";
import { McpServer } from "../../types";
import { mcpPresets } from "../../config/mcpPresets";
import { cardStyles, buttonStyles, cn } from "../../lib/styles";
import McpToggle from "./McpToggle";
interface McpListItemProps {
id: string;
server: McpServer;
onToggle: (id: string, enabled: boolean) => void;
onEdit: (id: string) => void;
onDelete: (id: string) => void;
}
/**
* MCP 列表项组件
* 每个 MCP 占一行,左侧是 Toggle 开关,中间是名称和详细信息,右侧是编辑和删除按钮
*/
const McpListItem: React.FC<McpListItemProps> = ({
id,
server,
onToggle,
onEdit,
onDelete,
}) => {
const { t } = useTranslation();
// 仅当显式为 true 时视为启用;避免 undefined 被误判为启用
const enabled = server.enabled === true;
const name = server.name || id;
// 只显示 description没有则留空
const description = server.description || "";
// 匹配预设元信息(用于展示文档链接等)
const meta = mcpPresets.find((p) => p.id === id);
const docsUrl = server.docs || meta?.docs;
const homepageUrl = server.homepage || meta?.homepage;
const tags = server.tags || meta?.tags;
const openDocs = async () => {
const url = docsUrl || homepageUrl;
if (!url) return;
try {
await window.api.openExternal(url);
} catch {
// ignore
}
};
return (
<div className={cn(cardStyles.interactive, "!p-4 h-16")}>
<div className="flex items-center gap-4 h-full">
{/* 左侧Toggle 开关 */}
<div className="flex-shrink-0">
<McpToggle
enabled={enabled}
onChange={(newEnabled) => onToggle(id, newEnabled)}
/>
</div>
{/* 中间:名称和详细信息 */}
<div className="flex-1 min-w-0">
<h3 className="font-medium text-gray-900 dark:text-gray-100 mb-1">
{name}
</h3>
{description && (
<p className="text-sm text-gray-500 dark:text-gray-400 truncate">
{description}
</p>
)}
{!description && tags && tags.length > 0 && (
<p className="text-xs text-gray-400 dark:text-gray-500 truncate">
{tags.join(", ")}
</p>
)}
{/* 预设标记已移除 */}
</div>
{/* 右侧:操作按钮 */}
<div className="flex items-center gap-2 flex-shrink-0">
{docsUrl && (
<button
onClick={openDocs}
className={buttonStyles.ghost}
title={t("mcp.presets.docs")}
>
{t("mcp.presets.docs")}
</button>
)}
<button
onClick={() => onEdit(id)}
className={buttonStyles.icon}
title={t("common.edit")}
>
<Edit3 size={16} />
</button>
<button
onClick={() => onDelete(id)}
className={cn(
buttonStyles.icon,
"hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10",
)}
title={t("common.delete")}
>
<Trash2 size={16} />
</button>
</div>
</div>
</div>
);
};
export default McpListItem;

View File

@@ -0,0 +1,306 @@
import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { X, Plus, Server, Check } from "lucide-react";
import { McpServer } from "../../types";
import McpListItem from "./McpListItem";
import McpFormModal from "./McpFormModal";
import { ConfirmDialog } from "../ConfirmDialog";
import {
extractErrorMessage,
translateMcpBackendError,
} from "../../utils/errorUtils";
// 预设相关逻辑已迁移到“新增 MCP”面板列表此处无需引用
import { buttonStyles } from "../../lib/styles";
import { AppType } from "../../lib/tauri-api";
interface McpPanelProps {
onClose: () => void;
onNotify?: (
message: string,
type: "success" | "error",
duration?: number,
) => void;
appType: AppType;
}
/**
* MCP 管理面板
* 采用与主界面一致的设计风格,右上角添加按钮,每个 MCP 占一行
*/
const McpPanel: React.FC<McpPanelProps> = ({ onClose, onNotify, appType }) => {
const { t } = useTranslation();
const [servers, setServers] = useState<Record<string, McpServer>>({});
const [loading, setLoading] = useState(true);
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [confirmDialog, setConfirmDialog] = useState<{
isOpen: boolean;
title: string;
message: string;
onConfirm: () => void;
} | null>(null);
const reload = async () => {
setLoading(true);
try {
const cfg = await window.api.getMcpConfig(appType);
setServers(cfg.servers || {});
} finally {
setLoading(false);
}
};
useEffect(() => {
const setup = async () => {
try {
// 初始化:仅从对应客户端导入已有 MCP不做“预设落库”
if (appType === "claude") {
await window.api.importMcpFromClaude();
} else if (appType === "codex") {
await window.api.importMcpFromCodex();
}
} catch (e) {
console.warn("MCP 初始化导入失败(忽略继续)", e);
} finally {
await reload();
}
};
setup();
// appType 改变时重新初始化
}, [appType]);
const handleToggle = async (id: string, enabled: boolean) => {
// 乐观更新:立即更新 UI
const previousServers = servers;
setServers((prev) => ({
...prev,
[id]: {
...prev[id],
enabled,
},
}));
try {
// 后台调用 API
await window.api.setMcpEnabled(appType, id, enabled);
onNotify?.(
enabled ? t("mcp.msg.enabled") : t("mcp.msg.disabled"),
"success",
1500,
);
} catch (e: any) {
// 失败时回滚
setServers(previousServers);
const detail = extractErrorMessage(e);
const mapped = translateMcpBackendError(detail, t);
onNotify?.(
mapped || detail || t("mcp.error.saveFailed"),
"error",
mapped || detail ? 6000 : 5000,
);
}
};
const handleEdit = (id: string) => {
setEditingId(id);
setIsFormOpen(true);
};
const handleAdd = () => {
setEditingId(null);
setIsFormOpen(true);
};
const handleDelete = (id: string) => {
setConfirmDialog({
isOpen: true,
title: t("mcp.confirm.deleteTitle"),
message: t("mcp.confirm.deleteMessage", { id }),
onConfirm: async () => {
try {
await window.api.deleteMcpServerInConfig(appType, id);
await reload();
setConfirmDialog(null);
onNotify?.(t("mcp.msg.deleted"), "success", 1500);
} catch (e: any) {
const detail = extractErrorMessage(e);
const mapped = translateMcpBackendError(detail, t);
onNotify?.(
mapped || detail || t("mcp.error.deleteFailed"),
"error",
mapped || detail ? 6000 : 5000,
);
}
},
});
};
const handleSave = async (id: string, server: McpServer) => {
try {
const payload: McpServer = { ...server, id };
await window.api.upsertMcpServerInConfig(appType, id, payload);
await reload();
setIsFormOpen(false);
setEditingId(null);
onNotify?.(t("mcp.msg.saved"), "success", 1500);
} catch (e: any) {
const detail = extractErrorMessage(e);
const mapped = translateMcpBackendError(detail, t);
onNotify?.(
mapped || detail || t("mcp.error.saveFailed"),
"error",
mapped || detail ? 6000 : 5000,
);
// 继续抛出错误,让表单层可以给到直观反馈(避免被更高层遮挡)
throw e;
}
};
const handleCloseForm = () => {
setIsFormOpen(false);
setEditingId(null);
};
const serverEntries = useMemo(
() => Object.entries(servers) as Array<[string, McpServer]>,
[servers],
);
const enabledCount = useMemo(
() => serverEntries.filter(([_, server]) => server.enabled).length,
[serverEntries],
);
const panelTitle =
appType === "claude" ? t("mcp.claudeTitle") : t("mcp.codexTitle");
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Panel */}
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg max-w-3xl w-full mx-4 overflow-hidden flex flex-col max-h-[85vh] min-h-[600px]">
{/* Header */}
<div className="flex-shrink-0 flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{panelTitle}
</h3>
<div className="flex items-center gap-3">
<button
onClick={handleAdd}
className={`inline-flex items-center gap-2 ${buttonStyles.mcp}`}
>
<Plus size={16} />
{t("mcp.add")}
</button>
<button
onClick={onClose}
className="p-1 text-gray-500 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
>
<X size={18} />
</button>
</div>
</div>
{/* Info Section */}
<div className="flex-shrink-0 px-6 pt-4 pb-2">
<div className="text-sm text-gray-500 dark:text-gray-400">
{t("mcp.serverCount", { count: Object.keys(servers).length })} ·{" "}
{t("mcp.enabledCount", { count: enabledCount })}
</div>
</div>
{/* Content - Scrollable */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{loading ? (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
{t("mcp.loading")}
</div>
) : (
(() => {
const hasAny = serverEntries.length > 0;
if (!hasAny) {
return (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
<Server
size={24}
className="text-gray-400 dark:text-gray-500"
/>
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
{t("mcp.empty")}
</h3>
<p className="text-gray-500 dark:text-gray-400 text-sm">
{t("mcp.emptyDescription")}
</p>
</div>
);
}
return (
<div className="space-y-3">
{/* 已安装 */}
{serverEntries.map(([id, server]) => (
<McpListItem
key={`installed-${id}`}
id={id}
server={server}
onToggle={handleToggle}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))}
{/* 预设已移至"新增 MCP"面板中展示与套用 */}
</div>
);
})()
)}
</div>
{/* Footer */}
<div className="flex-shrink-0 flex items-center justify-end p-6 border-t border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-800">
<button
onClick={onClose}
className={`inline-flex items-center gap-2 ${buttonStyles.mcp}`}
>
<Check size={16} />
{t("common.done")}
</button>
</div>
</div>
{/* Form Modal */}
{isFormOpen && (
<McpFormModal
appType={appType}
editingId={editingId || undefined}
initialData={editingId ? servers[editingId] : undefined}
existingIds={Object.keys(servers)}
onSave={handleSave}
onClose={handleCloseForm}
onNotify={onNotify}
/>
)}
{/* Confirm Dialog */}
{confirmDialog && (
<ConfirmDialog
isOpen={confirmDialog.isOpen}
title={confirmDialog.title}
message={confirmDialog.message}
onConfirm={confirmDialog.onConfirm}
onCancel={() => setConfirmDialog(null)}
/>
)}
</div>
);
};
export default McpPanel;

View File

@@ -0,0 +1,41 @@
import React from "react";
interface McpToggleProps {
enabled: boolean;
onChange: (enabled: boolean) => void;
disabled?: boolean;
}
/**
* Toggle 开关组件
* 启用时为淡绿色,禁用时为灰色
*/
const McpToggle: React.FC<McpToggleProps> = ({
enabled,
onChange,
disabled = false,
}) => {
return (
<button
type="button"
role="switch"
aria-checked={enabled}
disabled={disabled}
onClick={() => onChange(!enabled)}
className={`
relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500/20
${enabled ? "bg-emerald-500 dark:bg-emerald-600" : "bg-gray-300 dark:bg-gray-600"}
${disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}
`}
>
<span
className={`
inline-block h-4 w-4 transform rounded-full bg-white transition-transform
${enabled ? "translate-x-6" : "translate-x-1"}
`}
/>
</button>
);
};
export default McpToggle;

View File

@@ -0,0 +1,390 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { X, Save } from "lucide-react";
import { McpServerSpec } from "../../types";
import { isLinux } from "../../lib/platform";
interface McpWizardModalProps {
isOpen: boolean;
onClose: () => void;
onApply: (title: string, json: string) => void;
onNotify?: (
message: string,
type: "success" | "error",
duration?: number,
) => void;
}
/**
* 解析环境变量文本为对象
*/
const parseEnvText = (text: string): Record<string, string> => {
const lines = text
.split("\n")
.map((l) => l.trim())
.filter((l) => l.length > 0);
const env: Record<string, string> = {};
for (const l of lines) {
const idx = l.indexOf("=");
if (idx > 0) {
const k = l.slice(0, idx).trim();
const v = l.slice(idx + 1).trim();
if (k) env[k] = v;
}
}
return env;
};
/**
* 解析headers文本为对象支持 KEY: VALUE 或 KEY=VALUE
*/
const parseHeadersText = (text: string): Record<string, string> => {
const lines = text
.split("\n")
.map((l) => l.trim())
.filter((l) => l.length > 0);
const headers: Record<string, string> = {};
for (const l of lines) {
// 支持 KEY: VALUE 或 KEY=VALUE
const colonIdx = l.indexOf(":");
const equalIdx = l.indexOf("=");
let idx = -1;
if (colonIdx > 0 && (equalIdx === -1 || colonIdx < equalIdx)) {
idx = colonIdx;
} else if (equalIdx > 0) {
idx = equalIdx;
}
if (idx > 0) {
const k = l.slice(0, idx).trim();
const v = l.slice(idx + 1).trim();
if (k) headers[k] = v;
}
}
return headers;
};
/**
* MCP 配置向导模态框
* 帮助用户快速生成 MCP JSON 配置
*/
const McpWizardModal: React.FC<McpWizardModalProps> = ({
isOpen,
onClose,
onApply,
onNotify,
}) => {
const { t } = useTranslation();
const [wizardType, setWizardType] = useState<"stdio" | "http">("stdio");
const [wizardTitle, setWizardTitle] = useState("");
// stdio 字段
const [wizardCommand, setWizardCommand] = useState("");
const [wizardArgs, setWizardArgs] = useState("");
const [wizardEnv, setWizardEnv] = useState("");
// http 字段
const [wizardUrl, setWizardUrl] = useState("");
const [wizardHeaders, setWizardHeaders] = useState("");
// 生成预览 JSON
const generatePreview = (): string => {
const config: McpServerSpec = {
type: wizardType,
};
if (wizardType === "stdio") {
// stdio 类型必需字段
config.command = wizardCommand.trim();
// 可选字段
if (wizardArgs.trim()) {
config.args = wizardArgs
.split("\n")
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
if (wizardEnv.trim()) {
const env = parseEnvText(wizardEnv);
if (Object.keys(env).length > 0) {
config.env = env;
}
}
} else {
// http 类型必需字段
config.url = wizardUrl.trim();
// 可选字段
if (wizardHeaders.trim()) {
const headers = parseHeadersText(wizardHeaders);
if (Object.keys(headers).length > 0) {
config.headers = headers;
}
}
}
return JSON.stringify(config, null, 2);
};
const handleApply = () => {
if (!wizardTitle.trim()) {
onNotify?.(t("mcp.error.idRequired"), "error", 3000);
return;
}
if (wizardType === "stdio" && !wizardCommand.trim()) {
onNotify?.(t("mcp.error.commandRequired"), "error", 3000);
return;
}
if (wizardType === "http" && !wizardUrl.trim()) {
onNotify?.(t("mcp.wizard.urlRequired"), "error", 3000);
return;
}
const json = generatePreview();
onApply(wizardTitle.trim(), json);
handleClose();
};
const handleClose = () => {
// 重置表单
setWizardType("stdio");
setWizardTitle("");
setWizardCommand("");
setWizardArgs("");
setWizardEnv("");
setWizardUrl("");
setWizardHeaders("");
onClose();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && e.metaKey) {
e.preventDefault();
handleApply();
}
};
if (!isOpen) return null;
const preview = generatePreview();
return (
<div
className="fixed inset-0 z-[70] flex items-center justify-center"
onMouseDown={(e) => {
if (e.target === e.currentTarget) {
handleClose();
}
}}
>
{/* Backdrop */}
<div
className={`absolute inset-0 bg-black/50 dark:bg-black/70${
isLinux() ? "" : " backdrop-blur-sm"
}`}
/>
{/* Modal */}
<div className="relative mx-4 flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-white shadow-lg dark:bg-gray-900">
{/* Header */}
<div className="flex items-center justify-between border-b border-gray-200 p-6 dark:border-gray-800">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
{t("mcp.wizard.title")}
</h2>
<button
type="button"
onClick={handleClose}
className="rounded-md p-1 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100"
aria-label={t("common.close")}
>
<X size={18} />
</button>
</div>
{/* Content */}
<div className="flex-1 min-h-0 space-y-4 overflow-auto p-6">
{/* Hint */}
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-800 dark:bg-blue-900/20">
<p className="text-sm text-blue-800 dark:text-blue-200">
{t("mcp.wizard.hint")}
</p>
</div>
{/* Form Fields */}
<div className="space-y-4 min-h-[400px]">
{/* Type */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-900 dark:text-gray-100">
{t("mcp.wizard.type")} <span className="text-red-500">*</span>
</label>
<div className="flex gap-4">
<label className="inline-flex items-center gap-2 cursor-pointer">
<input
type="radio"
value="stdio"
checked={wizardType === "stdio"}
onChange={(e) =>
setWizardType(e.target.value as "stdio" | "http")
}
className="w-4 h-4 text-emerald-500 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 focus:ring-emerald-500 dark:focus:ring-emerald-400 focus:ring-2"
/>
<span className="text-sm text-gray-900 dark:text-gray-100">
{t("mcp.wizard.typeStdio")}
</span>
</label>
<label className="inline-flex items-center gap-2 cursor-pointer">
<input
type="radio"
value="http"
checked={wizardType === "http"}
onChange={(e) =>
setWizardType(e.target.value as "stdio" | "http")
}
className="w-4 h-4 text-emerald-500 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 focus:ring-emerald-500 dark:focus:ring-emerald-400 focus:ring-2"
/>
<span className="text-sm text-gray-900 dark:text-gray-100">
{t("mcp.wizard.typeHttp")}
</span>
</label>
</div>
</div>
{/* Title */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
{t("mcp.form.title")} <span className="text-red-500">*</span>
</label>
<input
type="text"
value={wizardTitle}
onChange={(e) => setWizardTitle(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t("mcp.form.titlePlaceholder")}
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
{/* Stdio 类型字段 */}
{wizardType === "stdio" && (
<>
{/* Command */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
{t("mcp.wizard.command")}{" "}
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={wizardCommand}
onChange={(e) => setWizardCommand(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t("mcp.wizard.commandPlaceholder")}
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
{/* Args */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
{t("mcp.wizard.args")}
</label>
<textarea
value={wizardArgs}
onChange={(e) => setWizardArgs(e.target.value)}
placeholder={t("mcp.wizard.argsPlaceholder")}
rows={3}
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 resize-y"
/>
</div>
{/* Env */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
{t("mcp.wizard.env")}
</label>
<textarea
value={wizardEnv}
onChange={(e) => setWizardEnv(e.target.value)}
placeholder={t("mcp.wizard.envPlaceholder")}
rows={3}
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 resize-y"
/>
</div>
</>
)}
{/* HTTP 类型字段 */}
{wizardType === "http" && (
<>
{/* URL */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
{t("mcp.wizard.url")}{" "}
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={wizardUrl}
onChange={(e) => setWizardUrl(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t("mcp.wizard.urlPlaceholder")}
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
{/* Headers */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
{t("mcp.wizard.headers")}
</label>
<textarea
value={wizardHeaders}
onChange={(e) => setWizardHeaders(e.target.value)}
placeholder={t("mcp.wizard.headersPlaceholder")}
rows={3}
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 resize-y"
/>
</div>
</>
)}
</div>
{/* Preview */}
{(wizardCommand ||
wizardArgs ||
wizardEnv ||
wizardUrl ||
wizardHeaders) && (
<div className="space-y-2 border-t border-gray-200 pt-4 dark:border-gray-700">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
{t("mcp.wizard.preview")}
</h3>
<pre className="overflow-x-auto rounded-lg bg-gray-50 p-3 text-xs font-mono text-gray-700 dark:bg-gray-800 dark:text-gray-300">
{preview}
</pre>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 border-t border-gray-200 bg-gray-100 p-6 dark:border-gray-800 dark:bg-gray-800">
<button
type="button"
onClick={handleClose}
className="rounded-lg px-4 py-2 text-sm font-medium text-gray-500 transition-colors hover:bg-white hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-100"
>
{t("common.cancel")}
</button>
<button
type="button"
onClick={handleApply}
className="flex items-center gap-2 rounded-lg bg-emerald-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700"
>
<Save className="h-4 w-4" />
{t("mcp.wizard.apply")}
</button>
</div>
</div>
</div>
);
};
export default McpWizardModal;

View File

@@ -1,20 +1,64 @@
/**
* Codex 预设供应商配置模板
*/
import { ProviderCategory } from "../types";
export interface CodexProviderPreset {
name: string;
websiteUrl: string;
// 第三方供应商可提供单独的获取 API Key 链接
apiKeyUrl?: string;
auth: Record<string, any>; // 将写入 ~/.codex/auth.json
config: string; // 将写入 ~/.codex/config.tomlTOML 字符串)
isOfficial?: boolean; // 标识是否为官方预设
category?: ProviderCategory; // 新增:分类
isCustomTemplate?: boolean; // 标识是否为自定义模板
// 新增:请求地址候选列表(用于地址管理/测速)
endpointCandidates?: string[];
}
/**
* 生成第三方供应商的 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"
requires_openai_auth = true`;
}
export const codexProviderPresets: CodexProviderPreset[] = [
{
name: "Codex官方",
name: "Codex Official",
websiteUrl: "https://chatgpt.com/codex",
isOfficial: true,
// 官方的 key 为null
category: "official",
auth: {
OPENAI_API_KEY: null,
},
@@ -23,19 +67,18 @@ export const codexProviderPresets: CodexProviderPreset[] = [
{
name: "PackyCode",
websiteUrl: "https://codex.packycode.com/",
// PackyCode 一般通过 API Key请将占位符替换为你的实际 key
auth: {
OPENAI_API_KEY: "sk-your-api-key-here",
},
config: `model_provider = "packycode"
model = "gpt-5"
model_reasoning_effort = "high"
disable_response_storage = true
[model_providers.packycode]
name = "packycode"
base_url = "https://codex-api.packycode.com/v1"
wire_api = "responses"
env_key = "packycode"`,
category: "third_party",
auth: generateThirdPartyAuth("sk-your-api-key-here"),
config: generateThirdPartyConfig(
"packycode",
"https://codex-api.packycode.com/v1",
"gpt-5-codex",
),
// Codex 请求地址候选(用于地址管理/测速)
endpointCandidates: [
"https://codex-api.packycode.com/v1",
"https://codex-api-hk-cn2.packycode.com/v1",
"https://codex-api-hk-cdn.packycode.com/v1",
],
},
];

85
src/config/mcpPresets.ts Normal file
View File

@@ -0,0 +1,85 @@
import { McpServer, McpServerSpec } from "../types";
export type McpPreset = Omit<McpServer, "enabled" | "description">;
// 预设 MCP逻辑简化版
// - 仅包含最常用、可快速落地的 stdio 模式示例
// - 不涉及分类/模板/测速等复杂逻辑,默认以 disabled 形式"回种"到 config.json
// - 用户可在 MCP 面板中一键启用/编辑
// - description 字段使用国际化 key在使用时通过 t() 函数获取翻译
export const mcpPresets: McpPreset[] = [
{
id: "fetch",
name: "mcp-server-fetch",
tags: ["stdio", "http", "web"],
server: {
type: "stdio",
command: "uvx",
args: ["mcp-server-fetch"],
} as McpServerSpec,
homepage: "https://github.com/modelcontextprotocol/servers",
docs: "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch",
},
{
id: "time",
name: "@modelcontextprotocol/server-time",
tags: ["stdio", "time", "utility"],
server: {
type: "stdio",
command: "npx",
args: ["-y", "@modelcontextprotocol/server-time"],
} as McpServerSpec,
homepage: "https://github.com/modelcontextprotocol/servers",
docs: "https://github.com/modelcontextprotocol/servers/tree/main/src/time",
},
{
id: "memory",
name: "@modelcontextprotocol/server-memory",
tags: ["stdio", "memory", "graph"],
server: {
type: "stdio",
command: "npx",
args: ["-y", "@modelcontextprotocol/server-memory"],
} as McpServerSpec,
homepage: "https://github.com/modelcontextprotocol/servers",
docs: "https://github.com/modelcontextprotocol/servers/tree/main/src/memory",
},
{
id: "sequential-thinking",
name: "@modelcontextprotocol/server-sequential-thinking",
tags: ["stdio", "thinking", "reasoning"],
server: {
type: "stdio",
command: "npx",
args: ["-y", "@modelcontextprotocol/server-sequential-thinking"],
} as McpServerSpec,
homepage: "https://github.com/modelcontextprotocol/servers",
docs: "https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking",
},
{
id: "context7",
name: "@upstash/context7-mcp",
tags: ["stdio", "docs", "search"],
server: {
type: "stdio",
command: "npx",
args: ["-y", "@upstash/context7-mcp"],
} as McpServerSpec,
homepage: "https://context7.com",
docs: "https://github.com/upstash/context7/blob/master/README.md",
},
];
// 获取带国际化描述的预设
export const getMcpPresetWithDescription = (
preset: McpPreset,
t: (key: string) => string,
): McpServer => {
const descriptionKey = `mcp.presets.${preset.id}.description`;
return {
...preset,
description: t(descriptionKey),
} as McpServer;
};
export default mcpPresets;

View File

@@ -1,54 +1,82 @@
/**
* 预设供应商配置模板
*/
import { ProviderCategory } from "../types";
export interface TemplateValueConfig {
label: string;
placeholder: string;
defaultValue?: string;
editorValue: string;
}
export interface ProviderPreset {
name: string;
websiteUrl: string;
// 新增:第三方/聚合等可单独配置获取 API Key 的链接
apiKeyUrl?: string;
settingsConfig: object;
isOfficial?: boolean; // 标识是否为官方预设
category?: ProviderCategory; // 新增:分类
// 新增:模板变量定义,用于动态替换配置中的值
templateValues?: Record<string, TemplateValueConfig>; // editorValue 存储编辑器中的实时输入值
// 新增:请求地址候选列表(用于地址管理/测速)
endpointCandidates?: string[];
}
export const providerPresets: ProviderPreset[] = [
{
name: "Claude官方登录",
name: "Claude Official",
websiteUrl: "https://www.anthropic.com/claude-code",
settingsConfig: {
env: {},
},
isOfficial: true, // 明确标识为官方预设
category: "official",
},
{
name: "DeepSeek v3.1",
name: "DeepSeek",
websiteUrl: "https://platform.deepseek.com",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://api.deepseek.com/anthropic",
ANTHROPIC_AUTH_TOKEN: "sk-your-api-key-here",
ANTHROPIC_MODEL: "deepseek-chat",
ANTHROPIC_SMALL_FAST_MODEL: "deepseek-chat",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "DeepSeek-V3.2-Exp",
ANTHROPIC_SMALL_FAST_MODEL: "DeepSeek-V3.2-Exp",
},
},
category: "cn_official",
},
{
name: "智谱GLM",
name: "Zhipu GLM",
websiteUrl: "https://open.bigmodel.cn",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://open.bigmodel.cn/api/anthropic",
ANTHROPIC_AUTH_TOKEN: "sk-your-api-key-here",
ANTHROPIC_AUTH_TOKEN: "",
// 兼容旧键名,保持前端读取一致
ANTHROPIC_MODEL: "GLM-4.6",
ANTHROPIC_SMALL_FAST_MODEL: "glm-4.5-air",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-4.5-air",
ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-4.6",
ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-4.6",
},
},
category: "cn_official",
},
{
name: "千问Qwen-Coder",
name: "Qwen Coder",
websiteUrl: "https://bailian.console.aliyun.com",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL:
"https://dashscope.aliyuncs.com/api/v2/apps/claude-code-proxy",
ANTHROPIC_AUTH_TOKEN: "sk-your-api-key-here",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "qwen3-max",
ANTHROPIC_SMALL_FAST_MODEL: "qwen3-max",
},
},
category: "cn_official",
},
{
name: "Kimi k2",
@@ -56,20 +84,85 @@ export const providerPresets: ProviderPreset[] = [
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://api.moonshot.cn/anthropic",
ANTHROPIC_AUTH_TOKEN: "sk-your-api-key-here",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "kimi-k2-turbo-preview",
ANTHROPIC_SMALL_FAST_MODEL: "kimi-k2-turbo-preview",
},
},
category: "cn_official",
},
{
name: "ModelScope",
websiteUrl: "https://modelscope.cn",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://api-inference.modelscope.cn",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "ZhipuAI/GLM-4.6",
ANTHROPIC_SMALL_FAST_MODEL: "ZhipuAI/GLM-4.6",
},
},
category: "aggregator",
},
{
name: "KAT-Coder",
websiteUrl: "https://console.streamlake.ai/wanqing/",
apiKeyUrl: "https://console.streamlake.ai/console/wanqing/api-key",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL:
"https://vanchin.streamlake.ai/api/gateway/v1/endpoints/${ENDPOINT_ID}/claude-code-proxy",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "KAT-Coder",
ANTHROPIC_SMALL_FAST_MODEL: "KAT-Coder",
},
},
category: "cn_official",
templateValues: {
ENDPOINT_ID: {
label: "Vanchin Endpoint ID",
placeholder: "ep-xxx-xxx",
defaultValue: "",
editorValue: "",
},
},
},
{
name: "Longcat",
websiteUrl: "https://longcat.chat/platform",
apiKeyUrl: "https://longcat.chat/platform/api_keys",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://api.longcat.chat/anthropic",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "LongCat-Flash-Chat",
ANTHROPIC_SMALL_FAST_MODEL: "LongCat-Flash-Chat",
ANTHROPIC_DEFAULT_SONNET_MODEL: "LongCat-Flash-Chat",
ANTHROPIC_DEFAULT_OPUS_MODEL: "LongCat-Flash-Chat",
CLAUDE_CODE_MAX_OUTPUT_TOKENS: "6000",
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: 1,
},
},
category: "cn_official",
},
{
name: "PackyCode",
websiteUrl: "https://www.packycode.com",
apiKeyUrl: "https://www.packycode.com/?aff=rlo54mgz",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://api.packycode.com",
ANTHROPIC_AUTH_TOKEN: "sk-your-api-key-here",
ANTHROPIC_AUTH_TOKEN: "",
},
},
// 请求地址候选(用于地址管理/测速)
endpointCandidates: [
"https://api.packycode.com",
"https://api-hk-cn2.packycode.com",
"https://api-hk-g.packycode.com",
"https://api-us-cn2.packycode.com",
"https://api-cf-pro.packycode.com",
],
category: "third_party",
},
];

View File

@@ -0,0 +1,155 @@
import React, {
createContext,
useContext,
useState,
useEffect,
useCallback,
useRef,
} from "react";
import type { UpdateInfo, UpdateHandle } from "../lib/updater";
import { checkForUpdate } from "../lib/updater";
interface UpdateContextValue {
// 更新状态
hasUpdate: boolean;
updateInfo: UpdateInfo | null;
updateHandle: UpdateHandle | null;
isChecking: boolean;
error: string | null;
// 提示状态
isDismissed: boolean;
dismissUpdate: () => void;
// 操作方法
checkUpdate: () => Promise<boolean>;
resetDismiss: () => void;
}
const UpdateContext = createContext<UpdateContextValue | undefined>(undefined);
export function UpdateProvider({ children }: { children: React.ReactNode }) {
const DISMISSED_VERSION_KEY = "ccswitch:update:dismissedVersion";
const LEGACY_DISMISSED_KEY = "dismissedUpdateVersion"; // 兼容旧键
const [hasUpdate, setHasUpdate] = useState(false);
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
const [updateHandle, setUpdateHandle] = useState<UpdateHandle | null>(null);
const [isChecking, setIsChecking] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isDismissed, setIsDismissed] = useState(false);
// 从 localStorage 读取已关闭的版本
useEffect(() => {
const current = updateInfo?.availableVersion;
if (!current) return;
// 读取新键;若不存在,尝试迁移旧键
let dismissedVersion = localStorage.getItem(DISMISSED_VERSION_KEY);
if (!dismissedVersion) {
const legacy = localStorage.getItem(LEGACY_DISMISSED_KEY);
if (legacy) {
localStorage.setItem(DISMISSED_VERSION_KEY, legacy);
localStorage.removeItem(LEGACY_DISMISSED_KEY);
dismissedVersion = legacy;
}
}
setIsDismissed(dismissedVersion === current);
}, [updateInfo?.availableVersion]);
const isCheckingRef = useRef(false);
const checkUpdate = useCallback(async () => {
if (isCheckingRef.current) return false;
isCheckingRef.current = true;
setIsChecking(true);
setError(null);
try {
const result = await checkForUpdate({ timeout: 30000 });
if (result.status === "available") {
setHasUpdate(true);
setUpdateInfo(result.info);
setUpdateHandle(result.update);
// 检查是否已经关闭过这个版本的提醒
let dismissedVersion = localStorage.getItem(DISMISSED_VERSION_KEY);
if (!dismissedVersion) {
const legacy = localStorage.getItem(LEGACY_DISMISSED_KEY);
if (legacy) {
localStorage.setItem(DISMISSED_VERSION_KEY, legacy);
localStorage.removeItem(LEGACY_DISMISSED_KEY);
dismissedVersion = legacy;
}
}
setIsDismissed(dismissedVersion === result.info.availableVersion);
return true; // 有更新
} else {
setHasUpdate(false);
setUpdateInfo(null);
setUpdateHandle(null);
setIsDismissed(false);
return false; // 已是最新
}
} catch (err) {
console.error("检查更新失败:", err);
setError(err instanceof Error ? err.message : "检查更新失败");
setHasUpdate(false);
throw err; // 抛出错误让调用方处理
} finally {
setIsChecking(false);
isCheckingRef.current = false;
}
}, []);
const dismissUpdate = useCallback(() => {
setIsDismissed(true);
if (updateInfo?.availableVersion) {
localStorage.setItem(DISMISSED_VERSION_KEY, updateInfo.availableVersion);
// 清理旧键
localStorage.removeItem(LEGACY_DISMISSED_KEY);
}
}, [updateInfo?.availableVersion]);
const resetDismiss = useCallback(() => {
setIsDismissed(false);
localStorage.removeItem(DISMISSED_VERSION_KEY);
localStorage.removeItem(LEGACY_DISMISSED_KEY);
}, []);
// 应用启动时自动检查更新
useEffect(() => {
// 延迟1秒后检查避免影响启动体验
const timer = setTimeout(() => {
checkUpdate().catch(console.error);
}, 1000);
return () => clearTimeout(timer);
}, [checkUpdate]);
const value: UpdateContextValue = {
hasUpdate,
updateInfo,
updateHandle,
isChecking,
error,
isDismissed,
dismissUpdate,
checkUpdate,
resetDismiss,
};
return (
<UpdateContext.Provider value={value}>{children}</UpdateContext.Provider>
);
}
export function useUpdate() {
const context = useContext(UpdateContext);
if (!context) {
throw new Error("useUpdate must be used within UpdateProvider");
}
return context;
}

85
src/hooks/useDarkMode.ts Normal file
View File

@@ -0,0 +1,85 @@
import { useState, useEffect } from "react";
export function useDarkMode() {
// 初始设为 false挂载后在 useEffect 中加载真实值
const [isDarkMode, setIsDarkMode] = useState<boolean>(false);
const [isInitialized, setIsInitialized] = useState(false);
const isDev = import.meta.env.DEV;
// 组件挂载后加载初始值(兼容 Tauri 环境)
useEffect(() => {
if (typeof window === "undefined") return;
try {
// 尝试读取已保存的偏好
const saved = localStorage.getItem("darkMode");
if (saved !== null) {
const savedBool = saved === "true";
setIsDarkMode(savedBool);
if (isDev)
console.log("[DarkMode] Loaded from localStorage:", savedBool);
} else {
// 回退到系统偏好
const prefersDark =
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches;
setIsDarkMode(prefersDark);
if (isDev)
console.log("[DarkMode] Using system preference:", prefersDark);
}
} catch (error) {
console.error("[DarkMode] Error loading preference:", error);
setIsDarkMode(false);
}
setIsInitialized(true);
}, []); // 仅在首次挂载时运行
// 将 dark 类应用到文档根节点
useEffect(() => {
if (!isInitialized) return;
// 添加短暂延迟以确保 Tauri 中 DOM 已就绪
const timer = setTimeout(() => {
try {
if (isDarkMode) {
document.documentElement.classList.add("dark");
if (isDev) console.log("[DarkMode] Added dark class to document");
} else {
document.documentElement.classList.remove("dark");
if (isDev) console.log("[DarkMode] Removed dark class from document");
}
// 检查类名是否已成功应用
const hasClass = document.documentElement.classList.contains("dark");
if (isDev) console.log("[DarkMode] Document has dark class:", hasClass);
} catch (error) {
console.error("[DarkMode] Error applying dark class:", error);
}
}, 0);
return () => clearTimeout(timer);
}, [isDarkMode, isInitialized]);
// 将偏好保存到 localStorage
useEffect(() => {
if (!isInitialized) return;
try {
localStorage.setItem("darkMode", isDarkMode.toString());
if (isDev) console.log("[DarkMode] Saved to localStorage:", isDarkMode);
} catch (error) {
console.error("[DarkMode] Error saving preference:", error);
}
}, [isDarkMode, isInitialized]);
const toggleDarkMode = () => {
setIsDarkMode((prev) => {
const newValue = !prev;
if (isDev) console.log("[DarkMode] Toggling from", prev, "to", newValue);
return newValue;
});
};
return { isDarkMode, toggleDarkMode };
}

60
src/i18n/index.ts Normal file
View File

@@ -0,0 +1,60 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import en from "./locales/en.json";
import zh from "./locales/zh.json";
const DEFAULT_LANGUAGE: "zh" | "en" = "zh";
const getInitialLanguage = (): "zh" | "en" => {
if (typeof window !== "undefined") {
try {
const stored = window.localStorage.getItem("language");
if (stored === "zh" || stored === "en") {
return stored;
}
} catch (error) {
console.warn("[i18n] Failed to read stored language preference", error);
}
}
const navigatorLang =
typeof navigator !== "undefined"
? (navigator.language?.toLowerCase() ??
navigator.languages?.[0]?.toLowerCase())
: undefined;
if (navigatorLang?.startsWith("zh")) {
return "zh";
}
if (navigatorLang?.startsWith("en")) {
return "en";
}
return DEFAULT_LANGUAGE;
};
const resources = {
en: {
translation: en,
},
zh: {
translation: zh,
},
};
i18n.use(initReactI18next).init({
resources,
lng: getInitialLanguage(), // 根据本地存储或系统语言选择默认语言
fallbackLng: "en", // 如果缺少中文翻译则退回英文
interpolation: {
escapeValue: false, // React 已经默认转义
},
// 开发模式下显示调试信息
debug: false,
});
export default i18n;

389
src/i18n/locales/en.json Normal file
View File

@@ -0,0 +1,389 @@
{
"app": {
"title": "CC Switch",
"description": "Claude Code & Codex Provider Switching Tool"
},
"common": {
"add": "Add",
"edit": "Edit",
"delete": "Delete",
"save": "Save",
"saving": "Saving...",
"cancel": "Cancel",
"confirm": "Confirm",
"close": "Close",
"done": "Done",
"settings": "Settings",
"about": "About",
"version": "Version",
"loading": "Loading...",
"success": "Success",
"error": "Error",
"unknown": "Unknown",
"enterValidValue": "Please enter a valid value"
},
"apiKeyInput": {
"placeholder": "Enter API Key",
"show": "Show API Key",
"hide": "Hide API Key"
},
"jsonEditor": {
"mustBeObject": "Configuration must be a JSON object, not an array or other type",
"invalidJson": "Invalid JSON format"
},
"claudeConfig": {
"configLabel": "Claude Code settings.json (JSON) *",
"writeCommonConfig": "Write Common Config",
"editCommonConfig": "Edit Common Config",
"editCommonConfigTitle": "Edit Common Config Snippet",
"commonConfigHint": "This snippet will be merged into settings.json when 'Write Common Config' is checked",
"fullSettingsHint": "Full Claude Code settings.json content"
},
"header": {
"viewOnGithub": "View on GitHub",
"toggleDarkMode": "Switch to Dark Mode",
"toggleLightMode": "Switch to Light Mode",
"addProvider": "Add Provider",
"switchToChinese": "Switch to Chinese",
"switchToEnglish": "Switch to English"
},
"provider": {
"noProviders": "No providers added yet",
"noProvidersDescription": "Click the \"Add Provider\" button in the top right to configure your first API provider",
"currentlyUsing": "Currently Using",
"enable": "Enable",
"inUse": "In Use",
"editProvider": "Edit Provider",
"deleteProvider": "Delete Provider",
"addNewProvider": "Add New Provider",
"addClaudeProvider": "Add Claude Code Provider",
"addCodexProvider": "Add Codex Provider",
"editClaudeProvider": "Edit Claude Code Provider",
"editCodexProvider": "Edit Codex Provider",
"configError": "Configuration Error",
"notConfigured": "Not configured for official website",
"applyToClaudePlugin": "Apply to Claude plugin",
"removeFromClaudePlugin": "Remove from Claude plugin"
},
"notifications": {
"providerSaved": "Provider configuration saved",
"providerDeleted": "Provider deleted successfully",
"switchSuccess": "Switch successful! Please restart {{appName}} terminal to take effect",
"switchFailed": "Switch failed, please check configuration",
"autoImported": "Default provider created from existing configuration",
"saveFailed": "Save failed: {{error}}",
"saveFailedGeneric": "Save failed, please try again",
"appliedToClaudePlugin": "Applied to Claude plugin",
"removedFromClaudePlugin": "Removed from Claude plugin",
"syncClaudePluginFailed": "Sync Claude plugin failed"
},
"confirm": {
"deleteProvider": "Delete Provider",
"deleteProviderMessage": "Are you sure you want to delete provider \"{{name}}\"? This action cannot be undone."
},
"settings": {
"title": "Settings",
"general": "General",
"language": "Language",
"importExport": "Import/Export Config",
"exportConfig": "Export Config to File",
"selectConfigFile": "Select Config File",
"import": "Import",
"importing": "Importing...",
"importSuccess": "Import Successful!",
"importFailed": "Import Failed",
"configExported": "Config exported to:",
"exportFailed": "Export failed",
"selectFileFailed": "Failed to select file",
"configCorrupted": "Config file may be corrupted or invalid",
"backupId": "Backup ID",
"autoReload": "Data will refresh automatically in 2 seconds...",
"languageOptionChinese": "中文",
"languageOptionEnglish": "English",
"windowBehavior": "Window Behavior",
"minimizeToTray": "Minimize to tray on close",
"minimizeToTrayDescription": "When checked, clicking the close button will hide to system tray, otherwise the app will exit directly.",
"enableClaudePluginIntegration": "Apply to Claude Code extension",
"enableClaudePluginIntegrationDescription": "When enabled, you can use third-party providers in the VS Code Claude Code extension",
"configFileLocation": "Configuration File Location",
"openFolder": "Open Folder",
"configDirectoryOverride": "Configuration Directory Override (Advanced)",
"configDirectoryDescription": "When using Claude Code or Codex in environments like WSL, you can manually specify the configuration directory in WSL to keep provider data consistent with the main environment.",
"claudeConfigDir": "Claude Code Configuration Directory",
"codexConfigDir": "Codex Configuration Directory",
"browsePlaceholderClaude": "e.g., /home/<your-username>/.claude",
"browsePlaceholderCodex": "e.g., /home/<your-username>/.codex",
"browseDirectory": "Browse Directory",
"resetDefault": "Reset to default directory (takes effect after saving)",
"checkForUpdates": "Check for Updates",
"updateTo": "Update to v{{version}}",
"updating": "Updating...",
"checking": "Checking...",
"upToDate": "Up to Date",
"releaseNotes": "Release Notes",
"viewReleaseNotes": "View release notes for this version",
"viewCurrentReleaseNotes": "View current version release notes",
"exportFailedError": "Export config failed:"
},
"apps": {
"claude": "Claude Code",
"codex": "Codex"
},
"console": {
"providerSwitchReceived": "Received provider switch event:",
"setupListenerFailed": "Failed to setup provider switch listener:",
"updateProviderFailed": "Update provider failed:",
"autoImportFailed": "Auto import default configuration failed:",
"openLinkFailed": "Failed to open link:",
"getVersionFailed": "Failed to get version info:",
"loadSettingsFailed": "Failed to load settings:",
"getConfigPathFailed": "Failed to get config path:",
"getConfigDirFailed": "Failed to get config directory:",
"detectPortableFailed": "Failed to detect portable mode:",
"saveSettingsFailed": "Failed to save settings:",
"updateFailed": "Update failed:",
"checkUpdateFailed": "Check for updates failed:",
"openConfigFolderFailed": "Failed to open config folder:",
"selectConfigDirFailed": "Failed to select config directory:",
"getDefaultConfigDirFailed": "Failed to get default config directory:",
"openReleaseNotesFailed": "Failed to open release notes:"
},
"providerForm": {
"supplierName": "Provider Name",
"supplierNameRequired": "Provider Name *",
"supplierNamePlaceholder": "e.g., Anthropic Official",
"websiteUrl": "Website URL",
"websiteUrlPlaceholder": "https://example.com (optional)",
"apiEndpoint": "API Endpoint",
"apiEndpointPlaceholder": "https://your-api-endpoint.com",
"codexApiEndpointPlaceholder": "https://your-api-endpoint.com/v1",
"manageAndTest": "Manage & Test",
"configContent": "Config Content",
"useConfigWizard": "Use Configuration Wizard",
"manualConfig": "Manually configure provider, requires complete configuration, or",
"officialNoApiKey": "Official login does not require API Key, save directly",
"codexOfficialNoApiKey": "Official does not require API Key, save directly",
"kimiApiKeyHint": "Fill in to get model list",
"apiKeyAutoFill": "Just fill in here, config below will be auto-filled",
"codexApiKeyAutoFill": "Just fill in here, auth.json below will be auto-filled",
"getApiKey": "Get API Key",
"parameterConfig": "Parameter Config - {{name}} *",
"mainModel": "Main Model (optional)",
"mainModelPlaceholder": "e.g., GLM-4.6",
"fastModel": "Fast Model (optional)",
"fastModelPlaceholder": "e.g., GLM-4.5-Air",
"modelHint": "💡 Leave blank to use provider's default model",
"apiHint": "💡 Fill in Claude API compatible service endpoint",
"codexApiHint": "💡 Fill in service endpoint compatible with OpenAI Response format",
"fillSupplierName": "Please fill in provider name",
"fillConfigContent": "Please fill in configuration content",
"fillParameter": "Please fill in {{label}}",
"configJsonError": "Config JSON format error, please check syntax",
"authJsonRequired": "auth.json must be a JSON object",
"authJsonError": "auth.json format error, please check JSON syntax",
"fillAuthJson": "Please fill in auth.json configuration",
"fillApiKey": "Please fill in OPENAI_API_KEY",
"visitWebsite": "Visit {{url}}"
},
"endpointTest": {
"title": "API Endpoint Management",
"endpoints": "endpoints",
"autoSelect": "Auto Select",
"testSpeed": "Test",
"testing": "Testing",
"addEndpointPlaceholder": "https://api.example.com",
"done": "Done",
"noEndpoints": "No endpoints",
"failed": "Failed",
"enterValidUrl": "Please enter a valid URL",
"invalidUrlFormat": "Invalid URL format",
"onlyHttps": "Only HTTP/HTTPS supported",
"urlExists": "This URL already exists",
"saveFailed": "Save failed, please try again",
"loadEndpointsFailed": "Failed to load custom endpoints:",
"addEndpointFailed": "Failed to add custom endpoint:",
"removeEndpointFailed": "Failed to remove custom endpoint:",
"pleaseAddEndpoint": "Please add an endpoint first",
"testUnavailable": "Speed test unavailable",
"noResult": "No result returned",
"testFailed": "Speed test failed: {{error}}"
},
"codexConfig": {
"quickWizard": "Quick Configuration Wizard",
"authJson": "auth.json (JSON) *",
"authJsonPlaceholder": "{\n \"OPENAI_API_KEY\": \"sk-your-api-key-here\"\n}",
"authJsonHint": "Codex auth.json configuration content",
"configToml": "config.toml (TOML)",
"configTomlHint": "Codex config.toml configuration content",
"writeCommonConfig": "Write Common Config",
"editCommonConfig": "Edit Common Config",
"editCommonConfigTitle": "Edit Codex Common Config Snippet",
"commonConfigHint": "This snippet will be appended to the end of config.toml when 'Write Common Config' is checked",
"wizardHint": "Enter key parameters, the system will automatically generate standard auth.json and config.toml configuration.",
"apiKeyLabel": "API Key *",
"apiKeyPlaceholder": "sk-your-api-key-here",
"supplierNameLabel": "Provider Name *",
"supplierNamePlaceholder": "e.g., Codex Official",
"supplierNameHint": "Will be displayed in the provider list, can use Chinese",
"supplierCodeLabel": "Provider Code (English)",
"supplierCodePlaceholder": "custom (optional)",
"supplierCodeHint": "Will be used as identifier in config file, defaults to custom",
"apiUrlLabel": "API Request URL *",
"apiUrlPlaceholder": "https://your-api-endpoint.com/v1",
"websiteLabel": "Website URL",
"websitePlaceholder": "https://example.com",
"websiteHint": "Official website address (optional)",
"modelNameLabel": "Model Name *",
"modelNamePlaceholder": "gpt-5-codex",
"configPreview": "Configuration Preview",
"applyConfig": "Apply Configuration"
},
"kimiSelector": {
"modelConfig": "Model Configuration",
"mainModel": "Main Model",
"fastModel": "Fast Model",
"refreshModels": "Refresh Model List",
"pleaseSelectModel": "Please select a model",
"noModels": "No models available",
"fillApiKeyFirst": "Please fill in API Key first",
"requestFailed": "Request failed: {{error}}",
"invalidData": "Invalid response data format",
"fetchModelsFailed": "Failed to fetch model list",
"apiKeyHint": "💡 Fill in API Key to automatically fetch available model list"
},
"presetSelector": {
"title": "Select Configuration Type",
"custom": "Custom",
"customDescription": "Manually configure provider, requires complete configuration",
"officialDescription": "Official login, no API Key required",
"presetDescription": "Use preset configuration, only API Key required"
},
"mcp": {
"title": "MCP Management",
"claudeTitle": "Claude Code MCP Management",
"codexTitle": "Codex MCP Management",
"userLevelPath": "User-level MCP path",
"serverList": "Servers",
"loading": "Loading...",
"empty": "No MCP servers",
"emptyDescription": "Click the button in the top right to add your first MCP server",
"add": "Add MCP",
"addServer": "Add MCP",
"editServer": "Edit MCP",
"addClaudeServer": "Add Claude Code MCP",
"editClaudeServer": "Edit Claude Code MCP",
"addCodexServer": "Add Codex MCP",
"editCodexServer": "Edit Codex MCP",
"configPath": "Config Path",
"serverCount": "{{count}} MCP server(s) configured",
"enabledCount": "{{count}} enabled",
"template": {
"fetch": "Quick Template: mcp-fetch"
},
"form": {
"title": "MCP Title (Unique)",
"titlePlaceholder": "my-mcp-server",
"name": "Display Name",
"namePlaceholder": "e.g. @modelcontextprotocol/server-time",
"description": "Description",
"descriptionPlaceholder": "Optional description",
"tags": "Tags (comma separated)",
"tagsPlaceholder": "stdio, time, utility",
"homepage": "Homepage",
"homepagePlaceholder": "https://example.com",
"docs": "Docs",
"docsPlaceholder": "https://example.com/docs",
"additionalInfo": "Additional Info",
"jsonConfig": "JSON Configuration",
"jsonPlaceholder": "{\n \"type\": \"stdio\",\n \"command\": \"uvx\",\n \"args\": [\"mcp-server-fetch\"]\n}",
"tomlConfig": "TOML Configuration",
"tomlPlaceholder": "type = \"stdio\"\ncommand = \"uvx\"\nargs = [\"mcp-server-fetch\"]",
"useWizard": "Config Wizard"
},
"wizard": {
"title": "MCP Configuration Wizard",
"hint": "Quickly configure MCP server and auto-generate JSON configuration",
"type": "Type",
"typeStdio": "stdio",
"typeHttp": "http",
"command": "Command",
"commandPlaceholder": "npx or uvx",
"args": "Arguments",
"argsPlaceholder": "arg1\narg2",
"env": "Environment Variables",
"envPlaceholder": "KEY1=value1\nKEY2=value2",
"url": "URL",
"urlPlaceholder": "https://api.example.com/mcp",
"urlRequired": "Please enter URL",
"headers": "Headers (optional)",
"headersPlaceholder": "Authorization: Bearer your_token_here\nContent-Type: application/json",
"preview": "Configuration Preview",
"apply": "Apply Configuration"
},
"id": "Identifier (unique)",
"type": "Type",
"command": "Command",
"validateCommand": "Validate Command",
"args": "Args",
"argsPlaceholder": "e.g., mcp-server-fetch --help",
"env": "Environment (one per line, KEY=VALUE)",
"envPlaceholder": "FOO=bar\nHELLO=world",
"reset": "Reset",
"notice": {
"restartClaude": "Written. Restart Claude to take effect."
},
"msg": {
"saved": "Saved",
"deleted": "Deleted",
"enabled": "Enabled",
"disabled": "Disabled",
"templateAdded": "Template added"
},
"error": {
"idRequired": "Please enter identifier",
"idExists": "Identifier already exists. Please choose another.",
"jsonInvalid": "Invalid JSON format",
"tomlInvalid": "Invalid TOML format",
"commandRequired": "Please enter command",
"singleServerObjectRequired": "Please paste a single MCP server object (do not include top-level mcpServers)",
"saveFailed": "Save failed",
"deleteFailed": "Delete failed"
},
"validation": {
"ok": "Command available",
"fail": "Command not found"
},
"confirm": {
"deleteTitle": "Delete MCP Server",
"deleteMessage": "Are you sure you want to delete MCP server \"{{id}}\"? This action cannot be undone."
},
"presets": {
"title": "Select MCP Type",
"enable": "Enable",
"enabled": "Enabled",
"installed": "Installed",
"docs": "Docs",
"requiresEnv": "Requires env",
"fetch": {
"name": "mcp-server-fetch",
"description": "Universal HTTP request tool, supports GET/POST and other HTTP methods, suitable for quick API requests and web data scraping"
},
"time": {
"name": "@modelcontextprotocol/server-time",
"description": "Time query tool providing current time, timezone conversion, and date calculation features"
},
"memory": {
"name": "@modelcontextprotocol/server-memory",
"description": "Knowledge graph memory system supporting entities, relations, and observations to help AI remember important information from conversations"
},
"sequential-thinking": {
"name": "@modelcontextprotocol/server-sequential-thinking",
"description": "Sequential thinking tool helping AI break down complex problems into multiple steps for deeper thinking"
},
"context7": {
"name": "@upstash/context7-mcp",
"description": "Context7 documentation search tool providing latest library docs and code examples, with higher limits when configured with a key"
}
}
}
}

389
src/i18n/locales/zh.json Normal file
View File

@@ -0,0 +1,389 @@
{
"app": {
"title": "CC Switch",
"description": "Claude Code & Codex 供应商切换工具"
},
"common": {
"add": "添加",
"edit": "编辑",
"delete": "删除",
"save": "保存",
"saving": "保存中...",
"cancel": "取消",
"confirm": "确定",
"close": "关闭",
"done": "完成",
"settings": "设置",
"about": "关于",
"version": "版本",
"loading": "加载中...",
"success": "成功",
"error": "错误",
"unknown": "未知",
"enterValidValue": "请输入有效的内容"
},
"apiKeyInput": {
"placeholder": "请输入API Key",
"show": "显示API Key",
"hide": "隐藏API Key"
},
"jsonEditor": {
"mustBeObject": "配置必须是JSON对象不能是数组或其他类型",
"invalidJson": "JSON格式错误"
},
"claudeConfig": {
"configLabel": "Claude Code 配置 (JSON) *",
"writeCommonConfig": "写入通用配置",
"editCommonConfig": "编辑通用配置",
"editCommonConfigTitle": "编辑通用配置片段",
"commonConfigHint": "该片段会在勾选\"写入通用配置\"时合并到 settings.json 中",
"fullSettingsHint": "完整的 Claude Code settings.json 配置内容"
},
"header": {
"viewOnGithub": "在 GitHub 上查看",
"toggleDarkMode": "切换到暗色模式",
"toggleLightMode": "切换到亮色模式",
"addProvider": "添加供应商",
"switchToChinese": "切换到中文",
"switchToEnglish": "切换到英文"
},
"provider": {
"noProviders": "还没有添加任何供应商",
"noProvidersDescription": "点击右上角的\"添加供应商\"按钮开始配置您的第一个API供应商",
"currentlyUsing": "当前使用",
"enable": "启用",
"inUse": "使用中",
"editProvider": "编辑供应商",
"deleteProvider": "删除供应商",
"addNewProvider": "添加新供应商",
"addClaudeProvider": "添加 Claude Code 供应商",
"addCodexProvider": "添加 Codex 供应商",
"editClaudeProvider": "编辑 Claude Code 供应商",
"editCodexProvider": "编辑 Codex 供应商",
"configError": "配置错误",
"notConfigured": "未配置官网地址",
"applyToClaudePlugin": "应用到 Claude 插件",
"removeFromClaudePlugin": "从 Claude 插件移除"
},
"notifications": {
"providerSaved": "供应商配置已保存",
"providerDeleted": "供应商删除成功",
"switchSuccess": "切换成功!请重启 {{appName}} 终端以生效",
"switchFailed": "切换失败,请检查配置",
"autoImported": "已从现有配置创建默认供应商",
"saveFailed": "保存失败:{{error}}",
"saveFailedGeneric": "保存失败,请重试",
"appliedToClaudePlugin": "已应用到 Claude 插件",
"removedFromClaudePlugin": "已从 Claude 插件移除",
"syncClaudePluginFailed": "同步 Claude 插件失败"
},
"confirm": {
"deleteProvider": "删除供应商",
"deleteProviderMessage": "确定要删除供应商 \"{{name}}\" 吗?此操作无法撤销。"
},
"settings": {
"title": "设置",
"general": "通用",
"language": "界面语言",
"importExport": "导入导出配置",
"exportConfig": "导出配置到文件",
"selectConfigFile": "选择配置文件",
"import": "导入",
"importing": "导入中...",
"importSuccess": "导入成功!",
"importFailed": "导入失败",
"configExported": "配置已导出到:",
"exportFailed": "导出失败",
"selectFileFailed": "选择文件失败",
"configCorrupted": "配置文件可能已损坏或格式不正确",
"backupId": "备份ID",
"autoReload": "数据将在2秒后自动刷新...",
"languageOptionChinese": "中文",
"languageOptionEnglish": "English",
"windowBehavior": "窗口行为",
"minimizeToTray": "关闭时最小化到托盘",
"minimizeToTrayDescription": "勾选后点击关闭按钮会隐藏到系统托盘,取消则直接退出应用。",
"enableClaudePluginIntegration": "应用到 Claude Code 插件",
"enableClaudePluginIntegrationDescription": "开启后可以在 Vscode Claude Code 插件里使用第三方供应商",
"configFileLocation": "配置文件位置",
"openFolder": "打开文件夹",
"configDirectoryOverride": "配置目录覆盖(高级)",
"configDirectoryDescription": "在 WSL 等环境使用 Claude Code 或 Codex 的时候,可手动指定 WSL 里的配置目录,供应商数据与主环境保持一致。",
"claudeConfigDir": "Claude Code 配置目录",
"codexConfigDir": "Codex 配置目录",
"browsePlaceholderClaude": "例如:/home/<你的用户名>/.claude",
"browsePlaceholderCodex": "例如:/home/<你的用户名>/.codex",
"browseDirectory": "浏览目录",
"resetDefault": "恢复默认目录(需保存后生效)",
"checkForUpdates": "检查更新",
"updateTo": "更新到 v{{version}}",
"updating": "更新中...",
"checking": "检查中...",
"upToDate": "已是最新",
"releaseNotes": "更新日志",
"viewReleaseNotes": "查看该版本更新日志",
"viewCurrentReleaseNotes": "查看当前版本更新日志",
"exportFailedError": "导出配置失败:"
},
"apps": {
"claude": "Claude Code",
"codex": "Codex"
},
"console": {
"providerSwitchReceived": "收到供应商切换事件:",
"setupListenerFailed": "设置供应商切换监听器失败:",
"updateProviderFailed": "更新供应商失败:",
"autoImportFailed": "自动导入默认配置失败:",
"openLinkFailed": "打开链接失败:",
"getVersionFailed": "获取版本信息失败:",
"loadSettingsFailed": "加载设置失败:",
"getConfigPathFailed": "获取配置路径失败:",
"getConfigDirFailed": "获取配置目录失败:",
"detectPortableFailed": "检测便携模式失败:",
"saveSettingsFailed": "保存设置失败:",
"updateFailed": "更新失败:",
"checkUpdateFailed": "检查更新失败:",
"openConfigFolderFailed": "打开配置文件夹失败:",
"selectConfigDirFailed": "选择配置目录失败:",
"getDefaultConfigDirFailed": "获取默认配置目录失败:",
"openReleaseNotesFailed": "打开更新日志失败:"
},
"providerForm": {
"supplierName": "供应商名称",
"supplierNameRequired": "供应商名称 *",
"supplierNamePlaceholder": "例如Anthropic 官方",
"websiteUrl": "官网地址",
"websiteUrlPlaceholder": "https://example.com可选",
"apiEndpoint": "请求地址",
"apiEndpointPlaceholder": "https://your-api-endpoint.com",
"codexApiEndpointPlaceholder": "https://your-api-endpoint.com/v1",
"manageAndTest": "管理与测速",
"configContent": "配置内容",
"useConfigWizard": "使用配置向导",
"manualConfig": "手动配置供应商,需要填写完整的配置信息,或者",
"officialNoApiKey": "官方登录无需填写 API Key直接保存即可",
"codexOfficialNoApiKey": "官方无需填写 API Key直接保存即可",
"kimiApiKeyHint": "填写后可获取模型列表",
"apiKeyAutoFill": "只需要填这里,下方配置会自动填充",
"codexApiKeyAutoFill": "只需要填这里,下方 auth.json 会自动填充",
"getApiKey": "获取 API Key",
"parameterConfig": "参数配置 - {{name}} *",
"mainModel": "主模型 (可选)",
"mainModelPlaceholder": "例如: GLM-4.6",
"fastModel": "快速模型 (可选)",
"fastModelPlaceholder": "例如: GLM-4.5-Air",
"modelHint": "💡 留空将使用供应商的默认模型",
"apiHint": "💡 填写兼容 Claude API 的服务端点地址",
"codexApiHint": "💡 填写兼容 OpenAI Response 格式的服务端点地址",
"fillSupplierName": "请填写供应商名称",
"fillConfigContent": "请填写配置内容",
"fillParameter": "请填写 {{label}}",
"configJsonError": "配置JSON格式错误请检查语法",
"authJsonRequired": "auth.json 必须是 JSON 对象",
"authJsonError": "auth.json 格式错误请检查JSON语法",
"fillAuthJson": "请填写 auth.json 配置",
"fillApiKey": "请填写 OPENAI_API_KEY",
"visitWebsite": "访问 {{url}}"
},
"endpointTest": {
"title": "请求地址管理",
"endpoints": "个端点",
"autoSelect": "自动选择",
"testSpeed": "测速",
"testing": "测速中",
"addEndpointPlaceholder": "https://api.example.com",
"done": "完成",
"noEndpoints": "暂无端点",
"failed": "失败",
"enterValidUrl": "请输入有效的 URL",
"invalidUrlFormat": "URL 格式不正确",
"onlyHttps": "仅支持 HTTP/HTTPS",
"urlExists": "该地址已存在",
"saveFailed": "保存失败,请重试",
"loadEndpointsFailed": "加载自定义端点失败:",
"addEndpointFailed": "添加自定义端点失败:",
"removeEndpointFailed": "删除自定义端点失败:",
"pleaseAddEndpoint": "请先添加端点",
"testUnavailable": "测速功能不可用",
"noResult": "未返回结果",
"testFailed": "测速失败: {{error}}"
},
"codexConfig": {
"quickWizard": "快速配置向导",
"authJson": "auth.json (JSON) *",
"authJsonPlaceholder": "{\n \"OPENAI_API_KEY\": \"sk-your-api-key-here\"\n}",
"authJsonHint": "Codex auth.json 配置内容",
"configToml": "config.toml (TOML)",
"configTomlHint": "Codex config.toml 配置内容",
"writeCommonConfig": "写入通用配置",
"editCommonConfig": "编辑通用配置",
"editCommonConfigTitle": "编辑 Codex 通用配置片段",
"commonConfigHint": "该片段会在勾选'写入通用配置'时追加到 config.toml 末尾",
"wizardHint": "输入关键参数,系统将自动生成标准的 auth.json 和 config.toml 配置。",
"apiKeyLabel": "API 密钥 *",
"apiKeyPlaceholder": "sk-your-api-key-here",
"supplierNameLabel": "供应商名称 *",
"supplierNamePlaceholder": "例如Codex 官方",
"supplierNameHint": "将显示在供应商列表中,可使用中文",
"supplierCodeLabel": "供应商代号(英文)",
"supplierCodePlaceholder": "custom可选",
"supplierCodeHint": "将用作配置文件中的标识符,默认为 custom",
"apiUrlLabel": "API 请求地址 *",
"apiUrlPlaceholder": "https://your-api-endpoint.com/v1",
"websiteLabel": "官网地址",
"websitePlaceholder": "https://example.com",
"websiteHint": "官方网站地址(可选)",
"modelNameLabel": "模型名称 *",
"modelNamePlaceholder": "gpt-5-codex",
"configPreview": "配置预览",
"applyConfig": "应用配置"
},
"kimiSelector": {
"modelConfig": "模型配置",
"mainModel": "主模型",
"fastModel": "快速模型",
"refreshModels": "刷新模型列表",
"pleaseSelectModel": "请选择模型",
"noModels": "暂无模型",
"fillApiKeyFirst": "请先填写 API Key",
"requestFailed": "请求失败: {{error}}",
"invalidData": "返回数据格式错误",
"fetchModelsFailed": "获取模型列表失败",
"apiKeyHint": "💡 填写 API Key 后将自动获取可用模型列表"
},
"presetSelector": {
"title": "选择配置类型",
"custom": "自定义",
"customDescription": "手动配置供应商,需要填写完整的配置信息",
"officialDescription": "官方登录,不需要填写 API Key",
"presetDescription": "使用预设配置,只需填写 API Key"
},
"mcp": {
"title": "MCP 管理",
"claudeTitle": "Claude Code MCP 管理",
"codexTitle": "Codex MCP 管理",
"userLevelPath": "用户级 MCP 配置路径",
"serverList": "服务器列表",
"loading": "加载中...",
"empty": "暂无 MCP 服务器",
"emptyDescription": "点击右上角按钮添加第一个 MCP 服务器",
"add": "添加 MCP",
"addServer": "新增 MCP",
"editServer": "编辑 MCP",
"addClaudeServer": "新增 Claude Code MCP",
"editClaudeServer": "编辑 Claude Code MCP",
"addCodexServer": "新增 Codex MCP",
"editCodexServer": "编辑 Codex MCP",
"configPath": "配置路径",
"serverCount": "已配置 {{count}} 个 MCP 服务器",
"enabledCount": "已启用 {{count}} 个",
"template": {
"fetch": "快速模板mcp-fetch"
},
"form": {
"title": "MCP 标题(唯一)",
"titlePlaceholder": "my-mcp-server",
"name": "显示名称",
"namePlaceholder": "例如 @modelcontextprotocol/server-time",
"description": "描述",
"descriptionPlaceholder": "可选的描述信息",
"tags": "标签(逗号分隔)",
"tagsPlaceholder": "stdio, time, utility",
"homepage": "主页链接",
"homepagePlaceholder": "https://example.com",
"docs": "文档链接",
"docsPlaceholder": "https://example.com/docs",
"additionalInfo": "附加信息",
"jsonConfig": "JSON 配置",
"jsonPlaceholder": "{\n \"type\": \"stdio\",\n \"command\": \"uvx\",\n \"args\": [\"mcp-server-fetch\"]\n}",
"tomlConfig": "TOML 配置",
"tomlPlaceholder": "type = \"stdio\"\ncommand = \"uvx\"\nargs = [\"mcp-server-fetch\"]",
"useWizard": "配置向导"
},
"wizard": {
"title": "MCP 配置向导",
"hint": "快速配置 MCP 服务器,自动生成 JSON 配置",
"type": "类型",
"typeStdio": "stdio",
"typeHttp": "http",
"command": "命令",
"commandPlaceholder": "npx 或 uvx",
"args": "参数",
"argsPlaceholder": "arg1\narg2",
"env": "环境变量",
"envPlaceholder": "KEY1=value1\nKEY2=value2",
"url": "URL",
"urlPlaceholder": "https://api.example.com/mcp",
"urlRequired": "请输入 URL",
"headers": "请求头(可选)",
"headersPlaceholder": "Authorization: Bearer your_token_here\nContent-Type: application/json",
"preview": "配置预览",
"apply": "应用配置"
},
"id": "标识 (唯一)",
"type": "类型",
"command": "命令",
"validateCommand": "校验命令",
"args": "参数",
"argsPlaceholder": "例如mcp-server-fetch --help",
"env": "环境变量 (一行一个KEY=VALUE)",
"envPlaceholder": "FOO=bar\nHELLO=world",
"reset": "重置",
"notice": {
"restartClaude": "已写入配置,重启 Claude 生效"
},
"msg": {
"saved": "已保存",
"deleted": "已删除",
"enabled": "已启用",
"disabled": "已禁用",
"templateAdded": "已添加模板"
},
"error": {
"idRequired": "请填写标识",
"idExists": "该标识已存在,请更换",
"jsonInvalid": "JSON 格式错误,请检查",
"tomlInvalid": "TOML 格式错误,请检查",
"commandRequired": "请填写命令",
"singleServerObjectRequired": "此处只需单个服务器对象,请不要粘贴包含 mcpServers 的整份配置",
"saveFailed": "保存失败",
"deleteFailed": "删除失败"
},
"validation": {
"ok": "命令可用",
"fail": "命令不可用"
},
"confirm": {
"deleteTitle": "删除 MCP 服务器",
"deleteMessage": "确定要删除 MCP 服务器 \"{{id}}\" 吗?此操作无法撤销。"
},
"presets": {
"title": "选择 MCP 类型",
"enable": "启用",
"enabled": "已启用",
"installed": "已安装",
"docs": "文档",
"requiresEnv": "需要环境变量",
"fetch": {
"name": "mcp-server-fetch",
"description": "通用 HTTP 请求工具,支持 GET/POST 等 HTTP 方法,适合快速请求接口/抓取网页数据"
},
"time": {
"name": "@modelcontextprotocol/server-time",
"description": "时间查询工具,提供当前时间、时区转换、日期计算等功能"
},
"memory": {
"name": "@modelcontextprotocol/server-memory",
"description": "知识图谱记忆系统,支持存储实体、关系和观察,让 AI 记住对话中的重要信息"
},
"sequential-thinking": {
"name": "@modelcontextprotocol/server-sequential-thinking",
"description": "顺序思考工具,帮助 AI 将复杂问题分解为多个步骤,逐步深入思考"
},
"context7": {
"name": "@upstash/context7-mcp",
"description": "Context7 文档搜索工具,提供最新的库文档和代码示例,配置 key 会有更高限额"
}
}
}
}

View File

@@ -1,30 +1,56 @@
/* 引入 Tailwind v4 内建样式与工具 */
@import "tailwindcss";
/* 覆盖 Tailwind v4 默认的 dark 变体为“类选择器”模式 */
@custom-variant dark (&:where(.dark, .dark *));
/* 全局基础样式 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
@apply font-sans antialiased;
line-height: 1.5;
/* 让原生控件与滚动条随主题切换配色 */
color-scheme: light;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu",
"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f5f5f5;
color: #333;
@apply m-0 p-0 bg-gray-50 text-gray-900 text-sm dark:bg-gray-950 dark:text-gray-100;
}
#root {
height: 100vh;
display: flex;
flex-direction: column;
/* 暗色模式下启用暗色原生控件/滚动条配色 */
html.dark {
color-scheme: dark;
}
/* 仅在 macOS 下为顶部预留交通灯空间 */
/* 保持 mac 下与内容区域左对齐(不额外偏移) */
/* 在 macOS 下稍微增加 banner 高度,拉开与交通灯的垂直距离 */
body.is-mac .app-header {
padding-top: 1.4rem;
padding-bottom: 1.4rem;
/* 滚动条样式(避免在伪元素中使用自定义 dark 变体,消除构建警告) */
::-webkit-scrollbar {
width: 0.375rem;
height: 0.375rem;
}
::-webkit-scrollbar-track {
background-color: #f4f4f5;
}
html.dark ::-webkit-scrollbar-track {
background-color: #27272a;
}
::-webkit-scrollbar-thumb {
background-color: #d4d4d8;
border-radius: 0.25rem;
}
html.dark ::-webkit-scrollbar-thumb {
background-color: #52525b;
}
::-webkit-scrollbar-thumb:hover {
background-color: #a1a1aa;
}
html.dark ::-webkit-scrollbar-thumb:hover {
background-color: #71717a;
}
/* 焦点样式 */
*:focus-visible {
@apply outline-2 outline-blue-500 outline-offset-2;
}

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

@@ -0,0 +1,31 @@
// 轻量平台检测,避免在 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;
}
};

82
src/lib/styles.ts Normal file
View File

@@ -0,0 +1,82 @@
/**
* 复用的 Tailwind 样式组合,覆盖常见 UI 模式
*/
// 按钮样式
export const buttonStyles = {
// 主按钮:蓝底白字
primary:
"px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 transition-colors text-sm font-medium",
// 次按钮:灰背景,深色文本
secondary:
"px-4 py-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200 rounded-lg transition-colors text-sm font-medium",
// 危险按钮:用于不可撤销/破坏性操作
danger:
"px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 dark:bg-red-600 dark:hover:bg-red-700 transition-colors text-sm font-medium",
// MCP 专属按钮:绿底白字
mcp: "px-4 py-2 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700 transition-colors text-sm font-medium",
// 幽灵按钮:无背景,仅悬浮反馈
ghost:
"px-4 py-2 text-gray-500 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors text-sm font-medium",
// 图标按钮:小尺寸,仅图标
icon: "p-1.5 text-gray-500 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors",
// 禁用态:可与其他样式组合
disabled: "opacity-50 cursor-not-allowed pointer-events-none",
} as const;
// 卡片样式
export const cardStyles = {
// 基础卡片容器
base: "bg-white rounded-lg border border-gray-200 p-4 dark:bg-gray-900 dark:border-gray-700",
// 带悬浮效果的卡片
interactive:
"bg-white rounded-lg border border-gray-200 p-4 hover:border-gray-300 hover:shadow-sm dark:bg-gray-900 dark:border-gray-700 dark:hover:border-gray-600 transition-[border-color,box-shadow] duration-200",
// 选中/激活态卡片
selected:
"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;
// 输入控件样式
export const inputStyles = {
// 文本输入框
text: "w-full px-3 py-2 border border-gray-200 rounded-lg focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 outline-none dark:bg-gray-900 dark:border-gray-700 dark:text-gray-100 dark:focus:border-blue-400 dark:focus:ring-blue-400/20 transition-colors",
// 下拉选择框
select:
"w-full px-3 py-2 border border-gray-200 rounded-lg focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 outline-none bg-white dark:bg-gray-900 dark:border-gray-700 dark:text-gray-100 dark:focus:border-blue-400 dark:focus:ring-blue-400/20 transition-colors",
// 复选框
checkbox:
"w-4 h-4 text-blue-500 rounded focus:ring-blue-500/20 border-gray-300 dark:border-gray-600 dark:bg-gray-800",
} as const;
// 徽标Badge样式
export const badgeStyles = {
// 成功徽标
success:
"inline-flex items-center gap-1 px-2 py-1 bg-green-500/10 text-green-500 rounded-md text-xs font-medium",
// 信息徽标
info: "inline-flex items-center gap-1 px-2 py-1 bg-blue-500/10 text-blue-500 rounded-md text-xs font-medium",
// 警告徽标
warning:
"inline-flex items-center gap-1 px-2 py-1 bg-amber-500/10 text-amber-500 rounded-md text-xs font-medium",
// 错误徽标
error:
"inline-flex items-center gap-1 px-2 py-1 bg-red-500/10 text-red-500 rounded-md text-xs font-medium",
} as const;
// 组合类名的工具函数
export function cn(...classes: (string | undefined | false)[]) {
return classes.filter(Boolean).join(" ");
}

View File

@@ -1,5 +1,14 @@
import { invoke } from "@tauri-apps/api/core";
import { Provider } from "../types";
import { listen, UnlistenFn } from "@tauri-apps/api/event";
import {
Provider,
Settings,
CustomEndpoint,
McpStatus,
McpServer,
McpServerSpec,
McpConfigResponse,
} from "../types";
// 应用类型
export type AppType = "claude" | "codex";
@@ -17,6 +26,13 @@ interface ImportResult {
message?: string;
}
export interface EndpointLatencyResult {
url: string;
latency: number | null;
status?: number;
error?: string;
}
// Tauri API 封装,提供统一的全局 API 接口
export const tauriAPI = {
// 获取所有供应商
@@ -84,8 +100,9 @@ export const tauriAPI = {
app,
});
} catch (error) {
// 让调用方拿到后端的详细错误信息
console.error("切换供应商失败:", error);
return false;
throw error;
}
},
@@ -121,40 +138,35 @@ export const tauriAPI = {
}
},
// 获取 Claude Code 配置状态
getClaudeConfigStatus: async (): Promise<ConfigStatus> => {
// 获取当前生效的配置目录
getConfigDir: async (app?: AppType): Promise<string> => {
try {
return await invoke("get_claude_config_status");
return await invoke("get_config_dir", { app_type: app, app });
} catch (error) {
console.error("获取配置状态失败:", error);
return {
exists: false,
path: "",
error: String(error),
};
console.error("获取配置目录失败:", error);
return "";
}
},
// 获取应用配置状态(通用
getConfigStatus: async (app?: AppType): Promise<ConfigStatus> => {
try {
return await invoke("get_config_status", { app_type: app, app });
} catch (error) {
console.error("获取配置状态失败:", error);
return {
exists: false,
path: "",
error: String(error),
};
}
},
// 打开配置文件夹
// 打开配置目录(按应用类型
openConfigFolder: async (app?: AppType): Promise<void> => {
try {
await invoke("open_config_folder", { app_type: app, app });
} catch (error) {
console.error("打开配置文件夹失败:", error);
console.error("打开配置目录失败:", error);
}
},
// 选择配置目录(可选默认路径)
selectConfigDirectory: async (
defaultPath?: string,
): Promise<string | null> => {
try {
// 后端参数为 snake_casedefault_path
return await invoke("pick_directory", { default_path: defaultPath });
} catch (error) {
console.error("选择配置目录失败:", error);
return null;
}
},
@@ -167,12 +179,456 @@ export const tauriAPI = {
}
},
// (保留空位,取消迁移提示)
// 更新托盘菜单
updateTrayMenu: async (): Promise<boolean> => {
try {
return await invoke<boolean>("update_tray_menu");
} catch (error) {
console.error("更新托盘菜单失败:", error);
return false;
}
},
// 选择配置文件Tauri 暂不实现,保留接口兼容性)
selectConfigFile: async (): Promise<string | null> => {
console.warn("selectConfigFile 在 Tauri 版本中暂不支持");
return null;
// 获取应用设置
getSettings: async (): Promise<Settings> => {
try {
return await invoke("get_settings");
} catch (error) {
console.error("获取设置失败:", error);
throw error;
}
},
// 保存设置
saveSettings: async (settings: Settings): Promise<boolean> => {
try {
return await invoke("save_settings", { settings });
} catch (error) {
console.error("保存设置失败:", error);
return false;
}
},
// 检查更新
checkForUpdates: async (): Promise<void> => {
try {
await invoke("check_for_updates");
} catch (error) {
console.error("检查更新失败:", error);
}
},
// 判断是否为便携模式
isPortable: async (): Promise<boolean> => {
try {
return await invoke<boolean>("is_portable_mode");
} catch (error) {
console.error("检测便携模式失败:", error);
return false;
}
},
// 获取应用配置文件路径
getAppConfigPath: async (): Promise<string> => {
try {
return await invoke("get_app_config_path");
} catch (error) {
console.error("获取应用配置路径失败:", error);
return "";
}
},
// 打开应用配置文件夹
openAppConfigFolder: async (): Promise<void> => {
try {
await invoke("open_app_config_folder");
} catch (error) {
console.error("打开应用配置文件夹失败:", error);
}
},
// Claude 插件:获取 ~/.claude/config.json 状态
getClaudePluginStatus: async (): Promise<ConfigStatus> => {
try {
return await invoke<ConfigStatus>("get_claude_plugin_status");
} catch (error) {
console.error("获取 Claude 插件状态失败:", error);
return { exists: false, path: "", error: String(error) };
}
},
// Claude 插件:读取配置内容
readClaudePluginConfig: async (): Promise<string | null> => {
try {
return await invoke<string | null>("read_claude_plugin_config");
} catch (error) {
throw new Error(`读取 Claude 插件配置失败: ${String(error)}`);
}
},
// Claude 插件:应用或移除固定配置
applyClaudePluginConfig: async (options: {
official: boolean;
}): Promise<boolean> => {
const { official } = options;
try {
return await invoke<boolean>("apply_claude_plugin_config", { official });
} catch (error) {
throw new Error(`写入 Claude 插件配置失败: ${String(error)}`);
}
},
// Claude 插件:检测是否已应用目标配置
isClaudePluginApplied: async (): Promise<boolean> => {
try {
return await invoke<boolean>("is_claude_plugin_applied");
} catch (error) {
throw new Error(`检测 Claude 插件配置失败: ${String(error)}`);
}
},
// Claude MCP获取状态用户级 ~/.claude.json
getClaudeMcpStatus: async (): Promise<McpStatus> => {
try {
return await invoke<McpStatus>("get_claude_mcp_status");
} catch (error) {
console.error("获取 MCP 状态失败:", error);
throw error;
}
},
// Claude MCP读取 ~/.claude.json 文本
readClaudeMcpConfig: async (): Promise<string | null> => {
try {
return await invoke<string | null>("read_claude_mcp_config");
} catch (error) {
console.error("读取 mcp.json 失败:", error);
throw error;
}
},
// Claude MCP新增/更新服务器定义
upsertClaudeMcpServer: async (
id: string,
spec: McpServerSpec | Record<string, any>,
): Promise<boolean> => {
try {
return await invoke<boolean>("upsert_claude_mcp_server", { id, spec });
} catch (error) {
console.error("保存 MCP 服务器失败:", error);
throw error;
}
},
// Claude MCP删除服务器定义
deleteClaudeMcpServer: async (id: string): Promise<boolean> => {
try {
return await invoke<boolean>("delete_claude_mcp_server", { id });
} catch (error) {
console.error("删除 MCP 服务器失败:", error);
throw error;
}
},
// Claude MCP校验命令是否在 PATH 中
validateMcpCommand: async (cmd: string): Promise<boolean> => {
try {
return await invoke<boolean>("validate_mcp_command", { cmd });
} catch (error) {
console.error("校验 MCP 命令失败:", error);
return false;
}
},
// 新config.json 为 SSOT 的 MCP API按客户端
getMcpConfig: async (app: AppType = "claude"): Promise<McpConfigResponse> => {
try {
return await invoke<McpConfigResponse>("get_mcp_config", { app });
} catch (error) {
console.error("获取 MCP 配置失败:", error);
throw error;
}
},
upsertMcpServerInConfig: async (
app: AppType = "claude",
id: string,
spec: McpServer,
): Promise<boolean> => {
try {
return await invoke<boolean>("upsert_mcp_server_in_config", {
app,
id,
spec,
});
} catch (error) {
console.error("写入 MCPconfig.json失败:", error);
throw error;
}
},
deleteMcpServerInConfig: async (
app: AppType = "claude",
id: string,
): Promise<boolean> => {
try {
return await invoke<boolean>("delete_mcp_server_in_config", { app, id });
} catch (error) {
console.error("删除 MCPconfig.json失败:", error);
throw error;
}
},
setMcpEnabled: async (
app: AppType = "claude",
id: string,
enabled: boolean,
): Promise<boolean> => {
try {
return await invoke<boolean>("set_mcp_enabled", { app, id, enabled });
} catch (error) {
console.error("设置 MCP 启用状态失败:", error);
throw error;
}
},
syncEnabledMcpToClaude: async (): Promise<boolean> => {
try {
return await invoke<boolean>("sync_enabled_mcp_to_claude");
} catch (error) {
console.error("同步启用 MCP 到 .claude.json 失败:", error);
throw error;
}
},
// 手动同步:将启用的 MCP 投影到 ~/.codex/config.toml
syncEnabledMcpToCodex: async (): Promise<boolean> => {
try {
return await invoke<boolean>("sync_enabled_mcp_to_codex");
} catch (error) {
console.error("同步启用 MCP 到 config.toml 失败:", error);
throw error;
}
},
importMcpFromClaude: async (): Promise<number> => {
try {
return await invoke<number>("import_mcp_from_claude");
} catch (error) {
console.error("从 ~/.claude.json 导入 MCP 失败:", error);
throw error;
}
},
// 从 ~/.codex/config.toml 导入 MCPCodex 作用域)
importMcpFromCodex: async (): Promise<number> => {
try {
return await invoke<number>("import_mcp_from_codex");
} catch (error) {
console.error("从 ~/.codex/config.toml 导入 MCP 失败:", error);
throw error;
}
},
// 读取当前生效live的 provider settings根据 appType
// Codex: { auth: object, config: string }
// Claude: settings.json 内容
getLiveProviderSettings: async (app?: AppType): Promise<any> => {
try {
return await invoke<any>("read_live_provider_settings", {
app_type: app,
app,
appType: app,
});
} catch (error) {
console.error("读取 live 配置失败:", error);
throw error;
}
},
// ours: 第三方/自定义供应商——测速与端点管理
// 第三方/自定义供应商:批量测试端点延迟
testApiEndpoints: async (
urls: string[],
options?: { timeoutSecs?: number },
): Promise<EndpointLatencyResult[]> => {
try {
return await invoke<EndpointLatencyResult[]>("test_api_endpoints", {
urls,
timeout_secs: options?.timeoutSecs,
});
} catch (error) {
console.error("测速调用失败:", error);
throw error;
}
},
// 获取自定义端点列表
getCustomEndpoints: async (
appType: AppType,
providerId: string,
): Promise<CustomEndpoint[]> => {
try {
return await invoke<CustomEndpoint[]>("get_custom_endpoints", {
// 兼容不同后端参数命名
app_type: appType,
app: appType,
appType: appType,
provider_id: providerId,
providerId: providerId,
});
} catch (error) {
console.error("获取自定义端点列表失败:", error);
return [];
}
},
// 添加自定义端点
addCustomEndpoint: async (
appType: AppType,
providerId: string,
url: string,
): Promise<void> => {
try {
await invoke("add_custom_endpoint", {
app_type: appType,
app: appType,
appType: appType,
provider_id: providerId,
providerId: providerId,
url,
});
} catch (error) {
console.error("添加自定义端点失败:", error);
// 尽量抛出可读信息
if (error instanceof Error) {
throw error;
} else {
throw new Error(String(error));
}
}
},
// 删除自定义端点
removeCustomEndpoint: async (
appType: AppType,
providerId: string,
url: string,
): Promise<void> => {
try {
await invoke("remove_custom_endpoint", {
app_type: appType,
app: appType,
appType: appType,
provider_id: providerId,
providerId: providerId,
url,
});
} catch (error) {
console.error("删除自定义端点失败:", error);
throw error;
}
},
// 更新端点最后使用时间
updateEndpointLastUsed: async (
appType: AppType,
providerId: string,
url: string,
): Promise<void> => {
try {
await invoke("update_endpoint_last_used", {
app_type: appType,
app: appType,
appType: appType,
provider_id: providerId,
providerId: providerId,
url,
});
} catch (error) {
console.error("更新端点最后使用时间失败:", error);
// 不抛出错误,因为这不是关键操作
}
},
// theirs: 导入导出与文件对话框
// 导出配置到文件
exportConfigToFile: async (
filePath: string,
): Promise<{
success: boolean;
message: string;
filePath: string;
}> => {
try {
// 兼容参数命名差异:同时传递 file_path 与 filePath
return await invoke("export_config_to_file", {
file_path: filePath,
filePath: filePath,
});
} catch (error) {
throw new Error(`导出配置失败: ${String(error)}`);
}
},
// 从文件导入配置
importConfigFromFile: async (
filePath: string,
): Promise<{
success: boolean;
message: string;
backupId?: string;
}> => {
try {
// 兼容参数命名差异:同时传递 file_path 与 filePath
return await invoke("import_config_from_file", {
file_path: filePath,
filePath: filePath,
});
} catch (error) {
throw new Error(`导入配置失败: ${String(error)}`);
}
},
// 保存文件对话框
saveFileDialog: async (defaultName: string): Promise<string | null> => {
try {
// 兼容参数命名差异:同时传递 default_name 与 defaultName
const result = await invoke<string | null>("save_file_dialog", {
default_name: defaultName,
defaultName: defaultName,
});
return result;
} catch (error) {
console.error("打开保存对话框失败:", error);
return null;
}
},
// 打开文件对话框
openFileDialog: async (): Promise<string | null> => {
try {
const result = await invoke<string | null>("open_file_dialog");
return result;
} catch (error) {
console.error("打开文件对话框失败:", error);
return null;
}
},
// 监听供应商切换事件
onProviderSwitched: async (
callback: (data: { appType: string; providerId: string }) => void,
): Promise<UnlistenFn> => {
const unlisten = await listen("provider-switched", (event) => {
try {
// 事件 payload 形如 { appType: string, providerId: string }
callback(event.payload as any);
} catch (e) {
console.error("处理 provider-switched 事件失败: ", e);
}
});
return unlisten;
},
};

126
src/lib/updater.ts Normal file
View File

@@ -0,0 +1,126 @@
import { getVersion } from "@tauri-apps/api/app";
// 可选导入:在未注册插件或非 Tauri 环境下,调用时会抛错,外层需做兜底
// 我们按需加载并在运行时捕获错误,避免构建期类型问题
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
import type { Update } from "@tauri-apps/plugin-updater";
export type UpdateChannel = "stable" | "beta";
export type UpdaterPhase =
| "idle"
| "checking"
| "available"
| "downloading"
| "installing"
| "restarting"
| "upToDate"
| "error";
export interface UpdateInfo {
currentVersion: string;
availableVersion: string;
notes?: string;
pubDate?: string;
}
export interface UpdateProgressEvent {
event: "Started" | "Progress" | "Finished";
total?: number;
downloaded?: number;
}
export interface UpdateHandle {
version: string;
notes?: string;
date?: string;
downloadAndInstall: (
onProgress?: (e: UpdateProgressEvent) => void,
) => Promise<void>;
download?: () => Promise<void>;
install?: () => Promise<void>;
}
export interface CheckOptions {
timeout?: number;
channel?: UpdateChannel;
}
function mapUpdateHandle(raw: Update): UpdateHandle {
return {
version: (raw as any).version ?? "",
notes: (raw as any).notes,
date: (raw as any).date,
async downloadAndInstall(onProgress?: (e: UpdateProgressEvent) => void) {
await (raw as any).downloadAndInstall((evt: any) => {
if (!onProgress) return;
const mapped: UpdateProgressEvent = {
event: evt?.event,
};
if (evt?.event === "Started") {
mapped.total = evt?.data?.contentLength ?? 0;
mapped.downloaded = 0;
} else if (evt?.event === "Progress") {
mapped.downloaded = evt?.data?.chunkLength ?? 0; // 累积由调用方完成
}
onProgress(mapped);
});
},
// 透传可选 API若插件版本支持
download: (raw as any).download
? async () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
await (raw as any).download();
}
: undefined,
install: (raw as any).install
? async () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
await (raw as any).install();
}
: undefined,
};
}
export async function getCurrentVersion(): Promise<string> {
try {
return await getVersion();
} catch {
return "";
}
}
export async function checkForUpdate(
opts: CheckOptions = {},
): Promise<
| { status: "up-to-date" }
| { status: "available"; info: UpdateInfo; update: UpdateHandle }
> {
// 动态引入,避免在未安装插件时导致打包期问题
const { check } = await import("@tauri-apps/plugin-updater");
const currentVersion = await getCurrentVersion();
const update = await check({ timeout: opts.timeout ?? 30000 } as any);
if (!update) {
return { status: "up-to-date" };
}
const mapped = mapUpdateHandle(update);
const info: UpdateInfo = {
currentVersion,
availableVersion: mapped.version,
notes: mapped.notes,
pubDate: mapped.date,
};
return { status: "available", info, update: mapped };
}
export async function relaunchApp(): Promise<void> {
const { relaunch } = await import("@tauri-apps/plugin-process");
await relaunch();
}
// 旧的聚合更新流程已由调用方直接使用 updateHandle 取代
// 如需单函数封装,可在需要时基于 checkForUpdate + updateHandle 复合调用

View File

@@ -1,9 +1,12 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { UpdateProvider } from "./contexts/UpdateContext";
import "./index.css";
// 导入 Tauri API自动绑定到 window.api
import "./lib/tauri-api";
// 导入国际化配置
import "./i18n";
// 根据平台添加 body class便于平台特定样式
try {
@@ -19,6 +22,8 @@ try {
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
<UpdateProvider>
<App />
</UpdateProvider>
</React.StrictMode>,
);

View File

@@ -1,11 +1,99 @@
export type ProviderCategory =
| "official" // 官方
| "cn_official" // 国产官方
| "aggregator" // 聚合网站
| "third_party" // 第三方供应商
| "custom"; // 自定义
export interface Provider {
id: string;
name: string;
settingsConfig: Record<string, any>; // 应用配置对象Claude 为 settings.jsonCodex 为 { auth, config }
websiteUrl?: string;
// 新增:供应商分类(用于差异化提示/能力开关)
category?: ProviderCategory;
createdAt?: number; // 添加时间戳(毫秒)
// 可选:供应商元数据(仅存于 ~/.cc-switch/config.json不写入 live 配置)
meta?: ProviderMeta;
}
export interface AppConfig {
providers: Record<string, Provider>;
current: string;
}
// 自定义端点配置
export interface CustomEndpoint {
url: string;
addedAt: number;
lastUsed?: number;
}
// 供应商元数据(字段名与后端一致,保持 snake_case
export interface ProviderMeta {
// 自定义端点:以 URL 为键,值为端点信息
custom_endpoints?: Record<string, CustomEndpoint>;
}
// 应用设置类型(用于 SettingsModal 与 Tauri API
export interface Settings {
// 是否在系统托盘macOS 菜单栏)显示图标
showInTray: boolean;
// 点击关闭按钮时是否最小化到托盘而不是关闭应用
minimizeToTrayOnClose: boolean;
// 启用 Claude 插件联动(写入 ~/.claude/config.json 的 primaryApiKey
enableClaudePluginIntegration?: boolean;
// 覆盖 Claude Code 配置目录(可选)
claudeConfigDir?: string;
// 覆盖 Codex 配置目录(可选)
codexConfigDir?: string;
// 首选语言(可选,默认中文)
language?: "en" | "zh";
// Claude 自定义端点列表
customEndpointsClaude?: Record<string, CustomEndpoint>;
// Codex 自定义端点列表
customEndpointsCodex?: Record<string, CustomEndpoint>;
}
// MCP 服务器连接参数(宽松:允许扩展字段)
export interface McpServerSpec {
// 可选:社区常见 .mcp.json 中 stdio 配置可不写 type
type?: "stdio" | "http";
// stdio 字段
command?: string;
args?: string[];
env?: Record<string, string>;
cwd?: string;
// http 字段
url?: string;
headers?: Record<string, string>;
// 通用字段
[key: string]: any;
}
// MCP 服务器条目(含元信息)
export interface McpServer {
id: string;
name?: string;
description?: string;
tags?: string[];
homepage?: string;
docs?: string;
enabled?: boolean;
server: McpServerSpec;
source?: string;
[key: string]: any;
}
// MCP 配置状态
export interface McpStatus {
userConfigPath: string;
userConfigExists: boolean;
serverCount: number;
}
// 新:来自 config.json 的 MCP 列表响应
export interface McpConfigResponse {
configPath: string;
servers: Record<string, McpServer>;
}

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

@@ -0,0 +1,107 @@
/**
* 从各种错误对象中提取错误信息
* @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 "";
};
/**
* 将已知的 MCP 相关后端错误(通常为中文硬编码)映射为 i18n 文案
* 采用包含式匹配,尽量稳健地覆盖不同上下文的相似消息。
* 若无法识别,返回空字符串以便调用方回退到原始 detail 或默认 i18n。
*/
export const translateMcpBackendError = (
message: string,
t: (key: string, opts?: any) => string,
): string => {
if (!message) return "";
const msg = String(message).trim();
// 基础字段与结构校验相关
if (msg.includes("MCP 服务器 ID 不能为空")) {
return t("mcp.error.idRequired");
}
if (
msg.includes("MCP 服务器定义必须为 JSON 对象") ||
msg.includes("MCP 服务器条目必须为 JSON 对象") ||
msg.includes("MCP 服务器条目缺少 server 字段") ||
msg.includes("MCP 服务器 server 字段必须为 JSON 对象") ||
msg.includes("MCP 服务器连接定义必须为 JSON 对象") ||
msg.includes("MCP 服务器 '" /* 不是对象 */) ||
msg.includes("不是对象") ||
msg.includes("服务器配置必须是对象") ||
msg.includes("MCP 服务器 name 必须为字符串") ||
msg.includes("MCP 服务器 description 必须为字符串") ||
msg.includes("MCP 服务器 homepage 必须为字符串") ||
msg.includes("MCP 服务器 docs 必须为字符串") ||
msg.includes("MCP 服务器 tags 必须为字符串数组") ||
msg.includes("MCP 服务器 enabled 必须为布尔值")
) {
return t("mcp.error.jsonInvalid");
}
if (msg.includes("MCP 服务器 type 必须是")) {
return t("mcp.error.jsonInvalid");
}
// 必填字段
if (
msg.includes("stdio 类型的 MCP 服务器缺少 command 字段") ||
msg.includes("必须包含 command 字段")
) {
return t("mcp.error.commandRequired");
}
if (
msg.includes("http 类型的 MCP 服务器缺少 url 字段") ||
msg.includes("必须包含 url 字段") ||
msg === "URL 不能为空"
) {
return t("mcp.wizard.urlRequired");
}
// 文件解析/序列化
if (
msg.includes("解析 ~/.claude.json 失败") ||
msg.includes("解析 config.toml 失败") ||
msg.includes("无法识别的 TOML 格式") ||
msg.includes("TOML 内容不能为空")
) {
return t("mcp.error.tomlInvalid");
}
if (msg.includes("序列化 config.toml 失败")) {
return t("mcp.error.tomlInvalid");
}
return "";
};

View File

@@ -1,54 +1,169 @@
// 供应商配置处理工具函数
// 处理includeCoAuthoredBy字段的添加/删除
export const updateCoAuthoredSetting = (
jsonString: string,
disable: boolean,
): string => {
try {
const config = JSON.parse(jsonString);
import type { TemplateValueConfig } from "../config/providerPresets";
if (disable) {
// 添加或更新includeCoAuthoredBy字段
config.includeCoAuthoredBy = false;
const isPlainObject = (value: unknown): value is Record<string, any> => {
return Object.prototype.toString.call(value) === "[object Object]";
};
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;
}
};
// 从JSON配置中提取并处理官网地址
export const extractWebsiteUrl = (jsonString: string): string => {
try {
const config = JSON.parse(jsonString);
const baseUrl = config?.env?.ANTHROPIC_BASE_URL;
if (baseUrl && typeof baseUrl === "string") {
// 去掉 "api." 前缀
return baseUrl.replace(/^https?:\/\/api\./, "https://");
}
} catch (err) {
// 忽略JSON解析错误
}
return "";
};
// 读取配置中的 API Keyenv.ANTHROPIC_AUTH_TOKEN
export const getApiKeyFromConfig = (jsonString: string): string => {
try {
@@ -60,6 +175,51 @@ export const getApiKeyFromConfig = (jsonString: string): string => {
}
};
// 模板变量替换
export const applyTemplateValues = (
config: any,
templateValues: Record<string, TemplateValueConfig> | undefined,
): any => {
const resolvedValues = Object.fromEntries(
Object.entries(templateValues ?? {}).map(([key, value]) => {
const resolvedValue =
value.editorValue !== undefined
? value.editorValue
: (value.defaultValue ?? "");
return [key, resolvedValue];
}),
);
const replaceInString = (str: string): string => {
return Object.entries(resolvedValues).reduce((acc, [key, value]) => {
const placeholder = `\${${key}}`;
if (!acc.includes(placeholder)) {
return acc;
}
return acc.split(placeholder).join(value ?? "");
}, str);
};
const traverse = (obj: any): any => {
if (typeof obj === "string") {
return replaceInString(obj);
}
if (Array.isArray(obj)) {
return obj.map(traverse);
}
if (obj && typeof obj === "object") {
const result: any = {};
for (const [key, value] of Object.entries(obj)) {
result[key] = traverse(value);
}
return result;
}
return obj;
};
return traverse(config);
};
// 判断配置中是否存在 API Key 字段
export const hasApiKeyField = (jsonString: string): boolean => {
try {
@@ -95,3 +255,136 @@ 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;
}
};
// 在 Codex 的 TOML 配置文本中写入或更新 base_url 字段
export const setCodexBaseUrl = (
configText: string,
baseUrl: string,
): string => {
const trimmed = baseUrl.trim();
if (!trimmed) {
return configText;
}
const normalizedUrl = trimmed.replace(/\s+/g, "").replace(/\/+$/, "");
const replacementLine = `base_url = "${normalizedUrl}"`;
const pattern = /base_url\s*=\s*(["'])([^"']+)\1/;
if (pattern.test(configText)) {
return configText.replace(pattern, replacementLine);
}
const prefix =
configText && !configText.endsWith("\n") ? `${configText}\n` : configText;
return `${prefix}${replacementLine}\n`;
};

202
src/utils/tomlUtils.ts Normal file
View File

@@ -0,0 +1,202 @@
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
import { McpServerSpec } from "../types";
/**
* 验证 TOML 格式并转换为 JSON 对象
* @param text TOML 文本
* @returns 错误信息(空字符串表示成功)
*/
export const validateToml = (text: string): string => {
if (!text.trim()) return "";
try {
const parsed = parseToml(text);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return "mustBeObject";
}
return "";
} catch (e: any) {
// 返回底层错误信息,由上层进行 i18n 包装
return e?.message || "parseError";
}
};
/**
* 将 McpServerSpec 对象转换为 TOML 字符串
* 使用 @iarna/toml 的 stringify自动处理转义与嵌套表
*/
export const mcpServerToToml = (server: McpServerSpec): string => {
const obj: any = {};
if (server.type) obj.type = server.type;
if (server.type === "stdio") {
if (server.command !== undefined) obj.command = server.command;
if (server.args && Array.isArray(server.args)) obj.args = server.args;
if (server.cwd !== undefined) obj.cwd = server.cwd;
if (server.env && typeof server.env === "object") obj.env = server.env;
} else if (server.type === "http") {
if (server.url !== undefined) obj.url = server.url;
if (server.headers && typeof server.headers === "object")
obj.headers = server.headers;
}
// 去除未定义字段,确保输出更干净
for (const k of Object.keys(obj)) {
if (obj[k] === undefined) delete obj[k];
}
// stringify 默认会带换行,做一次 trim 以适配文本框展示
return stringifyToml(obj).trim();
};
/**
* 将 TOML 文本转换为 McpServerSpec 对象(单个服务器配置)
* 支持两种格式:
* 1. 直接的服务器配置type, command, args 等)
* 2. [mcp.servers.<id>] 或 [mcp_servers.<id>] 格式(取第一个服务器)
* @param tomlText TOML 文本
* @returns McpServer 对象
* @throws 解析或转换失败时抛出错误
*/
export const tomlToMcpServer = (tomlText: string): McpServerSpec => {
if (!tomlText.trim()) {
throw new Error("TOML 内容不能为空");
}
const parsed = parseToml(tomlText);
// 情况 1: 直接是服务器配置(包含 type/command/url 等字段)
if (
parsed.type ||
parsed.command ||
parsed.url ||
parsed.args ||
parsed.env
) {
return normalizeServerConfig(parsed);
}
// 情况 2: [mcp.servers.<id>] 格式
if (parsed.mcp && typeof parsed.mcp === "object") {
const mcpObj = parsed.mcp as any;
if (mcpObj.servers && typeof mcpObj.servers === "object") {
const serverIds = Object.keys(mcpObj.servers);
if (serverIds.length > 0) {
const firstServer = mcpObj.servers[serverIds[0]];
return normalizeServerConfig(firstServer);
}
}
}
// 情况 3: [mcp_servers.<id>] 格式
if (parsed.mcp_servers && typeof parsed.mcp_servers === "object") {
const serverIds = Object.keys(parsed.mcp_servers);
if (serverIds.length > 0) {
const firstServer = (parsed.mcp_servers as any)[serverIds[0]];
return normalizeServerConfig(firstServer);
}
}
throw new Error(
"无法识别的 TOML 格式。请提供单个 MCP 服务器配置,或使用 [mcp.servers.<id>] 格式",
);
};
/**
* 规范化服务器配置对象为 McpServer 格式
*/
function normalizeServerConfig(config: any): McpServerSpec {
if (!config || typeof config !== "object") {
throw new Error("服务器配置必须是对象");
}
const type = (config.type as string) || "stdio";
if (type === "stdio") {
if (!config.command || typeof config.command !== "string") {
throw new Error("stdio 类型的 MCP 服务器必须包含 command 字段");
}
const server: McpServerSpec = {
type: "stdio",
command: config.command,
};
// 可选字段
if (config.args && Array.isArray(config.args)) {
server.args = config.args.map((arg: any) => String(arg));
}
if (config.env && typeof config.env === "object") {
const env: Record<string, string> = {};
for (const [k, v] of Object.entries(config.env)) {
env[k] = String(v);
}
server.env = env;
}
if (config.cwd && typeof config.cwd === "string") {
server.cwd = config.cwd;
}
return server;
} else if (type === "http") {
if (!config.url || typeof config.url !== "string") {
throw new Error("http 类型的 MCP 服务器必须包含 url 字段");
}
const server: McpServerSpec = {
type: "http",
url: config.url,
};
// 可选字段
if (config.headers && typeof config.headers === "object") {
const headers: Record<string, string> = {};
for (const [k, v] of Object.entries(config.headers)) {
headers[k] = String(v);
}
server.headers = headers;
}
return server;
} else {
throw new Error(`不支持的 MCP 服务器类型: ${type}`);
}
}
/**
* 尝试从 TOML 中提取合理的服务器 ID/标题
* @param tomlText TOML 文本
* @returns 建议的 ID失败返回空字符串
*/
export const extractIdFromToml = (tomlText: string): string => {
try {
const parsed = parseToml(tomlText);
// 尝试从 [mcp.servers.<id>] 或 [mcp_servers.<id>] 中提取 ID
if (parsed.mcp && typeof parsed.mcp === "object") {
const mcpObj = parsed.mcp as any;
if (mcpObj.servers && typeof mcpObj.servers === "object") {
const serverIds = Object.keys(mcpObj.servers);
if (serverIds.length > 0) {
return serverIds[0];
}
}
}
if (parsed.mcp_servers && typeof parsed.mcp_servers === "object") {
const serverIds = Object.keys(parsed.mcp_servers);
if (serverIds.length > 0) {
return serverIds[0];
}
}
// 尝试从 command 中推断
if (parsed.command && typeof parsed.command === "string") {
const cmd = parsed.command.split(/[\\/]/).pop() || "";
return cmd.replace(/\.(exe|bat|sh|js|py)$/i, "");
}
} catch {
// 解析失败,返回空
}
return "";
};

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

@@ -1,7 +1,16 @@
/// <reference types="vite/client" />
import { Provider } from "./types";
import {
Provider,
Settings,
CustomEndpoint,
McpStatus,
McpConfigResponse,
McpServer,
McpServerSpec,
} from "./types";
import { AppType } from "./lib/tauri-api";
import type { UnlistenFn } from "@tauri-apps/api/event";
interface ImportResult {
success: boolean;
@@ -27,9 +36,103 @@ declare global {
getClaudeCodeConfigPath: () => Promise<string>;
getClaudeConfigStatus: () => Promise<ConfigStatus>;
getConfigStatus: (app?: AppType) => Promise<ConfigStatus>;
selectConfigFile: () => Promise<string | null>;
getConfigDir: (app?: AppType) => Promise<string>;
saveFileDialog: (defaultName: string) => Promise<string | null>;
openFileDialog: () => Promise<string | null>;
exportConfigToFile: (filePath: string) => Promise<{
success: boolean;
message: string;
filePath: string;
}>;
importConfigFromFile: (filePath: string) => Promise<{
success: boolean;
message: string;
backupId?: string;
}>;
selectConfigDirectory: (defaultPath?: string) => Promise<string | null>;
openConfigFolder: (app?: AppType) => Promise<void>;
openExternal: (url: string) => Promise<void>;
updateTrayMenu: () => Promise<boolean>;
onProviderSwitched: (
callback: (data: { appType: string; providerId: string }) => void,
) => Promise<UnlistenFn>;
getSettings: () => Promise<Settings>;
saveSettings: (settings: Settings) => Promise<boolean>;
checkForUpdates: () => Promise<void>;
isPortable: () => Promise<boolean>;
getAppConfigPath: () => Promise<string>;
openAppConfigFolder: () => Promise<void>;
// Claude 插件配置能力
getClaudePluginStatus: () => Promise<ConfigStatus>;
readClaudePluginConfig: () => Promise<string | null>;
applyClaudePluginConfig: (options: {
official: boolean;
}) => Promise<boolean>;
isClaudePluginApplied: () => Promise<boolean>;
// Claude MCP
getClaudeMcpStatus: () => Promise<McpStatus>;
readClaudeMcpConfig: () => Promise<string | null>;
upsertClaudeMcpServer: (
id: string,
spec: McpServerSpec | Record<string, any>,
) => Promise<boolean>;
deleteClaudeMcpServer: (id: string) => Promise<boolean>;
validateMcpCommand: (cmd: string) => Promise<boolean>;
// 新config.json 为 SSOT 的 MCP API
getMcpConfig: (app?: AppType) => Promise<McpConfigResponse>;
upsertMcpServerInConfig: (
app: AppType | undefined,
id: string,
spec: McpServer,
) => Promise<boolean>;
deleteMcpServerInConfig: (
app: AppType | undefined,
id: string,
) => Promise<boolean>;
setMcpEnabled: (
app: AppType | undefined,
id: string,
enabled: boolean,
) => Promise<boolean>;
syncEnabledMcpToClaude: () => Promise<boolean>;
syncEnabledMcpToCodex: () => Promise<boolean>;
importMcpFromClaude: () => Promise<number>;
importMcpFromCodex: () => Promise<number>;
// 读取当前生效live的 provider settings根据 appType
// Codex: { auth: object, config: string }
// Claude: settings.json 内容
getLiveProviderSettings: (app?: AppType) => Promise<any>;
testApiEndpoints: (
urls: string[],
options?: { timeoutSecs?: number },
) => Promise<
Array<{
url: string;
latency: number | null;
status?: number;
error?: string;
}>
>;
// 自定义端点管理
getCustomEndpoints: (
appType: AppType,
providerId: string,
) => Promise<CustomEndpoint[]>;
addCustomEndpoint: (
appType: AppType,
providerId: string,
url: string,
) => Promise<void>;
removeCustomEndpoint: (
appType: AppType,
providerId: string,
url: string,
) => Promise<void>;
updateEndpointLastUsed: (
appType: AppType,
providerId: string,
url: string,
) => Promise<void>;
};
platform: {
isMac: boolean;

62
tailwind.config.js Normal file
View File

@@ -0,0 +1,62 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./src/index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
// 扩展蓝色系列以匹配 Linear 风格
blue: {
500: '#3498db',
600: '#2980b9',
400: '#5dade2',
},
// 自定义灰色系列
gray: {
50: '#fafafa', // bg-primary
100: '#f4f4f5', // bg-tertiary
200: '#e4e4e7', // border
300: '#d4d4d8', // border-hover
400: '#a1a1aa', // text-tertiary
500: '#71717a', // text-secondary
600: '#52525b', // text-secondary-dark
700: '#3f3f46', // bg-tertiary-dark
800: '#27272a', // bg-secondary-dark
900: '#18181b', // text-primary
950: '#0a0a0b', // bg-primary-dark
},
// 状态颜色
green: {
500: '#10b981',
100: '#d1fae5',
},
red: {
500: '#ef4444',
100: '#fee2e2',
},
amber: {
500: '#f59e0b',
100: '#fef3c7',
},
},
boxShadow: {
'sm': '0 1px 2px 0 rgb(0 0 0 / 0.05)',
'md': '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
'lg': '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
},
borderRadius: {
'sm': '0.375rem',
'md': '0.5rem',
'lg': '0.75rem',
'xl': '0.875rem',
},
fontFamily: {
sans: ['-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto', '"Helvetica Neue"', 'Arial', 'sans-serif'],
mono: ['ui-monospace', 'SFMono-Regular', '"SF Mono"', 'Consolas', '"Liberation Mono"', 'Menlo', 'monospace'],
},
},
},
plugins: [],
}

View File

@@ -2,8 +2,8 @@
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "CommonJS",
"moduleResolution": "node",
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"esModuleInterop": true,
@@ -11,5 +11,5 @@
"strict": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}
"include": ["vite.config.mts"]
}

19
vite.config.mts Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
root: "src",
plugins: [react(), tailwindcss()],
base: "./",
build: {
outDir: "../dist",
emptyOutDir: true,
},
server: {
port: 3000,
strictPort: true,
},
clearScreen: false,
envPrefix: ["VITE_", "TAURI_"],
});

View File

@@ -1,18 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
root: 'src',
plugins: [react()],
base: './',
build: {
outDir: '../dist',
emptyOutDir: true
},
server: {
port: 3000,
strictPort: true
},
clearScreen: false,
envPrefix: ['VITE_', 'TAURI_']
})