[
  {
    "path": ".gitattributes",
    "content": "# Auto detect text files and perform LF normalization\n* text=auto\n\n# Explicitly declare text files you want to always be normalized and converted\n# to native line endings on checkout.\n*.rs text eol=lf\n*.toml text eol=lf\n*.json text eol=lf\n*.md text eol=lf\n*.yml text eol=lf\n*.yaml text eol=lf\n*.txt text eol=lf\n\n# TypeScript/JavaScript files\n*.ts text eol=lf\n*.tsx text eol=lf\n*.js text eol=lf\n*.jsx text eol=lf\n\n# HTML/CSS files\n*.html text eol=lf\n*.css text eol=lf\n*.scss text eol=lf\n\n# Shell scripts\n*.sh text eol=lf\n\n# Denote all files that are truly binary and should not be modified.\n*.png binary\n*.jpg binary\n*.jpeg binary\n*.gif binary\n*.ico binary\n*.woff binary\n*.woff2 binary\n*.ttf binary\n*.exe binary\n*.dll binary"
  },
  {
    "path": ".gitignore",
    "content": "node_modules/\ndist/\nrelease/\n.DS_Store\n*.log\n.env\n.env.local\n*.tsbuildinfo\n.npmrc\nCLAUDE.md\n# AGENTS.md\nGEMINI.md\n/.claude\n/.codex\n/.gemini\n/.cc-switch\n/.idea\n/.vscode\nvitest-report.json\nnul\n\n# Flatpak build artifacts\nflatpak/cc-switch.deb\nflatpak-build/\nflatpak-repo/\n.worktrees/\n.spec-workflow/\ncopilot-api\n.history\nCODEBUDDY.md\n.github\n"
  },
  {
    "path": ".node-version",
    "content": "22.12.0\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Changelog\n\nAll notable changes to CC Switch will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),\nand this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n## [3.12.3] - 2026-03-15\n\nPost-v3.12.2 work adds a Tool Search domain restriction bypass, skill backup/restore lifecycle, proxy compatibility for OpenAI o-series models and gzip compression, and robustness fixes for Skills import, provider forms, and terminal session restore.\n\n**Stats**: 17 commits | 61 files changed | +3,335 insertions | -194 deletions\n\n### Added\n\n- **Tool Search Domain Bypass**: Added setting to bypass Claude CLI Tool Search domain whitelist via equal-length binary patching; backups stored in `~/.cc-switch/toolsearch-backups/` with auto-reapply on startup when enabled\n- **Skill Auto-Backup**: Skill files are automatically backed up to `~/.cc-switch/skill-backups/` before uninstall, with metadata preserved in `meta.json`; old backups pruned to keep at most 20\n- **Skill Backup Restore & Delete**: Added list/restore/delete commands for skill backups; restore copies files back to SSOT, saves the DB record, and syncs to the current app with rollback on failure\n\n### Changed\n\n- **Proxy Gzip Compression**: Non-streaming proxy requests now auto-negotiate gzip compression instead of forcing `identity`; streaming requests conservatively keep `identity` to avoid SSE decompression errors\n- **o1/o3 Model Compatibility**: Chat Completions proxy forwarding now correctly uses `max_completion_tokens` instead of `max_tokens` for OpenAI o-series models such as o1/o3/o4-mini (#1451)\n- **OpenCode Model Variants**: Placed OpenCode model variants at top level instead of inside options for better discoverability (#1317)\n- **Skills Import Flow**: Replaced implicit filesystem-based app inference with explicit `ImportSkillSelection` to prevent incorrect multi-app activation; added reconciliation to remove disabled/orphaned symlinks and MCP servers from live config\n\n### Fixed\n\n- **o-series Responses API Tokens**: Kept Responses API on the correct `max_output_tokens` field for o-series models instead of incorrectly injecting `max_completion_tokens`\n- **Provider Form Double Submit**: Prevented duplicate submissions on rapid button clicks in provider add/edit forms (#1352)\n- **Ghostty Session Restore**: Fixed Claude session restore in Ghostty terminal (#1506, thanks @canyonsehun)\n- **Skill ZIP Import Extension**: Added `.skill` file extension support in ZIP import dialog (#1240, #1455)\n- **Skill ZIP Install Target App**: ZIP skill installs now use the currently active app instead of always defaulting to Claude\n- **OpenClaw Active Card Highlight**: Fixed active OpenClaw provider card not being highlighted (#1419)\n- **Responsive Layout with TOC**: Improved responsive design when TOC title exists (#1491)\n- **Import Skills Dialog White Screen**: Added missing TooltipProvider in ImportSkillsDialog to prevent runtime crash when opening the dialog\n- **Panel Bottom Blank Area**: Replaced hardcoded `h-[calc(100vh-8rem)]` with `flex-1 min-h-0` across all content panels to eliminate bottom gap caused by mismatched offset values\n\n---\n\n## [3.12.2] - 2026-03-12\n\nPost-v3.12.1 work focuses on Common Config safety during proxy takeover and more reliable Codex TOML editing.\n\n**Stats**: 5 commits | 22 files changed | +1,716 insertions | -288 deletions\n\n### Added\n\n- **Empty State Guidance**: Improved first-run experience with detailed import instructions and a conditional Common Config snippet hint for Claude/Codex/Gemini providers\n\n### Changed\n\n- **Proxy Takeover Restore Flow**: Proxy takeover hot-switch and provider sync now refresh the restore backup instead of overwriting live config files, rebuilding effective provider settings with Common Config applied so rollback preserves the real user configuration\n- **Codex TOML Editing Engine**: Refactored Codex `config.toml` updates onto shared section-aware TOML helpers in Rust and TypeScript, covering `base_url` and `model` field edits across provider forms and takeover cleanup\n- **Common Config Initialization Lifecycle**: Startup now auto-extracts Common Config snippets from clean live configs before takeover restoration, tracks explicit \"snippet cleared\" state, and persists a one-time legacy migration flag to avoid repeated backfills\n\n### Fixed\n\n- **Common Config Loss During Takeover**: Fixed cases where proxy takeover could drop Common Config changes, overwrite live configs during sync, or produce incomplete restore snapshots when switching providers\n- **Codex Restore Snapshot Preservation**: Fixed Codex takeover restore backups so existing `mcp_servers` blocks survive provider hot-switches instead of being discarded; changed MCP backup preservation from wholesale table replacement to per-server-id merge so provider/common-config MCP updates win on conflict while live-only servers are retained\n- **Cleared Snippet Resurrection**: Fixed startup auto-extraction recreating Common Config snippets that users had intentionally cleared\n- **Codex `base_url` Misplacement**: Fixed Codex `base_url` extraction and editing to target the active `[model_providers.<name>]` section instead of appending to the file tail or confusing `mcp_servers.*.base_url` entries for provider endpoints\n\n---\n\n## [3.12.1] - 2026-03-12\n\n### Patch Release\n\nStability-focused patch release fixing the Common Config modal infinite reopen loop, a WebDAV sync foreign key constraint failure, several i18n interpolation issues, and a Windows toolbar compact mode bug. Also adds **StepFun** provider presets, **OpenClaw input type selection** and **authHeader** support, upgrades Gemini to **3.1-pro**, and welcomes four new sponsor partners.\n\n**Stats**: 19 commits | 56 files changed | +1,429 insertions | -396 deletions\n\n### Added\n\n#### Provider Presets\n\n- **StepFun**: Added StepFun (阶跃星辰) provider presets including the step-3.5-flash model across supported applications (#1369, thanks @hengm3467)\n\n#### OpenClaw Enhancements\n\n- **Input Type Selection**: Added input type selection dropdown for model Advanced Options in OpenClaw configuration form (#1368, thanks @liuxxxu)\n- **authHeader Field**: Added optional `authHeader` boolean to OpenClawProviderConfig for vendor-specific auth header support (e.g. Longcat), and refactored form state to reuse the shared type\n\n#### Sponsor Partners\n\n- **Micu API**: Added Micu API as sponsor partner with affiliate links\n- **XCodeAPI**: Added XCodeAPI as sponsor partner\n- **SiliconFlow**: Added SiliconFlow (硅基流动) as sponsor partner with affiliate links\n- **CTok**: Added CTok as sponsor partner\n\n### Changed\n\n- **UCloud → Compshare**: Renamed UCloud provider to Compshare (优云智算) with full i18n support across all three locales (EN/ZH/JA)\n- **Compshare Links**: Updated Compshare sponsor registration links to coding-plan page\n- **Gemini Model Upgrade**: Upgraded default Gemini model from 2.5-pro to 3.1-pro in provider presets\n\n### Fixed\n\n#### Common Config & UI\n\n- **Common Config Modal Loop**: Fixed an infinite reopen loop in the Common Config modal and added draft editing support to prevent data loss during edits\n- **Toolbar Compact Mode (Windows)**: Fixed toolbar compact mode not triggering on Windows due to left-side overflow (#1375, thanks @zuoliangyu)\n- **Session Search Index**: Fixed session search index not syncing with query data, causing stale list display after session deletion\n\n#### Sync & Data\n\n- **WebDAV Provider Health FK**: Fixed foreign key constraint failure when restoring `provider_health` table during WebDAV sync\n\n#### Provider & Preset\n\n- **Longcat authHeader**: Added missing `authHeader: true` to Longcat provider preset (#1377, thanks @wavever)\n- **OpenClaw Tool Permissions**: Aligned OpenClaw tool permission profiles with upstream schema (#1355, thanks @bigsongeth)\n- **X-Code API URL**: Corrected X-Code API URL from `www.x-code.cn` to `x-code.cc`\n\n#### i18n & Localization\n\n- **Stream Check Toast**: Fixed stream check toast i18n interpolation keys not matching translation placeholders\n- **Proxy Startup Toast**: Fixed proxy startup toast not interpolating address and port values (#1399, thanks @Mason-mengze)\n- **OpenCode API Format Label**: Renamed OpenCode API format label from \"OpenAI\" to \"OpenAI Responses\" for accuracy\n\n---\n\n## [3.12.0] - 2026-03-09\n\n### Feature Release\n\nThis release restores the **Model Health Check (Stream Check)** UI, adds **OpenAI Responses API** format conversion, introduces the **Bedrock Optimizer** for thinking + cache injection, expands provider presets (Ucloud, Micu, X-Code API, Novita, Bailian For Coding), overhauls **OpenClaw config panels** with a JSON5 round-trip write engine, enhances **WebDAV sync** with dual-layer versioning, and delivers a comprehensive **i18n audit** fixing 69 missing keys alongside 20+ bug fixes.\n\n**Stats**: 56 commits | 221 files changed | +20,582 insertions | -8,026 deletions\n\n### Added\n\n#### Stream Check (Model Health Check)\n\n- **Restore Stream Check UI**: Brought back the model health check (Stream Check) panel for testing provider endpoint availability with live streaming validation\n- **First-Run Confirmation**: Added a confirmation dialog on first use of Stream Check to inform users about the feature's purpose and network requests\n- **OpenAI Chat Format Support**: Stream Check now supports `openai_chat` api_format, enabling health checks for providers using OpenAI-compatible endpoints\n\n#### OpenAI Responses API\n\n- **Responses API Format Conversion**: New `api_format = \"openai_responses\"` option enabling Anthropic Messages ↔ OpenAI Responses API bidirectional conversion for providers that implement the Responses API\n- **Responses API Deduplication**: Deduplicated and improved the Responses API conversion logic, consolidating shared transformation code\n\n#### Bedrock Optimizer\n\n- **Bedrock Request Optimizer**: PRE-SEND optimizer that injects thinking parameters and cache control blocks into AWS Bedrock requests, enabling extended thinking and prompt caching on Bedrock endpoints (#1301)\n\n#### OpenClaw Enhancements\n\n- **JSON5 Round-Trip Write Engine**: Overhauled OpenClaw config panels with a JSON5 round-trip write engine that preserves comments, formatting, and ordering when saving configuration changes\n- **Config Panel Improvements**: Redesigned EnvPanel as a full JSON editor, added `tools.profile` selection to ToolsPanel, introduced OpenClawHealthBanner for config validation warnings, and added legacy timeout migration support in Agents Defaults\n- **Agent Model Dropdown**: Replaced text inputs with dropdown selects for OpenClaw agent model configuration, offering a curated list of available models\n- **User-Agent Toggle**: Added a User-Agent header toggle for OpenClaw, defaulting to off to avoid potential compatibility issues with certain providers\n\n#### Provider Presets\n\n- **Ucloud**: Added Ucloud partner provider preset for Claude, Codex, and OpenClaw with endpointCandidates, unified apiKeyUrl, refreshed model defaults, and OpenClaw `templateValues` / `suggestedDefaults`\n- **Micu**: Added Micu partner provider preset for Claude, Codex, OpenClaw, and OpenCode with OpenClaw `templateValues` / `suggestedDefaults`\n- **X-Code API**: Added X-Code API partner provider preset for Claude, Codex, and OpenCode with endpointCandidates\n- **Novita**: Added Novita provider presets and icon across all supported apps (#1192)\n- **Bailian For Coding**: Added Bailian For Coding preset configuration (#1263)\n- **SiliconFlow Partner Badge**: Added partner badge designation for SiliconFlow provider presets\n- **Model Role Badges**: Added model role badges (e.g., Opus, Sonnet) to provider presets and reordered presets to prioritize Opus models\n\n#### WebDAV Sync\n\n- **Dual-Layer Versioning**: Added protocol v2 + db-v6 dual-layer versioning to WebDAV sync, enabling backward-compatible sync format evolution and automatic migration detection\n- **Auto-Sync Confirmation**: Added a confirmation dialog when toggling WebDAV auto-sync on/off to prevent accidental changes\n\n#### Usage & Data\n\n- **Daily Rollups & Auto-Vacuum**: Added usage daily rollups for aggregated statistics, incremental auto-vacuum for storage management, and sync-aware backup that coordinates with WebDAV sync cycles\n- **UsageFooter Extra Fields**: Added extra field display in UsageFooter component for normal mode, showing additional usage metadata (#1137)\n\n#### Session Management\n\n- **Session Deletion**: Added session deletion with per-provider cleanup and path safety validation, allowing users to remove individual conversation sessions\n\n#### UI & Config\n\n- **Auth Field Selector**: Restored Claude provider auth field selector supporting both AUTH_TOKEN and API_KEY authentication modes\n- **Failover Toggle**: Moved failover toggle to display independently on the main page with a confirmation dialog for enabling/disabling\n- **Common Config Auto-Extract**: Auto-extract Common Config Snippets from live configuration files on first run, seeding initial common config without manual setup\n- **New Provider Page Improvements**: Improved the new provider page with API endpoint and model name fields (#1155)\n\n### Changed\n\n#### Architecture\n\n- **Common Config Runtime Overlay**: Common Config is now applied as a runtime overlay during provider switching instead of being materialized (merged) into each provider's stored config. This preserves the original provider config in the database and applies common settings dynamically at write time\n- **First-Run Auto-Extract**: On first run, Common Config Snippets are automatically extracted from the current live configuration files, eliminating the need for manual initial setup\n\n### Fixed\n\n#### Proxy & Streaming\n\n- **OpenAI Streaming Conversion**: Fixed OpenAI ChatCompletion → Anthropic Messages streaming conversion that could produce malformed events under certain response structures\n- **Codex /responses/compact Route**: Added support for Codex `/responses/compact` route in proxy forwarding (#1194)\n- **Codex Common Config TOML Merge**: Fixed Codex Common Config to use structural TOML merge/subset instead of raw string comparison, correctly handling key ordering and formatting differences\n- **Proxy Forwarder Failure Logs**: Improved proxy forwarder failure logging with more descriptive error messages\n\n#### Provider & Preset\n\n- **X-Code Rename**: Renamed \"X-Code\" provider to \"X-Code API\" for consistency with the official branding\n- **SSSAiCode Missing /v1**: Added missing `/v1` path to SSSAiCode default endpoint for Codex and OpenCode\n- **AICoding URL Fix**: Removed `www` prefix from aicoding.sh provider URLs to match the correct domain\n- **New Provider Page Input Handling**: Fixed the new provider page so API endpoint / model fields handle line-break deletion correctly and added the missing `codexConfig.modelNameHint` i18n key for zh/en/ja\n\n#### Platform\n\n- **Cache Hit Token Statistics**: Fixed missing token statistics for cache hits in streaming responses (#1244)\n- **Minimize-to-Tray Auto Exit**: Fixed issue where the application would automatically exit after being minimized to the system tray for a period of time (#1245)\n\n#### i18n & Localization\n\n- **Comprehensive i18n Audit**: Added 69 missing i18n keys and fixed hardcoded Chinese strings across the application, improving localization coverage for all three languages (zh/en/ja)\n- **Model Test Panel i18n**: Corrected i18n key paths for model test panel title and description\n- **JSON5 Slash Escaping**: Normalized JSON5 slash escaping and added i18n support for OpenClaw panel labels\n\n#### UI\n\n- **Skills Count Display**: Fixed skills count not displaying correctly when adding new skills (#1295)\n- **Endpoint Speed Test**: Removed HTTP status code display from endpoint speed test results to reduce visual noise\n- **Outline Button Text Tone**: Aligned outline button text color tone with usage refresh control for visual consistency (#1222)\n\n### Performance\n\n- **OpenClaw Config Write Skip**: Skip backup and atomic write when OpenClaw configuration content is unchanged, avoiding unnecessary I/O operations\n\n### Documentation\n\n- **User Manual i18n**: Restructured user manual for internationalization and added complete EN/JA translations alongside the existing ZH documentation\n- **User Manual OpenClaw**: Added OpenClaw coverage and completed settings documentation for the user manual\n- **UCloud CompShare Sponsor**: Added UCloud CompShare as a sponsor partner\n- **Docs Directory Reorganization**: Reorganized docs directory structure, added user manual links to all three README files, removed cross-language links from user manual sections, and synced README features across EN/ZH/JA\n\n### Maintenance\n\n- **Periodic Maintenance Timer**: Consolidated periodic maintenance timers into a unified scheduler, combining vacuum and rollup operations into a single timer\n- **OpenClaw Save Toast**: Removed backup path display from OpenClaw save toasts for cleaner notification messages\n\n---\n\n## [3.11.1] - 2026-02-28\n\n### Hotfix Release\n\nThis release reverts the Partial Key-Field Merging architecture introduced in v3.11.0, restoring the proven \"full config overwrite + Common Config Snippet\" mechanism, and fixes several UI and platform compatibility issues.\n\n**Stats**: 8 commits | 52 files changed | +3,948 insertions | -1,411 deletions\n\n### Reverted\n\n- **Restore Full Config Overwrite + Common Config Snippet** (revert 992dda5c): Reverted the partial key-field merging refactoring from v3.11.0 due to critical issues — non-whitelisted custom fields were lost during provider switching, backfill permanently stripped non-key fields from the database, and the whitelist required constant maintenance. Restores full config snapshot write, Common Config Snippet UI and backend commands, and 6 frontend components/hooks\n\n### Changed\n\n- **Proxy Panel Layout**: Moved proxy on/off toggle from accordion header into panel content area, placed directly above app takeover options, ensuring users see takeover configuration immediately after enabling the proxy\n- **Manual Import for OpenCode/OpenClaw**: Removed auto-import on startup; empty state now shows an \"Import Current Config\" button, consistent with Claude/Codex/Gemini behavior\n\n### Fixed\n\n- **\"Follow System\" Theme Not Auto-Updating**: Delegated to Tauri's native theme tracking (`set_window_theme(None)`) so the WebView's `prefers-color-scheme` media query stays in sync with OS theme changes\n- **Compact Mode Cannot Exit**: Restored `flex-1` on `toolbarRef` so `useAutoCompact`'s exit condition triggers correctly based on available width instead of content width\n- **Proxy Takeover Toast Shows {{app}}**: Added missing `app` interpolation parameter to i18next `t()` calls for proxy takeover enabled/disabled messages\n- **Windows Protocol Handler Side Effects**: Disabled environment check and one-click install on Windows to prevent unintended protocol handler registration\n\n---\n\n## [3.11.0] - 2026-02-26\n\n### Feature Release\n\nThis release introduces **OpenClaw** as the fifth supported application, a full **Session Manager** for browsing conversation history across all apps, an independent **Backup Management** panel, **Oh My OpenCode (OMO)** integration, and 50+ other features, fixes, and improvements across 147 commits.\n\n**Stats**: 147 commits | 274 files changed | +32,179 insertions | -5,467 deletions\n\n### Added\n\n#### OpenClaw Support (New Application)\n\n- **OpenClaw Integration**: Full management support for OpenClaw as the fifth application in CC Switch, including provider switching, configuration panels (Env / Tools / Agents Defaults), Workspace file management (HEARTBEAT / BOOTSTRAP / BOOT), daily memory files, and additive overlay mode\n- **OpenClaw Provider Presets**: 13+ built-in provider presets with brand icon and complete i18n (zh/en/ja)\n- **OpenClaw Form Fields**: Dedicated provider form with providerKey input, model allowlist auto-registration, and default model button\n- **OpenClaw Config Panels**: Env editor, Tools editor, and Agents Defaults editor backed by JSON5 read/write (`openclaw_config.rs`)\n\n#### Session Manager\n\n- **Session Manager**: Browse and search conversation history for Claude Code, Codex, Gemini CLI, OpenCode, and OpenClaw with table-of-contents navigation and in-session search\n- **Session App Filter**: Auto-filter sessions by current app when entering the session page\n- **Session Performance**: Parallel directory scanning and head-tail JSONL reading for faster session list loading\n\n#### Backup Management\n\n- **Backup Panel**: Independent backup management panel with configurable backup policy (max count, auto-cleanup) and backup rename support\n- **Periodic Backup**: Hourly automatic backup timer during runtime\n- **Pre-Migration Backup**: Automatic backup before database schema migrations with backfill warning\n- **Delete Backup**: Delete individual backup files with confirmation dialog\n- **Backup Time Fix**: Use local time instead of UTC for backup file names\n\n#### Oh My OpenCode (OMO)\n\n- **OMO Integration**: Full Oh My OpenCode config file management with agent model selection, category configuration, and recommended model fill\n- **OMO Slim**: Lightweight oh-my-opencode-slim mode support with OmoVariant parameterization\n- **OMO Cross-Exclusion**: Enforce OMO ↔ OMO Slim mutual exclusion at the database level\n\n#### Workspace\n\n- **Daily Memory Search**: Full-text search across daily memory files with date-sorted display\n- **Clickable Paths**: Directory paths in workspace panels are now clickable; renamed “Today's Note” to “Add Memory”\n- **Workspace Files Panel**: Manage bootstrap markdown files for OpenClaw (HEARTBEAT / BOOTSTRAP / BOOT types)\n\n#### Provider Presets\n\n- **AWS Bedrock**: Support for AKSK and API Key authentication modes (Claude and OpenCode)\n- **SSAI Code**: Partner provider preset across all five apps\n- **CrazyRouter**: Partner provider preset with custom icon\n- **AICoding**: Partner provider preset with i18n promotion text\n- **Bailian**: Renamed from Qwen Coder with new icon; updated domestic model providers to latest versions\n\n#### Proxy & Network\n\n- **Thinking Budget Rectifier**: New rectifier for thinking budget parameters with dedicated module (`thinking_budget_rectifier.rs`)\n- **WebDAV Auto Sync**: Automatic periodic sync with large file protection mechanism\n\n#### UI & UX\n\n- **Theme Animation**: Circular reveal animation when toggling between light and dark themes\n- **Claude Quick Toggles**: Quick toggle switches in the Claude config JSON editor for common settings\n- **Dynamic Endpoint Hint**: Context-aware hint text in endpoint input based on API format selection\n- **AppSwitcher Auto Compact**: Automatically collapse to compact mode based on available width, with smooth transition animation\n- **App Transition**: Fade-in/fade-out animation when switching between OpenClaw and other apps\n- **Silent Startup Conditional**: Show silent startup option only when launch-on-startup is enabled\n\n#### Settings & Environment\n\n- **First-Run Confirmation**: Confirmation dialogs for proxy and usage features on first use\n- **Local Proxy Toggle**: `enableLocalProxy` setting to control proxy UI visibility on the home page\n- **Environment Check**: More granular local environment detection (installed CLI tool versions, Volta path detection)\n\n#### Usage & Pricing\n\n- **Usage Dashboard Enhancement**: Auto-refresh control, robust formatting, and request log table improvements\n- **New Model Pricing**: Added pricing data for claude-opus-4-6 and gpt-5.3-codex with incremental data seeding\n\n### Changed\n\n#### Architecture\n\n- **Partial Key-Field Merging (⚠️ Breaking, reverted in v3.11.1)**: Provider switching now uses partial key-field merging instead of full config overwrite, preserving user's non-provider settings (plugins, MCP, permissions). The \"Common Config Snippet\" feature has been removed as it is no longer needed. Removes 6 frontend files and ~150 lines of backend dead code (#1098)\n- **Manual Import**: Replaced auto-import on startup with manual “Import Current Config” button in empty state, reducing ~47 lines of startup code\n- **OMO Variant Parameterization**: Eliminated ~250 lines of OMO/OMO Slim code duplication via `OmoVariant` struct with STANDARD/SLIM constants\n- **OMO Common Config Removal**: Removed the two-layer merge system for OMO common config (-1,733 lines across 21 files)\n\n#### Code Quality\n\n- **ProviderForm Decomposition**: Extracted ProviderForm.tsx from 2,227 lines to 1,526 lines by splitting into 5 focused modules (opencodeFormUtils, useOmoModelSource, useOpencodeFormState, useOmoDraftState, useOpenclawFormState)\n- **Shared MCP/Skills Components**: Extracted AppCountBar, AppToggleGroup, and ListItemRow shared components to eliminate duplication across MCP and Skills panels\n- **OpenClaw TanStack Query Migration**: Migrated Env, Tools, and AgentsDefaults panels from manual useState/useEffect to centralized TanStack Query hooks\n\n#### Settings Layout\n\n- **Proxy Tab**: Split Advanced tab into dedicated Proxy tab (local proxy, failover, rectifiers, global outbound proxy); moved pricing config to Usage dashboard as collapsible accordion. SettingsPage reduced from ~716 to ~426 lines with 5-tab layout: General | Proxy | Advanced | Usage | About\n- **Data Section Split**: Split data accordion into Import/Export and Cloud Sync sections for better discoverability\n\n#### Terminal & Config\n\n- **Unified Terminal Selection**: Consolidated terminal preference to global settings; added WezTerm support and terminal name mapping (iterm2 → iterm)\n- **OpenClaw Agents Panel**: Primary model field set to read-only; detailed model fields (context window, max tokens, reasoning, cost) moved to advanced options\n- **Claude Model Update**: Updated Claude model references from 4.5 to 4.6 across all provider presets\n\n### Fixed\n\n#### Critical\n\n- **Windows Home Dir Regression**: Restored default home directory resolution on Windows to prevent providers/settings “disappearing” when `HOME` env var differs from the real user profile directory (Git/MSYS environments); auto-detects v3.10.3 legacy database location\n- **Linux White Screen**: Disabled WebKitGTK hardware acceleration on AMD GPUs (Cezanne/Radeon Vega) to prevent EGL initialization failure causing blank screen on startup\n- **OpenAI Beta Parameter**: Stopped appending `?beta=true` to OpenAI Chat Completions endpoints, fixing request failures for Nvidia and other `apiFormat=”openai_chat”` providers\n- **Health Check Auth Mode**: Health check now respects provider's auth_mode setting instead of always using x-api-key header\n\n#### Provider & Preset\n\n- **OpenClaw /v1 Prefix**: Removed /v1 prefix from OpenClaw anthropic-messages presets to prevent double path (/v1/v1/messages) with Anthropic SDK auto-append\n- **Opus Pricing**: Corrected Opus pricing from $15/$75 to $5/$25 and upgraded model ID to claude-opus-4-6\n- **AIGoCode URLs**: Unified API base URL to https://api.aigocode.com across all apps; removed trailing /v1 suffix\n- **Zhipu GLM**: Removed outdated partner status from Claude, OpenCode, and OpenClaw presets\n- **API Key Visibility**: Restored API Key input field when creating new Claude providers (was incorrectly hidden for non-cloud_provider categories)\n\n#### OMO / OMO Slim\n\n- **OMO Slim Category Checks**: Added missing omo-slim category checks across add/form/mutation paths\n- **OMO Slim Cache Invalidation**: Invalidate OMO Slim query cache after provider mutations to prevent stale UI state\n- **OMO Recommended Models**: Synced agent/category recommended models with upstream sources; fixed provider/model format to pure model IDs\n- **OMO Fill Feedback**: Added toast feedback when “Fill Recommended” button silently fails\n- **OMO Last-Provider Restriction**: Removed last-provider deletion restriction for OMO/OMO Slim plugins\n- **OpenCode Model Validation**: Reject saving OpenCode providers without at least one configured model\n\n#### OpenClaw\n\n- **OpenClaw P0-P3 Fixes**: Fixed 25 missing i18n keys, replaced key={index} with stable crypto.randomUUID(), excluded openclaw from ProxyToggle/FailoverToggle, added deep link merge_additive_config(), unified serde(flatten) naming, added directory existence checks, removed dead code, added duplicate key validation\n- **OpenClaw Robustness**: Fixed EnvPanel visibleKeys using entry key names instead of array indices; added NaN guards; validated provider ID and model before import\n- **OpenClaw i18n Dedup**: Merged duplicate openclaw i18n keys to restore provider form translations\n\n#### Platform\n\n- **Window Flash**: Prevented window flicker on silent startup (Windows)\n- **Title Bar Theme**: Title bar now follows dark/light mode theme changes\n- **Skills Path Separator**: Fixed path separator matching for skill installation status on Windows (supports both `/` and `\\`)\n- **WSL Conditional Compilation**: Added `#[cfg(target_os = “windows”)]` to WSL helper functions to eliminate dead_code warnings on non-Windows platforms\n\n#### UI\n\n- **Toolbar Clipping**: Removed toolbar height limit that was clipping AppSwitcher\n- **Update Badge**: Show update badge instead of green check when a newer version is available\n- **Session Button Visibility**: Only show Session Manager button for Claude and Codex apps\n- **Directory Spacing**: Added vertical spacing between directory setting sections\n- **Dark Mode Cards**: Unified SQL import/export card styling in dark mode\n- **OpenClaw Scroll**: Enabled scrolling for OpenClaw configuration panel content\n\n#### i18n & Localization\n\n- **Session Manager i18n**: Replaced hardcoded Chinese strings with i18n keys for relative time, role labels, and UI elements\n- **OpenClaw Default Model Label**: Renamed “Enable/Default” to “Set as Default / Current Default” with wider button\n- **Daily Memory Sort**: Sort daily memory files by filename date (YYYY-MM-DD.md) instead of modification time\n- **Backup Name i18n**: Use local time for backup file names\n\n#### Other\n\n- **Skill Doc URL**: Use actual branch from download_repo for documentation URL; switched from /tree/ to /blob/ pointing to SKILL.md\n- **OpenCode Install Detection**: Added install.sh priority paths (OPENCODE_INSTALL_DIR > XDG_BIN_DIR > ~/bin > ~/.opencode/bin) with path dedup and cross-platform executable candidates\n- **Provider Auto-Import**: Removed auto-import side effect from useProvidersQuery queryFn; users now trigger import manually via empty state button\n- **Manual Backup Validation**: Treat missing database file as error during manual backup to prevent false success toast\n\n### Performance\n\n- **Session Panel Loading**: Parallel directory scanning and head-tail JSONL reading for Codex, OpenClaw, and OpenCode session providers\n- **Query Cache Cleanup**: Removed unnecessary TanStack Query cache overhead for Tauri local IPC calls\n\n### Documentation\n\n- **Sponsors**: Added/updated SSSAiCode, Crazyrouter, AICoding, Right Code, and MiniMax sponsor entries across all README languages\n- **User Manual**: Added user manual documentation (#979)\n\n### Maintenance\n\n- **Pre-Release Cleanup**: Removed debug logs, fixed clippy warnings, added missing Japanese translations, and formatted code\n- **UI Exclusions**: Hidden MCP, Skills, proxy/pricing, stream check, and model test panels for OpenClaw where not applicable\n\n---\n\n## [3.10.3] - 2026-01-30\n\n### Feature Release\n\nThis release introduces a generic API format selector, pricing configuration enhancements, and multiple UX improvements.\n\n### Added\n\n- **API Key Link for OpenCode**: API key link support for OpenCode provider form, enabling quick access to provider key management pages\n- **AICodeMirror Partner Preset**: Added AICodeMirror partner preset for all apps (Claude, Codex, Gemini, OpenCode)\n- **API Format Selector**: Generic API format chooser for Claude providers, replacing the OpenRouter-specific toggle. Supports Anthropic Messages (native) and OpenAI Chat Completions format\n- **API Format Presets**: Allow preset providers to specify API format (anthropic or openai_chat) for third-party proxy services\n- **Proxy Hint**: Display info toast when switching to OpenAI Chat format provider, reminding users to enable proxy\n- **Pricing Config Enhancement**: Per-provider cost multiplier, pricing model source (request/response), request model logging, and enriched usage UI (#781)\n- **Skills ZIP Install**: Install skills directly from local ZIP files with recursive scanning support\n- **Preferred Terminal**: Choose preferred terminal app per platform (macOS: Terminal.app/iTerm2/Alacritty/Kitty/Ghostty; Windows: cmd/PowerShell/Windows Terminal; Linux: GNOME Terminal/Konsole/Xfce4/Alacritty/Kitty/Ghostty)\n- **Silent Startup**: Option to prevent window popup on launch (#713)\n- **OpenCode Environment Check**: Version detection with Go path scanning and one-click install from GitHub Releases\n- **OpenCode Directory Sync**: Auto-sync all providers to live config on directory change with additive mode support\n- **NVIDIA NIM Preset**: New provider preset for Claude and OpenCode with nvidia.svg icon\n- **n1n.ai Preset**: New provider preset (#667)\n- **Update Badge Icon**: Replace update badge dot with ArrowUpCircle icon\n- **Linux ARM64**: CI build support for Linux ARM64 architecture\n\n### Changed\n\n- **API Format Migration**: Migrate api_format from settings_config to ProviderMeta to prevent polluting ~/.claude/settings.json\n- **DeepSeek max_tokens**: Remove max_tokens clamp from proxy transform layer\n- **Terminal Functions**: Consolidate redundant terminal launch functions\n- **Home Dir Utility**: Consolidate get_home_dir into single public function\n- **Kimi/Moonshot**: Upgrade provider presets to k2.5 model\n\n### Fixed\n\n- **Codex 404 & Timeout**: Fix 404 errors and connection timeout with custom base_url; improve /v1 prefix handling and system proxy detection (#760)\n- **Proxy URL Building**: Fix duplicate /v1/v1 in URL; extend ?beta=true to /v1/chat/completions endpoint\n- **OpenRouter Compat Mode**: Improve backward compatibility supporting number and string types\n- **Gemini Visibility**: Correct Gemini default visibility to true (#818)\n- **Footer Layout**: Correct footer layout in advanced settings tab\n- **Claude Code Detection**: Prioritize native install path for detection\n- **Tray Menu**: Simplify title labels and optimize menu separators (#796)\n- **Duplicate Skills**: Prevent duplicate skill installation from different repos (#778)\n- **Windows Tests**: Stabilize test environment (#644)\n- **i18n**: Update apiFormatOpenAIChat label to mention proxy requirement\n- **Error Display**: Use extractErrorMessage for complete error display in mutations\n- **Sponsors**: Add AICodeMirror and reorder sponsor list\n\n---\n\n## [3.10.2] - 2026-01-24\n\n### Patch Release\n\nThis maintenance release adds skill sync options and includes important bug fixes.\n\n### Added\n\n- **Skills**: Add skill sync method setting with symlink/copy options\n- **Partners**: Add RightCode as official partner\n\n### Fixed\n\n- **Prompts**: Clear prompt file when all prompts are disabled\n- **OpenCode**: Preserve extra model fields during serialization\n- **Provider Form**: Backfill model fields when editing Claude provider\n\n---\n\n## [3.10.1] - 2026-01-23\n\n### Patch Release\n\nThis maintenance release includes important bug fixes for Windows platform, UI improvements, and code quality enhancements.\n\n### Added\n\n- **Provider Icons**: Updated RightCode provider icon with improved visual design\n\n### Changed\n\n- **Proxy Rectifier**: Changed rectifier default state to disabled for better stability\n- **Window Settings**: Reordered window settings and updated default values for improved UX\n- **UI Layout**: Increased app icon collapse threshold from 3 to 4 icons\n- **Code Quality**: Simplified `RectifierConfig` implementation using `#[derive(Default)]`\n\n### Fixed\n\n- **Windows Platform**:\n  - Fixed terminal window closing immediately after execution on Windows\n  - Corrected OpenCode config path resolution on Windows\n- **UI Improvements**:\n  - Fixed ProviderIcon color validation to prevent black icons from appearing\n  - Unified layout padding across all panels for consistent spacing\n  - Fixed panel content alignment with header constraints\n- **Code Quality**: Resolved Rust Clippy warnings and applied consistent formatting\n\n---\n\n## [3.10.0] - 2026-01-21\n\n### Feature Release\n\nThis release introduces OpenCode support and brings improvements across proxy, usage tracking, and overall UX.\n\n### Added\n\n- **OpenCode Support** - Manage OpenCode providers, MCP servers, and Skills, with first-launch import and full internationalization (#695)\n- **Global Proxy** - Add global proxy settings for outbound network requests (#596)\n- **Claude Rectifier** - Add thinking signature rectifier for Claude API (#595)\n- **Health Check Enhancements** - Configurable prompt and CLI-compatible requests for stream health check (#623)\n- **Per-Provider Config** - Support provider-specific configuration and persistence (#663)\n- **App Visibility Controls** - Show/hide apps and keep tray menu in sync (Gemini hidden by default)\n- **Takeover Compact Mode** - Use a compact AppSwitcher layout when showing 3+ visible apps\n- **Keyboard Shortcut** - Press `ESC` to quickly go back/close panels (#670)\n- **Terminal Improvements** - Provider-specific terminal button, `fnm` path support, and safer cross-platform launching (#564)\n- **WSL Tool Detection** - Detect tool versions in WSL with additional security hardening (#627)\n- **Skills Presets** - Add `baoyu-skills` preset repo and auto-supplement missing default repos\n\n### Changed\n\n- **Proxy Logging** - Simplify proxy log output (#585)\n- **Pricing Editor UX** - Unify pricing edit modal with `FullScreenPanel`\n- **Advanced Settings Layout** - Move rectifier section below failover for better flow\n- **OpenRouter Compat Mode** - Disable OpenRouter compatibility mode by default and hide UI toggle\n\n### Fixed\n\n- **Auto Failover** - Switch to P1 immediately when enabling auto failover\n- **Provider Edit Dialog** - Fix stale data when reopening provider editor after save (#654)\n- **Deeplink** - Support multiple endpoints and prioritize `GOOGLE_GEMINI_BASE_URL` over `GEMINI_BASE_URL` (#597)\n- **MCP (WSL)** - Skip `cmd /c` wrapper for WSL target paths (#592)\n- **Usage Templates** - Add variable hints and validation fixes; prevent config leaking between providers (#628)\n- **Gemini Timeout Format** - Convert timeout params to Gemini CLI format (#580)\n- **UI** - Fix Select dropdown rendering in `FullScreenPanel`; auto-apply default icon color when unset\n- **Usage UI** - Auto-adapt usage block offset based on action buttons width (#613)\n- **Provider Endpoint** - Persist endpoint auto-select state (#611)\n- **Provider Form** - Reset baseUrl and apiKey states when switching presets\n\n---\n\n## [3.9.1] - 2026-01-09\n\n### Bug Fix Release\n\nThis release focuses on stability improvements and crash prevention.\n\n### Added\n\n- **Crash Logging** - Panic hook captures crash info to `~/.cc-switch/crash.log` with full stack traces (#562)\n- **Release Logging** - Enable logging for release builds with automatic rotation (keeps 2 most recent files)\n- **AIGoCode Icon** - Added colored icon for AIGoCode provider preset\n\n### Fixed\n\n- **Proxy Panic Prevention** - Graceful degradation when HTTP client initialization fails due to invalid proxy settings; falls back to no_proxy mode (#560)\n- **UTF-8 Safety** - Fix potential panic when masking API keys or truncating logs containing multi-byte characters (Chinese, emoji, etc.) (#560)\n- **Default Proxy Port** - Change default port from 5000 to 15721 to avoid conflict with macOS AirPlay Receiver (#560)\n- **Windows Title** - Display \"CC Switch\" instead of default \"Tauri app\" in window title\n- **Windows/Linux Spacing** - Remove extra 28px blank space below native titlebar introduced in v3.9.0\n- **Flatpak Tray Icon** - Bundle libayatana-appindicator for tray icon support on Flatpak (#556)\n- **Provider Preset** - Correct casing from \"AiGoCode\" to \"AIGoCode\" to match official branding\n\n---\n\n## [3.9.0] - 2026-01-07\n\n### Stable Release\n\nThis stable release includes all changes from `3.9.0-1`, `3.9.0-2`, and `3.9.0-3`.\n\n### Added\n\n- **Local API Proxy** - High-performance local HTTP proxy for Claude Code, Codex, and Gemini CLI (Axum-based)\n- **Per-App Takeover** - Independently route each app through the proxy with automatic live-config backup/redirect\n- **Auto Failover** - Circuit breaker + smart failover with independent queues and health tracking per app\n- **Universal Provider** - Shared provider configurations that can sync to Claude/Codex/Gemini (ideal for API gateways like NewAPI)\n- **Provider Search Filter** - Quick filter to find providers by name (#435)\n- **Keyboard Shortcut** - Open settings with Command+comma / Ctrl+comma (#436)\n- **Deeplink Usage Config** - Import usage query config via deeplink (#400)\n- **Provider Icon Colors** - Customize provider icon colors (#385)\n- **Skills Multi-App Support** - Skills now support both Claude Code and Codex (#365)\n- **Closable Toasts** - Close button for switch toast and all success toasts (#350)\n- **Skip First-Run Confirmation** - Option to skip Claude Code first-run confirmation dialog\n- **MCP Import** - Import MCP servers from installed apps\n- **Common Config Snippet Extraction** - Extract reusable common config snippets from the current provider or editor content (Claude/Codex/Gemini)\n- **Usage Enhancements** - Model extraction, request logging improvements, cache hit/creation metrics, and auto-refresh (#455, #508)\n- **Error Request Logging** - Detailed logging for proxy requests (#401)\n- **Linux Packaging** - Added RPM and Flatpak packaging targets\n- **Provider Presets & Icons** - Added/updated partner presets and icons (e.g., MiMo, DMXAPI, Cubence)\n\n### Changed\n\n- **Usage Terminology** - Rename \"Cache Read/Write\" to \"Cache Hit/Creation\" across all languages (#508)\n- **Model Pricing Data** - Refresh built-in model pricing table (Claude full version IDs, GPT-5 series, Gemini ID formats, and Chinese models) (#508)\n- **Proxy Header Forwarding** - Switch to a blacklist approach and improve header passthrough compatibility (#508)\n- **Failover Behavior** - Bypass timeout/retry configs when failover is disabled; update default failover timeout and circuit breaker values (#508, #521)\n- **Provider Presets** - Update default model versions and change the default Qwen base URL (#517)\n- **Skills Management** - Unify Skills management architecture with SSOT + React Query; improve caching for discoverable skills\n- **Settings UX** - Reorder items in the Advanced tab for better discoverability\n- **Proxy Active Theme** - Apply emerald theme when proxy takeover is active\n\n### Fixed\n\n- **Security** - Security fixes for JavaScript executor and usage script (#151)\n- **Usage Timezone & Parsing** - Fix datetime picker timezone handling; improve token parsing/billing for Gemini and Codex formats (#508)\n- **Windows Compatibility** - Improve MCP export and version check behavior to avoid terminal popups\n- **Windows Startup** - Use system titlebar to prevent black screen on startup\n- **WebView Compatibility** - Add fallback for crypto.randomUUID() on older WebViews\n- **macOS Autostart** - Use `.app` bundle path to prevent terminal window popups\n- **Database** - Add missing schema migrations; show an error dialog on initialization failure with a retry option\n- **Import/Export** - Restrict SQL import to CC Switch exported backups only; refresh providers immediately after import\n- **Prompts** - Allow saving prompts with empty content\n- **MCP Sync** - Skip sync when the target CLI app is not installed\n- **Common Config (Codex)** - Preserve MCP server `base_url` during extraction and remove provider-specific `model_providers` blocks\n- **Proxy** - Improve takeover detection and stability; clean up model override env vars when switching providers in takeover mode (#508)\n- **Skills** - Skip hidden directories during discovery; fix wrong skill repo branch\n- **Settings Navigation** - Navigate to About tab when clicking update badge\n- **UI** - Fix dialogs not opening on first click and improve window dragging area in `FullScreenPanel`\n\n---\n\n## [3.9.0-3] - 2025-12-29\n\n### Beta Release\n\nThird beta release with important bug fixes for Windows compatibility, UI improvements, and new features.\n\n### Added\n\n- **Universal Provider** - Support for universal provider configurations (#348)\n- **Provider Search Filter** - Quick filter to find providers by name (#435)\n- **Keyboard Shortcut** - Open settings with Command+comma / Ctrl+comma (#436)\n- **Xiaomi MiMo Icon** - Added MiMo icon and Claude provider configuration (#470)\n- **Usage Model Extraction** - Extract model info from usage statistics (#455)\n- **Skip First-Run Confirmation** - Option to skip Claude Code first-run confirmation dialog\n- **Exit Animations** - Added exit animation to FullScreenPanel dialogs\n- **Fade Transitions** - Smooth fade transitions for app/view/panel switching\n\n### Fixed\n\n#### Windows\n- Wrap npx/npm commands with `cmd /c` for MCP export\n- Prevent terminal windows from appearing during version check\n\n#### macOS\n- Use .app bundle path for autostart to prevent terminal window popup\n\n#### UI\n- Resolve Dialog/Modal not opening on first click (#492)\n- Improve dark mode text contrast for form labels\n- Reduce header spacing and fix layout shift on view switch\n- Prevent header layout shift when switching views\n\n#### Database & Schema\n- Add missing base columns migration for proxy_config\n- Add backward compatibility check for proxy_config seed insert\n\n#### Other\n- Use local timezone and robust DST handling in usage stats (#500)\n- Remove deprecated `sync_enabled_to_codex` call\n- Gracefully handle invalid Codex config.toml during MCP sync\n- Add missing translations for reasoning model and OpenRouter compat mode\n\n### Improved\n\n- **macOS Tray** - Use macOS tray template icon\n- **Header Alignment** - Remove macOS titlebar tint, align custom header\n- **Shadow Removal** - Cleaner UI by removing shadow styles\n- **Code Inspector** - Added code-inspector-plugin for development\n- **i18n** - Complete internationalization for usage panel and settings\n- **Sponsor Logos** - Made sponsor logos clickable\n\n### Stats\n\n- 35 commits since v3.9.0-2\n- 5 files changed in test/lint fixes\n\n---\n\n## [3.9.0-2] - 2025-12-20\n\n### Beta Release\n\nSecond beta release focusing on proxy stability, import safety, and provider preset polish.\n\n### Added\n\n- **DMXAPI Partner** - Added DMXAPI as an official partner provider preset\n- **Provider Icons** - Added provider icons for OpenRouter, LongCat, ModelScope, and AiHubMix\n\n### Changed\n\n- **Proxy (OpenRouter)** - Switched OpenRouter to passthrough mode for native Claude API\n\n### Fixed\n\n- **Import/Export** - Restrict SQL import to CC Switch exported backups only; refresh providers immediately after import\n- **Proxy** - Respect existing Claude token when syncing; add fallback recovery for orphaned takeover state; remove global auto-start flag\n- **Windows** - Add minimum window size to Windows platform config\n- **UI** - Improve About section UI (#419) and unify header toolbar styling\n\n### Stats\n\n- 13 commits since v3.9.0-1\n\n---\n\n## [3.9.0-1] - 2025-12-18\n\n### Beta Release\n\nThis beta release introduces the **Local API Proxy** feature, along with Skills multi-app support, UI improvements, and numerous bug fixes.\n\n### Major Features\n\n#### Local Proxy Server\n- **Local HTTP Proxy** - High-performance proxy server built on Axum framework\n- **Multi-app Support** - Unified proxy for Claude Code, Codex, and Gemini CLI API requests\n- **Per-app Takeover** - Independent control over which apps route through the proxy\n- **Live Config Takeover** - Automatically backs up and redirects CLI configurations to local proxy\n\n#### Auto Failover\n- **Circuit Breaker** - Automatically detects provider failures and triggers protection\n- **Smart Failover** - Automatically switches to backup provider when current one is unavailable\n- **Health Tracking** - Real-time monitoring of provider availability\n- **Independent Failover Queues** - Each app maintains its own failover queue\n\n#### Monitoring\n- **Request Logging** - Detailed logging of all proxy requests\n- **Usage Statistics** - Token consumption, latency, success rate metrics\n- **Real-time Status** - Frontend displays proxy status and statistics\n\n#### Skills Multi-App Support\n- **Multi-app Support** - Skills now support both Claude and Codex (#365)\n- **Multi-app Migration** - Existing Skills auto-migrate to multi-app structure (#378)\n- **Installation Path Fix** - Use directory basename for skill installation path (#358)\n\n### Added\n- **Provider Icon Colors** - Customize provider icon colors (#385)\n- **Deeplink Usage Config** - Import usage query config via deeplink (#400)\n- **Error Request Logging** - Detailed logging for proxy requests (#401)\n- **Closable Toast** - Added close button to switch notification toast (#350)\n- **Icon Color Component** - ProviderIcon component supports color prop (#384)\n\n### Fixed\n\n#### Proxy Related\n- Takeover Codex base_url via model_provider\n- Harden crash recovery with fallback detection\n- Sync UI when active provider differs from current setting\n- Resolve circuit breaker race condition and error classification\n- Stabilize live takeover and provider editing\n- Reset health badges when proxy stops\n- Retry failover for all HTTP errors including 4xx\n- Fix HalfOpen counter underflow and config field inconsistencies\n- Resolve circuit breaker state persistence and HalfOpen deadlock\n- Auto-recover live config after abnormal exit\n- Update live backup when hot-switching provider in proxy mode\n- Wait for server shutdown before exiting app\n- Disable auto-start on app launch by resetting enabled flag on stop\n- Sync live config tokens to database before takeover\n- Resolve 404 error and auto-setup proxy targets\n\n#### MCP Related\n- Skip sync when target CLI app is not installed\n- Improve upsert and import robustness\n- Use browser-compatible platform detection for MCP presets\n\n#### UI Related\n- Restore fade transition for Skills button\n- Add close button to all success toasts\n- Prevent card jitter when health badge appears\n- Update SettingsPage tab styles (#342)\n\n#### Other\n- Fix Azure website link (#407)\n- Add fallback to provider config for usage credentials (#360)\n- Fix Windows black screen on startup (use system titlebar)\n- Add fallback for crypto.randomUUID() on older WebViews\n- Use correct npm package for Codex CLI version check\n- Security fixes for JavaScript executor and usage script (#151)\n\n### Improved\n- **Proxy Active Theme** - Apply emerald theme when proxy takeover is active\n- **Card Animation** - Improved provider card hover animation\n- **Remove Restart Prompt** - No longer prompts restart when switching providers\n\n### Technical\n- Implement per-app takeover mode\n- Proxy module contains 20+ Rust files with complete layered architecture\n- Add 5 new database tables for proxy functionality\n- Modularize handlers.rs to reduce code duplication\n- Remove is_proxy_target in favor of failover_queue\n\n### Stats\n- 55 commits since v3.8.2\n- 164 files changed\n- +22,164 / -570 lines\n\n---\n\n## [3.8.0] - 2025-11-28\n\n### Major Updates\n\n- **Persistence architecture upgrade** - Moved from single JSON storage to SQLite + JSON dual-layer; added schema versioning, transactions, and SQL import/export; first launch auto-migrates `config.json` to SQLite while keeping originals safe.\n- **Brand new UI** - Full layout redesign, unified component/ConfirmDialog styles, smoother animations, overscroll disabled; Tailwind CSS downgraded to v3.4 for compatibility.\n- **Japanese language support** - UI now localized in Chinese/English/Japanese.\n\n### Added\n\n- **Skills recursive scanning** - Discovers nested `SKILL.md` files across multi-level directories; same-name skills allowed by full-path dedup.\n- **Provider icons** - Presets ship with default icons; custom icon colors; icons retained when duplicating providers.\n- **Auto launch on startup** - One-click enable/disable using Registry/LaunchAgent/XDG autostart.\n- **Provider preset** - Added MiniMax partner preset.\n- **Form validation** - Required fields get real-time validation and unified toast messaging.\n\n### Fixed\n\n- **Custom endpoints loss** - Switched provider updates to `UPDATE` to avoid cascade deletes from `INSERT OR REPLACE`.\n- **Gemini config writing** - Correctly writes custom env vars to `.env` and keeps auth configs isolated.\n- **Provider validation** - Handles missing current provider IDs and preserves icon fields on duplicate.\n- **Linux rendering** - Fixed WebKitGTK DMA-BUF rendering and preserved user `.desktop` customizations.\n- **Misc** - Removed redundant usage queries; corrected DMXAPI auth token field; restored missing deeplink translations; fixed usage script template init.\n\n### Technical\n\n- **Database modules** - Added `schema`, `backup`, `migration`, and DAO layers for providers/MCP/prompts/skills/settings.\n- **Service modularization** - Split provider service into live/auth/endpoints/usage modules; deeplink parsing/import logic modularized.\n- **Code cleanup** - Removed legacy JSON-era import/export, unused MCP types; unified error handling; tests migrated to SQLite backend and MSW handlers updated.\n\n### Migration Notes\n\n- First launch auto-migrates data from `config.json` to SQLite and device settings to `settings.json`; originals kept; error dialog on failure; dry-run supported.\n\n### Stats\n\n- 51 commits since v3.7.1; 207 files changed; +17,297 / -6,870 lines. See [release-note-v3.8.0](docs/release-notes/v3.8.0-en.md) for details.\n\n---\n\n## [3.7.1] - 2025-11-22\n\n### Fixed\n\n- **Skills third-party repository installation** (#268) - Fixed installation failure for skills repositories with custom subdirectories (e.g., `ComposioHQ/awesome-claude-skills`)\n- **Gemini configuration persistence** - Resolved issue where settings.json edits were lost when switching providers\n- **Dialog overlay click protection** - Prevented dialogs from closing when clicking outside, avoiding accidental form data loss (affects 11 dialog components)\n\n### Added\n\n- **Gemini configuration directory support** (#255) - Added custom configuration directory option for Gemini in settings\n- **ArchLinux installation support** (#259) - Added AUR installation via `paru -S cc-switch-bin`\n\n### Improved\n\n- **Skills error messages i18n** - Added 28+ detailed error messages (English & Chinese) with specific resolution suggestions\n- **Download timeout** - Extended from 15s to 60s to reduce network-related false positives\n- **Code formatting** - Applied unified Rust (`cargo fmt`) and TypeScript (`prettier`) formatting standards\n\n### Reverted\n\n- **Auto-launch on system startup** - Temporarily reverted feature pending further testing and optimization\n\n---\n\n## [3.7.0] - 2025-11-19\n\n### Major Features\n\n#### Gemini CLI Integration\n\n- **Complete Gemini CLI support** - Third major application added alongside Claude Code and Codex\n- **Dual-file configuration** - Support for both `.env` and `settings.json` file formats\n- **Environment variable detection** - Auto-detect `GOOGLE_GEMINI_BASE_URL`, `GEMINI_MODEL`, etc.\n- **MCP management** - Full MCP configuration capabilities for Gemini\n- **Provider presets**\n  - Google Official (OAuth authentication)\n  - PackyCode (partner integration)\n  - Custom endpoint support\n- **Deep link support** - Import Gemini providers via `ccswitch://` protocol\n- **System tray integration** - Quick-switch Gemini providers from tray menu\n- **Backend modules** - New `gemini_config.rs` (20KB) and `gemini_mcp.rs`\n\n#### MCP v3.7.0 Unified Architecture\n\n- **Unified management panel** - Single interface for Claude/Codex/Gemini MCP servers\n- **SSE transport type** - New Server-Sent Events support alongside stdio/http\n- **Smart JSON parser** - Fault-tolerant parsing of various MCP config formats\n- **Extended field support** - Preserve custom fields in Codex TOML conversion\n- **Codex format correction** - Proper `[mcp_servers]` format (auto-cleanup of incorrect `[mcp.servers]`)\n- **Import/export system** - Unified import from Claude/Codex/Gemini live configs\n- **UX improvements**\n  - Default app selection in forms\n  - JSON formatter for config validation\n  - Improved layout and visual hierarchy\n  - Better validation error messages\n\n#### Claude Skills Management System\n\n- **GitHub repository integration** - Auto-scan and discover skills from GitHub repos\n- **Pre-configured repositories**\n  - `ComposioHQ/awesome-claude-skills` (curated collection)\n  - `anthropics/skills` (official Anthropic skills)\n  - `cexll/myclaude` (community, with subdirectory scanning)\n- **Lifecycle management**\n  - One-click install to `~/.claude/skills/`\n  - Safe uninstall with state tracking\n  - Update checking (infrastructure ready)\n- **Custom repository support** - Add any GitHub repo as a skill source\n- **Subdirectory scanning** - Optional `skillsPath` for repos with nested skill directories\n- **Backend architecture** - `SkillService` (526 lines) with GitHub API integration\n- **Frontend interface**\n  - SkillsPage: Browse and manage skills\n  - SkillCard: Visual skill presentation\n  - RepoManager: Repository management dialog\n- **State persistence** - Installation state stored in `skills.json`\n- **Full i18n support** - Complete Chinese/English translations (47+ keys)\n\n#### Prompts (System Prompts) Management\n\n- **Multi-preset management** - Create, edit, and switch between multiple system prompts\n- **Cross-app support**\n  - Claude: `~/.claude/CLAUDE.md`\n  - Codex: `~/.codex/AGENTS.md`\n  - Gemini: `~/.gemini/GEMINI.md`\n- **Markdown editor** - Full-featured CodeMirror 6 editor with syntax highlighting\n- **Smart synchronization**\n  - Auto-write to live files on enable\n  - Content backfill protection (save current before switching)\n  - First-launch auto-import from live files\n- **Single-active enforcement** - Only one prompt can be active at a time\n- **Delete protection** - Cannot delete active prompts\n- **Backend service** - `PromptService` (213 lines) with CRUD operations\n- **Frontend components**\n  - PromptPanel: Main management interface (177 lines)\n  - PromptFormModal: Edit dialog with validation (160 lines)\n  - MarkdownEditor: CodeMirror integration (159 lines)\n  - usePromptActions: Business logic hook (152 lines)\n- **Full i18n support** - Complete Chinese/English translations (41+ keys)\n\n#### Deep Link Protocol (ccswitch://)\n\n- **Protocol registration** - `ccswitch://` URL scheme for one-click imports\n- **Provider import** - Import provider configurations from URLs or shared links\n- **Lifecycle integration** - Deep link handling integrated into app startup\n- **Cross-platform support** - Works on Windows, macOS, and Linux\n\n#### Environment Variable Conflict Detection\n\n- **Claude & Codex detection** - Identify conflicting environment variables\n- **Gemini auto-detection** - Automatic environment variable discovery\n- **Conflict management** - UI for resolving configuration conflicts\n- **Prevention system** - Warn before overwriting existing configurations\n\n### New Features\n\n#### Provider Management\n\n- **DouBaoSeed preset** - Added ByteDance's DouBao provider\n- **Kimi For Coding** - Moonshot AI coding assistant\n- **BaiLing preset** - BaiLing AI integration\n- **Removed AnyRouter preset** - Discontinued provider\n- **Model configuration** - Support for custom model names in Codex and Gemini\n- **Provider notes field** - Add custom notes to providers for better organization\n\n#### Configuration Management\n\n- **Common config migration** - Moved Claude common config snippets from localStorage to `config.json`\n- **Unified persistence** - Common config snippets now shared across all apps\n- **Auto-import on first launch** - Automatically import configs from live files on first run\n- **Backfill priority fix** - Correct priority handling when enabling prompts\n\n#### UI/UX Improvements\n\n- **macOS native design** - Migrated color scheme to macOS native design system\n- **Window centering** - Default window position centered on screen\n- **Password input fixes** - Disabled Edge/IE reveal and clear buttons\n- **URL overflow prevention** - Fixed overflow in provider cards\n- **Error notification enhancement** - Copy-to-clipboard for error messages\n- **Tray menu sync** - Real-time sync after drag-and-drop sorting\n\n### Improvements\n\n#### Architecture\n\n- **MCP v3.7.0 cleanup** - Removed legacy code and warnings\n- **Unified structure** - Default initialization with v3.7.0 unified structure\n- **Backward compatibility** - Compilation fixes for older configs\n- **Code formatting** - Applied consistent formatting across backend and frontend\n\n#### Platform Compatibility\n\n- **Windows fix** - Resolved winreg API compatibility issue (v0.52)\n- **Safe pattern matching** - Replaced `unwrap()` with safe patterns in tray menu\n\n#### Configuration\n\n- **MCP sync on switch** - Sync MCP configs for all apps when switching providers\n- **Gemini form sync** - Fixed form fields syncing with environment editor\n- **Gemini config reading** - Read from both `.env` and `settings.json`\n- **Validation improvements** - Enhanced input validation and boundary checks\n\n#### Internationalization\n\n- **JSON syntax fixes** - Resolved syntax errors in locale files\n- **App name i18n** - Added internationalization support for app names\n- **Deduplicated labels** - Reused providerForm keys to reduce duplication\n- **Gemini MCP title** - Added missing Gemini MCP panel title\n\n### Bug Fixes\n\n#### Critical Fixes\n\n- **Usage script validation** - Added input validation and boundary checks\n- **Gemini validation** - Relaxed validation when adding providers\n- **TOML quote normalization** - Handle CJK quotes to prevent parsing errors\n- **MCP field preservation** - Preserve custom fields in Codex TOML editor\n- **Password input** - Fixed white screen crash (FormLabel → Label)\n\n#### Stability\n\n- **Tray menu safety** - Replaced unwrap with safe pattern matching\n- **Error isolation** - Tray menu update failures don't block main operations\n- **Import classification** - Set category to custom for imported default configs\n\n#### UI Fixes\n\n- **Model placeholders** - Removed misleading model input placeholders\n- **Base URL population** - Auto-fill base URL for non-official providers\n- **Drag sort sync** - Fixed tray menu order after drag-and-drop\n\n### Technical Improvements\n\n#### Code Quality\n\n- **Type safety** - Complete TypeScript type coverage across codebase\n- **Test improvements** - Simplified boolean assertions in tests\n- **Clippy warnings** - Fixed `uninlined_format_args` warnings\n- **Code refactoring** - Extracted templates, optimized logic flows\n\n#### Dependencies\n\n- **Tauri** - Updated to 2.8.x series\n- **Rust dependencies** - Added `anyhow`, `zip`, `serde_yaml`, `tempfile` for Skills\n- **Frontend dependencies** - Added CodeMirror 6 packages for Markdown editor\n- **winreg** - Updated to v0.52 (Windows compatibility)\n\n#### Performance\n\n- **Startup optimization** - Removed legacy migration scanning\n- **Lock management** - Improved RwLock usage to prevent deadlocks\n- **Background query** - Enabled background mode for usage polling\n\n### Statistics\n\n- **Total commits**: 85 commits from v3.6.0 to v3.7.0\n- **Code changes**: 152 files changed, 18,104 insertions(+), 3,732 deletions(-)\n- **New modules**:\n  - Skills: 2,034 lines (21 files)\n  - Prompts: 1,302 lines (20 files)\n  - Gemini: ~1,000 lines (multiple files)\n  - MCP refactor: ~3,000 lines (refactored)\n\n### Strategic Positioning\n\nv3.7.0 represents a major evolution from \"Provider Switcher\" to **\"All-in-One AI CLI Management Platform\"**:\n\n1. **Capability Extension** - Skills provide external ability integration\n2. **Behavior Customization** - Prompts enable AI personality presets\n3. **Configuration Unification** - MCP v3.7.0 eliminates app silos\n4. **Ecosystem Openness** - Deep links enable community sharing\n5. **Multi-AI Support** - Claude/Codex/Gemini trinity\n6. **Intelligent Detection** - Auto-discovery of environment conflicts\n\n### Notes\n\n- Users upgrading from v3.1.0 or earlier should first upgrade to v3.2.x for one-time migration\n- Skills and Prompts management are new features requiring no migration\n- Gemini CLI support requires Gemini CLI to be installed separately\n- MCP v3.7.0 unified structure is backward compatible with previous configs\n\n## [3.6.0] - 2025-11-07\n\n### ✨ New Features\n\n- **Provider Duplicate** - Quick duplicate existing provider configurations for easy variant creation\n- **Edit Mode Toggle** - Show/hide drag handles to optimize editing experience\n- **Custom Endpoint Management** - Support multi-endpoint configuration for aggregator providers\n- **Usage Query Enhancements**\n  - Auto-refresh interval: Support periodic automatic usage query\n  - Test Script API: Validate JavaScript scripts before execution\n  - Template system expansion: Custom blank template, support for access token and user ID parameters\n- **Configuration Editor Improvements**\n  - Add JSON format button\n  - Real-time TOML syntax validation for Codex configuration\n- **Auto-sync on Directory Change** - When switching Claude/Codex config directories (e.g., WSL environment), automatically sync current provider to new directory without manual operation\n- **Load Live Config When Editing Active Provider** - When editing the currently active provider, prioritize displaying the actual effective configuration to protect user manual modifications\n- **New Provider Presets** - DMXAPI, Azure Codex, AnyRouter, AiHubMix, MiniMax\n- **Partner Promotion Mechanism** - Support ecosystem partner promotion (e.g., Zhipu GLM Z.ai)\n\n### 🔧 Improvements\n\n- **Configuration Directory Switching**\n  - Introduced unified post-change sync utility (`postChangeSync.ts`)\n  - Auto-sync current providers to new directory when changing Claude/Codex config directories\n  - Perfect support for WSL environment switching\n  - Auto-sync after config import to ensure immediate effectiveness\n  - Use Result pattern for graceful error handling without blocking main flow\n  - Distinguish \"fully successful\" and \"partially successful\" states for precise user feedback\n- **UI/UX Enhancements**\n  - Provider cards: Unique icons and color identification\n  - Unified border design system across all components\n  - Drag interaction optimization: Push effect animation, improved handle icons\n  - Enhanced current provider visual feedback\n  - Dialog size standardization and layout consistency\n  - Form experience: Optimized model placeholders, simplified provider hints, category-specific hints\n- **Complete Internationalization Coverage**\n  - Error messages internationalization\n  - Tray menu internationalization\n  - All UI components internationalization\n- **Usage Display Moved Inline** - Usage display moved next to enable button\n\n### 🐛 Bug Fixes\n\n- **Configuration Sync**\n  - Fixed `apiKeyUrl` priority issue\n  - Fixed MCP sync-to-other-side functionality failure\n  - Fixed sync issues after config import\n  - Prevent silent fallback and data loss on config error\n- **Usage Query**\n  - Fixed auto-query interval timing issue\n  - Ensure refresh button shows loading animation on click\n- **UI Issues**\n  - Fixed name collision error (`get_init_error` command)\n  - Fixed language setting rollback after successful save\n  - Fixed language switch state reset (dependency cycle)\n  - Fixed edit mode button alignment\n- **Configuration Management**\n  - Fixed Codex API Key auto-sync\n  - Fixed endpoint speed test functionality\n  - Fixed provider duplicate insertion position (next to original provider)\n  - Fixed custom endpoint preservation in edit mode\n- **Startup Issues**\n  - Force exit on config error (no silent fallback)\n  - Eliminate code duplication causing initialization errors\n\n### 🏗️ Technical Improvements (For Developers)\n\n**Backend Refactoring (Rust)** - Completed 5-phase refactoring:\n\n- **Phase 1**: Unified error handling (`AppError` + i18n error messages)\n- **Phase 2**: Command layer split by domain (`commands/{provider,mcp,config,settings,plugin,misc}.rs`)\n- **Phase 3**: Integration tests and transaction mechanism (config snapshot + failure rollback)\n- **Phase 4**: Extracted Service layer (`services/{provider,mcp,config,speedtest}.rs`)\n- **Phase 5**: Concurrency optimization (`RwLock` instead of `Mutex`, scoped guard to avoid deadlock)\n\n**Frontend Refactoring (React + TypeScript)** - Completed 4-stage refactoring:\n\n- **Stage 1**: Test infrastructure (vitest + MSW + @testing-library/react)\n- **Stage 2**: Extracted custom hooks (`useProviderActions`, `useMcpActions`, `useSettings`, `useImportExport`, etc.)\n- **Stage 3**: Component splitting and business logic extraction\n- **Stage 4**: Code cleanup and formatting unification\n\n**Testing System**:\n\n- Hooks unit tests 100% coverage\n- Integration tests covering key processes (App, SettingsDialog, MCP Panel)\n- MSW mocking backend API to ensure test independence\n\n**Code Quality**:\n\n- Unified parameter format: All Tauri commands migrated to camelCase (Tauri 2 specification)\n- `AppType` renamed to `AppId`: Semantically clearer\n- Unified parsing with `FromStr` trait: Centralized `app` parameter parsing\n- Eliminate code duplication: DRY violations cleanup\n- Remove unused code: `missing_param` helper function, deprecated `tauri-api.ts`, redundant `KimiModelSelector` component\n\n**Internal Optimizations**:\n\n- **Removed Legacy Migration Logic**: v3.6 removed v1 config auto-migration and copy file scanning logic\n  - ✅ **Impact**: Improved startup performance, cleaner code\n  - ✅ **Compatibility**: v2 format configs fully compatible, no action required\n  - ⚠️ **Note**: Users upgrading from v3.1.0 or earlier should first upgrade to v3.2.x or v3.5.x for one-time migration, then upgrade to v3.6\n- **Command Parameter Standardization**: Backend unified to use `app` parameter (values: `claude` or `codex`)\n  - ✅ **Impact**: More standardized code, friendlier error prompts\n  - ✅ **Compatibility**: Frontend fully adapted, users don't need to care about this change\n\n### 📦 Dependencies\n\n- Updated to Tauri 2.8.x\n- Updated to TailwindCSS 4.x\n- Updated to TanStack Query v5.90.x\n- Maintained React 18.2.x and TypeScript 5.3.x\n\n## [3.5.0] - 2025-01-15\n\n### ⚠ Breaking Changes\n\n- Tauri commands only accept the `app` parameter (`claude`/`codex`); removed `app_type`/`appType` compatibility.\n- Frontend types are standardized to `AppId` (removed `AppType` export); variable naming is standardized to `appId`.\n\n### ✨ New Features\n\n- **MCP (Model Context Protocol) Management** - Complete MCP server configuration management system\n  - Add, edit, delete, and toggle MCP servers in `~/.claude.json`\n  - Support for stdio and http server types with command validation\n  - Built-in templates for popular MCP servers (mcp-fetch, etc.)\n  - Real-time enable/disable toggle for MCP servers\n  - Atomic file writing to prevent configuration corruption\n- **Configuration Import/Export** - Backup and restore your provider configurations\n  - Export all configurations to JSON file with one click\n  - Import configurations with validation and automatic backup\n  - Automatic backup rotation (keeps 10 most recent backups)\n  - Progress modal with detailed status feedback\n- **Endpoint Speed Testing** - Test API endpoint response times\n  - Measure latency to different provider endpoints\n  - Visual indicators for connection quality\n  - Help users choose the fastest provider\n\n### 🔧 Improvements\n\n- Complete internationalization (i18n) coverage for all UI components\n- Enhanced error handling and user feedback throughout the application\n- Improved configuration file management with better validation\n- Added new provider presets: Longcat, kat-coder\n- Updated GLM provider configurations with latest models\n- Refined UI/UX with better spacing, icons, and visual feedback\n- Enhanced tray menu functionality and responsiveness\n- **Standardized release artifact naming** - All platform releases now use consistent version-tagged filenames:\n  - macOS: `CC-Switch-v{version}-macOS.tar.gz` / `.zip`\n  - Windows: `CC-Switch-v{version}-Windows.msi` / `-Portable.zip`\n  - Linux: `CC-Switch-v{version}-Linux.AppImage` / `.deb`\n\n### 🐛 Bug Fixes\n\n- Fixed layout shifts during provider switching\n- Improved config file path handling across different platforms\n- Better error messages for configuration validation failures\n- Fixed various edge cases in configuration import/export\n\n### 📦 Technical Details\n\n- Enhanced `import_export.rs` module with backup management\n- New `claude_mcp.rs` module for MCP configuration handling\n- Improved state management and lock handling in Rust backend\n- Better TypeScript type safety across the codebase\n\n## [3.4.0] - 2025-10-01\n\n### ✨ Features\n\n- Enable internationalization via i18next with a Chinese default and English fallback, plus an in-app language switcher\n- Add Claude plugin sync while retiring the legacy VS Code integration controls (Codex no longer requires settings.json edits)\n- Extend provider presets with optional API key URLs and updated models, including DeepSeek-V3.1-Terminus and Qwen3-Max\n- Support portable mode launches and enforce a single running instance to avoid conflicts\n\n### 🔧 Improvements\n\n- Allow minimizing the window to the system tray and add macOS Dock visibility management for tray workflows\n- Refresh the Settings modal with a scrollable layout, save icon, and cleaner language section\n- Smooth provider toggle states with consistent button widths/icons and prevent layout shifts when switching between Claude and Codex\n- Adjust the Windows MSI installer to target per-user LocalAppData and improve component tracking reliability\n\n### 🐛 Fixes\n\n- Remove the unnecessary OpenAI auth requirement from third-party provider configurations\n- Fix layout shifts while switching app types with Claude plugin sync enabled\n- Align Enable/In Use button states to avoid visual jank across app views\n\n## [3.3.0] - 2025-09-22\n\n### ✨ Features\n\n- 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)_\n- Enable VS Code auto-sync by default with window broadcast and tray hooks so Codex switches sync silently _(Removed in 3.4.x)_\n- Extend the Codex provider wizard with display name, dedicated API key URL, and clearer guidance\n- Introduce shared common config snippets with JSON/TOML reuse, validation, and consistent error surfaces\n\n### 🔧 Improvements\n\n- Keep the tray menu responsive when the window is hidden and standardize button styling and copy\n- Disable modal backdrop blur on Linux (WebKitGTK/Wayland) to avoid freezes; restore the window when clicking the macOS Dock icon\n- Support overriding config directories on WSL, refine placeholders/descriptions, and fix VS Code button wrapping on Windows\n- Add a `created_at` timestamp to provider records for future sorting and analytics\n\n### 🐛 Fixes\n\n- Correct regex escapes and common snippet trimming in the Codex wizard to prevent validation issues\n- Harden the VS Code sync flow with more reliable TOML/JSON parsing while reducing layout jank\n- Bundle `@codemirror/lint` to reinstate live linting in config editors\n\n## [3.2.0] - 2025-09-13\n\n### ✨ New Features\n\n- System tray provider switching with dynamic menu for Claude/Codex\n- Frontend receives `provider-switched` events and refreshes active app\n- Built-in update flow via Tauri Updater plugin with dismissible UpdateBadge\n\n### 🔧 Improvements\n\n- Single source of truth for provider configs; no duplicate copy files\n- One-time migration imports existing copies into `config.json` and archives originals\n- Duplicate provider de-duplication by name + API key at startup\n- Atomic writes for Codex `auth.json` + `config.toml` with rollback on failure\n- Logging standardized (Rust): use `log::{info,warn,error}` instead of stdout prints\n- Tailwind v4 integration and refined dark mode handling\n\n### 🐛 Fixes\n\n- Remove/minimize debug console logs in production builds\n- Fix CSS minifier warnings for scrollbar pseudo-elements\n- Prettier formatting across codebase for consistent style\n\n### 📦 Dependencies\n\n- Tauri: 2.8.x (core, updater, process, opener, log plugins)\n- React: 18.2.x · TypeScript: 5.3.x · Vite: 5.x\n\n### 🔄 Notes\n\n- `connect-src` CSP remains permissive for compatibility; can be tightened later as needed\n\n## [3.1.1] - 2025-09-03\n\n### 🐛 Bug Fixes\n\n- Fixed the default codex config.toml to match the latest modifications\n- Improved provider configuration UX with custom option\n\n### 📝 Documentation\n\n- Updated README with latest information\n\n## [3.1.0] - 2025-09-01\n\n### ✨ New Features\n\n- **Added Codex application support** - Now supports both Claude Code and Codex configuration management\n  - Manage auth.json and config.toml for Codex\n  - Support for backup and restore operations\n  - Preset providers for Codex (Official, PackyCode)\n  - API Key auto-write to auth.json when using presets\n- **New UI components**\n  - App switcher with segmented control design\n  - Dual editor form for Codex configuration\n  - Pills-style app switcher with consistent button widths\n- **Enhanced configuration management**\n  - Multi-app config v2 structure (claude/codex)\n  - Automatic v1→v2 migration with backup\n  - OPENAI_API_KEY validation for non-official presets\n  - TOML syntax validation for config.toml\n\n### 🔧 Technical Improvements\n\n- Unified Tauri command API with app_type parameter\n- Backward compatibility for app/appType parameters\n- Added get_config_status/open_config_folder/open_external commands\n- Improved error handling for empty config.toml\n\n### 🐛 Bug Fixes\n\n- Fixed config path reporting and folder opening for Codex\n- Corrected default import behavior when main config is missing\n- Fixed non_snake_case warnings in commands.rs\n\n## [3.0.0] - 2025-08-27\n\n### 🚀 Major Changes\n\n- **Complete migration from Electron to Tauri 2.0** - The application has been completely rewritten using Tauri, resulting in:\n  - **90% reduction in bundle size** (from ~150MB to ~15MB)\n  - **Significantly improved startup performance**\n  - **Native system integration** without Chromium overhead\n  - **Enhanced security** with Rust backend\n\n### ✨ New Features\n\n- **Native window controls** with transparent title bar on macOS\n- **Improved file system operations** using Rust for better performance\n- **Enhanced security model** with explicit permission declarations\n- **Better platform detection** using Tauri's native APIs\n\n### 🔧 Technical Improvements\n\n- Migrated from Electron IPC to Tauri command system\n- Replaced Node.js file operations with Rust implementations\n- Implemented proper CSP (Content Security Policy) for enhanced security\n- Added TypeScript strict mode for better type safety\n- Integrated Rust cargo fmt and clippy for code quality\n\n### 🐛 Bug Fixes\n\n- Fixed bundle identifier conflict on macOS (changed from .app to .desktop)\n- Resolved platform detection issues\n- Improved error handling in configuration management\n\n### 📦 Dependencies\n\n- **Tauri**: 2.8.2\n- **React**: 18.2.0\n- **TypeScript**: 5.3.0\n- **Vite**: 5.0.0\n\n### 🔄 Migration Notes\n\nFor users upgrading from v2.x (Electron version):\n\n- Configuration files remain compatible - no action required\n- The app will automatically migrate your existing provider configurations\n- Window position and size preferences have been reset to defaults\n\n#### Backup on v1→v2 Migration (cc-switch internal config)\n\n- 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.\n- Backup location: `~/.cc-switch/config.v1.backup.<timestamp>.json`\n- This only concerns cc-switch's own metadata file; your actual provider files under `~/.claude/` and `~/.codex/` are untouched.\n\n### 🛠️ Development\n\n- Added `pnpm typecheck` command for TypeScript validation\n- Added `pnpm format` and `pnpm format:check` for code formatting\n- Rust code now uses cargo fmt for consistent formatting\n\n## [2.0.0] - Previous Electron Release\n\n### Features\n\n- Multi-provider configuration management\n- Quick provider switching\n- Import/export configurations\n- Preset provider templates\n\n---\n\n## [1.0.0] - Initial Release\n\n### Features\n\n- Basic provider management\n- Claude Code integration\n- Configuration file handling\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2025 Jason Young\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE."
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\">\n\n# CC Switch\n\n### The All-in-One Manager for Claude Code, Codex, Gemini CLI, OpenCode & OpenClaw\n\n[![Version](https://img.shields.io/badge/version-3.12.3-blue.svg)](https://github.com/farion1231/cc-switch/releases)\n[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)\n[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)\n[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)\n\n<a href=\"https://trendshift.io/repositories/15372\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/15372\" alt=\"farion1231%2Fcc-switch | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n\nEnglish | [中文](README_ZH.md) | [日本語](README_JA.md) | [Changelog](CHANGELOG.md)\n\n</div>\n\n## ❤️Sponsor\n\n<details open>\n<summary>Click to collapse</summary>\n\n[![MiniMax](assets/partners/banners/minimax-en.jpeg)](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)\n\nMiniMax-M2.5 is a SOTA large language model designed for real-world productivity. Trained in a diverse range of complex real-world digital working environments, M2.5 builds upon the coding expertise of M2.1 to extend into general office work, reaching fluency in generating and operating Word, Excel, and Powerpoint files, context switching between diverse software environments, and working across different agent and human teams. Scoring 80.2% on SWE-Bench Verified, 51.3% on Multi-SWE-Bench, and 76.3% on BrowseComp, M2.5 is also more token efficient than previous generations, having been trained to optimize its actions and output through planning.\n\n[Click](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link) to get an exclusive 12% off the MiniMax Coding Plan!\n\n---\n\n<table>\n<tr>\n<td width=\"180\"><a href=\"https://www.packyapi.com/register?aff=cc-switch\"><img src=\"assets/partners/logos/packycode.png\" alt=\"PackyCode\" width=\"150\"></a></td>\n<td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href=\"https://www.packyapi.com/register?aff=cc-switch\">this link</a> and enter the \"cc-switch\" promo code during first recharge to get 10% off.</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://cloud.siliconflow.cn/i/drGuwc9k\"><img src=\"assets/partners/logos/silicon_en.jpg\" alt=\"SiliconFlow\" width=\"150\"></a></td>\n<td>Thanks to SiliconFlow for sponsoring this project! SiliconFlow is a high-performance AI infrastructure and model API platform, providing fast and reliable access to language, speech, image, and video models in one place. With pay-as-you-go billing, broad multimodal model support, high-speed inference, and enterprise-grade stability, SiliconFlow helps developers and teams build and scale AI applications more efficiently. Register via <a href=\"https://cloud.siliconflow.cn/i/drGuwc9k\">this link</a> and complete real-name verification to receive ¥20 in bonus credit, usable across models on the platform. SiliconFlow is also now compatible with OpenClaw, allowing users to connect a SiliconFlow API key and call major AI models for free.</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://aigocode.com/invite/CC-SWITCH\"><img src=\"assets/partners/logos/aigocode.png\" alt=\"AIGoCode\" width=\"150\"></a></td>\n<td>Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses. AIGoCode has prepared a special benefit for CC Switch users: if you register via <a href=\"https://aigocode.com/invite/CC-SWITCH\">this link</a>, you'll receive an extra 10% bonus credit on your first top-up!</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://www.aicodemirror.com/register?invitecode=9915W3\"><img src=\"assets/partners/logos/aicodemirror.jpg\" alt=\"AICodeMirror\" width=\"150\"></a></td>\n<td>Thanks to AICodeMirror for sponsoring this project! AICodeMirror provides official high-stability relay services for Claude Code / Codex / Gemini CLI, with enterprise-grade concurrency, fast invoicing, and 24/7 dedicated technical support.\nClaude Code / Codex / Gemini official channels at 38% / 2% / 9% of original price, with extra discounts on top-ups! AICodeMirror offers special benefits for CC Switch users: register via <a href=\"https://www.aicodemirror.com/register?invitecode=9915W3\">this link</a> to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off!</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://cubence.com/signup?code=CCSWITCH&source=ccs\"><img src=\"assets/partners/logos/cubence.png\" alt=\"Cubence\" width=\"150\"></a></td>\n<td>Thanks to Cubence for sponsoring this project! Cubence is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more with flexible billing options including pay-as-you-go and monthly plans. Cubence provides special discounts for CC Switch users: register using <a href=\"https://cubence.com/signup?code=CCSWITCH&source=ccs\">this link</a> and enter the \"CCSWITCH\" promo code during recharge to get 10% off every top-up!</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://www.dmxapi.cn/register?aff=bUHu\"><img src=\"assets/partners/logos/dmx-en.jpg\" alt=\"DMXAPI\" width=\"150\"></a></td>\n<td>Thanks to DMXAPI for sponsoring this project! DMXAPI provides global large model API services to 200+ enterprise users. One API key for all global models. Features include: instant invoicing, unlimited concurrency, starting from $0.15, 24/7 technical support. GPT/Claude/Gemini all at 32% off, domestic models 20-50% off, Claude Code exclusive models at 66% off! <a href=\"https://www.dmxapi.cn/register?aff=bUHu\">Register here</a></td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch\"><img src=\"assets/partners/logos/ucloud.png\" alt=\"Compshare\" width=\"150\"></a></td>\n<td>Thanks to Compshare for sponsoring this project! Compshare is UCloud's AI cloud platform, providing stable and comprehensive domestic and international model APIs with just one key. Featuring cost-effective monthly and pay-as-you-go Coding Plan packages at 60-80% off official prices. Supports Claude Code, Codex, and API access. Enterprise-grade high concurrency, 24/7 technical support, and self-service invoicing. Users who register via <a href=\"https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch\">this link</a> will receive a free 5 CNY platform trial credit!</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://www.right.codes/register?aff=CCSWITCH\"><img src=\"assets/partners/logos/rightcode.jpg\" alt=\"RightCode\" width=\"150\"></a></td>\n<td>Thank you to Right Code for sponsoring this project! Right Code reliably provides routing services for models such as Claude Code, Codex, and Gemini. It features a highly cost-effective Codex monthly subscription plan and <strong>supports quota rollovers—unused quota from one day can be carried over and used the next day.</strong> Invoices are available upon top-up. Enterprise and team users can receive dedicated one-on-one support. Right Code also offers an exclusive discount for CC Switch users: register via <a href=\"https://www.right.codes/register?aff=CCSWITCH\">this link</a>, and with every top-up you will receive pay-as-you-go credit equivalent to 25% of the amount paid.</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://aicoding.sh/i/CCSWITCH\"><img src=\"assets/partners/logos/aicoding.jpg\" alt=\"AICoding\" width=\"150\"></a></td>\n<td>Thanks to AICoding.sh for sponsoring this project! AICoding.sh — Global AI Model API Relay Service at Unbeatable Prices! Claude Code at 19% of original price, GPT at just 1%! Trusted by hundreds of enterprises for cost-effective AI services. Supports Claude Code, GPT, Gemini and major domestic models, with enterprise-grade high concurrency, fast invoicing, and 24/7 dedicated technical support. CC Switch users who register via <a href=\"https://aicoding.sh/i/CCSWITCH\">this link</a> get 10% off their first top-up!</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://crazyrouter.com/register?aff=OZcm&ref=cc-switch\"><img src=\"assets/partners/logos/crazyrouter.jpg\" alt=\"Crazyrouter\" width=\"150\"></a></td>\n<td>Thanks to Crazyrouter for sponsoring this project! Crazyrouter is a high-performance AI API aggregation platform — one API key for 300+ models including Claude Code, Codex, Gemini CLI, and more. All models at 55% of official pricing with auto-failover, smart routing, and unlimited concurrency. Crazyrouter offers an exclusive deal for CC Switch users: register via <a href=\"https://crazyrouter.com/register?aff=OZcm&ref=cc-switch\">this link</a>  to get <strong>$2 free credit</strong> instantly, plus enter promo code `CCSWITCH` on your first top-up for an extra <strong>30% bonus credit</strong>! </td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://www.sssaicode.com/register?ref=DCP0SM\"><img src=\"assets/partners/logos/sssaicode.png\" alt=\"SSSAiCode\" width=\"150\"></a></td>\n<td>Thanks to SSSAiCode for sponsoring this project! SSSAiCode is a stable and reliable API relay service, dedicated to providing stable, reliable, and affordable Claude and Codex model services, <strong>offering high cost-effective official Claude service at just ¥0.5/$ equivalent</strong>, supporting monthly and pay-as-you-go billing plans with same-day fast invoicing. SSSAiCode offers a special deal for CC Switch users: register via <a href=\"https://www.sssaicode.com/register?ref=DCP0SM\">this link</a> to enjoy $10 extra credit on every top-up!</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://www.openclaudecode.cn/register?aff=aOYQ\"><img src=\"assets/partners/logos/mikubanner.svg\" alt=\"Micu\" width=\"150\"></a></td>\n<td>Thanks to Micu API for sponsoring this project! Micu API is a global LLM relay service provider dedicated to delivering the best cost-performance ratio with high stability. Backed by a registered enterprise for core assurance, eliminating any risk of service discontinuation, with fast official invoicing support! We champion \"zero cost to try\": top up from as low as ¥1 with no minimum, and get fee-free refunds anytime! Micu API offers an exclusive deal for CC Switch users: register via <a href=\"https://www.openclaudecode.cn/register?aff=aOYQ\">this link</a> and enter promo code \"ccswitch\" when topping up to enjoy a <strong>10% discount</strong>!</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://x-code.cc/register?aff=IbPp\"><img src=\"assets/partners/logos/xcodeapi.png\" alt=\"XCodeAPI\" width=\"150\"></a></td>\n<td>Thanks to XCodeAPI for sponsoring this project! XCodeAPI offers a special benefit for CC Switch users: register via <a href=\"https://x-code.cc/register?aff=IbPp\">this link</a> and get an extra 10% credit bonus on your first order! (Contact the site admin to claim)</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://ctok.ai\"><img src=\"assets/partners/logos/ctok.png\" alt=\"CTok\" width=\"150\"></a></td>\n<td>Thanks to CTok.ai for sponsoring this project! CTok.ai is dedicated to building a one-stop AI programming tool service platform. We offer professional Claude Code packages and technical community services, with support for Google Gemini and OpenAI Codex. Through carefully designed plans and a professional tech community, we provide developers with reliable service guarantees and continuous technical support, making AI-assisted programming a true productivity tool. Click <a href=\"https://ctok.ai\">here</a> to register!</td>\n</tr>\n\n</table>\n\n</details>\n\n## Why CC Switch?\n\nModern AI-powered coding relies on CLI tools like Claude Code, Codex, Gemini CLI, OpenCode, and OpenClaw — but each has its own configuration format. Switching API providers means manually editing JSON, TOML, or `.env` files, and there is no unified way to manage MCP and Skills across multiple tools.\n\n**CC Switch** gives you a single desktop app to manage all five CLI tools. Instead of editing config files by hand, you get a visual interface to import providers with one click, switch between them instantly, with 50+ built-in provider presets, unified MCP and Skills management, and system tray quick switching — all backed by a reliable SQLite database with atomic writes that protect your configs from corruption.\n\n- **One App, Five CLI Tools** — Manage Claude Code, Codex, Gemini CLI, OpenCode, and OpenClaw from a single interface\n- **No More Manual Editing** — 50+ provider presets including AWS Bedrock, NVIDIA NIM, and community relays; just pick and switch\n- **Unified MCP & Skills Management** — One panel to manage MCP servers and Skills across four apps with bidirectional sync\n- **System Tray Quick Switch** — Switch providers instantly from the tray menu, no need to open the full app\n- **Cloud Sync** — Sync provider data across devices via Dropbox, OneDrive, iCloud, or WebDAV servers\n- **Cross-Platform** — Native desktop app for Windows, macOS, and Linux, built with Tauri 2\n- **Built-in Utilities** — Includes various utilities for first-launch login confirmation, signature bypass, plugin extension sync, and more\n\n## Screenshots\n\n|                  Main Interface                   |                  Add Provider                  |\n| :-----------------------------------------------: | :--------------------------------------------: |\n| ![Main Interface](assets/screenshots/main-en.png) | ![Add Provider](assets/screenshots/add-en.png) |\n\n## Features\n\n[Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-notes/v3.12.3-en.md)\n\n### Provider Management\n\n- **5 CLI tools, 50+ presets** — Claude Code, Codex, Gemini CLI, OpenCode, OpenClaw; copy your key and import with one click\n- **Universal providers** — One config syncs to multiple apps (OpenCode, OpenClaw)\n- One-click switching, system tray quick access, drag-and-drop sorting, import/export\n\n### Proxy & Failover\n\n- **Local proxy with hot-switching** — Format conversion, auto-failover, circuit breaker, provider health monitoring, and request rectifier\n- **App-level takeover** — Independently proxy Claude, Codex, or Gemini, down to individual providers\n\n### MCP, Prompts & Skills\n\n- **Unified MCP panel** — Manage MCP servers across 4 apps with bidirectional sync and Deep Link import\n- **Prompts** — Markdown editor with cross-app sync (CLAUDE.md / AGENTS.md / GEMINI.md) and backfill protection\n- **Skills** — One-click install from GitHub repos or ZIP files, custom repository management, with symlink and file copy support\n\n### Usage & Cost Tracking\n\n- **Usage dashboard** — Track spending, requests, and tokens with trend charts, detailed request logs, and custom per-model pricing\n\n### Session Manager & Workspace\n\n- Browse, search, and restore conversation history across all apps\n- **Workspace editor** (OpenClaw) — Edit agent files (AGENTS.md, SOUL.md, etc.) with Markdown preview\n\n### System & Platform\n\n- **Cloud sync** — Custom config directory (Dropbox, OneDrive, iCloud, NAS) and WebDAV server sync\n- **Deep Link** (`ccswitch://`) — Import providers, MCP servers, prompts, and skills via URL\n- Dark / Light / System theme, auto-launch, auto-updater, atomic writes, auto-backups, i18n (zh/en/ja)\n\n## FAQ\n\n<details>\n<summary><strong>Which AI CLI tools does CC Switch support?</strong></summary>\n\nCC Switch supports five tools: **Claude Code**, **Codex**, **Gemini CLI**, **OpenCode**, and **OpenClaw**. Each tool has dedicated provider presets and configuration management.\n\n</details>\n\n<details>\n<summary><strong>Do I need to restart the terminal after switching providers?</strong></summary>\n\nFor most tools, yes — restart your terminal or the CLI tool for changes to take effect. The exception is **Claude Code**, which currently supports hot-switching of provider data without a restart.\n\n</details>\n\n<details>\n<summary><strong>My plugin configuration disappeared after switching providers — what happened?</strong></summary>\n\nCC Switch provides a \"Shared Config Snippet\" feature to pass common data (beyond API keys and endpoints) between providers. Go to \"Edit Provider\" → \"Shared Config Panel\" → click \"Extract from Current Provider\" to save all common data. When creating a new provider, check \"Write Shared Config\" (enabled by default) to include plugin data in the new provider. All your configuration items are preserved in the default provider imported when you first launched the app.\n\n</details>\n\n<details>\n<summary><strong>macOS shows \"unidentified developer\" warning — how do I fix it?</strong></summary>\n\nThe author doesn't have an Apple Developer account yet (registration in progress). Close the warning, then go to **System Settings → Privacy & Security → Open Anyway**. After that, the app will open normally.\n\n</details>\n\n<details>\n<summary><strong>Why can't I delete the currently active provider?</strong></summary>\n\nCC Switch follows a \"minimal intrusion\" design principle — even if you uninstall the app, your CLI tools will continue to work normally. The system always keeps one active configuration, because deleting all configurations would make the corresponding CLI tool unusable. If you rarely use a specific CLI tool, you can hide it in Settings. To switch back to official login, see the next question.\n\n</details>\n\n<details>\n<summary><strong>How do I switch back to official login?</strong></summary>\n\nAdd an official provider from the preset list. After switching to it, run the Log out / Log in flow, and then you can freely switch between the official provider and third-party providers. Codex supports switching between different official providers, making it easy to switch between multiple Plus or Team accounts.\n\n</details>\n\n<details>\n<summary><strong>Where is my data stored?</strong></summary>\n\n- **Database**: `~/.cc-switch/cc-switch.db` (SQLite — providers, MCP, prompts, skills)\n- **Local settings**: `~/.cc-switch/settings.json` (device-level UI preferences)\n- **Backups**: `~/.cc-switch/backups/` (auto-rotated, keeps 10 most recent)\n- **Skills**: `~/.cc-switch/skills/` (symlinked to corresponding apps by default)\n- **Skill Backups**: `~/.cc-switch/skill-backups/` (created automatically before uninstall, keeps 20 most recent)\n\n</details>\n\n## Documentation\n\nFor detailed guides on every feature, check out the **[User Manual](docs/user-manual/en/README.md)** — covering provider management, MCP/Prompts/Skills, proxy & failover, and more.\n\n## Quick Start\n\n### Basic Usage\n\n1. **Add Provider**: Click \"Add Provider\" → Choose a preset or create custom configuration\n2. **Switch Provider**:\n   - Main UI: Select provider → Click \"Enable\"\n   - System Tray: Click provider name directly (instant effect)\n3. **Takes Effect**: Restart your terminal or the corresponding CLI tool to apply changes (Claude Code does not require a restart)\n4. **Back to Official**: Add an \"Official Login\" preset, restart the CLI tool, then follow its login/OAuth flow\n\n### MCP, Prompts, Skills & Sessions\n\n- **MCP**: Click the \"MCP\" button → Add servers via templates or custom config → Toggle per-app sync\n- **Prompts**: Click \"Prompts\" → Create presets with Markdown editor → Activate to sync to live files\n- **Skills**: Click \"Skills\" → Browse GitHub repos → One-click install to all apps\n- **Sessions**: Click \"Sessions\" → Browse, search, and restore conversation history across all apps\n\n> **Note**: On first launch, you can manually import existing CLI tool configs as the default provider.\n\n## Download & Installation\n\n### System Requirements\n\n- **Windows**: Windows 10 and above\n- **macOS**: macOS 12 (Monterey) and above\n- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+ and other mainstream distributions\n\n### Windows Users\n\nDownload the latest `CC-Switch-v{version}-Windows.msi` installer or `CC-Switch-v{version}-Windows-Portable.zip` portable version from the [Releases](../../releases) page.\n\n### macOS Users\n\n**Method 1: Install via Homebrew (Recommended)**\n\n```bash\nbrew tap farion1231/ccswitch\nbrew install --cask cc-switch\n```\n\nUpdate:\n\n```bash\nbrew upgrade --cask cc-switch\n```\n\n**Method 2: Manual Download**\n\nDownload `CC-Switch-v{version}-macOS.zip` from the [Releases](../../releases) page and extract to use.\n\n> **Note**: Since the author doesn't have an Apple Developer account, you may see an \"unidentified developer\" warning on first launch. Please close it first, then go to \"System Settings\" → \"Privacy & Security\" → click \"Open Anyway\", and you'll be able to open it normally afterwards.\n\n### Arch Linux Users\n\n**Install via paru (Recommended)**\n\n```bash\nparu -S cc-switch-bin\n```\n\n### Linux Users\n\nDownload the latest Linux build from the [Releases](../../releases) page:\n\n- `CC-Switch-v{version}-Linux.deb` (Debian/Ubuntu)\n- `CC-Switch-v{version}-Linux.rpm` (Fedora/RHEL/openSUSE)\n- `CC-Switch-v{version}-Linux.AppImage` (Universal)\n- `CC-Switch-v{version}-Linux.flatpak` (Flatpak)\n\nFlatpak install & run:\n\n```bash\nflatpak install --user ./CC-Switch-v{version}-Linux.flatpak\nflatpak run com.ccswitch.desktop\n```\n\n<details>\n<summary><strong>Architecture Overview</strong></summary>\n\n### Design Principles\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│                    Frontend (React + TS)                    │\n│  ┌─────────────┐  ┌──────────────┐  ┌──────────────────┐    │\n│  │ Components  │  │    Hooks     │  │  TanStack Query  │    │\n│  │   (UI)      │──│ (Bus. Logic) │──│   (Cache/Sync)   │    │\n│  └─────────────┘  └──────────────┘  └──────────────────┘    │\n└────────────────────────┬────────────────────────────────────┘\n                         │ Tauri IPC\n┌────────────────────────▼────────────────────────────────────┐\n│                  Backend (Tauri + Rust)                     │\n│  ┌─────────────┐  ┌──────────────┐  ┌──────────────────┐    │\n│  │  Commands   │  │   Services   │  │  Models/Config   │    │\n│  │ (API Layer) │──│ (Bus. Layer) │──│     (Data)       │    │\n│  └─────────────┘  └──────────────┘  └──────────────────┘    │\n└─────────────────────────────────────────────────────────────┘\n```\n\n**Core Design Patterns**\n\n- **SSOT** (Single Source of Truth): All data stored in `~/.cc-switch/cc-switch.db` (SQLite)\n- **Dual-layer Storage**: SQLite for syncable data, JSON for device-level settings\n- **Dual-way Sync**: Write to live files on switch, backfill from live when editing active provider\n- **Atomic Writes**: Temp file + rename pattern prevents config corruption\n- **Concurrency Safe**: Mutex-protected database connection avoids race conditions\n- **Layered Architecture**: Clear separation (Commands → Services → DAO → Database)\n\n**Key Components**\n\n- **ProviderService**: Provider CRUD, switching, backfill, sorting\n- **McpService**: MCP server management, import/export, live file sync\n- **ProxyService**: Local proxy mode with hot-switching and format conversion\n- **SessionManager**: Claude Code conversation history browsing\n- **ConfigService**: Config import/export, backup rotation\n- **SpeedtestService**: API endpoint latency measurement\n\n</details>\n\n<details>\n<summary><strong>Development Guide</strong></summary>\n\n### Environment Requirements\n\n- Node.js 18+\n- pnpm 8+\n- Rust 1.85+\n- Tauri CLI 2.8+\n\n### Development Commands\n\n```bash\n# Install dependencies\npnpm install\n\n# Dev mode (hot reload)\npnpm dev\n\n# Type check\npnpm typecheck\n\n# Format code\npnpm format\n\n# Check code format\npnpm format:check\n\n# Run frontend unit tests\npnpm test:unit\n\n# Run tests in watch mode (recommended for development)\npnpm test:unit:watch\n\n# Build application\npnpm build\n\n# Build debug version\npnpm tauri build --debug\n```\n\n### Rust Backend Development\n\n```bash\ncd src-tauri\n\n# Format Rust code\ncargo fmt\n\n# Run clippy checks\ncargo clippy\n\n# Run backend tests\ncargo test\n\n# Run specific tests\ncargo test test_name\n\n# Run tests with test-hooks feature\ncargo test --features test-hooks\n```\n\n### Testing Guide\n\n**Frontend Testing**:\n\n- Uses **vitest** as test framework\n- Uses **MSW (Mock Service Worker)** to mock Tauri API calls\n- Uses **@testing-library/react** for component testing\n\n**Running Tests**:\n\n```bash\n# Run all tests\npnpm test:unit\n\n# Watch mode (auto re-run)\npnpm test:unit:watch\n\n# With coverage report\npnpm test:unit --coverage\n```\n\n### Tech Stack\n\n**Frontend**: React 18 · TypeScript · Vite · TailwindCSS 3.4 · TanStack Query v5 · react-i18next · react-hook-form · zod · shadcn/ui · @dnd-kit\n\n**Backend**: Tauri 2.8 · Rust · serde · tokio · thiserror · tauri-plugin-updater/process/dialog/store/log\n\n**Testing**: vitest · MSW · @testing-library/react\n\n</details>\n\n<details>\n<summary><strong>Project Structure</strong></summary>\n\n```\n├── src/                        # Frontend (React + TypeScript)\n│   ├── components/\n│   │   ├── providers/          # Provider management\n│   │   ├── mcp/                # MCP panel\n│   │   ├── prompts/            # Prompts management\n│   │   ├── skills/             # Skills management\n│   │   ├── sessions/           # Session Manager\n│   │   ├── proxy/              # Proxy mode panel\n│   │   ├── openclaw/           # OpenClaw config panels\n│   │   ├── settings/           # Settings (Terminal/Backup/About)\n│   │   ├── deeplink/           # Deep Link import\n│   │   ├── env/                # Environment variable management\n│   │   ├── universal/          # Cross-app configuration\n│   │   ├── usage/              # Usage statistics\n│   │   └── ui/                 # shadcn/ui component library\n│   ├── hooks/                  # Custom hooks (business logic)\n│   ├── lib/\n│   │   ├── api/                # Tauri API wrapper (type-safe)\n│   │   └── query/              # TanStack Query config\n│   ├── locales/                # Translations (zh/en/ja)\n│   ├── config/                 # Presets (providers/mcp)\n│   └── types/                  # TypeScript definitions\n├── src-tauri/                  # Backend (Rust)\n│   └── src/\n│       ├── commands/           # Tauri command layer (by domain)\n│       ├── services/           # Business logic layer\n│       ├── database/           # SQLite DAO layer\n│       ├── proxy/              # Proxy module\n│       ├── session_manager/    # Session management\n│       ├── deeplink/           # Deep Link handling\n│       └── mcp/                # MCP sync module\n├── tests/                      # Frontend tests\n└── assets/                     # Screenshots & partner resources\n```\n\n</details>\n\n## Contributing\n\nIssues and suggestions are welcome!\n\nBefore submitting PRs, please ensure:\n\n- Pass type check: `pnpm typecheck`\n- Pass format check: `pnpm format:check`\n- Pass unit tests: `pnpm test:unit`\n\nFor new features, please open an issue for discussion before submitting a PR. PRs for features that are not a good fit for the project may be closed.\n\n## Star History\n\n[![Star History Chart](https://api.star-history.com/svg?repos=farion1231/cc-switch&type=Date)](https://www.star-history.com/#farion1231/cc-switch&Date)\n\n## License\n\nMIT © Jason Young\n"
  },
  {
    "path": "README_JA.md",
    "content": "<div align=\"center\">\n\n# CC Switch\n\n### Claude Code、Codex、Gemini CLI、OpenCode、OpenClaw のオールインワン管理ツール\n\n[![Version](https://img.shields.io/badge/version-3.12.3-blue.svg)](https://github.com/farion1231/cc-switch/releases)\n[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)\n[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)\n[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)\n\n<a href=\"https://trendshift.io/repositories/15372\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/15372\" alt=\"farion1231%2Fcc-switch | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n\n[English](README.md) | [中文](README_ZH.md) | 日本語 | [Changelog](CHANGELOG.md)\n\n</div>\n\n## ❤️スポンサー\n\n<details open>\n<summary>クリックで折りたたむ</summary>\n\n[![MiniMax](assets/partners/banners/minimax-en.jpeg)](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)\n\nMiniMax-M2.5 は、実際の生産性向上のために設計された最先端の大規模言語モデルです。多様で複雑な実環境のデジタルワークスペースでトレーニングされた M2.5 は、M2.1 のコーディング能力をベースに一般的なオフィス業務へと拡張し、Word・Excel・PowerPoint ファイルの生成と操作、多様なソフトウェア環境間のコンテキスト切り替え、異なるエージェントや人間チーム間での協働を流暢にこなします。SWE-Bench Verified で 80.2%、Multi-SWE-Bench で 51.3%、BrowseComp で 76.3% を達成し、計画的な行動と出力の最適化トレーニングにより、前世代よりもトークン効率に優れています。\n\n[こちら](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)から MiniMax Coding Plan の限定 12% オフを入手！\n\n---\n\n<table>\n<tr>\n<td width=\"180\"><a href=\"https://www.packyapi.com/register?aff=cc-switch\"><img src=\"assets/partners/logos/packycode.png\" alt=\"PackyCode\" width=\"150\"></a></td>\n<td>PackyCode のご支援に感謝します！PackyCode は Claude Code、Codex、Gemini などのリレーサービスを提供する信頼性の高い API 中継プラットフォームです。本ソフト利用者向けに特別割引があります：<a href=\"https://www.packyapi.com/register?aff=cc-switch\">このリンク</a>で登録し、チャージ時に「cc-switch」クーポンを入力すると 10% オフになります。</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://cloud.siliconflow.cn/i/drGuwc9k\"><img src=\"assets/partners/logos/silicon_en.jpg\" alt=\"SiliconFlow\" width=\"150\"></a></td>\n<td>SiliconFlow のご支援に感謝します！SiliconFlow は高性能 AI インフラストラクチャおよびモデル API プラットフォームで、言語・音声・画像・動画モデルへの高速かつ信頼性の高いアクセスをワンストップで提供します。従量課金制、豊富なマルチモーダルモデル対応、高速推論、エンタープライズグレードの安定性を備え、開発者やチームがより効率的に AI アプリケーションを構築・拡張できるようサポートします。<a href=\"https://cloud.siliconflow.cn/i/drGuwc9k\">このリンク</a>から登録し、本人確認を完了すると、プラットフォーム内の全モデルで利用可能な ¥20 のボーナスクレジットが付与されます。SiliconFlow は OpenClaw にも対応しており、SiliconFlow の API キーを接続することで主要な AI モデルを無料で呼び出すことができます。</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://aigocode.com/invite/CC-SWITCH\"><img src=\"assets/partners/logos/aigocode.png\" alt=\"AIGoCode\" width=\"150\"></a></td>\n<td>本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、<a href=\"https://aigocode.com/invite/CC-SWITCH\">このリンク</a>から登録すると、初回チャージ時に10％分のボーナスクレジットが付与されます！</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://www.aicodemirror.com/register?invitecode=9915W3\"><img src=\"assets/partners/logos/aicodemirror.jpg\" alt=\"AICodeMirror\" width=\"150\"></a></td>\n<td>AICodeMirror のご支援に感謝します！AICodeMirror は Claude Code / Codex / Gemini CLI の公式高安定リレーサービスを提供しており、エンタープライズ級の同時接続、迅速な請求書発行、24時間年中無休の専用テクニカルサポートを備えています。\nClaude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% / 2% / 9%、チャージ時にはさらに割引！AICodeMirror は CC Switch ユーザー向けに特別特典を用意：<a href=\"https://www.aicodemirror.com/register?invitecode=9915W3\">このリンク</a>から登録すると初回チャージ 20% オフ、法人のお客様は最大 25% オフ！</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://cubence.com/signup?code=CCSWITCH&source=ccs\"><img src=\"assets/partners/logos/cubence.png\" alt=\"Cubence\" width=\"150\"></a></td>\n<td>Cubence のご支援に感謝します！Cubence は Claude Code、Codex、Gemini などのリレーサービスを提供する信頼性の高い API 中継プラットフォームで、従量課金や月額プランなど柔軟な料金体系を提供しています。CC Switch ユーザー向けの特別割引：<a href=\"https://cubence.com/signup?code=CCSWITCH&source=ccs\">このリンク</a>で登録し、チャージ時に「CCSWITCH」クーポンを入力すると、毎回 10% オフになります！</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://www.dmxapi.cn/register?aff=bUHu\"><img src=\"assets/partners/logos/dmx-en.jpg\" alt=\"DMXAPI\" width=\"150\"></a></td>\n<td>DMXAPI のご支援に感謝します！DMXAPI は 200 社以上の企業ユーザーにグローバル大規模モデル API サービスを提供しています。1 つの API キーで全世界のモデルにアクセス可能。即時請求書発行、同時接続数無制限、最低 $0.15 から、24 時間年中無休のテクニカルサポート。GPT/Claude/Gemini が全て 32% オフ、国内モデルは 20〜50% オフ、Claude Code 専用モデルは 66% オフ実施中！<a href=\"https://www.dmxapi.cn/register?aff=bUHu\">登録はこちら</a></td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch\"><img src=\"assets/partners/logos/ucloud.png\" alt=\"Compshare\" width=\"150\"></a></td>\n<td>Compshare のご支援に感謝します！Compshare は UCloud 傘下の AI クラウドプラットフォームで、国内外の安定した包括的なモデル API を 1 つのキーだけで利用可能。月額・従量課金のコストパフォーマンスに優れた Coding Plan パッケージを提供し、公式価格の 60〜80% オフで利用できます。Claude Code、Codex および API アクセスに対応。エンタープライズ級の高同時接続、24 時間年中無休のテクニカルサポート、セルフサービス請求書発行に対応。<a href=\"https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch\">こちらのリンク</a>から登録すると、無料で 5 元分のプラットフォーム体験クレジットがもらえます！</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://www.right.codes/register?aff=CCSWITCH\"><img src=\"assets/partners/logos/rightcode.jpg\" alt=\"RightCode\" width=\"150\"></a></td>\n<td>本プロジェクトへのご支援として、Right Code にご協賛いただき誠にありがとうございます。Right Code は、Claude Code、Codex、Gemini などのモデルに対応した中継（プロキシ）サービスを安定して提供しています。特に高いコストパフォーマンスを誇る Codex の月額プランを主力としており、<strong>未使用分の利用枠を翌日に繰り越して利用できる（繰越対応）</strong>点が特長です。チャージ（入金）後に請求書の発行が可能で、企業・チーム向けには専任担当による個別対応も行っています。さらに CC Switch ユーザー向けの特別優待として、<a href=\"https://www.right.codes/register?aff=CCSWITCH\">こちらのリンク</a>からご登録いただくと、チャージのたびに実支払額の 25% 相当の従量課金クレジットが付与されます。</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://aicoding.sh/i/CCSWITCH\"><img src=\"assets/partners/logos/aicoding.jpg\" alt=\"AICoding\" width=\"150\"></a></td>\n<td>AICoding.sh のご支援に感謝します！AICoding.sh —— グローバル AI モデル API 超お得な中継サービス！Claude Code 81% オフ、GPT 99% オフ！数百社の企業に高コストパフォーマンスの AI サービスを提供。Claude Code、GPT、Gemini および国内主要モデルに対応、エンタープライズ級の高同時接続、迅速な請求書発行、24 時間年中無休の専属テクニカルサポート。<a href=\"https://aicoding.sh/i/CCSWITCH\">こちらのリンク</a>から登録した CC Switch ユーザーは、初回チャージ 10% オフ！</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://crazyrouter.com/register?aff=OZcm&ref=cc-switch\"><img src=\"assets/partners/logos/crazyrouter.jpg\" alt=\"Crazyrouter\" width=\"150\"></a></td>\n<td>Crazyrouter のご支援に感謝します！Crazyrouter は高性能 AI API アグリゲーションプラットフォームです。1 つの API キーで Claude Code、Codex、Gemini CLI など 300 以上のモデルにアクセス可能。全モデルが公式価格の 55% で利用でき、自動フェイルオーバー、スマートルーティング、無制限同時接続に対応。CC Switch ユーザー向けの限定特典：<a href=\"https://crazyrouter.com/register?aff=OZcm&ref=cc-switch\">こちらのリンク</a>から登録すると <strong>$2 の無料クレジット</strong> を即時進呈。さらに初回チャージ時にプロモコード `CCSWITCH` を入力すると <strong>30% のボーナスクレジット</strong> が追加されます！</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://www.sssaicode.com/register?ref=DCP0SM\"><img src=\"assets/partners/logos/sssaicode.png\" alt=\"SSSAiCode\" width=\"150\"></a></td>\n<td>SSSAiCode のご支援に感謝します！SSSAiCode は安定性と信頼性に優れた API 中継サービスで、安定的で信頼性が高く、手頃な価格の Claude・Codex モデルサービスを提供しています。<strong>高コストパフォーマンスの公式 Claude サービスを 0.5￥/$ 換算で提供</strong>、月額制・Paygo など多様な課金方式に対応し、当日の迅速な請求書発行をサポート。CC Switch ユーザー向けの特別特典：<a href=\"https://www.sssaicode.com/register?ref=DCP0SM\">こちらのリンク</a>から登録すると、毎回のチャージで $10 の追加ボーナスを受けられます！</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://www.openclaudecode.cn/register?aff=aOYQ\"><img src=\"assets/partners/logos/mikubanner.svg\" alt=\"Micu\" width=\"150\"></a></td>\n<td>Micu API のご支援に感謝します！Micu API は、最高のコストパフォーマンスと高い安定性を追求するグローバル大規模言語モデル中継サービスプロバイダーです。法人企業がバックアップしており、サービス停止のリスクを排除、迅速な正規請求書発行に対応！「試行コストゼロ」をモットーに、最低 1 元からチャージ可能で手数料無料、いつでも返金可能！CC Switch ユーザー向けの限定特典：<a href=\"https://www.openclaudecode.cn/register?aff=aOYQ\">こちらのリンク</a>から登録し、チャージ時にプロモコード「ccswitch」を入力すると <strong>10% 割引</strong> が適用されます！</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://x-code.cc/register?aff=IbPp\"><img src=\"assets/partners/logos/xcodeapi.png\" alt=\"XCodeAPI\" width=\"150\"></a></td>\n<td>XCodeAPI のご支援に感謝します！CC Switch ユーザー向けの特別特典：<a href=\"https://x-code.cc/register?aff=IbPp\">こちらのリンク</a>から登録すると、初回注文で 10% の追加クレジットボーナスがもらえます！（サイト管理者に連絡して受け取りください）</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://ctok.ai\"><img src=\"assets/partners/logos/ctok.png\" alt=\"CTok\" width=\"150\"></a></td>\n<td>CTok.ai のご支援に感謝します！CTok.ai はワンストップ AI プログラミングツールサービスプラットフォームの構築に取り組んでいます。Claude Code のプロフェッショナルプランと技術コミュニティサービスを提供し、Google Gemini や OpenAI Codex にも対応しています。丁寧に設計されたプランと専門的な技術コミュニティを通じて、開発者に安定したサービス保証と継続的な技術サポートを提供し、AI アシストプログラミングを真の生産性ツールにします。<a href=\"https://ctok.ai\">こちら</a>から登録してください！</td>\n</tr>\n\n</table>\n\n</details>\n\n## CC Switch を選ぶ理由\n\n最新の AI コーディングは Claude Code、Codex、Gemini CLI、OpenCode、OpenClaw などの CLI ツールに依存していますが、各ツールの設定形式はバラバラです。API プロバイダを切り替えるたびに JSON、TOML、`.env` ファイルを手動で編集する必要があり、複数ツール間で MCP や Skills を統一的に管理する手段もありません。\n\n**CC Switch** は、5 つの CLI ツールを 1 つのデスクトップアプリで一元管理できます。設定ファイルを手作業で編集する代わりに、ワンクリックでプロバイダをインポートし、瞬時に切り替えられるビジュアルインターフェースを提供します。50 以上の組み込みプリセット、統一 MCP・Skills 管理、システムトレイからの即時切り替え機能を搭載。すべてはアトミック書き込みによる信頼性の高い SQLite データベースに支えられており、設定の破損を防ぎます。\n\n- **1 つのアプリで 5 つの CLI ツール** -- Claude Code、Codex、Gemini CLI、OpenCode、OpenClaw を単一インターフェースで管理\n- **手動編集は不要** -- AWS Bedrock、NVIDIA NIM、コミュニティリレーなど 50 以上のプロバイダプリセットを内蔵。選んで切り替えるだけ\n- **統一 MCP・Skills 管理** -- 1 つのパネルで 4 つのアプリの MCP サーバーと Skills を双方向同期で管理\n- **システムトレイでクイック切り替え** -- トレイメニューから即座にプロバイダを切り替え。アプリを開く必要なし\n- **クラウド同期** -- Dropbox、OneDrive、iCloud、または WebDAV サーバー経由でデバイス間のプロバイダデータを同期\n- **クロスプラットフォーム** -- Tauri 2 で構築された Windows、macOS、Linux 対応のネイティブデスクトップアプリ\n- **便利ツール内蔵** -- 初回起動時のログイン確認、署名バイパス、プラグイン拡張の同期など、さまざまなユーティリティを搭載\n\n## スクリーンショット\n\n|                  メイン画面                   |                  プロバイダ追加                  |\n| :-------------------------------------------: | :----------------------------------------------: |\n| ![メイン画面](assets/screenshots/main-ja.png) | ![プロバイダ追加](assets/screenshots/add-ja.png) |\n\n## 特長\n\n[完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-notes/v3.12.3-ja.md)\n\n### プロバイダ管理\n\n- **5 つの CLI ツール、50 以上のプリセット** -- Claude Code、Codex、Gemini CLI、OpenCode、OpenClaw。キーをコピーしてワンクリックでインポート\n- **ユニバーサルプロバイダ** -- 1 つの設定を複数アプリに同期（OpenCode、OpenClaw）\n- ワンクリック切り替え、システムトレイクイックアクセス、ドラッグ＆ドロップ並び替え、インポート/エクスポート\n\n### プロキシ & フェイルオーバー\n\n- **ローカルプロキシのホットスイッチ** -- フォーマット変換、自動フェイルオーバー、サーキットブレーカー、プロバイダヘルスモニタリング、リクエストレクティファイア\n- **アプリレベルのテイクオーバー** -- Claude、Codex、Gemini を個別にプロキシ経由でルーティング、プロバイダ単位で設定可能\n\n### MCP、Prompts & Skills\n\n- **統一 MCP パネル** -- 4 つのアプリの MCP サーバーを管理、双方向同期、Deep Link インポート対応\n- **Prompts** -- Markdown エディタ、クロスアプリ同期（CLAUDE.md / AGENTS.md / GEMINI.md）、バックフィル保護\n- **Skills** -- GitHub リポジトリまたは ZIP ファイルからワンクリックインストール、カスタムリポジトリ管理、シンボリックリンクとファイルコピーに対応\n\n### 使用量 & コストトラッキング\n\n- **使用量ダッシュボード** -- プロバイダ横断で支出・リクエスト数・トークン使用量を追跡、トレンドチャート、詳細リクエストログ、カスタムモデル価格設定\n\n### Session Manager & ワークスペース\n\n- すべてのアプリの会話履歴を閲覧・検索・復元\n- **ワークスペースエディタ**（OpenClaw）-- エージェントファイル（AGENTS.md、SOUL.md など）を Markdown プレビュー付きで編集\n\n### システム & プラットフォーム\n\n- **クラウド同期** -- カスタム設定ディレクトリ（Dropbox、OneDrive、iCloud、NAS）および WebDAV サーバー同期\n- **Deep Link** (`ccswitch://`) -- URL 経由でプロバイダ、MCP サーバー、Prompts、Skills をワンクリックインポート\n- ダーク / ライト / システムテーマ、自動起動、自動アップデーター、アトミック書き込み、自動バックアップ、多言語対応（中/英/日）\n\n## よくある質問\n\n<details>\n<summary><strong>CC Switch はどの AI CLI ツールに対応していますか？</strong></summary>\n\nCC Switch は **Claude Code**、**Codex**、**Gemini CLI**、**OpenCode**、**OpenClaw** の 5 つのツールに対応しています。各ツールに専用のプロバイダプリセットと設定管理が用意されています。\n\n</details>\n\n<details>\n<summary><strong>プロバイダを切り替えた後、ターミナルの再起動は必要ですか？</strong></summary>\n\nほとんどのツールでは、はい。変更を反映するにはターミナルまたは CLI ツールを再起動してください。ただし **Claude Code** は例外で、現在プロバイダデータのホットスイッチに対応しており、再起動は不要です。\n\n</details>\n\n<details>\n<summary><strong>プロバイダを切り替えた後、プラグイン設定が消えてしまいました。どうすればよいですか？</strong></summary>\n\nCC Switch には「共有設定スニペット」機能があり、APIキーやエンドポイント以外の共通データをプロバイダ間で引き継ぐことができます。「プロバイダ編集」→「共有設定パネル」→「現在のプロバイダから抽出」をクリックして、すべての共通データを保存してください。新しいプロバイダを作成する際に「共有設定を書き込む」にチェック（デフォルトで有効）を入れれば、プラグインなどのデータが新しいプロバイダ設定に含まれます。すべての設定項目は、アプリ初回起動時にインポートされたデフォルトプロバイダに保存されており、失われることはありません。\n\n</details>\n\n<details>\n<summary><strong>macOS で「開発元を確認できません」と表示されます。どうすればよいですか？</strong></summary>\n\n開発者が Apple Developer アカウントをまだ取得していないためです（登録手続き中）。警告を閉じてから、**システム設定 → プライバシーとセキュリティ → このまま開く**をクリックしてください。以降は通常通り起動できます。\n\n</details>\n\n<details>\n<summary><strong>現在アクティブなプロバイダを削除できないのはなぜですか？</strong></summary>\n\nCC Switch は「最小限の介入」という設計原則に従っています。アプリをアンインストールしても、CLI ツールは正常に動作し続けます。すべての設定を削除すると対応する CLI ツールが使用できなくなるため、システムは常にアクティブな設定を 1 つ保持します。特定の CLI ツールをあまり使用しない場合は、設定で非表示にできます。公式ログインに戻す方法は、次の質問をご覧ください。\n\n</details>\n\n<details>\n<summary><strong>公式ログインに戻すにはどうすればよいですか？</strong></summary>\n\nプリセットリストから公式プロバイダを追加してください。切り替え後、ログアウト／ログインのフローを実行すれば、以降は公式プロバイダとサードパーティプロバイダを自由に切り替えられます。Codex では異なる公式プロバイダ間の切り替えに対応しており、複数の Plus アカウントや Team アカウントの切り替えに便利です。\n\n</details>\n\n<details>\n<summary><strong>データはどこに保存されますか？</strong></summary>\n\n- **データベース**: `~/.cc-switch/cc-switch.db`（SQLite -- プロバイダ、MCP、Prompts、Skills）\n- **ローカル設定**: `~/.cc-switch/settings.json`（デバイスレベルの UI 設定）\n- **バックアップ**: `~/.cc-switch/backups/`（自動ローテーション、最新 10 件を保持）\n- **Skills**: `~/.cc-switch/skills/`（デフォルトでシンボリックリンクにより対応アプリに接続）\n- **Skill バックアップ**: `~/.cc-switch/skill-backups/`（アンインストール前に自動作成、最新 20 件を保持）\n\n</details>\n\n## ドキュメント\n\n各機能の詳しい使い方については、**[ユーザーマニュアル](docs/user-manual/ja/README.md)** をご覧ください。プロバイダ管理、MCP/Prompts/Skills、プロキシとフェイルオーバーなど、すべての機能を網羅しています。\n\n## クイックスタート\n\n### 基本的な使い方\n\n1. **プロバイダ追加**: 「Add Provider」をクリック → プリセットを選ぶかカスタム設定を作成\n2. **プロバイダ切り替え**:\n   - メイン UI: プロバイダを選択 → 「Enable」をクリック\n   - システムトレイ: プロバイダ名をクリック（即時反映）\n3. **反映**: ターミナルまたは対応する CLI ツールを再起動して適用（Claude Code は再起動不要）\n4. **公式設定に戻す**: 「Official Login」プリセットを追加し、CLI ツールを再起動してログイン/OAuth フローを実行\n\n### MCP、Prompts、Skills & Sessions\n\n- **MCP**: 「MCP」ボタンをクリック → テンプレートまたはカスタム設定でサーバーを追加 → アプリごとの同期をトグルで切り替え\n- **Prompts**: 「Prompts」をクリック → Markdown エディタでプリセットを作成 → 有効化してライブファイルに同期\n- **Skills**: 「Skills」をクリック → GitHub リポジトリを閲覧 → ワンクリックですべてのアプリにインストール\n- **Sessions**: 「Sessions」をクリック → すべてのアプリの会話履歴を閲覧・検索・復元\n\n> **補足**: 初回起動時に、既存の CLI ツール設定を手動でインポートしてデフォルトプロバイダとして使用できます。\n\n## ダウンロード & インストール\n\n### システム要件\n\n- **Windows**: Windows 10 以上\n- **macOS**: macOS 12 (Monterey) 以上\n- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+ など主要ディストリビューション\n\n### Windows ユーザー\n\n[Releases](../../releases) ページから最新版の `CC-Switch-v{version}-Windows.msi` インストーラー、またはポータブル版 `CC-Switch-v{version}-Windows-Portable.zip` をダウンロード。\n\n### macOS ユーザー\n\n**方法 1: Homebrew でインストール（推奨）**\n\n```bash\nbrew tap farion1231/ccswitch\nbrew install --cask cc-switch\n```\n\nアップデート:\n\n```bash\nbrew upgrade --cask cc-switch\n```\n\n**方法 2: 手動ダウンロード**\n\n[Releases](../../releases) から `CC-Switch-v{version}-macOS.zip` をダウンロードして展開。\n\n> **注意**: 開発者アカウント未登録のため、初回起動時に「開発元を確認できません」と表示される場合があります。一度閉じてから「システム設定」→「プライバシーとセキュリティ」→「このまま開く」をクリックしてください。以降は通常通り起動できます。\n\n### Arch Linux ユーザー\n\n**paru でインストール（推奨）**\n\n```bash\nparu -S cc-switch-bin\n```\n\n### Linux ユーザー\n\n[Releases](../../releases) から最新版の Linux ビルドをダウンロード：\n\n- `CC-Switch-v{version}-Linux.deb`（Debian/Ubuntu）\n- `CC-Switch-v{version}-Linux.rpm`（Fedora/RHEL/openSUSE）\n- `CC-Switch-v{version}-Linux.AppImage`（汎用）\n- `CC-Switch-v{version}-Linux.flatpak`（Flatpak）\n\nFlatpak のインストールと起動：\n\n```bash\nflatpak install --user ./CC-Switch-v{version}-Linux.flatpak\nflatpak run com.ccswitch.desktop\n```\n\n<details>\n<summary><strong>アーキテクチャ概要</strong></summary>\n\n### 設計原則\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│                    Frontend (React + TS)                    │\n│  ┌─────────────┐  ┌──────────────┐  ┌──────────────────┐    │\n│  │ Components  │  │    Hooks     │  │  TanStack Query  │    │\n│  │   (UI)      │──│ (Bus. Logic) │──│   (Cache/Sync)   │    │\n│  └─────────────┘  └──────────────┘  └──────────────────┘    │\n└────────────────────────┬────────────────────────────────────┘\n                         │ Tauri IPC\n┌────────────────────────▼────────────────────────────────────┐\n│                  Backend (Tauri + Rust)                     │\n│  ┌─────────────┐  ┌──────────────┐  ┌──────────────────┐    │\n│  │  Commands   │  │   Services   │  │  Models/Config   │    │\n│  │ (API Layer) │──│ (Bus. Layer) │──│     (Data)       │    │\n│  └─────────────┘  └──────────────┘  └──────────────────┘    │\n└─────────────────────────────────────────────────────────────┘\n```\n\n**コア設計パターン**\n\n- **SSOT** (Single Source of Truth): すべてのデータを `~/.cc-switch/cc-switch.db`（SQLite）に集約\n- **二層ストレージ**: 同期データは SQLite、デバイスデータは JSON\n- **双方向同期**: 切り替え時はライブファイルへ書き込み、編集時はアクティブプロバイダから逆同期\n- **アトミック書き込み**: 一時ファイル + rename パターンで設定破損を防止\n- **並行安全**: Mutex で保護された DB 接続でレースコンディションを防止\n- **レイヤードアーキテクチャ**: Commands → Services → DAO → Database を明確に分離\n\n**主要コンポーネント**\n\n- **ProviderService**: プロバイダの CRUD、切り替え、バックフィル、ソート\n- **McpService**: MCP サーバー管理、インポート/エクスポート、ライブファイル同期\n- **ProxyService**: ローカル Proxy モードのホットスイッチとフォーマット変換\n- **SessionManager**: Claude Code の会話履歴閲覧\n- **ConfigService**: 設定のインポート/エクスポート、バックアップローテーション\n- **SpeedtestService**: API エンドポイントの遅延計測\n\n</details>\n\n<details>\n<summary><strong>開発ガイド</strong></summary>\n\n### 開発環境\n\n- Node.js 18+\n- pnpm 8+\n- Rust 1.85+\n- Tauri CLI 2.8+\n\n### 開発コマンド\n\n```bash\n# 依存関係をインストール\npnpm install\n\n# ホットリロード付き開発モード\npnpm dev\n\n# 型チェック\npnpm typecheck\n\n# コード整形\npnpm format\n\n# フォーマット検証\npnpm format:check\n\n# フロントエンド単体テスト\npnpm test:unit\n\n# ウォッチモード（開発に推奨）\npnpm test:unit:watch\n\n# アプリをビルド\npnpm build\n\n# デバッグビルド\npnpm tauri build --debug\n```\n\n### Rust バックエンド開発\n\n```bash\ncd src-tauri\n\n# Rust コード整形\ncargo fmt\n\n# clippy チェック\ncargo clippy\n\n# バックエンドテスト\ncargo test\n\n# 特定テストのみ実行\ncargo test test_name\n\n# test-hooks フィーチャー付きでテスト\ncargo test --features test-hooks\n```\n\n### テストガイド\n\n**フロントエンドテスト**:\n\n- テストフレームワークに **vitest** を使用\n- **MSW (Mock Service Worker)** で Tauri API 呼び出しをモック\n- コンポーネントテストに **@testing-library/react** を採用\n\n**テスト実行**:\n\n```bash\n# 全テストを実行\npnpm test:unit\n\n# ウォッチモード（自動再実行）\npnpm test:unit:watch\n\n# カバレッジレポート付き\npnpm test:unit --coverage\n```\n\n### 技術スタック\n\n**フロントエンド**: React 18 · TypeScript · Vite · TailwindCSS 3.4 · TanStack Query v5 · react-i18next · react-hook-form · zod · shadcn/ui · @dnd-kit\n\n**バックエンド**: Tauri 2.8 · Rust · serde · tokio · thiserror · tauri-plugin-updater/process/dialog/store/log\n\n**テスト**: vitest · MSW · @testing-library/react\n\n</details>\n\n<details>\n<summary><strong>プロジェクト構成</strong></summary>\n\n```\n├── src/                        # フロントエンド (React + TypeScript)\n│   ├── components/\n│   │   ├── providers/          # プロバイダ管理\n│   │   ├── mcp/                # MCP パネル\n│   │   ├── prompts/            # Prompts 管理\n│   │   ├── skills/             # Skills 管理\n│   │   ├── sessions/           # Session Manager\n│   │   ├── proxy/              # Proxy モードパネル\n│   │   ├── openclaw/           # OpenClaw 設定パネル\n│   │   ├── settings/           # 設定 (Terminal/Backup/About)\n│   │   ├── deeplink/           # Deep Link インポート\n│   │   ├── env/                # 環境変数管理\n│   │   ├── universal/          # クロスアプリ設定\n│   │   ├── usage/              # 使用量統計\n│   │   └── ui/                 # shadcn/ui コンポーネントライブラリ\n│   ├── hooks/                  # カスタムフック（ビジネスロジック）\n│   ├── lib/\n│   │   ├── api/                # Tauri API ラッパー（型安全）\n│   │   └── query/              # TanStack Query 設定\n│   ├── locales/                # 翻訳 (zh/en/ja)\n│   ├── config/                 # プリセット (providers/mcp)\n│   └── types/                  # TypeScript 型定義\n├── src-tauri/                  # バックエンド (Rust)\n│   └── src/\n│       ├── commands/           # Tauri コマンド層（ドメイン別）\n│       ├── services/           # ビジネスロジック層\n│       ├── database/           # SQLite DAO 層\n│       ├── proxy/              # Proxy モジュール\n│       ├── session_manager/    # セッション管理\n│       ├── deeplink/           # Deep Link 処理\n│       └── mcp/                # MCP 同期モジュール\n├── tests/                      # フロントエンドテスト\n└── assets/                     # スクリーンショット & パートナーリソース\n```\n\n</details>\n\n## 貢献\n\nIssue や提案を歓迎します！\n\nPR を送る前に以下をご確認ください：\n\n- 型チェック: `pnpm typecheck`\n- フォーマットチェック: `pnpm format:check`\n- 単体テスト: `pnpm test:unit`\n\n新機能の場合は、PR を送る前に Issue でディスカッションしてください。プロジェクトに合わない機能の PR はクローズされる場合があります。\n\n## Star History\n\n[![Star History Chart](https://api.star-history.com/svg?repos=farion1231/cc-switch&type=Date)](https://www.star-history.com/#farion1231/cc-switch&Date)\n\n## ライセンス\n\nMIT © Jason Young\n"
  },
  {
    "path": "README_ZH.md",
    "content": "<div align=\"center\">\n\n# CC Switch\n\n### Claude Code、Codex、Gemini CLI、OpenCode 和 OpenClaw 的全方位管理工具\n\n[![Version](https://img.shields.io/badge/version-3.12.3-blue.svg)](https://github.com/farion1231/cc-switch/releases)\n[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)\n[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)\n[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)\n\n<a href=\"https://trendshift.io/repositories/15372\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/15372\" alt=\"farion1231%2Fcc-switch | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n\n[English](README.md) | 中文 | [日本語](README_JA.md) | [更新日志](CHANGELOG.md)\n\n</div>\n\n## ❤️赞助商\n\n<details open>\n<summary>点击折叠</summary>\n\n[![MiniMax](assets/partners/banners/minimax-zh.jpeg)](https://platform.minimaxi.com/subscribe/coding-plan?code=7kYF2VoaCn&source=link)\n\nMiniMax M2.5 在编程、工具调用与搜索、办公等核心生产力场景均达到或刷新行业 SOTA，拥有架构师级代码能力与高效任务拆解能力，推理速度较上一代提升 37%、token 消耗更优；100 token/s 连续工作一小时仅需 1 美金，让复杂 Agent 规模化部署经济可行，已在企业多职能场景深度落地，加速全民 Agent 时代到来。\n\n[点击](https://platform.minimaxi.com/subscribe/coding-plan?code=7kYF2VoaCn&source=link)即可领取 MiniMax Coding Plan 专属 88 折优惠！\n\n---\n\n<table>\n<tr>\n<td width=\"180\"><a href=\"https://www.packyapi.com/register?aff=cc-switch\"><img src=\"assets/partners/logos/packycode.png\" alt=\"PackyCode\" width=\"150\"></a></td>\n<td>感谢 PackyCode 赞助了本项目！PackyCode 是一家稳定、高效的API中转服务商，提供 Claude Code、Codex、Gemini 等多种中转服务。PackyCode 为本软件的用户提供了特别优惠，使用<a href=\"https://www.packyapi.com/register?aff=cc-switch\">此链接</a>注册并在充值时填写\"cc-switch\"优惠码，首次充值可以享受9折优惠！</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://cloud.siliconflow.cn/i/drGuwc9k\"><img src=\"assets/partners/logos/silicon_zh.jpg\" alt=\"SiliconFlow\" width=\"150\"></a></td>\n<td>感谢硅基流动赞助了本项目！硅基流动是一个高性能 AI 基础设施与模型 API 平台，一站式提供语言、语音、图像、视频等多模态模型的快速、可靠访问。平台支持按量计费、丰富的多模态模型选择、高速推理和企业级稳定性，帮助开发者和团队更高效地构建和扩展 AI 应用。通过<a href=\"https://cloud.siliconflow.cn/i/drGuwc9k\">此链接</a>注册并完成实名认证，即可获得 ¥20 奖励金，可在平台内跨模型使用。硅基流动现已兼容 OpenClaw，用户可接入硅基流动 API Key 免费调用主流 AI 模型。</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://aigocode.com/invite/CC-SWITCH\"><img src=\"assets/partners/logos/aigocode.png\" alt=\"AIGoCode\" width=\"150\"></a></td>\n<td>感谢 AIGoCode 赞助了本项目！AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台，为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划，零封号风险，国内直连，无需魔法，极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利，通过<a href=\"https://aigocode.com/invite/CC-SWITCH\">此链接</a>注册的用户首次充值可以获得额外10%奖励额度！</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://www.aicodemirror.com/register?invitecode=9915W3\"><img src=\"assets/partners/logos/aicodemirror.jpg\" alt=\"AICodeMirror\" width=\"150\"></a></td>\n<td>感谢 AICodeMirror 赞助了本项目！AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定中转服务，支持企业级高并发、极速开票、7×24 专属技术支持。\nClaude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折，充值更有折上折！AICodeMirror 为 CCSwitch 的用户提供了特别福利，通过<a href=\"https://www.aicodemirror.com/register?invitecode=9915W3\">此链接</a>注册的用户，可享受首充8折，企业客户最高可享 7.5 折！</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://cubence.com/signup?code=CCSWITCH&source=ccs\"><img src=\"assets/partners/logos/cubence.png\" alt=\"Cubence\" width=\"150\"></a></td>\n<td>感谢 Cubence 赞助本项目！Cubence 是一家可靠高效的 API 中继服务提供商，提供对 Claude Code、Codex、Gemini 等模型的中继服务，并提供按量、包月等灵活的计费方式。Cubence 为 CC Switch 的用户提供了特别优惠：使用 <a href=\"https://cubence.com/signup?code=CCSWITCH&source=ccs\">此链接</a> 注册，并在充值时输入 \"CCSWITCH\" 优惠码，每次充值均可享受九折优惠！</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://www.dmxapi.cn/register?aff=bUHu\"><img src=\"assets/partners/logos/dmx-zh.jpeg\" alt=\"DMXAPI\" width=\"150\"></a></td>\n<td>感谢 DMXAPI（大模型API）赞助了本项目！ DMXAPI，一个Key用全球大模型。\n为200多家企业用户提供全球大模型API服务。· 充值即开票 ·当天开票 ·并发不限制  ·1元起充 ·  7x24 在线技术辅导，GPT/Claude/Gemini全部6.8折，国内模型5~8折，Claude Code 专属模型3.4折进行中！<a href=\"https://www.dmxapi.cn/register?aff=bUHu\">点击这里注册</a></td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch\"><img src=\"assets/partners/logos/ucloud.png\" alt=\"优云智算\" width=\"150\"></a></td>\n<td>感谢优云智算赞助了本项目！优云智算是UCloud旗下AI云平台，提供稳定、全面的国内外模型API，仅一个key即可调用。主打包月、按量的高性价比 Coding Plan 套餐，基于官方2~5折优惠。支持接入 Claude Code、Codex 及 API 调用。支持企业高并发、7*24技术支持、自助开票。通过<a href=\"https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch\">此链接</a>注册的用户，可得免费5元平台体验金！</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://www.right.codes/register?aff=CCSWITCH\"><img src=\"assets/partners/logos/rightcode.jpg\" alt=\"RightCode\" width=\"150\"></a></td>\n<td>感谢 Right Code 赞助了本项目！Right Code 稳定提供 Claude Code、Codex、Gemini 等模型的中转服务。主打<strong>极高性价比</strong>的Codex包月套餐，<strong>提供额度转结，套餐当天用不完的额度，第二天还能接着用！</strong>充值即可开票，企业、团队用户一对一对接。同时为 CC Switch 的用户提供了特别优惠：通过<a href=\"https://www.right.codes/register?aff=CCSWITCH\">此链接</a>注册，每次充值均可获得实付金额25%的按量额度！</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://aicoding.sh/i/CCSWITCH\"><img src=\"assets/partners/logos/aicoding.jpg\" alt=\"AICoding\" width=\"150\"></a></td>\n<td>感谢 AICoding.sh 赞助了本项目！AICoding.sh —— 全球大模型 API 超值中转服务！Claude Code 1.9 折，GPT 0.1 折，已为数百家企业提供高性价比 AI 服务。支持 Claude Code、GPT、Gemini 及国内主流模型，企业级高并发、极速开票、7×24 专属技术支持，通过<a href=\"https://aicoding.sh/i/CCSWITCH\">此链接</a> 注册的 CC Switch 用户，首充可享受九折优惠！</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://crazyrouter.com/register?aff=OZcm&ref=cc-switch\"><img src=\"assets/partners/logos/crazyrouter.jpg\" alt=\"Crazyrouter\" width=\"150\"></a></td>\n<td>感谢 Crazyrouter 赞助了本项目！Crazyrouter 是一个高性能 AI API 聚合平台——一个 API Key 即可访问 300+ 模型，包括 Claude Code、Codex、Gemini CLI 等。全部模型低至官方定价的 55%，支持自动故障转移、智能路由和无限并发。Crazyrouter 为 CC Switch 用户提供了专属优惠：通过<a href=\"https://crazyrouter.com/register?aff=OZcm&ref=cc-switch\">此链接</a>注册即可获得 <strong>$2 免费额度</strong>，首次充值时输入优惠码 `CCSWITCH` 还可获得额外 <strong>30% 奖励额度</strong>！</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://www.sssaicode.com/register?ref=DCP0SM\"><img src=\"assets/partners/logos/sssaicode.png\" alt=\"SSSAiCode\" width=\"150\"></a></td>\n<td>感谢 SSSAiCode 赞助了本项目！SSSAiCode 是一家稳定可靠的API中转站，致力于提供稳定、可靠、平价的Claude、CodeX模型服务，<strong>提供高性价比折合0.5￥/$的官方Claude服务</strong>，支持包月、Paygo多种计费方式、支持当日快速开票，SSSAiCode为本软件的用户提供特别优惠，使用<a href=\"https://www.sssaicode.com/register?ref=DCP0SM\">此链接</a>注册每次充值均可享受10$的额外奖励！</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://www.openclaudecode.cn/register?aff=aOYQ\"><img src=\"assets/partners/logos/mikubanner.svg\" alt=\"Micu\" width=\"150\"></a></td>\n<td>感谢 米醋API 赞助了本项目！米醋API 是一家致力于提供极致性价比与高稳定性的全球大模型中转服务商。米醋API 背后有实体企业做核心保障，杜绝跑路风险，支持极速正规开票！我们主打“试错零成本”：1 元起充低门槛，0 手续费随时退款！米醋API 为本软件的用户提供了特别优惠，使用<a href=\"https://www.openclaudecode.cn/register?aff=aOYQ\">此链接</a>注册并在充值时填写\"ccswitch\"优惠码可享九折优惠！</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://x-code.cc/register?aff=IbPp\"><img src=\"assets/partners/logos/xcodeapi.png\" alt=\"XCodeAPI\" width=\"150\"></a></td>\n<td>感谢 XCodeAPI 赞助了本项目！XCodeAPI 为本软件的用户提供特别福利，使用<a href=\"https://x-code.cc/register?aff=IbPp\">此链接</a>注册后首单加赠10%的额度!(联系站长领取)</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://ctok.ai\"><img src=\"assets/partners/logos/ctok.png\" alt=\"CTok\" width=\"150\"></a></td>\n<td>感谢 CTok.ai 赞助了本项目！CTok.ai 致力于打造一站式 AI 编程工具服务平台。我们提供 Claude Code 专业套餐及技术社群服务，同时支持 Google Gemini 和 OpenAI Codex。通过精心设计的套餐方案和专业的技术社群，为开发者提供稳定的服务保障和持续的技术支持，让 AI 辅助编程真正成为开发者的生产力工具。点击<a href=\"https://ctok.ai\">这里</a>注册！</td>\n</tr>\n\n</table>\n\n</details>\n\n## 为什么选择 CC Switch？\n\n现代 AI 编程依赖于 Claude Code、Codex、Gemini CLI、OpenCode 和 OpenClaw 等 CLI 工具——但每个工具都有自己的配置格式。切换 API 供应商意味着手动编辑 JSON、TOML 或 `.env` 文件，而在多个工具之间缺乏一个统一管理 MCP, SKILLS 的方式。\n\n**CC Switch** 为你提供一个桌面应用来管理所有五个 CLI 工具。无需手动编辑配置文件，你将获得一个可视化界面，一键将供应商导入应用，一键在不同的供应商之间进行切换，内置 50+ 供应商预设、统一的 MCP, SKILLS 管理以及系统托盘即时切换功能——所有操作都基于可靠的 SQLite 数据库和原子写入机制，保护你的配置不被损坏。\n\n- **一个应用，五个 CLI 工具** — 在单一界面中管理 Claude Code、Codex、Gemini CLI、OpenCode 和 OpenClaw\n- **告别手动编辑** — 50+ 供应商预设，包括 AWS Bedrock、NVIDIA NIM 和社区中转服务；一键即可切换\n- **统一 MCP, SKILLS 管理** — 一个面板管理四个应用的 MCP, SKILLS, 支持双向同步\n- **系统托盘快速切换** — 从托盘菜单即时切换供应商，无需打开完整应用\n- **云同步** — 通过 Dropbox、OneDrive、iCloud 或 WebDAV 服务器在不同设备之间同步供应商数据\n- **跨平台** — 基于 Tauri 2 构建的原生桌面应用，支持 Windows、macOS 和 Linux\n- **小工具** - 内置了多种小工具来解决首次安装登录确认、禁止签名、插件拓展同步等多种功能\n\n## 界面预览\n\n|                  主界面                   |                  添加供应商                  |\n| :---------------------------------------: | :------------------------------------------: |\n| ![主界面](assets/screenshots/main-zh.png) | ![添加供应商](assets/screenshots/add-zh.png) |\n\n## 功能特性\n\n[完整更新日志](CHANGELOG.md) | [发布说明](docs/release-notes/v3.12.3-zh.md)\n\n### 供应商管理\n\n- **5 个 CLI 工具，50+ 预设** — Claude Code、Codex、Gemini CLI、OpenCode、OpenClaw；复制 key 即可一键导入\n- **通用供应商** — 一份配置同步到多个应用（OpenCode、OpenClaw）\n- 一键切换、系统托盘快速访问、拖拽排序、导入导出\n\n### 代理与故障转移\n\n- **本地代理热切换** — 格式转换、自动故障转移、熔断器、供应商健康监控和整流器\n- **应用级代理接管** — 独立为 Claude、Codex 或 Gemini 配置代理，具体到单个供应商\n\n### MCP、Prompts 与 Skills\n\n- **统一 MCP 面板** — 管理 4 个应用的 MCP 服务器，双向同步，支持 Deep Link 导入\n- **Prompts** — Markdown 编辑器，跨应用同步（CLAUDE.md / AGENTS.md / GEMINI.md），回填保护\n- **Skills** — 从 GitHub 仓库或 ZIP 文件一键安装，自定义仓库管理，支持软连接和文件复制\n\n### 用量与成本追踪\n\n- **用量仪表盘** — 跨供应商追踪支出、请求数和 Token 用量，趋势图表、详细请求日志和自定义模型定价\n\n### 会话管理器与工作区\n\n- 浏览、搜索和恢复全部应用对话历史\n- **工作区编辑器**（OpenClaw）— 编辑 Agent 文件（AGENTS.md、SOUL.md 等），支持 Markdown 预览\n\n### 系统与平台\n\n- **云同步** — 自定义配置目录（Dropbox、OneDrive、iCloud、坚果云、NAS）及 WebDAV 服务器同步\n- **Deep Link** (`ccswitch://`) — 通过 URL 一键导入供应商、MCP 服务器、提示词和技能\n- 深色 / 浅色 / 跟随系统主题、开机自启、自动更新、原子写入、自动备份、国际化（中/英/日）\n\n## 常见问题\n\n<details>\n<summary><strong>CC Switch 支持哪些 AI CLI 工具？</strong></summary>\n\nCC Switch 支持五个工具：**Claude Code**、**Codex**、**Gemini CLI**、**OpenCode** 和 **OpenClaw**。每个工具都有专属的供应商预设和配置管理。\n\n</details>\n\n<details>\n<summary><strong>切换供应商后需要重启终端吗？</strong></summary>\n\n大多数工具需要重启终端或 CLI 工具才能使更改生效。例外的是 **Claude Code**，它目前支持供应商数据的热切换，无需重启。\n\n</details>\n\n<details>\n<summary><strong>切换供应商之后我的插件配置怎么不见了？</strong></summary>\n\nCC Switch 使用“通用配置片段”功能，在不同的供应商之间传递 Key 和请求地址之外的通用数据，您可以在“编辑供应商”菜单的“通用配置面板”里，点击“从当前供应商提取”，把所有的通用数据提取到通用配置中，之后在新建“供应商”的时候，只要勾选“写入通用配置”（默认勾选），就会把插件等数据写入到新的供应商配置中。您的所有配置项都会保存在运行本软件的时候，第一次导入的默认供应商里面，不会丢失。\n\n</details>\n\n<details>\n<summary><strong>macOS 提示\"未知开发者\"警告 — 如何解决？</strong></summary>\n\n这是由于作者没有苹果开发者账号（正在注册中）。关闭警告后，前往**系统设置 → 隐私与安全性 → 仍要打开**。之后应用即可正常打开。\n\n</details>\n\n<details>\n<summary><strong>为什么总有一个正在激活中的供应商无法删除？</strong></summary>\n\n本软件的设计原则是“最小侵入性”，即使卸载本软件，也不会影响应用的正常使用。\n\n所以系统总会保留一个正在激活中的配置，因为如果将所有配置全部删除，该应用将无法正常使用。如果你不经常使用某个对应的应用，可以在设置中关掉该应用的显示。如果你想切换回官方登录，可以参考下条。\n\n</details>\n\n<details>\n<summary><strong>如何切换回官方登录？</strong></summary>\n\n可以在预设供应商里面添加一个官方供应商。切换过去之后，执行一遍 Log out / Log in 流程，之后便可以在官方供应商和第三方供应商之间随意切换。CodeX 可以在不同官方供应商之间进行切换，方便多个 Plus 或者 Team 账号之间切换。\n\n</details>\n\n<details>\n<summary><strong>我的数据存储在哪里？</strong></summary>\n\n- **数据库**：`~/.cc-switch/cc-switch.db`（SQLite — 供应商、MCP、提示词、技能）\n- **本地设置**：`~/.cc-switch/settings.json`（设备级 UI 偏好设置）\n- **备份**：`~/.cc-switch/backups/`（自动轮换，保留最近 10 个）\n- **SKILLS**：`~/.cc-switch/skills/`（默认通过软链接连接到对应应用）\n- **技能备份**：`~/.cc-switch/skill-backups/`（卸载前自动创建，保留最近 20 个）\n\n</details>\n\n## 文档\n\n如需了解各项功能的详细使用方法，请查阅 **[用户手册](docs/user-manual/zh/README.md)** — 涵盖供应商管理、MCP/Prompts/Skills、代理与故障转移等全部功能。\n\n## 快速开始\n\n### 基本使用\n\n1. **添加供应商**：点击\"添加供应商\" → 选择预设或创建自定义配置\n2. **切换供应商**：\n   - 主界面：选择供应商 → 点击\"启用\"\n   - 系统托盘：直接点击供应商名称（立即生效）\n3. **生效方式**：重启终端或对应的 CLI 工具以应用更改（CLaude Code 无需重启）\n4. **恢复官方登录**：添加\"官方登录\"预设，重启 CLI 工具后按照其登录/OAuth 流程操作\n\n### MCP、Prompts、Skills 与会话\n\n- **MCP**：点击\"MCP\"按钮 → 通过模板或自定义配置添加服务器 → 切换各应用同步开关\n- **Prompts**：点击\"Prompts\" → 使用 Markdown 编辑器创建预设 → 激活后同步到 live 文件\n- **Skills**：点击\"Skills\" → 浏览 GitHub 仓库 → 一键安装到全部应用\n- **会话**：点击\"Sessions\" → 浏览和搜索和恢复全部应用对话历史\n\n> **注意**：首次启动可以手动导入现有 CLI 工具配置作为默认供应商。\n\n## 下载安装\n\n### 系统要求\n\n- **Windows**：Windows 10 及以上\n- **macOS**：macOS 12 (Monterey) 及以上\n- **Linux**：Ubuntu 22.04+ / Debian 11+ / Fedora 34+ 等主流发行版\n\n### Windows 用户\n\n从 [Releases](../../releases) 页面下载最新版本的 `CC-Switch-v{版本号}-Windows.msi` 安装包或 `CC-Switch-v{版本号}-Windows-Portable.zip` 绿色版。\n\n### macOS 用户\n\n**方式一：通过 Homebrew 安装（推荐）**\n\n```bash\nbrew tap farion1231/ccswitch\nbrew install --cask cc-switch\n```\n\n更新：\n\n```bash\nbrew upgrade --cask cc-switch\n```\n\n**方式二：手动下载**\n\n从 [Releases](../../releases) 页面下载 `CC-Switch-v{版本号}-macOS.zip` 解压使用。\n\n> **注意**：由于作者没有苹果开发者账号，首次打开可能出现\"未知开发者\"警告，请先关闭，然后前往\"系统设置\" → \"隐私与安全性\" → 点击\"仍要打开\"，之后便可以正常打开。\n\n### Arch Linux 用户\n\n**通过 paru 安装（推荐）**\n\n```bash\nparu -S cc-switch-bin\n```\n\n### Linux 用户\n\n从 [Releases](../../releases) 页面下载最新版本的 Linux 安装包：\n\n- `CC-Switch-v{版本号}-Linux.deb`（Debian/Ubuntu）\n- `CC-Switch-v{版本号}-Linux.rpm`（Fedora/RHEL/openSUSE）\n- `CC-Switch-v{版本号}-Linux.AppImage`（通用）\n- `CC-Switch-v{版本号}-Linux.flatpak`（Flatpak）\n\nFlatpak 安装与运行：\n\n```bash\nflatpak install --user ./CC-Switch-v{版本号}-Linux.flatpak\nflatpak run com.ccswitch.desktop\n```\n\n<details>\n<summary><strong>架构总览</strong></summary>\n\n### 设计原则\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│                    前端 (React + TS)                         │\n│  ┌─────────────┐  ┌──────────────┐  ┌──────────────────┐    │\n│  │ Components  │  │    Hooks     │  │  TanStack Query  │    │\n│  │   （UI）     │──│ （业务逻辑）   │──│   （缓存/同步）    │    │\n│  └─────────────┘  └──────────────┘  └──────────────────┘    │\n└────────────────────────┬────────────────────────────────────┘\n                         │ Tauri IPC\n┌────────────────────────▼────────────────────────────────────┐\n│                  后端 (Tauri + Rust)                         │\n│  ┌─────────────┐  ┌──────────────┐  ┌──────────────────┐    │\n│  │  Commands   │  │   Services   │  │  Models/Config   │    │\n│  │ （API 层）   │──│  （业务层）    │──│    （数据）       │    │\n│  └─────────────┘  └──────────────┘  └──────────────────┘    │\n└─────────────────────────────────────────────────────────────┘\n```\n\n**核心设计模式**\n\n- **SSOT**（单一事实源）：所有数据存储在 `~/.cc-switch/cc-switch.db`（SQLite）\n- **双层存储**：SQLite 存储可同步数据，JSON 存储设备级设置\n- **双向同步**：切换时写入 live 文件，编辑当前供应商时从 live 回填\n- **原子写入**：临时文件 + 重命名模式防止配置损坏\n- **并发安全**：Mutex 保护的数据库连接避免竞态条件\n- **分层架构**：清晰分离（Commands → Services → DAO → Database）\n\n**核心组件**\n\n- **ProviderService**：供应商增删改查、切换、回填、排序\n- **McpService**：MCP 服务器管理、导入导出、live 文件同步\n- **ProxyService**：本地 Proxy 模式，支持热切换和格式转换\n- **SessionManager**：Claude Code 对话历史浏览\n- **ConfigService**：配置导入导出、备份轮换\n- **SpeedtestService**：API 端点延迟测量\n\n</details>\n\n<details>\n<summary><strong>开发指南</strong></summary>\n\n### 环境要求\n\n- Node.js 18+\n- pnpm 8+\n- Rust 1.85+\n- Tauri CLI 2.8+\n\n### 开发命令\n\n```bash\n# 安装依赖\npnpm install\n\n# 开发模式（热重载）\npnpm dev\n\n# 类型检查\npnpm typecheck\n\n# 代码格式化\npnpm format\n\n# 检查代码格式\npnpm format:check\n\n# 运行前端单元测试\npnpm test:unit\n\n# 监听模式运行测试（推荐开发时使用）\npnpm test:unit:watch\n\n# 构建应用\npnpm build\n\n# 构建调试版本\npnpm tauri build --debug\n```\n\n### Rust 后端开发\n\n```bash\ncd src-tauri\n\n# 格式化 Rust 代码\ncargo fmt\n\n# 运行 clippy 检查\ncargo clippy\n\n# 运行后端测试\ncargo test\n\n# 运行特定测试\ncargo test test_name\n\n# 运行带测试 hooks 的测试\ncargo test --features test-hooks\n```\n\n### 测试说明\n\n**前端测试**：\n\n- 使用 **vitest** 作为测试框架\n- 使用 **MSW (Mock Service Worker)** 模拟 Tauri API 调用\n- 使用 **@testing-library/react** 进行组件测试\n\n**运行测试**：\n\n```bash\n# 运行所有测试\npnpm test:unit\n\n# 监听模式（自动重跑）\npnpm test:unit:watch\n\n# 带覆盖率报告\npnpm test:unit --coverage\n```\n\n### 技术栈\n\n**前端**：React 18 · TypeScript · Vite · TailwindCSS 3.4 · TanStack Query v5 · react-i18next · react-hook-form · zod · shadcn/ui · @dnd-kit\n\n**后端**：Tauri 2.8 · Rust · serde · tokio · thiserror · tauri-plugin-updater/process/dialog/store/log\n\n**测试**：vitest · MSW · @testing-library/react\n\n</details>\n\n<details>\n<summary><strong>项目结构</strong></summary>\n\n```\n├── src/                        # 前端 (React + TypeScript)\n│   ├── components/\n│   │   ├── providers/          # 供应商管理\n│   │   ├── mcp/                # MCP 面板\n│   │   ├── prompts/            # Prompts 管理\n│   │   ├── skills/             # Skills 管理\n│   │   ├── sessions/           # 会话管理器\n│   │   ├── proxy/              # Proxy 模式面板\n│   │   ├── openclaw/           # OpenClaw 配置面板\n│   │   ├── settings/           # 设置（终端/备份/关于）\n│   │   ├── deeplink/           # Deep Link 导入\n│   │   ├── env/                # 环境变量管理\n│   │   ├── universal/          # 跨应用配置\n│   │   ├── usage/              # 用量统计\n│   │   └── ui/                 # shadcn/ui 组件库\n│   ├── hooks/                  # 自定义 hooks（业务逻辑）\n│   ├── lib/\n│   │   ├── api/                # Tauri API 封装（类型安全）\n│   │   └── query/              # TanStack Query 配置\n│   ├── locales/                # 翻译 (zh/en/ja)\n│   ├── config/                 # 预设 (providers/mcp)\n│   └── types/                  # TypeScript 类型定义\n├── src-tauri/                  # 后端 (Rust)\n│   └── src/\n│       ├── commands/           # Tauri 命令层（按领域）\n│       ├── services/           # 业务逻辑层\n│       ├── database/           # SQLite DAO 层\n│       ├── proxy/              # Proxy 模块\n│       ├── session_manager/    # 会话管理\n│       ├── deeplink/           # Deep Link 处理\n│       └── mcp/                # MCP 同步模块\n├── tests/                      # 前端测试\n└── assets/                     # 截图 & 合作商资源\n```\n\n</details>\n\n## 贡献\n\n欢迎提交 Issue 反馈问题和建议！\n\n提交 PR 前请确保：\n\n- 通过类型检查：`pnpm typecheck`\n- 通过格式检查：`pnpm format:check`\n- 通过单元测试：`pnpm test:unit`\n\n新功能开发前，欢迎先开 Issue 讨论实现方案，不适合项目的功能性 PR 有可能会被关闭。\n\n## Star History\n\n[![Star History Chart](https://api.star-history.com/svg?repos=farion1231/cc-switch&type=Date)](https://www.star-history.com/#farion1231/cc-switch&Date)\n\n## License\n\nMIT © Jason Young\n"
  },
  {
    "path": "cc-switch-main/src/config/universalProviderPresets.ts",
    "content": "/**\n * 统一供应商（Universal Provider）预设配置\n *\n * 统一供应商是跨应用共享的配置，修改后会自动同步到 Claude、Codex、Gemini 三个应用。\n * 适用于 NewAPI 等支持多种协议的 API 网关。\n */\n\nimport type {\n  UniversalProvider,\n  UniversalProviderApps,\n  UniversalProviderModels,\n} from \"@/types\";\n\n/**\n * 统一供应商预设接口\n */\nexport interface UniversalProviderPreset {\n  /** 预设名称 */\n  name: string;\n  /** 供应商类型标识 */\n  providerType: string;\n  /** 默认启用的应用 */\n  defaultApps: UniversalProviderApps;\n  /** 默认模型配置 */\n  defaultModels: UniversalProviderModels;\n  /** 网站链接 */\n  websiteUrl?: string;\n  /** 图标名称 */\n  icon?: string;\n  /** 图标颜色 */\n  iconColor?: string;\n  /** 描述 */\n  description?: string;\n  /** 是否为自定义模板（允许用户完全自定义） */\n  isCustomTemplate?: boolean;\n}\n\n/**\n * NewAPI 默认模型配置\n */\nconst NEWAPI_DEFAULT_MODELS: UniversalProviderModels = {\n  claude: {\n    model: \"claude-sonnet-4-20250514\",\n    haikuModel: \"claude-haiku-4-20250514\",\n    sonnetModel: \"claude-sonnet-4-20250514\",\n    opusModel: \"claude-sonnet-4-20250514\",\n  },\n  codex: {\n    model: \"gpt-4o\",\n    reasoningEffort: \"high\",\n  },\n  gemini: {\n    model: \"gemini-2.5-pro\",\n  },\n};\n\nconst N1N_DEFAULT_MODELS: UniversalProviderModels = {\n  claude: {\n    model: \"claude-3-5-sonnet-20240620\",\n    haikuModel: \"claude-3-haiku-20240307\",\n    sonnetModel: \"claude-3-5-sonnet-20240620\",\n    opusModel: \"claude-3-opus-20240229\",\n  },\n  codex: {\n    model: \"gpt-4o\",\n    reasoningEffort: \"high\",\n  },\n  gemini: {\n    model: \"gemini-1.5-pro-latest\",\n  },\n};\n\n/**\n * 统一供应商预设列表\n */\nexport const universalProviderPresets: UniversalProviderPreset[] = [\n  {\n    name: \"n1n.ai\",\n    providerType: \"n1n\",\n    defaultApps: {\n      claude: true,\n      codex: true,\n      gemini: true,\n    },\n    defaultModels: N1N_DEFAULT_MODELS,\n    websiteUrl: \"https://n1n.ai\",\n    icon: \"openai\",\n    iconColor: \"#000000\",\n    description:\n      \"n1n.ai - 聚合 OpenAI, Anthropic, Google 等主流大模型的一站式 AI 服务平台\",\n  },\n  {\n    name: \"NewAPI\",\n    providerType: \"newapi\",\n    defaultApps: {\n      claude: true,\n      codex: true,\n      gemini: true,\n    },\n    defaultModels: NEWAPI_DEFAULT_MODELS,\n    websiteUrl: \"https://www.newapi.pro\",\n    icon: \"newapi\",\n    iconColor: \"#00A67E\",\n    description:\n      \"NewAPI 是一个可自部署的 API 网关，支持 Anthropic、OpenAI、Gemini 等多种协议\",\n  },\n  {\n    name: \"自定义网关\",\n    providerType: \"custom_gateway\",\n    defaultApps: {\n      claude: true,\n      codex: true,\n      gemini: true,\n    },\n    defaultModels: NEWAPI_DEFAULT_MODELS,\n    icon: \"openai\",\n    iconColor: \"#6366F1\",\n    description: \"自定义配置的 API 网关\",\n    isCustomTemplate: true,\n  },\n];\n\n/**\n * 根据预设创建统一供应商\n */\nexport function createUniversalProviderFromPreset(\n  preset: UniversalProviderPreset,\n  id: string,\n  baseUrl: string,\n  apiKey: string,\n  customName?: string,\n): UniversalProvider {\n  return {\n    id,\n    name: customName || preset.name,\n    providerType: preset.providerType,\n    apps: { ...preset.defaultApps },\n    baseUrl,\n    apiKey,\n    models: JSON.parse(JSON.stringify(preset.defaultModels)), // Deep copy\n    websiteUrl: preset.websiteUrl,\n    icon: preset.icon,\n    iconColor: preset.iconColor,\n    createdAt: Date.now(),\n  };\n}\n\n/**\n * 获取预设的显示名称（用于 UI）\n */\nexport function getPresetDisplayName(preset: UniversalProviderPreset): string {\n  return preset.name;\n}\n\n/**\n * 根据类型查找预设\n */\nexport function findPresetByType(\n  providerType: string,\n): UniversalProviderPreset | undefined {\n  return universalProviderPresets.find((p) => p.providerType === providerType);\n}\n"
  },
  {
    "path": "components.json",
    "content": "{\n  \"$schema\": \"https://ui.shadcn.com/schema.json\",\n  \"style\": \"default\",\n  \"rsc\": false,\n  \"tsx\": true,\n  \"tailwind\": {\n    \"config\": \"tailwind.config.cjs\",\n    \"css\": \"src/index.css\",\n    \"baseColor\": \"neutral\",\n    \"cssVariables\": true,\n    \"prefix\": \"\"\n  },\n  \"iconLibrary\": \"lucide\",\n  \"aliases\": {\n    \"components\": \"@/components\",\n    \"utils\": \"@/lib/utils\",\n    \"ui\": \"@/components/ui\",\n    \"lib\": \"@/lib\",\n    \"hooks\": \"@/hooks\"\n  }\n}\n\n"
  },
  {
    "path": "deplink.html",
    "content": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>CC Switch 深链接测试</title>\n    <style>\n        * {\n            margin: 0;\n            padding: 0;\n            box-sizing: border-box;\n        }\n\n        body {\n            font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif;\n            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);\n            min-height: 100vh;\n            padding: 20px;\n        }\n\n        .container {\n            max-width: 900px;\n            margin: 0 auto;\n            background: white;\n            border-radius: 16px;\n            box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);\n            overflow: hidden;\n        }\n\n        .header {\n            background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);\n            color: white;\n            padding: 40px;\n            text-align: center;\n        }\n\n        .header h1 {\n            font-size: 32px;\n            margin-bottom: 10px;\n        }\n\n        .header p {\n            font-size: 16px;\n            opacity: 0.9;\n        }\n\n        .content {\n            padding: 40px;\n        }\n\n        .section {\n            margin-bottom: 40px;\n        }\n\n        .section h2 {\n            color: #2c3e50;\n            font-size: 24px;\n            margin-bottom: 20px;\n            padding-bottom: 10px;\n            border-bottom: 2px solid #ecf0f1;\n        }\n\n        .version-badge {\n            display: inline-block;\n            background: linear-gradient(135deg, #f39c12 0%, #e67e22 100%);\n            color: white;\n            padding: 4px 12px;\n            border-radius: 12px;\n            font-size: 14px;\n            font-weight: 600;\n            margin-left: 8px;\n            vertical-align: middle;\n        }\n\n        .link-card {\n            background: #f8f9fa;\n            border-radius: 12px;\n            padding: 24px;\n            margin-bottom: 20px;\n            border: 2px solid #e9ecef;\n            transition: all 0.3s ease;\n        }\n\n        .link-card:hover {\n            border-color: #3498db;\n            box-shadow: 0 4px 12px rgba(52, 152, 219, 0.15);\n            transform: translateY(-2px);\n        }\n\n        .link-card h3 {\n            color: #2c3e50;\n            font-size: 20px;\n            margin-bottom: 12px;\n            display: flex;\n            align-items: center;\n            gap: 8px;\n        }\n\n        .link-card .description {\n            color: #7f8c8d;\n            font-size: 14px;\n            margin-bottom: 16px;\n            line-height: 1.6;\n        }\n\n        .deep-link {\n            display: inline-flex;\n            align-items: center;\n            gap: 8px;\n            background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);\n            color: white;\n            padding: 12px 24px;\n            text-decoration: none;\n            border-radius: 8px;\n            font-weight: 500;\n            transition: all 0.3s ease;\n            box-shadow: 0 2px 8px rgba(52, 152, 219, 0.3);\n        }\n\n        .deep-link:hover {\n            background: linear-gradient(135deg, #2980b9 0%, #1f6391 100%);\n            box-shadow: 0 4px 12px rgba(52, 152, 219, 0.4);\n            transform: translateY(-1px);\n        }\n\n        .deep-link:active {\n            transform: translateY(0);\n        }\n\n        .info-box {\n            background: #fff3cd;\n            border-left: 4px solid #ffc107;\n            padding: 16px;\n            border-radius: 8px;\n            margin-top: 20px;\n        }\n\n        .info-box h4 {\n            color: #856404;\n            margin-bottom: 8px;\n            font-size: 16px;\n        }\n\n        .info-box ul {\n            list-style: disc;\n            margin-left: 20px;\n            color: #856404;\n            font-size: 14px;\n            line-height: 1.8;\n            padding-left: 20px;\n        }\n\n        .generator-section {\n            background: #e8f4f8;\n            border-radius: 12px;\n            padding: 30px;\n            margin-top: 40px;\n        }\n\n        .generator-section h2 {\n            color: #2c3e50;\n            margin-bottom: 24px;\n            border-bottom: 2px solid #3498db;\n            padding-bottom: 10px;\n        }\n\n        .form-group {\n            margin-bottom: 20px;\n        }\n\n        .form-group label {\n            display: block;\n            margin-bottom: 8px;\n            color: #2c3e50;\n            font-weight: 500;\n            font-size: 14px;\n        }\n\n        .form-group input,\n        .form-group select,\n        .form-group textarea {\n            width: 100%;\n            padding: 12px;\n            border: 2px solid #dee2e6;\n            border-radius: 8px;\n            font-size: 14px;\n            transition: border-color 0.3s ease;\n        }\n\n        .form-group input:focus,\n        .form-group select:focus,\n        .form-group textarea:focus {\n            outline: none;\n            border-color: #3498db;\n        }\n\n        .btn {\n            background: linear-gradient(135deg, #27ae60 0%, #229954 100%);\n            color: white;\n            padding: 14px 28px;\n            border: none;\n            border-radius: 8px;\n            font-size: 16px;\n            font-weight: 500;\n            cursor: pointer;\n            transition: all 0.3s ease;\n            box-shadow: 0 2px 8px rgba(39, 174, 96, 0.3);\n        }\n\n        .btn:hover {\n            background: linear-gradient(135deg, #229954 0%, #1e8449 100%);\n            box-shadow: 0 4px 12px rgba(39, 174, 96, 0.4);\n            transform: translateY(-1px);\n        }\n\n        .btn:active {\n            transform: translateY(0);\n        }\n\n        .result-box {\n            background: white;\n            border-radius: 8px;\n            padding: 20px;\n            margin-top: 20px;\n            border: 2px solid #3498db;\n        }\n\n        .result-box strong {\n            color: #2c3e50;\n            font-size: 16px;\n        }\n\n        .result-text {\n            background: #f8f9fa;\n            padding: 12px;\n            border-radius: 6px;\n            margin: 12px 0;\n            word-break: break-all;\n            font-family: monospace;\n            font-size: 13px;\n            color: #2c3e50;\n            border: 1px solid #dee2e6;\n        }\n\n        .btn-copy {\n            background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%);\n            margin-right: 10px;\n        }\n\n        .btn-copy:hover {\n            background: linear-gradient(135deg, #8e44ad 0%, #7d3c98 100%);\n        }\n\n        .app-badge {\n            display: inline-block;\n            padding: 4px 12px;\n            border-radius: 12px;\n            font-size: 12px;\n            font-weight: 600;\n            text-transform: uppercase;\n        }\n\n        .badge-claude {\n            background: #e8f4f8;\n            color: #3498db;\n        }\n\n        .badge-codex {\n            background: #fef5e7;\n            color: #f39c12;\n        }\n\n        .badge-gemini {\n            background: #fdeef4;\n            color: #e91e63;\n        }\n\n        .param-list {\n            background: #f8f9fa;\n            border-left: 3px solid #3498db;\n            padding: 12px;\n            border-radius: 6px;\n            margin: 12px 0;\n            font-size: 13px;\n            line-height: 1.8;\n            color: #495057;\n            font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;\n        }\n\n        .param-tag {\n            display: inline-block;\n            padding: 2px 8px;\n            border-radius: 4px;\n            font-size: 11px;\n            font-weight: 600;\n            text-transform: uppercase;\n            margin-right: 6px;\n            background: #3498db;\n            color: white;\n        }\n\n        .param-tag.optional {\n            background: #95a5a6;\n        }\n\n        @media (max-width: 768px) {\n            .header h1 {\n                font-size: 24px;\n            }\n\n            .content {\n                padding: 20px;\n            }\n\n            .generator-section {\n                padding: 20px;\n            }\n        }\n    </style>\n</head>\n\n<body>\n    <div class=\"container\">\n        <div class=\"header\">\n            <h1>🔗 CC Switch 深链接测试</h1>\n            <p>点击下方链接测试深链接导入功能</p>\n        </div>\n\n        <div class=\"content\">\n\n            <!-- 配置文件导入示例 (v3.8+) -->\n            <div class=\"section\">\n                <h2>📦 配置文件导入示例 <span class=\"version-badge\">v3.8+</span></h2>\n\n                <!-- Claude 配置文件导入 -->\n                <div class=\"link-card\">\n                    <h3>\n                        <span class=\"app-badge badge-claude\">Claude</span>\n                        完整 JSON 配置导入\n                    </h3>\n                    <p class=\"description\">\n                        通过 Base64 编码的 JSON 配置文件导入完整配置，包含所有四种模型和端点信息。\n                    </p>\n                    <div class=\"param-list\">\n                        <span class=\"param-tag\">必需</span> resource=provider, app=claude, name<br>\n                        <span class=\"param-tag optional\">可选</span> <strong>config</strong> (Base64 JSON),\n                        <strong>configFormat=json</strong>\n                    </div>\n                    <div style=\"display: flex; gap: 10px; align-items: center; flex-wrap: wrap;\">\n                        <a href=\"ccswitch://v1/import?resource=provider&app=claude&name=Claude%20Complete&configFormat=json&config=eyJlbnYiOnsiQU5USFJPUElDX0FVVEhfVE9LRU4iOiJzay1hbnQtdGVzdC1rZXkxMjMiLCJBTlRIUk9QSUNfQkFTRV9VUkwiOiJodHRwczovL2FwaS5hbnRocm9waWMuY29tL3YxIiwiQU5USFJPUElDX01PREVMIjoiY2xhdWRlLXNvbm5ldC00LjUiLCJBTlRIUk9QSUNfREVGQVVMVF9IQUlLVV9NT0RFTCI6ImNsYXVkZS1oYWlrdS00LjEiLCJBTlRIUk9QSUNfREVGQVVMVF9TT05ORVRfTU9ERUwiOiJjbGF1ZGUtc29ubmV0LTQuNSIsIkFOVEhST1BJQ19ERUZBVUxUX09QVVNfTU9ERUwiOiJjbGF1ZGUtb3B1cy00In19\"\n                            class=\"deep-link\">\n                            📥 导入完整配置\n                        </a>\n                        <button class=\"deep-link\"\n                            style=\"background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%); cursor: pointer; border: none;\"\n                            onclick=\"copyDeepLink('ccswitch://v1/import?resource=provider&app=claude&name=Claude%20Complete&configFormat=json&config=eyJlbnYiOnsiQU5USFJPUElDX0FVVEhfVE9LRU4iOiJzay1hbnQtdGVzdC1rZXkxMjMiLCJBTlRIUk9QSUNfQkFTRV9VUkwiOiJodHRwczovL2FwaS5hbnRocm9waWMuY29tL3YxIiwiQU5USFJPUElDX01PREVMIjoiY2xhdWRlLXNvbm5ldC00LjUiLCJBTlRIUk9QSUNfREVGQVVMVF9IQUlLVV9NT0RFTCI6ImNsYXVkZS1oYWlrdS00LjEiLCJBTlRIUk9QSUNfREVGQVVMVF9TT05ORVRfTU9ERUwiOiJjbGF1ZGUtc29ubmV0LTQuNSIsIkFOVEhST1BJQ19ERUZBVUxUX09QVVNfTU9ERUwiOiJjbGF1ZGUtb3B1cy00In19', this)\">\n                            📋 复制链接\n                        </button>\n                    </div>\n                    <div class=\"code-block\"\n                        style=\"margin-top: 12px; padding: 12px; background: #2c3e50; color: #ecf0f1; border-radius: 8px; font-family: monospace; font-size: 12px; overflow-x: auto;\">\n                        <div style=\"color: #95a5a6; margin-bottom: 8px;\">// 解码后的配置内容:</div>\n                        {<br>\n                        &nbsp;&nbsp;\"env\": {<br>\n                        &nbsp;&nbsp;&nbsp;&nbsp;\"ANTHROPIC_AUTH_TOKEN\": \"sk-ant-test-key123\",<br>\n                        &nbsp;&nbsp;&nbsp;&nbsp;\"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com/v1\",<br>\n                        &nbsp;&nbsp;&nbsp;&nbsp;\"ANTHROPIC_MODEL\": \"claude-sonnet-4.5\",<br>\n                        &nbsp;&nbsp;&nbsp;&nbsp;\"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"claude-haiku-4.1\",<br>\n                        &nbsp;&nbsp;&nbsp;&nbsp;\"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"claude-sonnet-4.5\",<br>\n                        &nbsp;&nbsp;&nbsp;&nbsp;\"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"claude-opus-4\"<br>\n                        &nbsp;&nbsp;}<br>\n                        }\n                    </div>\n                </div>\n\n                <!-- URL 参数覆盖配置文件 -->\n                <div class=\"link-card\">\n                    <h3>\n                        <span class=\"app-badge badge-claude\">Claude</span>\n                        配置 + URL 参数覆盖\n                    </h3>\n                    <p class=\"description\">\n                        配置文件提供基础设置,URL 参数覆盖 API Key。URL 参数优先级最高。\n                    </p>\n                    <div class=\"param-list\">\n                        <span class=\"param-tag\">必需</span> name, config<br>\n                        <span class=\"param-tag optional\">覆盖</span> <strong>apiKey</strong> (覆盖配置文件中的值)\n                    </div>\n                    <div style=\"display: flex; gap: 10px; align-items: center; flex-wrap: wrap;\">\n                        <a href=\"ccswitch://v1/import?resource=provider&app=claude&name=My%20Custom&apiKey=sk-ant-my-new-key&configFormat=json&config=eyJlbnYiOnsiQU5USFJPUElDX0JBU0VfVVJMIjoiaHR0cHM6Ly9hcGkuYW50aHJvcGljLmNvbS92MSIsIkFOVEhST1BJQ19NT0RFTCI6ImNsYXVkZS1zb25uZXQtNC41In19\"\n                            class=\"deep-link\">\n                            📥 导入混合配置\n                        </a>\n                        <button class=\"deep-link\"\n                            style=\"background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%); cursor: pointer; border: none;\"\n                            onclick=\"copyDeepLink('ccswitch://v1/import?resource=provider&app=claude&name=My%20Custom&apiKey=sk-ant-my-new-key&configFormat=json&config=eyJlbnYiOnsiQU5USFJPUElDX0JBU0VfVVJMIjoiaHR0cHM6Ly9hcGkuYW50aHJvcGljLmNvbS92MSIsIkFOVEhST1BJQ19NT0RFTCI6ImNsYXVkZS1zb25uZXQtNC41In19', this)\">\n                            📋 复制链接\n                        </button>\n                    </div>\n                    <div class=\"code-block\"\n                        style=\"margin-top: 12px; padding: 12px; background: #2c3e50; color: #ecf0f1; border-radius: 8px; font-family: monospace; font-size: 12px; overflow-x: auto;\">\n                        <div style=\"color: #95a5a6; margin-bottom: 8px;\">// 解码后的配置内容:</div>\n                        {<br>\n                        &nbsp;&nbsp;\"env\": {<br>\n                        &nbsp;&nbsp;&nbsp;&nbsp;\"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com/v1\",<br>\n                        &nbsp;&nbsp;&nbsp;&nbsp;\"ANTHROPIC_MODEL\": \"claude-sonnet-4.5\"<br>\n                        &nbsp;&nbsp;}<br>\n                        }<br>\n                        <div style=\"color: #f39c12; margin-top: 8px;\">// URL 参数覆盖: apiKey=sk-ant-my-new-key</div>\n                    </div>\n                    <div\n                        style=\"margin-top: 12px; padding: 10px; background: #fff3cd; border-left: 4px solid #ffc107; border-radius: 4px; font-size: 13px;\">\n                        <strong>优先级规则:</strong> URL 参数 (apiKey) > 配置文件 (endpoint, model)\n                    </div>\n                </div>\n\n                <!-- Codex TOML 配置导入 -->\n                <div class=\"link-card\">\n                    <h3>\n                        <span class=\"app-badge badge-codex\">Codex</span>\n                        TOML 格式配置导入\n                    </h3>\n                    <p class=\"description\">\n                        Codex 使用 TOML 格式的配置文件,包含 auth 和 config 两部分。\n                    </p>\n                    <div class=\"param-list\">\n                        <span class=\"param-tag\">必需</span> name, config<br>\n                        <span class=\"param-tag optional\">可选</span> <strong>configFormat=json</strong> (Codex 配置为 JSON\n                        包装的 TOML)\n                    </div>\n                    <div style=\"display: flex; gap: 10px; align-items: center; flex-wrap: wrap;\">\n                        <a href=\"ccswitch://v1/import?resource=provider&app=codex&name=OpenAI%20Complete&configFormat=json&config=eyJhdXRoIjp7Ik9QRU5BSV9BUElfS0VZIjoic2stcHJvai10ZXN0LWtleTEyMyJ9LCJjb25maWciOiJbbW9kZWxfcHJvdmlkZXJzLm9wZW5haV1cbmJhc2VfdXJsID0gXCJodHRwczovL2FwaS5vcGVuYWkuY29tL3YxXCJcblxuW2dlbmVyYWxdXG5tb2RlbCA9IFwiZ3B0LTUuMVwiIn0=\"\n                            class=\"deep-link\">\n                            📥 导入 Codex 配置\n                        </a>\n                        <button class=\"deep-link\"\n                            style=\"background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%); cursor: pointer; border: none;\"\n                            onclick=\"copyDeepLink('ccswitch://v1/import?resource=provider&app=codex&name=OpenAI%20Complete&configFormat=json&config=eyJhdXRoIjp7Ik9QRU5BSV9BUElfS0VZIjoic2stcHJvai10ZXN0LWtleTEyMyJ9LCJjb25maWciOiJbbW9kZWxfcHJvdmlkZXJzLm9wZW5haV1cbmJhc2VfdXJsID0gXCJodHRwczovL2FwaS5vcGVuYWkuY29tL3YxXCJcblxuW2dlbmVyYWxdXG5tb2RlbCA9IFwiZ3B0LTUuMVwiIn0=', this)\">\n                            📋 复制链接\n                        </button>\n                    </div>\n                    <div class=\"code-block\"\n                        style=\"margin-top: 12px; padding: 12px; background: #2c3e50; color: #ecf0f1; border-radius: 8px; font-family: monospace; font-size: 12px; overflow-x: auto;\">\n                        <div style=\"color: #95a5a6; margin-bottom: 8px;\">// 解码后的配置内容:</div>\n                        {<br>\n                        &nbsp;&nbsp;\"auth\": {<br>\n                        &nbsp;&nbsp;&nbsp;&nbsp;\"OPENAI_API_KEY\": \"sk-proj-test-key123\"<br>\n                        &nbsp;&nbsp;},<br>\n                        &nbsp;&nbsp;\"config\": \"[model_providers.openai]\\nbase_url =\n                        \\\"https://api.openai.com/v1\\\"\\n\\n[general]\\nmodel = \\\"gpt-5.1\\\"\"<br>\n                        }\n                        <div style=\"color: #95a5a6; margin-top: 12px; margin-bottom: 4px;\">// config 字段解析 (TOML):</div>\n                        <div style=\"color: #a8d08d;\">[model_providers.openai]</div>\n                        <div style=\"color: #a8d08d;\">base_url = \"https://api.openai.com/v1\"</div>\n                        <div style=\"color: #a8d08d; margin-top: 8px;\">[general]</div>\n                        <div style=\"color: #a8d08d;\">model = \"gpt-5.1\"</div>\n                    </div>\n                </div>\n\n                <!-- Gemini 配置导入 -->\n                <div class=\"link-card\">\n                    <h3>\n                        <span class=\"app-badge badge-gemini\">Gemini</span>\n                        Gemini 配置导入\n                    </h3>\n                    <p class=\"description\">\n                        Gemini 使用扁平的环境变量格式,简洁明了。\n                    </p>\n                    <div class=\"param-list\">\n                        <span class=\"param-tag\">必需</span> name, config<br>\n                        <span class=\"param-tag optional\">可选</span> <strong>configFormat=json</strong>\n                    </div>\n                    <div style=\"display: flex; gap: 10px; align-items: center; flex-wrap: wrap;\">\n                        <a href=\"ccswitch://v1/import?resource=provider&app=gemini&name=Google%20Gemini&configFormat=json&config=eyJHRU1JTklfQVBJX0tFWSI6IkFJemFTeUR0ZXN0a2V5MTIzIiwiR0VNSU5JX0JBU0VfVVJMIjoiaHR0cHM6Ly9nZW5lcmF0aXZlbGFuZ3VhZ2UuZ29vZ2xlYXBpcy5jb20vdjFiZXRhIiwiR0VNSU5JX01PREVMIjoiZ2VtaW5pLTMtcHJvLXByZXZpZXcifQ==\"\n                            class=\"deep-link\">\n                            📥 导入 Gemini 配置\n                        </a>\n                        <button class=\"deep-link\"\n                            style=\"background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%); cursor: pointer; border: none;\"\n                            onclick=\"copyDeepLink('ccswitch://v1/import?resource=provider&app=gemini&name=Google%20Gemini&configFormat=json&config=eyJHRU1JTklfQVBJX0tFWSI6IkFJemFTeUR0ZXN0a2V5MTIzIiwiR0VNSU5JX0JBU0VfVVJMIjoiaHR0cHM6Ly9nZW5lcmF0aXZlbGFuZ3VhZ2UuZ29vZ2xlYXBpcy5jb20vdjFiZXRhIiwiR0VNSU5JX01PREVMIjoiZ2VtaW5pLTMtcHJvLXByZXZpZXcifQ==', this)\">\n                            📋 复制链接\n                        </button>\n                    </div>\n                    <div class=\"code-block\"\n                        style=\"margin-top: 12px; padding: 12px; background: #2c3e50; color: #ecf0f1; border-radius: 8px; font-family: monospace; font-size: 12px; overflow-x: auto;\">\n                        <div style=\"color: #95a5a6; margin-bottom: 8px;\">// 解码后的配置内容:</div>\n                        {<br>\n                        &nbsp;&nbsp;\"GEMINI_API_KEY\": \"AIzaSyDtestkey123\",<br>\n                        &nbsp;&nbsp;\"GEMINI_BASE_URL\": \"https://generativelanguage.googleapis.com/v1beta\",<br>\n                        &nbsp;&nbsp;\"GEMINI_MODEL\": \"gemini-3-pro-preview\"<br>\n                        }\n                    </div>\n                </div>\n\n            </div>\n\n            <!-- MCP、Prompt、Skill 示例 -->\n            <div class=\"section\">\n                <h2>🔌 MCP Servers 导入 <span class=\"version-badge\">v3.8+</span></h2>\n\n                <div class=\"link-card\">\n                    <h3>📦📦 JSON 配置示例 - 批量导入多个 MCP Servers</h3>\n                    <p class=\"description\">\n                        一次性导入多个 MCP 服务器 (Context7 + Sequential-thinking)。\n                    </p>\n                    <div class=\"param-list\">\n                        <span class=\"param-tag\">必需</span> resource=mcp, apps, config (Base64)<br>\n                        <span class=\"param-tag optional\">可选</span> enabled\n                    </div>\n                    <div style=\"display: flex; gap: 10px; align-items: center; flex-wrap: wrap;\">\n                        <a href=\"ccswitch://v1/import?resource=mcp&apps=claude,codex&config=eyJtY3BTZXJ2ZXJzIjp7ImNvbnRleHQ3Ijp7ImNvbW1hbmQiOiJidW54IiwiYXJncyI6WyIteSIsIkB1cHN0YXNoL2NvbnRleHQ3LW1jcCIsIi0tYXBpLWtleSIsImN0eDdzay00ZGRkNGY2Ni1lNzUyLTQwMjItYjFmNi1jOGNmNjI3OWI4MGQiXSwiZW52Ijp7fX0sInNlcXVlbnRpYWwtdGhpbmtpbmciOnsiY29tbWFuZCI6Im5weCIsImFyZ3MiOlsiLXkiLCJAbW9kZWxjb250ZXh0cHJvdG9jb2wvc2VydmVyLXNlcXVlbnRpYWwtdGhpbmtpbmciXSwiZW52Ijp7fX19fQ==&enabled=true\"\n                            class=\"deep-link\">📥 批量导入 2 个 MCP Servers</a>\n                        <button class=\"deep-link\"\n                            style=\"background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%); cursor: pointer; border: none;\"\n                            onclick=\"copyDeepLink('ccswitch://v1/import?resource=mcp&apps=claude,codex&config=eyJtY3BTZXJ2ZXJzIjp7ImNvbnRleHQ3Ijp7ImNvbW1hbmQiOiJidW54IiwiYXJncyI6WyIteSIsIkB1cHN0YXNoL2NvbnRleHQ3LW1jcCIsIi0tYXBpLWtleSIsImN0eDdzay00ZGRkNGY2Ni1lNzUyLTQwMjItYjFmNi1jOGNmNjI3OWI4MGQiXSwiZW52Ijp7fX0sInNlcXVlbnRpYWwtdGhpbmtpbmciOnsiY29tbWFuZCI6Im5weCIsImFyZ3MiOlsiLXkiLCJAbW9kZWxjb250ZXh0cHJvdG9jb2wvc2VydmVyLXNlcXVlbnRpYWwtdGhpbmtpbmciXSwiZW52Ijp7fX19fQ==&enabled=true', this)\">📋\n                            复制</button>\n                    </div>\n\n                    <!-- JSON 配置展示 -->\n                    <div class=\"code-block\"\n                        style=\"margin-top: 12px; padding: 12px; background: #2c3e50; color: #ecf0f1; border-radius: 8px; font-family: monospace; font-size: 12px; overflow-x: auto;\">\n                        <div style=\"color: #95a5a6; margin-bottom: 8px;\">📦 批量 MCP 配置 JSON:</div>\n                        <pre style=\"margin: 0; color: #a8d08d; line-height: 1.6;\">{\n  \"mcpServers\": {\n    \"context7\": {\n      \"command\": \"bunx\",\n      \"args\": [\n        \"-y\",\n        \"@upstash/context7-mcp\",\n        \"--api-key\",\n        \"ctx7sk-4ddd4f66-e752-4022-b1f6-c8cf6279b80d\"\n      ],\n      \"env\": {}\n    },\n    \"sequential-thinking\": {\n      \"command\": \"npx\",\n      \"args\": [\n        \"-y\",\n        \"@modelcontextprotocol/server-sequential-thinking\"\n      ],\n      \"env\": {}\n    }\n  }\n}</pre>\n                        <div style=\"color: #95a5a6; margin-top: 8px; padding-top: 8px; border-top: 1px solid #34495e;\">\n                            💡 <strong>批量导入说明</strong>: 一次性导入 2 个 MCP 服务器<br>\n                            📦 <strong>服务器 1</strong>: context7 - Upstash Context7 MCP 服务器<br>\n                            📦 <strong>服务器 2</strong>: sequential-thinking - 结构化思维推理服务器<br>\n                            🎯 <strong>目标应用</strong>: Claude 和 Codex<br>\n                            🔄 <strong>智能合并</strong>: 如果服务器已存在，只更新应用启用状态，不覆盖配置\n                        </div>\n                    </div>\n                </div>\n            </div>\n\n            <!-- Prompt 导入示例 -->\n            <div class=\"section\">\n                <h2>💬 Prompt 导入 <span class=\"version-badge\">v3.8+</span></h2>\n\n                <div class=\"link-card\">\n                    <h3><span class=\"app-badge badge-claude\">Claude</span> 代码审查专家</h3>\n                    <p class=\"description\">为 Claude 导入代码审查提示词。</p>\n                    <div style=\"display: flex; gap: 10px; flex-wrap: wrap;\">\n                        <a href=\"ccswitch://v1/import?resource=prompt&app=claude&name=代码审查专家&content=IyDku6PnoIHlrqHmn6XkuJPlrrYKCuS9oOaYr+S4gOS9jeaciee7j+mqjOeahOS7o+eggeWuoeafpeWRmO+8jOivt+WcqOS7o+eggeWuoeafpeWbnuWkjeeahOaXtuWAmeWBmuWQr+S4i+OAgg==&description=专注代码质量&enabled=true\"\n                            class=\"deep-link\">📥 导入</a>\n                        <button class=\"deep-link\"\n                            style=\"background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%); cursor: pointer; border: none;\"\n                            onclick=\"copyDeepLink('ccswitch://v1/import?resource=prompt&app=claude&name=代码审查专家&content=IyDku6PnoIHlrqHmn6XkuJPlrrYKCuS9oOaYr+S4gOS9jeaciee7j+mqjOeahOS7o+eggeWuoeafpeWRmO+8jOivt+WcqOS7o+eggeWuoeafpeWbnuWkjeeahOaXtuWAmeWBmuWQr+S4i+OAgg==&description=专注代码质量&enabled=true', this)\">📋\n                            复制</button>\n                    </div>\n\n                    <!-- 内容解释 -->\n                    <div class=\"code-block\"\n                        style=\"margin-top: 12px; padding: 12px; background: #2c3e50; color: #ecf0f1; border-radius: 8px; font-family: monospace; font-size: 12px; overflow-x: auto;\">\n                        <div style=\"color: #95a5a6; margin-bottom: 8px;\">📝 Prompt 内容:</div>\n                        <div style=\"color: #a8d08d; white-space: pre-wrap; line-height: 1.6;\"># 代码审查专家\n\n                            你是一位有经验的代码审查员，请在代码审查回复的时候做启下。</div>\n                        <div style=\"color: #95a5a6; margin-top: 12px; padding-top: 8px; border-top: 1px solid #34495e;\">\n                            • <strong>应用</strong>: Claude<br>\n                            • <strong>描述</strong>: 专注代码质量<br>\n                            • <strong>状态</strong>: 导入后立即启用\n                        </div>\n                    </div>\n                </div>\n            </div>\n\n            <!-- Skill 导入示例 -->\n            <div class=\"section\">\n                <h2>🛠️ Skill 仓库导入 <span class=\"version-badge\">v3.8+</span></h2>\n\n                <div class=\"link-card\">\n                    <h3>添加 Claude Skill 仓库</h3>\n                    <p class=\"description\">\n                        从 GitHub 仓库导入 Claude Skills，支持指定分支和子目录路径。\n                    </p>\n                    <div class=\"param-list\">\n                        <span class=\"param-tag\">必需</span> resource=skill, repo (owner/name)<br>\n                        <span class=\"param-tag optional\">可选</span> branch, skills_path, directory\n                    </div>\n                    <div style=\"display: flex; gap: 10px; align-items: center; flex-wrap: wrap;\">\n                        <a href=\"ccswitch://v1/import?resource=skill&repo=example/claude-skills&branch=main&skills_path=skills&directory=my-skills\"\n                            class=\"deep-link\">\n                            📥 导入 Skill 仓库示例\n                        </a>\n                        <button class=\"deep-link\"\n                            style=\"background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%); cursor: pointer; border: none;\"\n                            onclick=\"copyDeepLink('ccswitch://v1/import?resource=skill&repo=example/claude-skills&branch=main&skills_path=skills&directory=my-skills', this)\">\n                            📋 复制链接\n                        </button>\n                    </div>\n\n                    <!-- 内容解释 -->\n                    <div class=\"code-block\"\n                        style=\"margin-top: 12px; padding: 12px; background: #2c3e50; color: #ecf0f1; border-radius: 8px; font-family: monospace; font-size: 12px; overflow-x: auto;\">\n                        <div style=\"color: #95a5a6; margin-bottom: 8px;\">🗂️ 将添加以下 Skill 仓库:</div>\n                        <div style=\"color: #52c41a; margin-bottom: 4px;\">• <strong>GitHub 仓库</strong>:\n                            example/claude-skills</div>\n                        <div style=\"color: #a8d08d; margin-bottom: 4px;\">• <strong>分支</strong>: main (默认分支)</div>\n                        <div style=\"color: #a8d08d; margin-bottom: 4px;\">• <strong>Skills 路径</strong>: skills\n                            (仓库中技能文件所在的子目录)</div>\n                        <div style=\"color: #a8d08d; margin-bottom: 4px;\">• <strong>本地目录</strong>: my-skills (克隆到本地的目录名)\n                        </div>\n                        <div style=\"color: #95a5a6; margin-top: 12px; padding-top: 8px; border-top: 1px solid #34495e;\">\n                            💡 <strong>说明</strong>: 此操作会把仓库添加到 Skill 列表中。添加后，您可以在 Skills 管理界面选择安装具体的技能文件。<br>\n                            🔧 <strong>应用</strong>: Claude (Skills 功能仅支持 Claude)\n                        </div>\n                    </div>\n                </div>\n            </div>\n\n            <!--  深链接生成器 -->\n            <div class=\"section\">\n                <h2>🚀 深链接生成器</h2>\n                <p style=\"color: #7f8c8d; margin-bottom: 24px;\">\n                    填写参数信息，自动生成深链接并导入到 CC Switch\n                </p>\n                <!-- MCP Servers 生成器 -->\n                <div class=\"generator-section\">\n                    <h3 style=\"color: #2c3e50; margin-bottom: 16px;\">🔌 MCP Servers 导入生成器</h3>\n\n                    <div class=\"form-group\">\n                        <label>目标应用 *</label>\n                        <input type=\"text\" id=\"mcpApps\" placeholder=\"例如: claude,codex,gemini 或 claude\" />\n                        <small style=\"color: #7f8c8d;\">多个应用用逗号分隔</small>\n                    </div>\n\n                    <div class=\"form-group\">\n                        <label>MCP 配置 (JSON) *</label>\n                        <textarea id=\"mcpConfig\" rows=\"8\" placeholder='{\n  \"mcpServers\": {\n    \"server-name\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"@modelcontextprotocol/server-xxx\"],\n      \"type\": \"stdio\"\n    }\n  }\n}'></textarea>\n                        <small style=\"color: #7f8c8d;\">完整的 MCP 配置 JSON</small>\n                    </div>\n\n                    <div class=\"form-group\">\n                        <label>是否启用</label>\n                        <select id=\"mcpEnabled\">\n                            <option value=\"true\">是 (enabled=true)</option>\n                            <option value=\"false\">否 (enabled=false)</option>\n                        </select>\n                    </div>\n\n                    <button class=\"btn\" onclick=\"generateMcpLink()\">🎯 生成 MCP 深链接</button>\n\n                    <div id=\"mcpResult\" class=\"result-section\" style=\"display: none;\">\n                        <label>生成的深链接：</label>\n                        <div class=\"result-url\" id=\"mcpUrl\" onclick=\"selectText(this)\"></div>\n                        <div style=\"display: flex; gap: 10px; margin-top: 12px;\">\n                            <button class=\"btn\" onclick=\"copyGeneratedLink('mcpUrl')\">📋 复制链接</button>\n                            <a id=\"mcpImportBtn\" class=\"btn\"\n                                style=\"background: linear-gradient(135deg, #27ae60 0%, #229954 100%); text-decoration: none;\">\n                                📥 立即导入\n                            </a>\n                        </div>\n                    </div>\n                </div>\n\n                <!-- Prompt 生成器 -->\n                <div class=\"generator-section\">\n                    <h3 style=\"color: #2c3e50; margin-bottom: 16px;\">💬 Prompt 导入生成器</h3>\n\n                    <div class=\"form-group\">\n                        <label>目标应用 *</label>\n                        <select id=\"promptApp\">\n                            <option value=\"claude\">Claude</option>\n                            <option value=\"codex\">Codex</option>\n                            <option value=\"gemini\">Gemini</option>\n                        </select>\n                    </div>\n\n                    <div class=\"form-group\">\n                        <label>提示词名称 *</label>\n                        <input type=\"text\" id=\"promptName\" placeholder=\"例如: 代码审查专家\" />\n                    </div>\n\n                    <div class=\"form-group\">\n                        <label>提示词内容 *</label>\n                        <textarea id=\"promptContent\" rows=\"6\" placeholder=\"# 角色定义\n\n你是一位专业的...\"></textarea>\n                        <small style=\"color: #7f8c8d;\">支持 Markdown 格式，自动 Base64 编码</small>\n                    </div>\n\n                    <div class=\"form-group\">\n                        <label>描述</label>\n                        <input type=\"text\" id=\"promptDescription\" placeholder=\"例如: 专注代码质量\" />\n                    </div>\n\n                    <div class=\"form-group\">\n                        <label>导入后是否启用</label>\n                        <select id=\"promptEnabled\">\n                            <option value=\"true\">是 (将禁用其他提示词)</option>\n                            <option value=\"false\">否 (保持禁用状态)</option>\n                        </select>\n                    </div>\n\n                    <button class=\"btn\" onclick=\"generatePromptLink()\">🎯 生成 Prompt 深链接</button>\n\n                    <div id=\"promptResult\" class=\"result-section\" style=\"display: none;\">\n                        <label>生成的深链接：</label>\n                        <div class=\"result-url\" id=\"promptUrl\" onclick=\"selectText(this)\"></div>\n                        <div style=\"display: flex; gap: 10px; margin-top: 12px;\">\n                            <button class=\"btn\" onclick=\"copyGeneratedLink('promptUrl')\">📋 复制链接</button>\n                            <a id=\"promptImportBtn\" class=\"btn\"\n                                style=\"background: linear-gradient(135deg, #27ae60 0%, #229954 100%); text-decoration: none;\">\n                                📥 立即导入\n                            </a>\n                        </div>\n                    </div>\n                </div>\n\n                <!-- Skill 生成器 -->\n                <div class=\"generator-section\">\n                    <h3 style=\"color: #2c3e50; margin-bottom: 16px;\">🛠️ Skill 仓库导入生成器</h3>\n\n                    <div class=\"form-group\">\n                        <label>GitHub 仓库 *</label>\n                        <input type=\"text\" id=\"skillRepo\" placeholder=\"例如: owner/repo-name\" />\n                        <small style=\"color: #7f8c8d;\">格式: 所有者/仓库名</small>\n                    </div>\n\n                    <div class=\"form-group\">\n                        <label>分支</label>\n                        <input type=\"text\" id=\"skillBranch\" placeholder=\"main\" value=\"main\" />\n                    </div>\n\n                    <div class=\"form-group\">\n                        <label>Skills 路径</label>\n                        <input type=\"text\" id=\"skillPath\" placeholder=\"skills\" value=\"skills\" />\n                        <small style=\"color: #7f8c8d;\">仓库中技能文件所在的子目录</small>\n                    </div>\n\n                    <div class=\"form-group\">\n                        <label>本地目录名</label>\n                        <input type=\"text\" id=\"skillDirectory\" placeholder=\"my-skills\" />\n                        <small style=\"color: #7f8c8d;\">克隆到本地的目录名（可选）</small>\n                    </div>\n\n                    <button class=\"btn\" onclick=\"generateSkillLink()\">🎯 生成 Skill 深链接</button>\n\n                    <div id=\"skillResult\" class=\"result-section\" style=\"display: none;\">\n                        <label>生成的深链接：</label>\n                        <div class=\"result-url\" id=\"skillUrl\" onclick=\"selectText(this)\"></div>\n                        <div style=\"display: flex; gap: 10px; margin-top: 12px;\">\n                            <button class=\"btn\" onclick=\"copyGeneratedLink('skillUrl')\">📋 复制链接</button>\n                            <a id=\"skillImportBtn\" class=\"btn\"\n                                style=\"background: linear-gradient(135deg, #27ae60 0%, #229954 100%); text-decoration: none;\">\n                                📥 立即导入\n                            </a>\n                        </div>\n                    </div>\n                </div>\n            </div>\n\n            <!-- Base64 编解码器 -->\n            <div class=\"section\">\n                <h2>🔐 Base64 编解码器</h2>\n\n                <div class=\"generator-section\" style=\"background: #f5f5f5; border: 2px solid #95a5a6;\">\n                    <h3 style=\"color: #2c3e50; margin-bottom: 16px;\">编码器 (UTF-8 → Base64)</h3>\n                    <div class=\"form-group\">\n                        <label>原始内容（UTF-8 文本）</label>\n                        <textarea id=\"encodeInput\" rows=\"6\" placeholder=\"输入要编码的文本...&#10;支持中文、JSON、TOML 等所有 UTF-8 字符\"\n                            style=\"font-family: monospace; font-size: 13px;\"></textarea>\n                    </div>\n                    <button class=\"btn\" style=\"background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);\"\n                        onclick=\"encodeToBase64()\">\n                        🔒 编码为 Base64\n                    </button>\n                    <div id=\"encodeResult\" style=\"display: none;\" class=\"result-box\">\n                        <strong>✅ 编码结果：</strong>\n                        <div class=\"result-text\" id=\"encodeOutput\"></div>\n                        <button class=\"btn btn-copy\" onclick=\"copyEncoded()\">📋 复制结果</button>\n                        <small style=\"color: #7f8c8d; display: block; margin-top: 8px;\">\n                            💡 可直接用于深链接的 config 或 content 参数\n                        </small>\n                    </div>\n                </div>\n\n                <div class=\"generator-section\"\n                    style=\"background: #f0f9ff; border: 2px solid #3498db; margin-top: 24px;\">\n                    <h3 style=\"color: #2c3e50; margin-bottom: 16px;\">解码器 (Base64 → UTF-8)</h3>\n                    <div class=\"form-group\">\n                        <label>Base64 编码内容</label>\n                        <textarea id=\"decodeInput\" rows=\"6\"\n                            placeholder=\"粘贴 Base64 编码的文本...&#10;例如: eyJlbnYiOnsiaGVsbG8iOiJ3b3JsZCJ9fQ==\"\n                            style=\"font-family: monospace; font-size: 13px;\"></textarea>\n                    </div>\n                    <button class=\"btn\" style=\"background: linear-gradient(135deg, #27ae60 0%, #229954 100%);\"\n                        onclick=\"decodeFromBase64()\">\n                        🔓 解码为文本\n                    </button>\n                    <div id=\"decodeResult\" style=\"display: none;\" class=\"result-box\">\n                        <strong>✅ 解码结果：</strong>\n                        <div class=\"result-text\" id=\"decodeOutput\" style=\"white-space: pre-wrap;\"></div>\n                        <button class=\"btn btn-copy\" onclick=\"copyDecoded()\">📋 复制结果</button>\n                        <div id=\"jsonFormat\" style=\"display: none; margin-top: 12px;\">\n                            <button class=\"btn\" style=\"background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%);\"\n                                onclick=\"formatJson()\">\n                                ✨ 格式化 JSON\n                            </button>\n                        </div>\n                    </div>\n                </div>\n\n                <div class=\"info-box\" style=\"margin-top: 24px; background: #e8f4f8; border-left: 4px solid #3498db;\">\n                    <h4 style=\"color: #2c3e50;\">💡 使用建议</h4>\n                    <ul style=\"color: #2c3e50;\">\n                        <li><strong>编码配置文件</strong>：将 JSON 或 TOML 内容编码后用于 config 参数</li>\n                        <li><strong>编码 Prompt</strong>：将 Markdown 提示词内容编码后用于 content 参数</li>\n                        <li><strong>验证深链接</strong>：解码验证深链接中的配置内容是否正确</li>\n                        <li><strong>UTF-8 支持</strong>：完整支持中文及其他 Unicode 字符</li>\n                    </ul>\n                </div>\n            </div>\n\n            <!-- 注意事项 -->\n            <div class=\"info-box\">\n                <h4>⚠️ 使用注意事项</h4>\n                <ul>\n                    <li><strong>首次点击</strong>：浏览器会询问是否允许打开 CC Switch，请点击\"允许\"或\"打开\"</li>\n                    <li><strong>macOS 用户</strong>：可能需要在\"系统设置\" → \"隐私与安全性\"中允许应用</li>\n                    <li><strong>测试 API Key</strong>：示例中的 API Key 仅用于测试格式，无法实际使用</li>\n                    <li><strong>导入确认</strong>：点击链接后会弹出确认对话框，API Key 会被掩码显示（前4位+****）</li>\n                    <li><strong>编辑配置</strong>：导入后可以在 CC Switch 中随时编辑或删除配置</li>\n                </ul>\n            </div>\n\n            <!-- 深链接解析器 -->\n            <div class=\"generator-section\" style=\"background: #f0f9ff; border: 2px solid #3498db;\">\n                <h2>🔍 深链接解析器</h2>\n                <p style=\"color: #7f8c8d; margin-bottom: 24px;\">粘贴深链接 URL，查看解析结果</p>\n\n                <div class=\"form-group\">\n                    <label>深链接 URL</label>\n                    <textarea id=\"parseUrl\" rows=\"3\" placeholder=\"粘贴完整的 ccswitch:// 深链接...\"\n                        style=\"font-family: monospace; font-size: 13px;\"></textarea>\n                </div>\n\n                <button class=\"btn\" style=\"background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);\"\n                    onclick=\"parseDeepLink()\">\n                    🔍 解析深链接\n                </button>\n\n                <!-- 解析结果 -->\n                <div id=\"parseResult\" style=\"display: none;\">\n                    <div class=\"result-box\" style=\"margin-top: 20px;\">\n                        <strong>✅ 解析结果：</strong>\n\n                        <!-- 基本信息 -->\n                        <div id=\"parseBasicInfo\" style=\"margin-top: 16px;\"></div>\n\n                        <!-- URL 参数 -->\n                        <div id=\"parseUrlParams\" style=\"margin-top: 16px;\"></div>\n\n                        <!-- 配置文件解析 -->\n                        <div id=\"parseConfigContent\" style=\"margin-top: 16px;\"></div>\n                    </div>\n                </div>\n            </div>\n\n            <!-- 深链接生成器 -->\n            <div class=\"generator-section\">\n                <h2>🛠️ 深链接生成器</h2>\n                <p style=\"color: #7f8c8d; margin-bottom: 24px;\">填写下方表单，生成您自己的深链接</p>\n\n                <!-- 导入模式切换 -->\n                <div class=\"form-group\">\n                    <label>导入模式</label>\n                    <select id=\"importMode\" onchange=\"toggleImportMode()\">\n                        <option value=\"url\">URL 参数模式（传统）</option>\n                        <option value=\"config\">配置文件模式（v3.8+）</option>\n                    </select>\n                    <small style=\"color: #7f8c8d; font-size: 12px; display: block; margin-top: 4px;\">\n                        URL 参数模式：直接在 URL 中传递参数 | 配置文件模式：使用 Base64 编码的 JSON/TOML\n                    </small>\n                </div>\n\n                <div class=\"form-group\">\n                    <label>应用类型 *</label>\n                    <select id=\"app\" onchange=\"updateModelFields()\">\n                        <option value=\"claude\">Claude Code</option>\n                        <option value=\"codex\">Codex</option>\n                        <option value=\"gemini\">Gemini</option>\n                    </select>\n                </div>\n\n                <div class=\"form-group\">\n                    <label>供应商名称 *</label>\n                    <input type=\"text\" id=\"name\" placeholder=\"例如: Claude Official\">\n                    <small style=\"color: #e74c3c; font-size: 12px; display: block; margin-top: 4px;\">\n                        ⚠️ 唯一必填项\n                    </small>\n                </div>\n\n                <!-- URL 参数模式字段 -->\n                <div id=\"urlModeFields\">\n                    <div class=\"form-group\">\n                        <label>官网地址</label>\n                        <input type=\"url\" id=\"homepage\" placeholder=\"https://example.com（可选，配置文件模式可自动推断）\">\n                    </div>\n\n                    <div class=\"form-group\">\n                        <label>API 端点</label>\n                        <input type=\"url\" id=\"endpoint\" placeholder=\"https://api.example.com/v1（配置文件模式可从配置提取）\">\n                        <small style=\"color: #7f8c8d; font-size: 11px; display: block; margin-top: 4px;\">\n                            主 API 端点地址\n                        </small>\n                    </div>\n\n                    <div class=\"form-group\">\n                        <label>备用 API 端点（可选，逗号分隔）</label>\n                        <input type=\"text\" id=\"extraEndpoints\" placeholder=\"https://api2.example.com/v1, https://api3.example.com/v1\">\n                        <small style=\"color: #7f8c8d; font-size: 11px; display: block; margin-top: 4px;\">\n                            多个备用端点用逗号分隔，导入后自动添加为自定义端点\n                        </small>\n                    </div>\n\n                    <div class=\"form-group\">\n                        <label>API Key</label>\n                        <input type=\"text\" id=\"apiKey\" placeholder=\"sk-xxxxx 或 AIzaSyXXXXX（配置文件模式可从配置提取）\">\n                    </div>\n                </div>\n\n                <!-- 配置文件模式字段 -->\n                <div id=\"configModeFields\" style=\"display: none;\">\n                    <!-- 通用/Claude/Gemini JSON 配置 -->\n                    <div id=\"generalConfigGroup\" class=\"form-group\">\n                        <label id=\"configJsonLabel\">配置文件内容（JSON）</label>\n                        <textarea id=\"configJson\" rows=\"12\"\n                            placeholder='输入 JSON 配置，例如：&#10;{&#10;  \"env\": {&#10;    \"ANTHROPIC_AUTH_TOKEN\": \"sk-ant-xxx\",&#10;    \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com/v1\",&#10;    \"ANTHROPIC_MODEL\": \"claude-sonnet-4.5\"&#10;  }&#10;}'></textarea>\n                        <small style=\"color: #7f8c8d; font-size: 12px; display: block; margin-top: 4px;\">\n                            配置文件将自动进行 Base64 编码\n                        </small>\n                    </div>\n\n                    <!-- Codex 专用配置字段 -->\n                    <div id=\"codexConfigGroup\" style=\"display: none;\">\n                        <div class=\"form-group\">\n                            <label>Codex 认证信息 (JSON)</label>\n                            <textarea id=\"codexAuthJson\" rows=\"5\"\n                                placeholder='{&#10;  \"auth\": {&#10;    \"OPENAI_API_KEY\": \"sk-...\"&#10;  }&#10;}'></textarea>\n                            <small style=\"color: #7f8c8d; font-size: 12px; display: block; margin-top: 4px;\">\n                                包含 API Key 的认证信息 JSON 对象\n                            </small>\n                        </div>\n                        <div class=\"form-group\">\n                            <label>Codex 配置文件 (TOML)</label>\n                            <textarea id=\"codexConfigToml\" rows=\"10\"\n                                placeholder='[model_providers.openai]&#10;base_url = \"...\"&#10;&#10;[general]&#10;model = \"...\"'\n                                style=\"font-family: monospace;\"></textarea>\n                            <small style=\"color: #7f8c8d; font-size: 12px; display: block; margin-top: 4px;\">\n                                config.toml 的原始内容\n                            </small>\n                        </div>\n                    </div>\n\n                    <div class=\"form-group\">\n                        <label>URL 参数覆盖（可选）</label>\n                        <div style=\"background: #fff3cd; padding: 12px; border-radius: 8px; margin-bottom: 8px;\">\n                            <p style=\"font-size: 12px; color: #856404; margin: 0;\">\n                                💡 可以在下方填写 API Key、端点等参数来覆盖配置文件中的值。留空则完全使用配置文件。\n                            </p>\n                        </div>\n                        <input type=\"text\" id=\"overrideApiKey\" placeholder=\"覆盖配置文件中的 API Key（可选）\">\n                        <input type=\"url\" id=\"overrideEndpoint\" placeholder=\"覆盖配置文件中的端点（可选）\" style=\"margin-top: 8px;\">\n                    </div>\n                </div>\n\n                <div class=\"form-group\">\n                    <label>默认模型（可选）</label>\n                    <input type=\"text\" id=\"model\" placeholder=\"例如: claude-haiku-4.1, gpt-5.1, gemini-3-pro-preview\">\n                    <small style=\"color: #7f8c8d; font-size: 12px; display: block; margin-top: 4px;\">\n                        通用模型字段，适用于所有应用类型\n                    </small>\n                </div>\n\n                <!-- Claude 专用字段 -->\n                <div id=\"claudeFields\" style=\"display: block;\">\n                    <div style=\"background: #e8f4f8; padding: 16px; border-radius: 8px; margin: 16px 0;\">\n                        <h4 style=\"color: #2c3e50; margin-bottom: 12px; font-size: 14px;\">\n                            📋 Claude 专用模型字段（可选）\n                        </h4>\n                        <p style=\"color: #7f8c8d; font-size: 12px; margin-bottom: 12px;\">\n                            可以根据需要设置特定的模型字段，这些字段仅在 Claude 应用中生效\n                        </p>\n\n                        <div class=\"form-group\">\n                            <label>Haiku 模型</label>\n                            <input type=\"text\" id=\"haikuModel\" placeholder=\"claude-haiku-4.1\">\n                            <small style=\"color: #7f8c8d; font-size: 11px; display: block; margin-top: 4px;\">\n                                对应环境变量：ANTHROPIC_DEFAULT_HAIKU_MODEL\n                            </small>\n                        </div>\n\n                        <div class=\"form-group\">\n                            <label>Sonnet 模型</label>\n                            <input type=\"text\" id=\"sonnetModel\" placeholder=\"claude-sonnet-4.5\">\n                            <small style=\"color: #7f8c8d; font-size: 11px; display: block; margin-top: 4px;\">\n                                对应环境变量：ANTHROPIC_DEFAULT_SONNET_MODEL\n                            </small>\n                        </div>\n\n                        <div class=\"form-group\">\n                            <label>Opus 模型</label>\n                            <input type=\"text\" id=\"opusModel\" placeholder=\"claude-opus-4\">\n                            <small style=\"color: #7f8c8d; font-size: 11px; display: block; margin-top: 4px;\">\n                                对应环境变量：ANTHROPIC_DEFAULT_OPUS_MODEL\n                            </small>\n                        </div>\n                    </div>\n                </div>\n\n                <div class=\"form-group\">\n                    <label>图标（可选）</label>\n                    <input type=\"text\" id=\"icon\" placeholder=\"例如: openai, anthropic, google\">\n                    <small style=\"color: #7f8c8d; font-size: 12px; display: block; margin-top: 4px;\">\n                        图标名称，用于在界面中显示\n                    </small>\n                </div>\n\n                <div class=\"form-group\">\n                    <label>导入后是否设为当前供应商</label>\n                    <select id=\"enabled\">\n                        <option value=\"true\">是 (立即切换到此供应商)</option>\n                        <option value=\"false\">否 (仅添加，不切换)</option>\n                    </select>\n                </div>\n\n                <div class=\"form-group\">\n                    <label>备注（可选）</label>\n                    <textarea id=\"notes\" rows=\"2\" placeholder=\"例如: 公司专用账号\"></textarea>\n                </div>\n\n                <!-- 用量查询配置 (v3.9+) -->\n                <div style=\"background: #f0fff4; padding: 16px; border-radius: 8px; margin: 16px 0; border: 2px solid #27ae60;\">\n                    <h4 style=\"color: #27ae60; margin-bottom: 12px; font-size: 14px;\">\n                        📊 用量查询配置（v3.9+，可选）\n                    </h4>\n                    <p style=\"color: #7f8c8d; font-size: 12px; margin-bottom: 12px;\">\n                        配置用量查询脚本，可自动查询 API 余额\n                    </p>\n\n                    <div class=\"form-group\">\n                        <label>启用用量查询</label>\n                        <select id=\"usageEnabled\">\n                            <option value=\"\">不配置</option>\n                            <option value=\"true\">启用</option>\n                            <option value=\"false\">禁用</option>\n                        </select>\n                    </div>\n\n                    <div class=\"form-group\">\n                        <label>用量查询 Base URL</label>\n                        <input type=\"url\" id=\"usageBaseUrl\" placeholder=\"https://example.com（用于用量查询的基础 URL）\">\n                        <small style=\"color: #7f8c8d; font-size: 11px; display: block; margin-top: 4px;\">\n                            用量查询接口的基础地址，必须与脚本中的请求 URL 同源\n                        </small>\n                    </div>\n\n                    <div class=\"form-group\">\n                        <label>用量查询专用 API Key（可选）</label>\n                        <input type=\"text\" id=\"usageApiKey\" placeholder=\"留空则使用供应商的 API Key\">\n                    </div>\n\n                    <div class=\"form-group\">\n                        <label>用量查询脚本</label>\n                        <textarea id=\"usageScript\" rows=\"12\" style=\"font-family: monospace; font-size: 12px;\"\n                            placeholder='({\n  request: {\n    url: \"{{baseUrl}}/api/v1/user/subscription-info\",\n    method: \"GET\",\n    headers: { \"Authorization\": \"{{apiKey}}\" }\n  },\n  extractor: function(response) {\n    const data = typeof response === \"string\" ? JSON.parse(response) : response;\n    return {\n      isValid: true,\n      remaining: data.balance ?? 0,\n      unit: \"USD\"\n    };\n  }\n})'></textarea>\n                        <small style=\"color: #7f8c8d; font-size: 11px; display: block; margin-top: 4px;\">\n                            支持模板变量：{{baseUrl}}、{{apiKey}}、{{accessToken}}、{{userId}}\n                        </small>\n                    </div>\n\n                    <div class=\"form-group\">\n                        <label>自动查询间隔（分钟）</label>\n                        <input type=\"number\" id=\"usageAutoInterval\" placeholder=\"30\" min=\"0\">\n                        <small style=\"color: #7f8c8d; font-size: 11px; display: block; margin-top: 4px;\">\n                            0 表示禁用自动查询\n                        </small>\n                    </div>\n\n                    <div class=\"form-group\">\n                        <label>Access Token（可选）</label>\n                        <input type=\"text\" id=\"usageAccessToken\" placeholder=\"某些 API 需要的访问令牌\">\n                    </div>\n\n                    <div class=\"form-group\">\n                        <label>User ID（可选）</label>\n                        <input type=\"text\" id=\"usageUserId\" placeholder=\"某些 API 需要的用户 ID\">\n                    </div>\n                </div>\n\n                <button class=\"btn\" onclick=\"generateLink()\">🚀 生成深链接</button>\n\n                <div id=\"result\" style=\"display: none;\">\n                    <div class=\"result-box\">\n                        <strong>✅ 生成的深链接：</strong>\n                        <div class=\"result-text\" id=\"linkText\"></div>\n                        <button class=\"btn btn-copy\" onclick=\"copyLink()\">📋 复制链接</button>\n                        <a id=\"testLink\" class=\"deep-link\" style=\"text-decoration: none;\">\n                            🧪 测试链接\n                        </a>\n                    </div>\n                </div>\n            </div>\n        </div>\n    </div>\n\n    <script>\n        // UTF-8 字符串转 Base64\n        function utf8_to_b64(str) {\n            try {\n                const bytes = new TextEncoder().encode(str);\n                const binString = Array.from(bytes, (byte) => String.fromCharCode(byte)).join(\"\");\n                return btoa(binString);\n            } catch (e) {\n                console.error(\"Base64 encode error:\", e);\n                return window.btoa(unescape(encodeURIComponent(str)));\n            }\n        }\n\n        // Base64 转 UTF-8 字符串\n        function b64_to_utf8(str) {\n            try {\n                const binString = atob(str);\n                const bytes = Uint8Array.from(binString, (m) => m.codePointAt(0));\n                return new TextDecoder().decode(bytes);\n            } catch (e) {\n                console.error(\"Base64 decode error:\", e);\n                return decodeURIComponent(escape(window.atob(str)));\n            }\n        }\n\n        // 切换导入模式\n        function toggleImportMode() {\n            const mode = document.getElementById('importMode').value;\n            const urlFields = document.getElementById('urlModeFields');\n            const configFields = document.getElementById('configModeFields');\n\n            if (mode === 'url') {\n                urlFields.style.display = 'block';\n                configFields.style.display = 'none';\n            } else {\n                urlFields.style.display = 'none';\n                configFields.style.display = 'block';\n                // 当切换到配置文件模式时，自动填充示例配置\n                populateConfigTemplate();\n            }\n        }\n\n        // ... (rest of the functions) ...\n\n        // 根据应用类型填充配置模板\n        function populateConfigTemplate() {\n            const app = document.getElementById('app').value;\n            const configTextarea = document.getElementById('configJson');\n            const codexAuthTextarea = document.getElementById('codexAuthJson');\n            const codexConfigTextarea = document.getElementById('codexConfigToml');\n\n            let template = '';\n\n            if (app === 'claude') {\n                template = `{\n  \"env\": {\n    \"ANTHROPIC_AUTH_TOKEN\": \"sk-ant-your-api-key-here\",\n    \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com/v1\",\n    \"ANTHROPIC_MODEL\": \"claude-sonnet-4.5\",\n    \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"claude-haiku-4.1\",\n    \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"claude-sonnet-4.5\",\n    \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"claude-opus-4\"\n  }\n}`;\n                configTextarea.value = template;\n            } else if (app === 'codex') {\n                // Codex 分开填充\n                codexAuthTextarea.value = `{\n  \"auth\": {\n    \"OPENAI_API_KEY\": \"sk-proj-your-api-key-here\"\n  }\n}`;\n                codexConfigTextarea.value = `model_provider = \"custom\"\nmodel = \"gpt-5.1\"\nmodel_reasoning_effort = \"high\"\ndisable_response_storage = true\n\n[model_providers.custom]\nname = \"custom\"\nbase_url = \"https://api.openai.com/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true`;\n            } else if (app === 'gemini') {\n                template = `{\n  \"GEMINI_API_KEY\": \"AIzaSy-your-api-key-here\",\n  \"GEMINI_BASE_URL\": \"https://generativelanguage.googleapis.com/v1beta\",\n  \"GEMINI_MODEL\": \"gemini-3-pro-preview\"\n}`;\n                configTextarea.value = template;\n            }\n        }\n\n        // 更新模型字段显示\n        function updateModelFields() {\n            const app = document.getElementById('app').value;\n            const claudeFields = document.getElementById('claudeFields');\n            const generalConfigGroup = document.getElementById('generalConfigGroup');\n            const codexConfigGroup = document.getElementById('codexConfigGroup');\n            const mode = document.getElementById('importMode').value;\n\n            // Claude 字段显示控制\n            if (app === 'claude') {\n                claudeFields.style.display = 'block';\n            } else {\n                claudeFields.style.display = 'none';\n            }\n\n            // 配置文件输入框控制\n            if (mode === 'config') {\n                if (app === 'codex') {\n                    generalConfigGroup.style.display = 'none';\n                    codexConfigGroup.style.display = 'block';\n                } else {\n                    generalConfigGroup.style.display = 'block';\n                    codexConfigGroup.style.display = 'none';\n                }\n                populateConfigTemplate();\n            }\n        }\n\n        function generateLink() {\n            const mode = document.getElementById('importMode').value;\n            const app = document.getElementById('app').value;\n            const name = document.getElementById('name').value.trim();\n\n            // 验证必填字段（只有名称是必填的）\n            if (!name) {\n                alert('❌ 请填写供应商名称！');\n                return;\n            }\n\n            // 构建基础参数\n            const params = new URLSearchParams({\n                resource: 'provider',\n                app: app,\n                name: name\n            });\n\n            if (mode === 'url') {\n                // URL 参数模式\n                const homepage = document.getElementById('homepage').value.trim();\n                const endpoint = document.getElementById('endpoint').value.trim();\n                const apiKey = document.getElementById('apiKey').value.trim();\n                const model = document.getElementById('model').value.trim();\n                const icon = document.getElementById('icon').value.trim();\n                const enabled = document.getElementById('enabled').value;\n                const notes = document.getElementById('notes').value.trim();\n\n                // Claude 专用字段\n                const haikuModel = document.getElementById('haikuModel').value.trim();\n                const sonnetModel = document.getElementById('sonnetModel').value.trim();\n                const opusModel = document.getElementById('opusModel').value.trim();\n\n                // URL 模式下，至少需要 endpoint 和 apiKey\n                if (!endpoint || !apiKey) {\n                    alert('❌ URL 参数模式下，端点和 API Key 是必填的！');\n                    return;\n                }\n\n                // 验证 URL 格式\n                if (homepage) {\n                    try {\n                        new URL(homepage);\n                    } catch (e) {\n                        alert('❌ 请输入有效的官网 URL 格式（需包含 http:// 或 https://）！');\n                        return;\n                    }\n                }\n\n                try {\n                    new URL(endpoint);\n                } catch (e) {\n                    alert('❌ 请输入有效的端点 URL 格式（需包含 http:// 或 https://）！');\n                    return;\n                }\n\n                // 添加参数\n                if (homepage) params.append('homepage', homepage);\n\n                // 合并主端点和备用端点\n                const extraEndpoints = document.getElementById('extraEndpoints').value.trim();\n                let fullEndpoint = endpoint;\n                if (extraEndpoints) {\n                    const extras = extraEndpoints.split(',').map(e => e.trim()).filter(e => e);\n                    if (extras.length > 0) {\n                        fullEndpoint = endpoint + ',' + extras.join(',');\n                    }\n                }\n                params.append('endpoint', fullEndpoint);\n\n                params.append('apiKey', apiKey);\n                if (model) params.append('model', model);\n                if (icon) params.append('icon', icon);\n                if (enabled) params.append('enabled', enabled);\n                if (notes) params.append('notes', notes);\n\n                // 添加 Claude 专用模型字段\n                if (app === 'claude') {\n                    if (haikuModel) params.append('haikuModel', haikuModel);\n                    if (sonnetModel) params.append('sonnetModel', sonnetModel);\n                    if (opusModel) params.append('opusModel', opusModel);\n                }\n            } else {\n                // 配置文件模式\n                let configJson = '';\n\n                if (app === 'codex') {\n                    // Codex 特殊处理：合并 Auth JSON 和 Config TOML\n                    const authJson = document.getElementById('codexAuthJson').value.trim();\n                    const configToml = document.getElementById('codexConfigToml').value.trim();\n\n                    if (!authJson) {\n                        alert('❌ 请填写 Codex 认证信息 (JSON)！');\n                        return;\n                    }\n                    if (!configToml) {\n                        alert('❌ 请填写 Codex 配置文件 (TOML)！');\n                        return;\n                    }\n\n                    try {\n                        const authObj = JSON.parse(authJson);\n                        // 构造最终对象\n                        const finalObj = {\n                            ...authObj,\n                            config: configToml\n                        };\n                        configJson = JSON.stringify(finalObj);\n                    } catch (e) {\n                        alert('❌ Codex 认证信息不是有效的 JSON 格式：' + e.message);\n                        return;\n                    }\n                } else {\n                    // 其他应用使用通用 JSON 输入框\n                    configJson = document.getElementById('configJson').value.trim();\n                    if (!configJson) {\n                        alert('❌ 配置文件模式下，请填写配置文件内容！');\n                        return;\n                    }\n                    // 验证 JSON 格式\n                    try {\n                        JSON.parse(configJson);\n                    } catch (e) {\n                        alert('❌ 配置文件不是有效的 JSON 格式：' + e.message);\n                        return;\n                    }\n                }\n\n                const overrideApiKey = document.getElementById('overrideApiKey').value.trim();\n                const overrideEndpoint = document.getElementById('overrideEndpoint').value.trim();\n                const model = document.getElementById('model').value.trim();\n                const icon = document.getElementById('icon').value.trim();\n                const enabled = document.getElementById('enabled').value;\n                const notes = document.getElementById('notes').value.trim();\n\n                // Claude 专用字段\n                const haikuModel = document.getElementById('haikuModel').value.trim();\n                const sonnetModel = document.getElementById('sonnetModel').value.trim();\n                const opusModel = document.getElementById('opusModel').value.trim();\n\n                // Base64 编码配置文件\n                const configB64 = utf8_to_b64(configJson);\n                params.append('config', configB64);\n                params.append('configFormat', 'json');\n\n                // 添加覆盖参数\n                if (overrideApiKey) params.append('apiKey', overrideApiKey);\n                if (overrideEndpoint) {\n                    try {\n                        new URL(overrideEndpoint);\n                        params.append('endpoint', overrideEndpoint);\n                    } catch (e) {\n                        alert('❌ 覆盖端点 URL 格式无效！');\n                        return;\n                    }\n                }\n\n                if (model) params.append('model', model);\n                if (icon) params.append('icon', icon);\n                if (enabled) params.append('enabled', enabled);\n                if (notes) params.append('notes', notes);\n\n                // 添加 Claude 专用模型字段\n                if (app === 'claude') {\n                    if (haikuModel) params.append('haikuModel', haikuModel);\n                    if (sonnetModel) params.append('sonnetModel', sonnetModel);\n                    if (opusModel) params.append('opusModel', opusModel);\n                }\n            }\n\n            // 添加用量查询参数 (v3.9+)\n            const usageEnabled = document.getElementById('usageEnabled').value;\n            const usageBaseUrl = document.getElementById('usageBaseUrl').value.trim();\n            const usageApiKey = document.getElementById('usageApiKey').value.trim();\n            const usageScript = document.getElementById('usageScript').value.trim();\n            const usageAutoInterval = document.getElementById('usageAutoInterval').value.trim();\n            const usageAccessToken = document.getElementById('usageAccessToken').value.trim();\n            const usageUserId = document.getElementById('usageUserId').value.trim();\n\n            if (usageEnabled) params.append('usageEnabled', usageEnabled);\n            if (usageBaseUrl) params.append('usageBaseUrl', usageBaseUrl);\n            if (usageApiKey) params.append('usageApiKey', usageApiKey);\n            if (usageScript) {\n                // URL-safe Base64 编码\n                const scriptB64 = utf8_to_b64(usageScript)\n                    .replace(/\\+/g, '-')\n                    .replace(/\\//g, '_')\n                    .replace(/=+$/, '');\n                params.append('usageScript', scriptB64);\n            }\n            if (usageAutoInterval) params.append('usageAutoInterval', usageAutoInterval);\n            if (usageAccessToken) params.append('usageAccessToken', usageAccessToken);\n            if (usageUserId) params.append('usageUserId', usageUserId);\n\n            const deepLink = `ccswitch://v1/import?${params.toString()}`;\n\n            // 显示结果\n            document.getElementById('linkText').textContent = deepLink;\n            document.getElementById('testLink').href = deepLink;\n            document.getElementById('result').style.display = 'block';\n\n            // 滚动到结果区域\n            document.getElementById('result').scrollIntoView({\n                behavior: 'smooth',\n                block: 'nearest'\n            });\n        }\n\n        function copyLink() {\n            const linkText = document.getElementById('linkText').textContent;\n\n            navigator.clipboard.writeText(linkText).then(() => {\n                const btn = event.target;\n                const originalText = btn.textContent;\n                btn.textContent = '✅ 已复制！';\n                btn.style.background = 'linear-gradient(135deg, #27ae60 0%, #229954 100%)';\n\n                setTimeout(() => {\n                    btn.textContent = originalText;\n                    btn.style.background = '';\n                }, 2000);\n            }).catch(err => {\n                console.error('复制失败:', err);\n                alert('❌ 复制失败，请手动复制链接');\n            });\n        }\n\n        // 复制深链接\n        function copyDeepLink(url, button) {\n            navigator.clipboard.writeText(url).then(() => {\n                const originalText = button.textContent;\n                button.textContent = '✅ 已复制！';\n                button.style.background = 'linear-gradient(135deg, #27ae60 0%, #229954 100%)';\n\n                setTimeout(() => {\n                    button.textContent = originalText;\n                    button.style.background = '';\n                }, 2000);\n            }).catch(err => {\n                console.error('复制失败:', err);\n                alert('❌ 复制失败，请手动复制链接');\n            });\n        }\n\n        // 深链接解析器\n        function parseDeepLink() {\n            const urlInput = document.getElementById('parseUrl').value.trim();\n\n            if (!urlInput) {\n                alert('❌ 请输入深链接 URL！');\n                return;\n            }\n\n            try {\n                // 解析 URL\n                const url = new URL(urlInput);\n\n                // 验证协议\n                if (url.protocol !== 'ccswitch:') {\n                    alert('❌ 无效的深链接协议！必须以 ccswitch:// 开头');\n                    return;\n                }\n\n                // 提取版本和路径\n                const version = url.hostname;\n                const path = url.pathname;\n\n                // 解析查询参数\n                const params = new URLSearchParams(url.search);\n                const paramsObj = {};\n                params.forEach((value, key) => {\n                    paramsObj[key] = value;\n                });\n\n                // 构建基本信息 HTML\n                let basicInfoHtml = `\n                    <div style=\"background: #e8f4f8; padding: 16px; border-radius: 8px; border-left: 4px solid #3498db;\">\n                        <h4 style=\"margin-bottom: 12px; color: #2c3e50;\">📋 基本信息</h4>\n                        <div style=\"display: grid; grid-template-columns: 120px 1fr; gap: 8px; font-size: 14px;\">\n                            <div style=\"color: #7f8c8d;\">协议版本:</div>\n                            <div style=\"font-weight: 600; color: #2c3e50;\">${version}</div>\n                            <div style=\"color: #7f8c8d;\">路径:</div>\n                            <div style=\"font-weight: 600; color: #2c3e50;\">${path}</div>\n                            <div style=\"color: #7f8c8d;\">资源类型:</div>\n                            <div style=\"font-weight: 600; color: #2c3e50;\">${paramsObj.resource || '-'}</div>\n                            <div style=\"color: #7f8c8d;\">应用类型:</div>\n                            <div style=\"font-weight: 600; color: #2c3e50; text-transform: capitalize;\">${paramsObj.app || '-'}</div>\n                            <div style=\"color: #7f8c8d;\">供应商名称:</div>\n                            <div style=\"font-weight: 600; color: #2c3e50;\">${paramsObj.name || '-'}</div>\n                        </div>\n                    </div>\n                `;\n\n                // 构建 URL 参数 HTML\n                let urlParamsHtml = `\n                    <div style=\"background: #fff3cd; padding: 16px; border-radius: 8px; border-left: 4px solid #ffc107;\">\n                        <h4 style=\"margin-bottom: 12px; color: #856404;\">🔗 URL 参数</h4>\n                        <div style=\"display: grid; grid-template-columns: 150px 1fr; gap: 8px; font-size: 13px;\">\n                `;\n\n                // 常规参数（排除 endpoint，单独处理）\n                const regularParams = ['homepage', 'apiKey', 'model', 'notes'];\n                regularParams.forEach(key => {\n                    if (paramsObj[key]) {\n                        let displayValue = paramsObj[key];\n                        // API Key 掩码处理\n                        if (key === 'apiKey') {\n                            displayValue = displayValue.substring(0, 10) + '****';\n                        }\n                        urlParamsHtml += `\n                            <div style=\"color: #856404; font-weight: 500;\">${key}:</div>\n                            <div style=\"color: #856404; word-break: break-all; font-family: monospace; font-size: 12px;\">${displayValue}</div>\n                        `;\n                    }\n                });\n\n                // 单独处理 endpoint（支持多端点）\n                if (paramsObj.endpoint) {\n                    const endpoints = paramsObj.endpoint.split(',').map(e => e.trim()).filter(e => e);\n                    if (endpoints.length === 1) {\n                        // 单个端点\n                        urlParamsHtml += `\n                            <div style=\"color: #856404; font-weight: 500;\">endpoint:</div>\n                            <div style=\"color: #856404; word-break: break-all; font-family: monospace; font-size: 12px;\">${endpoints[0]}</div>\n                        `;\n                    } else {\n                        // 多个端点\n                        urlParamsHtml += `\n                            <div style=\"color: #856404; font-weight: 500;\">endpoints:</div>\n                            <div style=\"color: #856404; word-break: break-all; font-family: monospace; font-size: 12px;\">\n                        `;\n                        endpoints.forEach((ep, idx) => {\n                            const label = idx === 0 ? '🔹 主端点' : `└ 备用 ${idx}`;\n                            urlParamsHtml += `\n                                <div style=\"margin-bottom: 4px; ${idx === 0 ? 'font-weight: 600;' : 'color: #a08040;'}\">\n                                    ${label}: ${ep}\n                                </div>\n                            `;\n                        });\n                        urlParamsHtml += `\n                                <div style=\"margin-top: 8px; padding: 6px 10px; background: #fff8e1; border-radius: 4px; font-size: 11px; color: #856404;\">\n                                    💡 共 ${endpoints.length} 个端点，第一个为主端点，其余为备用端点\n                                </div>\n                            </div>\n                        `;\n                    }\n                }\n\n                // Claude 专用模型参数\n                const claudeModelParams = ['haikuModel', 'sonnetModel', 'opusModel'];\n                claudeModelParams.forEach(key => {\n                    if (paramsObj[key]) {\n                        urlParamsHtml += `\n                            <div style=\"color: #856404; font-weight: 500;\">${key}:</div>\n                            <div style=\"color: #856404; word-break: break-all; font-family: monospace; font-size: 12px;\">${paramsObj[key]}</div>\n                        `;\n                    }\n                });\n\n                urlParamsHtml += '</div></div>';\n\n                // 配置文件解析\n                let configHtml = '';\n                if (paramsObj.config) {\n                    try {\n                        // 解码 Base64\n                        const decoded = b64_to_utf8(paramsObj.config);\n                        const configObj = JSON.parse(decoded);\n\n                        configHtml = `\n                            <div style=\"background: #d1ecf1; padding: 16px; border-radius: 8px; border-left: 4px solid #17a2b8;\">\n                                <h4 style=\"margin-bottom: 12px; color: #0c5460;\">📄 配置文件内容 (${paramsObj.configFormat?.toUpperCase() || 'JSON'})</h4>\n                        `;\n\n                        // 根据应用类型解析配置\n                        if (paramsObj.app === 'claude') {\n                            const env = configObj.env || {};\n                            configHtml += `\n                                <div style=\"background: #fff; padding: 12px; border-radius: 6px; margin-bottom: 12px;\">\n                                    <div style=\"font-size: 12px; color: #0c5460; margin-bottom: 8px; font-weight: 600;\">Claude 环境变量:</div>\n                                    <div style=\"display: grid; grid-template-columns: 250px 1fr; gap: 6px; font-size: 12px; font-family: monospace;\">\n                            `;\n                            Object.keys(env).forEach(key => {\n                                let value = env[key];\n                                if (key.includes('TOKEN') || key.includes('KEY')) {\n                                    value = value.substring(0, 10) + '****';\n                                }\n                                configHtml += `\n                                    <div style=\"color: #0c5460; font-weight: 500;\">${key}:</div>\n                                    <div style=\"color: #0c5460;\">${value}</div>\n                                `;\n                            });\n                            configHtml += '</div></div>';\n                        } else if (paramsObj.app === 'codex') {\n                            const auth = configObj.auth || {};\n                            const config = configObj.config || '';\n\n                            configHtml += `\n                                <div style=\"background: #fff; padding: 12px; border-radius: 6px; margin-bottom: 12px;\">\n                                    <div style=\"font-size: 12px; color: #0c5460; margin-bottom: 8px; font-weight: 600;\">Codex 认证信息:</div>\n                                    <div style=\"display: grid; grid-template-columns: 200px 1fr; gap: 6px; font-size: 12px; font-family: monospace;\">\n                            `;\n                            Object.keys(auth).forEach(key => {\n                                let value = auth[key];\n                                if (key.includes('KEY')) {\n                                    value = value.substring(0, 10) + '****';\n                                }\n                                configHtml += `\n                                    <div style=\"color: #0c5460; font-weight: 500;\">${key}:</div>\n                                    <div style=\"color: #0c5460;\">${value}</div>\n                                `;\n                            });\n                            configHtml += '</div></div>';\n\n                            if (config) {\n                                configHtml += `\n                                    <div style=\"background: #fff; padding: 12px; border-radius: 6px;\">\n                                        <div style=\"font-size: 12px; color: #0c5460; margin-bottom: 8px; font-weight: 600;\">TOML 配置:</div>\n                                        <pre style=\"margin: 0; font-size: 11px; color: #0c5460; white-space: pre-wrap; word-break: break-all;\">${config}</pre>\n                                    </div>\n                                `;\n                            }\n                        } else if (paramsObj.app === 'gemini') {\n                            configHtml += `\n                                <div style=\"background: #fff; padding: 12px; border-radius: 6px;\">\n                                    <div style=\"font-size: 12px; color: #0c5460; margin-bottom: 8px; font-weight: 600;\">Gemini 环境变量:</div>\n                                    <div style=\"display: grid; grid-template-columns: 200px 1fr; gap: 6px; font-size: 12px; font-family: monospace;\">\n                            `;\n                            Object.keys(configObj).forEach(key => {\n                                let value = configObj[key];\n                                if (key.includes('KEY')) {\n                                    value = value.substring(0, 10) + '****';\n                                }\n                                configHtml += `\n                                    <div style=\"color: #0c5460; font-weight: 500;\">${key}:</div>\n                                    <div style=\"color: #0c5460;\">${value}</div>\n                                `;\n                            });\n                            configHtml += '</div></div>';\n                        }\n\n                        // 原始 JSON\n                        configHtml += `\n                            <details style=\"margin-top: 12px;\">\n                                <summary style=\"cursor: pointer; color: #0c5460; font-size: 12px; font-weight: 600;\">查看原始 JSON →</summary>\n                                <pre style=\"margin-top: 8px; padding: 12px; background: #f8f9fa; border-radius: 6px; font-size: 11px; overflow-x: auto; border: 1px solid #dee2e6;\">${JSON.stringify(configObj, null, 2)}</pre>\n                            </details>\n                        `;\n\n                        configHtml += '</div>';\n                    } catch (e) {\n                        configHtml = `\n                            <div style=\"background: #f8d7da; padding: 16px; border-radius: 8px; border-left: 4px solid #dc3545;\">\n                                <h4 style=\"margin-bottom: 8px; color: #721c24;\">❌ 配置文件解析失败</h4>\n                                <div style=\"color: #721c24; font-size: 13px;\">${e.message}</div>\n                            </div>\n                        `;\n                    }\n                }\n\n                // 用量查询配置解析 (v3.9+)\n                let usageHtml = '';\n                if (paramsObj.usageEnabled || paramsObj.usageScript || paramsObj.usageBaseUrl) {\n                    usageHtml = `\n                        <div style=\"background: #f0fff4; padding: 16px; border-radius: 8px; border-left: 4px solid #27ae60; margin-top: 16px;\">\n                            <h4 style=\"margin-bottom: 12px; color: #27ae60;\">📊 用量查询配置 (v3.9+)</h4>\n                            <div style=\"display: grid; grid-template-columns: 150px 1fr; gap: 8px; font-size: 13px;\">\n                    `;\n\n                    if (paramsObj.usageEnabled) {\n                        usageHtml += `\n                            <div style=\"color: #27ae60; font-weight: 500;\">启用状态:</div>\n                            <div style=\"color: #27ae60;\">${paramsObj.usageEnabled === 'true' ? '✅ 已启用' : '❌ 已禁用'}</div>\n                        `;\n                    }\n\n                    if (paramsObj.usageBaseUrl) {\n                        usageHtml += `\n                            <div style=\"color: #27ae60; font-weight: 500;\">Base URL:</div>\n                            <div style=\"color: #27ae60; word-break: break-all; font-family: monospace; font-size: 12px;\">${paramsObj.usageBaseUrl}</div>\n                        `;\n                    }\n\n                    if (paramsObj.usageApiKey) {\n                        usageHtml += `\n                            <div style=\"color: #27ae60; font-weight: 500;\">API Key:</div>\n                            <div style=\"color: #27ae60; font-family: monospace; font-size: 12px;\">${paramsObj.usageApiKey.substring(0, 10)}****</div>\n                        `;\n                    }\n\n                    if (paramsObj.usageAutoInterval) {\n                        usageHtml += `\n                            <div style=\"color: #27ae60; font-weight: 500;\">自动查询间隔:</div>\n                            <div style=\"color: #27ae60;\">${paramsObj.usageAutoInterval} 分钟</div>\n                        `;\n                    }\n\n                    if (paramsObj.usageAccessToken) {\n                        usageHtml += `\n                            <div style=\"color: #27ae60; font-weight: 500;\">Access Token:</div>\n                            <div style=\"color: #27ae60; font-family: monospace; font-size: 12px;\">${paramsObj.usageAccessToken.substring(0, 10)}****</div>\n                        `;\n                    }\n\n                    if (paramsObj.usageUserId) {\n                        usageHtml += `\n                            <div style=\"color: #27ae60; font-weight: 500;\">User ID:</div>\n                            <div style=\"color: #27ae60; font-family: monospace; font-size: 12px;\">${paramsObj.usageUserId}</div>\n                        `;\n                    }\n\n                    usageHtml += '</div>';\n\n                    // 解析用量脚本\n                    if (paramsObj.usageScript) {\n                        try {\n                            // URL-safe Base64 解码\n                            let scriptB64 = paramsObj.usageScript\n                                .replace(/-/g, '+')\n                                .replace(/_/g, '/');\n                            // 补齐 padding\n                            while (scriptB64.length % 4) {\n                                scriptB64 += '=';\n                            }\n                            const scriptContent = b64_to_utf8(scriptB64);\n                            usageHtml += `\n                                <div style=\"margin-top: 12px;\">\n                                    <div style=\"font-size: 12px; color: #27ae60; margin-bottom: 8px; font-weight: 600;\">用量查询脚本:</div>\n                                    <pre style=\"margin: 0; padding: 12px; background: #fff; border-radius: 6px; font-size: 11px; overflow-x: auto; white-space: pre-wrap; word-break: break-all; border: 1px solid #27ae60;\">${scriptContent}</pre>\n                                </div>\n                            `;\n                        } catch (e) {\n                            usageHtml += `\n                                <div style=\"margin-top: 12px; color: #dc3545; font-size: 12px;\">\n                                    ⚠️ 脚本解码失败: ${e.message}\n                                </div>\n                            `;\n                        }\n                    }\n\n                    usageHtml += '</div>';\n                }\n\n                // 显示结果\n                document.getElementById('parseBasicInfo').innerHTML = basicInfoHtml;\n                document.getElementById('parseUrlParams').innerHTML = urlParamsHtml;\n                document.getElementById('parseConfigContent').innerHTML = configHtml + usageHtml;\n                document.getElementById('parseResult').style.display = 'block';\n\n                // 滚动到结果\n                document.getElementById('parseResult').scrollIntoView({\n                    behavior: 'smooth',\n                    block: 'nearest'\n                });\n\n            } catch (e) {\n                alert('❌ 深链接解析失败：' + e.message);\n                console.error('Parse error:', e);\n            }\n        }\n\n        // 阻止表单默认提交行为\n        document.addEventListener('DOMContentLoaded', function () {\n            const inputs = document.querySelectorAll('input, textarea, select');\n            inputs.forEach(input => {\n                input.addEventListener('keypress', function (e) {\n                    if (e.key === 'Enter' && e.target.tagName !== 'TEXTAREA') {\n                        e.preventDefault();\n                        generateLink();\n                    }\n                });\n            });\n\n            // 初始化显示 Claude 字段\n            updateModelFields();\n        });\n\n        // Base64 编码功能\n        function encodeToBase64() {\n            const input = document.getElementById('encodeInput').value;\n\n            if (!input.trim()) {\n                alert('❌ 请输入要编码的内容！');\n                return;\n            }\n\n            try {\n                const encoded = utf8_to_b64(input);\n                document.getElementById('encodeOutput').textContent = encoded;\n                document.getElementById('encodeResult').style.display = 'block';\n\n                // 滚动到结果\n                document.getElementById('encodeResult').scrollIntoView({\n                    behavior: 'smooth',\n                    block: 'nearest'\n                });\n            } catch (e) {\n                alert('❌ 编码失败：' + e.message);\n                console.error('Encode error:', e);\n            }\n        }\n\n        // Base64 解码功能\n        function decodeFromBase64() {\n            const input = document.getElementById('decodeInput').value.trim();\n\n            if (!input) {\n                alert('❌ 请输入要解码的 Base64 内容！');\n                return;\n            }\n\n            try {\n                const decoded = b64_to_utf8(input);\n                document.getElementById('decodeOutput').textContent = decoded;\n                document.getElementById('decodeResult').style.display = 'block';\n\n                // 检查是否是 JSON，如果是则显示格式化按钮\n                try {\n                    JSON.parse(decoded);\n                    document.getElementById('jsonFormat').style.display = 'block';\n                } catch {\n                    document.getElementById('jsonFormat').style.display = 'none';\n                }\n\n                // 滚动到结果\n                document.getElementById('decodeResult').scrollIntoView({\n                    behavior: 'smooth',\n                    block: 'nearest'\n                });\n            } catch (e) {\n                alert('❌ 解码失败：' + e.message + '\\n\\n请确保输入的是有效的 Base64 编码');\n                console.error('Decode error:', e);\n            }\n        }\n\n        // 格式化 JSON\n        function formatJson() {\n            try {\n                const text = document.getElementById('decodeOutput').textContent;\n                const obj = JSON.parse(text);\n                const formatted = JSON.stringify(obj, null, 2);\n                document.getElementById('decodeOutput').textContent = formatted;\n            } catch (e) {\n                alert('❌ JSON 格式化失败：' + e.message);\n            }\n        }\n\n        // 复制编码结果\n        function copyEncoded() {\n            const text = document.getElementById('encodeOutput').textContent;\n            navigator.clipboard.writeText(text).then(() => {\n                const btn = event.target;\n                const originalText = btn.textContent;\n                btn.textContent = '✅ 已复制！';\n                btn.style.background = 'linear-gradient(135deg, #27ae60 0%, #229954 100%)';\n\n                setTimeout(() => {\n                    btn.textContent = originalText;\n                    btn.style.background = '';\n                }, 2000);\n            }).catch(err => {\n                console.error('复制失败:', err);\n                alert('❌ 复制失败，请手动复制');\n            });\n        }\n\n        // 复制解码结果\n        function copyDecoded() {\n            const text = document.getElementById('decodeOutput').textContent;\n            navigator.clipboard.writeText(text).then(() => {\n                const btn = event.target;\n                const originalText = btn.textContent;\n                btn.textContent = '✅ 已复制！';\n                btn.style.background = 'linear-gradient(135deg, #27ae60 0%, #229954 100%)';\n\n                setTimeout(() => {\n                    btn.textContent = originalText;\n                    btn.style.background = '';\n                }, 2000);\n            }).catch(err => {\n                console.error('复制失败:', err);\n                alert('❌ 复制失败，请手动复制');\n            });\n        }\n\n        // ==================== 深链接生成器函数 ====================\n\n        // 生成供应商深链接\n        function generateProviderLink() {\n            const app = document.getElementById('providerApp').value;\n            const name = document.getElementById('providerName').value.trim();\n            const apiKey = document.getElementById('providerApiKey').value.trim();\n            const endpoint = document.getElementById('providerEndpoint').value.trim();\n            const homepage = document.getElementById('providerHomepage').value.trim();\n            const model = document.getElementById('providerModel').value.trim();\n            const notes = document.getElementById('providerNotes').value.trim();\n            const enabled = document.getElementById('providerEnabled').value;\n\n            // 验证必填字段\n            if (!name) {\n                alert('❌ 请填写供应商名称');\n                return;\n            }\n\n            if (!apiKey) {\n                alert('❌ 请填写 API Key');\n                return;\n            }\n\n            if (!endpoint) {\n                alert('❌ 请填写 API Endpoint');\n                return;\n            }\n\n            if (!homepage) {\n                alert('❌ 请填写主页链接');\n                return;\n            }\n\n            // 构建深链接\n            let url = `ccswitch://v1/import?resource=provider&app=${app}&name=${encodeURIComponent(name)}&endpoint=${encodeURIComponent(endpoint)}&homepage=${encodeURIComponent(homepage)}&apiKey=${encodeURIComponent(apiKey)}`;\n\n            if (model) {\n                url += `&model=${encodeURIComponent(model)}`;\n            }\n\n            if (notes) {\n                url += `&notes=${encodeURIComponent(notes)}`;\n            }\n\n            if (enabled === 'true') {\n                url += '&enabled=true';\n            }\n\n            // 显示结果\n            document.getElementById('providerUrl').textContent = url;\n            document.getElementById('providerImportBtn').href = url;\n            document.getElementById('providerResult').style.display = 'block';\n\n            // 滚动到结果\n            document.getElementById('providerResult').scrollIntoView({\n                behavior: 'smooth',\n                block: 'nearest'\n            });\n        }\n\n        // 生成 MCP 深链接\n        function generateMcpLink() {\n            const apps = document.getElementById('mcpApps').value.trim();\n            const config = document.getElementById('mcpConfig').value.trim();\n            const enabled = document.getElementById('mcpEnabled').value;\n\n            if (!apps) {\n                alert('❌ 请填写目标应用');\n                return;\n            }\n\n            if (!config) {\n                alert('❌ 请填写 MCP 配置');\n                return;\n            }\n\n            try {\n                // 验证 JSON 格式\n                const jsonObj = JSON.parse(config);\n                if (!jsonObj.mcpServers) {\n                    alert('❌ 配置必须包含 mcpServers 字段');\n                    return;\n                }\n\n                // Base64 编码配置\n                const configB64 = utf8_to_b64(config);\n\n                // 构建深链接\n                let url = `ccswitch://v1/import?resource=mcp&apps=${encodeURIComponent(apps)}&config=${encodeURIComponent(configB64)}`;\n\n                if (enabled === 'true') {\n                    url += '&enabled=true';\n                }\n\n                // 显示结果\n                document.getElementById('mcpUrl').textContent = url;\n                document.getElementById('mcpImportBtn').href = url;\n                document.getElementById('mcpResult').style.display = 'block';\n\n                // 滚动到结果\n                document.getElementById('mcpResult').scrollIntoView({\n                    behavior: 'smooth',\n                    block: 'nearest'\n                });\n            } catch (e) {\n                alert('❌ JSON 格式错误：' + e.message);\n            }\n        }\n\n        // 生成 Prompt 深链接\n        function generatePromptLink() {\n            const app = document.getElementById('promptApp').value;\n            const name = document.getElementById('promptName').value.trim();\n            const content = document.getElementById('promptContent').value.trim();\n            const description = document.getElementById('promptDescription').value.trim();\n            const enabled = document.getElementById('promptEnabled').value;\n\n            if (!name) {\n                alert('❌ 请填写提示词名称');\n                return;\n            }\n\n            if (!content) {\n                alert('❌ 请填写提示词内容');\n                return;\n            }\n\n            // Base64 编码内容\n            const contentB64 = utf8_to_b64(content);\n\n            // 构建深链接\n            let url = `ccswitch://v1/import?resource=prompt&app=${app}&name=${encodeURIComponent(name)}&content=${encodeURIComponent(contentB64)}`;\n\n            if (description) {\n                url += `&description=${encodeURIComponent(description)}`;\n            }\n\n            if (enabled === 'true') {\n                url += '&enabled=true';\n            }\n\n            // 显示结果\n            document.getElementById('promptUrl').textContent = url;\n            document.getElementById('promptImportBtn').href = url;\n            document.getElementById('promptResult').style.display = 'block';\n\n            // 滚动到结果\n            document.getElementById('promptResult').scrollIntoView({\n                behavior: 'smooth',\n                block: 'nearest'\n            });\n        }\n\n        // 生成 Skill 深链接\n        function generateSkillLink() {\n            const repo = document.getElementById('skillRepo').value.trim();\n            const branch = document.getElementById('skillBranch').value.trim() || 'main';\n            const skillsPath = document.getElementById('skillPath').value.trim() || 'skills';\n            const directory = document.getElementById('skillDirectory').value.trim();\n\n            if (!repo) {\n                alert('❌ 请填写 GitHub 仓库');\n                return;\n            }\n\n            // 验证仓库格式\n            if (!repo.includes('/')) {\n                alert('❌ 仓库格式应为: owner/repo-name');\n                return;\n            }\n\n            // 构建深链接\n            let url = `ccswitch://v1/import?resource=skill&repo=${encodeURIComponent(repo)}&branch=${encodeURIComponent(branch)}&skills_path=${encodeURIComponent(skillsPath)}`;\n\n            if (directory) {\n                url += `&directory=${encodeURIComponent(directory)}`;\n            }\n\n            // 显示结果\n            document.getElementById('skillUrl').textContent = url;\n            document.getElementById('skillImportBtn').href = url;\n            document.getElementById('skillResult').style.display = 'block';\n\n            // 滚动到结果\n            document.getElementById('skillResult').scrollIntoView({\n                behavior: 'smooth',\n                block: 'nearest'\n            });\n        }\n\n        // 复制生成的链接\n        function copyGeneratedLink(elementId) {\n            const text = document.getElementById(elementId).textContent;\n            navigator.clipboard.writeText(text).then(() => {\n                const btn = event.target;\n                const originalText = btn.textContent;\n                btn.textContent = '✅ 已复制！';\n                btn.style.background = 'linear-gradient(135deg, #27ae60 0%, #229954 100%)';\n\n                setTimeout(() => {\n                    btn.textContent = originalText;\n                    btn.style.background = '';\n                }, 2000);\n            }).catch(err => {\n                console.error('复制失败:', err);\n                alert('❌ 复制失败，请手动复制');\n            });\n        }\n\n        // 选中文本（点击 URL 时）\n        function selectText(element) {\n            const range = document.createRange();\n            range.selectNodeContents(element);\n            const selection = window.getSelection();\n            selection.removeAllRanges();\n            selection.addRange(range);\n        }\n    </script>\n</body>\n\n</html>\n"
  },
  {
    "path": "docs/proxy-guide-zh.md",
    "content": "# CC Switch 代理功能使用指南\n\n## 功能介绍\n\nCC Switch 的代理功能是一个本地 HTTP 代理服务器，可以统一管理 Claude Code、Codex 和 Gemini CLI 的 API 请求。主要特性包括：\n\n- **统一代理入口** - 所有 CLI 应用的请求通过本地代理转发\n- **自动故障转移** - 当前供应商故障时自动切换到备用供应商\n- **按应用控制** - 可独立控制每个应用是否启用代理\n- **配置保护** - 自动备份原始配置，停止代理时安全恢复\n\n## 快速开始\n\n### 1. 启动代理\n\n在 CC Switch 主界面，点击右上角的 **Proxy** 按钮，可以看到代理控制面板。\n\n点击 **启动代理** 按钮启动本地代理服务器。代理默认监听 `127.0.0.1:15721`。\n\n### 2. 启用应用接管\n\n代理启动后，你可以选择让哪些应用的请求通过代理：\n\n- **Claude** - 接管 Claude Code 的 API 请求\n- **Codex** - 接管 Codex CLI 的 API 请求\n- **Gemini** - 接管 Gemini CLI 的 API 请求\n\n点击对应应用的开关即可启用/禁用接管。\n\n> **注意**：启用接管后，CC Switch 会自动修改对应应用的配置文件，将 API 端点指向本地代理。原始配置会被安全备份。\n\n### 3. 正常使用 CLI\n\n启用接管后，你可以正常使用各个 CLI 工具。所有请求都会经过 CC Switch 代理转发到配置的供应商。\n\n### 4. 停止代理\n\n当你不再需要代理时，点击 **停止代理** 按钮。CC Switch 会：\n\n1. 安全关闭代理服务器\n2. 自动恢复所有应用的原始配置\n3. 清除代理状态\n\n## 自动故障转移\n\n### 工作原理\n\n代理功能内置了智能故障转移机制：\n\n1. **健康监控** - 实时监控每个供应商的响应状态\n2. **熔断器** - 连续失败 5 次后触发熔断，暂停使用该供应商\n3. **自动切换** - 熔断后自动切换到列表中的下一个供应商\n4. **自动恢复** - 30 秒后尝试恢复熔断的供应商\n\n### 配置故障转移\n\n要使用故障转移功能，你需要：\n\n1. 在对应应用下添加多个供应商（至少 2 个）\n2. 启动代理并启用接管\n3. 当主供应商故障时，代理会自动切换到备用供应商\n\n### 健康状态指示\n\n在供应商卡片上可以看到健康状态指示：\n\n- **绿色** - 供应商正常\n- **红色** - 供应商故障/熔断中\n- **灰色** - 未使用代理或未检测\n\n## 按应用接管\n\nv3.9.0 新增了按应用分粒度控制功能：\n\n- 你可以只接管 Claude，而让 Codex 使用原始配置\n- 每个应用的接管状态独立管理\n- 启用/禁用不会影响其他应用\n\n### 接管状态检测\n\nCC Switch 通过检测配置备份来判断接管状态：\n\n- 存在备份 = 已接管\n- 无备份 = 未接管\n\n这确保了即使 CC Switch 异常退出，重新启动后也能正确识别状态。\n\n## 代理配置\n\n在代理面板中，你可以配置以下参数：\n\n| 参数 | 默认值 | 说明 |\n|------|--------|------|\n| 监听地址 | 127.0.0.1 | 代理服务器绑定地址 |\n| 监听端口 | 15721 | 代理服务器端口 |\n| 最大重试 | 3 | 请求失败时的最大重试次数 |\n| 请求超时 | 120 秒 | 单个请求的超时时间 |\n| 启用日志 | 是 | 是否记录请求日志 |\n\n## 常见问题\n\n### Q: 代理启动失败，提示端口被占用？\n\nA: 默认端口 15721 可能被其他程序占用。你可以：\n- 关闭占用该端口的程序\n- 在代理配置中修改端口号\n\n### Q: 启用接管后 CLI 无法使用？\n\nA: 请检查：\n1. 代理服务器是否正常运行（查看代理面板状态）\n2. 供应商配置是否正确（API Key 等）\n3. 网络连接是否正常\n\n### Q: 如何恢复原始配置？\n\nA: 点击 **停止代理** 按钮，CC Switch 会自动恢复所有应用的原始配置。\n\n如果 CC Switch 异常退出，重新启动后会检测到之前的备份，你可以：\n- 点击停止代理来恢复配置\n- 或继续使用代理功能\n\n### Q: 故障转移没有生效？\n\nA: 请确保：\n1. 配置了至少 2 个供应商\n2. 代理已启动且接管已启用\n3. 故障转移只在代理模式下工作\n\n### Q: 代理会影响性能吗？\n\nA: 本地代理的延迟开销非常小（通常 < 1ms）。但如果启用了请求日志，在高频请求场景下可能会有少量性能影响。\n\n## 技术细节\n\n### 配置文件位置\n\n启用接管后，CC Switch 会修改以下配置文件：\n\n| 应用 | 配置文件 | 修改内容 |\n|------|----------|----------|\n| Claude | `~/.claude/settings.json` | `apiBaseUrl` 指向代理 |\n| Codex | `~/.codex/config.toml` | `[api] baseUrl` 指向代理 |\n| Gemini | `~/.gemini/.env` | `GEMINI_BASE_URL` 指向代理 |\n\n原始配置备份在 CC Switch 数据库中，停止代理时自动恢复。\n\n### 代理模式\n\n代理服务器运行在接管模式下，会：\n\n1. 接收来自 CLI 的 HTTPS 请求\n2. 根据当前供应商配置转发到真实 API 端点\n3. 返回响应给 CLI\n4. 记录请求日志和健康状态\n\n### 数据库表\n\n代理功能使用以下数据库表：\n\n- `proxy_config` - 代理配置\n- `provider_health` - 供应商健康状态\n- `proxy_request_logs` - 请求日志\n- `circuit_breaker_config` - 熔断器配置\n- `proxy_live_backup` - Live 配置备份\n"
  },
  {
    "path": "docs/release-notes/v3.10.0-en.md",
    "content": "# CC Switch v3.10.0\n\n> OpenCode Support, Global Proxy, Claude Rectifier & Multi-App Experience Enhancements\n\n**[中文版 →](v3.10.0-zh.md) | [日本語版 →](v3.10.0-ja.md)**\n\n---\n\n## Overview\n\nCC Switch v3.10.0 introduces OpenCode support, becoming the fourth managed CLI application.\nThis release also brings global proxy settings, Claude Rectifier (thinking signature fixer), enhanced health checks, per-provider configuration, and many other important features, along with comprehensive improvements to multi-app workflows and terminal experience.\n\n**Release Date**: 2026-01-21\n\n---\n\n## Highlights\n\n- OpenCode Support: Full management of providers, MCP servers, and Skills with auto-import on first launch\n- Global Proxy: Configure a unified proxy for all outbound network requests\n- Claude Rectifier: Thinking signature fixer for better compatibility with third-party APIs\n- Enhanced Health Checks: Configurable prompts and CLI-compatible request format\n- Per-Provider Config: Persistent provider-specific configuration support\n- App Visibility Control: Freely show/hide apps with synchronized tray menu updates\n- Terminal Improvements: Provider-specific terminal buttons, fnm path support, cross-platform safe launch\n- WSL Tool Detection: Detect tool versions in WSL environment with security hardening\n\n---\n\n## Main Features\n\n### OpenCode Support (New Fourth App)\n\n- Complete OpenCode provider management: add, edit, switch, delete\n- MCP server management: unified architecture with Claude/Codex/Gemini\n- Skills support: OpenCode can also use Skills functionality\n- Auto-import on first launch: automatically imports existing OpenCode configuration when detected\n- Full internationalization: Chinese/English/Japanese support (#695)\n\n### Global Proxy\n\n- Configure a unified proxy for all outbound network requests (#596, thanks @yovinchen)\n- Supports HTTP/HTTPS proxy protocols\n- Suitable for network environments requiring proxy access to external APIs\n\n### Claude Rectifier (Thinking Signature Fixer)\n\n- Automatically fixes Claude API thinking signatures (#595, thanks @yovinchen)\n- Resolves incompatible thinking block formats returned by some third-party API gateways\n- Can be enabled/disabled in Advanced Settings\n\n### Enhanced Health Checks\n\n- Configurable custom prompts for streaming health checks (#623, thanks @yovinchen)\n- Supports CLI-compatible request format for better simulation of real usage scenarios\n- Improves fault detection accuracy\n\n### Per-Provider Config\n\n- Support for saving configuration separately for each provider (#663, thanks @yovinchen)\n- Persistent configuration: provider-specific settings retained after restart\n- Suitable for scenarios where different providers require different configurations\n\n### App Visibility Control\n\n- Freely show/hide any app (Gemini hidden by default)\n- Tray menu automatically syncs visibility settings\n- Hidden apps won't appear in the main interface or tray menu\n\n### Takeover Compact Mode\n\n- Automatically uses compact layout when 3 or more visible apps are displayed\n- Optimizes space utilization in multi-app scenarios\n\n### Terminal Improvements\n\n- Provider-specific terminal button: one-click to use current provider in terminal (#564, thanks @kkkman22)\n- `fnm` path support: automatically recognizes Node.js paths managed by fnm\n- Cross-platform safe launch: improved terminal launch logic for Windows/macOS/Linux\n\n### WSL Tool Detection\n\n- Detect tool versions in WSL environment (#627, thanks @yovinchen)\n- Added security hardening to prevent command injection risks\n\n### Skills Preset Enhancements\n\n- Added `baoyu-skills` preset repository\n- Automatically supplements missing default repositories for out-of-the-box experience\n\n---\n\n## Experience Improvements\n\n- Keyboard shortcuts: Press `ESC` to quickly return/close panels (#670, thanks @xxk8)\n- Simplified proxy logs: cleaner and more readable output (#585, thanks @yovinchen)\n- Pricing editor UX: unified `FullScreenPanel` style\n- Advanced settings layout: Rectifier section moved below Failover for better logical flow\n- OpenRouter compatibility mode: disabled by default, UI toggle hidden (reduces clutter)\n\n---\n\n## Bug Fixes\n\n### Proxy & Failover\n\n- Immediately switch to P1 when auto-failover is enabled (instead of waiting for next request)\n\n### Provider Management\n\n- Fixed stale data when reopening provider edit dialog after save (#654, thanks @YangYongAn)\n- Fixed baseUrl and apiKey state not resetting when switching presets\n- Fixed endpoint auto-selection state not persisting (#611, thanks @yovinchen)\n- Automatically apply default color when icon color is not set\n\n### Deep Links\n\n- Support multi-endpoint import (#597, thanks @yovinchen)\n- Prefer `GOOGLE_GEMINI_BASE_URL` over `GEMINI_BASE_URL`\n\n### MCP\n\n- Skip `cmd /c` wrapper for WSL target paths (#592, thanks @cxyfer)\n\n### Usage Templates\n\n- Added variable hints, fixed validation issues (#628, thanks @YangYongAn)\n- Prevent configuration leakage between providers\n- Usage block offset automatically adapts to action button width (#613, thanks @yovinchen)\n\n### Gemini\n\n- Convert timeout parameters to Gemini CLI format (#580, thanks @cxyfer)\n\n### UI\n\n- Fixed Select dropdown rendering issues in `FullScreenPanel`\n\n---\n\n## Notes & Considerations\n\n- **OpenCode is a newly supported app**: OpenCode CLI must be installed first to use related features.\n- **Global proxy affects all outbound requests**: including usage queries, health checks, and other network operations.\n- **Rectifier is experimental**: can be disabled in Advanced Settings if issues occur.\n\n---\n\n## Special Thanks\n\nThanks to @yovinchen @YangYongAn @cxyfer @xxk8 @kkkman22 @Shuimo03 for their contributions to this release!\nThanks to @libukai for designing the elegant failover-related UI!\n\n---\n\n## Download & Installation\n\nVisit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download the appropriate version.\n\n### System Requirements\n\n| System  | Minimum Version                | Architecture                        |\n| ------- | ------------------------------ | ----------------------------------- |\n| Windows | Windows 10 or later            | x64                                 |\n| macOS   | macOS 10.15 (Catalina) or later | Intel (x64) / Apple Silicon (arm64) |\n| Linux   | See table below                | x64                                 |\n\n### Windows\n\n| File                                     | Description                                          |\n| ---------------------------------------- | ---------------------------------------------------- |\n| `CC-Switch-v3.10.0-Windows.msi`          | **Recommended** - MSI installer with auto-update     |\n| `CC-Switch-v3.10.0-Windows-Portable.zip` | Portable version, extract and run, no registry write |\n\n### macOS\n\n| File                             | Description                                                        |\n| -------------------------------- | ------------------------------------------------------------------ |\n| `CC-Switch-v3.10.0-macOS.zip`    | **Recommended** - Extract and drag to Applications, Universal Binary |\n| `CC-Switch-v3.10.0-macOS.tar.gz` | For Homebrew installation and auto-update                          |\n\n> **Note**: Since the author doesn't have an Apple Developer account, you may see an \"unidentified developer\" warning on first launch. Please close it, then go to \"System Settings\" → \"Privacy & Security\" → click \"Open Anyway\", and it will open normally afterwards.\n\n### Homebrew (macOS)\n\n```bash\nbrew tap farion1231/ccswitch\nbrew install --cask cc-switch\n```\n\nUpdate:\n\n```bash\nbrew upgrade --cask cc-switch\n```\n\n### Linux\n\n| Distribution                            | Recommended Format | Installation Method                                                    |\n| --------------------------------------- | ------------------ | ---------------------------------------------------------------------- |\n| Ubuntu / Debian / Linux Mint / Pop!\\_OS | `.deb`             | `sudo dpkg -i CC-Switch-*.deb` or `sudo apt install ./CC-Switch-*.deb` |\n| Fedora / RHEL / CentOS / Rocky Linux    | `.rpm`             | `sudo rpm -i CC-Switch-*.rpm` or `sudo dnf install ./CC-Switch-*.rpm`  |\n| openSUSE                                | `.rpm`             | `sudo zypper install ./CC-Switch-*.rpm`                                |\n| Arch Linux / Manjaro                    | `.AppImage`        | Add execute permission and run directly, or use AUR                    |\n| Other distributions / Unsure            | `.AppImage`        | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage`              |\n"
  },
  {
    "path": "docs/release-notes/v3.10.0-ja.md",
    "content": "# CC Switch v3.10.0\n\n> OpenCode サポート、グローバルプロキシ、Claude Rectifier とマルチアプリ体験の強化\n\n**[中文版 →](v3.10.0-zh.md) | [English →](v3.10.0-en.md)**\n\n---\n\n## 概要\n\nCC Switch v3.10.0 では OpenCode サポートが追加され、4番目の管理対象 CLI アプリケーションとなりました。\nまた、グローバルプロキシ設定、Claude Rectifier（thinking 署名修正機能）、ヘルスチェックの強化、プロバイダー別設定など、多くの重要な機能が追加され、マルチアプリワークフローとターミナル体験が全面的に改善されました。\n\n**リリース日**: 2026-01-21\n\n---\n\n## ハイライト\n\n- OpenCode サポート：プロバイダー、MCP サーバー、Skills の完全管理、初回起動時の自動インポート\n- グローバルプロキシ：すべての送信ネットワークリクエストに統一プロキシを設定\n- Claude Rectifier：thinking 署名修正機能、サードパーティ API との互換性向上\n- ヘルスチェック強化：カスタムプロンプト設定、CLI 互換リクエスト形式\n- プロバイダー別設定：プロバイダー固有の設定の永続化をサポート\n- アプリ表示制御：アプリの表示/非表示を自由に設定、トレイメニューと同期\n- ターミナル改善：プロバイダー専用ターミナルボタン、fnm パスサポート、クロスプラットフォーム安全起動\n- WSL ツール検出：WSL 環境でのツールバージョン検出とセキュリティ強化\n\n---\n\n## 主な機能\n\n### OpenCode サポート（新しい4番目のアプリ）\n\n- 完全な OpenCode プロバイダー管理：追加、編集、切り替え、削除\n- MCP サーバー管理：Claude/Codex/Gemini と統一されたアーキテクチャ\n- Skills サポート：OpenCode でも Skills 機能を使用可能\n- 初回起動時の自動インポート：既存の OpenCode 設定を検出すると自動的にインポート\n- 完全な国際化：中国語/英語/日本語サポート (#695)\n\n### グローバルプロキシ\n\n- すべての送信ネットワークリクエストに統一プロキシを設定 (#596、@yovinchen に感謝)\n- HTTP/HTTPS プロキシプロトコルをサポート\n- 外部 API へのプロキシアクセスが必要なネットワーク環境に適用\n\n### Claude Rectifier（Thinking 署名修正機能）\n\n- Claude API の thinking 署名を自動修正 (#595、@yovinchen に感謝)\n- 一部のサードパーティ API ゲートウェイが返す互換性のない thinking ブロック形式を解決\n- 詳細設定で有効/無効を切り替え可能\n\n### ヘルスチェック強化\n\n- ストリーミングヘルスチェック用のカスタムプロンプトを設定可能 (#623、@yovinchen に感謝)\n- CLI 互換リクエスト形式をサポートし、実際の使用シナリオをより良くシミュレート\n- 障害検出の精度を向上\n\n### プロバイダー別設定\n\n- 各プロバイダーごとに設定を個別に保存可能 (#663、@yovinchen に感謝)\n- 設定の永続化：再起動後もプロバイダー固有の設定を保持\n- 異なるプロバイダーに異なる設定が必要なシナリオに適用\n\n### アプリ表示制御\n\n- 任意のアプリを自由に表示/非表示（Gemini はデフォルトで非表示）\n- トレイメニューは表示設定と自動的に同期\n- 非表示のアプリはメインインターフェースとトレイメニューに表示されない\n\n### Takeover コンパクトモード\n\n- 3つ以上の表示アプリがある場合、自動的にコンパクトレイアウトを使用\n- マルチアプリシナリオでのスペース利用を最適化\n\n### ターミナル改善\n\n- プロバイダー専用ターミナルボタン：ワンクリックでターミナルで現在のプロバイダーを使用 (#564、@kkkman22 に感謝)\n- `fnm` パスサポート：fnm で管理された Node.js パスを自動認識\n- クロスプラットフォーム安全起動：Windows/macOS/Linux のターミナル起動ロジックを改善\n\n### WSL ツール検出\n\n- WSL 環境でツールバージョンを検出 (#627、@yovinchen に感謝)\n- コマンドインジェクションリスクを防ぐためのセキュリティ強化を追加\n\n### Skills プリセット強化\n\n- `baoyu-skills` プリセットリポジトリを追加\n- 不足しているデフォルトリポジトリを自動補完し、すぐに使える状態を確保\n\n---\n\n## 体験の改善\n\n- キーボードショートカット：`ESC` を押してパネルをすばやく戻る/閉じる (#670、@xxk8 に感謝)\n- プロキシログの簡素化：より明確で読みやすい出力 (#585、@yovinchen に感謝)\n- 価格エディター UX：統一された `FullScreenPanel` スタイル\n- 詳細設定レイアウト：Rectifier セクションを Failover の下に移動し、論理的な流れを改善\n- OpenRouter 互換モード：デフォルトで無効、UI トグルを非表示（煩雑さを軽減）\n\n---\n\n## バグ修正\n\n### プロキシとフェイルオーバー\n\n- 自動フェイルオーバーが有効な場合、すぐに P1 に切り替え（次のリクエストを待たずに）\n\n### プロバイダー管理\n\n- 保存後にプロバイダー編集ダイアログを再度開いたときにデータが古い問題を修正 (#654、@YangYongAn に感謝)\n- プリセット切り替え時に baseUrl と apiKey の状態がリセットされない問題を修正\n- エンドポイント自動選択状態が永続化されない問題を修正 (#611、@yovinchen に感謝)\n- アイコンカラーが設定されていない場合、デフォルトカラーを自動適用\n\n### ディープリンク\n\n- マルチエンドポイントインポートをサポート (#597、@yovinchen に感謝)\n- `GEMINI_BASE_URL` より `GOOGLE_GEMINI_BASE_URL` を優先\n\n### MCP\n\n- WSL ターゲットパスの `cmd /c` ラッパーをスキップ (#592、@cxyfer に感謝)\n\n### 使用量テンプレート\n\n- 変数ヒントを追加、検証の問題を修正 (#628、@YangYongAn に感謝)\n- プロバイダー間での設定漏洩を防止\n- 使用量ブロックのオフセットがアクションボタンの幅に自動適応 (#613、@yovinchen に感謝)\n\n### Gemini\n\n- タイムアウトパラメータを Gemini CLI 形式に変換 (#580、@cxyfer に感謝)\n\n### UI\n\n- `FullScreenPanel` での Select ドロップダウンのレンダリング問題を修正\n\n---\n\n## 注意事項\n\n- **OpenCode は新しくサポートされたアプリです**：関連機能を使用するには、まず OpenCode CLI をインストールする必要があります。\n- **グローバルプロキシはすべての送信リクエストに影響します**：使用量クエリ、ヘルスチェックなどのネットワーク操作を含みます。\n- **Rectifier は実験的機能です**：問題が発生した場合は、詳細設定で無効にできます。\n\n---\n\n## 特別な感謝\n\n@yovinchen @YangYongAn @cxyfer @xxk8 @kkkman22 @Shuimo03 の皆様、このリリースへの貢献に感謝します！\n@libukai 様、エレガントなフェイルオーバー関連 UI のデザインに感謝します！\n\n---\n\n## ダウンロードとインストール\n\n[Releases](https://github.com/farion1231/cc-switch/releases/latest) から適切なバージョンをダウンロードしてください。\n\n### システム要件\n\n| システム | 最小バージョン                   | アーキテクチャ                      |\n| -------- | -------------------------------- | ----------------------------------- |\n| Windows  | Windows 10 以降                  | x64                                 |\n| macOS    | macOS 10.15 (Catalina) 以降      | Intel (x64) / Apple Silicon (arm64) |\n| Linux    | 下表参照                         | x64                                 |\n\n### Windows\n\n| ファイル                                 | 説明                                                 |\n| ---------------------------------------- | ---------------------------------------------------- |\n| `CC-Switch-v3.10.0-Windows.msi`          | **推奨** - MSI インストーラー、自動更新対応          |\n| `CC-Switch-v3.10.0-Windows-Portable.zip` | ポータブル版、解凍して実行、レジストリ書き込みなし   |\n\n### macOS\n\n| ファイル                         | 説明                                                              |\n| -------------------------------- | ----------------------------------------------------------------- |\n| `CC-Switch-v3.10.0-macOS.zip`    | **推奨** - 解凍して Applications にドラッグ、Universal Binary     |\n| `CC-Switch-v3.10.0-macOS.tar.gz` | Homebrew インストールと自動更新用                                 |\n\n> **注意**：作者が Apple Developer アカウントを持っていないため、初回起動時に「開発元を確認できません」という警告が表示される場合があります。一度閉じてから、「システム設定」→「プライバシーとセキュリティ」→「このまま開く」をクリックすると、その後は正常に開けます。\n\n### Homebrew (macOS)\n\n```bash\nbrew tap farion1231/ccswitch\nbrew install --cask cc-switch\n```\n\n更新：\n\n```bash\nbrew upgrade --cask cc-switch\n```\n\n### Linux\n\n| ディストリビューション                  | 推奨形式    | インストール方法                                                       |\n| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |\n| Ubuntu / Debian / Linux Mint / Pop!\\_OS | `.deb`      | `sudo dpkg -i CC-Switch-*.deb` または `sudo apt install ./CC-Switch-*.deb` |\n| Fedora / RHEL / CentOS / Rocky Linux    | `.rpm`      | `sudo rpm -i CC-Switch-*.rpm` または `sudo dnf install ./CC-Switch-*.rpm`  |\n| openSUSE                                | `.rpm`      | `sudo zypper install ./CC-Switch-*.rpm`                                |\n| Arch Linux / Manjaro                    | `.AppImage` | 実行権限を追加して直接実行、または AUR を使用                          |\n| その他のディストリビューション / 不明   | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage`              |\n"
  },
  {
    "path": "docs/release-notes/v3.10.0-zh.md",
    "content": "# CC Switch v3.10.0\n\n> OpenCode 支持、全局代理、Claude Rectifier 与多应用体验增强\n\n**[English →](v3.10.0-en.md) | [日本語版 →](v3.10.0-ja.md)**\n\n---\n\n## 概览\n\nCC Switch v3.10.0 新增 OpenCode 支持，成为第四个受管理的 CLI 应用。\n同时带来全局代理设置、Claude Rectifier（thinking 签名修正器）、健康检查增强、按供应商配置等多项重要功能，并对多应用工作流与终端体验做了全面改进。\n\n**发布日期**：2026-01-21\n\n---\n\n## 重点内容\n\n- OpenCode 支持：供应商、MCP 服务器、Skills 全面管理，首次启动自动导入\n- 全局代理：为出站网络请求统一配置代理\n- Claude Rectifier：thinking 签名修正器，兼容更多第三方 API\n- 健康检查增强：可配置提示词、CLI 兼容请求\n- 按供应商配置：支持供应商特定配置的持久化\n- 应用可见性控制：自由显示/隐藏应用，托盘菜单同步更新\n- 终端改进：供应商专属终端按钮、fnm 路径支持、跨平台安全启动\n- WSL 工具检测：在 WSL 环境检测工具版本，并增加安全加固\n\n---\n\n## 主要功能\n\n### OpenCode 支持（新增第四应用）\n\n- 完整的 OpenCode 供应商管理：新增、编辑、切换、删除\n- MCP 服务器管理：与 Claude/Codex/Gemini 统一架构\n- Skills 支持：OpenCode 也可使用 Skills 功能\n- 首次启动自动导入：检测到已有 OpenCode 配置时自动导入\n- 完整国际化：中/英/日三语支持（#695）\n\n### 全局代理（Global Proxy）\n\n- 为所有出站网络请求配置统一代理（#596，感谢 @yovinchen）\n- 支持 HTTP/HTTPS 代理协议\n- 适用于需要代理访问外部 API 的网络环境\n\n### Claude Rectifier（Thinking 签名修正器）\n\n- 自动修正 Claude API 的 thinking 签名（#595，感谢 @yovinchen）\n- 解决部分第三方 API 网关返回的 thinking 块格式不兼容问题\n- 在高级设置中可开启/关闭\n\n### 健康检查增强\n\n- 可配置自定义提示词（prompt）用于流式健康检查（#623，感谢 @yovinchen）\n- 支持 CLI 兼容请求格式，更好地模拟真实使用场景\n- 提升故障检测的准确性\n\n### 按供应商配置（Per-Provider Config）\n\n- 支持为每个供应商单独保存配置（#663，感谢 @yovinchen）\n- 配置持久化：重启后保留供应商专属设置\n- 适用于不同供应商需要不同配置的场景\n\n### 应用可见性控制\n\n- 自由显示/隐藏任意应用（Gemini 默认隐藏）\n- 托盘菜单自动同步可见性设置\n- 隐藏的应用不会出现在主界面和托盘菜单中\n\n### Takeover Compact Mode\n\n- 当显示 3 个及以上可见应用时，自动使用紧凑布局\n- 优化多应用场景下的空间利用\n\n### 终端改进\n\n- 供应商专属终端按钮：一键在终端中使用当前供应商（#564，感谢 @kkkman22）\n- `fnm` 路径支持：自动识别 fnm 管理的 Node.js 路径\n- 跨平台安全启动：改进 Windows/macOS/Linux 的终端启动逻辑\n\n### WSL 工具检测\n\n- 在 WSL 环境中检测工具版本（#627，感谢 @yovinchen）\n- 增加安全加固，防止命令注入风险\n\n### Skills 预设增强\n\n- 新增 `baoyu-skills` 预设仓库\n- 自动补充缺失的默认仓库，确保开箱即用\n\n---\n\n## 体验优化\n\n- 键盘快捷键：按 `ESC` 快速返回/关闭面板（#670，感谢 @xxk8）\n- 代理日志简化：输出更清晰易读（#585，感谢 @yovinchen）\n- 定价编辑器 UX：统一使用 `FullScreenPanel` 风格\n- 高级设置布局：Rectifier 区块移至 Failover 下方，逻辑更顺畅\n- OpenRouter 兼容模式：默认禁用，UI 开关隐藏（减少干扰）\n\n---\n\n## Bug 修复\n\n### 代理与故障切换\n\n- 启用自动故障切换时立即切换到 P1（而非等待下次请求）\n\n### 供应商管理\n\n- 修复供应商编辑对话框保存后重新打开时数据过时的问题（#654，感谢 @YangYongAn）\n- 修复切换预设时 baseUrl 和 apiKey 状态未重置的问题\n- 修复端点自动选择状态未持久化的问题（#611，感谢 @yovinchen）\n- 未设置图标颜色时自动应用默认颜色\n\n### 深链接\n\n- 支持多端点导入（#597，感谢 @yovinchen）\n- 优先使用 `GOOGLE_GEMINI_BASE_URL` 而非 `GEMINI_BASE_URL`\n\n### MCP\n\n- WSL 目标路径跳过 `cmd /c` 包裹（#592，感谢 @cxyfer）\n\n### 用量模板\n\n- 新增变量提示，修复验证问题（#628，感谢 @YangYongAn）\n- 防止配置在供应商之间泄漏\n- 用量区块偏移量根据操作按钮宽度自动适应（#613，感谢 @yovinchen）\n\n### Gemini\n\n- 超时参数转换为 Gemini CLI 格式（#580，感谢 @cxyfer）\n\n### UI\n\n- 修复 `FullScreenPanel` 中 Select 下拉框渲染问题\n\n---\n\n## 说明与注意事项\n\n- **OpenCode 为新支持的应用**：需要先安装 OpenCode CLI 才能使用相关功能。\n- **全局代理会影响所有出站请求**：包括用量查询、健康检查等网络操作。\n- **Rectifier 功能为实验性**：如遇问题可在高级设置中关闭。\n\n---\n\n## 特别感谢\n\n感谢 @yovinchen @YangYongAn @cxyfer @xxk8 @kkkman22 @Shuimo03 为本版本做出的贡献！\n感谢 @libukai 设计的故障转移相关 UI，非常优雅！\n\n---\n\n## 下载与安装\n\n访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。\n\n### 系统要求\n\n| 系统    | 最低版本                      | 架构                                |\n| ------- | ----------------------------- | ----------------------------------- |\n| Windows | Windows 10 及以上             | x64                                 |\n| macOS   | macOS 10.15 (Catalina) 及以上 | Intel (x64) / Apple Silicon (arm64) |\n| Linux   | 见下表                        | x64                                 |\n\n### Windows\n\n| 文件                                     | 说明                                |\n| ---------------------------------------- | ----------------------------------- |\n| `CC-Switch-v3.10.0-Windows.msi`          | **推荐** - MSI 安装包，支持自动更新 |\n| `CC-Switch-v3.10.0-Windows-Portable.zip` | 便携版，解压即用，不写入注册表      |\n\n### macOS\n\n| 文件                             | 说明                                                      |\n| -------------------------------- | --------------------------------------------------------- |\n| `CC-Switch-v3.10.0-macOS.zip`    | **推荐** - 解压后拖入 Applications 即可，Universal Binary |\n| `CC-Switch-v3.10.0-macOS.tar.gz` | 用于 Homebrew 安装和自动更新                              |\n\n> **注意**：由于作者没有苹果开发者账号，首次打开可能出现\"未知开发者\"警告，请先关闭，然后前往\"系统设置\" → \"隐私与安全性\" → 点击\"仍要打开\"，之后便可以正常打开\n\n### Homebrew（macOS）\n\n```bash\nbrew tap farion1231/ccswitch\nbrew install --cask cc-switch\n```\n\n更新：\n\n```bash\nbrew upgrade --cask cc-switch\n```\n\n### Linux\n\n| 发行版                                  | 推荐格式    | 安装方式                                                               |\n| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |\n| Ubuntu / Debian / Linux Mint / Pop!\\_OS | `.deb`      | `sudo dpkg -i CC-Switch-*.deb` 或 `sudo apt install ./CC-Switch-*.deb` |\n| Fedora / RHEL / CentOS / Rocky Linux    | `.rpm`      | `sudo rpm -i CC-Switch-*.rpm` 或 `sudo dnf install ./CC-Switch-*.rpm`  |\n| openSUSE                                | `.rpm`      | `sudo zypper install ./CC-Switch-*.rpm`                                |\n| Arch Linux / Manjaro                    | `.AppImage` | 添加执行权限后直接运行，或使用 AUR                                     |\n| 其他发行版 / 不确定                     | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage`              |\n"
  },
  {
    "path": "docs/release-notes/v3.11.0-en.md",
    "content": "# CC Switch v3.11.0\n\n> OpenClaw Support, Session Manager, Backup Management & 50+ Improvements\n\n**[中文版 →](v3.11.0-zh.md) | [日本語版 →](v3.11.0-ja.md)**\n\n---\n\n## Overview\n\nCC Switch v3.11.0 is a major update that adds full management support for **OpenClaw** as the fifth application, introduces a new **Session Manager** and **Backup Management** feature. Additionally, **Oh My OpenCode (OMO) integration**, the **partial key-field merging** architecture upgrade for provider switching, **settings page refactoring**, and many other improvements make the overall experience more polished.\n\n**Release Date**: 2026-02-26\n\n**Update Scale**: 147 commits | 274 files changed | +32,179 / -5,467 lines\n\n---\n\n## Highlights\n\n- **OpenClaw Support**: Fifth managed application with 13 provider presets, Env/Tools/AgentsDefaults config editors, and Workspace file management\n- **Session Manager**: Browse conversation history across all five apps with table-of-contents navigation and in-session search\n- **Backup Management**: Independent backup panel with configurable policies, periodic backups, and pre-migration auto-backup\n- **Oh My OpenCode Integration**: Full OMO config management with OMO Slim lightweight mode support\n- **Partial Key-Field Merging (⚠️ Breaking Change)**: Provider switching now only replaces provider-related fields, preserving all other settings; the \"Common Config Snippet\" feature has been removed\n- **Settings Page Refactoring**: 5-tab layout with ~40% code reduction\n- **6 New Provider Presets**: AWS Bedrock, SSAI Code, CrazyRouter, AICoding, and more\n- **Thinking Budget Rectifier**: Fine-grained thinking budget control\n- **Theme Switch Animation**: Circular reveal transition animation\n- **WebDAV Auto Sync**: Automatic sync with large file protection\n\n---\n\n## Main Features\n\n### OpenClaw Support (New Fifth App)\n\nFull management support for OpenClaw, the fifth managed application following Claude Code, Codex, Gemini CLI, and OpenCode.\n\n- **Provider Management**: Add, edit, switch, and delete OpenClaw providers with 13 built-in presets\n- **Config Editors**: Three dedicated panels for Env (environment variables), Tools, and AgentsDefaults\n- **Workspace Panel**: HEARTBEAT/BOOTSTRAP/BOOT file management and daily memory\n- **Additive Overlay Mode**: Support config overlay instead of overwrite\n- **Default Model Button**: One-click to fill recommended models; auto-register suggested models to allowlist when adding providers\n- **Brand & Interaction**: Dedicated brand icon, fade-in/fade-out transition animation when switching apps\n- **Deep Link Support**: Import OpenClaw provider configurations via URL\n- **Full Internationalization**: Complete Chinese/English/Japanese support\n\n### Session Manager\n\nA brand-new session manager to browse and search conversation history.\n\n- Browse conversation history across Claude Code, Codex, Gemini CLI, OpenCode, and OpenClaw (#867, thanks @TinsFox)\n- Table-of-contents navigation and in-session search\n- Auto-filter by current app when entering the session page\n- Parallel directory scanning + head-tail JSONL reading for optimized loading performance\n\n### Backup Management\n\nAn independent backup management panel for better data safety.\n\n- Configurable backup policy: maximum backup count and auto-cleanup rules\n- Hourly automatic backup timer during runtime\n- Auto-backup before database schema migrations with backfill warning\n- Support backup rename and deletion (with confirmation dialog)\n- Backup filenames use local time for better clarity\n\n### Oh My OpenCode (OMO) Integration\n\nFull Oh My OpenCode config file management.\n\n- Agent model selection, category configuration, and recommended model fill (#972, thanks @yovinchen)\n- Improved agent model selection UX with lowercase key fix (#1004, thanks @yovinchen)\n- OMO Slim lightweight mode support\n- OMO ↔ OMO Slim mutual exclusion (enforced at database level)\n\n### Workspace\n\n- Full-text search across daily memory files, sorted by date\n- Clickable directory paths for quick file location access\n\n### Toolbar\n\n- AppSwitcher auto-collapses to compact mode based on available width\n- Smooth transition animation for compact mode toggle\n\n### Settings\n\n- First-use confirmation dialogs for proxy and usage features to prevent accidental operations\n- New `enableLocalProxy` switch to control proxy UI visibility on home page\n- More granular local environment checks: CLI tool version detection (#870, thanks @kv-chiu), Volta path detection (#969, thanks @myjustify)\n\n### Provider Presets\n\n- **AWS Bedrock**: Support for AKSK and API Key authentication modes (#1047, thanks @keithyt06)\n- **SSAI Code**: Partner preset across all five apps\n- **CrazyRouter**: Partner preset with dedicated icon\n- **AICoding**: Partner preset with i18n promotion text\n- Updated domestic model provider presets to latest versions\n- Renamed Qwen Coder to Bailian (#965, thanks @zhu-jl18)\n\n### Other New Features\n\n- **Thinking Budget Rectifier**: Fine-grained thinking budget allocation control (#1005, thanks @yovinchen)\n- **WebDAV Auto Sync**: Automatic sync with large file protection (#923, thanks @clx20000410; #1043, thanks @SaladDay)\n- **Theme Switch Animation**: Circular reveal transition for a smoother visual experience (#905, thanks @funnytime75)\n- **Claude Config Editor Quick Toggles**: Quick toggle switches for common settings (#1012, thanks @JIA-ss)\n- **Dynamic Endpoint Hint**: Context-aware hint text based on API format selection (#860, thanks @zhu-jl18)\n- **Usage Dashboard Enhancement**: Auto-refresh control and robust formatting (#942, thanks @yovinchen)\n- **New Pricing Data**: claude-opus-4-6 and gpt-5.3-codex (#943, thanks @yovinchen)\n- **Silent Startup Optimization**: Silent startup option only shown when launch-on-startup is enabled\n\n---\n\n## Architecture Improvements\n\n### Partial Key-Field Merging (⚠️ Breaking Change)\n\nProvider switching now uses partial key-field merging instead of full config overwrite (#1098).\n\n**Before**: Switching providers overwrote the entire `settings_config` to the live config file. This meant that any non-provider settings the user manually added to the live file (plugins, MCP config, permissions, etc.) would be lost on every switch. To work around this, previous versions offered a \"Common Config Snippet\" feature that let users define shared config to be merged on every switch.\n\n**After**: Switching providers now only replaces provider-related key-values (API keys, endpoints, models, etc.), leaving all other settings intact. The \"Common Config Snippet\" feature is therefore no longer needed and has been removed.\n\n**Impact & Migration**:\n- If you **didn't use** Common Config Snippets, this change is fully transparent — switching just works better now\n- If you **used** Common Config Snippets to preserve custom settings (MCP config, permissions, etc.), those settings are now automatically preserved during switches — no action needed\n- If you used Common Config Snippets for other purposes (e.g., injecting extra config on every switch), please manually add those settings to your live config file after upgrading\n\nThis refactoring removed 6 frontend files (3 components + 3 hooks) and ~150 lines of backend dead code.\n\n### Manual Import Replaces Auto-Import\n\nStartup no longer auto-imports external configurations. Users now click \"Import Current Config\" manually, preventing accidental data overwrites.\n\n### OmoVariant Parameterization\n\nEliminated ~250 lines of duplicated code in the OMO module via `OmoVariant` struct parameterization.\n\n### OMO Common Config Removal\n\nRemoved the two-layer merge system, reducing ~1,733 lines of code and simplifying the architecture.\n\n### ProviderForm Decomposition\n\nReduced ProviderForm component from 2,227 lines to 1,526 lines by extracting 5 independent modules (opencodeFormUtils, useOmoModelSource, useOpencodeFormState, useOmoDraftState, useOpenclawFormState), significantly improving maintainability.\n\n### Shared MCP/Skills Components\n\nExtracted AppCountBar, AppToggleGroup, and ListItemRow shared components to reduce duplication across MCP and Skills panels (#897, thanks @PeanutSplash).\n\n### Settings Page Refactoring\n\nRefactored settings page to a 5-tab layout (General | Proxy | Advanced | Usage | About), reducing SettingsPage code from ~716 to ~426 lines.\n\n### Other Improvements\n\n- Unified terminal selection via global settings with WezTerm support added\n- Updated Claude model references from 4.5 to 4.6\n\n---\n\n## Bug Fixes\n\n### Critical Fixes\n\n- **Windows Home Dir Regression**: Restored default home directory resolution to prevent providers/settings \"disappearing\" when `HOME` env var differs from the real user profile directory in Git/MSYS environments\n- **Linux White Screen**: Disabled WebKitGTK hardware acceleration on AMD GPUs (Cezanne/Radeon Vega) to prevent blank screen on startup (#986, thanks @ThendCN)\n- **OpenAI Beta Parameter**: Stopped appending `?beta=true` to `/v1/chat/completions` endpoints, fixing request failures for Nvidia and other `apiFormat=\"openai_chat\"` providers (#1052, thanks @jnorthrup)\n- **Health Check Auth**: Health check now respects provider's `auth_mode` setting, preventing failures for proxy services that only support Bearer authentication (#824, thanks @Jassy930)\n\n### Provider Preset Fixes\n\n- Fixed OpenClaw `/v1` prefix causing double path (/v1/v1/messages)\n- Corrected Opus pricing ($15/$75 → $5/$25) and upgraded to 4.6\n- Unified AIGoCode URL to `https://api.aigocode.com` across all apps\n- Removed outdated partner status from Zhipu GLM presets\n- Restored API Key input visibility when creating new Claude providers\n- Hide quick toggles for non-active providers, show context-aware JSON editor hints\n\n### OMO Fixes\n\n- Added missing omo-slim category checks across add/form/mutation paths\n- Fixed OMO Slim query cache invalidation after provider mutations\n- Synced OMO agent/category recommended models with upstream sources\n- Added toast feedback for \"Fill Recommended\" button silent failures\n- Removed last-provider deletion restriction for OMO/OMO Slim\n- Reject saving OpenCode providers without configured models (#932, thanks @yovinchen)\n\n### OpenClaw Fixes\n\n- Fixed 25 missing i18n keys, replaced key={index} with stable IDs, added deep link additive merge, and other code review issues\n- Enhanced EnvPanel robustness (NaN guards, entry key names instead of array indices)\n- Merged duplicate i18n keys to restore provider form translations\n\n### Platform Fixes\n\n- Windows silent startup window flicker (#901, thanks @funnytime75)\n- Title bar dark mode theme following (#903, thanks @funnytime75)\n- Windows Skills path separator matching (#868, thanks @stmoonar)\n- WSL helper functions conditional compilation\n\n### UI Fixes\n\n- Toolbar height clipping causing AppSwitcher to be obscured\n- Show update badge instead of green checkmark when newer version available\n- Session Manager button only visible for Claude/Codex apps\n- Unified SQL import/export card dark mode styling (#1067, thanks @SaladDay)\n\n### Other Fixes\n\n- Replaced hardcoded Chinese strings in Session Manager with i18n keys\n- Fixed Skill documentation URL branch and path resolution (#977, thanks @yovinchen)\n- Added missing OpenCode install.sh installation path detection (#988, thanks @zhu-jl18)\n- Fixed Skill ZIP symlink resolution (#1040, thanks @yovinchen)\n- Added missing OpenCode checkbox in MCP add/edit form (#1026, thanks @yovinchen)\n- Removed auto-import side effect from useProvidersQuery queryFn\n\n---\n\n## Performance\n\n- Parallel directory scanning + head-tail JSONL reading for session panel, significantly improving session list loading speed\n- Removed unnecessary TanStack Query cache overhead for Tauri local IPC calls\n\n---\n\n## Documentation\n\n- Sponsor updates: SSSAiCode, Crazyrouter, AICoding, Right Code, MiniMax\n- Added user manual documentation (#979, thanks @yovinchen)\n\n---\n\n## Notes & Considerations\n\n- **OpenClaw is a newly supported app**: OpenClaw CLI must be installed first to use related features.\n- **⚠️ Common Config Snippet feature has been removed**: Since provider switching now uses partial key-field merging (only replacing API keys, endpoints, models, etc.), user's other settings are automatically preserved, making Common Config Snippets unnecessary. See the \"Architecture Improvements\" section above for migration details.\n- **Auto-import changed to manual**: External configurations are no longer auto-imported on startup. Click \"Import Current Config\" manually when needed.\n- **OMO and OMO Slim are mutually exclusive**: Only one can be active at a time. Switching to one automatically disables the other.\n- **Backup is enabled by default**: Automatic hourly backup during runtime. Adjust the policy in the Backup panel.\n\n---\n\n## Special Thanks\n\nThanks to all contributors for their contributions to this release!\n\n@TinsFox @keithyt06 @kv-chiu @SaladDay @jnorthrup @JIA-ss @clx20000410 @ThendCN @yovinchen @zhu-jl18 @myjustify @funnytime75 @PeanutSplash @Jassy930 @stmoonar\n\n---\n\n## Download & Installation\n\nVisit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download the appropriate version.\n\n### System Requirements\n\n| System  | Minimum Version                 | Architecture                        |\n| ------- | ------------------------------- | ----------------------------------- |\n| Windows | Windows 10 or later             | x64                                 |\n| macOS   | macOS 10.15 (Catalina) or later | Intel (x64) / Apple Silicon (arm64) |\n| Linux   | See table below                 | x64                                 |\n\n### Windows\n\n| File                                     | Description                                          |\n| ---------------------------------------- | ---------------------------------------------------- |\n| `CC-Switch-v3.11.0-Windows.msi`          | **Recommended** - MSI installer with auto-update     |\n| `CC-Switch-v3.11.0-Windows-Portable.zip` | Portable version, extract and run, no registry write |\n\n### macOS\n\n| File                             | Description                                                          |\n| -------------------------------- | -------------------------------------------------------------------- |\n| `CC-Switch-v3.11.0-macOS.zip`    | **Recommended** - Extract and drag to Applications, Universal Binary |\n| `CC-Switch-v3.11.0-macOS.tar.gz` | For Homebrew installation and auto-update                            |\n\n> **Note**: Since the author doesn't have an Apple Developer account, you may see an \"unidentified developer\" warning on first launch. Please close it, then go to \"System Settings\" → \"Privacy & Security\" → click \"Open Anyway\", and it will open normally afterwards.\n\n### Homebrew (macOS)\n\n```bash\nbrew tap farion1231/ccswitch\nbrew install --cask cc-switch\n```\n\nUpdate:\n\n```bash\nbrew upgrade --cask cc-switch\n```\n\n### Linux\n\n| Distribution                            | Recommended Format | Installation Method                                                    |\n| --------------------------------------- | ------------------ | ---------------------------------------------------------------------- |\n| Ubuntu / Debian / Linux Mint / Pop!\\_OS | `.deb`             | `sudo dpkg -i CC-Switch-*.deb` or `sudo apt install ./CC-Switch-*.deb` |\n| Fedora / RHEL / CentOS / Rocky Linux    | `.rpm`             | `sudo rpm -i CC-Switch-*.rpm` or `sudo dnf install ./CC-Switch-*.rpm`  |\n| openSUSE                                | `.rpm`             | `sudo zypper install ./CC-Switch-*.rpm`                                |\n| Arch Linux / Manjaro                    | `.AppImage`        | Add execute permission and run directly, or use AUR                    |\n| Other distributions / Unsure            | `.AppImage`        | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage`              |\n"
  },
  {
    "path": "docs/release-notes/v3.11.0-ja.md",
    "content": "# CC Switch v3.11.0\n\n> OpenClaw サポート、セッションマネージャー、バックアップ管理と 50 以上の改善\n\n**[中文版 →](v3.11.0-zh.md) | [English →](v3.11.0-en.md)**\n\n---\n\n## 概要\n\nCC Switch v3.11.0 は大規模なアップデートです。5番目のアプリケーション **OpenClaw** の完全管理サポートを追加し、新しい**セッションマネージャー**と**バックアップ管理**機能を導入しました。さらに、**Oh My OpenCode (OMO) 統合**、プロバイダー切り替えの**部分キーフィールドマージ**アーキテクチャアップグレード、**設定ページのリファクタリング**など、多数の改善により全体的な体験がさらに向上しました。\n\n**リリース日**: 2026-02-26\n\n**更新規模**: 147 commits | 274 files changed | +32,179 / -5,467 lines\n\n---\n\n## ハイライト\n\n- **OpenClaw サポート**: 5番目の管理対象アプリ、13 のプロバイダープリセット、Env/Tools/AgentsDefaults 設定エディター、Workspace ファイル管理\n- **セッションマネージャー**: 5つのアプリの会話履歴を閲覧、目次ナビゲーションとセッション内検索\n- **バックアップ管理**: 独立バックアップパネル、設定可能なポリシー、定期バックアップ、マイグレーション前自動バックアップ\n- **Oh My OpenCode 統合**: 完全な OMO 設定管理、OMO Slim 軽量モードサポート\n- **部分キーフィールドマージ（⚠️ 破壊的変更）**: プロバイダー切り替え時にプロバイダー関連フィールドのみ置換し、その他の設定を保持；「共通設定スニペット」機能は削除されました\n- **設定ページリファクタリング**: 5タブレイアウト、コード量約 40% 削減\n- **6つの新プロバイダープリセット**: AWS Bedrock、SSAI Code、CrazyRouter、AICoding など\n- **Thinking Budget Rectifier**: より精密な thinking budget 制御\n- **テーマ切り替えアニメーション**: 円形リビール遷移アニメーション\n- **WebDAV 自動同期**: 自動同期と大容量ファイル保護\n\n---\n\n## 主な機能\n\n### OpenClaw サポート（新しい5番目のアプリ）\n\nClaude Code、Codex、Gemini CLI、OpenCode に続く5番目の管理対象アプリケーションとして OpenClaw の完全管理サポートを追加しました。\n\n- **プロバイダー管理**: OpenClaw プロバイダーの追加、編集、切り替え、削除、13 の内蔵プリセット\n- **設定エディター**: Env（環境変数）、Tools（ツール）、AgentsDefaults（エージェントデフォルト）の3つの専用パネル\n- **Workspace パネル**: HEARTBEAT/BOOTSTRAP/BOOT ファイル管理とデイリーメモリ\n- **Additive オーバーレイモード**: 上書きではなく設定の重ね合わせをサポート\n- **デフォルトモデルボタン**: ワンクリックで推奨モデルを入力、プロバイダー追加時に候補モデルを allowlist に自動登録\n- **ブランドとインタラクション**: 専用ブランドアイコン、アプリ切り替えフェード遷移アニメーション\n- **ディープリンクサポート**: URL 経由で OpenClaw プロバイダー設定をインポート\n- **完全な国際化**: 中/英/日 三言語完全対応\n\n### セッションマネージャー\n\n会話履歴を閲覧・検索できる新しいセッションマネージャーです。\n\n- Claude Code、Codex、Gemini CLI、OpenCode、OpenClaw の5つのアプリの会話履歴を閲覧（#867、@TinsFox に感謝）\n- 目次ナビゲーションとセッション内検索\n- セッションページに入ると現在のアプリで自動フィルター\n- 並列ディレクトリスキャン + ヘッドテール JSONL 読み取りで読み込みパフォーマンスを最適化\n\n### バックアップ管理\n\nデータの安全性を高める独立バックアップ管理パネルです。\n\n- 設定可能なバックアップポリシー: 最大バックアップ数、自動クリーンアップルール\n- ランタイム中の1時間ごとの定期自動バックアップ\n- データベースマイグレーション前の自動バックアップ、バックフィル警告プロンプト\n- バックアップのリネームと削除をサポート（確認ダイアログ付き）\n- バックアップファイル名にローカルタイムを使用、より直感的に\n\n### Oh My OpenCode (OMO) 統合\n\n完全な Oh My OpenCode 設定ファイル管理です。\n\n- エージェントモデル選択、カテゴリ設定、推奨モデル入力（#972、@yovinchen に感謝）\n- エージェントモデル選択 UX の改善、lowercase key 問題の修正（#1004、@yovinchen に感謝）\n- OMO Slim 軽量モードサポート\n- OMO と OMO Slim の相互排他（データベースレベルで一貫性を保証）\n\n### ワークスペース\n\n- デイリーメモリファイルの全文検索、日付順ソート\n- ディレクトリパスがクリック可能に、ファイル位置をすばやく開く\n\n### ツールバー\n\n- AppSwitcher がウィンドウ幅に応じて自動的にコンパクトモードに折りたたみ\n- コンパクトモード切り替えのスムーズ遷移アニメーション\n\n### 設定\n\n- プロキシと使用量機能に初回使用確認ダイアログを追加、誤操作を防止\n- `enableLocalProxy` スイッチを追加、ホーム画面のプロキシ UI 表示を制御\n- より詳細なローカル環境チェック: CLI ツールバージョン検出（#870、@kv-chiu に感謝）、Volta パス検出（#969、@myjustify に感謝）\n\n### プロバイダープリセット\n\n- **AWS Bedrock**: AKSK と API Key の2種類の認証方式をサポート（#1047、@keithyt06 に感謝）\n- **SSAI Code**: パートナープリセット、5アプリ対応\n- **CrazyRouter**: パートナープリセットと専用アイコン\n- **AICoding**: パートナープリセットとプロモーションテキスト\n- 国内モデルプロバイダープリセットを最新版に更新\n- Qwen Coder を百炼 (Bailian) にリネーム（#965、@zhu-jl18 に感謝）\n\n### その他の新機能\n\n- **Thinking Budget Rectifier**: より精密な thinking budget 制御（#1005、@yovinchen に感謝）\n- **WebDAV 自動同期**: 自動同期設定と大容量ファイル保護（#923、@clx20000410 に感謝；#1043、@SaladDay に感謝）\n- **テーマ切り替えアニメーション**: 円形リビール遷移アニメーション（#905、@funnytime75 に感謝）\n- **Claude 設定エディタークイックトグル**: よく使う設定項目のクイック切り替え（#1012、@JIA-ss に感謝）\n- **動的エンドポイントヒント**: API フォーマット選択に基づく動的ヒントテキスト（#860、@zhu-jl18 に感謝）\n- **使用量ダッシュボード強化**: 自動更新、堅牢なフォーマット（#942、@yovinchen に感謝）\n- **新しい価格データ**: claude-opus-4-6 と gpt-5.3-codex（#943、@yovinchen に感謝）\n- **サイレント起動の最適化**: サイレント起動オプションは自動起動が有効な場合のみ表示\n\n---\n\n## アーキテクチャ改善\n\n### 部分キーフィールドマージ（⚠️ 破壊的変更）\n\nプロバイダー切り替えを完全な設定上書きから部分キーフィールドマージ戦略に変更しました（#1098）。\n\n**変更前**: プロバイダーを切り替えると、`settings_config` 全体がライブ設定ファイルに上書きされていました。つまり、ユーザーがライブファイルに手動で追加した非プロバイダー設定（プラグイン設定、MCP 設定、権限設定など）は、切り替えのたびに失われていました。この問題を補うため、以前のバージョンでは「共通設定スニペット」機能を提供し、毎回の切り替え時にマージされる共通設定を定義できました。\n\n**変更後**: プロバイダー切り替え時に、プロバイダー関連のキー値（API キー、エンドポイント、モデルなど）のみが置換され、その他の設定はそのまま保持されます。そのため「共通設定スニペット」機能は不要となり、削除されました。\n\n**影響と移行**:\n- 共通設定スニペットを**使用していなかった**場合、この変更は完全に透過的で、切り替え体験が向上するだけです\n- カスタム設定（MCP 設定、権限など）を保持するために共通設定スニペットを**使用していた**場合、それらの設定は切り替え時に自動的に保持されるようになり、追加の操作は不要です\n- 共通設定スニペットを他の目的（切り替え時に追加設定を注入するなど）で使用していた場合は、アップグレード後にライブ設定ファイルに手動で設定を追加してください\n\nこのリファクタリングにより、フロントエンドファイル 6 つ（コンポーネント 3 つ + hooks 3 つ）と約 150 行のバックエンドデッドコードを削除しました。\n\n### 手動インポートに変更\n\n起動時の自動インポートを廃止し、手動の「現在の設定をインポート」ボタンに変更。意図しないユーザーデータの上書きを防止します。\n\n### OmoVariant パラメータ化\n\n`OmoVariant` 構造体によるパラメータ化で、OMO モジュールの約250行の重複コードを削除しました。\n\n### OMO 共通設定の削除\n\n2層マージシステムを削除し、約1,733行のコードを削減、アーキテクチャを簡素化しました。\n\n### ProviderForm 分割\n\nProviderForm コンポーネントを2,227行から1,526行に削減し、5つの独立モジュール（opencodeFormUtils、useOmoModelSource、useOpencodeFormState、useOmoDraftState、useOpenclawFormState）に分離。保守性が大幅に向上しました。\n\n### MCP/Skills 共有コンポーネント\n\nAppCountBar、AppToggleGroup、ListItemRow などの共有コンポーネントを抽出し、MCP と Skills パネルの重複コードを削減（#897、@PeanutSplash に感謝）。\n\n### 設定ページリファクタリング\n\n設定ページを5タブレイアウト（一般 | プロキシ | 詳細 | 使用量 | 情報）にリファクタリング。SettingsPage のコードを約716行から約426行に削減しました。\n\n### その他の改善\n\n- ターミナル統一: グローバル設定でターミナル選択を統一、WezTerm サポートを追加\n- Claude モデル参照を 4.5 から 4.6 に更新\n\n---\n\n## バグ修正\n\n### 重大な修正\n\n- **Windows ホームディレクトリ回帰**: デフォルトのホームディレクトリ解決を復元し、Git/MSYS 環境でのデータベースパス変更によるデータ「消失」を防止\n- **Linux 白画面**: AMD GPU の WebKitGTK ハードウェアアクセラレーションを無効化し、一部の Linux システムの起動白画面問題を解決（#986、@ThendCN に感謝）\n- **OpenAI Beta パラメータ**: `/v1/chat/completions` に `?beta=true` を追加しないように修正、Nvidia など OpenAI Chat 形式を使用するプロバイダーのリクエスト失敗を修正（#1052、@jnorthrup に感謝）\n- **ヘルスチェック認証**: プロバイダーの `auth_mode` 設定を尊重し、Bearer 認証のみをサポートするプロキシサービスのヘルスチェック失敗を回避（#824、@Jassy930 に感謝）\n\n### プロバイダープリセット修正\n\n- OpenClaw `/v1` プレフィックスの二重パス問題を修正\n- Opus 価格修正（$15/$75 → $5/$25）と 4.6 へのアップグレード\n- AIGoCode URL を `https://api.aigocode.com` に統一\n- Zhipu GLM の古いパートナーステータスを削除\n- 新規 Claude プロバイダー作成時の API Key 入力フィールドの表示を復元\n- 非アクティブプロバイダーのクイックトグルを非表示、コンテキスト対応の JSON エディターヒントを表示\n\n### OMO 修正\n\n- omo-slim カテゴリチェックの補完（add/form/mutation パス）\n- OMO Slim プロバイダー変更後のクエリキャッシュ無効化を修正\n- OMO agent/category 推奨モデルをアップストリームソースと同期\n- 「推奨を入力」ボタン失敗時の toast フィードバックを追加\n- OMO/OMO Slim の最後のプロバイダー削除制限を撤廃\n- OpenCode でモデル未設定時の保存を拒否（#932、@yovinchen に感謝）\n\n### OpenClaw 修正\n\n- 25個の欠落 i18n キー、key={index} を安定 ID に置換、ディープリンク additive マージなどのコードレビュー問題を修正\n- EnvPanel 堅牢性強化（NaN ガード、配列インデックスではなくエントリーキー名を使用）\n- i18n 重複キーのマージ、プロバイダーフォーム翻訳を復元\n\n### プラットフォーム修正\n\n- Windows サイレント起動時のウィンドウフラッシュ（#901、@funnytime75 に感謝）\n- タイトルバーのダークモード追従（#903、@funnytime75 に感謝）\n- Windows の Skills パスセパレーターマッチング（#868、@stmoonar に感謝）\n- WSL ヘルパー関数の条件付きコンパイル\n\n### UI 修正\n\n- ツールバーの高さクリッピングによる AppSwitcher の遮蔽を修正\n- 新バージョンがある場合、緑のチェックマークではなく更新バッジを表示\n- セッションマネージャーボタンを Claude/Codex アプリでのみ表示\n- SQL インポート/エクスポートカードのダークモードスタイルを統一（#1067、@SaladDay に感謝）\n\n### その他の修正\n\n- セッションマネージャーのハードコードされた中国語文字列を i18n キーに置換\n- Skill ドキュメント URL のブランチとパスを修正（#977、@yovinchen に感謝）\n- OpenCode install.sh インストールパス検出の補完（#988、@zhu-jl18 に感謝）\n- Skill ZIP シンボリックリンク解決の修正（#1040、@yovinchen に感謝）\n- MCP フォームに OpenCode チェックボックスを追加（#1026、@yovinchen に感謝）\n- useProvidersQuery の自動インポート副作用を削除\n\n---\n\n## パフォーマンス最適化\n\n- セッションパネルの並列ディレクトリスキャン + ヘッドテール JSONL 読み取りで、セッションリスト読み込み速度を大幅向上\n- Tauri ローカル IPC の不要な query cache を削除し、メモリ使用量を削減\n\n---\n\n## ドキュメント\n\n- スポンサー更新: SSSAiCode、Crazyrouter、AICoding、Right Code、MiniMax\n- ユーザーマニュアルを追加（#979、@yovinchen に感謝）\n\n---\n\n## 注意事項\n\n- **OpenClaw は新しくサポートされたアプリです**: 関連機能を使用するには、先に OpenClaw CLI をインストールする必要があります。\n- **⚠️ 共通設定スニペット機能は削除されました**: プロバイダー切り替えが部分キーフィールドマージ（API キー、エンドポイント、モデルなどのみ置換）に変更されたため、ユーザーのその他の設定は自動的に保持され、共通設定スニペットは不要になりました。移行の詳細は上記「アーキテクチャ改善」セクションを参照してください。\n- **自動インポートは手動に変更されました**: 起動時に外部設定を自動インポートしなくなりました。必要に応じて「現在の設定をインポート」を手動でクリックしてください。\n- **OMO と OMO Slim は相互排他**: 同時に一つだけ有効にできます。切り替え時にもう一方は自動的に無効になります。\n- **バックアップ機能はデフォルトで有効**: ランタイム中に1時間ごとに自動バックアップします。バックアップパネルでポリシーを調整できます。\n\n---\n\n## 特別な感謝\n\n以下のコントリビューターの皆様、このリリースへの貢献に感謝します！\n\n@TinsFox @keithyt06 @kv-chiu @SaladDay @jnorthrup @JIA-ss @clx20000410 @ThendCN @yovinchen @zhu-jl18 @myjustify @funnytime75 @PeanutSplash @Jassy930 @stmoonar\n\n---\n\n## ダウンロードとインストール\n\n[Releases](https://github.com/farion1231/cc-switch/releases/latest) から適切なバージョンをダウンロードしてください。\n\n### システム要件\n\n| システム | 最小バージョン                   | アーキテクチャ                      |\n| -------- | -------------------------------- | ----------------------------------- |\n| Windows  | Windows 10 以降                  | x64                                 |\n| macOS    | macOS 10.15 (Catalina) 以降      | Intel (x64) / Apple Silicon (arm64) |\n| Linux    | 下表参照                         | x64                                 |\n\n### Windows\n\n| ファイル                                 | 説明                                                 |\n| ---------------------------------------- | ---------------------------------------------------- |\n| `CC-Switch-v3.11.0-Windows.msi`          | **推奨** - MSI インストーラー、自動更新対応          |\n| `CC-Switch-v3.11.0-Windows-Portable.zip` | ポータブル版、解凍して実行、レジストリ書き込みなし   |\n\n### macOS\n\n| ファイル                         | 説明                                                              |\n| -------------------------------- | ----------------------------------------------------------------- |\n| `CC-Switch-v3.11.0-macOS.zip`    | **推奨** - 解凍して Applications にドラッグ、Universal Binary     |\n| `CC-Switch-v3.11.0-macOS.tar.gz` | Homebrew インストールと自動更新用                                 |\n\n> **注意**: 作者が Apple Developer アカウントを持っていないため、初回起動時に「開発元を確認できません」という警告が表示される場合があります。一度閉じてから、「システム設定」→「プライバシーとセキュリティ」→「このまま開く」をクリックすると、その後は正常に開けます。\n\n### Homebrew (macOS)\n\n```bash\nbrew tap farion1231/ccswitch\nbrew install --cask cc-switch\n```\n\n更新：\n\n```bash\nbrew upgrade --cask cc-switch\n```\n\n### Linux\n\n| ディストリビューション                  | 推奨形式    | インストール方法                                                       |\n| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |\n| Ubuntu / Debian / Linux Mint / Pop!\\_OS | `.deb`      | `sudo dpkg -i CC-Switch-*.deb` または `sudo apt install ./CC-Switch-*.deb` |\n| Fedora / RHEL / CentOS / Rocky Linux    | `.rpm`      | `sudo rpm -i CC-Switch-*.rpm` または `sudo dnf install ./CC-Switch-*.rpm`  |\n| openSUSE                                | `.rpm`      | `sudo zypper install ./CC-Switch-*.rpm`                                |\n| Arch Linux / Manjaro                    | `.AppImage` | 実行権限を追加して直接実行、または AUR を使用                          |\n| その他のディストリビューション / 不明   | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage`              |\n"
  },
  {
    "path": "docs/release-notes/v3.11.0-zh.md",
    "content": "# CC Switch v3.11.0\n\n> OpenClaw 支持、会话管理器、备份管理与 50+ 项改进\n\n**[English →](v3.11.0-en.md) | [日本語版 →](v3.11.0-ja.md)**\n\n---\n\n## 概览\n\nCC Switch v3.11.0 是一次大规模更新，新增第五个应用 **OpenClaw** 的完整管理支持，同时带来全新的**会话管理器**和**备份管理**功能。此外，**Oh My OpenCode (OMO) 集成**、供应商切换的**部分键值合并**架构升级、**设置页面重构**等多项改进使整体体验更加完善。\n\n**发布日期**：2026-02-26\n\n**更新规模**：147 commits | 274 files changed | +32,179 / -5,467 lines\n\n---\n\n## 重点内容\n\n- **OpenClaw 支持**：第五个受管理应用，含 13 个供应商预设、Env/Tools/AgentsDefaults 配置编辑器、Workspace 文件管理\n- **会话管理器**：浏览五个应用的历史会话，支持目录导航和会话内搜索\n- **备份管理**：独立备份面板，可配置策略、定时备份、迁移前自动备份\n- **Oh My OpenCode 集成**：完整 OMO 配置管理，支持 OMO Slim 轻量模式\n- **部分键值合并（⚠️ 破坏性变更）**：供应商切换改为仅替换供应商相关字段，保留用户的其余设置；\"通用配置片段\"功能因此移除\n- **设置页面重构**：5 标签页布局，代码量减少约 40%\n- **6 组新供应商预设**：AWS Bedrock、SSAI Code、CrazyRouter、AICoding 等\n- **Thinking Budget Rectifier**：代理矫正器，更精细的 thinking budget 控制\n- **主题切换动画**：圆形揭示过渡动画，视觉体验升级\n- **WebDAV 自动同步**：支持自动同步与大文件防护\n\n---\n\n## 主要功能\n\n### OpenClaw 支持（新增第五应用）\n\nCC Switch 新增对 OpenClaw 的完整管理支持，这是继 Claude Code、Codex、Gemini CLI、OpenCode 之后的第五个受管理应用。\n\n- **供应商管理**：新增、编辑、切换、删除 OpenClaw 供应商，含 13 个内置预设\n- **配置编辑器**：Env（环境变量）、Tools（工具）、AgentsDefaults（代理默认值）三个专属配置面板\n- **Workspace 面板**：支持 HEARTBEAT/BOOTSTRAP/BOOT 文件管理及每日记忆\n- **Additive 叠加模式**：支持配置叠加而非覆盖\n- **默认模型按钮**：一键填充推荐模型，添加供应商时自动将建议模型注册到 allowlist\n- **品牌与交互**：专属品牌图标、应用切换淡入淡出过渡动画\n- **深链接支持**：通过 URL 导入 OpenClaw 供应商配置\n- **完整国际化**：中/英/日三语全面支持\n\n### 会话管理器 Sessions\n\n全新的会话管理器，帮助你浏览和检索历史会话记录。\n\n- 支持浏览 Claude Code、Codex、Gemini CLI、OpenCode、OpenClaw 五个应用的历史会话（#867，感谢 @TinsFox）\n- 目录导航和会话内搜索\n- 进入会话页面时默认过滤为当前应用，快速定位\n- 并行目录扫描 + 头尾 JSONL 读取，优化加载性能\n\n### 备份管理 Backup\n\n独立的备份管理面板，让数据安全更有保障。\n\n- 可配置备份策略：最大备份数量、自动清理规则\n- 运行时每小时定期自动备份\n- 数据库迁移前自动备份，带回填警告提示\n- 支持备份重命名和删除（含确认对话框）\n- 备份文件名使用本地时间，更直观\n\n### Oh My OpenCode (OMO) 集成\n\n完整的 Oh My OpenCode 配置文件管理。\n\n- Agent 模型选择、Category 配置、推荐模型填充（#972，感谢 @yovinchen）\n- 改进 Agent 模型选择 UX，修复 lowercase key 问题（#1004，感谢 @yovinchen）\n- OMO Slim 轻量模式支持\n- OMO 与 OMO Slim 互斥切换（数据库层级强制保证一致性）\n\n### 工作空间 Workspace\n\n- 每日记忆文件全文搜索，按日期排序\n- 目录路径可点击跳转，快速打开文件位置\n\n### 工具栏 Toolbar\n\n- AppSwitcher 根据窗口宽度自动折叠为紧凑模式\n- 紧凑模式切换平滑过渡动画\n\n### 设置 Settings\n\n- 代理和用量功能新增首次使用确认对话框，避免误操作\n- 新增 `enableLocalProxy` 开关，控制主页代理 UI 显示\n- 更精细的本地环境检查：CLI 工具版本检测（#870，感谢 @kv-chiu）、Volta 路径检测（#969，感谢 @myjustify）\n\n### 供应商预设 Preset\n\n- **AWS Bedrock**：支持 AKSK 和 API Key 两种认证方式（#1047，感谢 @keithyt06）\n- **SSAI Code**：合作伙伴预设，覆盖五端\n- **CrazyRouter**：合作伙伴预设及专属图标\n- **AICoding**：合作伙伴预设及推广文案\n- 更新国内模型供应商预设至最新版本\n- Qwen Coder 重命名为百炼 (Bailian)（#965，感谢 @zhu-jl18）\n\n### 其他新功能\n\n- **Thinking Budget Rectifier**：代理矫正器，更精细地控制 thinking budget 分配（#1005，感谢 @yovinchen）\n- **WebDAV 自动同步**：支持自动同步配置，并增加大文件防护（#923，感谢 @clx20000410；#1043，感谢 @SaladDay）\n- **主题切换动画**：圆形揭示过渡动画，视觉体验更流畅（#905，感谢 @funnytime75）\n- **Claude 配置编辑器快速开关**：快速切换常用配置项（#1012，感谢 @JIA-ss）\n- **动态端点提示**：根据 API 格式选择动态显示端点提示文本（#860，感谢 @zhu-jl18）\n- **用量仪表盘增强**：自动刷新、更强健的数据格式化（#942，感谢 @yovinchen）\n- **新增定价数据**：claude-opus-4-6 和 gpt-5.3-codex（#943，感谢 @yovinchen）\n- **静默启动优化**：静默启动选项仅在开机启动开启时显示\n\n---\n\n## 架构改进\n\n### 部分键值合并（⚠️ 破坏性变更）\n\n供应商切换从全量配置覆写改为部分键值合并策略（#1098）。\n\n**变更前**：切换供应商时，整个 `settings_config` 会覆写到 live 配置文件。这意味着用户在 live 文件中手动添加的非供应商设置（插件配置、MCP 配置、权限设置等）会在每次切换时丢失。为了弥补这个问题，之前版本提供了\"通用配置片段\"功能，让用户定义每次切换时都会合并的公共配置。\n\n**变更后**：切换供应商时，仅替换供应商相关的键值（API Key、端点、模型等），用户的其余设置完整保留。因此\"通用配置片段\"功能不再需要，已被移除。\n\n**影响与迁移**：\n- 如果你之前**没有使用**通用配置片段功能，此变更对你完全透明，切换体验只会更好\n- 如果你之前**使用了**通用配置片段功能来保留自定义设置（如 MCP 配置、权限等），升级后这些设置会在切换时自动保留，无需额外操作\n- 如果你利用通用配置片段做其他用途（如在切换时注入额外配置），请在升级后手动将这些配置写入 live 配置文件中\n\n此次重构删除了 6 个前端文件（3 个组件 + 3 个 hooks）、约 150 行后端死代码。\n\n### 手动导入替代自动导入\n\n启动时不再自动导入外部配置，改为手动点击\"导入当前配置\"按钮，避免意外覆盖用户数据。\n\n### OMO Variant 参数化\n\n通过 `OmoVariant` 结构体参数化消除 OMO 模块约 250 行重复代码。\n\n### OMO 公共配置移除\n\n删除二层合并系统，减少约 1,733 行代码，简化架构。\n\n### ProviderForm 拆分\n\nProviderForm 组件从 2,227 行减至 1,526 行，提取 5 个独立模块（opencodeFormUtils、useOmoModelSource、useOpencodeFormState、useOmoDraftState、useOpenclawFormState），可维护性显著提升。\n\n### MCP/Skills 共享组件\n\n提取 AppCountBar、AppToggleGroup、ListItemRow 等共享组件，减少 MCP 和 Skills 面板的重复代码（#897，感谢 @PeanutSplash）。\n\n### 设置页面重构\n\n设置页面重构为 5 标签页布局（通用 | 代理 | 高级 | 用量 | 关于），SettingsPage 代码从约 716 行减至约 426 行。\n\n### 其他改进\n\n- 终端统一：全局设置统一终端选择，新增 WezTerm 支持\n- Claude 模型引用从 4.5 更新到 4.6\n\n---\n\n## Bug 修复\n\n### 严重修复\n\n- **Windows 主目录回归**：恢复默认主目录解析，防止 Git/MSYS 环境下数据库路径变更导致数据\"丢失\"\n- **Linux 白屏**：禁用 AMD GPU 的 WebKitGTK 硬件加速，解决部分 Linux 系统启动白屏问题（#986，感谢 @ThendCN）\n- **OpenAI Beta 参数**：不再为 `/v1/chat/completions` 添加 `?beta=true`，修复 Nvidia 等使用 OpenAI Chat 格式的供应商请求失败（#1052，感谢 @jnorthrup）\n- **健康检查认证**：尊重供应商 `auth_mode` 设置，避免仅支持 Bearer 认证的代理服务健康检查失败（#824，感谢 @Jassy930）\n\n### 供应商预设修复\n\n- 修复 OpenClaw `/v1` 前缀双重路径问题\n- Opus 定价修正（$15/$75 → $5/$25）并升级到 4.6\n- AIGoCode URL 统一为 `https://api.aigocode.com`\n- Zhipu GLM 移除过时合作伙伴状态\n- 新建 Claude 供应商时 API Key 输入框可见性恢复\n- 非活跃供应商隐藏快速开关，显示上下文感知的 JSON 编辑器提示\n\n### OMO 修复\n\n- omo-slim 分类检查补齐（add/form/mutation 路径）\n- OMO Slim 供应商变更后正确失效查询缓存\n- OMO agent/category 推荐模型与上游源同步\n- \"填充推荐\"按钮失败时增加 toast 反馈\n- 移除 OMO/OMO Slim 最后一个供应商的删除限制\n- OpenCode 未配置模型时拒绝保存（#932，感谢 @yovinchen）\n\n### OpenClaw 修复\n\n- 修复 25 个缺失 i18n key、替换 key={index} 为稳定 ID、深链接 additive 合并等代码审查问题\n- EnvPanel 健壮性增强（NaN 守卫、使用条目键名而非数组索引）\n- i18n 重复键合并，恢复供应商表单翻译\n\n### 平台修复\n\n- Windows 静默启动时窗口闪烁（#901，感谢 @funnytime75）\n- 标题栏暗黑模式跟随主题（#903，感谢 @funnytime75）\n- Windows Skills 路径分隔符匹配（#868，感谢 @stmoonar）\n- WSL 辅助函数条件编译\n\n### UI 修复\n\n- 工具栏高度裁切导致 AppSwitcher 被遮挡\n- 有新版本时显示更新徽章而非绿色对勾\n- 仅 Claude/Codex 应用显示会话管理器按钮\n- SQL 导入/导出卡片暗黑模式样式统一（#1067，感谢 @SaladDay）\n\n### 其他修复\n\n- 会话管理器硬编码中文字符串替换为 i18n key\n- Skill 文档 URL 分支和路径修正（#977，感谢 @yovinchen）\n- OpenCode install.sh 安装路径检测补齐（#988，感谢 @zhu-jl18）\n- Skill ZIP 符号链接解析修复（#1040，感谢 @yovinchen）\n- MCP 表单补齐 OpenCode 复选框（#1026，感谢 @yovinchen）\n- useProvidersQuery 中自动导入副作用移除\n\n---\n\n## 性能优化\n\n- 会话面板并行目录扫描 + 头尾 JSONL 读取，大幅提升会话列表加载速度\n- 移除 Tauri 本地 IPC 不必要的 query cache，减少内存占用\n\n---\n\n## 文档\n\n- 赞助商更新：SSSAiCode、Crazyrouter、AICoding、Right Code、MiniMax\n- 新增用户手册（#979，感谢 @yovinchen）\n\n---\n\n## 说明与注意事项\n\n- **OpenClaw 为新支持的应用**：需要先安装 OpenClaw CLI 才能使用相关功能。\n- **⚠️ 通用配置片段功能已移除**：由于供应商切换改为部分键值合并（仅替换 API Key、端点、模型等字段），用户的其余设置会自动保留，\"通用配置片段\"功能不再需要。详见上方\"架构改进\"章节的迁移说明。\n- **自动导入已改为手动**：启动时不再自动导入外部配置，请在需要时手动点击\"导入当前配置\"。\n- **OMO 与 OMO Slim 互斥**：同一时间只能启用其中一个，切换时另一个会自动禁用。\n- **备份功能默认开启**：运行时每小时自动备份，可在备份面板调整策略。\n\n---\n\n## 特别感谢\n\n感谢以下贡献者为本版本做出的贡献！\n\n@TinsFox @keithyt06 @kv-chiu @SaladDay @jnorthrup @JIA-ss @clx20000410 @ThendCN @yovinchen @zhu-jl18 @myjustify @funnytime75 @PeanutSplash @Jassy930 @stmoonar\n\n---\n\n## 下载与安装\n\n访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。\n\n### 系统要求\n\n| 系统    | 最低版本                      | 架构                                |\n| ------- | ----------------------------- | ----------------------------------- |\n| Windows | Windows 10 及以上             | x64                                 |\n| macOS   | macOS 10.15 (Catalina) 及以上 | Intel (x64) / Apple Silicon (arm64) |\n| Linux   | 见下表                        | x64                                 |\n\n### Windows\n\n| 文件                                     | 说明                                |\n| ---------------------------------------- | ----------------------------------- |\n| `CC-Switch-v3.11.0-Windows.msi`          | **推荐** - MSI 安装包，支持自动更新 |\n| `CC-Switch-v3.11.0-Windows-Portable.zip` | 便携版，解压即用，不写入注册表      |\n\n### macOS\n\n| 文件                             | 说明                                                      |\n| -------------------------------- | --------------------------------------------------------- |\n| `CC-Switch-v3.11.0-macOS.zip`    | **推荐** - 解压后拖入 Applications 即可，Universal Binary |\n| `CC-Switch-v3.11.0-macOS.tar.gz` | 用于 Homebrew 安装和自动更新                              |\n\n> **注意**：由于作者没有苹果开发者账号，首次打开可能出现\"未知开发者\"警告，请先关闭，然后前往\"系统设置\" → \"隐私与安全性\" → 点击\"仍要打开\"，之后便可以正常打开\n\n### Homebrew（macOS）\n\n```bash\nbrew tap farion1231/ccswitch\nbrew install --cask cc-switch\n```\n\n更新：\n\n```bash\nbrew upgrade --cask cc-switch\n```\n\n### Linux\n\n| 发行版                                  | 推荐格式    | 安装方式                                                               |\n| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |\n| Ubuntu / Debian / Linux Mint / Pop!\\_OS | `.deb`      | `sudo dpkg -i CC-Switch-*.deb` 或 `sudo apt install ./CC-Switch-*.deb` |\n| Fedora / RHEL / CentOS / Rocky Linux    | `.rpm`      | `sudo rpm -i CC-Switch-*.rpm` 或 `sudo dnf install ./CC-Switch-*.rpm`  |\n| openSUSE                                | `.rpm`      | `sudo zypper install ./CC-Switch-*.rpm`                                |\n| Arch Linux / Manjaro                    | `.AppImage` | 添加执行权限后直接运行，或使用 AUR                                     |\n| 其他发行版 / 不确定                     | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage`              |\n"
  },
  {
    "path": "docs/release-notes/v3.11.1-en.md",
    "content": "# CC Switch v3.11.1\n\n> Revert Partial Key-Field Merging, Restore Common Config Snippet & Bug Fixes\n\n**[中文版 →](v3.11.1-zh.md) | [日本語版 →](v3.11.1-ja.md)**\n\n---\n\n## Overview\n\nCC Switch v3.11.1 is a hotfix release that reverts the **Partial Key-Field Merging** architecture introduced in v3.11.0, restoring the proven \"**full config overwrite + Common Config Snippet**\" mechanism. It also includes several UI and platform compatibility fixes.\n\n**Release Date**: 2026-02-28\n\n**Update Scale**: 8 commits | 52 files changed | +3,948 / -1,411 lines\n\n---\n\n## Highlights\n\n- **Restore Full Config Overwrite + Common Config Snippet**: Reverted partial key-field merging due to critical data loss issues; restores full config snapshot write and Common Config Snippet UI\n- **Proxy Panel Improvements**: Proxy toggle moved into panel body for better discoverability of takeover options\n- **Theme & Compact Mode Fixes**: \"Follow System\" theme now auto-updates; compact mode exit works correctly\n- **Windows Compatibility**: Disabled env check and one-click install to prevent protocol handler side effects\n\n---\n\n## Reverted\n\n### Restore Full Config Overwrite + Common Config Snippet\n\nReverted the partial key-field merging refactoring introduced in v3.11.0 (revert 992dda5c).\n\n**Why reverted**: The partial key-field merging approach had three critical issues:\n1. **Data loss on switch**: Non-whitelisted custom fields were silently dropped during provider switching\n2. **Permanent backfill stripping**: Backfill permanently removed non-key fields from the database, causing irreversible data loss\n3. **Maintenance burden**: The whitelist of \"key fields\" required constant maintenance as new config keys were added\n\n**What's restored**:\n- Full config snapshot write on provider switch (predictable, complete overwrite)\n- Common Config Snippet UI and backend commands\n- 6 frontend components/hooks (3 components + 3 hooks)\n\n**Migration**:\n- If you upgraded to v3.11.0 and your providers lost custom fields, re-import your config or manually re-add the missing fields\n- Common Config Snippet is available again — use it to define shared config that should persist across provider switches\n\n---\n\n## Changed\n\n- **Proxy Panel Layout**: Moved proxy on/off toggle from accordion header into panel content area, placed directly above app takeover options. This ensures users see takeover configuration immediately after enabling the proxy, avoiding the common mistake of enabling the proxy without configuring takeover\n- **Manual Import for OpenCode/OpenClaw**: Removed auto-import on startup; empty state now shows an \"Import Current Config\" button, consistent with Claude/Codex/Gemini behavior\n\n---\n\n## Fixed\n\n- **\"Follow System\" Theme Not Auto-Updating**: Delegated to Tauri's native theme tracking (`set_window_theme(None)`) so the WebView's `prefers-color-scheme` media query stays in sync with OS theme changes\n- **Compact Mode Cannot Exit**: Restored `flex-1` on `toolbarRef` so `useAutoCompact`'s exit condition triggers correctly based on available width instead of content width\n- **Proxy Takeover Toast Shows {{app}}**: Added missing `app` interpolation parameter to i18next `t()` calls for proxy takeover enabled/disabled messages\n- **Windows Protocol Handler Side Effects**: Disabled environment check and one-click install on Windows to prevent unintended protocol handler registration\n\n---\n\n## Notes & Considerations\n\n- **Common Config Snippet is back**: If you relied on this feature in v3.10.x and earlier, it works the same way again. Define shared config that should persist across all provider switches.\n- **v3.11.0 Partial Key-Field Merging users**: If you noticed missing config fields after switching providers in v3.11.0, re-import your config to restore them.\n\n---\n\n## Download & Installation\n\nVisit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download the appropriate version.\n\n### System Requirements\n\n| System  | Minimum Version                 | Architecture                        |\n| ------- | ------------------------------- | ----------------------------------- |\n| Windows | Windows 10 or later             | x64                                 |\n| macOS   | macOS 10.15 (Catalina) or later | Intel (x64) / Apple Silicon (arm64) |\n| Linux   | See table below                 | x64                                 |\n\n### Windows\n\n| File                                     | Description                                          |\n| ---------------------------------------- | ---------------------------------------------------- |\n| `CC-Switch-v3.11.1-Windows.msi`          | **Recommended** - MSI installer with auto-update     |\n| `CC-Switch-v3.11.1-Windows-Portable.zip` | Portable version, extract and run, no registry write |\n\n### macOS\n\n| File                             | Description                                                          |\n| -------------------------------- | -------------------------------------------------------------------- |\n| `CC-Switch-v3.11.1-macOS.zip`    | **Recommended** - Extract and drag to Applications, Universal Binary |\n| `CC-Switch-v3.11.1-macOS.tar.gz` | For Homebrew installation and auto-update                            |\n\n> **Note**: Since the author doesn't have an Apple Developer account, you may see an \"unidentified developer\" warning on first launch. Please close it, then go to \"System Settings\" → \"Privacy & Security\" → click \"Open Anyway\", and it will open normally afterwards.\n\n### Homebrew (macOS)\n\n```bash\nbrew tap farion1231/ccswitch\nbrew install --cask cc-switch\n```\n\nUpdate:\n\n```bash\nbrew upgrade --cask cc-switch\n```\n\n### Linux\n\n| Distribution                            | Recommended Format | Installation Method                                                    |\n| --------------------------------------- | ------------------ | ---------------------------------------------------------------------- |\n| Ubuntu / Debian / Linux Mint / Pop!\\_OS | `.deb`             | `sudo dpkg -i CC-Switch-*.deb` or `sudo apt install ./CC-Switch-*.deb` |\n| Fedora / RHEL / CentOS / Rocky Linux    | `.rpm`             | `sudo rpm -i CC-Switch-*.rpm` or `sudo dnf install ./CC-Switch-*.rpm`  |\n| openSUSE                                | `.rpm`             | `sudo zypper install ./CC-Switch-*.rpm`                                |\n| Arch Linux / Manjaro                    | `.AppImage`        | Add execute permission and run directly, or use AUR                    |\n| Other distributions / Unsure            | `.AppImage`        | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage`              |\n"
  },
  {
    "path": "docs/release-notes/v3.11.1-ja.md",
    "content": "# CC Switch v3.11.1\n\n> 部分キーフィールドマージの撤回、共通設定スニペットの復元とバグ修正\n\n**[中文版 →](v3.11.1-zh.md) | [English →](v3.11.1-en.md)**\n\n---\n\n## 概要\n\nCC Switch v3.11.1 は修正リリースです。v3.11.0 で導入された**部分キーフィールドマージ**アーキテクチャを撤回し、実績のある「**完全設定上書き + 共通設定スニペット**」メカニズムを復元しました。また、複数の UI とプラットフォーム互換性の問題を修正しています。\n\n**リリース日**: 2026-02-28\n\n**更新規模**: 8 commits | 52 files changed | +3,948 / -1,411 lines\n\n---\n\n## ハイライト\n\n- **完全設定上書き + 共通設定スニペットの復元**: 重大なデータ損失問題のため部分キーフィールドマージを撤回、完全設定スナップショット書き込みと共通設定スニペット UI を復元\n- **プロキシパネルの改善**: プロキシトグルをパネル本体に移動し、テイクオーバーオプションの発見性を向上\n- **テーマとコンパクトモードの修正**: 「システムに従う」テーマが正しく自動更新、コンパクトモードの終了が正常に動作\n- **Windows 互換性**: プロトコルハンドラーの副作用を防ぐため、環境チェックとワンクリックインストールを無効化\n\n---\n\n## 撤回\n\n### 完全設定上書き + 共通設定スニペットの復元\n\nv3.11.0 で導入された部分キーフィールドマージリファクタリングを撤回しました（revert 992dda5c）。\n\n**撤回理由**: 部分キーフィールドマージのアプローチには3つの重大な問題がありました：\n1. **切り替え時のデータ損失**: ホワイトリストにないカスタムフィールドがプロバイダー切り替え時にサイレントに破棄された\n2. **バックフィルによる永続的な剥離**: バックフィル操作がデータベースから非キーフィールドを永続的に削除し、不可逆なデータ損失を引き起こした\n3. **メンテナンス負担**: 「キーフィールド」のホワイトリストは新しい設定キーが追加されるたびに継続的なメンテナンスが必要\n\n**復元された内容**:\n- プロバイダー切り替え時の完全設定スナップショット書き込み（予測可能な完全上書き）\n- 共通設定スニペット UI およびバックエンドコマンド\n- 6つのフロントエンドファイル（コンポーネント 3つ + hooks 3つ）\n\n**移行ガイド**:\n- v3.11.0 にアップグレードしてプロバイダーのカスタムフィールドが失われた場合は、設定を再インポートするか、欠落したフィールドを手動で追加してください\n- 共通設定スニペット機能が再び利用可能です — プロバイダー切り替え時に保持すべき共有設定を定義するために使用してください\n\n---\n\n## 変更\n\n- **プロキシパネルレイアウト**: プロキシのオン/オフトグルをアコーディオンヘッダーからパネルのコンテンツエリアに移動し、アプリテイクオーバーオプションの直上に配置。プロキシを有効にした後すぐにテイクオーバー設定が見えるようになり、「プロキシだけ有効にしてテイクオーバーを設定しない」というよくある誤操作を防止\n- **OpenCode/OpenClaw の手動インポート**: 起動時の自動インポートを削除。空の状態ページに「現在の設定をインポート」ボタンを表示し、Claude/Codex/Gemini と同じ動作に統一\n\n---\n\n## 修正\n\n- **「システムに従う」テーマが自動更新されない**: Tauri のネイティブテーマ追跡（`set_window_theme(None)`）に委譲し、WebView の `prefers-color-scheme` メディアクエリが OS テーマの変更に同期するように修正\n- **コンパクトモードを終了できない**: `toolbarRef` の `flex-1` を復元し、`useAutoCompact` の終了条件がコンテンツ幅ではなく利用可能な幅に基づいて正しくトリガーされるように修正\n- **プロキシテイクオーバー Toast に {{app}} が表示される**: プロキシテイクオーバーの有効/無効メッセージの i18next `t()` 呼び出しに欠落していた `app` 補間パラメータを追加\n- **Windows プロトコルハンドラーの副作用**: 意図しないプロトコルハンドラー登録を防ぐため、Windows で環境チェックとワンクリックインストールを無効化\n\n---\n\n## 注意事項\n\n- **共通設定スニペットが復活しました**: v3.10.x 以前でこの機能を使用していた場合、同じ方法で動作します。プロバイダー切り替え時に保持すべき共有設定を定義するために使用してください。\n- **v3.11.0 部分キーフィールドマージユーザーの方へ**: v3.11.0 でプロバイダー切り替え後に設定フィールドが欠落していた場合は、設定を再インポートして復元してください。\n\n---\n\n## ダウンロードとインストール\n\n[Releases](https://github.com/farion1231/cc-switch/releases/latest) から適切なバージョンをダウンロードしてください。\n\n### システム要件\n\n| システム | 最小バージョン                   | アーキテクチャ                      |\n| -------- | -------------------------------- | ----------------------------------- |\n| Windows  | Windows 10 以降                  | x64                                 |\n| macOS    | macOS 10.15 (Catalina) 以降      | Intel (x64) / Apple Silicon (arm64) |\n| Linux    | 下表参照                         | x64                                 |\n\n### Windows\n\n| ファイル                                 | 説明                                                 |\n| ---------------------------------------- | ---------------------------------------------------- |\n| `CC-Switch-v3.11.1-Windows.msi`          | **推奨** - MSI インストーラー、自動更新対応          |\n| `CC-Switch-v3.11.1-Windows-Portable.zip` | ポータブル版、解凍して実行、レジストリ書き込みなし   |\n\n### macOS\n\n| ファイル                         | 説明                                                              |\n| -------------------------------- | ----------------------------------------------------------------- |\n| `CC-Switch-v3.11.1-macOS.zip`    | **推奨** - 解凍して Applications にドラッグ、Universal Binary     |\n| `CC-Switch-v3.11.1-macOS.tar.gz` | Homebrew インストールと自動更新用                                 |\n\n> **注意**: 作者が Apple Developer アカウントを持っていないため、初回起動時に「開発元を確認できません」という警告が表示される場合があります。一度閉じてから、「システム設定」→「プライバシーとセキュリティ」→「このまま開く」をクリックすると、その後は正常に開けます。\n\n### Homebrew (macOS)\n\n```bash\nbrew tap farion1231/ccswitch\nbrew install --cask cc-switch\n```\n\n更新：\n\n```bash\nbrew upgrade --cask cc-switch\n```\n\n### Linux\n\n| ディストリビューション                  | 推奨形式    | インストール方法                                                       |\n| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |\n| Ubuntu / Debian / Linux Mint / Pop!\\_OS | `.deb`      | `sudo dpkg -i CC-Switch-*.deb` または `sudo apt install ./CC-Switch-*.deb` |\n| Fedora / RHEL / CentOS / Rocky Linux    | `.rpm`      | `sudo rpm -i CC-Switch-*.rpm` または `sudo dnf install ./CC-Switch-*.rpm`  |\n| openSUSE                                | `.rpm`      | `sudo zypper install ./CC-Switch-*.rpm`                                |\n| Arch Linux / Manjaro                    | `.AppImage` | 実行権限を追加して直接実行、または AUR を使用                          |\n| その他のディストリビューション / 不明   | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage`              |\n"
  },
  {
    "path": "docs/release-notes/v3.11.1-zh.md",
    "content": "# CC Switch v3.11.1\n\n> 回退部分键值合并、恢复通用配置片段与多项修复\n\n**[English →](v3.11.1-en.md) | [日本語版 →](v3.11.1-ja.md)**\n\n---\n\n## 概览\n\nCC Switch v3.11.1 是一个修复版本，回退了 v3.11.0 中引入的**部分键值合并**架构，恢复经过验证的「**全量配置覆写 + 通用配置片段**」机制，同时修复了多个 UI 和平台兼容性问题。\n\n**发布日期**：2026-02-28\n\n**更新规模**：8 commits | 52 files changed | +3,948 / -1,411 lines\n\n---\n\n## 重点内容\n\n- **恢复全量配置覆写 + 通用配置片段**：因关键数据丢失问题回退部分键值合并，恢复完整配置快照写入和通用配置片段 UI\n- **代理面板交互优化**：代理开关移入面板内部，接管选项一目了然\n- **主题与紧凑模式修复**：「跟随系统」主题现可正确自动更新，紧凑模式退出恢复正常\n- **Windows 兼容性**：禁用环境检查和一键安装，防止协议处理程序副作用\n\n---\n\n## 回退\n\n### 恢复全量配置覆写 + 通用配置片段\n\n回退了 v3.11.0 中引入的部分键值合并重构（revert 992dda5c）。\n\n**回退原因**：部分键值合并方案存在三个关键缺陷：\n1. **切换时数据丢失**：非白名单的自定义字段在供应商切换时被静默丢弃\n2. **回填永久剥离**：回填操作永久移除数据库中的非键字段，造成不可逆的数据丢失\n3. **维护成本高**：「键字段」白名单需要随新配置项不断维护，容易遗漏\n\n**恢复的内容**：\n- 供应商切换时的完整配置快照写入（可预测的全量覆写）\n- 通用配置片段 UI 及后端命令\n- 6 个前端文件（3 个组件 + 3 个 hooks）\n\n**迁移说明**：\n- 如果你在 v3.11.0 中切换供应商后丢失了自定义字段，请重新导入配置或手动补回缺失的字段\n- 通用配置片段功能已恢复——用它来定义切换供应商时需要保留的共享配置\n\n---\n\n## 变更\n\n- **代理面板交互优化**：将代理开关从折叠面板标题移入面板内部，紧邻应用接管选项。确保用户启用代理后能立即看到接管配置，避免「只开代理不接管」的常见误操作\n- **OpenCode/OpenClaw 手动导入**：移除启动时自动导入供应商配置的行为，改为在空状态页显示「导入当前配置」按钮，与 Claude/Codex/Gemini 保持一致\n\n---\n\n## 修复\n\n- **「跟随系统」主题不自动更新**：改用 Tauri 原生主题追踪（`set_window_theme(None)`），使 WebView 的 `prefers-color-scheme` 媒体查询能正确响应 OS 主题切换\n- **紧凑模式无法退出**：恢复 `toolbarRef` 上的 `flex-1` class，修复 `useAutoCompact` 的退出条件因宽度计算错误而永远不触发的问题\n- **代理接管 Toast 显示 {{app}}**：为 proxy takeover 的 i18next `t()` 调用补充缺失的 `app` 插值参数\n- **Windows 协议处理副作用**：在 Windows 上禁用环境检查和一键安装功能，防止协议处理程序注册引发的意外副作用\n\n---\n\n## 说明与注意事项\n\n- **通用配置片段已恢复**：如果你在 v3.10.x 及更早版本中使用了此功能，它的工作方式与之前完全一致。用它来定义切换供应商时需要保留的共享配置。\n- **v3.11.0 部分键值合并用户**：如果你在 v3.11.0 中切换供应商后发现配置字段丢失，请重新导入配置以恢复。\n\n---\n\n## 下载与安装\n\n访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。\n\n### 系统要求\n\n| 系统    | 最低版本                      | 架构                                |\n| ------- | ----------------------------- | ----------------------------------- |\n| Windows | Windows 10 及以上             | x64                                 |\n| macOS   | macOS 10.15 (Catalina) 及以上 | Intel (x64) / Apple Silicon (arm64) |\n| Linux   | 见下表                        | x64                                 |\n\n### Windows\n\n| 文件                                     | 说明                                |\n| ---------------------------------------- | ----------------------------------- |\n| `CC-Switch-v3.11.1-Windows.msi`          | **推荐** - MSI 安装包，支持自动更新 |\n| `CC-Switch-v3.11.1-Windows-Portable.zip` | 便携版，解压即用，不写入注册表      |\n\n### macOS\n\n| 文件                             | 说明                                                      |\n| -------------------------------- | --------------------------------------------------------- |\n| `CC-Switch-v3.11.1-macOS.zip`    | **推荐** - 解压后拖入 Applications 即可，Universal Binary |\n| `CC-Switch-v3.11.1-macOS.tar.gz` | 用于 Homebrew 安装和自动更新                              |\n\n> **注意**：由于作者没有苹果开发者账号，首次打开可能出现「未知开发者」警告，请先关闭，然后前往「系统设置」→「隐私与安全性」→ 点击「仍要打开」，之后便可以正常打开\n\n### Homebrew（macOS）\n\n```bash\nbrew tap farion1231/ccswitch\nbrew install --cask cc-switch\n```\n\n更新：\n\n```bash\nbrew upgrade --cask cc-switch\n```\n\n### Linux\n\n| 发行版                                  | 推荐格式    | 安装方式                                                               |\n| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |\n| Ubuntu / Debian / Linux Mint / Pop!\\_OS | `.deb`      | `sudo dpkg -i CC-Switch-*.deb` 或 `sudo apt install ./CC-Switch-*.deb` |\n| Fedora / RHEL / CentOS / Rocky Linux    | `.rpm`      | `sudo rpm -i CC-Switch-*.rpm` 或 `sudo dnf install ./CC-Switch-*.rpm`  |\n| openSUSE                                | `.rpm`      | `sudo zypper install ./CC-Switch-*.rpm`                                |\n| Arch Linux / Manjaro                    | `.AppImage` | 添加执行权限后直接运行，或使用 AUR                                     |\n| 其他发行版 / 不确定                     | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage`              |\n"
  },
  {
    "path": "docs/release-notes/v3.12.0-en.md",
    "content": "# CC Switch v3.12.0\n\n> Stream Check Returns, OpenAI Responses API Arrives, and OpenClaw / WebDAV Get a Major Upgrade\n\n**[中文版 →](v3.12.0-zh.md) | [日本語版 →](v3.12.0-ja.md)**\n\n---\n\n## Overview\n\nCC Switch v3.12.0 is a feature release focused on provider compatibility, OpenClaw editing, Common Config usability, and sync/data reliability. It restores the **Model Health Check (Stream Check)** UI with improved stability, adds **OpenAI Responses API** format conversion, expands provider presets for **Ucloud**, **Micu**, **X-Code API**, **Novita**, and **Bailian For Coding**, and upgrades **WebDAV sync** with dual-layer versioning.\n\n**Release Date**: 2026-03-09\n\n**Update Scale**: 56 commits | 221 files changed | +20,582 / -8,026 lines\n\n---\n\n## Highlights\n\n- **Stream Check returns**: Restored the model health check UI, added first-run confirmation, and fixed `openai_chat` provider support\n- **OpenAI Responses API**: Added `api_format = \"openai_responses\"` with bidirectional conversion and shared conversion cleanup — simply select the Responses API format when adding a provider and enable proxy takeover, and you can use GPT-series models in Claude Code!\n- **OpenClaw overhaul**: Introduced JSON5 round-trip config editing, a config health banner, better agent model selection, and a User-Agent toggle\n- **Preset expansion**: Added Ucloud, Micu, X-Code API, Novita, and Bailian For Coding updates, plus SiliconFlow partner badge and model-role badges\n- **Sync and maintenance improvements**: Added WebDAV protocol v2 + db-v6 versioning, daily rollups, incremental auto-vacuum, and sync-aware backup\n- **Common Config usability improvements**: After updating a Common Config Snippet, it is now automatically applied when switching providers — no more manual checkbox needed\n\n---\n\n## Main Features\n\n### Model Health Check (Stream Check)\n\nRestored the Stream Check panel for live provider validation, improving the reliability of provider management.\n\n- Restored Stream Check UI panel with single and batch provider availability testing\n- Added first-run confirmation dialog to prevent unsupported providers from showing misleading errors\n- Fixed detection compatibility for `openai_chat` API format providers\n\n### OpenAI Responses API\n\nAdded native support for providers using the OpenAI Responses API with a new `openai_responses` API format.\n\n- New `api_format = \"openai_responses\"` provider format option\n- Bidirectional Anthropic Messages <-> OpenAI Responses API format conversion\n- Consolidated shared conversion logic to reduce code duplication\n\n### Bedrock Request Optimizer\n\nAdded a PRE-SEND phase request optimizer for AWS Bedrock providers to improve compatibility and performance.\n\n- PRE-SEND thinking + cache injection optimizer (#1301, thanks @keithyt06)\n\n### OpenClaw Config Enhancements\n\nComprehensive upgrade to the OpenClaw configuration editing experience with richer management capabilities.\n\n- JSON5 round-trip write-back: preserves comments and formatting when editing configs\n- EnvPanel JSON editing mode and `tools.profile` selection support\n- New config validation warnings and config health status checks\n- Improved agent model dropdown with recommended model fill from provider presets\n- User-Agent toggle: optionally append OpenClaw identifier to requests (defaults to off)\n- Legacy timeout configuration auto-migration\n\n### Provider Presets\n\nNew and expanded provider presets covering more providers and use cases.\n\n- **Ucloud**: Added `endpointCandidates` and OpenClaw defaults, refreshed `templateValues` / `suggestedDefaults`\n- **Micu**: Added preset defaults and OpenClaw recommended models\n- **X-Code API**: Added Claude presets and `endpointCandidates`\n- **Novita**: New provider preset (#1192, thanks @Alex-wuhu)\n- **Bailian For Coding**: New provider preset (#1263, thanks @suki135246)\n- **SiliconFlow**: Added partner badge\n- **Model Role Badges**: Provider presets now support model-role badge display\n\n### WebDAV Sync Enhancements\n\nWebDAV sync introduces dual-layer versioning for improved sync reliability and data safety.\n\n- New WebDAV protocol v2 + db-v6 dual-layer versioning\n- Confirmation dialog when toggling WebDAV auto-sync on/off to prevent accidental changes\n- Sync-aware backup: uses a sync-specific backup variant that skips local-only table data\n\n### Usage & Data\n\nEnhanced usage statistics and data maintenance capabilities for finer-grained data management, significantly reducing database growth rate.\n\n- Daily rollups: aggregate usage data by day to reduce storage overhead\n- Auto-vacuum: incremental database cleanup to maintain database health\n- UsageFooter extra statistics fields (#1137, thanks @bugparty)\n\n### Other New Features\n\n- **Session Deletion**: Per-provider session cleanup with path safety validation\n- **Claude Auth Field Selector**: Restored authentication field selector\n- **Failover Toggle on Main Page**: Moved the failover toggle to display independently on the main page with a first-use confirmation dialog\n- **Common Config Auto-Extract**: On first run, automatically extracts common config snippets from live config files\n- **New Provider Page Improvements**: Improved new provider page experience (#1155, thanks @wugeer)\n\n---\n\n## Architecture Improvements\n\n### Common Config Runtime Overlay\n\nCommon Config Snippets are now applied as a runtime overlay instead of being materialized into stored provider configs.\n\n**Before**: Common Config content was merged directly into each provider's `settings_config` on save or switch. This caused shared configuration to be duplicated across every provider entry, requiring manual sync when changes were needed.\n\n**After**: Common Config is only injected as a runtime overlay when switching providers and writing to the live file — provider entries themselves no longer contain shared configuration. This means modifying Common Config takes effect immediately without updating each provider individually.\n\n### Common Config Auto-Extract\n\nOn first run, if no Common Config Snippet exists in the database, one is automatically extracted from the current live config. This ensures users upgrading from older versions do not lose their existing shared configuration settings.\n\n### Periodic Maintenance Timer Consolidation\n\nConsolidated daily rollups and auto-vacuum into a unified periodic maintenance timer, eliminating resource contention and complexity from multiple independent timers.\n\n---\n\n## Bug Fixes\n\n### Proxy & Streaming\n\n- Fixed OpenAI ChatCompletion -> Anthropic Messages streaming conversion\n- Added Codex `/responses/compact` route support (#1194, thanks @Tsukumi233)\n- Improved TOML config merge logic to prevent key-value loss\n- Improved proxy forwarder failure logs with additional diagnostic information\n\n### Provider & Preset Fixes\n\n- Renamed X-Code to X-Code API for consistent branding\n- Fixed SSSAiCode `/v1` path issue\n- Removed incorrect `www` prefix from AICoding URLs\n- Fixed new provider page line-break deletion issue (#1155, thanks @wugeer)\n\n### Platform Fixes\n\n- Fixed cache hit token statistics not being reported (#1244, thanks @a1398394385)\n- Fixed minimize-to-tray causing auto exit after some time (#1245, thanks @YewFence)\n\n### i18n Fixes\n\n- Added 69 missing translation keys and removed remaining hardcoded Chinese strings\n- Fixed model test panel i18n issues\n- Normalized JSON5 slash escaping to prevent i18n string parsing errors\n\n### UI Fixes\n\n- Fixed Skills count display (#1295, thanks @fzzv)\n- Removed HTTP status code display from endpoint speed test to reduce visual noise\n- Fixed outline button styling (#1222, thanks @Sube-py)\n\n---\n\n## Performance\n\n- Skip unnecessary OpenClaw config writes when config is unchanged, reducing disk I/O\n\n---\n\n## Documentation\n\n- Restructured the user manual for i18n and added complete EN/JA coverage\n- Added OpenClaw usage documentation and completed settings documentation\n- Added UCloud sponsor information\n- Reorganized the docs directory and synced README feature sections across EN/ZH/JA\n\n---\n\n## Notes & Considerations\n\n- **Common Config now uses runtime overlay**: Common Config Snippets are no longer materialized into each provider's stored config. They are dynamically applied at switch time. Modifying Common Config takes effect immediately without updating each provider.\n- **Stream Check requires first-use confirmation**: A confirmation dialog appears when using the model health check for the first time. Testing proceeds only after confirmation.\n- **OpenClaw User-Agent toggle defaults to off**: The User-Agent identifier must be manually enabled in the OpenClaw configuration.\n\n---\n\n## Special Thanks\n\nThanks to all contributors for their contributions to this release!\n\n@keithyt06 @bugparty @Alex-wuhu @suki135246 @Tsukumi233 @wugeer @fzzv @Sube-py @a1398394385 @YewFence\n\n---\n\n## Download & Installation\n\nVisit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download the appropriate version.\n\n### System Requirements\n\n| System  | Minimum Version                 | Architecture                        |\n| ------- | ------------------------------- | ----------------------------------- |\n| Windows | Windows 10 or later             | x64                                 |\n| macOS   | macOS 10.15 (Catalina) or later | Intel (x64) / Apple Silicon (arm64) |\n| Linux   | See table below                 | x64                                 |\n\n### Windows\n\n| File                                     | Description                                          |\n| ---------------------------------------- | ---------------------------------------------------- |\n| `CC-Switch-v3.12.0-Windows.msi`          | **Recommended** - MSI installer with auto-update     |\n| `CC-Switch-v3.12.0-Windows-Portable.zip` | Portable version, extract and run, no registry write |\n\n### macOS\n\n| File                             | Description                                                          |\n| -------------------------------- | -------------------------------------------------------------------- |\n| `CC-Switch-v3.12.0-macOS.zip`    | **Recommended** - Extract and drag to Applications, Universal Binary |\n| `CC-Switch-v3.12.0-macOS.tar.gz` | For Homebrew installation and auto-update                            |\n\n> **Note**: Since the author doesn't have an Apple Developer account, you may see an \"unidentified developer\" warning on first launch. Please close it, then go to \"System Settings\" -> \"Privacy & Security\" -> click \"Open Anyway\", and it will open normally afterwards.\n\n### Homebrew (macOS)\n\n```bash\nbrew tap farion1231/ccswitch\nbrew install --cask cc-switch\n```\n\nUpdate:\n\n```bash\nbrew upgrade --cask cc-switch\n```\n\n### Linux\n\n| Distribution                            | Recommended Format | Installation Method                                                    |\n| --------------------------------------- | ------------------ | ---------------------------------------------------------------------- |\n| Ubuntu / Debian / Linux Mint / Pop!\\_OS | `.deb`             | `sudo dpkg -i CC-Switch-*.deb` or `sudo apt install ./CC-Switch-*.deb` |\n| Fedora / RHEL / CentOS / Rocky Linux    | `.rpm`             | `sudo rpm -i CC-Switch-*.rpm` or `sudo dnf install ./CC-Switch-*.rpm`  |\n| openSUSE                                | `.rpm`             | `sudo zypper install ./CC-Switch-*.rpm`                                |\n| Arch Linux / Manjaro                    | `.AppImage`        | Add execute permission and run directly, or use AUR                    |\n| Other distributions / Unsure            | `.AppImage`        | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage`              |\n"
  },
  {
    "path": "docs/release-notes/v3.12.0-ja.md",
    "content": "# CC Switch v3.12.0\n\n> Stream Check が復活し、OpenAI Responses API に対応、OpenClaw と WebDAV も大幅強化\n\n**[中文版 →](v3.12.0-zh.md) | [English →](v3.12.0-en.md)**\n\n---\n\n## 概要\n\nCC Switch v3.12.0 は、プロバイダー互換性、OpenClaw の設定編集、共通設定の使い勝手、同期とデータ保守性を強化する機能リリースです。安定性を強化した **Model Health Check (Stream Check)** UI を復元し、**OpenAI Responses API** 形式変換を追加、**Ucloud**、**Micu**、**X-Code API**、**Novita**、**Bailian For Coding** などのプリセットを拡張し、**WebDAV 同期** に二層バージョニングを導入しました。\n\n**リリース日**: 2026-03-09\n\n**更新規模**: 56 commits | 221 files changed | +20,582 / -8,026 lines\n\n---\n\n## ハイライト\n\n- **Stream Check 復活**: モデルヘルスチェック UI を復元し、初回確認ダイアログを追加、`openai_chat` プロバイダー対応も修正\n- **OpenAI Responses API**: `api_format = \"openai_responses\"` を追加し、双方向変換と共有変換ロジックの整理を実施 — プロバイダー追加時に Responses API フォーマットを選択してプロキシテイクオーバーを有効にするだけで、Claude Code で GPT シリーズモデルが使えます！\n- **OpenClaw パネル強化**: JSON5 round-trip 編集、設定ヘルスバナー、改良された Agent Model 選択、User-Agent トグルを導入\n- **プリセット拡張**: Ucloud、Micu、X-Code API、Novita、Bailian For Coding を追加・更新し、SiliconFlow partner badge とモデルロールバッジも追加\n- **同期と保守の改善**: WebDAV protocol v2 + db-v6、daily rollups、incremental auto-vacuum、sync-aware backup を追加\n- **共通設定の使い勝手向上**: 共通設定スニペットを更新すると、プロバイダー切り替え時に自動的に反映されるようになりました。手動でチェックを入れ直す必要はありません\n\n---\n\n## 主な機能\n\n### モデルヘルスチェック (Stream Check)\n\nStream Check パネルを復元し、プロバイダーの可用性をリアルタイムで検証できるようにしました。\n\n- Stream Check UI パネルを復元し、単一またはバッチでのプロバイダー可用性検出をサポート\n- 初回使用確認ダイアログを追加、ヘルスチェック非対応プロバイダーの誤検出によるユーザー混乱を防止\n- `openai_chat` API フォーマットプロバイダーの検出互換性を修正\n\n### OpenAI Responses API\n\n新しい `openai_responses` API フォーマットを追加し、OpenAI Responses API を使用するプロバイダーのネイティブサポートを提供します。\n\n- `api_format = \"openai_responses\"` プロバイダーフォーマットオプションを追加\n- Anthropic Messages <-> OpenAI Responses API の双方向フォーマット変換をサポート\n- 共有変換ロジックを整理し、重複コードを削減\n\n### Bedrock リクエストオプティマイザー\n\nAWS Bedrock プロバイダー向けに PRE-SEND フェーズのリクエスト最適化を追加し、互換性とパフォーマンスを向上させました。\n\n- PRE-SEND thinking + cache injection オプティマイザー（#1301、@keithyt06 に感謝）\n\n### OpenClaw 設定強化\n\nOpenClaw の設定編集体験を全面的にアップグレードし、より豊富な設定管理をサポートします。\n\n- JSON5 round-trip 書き戻し: 編集時にコメントとフォーマットを保持\n- EnvPanel の JSON 編集モードと `tools.profile` 選択をサポート\n- 設定検証バナーと設定ヘルスステータスチェックを追加\n- Agent モデルのドロップダウン改善、プロバイダープリセットから推奨モデルを自動入力\n- User-Agent トグル: リクエストに OpenClaw 識別子を付加する機能（デフォルトオフ）\n- Legacy timeout 設定の自動マイグレーション\n\n### プロバイダープリセット\n\n新規および既存のプロバイダープリセットを拡張し、より多くのプロバイダーとユースケースをカバーします。\n\n- **Ucloud**: `endpointCandidates` および OpenClaw デフォルト値を追加、`templateValues` / `suggestedDefaults` を更新\n- **Micu**: プリセットデフォルト値および OpenClaw 推奨モデルを追加\n- **X-Code API**: Claude プリセットおよび `endpointCandidates` を追加\n- **Novita**: プロバイダープリセットを追加（#1192、@Alex-wuhu に感謝）\n- **Bailian For Coding**: プロバイダープリセットを追加（#1263、@suki135246 に感謝）\n- **SiliconFlow**: partner badge 識別を追加\n- **モデルロールバッジ**: プロバイダープリセットでモデルロール badge 表示をサポート\n\n### WebDAV 同期強化\n\nWebDAV 同期に二層バージョン管理を導入し、同期の信頼性とデータ安全性を向上させました。\n\n- WebDAV protocol v2 + db-v6 二層バージョン管理を追加\n- WebDAV auto-sync の切り替え時に確認ダイアログを表示し、誤操作を防止\n- sync-aware backup: 同期時にローカル専用テーブルを除外した sync バリアントバックアップを使用\n\n### 使用量とデータ\n\n使用量統計とデータ保守機能を強化し、より精密なデータ管理を実現、データベースの増加速度を大幅に抑制します。\n\n- Daily rollups: 日次で使用量データを集計し、ストレージ使用量を削減\n- Auto-vacuum: インクリメンタルなデータベースクリーンアップ、データベースの健全性を維持\n- UsageFooter に追加統計フィールドを追加（#1137、@bugparty に感謝）\n\n### その他の新機能\n\n- **セッション削除**: プロバイダー単位のクリーンアップとパス安全性検証付きのセッション削除\n- **Claude auth field selector 復元**: 認証フィールドセレクターを復元\n- **Failover トグルをメインページへ移動**: failover toggle を設定パネルからメインページに独立表示し、初回確認ダイアログを追加\n- **共通設定の自動抽出**: 初回起動時に live config から共通設定スニペットを自動抽出\n- **新規プロバイダーページの改善**: 新規プロバイダーページの体験を最適化（#1155、@wugeer に感謝）\n\n---\n\n## アーキテクチャ改善\n\n### Common Config ランタイムオーバーレイ\n\n共通設定スニペット（Common Config Snippet）をランタイムオーバーレイ方式に変更し、保存済みプロバイダー設定への物理マージを廃止しました。\n\n**変更前**: Common Config の内容は保存時または切り替え時に各プロバイダーの `settings_config` に直接マージされていました。これにより共通設定が各プロバイダーエントリーにコピーされ、変更時には一つずつ同期する必要がありました。\n\n**変更後**: Common Config はプロバイダー切り替え時に live ファイルへ書き込む際のみ runtime overlay として注入され、プロバイダーエントリー自体には共通設定を含みません。つまり Common Config の変更は即座に反映され、各プロバイダーを個別に更新する必要はありません。\n\n### Common Config 初回自動抽出\n\n初回起動時にデータベースに Common Config Snippet がまだ存在しない場合、現在の live config から自動抽出します。これにより旧バージョンからアップグレードしたユーザーの既存の共通設定が失われないことを保証します。\n\n### 定期メンテナンスタイマー統合\n\ndaily rollups と auto-vacuum を統一の定期メンテナンスタイマーに統合し、複数の独立タイマーによるリソース競合と複雑さを回避しました。\n\n---\n\n## バグ修正\n\n### プロキシとストリーミング\n\n- OpenAI ChatCompletion -> Anthropic Messages のストリーミング変換問題を修正\n- Codex `/responses/compact` ルーティングをサポート（#1194、@Tsukumi233 に感謝）\n- TOML 設定マージロジックを改善し、キー値の欠落を回避\n- proxy forwarder の失敗ログを改善し、診断情報を追加\n\n### プロバイダーとプリセットの修正\n\n- X-Code を X-Code API にリネームし、ブランド名称を統一\n- SSSAiCode の `/v1` パス問題を修正\n- AICoding URL の誤った `www` プレフィックスを削除\n- 新規プロバイダーページの改行削除問題を修正（#1155、@wugeer に感謝）\n\n### プラットフォーム修正\n\n- cache hit token の統計欠落を修正（#1244、@a1398394385 に感謝）\n- 最小化後しばらくすると自動終了する問題を修正（#1245、@YewFence に感謝）\n\n### i18n 修正\n\n- 69 個の欠落翻訳キーを補完し、残りのハードコード中国語を除去\n- model test panel の i18n 問題を修正\n- JSON5 slash escaping を正規化し、国際化文字列の解析異常を回避\n\n### UI 修正\n\n- Skills カウント表示の問題を修正（#1295、@fzzv に感謝）\n- endpoint speed test から HTTP ステータスコード表示を削除し、視覚的ノイズを軽減\n- outline button のスタイル問題を修正（#1222、@Sube-py に感謝）\n\n---\n\n## パフォーマンス\n\n- OpenClaw 設定が未変更の場合に不要な書き込みをスキップし、ディスク I/O を削減\n\n---\n\n## ドキュメント\n\n- ユーザーマニュアルを i18n 対応で再構成し、EN/JA の内容を拡充\n- OpenClaw の説明を追加し、設定ドキュメントを補完\n- UCloud スポンサー情報を追加\n- docs ディレクトリを再編成し、EN/ZH/JA の README 機能説明を同期\n\n---\n\n## 注意事項\n\n- **Common Config はランタイムオーバーレイに変更**: 共通設定スニペットは各プロバイダー設定への物理マージではなく、切り替え時に動的にオーバーレイされます。Common Config の変更は即座に反映され、各プロバイダーを個別に更新する必要はありません。\n- **Stream Check は初回使用時に確認が必要**: 初回使用時にモデルヘルスチェックの確認ダイアログが表示され、確認後に使用可能になります。\n- **OpenClaw の User-Agent トグルはデフォルトオフ**: OpenClaw 設定で User-Agent 識別子の付加機能を手動で有効にする必要があります。\n\n---\n\n## 謝辞\n\n以下のコントリビューターの皆様、このリリースへの貢献に感謝します！\n\n@keithyt06 @bugparty @Alex-wuhu @suki135246 @Tsukumi233 @wugeer @fzzv @Sube-py @a1398394385 @YewFence\n\n---\n\n## ダウンロードとインストール\n\n[Releases](https://github.com/farion1231/cc-switch/releases/latest) から適切なバージョンをダウンロードしてください。\n\n### システム要件\n\n| システム | 最小バージョン                   | アーキテクチャ                      |\n| -------- | -------------------------------- | ----------------------------------- |\n| Windows  | Windows 10 以降                  | x64                                 |\n| macOS    | macOS 10.15 (Catalina) 以降      | Intel (x64) / Apple Silicon (arm64) |\n| Linux    | 下表参照                         | x64                                 |\n\n### Windows\n\n| ファイル                                 | 説明                                                 |\n| ---------------------------------------- | ---------------------------------------------------- |\n| `CC-Switch-v3.12.0-Windows.msi`          | **推奨** - MSI インストーラー、自動更新対応          |\n| `CC-Switch-v3.12.0-Windows-Portable.zip` | ポータブル版、解凍して実行、レジストリ書き込みなし   |\n\n### macOS\n\n| ファイル                         | 説明                                                              |\n| -------------------------------- | ----------------------------------------------------------------- |\n| `CC-Switch-v3.12.0-macOS.zip`    | **推奨** - 解凍して Applications にドラッグ、Universal Binary     |\n| `CC-Switch-v3.12.0-macOS.tar.gz` | Homebrew インストールと自動更新用                                 |\n\n> **注意**: 作者が Apple Developer アカウントを持っていないため、初回起動時に「開発元を確認できません」という警告が表示される場合があります。一度閉じてから、「システム設定」→「プライバシーとセキュリティ」→「このまま開く」をクリックすると、その後は正常に開けます。\n\n### Homebrew (macOS)\n\n```bash\nbrew tap farion1231/ccswitch\nbrew install --cask cc-switch\n```\n\n更新：\n\n```bash\nbrew upgrade --cask cc-switch\n```\n\n### Linux\n\n| ディストリビューション                  | 推奨形式    | インストール方法                                                       |\n| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |\n| Ubuntu / Debian / Linux Mint / Pop!\\_OS | `.deb`      | `sudo dpkg -i CC-Switch-*.deb` または `sudo apt install ./CC-Switch-*.deb` |\n| Fedora / RHEL / CentOS / Rocky Linux    | `.rpm`      | `sudo rpm -i CC-Switch-*.rpm` または `sudo dnf install ./CC-Switch-*.rpm`  |\n| openSUSE                                | `.rpm`      | `sudo zypper install ./CC-Switch-*.rpm`                                |\n| Arch Linux / Manjaro                    | `.AppImage` | 実行権限を追加して直接実行、または AUR を使用                          |\n| その他のディストリビューション / 不明   | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage`              |\n"
  },
  {
    "path": "docs/release-notes/v3.12.0-zh.md",
    "content": "# CC Switch v3.12.0\n\n> Stream Check 回归，OpenAI Responses API 上线，OpenClaw 与 WebDAV 迎来一次大升级\n\n**[English →](v3.12.0-en.md) | [日本語版 →](v3.12.0-ja.md)**\n\n---\n\n## 概览\n\nCC Switch v3.12.0 是一个功能版本，重点提升供应商兼容性、OpenClaw 配置编辑体验、通用配置功能使用体验，以及同步与数据维护能力。本次恢复了增强稳定性后的 **模型健康检查（Stream Check）** UI，新增 **OpenAI Responses API** 格式转换，扩展了 **Ucloud**、**Micu**、**X-Code API**、**Novita**、**Bailian For Coding** 等供应商预设，并为 **WebDAV 同步** 引入双层版本控制。\n\n**发布日期**：2026-03-09\n\n**更新规模**：56 commits | 221 files changed | +20,582 / -8,026 lines\n\n---\n\n## 重点内容\n\n- **Stream Check 回归**：恢复模型健康检查 UI，新增首次使用确认，并修复 `openai_chat` 供应商检测\n- **OpenAI Responses API**：新增 `api_format = \"openai_responses\"`，支持双向格式转换并整理共享转换逻辑，只需要在添加供应商的时候选择 Response 接口格式并开启代理接管，您就可以在 Claude Code 中使用 gpt 系列模型了！\n- **OpenClaw 面板升级**：引入 JSON5 round-trip 配置编辑、配置健康提示、改进后的 Agent Model 选择和 User-Agent 开关\n- **预设扩展**：补充 Ucloud、Micu、X-Code API、Novita、Bailian For Coding 预设，并新增 SiliconFlow partner badge 与模型角色标识\n- **同步与维护增强**：新增 WebDAV protocol v2 + db-v6 双层版本、daily rollups、增量 auto-vacuum 和 sync-aware backup\n- **通用配置功能使用体验优化**：现在通用配置片段更新之后，会在切换供应商时自动同步到新的供应商，不需要再手动勾选。\n\n---\n\n## 主要功能\n\n### 模型健康检查 Stream Check\n\n恢复 Stream Check 面板，用于实时验证供应商可用性，增强供应商管理的可靠性。\n\n- 恢复 Stream Check UI 面板，支持单个或批量检测供应商可用性\n- 新增首次使用确认对话框，避免不支持健康检查的供应商报错误导用户\n- 修复 `openai_chat` API 格式供应商的检测兼容性\n\n### OpenAI Responses API\n\n新增 `openai_responses` API 格式，为使用 OpenAI Responses API 的供应商提供原生支持。\n\n- 新增 `api_format = \"openai_responses\"` 供应商格式选项\n- 支持 Anthropic Messages <-> OpenAI Responses API 双向格式转换\n- 整理共享转换逻辑，减少重复代码\n\n### Bedrock 请求优化器\n\n为 AWS Bedrock 供应商新增 PRE-SEND 阶段请求优化器，提升兼容性和性能。\n\n- PRE-SEND thinking + cache injection 优化器（#1301，感谢 @keithyt06）\n\n### OpenClaw 配置增强\n\nOpenClaw 配置编辑体验全面升级，支持更丰富的配置管理。\n\n- JSON5 round-trip 写回：编辑配置时保留注释和格式\n- EnvPanel 支持 JSON 编辑模式和 `tools.profile` 选择\n- 新增配置校验提示和配置健康状态检查\n- Agent 模型下拉框改进，支持从供应商预设填充推荐模型\n- User-Agent 开关：可选在请求中附加 User-Agent 标识（默认关闭）\n- Legacy timeout 配置自动迁移\n\n### 供应商预设 Preset\n\n新增和扩展多组供应商预设，覆盖更多供应商和使用场景。\n\n- **Ucloud**：新增 `endpointCandidates` 以及 OpenClaw 默认值，刷新 `templateValues` / `suggestedDefaults`\n- **Micu**：新增预设默认值及 OpenClaw 推荐模型\n- **X-Code API**：新增 Claude 预设及 `endpointCandidates`\n- **Novita**：新增供应商预设（#1192，感谢 @Alex-wuhu）\n- **Bailian For Coding**：新增供应商预设（#1263，感谢 @suki135246）\n- **SiliconFlow**：新增 partner badge 标识\n- **模型角色标识**：供应商预设支持模型角色 badge 显示\n\n### WebDAV 同步增强\n\nWebDAV 同步引入双层版本控制，提升同步可靠性和数据安全性。\n\n- 新增 WebDAV protocol v2 + db-v6 双层版本控制\n- 切换 WebDAV auto-sync 时弹出确认对话框，防止误操作\n- sync-aware backup：WebDAV 同步时使用 sync 变体备份，跳过仅本地使用的表数据\n\n### 用量与数据\n\n用量统计和数据维护能力增强，数据管理更精细，极大降低数据库增长速度。\n\n- Daily rollups：按天汇总用量数据，减少存储占用\n- Auto-vacuum：增量式数据库清理，保持数据库健康\n- UsageFooter 新增额外统计字段（#1137，感谢 @bugparty）\n\n### 其他新功能\n\n- **会话删除**：按供应商清理会话记录，带路径安全校验\n- **Claude auth field selector 恢复**：恢复认证字段选择器\n- **Failover 开关独立显示**：将 failover toggle 从设置面板移到主页独立展示，并新增首次确认对话框\n- **通用配置自动抽取**：首次运行时自动从 live config 中抽取通用配置片段\n- **新供应商页面改进**：优化新建供应商页面体验（#1155，感谢 @wugeer）\n\n---\n\n## 架构改进\n\n### Common Config 运行时叠加\n\n通用配置片段（Common Config Snippet）改为运行时叠加方式应用，不再物化写入每个供应商配置。\n\n**变更前**：Common Config 内容在保存或切换时直接合并写入每个供应商的 `settings_config`。这导致公共配置被复制到每个供应商条目中，修改时需要逐一同步。\n\n**变更后**：Common Config 仅在切换供应商写入 live 文件时以 runtime overlay 方式注入，供应商条目本身不包含公共配置。这意味着修改 Common Config 后立即生效，无需逐一更新每个供应商。\n\n### 通用配置首次自动抽取\n\n首次运行时，如果数据库中尚无 Common Config Snippet，会自动从当前 live config 中抽取通用配置。这确保了从旧版本升级的用户不会丢失已有的通用配置设置。\n\n### 定期维护定时器整合\n\n将 daily rollups 和 auto-vacuum 整合到统一的定期维护定时器中，避免多个独立定时器带来的资源竞争和复杂度。\n\n---\n\n## Bug 修复\n\n### 代理与流式转换\n\n- 修复 OpenAI ChatCompletion -> Anthropic Messages 流式转换问题\n- 新增 Codex `/responses/compact` 路由支持（#1194，感谢 @Tsukumi233）\n- 改进 TOML 配置合并逻辑，避免键值丢失\n- 改进 proxy forwarder 失败日志，增加更多诊断信息\n\n### 供应商预设修复\n\n- X-Code 更名为 X-Code API，统一品牌命名\n- 修复 SSSAiCode `/v1` 路径问题\n- 移除 AICoding URL 错误的 `www` 前缀\n- 优化新建供应商页面换行删除问题（#1155，感谢 @wugeer）\n\n### 平台修复\n\n- 修复 cache hit token 统计缺失（#1244，感谢 @a1398394385）\n- 修复最小化到托盘后一段时间自动退出的问题（#1245，感谢 @YewFence）\n\n### i18n 修复\n\n- 补齐 69 个缺失翻译 key，清理剩余硬编码中文\n- 修复 model test panel 的 i18n 问题\n- 规范 JSON5 slash escaping，避免国际化字符串解析异常\n\n### UI 修复\n\n- 修复 Skills 计数显示问题（#1295，感谢 @fzzv）\n- 移除 endpoint speed test 的 HTTP 状态码显示，减少视觉噪音\n- 修复 outline button 样式问题（#1222，感谢 @Sube-py）\n\n---\n\n## 性能优化\n\n- OpenClaw 配置未变化时跳过无意义写入，减少磁盘 I/O\n\n---\n\n## 文档\n\n- 重构用户手册以支持国际化，补齐 EN/JA 完整内容\n- 新增 OpenClaw 使用说明，补完设置章节\n- 新增 UCloud 赞助商信息\n- 重组 docs 目录结构，同步 EN/ZH/JA README 的功能说明\n\n---\n\n## 说明与注意事项\n\n- **Common Config 改为运行时叠加**：通用配置片段不再物化写入每个供应商配置，而是在切换时动态叠加。修改 Common Config 后立即生效，无需逐一更新供应商。\n- **Stream Check 首次使用需确认**：首次使用模型健康检查时会弹出确认对话框，确认后方可使用。\n- **OpenClaw User-Agent 开关默认关闭**：需要在 OpenClaw 配置中手动开启 User-Agent 标识附加功能。\n\n---\n\n## 特别感谢\n\n感谢以下贡献者为本版本做出的贡献！\n\n@keithyt06 @bugparty @Alex-wuhu @suki135246 @Tsukumi233 @wugeer @fzzv @Sube-py @a1398394385 @YewFence\n\n---\n\n## 下载与安装\n\n访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。\n\n### 系统要求\n\n| 系统    | 最低版本                      | 架构                                |\n| ------- | ----------------------------- | ----------------------------------- |\n| Windows | Windows 10 及以上             | x64                                 |\n| macOS   | macOS 10.15 (Catalina) 及以上 | Intel (x64) / Apple Silicon (arm64) |\n| Linux   | 见下表                        | x64                                 |\n\n### Windows\n\n| 文件                                     | 说明                                |\n| ---------------------------------------- | ----------------------------------- |\n| `CC-Switch-v3.12.0-Windows.msi`          | **推荐** - MSI 安装包，支持自动更新 |\n| `CC-Switch-v3.12.0-Windows-Portable.zip` | 便携版，解压即用，不写入注册表      |\n\n### macOS\n\n| 文件                             | 说明                                                      |\n| -------------------------------- | --------------------------------------------------------- |\n| `CC-Switch-v3.12.0-macOS.zip`    | **推荐** - 解压后拖入 Applications 即可，Universal Binary |\n| `CC-Switch-v3.12.0-macOS.tar.gz` | 用于 Homebrew 安装和自动更新                              |\n\n> **注意**：由于作者没有苹果开发者账号，首次打开可能出现\"未知开发者\"警告，请先关闭，然后前往\"系统设置\" → \"隐私与安全性\" → 点击\"仍要打开\"，之后便可以正常打开\n\n### Homebrew（macOS）\n\n```bash\nbrew tap farion1231/ccswitch\nbrew install --cask cc-switch\n```\n\n更新：\n\n```bash\nbrew upgrade --cask cc-switch\n```\n\n### Linux\n\n| 发行版                                  | 推荐格式    | 安装方式                                                               |\n| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |\n| Ubuntu / Debian / Linux Mint / Pop!\\_OS | `.deb`      | `sudo dpkg -i CC-Switch-*.deb` 或 `sudo apt install ./CC-Switch-*.deb` |\n| Fedora / RHEL / CentOS / Rocky Linux    | `.rpm`      | `sudo rpm -i CC-Switch-*.rpm` 或 `sudo dnf install ./CC-Switch-*.rpm`  |\n| openSUSE                                | `.rpm`      | `sudo zypper install ./CC-Switch-*.rpm`                                |\n| Arch Linux / Manjaro                    | `.AppImage` | 添加执行权限后直接运行，或使用 AUR                                     |\n| 其他发行版 / 不确定                     | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage`              |\n"
  },
  {
    "path": "docs/release-notes/v3.12.1-en.md",
    "content": "# CC Switch v3.12.1\n\n> Stability Fixes, StepFun Presets, OpenClaw authHeader, and New Sponsor Partners\n\n**[中文版 →](v3.12.1-zh.md) | [日本語版 →](v3.12.1-ja.md)**\n\n---\n\n## Overview\n\nCC Switch v3.12.1 is a patch release focused on stability improvements and bug fixes. It resolves a Common Config modal infinite reopen loop, a WebDAV sync foreign key constraint failure, and several i18n interpolation issues. It also adds **StepFun** provider presets, **OpenClaw input type selection** and **authHeader** support, upgrades the default Gemini model to **3.1-pro**, and welcomes four new sponsor partners.\n\n**Release Date**: 2026-03-12\n\n**Update Scale**: 19 commits | 56 files changed | +1,429 / -396 lines\n\n---\n\n## Highlights\n\n- **Common Config modal fix**: Resolved an infinite reopen loop in the Common Config modal and added draft editing support\n- **WebDAV sync reliability**: Fixed a foreign key constraint failure when restoring `provider_health` during WebDAV sync\n- **StepFun presets**: Added StepFun (阶跃星辰) provider presets including the step-3.5-flash model\n- **OpenClaw enhancements**: Added input type selection for model Advanced Options and `authHeader` field for vendor-specific auth header support\n- **Gemini model upgrade**: Upgraded default Gemini model to 3.1-pro in provider presets\n- **New sponsors**: Welcomed Micu API, XCodeAPI, SiliconFlow, and CTok as sponsor partners\n\n---\n\n## New Features\n\n### StepFun Provider Presets\n\nAdded provider presets for StepFun (阶跃星辰), a leading Chinese AI model provider.\n\n- New preset entries for StepFun across supported applications\n- Includes the step-3.5-flash model (#1369, thanks @hengm3467)\n\n### OpenClaw Enhancements\n\nEnhanced the OpenClaw configuration with more granular control and better vendor compatibility.\n\n- Added input type selection dropdown for model Advanced Options (#1368, thanks @liuxxxu)\n- Added optional `authHeader` boolean to `OpenClawProviderConfig` for vendor-specific auth header support (e.g. Longcat), and refactored form state to reuse the shared type\n\n### Sponsor Partners\n\n- **Micu API**: Added Micu API as sponsor partner with affiliate links\n- **XCodeAPI**: Added XCodeAPI as sponsor partner\n- **SiliconFlow**: Added SiliconFlow (硅基流动) as sponsor partner with affiliate links\n- **CTok**: Added CTok as sponsor partner\n\n---\n\n## Changes\n\n- **UCloud → Compshare**: Renamed UCloud provider to Compshare (优云智算) with full i18n support across all three locales (EN/ZH/JA)\n- **Compshare Links**: Updated Compshare sponsor registration links to coding-plan page\n- **Gemini Model Upgrade**: Upgraded default Gemini model from 2.5-pro to 3.1-pro in provider presets\n\n---\n\n## Bug Fixes\n\n### Common Config & UI\n\n- Fixed an infinite reopen loop in the Common Config modal and added draft editing support to prevent data loss during edits\n- Fixed toolbar compact mode not triggering on Windows due to left-side overflow (#1375, thanks @zuoliangyu)\n- Fixed session search index not syncing with query data, causing stale list display after session deletion\n\n### Sync & Data\n\n- Fixed foreign key constraint failure when restoring `provider_health` table during WebDAV sync\n\n### Provider & Preset\n\n- Added missing `authHeader: true` to Longcat provider preset (#1377, thanks @wavever)\n- Aligned OpenClaw tool permission profiles with upstream schema (#1355, thanks @bigsongeth)\n- Corrected X-Code API URL from `www.x-code.cn` to `x-code.cc`\n\n### i18n & Localization\n\n- Fixed stream check toast i18n interpolation keys not matching translation placeholders\n- Fixed proxy startup toast not interpolating address and port values (#1399, thanks @Mason-mengze)\n- Renamed OpenCode API format label from \"OpenAI\" to \"OpenAI Responses\" for accuracy\n\n---\n\n## Special Thanks\n\nThanks to all contributors for their contributions to this release!\n\n@hengm3467 @liuxxxu @bigsongeth @zuoliangyu @wavever @Mason-mengze\n\n---\n\n## Download & Installation\n\nVisit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download the appropriate version.\n\n### System Requirements\n\n| System  | Minimum Version                 | Architecture                        |\n| ------- | ------------------------------- | ----------------------------------- |\n| Windows | Windows 10 or later             | x64                                 |\n| macOS   | macOS 10.15 (Catalina) or later | Intel (x64) / Apple Silicon (arm64) |\n| Linux   | See table below                 | x64                                 |\n\n### Windows\n\n| File                                       | Description                                          |\n| ------------------------------------------ | ---------------------------------------------------- |\n| `CC-Switch-v3.12.1-Windows.msi`            | **Recommended** - MSI installer with auto-update     |\n| `CC-Switch-v3.12.1-Windows-Portable.zip`   | Portable version, extract and run, no registry write |\n\n### macOS\n\n| File                               | Description                                                          |\n| ---------------------------------- | -------------------------------------------------------------------- |\n| `CC-Switch-v3.12.1-macOS.zip`      | **Recommended** - Extract and drag to Applications, Universal Binary |\n| `CC-Switch-v3.12.1-macOS.tar.gz`   | For Homebrew installation and auto-update                            |\n\n> **Note**: Since the author doesn't have an Apple Developer account, you may see an \"unidentified developer\" warning on first launch. Please close it, then go to \"System Settings\" -> \"Privacy & Security\" -> click \"Open Anyway\", and it will open normally afterwards.\n\n### Homebrew (macOS)\n\n```bash\nbrew tap farion1231/ccswitch\nbrew install --cask cc-switch\n```\n\nUpdate:\n\n```bash\nbrew upgrade --cask cc-switch\n```\n\n### Linux\n\n| Distribution                            | Recommended Format | Installation Method                                                    |\n| --------------------------------------- | ------------------ | ---------------------------------------------------------------------- |\n| Ubuntu / Debian / Linux Mint / Pop!\\_OS | `.deb`             | `sudo dpkg -i CC-Switch-*.deb` or `sudo apt install ./CC-Switch-*.deb` |\n| Fedora / RHEL / CentOS / Rocky Linux    | `.rpm`             | `sudo rpm -i CC-Switch-*.rpm` or `sudo dnf install ./CC-Switch-*.rpm`  |\n| openSUSE                                | `.rpm`             | `sudo zypper install ./CC-Switch-*.rpm`                                |\n| Arch Linux / Manjaro                    | `.AppImage`        | Add execute permission and run directly, or use AUR                    |\n| Other distributions / Unsure            | `.AppImage`        | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage`              |\n"
  },
  {
    "path": "docs/release-notes/v3.12.1-ja.md",
    "content": "# CC Switch v3.12.1\n\n> 安定性修正、StepFun プリセット、OpenClaw authHeader 対応、新スポンサーパートナー\n\n**[中文版 →](v3.12.1-zh.md) | [English →](v3.12.1-en.md)**\n\n---\n\n## 概要\n\nCC Switch v3.12.1 は、安定性の改善とバグ修正に焦点を当てたパッチリリースです。共通設定モーダルの無限再オープンループ、WebDAV 同期時の外部キー制約エラー、複数の i18n 補間問題を修正しました。また、**StepFun（阶跃星辰）** プロバイダープリセットの追加、OpenClaw の**入力タイプ選択**と **authHeader** サポート、デフォルト Gemini モデルの **3.1-pro** へのアップグレード、4 つの新スポンサーパートナーの追加が含まれます。\n\n**リリース日**: 2026-03-12\n\n**更新規模**: 19 commits | 56 files changed | +1,429 / -396 lines\n\n---\n\n## ハイライト\n\n- **共通設定モーダル修正**: 共通設定モーダルの無限再オープンループを解決し、下書き編集サポートを追加\n- **WebDAV 同期の信頼性向上**: WebDAV 同期で `provider_health` 復元時の外部キー制約エラーを修正\n- **StepFun プリセット**: StepFun（阶跃星辰）プロバイダープリセットを追加、step-3.5-flash モデルを含む\n- **OpenClaw 強化**: モデル詳細設定に入力タイプ選択を追加、ベンダー固有の認証ヘッダーサポート用 `authHeader` フィールドを追加\n- **Gemini モデルアップグレード**: プロバイダープリセットのデフォルト Gemini モデルを 3.1-pro にアップグレード\n- **新スポンサー**: Micu API、XCodeAPI、SiliconFlow、CTok をスポンサーパートナーとして追加\n\n---\n\n## 新機能\n\n### StepFun プロバイダープリセット\n\n中国の主要 AI モデルプロバイダーである StepFun（阶跃星辰）のプロバイダープリセットを追加しました。\n\n- サポート対象アプリケーション全体に StepFun プリセットエントリーを追加\n- step-3.5-flash モデルを含む（#1369、@hengm3467 に感謝）\n\n### OpenClaw 強化\n\nOpenClaw 設定をより細かく制御でき、ベンダー互換性を向上させました。\n\n- モデル詳細設定に入力タイプ（input type）選択ドロップダウンを追加（#1368、@liuxxxu に感謝）\n- `OpenClawProviderConfig` にオプションの `authHeader` ブール値を追加し、ベンダー固有の認証ヘッダー（例: Longcat）をサポート。フォーム状態を共有型の再利用にリファクタリング\n\n### スポンサーパートナー\n\n- **Micu API**: Micu API をスポンサーパートナーとして追加、アフィリエイトリンク付き\n- **XCodeAPI**: XCodeAPI をスポンサーパートナーとして追加\n- **SiliconFlow**: SiliconFlow（硅基流动）をスポンサーパートナーとして追加、アフィリエイトリンク付き\n- **CTok**: CTok をスポンサーパートナーとして追加\n\n---\n\n## 変更\n\n- **UCloud → Compshare**: UCloud プロバイダーを Compshare（优云智算）にリネームし、3 言語（EN/ZH/JA）の完全な i18n サポートを追加\n- **Compshare リンク**: Compshare スポンサー登録リンクを coding-plan ページに更新\n- **Gemini モデルアップグレード**: プロバイダープリセットのデフォルト Gemini モデルを 2.5-pro から 3.1-pro にアップグレード\n\n---\n\n## バグ修正\n\n### 共通設定と UI\n\n- 共通設定モーダルの無限再オープンループを修正し、編集中のデータ損失を防ぐための下書き編集サポートを追加\n- Windows でツールバーコンパクトモードが左側のオーバーフローにより機能しない問題を修正（#1375、@zuoliangyu に感謝）\n- セッション削除後にクエリデータと検索インデックスが同期されず、リストが更新されない問題を修正\n\n### 同期とデータ\n\n- WebDAV 同期で `provider_health` テーブルを復元する際の外部キー制約エラーを修正\n\n### プロバイダーとプリセット\n\n- Longcat プロバイダープリセットに欠落していた `authHeader: true` を追加（#1377、@wavever に感謝）\n- OpenClaw のツール権限プロファイルをアップストリームスキーマに合わせて修正（#1355、@bigsongeth に感謝）\n- X-Code API の URL を `www.x-code.cn` から `x-code.cc` に修正\n\n### i18n とローカリゼーション\n\n- Stream Check トーストの i18n 補間キーが翻訳プレースホルダーと一致しない問題を修正\n- プロキシ起動トーストでアドレスとポート値が補間されない問題を修正（#1399、@Mason-mengze に感謝）\n- OpenCode の API フォーマットラベルを「OpenAI」から「OpenAI Responses」にリネームし、正確性を向上\n\n---\n\n## 謝辞\n\n以下のコントリビューターの皆様、このリリースへの貢献に感謝します！\n\n@hengm3467 @liuxxxu @bigsongeth @zuoliangyu @wavever @Mason-mengze\n\n---\n\n## ダウンロードとインストール\n\n[Releases](https://github.com/farion1231/cc-switch/releases/latest) から適切なバージョンをダウンロードしてください。\n\n### システム要件\n\n| システム | 最小バージョン                   | アーキテクチャ                      |\n| -------- | -------------------------------- | ----------------------------------- |\n| Windows  | Windows 10 以降                  | x64                                 |\n| macOS    | macOS 10.15 (Catalina) 以降      | Intel (x64) / Apple Silicon (arm64) |\n| Linux    | 下表参照                         | x64                                 |\n\n### Windows\n\n| ファイル                                   | 説明                                                 |\n| ------------------------------------------ | ---------------------------------------------------- |\n| `CC-Switch-v3.12.1-Windows.msi`            | **推奨** - MSI インストーラー、自動更新対応          |\n| `CC-Switch-v3.12.1-Windows-Portable.zip`   | ポータブル版、解凍して実行、レジストリ書き込みなし   |\n\n### macOS\n\n| ファイル                           | 説明                                                              |\n| ---------------------------------- | ----------------------------------------------------------------- |\n| `CC-Switch-v3.12.1-macOS.zip`      | **推奨** - 解凍して Applications にドラッグ、Universal Binary     |\n| `CC-Switch-v3.12.1-macOS.tar.gz`   | Homebrew インストールと自動更新用                                 |\n\n> **注意**: 作者が Apple Developer アカウントを持っていないため、初回起動時に「開発元を確認できません」という警告が表示される場合があります。一度閉じてから、「システム設定」→「プライバシーとセキュリティ」→「このまま開く」をクリックすると、その後は正常に開けます。\n\n### Homebrew (macOS)\n\n```bash\nbrew tap farion1231/ccswitch\nbrew install --cask cc-switch\n```\n\n更新：\n\n```bash\nbrew upgrade --cask cc-switch\n```\n\n### Linux\n\n| ディストリビューション                  | 推奨形式    | インストール方法                                                       |\n| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |\n| Ubuntu / Debian / Linux Mint / Pop!\\_OS | `.deb`      | `sudo dpkg -i CC-Switch-*.deb` または `sudo apt install ./CC-Switch-*.deb` |\n| Fedora / RHEL / CentOS / Rocky Linux    | `.rpm`      | `sudo rpm -i CC-Switch-*.rpm` または `sudo dnf install ./CC-Switch-*.rpm`  |\n| openSUSE                                | `.rpm`      | `sudo zypper install ./CC-Switch-*.rpm`                                |\n| Arch Linux / Manjaro                    | `.AppImage` | 実行権限を追加して直接実行、または AUR を使用                          |\n| その他のディストリビューション / 不明   | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage`              |\n"
  },
  {
    "path": "docs/release-notes/v3.12.1-zh.md",
    "content": "# CC Switch v3.12.1\n\n> 稳定性修复、StepFun 预设、OpenClaw authHeader 支持，以及新赞助商伙伴\n\n**[English →](v3.12.1-en.md) | [日本語版 →](v3.12.1-ja.md)**\n\n---\n\n## 概览\n\nCC Switch v3.12.1 是一个以稳定性改进和 Bug 修复为主的补丁版本。修复了通用配置弹窗无限重复打开的循环问题、WebDAV 同步时的外键约束失败以及多个 i18n 插值问题。同时新增了 **StepFun（阶跃星辰）** 供应商预设、OpenClaw **输入类型选择** 和 **authHeader** 支持，将默认 Gemini 模型升级到 **3.1-pro**，并欢迎四位新赞助商伙伴加入。\n\n**发布日期**：2026-03-12\n\n**更新规模**：19 commits | 56 files changed | +1,429 / -396 lines\n\n---\n\n## 重点内容\n\n- **通用配置弹窗修复**：解决了通用配置弹窗无限重复打开的循环问题，并新增草稿编辑支持\n- **WebDAV 同步可靠性**：修复了 WebDAV 同步恢复 `provider_health` 时的外键约束失败\n- **StepFun 预设**：新增 StepFun（阶跃星辰）供应商预设，包含 step-3.5-flash 模型\n- **OpenClaw 增强**：新增模型高级选项的输入类型选择和 `authHeader` 字段，支持供应商特定的认证头\n- **Gemini 模型升级**：供应商预设中的默认 Gemini 模型升级到 3.1-pro\n- **新赞助商**：欢迎 Micu API、XCodeAPI、SiliconFlow、CTok 加入赞助伙伴\n\n---\n\n## 新功能\n\n### StepFun 供应商预设\n\n新增 StepFun（阶跃星辰）供应商预设，阶跃星辰是领先的中国 AI 模型提供商。\n\n- 在各支持应用中新增 StepFun 预设条目\n- 包含 step-3.5-flash 模型（#1369，感谢 @hengm3467）\n\n### OpenClaw 增强\n\n增强 OpenClaw 配置能力，提供更细粒度的控制和更好的供应商兼容性。\n\n- 新增模型高级选项的输入类型（input type）选择下拉框（#1368，感谢 @liuxxxu）\n- 在 `OpenClawProviderConfig` 中新增可选的 `authHeader` 布尔字段，支持供应商特定的认证头（如 Longcat），并重构表单状态以复用共享类型\n\n### 赞助商伙伴\n\n- **Micu API**：新增 Micu API 赞助商及推广链接\n- **XCodeAPI**：新增 XCodeAPI 赞助商\n- **SiliconFlow**：新增 SiliconFlow（硅基流动）赞助商及推广链接\n- **CTok**：新增 CTok 赞助商\n\n---\n\n## 变更\n\n- **UCloud → Compshare**：将 UCloud 供应商更名为 Compshare（优云智算），支持三种语言（中/英/日）的完整国际化\n- **Compshare 链接**：更新 Compshare 赞助商注册链接指向 coding-plan 页面\n- **Gemini 模型升级**：供应商预设中的默认 Gemini 模型从 2.5-pro 升级到 3.1-pro\n\n---\n\n## Bug 修复\n\n### 通用配置与 UI\n\n- 修复通用配置弹窗无限重复打开的循环问题，并新增草稿编辑支持以防止编辑过程中数据丢失\n- 修复 Windows 下因左侧溢出导致工具栏紧凑模式不触发的问题（#1375，感谢 @zuoliangyu）\n- 修复会话删除后搜索索引未与查询数据同步，导致列表显示过期的问题\n\n### 同步与数据\n\n- 修复 WebDAV 同步恢复 `provider_health` 表时的外键约束失败\n\n### 供应商与预设\n\n- 为 Longcat 供应商预设补充缺失的 `authHeader: true`（#1377，感谢 @wavever）\n- 对齐 OpenClaw 工具权限配置与上游 schema（#1355，感谢 @bigsongeth）\n- 修正 X-Code API URL，从 `www.x-code.cn` 改为 `x-code.cc`\n\n### i18n 与本地化\n\n- 修复 Stream Check Toast 的 i18n 插值 key 与翻译占位符不匹配\n- 修复代理启动 Toast 未正确插值地址和端口的问题（#1399，感谢 @Mason-mengze）\n- 将 OpenCode API 格式标签从 \"OpenAI\" 改为 \"OpenAI Responses\"，更准确地反映实际格式\n\n---\n\n## 特别感谢\n\n感谢以下贡献者为本版本做出的贡献！\n\n@hengm3467 @liuxxxu @bigsongeth @zuoliangyu @wavever @Mason-mengze\n\n---\n\n## 下载与安装\n\n访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。\n\n### 系统要求\n\n| 系统    | 最低版本                      | 架构                                |\n| ------- | ----------------------------- | ----------------------------------- |\n| Windows | Windows 10 及以上             | x64                                 |\n| macOS   | macOS 10.15 (Catalina) 及以上 | Intel (x64) / Apple Silicon (arm64) |\n| Linux   | 见下表                        | x64                                 |\n\n### Windows\n\n| 文件                                       | 说明                                |\n| ------------------------------------------ | ----------------------------------- |\n| `CC-Switch-v3.12.1-Windows.msi`            | **推荐** - MSI 安装包，支持自动更新 |\n| `CC-Switch-v3.12.1-Windows-Portable.zip`   | 便携版，解压即用，不写入注册表      |\n\n### macOS\n\n| 文件                               | 说明                                                      |\n| ---------------------------------- | --------------------------------------------------------- |\n| `CC-Switch-v3.12.1-macOS.zip`      | **推荐** - 解压后拖入 Applications 即可，Universal Binary |\n| `CC-Switch-v3.12.1-macOS.tar.gz`   | 用于 Homebrew 安装和自动更新                              |\n\n> **注意**：由于作者没有苹果开发者账号，首次打开可能出现\"未知开发者\"警告，请先关闭，然后前往\"系统设置\" → \"隐私与安全性\" → 点击\"仍要打开\"，之后便可以正常打开\n\n### Homebrew（macOS）\n\n```bash\nbrew tap farion1231/ccswitch\nbrew install --cask cc-switch\n```\n\n更新：\n\n```bash\nbrew upgrade --cask cc-switch\n```\n\n### Linux\n\n| 发行版                                  | 推荐格式    | 安装方式                                                               |\n| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |\n| Ubuntu / Debian / Linux Mint / Pop!\\_OS | `.deb`      | `sudo dpkg -i CC-Switch-*.deb` 或 `sudo apt install ./CC-Switch-*.deb` |\n| Fedora / RHEL / CentOS / Rocky Linux    | `.rpm`      | `sudo rpm -i CC-Switch-*.rpm` 或 `sudo dnf install ./CC-Switch-*.rpm`  |\n| openSUSE                                | `.rpm`      | `sudo zypper install ./CC-Switch-*.rpm`                                |\n| Arch Linux / Manjaro                    | `.AppImage` | 添加执行权限后直接运行，或使用 AUR                                     |\n| 其他发行版 / 不确定                     | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage`              |\n"
  },
  {
    "path": "docs/release-notes/v3.12.2-en.md",
    "content": "# CC Switch v3.12.2\n\n> Common Config Protection During Proxy Takeover, Snippet Lifecycle Stability, Section-Aware Codex TOML Editing\n\n**[中文版 →](v3.12.2-zh.md) | [日本語版 →](v3.12.2-ja.md)**\n\n---\n\n## Overview\n\nCC Switch v3.12.2 is a reliability-focused patch release that addresses Common Config loss during proxy takeover and improves Codex TOML editing accuracy. Proxy takeover hot-switches and provider sync now update the restore backup instead of overwriting live config files; the startup sequence has been reordered so snippets are extracted from clean live files before takeover state is restored; and Codex `base_url` editing has been refactored into a section-aware model that no longer appends to the end of the file.\n\n**Release Date**: 2026-03-12\n\n**Update Scale**: 5 commits | 22 files changed | +1,716 / -288 lines\n\n---\n\n## Highlights\n\n- **Empty state guidance**: Provider list empty state now shows detailed import instructions with a conditional Common Config snippet hint for Claude/Codex/Gemini\n\n- **Proxy takeover restore flow rework**: Hot-switches and provider sync now refresh the restore backup instead of overwriting live config files, preserving the full user configuration on rollback\n- **Snippet lifecycle stability**: Introduced a `cleared` flag to prevent auto-extraction from resurrecting cleared snippets, and reordered startup to extract from clean state\n- **Section-aware Codex TOML editing**: `base_url` and `model` field reads/writes now target the correct `[model_providers.<name>]` section\n- **Codex MCP config protection**: Existing `mcp_servers` blocks in restore snapshots survive provider hot-switches via per-server-id merge instead of wholesale replacement, with provider/common-config definitions winning on conflict\n\n---\n\n## New Features\n\n### Empty State Guidance\n\nImproved the first-run experience with helpful guidance when the provider list is empty.\n\n- Empty state page shows step-by-step import instructions\n- Conditionally displays a Common Config snippet hint for Claude/Codex/Gemini providers (not shown for OpenCode/OpenClaw)\n\n---\n\n## Changes\n\n### Proxy Takeover Restore Flow\n\nThe proxy takeover hot-switch and provider sync logic has been reworked to protect Common Config throughout the takeover lifecycle.\n\n- Provider sync now updates the restore backup instead of writing directly to live config files when takeover is active\n- Effective provider settings are rebuilt with Common Config applied before saving restore snapshots, so rollback restores the real user configuration\n- Legacy providers with inferred common config usage are automatically marked with `commonConfigEnabled=true`\n\n### Codex TOML Editing Engine\n\nCodex `config.toml` update logic has been refactored onto shared section-aware TOML helpers.\n\n- New Rust module `codex_config.rs` with `update_codex_toml_field` and `remove_codex_toml_base_url_if`\n- New frontend utilities `getTomlSectionRange` / `getCodexProviderSectionName` for section-aware operations\n- Inline TOML editing logic scattered across `proxy.rs` now delegates to the new module\n\n### Common Config Initialization Lifecycle\n\nThe startup sequence has been reordered for more robust snippet extraction and migration.\n\n- Startup now auto-extracts Common Config snippets from clean live files before restoring proxy takeover state\n- Introduced a snippet `cleared` flag to track whether a user intentionally cleared a snippet\n- Persisted a one-time legacy migration flag to avoid repeated `commonConfigEnabled` backfills\n\n---\n\n## Bug Fixes\n\n### Common Config Loss\n\n- Fixed multiple scenarios where Common Config could be dropped during proxy takeover: sync overwriting live files, hot-switches producing incomplete restore snapshots, and provider switches losing config changes\n\n### Codex Restore Snapshot Preservation\n\n- Fixed Codex takeover restore backups discarding existing `mcp_servers` blocks during provider hot-switches; changed MCP backup preservation from wholesale table replacement to per-server-id merge so provider/common-config MCP updates win on conflict while backup-only servers are retained\n\n### Cleared Snippet Resurrection\n\n- Fixed startup auto-extraction recreating Common Config snippets that users had intentionally cleared\n\n### Codex `base_url` Misplacement\n\n- Fixed Codex `base_url` extraction and editing not targeting the correct `[model_providers.<name>]` section, causing it to append to the file tail or confuse `mcp_servers.*.base_url` entries for provider endpoints\n\n---\n\n## Download & Installation\n\nVisit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download the appropriate version.\n\n### System Requirements\n\n| System  | Minimum Version                 | Architecture                        |\n| ------- | ------------------------------- | ----------------------------------- |\n| Windows | Windows 10 or later             | x64                                 |\n| macOS   | macOS 10.15 (Catalina) or later | Intel (x64) / Apple Silicon (arm64) |\n| Linux   | See table below                 | x64                                 |\n\n### Windows\n\n| File                                       | Description                                          |\n| ------------------------------------------ | ---------------------------------------------------- |\n| `CC-Switch-v3.12.2-Windows.msi`            | **Recommended** - MSI installer with auto-update     |\n| `CC-Switch-v3.12.2-Windows-Portable.zip`   | Portable version, extract and run, no registry write |\n\n### macOS\n\n| File                               | Description                                                          |\n| ---------------------------------- | -------------------------------------------------------------------- |\n| `CC-Switch-v3.12.2-macOS.zip`      | **Recommended** - Extract and drag to Applications, Universal Binary |\n| `CC-Switch-v3.12.2-macOS.tar.gz`   | For Homebrew installation and auto-update                            |\n\n> **Note**: Since the author doesn't have an Apple Developer account, you may see an \"unidentified developer\" warning on first launch. Please close it, then go to \"System Settings\" -> \"Privacy & Security\" -> click \"Open Anyway\", and it will open normally afterwards.\n\n### Homebrew (macOS)\n\n```bash\nbrew tap farion1231/ccswitch\nbrew install --cask cc-switch\n```\n\nUpdate:\n\n```bash\nbrew upgrade --cask cc-switch\n```\n\n### Linux\n\n| Distribution                            | Recommended Format | Installation Method                                                    |\n| --------------------------------------- | ------------------ | ---------------------------------------------------------------------- |\n| Ubuntu / Debian / Linux Mint / Pop!\\_OS | `.deb`             | `sudo dpkg -i CC-Switch-*.deb` or `sudo apt install ./CC-Switch-*.deb` |\n| Fedora / RHEL / CentOS / Rocky Linux    | `.rpm`             | `sudo rpm -i CC-Switch-*.rpm` or `sudo dnf install ./CC-Switch-*.rpm`  |\n| openSUSE                                | `.rpm`             | `sudo zypper install ./CC-Switch-*.rpm`                                |\n| Arch Linux / Manjaro                    | `.AppImage`        | Add execute permission and run directly, or use AUR                    |\n| Other distributions / Unsure            | `.AppImage`        | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage`              |\n"
  },
  {
    "path": "docs/release-notes/v3.12.2-ja.md",
    "content": "# CC Switch v3.12.2\n\n> プロキシテイクオーバー中の共通設定保護、Snippet ライフサイクルの安定化、Codex TOML セクション対応編集\n\n**[中文版 →](v3.12.2-zh.md) | [English →](v3.12.2-en.md)**\n\n---\n\n## 概要\n\nCC Switch v3.12.2 は、信頼性を重視したパッチリリースです。プロキシテイクオーバーモードでの共通設定（Common Config）の消失問題を解決し、Codex TOML 設定の編集精度を改善しました。テイクオーバーのホットスイッチとプロバイダー同期は、ライブ設定ファイルを上書きする代わりにリストアバックアップを更新するようになりました。起動シーケンスを再整理し、テイクオーバー状態を復元する前にクリーンなライブファイルから Snippet を抽出するようにしました。また Codex の `base_url` 編集をセクション対応モデルにリファクタリングし、ファイル末尾への誤追加を防止しました。\n\n**リリース日**: 2026-03-12\n\n**更新規模**: 5 commits | 22 files changed | +1,716 / -288 lines\n\n---\n\n## ハイライト\n\n- **空状態ガイダンスの改善**: プロバイダーリストが空の場合に詳細なインポート手順を表示し、Claude/Codex/Gemini には共通設定 Snippet のヒントを条件付きで表示\n\n- **プロキシテイクオーバーリストアフロー刷新**: ホットスイッチとプロバイダー同期がライブ設定ファイルの上書きではなくリストアバックアップの更新を行うようになり、ロールバック時に完全なユーザー設定を保持\n- **Snippet ライフサイクルの安定化**: `cleared` フラグを導入し、クリア済み Snippet の自動再抽出を防止。起動順序を調整してクリーンな状態から抽出\n- **Codex TOML セクション対応編集**: `base_url` と `model` フィールドの読み書きが正しい `[model_providers.<name>]` セクションを対象にするように改善\n- **Codex MCP 設定の保護**: プロバイダーホットスイッチ時にリストアスナップショット内の既存 `mcp_servers` ブロックが保持されるように修正。テーブル全体の置換からサーバー ID ごとのマージに変更し、プロバイダー/共通設定の MCP 定義が競合時に優先\n\n---\n\n## 新機能\n\n### 空状態ガイダンスの改善\n\nプロバイダーリストが空の場合の初回利用体験を改善しました。\n\n- 空状態ページにプロバイダーインポートの操作ガイドを表示\n- Claude/Codex/Gemini アプリケーションに共通設定 Snippet のヒントを条件付きで表示（OpenCode/OpenClaw には非表示）\n\n---\n\n## 変更\n\n### プロキシテイクオーバーリストアフロー\n\nテイクオーバーのホットスイッチとプロバイダー同期ロジックをリファクタリングし、テイクオーバーライフサイクル全体で共通設定を保護します。\n\n- テイクオーバーがアクティブな場合、プロバイダー同期がライブ設定ファイルへの直接書き込みではなくリストアバックアップを更新\n- リストアスナップショットの保存前に共通設定を適用した実効プロバイダー設定を再構築し、ロールバックで実際のユーザー設定を復元\n- 共通設定の使用が推測されるレガシープロバイダーに `commonConfigEnabled=true` を自動マーク\n\n### Codex TOML 編集エンジン\n\nCodex `config.toml` の更新ロジックを共有のセクション対応 TOML ヘルパーにリファクタリングしました。\n\n- Rust 側に新モジュール `codex_config.rs` を追加（`update_codex_toml_field` と `remove_codex_toml_base_url_if`）\n- フロントエンドにセクション対応ユーティリティ `getTomlSectionRange` / `getCodexProviderSectionName` を追加\n- `proxy.rs` に散在していたインライン TOML 編集ロジックを新モジュールに委譲\n\n### 共通設定初期化ライフサイクル\n\nSnippet の抽出とマイグレーションをより堅牢にするため、起動シーケンスを再整理しました。\n\n- 起動時にプロキシテイクオーバー状態を復元する前に、クリーンなライブファイルから共通設定 Snippet を自動抽出\n- Snippet の `cleared` フラグを導入し、ユーザーが意図的にクリアしたかどうかを追跡\n- 一回限りのレガシーマイグレーションフラグを永続化し、`commonConfigEnabled` のバックフィルの繰り返しを防止\n\n---\n\n## バグ修正\n\n### 共通設定の消失\n\n- プロキシテイクオーバー中に共通設定が消失する複数のシナリオを修正：同期によるライブファイルの上書き、ホットスイッチによる不完全なリストアスナップショット、プロバイダー切り替え時の設定変更の消失\n\n### Codex リストアスナップショットの保護\n\n- プロバイダーホットスイッチ時に Codex テイクオーバーリストアバックアップが既存の `mcp_servers` ブロックを破棄する問題を修正。MCP バックアップ保持をテーブル全体の置換からサーバー ID ごとのマージに変更し、プロバイダー/共通設定の MCP 更新が競合時に優先され、バックアップのみのサーバーも保持\n\n### クリア済み Snippet の復活\n\n- 起動時の自動抽出が、ユーザーが意図的にクリアした共通設定 Snippet を再作成する問題を修正\n\n### Codex `base_url` の配置エラー\n\n- Codex `base_url` の抽出と編集が正しい `[model_providers.<name>]` セクションを対象にせず、ファイル末尾に追加されたり `mcp_servers.*.base_url` をプロバイダーエンドポイントと誤認する問題を修正\n\n---\n\n## ダウンロードとインストール\n\n[Releases](https://github.com/farion1231/cc-switch/releases/latest) から適切なバージョンをダウンロードしてください。\n\n### システム要件\n\n| システム | 最小バージョン                   | アーキテクチャ                      |\n| -------- | -------------------------------- | ----------------------------------- |\n| Windows  | Windows 10 以降                  | x64                                 |\n| macOS    | macOS 10.15 (Catalina) 以降      | Intel (x64) / Apple Silicon (arm64) |\n| Linux    | 下表参照                         | x64                                 |\n\n### Windows\n\n| ファイル                                   | 説明                                                 |\n| ------------------------------------------ | ---------------------------------------------------- |\n| `CC-Switch-v3.12.2-Windows.msi`            | **推奨** - MSI インストーラー、自動更新対応          |\n| `CC-Switch-v3.12.2-Windows-Portable.zip`   | ポータブル版、解凍して実行、レジストリ書き込みなし   |\n\n### macOS\n\n| ファイル                           | 説明                                                              |\n| ---------------------------------- | ----------------------------------------------------------------- |\n| `CC-Switch-v3.12.2-macOS.zip`      | **推奨** - 解凍して Applications にドラッグ、Universal Binary     |\n| `CC-Switch-v3.12.2-macOS.tar.gz`   | Homebrew インストールと自動更新用                                 |\n\n> **注意**: 作者が Apple Developer アカウントを持っていないため、初回起動時に「開発元を確認できません」という警告が表示される場合があります。一度閉じてから、「システム設定」→「プライバシーとセキュリティ」→「このまま開く」をクリックすると、その後は正常に開けます。\n\n### Homebrew (macOS)\n\n```bash\nbrew tap farion1231/ccswitch\nbrew install --cask cc-switch\n```\n\n更新：\n\n```bash\nbrew upgrade --cask cc-switch\n```\n\n### Linux\n\n| ディストリビューション                  | 推奨形式    | インストール方法                                                       |\n| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |\n| Ubuntu / Debian / Linux Mint / Pop!\\_OS | `.deb`      | `sudo dpkg -i CC-Switch-*.deb` または `sudo apt install ./CC-Switch-*.deb` |\n| Fedora / RHEL / CentOS / Rocky Linux    | `.rpm`      | `sudo rpm -i CC-Switch-*.rpm` または `sudo dnf install ./CC-Switch-*.rpm`  |\n| openSUSE                                | `.rpm`      | `sudo zypper install ./CC-Switch-*.rpm`                                |\n| Arch Linux / Manjaro                    | `.AppImage` | 実行権限を追加して直接実行、または AUR を使用                          |\n| その他のディストリビューション / 不明   | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage`              |\n"
  },
  {
    "path": "docs/release-notes/v3.12.2-zh.md",
    "content": "# CC Switch v3.12.2\n\n> 代理接管期间通用配置保护、Snippet 生命周期稳定性、Codex TOML Section 感知编辑\n\n**[English →](v3.12.2-en.md) | [日本語版 →](v3.12.2-ja.md)**\n\n---\n\n## 概览\n\nCC Switch v3.12.2 是一个以可靠性为核心的补丁版本，重点解决代理（Proxy）接管模式下通用配置（Common Config）丢失的问题，并改进了 Codex TOML 配置的编辑准确性。代理接管的热切换和供应商同步现在会更新恢复备份而非直接覆盖 live 文件；启动流程重新排序，确保先从干净的 live 文件提取 Snippet 再恢复接管状态；Codex 的 `base_url` 编辑重构为 Section 感知模式，不再错误追加到文件末尾。\n\n**发布日期**：2026-03-12\n\n**更新规模**：5 commits | 22 files changed | +1,716 / -288 lines\n\n---\n\n## 重点内容\n\n- **首次使用引导优化**：供应商列表空状态显示详细的导入说明，Claude/Codex/Gemini 还会提示通用配置 Snippet 功能\n\n- **代理接管恢复流程重构**：热切换和供应商同步现在刷新恢复备份，而非覆盖 live 配置文件，回滚时保留完整的用户配置\n- **Snippet 生命周期稳定**：引入 `cleared` 标志防止已清除的 Snippet 被自动重新提取，启动顺序调整确保从干净状态提取\n- **Codex TOML Section 感知编辑**：`base_url` 和 `model` 字段的读写现在定位到正确的 `[model_providers.<name>]` Section\n- **Codex MCP 配置保护**：热切换供应商时保留恢复快照中已有的 `mcp_servers` 配置块，按 server id 合并而非整表替换，供应商/通用配置的 MCP 定义优先\n\n---\n\n## 新功能\n\n### 空状态引导优化\n\n改善首次使用体验，当供应商列表为空时显示详细的导入说明。\n\n- 空状态页面展示导入供应商的操作指引\n- 对 Claude/Codex/Gemini 应用有条件地显示通用配置 Snippet 提示（OpenCode/OpenClaw 不显示）\n\n---\n\n## 变更\n\n### 代理接管恢复流程\n\n代理接管的热切换和供应商同步逻辑经过重构，确保通用配置在整个接管生命周期中得到保护。\n\n- 接管活跃时，供应商同步更新恢复备份而非直接写入 live 配置文件\n- 保存恢复快照前先应用通用配置，使回滚能还原真实的用户配置\n- 遗留供应商中推断使用了通用配置的条目自动标记 `commonConfigEnabled=true`\n\n### Codex TOML 编辑引擎\n\n将 Codex `config.toml` 的更新逻辑重构到共享的 Section 感知 TOML 辅助函数上。\n\n- Rust 端新增 `codex_config.rs` 模块，包含 `update_codex_toml_field` 和 `remove_codex_toml_base_url_if`\n- 前端新增 `getTomlSectionRange` / `getCodexProviderSectionName` 等 Section 感知工具函数\n- `proxy.rs` 中散落的 TOML 内联编辑逻辑统一委托给新模块\n\n### 通用配置初始化生命周期\n\n启动流程重新排序，通用配置 Snippet 的提取和迁移逻辑更加健壮。\n\n- 启动时先从干净的 live 文件自动提取通用配置 Snippet，再恢复代理接管状态\n- 引入 Snippet `cleared` 标志，追踪用户是否主动清除了某个 Snippet\n- 持久化一次性遗留迁移标志，避免重复执行旧版 `commonConfigEnabled` 回填\n\n---\n\n## Bug 修复\n\n### 通用配置丢失\n\n- 修复代理接管期间通用配置可能被丢弃的多种场景：同步覆盖 live 文件、热切换产生不完整的恢复快照、供应商切换丢失配置变更\n\n### Codex 恢复快照保护\n\n- 修复 Codex 接管恢复备份在供应商热切换时丢弃已有 `mcp_servers` 配置块的问题；将 MCP 备份保留策略从整表替换改为按 server id 合并，供应商/通用配置的 MCP 定义在冲突时优先，备份中独有的服务器仍被保留\n\n### 已清除 Snippet 复活\n\n- 修复启动时自动提取机制重新创建用户已主动清除的通用配置 Snippet 的问题\n\n### Codex `base_url` 位置错误\n\n- 修复 Codex `base_url` 提取和编辑未定位到正确的 `[model_providers.<name>]` Section，导致追加到文件末尾或误将 `mcp_servers.*.base_url` 识别为供应商端点的问题\n\n---\n\n## 下载与安装\n\n访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。\n\n### 系统要求\n\n| 系统    | 最低版本                      | 架构                                |\n| ------- | ----------------------------- | ----------------------------------- |\n| Windows | Windows 10 及以上             | x64                                 |\n| macOS   | macOS 10.15 (Catalina) 及以上 | Intel (x64) / Apple Silicon (arm64) |\n| Linux   | 见下表                        | x64                                 |\n\n### Windows\n\n| 文件                                       | 说明                                |\n| ------------------------------------------ | ----------------------------------- |\n| `CC-Switch-v3.12.2-Windows.msi`            | **推荐** - MSI 安装包，支持自动更新 |\n| `CC-Switch-v3.12.2-Windows-Portable.zip`   | 便携版，解压即用，不写入注册表      |\n\n### macOS\n\n| 文件                               | 说明                                                      |\n| ---------------------------------- | --------------------------------------------------------- |\n| `CC-Switch-v3.12.2-macOS.zip`      | **推荐** - 解压后拖入 Applications 即可，Universal Binary |\n| `CC-Switch-v3.12.2-macOS.tar.gz`   | 用于 Homebrew 安装和自动更新                              |\n\n> **注意**：由于作者没有苹果开发者账号，首次打开可能出现\"未知开发者\"警告，请先关闭，然后前往\"系统设置\" → \"隐私与安全性\" → 点击\"仍要打开\"，之后便可以正常打开\n\n### Homebrew（macOS）\n\n```bash\nbrew tap farion1231/ccswitch\nbrew install --cask cc-switch\n```\n\n更新：\n\n```bash\nbrew upgrade --cask cc-switch\n```\n\n### Linux\n\n| 发行版                                  | 推荐格式    | 安装方式                                                               |\n| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |\n| Ubuntu / Debian / Linux Mint / Pop!\\_OS | `.deb`      | `sudo dpkg -i CC-Switch-*.deb` 或 `sudo apt install ./CC-Switch-*.deb` |\n| Fedora / RHEL / CentOS / Rocky Linux    | `.rpm`      | `sudo rpm -i CC-Switch-*.rpm` 或 `sudo dnf install ./CC-Switch-*.rpm`  |\n| openSUSE                                | `.rpm`      | `sudo zypper install ./CC-Switch-*.rpm`                                |\n| Arch Linux / Manjaro                    | `.AppImage` | 添加执行权限后直接运行，或使用 AUR                                     |\n| 其他发行版 / 不确定                     | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage`              |\n"
  },
  {
    "path": "docs/release-notes/v3.12.3-en.md",
    "content": "# CC Switch v3.12.3\n\n> Tool Search Domain Bypass, Skill Backup/Restore Lifecycle, Proxy Gzip & o-Series Compatibility\n\n**[中文版 →](v3.12.3-zh.md) | [日本語版 →](v3.12.3-ja.md)**\n\n---\n\n## Overview\n\nCC Switch v3.12.3 adds a Tool Search domain restriction bypass via binary patching, introduces a full skill backup/restore lifecycle, improves proxy compatibility for OpenAI o-series models and gzip compression, and delivers robustness fixes for Skills import, provider forms, and terminal session restore. Skills are now automatically backed up before uninstall with restore and delete management, and the import flow has been reworked from implicit filesystem inference to explicit app selection.\n\n**Release Date**: 2026-03-16\n\n**Update Scale**: 17 commits | 61 files changed | +3,335 / -194 lines\n\n---\n\n## Highlights\n\n- **Tool Search domain bypass**: New setting to remove Claude CLI Tool Search domain whitelist via equal-length binary patching, with automatic backup and reapply on startup\n- **Skill backup/restore lifecycle**: Skills are automatically backed up before uninstall; backup list with restore and delete management added\n\n- **Proxy gzip compression**: Non-streaming proxy requests now auto-negotiate gzip compression, reducing bandwidth usage\n- **o-series model compatibility**: Chat Completions proxy correctly uses `max_completion_tokens` for o1/o3/o4-mini models; Responses API kept on the correct `max_output_tokens` field\n- **Skills import rework**: Replaced implicit filesystem-based app inference with explicit `ImportSkillSelection` to prevent incorrect multi-app activation\n- **Ghostty terminal support**: Fixed Claude session restore in Ghostty terminal\n\n---\n\n## New Features\n\n### Tool Search Domain Bypass\n\nAdded a setting to bypass Claude CLI Tool Search domain whitelist restrictions.\n\n- Resolves the active `claude` command from PATH and applies an equal-length byte patch to remove the domain whitelist check\n- Backups stored in `~/.cc-switch/toolsearch-backups/` (SHA-256 of path) so they survive Claude Code version upgrades\n- The patch auto-reapplies on app startup when the setting is enabled\n- Frontend checks patch result and rolls back the setting on failure\n\n### Skill Auto-Backup on Uninstall\n\nSkill files are now automatically backed up before uninstall to prevent accidental data loss.\n\n- Backups stored in `~/.cc-switch/skill-backups/` with all skill files and a `meta.json` containing original metadata\n- Old backups are automatically pruned to keep at most 20\n- Backup path is returned to the frontend and shown in the success toast\n\n### Skill Backup Restore & Delete\n\nAdded management commands for skill backups created during uninstall.\n\n- List all available skill backups with metadata\n- Restore copies files back to SSOT, saves the DB record, and syncs to the current app with rollback on failure\n- Delete removes the backup directory after a confirmation dialog\n- ConfirmDialog gains a configurable zIndex prop to support nested dialog stacking\n\n---\n\n## Changes\n\n### Proxy Gzip Compression\n\nNon-streaming proxy requests now support gzip compression for reduced bandwidth usage.\n\n- Non-streaming requests let reqwest auto-negotiate gzip and transparently decompress responses\n- Streaming requests conservatively keep `Accept-Encoding: identity` to avoid decompression errors on interrupted SSE streams\n\n### o1/o3 Model Compatibility\n\nProxy forwarding now handles OpenAI o-series model token parameters correctly.\n\n- Chat Completions path uses `max_completion_tokens` instead of `max_tokens` for o1/o3/o4-mini models (#1451)\n- Responses API path kept on the correct `max_output_tokens` field instead of incorrectly injecting `max_completion_tokens`\n\n### OpenCode Model Variants\n\n- Placed OpenCode model variants at top level instead of inside options for better discoverability (#1317)\n\n### Skills Import Flow\n\nThe Skills import flow has been reworked for correctness and cleanup.\n\n- Replaced implicit filesystem-based app inference with explicit `ImportSkillSelection` to prevent incorrect multi-app activation when the same skill directory exists under multiple app paths\n- Added reconciliation to `sync_to_app` to remove disabled/orphaned symlinks\n- MCP `sync_all_enabled` now removes disabled servers from live config\n- Schema migration preserves a snapshot of legacy app mappings to avoid lossy reconstruction\n\n---\n\n## Bug Fixes\n\n### Provider Form Double Submit\n\n- Prevented duplicate submissions on rapid button clicks in provider add/edit forms (#1352)\n\n### Ghostty Session Restore\n\n- Fixed Claude session restore in Ghostty terminal (#1506, thanks @canyonsehun)\n\n### Skill ZIP Import Extension\n\n- Added `.skill` file extension support in ZIP import dialog (#1240, #1455)\n\n### Skill ZIP Install Target App\n\n- ZIP skill installs now use the currently active app instead of always defaulting to Claude\n\n### OpenClaw Active Card Highlight\n\n- Fixed active OpenClaw provider card not being highlighted (#1419)\n\n### Responsive Layout with TOC\n\n- Improved responsive design when TOC title exists (#1491)\n\n### Import Skills Dialog White Screen\n\n- Added missing TooltipProvider in ImportSkillsDialog to prevent runtime crash when opening the dialog\n\n### Panel Bottom Blank Area\n\n- Replaced hardcoded `h-[calc(100vh-8rem)]` with `flex-1 min-h-0` across all content panels to eliminate bottom gap caused by mismatched offset values on different platforms\n\n---\n\n## Download & Installation\n\nVisit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download the appropriate version.\n\n### System Requirements\n\n| System  | Minimum Version                 | Architecture                        |\n| ------- | ------------------------------- | ----------------------------------- |\n| Windows | Windows 10 or later             | x64                                 |\n| macOS   | macOS 12 (Monterey) or later    | Intel (x64) / Apple Silicon (arm64) |\n| Linux   | See table below                 | x64                                 |\n\n### Windows\n\n| File                                       | Description                                          |\n| ------------------------------------------ | ---------------------------------------------------- |\n| `CC-Switch-v3.12.3-Windows.msi`            | **Recommended** - MSI installer with auto-update     |\n| `CC-Switch-v3.12.3-Windows-Portable.zip`   | Portable version, extract and run, no registry write |\n\n### macOS\n\n| File                               | Description                                                          |\n| ---------------------------------- | -------------------------------------------------------------------- |\n| `CC-Switch-v3.12.3-macOS.zip`      | **Recommended** - Extract and drag to Applications, Universal Binary |\n| `CC-Switch-v3.12.3-macOS.tar.gz`   | For Homebrew installation and auto-update                            |\n\n> **Note**: Since the author doesn't have an Apple Developer account, you may see an \"unidentified developer\" warning on first launch. Please close it, then go to \"System Settings\" -> \"Privacy & Security\" -> click \"Open Anyway\", and it will open normally afterwards.\n\n### Homebrew (macOS)\n\n```bash\nbrew tap farion1231/ccswitch\nbrew install --cask cc-switch\n```\n\nUpdate:\n\n```bash\nbrew upgrade --cask cc-switch\n```\n\n### Linux\n\n| Distribution                            | Recommended Format | Installation Method                                                    |\n| --------------------------------------- | ------------------ | ---------------------------------------------------------------------- |\n| Ubuntu / Debian / Linux Mint / Pop!\\_OS | `.deb`             | `sudo dpkg -i CC-Switch-*.deb` or `sudo apt install ./CC-Switch-*.deb` |\n| Fedora / RHEL / CentOS / Rocky Linux    | `.rpm`             | `sudo rpm -i CC-Switch-*.rpm` or `sudo dnf install ./CC-Switch-*.rpm`  |\n| openSUSE                                | `.rpm`             | `sudo zypper install ./CC-Switch-*.rpm`                                |\n| Arch Linux / Manjaro                    | `.AppImage`        | Add execute permission and run directly, or use AUR                    |\n| Other distributions / Unsure            | `.AppImage`        | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage`              |\n"
  },
  {
    "path": "docs/release-notes/v3.12.3-ja.md",
    "content": "# CC Switch v3.12.3\n\n> Tool Search ドメイン制限バイパス、Skill バックアップ/リストアライフサイクル、プロキシ Gzip 圧縮と o シリーズモデル互換性\n\n**[中文版 →](v3.12.3-zh.md) | [English →](v3.12.3-en.md)**\n\n---\n\n## 概要\n\nCC Switch v3.12.3 は、バイナリパッチによる Tool Search ドメインホワイトリスト制限のバイパス機能を追加し、完全な Skill バックアップ/リストアライフサイクルを導入し、OpenAI o シリーズモデルのプロキシ互換性と gzip 圧縮を改善し、Skills インポート、プロバイダーフォーム、ターミナルセッション復元の堅牢性を修正しました。Skill はアンインストール前に自動バックアップされ、リストアと削除の管理機能が追加されました。インポートフローはファイルシステムベースの暗黙的な推論から明示的なアプリ選択に変更されました。\n\n**リリース日**: 2026-03-16\n\n**更新規模**: 17 commits | 61 files changed | +3,335 / -194 lines\n\n---\n\n## ハイライト\n\n- **Tool Search ドメインバイパス**: 等長バイナリパッチで Claude CLI Tool Search のドメインホワイトリストチェックを削除する新設定。起動時に自動バックアップと再適用\n- **Skill バックアップ/リストアライフサイクル**: アンインストール前に Skill ファイルを自動バックアップ。バックアップリスト、リストア、削除の管理機能を追加\n\n- **プロキシ Gzip 圧縮**: 非ストリーミングプロキシリクエストが gzip 圧縮を自動ネゴシエーションし、帯域幅消費を削減\n- **o シリーズモデル互換性**: Chat Completions プロキシが o1/o3/o4-mini モデルに `max_completion_tokens` を正しく使用。Responses API は正しい `max_output_tokens` フィールドを維持\n- **Skills インポートの刷新**: ファイルシステムベースの暗黙的なアプリ推論を明示的な `ImportSkillSelection` に置き換え、複数アプリの誤った有効化を防止\n- **Ghostty ターミナルサポート**: Ghostty ターミナルでの Claude セッション復元を修正\n\n---\n\n## 新機能\n\n### Tool Search ドメイン制限バイパス\n\nClaude CLI Tool Search のドメインホワイトリスト制限をバイパスする設定を追加しました。\n\n- PATH からアクティブな `claude` コマンドを解決し、等長バイトパッチを適用してドメインホワイトリストチェックを削除\n- バックアップは `~/.cc-switch/toolsearch-backups/`（パスの SHA-256）に保存され、Claude Code のバージョンアップグレード後も有効\n- 設定が有効な場合、アプリ起動時にパッチを自動的に再適用\n- フロントエンドがパッチ結果を確認し、失敗時に設定を自動ロールバック\n\n### Skill アンインストール時の自動バックアップ\n\nアンインストール前に Skill ファイルを自動バックアップし、意図しないデータ損失を防止します。\n\n- バックアップは `~/.cc-switch/skill-backups/` に保存され、すべての skill ファイルと元のメタデータを含む `meta.json` が含まれます\n- 古いバックアップは自動的にプルーニングされ、最大 20 個を保持\n- バックアップパスはフロントエンドに返され、成功トーストに表示\n\n### Skill バックアップのリストアと削除\n\nアンインストール時に作成された Skill バックアップの管理コマンドを追加しました。\n\n- すべての利用可能な skill バックアップをメタデータ付きで一覧表示\n- リストアはファイルを SSOT にコピーし、DB レコードを保存し、現在のアプリに同期。失敗時は自動ロールバック\n- 削除は確認ダイアログの後にバックアップディレクトリを削除\n- ConfirmDialog にネストされたダイアログスタッキングをサポートする設定可能な zIndex プロパティを追加\n\n---\n\n## 変更\n\n### プロキシ Gzip 圧縮\n\n非ストリーミングプロキシリクエストが gzip 圧縮をサポートし、帯域幅消費を削減しました。\n\n- 非ストリーミングリクエストは reqwest が gzip を自動ネゴシエーションし、レスポンスを透過的に解凍\n- ストリーミングリクエストは中断された SSE ストリームの解凍エラーを避けるため、保守的に `Accept-Encoding: identity` を維持\n\n### o1/o3 モデル互換性\n\nプロキシ転送が OpenAI o シリーズモデルのトークンパラメータを正しく処理するようになりました。\n\n- Chat Completions パスが o1/o3/o4-mini モデルに `max_tokens` の代わりに `max_completion_tokens` を使用 (#1451)\n- Responses API パスが正しい `max_output_tokens` フィールドを維持し、`max_completion_tokens` の誤った注入を防止\n\n### OpenCode モデルバリアント\n\n- OpenCode のモデルバリアントを options 内部ではなくプリセットのトップレベルに配置し、発見しやすさを向上 (#1317)\n\n### Skills インポートフロー\n\nSkills インポートフローが正確性とクリーンアップのためにリワークされました。\n\n- ファイルシステムベースの暗黙的なアプリ推論を明示的な `ImportSkillSelection` に置き換え、同じ skill ディレクトリが複数アプリパスに存在する場合の複数アプリ誤有効化を防止\n- `sync_to_app` に調整ロジックを追加し、無効化/孤立したシンボリックリンクを削除\n- MCP `sync_all_enabled` がライブ設定から無効化されたサーバーを削除するように改善\n- スキーママイグレーションがレガシーアプリマッピングのスナップショットを保持し、損失のある再構築を回避\n\n---\n\n## バグ修正\n\n### プロバイダーフォームの二重送信\n\n- プロバイダー追加/編集フォームでの高速連続クリックによる重複送信を防止 (#1352)\n\n### Ghostty ターミナルセッション復元\n\n- Ghostty ターミナルでの Claude セッション復元の失敗を修正 (#1506、@canyonsehun に感謝)\n\n### Skill ZIP インポート拡張子\n\n- ZIP インポートダイアログが `.skill` ファイル拡張子をサポートするように修正 (#1240, #1455)\n\n### Skill ZIP インストール対象アプリ\n\n- ZIP 方式でインストールされた skill が常に Claude をデフォルトにするのではなく、現在アクティブなアプリを使用するように修正\n\n### OpenClaw アクティブカードのハイライト\n\n- OpenClaw の現在アクティブなプロバイダーカードがハイライト表示されない問題を修正 (#1419)\n\n### TOC 付きレスポンシブレイアウト\n\n- TOC タイトルが存在する場合のレスポンシブデザインを改善 (#1491)\n\n### Skills インポートダイアログの白い画面\n\n- ImportSkillsDialog に不足していた TooltipProvider を追加し、ダイアログを開く際のランタイムクラッシュを防止\n\n### パネル下部の空白エリア\n\n- すべてのコンテンツパネルのハードコードされた `h-[calc(100vh-8rem)]` を `flex-1 min-h-0` に置き換え、異なるプラットフォーム間のオフセット値の不一致による下部のギャップを解消\n\n---\n\n## ダウンロードとインストール\n\n[Releases](https://github.com/farion1231/cc-switch/releases/latest) から適切なバージョンをダウンロードしてください。\n\n### システム要件\n\n| システム | 最小バージョン                   | アーキテクチャ                      |\n| -------- | -------------------------------- | ----------------------------------- |\n| Windows  | Windows 10 以降                  | x64                                 |\n| macOS    | macOS 12 (Monterey) 以降         | Intel (x64) / Apple Silicon (arm64) |\n| Linux    | 下表参照                         | x64                                 |\n\n### Windows\n\n| ファイル                                   | 説明                                                 |\n| ------------------------------------------ | ---------------------------------------------------- |\n| `CC-Switch-v3.12.3-Windows.msi`            | **推奨** - MSI インストーラー、自動更新対応          |\n| `CC-Switch-v3.12.3-Windows-Portable.zip`   | ポータブル版、解凍して実行、レジストリ書き込みなし   |\n\n### macOS\n\n| ファイル                           | 説明                                                              |\n| ---------------------------------- | ----------------------------------------------------------------- |\n| `CC-Switch-v3.12.3-macOS.zip`      | **推奨** - 解凍して Applications にドラッグ、Universal Binary     |\n| `CC-Switch-v3.12.3-macOS.tar.gz`   | Homebrew インストールと自動更新用                                 |\n\n> **注意**: 作者が Apple Developer アカウントを持っていないため、初回起動時に「開発元を確認できません」という警告が表示される場合があります。一度閉じてから、「システム設定」→「プライバシーとセキュリティ」→「このまま開く」をクリックすると、その後は正常に開けます。\n\n### Homebrew (macOS)\n\n```bash\nbrew tap farion1231/ccswitch\nbrew install --cask cc-switch\n```\n\n更新：\n\n```bash\nbrew upgrade --cask cc-switch\n```\n\n### Linux\n\n| ディストリビューション                  | 推奨形式    | インストール方法                                                       |\n| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |\n| Ubuntu / Debian / Linux Mint / Pop!\\_OS | `.deb`      | `sudo dpkg -i CC-Switch-*.deb` または `sudo apt install ./CC-Switch-*.deb` |\n| Fedora / RHEL / CentOS / Rocky Linux    | `.rpm`      | `sudo rpm -i CC-Switch-*.rpm` または `sudo dnf install ./CC-Switch-*.rpm`  |\n| openSUSE                                | `.rpm`      | `sudo zypper install ./CC-Switch-*.rpm`                                |\n| Arch Linux / Manjaro                    | `.AppImage` | 実行権限を追加して直接実行、または AUR を使用                          |\n| その他のディストリビューション / 不明   | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage`              |\n"
  },
  {
    "path": "docs/release-notes/v3.12.3-zh.md",
    "content": "# CC Switch v3.12.3\n\n> Tool Search 域名限制绕过、Skill 备份/恢复生命周期、代理 Gzip 压缩与 o 系列模型兼容性\n\n**[English →](v3.12.3-en.md) | [日本語版 →](v3.12.3-ja.md)**\n\n---\n\n## 概览\n\nCC Switch v3.12.3 新增了通过二进制补丁绕过 Tool Search 域名白名单限制的功能，引入了完整的 Skill 备份/恢复生命周期，改进了代理对 OpenAI o 系列模型的兼容性和 gzip 压缩支持，并修复了 Skills 导入、供应商表单和终端会话恢复等方面的问题。Skill 卸载前会自动备份并支持恢复和删除管理，导入流程从基于文件系统的隐式推断改为显式应用选择。\n\n**发布日期**：2026-03-16\n\n**更新规模**：17 commits | 61 files changed | +3,335 / -194 lines\n\n---\n\n## 重点内容\n\n- **Tool Search 域名绕过**：新增设置项，通过等长二进制补丁移除 Claude CLI Tool Search 域名白名单检查，启动时自动备份和重新应用\n- **Skill 备份/恢复生命周期**：卸载前自动备份 Skill 文件；新增备份列表、恢复和删除管理\n\n- **代理 Gzip 压缩**：非流式代理请求现在自动协商 gzip 压缩，减少带宽消耗\n- **o 系列模型兼容性**：Chat Completions 代理正确使用 `max_completion_tokens` 处理 o1/o3/o4-mini 模型；Responses API 保持使用正确的 `max_output_tokens` 字段\n- **Skills 导入重构**：将基于文件系统的隐式应用推断替换为显式的 `ImportSkillSelection`，防止多应用错误激活\n- **Ghostty 终端支持**：修复在 Ghostty 终端中恢复 Claude 会话的问题\n\n---\n\n## 新功能\n\n### Tool Search 域名限制绕过\n\n新增设置项，可绕过 Claude CLI Tool Search 的域名白名单限制。\n\n- 从 PATH 中解析当前活跃的 `claude` 命令，应用等长字节补丁移除域名白名单检查\n- 备份存储在 `~/.cc-switch/toolsearch-backups/`（以路径的 SHA-256 为文件名），Claude Code 升级后备份仍然有效\n- 设置启用时，应用启动自动重新应用补丁\n- 前端检查补丁结果，失败时自动回滚设置\n\n### Skill 卸载自动备份\n\n卸载 Skill 前自动备份文件，防止数据意外丢失。\n\n- 备份存储在 `~/.cc-switch/skill-backups/`，包含所有 skill 文件和记录原始元数据的 `meta.json`\n- 旧备份自动清理，最多保留 20 个\n- 备份路径返回前端并在成功提示中显示\n\n### Skill 备份恢复与删除\n\n新增卸载时创建的 Skill 备份的管理功能。\n\n- 列出所有可用的 skill 备份及元数据\n- 恢复操作将文件拷回 SSOT，保存数据库记录，并同步到当前应用，失败时自动回滚\n- 删除操作在确认对话框后移除备份目录\n- ConfirmDialog 新增可配置的 zIndex 属性，支持嵌套对话框堆叠\n\n---\n\n## 变更\n\n### 代理 Gzip 压缩\n\n非流式代理请求现在支持 gzip 压缩，减少带宽消耗。\n\n- 非流式请求允许 reqwest 自动协商 gzip 并透明解压响应\n- 流式请求保守地保持 `Accept-Encoding: identity`，避免中断的 SSE 流解压出错\n\n### o1/o3 模型兼容性\n\n代理转发现在正确处理 OpenAI o 系列模型的 token 参数。\n\n- Chat Completions 路径对 o1/o3/o4-mini 模型使用 `max_completion_tokens` 替代 `max_tokens` (#1451)\n- Responses API 路径保持使用正确的 `max_output_tokens` 字段，不再错误注入 `max_completion_tokens`\n\n### OpenCode 模型变体\n\n- 将 OpenCode 的模型变体放在预设顶层而非嵌套在 options 内部，提升可发现性 (#1317)\n\n### Skills 导入流程\n\nSkills 导入流程经过重构，提升正确性和清理能力。\n\n- 将基于文件系统的隐式应用推断替换为显式的 `ImportSkillSelection`，防止同一 skill 目录存在于多个应用路径下时错误激活多个应用\n- 为 `sync_to_app` 增加协调逻辑，移除已禁用/孤立的符号链接\n- MCP `sync_all_enabled` 现在会从 live 配置中移除已禁用的服务器\n- 数据库迁移保留旧版应用映射快照，避免有损重建\n\n---\n\n## Bug 修复\n\n### 供应商表单防重复提交\n\n- 修复快速连续点击按钮时供应商添加/编辑表单重复提交的问题 (#1352)\n\n### Ghostty 终端会话恢复\n\n- 修复在 Ghostty 终端中恢复 Claude 会话失败的问题 (#1506，感谢 @canyonsehun)\n\n### Skill ZIP 导入扩展名\n\n- ZIP 导入对话框现在支持 `.skill` 文件扩展名 (#1240, #1455)\n\n### Skill ZIP 安装目标应用\n\n- ZIP 方式安装的 skill 现在使用当前活跃应用，而非始终默认为 Claude\n\n### OpenClaw 活跃供应商高亮\n\n- 修复 OpenClaw 当前激活的供应商卡片未高亮显示的问题 (#1419)\n\n### 响应式布局与 TOC\n\n- 改善存在 TOC 标题时的响应式布局 (#1491)\n\n### Skills 导入对话框白屏\n\n- 在 ImportSkillsDialog 中补充缺失的 TooltipProvider，修复打开对话框时的运行时崩溃\n\n### 面板底部空白区域\n\n- 将所有内容面板的硬编码 `h-[calc(100vh-8rem)]` 替换为 `flex-1 min-h-0`，消除因不同平台偏移量不匹配导致的底部空白\n\n---\n\n## 下载与安装\n\n访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。\n\n### 系统要求\n\n| 系统    | 最低版本                      | 架构                                |\n| ------- | ----------------------------- | ----------------------------------- |\n| Windows | Windows 10 及以上             | x64                                 |\n| macOS   | macOS 12 (Monterey) 及以上    | Intel (x64) / Apple Silicon (arm64) |\n| Linux   | 见下表                        | x64                                 |\n\n### Windows\n\n| 文件                                       | 说明                                |\n| ------------------------------------------ | ----------------------------------- |\n| `CC-Switch-v3.12.3-Windows.msi`            | **推荐** - MSI 安装包，支持自动更新 |\n| `CC-Switch-v3.12.3-Windows-Portable.zip`   | 便携版，解压即用，不写入注册表      |\n\n### macOS\n\n| 文件                               | 说明                                                      |\n| ---------------------------------- | --------------------------------------------------------- |\n| `CC-Switch-v3.12.3-macOS.zip`      | **推荐** - 解压后拖入 Applications 即可，Universal Binary |\n| `CC-Switch-v3.12.3-macOS.tar.gz`   | 用于 Homebrew 安装和自动更新                              |\n\n> **注意**：由于作者没有苹果开发者账号，首次打开可能出现\"未知开发者\"警告，请先关闭，然后前往\"系统设置\" → \"隐私与安全性\" → 点击\"仍要打开\"，之后便可以正常打开\n\n### Homebrew（macOS）\n\n```bash\nbrew tap farion1231/ccswitch\nbrew install --cask cc-switch\n```\n\n更新：\n\n```bash\nbrew upgrade --cask cc-switch\n```\n\n### Linux\n\n| 发行版                                  | 推荐格式    | 安装方式                                                               |\n| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |\n| Ubuntu / Debian / Linux Mint / Pop!\\_OS | `.deb`      | `sudo dpkg -i CC-Switch-*.deb` 或 `sudo apt install ./CC-Switch-*.deb` |\n| Fedora / RHEL / CentOS / Rocky Linux    | `.rpm`      | `sudo rpm -i CC-Switch-*.rpm` 或 `sudo dnf install ./CC-Switch-*.rpm`  |\n| openSUSE                                | `.rpm`      | `sudo zypper install ./CC-Switch-*.rpm`                                |\n| Arch Linux / Manjaro                    | `.AppImage` | 添加执行权限后直接运行，或使用 AUR                                     |\n| 其他发行版 / 不确定                     | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage`              |\n"
  },
  {
    "path": "docs/release-notes/v3.6.0-en.md",
    "content": "## Major architecture refactoring with enhanced config sync and data protection\n\n**[中文更新说明 Chinese Documentation →](https://github.com/farion1231/cc-switch/blob/main/docs/release-notes/v3.6.0-zh.md)**\n\n---\n\n## What's New\n\n### Edit Mode & Provider Management\n\n- **Provider Duplication** - Quickly duplicate existing provider configurations to create variants with one click\n- **Manual Sorting** - Drag and drop to reorder providers, with visual push effect animations. Thanks to @ZyphrZero\n- **Edit Mode Toggle** - Show/hide drag handles to optimize editing experience\n\n### Custom Endpoint Management\n\n- **Multi-Endpoint Configuration** - Support for aggregator providers with multiple API endpoints\n- **Endpoint Input Visibility** - Shows endpoint field for all non-official providers automatically\n\n### Usage Query Enhancements\n\n- **Auto-Refresh Interval** - Configure periodic automatic usage queries with customizable intervals\n- **Test Script API** - Validate JavaScript usage query scripts before execution\n- **Enhanced Templates** - Custom blank templates with access token and user ID parameter support\n  Thanks to @Sirhexs\n\n### Custom Configuration Directory (Cloud Sync)\n\n- **Customizable Storage Location** - Customize CC Switch's configuration storage directory\n- **Cloud Sync Support** - Point to cloud sync folders (Dropbox, OneDrive, iCloud Drive, etc.) to enable automatic config synchronization across devices\n- **Independent Management** - Managed via Tauri Store for better isolation and reliability\n  Thanks to @ZyphrZero\n\n### Configuration Directory Switching (WSL Support)\n\n- **Auto-Sync on Directory Change** - When switching Claude/Codex config directories (e.g., WSL environment), automatically sync current provider to the new directory without manual operation\n- **Post-Change Sync Utility** - Unified `postChangeSync.ts` utility for graceful error handling without blocking main flow\n- **Import Config Auto-Sync** - Automatically sync after config import to ensure immediate effectiveness\n- **Smart Conflict Resolution** - Distinguishes \"fully successful\" and \"partially successful\" states for precise user feedback\n\n### Configuration Editor Improvements\n\n- **JSON Format Button** - One-click JSON formatting in configuration editors\n- **Real-Time TOML Validation** - Live syntax validation for Codex configuration with error highlighting\n\n### Load Live Config When Editing\n\n- **Protect Manual Modifications** - When editing the currently active provider, prioritize displaying the actual effective configuration from live files\n- **Dual-Source Strategy** - Automatically loads from live config for active provider, SSOT for inactive ones\n\n### Claude Configuration Data Structure Enhancements\n\n- **Granular Model Configuration** - Migrated from dual-key to quad-key system for better model tier differentiation\n  - New fields: `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_MODEL`\n  - Replaces legacy `ANTHROPIC_SMALL_FAST_MODEL` with automatic migration\n  - Backend normalizes old configs on first read/write with smart fallback chain\n  - UI expanded from 2 to 4 model input fields with intelligent defaults\n- **ANTHROPIC_API_KEY Support** - Providers can now use `ANTHROPIC_API_KEY` field in addition to `ANTHROPIC_AUTH_TOKEN`\n- **Template Variable System** - Support for dynamic configuration replacement (e.g., KAT-Coder's `ENDPOINT_ID` parameter)\n- **Endpoint Candidates** - Predefined endpoint list for speed testing and endpoint management\n- **Visual Theme Configuration** - Custom icons and colors for provider cards\n\n### Updated Provider Models\n\n- **Kimi k2** - Updated to latest `kimi-k2-thinking` model\n\n### New Provider Presets\n\nAdded 5 new provider presets:\n\n- **DMXAPI** - Multi-model aggregation service\n- **Azure Codex** - Microsoft Azure OpenAI endpoint\n- **AnyRouter** - None-profit routing service\n- **AiHubMix** - Multi-model aggregation service\n- **MiniMax** - Open source AI model provider\n\n### Partner Promotion Mechanism\n\n- Support for ecosystem partner promotion (Zhipu GLM Z.ai)\n- Sponsored banner integration in README\n\n---\n\n## Improvements\n\n### Configuration & Sync\n\n- **Unified Error Handling** - AppError with internationalized error messages throughout backend\n- **Fixed apiKeyUrl Priority** - Correct priority order for API key URL resolution\n- **Fixed MCP Sync Issues** - Resolved sync-to-other-side functionality failures\n- **Import Config Sync** - Fixed sync issues after configuration import\n- **Config Error Handling** - Force exit on config error to prevent silent fallback and data loss\n\n### UI/UX Enhancements\n\n- **Unique Provider Icons** - Each provider card now has unique icons and color identification\n- **Unified Border System** - Consistent border design across all components\n- **Drag Interaction** - Push effect animation and improved drag handle icons\n- **Enhanced Visual Feedback** - Better current provider visual indication\n- **Dialog Standardization** - Unified dialog sizes and layout consistency\n- **Form Improvements** - Optimized model placeholders, simplified provider hints, category-specific hints\n- **Usage Display Inline** - Usage info moved next to enable button for better space utilization\n\n### Complete Internationalization\n\n- **Error Messages i18n** - All backend error messages support Chinese/English\n- **Tray Menu i18n** - System tray menu fully internationalized\n- **UI Components i18n** - 100% coverage across all user-facing components\n\n---\n\n## Bug Fixes\n\n### Configuration Management\n\n- Fixed `apiKeyUrl` priority issue\n- Fixed MCP sync-to-other-side functionality failure\n- Fixed sync issues after config import\n- Fixed Codex API Key auto-sync\n- Fixed endpoint speed test functionality\n- Fixed provider duplicate insertion position (now inserts next to original)\n- Fixed custom endpoint preservation in edit mode\n- Prevent silent fallback and data loss on config error\n\n### Usage Query\n\n- Fixed auto-query interval timing issue\n- Ensured refresh button shows loading animation on click\n\n### UI Issues\n\n- Fixed name collision error (`get_init_error` command)\n- Fixed language setting rollback after successful save\n- Fixed language switch state reset (dependency cycle)\n- Fixed edit mode button alignment\n\n### Startup Issues\n\n- Force exit on config error (no silent fallback)\n- Eliminated code duplication causing initialization errors\n\n---\n\n## Architecture Refactoring\n\n### Backend (Rust) - 5 Phase Refactoring\n\n1. **Phase 1**: Unified error handling (`AppError` + i18n error messages)\n2. **Phase 2**: Command layer split by domain (`commands/{provider,mcp,config,settings,plugin,misc}.rs`)\n3. **Phase 3**: Integration tests and transaction mechanism (config snapshot + failure rollback)\n4. **Phase 4**: Extracted Service layer (`services/{provider,mcp,config,speedtest}.rs`)\n5. **Phase 5**: Concurrency optimization (`RwLock` instead of `Mutex`, scoped guard to avoid deadlock)\n\n### Frontend (React + TypeScript) - 4 Stage Refactoring\n\n1. **Stage 1**: Test infrastructure (vitest + MSW + @testing-library/react)\n2. **Stage 2**: Extracted custom hooks (`useProviderActions`, `useMcpActions`, `useSettings`, `useImportExport`, etc.)\n3. **Stage 3**: Component splitting and business logic extraction\n4. **Stage 4**: Code cleanup and formatting unification\n\n### Testing System\n\n- **Hooks Unit Tests** - 100% coverage for all custom hooks\n- **Integration Tests** - Coverage for key processes (App, SettingsDialog, MCP Panel)\n- **MSW Mocking** - Backend API mocking to ensure test independence\n- **Test Infrastructure** - vitest + MSW + @testing-library/react\n\n### Code Quality\n\n- **Unified Parameter Format** - All Tauri commands migrated to camelCase (Tauri 2 specification)\n- **Semantic Clarity** - `AppType` renamed to `AppId` for better semantics\n- **Centralized Parsing** - Unified `app` parameter parsing with `FromStr` trait\n- **DRY Violations Cleanup** - Eliminated code duplication throughout codebase\n- **Dead Code Removal** - Removed unused `missing_param` helper, deprecated `tauri-api.ts`, redundant `KimiModelSelector`\n\n---\n\n## Internal Optimizations (User Transparent)\n\n### Removed Legacy Migration Logic\n\nv3.6.0 removed v1 config auto-migration and copy file scanning logic:\n\n- **Impact**: Improved startup performance, cleaner codebase\n- **Compatibility**: v2 format configs fully compatible, no action required\n- **Note**: Users upgrading from v3.1.0 or earlier should first upgrade to v3.2.x or v3.5.x for one-time migration, then upgrade to v3.6.0\n\n### Command Parameter Standardization\n\nBackend unified to use `app` parameter (values: `claude` or `codex`):\n\n- **Impact**: More standardized code, friendlier error prompts\n- **Compatibility**: Frontend fully adapted, users don't need to care about this change\n\n---\n\n## Dependencies\n\n- Updated to **Tauri 2.8.x**\n- Updated to **TailwindCSS 4.x**\n- Updated to **TanStack Query v5.90.x**\n- Maintained **React 18.2.x** and **TypeScript 5.3.x**\n\n---\n\n## Installation\n\n### macOS\n\n**Via Homebrew (Recommended):**\n\n```bash\nbrew tap farion1231/ccswitch\nbrew install --cask cc-switch\n```\n\n**Manual Download:**\n\n- Download `CC-Switch-v3.6.0-macOS.zip` from [Assets](#assets) below\n\n> **Note**: Due to lack of Apple Developer account, you may see \"unidentified developer\" warning. Go to System Settings → Privacy & Security → Click \"Open Anyway\"\n\n### Windows\n\n- **Installer**: `CC-Switch-v3.6.0-Windows.msi`\n- **Portable**: `CC-Switch-v3.6.0-Windows-Portable.zip`\n\n### Linux\n\n- **AppImage**: `CC-Switch-v3.6.0-Linux.AppImage`\n- **Debian**: `CC-Switch-v3.6.0-Linux.deb`\n\n---\n\n## Documentation\n\n- [中文文档 (Chinese)](https://github.com/farion1231/cc-switch/blob/main/README_ZH.md)\n- [English Documentation](https://github.com/farion1231/cc-switch/blob/main/README.md)\n- [完整更新日志 (Full Changelog)](https://github.com/farion1231/cc-switch/blob/main/CHANGELOG.md)\n\n---\n\n## Acknowledgments\n\nSpecial thanks to **Zhipu AI** for sponsoring this project with their GLM CODING PLAN!\n\n---\n\n**Full Changelog**: https://github.com/farion1231/cc-switch/compare/v3.5.1...v3.6.0\n"
  },
  {
    "path": "docs/release-notes/v3.6.0-zh.md",
    "content": "# CC Switch v3.6.0\n\n> 全栈架构重构，增强配置同步与数据保护\n\n**[English Version →](v3.6.0-en.md)**\n\n---\n\n## 新增功能\n\n### 编辑模式与供应商管理\n\n- **供应商复制功能** - 一键快速复制现有供应商配置，轻松创建变体配置\n- **手动排序功能** - 通过拖拽对供应商进行重新排序，带有视觉推送效果动画\n- **编辑模式切换** - 显示/隐藏拖拽手柄，优化编辑体验\n\n### 自定义端点管理\n\n- **多端点配置** - 支持聚合类供应商的多 API 端点配置\n- **端点输入可见性** - 为所有非官方供应商自动显示端点字段\n\n### 自定义配置目录（云同步）\n\n- **自定义存储位置** - 自定义 CC Switch 的配置存储目录\n- **云同步支持** - 指定到云同步文件夹（Dropbox、OneDrive、iCloud Drive、坚果云等）即可实现跨设备配置自动同步\n- **独立管理** - 通过 Tauri Store 管理，更好的隔离性和可靠性\n\n### 使用量查询增强\n\n- **自动刷新间隔** - 配置定时自动使用量查询，支持自定义间隔时间\n- **测试脚本 API** - 在执行前验证 JavaScript 使用量查询脚本\n- **增强模板系统** - 自定义空白模板，支持 access token 和 user ID 参数\n\n### 配置目录切换（WSL 支持）\n\n- **目录变更自动同步** - 切换 Claude/Codex 配置目录（如 WSL 环境）时，自动同步当前供应商到新目录，无需手动操作\n- **后置同步工具** - 统一的 `postChangeSync.ts` 工具，优雅处理错误而不阻塞主流程\n- **导入配置自动同步** - 配置导入后自动同步，确保立即生效\n- **智能冲突解决** - 区分\"完全成功\"和\"部分成功\"状态，提供精确的用户反馈\n\n### 配置编辑器改进\n\n- **JSON 格式化按钮** - 配置编辑器中一键 JSON 格式化\n- **实时 TOML 验证** - Codex 配置的实时语法验证，带有错误高亮\n\n### 编辑时加载 Live 配置\n\n- **保护手动修改** - 编辑当前激活的供应商时，优先显示来自 live 文件的实际生效配置\n- **双源策略** - 活动供应商自动从 live 配置加载，非活动供应商从 SSOT 加载\n\n### Claude 配置数据结构增强\n\n- **细粒度模型配置** - 从双键系统升级到四键系统，以匹配官方最新数据结构\n  - 新增字段：`ANTHROPIC_DEFAULT_HAIKU_MODEL`、`ANTHROPIC_DEFAULT_SONNET_MODEL`、`ANTHROPIC_DEFAULT_OPUS_MODEL`、`ANTHROPIC_MODEL`\n  - 替换旧版 `ANTHROPIC_SMALL_FAST_MODEL`，支持自动迁移\n  - 后端在首次读写时自动规范化旧配置，带有智能回退链\n  - UI 从 2 个模型输入字段扩展到 4 个，具有智能默认值\n- **ANTHROPIC_API_KEY 支持** - 供应商现可使用 `ANTHROPIC_API_KEY` 字段（除 `ANTHROPIC_AUTH_TOKEN` 外）\n- **模板变量系统** - 支持动态配置替换（如 KAT-Coder 的 `ENDPOINT_ID` 参数）\n- **端点候选列表** - 预定义端点列表，用于速度测试和端点管理\n- **视觉主题配置** - 供应商卡片自定义图标和颜色\n\n### 供应商模型更新\n\n- **Kimi k2** - 更新到最新的 `kimi-k2-thinking` 模型\n\n### 新增供应商预设\n\n新增 5 个供应商预设：\n\n- **DMXAPI** - 多模型聚合服务\n- **Azure Codex** - 微软 Azure OpenAI 端点\n- **AnyRouter** - API 路由服务\n- **AiHubMix** - AI 模型集合\n- **MiniMax** - 国产 AI 模型提供商\n\n### 合作伙伴推广机制\n\n- 支持生态合作伙伴推广（智谱 GLM Z.ai）\n- README 中集成赞助商横幅\n\n---\n\n## 改进优化\n\n### 配置与同步\n\n- **统一错误处理** - 后端全面使用 AppError 与国际化错误消息\n- **修复 apiKeyUrl 优先级** - 修正 API key URL 解析的优先级顺序\n- **修复 MCP 同步问题** - 解决同步到另一端功能失效的问题\n- **导入配置同步** - 修复配置导入后的同步问题\n- **配置错误处理** - 配置错误时强制退出，防止静默回退和数据丢失\n\n### UI/UX 增强\n\n- **独特的供应商图标** - 每个供应商卡片现在都有独特的图标和颜色识别\n- **统一边框系统** - 所有组件采用一致的边框设计\n- **拖拽交互** - 推送效果动画和改进的拖拽手柄图标\n- **增强视觉反馈** - 更好的当前供应商视觉指示\n- **对话框标准化** - 统一的对话框尺寸和布局一致性\n- **表单改进** - 优化模型占位符，简化供应商提示，分类特定提示\n- **使用量内联显示** - 使用量信息移至启用按钮旁边，更好地利用空间\n\n### 完整国际化\n\n- **错误消息国际化** - 所有后端错误消息支持中英文\n- **托盘菜单国际化** - 系统托盘菜单完全国际化\n- **UI 组件国际化** - 所有面向用户的组件 100% 覆盖\n\n---\n\n## Bug 修复\n\n### 配置管理\n\n- 修复 `apiKeyUrl` 优先级问题\n- 修复 MCP 同步到另一端功能失效\n- 修复配置导入后的同步问题\n- 修复 Codex API Key 自动同步\n- 修复端点速度测试功能\n- 修复供应商复制插入位置（现在插入到原供应商旁边）\n- 修复编辑模式下自定义端点保留问题\n- 防止配置错误时的静默回退和数据丢失\n\n### 使用量查询\n\n- 修复自动查询间隔时间问题\n- 确保刷新按钮点击时显示加载动画\n\n### UI 问题\n\n- 修复名称冲突错误（`get_init_error` 命令）\n- 修复保存成功后语言设置回滚\n- 修复语言切换状态重置（依赖循环）\n- 修复编辑模式按钮对齐\n\n### 启动问题\n\n- 配置错误时强制退出（不再静默回退）\n- 消除导致初始化错误的代码重复\n\n---\n\n## 架构重构\n\n### 后端（Rust）- 5 阶段重构\n\n1. **阶段 1**：统一错误处理（`AppError` + 国际化错误消息）\n2. **阶段 2**：命令层按领域拆分（`commands/{provider,mcp,config,settings,plugin,misc}.rs`）\n3. **阶段 3**：集成测试和事务机制（配置快照 + 失败回滚）\n4. **阶段 4**：提取 Service 层（`services/{provider,mcp,config,speedtest}.rs`）\n5. **阶段 5**：并发优化（`RwLock` 替代 `Mutex`，作用域 guard 避免死锁）\n\n### 前端（React + TypeScript）- 4 阶段重构\n\n1. **阶段 1**：测试基础设施（vitest + MSW + @testing-library/react）\n2. **阶段 2**：提取自定义 hooks（`useProviderActions`、`useMcpActions`、`useSettings`、`useImportExport` 等）\n3. **阶段 3**：组件拆分和业务逻辑提取\n4. **阶段 4**：代码清理和格式化统一\n\n### 测试体系\n\n- **Hooks 单元测试** - 所有自定义 hooks 100% 覆盖\n- **集成测试** - 关键流程覆盖（App、SettingsDialog、MCP 面板）\n- **MSW 模拟** - 后端 API 模拟确保测试独立性\n- **测试基础设施** - vitest + MSW + @testing-library/react\n\n### 代码质量\n\n- **统一参数格式** - 所有 Tauri 命令迁移到 camelCase（Tauri 2 规范）\n- **语义清晰** - `AppType` 重命名为 `AppId` 以获得更好的语义\n- **集中解析** - 使用 `FromStr` trait 统一 `app` 参数解析\n- **DRY 违规清理** - 消除整个代码库中的代码重复\n- **死代码移除** - 移除未使用的 `missing_param` 辅助函数、废弃的 `tauri-api.ts`、冗余的 `KimiModelSelector`\n\n---\n\n## 内部优化（用户无感知）\n\n### 移除遗留迁移逻辑\n\nv3.6.0 移除了 v1 配置自动迁移和副本文件扫描逻辑：\n\n- **影响**：提升启动性能，代码更简洁\n- **兼容性**：v2 格式配置完全兼容，无需任何操作\n- **注意**：从 v3.1.0 或更早版本升级的用户，请先升级到 v3.2.x 或 v3.5.x 进行一次性迁移，然后再升级到 v3.6.0\n\n### 命令参数标准化\n\n后端统一使用 `app` 参数（取值：`claude` 或 `codex`）：\n\n- **影响**：代码更规范，错误提示更友好\n- **兼容性**：前端已完全适配，用户无需关心此变更\n\n---\n\n## 依赖更新\n\n- 更新到 **Tauri 2.8.x**\n- 更新到 **TailwindCSS 4.x**\n- 更新到 **TanStack Query v5.90.x**\n- 保持 **React 18.2.x** 和 **TypeScript 5.3.x**\n\n---\n\n## 安装方式\n\n### macOS\n\n**通过 Homebrew 安装（推荐）：**\n\n```bash\nbrew tap farion1231/ccswitch\nbrew install --cask cc-switch\n```\n\n**手动下载：**\n\n- 从下方 [Assets](#assets) 下载 `CC-Switch-v3.6.0-macOS.zip`\n\n> **注意**：由于作者没有苹果开发者账号，首次打开可能出现\"未知开发者\"警告。请前往\"系统设置\" → \"隐私与安全性\" → 点击\"仍要打开\"\n\n### Windows\n\n- **安装包**：`CC-Switch-v3.6.0-Windows.msi`\n- **便携版**：`CC-Switch-v3.6.0-Windows-Portable.zip`\n\n### Linux\n\n- **AppImage**：`CC-Switch-v3.6.0-Linux.AppImage`\n- **Debian**：`CC-Switch-v3.6.0-Linux.deb`\n\n---\n\n## 文档\n\n- [中文文档](https://github.com/farion1231/cc-switch/blob/main/README_ZH.md)\n- [English Documentation](https://github.com/farion1231/cc-switch/blob/main/README.md)\n- [完整更新日志](https://github.com/farion1231/cc-switch/blob/main/CHANGELOG.md)\n\n---\n\n## 致谢\n\n特别感谢**智谱 AI** 通过 GLM CODING PLAN 赞助本项目！\n\n---\n\n**完整变更记录**: https://github.com/farion1231/cc-switch/compare/v3.5.1...v3.6.0\n"
  },
  {
    "path": "docs/release-notes/v3.6.1-en.md",
    "content": "# CC Switch v3.6.1\n\n> Stability improvements and user experience optimization (based on v3.6.0)\n\n**[中文更新说明 Chinese Documentation →](https://github.com/farion1231/cc-switch/blob/main/docs/release-notes/v3.6.1-zh.md)**\n\n---\n\n## 📦 What's New in v3.6.1 (2025-11-10)\n\nThis release focuses on **user experience optimization** and **configuration parsing robustness**, fixing several critical bugs and enhancing the usage query system.\n\n### ✨ New Features\n\n#### Usage Query System Enhancements\n\n- **Credential Decoupling** - Usage queries can now use independent API Key and Base URL, no longer dependent on provider configuration\n  - Support for different query endpoints and authentication methods\n  - Automatically displays credential input fields based on template type\n  - General template: API Key + Base URL\n  - NewAPI template: Base URL + Access Token + User ID\n  - Custom template: Fully customizable\n- **UI Component Upgrade** - Replaced native checkbox with shadcn/ui Switch component for modern experience\n- **Form Unification** - Unified use of shadcn/ui Input components, consistent styling with the application\n- **Password Visibility Toggle** - Added show/hide password functionality (API Key, Access Token)\n\n#### Form Validation Infrastructure\n\n- **Common Schema Library** - New JSON/TOML generic validators to reduce code duplication\n  - `jsonConfigSchema`: Generic JSON object validator\n  - `tomlConfigSchema`: Generic TOML format validator\n  - `mcpJsonConfigSchema`: MCP-specific JSON validator\n- **MCP Conditional Field Validation** - Strict type checking\n  - stdio type requires `command` field\n  - http type requires `url` field\n\n#### Partner Integration\n\n- **PackyCode** - New official partner\n  - Added to Claude and Codex provider presets\n  - 10% discount promotion support\n  - New logo and partner identification\n\n---\n\n### 🔧 Improvements\n\n#### User Experience\n\n- **Drag Sort Sync** - Tray menu order now syncs with drag-and-drop sorting in real-time\n- **Enhanced Error Notifications** - Provider switch failures now display copyable error messages\n- **Removed Misleading Placeholders** - Deleted example text from model input fields to avoid user confusion\n- **Auto-fill Base URL** - All non-official provider categories automatically populate the Base URL input field\n\n#### Configuration Parsing\n\n- **CJK Quote Normalization** - Automatically handles IME-input fullwidth quotes to prevent TOML parsing errors\n  - Supports automatic conversion of Chinese quotes (\" \" ' ') to ASCII quotes\n  - Applied in TOML input handlers\n  - Disabled browser auto-correction in Textarea component\n- **Preserve Custom Fields** - Editing Codex MCP TOML configuration now preserves unknown fields\n  - Supports extension fields like timeout_ms, retry_count\n  - Forward compatibility with future MCP protocol extensions\n\n---\n\n### 🐛 Bug Fixes\n\n#### Critical Fixes\n\n- **Fixed usage script panel white screen crash** - FormLabel component missing FormField context caused entire app to crash\n  - Replaced with standalone Label component\n  - Root cause: FormLabel internally calls useFormField() hook which requires FormFieldContext\n- **Fixed CJK input quote parsing failure** - IME-input fullwidth quotes caused TOML parsing errors\n  - Added textNormalization utility function\n  - Automatically normalizes quotes before parsing\n- **Fixed drag sort tray desync** (#179) - Tray menu order not updated after drag-and-drop sorting\n  - Automatically calls updateTrayMenu after sorting completes\n  - Ensures UI and tray menu stay consistent\n- **Fixed MCP custom field loss** - Custom fields silently dropped when editing Codex MCP configuration\n  - Uses spread operator to retain all fields\n  - Preserves unknown fields in normalizeServerConfig\n\n#### Stability Improvements\n\n- **Error Isolation** - Tray menu update failures no longer affect main operations\n  - Decoupled tray update errors from main operations\n  - Provides warning when main operation succeeds but tray update fails\n- **Safe Pattern Matching** - Replaced `unwrap()` with safe pattern matching\n  - Avoids panic-induced app crashes\n  - Tray menu event handling uses match patterns\n- **Import Config Classification** - Importing from default config now automatically sets category to `custom`\n  - Avoids imported configs being mistaken for official presets\n  - Provides clearer configuration source identification\n\n---\n\n### 📊 Technical Statistics\n\n```\nCommits: 17 commits\nCode Changes: 31 files\n  - Additions: 1,163 lines\n  - Deletions: 811 lines\n  - Net Growth: +352 lines\nContributors: Jason (16), ZyphrZero (1)\n```\n\n**By Module**:\n- UI/User Interface: 3 commits\n- Usage Query System: 3 commits\n- Configuration Parsing: 2 commits\n- Form Validation: 1 commit\n- Other Improvements: 8 commits\n\n---\n\n### 📥 Installation\n\n#### macOS\n\n**Via Homebrew (Recommended):**\n\n```bash\nbrew tap farion1231/ccswitch\nbrew install --cask cc-switch\n```\n\n**Manual Download:**\n\n- Download `CC-Switch-v3.6.1-macOS.zip` from [Assets](#assets) below\n\n> **Note**: Due to lack of Apple Developer account, you may see \"unidentified developer\" warning. Go to System Settings → Privacy & Security → Click \"Open Anyway\"\n\n#### Windows\n\n- **Installer**: `CC-Switch-v3.6.1-Windows.msi`\n- **Portable**: `CC-Switch-v3.6.1-Windows-Portable.zip`\n\n#### Linux\n\n- **AppImage**: `CC-Switch-v3.6.1-Linux.AppImage`\n- **Debian**: `CC-Switch-v3.6.1-Linux.deb`\n\n---\n\n### 📚 Documentation\n\n- [中文文档 (Chinese)](https://github.com/farion1231/cc-switch/blob/main/README_ZH.md)\n- [English Documentation](https://github.com/farion1231/cc-switch/blob/main/README.md)\n- [完整更新日志 (Full Changelog)](https://github.com/farion1231/cc-switch/blob/main/CHANGELOG.md)\n\n---\n\n### 🙏 Acknowledgments\n\nSpecial thanks to:\n- **Zhipu AI** - For sponsoring this project with GLM CODING PLAN\n- **PackyCode** - New official partner\n- **ZyphrZero** - For contributing tray menu sync fix (#179)\n\n---\n\n**Full Changelog**: https://github.com/farion1231/cc-switch/compare/v3.6.0...v3.6.1\n\n---\n---\n\n## 📜 v3.6.0 Complete Feature Review\n\n> Content below is from v3.6.0 (2025-11-07), helping you understand the complete feature set\n\n<details>\n<summary><b>Click to expand v3.6.0 detailed content →</b></summary>\n\n## What's New\n\n### Edit Mode & Provider Management\n\n- **Provider Duplication** - Quickly duplicate existing provider configurations to create variants with one click\n- **Manual Sorting** - Drag and drop to reorder providers, with visual push effect animations. Thanks to @ZyphrZero\n- **Edit Mode Toggle** - Show/hide drag handles to optimize editing experience\n\n### Custom Endpoint Management\n\n- **Multi-Endpoint Configuration** - Support for aggregator providers with multiple API endpoints\n- **Endpoint Input Visibility** - Shows endpoint field for all non-official providers automatically\n\n### Usage Query Enhancements\n\n- **Auto-Refresh Interval** - Configure periodic automatic usage queries with customizable intervals\n- **Test Script API** - Validate JavaScript usage query scripts before execution\n- **Enhanced Templates** - Custom blank templates with access token and user ID parameter support\n  Thanks to @Sirhexs\n\n### Custom Configuration Directory (Cloud Sync)\n\n- **Customizable Storage Location** - Customize CC Switch's configuration storage directory\n- **Cloud Sync Support** - Point to cloud sync folders (Dropbox, OneDrive, iCloud Drive, etc.) to enable automatic config synchronization across devices\n- **Independent Management** - Managed via Tauri Store for better isolation and reliability\n  Thanks to @ZyphrZero\n\n### Configuration Directory Switching (WSL Support)\n\n- **Auto-Sync on Directory Change** - When switching Claude/Codex config directories (e.g., WSL environment), automatically sync current provider to the new directory without manual operation\n- **Post-Change Sync Utility** - Unified `postChangeSync.ts` utility for graceful error handling without blocking main flow\n- **Import Config Auto-Sync** - Automatically sync after config import to ensure immediate effectiveness\n- **Smart Conflict Resolution** - Distinguishes \"fully successful\" and \"partially successful\" states for precise user feedback\n\n### Configuration Editor Improvements\n\n- **JSON Format Button** - One-click JSON formatting in configuration editors\n- **Real-Time TOML Validation** - Live syntax validation for Codex configuration with error highlighting\n\n### Load Live Config When Editing\n\n- **Protect Manual Modifications** - When editing the currently active provider, prioritize displaying the actual effective configuration from live files\n- **Dual-Source Strategy** - Automatically loads from live config for active provider, SSOT for inactive ones\n\n### Claude Configuration Data Structure Enhancements\n\n- **Granular Model Configuration** - Migrated from dual-key to quad-key system for better model tier differentiation\n  - New fields: `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_MODEL`\n  - Replaces legacy `ANTHROPIC_SMALL_FAST_MODEL` with automatic migration\n  - Backend normalizes old configs on first read/write with smart fallback chain\n  - UI expanded from 2 to 4 model input fields with intelligent defaults\n- **ANTHROPIC_API_KEY Support** - Providers can now use `ANTHROPIC_API_KEY` field in addition to `ANTHROPIC_AUTH_TOKEN`\n- **Template Variable System** - Support for dynamic configuration replacement (e.g., KAT-Coder's `ENDPOINT_ID` parameter)\n- **Endpoint Candidates** - Predefined endpoint list for speed testing and endpoint management\n- **Visual Theme Configuration** - Custom icons and colors for provider cards\n\n### Updated Provider Models\n\n- **Kimi k2** - Updated to latest `kimi-k2-thinking` model\n\n### New Provider Presets\n\nAdded 5 new provider presets:\n\n- **DMXAPI** - Multi-model aggregation service\n- **Azure Codex** - Microsoft Azure OpenAI endpoint\n- **AnyRouter** - None-profit routing service\n- **AiHubMix** - Multi-model aggregation service\n- **MiniMax** - Open source AI model provider\n\n### Partner Promotion Mechanism\n\n- Support for ecosystem partner promotion (Zhipu GLM Z.ai)\n- Sponsored banner integration in README\n\n---\n\n## Improvements\n\n### Configuration & Sync\n\n- **Unified Error Handling** - AppError with internationalized error messages throughout backend\n- **Fixed apiKeyUrl Priority** - Correct priority order for API key URL resolution\n- **Fixed MCP Sync Issues** - Resolved sync-to-other-side functionality failures\n- **Import Config Sync** - Fixed sync issues after configuration import\n- **Config Error Handling** - Force exit on config error to prevent silent fallback and data loss\n\n### UI/UX Enhancements\n\n- **Unique Provider Icons** - Each provider card now has unique icons and color identification\n- **Unified Border System** - Consistent border design across all components\n- **Drag Interaction** - Push effect animation and improved drag handle icons\n- **Enhanced Visual Feedback** - Better current provider visual indication\n- **Dialog Standardization** - Unified dialog sizes and layout consistency\n- **Form Improvements** - Optimized model placeholders, simplified provider hints, category-specific hints\n- **Usage Display Inline** - Usage info moved next to enable button for better space utilization\n\n### Complete Internationalization\n\n- **Error Messages i18n** - All backend error messages support Chinese/English\n- **Tray Menu i18n** - System tray menu fully internationalized\n- **UI Components i18n** - 100% coverage across all user-facing components\n\n---\n\n## Bug Fixes\n\n### Configuration Management\n\n- Fixed `apiKeyUrl` priority issue\n- Fixed MCP sync-to-other-side functionality failure\n- Fixed sync issues after config import\n- Fixed Codex API Key auto-sync\n- Fixed endpoint speed test functionality\n- Fixed provider duplicate insertion position (now inserts next to original)\n- Fixed custom endpoint preservation in edit mode\n- Prevent silent fallback and data loss on config error\n\n### Usage Query\n\n- Fixed auto-query interval timing issue\n- Ensured refresh button shows loading animation on click\n\n### UI Issues\n\n- Fixed name collision error (`get_init_error` command)\n- Fixed language setting rollback after successful save\n- Fixed language switch state reset (dependency cycle)\n- Fixed edit mode button alignment\n\n### Startup Issues\n\n- Force exit on config error (no silent fallback)\n- Eliminated code duplication causing initialization errors\n\n---\n\n## Architecture Refactoring\n\n### Backend (Rust) - 5 Phase Refactoring\n\n1. **Phase 1**: Unified error handling (`AppError` + i18n error messages)\n2. **Phase 2**: Command layer split by domain (`commands/{provider,mcp,config,settings,plugin,misc}.rs`)\n3. **Phase 3**: Integration tests and transaction mechanism (config snapshot + failure rollback)\n4. **Phase 4**: Extracted Service layer (`services/{provider,mcp,config,speedtest}.rs`)\n5. **Phase 5**: Concurrency optimization (`RwLock` instead of `Mutex`, scoped guard to avoid deadlock)\n\n### Frontend (React + TypeScript) - 4 Stage Refactoring\n\n1. **Stage 1**: Test infrastructure (vitest + MSW + @testing-library/react)\n2. **Stage 2**: Extracted custom hooks (`useProviderActions`, `useMcpActions`, `useSettings`, `useImportExport`, etc.)\n3. **Stage 3**: Component splitting and business logic extraction\n4. **Stage 4**: Code cleanup and formatting unification\n\n### Testing System\n\n- **Hooks Unit Tests** - 100% coverage for all custom hooks\n- **Integration Tests** - Coverage for key processes (App, SettingsDialog, MCP Panel)\n- **MSW Mocking** - Backend API mocking to ensure test independence\n- **Test Infrastructure** - vitest + MSW + @testing-library/react\n\n### Code Quality\n\n- **Unified Parameter Format** - All Tauri commands migrated to camelCase (Tauri 2 specification)\n- **Semantic Clarity** - `AppType` renamed to `AppId` for better semantics\n- **Centralized Parsing** - Unified `app` parameter parsing with `FromStr` trait\n- **DRY Violations Cleanup** - Eliminated code duplication throughout codebase\n- **Dead Code Removal** - Removed unused `missing_param` helper, deprecated `tauri-api.ts`, redundant `KimiModelSelector`\n\n---\n\n## Internal Optimizations (User Transparent)\n\n### Removed Legacy Migration Logic\n\nv3.6.0 removed v1 config auto-migration and copy file scanning logic:\n\n- **Impact**: Improved startup performance, cleaner codebase\n- **Compatibility**: v2 format configs fully compatible, no action required\n- **Note**: Users upgrading from v3.1.0 or earlier should first upgrade to v3.2.x or v3.5.x for one-time migration, then upgrade to v3.6.0\n\n### Command Parameter Standardization\n\nBackend unified to use `app` parameter (values: `claude` or `codex`):\n\n- **Impact**: More standardized code, friendlier error prompts\n- **Compatibility**: Frontend fully adapted, users don't need to care about this change\n\n---\n\n## Dependencies\n\n- Updated to **Tauri 2.8.x**\n- Updated to **TailwindCSS 4.x**\n- Updated to **TanStack Query v5.90.x**\n- Maintained **React 18.2.x** and **TypeScript 5.3.x**\n\n</details>\n\n---\n\n## 🌟 About CC Switch\n\nCC Switch is a cross-platform desktop application for managing and switching between different provider configurations for Claude Code and Codex. Built with Tauri 2.0 + React 18 + TypeScript, supporting Windows, macOS, and Linux.\n\n**Core Features**:\n- 🔄 One-click switching between multiple AI providers\n- 📦 Support for both Claude Code and Codex applications\n- 🎨 Modern UI with complete Chinese/English internationalization\n- 🔐 Local storage, secure and reliable data\n- ☁️ Support for cloud sync configurations\n- 🧩 Unified MCP server management\n\n---\n\n**Project Repository**: https://github.com/farion1231/cc-switch\n"
  },
  {
    "path": "docs/release-notes/v3.6.1-zh.md",
    "content": "# CC Switch v3.6.1\n\n> 稳定性提升与用户体验优化（基于 v3.6.0）\n\n**[English Version →](v3.6.1-en.md)**\n\n---\n\n## 📦 v3.6.1 新增内容 (2025-11-10)\n\n本次更新主要聚焦于**用户体验优化**和**配置解析健壮性**，修复了多个关键 Bug，并增强了用量查询系统。\n\n### ✨ 新增功能\n\n#### 用量查询系统增强\n\n- **凭证解耦** - 用量查询可使用独立的 API Key 和 Base URL，不再依赖供应商配置\n  - 支持不同的查询端点和认证方式\n  - 根据模板类型自动显示对应的凭证输入框\n  - General 模板：API Key + Base URL\n  - NewAPI 模板：Base URL + Access Token + User ID\n  - Custom 模板：完全自定义\n- **UI 组件升级** - 使用 shadcn/ui Switch 替代原生 checkbox，体验更现代\n- **表单统一化** - 统一使用 shadcn/ui 输入组件，样式与应用保持一致\n- **密码显示切换** - 添加查看/隐藏密码功能（API Key、Access Token）\n\n#### 表单验证基础设施\n\n- **通用 Schema 库** - 新增 JSON/TOML 通用验证器，减少重复代码\n  - `jsonConfigSchema`：通用 JSON 对象验证器\n  - `tomlConfigSchema`：通用 TOML 格式验证器\n  - `mcpJsonConfigSchema`：MCP 专用 JSON 验证器\n- **MCP 条件字段验证** - 严格的类型检查\n  - stdio 类型强制要求 `command` 字段\n  - http 类型强制要求 `url` 字段\n\n#### 合作伙伴集成\n\n- **PackyCode** - 新增官方合作伙伴\n  - 添加到 Claude 和 Codex 供应商预设\n  - 支持 10% 折扣优惠（促销信息集成）\n  - 新增 Logo 和合作伙伴标识\n\n---\n\n### 🔧 改进优化\n\n#### 用户体验\n\n- **拖拽排序同步** - 托盘菜单顺序实时同步拖拽排序结果\n- **错误通知增强** - 切换供应商失败时显示可复制的错误信息\n- **移除误导性占位符** - 删除模型输入框的示例文本，避免用户混淆\n- **Base URL 自动填充** - 所有非官方供应商类别自动填充 Base URL 输入框\n\n#### 配置解析\n\n- **中文引号规范化** - 自动处理 IME 输入的全角引号，防止 TOML 解析错误\n  - 支持中文引号（\" \" ' '）自动转换为 ASCII 引号\n  - 在 TOML 输入处理器中应用\n  - Textarea 组件禁用浏览器自动纠正\n- **自定义字段保留** - 编辑 Codex MCP TOML 配置时保留未知字段\n  - 支持 timeout_ms、retry_count 等扩展字段\n  - 向前兼容未来的 MCP 协议扩展\n\n---\n\n### 🐛 Bug 修复\n\n#### 关键修复\n\n- **修复用量脚本面板白屏崩溃** - FormLabel 组件缺少 FormField context 导致整个应用崩溃\n  - 替换为独立的 Label 组件\n  - 根本原因：FormLabel 内部调用 useFormField() hook 需要 FormFieldContext\n- **修复中文输入法引号解析失败** - IME 输入的全角引号导致 TOML 解析错误\n  - 新增 textNormalization 工具函数\n  - 在解析前自动规范化引号\n- **修复拖拽排序托盘不同步** (#179) - 拖拽排序后托盘菜单顺序未更新\n  - 在排序完成后自动调用 updateTrayMenu\n  - 确保 UI 和托盘菜单保持一致\n- **修复 MCP 自定义字段丢失** - 编辑 Codex MCP 配置时自定义字段被静默丢弃\n  - 使用 spread 操作符保留所有字段\n  - normalizeServerConfig 中保留未知字段\n\n#### 稳定性改进\n\n- **错误隔离** - 托盘菜单更新失败不再影响主操作流程\n  - 将托盘更新错误与主操作解耦\n  - 主操作成功但托盘更新失败时给出警告\n- **安全模式匹配** - 替换 `unwrap()` 为安全的 pattern matching\n  - 避免 panic 导致应用崩溃\n  - 托盘菜单事件处理使用 match 模式\n- **导入配置分类** - 从默认配置导入时自动设置 category 为 `custom`\n  - 避免导入的配置被误认为官方预设\n  - 提供更清晰的配置来源标识\n\n---\n\n### 📊 技术统计\n\n```\n提交数: 17 commits\n代码变更: 31 个文件\n  - 新增: 1,163 行\n  - 删除: 811 行\n  - 净增长: +352 行\n贡献者: Jason (16), ZyphrZero (1)\n```\n\n**按模块分类**：\n- UI/用户界面：3 commits\n- 用量查询系统：3 commits\n- 配置解析：2 commits\n- 表单验证：1 commit\n- 其他改进：8 commits\n\n---\n\n### 📥 安装方式\n\n#### macOS\n\n**通过 Homebrew 安装（推荐）：**\n\n```bash\nbrew tap farion1231/ccswitch\nbrew install --cask cc-switch\n```\n\n**手动下载：**\n\n- 从下方 [Assets](#assets) 下载 `CC-Switch-v3.6.1-macOS.zip`\n\n> **注意**：由于作者没有苹果开发者账号，首次打开可能出现\"未知开发者\"警告。请前往\"系统设置\" → \"隐私与安全性\" → 点击\"仍要打开\"\n\n#### Windows\n\n- **安装包**：`CC-Switch-v3.6.1-Windows.msi`\n- **便携版**：`CC-Switch-v3.6.1-Windows-Portable.zip`\n\n#### Linux\n\n- **AppImage**：`CC-Switch-v3.6.1-Linux.AppImage`\n- **Debian**：`CC-Switch-v3.6.1-Linux.deb`\n\n---\n\n### 📚 文档\n\n- [中文文档](https://github.com/farion1231/cc-switch/blob/main/README_ZH.md)\n- [English Documentation](https://github.com/farion1231/cc-switch/blob/main/README.md)\n- [完整更新日志](https://github.com/farion1231/cc-switch/blob/main/CHANGELOG.md)\n\n---\n\n### 🙏 致谢\n\n特别感谢：\n- **智谱 AI** - 通过 GLM CODING PLAN 赞助本项目\n- **PackyCode** - 新加入的官方合作伙伴\n- **ZyphrZero** - 贡献托盘菜单同步修复 (#179)\n\n---\n\n**完整变更记录**: https://github.com/farion1231/cc-switch/compare/v3.6.0...v3.6.1\n\n---\n---\n\n## 📜 v3.6.0 完整功能回顾\n\n> 以下内容来自 v3.6.0 (2025-11-07)，帮助您了解完整的功能集\n\n<details>\n<summary><b>点击展开 v3.6.0 的详细内容 →</b></summary>\n\n## 新增功能\n\n### 编辑模式与供应商管理\n\n- **供应商复制功能** - 一键快速复制现有供应商配置，轻松创建变体配置\n- **手动排序功能** - 通过拖拽对供应商进行重新排序，带有视觉推送效果动画\n- **编辑模式切换** - 显示/隐藏拖拽手柄，优化编辑体验\n\n### 自定义端点管理\n\n- **多端点配置** - 支持聚合类供应商的多 API 端点配置\n- **端点输入可见性** - 为所有非官方供应商自动显示端点字段\n\n### 自定义配置目录（云同步）\n\n- **自定义存储位置** - 自定义 CC Switch 的配置存储目录\n- **云同步支持** - 指定到云同步文件夹（Dropbox、OneDrive、iCloud Drive、坚果云等）即可实现跨设备配置自动同步\n- **独立管理** - 通过 Tauri Store 管理，更好的隔离性和可靠性\n\n### 使用量查询增强\n\n- **自动刷新间隔** - 配置定时自动使用量查询，支持自定义间隔时间\n- **测试脚本 API** - 在执行前验证 JavaScript 使用量查询脚本\n- **增强模板系统** - 自定义空白模板，支持 access token 和 user ID 参数\n\n### 配置目录切换（WSL 支持）\n\n- **目录变更自动同步** - 切换 Claude/Codex 配置目录（如 WSL 环境）时，自动同步当前供应商到新目录，无需手动操作\n- **后置同步工具** - 统一的 `postChangeSync.ts` 工具，优雅处理错误而不阻塞主流程\n- **导入配置自动同步** - 配置导入后自动同步，确保立即生效\n- **智能冲突解决** - 区分\"完全成功\"和\"部分成功\"状态，提供精确的用户反馈\n\n### 配置编辑器改进\n\n- **JSON 格式化按钮** - 配置编辑器中一键 JSON 格式化\n- **实时 TOML 验证** - Codex 配置的实时语法验证，带有错误高亮\n\n### 编辑时加载 Live 配置\n\n- **保护手动修改** - 编辑当前激活的供应商时，优先显示来自 live 文件的实际生效配置\n- **双源策略** - 活动供应商自动从 live 配置加载，非活动供应商从 SSOT 加载\n\n### Claude 配置数据结构增强\n\n- **细粒度模型配置** - 从双键系统升级到四键系统，以匹配官方最新数据结构\n  - 新增字段：`ANTHROPIC_DEFAULT_HAIKU_MODEL`、`ANTHROPIC_DEFAULT_SONNET_MODEL`、`ANTHROPIC_DEFAULT_OPUS_MODEL`、`ANTHROPIC_MODEL`\n  - 替换旧版 `ANTHROPIC_SMALL_FAST_MODEL`，支持自动迁移\n  - 后端在首次读写时自动规范化旧配置，带有智能回退链\n  - UI 从 2 个模型输入字段扩展到 4 个，具有智能默认值\n- **ANTHROPIC_API_KEY 支持** - 供应商现可使用 `ANTHROPIC_API_KEY` 字段（除 `ANTHROPIC_AUTH_TOKEN` 外）\n- **模板变量系统** - 支持动态配置替换（如 KAT-Coder 的 `ENDPOINT_ID` 参数）\n- **端点候选列表** - 预定义端点列表，用于速度测试和端点管理\n- **视觉主题配置** - 供应商卡片自定义图标和颜色\n\n### 供应商模型更新\n\n- **Kimi k2** - 更新到最新的 `kimi-k2-thinking` 模型\n\n### 新增供应商预设\n\n新增 5 个供应商预设：\n\n- **DMXAPI** - 多模型聚合服务\n- **Azure Codex** - 微软 Azure OpenAI 端点\n- **AnyRouter** - API 路由服务\n- **AiHubMix** - AI 模型集合\n- **MiniMax** - 国产 AI 模型提供商\n\n### 合作伙伴推广机制\n\n- 支持生态合作伙伴推广（智谱 GLM Z.ai）\n- README 中集成赞助商横幅\n\n---\n\n## 改进优化\n\n### 配置与同步\n\n- **统一错误处理** - 后端全面使用 AppError 与国际化错误消息\n- **修复 apiKeyUrl 优先级** - 修正 API key URL 解析的优先级顺序\n- **修复 MCP 同步问题** - 解决同步到另一端功能失效的问题\n- **导入配置同步** - 修复配置导入后的同步问题\n- **配置错误处理** - 配置错误时强制退出，防止静默回退和数据丢失\n\n### UI/UX 增强\n\n- **独特的供应商图标** - 每个供应商卡片现在都有独特的图标和颜色识别\n- **统一边框系统** - 所有组件采用一致的边框设计\n- **拖拽交互** - 推送效果动画和改进的拖拽手柄图标\n- **增强视觉反馈** - 更好的当前供应商视觉指示\n- **对话框标准化** - 统一的对话框尺寸和布局一致性\n- **表单改进** - 优化模型占位符，简化供应商提示，分类特定提示\n- **使用量内联显示** - 使用量信息移至启用按钮旁边，更好地利用空间\n\n### 完整国际化\n\n- **错误消息国际化** - 所有后端错误消息支持中英文\n- **托盘菜单国际化** - 系统托盘菜单完全国际化\n- **UI 组件国际化** - 所有面向用户的组件 100% 覆盖\n\n---\n\n## Bug 修复\n\n### 配置管理\n\n- 修复 `apiKeyUrl` 优先级问题\n- 修复 MCP 同步到另一端功能失效\n- 修复配置导入后的同步问题\n- 修复 Codex API Key 自动同步\n- 修复端点速度测试功能\n- 修复供应商复制插入位置（现在插入到原供应商旁边）\n- 修复编辑模式下自定义端点保留问题\n- 防止配置错误时的静默回退和数据丢失\n\n### 使用量查询\n\n- 修复自动查询间隔时间问题\n- 确保刷新按钮点击时显示加载动画\n\n### UI 问题\n\n- 修复名称冲突错误（`get_init_error` 命令）\n- 修复保存成功后语言设置回滚\n- 修复语言切换状态重置（依赖循环）\n- 修复编辑模式按钮对齐\n\n### 启动问题\n\n- 配置错误时强制退出（不再静默回退）\n- 消除导致初始化错误的代码重复\n\n---\n\n## 架构重构\n\n### 后端（Rust）- 5 阶段重构\n\n1. **阶段 1**：统一错误处理（`AppError` + 国际化错误消息）\n2. **阶段 2**：命令层按领域拆分（`commands/{provider,mcp,config,settings,plugin,misc}.rs`）\n3. **阶段 3**：集成测试和事务机制（配置快照 + 失败回滚）\n4. **阶段 4**：提取 Service 层（`services/{provider,mcp,config,speedtest}.rs`）\n5. **阶段 5**：并发优化（`RwLock` 替代 `Mutex`，作用域 guard 避免死锁）\n\n### 前端（React + TypeScript）- 4 阶段重构\n\n1. **阶段 1**：测试基础设施（vitest + MSW + @testing-library/react）\n2. **阶段 2**：提取自定义 hooks（`useProviderActions`、`useMcpActions`、`useSettings`、`useImportExport` 等）\n3. **阶段 3**：组件拆分和业务逻辑提取\n4. **阶段 4**：代码清理和格式化统一\n\n### 测试体系\n\n- **Hooks 单元测试** - 所有自定义 hooks 100% 覆盖\n- **集成测试** - 关键流程覆盖（App、SettingsDialog、MCP 面板）\n- **MSW 模拟** - 后端 API 模拟确保测试独立性\n- **测试基础设施** - vitest + MSW + @testing-library/react\n\n### 代码质量\n\n- **统一参数格式** - 所有 Tauri 命令迁移到 camelCase（Tauri 2 规范）\n- **语义清晰** - `AppType` 重命名为 `AppId` 以获得更好的语义\n- **集中解析** - 使用 `FromStr` trait 统一 `app` 参数解析\n- **DRY 违规清理** - 消除整个代码库中的代码重复\n- **死代码移除** - 移除未使用的 `missing_param` 辅助函数、废弃的 `tauri-api.ts`、冗余的 `KimiModelSelector`\n\n---\n\n## 内部优化（用户无感知）\n\n### 移除遗留迁移逻辑\n\nv3.6.0 移除了 v1 配置自动迁移和副本文件扫描逻辑：\n\n- **影响**：提升启动性能，代码更简洁\n- **兼容性**：v2 格式配置完全兼容，无需任何操作\n- **注意**：从 v3.1.0 或更早版本升级的用户，请先升级到 v3.2.x 或 v3.5.x 进行一次性迁移，然后再升级到 v3.6.0\n\n### 命令参数标准化\n\n后端统一使用 `app` 参数（取值：`claude` 或 `codex`）：\n\n- **影响**：代码更规范，错误提示更友好\n- **兼容性**：前端已完全适配，用户无需关心此变更\n\n---\n\n## 依赖更新\n\n- 更新到 **Tauri 2.8.x**\n- 更新到 **TailwindCSS 4.x**\n- 更新到 **TanStack Query v5.90.x**\n- 保持 **React 18.2.x** 和 **TypeScript 5.3.x**\n\n</details>\n\n---\n\n## 🌟 关于 CC Switch\n\nCC Switch 是一个跨平台桌面应用，用于管理和切换 Claude Code 与 Codex 的不同供应商配置。基于 Tauri 2.0 + React 18 + TypeScript 构建，支持 Windows、macOS、Linux。\n\n**核心特性**：\n- 🔄 一键切换多个 AI 供应商\n- 📦 支持 Claude Code 和 Codex 双应用\n- 🎨 现代化 UI，完整的中英文国际化\n- 🔐 本地存储，数据安全可靠\n- ☁️ 支持云同步配置\n- 🧩 MCP 服务器统一管理\n\n---\n\n**项目地址**: https://github.com/farion1231/cc-switch\n"
  },
  {
    "path": "docs/release-notes/v3.7.0-en.md",
    "content": "# CC Switch v3.7.0\n\n> From Provider Switcher to All-in-One AI CLI Management Platform\n\n**[中文更新说明 Chinese Documentation →](v3.7.0-zh.md)**\n\n---\n\n## Overview\n\nCC Switch v3.7.0 introduces six major features with over 18,000 lines of new code.\n\n**Release Date**: 2025-11-19\n**Commits**: 85 from v3.6.0\n**Code Changes**: 152 files, +18,104 / -3,732 lines\n\n---\n\n## New Features\n\n### Gemini CLI Integration\n\nComplete support for Google Gemini CLI, becoming the third supported application (Claude Code, Codex, Gemini).\n\n**Core Capabilities**:\n\n- **Dual-file configuration** - Support for both `.env` and `settings.json` formats\n- **Auto-detection** - Automatically detect `GOOGLE_GEMINI_BASE_URL`, `GEMINI_MODEL`, etc.\n- **Full MCP support** - Complete MCP server management for Gemini\n- **Deep link integration** - Import via `ccswitch://` protocol\n- **System tray** - Quick-switch from tray menu\n\n**Provider Presets**:\n\n- **Google Official** - OAuth authentication support\n- **PackyCode** - Partner integration\n- **Custom** - Full customization support\n\n**Technical Implementation**:\n\n- New backend modules: `gemini_config.rs` (20KB), `gemini_mcp.rs`\n- Form synchronization with environment editor\n- Dual-file atomic writes\n\n---\n\n### MCP v3.7.0 Unified Architecture\n\nComplete refactoring of MCP management system for cross-application unification.\n\n**Architecture Improvements**:\n\n- **Unified panel** - Single interface for Claude/Codex/Gemini MCP servers\n- **SSE transport** - New Server-Sent Events support\n- **Smart parser** - Fault-tolerant JSON parsing\n- **Format correction** - Auto-fix Codex `[mcp_servers]` format\n- **Extended fields** - Preserve custom TOML fields\n\n**User Experience**:\n\n- Default app selection in forms\n- JSON formatter for validation\n- Improved visual hierarchy\n- Better error messages\n\n**Import/Export**:\n\n- Unified import from all three apps\n- Bidirectional synchronization\n- State preservation\n\n---\n\n### Claude Skills Management System\n\n**Approximately 2,000 lines of code** - A complete skill ecosystem platform.\n\n**GitHub Integration**:\n\n- Auto-scan skills from GitHub repositories\n- Pre-configured repos:\n  - `ComposioHQ/awesome-claude-skills` - Curated collection\n  - `anthropics/skills` - Official Anthropic skills\n  - `cexll/myclaude` - Community contributions\n- Add custom repositories\n- Subdirectory scanning support (`skillsPath`)\n\n**Lifecycle Management**:\n\n- **Discover** - Auto-detect `SKILL.md` files\n- **Install** - One-click to `~/.claude/skills/`\n- **Uninstall** - Safe removal with tracking\n- **Update** - Check for updates (infrastructure ready)\n\n**Technical Architecture**:\n\n- **Backend**: `SkillService` (526 lines) with GitHub API integration\n- **Frontend**: SkillsPage, SkillCard, RepoManager\n- **UI Components**: Badge, Card, Table (shadcn/ui)\n- **State**: Persistent storage in `skills.json`\n- **i18n**: 47+ translation keys\n\n---\n\n### Prompts Management System\n\n**Approximately 1,300 lines of code** - Complete system prompt management.\n\n**Multi-Preset Management**:\n\n- Create unlimited prompt presets\n- Quick switch between presets\n- One active prompt at a time\n- Delete protection for active prompts\n\n**Cross-App Support**:\n\n- **Claude**: `~/.claude/CLAUDE.md`\n- **Codex**: `~/.codex/AGENTS.md`\n- **Gemini**: `~/.gemini/GEMINI.md`\n\n**Markdown Editor**:\n\n- Full-featured CodeMirror 6 integration\n- Syntax highlighting\n- Dark theme (One Dark)\n- Real-time preview\n\n**Smart Synchronization**:\n\n- **Auto-write** - Immediately write to live files\n- **Backfill protection** - Save current content before switching\n- **Auto-import** - Import from live files on first launch\n- **Modification protection** - Preserve manual modifications\n\n**Technical Implementation**:\n\n- **Backend**: `PromptService` (213 lines)\n- **Frontend**: PromptPanel (177), PromptFormModal (160), MarkdownEditor (159)\n- **Hooks**: usePromptActions (152 lines)\n- **i18n**: 41+ translation keys\n\n---\n\n### Deep Link Protocol (ccswitch://)\n\nOne-click provider configuration import via URL scheme.\n\n**Features**:\n\n- Protocol registration on all platforms\n- Import from shared links\n- Lifecycle integration\n- Security validation\n\n---\n\n### Environment Variable Conflict Detection\n\nIntelligent detection and management of configuration conflicts.\n\n**Detection Scope**:\n\n- **Claude & Codex** - Cross-app conflicts\n- **Gemini** - Auto-discovery\n- **MCP** - Server configuration conflicts\n\n**Management Features**:\n\n- Visual conflict indicators\n- Resolution suggestions\n- Override warnings\n- Backup before changes\n\n---\n\n## Improvements\n\n### Provider Management\n\n**New Presets**:\n\n- **DouBaoSeed** - ByteDance's DouBao\n- **Kimi For Coding** - Moonshot AI\n- **BaiLing** - BaiLing AI\n- **Removed AnyRouter** - To avoid confusion\n\n**Enhancements**:\n\n- Model name configuration for Codex and Gemini\n- Provider notes field for organization\n- Enhanced preset metadata\n\n### Configuration Management\n\n- **Common config migration** - From localStorage to `config.json`\n- **Unified persistence** - Shared across all apps\n- **Auto-import** - First launch configuration import\n- **Backfill priority** - Correct handling of live files\n\n### UI/UX Improvements\n\n**Design System**:\n\n- **macOS native** - System-aligned color scheme\n- **Window centering** - Default centered position\n- **Visual polish** - Improved spacing and hierarchy\n\n**Interactions**:\n\n- **Password input** - Fixed Edge/IE reveal buttons\n- **URL overflow** - Fixed card overflow\n- **Error copying** - Copy-to-clipboard errors\n- **Tray sync** - Real-time drag-and-drop sync\n\n---\n\n## Bug Fixes\n\n### Critical Fixes\n\n- **Usage script validation** - Boundary checks\n- **Gemini validation** - Relaxed constraints\n- **TOML parsing** - CJK quote handling\n- **MCP fields** - Custom field preservation\n- **White screen** - FormLabel crash fix\n\n### Stability\n\n- **Tray safety** - Pattern matching instead of unwrap\n- **Error isolation** - Tray failures don't block operations\n- **Import classification** - Correct category assignment\n\n### UI Fixes\n\n- **Model placeholders** - Removed misleading hints\n- **Base URL** - Auto-fill for third-party providers\n- **Drag sort** - Tray menu synchronization\n\n---\n\n## Technical Improvements\n\n### Architecture\n\n**MCP v3.7.0**:\n\n- Removed legacy code (~1,000 lines)\n- Unified initialization structure\n- Backward compatibility maintained\n- Comprehensive code formatting\n\n**Platform Compatibility**:\n\n- Windows winreg API fix (v0.52)\n- Safe pattern matching (no `unwrap()`)\n- Cross-platform tray handling\n\n### Configuration\n\n**Synchronization**:\n\n- MCP sync across all apps\n- Gemini form-editor sync\n- Dual-file reading (.env + settings.json)\n\n**Validation**:\n\n- Input boundary checks\n- TOML quote normalization (CJK)\n- Custom field preservation\n- Enhanced error messages\n\n### Code Quality\n\n**Type Safety**:\n\n- Complete TypeScript coverage\n- Rust type refinements\n- API contract validation\n\n**Testing**:\n\n- Simplified assertions\n- Better test coverage\n- Integration test updates\n\n**Dependencies**:\n\n- Tauri 2.8.x\n- Rust: `anyhow`, `zip`, `serde_yaml`, `tempfile`\n- Frontend: CodeMirror 6 packages\n- winreg 0.52 (Windows)\n\n---\n\n## Technical Statistics\n\n```\nTotal Changes:\n- Commits: 85\n- Files: 152 changed\n- Additions: +18,104 lines\n- Deletions: -3,732 lines\n\nNew Modules:\n- Skills Management: 2,034 lines (21 files)\n- Prompts Management: 1,302 lines (20 files)\n- Gemini Integration: ~1,000 lines\n- MCP Refactor: ~3,000 lines refactored\n\nCode Distribution:\n- Backend (Rust): ~4,500 lines new\n- Frontend (React): ~3,000 lines new\n- Configuration: ~1,500 lines refactored\n- Tests: ~500 lines\n```\n\n---\n\n## Strategic Positioning\n\n### From Tool to Platform\n\nv3.7.0 represents a shift in CC Switch's positioning:\n\n| Aspect            | v3.6                     | v3.7.0                       |\n| ----------------- | ------------------------ | ---------------------------- |\n| **Identity**      | Provider Switcher        | AI CLI Management Platform   |\n| **Scope**         | Configuration Management | Ecosystem Management         |\n| **Applications**  | Claude + Codex           | Claude + Codex + Gemini      |\n| **Capabilities**  | Switch configs           | Extend capabilities (Skills) |\n| **Customization** | Manual editing           | Visual management (Prompts)  |\n| **Integration**   | Isolated apps            | Unified management (MCP)     |\n\n### Six Pillars of AI CLI Management\n\n1. **Configuration Management** - Provider switching and management\n2. **Capability Extension** - Skills installation and lifecycle\n3. **Behavior Customization** - System prompt presets\n4. **Ecosystem Integration** - Deep links and sharing\n5. **Multi-AI Support** - Claude/Codex/Gemini\n6. **Intelligent Detection** - Conflict prevention\n\n---\n\n## Download & Installation\n\n### System Requirements\n\n- **Windows**: Windows 10+\n- **macOS**: macOS 10.15 (Catalina)+\n- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+\n\n### Download Links\n\nVisit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download:\n\n- **Windows**: `CC-Switch-v3.7.0-Windows.msi` or `-Portable.zip`\n- **macOS**: `CC-Switch-v3.7.0-macOS.tar.gz` or `.zip`\n- **Linux**: `CC-Switch-v3.7.0-Linux.AppImage` or `.deb`\n\n### Homebrew (macOS)\n\n```bash\nbrew tap farion1231/ccswitch\nbrew install --cask cc-switch\n```\n\nUpdate:\n\n```bash\nbrew upgrade --cask cc-switch\n```\n\n---\n\n## Migration Notes\n\n### From v3.6.x\n\n**Automatic migration** - No action required, configs are fully compatible\n\n### From v3.1.x or Earlier\n\n**Two-step migration required**:\n\n1. First upgrade to v3.2.x (performs one-time migration)\n2. Then upgrade to v3.7.0\n\n### New Features\n\n- **Skills**: No migration needed, start fresh\n- **Prompts**: Auto-import from live files on first launch\n- **Gemini**: Install Gemini CLI separately if needed\n- **MCP v3.7.0**: Backward compatible with previous configs\n\n---\n\n## Acknowledgments\n\n### Contributors\n\nThanks to all contributors who made this release possible:\n\n- [@YoVinchen](https://github.com/YoVinchen) - Skills & Prompts & Gemini integration implementation\n- [@farion1231](https://github.com/farion1231) - From developer to issue responder\n- Community members for testing and feedback\n\n### Sponsors\n\n**Z.ai** - GLM CODING PLAN sponsor\n[Get 10% OFF with this link](https://z.ai/subscribe?ic=8JVLJQFSKB)\n\n**PackyCode** - API relay service partner\n[Register with \"cc-switch\" code for 10% discount](https://www.packyapi.com/register?aff=cc-switch)\n\n---\n\n## Feedback & Support\n\n- **Issues**: [GitHub Issues](https://github.com/farion1231/cc-switch/issues)\n- **Discussions**: [GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)\n- **Documentation**: [README](../README.md)\n- **Changelog**: [CHANGELOG.md](../CHANGELOG.md)\n\n---\n\n## What's Next\n\n**v3.8.0 Preview** (Tentative):\n\n- Local proxy functionality\n\nStay tuned for more updates!\n\n---\n\n**Happy Coding!**\n"
  },
  {
    "path": "docs/release-notes/v3.7.0-zh.md",
    "content": "# CC Switch v3.7.0\n\n> 从供应商切换器到 AI CLI 一体化管理平台\n\n**[English Version →](v3.7.0-en.md)**\n\n---\n\n## 概览\n\nCC Switch v3.7.0 新增六大核心功能，新增超过 18,000 行代码。\n\n**发布日期**：2025-11-19\n**提交数量**：从 v3.6.0 开始 85 个提交\n**代码变更**：152 个文件，+18,104 / -3,732 行\n\n---\n\n## 新增功能\n\n### Gemini CLI 集成\n\n完整支持 Google Gemini CLI，成为第三个支持的应用（Claude Code、Codex、Gemini）。\n\n**核心能力**：\n\n- **双文件配置** - 同时支持 `.env` 和 `settings.json` 格式\n- **自动检测** - 自动检测 `GOOGLE_GEMINI_BASE_URL`、`GEMINI_MODEL` 等环境变量\n- **完整 MCP 支持** - 为 Gemini 提供完整的 MCP 服务器管理\n- **深度链接集成** - 通过 `ccswitch://` 协议导入配置\n- **系统托盘** - 从托盘菜单快速切换\n\n**供应商预设**：\n\n- **Google Official** - 支持 OAuth 认证\n- **PackyCode** - 合作伙伴集成\n- **自定义** - 完全自定义支持\n\n**技术实现**：\n\n- 新增后端模块：`gemini_config.rs`（20KB）、`gemini_mcp.rs`\n- 表单与环境编辑器同步\n- 双文件原子写入\n\n---\n\n### MCP v3.7.0 统一架构\n\nMCP 管理系统完整重构，实现跨应用统一管理。\n\n**架构改进**：\n\n- **统一管理面板** - 单一界面管理 Claude/Codex/Gemini MCP 服务器\n- **SSE 传输类型** - 新增 Server-Sent Events 支持\n- **智能解析器** - 容错性 JSON 解析\n- **格式修正** - 自动修复 Codex `[mcp_servers]` 格式\n- **扩展字段** - 保留自定义 TOML 字段\n\n**用户体验**：\n\n- 表单中的默认应用选择\n- JSON 格式化器用于验证\n- 改进的视觉层次\n- 更好的错误消息\n\n**导入/导出**：\n\n- 统一从三个应用导入\n- 双向同步\n- 状态保持\n\n---\n\n### Claude Skills 管理系统\n\n**约 2,000 行代码** - 完整的技能生态平台。\n\n**GitHub 集成**：\n\n- 从 GitHub 仓库自动扫描技能\n- 预配置仓库：\n  - `ComposioHQ/awesome-claude-skills` - 精选集合\n  - `anthropics/skills` - Anthropic 官方技能\n  - `cexll/myclaude` - 社区贡献\n- 添加自定义仓库\n- 子目录扫描支持（`skillsPath`）\n\n**生命周期管理**：\n\n- **发现** - 自动检测 `SKILL.md` 文件\n- **安装** - 一键安装到 `~/.claude/skills/`\n- **卸载** - 安全移除并跟踪状态\n- **更新** - 检查更新（基础设施已就绪）\n\n**技术架构**：\n\n- **后端**：`SkillService`（526 行）集成 GitHub API\n- **前端**：SkillsPage、SkillCard、RepoManager\n- **UI 组件**：Badge、Card、Table（shadcn/ui）\n- **状态**：持久化存储在 `skills.json`\n- **国际化**：47+ 个翻译键\n\n---\n\n### Prompts 管理系统\n\n**约 1,300 行代码** - 完整的系统提示词管理。\n\n**多预设管理**：\n\n- 创建无限数量的提示词预设\n- 快速在预设间切换\n- 同时只能激活一个提示词\n- 活动提示词删除保护\n\n**跨应用支持**：\n\n- **Claude**：`~/.claude/CLAUDE.md`\n- **Codex**：`~/.codex/AGENTS.md`\n- **Gemini**：`~/.gemini/GEMINI.md`\n\n**Markdown 编辑器**：\n\n- 完整的 CodeMirror 6 集成\n- 语法高亮\n- 暗色主题（One Dark）\n- 实时预览\n\n**智能同步**：\n\n- **自动写入** - 立即写入 live 文件\n- **回填保护** - 切换前保存当前内容\n- **自动导入** - 首次启动从 live 文件导入\n- **修改保护** - 保留手动修改\n\n**技术实现**：\n\n- **后端**：`PromptService`（213 行）\n- **前端**：PromptPanel（177）、PromptFormModal（160）、MarkdownEditor（159）\n- **Hooks**：usePromptActions（152 行）\n- **国际化**：41+ 个翻译键\n\n---\n\n### 深度链接协议（ccswitch://）\n\n通过 URL 方案一键导入供应商配置。\n\n**功能特性**：\n\n- 所有平台的协议注册\n- 从共享链接导入\n- 生命周期集成\n- 安全验证\n\n---\n\n### 环境变量冲突检测\n\n智能检测和管理配置冲突。\n\n**检测范围**：\n\n- **Claude & Codex** - 跨应用冲突\n- **Gemini** - 自动发现\n- **MCP** - 服务器配置冲突\n\n**管理功能**：\n\n- 可视化冲突指示器\n- 解决建议\n- 覆盖警告\n- 更改前备份\n\n---\n\n## 改进优化\n\n### 供应商管理\n\n**新增预设**：\n\n- **DouBaoSeed** - 字节跳动的豆包\n- **Kimi For Coding** - 月之暗面\n- **BaiLing** - 百灵 AI\n- **移除 AnyRouter** - 避免误导\n\n**增强功能**：\n\n- Codex 和 Gemini 的模型名称配置\n- 供应商备注字段用于组织\n- 增强的预设元数据\n\n### 配置管理\n\n- **通用配置迁移** - 从 localStorage 迁移到 `config.json`\n- **统一持久化** - 跨所有应用共享\n- **自动导入** - 首次启动配置导入\n- **回填优先级** - 正确处理 live 文件\n\n### UI/UX 改进\n\n**设计系统**：\n\n- **macOS 原生** - 与系统对齐的配色方案\n- **窗口居中** - 默认居中位置\n- **视觉优化** - 改进的间距和层次\n\n**交互优化**：\n\n- **密码输入** - 修复 Edge/IE 显示按钮\n- **URL 溢出** - 修复卡片溢出\n- **错误复制** - 可复制到剪贴板的错误\n- **托盘同步** - 实时拖放同步\n\n---\n\n## Bug 修复\n\n### 关键修复\n\n- **用量脚本验证** - 边界检查\n- **Gemini 验证** - 放宽约束\n- **TOML 解析** - CJK 引号处理\n- **MCP 字段** - 自定义字段保留\n- **白屏** - FormLabel 崩溃修复\n\n### 稳定性\n\n- **托盘安全** - 模式匹配替代 unwrap\n- **错误隔离** - 托盘失败不阻塞操作\n- **导入分类** - 正确的类别分配\n\n### UI 修复\n\n- **模型占位符** - 移除误导性提示\n- **Base URL** - 第三方供应商自动填充\n- **拖拽排序** - 托盘菜单同步\n\n---\n\n## 技术改进\n\n### 架构\n\n**MCP v3.7.0**：\n\n- 移除遗留代码（约 1,000 行）\n- 统一初始化结构\n- 保持向后兼容性\n- 全面的代码格式化\n\n**平台兼容性**：\n\n- Windows winreg API 修复（v0.52）\n- 安全模式匹配（无 `unwrap()`）\n- 跨平台托盘处理\n\n### 配置\n\n**同步机制**：\n\n- 跨所有应用的 MCP 同步\n- Gemini 表单-编辑器同步\n- 双文件读取（.env + settings.json）\n\n**验证增强**：\n\n- 输入边界检查\n- TOML 引号规范化（CJK）\n- 自定义字段保留\n- 增强的错误消息\n\n### 代码质量\n\n**类型安全**：\n\n- 完整的 TypeScript 覆盖\n- Rust 类型改进\n- API 契约验证\n\n**测试**：\n\n- 简化的断言\n- 更好的测试覆盖\n- 集成测试更新\n\n**依赖项**：\n\n- Tauri 2.8.x\n- Rust：`anyhow`、`zip`、`serde_yaml`、`tempfile`\n- 前端：CodeMirror 6 包\n- winreg 0.52（Windows）\n\n---\n\n## 技术统计\n\n```\n总体变更：\n- 提交数：85\n- 文件数：152 个文件变更\n- 新增：+18,104 行\n- 删除：-3,732 行\n\n新增模块：\n- Skills 管理：2,034 行（21 个文件）\n- Prompts 管理：1,302 行（20 个文件）\n- Gemini 集成：约 1,000 行\n- MCP 重构：约 3,000 行重构\n\n代码分布：\n- 后端（Rust）：约 4,500 行新增\n- 前端（React）：约 3,000 行新增\n- 配置：约 1,500 行重构\n- 测试：约 500 行\n```\n\n---\n\n## 战略定位\n\n### 从工具到平台\n\nv3.7.0 代表了 CC Switch 定位的转变：\n\n| 方面     | v3.6           | v3.7.0                  |\n| -------- | -------------- | ----------------------- |\n| **身份** | 供应商切换器   | AI CLI 管理平台         |\n| **范围** | 配置管理       | 生态系统管理            |\n| **应用** | Claude + Codex | Claude + Codex + Gemini |\n| **能力** | 切换配置       | 扩展能力（Skills）      |\n| **定制** | 手动编辑       | 可视化管理（Prompts）   |\n| **集成** | 孤立应用       | 统一管理（MCP）         |\n\n### AI CLI 管理六大支柱\n\n1. **配置管理** - 供应商切换和管理\n2. **能力扩展** - Skills 安装和生命周期\n3. **行为定制** - 系统提示词预设\n4. **生态集成** - 深度链接和共享\n5. **多 AI 支持** - Claude/Codex/Gemini\n6. **智能检测** - 冲突预防\n\n---\n\n## 下载与安装\n\n### 系统要求\n\n- **Windows**：Windows 10+\n- **macOS**：macOS 10.15（Catalina）+\n- **Linux**：Ubuntu 22.04+ / Debian 11+ / Fedora 34+\n\n### 下载链接\n\n访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载：\n\n- **Windows**：`CC-Switch-v3.7.0-Windows.msi` 或 `-Portable.zip`\n- **macOS**：`CC-Switch-v3.7.0-macOS.tar.gz` 或 `.zip`\n- **Linux**：`CC-Switch-v3.7.0-Linux.AppImage` 或 `.deb`\n\n### Homebrew（macOS）\n\n```bash\nbrew tap farion1231/ccswitch\nbrew install --cask cc-switch\n```\n\n更新：\n\n```bash\nbrew upgrade --cask cc-switch\n```\n\n---\n\n## 迁移说明\n\n### 从 v3.6.x 升级\n\n**自动迁移** - 无需任何操作，配置完全兼容\n\n### 从 v3.1.x 或更早版本升级\n\n**需要两步迁移**：\n\n1. 首先升级到 v3.2.x（执行一次性迁移）\n2. 然后升级到 v3.7.0\n\n### 新功能\n\n- **Skills**：无需迁移，全新开始\n- **Prompts**：首次启动时从 live 文件自动导入\n- **Gemini**：需要单独安装 Gemini CLI\n- **MCP v3.7.0**：与之前的配置向后兼容\n\n---\n\n## 致谢\n\n### 贡献者\n\n感谢所有让这个版本成为可能的贡献者：\n\n- [@YoVinchen](https://github.com/YoVinchen) - Skills & Prompts & Geimini 集成实现\n- [@farion1231](https://github.com/farion1231) - 从开发沦为 issue 回复机\n- 社区成员的测试和反馈\n\n### 赞助商\n\n**Z.ai** - GLM CODING PLAN 赞助商\n[通过此链接获得 10% 折扣](https://z.ai/subscribe?ic=8JVLJQFSKB)\n\n**PackyCode** - API 中继服务合作伙伴\n[使用 \"cc-switch\" 代码注册可享受 10% 折扣](https://www.packyapi.com/register?aff=cc-switch)\n\n---\n\n## 反馈与支持\n\n- **问题反馈**：[GitHub Issues](https://github.com/farion1231/cc-switch/issues)\n- **讨论**：[GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)\n- **文档**：[README](../README_ZH.md)\n- **更新日志**：[CHANGELOG.md](../CHANGELOG.md)\n\n---\n\n## 未来展望\n\n**v3.8.0 预览**（暂定）：\n\n- 本地代理功能\n\n敬请期待更多更新！\n"
  },
  {
    "path": "docs/release-notes/v3.7.1-en.md",
    "content": "# CC Switch v3.7.1\n\n> Stability Enhancements and User Experience Improvements\n\n**[中文更新说明 Chinese Documentation →](v3.7.1-zh.md)**\n\n---\n\n## v3.7.1 Updates\n\n**Release Date**: 2025-11-22\n**Code Changes**: 17 files, +524 / -81 lines\n\n### Bug Fixes\n\n- **Fix Third-Party Skills Installation Failure** (#268)\n  Fixed installation issues for skills repositories with custom subdirectories, now supports repos like `ComposioHQ/awesome-claude-skills` with subdirectories\n\n- **Fix Gemini Configuration Persistence Issue**\n  Resolved the issue where settings.json edits in Gemini form were lost when switching providers\n\n- **Prevent Dialogs from Closing on Overlay Click**\n  Added protection against clicking overlay/backdrop, preventing accidental form data loss across all 11 dialog components\n\n### New Features\n\n- **Gemini Configuration Directory Support** (#255)\n  Added Gemini configuration directory option in settings, supports customizing `~/.gemini/` path\n\n- **ArchLinux Installation Support** (#259)\n  Added AUR installation method: `paru -S cc-switch-bin`\n\n### Improvements\n\n- **Skills Error Message i18n Enhancement**\n  Added 28+ detailed error messages (English & Chinese) with specific resolution suggestions, extended download timeout from 15s to 60s\n\n- **Code Formatting**\n  Applied unified Rust and TypeScript code formatting standards\n\n### Download\n\nVisit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download the latest version\n\n---\n\n## v3.7.0 Complete Release Notes\n\n> From Provider Switcher to All-in-One AI CLI Management Platform\n\n**Release Date**: 2025-11-19\n**Commits**: 85 from v3.6.0\n**Code Changes**: 152 files, +18,104 / -3,732 lines\n\n---\n\n## New Features\n\n### Gemini CLI Integration\n\nComplete support for Google Gemini CLI, becoming the third supported application (Claude Code, Codex, Gemini).\n\n**Core Capabilities**:\n\n- **Dual-file configuration** - Support for both `.env` and `settings.json` formats\n- **Auto-detection** - Automatically detect `GOOGLE_GEMINI_BASE_URL`, `GEMINI_MODEL`, etc.\n- **Full MCP support** - Complete MCP server management for Gemini\n- **Deep link integration** - Import via `ccswitch://` protocol\n- **System tray** - Quick-switch from tray menu\n\n**Provider Presets**:\n\n- **Google Official** - OAuth authentication support\n- **PackyCode** - Partner integration\n- **Custom** - Full customization support\n\n**Technical Implementation**:\n\n- New backend modules: `gemini_config.rs` (20KB), `gemini_mcp.rs`\n- Form synchronization with environment editor\n- Dual-file atomic writes\n\n---\n\n### MCP v3.7.0 Unified Architecture\n\nComplete refactoring of MCP management system for cross-application unification.\n\n**Architecture Improvements**:\n\n- **Unified panel** - Single interface for Claude/Codex/Gemini MCP servers\n- **SSE transport** - New Server-Sent Events support\n- **Smart parser** - Fault-tolerant JSON parsing\n- **Format correction** - Auto-fix Codex `[mcp_servers]` format\n- **Extended fields** - Preserve custom TOML fields\n\n**User Experience**:\n\n- Default app selection in forms\n- JSON formatter for validation\n- Improved visual hierarchy\n- Better error messages\n\n**Import/Export**:\n\n- Unified import from all three apps\n- Bidirectional synchronization\n- State preservation\n\n---\n\n### Claude Skills Management System\n\n**Approximately 2,000 lines of code** - A complete skill ecosystem platform.\n\n**GitHub Integration**:\n\n- Auto-scan skills from GitHub repositories\n- Pre-configured repos:\n  - `ComposioHQ/awesome-claude-skills` - Curated collection\n  - `anthropics/skills` - Official Anthropic skills\n  - `cexll/myclaude` - Community contributions\n- Add custom repositories\n- Subdirectory scanning support (`skillsPath`)\n\n**Lifecycle Management**:\n\n- **Discover** - Auto-detect `SKILL.md` files\n- **Install** - One-click to `~/.claude/skills/`\n- **Uninstall** - Safe removal with tracking\n- **Update** - Check for updates (infrastructure ready)\n\n**Technical Architecture**:\n\n- **Backend**: `SkillService` (526 lines) with GitHub API integration\n- **Frontend**: SkillsPage, SkillCard, RepoManager\n- **UI Components**: Badge, Card, Table (shadcn/ui)\n- **State**: Persistent storage in `config.json`\n- **i18n**: 47+ translation keys\n\n---\n\n### Prompts Management System\n\n**Approximately 1,300 lines of code** - Complete system prompt management.\n\n**Multi-Preset Management**:\n\n- Create unlimited prompt presets\n- Quick switch between presets\n- One active prompt at a time\n- Delete protection for active prompts\n\n**Cross-App Support**:\n\n- **Claude**: `~/.claude/CLAUDE.md`\n- **Codex**: `~/.codex/AGENTS.md`\n- **Gemini**: `~/.gemini/GEMINI.md`\n\n**Markdown Editor**:\n\n- Full-featured CodeMirror 6 integration\n- Syntax highlighting\n- Dark theme (One Dark)\n- Real-time preview\n\n**Smart Synchronization**:\n\n- **Auto-write** - Immediately write to live files\n- **Backfill protection** - Save current content before switching\n- **Auto-import** - Import from live files on first launch\n- **Modification protection** - Preserve manual modifications\n\n**Technical Implementation**:\n\n- **Backend**: `PromptService` (213 lines)\n- **Frontend**: PromptPanel (177), PromptFormModal (160), MarkdownEditor (159)\n- **Hooks**: usePromptActions (152 lines)\n- **i18n**: 41+ translation keys\n\n---\n\n### Deep Link Protocol (ccswitch://)\n\nOne-click provider configuration import via URL scheme.\n\n**Features**:\n\n- Protocol registration on all platforms\n- Import from shared links\n- Lifecycle integration\n- Security validation\n\n---\n\n### Environment Variable Conflict Detection\n\nIntelligent detection and management of configuration conflicts.\n\n**Detection Scope**:\n\n- **Claude & Codex** - Cross-app conflicts\n- **Gemini** - Auto-discovery\n- **MCP** - Server configuration conflicts\n\n**Management Features**:\n\n- Visual conflict indicators\n- Resolution suggestions\n- Override warnings\n- Backup before changes\n\n---\n\n## Improvements\n\n### Provider Management\n\n**New Presets**:\n\n- **DouBaoSeed** - ByteDance's DouBao\n- **Kimi For Coding** - Moonshot AI\n- **BaiLing** - BaiLing AI\n- **Removed AnyRouter** - To avoid confusion\n\n**Enhancements**:\n\n- Model name configuration for Codex and Gemini\n- Provider notes field for organization\n- Enhanced preset metadata\n\n### Configuration Management\n\n- **Common config migration** - From localStorage to `config.json`\n- **Unified persistence** - Shared across all apps\n- **Auto-import** - First launch configuration import\n- **Backfill priority** - Correct handling of live files\n\n### UI/UX Improvements\n\n**Design System**:\n\n- **macOS native** - System-aligned color scheme\n- **Window centering** - Default centered position\n- **Visual polish** - Improved spacing and hierarchy\n\n**Interactions**:\n\n- **Password input** - Fixed Edge/IE reveal buttons\n- **URL overflow** - Fixed card overflow\n- **Error copying** - Copy-to-clipboard errors\n- **Tray sync** - Real-time drag-and-drop sync\n\n---\n\n## Bug Fixes\n\n### Critical Fixes\n\n- **Usage script validation** - Boundary checks\n- **Gemini validation** - Relaxed constraints\n- **TOML parsing** - CJK quote handling\n- **MCP fields** - Custom field preservation\n- **White screen** - FormLabel crash fix\n\n### Stability\n\n- **Tray safety** - Pattern matching instead of unwrap\n- **Error isolation** - Tray failures don't block operations\n- **Import classification** - Correct category assignment\n\n### UI Fixes\n\n- **Model placeholders** - Removed misleading hints\n- **Base URL** - Auto-fill for third-party providers\n- **Drag sort** - Tray menu synchronization\n\n---\n\n## Technical Improvements\n\n### Architecture\n\n**MCP v3.7.0**:\n\n- Removed legacy code (~1,000 lines)\n- Unified initialization structure\n- Backward compatibility maintained\n- Comprehensive code formatting\n\n**Platform Compatibility**:\n\n- Windows winreg API fix (v0.52)\n- Safe pattern matching (no `unwrap()`)\n- Cross-platform tray handling\n\n### Configuration\n\n**Synchronization**:\n\n- MCP sync across all apps\n- Gemini form-editor sync\n- Dual-file reading (.env + settings.json)\n\n**Validation**:\n\n- Input boundary checks\n- TOML quote normalization (CJK)\n- Custom field preservation\n- Enhanced error messages\n\n### Code Quality\n\n**Type Safety**:\n\n- Complete TypeScript coverage\n- Rust type refinements\n- API contract validation\n\n**Testing**:\n\n- Simplified assertions\n- Better test coverage\n- Integration test updates\n\n**Dependencies**:\n\n- Tauri 2.8.x\n- Rust: `anyhow`, `zip`, `serde_yaml`, `tempfile`\n- Frontend: CodeMirror 6 packages\n- winreg 0.52 (Windows)\n\n---\n\n## Technical Statistics\n\n```\nTotal Changes:\n- Commits: 85\n- Files: 152 changed\n- Additions: +18,104 lines\n- Deletions: -3,732 lines\n\nNew Modules:\n- Skills Management: 2,034 lines (21 files)\n- Prompts Management: 1,302 lines (20 files)\n- Gemini Integration: ~1,000 lines\n- MCP Refactor: ~3,000 lines refactored\n\nCode Distribution:\n- Backend (Rust): ~4,500 lines new\n- Frontend (React): ~3,000 lines new\n- Configuration: ~1,500 lines refactored\n- Tests: ~500 lines\n```\n\n---\n\n## Strategic Positioning\n\n### From Tool to Platform\n\nv3.7.0 represents a shift in CC Switch's positioning:\n\n| Aspect            | v3.6                     | v3.7.0                       |\n| ----------------- | ------------------------ | ---------------------------- |\n| **Identity**      | Provider Switcher        | AI CLI Management Platform   |\n| **Scope**         | Configuration Management | Ecosystem Management         |\n| **Applications**  | Claude + Codex           | Claude + Codex + Gemini      |\n| **Capabilities**  | Switch configs           | Extend capabilities (Skills) |\n| **Customization** | Manual editing           | Visual management (Prompts)  |\n| **Integration**   | Isolated apps            | Unified management (MCP)     |\n\n### Six Pillars of AI CLI Management\n\n1. **Configuration Management** - Provider switching and management\n2. **Capability Extension** - Skills installation and lifecycle\n3. **Behavior Customization** - System prompt presets\n4. **Ecosystem Integration** - Deep links and sharing\n5. **Multi-AI Support** - Claude/Codex/Gemini\n6. **Intelligent Detection** - Conflict prevention\n\n---\n\n## Download & Installation\n\n### System Requirements\n\n- **Windows**: Windows 10+\n- **macOS**: macOS 10.15 (Catalina)+\n- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+ / ArchLinux\n\n### Download Links\n\nVisit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download:\n\n- **Windows**: `CC-Switch-Windows.msi` or `-Portable.zip`\n- **macOS**: `CC-Switch-macOS.tar.gz` or `.zip`\n- **Linux**: `CC-Switch-Linux.AppImage` or `.deb`\n- **ArchLinux**: `paru -S cc-switch-bin`\n\n### Homebrew (macOS)\n\n```bash\nbrew tap farion1231/ccswitch\nbrew install --cask cc-switch\n```\n\nUpdate:\n\n```bash\nbrew upgrade --cask cc-switch\n```\n\n---\n\n## Migration Notes\n\n### From v3.6.x\n\n**Automatic migration** - No action required, configs are fully compatible\n\n### From v3.1.x or Earlier\n\n**Two-step migration required**:\n\n1. First upgrade to v3.2.x (performs one-time migration)\n2. Then upgrade to v3.7.0\n\n### New Features\n\n- **Skills**: No migration needed, start fresh\n- **Prompts**: Auto-import from live files on first launch\n- **Gemini**: Install Gemini CLI separately if needed\n- **MCP v3.7.0**: Backward compatible with previous configs\n\n---\n\n## Acknowledgments\n\n### Contributors\n\nThanks to all contributors who made this release possible:\n\n- [@YoVinchen](https://github.com/YoVinchen) - Skills & Prompts & Gemini integration implementation\n- [@farion1231](https://github.com/farion1231) - From developer to issue responder\n- Community members for testing and feedback\n\n### Sponsors\n\n**Z.ai** - GLM CODING PLAN sponsor\n[Get 10% OFF with this link](https://z.ai/subscribe?ic=8JVLJQFSKB)\n\n**PackyCode** - API relay service partner\n[Register with \"cc-switch\" code for 10% discount](https://www.packyapi.com/register?aff=cc-switch)\n\n**ShanDianShuo** - Local-first AI voice input\n[Free download](https://shandianshuo.cn) for Mac/Win\n\n---\n\n## Feedback & Support\n\n- **Issues**: [GitHub Issues](https://github.com/farion1231/cc-switch/issues)\n- **Discussions**: [GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)\n- **Documentation**: [README](../README.md)\n- **Changelog**: [CHANGELOG.md](../CHANGELOG.md)\n\n---\n\n## What's Next\n\n**v3.8.0 Preview** (Tentative):\n\n- Local proxy functionality\n\nStay tuned for more updates!\n\n---\n\n**Happy Coding!**\n"
  },
  {
    "path": "docs/release-notes/v3.7.1-zh.md",
    "content": "# CC Switch v3.7.1\n\n> 稳定性增强与用户体验改进\n\n**[English Version →](v3.7.1-en.md)**\n\n---\n\n## v3.7.1 更新内容\n\n**发布日期**：2025-11-22\n**代码变更**：17 个文件，+524 / -81 行\n\n### Bug 修复\n\n- **修复 Skills 第三方仓库安装失败** (#268)\n  修复使用自定义子目录的 skills 仓库无法安装的问题，支持类似 `ComposioHQ/awesome-claude-skills` 这样带子目录的仓库\n\n- **修复 Gemini 配置持久化问题**\n  解决在 Gemini 表单中编辑 settings.json 后，切换供应商时修改丢失的问题\n\n- **防止对话框意外关闭**\n  添加点击遮罩时的保护，避免误操作导致表单数据丢失，影响所有 11 个对话框组件\n\n### 新增功能\n\n- **Gemini 配置目录支持** (#255)\n  在设置中添加 Gemini 配置目录选项，支持自定义 `~/.gemini/` 路径\n\n- **ArchLinux 安装支持** (#259)\n  添加 AUR 安装方式：`paru -S cc-switch-bin`\n\n### 改进\n\n- **Skills 错误消息国际化增强**\n  新增 28+ 条详细错误消息（中英文），提供具体的解决建议，下载超时从 15 秒延长到 60 秒\n\n- **代码格式化**\n  应用统一的 Rust 和 TypeScript 代码格式化标准\n\n### 下载\n\n访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载最新版本\n\n---\n\n## v3.7.0 完整更新说明\n\n> 从供应商切换器到 AI CLI 一体化管理平台\n\n**发布日期**：2025-11-19\n**提交数量**：从 v3.6.0 开始 85 个提交\n**代码变更**：152 个文件，+18,104 / -3,732 行\n\n---\n\n## 新增功能\n\n### Gemini CLI 集成\n\n完整支持 Google Gemini CLI，成为第三个支持的应用（Claude Code、Codex、Gemini）。\n\n**核心能力**：\n\n- **双文件配置** - 同时支持 `.env` 和 `settings.json` 格式\n- **自动检测** - 自动检测 `GOOGLE_GEMINI_BASE_URL`、`GEMINI_MODEL` 等环境变量\n- **完整 MCP 支持** - 为 Gemini 提供完整的 MCP 服务器管理\n- **深度链接集成** - 通过 `ccswitch://` 协议导入配置\n- **系统托盘** - 从托盘菜单快速切换\n\n**供应商预设**：\n\n- **Google Official** - 支持 OAuth 认证\n- **PackyCode** - 合作伙伴集成\n- **自定义** - 完全自定义支持\n\n**技术实现**：\n\n- 新增后端模块：`gemini_config.rs`（20KB）、`gemini_mcp.rs`\n- 表单与环境编辑器同步\n- 双文件原子写入\n\n---\n\n### MCP v3.7.0 统一架构\n\nMCP 管理系统完整重构，实现跨应用统一管理。\n\n**架构改进**：\n\n- **统一管理面板** - 单一界面管理 Claude/Codex/Gemini MCP 服务器\n- **SSE 传输类型** - 新增 Server-Sent Events 支持\n- **智能解析器** - 容错性 JSON 解析\n- **格式修正** - 自动修复 Codex `[mcp_servers]` 格式\n- **扩展字段** - 保留自定义 TOML 字段\n\n**用户体验**：\n\n- 表单中的默认应用选择\n- JSON 格式化器用于验证\n- 改进的视觉层次\n- 更好的错误消息\n\n**导入/导出**：\n\n- 统一从三个应用导入\n- 双向同步\n- 状态保持\n\n---\n\n### Claude Skills 管理系统\n\n**约 2,000 行代码** - 完整的技能生态平台。\n\n**GitHub 集成**：\n\n- 从 GitHub 仓库自动扫描技能\n- 预配置仓库：\n  - `ComposioHQ/awesome-claude-skills` - 精选集合\n  - `anthropics/skills` - Anthropic 官方技能\n  - `cexll/myclaude` - 社区贡献\n- 添加自定义仓库\n- 子目录扫描支持（`skillsPath`）\n\n**生命周期管理**：\n\n- **发现** - 自动检测 `SKILL.md` 文件\n- **安装** - 一键安装到 `~/.claude/skills/`\n- **卸载** - 安全移除并跟踪状态\n- **更新** - 检查更新（基础设施已就绪）\n\n**技术架构**：\n\n- **后端**：`SkillService`（526 行）集成 GitHub API\n- **前端**：SkillsPage、SkillCard、RepoManager\n- **UI 组件**：Badge、Card、Table（shadcn/ui）\n- **状态**：持久化存储在 `config.json`\n- **国际化**：47+ 个翻译键\n\n---\n\n### Prompts 管理系统\n\n**约 1,300 行代码** - 完整的系统提示词管理。\n\n**多预设管理**：\n\n- 创建无限数量的提示词预设\n- 快速在预设间切换\n- 同时只能激活一个提示词\n- 活动提示词删除保护\n\n**跨应用支持**：\n\n- **Claude**：`~/.claude/CLAUDE.md`\n- **Codex**：`~/.codex/AGENTS.md`\n- **Gemini**：`~/.gemini/GEMINI.md`\n\n**Markdown 编辑器**：\n\n- 完整的 CodeMirror 6 集成\n- 语法高亮\n- 暗色主题（One Dark）\n- 实时预览\n\n**智能同步**：\n\n- **自动写入** - 立即写入 live 文件\n- **回填保护** - 切换前保存当前内容\n- **自动导入** - 首次启动从 live 文件导入\n- **修改保护** - 保留手动修改\n\n**技术实现**：\n\n- **后端**：`PromptService`（213 行）\n- **前端**：PromptPanel（177）、PromptFormModal（160）、MarkdownEditor（159）\n- **Hooks**：usePromptActions（152 行）\n- **国际化**：41+ 个翻译键\n\n---\n\n### 深度链接协议（ccswitch://）\n\n通过 URL 方案一键导入供应商配置。\n\n**功能特性**：\n\n- 所有平台的协议注册\n- 从共享链接导入\n- 生命周期集成\n- 安全验证\n\n---\n\n### 环境变量冲突检测\n\n智能检测和管理配置冲突。\n\n**检测范围**：\n\n- **Claude & Codex** - 跨应用冲突\n- **Gemini** - 自动发现\n- **MCP** - 服务器配置冲突\n\n**管理功能**：\n\n- 可视化冲突指示器\n- 解决建议\n- 覆盖警告\n- 更改前备份\n\n---\n\n## 改进优化\n\n### 供应商管理\n\n**新增预设**：\n\n- **DouBaoSeed** - 字节跳动的豆包\n- **Kimi For Coding** - 月之暗面\n- **BaiLing** - 百灵 AI\n- **移除 AnyRouter** - 避免误导\n\n**增强功能**：\n\n- Codex 和 Gemini 的模型名称配置\n- 供应商备注字段用于组织\n- 增强的预设元数据\n\n### 配置管理\n\n- **通用配置迁移** - 从 localStorage 迁移到 `config.json`\n- **统一持久化** - 跨所有应用共享\n- **自动导入** - 首次启动配置导入\n- **回填优先级** - 正确处理 live 文件\n\n### UI/UX 改进\n\n**设计系统**：\n\n- **macOS 原生** - 与系统对齐的配色方案\n- **窗口居中** - 默认居中位置\n- **视觉优化** - 改进的间距和层次\n\n**交互优化**：\n\n- **密码输入** - 修复 Edge/IE 显示按钮\n- **URL 溢出** - 修复卡片溢出\n- **错误复制** - 可复制到剪贴板的错误\n- **托盘同步** - 实时拖放同步\n\n---\n\n## Bug 修复\n\n### 关键修复\n\n- **用量脚本验证** - 边界检查\n- **Gemini 验证** - 放宽约束\n- **TOML 解析** - CJK 引号处理\n- **MCP 字段** - 自定义字段保留\n- **白屏** - FormLabel 崩溃修复\n\n### 稳定性\n\n- **托盘安全** - 模式匹配替代 unwrap\n- **错误隔离** - 托盘失败不阻塞操作\n- **导入分类** - 正确的类别分配\n\n### UI 修复\n\n- **模型占位符** - 移除误导性提示\n- **Base URL** - 第三方供应商自动填充\n- **拖拽排序** - 托盘菜单同步\n\n---\n\n## 技术改进\n\n### 架构\n\n**MCP v3.7.0**：\n\n- 移除遗留代码（约 1,000 行）\n- 统一初始化结构\n- 保持向后兼容性\n- 全面的代码格式化\n\n**平台兼容性**：\n\n- Windows winreg API 修复（v0.52）\n- 安全模式匹配（无 `unwrap()`）\n- 跨平台托盘处理\n\n### 配置\n\n**同步机制**：\n\n- 跨所有应用的 MCP 同步\n- Gemini 表单-编辑器同步\n- 双文件读取（.env + settings.json）\n\n**验证增强**：\n\n- 输入边界检查\n- TOML 引号规范化（CJK）\n- 自定义字段保留\n- 增强的错误消息\n\n### 代码质量\n\n**类型安全**：\n\n- 完整的 TypeScript 覆盖\n- Rust 类型改进\n- API 契约验证\n\n**测试**：\n\n- 简化的断言\n- 更好的测试覆盖\n- 集成测试更新\n\n**依赖项**：\n\n- Tauri 2.8.x\n- Rust：`anyhow`、`zip`、`serde_yaml`、`tempfile`\n- 前端：CodeMirror 6 包\n- winreg 0.52（Windows）\n\n---\n\n## 技术统计\n\n```\n总体变更：\n- 提交数：85\n- 文件数：152 个文件变更\n- 新增：+18,104 行\n- 删除：-3,732 行\n\n新增模块：\n- Skills 管理：2,034 行（21 个文件）\n- Prompts 管理：1,302 行（20 个文件）\n- Gemini 集成：约 1,000 行\n- MCP 重构：约 3,000 行重构\n\n代码分布：\n- 后端（Rust）：约 4,500 行新增\n- 前端（React）：约 3,000 行新增\n- 配置：约 1,500 行重构\n- 测试：约 500 行\n```\n\n---\n\n## 战略定位\n\n### 从工具到平台\n\nv3.7.0 代表了 CC Switch 定位的转变：\n\n| 方面     | v3.6           | v3.7.0                  |\n| -------- | -------------- | ----------------------- |\n| **身份** | 供应商切换器   | AI CLI 管理平台         |\n| **范围** | 配置管理       | 生态系统管理            |\n| **应用** | Claude + Codex | Claude + Codex + Gemini |\n| **能力** | 切换配置       | 扩展能力（Skills）      |\n| **定制** | 手动编辑       | 可视化管理（Prompts）   |\n| **集成** | 孤立应用       | 统一管理（MCP）         |\n\n### AI CLI 管理六大支柱\n\n1. **配置管理** - 供应商切换和管理\n2. **能力扩展** - Skills 安装和生命周期\n3. **行为定制** - 系统提示词预设\n4. **生态集成** - 深度链接和共享\n5. **多 AI 支持** - Claude/Codex/Gemini\n6. **智能检测** - 冲突预防\n\n---\n\n## 下载与安装\n\n### 系统要求\n\n- **Windows**：Windows 10+\n- **macOS**：macOS 10.15（Catalina）+\n- **Linux**：Ubuntu 22.04+ / Debian 11+ / Fedora 34+ / ArchLinux\n\n### 下载链接\n\n访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载：\n\n- **Windows**：`CC-Switch-Windows.msi` 或 `-Portable.zip`\n- **macOS**：`CC-Switch-macOS.tar.gz` 或 `.zip`\n- **Linux**：`CC-Switch-Linux.AppImage` 或 `.deb`\n- **ArchLinux**：`paru -S cc-switch-bin`\n\n### Homebrew（macOS）\n\n```bash\nbrew tap farion1231/ccswitch\nbrew install --cask cc-switch\n```\n\n更新：\n\n```bash\nbrew upgrade --cask cc-switch\n```\n\n---\n\n## 迁移说明\n\n### 从 v3.6.x 升级\n\n**自动迁移** - 无需任何操作，配置完全兼容\n\n### 从 v3.1.x 或更早版本升级\n\n**需要两步迁移**：\n\n1. 首先升级到 v3.2.x（执行一次性迁移）\n2. 然后升级到 v3.7.0\n\n### 新功能\n\n- **Skills**：无需迁移，全新开始\n- **Prompts**：首次启动时从 live 文件自动导入\n- **Gemini**：需要单独安装 Gemini CLI\n- **MCP v3.7.0**：与之前的配置向后兼容\n\n---\n\n## 致谢\n\n### 贡献者\n\n感谢所有让这个版本成为可能的贡献者：\n\n- [@YoVinchen](https://github.com/YoVinchen) - Skills & Prompts & Gemini 集成实现\n- [@farion1231](https://github.com/farion1231) - 从开发沦为 issue 回复机\n- 社区成员的测试和反馈\n\n### 赞助商\n\n**智谱AI** - GLM CODING PLAN 赞助商\n[使用此链接购买可享九折优惠](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)\n\n**PackyCode** - API 中转服务合作伙伴\n[使用 \"cc-switch\" 优惠码注册享 9 折优惠](https://www.packyapi.com/register?aff=cc-switch)\n\n**闪电说** - 本地优先的 AI 语音输入法\n[免费下载](https://shandianshuo.cn) Mac/Win 双平台\n\n---\n\n## 反馈与支持\n\n- **问题反馈**：[GitHub Issues](https://github.com/farion1231/cc-switch/issues)\n- **讨论**：[GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)\n- **文档**：[README](../README_ZH.md)\n- **更新日志**：[CHANGELOG.md](../CHANGELOG.md)\n\n---\n\n## 未来展望\n\n**v3.8.0 预览**（暂定）：\n\n- 本地代理功能\n\n敬请期待更多更新！\n\n---\n\n**Happy Coding!**\n"
  },
  {
    "path": "docs/release-notes/v3.8.0-en.md",
    "content": "# CC Switch v3.8.0\n\n> Persistence Architecture Upgrade, Laying the Foundation for Cloud Sync\n\n**[中文版 →](v3.8.0-zh.md) | [日本語版 →](v3.8.0-ja.md)**\n\n---\n\n## Overview\n\nCC Switch v3.8.0 is a major architectural upgrade that restructures the data persistence layer and user interface, laying the foundation for future cloud sync and local proxy features.\n\n**Release Date**: 2025-11-28\n**Commits**: 51 commits since v3.7.1\n**Code Changes**: 207 files, +17,297 / -6,870 lines\n\n---\n\n## Major Updates\n\n### Persistence Architecture Upgrade\n\nMigrated from single JSON file storage to SQLite + JSON dual-layer architecture for hierarchical data management.\n\n**Architecture Changes**:\n\n```\nv3.7.x (Old)                     v3.8.0 (New)\n┌─────────────────┐              ┌─────────────────────────────────┐\n│  config.json    │              │  SQLite (Syncable Data)         │\n│  ┌───────────┐  │              │  ├─ providers     Provider cfg  │\n│  │ providers │  │              │  ├─ mcp_servers   MCP servers   │\n│  │ mcp       │  │     ──>      │  ├─ prompts       Prompts       │\n│  │ prompts   │  │              │  ├─ skills        Skills        │\n│  │ settings  │  │              │  └─ settings      General cfg   │\n│  └───────────┘  │              ├─────────────────────────────────┤\n└─────────────────┘              │  JSON (Device-level Data)       │\n                                 │  └─ settings.json Local settings│\n                                 │     ├─ Window position          │\n                                 │     ├─ Path overrides           │\n                                 │     └─ Current provider ID      │\n                                 └─────────────────────────────────┘\n```\n\n**Dual-layer Structure Design**:\n\n| Layer      | Storage | Data Types                      | Sync Strategy   |\n| ---------- | ------- | ------------------------------- | --------------- |\n| Cloud Sync | SQLite  | Providers, MCP, Prompts, Skills | Future syncable |\n| Device     | JSON    | Window state, local paths       | Stays local     |\n\n**Technical Implementation**:\n\n- **Schema Version Management** - Supports database structure upgrade migrations\n- **SQL Import/Export** - `backup.rs` supports SQL dump for cloud storage\n- **Transaction Support** - SQLite native transactions ensure data consistency\n- **Auto Migration** - Automatically migrates from `config.json` on first launch\n\n**Modular Refactoring**:\n\n```\ndatabase/\n├── mod.rs              Core Database struct and initialization\n├── schema.rs           Table definitions, schema version migrations\n├── backup.rs           SQL import/export, binary snapshot backup\n├── migration.rs        JSON → SQLite data migration engine\n└── dao/                Data Access Object layer\n    ├── providers.rs    Provider CRUD\n    ├── mcp.rs          MCP server CRUD\n    ├── prompts.rs      Prompts CRUD\n    ├── skills.rs       Skills CRUD\n    └── settings.rs     Key-value settings storage\n```\n\n---\n\n### Brand New User Interface\n\nCompletely redesigned UI providing a more modern visual experience.\n\n**Visual Improvements**:\n\n- Redesigned interface layout\n- Unified component styles\n- Smoother transition animations\n- Optimized visual hierarchy\n\n**Interaction Enhancements**:\n\n- Redesigned header toolbar\n- Unified ConfirmDialog styling\n- Disabled overscroll bounce effect on main view\n- Improved form validation feedback\n\n**Compatibility Adjustments**:\n\n- Downgraded Tailwind CSS from v4 to v3.4 for better browser compatibility\n\n---\n\n### Japanese Language Support\n\nAdded Japanese interface support, expanding internationalization to three languages.\n\n**Supported Languages**:\n\n- Simplified Chinese\n- English\n- Japanese (New)\n\n---\n\n## New Features\n\n### Skills Recursive Scanning\n\nSkills management system now supports recursive scanning of repository directories, automatically discovering nested skill files.\n\n**Improvements**:\n\n- Support for multi-level directory structures\n- Automatic discovery of all `SKILL.md` files\n- Allow same-named skills from different repositories (using full path for deduplication)\n\n---\n\n### Provider Icon Configuration\n\nProvider presets now support custom icon configuration.\n\n**Features**:\n\n- Preset providers include default icons\n- Icon settings preserved when duplicating providers\n- Custom icon colors\n\n---\n\n### Enhanced Form Validation\n\nProvider forms now include required field validation with friendlier error messages.\n\n**Improvements**:\n\n- Real-time validation for required fields\n- Unified Toast notifications for validation errors\n- Clearer error messages\n\n---\n\n### Auto Launch on Startup\n\nAdded auto-launch functionality supporting Windows, macOS, and Linux platforms.\n\n**Features**:\n\n- One-click enable/disable in settings\n- Implemented using platform-native APIs\n- Windows uses Registry, macOS uses LaunchAgent, Linux uses XDG autostart\n\n---\n\n### New Provider Presets\n\n- **MiniMax** - Official partner\n\n---\n\n## Bug Fixes\n\n### Critical Fixes\n\n**Custom Endpoints Lost Issue**\n\nFixed an issue where custom request URLs were unexpectedly lost when updating providers.\n\n- Root Cause: `INSERT OR REPLACE` executes `DELETE + INSERT` under the hood in SQLite, triggering foreign key cascade deletion\n- Fix: Changed to use `UPDATE` statement for existing providers\n\n**Gemini Configuration Issues**\n\n- Fixed custom provider environment variables not correctly written to `.env` file\n- Fixed security auth config incorrectly written to other config files\n\n**Provider Validation Issues**\n\n- Fixed validation error when current provider ID doesn't exist\n- Fixed icon fields lost when duplicating providers\n\n### Platform Compatibility\n\n**Linux**\n\n- Resolved WebKitGTK DMA-BUF rendering issue\n- Preserve user `.desktop` file customizations\n\n### Other Fixes\n\n- Fixed redundant usage queries when switching apps\n- Fixed DMXAPI preset using wrong auth token field\n- Fixed missing translation keys in deeplink components\n- Fixed usage script template initialization logic\n\n---\n\n## Technical Improvements\n\n### Architecture Refactoring\n\n**Provider Service Modularization**:\n\n```\nservices/provider/\n├── mod.rs          Core service - add/update/delete/switch/validate\n├── live.rs         Live config file operations\n├── gemini_auth.rs  Gemini auth type detection\n├── endpoints.rs    Custom endpoint management\n└── usage.rs        Usage script execution\n```\n\n**Deeplink Modularization**:\n\n```\ndeeplink/\n├── mod.rs       Module exports\n├── parser.rs    URL parsing\n├── provider.rs  Provider import logic\n├── mcp.rs       MCP import logic\n├── prompt.rs    Prompt import\n├── skill.rs     Skills import\n└── utils.rs     Utility functions\n```\n\n### Code Quality\n\n**Cleanup**:\n\n- Removed legacy JSON-era import/export dead code\n- Removed unused MCP type exports\n- Unified error handling approach\n\n**Test Updates**:\n\n- Migrated tests to SQLite database architecture\n- Updated component tests to match current implementation\n- Fixed MSW handlers to adapt to new API\n\n---\n\n## Technical Statistics\n\n```\nOverall Changes:\n- Commits: 51\n- Files: 207 files changed\n- Additions: +17,297 lines\n- Deletions: -6,870 lines\n- Net: +10,427 lines\n\nCommit Type Distribution:\n- fix: 25 (Bug fixes)\n- refactor: 11 (Code refactoring)\n- feat: 9 (New features)\n- test: 1 (Testing)\n- other: 5\n\nChange Area Distribution:\n- Frontend source: 112 files\n- Rust backend: 63 files\n- Test files: 20 files\n- i18n files: 3 files\n```\n\n---\n\n## Migration Guide\n\n### Upgrading from v3.7.x\n\n**Auto Migration** - Executes automatically on first launch:\n\n1. Detects if `config.json` exists\n2. Migrates all data to SQLite within a transaction\n3. Migrates device-level settings to `settings.json`\n4. Shows migration success notification\n\n**Data Safety**:\n\n- Original `config.json` file is preserved (not deleted)\n- Error dialog displayed on migration failure, `config.json` preserved\n- Supports Dry-run mode to verify migration logic\n\n---\n\n## Download & Installation\n\n### System Requirements\n\n- **Windows**: Windows 10+\n- **macOS**: macOS 10.15 (Catalina)+\n- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+\n\n### Download Links\n\nVisit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download:\n\n- **Windows**: `CC-Switch-v3.8.0-Windows.msi` or `-Portable.zip`\n- **macOS**: `CC-Switch-v3.8.0-macOS.tar.gz` or `.zip`\n- **Linux**: `CC-Switch-v3.8.0-Linux.AppImage` or `.deb`\n\n### Homebrew (macOS)\n\n```bash\nbrew tap farion1231/ccswitch\nbrew install --cask cc-switch\n```\n\nUpdate:\n\n```bash\nbrew upgrade --cask cc-switch\n```\n\n## Acknowledgments\n\n### Contributors\n\nThanks to all contributors who made this release possible:\n\n- [@YoVinchen](https://github.com/YoVinchen) - UI and database refactoring\n- [@farion1231](https://github.com/farion1231) - Bug fixes and feature enhancements\n- Community members for testing and feedback\n\n### Sponsors\n\n**Zhipu AI** - GLM CODING PLAN Sponsor\n[Get 10% off with this link](https://z.ai/subscribe?ic=8JVLJQFSKB)\n\n**PackyCode** - API Relay Service Partner\n[Use code \"cc-switch\" for 10% off registration](https://www.packyapi.com/register?aff=cc-switch)\n\n**ShandianShuo** - Local-first AI Voice Input\n[Free download](https://shandianshuo.cn) for Mac/Windows\n\n**MiniMax** - MiniMax M2 CODING PLAN Sponsor\n[Black Friday sale, plans starting at $2](https://platform.minimax.io/subscribe/coding-plan)\n\n---\n\n## Feedback & Support\n\n- **Issue Reports**: [GitHub Issues](https://github.com/farion1231/cc-switch/issues)\n- **Discussions**: [GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)\n- **Documentation**: [README](../README.md)\n- **Changelog**: [CHANGELOG.md](../CHANGELOG.md)\n\n---\n\n## Future Roadmap\n\n**v3.9.0 Preview** (Tentative):\n\n- Local proxy feature\n\nStay tuned for more updates!\n\n---\n\n**Happy Coding!**\n"
  },
  {
    "path": "docs/release-notes/v3.8.0-ja.md",
    "content": "# CC Switch v3.8.0\n\n> 永続化アーキテクチャを刷新し、クラウド同期の土台を構築\n\n**[English →](v3.8.0-en.md) | [中文版 →](v3.8.0-zh.md)**\n\n---\n\n## 概要\n\nCC Switch v3.8.0 はデータ永続化レイヤーと UI を大幅に作り替え、今後のクラウド同期やローカルプロキシ機能に向けた基盤を整えたメジャーアップデートです。\n\n**リリース日**: 2025-11-28  \n**コミット数**: v3.7.1 以降 51 commits  \n**変更量**: 207 files, +17,297 / -6,870 lines\n\n---\n\n## 主要アップデート\n\n### 永続化アーキテクチャの刷新\n\n単一の JSON 保存から、階層化された SQLite + JSON の二層構造へ移行。\n\n**アーキテクチャ変更**:\n\n```\nv3.7.x (旧)                       v3.8.0 (新)\n┌─────────────────┐              ┌─────────────────────────────────┐\n│  config.json    │              │  SQLite (同期対象データ)         │\n│  ┌───────────┐  │              │  ├─ providers     プロバイダ設定 │\n│  │ providers │  │              │  ├─ mcp_servers   MCP サーバー   │\n│  │ mcp       │  │     ──>      │  ├─ prompts       プロンプト     │\n│  │ prompts   │  │              │  ├─ skills        Skills         │\n│  │ settings  │  │              │  └─ settings      汎用設定       │\n│  └───────────┘  │              ├─────────────────────────────────┤\n└─────────────────┘              │  JSON (デバイス固有データ)        │\n                                 │  └─ settings.json ローカル設定    │\n                                 │     ├─ ウィンドウ位置            │\n                                 │     ├─ パスの上書き              │\n                                 │     └─ 現在のプロバイダ ID        │\n                                 └─────────────────────────────────┘\n```\n\n**二層構造の設計**:\n\n| レイヤー | ストレージ | データ種別                          | 同期戦略         |\n| -------- | ---------- | ----------------------------------- | ---------------- |\n| クラウド | SQLite     | Providers, MCP, Prompts, Skills     | 将来同期対象     |\n| デバイス | JSON       | ウィンドウ状態、ローカルパス         | ローカル保持     |\n\n**実装ポイント**:\n\n- **スキーマバージョン管理**: DB 構造のマイグレーションに対応\n- **SQL インポート/エクスポート**: `backup.rs` が SQL ダンプをサポート\n- **トランザクション対応**: SQLite ネイティブで整合性を確保\n- **自動マイグレーション**: 初回起動で `config.json` から自動移行\n\n**モジュール分割**:\n\n```\ndatabase/\n├── mod.rs              Database 構造体と初期化\n├── schema.rs           テーブル定義とスキーマ移行\n├── backup.rs           SQL インポート/エクスポートとスナップショット\n├── migration.rs        JSON → SQLite 変換エンジン\n└── dao/                DAO レイヤー\n    ├── providers.rs    プロバイダ CRUD\n    ├── mcp.rs          MCP CRUD\n    ├── prompts.rs      プロンプト CRUD\n    ├── skills.rs       Skills CRUD\n    └── settings.rs     設定 Key-Value 保存\n```\n\n---\n\n### 新しいユーザーインターフェース\n\nよりモダンな見た目と操作感に再設計。\n\n- レイアウト全面刷新、コンポーネントスタイルを統一\n- トランジションを滑らかにし、視覚的階層を最適化\n- メインビューのオーバースクロールバウンスを無効化\n- ブラウザ互換性向上のため Tailwind CSS を v4→v3.4 にダウングレード\n\n---\n\n### 日語化\n\nUI が日本語に対応し、国際化が 3 言語（中/英/日）へ拡大。\n\n---\n\n## 新機能\n\n### Skills 递帰スキャン\n\nSkills 管理がリポジトリを再帰的に走査し、ネストされた `SKILL.md` を自動検出。\n\n- 複数階層のディレクトリに対応\n- すべての `SKILL.md` を自動発見\n- パスをキーにした重複排除で同名スキルを許容\n\n### プロバイダアイコン設定\n\nプリセットがデフォルトアイコンを持ち、複製してもアイコンを保持。カスタム色も設定可能。\n\n### フォームバリデーション強化\n\n必須項目にリアルタイム検証と分かりやすいエラーメッセージを追加し、トースト通知を統一。\n\n### 自動起動\n\nWindows/macOS/Linux で自動起動をサポート。\n\n- 設定画面からワンクリックで ON/OFF\n- Registry / LaunchAgent / XDG autostart を使用\n\n### 新プロバイダプリセット\n\n- **MiniMax** - 公式パートナー\n\n---\n\n## バグ修正\n\n### 重要修正\n\n**カスタムエンドポイント消失**\n\n- 原因: SQLite の `INSERT OR REPLACE` が内部で `DELETE + INSERT` を実行し、外部キーのカスケード削除が発生\n- 対応: 既存プロバイダ更新を `UPDATE` に変更\n\n**Gemini 設定**\n\n- カスタム環境変数が `.env` に正しく書き込まれない問題を修正\n- 認証設定が他ファイルに誤って書き込まれる問題を修正\n\n**プロバイダ検証**\n\n- 現在プロバイダ ID が欠落している場合のバリデーションエラーを修正\n- 複製時にアイコンフィールドが失われる問題を修正\n\n### プラットフォーム互換性\n\n**Linux**\n\n- WebKitGTK の DMA-BUF 描画問題を解消\n- ユーザーの `.desktop` カスタマイズを保持\n\n### その他修正\n\n- アプリ切り替え時の不要な使用量クエリを削減\n- DMXAPI プリセットの誤ったトークンフィールドを修正\n- Deeplink コンポーネントの欠損翻訳キーを補完\n- 使用量スクリプトテンプレート初期化を修正\n\n---\n\n## 技術的改善\n\n### アーキテクチャ再編\n\n**Provider Service のモジュール化**:\n\n```\nservices/provider/\n├── mod.rs          追加/更新/削除/切替/検証の中核\n├── live.rs         ライブ設定ファイル操作\n├── gemini_auth.rs  Gemini 認証タイプ検出\n├── endpoints.rs    カスタムエンドポイント管理\n└── usage.rs        使用量スクリプト実行\n```\n\n**Deeplink のモジュール化**:\n\n```\ndeeplink/\n├── mod.rs       エクスポート\n├── parser.rs    URL パース\n├── provider.rs  プロバイダ取り込み\n├── mcp.rs       MCP 取り込み\n├── prompt.rs    プロンプト取り込み\n├── skill.rs     Skills 取り込み\n└── utils.rs     ユーティリティ\n```\n\n### コード品質\n\n- レガシーな JSON 時代のインポート/エクスポート死蔵コードを削除\n- 使われていない MCP 型を削除し、エラーハンドリングを統一\n- テストを SQLite バックエンドに移行し、MSW ハンドラを最新 API に合わせて更新\n\n---\n\n## 技術統計\n\n```\n全体変更:\n- コミット: 51\n- 変更ファイル: 207\n- 追加: +17,297 行\n- 削除: -6,870 行\n- 純増: +10,427 行\n\nコミット種別:\n- fix: 25\n- refactor: 11\n- feat: 9\n- test: 1\n- other: 5\n\n変更箇所:\n- フロントエンド: 112 files\n- Rust バックエンド: 63 files\n- テスト: 20 files\n- i18n: 3 files\n```\n\n---\n\n## マイグレーションガイド\n\n### v3.7.x からのアップグレード\n\n**自動マイグレーション**（初回起動時）:\n\n1. `config.json` の存在を検出\n2. 全データをトランザクションで SQLite に移行\n3. デバイス設定を `settings.json` へ移行\n4. 移行成功の通知を表示\n\n**データ保護**:\n\n- 元の `config.json` は保持（削除しない）\n- 失敗時はエラーダイアログを表示し、`config.json` を温存\n- Dry-run モードで検証可能\n\n---\n\n## ダウンロード & インストール\n\n### システム要件\n\n- **Windows**: Windows 10+\n- **macOS**: macOS 10.15 (Catalina)+\n- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+\n\n### ダウンロード\n\n[Releases](https://github.com/farion1231/cc-switch/releases/latest) から入手:\n\n- **Windows**: `CC-Switch-v3.8.0-Windows.msi` または `-Portable.zip`\n- **macOS**: `CC-Switch-v3.8.0-macOS.tar.gz` または `.zip`\n- **Linux**: `CC-Switch-v3.8.0-Linux.AppImage` または `.deb`\n\n### Homebrew (macOS)\n\n```bash\nbrew tap farion1231/ccswitch\nbrew install --cask cc-switch\n```\n\nアップデート:\n\n```bash\nbrew upgrade --cask cc-switch\n```\n\n---\n\n## 謝辞\n\n### コントリビューター\n\n- [@YoVinchen](https://github.com/YoVinchen) - UI とデータベースリファクタ\n- [@farion1231](https://github.com/farion1231) - バグ修正と機能拡張\n- コミュニティの皆さん - テストとフィードバック\n\n### スポンサー\n\n**Zhipu AI** - GLM CODING PLAN スポンサー  \n[10% オフリンク](https://z.ai/subscribe?ic=8JVLJQFSKB)\n\n**PackyCode** - API リレーサービスパートナー  \n[登録時に「cc-switch」で 10% オフ](https://www.packyapi.com/register?aff=cc-switch)\n\n**ShandianShuo** - ローカルファースト音声入力  \n[Mac/Windows 無料ダウンロード](https://shandianshuo.cn)\n\n**MiniMax** - MiniMax M2 CODING PLAN スポンサー  \n[ブラックフライデーセール中、$2 から](https://platform.minimax.io/subscribe/coding-plan)\n\n---\n\n## フィードバック & サポート\n\n- **Issue**: [GitHub Issues](https://github.com/farion1231/cc-switch/issues)\n- **Discussions**: [GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)\n- **ドキュメント**: [README](../README.md)\n- **更新履歴**: [CHANGELOG.md](../CHANGELOG.md)\n\n---\n\n## 今後のロードマップ\n\n**v3.9.0 予告（予定）**:\n\n- ローカルプロキシ機能\n\n続報にご期待ください！\n\n---\n\n**Happy Coding!**\n"
  },
  {
    "path": "docs/release-notes/v3.8.0-zh.md",
    "content": "# CC Switch v3.8.0\n\n> 持久化架构升级，为云同步奠定基础\n\n**[English Version →](v3.8.0-en.md)**\n\n---\n\n## 概览\n\nCC Switch v3.8.0 是一次重大的架构升级版本，重构了数据持久化层和用户界面，为未来的云同步和本地代理功能奠定基础。\n\n**发布日期**：2025-11-28\n**提交数量**：从 v3.7.1 开始 51 个提交\n**代码变更**：207 个文件，+17,297 / -6,870 行\n\n---\n\n## 重大更新\n\n### 持久化架构升级\n\n从单一 JSON 文件存储迁移到 SQLite + JSON 双层架构，实现数据分层管理。\n\n**架构变更**：\n\n```\nv3.7.x (旧)                      v3.8.0 (新)\n┌─────────────────┐              ┌─────────────────────────────────┐\n│  config.json    │              │  SQLite (可同步数据)             │\n│  ┌───────────┐  │              │  ├─ providers     供应商配置     │\n│  │ providers │  │              │  ├─ mcp_servers   MCP 服务器     │\n│  │ mcp       │  │     ──>      │  ├─ prompts       提示词         │\n│  │ prompts   │  │              │  ├─ skills        技能           │\n│  │ settings  │  │              │  └─ settings      通用设置       │\n│  └───────────┘  │              ├─────────────────────────────────┤\n└─────────────────┘              │  JSON (设备级数据)               │\n                                 │  └─ settings.json 本地设置       │\n                                 │     ├─ 窗口位置                  │\n                                 │     ├─ 路径覆盖                  │\n                                 │     └─ 当前选中供应商 ID          │\n                                 └─────────────────────────────────┘\n```\n\n**双层结构设计**：\n\n| 层级     | 存储方式 | 数据类型                     | 同步策略   |\n| -------- | -------- | ---------------------------- | ---------- |\n| 云同步层 | SQLite   | 供应商、MCP、Prompts、Skills | 未来可同步 |\n| 设备层   | JSON     | 窗口状态、本地路径、当前选择 | 保持本地   |\n\n**技术实现**：\n\n- **Schema 版本管理** - 支持数据库结构升级迁移\n- **SQL 导入导出** - `backup.rs` 支持 SQL dump，便于云端存储\n- **事务支持** - SQLite 原生事务保证数据一致性\n- **自动迁移** - 首次启动自动从 `config.json` 迁移数据\n\n**模块化重构**：\n\n```\ndatabase/\n├── mod.rs              核心 Database 结构体和初始化\n├── schema.rs           表结构定义、Schema 版本迁移\n├── backup.rs           SQL 导入导出、二进制快照备份\n├── migration.rs        JSON → SQLite 数据迁移引擎\n└── dao/                数据访问对象层\n    ├── providers.rs    供应商 CRUD\n    ├── mcp.rs          MCP 服务器 CRUD\n    ├── prompts.rs      提示词 CRUD\n    ├── skills.rs       Skills CRUD\n    └── settings.rs     键值对设置存储\n```\n\n---\n\n### 全新用户界面\n\n完整重构的 UI 设计，提供更现代化的视觉体验。\n\n**视觉改进**：\n\n- 重新设计的界面布局\n- 统一的组件样式\n- 更流畅的过渡动画\n- 优化的视觉层次\n\n**交互优化**：\n\n- Header toolbar 重新设计\n- ConfirmDialog 样式统一\n- 禁用主视图 overscroll 弹跳效果\n- 改进的表单验证反馈\n\n**兼容性调整**：\n\n- Tailwind CSS 从 v4 降级到 v3.4，提升浏览器兼容性\n\n---\n\n### 日语支持\n\n新增日语（日本語）界面支持，国际化语言扩展到三种。\n\n**支持语言**：\n\n- 简体中文\n- English\n- 日本語（新增）\n\n---\n\n## 新增功能\n\n### Skills 递归扫描\n\nSkills 管理系统支持递归扫描仓库目录，自动发现嵌套的技能文件。\n\n**改进内容**：\n\n- 支持多层目录结构\n- 自动发现所有 `SKILL.md` 文件\n- 允许不同仓库的同名技能（使用完整路径去重）\n\n---\n\n### 供应商图标配置\n\n供应商预设支持自定义图标配置。\n\n**功能特性**：\n\n- 预设供应商包含默认图标\n- 复制供应商时保留图标设置\n- 图标颜色自定义\n\n---\n\n### 表单验证增强\n\n供应商表单新增必填字段验证，提供更友好的错误提示。\n\n**改进内容**：\n\n- 必填字段实时校验\n- 统一使用 Toast 通知显示验证错误\n- 更清晰的错误信息\n\n---\n\n### 开机自启\n\n新增开机自动启动功能，支持 Windows、macOS 和 Linux 三个平台。\n\n**功能特性**：\n\n- 在设置中一键开启/关闭\n- 使用平台原生 API 实现\n- Windows 使用注册表、macOS 使用 LaunchAgent、Linux 使用 XDG autostart\n\n---\n\n### 新增供应商预设\n\n- **MiniMax** - 官方合作伙伴\n\n---\n\n## Bug 修复\n\n### 关键修复\n\n**自定义端点丢失问题**\n\n修复更新供应商时自定义请求地址意外丢失的问题。\n\n- 根因：`INSERT OR REPLACE` 在 SQLite 底层执行 `DELETE + INSERT`，触发外键级联删除\n- 修复：改用 `UPDATE` 语句更新已存在的供应商\n\n**Gemini 配置问题**\n\n- 修复自定义供应商环境变量未正确写入 `.env` 文件\n- 修复安全认证配置错误写入到其他配置文件\n\n**供应商验证问题**\n\n- 修复当前供应商 ID 不存在时的验证错误\n- 修复供应商复制时图标字段丢失\n\n### 平台兼容性\n\n**Linux**\n\n- 解决 WebKitGTK DMA-BUF 渲染问题\n- 保留用户 `.desktop` 文件自定义\n\n### 其他修复\n\n- 修复切换应用时的冗余用量查询\n- 修复 DMXAPI 预设使用错误的认证令牌字段\n- 修复深链接组件缺少翻译键\n- 修复用量脚本模板初始化逻辑\n\n---\n\n## 技术改进\n\n### 架构重构\n\n**供应商服务模块化**：\n\n```\nservices/provider/\n├── mod.rs          核心服务 - add/update/delete/switch/validate\n├── live.rs         Live 配置文件操作\n├── gemini_auth.rs  Gemini 认证类型检测\n├── endpoints.rs    自定义端点管理\n└── usage.rs        用量脚本执行\n```\n\n**深链接模块化**：\n\n```\ndeeplink/\n├── mod.rs       模块导出\n├── parser.rs    URL 解析\n├── provider.rs  供应商导入逻辑\n├── mcp.rs       MCP 导入逻辑\n├── prompt.rs    提示词导入\n├── skill.rs     Skills 导入\n└── utils.rs     工具函数\n```\n\n### 代码质量\n\n**清理工作**：\n\n- 移除 JSON 时代遗留的导入导出死代码\n- 移除未使用的 MCP 类型导出\n- 统一错误处理方式\n\n**测试更新**：\n\n- 迁移测试到 SQLite 数据库架构\n- 更新组件测试匹配当前实现\n- 修复 MSW handlers 适配新 API\n\n---\n\n## 技术统计\n\n```\n总体变更：\n- 提交数：51\n- 文件数：207 个文件变更\n- 新增：+17,297 行\n- 删除：-6,870 行\n- 净增：+10,427 行\n\n提交类型分布：\n- fix：25 个（Bug 修复）\n- refactor：11 个（代码重构）\n- feat：9 个（新功能）\n- test：1 个（测试）\n- 其他：5 个\n\n改动区域分布：\n- 前端源码：112 个文件\n- Rust 后端：63 个文件\n- 测试文件：20 个文件\n- 国际化文件：3 个文件\n```\n\n---\n\n## 迁移说明\n\n### 从 v3.7.x 升级\n\n**自动迁移** - 首次启动时自动执行：\n\n1. 检测 `config.json` 是否存在\n2. 在事务中迁移所有数据到 SQLite\n3. 设备级设置迁移到 `settings.json`\n4. 显示迁移成功通知\n\n**数据安全**：\n\n- 原 `config.json` 文件保留不删除\n- 迁移失败时显示错误对话框，保留`config.json`\n- 支持 Dry-run 模式验证迁移逻辑\n\n---\n\n## 下载与安装\n\n### 系统要求\n\n- **Windows**：Windows 10+\n- **macOS**：macOS 10.15（Catalina）+\n- **Linux**：Ubuntu 22.04+ / Debian 11+ / Fedora 34+\n\n### 下载链接\n\n访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载：\n\n- **Windows**：`CC-Switch-v3.8.0-Windows.msi` 或 `-Portable.zip`\n- **macOS**：`CC-Switch-v3.8.0-macOS.tar.gz` 或 `.zip`\n- **Linux**：`CC-Switch-v3.8.0-Linux.AppImage` 或 `.deb`\n\n### Homebrew（macOS）\n\n```bash\nbrew tap farion1231/ccswitch\nbrew install --cask cc-switch\n```\n\n更新：\n\n```bash\nbrew upgrade --cask cc-switch\n```\n\n## 致谢\n\n### 贡献者\n\n感谢所有让这个版本成为可能的贡献者：\n\n- [@YoVinchen](https://github.com/YoVinchen) - UI 和数据库重构\n- [@farion1231](https://github.com/farion1231) - BUG 修复和功能增强\n- 社区成员的测试和反馈\n\n### 赞助商\n\n**智谱AI** - GLM CODING PLAN 赞助商\n[使用此链接购买可享九折优惠](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)\n\n**PackyCode** - API 中转服务合作伙伴\n[使用 \"cc-switch\" 优惠码注册享 9 折优惠](https://www.packyapi.com/register?aff=cc-switch)\n\n**闪电说** - 本地优先的 AI 语音输入法\n[免费下载](https://shandianshuo.cn) Mac/Win 双平台\n\n**MiniMax** - MiniMax M2 CODING PLAN 赞助商\n[黑五优惠进行中，套餐9.9元起](https://platform.minimaxi.com/subscribe/coding-plan)\n\n---\n\n## 反馈与支持\n\n- **问题反馈**：[GitHub Issues](https://github.com/farion1231/cc-switch/issues)\n- **讨论**：[GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)\n- **文档**：[README](../README_ZH.md)\n- **更新日志**：[CHANGELOG.md](../CHANGELOG.md)\n\n---\n\n## 未来展望\n\n**v3.9.0 预览**（暂定）：\n\n- 本地代理功能\n\n敬请期待更多更新！\n\n---\n\n**Happy Coding!**\n"
  },
  {
    "path": "docs/release-notes/v3.9.0-en.md",
    "content": "# CC Switch v3.9.0\n\n> Local API Proxy, Auto Failover, Universal Provider, and a more complete multi-app workflow\n\n**[中文版 →](v3.9.0-zh.md) | [日本語版 →](v3.9.0-ja.md)**\n\n---\n\n## Overview\n\nCC Switch v3.9.0 is the stable release of the v3.9 beta series (`3.9.0-1`, `3.9.0-2`, `3.9.0-3`).\nIt introduces a local API proxy with per-app takeover, automatic failover, universal providers, and many stability and UX improvements across Claude Code, Codex, and Gemini CLI.\n\n**Release Date**: 2026-01-07\n\n---\n\n## Highlights\n\n- Local API Proxy for Claude Code / Codex / Gemini CLI\n- Auto Failover with circuit breaker and per-app failover queues\n- Universal Provider: one shared config synced across apps (ideal for API gateways like NewAPI)\n- Skills improvements: multi-app support, unified management with SSOT + React Query\n- Common config snippets: extract reusable snippets from the editor or the current provider\n- MCP import: import MCP servers from installed apps\n- Usage improvements: auto-refresh, cache hit/creation metrics, and timezone fixes\n- Linux packaging: RPM and Flatpak artifacts\n\n---\n\n## Major Features\n\n### Local API Proxy\n\n- Runs a local high-performance HTTP proxy server (Axum-based)\n- Supports Claude Code, Codex, and Gemini CLI with a unified proxy\n- Per-app takeover: you can independently decide which app routes through the proxy\n- Live config takeover: backs up and redirects the CLI live config to the local proxy when takeover is enabled\n- Monitoring: request logging and usage statistics for easier debugging and cost tracking\n- Error request logging: keep detailed logs for failed proxy requests to simplify debugging (#401, thanks @yovinchen)\n\n### Auto Failover (Circuit Breaker)\n\n- Automatically detects provider failures and triggers protection (circuit breaker)\n- Automatically switches to a backup provider when the current one is unhealthy\n- Tracks provider health in real time, and keeps independent failover queues per app\n- When failover is disabled, timeout/retry related settings no longer affect normal request flow\n\n### Skills Management\n\n- Multi-app Skills support for Claude Code and Codex, with smoother migration from older skill layouts (#365, #378, thanks @yovinchen)\n- Unified Skills management architecture (SSOT + React Query) for more consistent state and refresh behavior\n- Better discovery UX and performance:\n  - Skip hidden directories during discovery\n  - Faster discovery with long-lived caching for discoverable skills\n  - Clear loading indicators and more discoverable header actions (import/refresh)\n  - Fix wrong skill repo branch (#505, thanks @kjasn)\n\n### Universal Provider\n\n- Add a shared provider configuration that can sync to Claude/Codex/Gemini (#348, thanks @Calcium-Ion)\n- Designed for API gateways that support multiple protocols (e.g., NewAPI)\n- Allows per-app default model mapping under a single provider\n\n### Common Config Snippets (Claude/Codex/Gemini)\n\n- Maintain a reusable \"common config\" snippet and merge/append it into providers that enable it\n- New extraction workflow:\n  - Extract from the editor content (what you are currently editing)\n  - Or extract from the current active provider when the editor content is not provided\n- Codex extraction is safer:\n  - Removes provider-specific sections like `model_provider`, `model`, and the entire `model_providers` table\n  - Preserves `base_url` under `[mcp_servers.*]` so MCP configs are not accidentally broken\n\n### MCP Management\n\n- Import MCP servers from installed apps\n- Improve robustness: skip sync when the target CLI app is not installed; handle invalid Codex `config.toml` gracefully (#461, thanks @majiayu000)\n- Windows compatibility: wrap npx/npm commands with `cmd /c` for MCP export\n\n### Usage & Pricing\n\n- Usage & pricing improvements: auto-refresh, cache hit/creation metrics, timezone handling fixes, and refreshed built-in pricing table (#508, thanks @yovinchen)\n- DeepLink support: import usage query configuration via deeplink (#400, thanks @qyinter)\n- Model extraction for usage statistics (#455, thanks @yovinchen)\n- Usage query credentials can fall back to provider config (#360, thanks @Sirhexs)\n\n---\n\n## UX Improvements\n\n- Provider search filter: quickly find providers by name (#435, thanks @TinsFox)\n- Provider icon colors: customize provider icon colors for quicker visual identification (#385, thanks @yovinchen)\n- Keyboard shortcut: `Cmd/Ctrl + ,` opens Settings (#436, thanks @TinsFox)\n- Skip Claude Code first-run confirmation dialog (optional)\n- Closable toasts: close buttons for switch toast and all success toasts (#350, thanks @ForteScarlet)\n- Update badge navigation: clicking the update badge opens the About tab\n- Settings page tab style improvements (#342, thanks @wenyuanw)\n- Smoother transitions: fade transitions for app/view switching and exit animations for panels\n- Proxy takeover active theme: apply an emerald theme while takeover is active\n- Dark mode readability improvements for forms and labels\n- Better window dragging area for full-screen panels (#525, thanks @zerob13)\n\n---\n\n## Platform Notes\n\n### Windows\n\n- Prevent terminal windows from appearing during version checks\n- Improve window sizing defaults (minimum width/height)\n- Fix black screen on startup by using the system titlebar\n- Add a fallback for `crypto.randomUUID()` on older WebViews\n\n### macOS\n\n- Use `.app` bundle path for autostart to avoid terminal window popups (#462, thanks @majiayu000)\n- Improve tray/icon behavior and header alignment\n\n---\n\n## Packaging\n\n- Linux: RPM and Flatpak packaging targets are now available for building release artifacts\n\n---\n\n## Notes\n\n- Security improvements for the JavaScript executor and usage script execution (#151, thanks @luojiyin1987).\n- SQL import is restricted to CC Switch exported backups to reduce the risk of importing unsafe or incompatible SQL dumps.\n- Proxy takeover modifies CLI live configs; CC Switch will back up the live config before redirecting it to the local proxy. If you want to revert, disable takeover/stop the proxy and restore from the backup when needed.\n\n## Special Thanks\n\nSpecial thanks to @xunyu @deijing @su-fen for their support and contributions. This release wouldn't be possible without you!\n\n## Download & Installation\n\nVisit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download the appropriate version.\n\n### System Requirements\n\n| System  | Minimum Version                 | Architecture                        |\n| ------- | ------------------------------- | ----------------------------------- |\n| Windows | Windows 10 or later             | x64                                 |\n| macOS   | macOS 10.15 (Catalina) or later | Intel (x64) / Apple Silicon (arm64) |\n| Linux   | See table below                 | x64                                 |\n\n### Windows\n\n| File                                    | Description                                        |\n| --------------------------------------- | -------------------------------------------------- |\n| `CC-Switch-v3.9.0-Windows.msi`          | **Recommended** - MSI installer with auto-update support |\n| `CC-Switch-v3.9.0-Windows-Portable.zip` | Portable version, no installation required         |\n\n### macOS\n\n| File                            | Description                                                       |\n| ------------------------------- | ----------------------------------------------------------------- |\n| `CC-Switch-v3.9.0-macOS.zip`    | **Recommended** - Extract and drag to Applications, Universal Binary |\n| `CC-Switch-v3.9.0-macOS.tar.gz` | For Homebrew installation and auto-update                         |\n\n> **Note**: Since the author does not have an Apple Developer account, you may see an \"unidentified developer\" warning on first launch. Close the app, then go to \"System Settings\" → \"Privacy & Security\" → click \"Open Anyway\", and it will open normally afterwards.\n\n### Homebrew (MacOS)\n\n```bash\nbrew tap farion1231/ccswitch\nbrew install --cask cc-switch\n```\n\nUpdate:\n\n```bash\nbrew upgrade --cask cc-switch\n```\n\n### Linux\n\n| Distribution                            | Recommended Format | Installation                                                           |\n| --------------------------------------- | ------------------ | ---------------------------------------------------------------------- |\n| Ubuntu / Debian / Linux Mint / Pop!\\_OS | `.deb`             | `sudo dpkg -i CC-Switch-*.deb` or `sudo apt install ./CC-Switch-*.deb` |\n| Fedora / RHEL / CentOS / Rocky Linux    | `.rpm`             | `sudo rpm -i CC-Switch-*.rpm` or `sudo dnf install ./CC-Switch-*.rpm`  |\n| openSUSE                                | `.rpm`             | `sudo zypper install ./CC-Switch-*.rpm`                                |\n| Arch Linux / Manjaro                    | `.AppImage`        | Make executable and run directly, or use AUR                           |\n| Other distros / Unsure                  | `.AppImage`        | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage`              |\n| Sandboxed installation                  | `.flatpak`         | `flatpak install CC-Switch-*.flatpak`                                  |\n"
  },
  {
    "path": "docs/release-notes/v3.9.0-ja.md",
    "content": "# CC Switch v3.9.0\n\n> ローカル API プロキシ、自動フェイルオーバー、Universal Provider、多アプリ対応の強化\n\n**[English →](v3.9.0-en.md) | [中文版 →](v3.9.0-zh.md)**\n\n---\n\n## 概要\n\nCC Switch v3.9.0 は v3.9 ベータ（`3.9.0-1`、`3.9.0-2`、`3.9.0-3`）の安定版です。\nローカル API プロキシ（アプリ別テイクオーバー対応）、自動フェイルオーバー、Universal Provider を追加し、Claude Code / Codex / Gemini CLI の安定性と操作性を大きく改善しました。\n\n**リリース日**：2026-01-07\n\n---\n\n## ハイライト\n\n- ローカル API プロキシ：Claude Code / Codex / Gemini CLI を統一的にプロキシ\n- 自動フェイルオーバー：サーキットブレーカーとアプリ別のフェイルオーバーキュー\n- Universal Provider：1つの設定を複数アプリへ同期（NewAPI などのゲートウェイ向け）\n- Skills の改善：マルチアプリ対応、SSOT + React Query による管理の統一\n- 共通設定スニペット：エディタ内容または現在のプロバイダから抽出\n- MCP インポート：インストール済みアプリから MCP servers を取り込み\n- 使用量の改善：自動更新、キャッシュ指標、タイムゾーン修正\n- Linux パッケージ：RPM / Flatpak の成果物を追加\n\n---\n\n## 主要機能\n\n### ローカル API プロキシ（Local API Proxy）\n\n- ローカルで高性能な HTTP プロキシサーバーを起動（Axum ベース）\n- Claude Code / Codex / Gemini CLI の API リクエストを統一的に扱う\n- アプリ別テイクオーバー：アプリごとにプロキシ経由にするかを個別に切り替え可能\n- Live 設定テイクオーバー：有効化時に CLI の live 設定をバックアップし、ローカルプロキシへリダイレクト\n- 監視：リクエストログと使用量統計でデバッグとコスト把握を支援\n- エラーリクエストのログ：失敗したプロキシリクエストも詳細に記録してデバッグを容易に（#401、@yovinchen に感謝）\n\n### 自動フェイルオーバー（Auto Failover / サーキットブレーカー）\n\n- 障害を検知して保護（サーキットブレーカー）を自動で発動\n- 現在のプロバイダが不調な場合、バックアッププロバイダへ自動切り替え\n- アプリごとに独立したフェイルオーバーキューとヘルス状態を管理\n- フェイルオーバーを無効化している場合、タイムアウト/リトライ関連の設定は通常フローに影響しません\n\n### Skills 管理\n\n- Claude Code と Codex の Skills をマルチアプリで利用可能にし、旧レイアウトからの移行もよりスムーズに（#365、#378、@yovinchen に感謝）\n- SSOT + React Query による Skills 管理の統一で、状態の一貫性と更新挙動を改善\n- Discovery の体験と性能を改善：\n  - スキャン時に隠しディレクトリをスキップ\n  - Discoverable skills に長寿命キャッシュを適用して高速化\n  - ローディング表示の改善と、インポート/更新などの操作導線を整理\n  - Skills リポジトリのブランチ設定を修正（#505、@kjasn に感謝）\n\n### Universal Provider\n\n- 複数アプリで共有できるプロバイダ設定を追加（Claude/Codex/Gemini へ同期）（#348、@Calcium-Ion に感謝）\n- NewAPI のような複数プロトコル対応の API ゲートウェイを想定\n- 1つのプロバイダ内でアプリ別にデフォルトモデルを割り当て可能\n\n### 共通設定スニペット（Claude/Codex/Gemini）\n\n- 「共通設定スニペット」を保持し、有効化したプロバイダへマージ/追記\n- 新しい抽出フロー：\n  - エディタの現在内容から抽出（編集している内容）\n  - エディタ内容がない場合は、現在アクティブなプロバイダから抽出\n- Codex の抽出はより安全：\n  - `model_provider`、`model`、および `model_providers` テーブル全体など、プロバイダ固有の設定を除去\n  - `[mcp_servers.*]` 配下の `base_url` は保持し、MCP 設定を壊しにくくしています\n\n### MCP 管理\n\n- インストール済みアプリから MCP servers をインポート\n- 安定性向上：対象 CLI が未インストールなら同期をスキップし、無効な Codex `config.toml` も適切に扱います（#461、@majiayu000 に感謝）\n- Windows 互換性：MCP エクスポート時の npx/npm 呼び出しを `cmd /c` でラップ\n\n### 使用量と価格データ\n\n- 使用量/価格の改善：自動更新、キャッシュ指標、タイムゾーン修正、内蔵価格テーブル更新（#508、@yovinchen に感謝）\n- DeepLink 対応：deeplink から使用量クエリ設定をインポート（#400、@qyinter に感謝）\n- 使用量統計からモデル情報を抽出（#455、@yovinchen に感謝）\n- 使用量クエリ資格情報はプロバイダ設定へフォールバック可能（#360、@Sirhexs に感謝）\n\n---\n\n## 使い勝手の改善\n\n- プロバイダ検索フィルター（名前で素早く検索）（#435、@TinsFox に感謝）\n- プロバイダのアイコン色：アイコンに任意の色を設定して見分けやすく（#385、@yovinchen に感謝）\n- ショートカット：`Cmd/Ctrl + ,` で設定を開く（#436、@TinsFox に感謝）\n- Claude Code の初回確認ダイアログをスキップ可能（任意）\n- トースト通知のクローズボタン：切り替え通知と成功通知を閉じられるように（#350、@ForteScarlet に感謝）\n- 更新バッジをクリックすると About タブへ移動\n- 設定ページのタブスタイル改善（#342、@wenyuanw に感謝）\n- アプリ/ビュー切り替えのフェードとパネル終了アニメーション\n- プロキシテイクオーバー中はエメラルド系テーマを適用して状態を分かりやすく\n- ダークモードの視認性改善\n- FullScreenPanel のウィンドウドラッグ領域を改善（#525、@zerob13 に感謝）\n\n---\n\n## プラットフォーム別メモ\n\n### Windows\n\n- バージョンチェック時にターミナルが表示されないよう改善\n- ウィンドウ最小サイズのデフォルトを調整\n- 起動時の黒画面を避けるため、システムタイトルバー方式を採用\n- 古い WebView 向けに `crypto.randomUUID()` のフォールバックを追加\n\n### macOS\n\n- 自動起動で `.app` バンドルパスを使用し、ターミナル表示を回避（#462、@majiayu000 に感謝）\n- トレイとヘッダー周りの体験を改善\n\n---\n\n## パッケージ\n\n- Linux：RPM と Flatpak のパッケージングを追加し、リリース成果物の生成に対応\n\n---\n\n## 注意事項\n\n- セキュリティ強化：JavaScript 実行器と使用量スクリプト実行に関するセキュリティ問題を修正（#151、@luojiyin1987 に感謝）。\n- SQL インポートは CC Switch がエクスポートしたバックアップのみに制限されます（安全性のため）。\n- プロキシのテイクオーバーは CLI の live 設定を変更します。CC Switch はリダイレクト前に live 設定をバックアップします。元に戻す場合はテイクオーバー無効化/プロキシ停止を行い、必要に応じてバックアップから復元してください。\n\n## 特別な謝辞\n\n@xunyu @deijing @su-fen の皆様のサポートと貢献に特別な感謝を申し上げます。皆様なしではこのリリースは実現しませんでした！\n\n## ダウンロード & インストール\n\n[Releases](https://github.com/farion1231/cc-switch/releases/latest) から該当するバージョンをダウンロードしてください。\n\n### システム要件\n\n| システム | 最低バージョン                | アーキテクチャ                      |\n| -------- | ----------------------------- | ----------------------------------- |\n| Windows  | Windows 10 以降               | x64                                 |\n| macOS    | macOS 10.15 (Catalina) 以降   | Intel (x64) / Apple Silicon (arm64) |\n| Linux    | 下表参照                      | x64                                 |\n\n### Windows\n\n| ファイル                                | 説明                                         |\n| --------------------------------------- | -------------------------------------------- |\n| `CC-Switch-v3.9.0-Windows.msi`          | **推奨** - MSI インストーラー、自動更新対応  |\n| `CC-Switch-v3.9.0-Windows-Portable.zip` | ポータブル版、インストール不要               |\n\n### macOS\n\n| ファイル                        | 説明                                                              |\n| ------------------------------- | ----------------------------------------------------------------- |\n| `CC-Switch-v3.9.0-macOS.zip`    | **推奨** - 解凍して Applications へドラッグ、Universal Binary     |\n| `CC-Switch-v3.9.0-macOS.tar.gz` | Homebrew インストールおよび自動更新用                             |\n\n> **注意**: 作者が Apple Developer アカウントを持っていないため、初回起動時に「開発元が未確認」という警告が表示される場合があります。アプリを閉じてから、「システム設定」→「プライバシーとセキュリティ」→「このまま開く」をクリックすると、正常に開けるようになります。\n\n### Homebrew (MacOS)\n\n```bash\nbrew tap farion1231/ccswitch\nbrew install --cask cc-switch\n```\n\nアップデート:\n\n```bash\nbrew upgrade --cask cc-switch\n```\n\n### Linux\n\n| ディストリビューション                  | 推奨形式    | インストール方法                                                               |\n| --------------------------------------- | ----------- | ------------------------------------------------------------------------------ |\n| Ubuntu / Debian / Linux Mint / Pop!\\_OS | `.deb`      | `sudo dpkg -i CC-Switch-*.deb` または `sudo apt install ./CC-Switch-*.deb`     |\n| Fedora / RHEL / CentOS / Rocky Linux    | `.rpm`      | `sudo rpm -i CC-Switch-*.rpm` または `sudo dnf install ./CC-Switch-*.rpm`      |\n| openSUSE                                | `.rpm`      | `sudo zypper install ./CC-Switch-*.rpm`                                        |\n| Arch Linux / Manjaro                    | `.AppImage` | 実行権限を付与して直接実行、または AUR を使用                                  |\n| その他 / 不明                           | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage`                      |\n| サンドボックスで実行したい場合          | `.flatpak`  | `flatpak install CC-Switch-*.flatpak`                                          |\n"
  },
  {
    "path": "docs/release-notes/v3.9.0-zh.md",
    "content": "# CC Switch v3.9.0\n\n> 本地 API 代理、自动故障切换、统一供应商与多应用工作流增强\n\n**[English →](v3.9.0-en.md) | [日本語版 →](v3.9.0-ja.md)**\n\n---\n\n## 概览\n\nCC Switch v3.9.0 是 v3.9 测试版序列（`3.9.0-1`、`3.9.0-2`、`3.9.0-3`）的稳定版。\n本次更新带来本地 API 代理（支持按应用接管）、自动故障切换、统一供应商（Universal Provider），并对 Claude Code / Codex / Gemini CLI 的稳定性与使用体验做了大量改进。\n\n**发布日期**：2026-01-07\n\n---\n\n## 重点内容\n\n- 本地 API 代理：Claude Code / Codex / Gemini CLI 统一接入\n- 自动故障切换：熔断保护 + 每个应用独立的 failover 队列\n- 统一供应商：一份配置可同步到多个应用（适合 NewAPI 等网关）\n- Skills 相关增强：支持多应用、管理架构统一（SSOT + React Query）\n- 通用配置片段：支持从编辑器内容或当前供应商提取可复用片段\n- MCP 导入：支持从已安装应用导入 MCP servers\n- 用量增强：自动刷新、缓存命中/创建指标、时区修复\n- Linux 打包：新增 RPM 与 Flatpak 制品\n\n---\n\n## 主要功能\n\n### 本地 API 代理（Local API Proxy）\n\n- 运行一个本地高性能 HTTP 代理服务（基于 Axum）\n- 统一代理 Claude Code、Codex、Gemini CLI 的 API 请求\n- 按应用接管：你可以分别控制每个应用是否走本地代理\n- Live 配置接管：启用接管时，会备份并重定向 CLI 的 live 配置到本地代理\n- 监控能力：记录请求日志与用量统计，便于排错与成本分析\n- 错误请求日志：代理会记录失败请求的详细信息，便于定位问题（#401，感谢 @yovinchen）\n\n### 自动故障切换（Auto Failover / 熔断）\n\n- 自动检测供应商异常并触发熔断保护\n- 当前供应商不可用时自动切换到备用供应商\n- 每个应用维护独立的 failover 队列，并实时追踪健康状态\n- 当关闭故障切换时，超时/重试相关配置不会影响正常请求流程\n\n### Skills 管理\n\n- Skills 支持 Claude Code 与 Codex 多应用使用，并提供旧结构到新结构的平滑迁移（#365、#378，感谢 @yovinchen）\n- Skills 管理架构统一（SSOT + React Query），状态刷新与数据一致性更稳定\n- 发现（Discovery）体验与性能改进：\n  - 扫描时跳过隐藏目录\n  - Discoverable skills 使用长生命周期缓存提升性能\n  - 增加加载状态提示，导入/刷新等操作入口更显眼\n  - 修复 Skills 仓库分支配置错误（#505，感谢 @kjasn）\n\n### 统一供应商（Universal Provider）\n\n- 新增“跨应用共享”的供应商配置，可同步到 Claude/Codex/Gemini（#348，感谢 @Calcium-Ion）\n- 适配支持多协议的 API 网关（例如 NewAPI）\n- 同一个供应商下可按应用分别设置默认模型映射\n\n### 通用配置片段（Claude/Codex/Gemini）\n\n- 维护一段“通用配置片段”，并将其合并/追加到启用该功能的供应商配置中\n- 新增“提取通用配置片段”工作流：\n  - 优先从编辑器当前内容提取（你正在编辑的内容）\n  - 若未提供编辑器内容，则从当前激活的供应商提取\n- Codex 场景提取更安全：\n  - 自动移除 `model_provider`、`model` 以及整个 `model_providers` 表等供应商相关内容\n  - 会保留 `[mcp_servers.*]` 下的 `base_url`，避免误伤 MCP 配置\n\n### MCP 管理\n\n- 支持从已安装应用导入 MCP servers\n- 同步更稳健：目标 CLI 未安装则跳过；无效的 Codex `config.toml` 可更优雅处理（#461，感谢 @majiayu000）\n- Windows 兼容性：MCP 导出相关的 npx/npm 调用使用 `cmd /c` 包裹\n\n### 用量与计费数据\n\n- 用量与计费增强：自动刷新、缓存命中/创建指标、时区修复，以及内置价格表更新（#508，感谢 @yovinchen）\n- 深链支持：可通过 deeplink 导入用量查询配置（#400，感谢 @qyinter）\n- 用量统计支持提取模型信息（#455，感谢 @yovinchen）\n- 用量查询凭证支持从供应商配置回退（#360，感谢 @Sirhexs）\n\n---\n\n## 体验优化\n\n- 供应商搜索过滤：按名称快速查找（#435，感谢 @TinsFox）\n- 供应商图标颜色：支持为供应商图标设置自定义颜色，便于快速区分（#385，感谢 @yovinchen）\n- 快捷键：`Cmd/Ctrl + ,` 打开设置（#436，感谢 @TinsFox）\n- 可跳过 Claude Code 首次确认弹窗（可选）\n- Toast 通知可关闭：切换提示与成功提示都支持关闭按钮（#350，感谢 @ForteScarlet）\n- 点击更新徽章会自动跳转到 About 标签页\n- 设置页 Tab 样式改进（#342，感谢 @wenyuanw）\n- 更顺滑的切换动效：应用/视图淡入淡出与面板退出动画\n- 代理接管激活时应用翡翠绿主题，便于一眼识别当前状态\n- 深色模式可读性增强（表单与标签对比度等）\n- FullScreenPanel 的窗口拖拽区域优化（#525，感谢 @zerob13）\n\n---\n\n## 平台说明\n\n### Windows\n\n- 版本检查不再弹出终端窗口\n- 改进窗口尺寸默认值（最小宽高）\n- 修复部分设备启动黑屏问题（使用系统标题栏方案）\n- 兼容旧 WebView：为 `crypto.randomUUID()` 增加降级方案\n\n### macOS\n\n- 自启动使用 `.app bundle` 路径，避免弹出终端窗口（#462，感谢 @majiayu000）\n- 托盘与标题栏相关体验优化\n\n---\n\n## 打包\n\n- Linux：新增 RPM 与 Flatpak 打包目标，用于生成发布制品\n\n---\n\n## 说明与注意事项\n\n- 安全增强：修复 JavaScript 执行器与用量脚本相关的安全问题（#151，感谢 @luojiyin1987）。\n- 为降低导入风险，SQL 导入被限制为仅允许导入 CC Switch 自己导出的备份。\n- Proxy 接管会修改 CLI 的 live 配置；CC Switch 会在重定向前自动备份 live 配置。如需回退，可关闭接管/停止代理，并在必要时从备份恢复。\n\n## 特别感谢\n\n特别感谢 @xunyu @deijing @su-fen 做出的支持和贡献，没有你们就没有这个版本！\n\n## 下载与安装\n\n访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。\n\n### 系统要求\n\n| 系统    | 最低版本                      | 架构                                |\n| ------- | ----------------------------- | ----------------------------------- |\n| Windows | Windows 10 及以上             | x64                                 |\n| macOS   | macOS 10.15 (Catalina) 及以上 | Intel (x64) / Apple Silicon (arm64) |\n| Linux   | 见下表                        | x64                                 |\n\n### Windows\n\n| 文件                                    | 说明                                |\n| --------------------------------------- | ----------------------------------- |\n| `CC-Switch-v3.9.0-Windows.msi`          | **推荐** - MSI 安装包，支持自动更新 |\n| `CC-Switch-v3.9.0-Windows-Portable.zip` | 便携版，解压即用，不写入注册表      |\n\n### macOS\n\n| 文件                            | 说明                                                      |\n| ------------------------------- | --------------------------------------------------------- |\n| `CC-Switch-v3.9.0-macOS.zip`    | **推荐** - 解压后拖入 Applications 即可，Universal Binary |\n| `CC-Switch-v3.9.0-macOS.tar.gz` | 用于 Homebrew 安装和自动更新                              |\n\n> **注意**：由于作者没有苹果开发者账号，首次打开可能出现\"未知开发者\"警告，请先关闭，然后前往\"系统设置\" → \"隐私与安全性\" → 点击\"仍要打开\"，之后便可以正常打开\n\n### Homebrew（MacOS）\n\n```bash\nbrew tap farion1231/ccswitch\nbrew install --cask cc-switch\n```\n\n更新：\n\n```bash\nbrew upgrade --cask cc-switch\n```\n\n### Linux\n\n| 发行版                                  | 推荐格式    | 安装方式                                                               |\n| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |\n| Ubuntu / Debian / Linux Mint / Pop!\\_OS | `.deb`      | `sudo dpkg -i CC-Switch-*.deb` 或 `sudo apt install ./CC-Switch-*.deb` |\n| Fedora / RHEL / CentOS / Rocky Linux    | `.rpm`      | `sudo rpm -i CC-Switch-*.rpm` 或 `sudo dnf install ./CC-Switch-*.rpm`  |\n| openSUSE                                | `.rpm`      | `sudo zypper install ./CC-Switch-*.rpm`                                |\n| Arch Linux / Manjaro                    | `.AppImage` | 添加执行权限后直接运行，或使用 AUR                                     |\n| 其他发行版 / 不确定                     | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage`              |\n| 沙箱隔离需求                            | `.flatpak`  | `flatpak install CC-Switch-*.flatpak`                                  |\n"
  },
  {
    "path": "docs/user-manual/README.md",
    "content": "# CC Switch User Manual / 用户手册 / ユーザーマニュアル\n\n> Claude Code / Codex / Gemini CLI / OpenCode / OpenClaw\n\n## Language / 语言 / 言語\n\n| Language | Link |\n|----------|------|\n| [中文](./zh/README.md) | 简体中文用户手册 |\n| [English](./en/README.md) | English User Manual |\n| [日本語](./ja/README.md) | 日本語ユーザーマニュアル |\n\n## Version / 版本 / バージョン\n\n- Documentation version: v3.12.0\n- Last updated: 2026-03-09\n- Compatible with CC Switch v3.12.0+\n\n## Links\n\n- [GitHub Issues](https://github.com/farion1231/cc-switch/issues)\n- [GitHub Repository](https://github.com/farion1231/cc-switch)\n"
  },
  {
    "path": "docs/user-manual/en/1-getting-started/1.1-introduction.md",
    "content": "# 1.1 Introduction\n\n## What is CC Switch\n\nCC Switch is a cross-platform desktop application designed for developers who use AI coding tools. It helps you centrally manage configurations for five major AI coding tools: **Claude Code**, **Codex**, **Gemini CLI**, **OpenCode**, and **OpenClaw**.\n\n## What Problems Does It Solve\n\nIn your daily development workflow, you may encounter these pain points:\n\n- **Tedious multi-provider switching**: Using different API providers (official, proxy services) requires manually editing configuration files\n- **Scattered configurations**: Claude, Codex, Gemini, OpenCode, and OpenClaw each have independent configuration files in different formats\n- **No usage monitoring**: No visibility into how many API calls were made or how much they cost\n- **Service instability**: When a single provider goes down, your entire workflow is interrupted\n\nCC Switch solves these problems through a unified interface.\n\n## Core Features\n\n### Provider Management\n- One-click switching between multiple API provider configurations\n- Preset templates for quickly adding common providers\n- Universal provider feature for sharing configurations across apps\n- Usage query and balance display\n- Endpoint speed testing\n\n### Extensions\n- **MCP Servers**: Manage Model Context Protocol servers to extend AI capabilities\n- **Prompts**: Manage system prompt presets for quick scenario switching\n- **Skills**: Install and manage skill extensions\n\n### Proxy & High Availability\n- Local proxy service for request logging and usage statistics\n- Automatic failover that switches to a backup provider when the primary one fails\n- Circuit breaker mechanism to prevent repeated retries against failing providers\n- Detailed token usage tracking and cost estimation\n\n## Supported Applications\n\n| Application | Description |\n|-------------|-------------|\n| **Claude Code** | Anthropic's official AI coding assistant |\n| **Codex** | OpenAI's code generation tool |\n| **Gemini CLI** | Google's AI command-line tool |\n| **OpenCode** | Open-source AI coding terminal tool |\n| **OpenClaw** | Open-source AI coding assistant (multi-provider gateway) |\n\n## Supported Platforms\n\n- **Windows** 10 and above\n- **macOS** 10.15 (Catalina) and above\n- **Linux** Ubuntu 22.04+ / Debian 11+ / Fedora 34+\n\n## Technical Architecture\n\nCC Switch is built with a modern technology stack:\n\n- **Frontend**: React 18 + TypeScript + Tailwind CSS\n- **Backend**: Tauri 2 + Rust\n- **Data Storage**: SQLite (providers, MCP, Prompts) + JSON (device settings)\n\nThis architecture ensures:\n- Consistent cross-platform experience\n- Native-level performance\n- Secure local data storage\n"
  },
  {
    "path": "docs/user-manual/en/1-getting-started/1.2-installation.md",
    "content": "# 1.2 Installation Guide\n\n## Prerequisites\n\n### Install Node.js\n\nThe CLI tools managed by CC Switch (Claude Code, Codex, Gemini CLI) require a Node.js environment.\n\n**Recommended version**: Node.js 18 LTS or higher\n\n#### Windows\n\n1. Visit the [Node.js official website](https://nodejs.org/)\n\n2. Download the LTS version installer\n\n3. Run the installer and follow the prompts\n\n4. Verify installation:\n\n ```bash\n node --version\n npm --version\n ```\n\n#### macOS\n\n```bash\n# Install with Homebrew\nbrew install node\n\n# Or use nvm (recommended)\ncurl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash\nnvm install --lts\n```\n\n#### Linux\n\n```bash\n# Ubuntu/Debian\ncurl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -\nsudo apt-get install -y nodejs\n\n# Or use nvm\ncurl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash\nnvm install --lts\n```\n\n### Install CLI Tools\n\n#### Claude Code\n\n**Option 1: Homebrew (recommended for macOS)**\n\n```bash\nbrew install claude-code\n```\n\n**Option 2: npm**\n\n```bash\nnpm install -g @anthropic-ai/claude-code\n```\n\n#### Codex\n\n**Option 1: Homebrew (recommended for macOS)**\n\n```bash\nbrew install codex\n```\n\n**Option 2: npm**\n\n```bash\nnpm install -g @openai/codex\n```\n\n#### Gemini CLI\n\n**Option 1: Homebrew (recommended for macOS)**\n\n```bash\nbrew install gemini-cli\n```\n\n**Option 2: npm**\n\n```bash\nnpm install -g @google/gemini-cli\n```\n\n---\n\n## Windows\n\n### Installer\n\n1. Visit the [Releases page](https://github.com/farion1231/cc-switch/releases)\n2. Download `CC-Switch-v{version}-Windows.msi`\n3. Double-click to run the installer\n4. Follow the prompts to complete installation\n\n### Portable Version (No Installation Required)\n\n1. Download `CC-Switch-v{version}-Windows-Portable.zip`\n2. Extract to any directory\n3. Run `CC-Switch.exe`\n\n## macOS\n\n### Option 1: Homebrew (Recommended)\n\n```bash\n# Add tap\nbrew tap farion1231/ccswitch\n\n# Install\nbrew install --cask cc-switch\n```\n\nUpdate to the latest version:\n\n```bash\nbrew upgrade --cask cc-switch\n```\n\n### Option 2: Manual Download\n\n1. Download `CC-Switch-v{version}-macOS.zip`\n2. Extract to get `CC Switch.app`\n3. Drag it to the Applications folder\n\n### First Launch Warning\n\nSince the developer does not have an Apple Developer account, a \"developer cannot be verified\" warning may appear on first launch:\n\n**Recommended solution**:\nOpen Terminal and run the following command:\n```bash\nsudo xattr -dr com.apple.quarantine /Applications/CC\\ Switch.app/\n```\n\n**Alternative solution (via System Settings)**:\n1. Close the warning dialog\n2. Open \"System Settings\" > \"Privacy & Security\"\n3. Find the CC Switch prompt and click \"Open Anyway\"\n4. Reopen the app to use it normally\n\n## Linux\n\n### ArchLinux\n\nInstall using an AUR helper:\n\n```bash\n# Using paru\nparu -S cc-switch-bin\n\n# Or using yay\nyay -S cc-switch-bin\n```\n\n### Debian / Ubuntu\n\n1. Download `CC-Switch-v{version}-Linux.deb`\n2. Install:\n\n```bash\nsudo dpkg -i CC-Switch-v{version}-Linux.deb\n\n# If there are dependency issues\nsudo apt-get install -f\n```\n\n### AppImage (Universal)\n\n1. Download `CC-Switch-v{version}-Linux.AppImage`\n2. Add execute permission:\n\n```bash\nchmod +x CC-Switch-v{version}-Linux.AppImage\n```\n\n3. Run:\n\n```bash\n./CC-Switch-v{version}-Linux.AppImage\n```\n\n## Verify Installation\n\nAfter installation, launch CC Switch:\n\n1. The app window displays correctly\n2. A CC Switch icon appears in the system tray\n3. You can switch between Claude / Codex / Gemini apps\n\n## Auto Update\n\nCC Switch includes built-in auto-update functionality:\n\n- Automatically checks for updates on startup\n- Displays an update prompt in the UI when a new version is available\n- Click to download and install\n\nYou can also manually check for updates in \"Settings > About\".\n\n## Uninstall\n\n### Windows\n\n- Uninstall via \"Settings > Apps\"\n- Or run the uninstaller in the installation directory\n\n### macOS\n\n- Move `CC Switch.app` to Trash\n- Optional: Delete the configuration directory `~/.cc-switch/`\n\n### Linux\n\n```bash\n# Debian/Ubuntu\nsudo apt remove cc-switch\n\n# ArchLinux\nparu -R cc-switch-bin\n```\n"
  },
  {
    "path": "docs/user-manual/en/1-getting-started/1.3-interface.md",
    "content": "# 1.3 Interface Overview\n\n## Main Interface Layout\n\n![image-20260108001629138](../../assets/image-20260108001629138.png)\n\n## Top Navigation Bar\n\n| # | Element | Description |\n|---|---------|-------------|\n| 1 | Logo | Click to visit the GitHub project page |\n| 2 | Settings Button | Open the settings page (shortcut `Cmd/Ctrl + ,`) |\n| 3 | Proxy Toggle | Start/stop the local proxy service |\n| 4 | App Switcher | Switch between Claude / Codex / Gemini / OpenCode / OpenClaw |\n| 5 | Feature Area | Skills / Prompts / MCP entry points |\n| 6 | Add Button | Add a new provider |\n\n### App Switcher\n\nClick the dropdown menu to switch the currently managed application:\n\n- **Claude** - Manage Claude Code configuration\n- **Codex** - Manage Codex configuration\n- **Gemini** - Manage Gemini CLI configuration\n- **OpenCode** - Manage OpenCode configuration\n- **OpenClaw** - Manage OpenClaw configuration\n\nAfter switching, the provider list displays the configurations for the selected application.\n\n### Feature Area Buttons\n\n| Button | Function | Visibility |\n|--------|----------|------------|\n| Skills | Skill extension management | Always visible |\n| Prompts | System prompt management | Always visible |\n| MCP | MCP server management | Always visible |\n\n## Provider Cards\n\nEach provider is displayed as a card, containing the following elements from left to right:\n\n### Card Elements (Left to Right)\n\n| # | Element | Icon | Description |\n|---|---------|------|-------------|\n| 1 | Drag Handle | ≡ | Hold and drag up/down to reorder providers |\n| 2 | Provider Icon | - | Displays provider brand icon with customizable color |\n| 3 | Provider Info | - | Name, notes/endpoint URL (clickable to open website) |\n| 4 | Usage Info | - | Shows remaining balance; displays plan count for multi-plan |\n| 5 | Enable Button | - | Switch to this provider |\n| 6 | Edit Button | - | Edit provider configuration |\n| 7 | Duplicate Button | - | Duplicate provider (create a copy) |\n| 8 | Speed Test Button | - | Test model availability and response speed |\n| 9 | Usage Query | - | Configure usage query script |\n| 10 | Delete Button | - | Delete provider (disabled when currently active) |\n\n> **Tip**: The action buttons area (5-10) appears on hover and is hidden by default to keep the interface clean.\n\n### Button Details\n\n| Button | State Changes | Notes |\n|--------|---------------|-------|\n| **Enable** | Shows checkmark and disables when active | Changes to \"Join/Joined\" in failover mode |\n| **Edit** | Always available | Opens edit panel to modify configuration |\n| **Duplicate** | Always available | Creates a copy with `copy` suffix |\n| **Speed Test** | Shows loading animation during test | Only available when proxy service is running |\n| **Usage Query** | Always available | Configure custom usage query script |\n| **Delete** | Semi-transparent/disabled when active | Must switch to another provider first |\n\n### Card States\n\n| State | Border Color | Description |\n|-------|--------------|-------------|\n| **Currently Active** | Blue border | Current provider in normal mode |\n| **Proxy Active** | Green border | Provider actually in use during proxy takeover mode |\n| **Normal** | Default border | Inactive provider |\n| **In Failover** | Shows priority badge | e.g., P1, P2 indicates failover priority |\n\n### Health Status Badges\n\nIn proxy mode, providers in the failover queue display health status:\n\n| Badge | Color | Description |\n|-------|-------|-------------|\n| Healthy | Green | 0 consecutive failures |\n| Warning | Yellow | 1-2 consecutive failures |\n| Unhealthy | Red | 3+ consecutive failures, may trigger circuit breaker |\n\n\n## System Tray\n\nCC Switch displays an icon in the system tray, providing quick access to operations.\n\n### Tray Menu Structure\n\n![image-20260108002153668](../../assets/image-20260108002153668.png)\n\n### Menu Functions\n\n| Menu Item | Function |\n|-----------|----------|\n| Open Main Window | Show and focus the main window |\n| App Groups | Providers grouped by Claude/Codex/Gemini/OpenCode/OpenClaw |\n| Provider List | Click to switch; currently active one shows a checkmark |\n| Quit | Fully exit the application |\n\n### Multi-language Support\n\nThe tray menu supports three languages, automatically switching based on settings:\n\n| Language | Open Main Window | Quit |\n|----------|-----------------|------|\n| Chinese | Open Main Window | Quit |\n| English | Open main window | Quit |\n| Japanese | Open main window | Quit |\n\n### Use Cases\n\nSwitching providers via the tray menu doesn't require opening the main window, suitable for:\n\n- Frequently switching providers\n- Quick operations when the main window is minimized\n- Managing configurations while running in the background\n\n## Settings Page\n\nThe settings page is divided into multiple tabs:\n\n### General Tab\n\n- Language settings (Chinese/English/Japanese)\n- Theme settings (System/Light/Dark)\n- Window behavior (launch on startup, close behavior)\n\n### Advanced Tab\n\n- Configuration directory settings\n- Proxy service configuration\n- Failover settings\n- Pricing configuration\n- Data import/export\n\n### Usage Tab\n\n- Request statistics overview\n- Trend charts\n- Request logs\n- Provider/model statistics\n\n### About Tab\n\n- Version information\n- Update check\n- Open source license\n\n## Keyboard Shortcuts\n\n| Shortcut | Function |\n|----------|----------|\n| `Cmd/Ctrl + ,` | Open Settings |\n| `Cmd/Ctrl + F` | Search providers |\n| `Esc` | Close dialog/search |\n\n## Search\n\nPress `Cmd/Ctrl + F` to open the search bar:\n\n- Search by name, notes, or URL\n- Real-time provider list filtering\n- Press `Esc` to close search\n"
  },
  {
    "path": "docs/user-manual/en/1-getting-started/1.4-quickstart.md",
    "content": "# 1.4 Quick Start\n\nThis section helps you complete the initial setup in 5 minutes.\n\n## Step 1: Add a Provider\n\n1. Click the **+** button in the top-right corner of the main interface\n2. Select your provider from the \"Preset\" dropdown\n   - Common presets: Zhipu GLM, MiniMax, DeepSeek, Kimi, PackyCode\n   - Or select \"Custom\" for manual configuration\n3. Enter your **API Key**\n4. Click \"Add\"\n\n![image-20260108002807657](../../assets/image-20260108002807657.png)\n\n> **Tip**: Presets auto-fill the endpoint URL, so you only need to enter your API Key.\n\n## Step 2: Switch Provider\n\nAfter adding, the provider appears in the list.\n\n**Option 1: Switch from the main interface**\n- Click the \"Enable\" button on the provider card\n\n**Option 2: Quick switch via system tray**\n- Right-click the CC Switch icon in the system tray\n- Click the provider name directly\n\n## Step 3: Activation\n\nAfter switching providers, each CLI tool activates differently:\n\n| Application | Activation Method |\n|-------------|-------------------|\n| Claude Code | Instant effect (supports hot reload) |\n| Codex | Requires closing and reopening the terminal |\n| Gemini | Instant effect (re-reads config on each request) |\n\n### Claude Code First Launch Prompt\n\nIf Claude Code prompts you to **log in** or shows an onboarding wizard on first launch, enable the \"Skip Claude Code first-run confirmation\" option in CC Switch:\n\n1. Open CC Switch \"Settings > General\"\n2. Enable the \"Skip Claude Code first-run confirmation\" toggle\n3. Restart Claude Code\n\n![image-20260108002626389](../../assets/image-20260108002626389.png)\n\n> **Note**: This option writes the `skipIntroduction` field to `~/.claude/settings.json`, skipping the official onboarding flow.\n\n## Verify Configuration\n\nAfter restarting, launch the corresponding CLI tool and enter a simple question to test:\n\n```bash\n# Claude Code - enter a test question after launching\nclaude\n> Hello, please briefly introduce yourself\n\n# Codex - enter a test question after launching\ncodex\n> Hello, please briefly introduce yourself\n\n# Gemini - enter a test question after launching\ngemini\n> Hello, please briefly introduce yourself\n```\n\nIf the AI responds normally, the configuration is successful.\n\n## Next Steps\n\nCongratulations! You have completed the basic configuration. Next, you can:\n\n- [Add more providers](../2-providers/2.1-add.md) - Configure multiple providers for easy switching\n- [Configure MCP servers](../3-extensions/3.1-mcp.md) - Extend AI tool capabilities\n- [Set up system prompts](../3-extensions/3.2-prompts.md) - Customize AI behavior\n- [Enable proxy service](../4-proxy/4.1-service.md) - Monitor usage and enable automatic failover\n\n## Common Issues\n\n### Not taking effect after switching?\n\nMake sure you restarted the terminal or CLI tool. The configuration file is updated at switch time, but running programs do not automatically reload it.\n\n### Can't find a preset?\n\nIf your provider is not in the preset list, select \"Custom\" for manual configuration. See [Add Provider](../2-providers/2.1-add.md) for configuration format details.\n\n### How to restore official login?\n\nSelect the \"Official Login\" preset (Claude/Codex) or \"Google Official\" preset (Gemini), restart the client, and follow the login flow.\n"
  },
  {
    "path": "docs/user-manual/en/1-getting-started/1.5-settings.md",
    "content": "# 1.5 Personalization\n\nThis section describes how to configure CC Switch according to your preferences.\n\n## Open Settings\n\n- Click the **gear** button in the top-left corner\n- Or use the shortcut `Cmd/Ctrl + ,`\n\n## Language Settings\n\nCC Switch supports three languages:\n\n| Language | Description |\n|----------|-------------|\n| Simplified Chinese | Default language |\n| English | English interface |\n| Japanese | Japanese interface |\n\nLanguage changes take effect immediately without restarting.\n\n## Theme Settings\n\n| Option | Description |\n|--------|-------------|\n| System | Automatically matches the system's dark/light mode |\n| Light | Always use the light theme |\n| Dark | Always use the dark theme |\n\n## Window Behavior\n\n### Launch on Startup\n\nWhen enabled, CC Switch automatically runs when the system starts.\n\n- **Windows**: Implemented via the registry\n- **macOS**: Implemented via LaunchAgent\n- **Linux**: Implemented via XDG autostart\n\n### Close Behavior\n\n| Option | Description |\n|--------|-------------|\n| Minimize to tray | Clicking the close button hides to the system tray |\n| Exit directly | Clicking the close button fully exits the app |\n\n\"Minimize to tray\" is recommended for convenient provider switching via the tray.\n\n### Claude Plugin Integration\n\nWhen enabled, CC Switch automatically syncs the configuration to the VS Code Claude Code extension (writes `primaryApiKey` to `~/.claude/config.json`) when switching providers.\n\n> **Use case**: If you use both Claude Code CLI and the VS Code extension, enable this option to keep both configurations in sync.\n\n### Skip Claude Onboarding\n\nWhen enabled, skips the Claude Code onboarding flow, suitable for users already familiar with Claude Code.\n\n> **Note**: This option writes the `skipIntroduction` field to `~/.claude/settings.json`.\n\n### App Visibility\n\nChoose which applications to display in the app switcher. Each app can be toggled independently, but at least one must remain visible.\n\nConfigurable apps: Claude, Codex, Gemini, OpenCode, OpenClaw.\n\n> **Use case**: If you only use Claude Code and Codex CLI, you can hide the other apps to keep the interface clean.\n\n### Skill Sync Method\n\nSet the sync method when installing skills to each app's directory:\n\n| Method | Description |\n|--------|-------------|\n| Symlink | Creates symbolic links pointing to skill source files; saves space, syncs in real-time |\n| Copy | Copies skill files entirely to the target directory |\n\n> **Recommended**: Symlink is the default method. Switch to Copy if you encounter permission issues.\n\n### Terminal Settings\n\nChoose the terminal application that CC Switch uses when opening a terminal.\n\nSupported terminals (by platform):\n\n| Platform | Terminal Options |\n|----------|-----------------|\n| macOS | Terminal, iTerm2, Alacritty, Kitty, Ghostty, WezTerm |\n| Windows | CMD, PowerShell, Windows Terminal |\n| Linux | GNOME Terminal, Konsole, Xfce4 Terminal, Alacritty, Kitty, Ghostty |\n\n## Directory Configuration\n\n### App Configuration Directory\n\nThe storage location for CC Switch's own data, defaulting to `~/.cc-switch/`.\n\n### CLI Tool Directories\n\nYou can customize each CLI tool's configuration directory:\n\n| Setting | Default | Description |\n|---------|---------|-------------|\n| Claude Directory | `~/.claude/` | Claude Code configuration directory |\n| Codex Directory | `~/.codex/` | Codex configuration directory |\n| Gemini Directory | `~/.gemini/` | Gemini CLI configuration directory |\n| OpenCode Directory | `~/.opencode/` | OpenCode configuration directory |\n| OpenClaw Directory | `~/.openclaw/` | OpenClaw configuration directory |\n\n> **Note**: After changing directories, the app must be restarted, and the corresponding CLI tools must also be configured to use the same directory.\n\n## Data Management\n\n### Export Configuration\n\nClick the \"Export\" button to save a backup file containing:\n\n- All provider configurations\n- MCP server configurations\n- Prompt presets\n- App settings\n\nThe backup file is in JSON format and can be viewed with a text editor.\n\n### Import Configuration\n\n1. Click \"Select File\"\n2. Select a previously exported backup file\n3. Click \"Import\"\n4. Confirm to overwrite existing configuration\n\n> **Note**: Importing will overwrite existing configuration. It is recommended to export your current configuration as a backup first.\n\n## Proxy Settings\n\nSettings > Proxy Tab\n\nThe Proxy tab centralizes all proxy-related features:\n\n### Local Proxy\n\nStart/stop the local proxy service, configure the listen address and port. See [4.1 Proxy Service](../4-proxy/4.1-service.md) for details.\n\n### Failover\n\nConfigure failover queues and automatic switching strategies by app (Claude/Codex/Gemini). See [4.3 Failover](../4-proxy/4.3-failover.md) for details.\n\n### Pricing Rectifier\n\nConfigure model pricing correction rules for proxy billing statistics calibration.\n\n### Global Outbound Proxy\n\nConfigure CC Switch's outbound HTTP/HTTPS proxy, applicable for scenarios where external API access requires a proxy.\n\n## Advanced Settings\n\nSettings > Advanced Tab\n\n### Configuration Directories\n\nCustomize configuration file directories for each app. See the \"Directory Configuration\" section above for details.\n\n### Data Management\n\nImport/export configuration backups. See the \"Data Management\" section above for details.\n\n### Backup & Restore\n\nManage automatic backups:\n\n| Setting | Description |\n|---------|-------------|\n| Backup Interval | Time interval for automatic backups (hours) |\n| Retention Count | Number of backups to retain |\n\nSupports viewing the backup list and restoring from backups.\n\n### Cloud Sync (WebDAV)\n\nSync configurations across multiple devices via the WebDAV protocol.\n\n| Setting | Description |\n|---------|-------------|\n| Service Preset | Jianguoyun / Nextcloud / Synology / Custom |\n| Server URL | WebDAV server URL |\n| Username | Login username |\n| Password | Login password (app-specific password) |\n| Remote Directory | Remote storage path (default: `cc-switch-sync`) |\n| Profile Name | Device profile name (default: `default`) |\n| Auto Sync | Automatically upload changes when enabled |\n\nOperations:\n\n- **Test Connection**: Verify WebDAV configuration is correct\n- **Save**: Save configuration and auto-test\n- **Upload**: Upload local data to the remote server\n- **Download**: Download data from the remote server to local\n\n> **Note**: Upload will overwrite remote data, and download will overwrite local data. Please confirm before proceeding.\n\n### Log Configuration\n\n| Setting | Description |\n|---------|-------------|\n| Enable Logging | Enable/disable application logging |\n| Log Level | error / warn / info / debug / trace |\n\nLog level descriptions:\n\n- **error** - Critical errors only\n- **warn** - Warnings and errors\n- **info** - General information (recommended)\n- **debug** - Detailed debugging information\n- **trace** - All verbose information\n\n## About Page\n\nSettings > About Tab\n\n### Version Information\n\nDisplays the current CC Switch version number, with support for:\n\n- Viewing release notes\n- Checking for updates\n- Downloading and installing new versions\n\n### Local Environment Check\n\nAutomatically detects installed CLI tool versions:\n\n| Tool | Detection Contents |\n|------|-------------------|\n| Claude | Current version, latest version |\n| Codex | Current version, latest version |\n| Gemini | Current version, latest version |\n| OpenCode | Current version, latest version |\n| OpenClaw | Current version, latest version |\n\nClick the \"Refresh\" button to re-detect.\n\n### One-click Install Commands\n\nProvides quick commands to install/update CLI tools:\n\n```bash\nnpm i -g @anthropic-ai/claude-code@latest\nnpm i -g @openai/codex@latest\nnpm i -g @google/gemini-cli@latest\nnpm i -g opencode@latest\nnpm i -g openclaw@latest\n```\n\nClick the \"Copy\" button to copy to clipboard.\n"
  },
  {
    "path": "docs/user-manual/en/2-providers/2.1-add.md",
    "content": "# 2.1 Add Provider\n\n## Open the Add Panel\n\nClick the **+** button in the top-right corner of the main interface to open the Add Provider panel.\n\nThe panel has two tabs:\n- **App-specific Provider**: Only for the currently selected app (Claude/Codex/Gemini/OpenCode/OpenClaw)\n- **Universal Provider**: Shared configuration across apps\n\n## Add Using Presets\n\nPresets are pre-configured provider templates that only require an API Key to use.\n\n### Steps\n\n1. Select a provider from the \"Preset\" dropdown\n2. Name and endpoint are auto-filled\n3. Enter your **API Key**\n4. (Optional) Add notes\n5. Click \"Add\"\n\n### Common Presets\n\n#### Claude Presets\n\n| Preset Name | Description |\n|-------------|-------------|\n| Claude Official | Log in with an Anthropic official account |\n| DeepSeek | DeepSeek model |\n| Zhipu GLM | Zhipu AI GLM model |\n| Zhipu GLM en | Zhipu AI (English version) |\n| Bailian | Alibaba Cloud Bailian (Qwen) |\n| Kimi | Moonshot Kimi model |\n| Kimi For Coding | Kimi coding-specific model |\n| StepFun | StepFun model |\n| ModelScope | ModelScope community |\n| KAT-Coder | KAT-Coder model |\n| Longcat | Longcat AI |\n| MiniMax | MiniMax model |\n| MiniMax en | MiniMax (English version) |\n| DouBaoSeed | DouBao Seed model |\n| BaiLing | BaiLing AI |\n| AiHubMix | AiHubMix aggregation service |\n| SiliconFlow | SiliconFlow |\n| SiliconFlow en | SiliconFlow (English version) |\n| DMXAPI | DMXAPI proxy service |\n| PackyCode | PackyCode proxy service |\n| Cubence | Cubence service |\n| AIGoCode | AIGoCode service |\n| RightCode | RightCode service |\n| AICodeMirror | AICodeMirror service |\n| OpenRouter | Aggregation routing service |\n| Nvidia | Nvidia AI service |\n| Xiaomi MiMo | Xiaomi MiMo model |\n\n> The preset list may be updated with new versions. Refer to the actual list shown in the app.\n\n#### Codex Presets\n\n| Preset Name | Description |\n|-------------|-------------|\n| OpenAI Official | Log in with an OpenAI official account |\n| Azure OpenAI | Azure OpenAI service |\n| AiHubMix | AiHubMix aggregation service |\n| DMXAPI | DMXAPI proxy service |\n| PackyCode | PackyCode proxy service |\n| Cubence | Cubence service |\n| AIGoCode | AIGoCode service |\n| RightCode | RightCode service |\n| AICodeMirror | AICodeMirror service |\n| OpenRouter | Aggregation routing service |\n\n#### Gemini Presets\n\n| Preset Name | Description |\n|-------------|-------------|\n| Google Official | Log in with Google OAuth |\n| PackyCode | PackyCode proxy service |\n| Cubence | Cubence service |\n| AIGoCode | AIGoCode service |\n| AICodeMirror | AICodeMirror service |\n| OpenRouter | Aggregation routing service |\n| Custom | Manually configure all parameters |\n\n#### OpenCode Presets\n\n| Preset Name | Description |\n|-------------|-------------|\n| DeepSeek | DeepSeek model |\n| Zhipu GLM | Zhipu AI GLM model |\n| Zhipu GLM en | Zhipu AI (English version) |\n| Bailian | Alibaba Cloud Bailian |\n| Kimi k2.5 | Moonshot Kimi-k2.5 model |\n| Kimi For Coding | Kimi coding-specific model |\n| StepFun | StepFun model |\n| ModelScope | ModelScope community |\n| KAT-Coder | KAT-Coder model |\n| Longcat | Longcat AI |\n| MiniMax | MiniMax model |\n| MiniMax en | MiniMax (English version) |\n| DouBaoSeed | DouBao Seed model |\n| BaiLing | BaiLing AI |\n| Xiaomi MiMo | Xiaomi MiMo model |\n| AiHubMix | AiHubMix aggregation service |\n| DMXAPI | DMXAPI proxy service |\n| OpenRouter | Aggregation routing service |\n| Nvidia | Nvidia AI service |\n| PackyCode | PackyCode proxy service |\n| Cubence | Cubence service |\n| AIGoCode | AIGoCode service |\n| RightCode | RightCode service |\n| AICodeMirror | AICodeMirror service |\n| OpenAI Compatible | OpenAI-compatible interface |\n| Oh My OpenCode | Oh My OpenCode service |\n\n> The preset list is continuously updated. Refer to the actual list shown in the app.\n\n#### OpenClaw Presets\n\n| Preset Name | Description |\n|-------------|-------------|\n| DeepSeek | DeepSeek model |\n| Zhipu GLM | Zhipu AI GLM model |\n| Zhipu GLM en | Zhipu AI (English version) |\n| Qwen Coder | Qwen coding model |\n| Kimi k2.5 | Moonshot Kimi-k2.5 model |\n| Kimi For Coding | Kimi coding-specific model |\n| StepFun | StepFun model |\n| MiniMax | MiniMax model |\n| MiniMax en | MiniMax (English version) |\n| KAT-Coder | KAT-Coder model |\n| Longcat | Longcat AI |\n| DouBaoSeed | DouBao Seed model |\n| BaiLing | BaiLing AI |\n| Xiaomi MiMo | Xiaomi MiMo model |\n| AiHubMix | AiHubMix aggregation service |\n| DMXAPI | DMXAPI proxy service |\n| OpenRouter | Aggregation routing service |\n| ModelScope | ModelScope community |\n| SiliconFlow | SiliconFlow |\n| SiliconFlow en | SiliconFlow (English version) |\n| Nvidia | Nvidia AI service |\n| PackyCode | PackyCode proxy service |\n| Cubence | Cubence service |\n| AIGoCode | AIGoCode service |\n| RightCode | RightCode service |\n| AICodeMirror | AICodeMirror service |\n| AICoding | AICoding service |\n| CrazyRouter | CrazyRouter service |\n| SSSAiCode | SSSAiCode service |\n| AWS Bedrock | AWS Bedrock service |\n| OpenAI Compatible | OpenAI-compatible interface |\n\n## Custom Configuration\n\nAfter selecting the \"Custom\" preset, you need to manually edit the JSON configuration.\n\n### Claude Configuration Format\n\n```json\n{\n  \"env\": {\n    \"ANTHROPIC_API_KEY\": \"your-api-key\",\n    \"ANTHROPIC_BASE_URL\": \"https://api.example.com\"\n  }\n}\n```\n\n| Field | Required | Description |\n|-------|----------|-------------|\n| `ANTHROPIC_API_KEY` | Yes | API key |\n| `ANTHROPIC_BASE_URL` | No | Custom endpoint URL |\n| `ANTHROPIC_AUTH_TOKEN` | No | Alternative authentication method to API_KEY |\n\n### Codex Configuration Format\n\nCodex uses two configuration files:\n\n**1. auth.json** (`~/.codex/auth.json`) - Stores API key:\n\n```json\n{\n  \"OPENAI_API_KEY\": \"your-api-key\"\n}\n```\n\n**2. config.toml** (`~/.codex/config.toml`) - Stores model and endpoint configuration:\n\n```toml\n# Basic configuration\nmodel_provider = \"custom\"\nmodel = \"gpt-5.2\"\nmodel_reasoning_effort = \"high\"\ndisable_response_storage = true\n\n# Custom provider configuration\n[model_providers.custom]\nname = \"custom\"\nbase_url = \"https://api.example.com/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n```\n\n**auth.json field descriptions**:\n\n| Field | Required | Description |\n|-------|----------|-------------|\n| `OPENAI_API_KEY` | Yes | API key |\n\n**config.toml field descriptions**:\n\n| Field | Required | Description |\n|-------|----------|-------------|\n| `model_provider` | Yes | Model provider name (must match `[model_providers.xxx]`) |\n| `model` | Yes | Model to use (e.g., `gpt-5.2`, `gpt-4o`) |\n| `model_reasoning_effort` | No | Reasoning effort: `low` / `medium` / `high` |\n| `disable_response_storage` | No | Whether to disable response storage |\n| `base_url` | Yes | API endpoint URL |\n| `wire_api` | No | API protocol type (usually `responses`) |\n| `requires_openai_auth` | No | Whether to use OpenAI authentication |\n\n\n### Gemini Configuration Format\n\n```json\n{\n  \"env\": {\n    \"GEMINI_API_KEY\": \"your-api-key\",\n    \"GOOGLE_GEMINI_BASE_URL\": \"https://api.example.com\"\n  }\n}\n```\n\n| Field | Required | Description |\n|-------|----------|-------------|\n| `GEMINI_API_KEY` | Yes | API key |\n| `GOOGLE_GEMINI_BASE_URL` | No | Custom endpoint URL |\n| `GEMINI_MODEL` | No | Specify model |\n\n> Authentication type is automatically detected by CC Switch (PackyCode API proxy / Google OAuth / generic API Key), no manual configuration needed.\n\n## Universal Provider\n\nUniversal providers can share configurations across Claude/Codex/Gemini/OpenCode/OpenClaw, suitable for proxy services that support multiple API formats.\n\n### Create a Universal Provider\n\n1. Switch to the \"Universal Provider\" tab\n2. Click \"Add Universal Provider\"\n3. Fill in the common configuration:\n   - Name\n   - API Key\n   - Endpoint URL\n4. Check the apps to sync to (Claude/Codex/Gemini/OpenCode/OpenClaw)\n5. Save\n\n### Sync Mechanism\n\nUniversal providers automatically sync to the selected apps:\n\n- After modifying a universal provider, all linked app configurations are updated\n- After deleting a universal provider, linked app configurations are also deleted\n\n### Save and Sync\n\nWhen editing a universal provider, you can choose:\n\n| Action | Description |\n|--------|-------------|\n| Save | Save configuration only, without immediate sync |\n| Save and Sync | Save configuration and immediately sync to all enabled apps |\n\n### Manual Sync\n\nIf you need to manually trigger a sync:\n\n1. Click the \"Sync\" button on the universal provider card\n2. Confirm the sync operation\n3. Configuration will overwrite the linked provider in each app\n\n## Import Providers\n\nCC Switch supports two ways to import provider configurations:\n\n### Option 1: Deep Link Import\n\nOne-click import via `ccswitch://` protocol links:\n\n1. Click or visit the deep link\n2. CC Switch opens automatically and shows the import confirmation\n3. Preview the configuration information\n4. Click \"Confirm Import\"\n\n**Getting deep links**:\n- Obtain from shared links by others\n- Create using the [online generator tool](https://farion1231.github.io/cc-switch/deplink.html)\n\n### Option 2: Database Backup Import\n\nBatch import from SQL backup files:\n\n1. Open \"Settings > Advanced > Data Management\"\n2. Click \"Select File\"\n3. Select a previously exported `.sql` backup file\n4. Click \"Import\"\n5. Confirm to overwrite existing configuration\n\n**Imported contents**:\n- All provider configurations\n- MCP server configurations\n- Prompt presets\n- Usage logs\n\n> **Note**: Importing will overwrite the existing database. It is recommended to export your current configuration as a backup first. The exported file name format is `cc-switch-export-{timestamp}.sql`.\n\n## Advanced Options\n\n### Custom Icon\n\nClick the icon area to the left of the name to:\n\n- Select a preset icon\n- Customize icon color\n\n### Website Link\n\nEnter the provider's website or console URL for quick access:\n\n- Click the link icon on the provider card to open directly\n- Useful for checking balance, obtaining API keys, etc.\n\n### Notes\n\nAdd notes such as:\n\n- Account purpose (personal/work)\n- Plan information\n- Expiration date\n\nNotes are displayed on the provider card and are searchable.\n\n### Endpoint Speed Test\n\nAfter adding a provider, you can speed-test API endpoints:\n\n1. Click the \"Speed Test\" button on the provider card\n2. Add multiple endpoint URLs in the speed test panel\n3. Click \"Test\" to run the test\n4. Select the endpoint with the lowest latency\n\n**Test results**:\n- Green: Latency < 500ms (Excellent)\n- Yellow: Latency 500-1000ms (Fair)\n- Red: Latency > 1000ms (Slow)\n\n![image-20260108005327817](../../assets/image-20260108005327817.png)\n"
  },
  {
    "path": "docs/user-manual/en/2-providers/2.2-switch.md",
    "content": "# 2.2 Switch Provider\n\n## Switch from Main Interface\n\nIn the provider list, click the \"Enable\" button on the target provider card.\n\n### Switching Flow\n\n1. Click the \"Enable\" button\n2. CC Switch updates the configuration file\n3. The card status changes to \"Currently Active\"\n4. Claude/Gemini take effect immediately, Codex requires a terminal restart\n\n### Status Indicators\n\n| Status | Display | Description |\n|--------|---------|-------------|\n| Currently Active | Blue border + label | Current provider in the configuration file |\n| Proxy Active | Green border | Provider actually in use during proxy mode |\n| Normal | Default style | Inactive provider |\n\n## Quick Switch via System Tray\n\nQuickly switch providers via the system tray without opening the main interface.\n\n### Steps\n\n1. Right-click the CC Switch icon in the system tray\n2. Find the corresponding app (Claude/Codex/Gemini/OpenCode) in the menu\n3. Click the provider name you want to switch to\n4. Switching completes with a brief tray notification\n\n### Tray Menu Structure\n\n![image-20260108004348993](../../assets/image-20260108004348993.png)\n\n## Activation Methods\n\n### Claude Code\n\n**Takes effect immediately after switching**, no restart needed.\n\nClaude Code supports hot reload and automatically detects configuration file changes and reloads.\n\n### Codex\n\nRequires restart after switching:\n- Close the current terminal window\n- Reopen the terminal\n\n### Gemini CLI\n\n**Takes effect immediately after switching**, no restart needed.\n\nGemini CLI re-reads the `.env` file on each request.\n\n## Configuration File Changes\n\nWhen switching providers, CC Switch modifies the following files:\n\n### Claude\n\n```\n~/.claude/settings.json\n```\n\nModified content:\n```json\n{\n  \"env\": {\n    \"ANTHROPIC_API_KEY\": \"new API Key\",\n    \"ANTHROPIC_BASE_URL\": \"new endpoint\"\n  }\n}\n```\n\n### Codex\n\n```\n~/.codex/auth.json\n~/.codex/config.toml (if additional configuration exists)\n```\n\n### Gemini\n\n```\n~/.gemini/.env\n~/.gemini/settings.json\n```\n\n## Handling Switch Failures\n\nIf switching fails, possible reasons:\n\n### Configuration File Is Locked\n\nAnother program is using the configuration file.\n\n**Solution**: Close the running CLI tool and try switching again.\n\n### Insufficient Permissions\n\nNo write permission to the configuration file.\n\n**Solution**: Check the permission settings of the configuration directory.\n\n### Invalid Configuration Format\n\nThe provider's JSON configuration has format errors.\n\n**Solution**: Edit the provider, check and fix the JSON format.\n"
  },
  {
    "path": "docs/user-manual/en/2-providers/2.3-edit.md",
    "content": "# 2.3 Edit Provider\n\n## Open the Edit Panel\n\n1. Find the provider card you want to edit\n2. Hover over the card to reveal action buttons\n3. Click the \"Edit\" button\n\n## Editable Content\n\n### Basic Information\n\n| Field | Description |\n|-------|-------------|\n| Name | Provider display name |\n| Notes | Additional notes |\n| Website Link | Provider website or console URL |\n| Icon | Custom icon and color |\n\n### Icon Customization\n\nCC Switch provides rich icon customization features:\n\n#### Icon Picker\n\n1. Click the icon area to open the icon picker\n2. Use the search box to search icons by name\n3. Click to select the desired icon\n\nThe icon library includes common AI service provider and technology icons, supporting:\n- Fuzzy search by name\n- Icon name tooltips\n- Real-time preview of selected icon\n\n![image-20260108004734882](../../assets/image-20260108004734882.png)\n\n### Configuration\n\nJSON-formatted configuration content, including:\n\n- API Key\n- Endpoint URL\n- Other environment variables\n\n### Editing the Currently Active Provider\n\nWhen editing the currently active provider, a special \"backfill\" mechanism applies:\n\n1. When opening the edit panel, the latest content is read from the live configuration file\n2. If you manually modified the configuration in the CLI tool, those changes are synced back\n3. After saving, modifications are written to the live configuration file\n\nThis ensures CC Switch and CLI tool configurations stay in sync.\n\n## Modify API Key\n\nWhen editing a provider, you can modify the key directly in the **API Key** input field:\n\n1. Click the \"Edit\" button on the provider card\n2. Enter the new key in the \"API Key\" input field\n3. Click \"Save\"\n\n> **Tip**: The API Key input field supports a show/hide toggle. Click the eye icon on the right to view the full key.\n\n## Modify Endpoint URL\n\nWhen editing a provider, you can modify the URL directly in the **Endpoint URL** input field:\n\n1. Click the \"Edit\" button on the provider card\n2. Enter the new URL in the \"Endpoint URL\" input field\n3. Click \"Save\"\n\n### Endpoint URL Format\n\n| Application | Format Example |\n|-------------|----------------|\n| Claude | `https://api.example.com` |\n| Codex | `https://api.example.com/v1` |\n| Gemini | `https://api.example.com` |\n\n## Add Custom Endpoints\n\nProviders can be configured with multiple endpoints for:\n\n- Testing multiple addresses during speed tests\n- Backup endpoints for failover\n\n### Auto-collection\n\nWhen adding a provider, CC Switch automatically extracts endpoint URLs from the configuration.\n\n### Manual Addition\n\nWhen editing a provider, in the \"Endpoint Management\" area you can:\n\n- Add new endpoints\n- Delete existing endpoints\n- Set a default endpoint\n\n## JSON Editor\n\nConfiguration uses JSON format, and the editor provides:\n\n- Syntax highlighting\n- Format validation\n- Error messages\n\n### Common Errors\n\n**Missing quotes**:\n```json\n// Wrong\n{ env: { KEY: \"value\" } }\n\n// Correct\n{ \"env\": { \"KEY\": \"value\" } }\n```\n\n**Trailing comma**:\n```json\n// Wrong\n{ \"env\": { \"KEY\": \"value\", } }\n\n// Correct\n{ \"env\": { \"KEY\": \"value\" } }\n```\n\n**Unclosed brackets**:\n```json\n// Wrong\n{ \"env\": { \"KEY\": \"value\" }\n\n// Correct\n{ \"env\": { \"KEY\": \"value\" } }\n```\n\n## Save and Activate\n\n1. Click the \"Save\" button\n2. If this is the currently active provider, the configuration is immediately written to the live file\n3. Restart the CLI tool for changes to take effect\n\n## Cancel Editing\n\nClick \"Cancel\" or press the `Esc` key to close the edit panel. All modifications will be discarded.\n"
  },
  {
    "path": "docs/user-manual/en/2-providers/2.4-sort-duplicate.md",
    "content": "# 2.4 Sort & Duplicate\n\n## Drag to Reorder\n\nAdjust the display order of providers by dragging.\n\n### Steps\n\n1. Move the mouse to the **≡** drag handle on the left side of the provider card\n2. Hold the left mouse button\n3. Drag up or down to the target position\n4. Release the mouse to complete reordering\n\n### Reorder Uses\n\n- **Prioritize frequently used**: Place frequently used providers at the top of the list\n- **Failover order**: Sorting affects the default order of the failover queue\n\n## Duplicate Provider\n\nQuickly create a copy of a provider, useful for:\n\n- Creating variations based on existing configurations\n- Backing up current configurations\n- Creating test configurations\n\n### Steps\n\n1. Hover over the provider card to reveal action buttons\n2. Click the \"Duplicate\" button\n3. A copy is automatically created with a `copy` name suffix\n4. Edit the copy to modify the configuration\n\n### Duplicated Content\n\nDuplication creates a complete copy, including:\n\n| Content | Duplicated |\n|---------|------------|\n| Name | Yes (with `copy` suffix) |\n| Configuration | Fully duplicated |\n| Notes | Yes |\n| Website Link | Yes |\n| Icon | Yes |\n| Endpoint List | Yes |\n| Sort Position | Inserted below the original provider |\n\n### After Duplication\n\nAfter duplication, you typically need to modify:\n\n1. **Name**: Change to a meaningful name\n2. **API Key**: If using a different account\n3. **Endpoint**: If using a different service\n\n## Delete Provider\n\n### Steps\n\n1. Hover over the provider card to reveal action buttons\n2. Click the \"Delete\" button\n3. Confirm deletion\n\n### Deletion Confirmation\n\nA confirmation dialog appears before deletion, showing:\n\n- Provider name\n- Warning that deletion cannot be undone\n\n### Deletion Restrictions\n\n- **Currently active provider**: Can be deleted, but it is recommended to switch to another provider first\n- **Universal provider**: Deleting will also remove linked app configurations\n\n![image-20260108004946288](../../assets/image-20260108004946288.png)\n"
  },
  {
    "path": "docs/user-manual/en/2-providers/2.5-usage-query.md",
    "content": "# 2.5 Usage Query\n\n## Overview\n\nThe usage query feature allows you to configure custom scripts to query a provider's remaining balance, used amount, and other information in real time.\n\n**Use cases**:\n- Check API account remaining balance\n- Monitor plan usage\n- Multi-plan balance summary display\n\n## Open Configuration\n\n1. Hover over the provider card to reveal action buttons\n2. Click the \"Usage Query\" button (chart icon)\n3. Opens the usage query configuration panel\n\n## Enable Usage Query\n\nAt the top of the configuration panel, enable the \"Enable Usage Query\" toggle.\n\n## Preset Templates\n\nCC Switch provides three preset templates:\n\n### Custom Template\n\nFully customizable request and extraction logic, suitable for special API formats.\n\n### Generic Template\n\nSuitable for most providers with standard API formats:\n\n```javascript\n({\n  request: {\n    url: \"{{baseUrl}}/user/balance\",\n    method: \"GET\",\n    headers: {\n      \"Authorization\": \"Bearer {{apiKey}}\",\n      \"User-Agent\": \"cc-switch/1.0\"\n    }\n  },\n  extractor: function(response) {\n    return {\n      isValid: response.is_active || true,\n      remaining: response.balance,\n      unit: \"USD\"\n    };\n  }\n})\n```\n\n**Configuration parameters**:\n| Parameter | Description |\n|-----------|-------------|\n| API Key | Authentication key (optional, uses provider's key if empty) |\n| Base URL | API base URL (optional, uses provider's endpoint if empty) |\n\n### New API Template\n\nDesigned specifically for New API-type proxy services:\n\n```javascript\n({\n  request: {\n    url: \"{{baseUrl}}/api/user/self\",\n    method: \"GET\",\n    headers: {\n      \"Content-Type\": \"application/json\",\n      \"Authorization\": \"Bearer {{accessToken}}\",\n      \"New-Api-User\": \"{{userId}}\"\n    },\n  },\n  extractor: function (response) {\n    if (response.success && response.data) {\n      return {\n        planName: response.data.group || \"Default Plan\",\n        remaining: response.data.quota / 500000,\n        used: response.data.used_quota / 500000,\n        total: (response.data.quota + response.data.used_quota) / 500000,\n        unit: \"USD\",\n      };\n    }\n    return {\n      isValid: false,\n      invalidMessage: response.message || \"Query failed\"\n    };\n  },\n})\n```\n\n**Configuration parameters**:\n| Parameter | Description |\n|-----------|-------------|\n| Base URL | New API service URL |\n| Access Token | Access token |\n| User ID | User ID |\n\n## General Configuration\n\n### Timeout\n\nRequest timeout in seconds, default 10 seconds.\n\n### Auto Query Interval\n\nInterval for automatically refreshing usage data (minutes):\n- Set to `0` to disable auto query\n- Range: 0-1440 minutes (up to 24 hours)\n- Only effective when the provider is in \"Currently Active\" status\n\n## Extractor Return Format\n\nThe extractor function must return an object containing the following fields:\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `isValid` | boolean | No | Whether the account is valid, defaults to true |\n| `invalidMessage` | string | No | Message when invalid |\n| `remaining` | number | Yes | Remaining balance |\n| `unit` | string | Yes | Unit (e.g., USD, CNY, times) |\n| `planName` | string | No | Plan name (supports multi-plan) |\n| `total` | number | No | Total balance |\n| `used` | number | No | Used amount |\n| `extra` | object | No | Additional information |\n\n## Test Script\n\nAfter configuration, click the \"Test Script\" button to verify:\n\n1. Sends a request to the configured URL\n2. Executes the extractor function\n3. Displays the returned result or error message\n\n## Display\n\nAfter successful configuration, the provider card displays:\n\n- **Single plan**: Directly shows remaining balance\n- **Multi-plan**: Shows plan count, click to expand for details\n\n## Variable Placeholders\n\nThe following placeholders can be used in scripts and are automatically replaced at runtime:\n\n| Placeholder | Description |\n|-------------|-------------|\n| `{{apiKey}}` | Configured API Key |\n| `{{baseUrl}}` | Configured Base URL |\n| `{{accessToken}}` | Configured Access Token (New API) |\n| `{{userId}}` | Configured User ID (New API) |\n\n## Common Provider Configuration Examples\n\n### Troubleshooting\n\n### Query Failed\n\n**Check**:\n1. Is the API Key correct\n2. Is the Base URL correct\n3. Is the network accessible\n4. Is the timeout sufficient\n\n### Empty Response Data\n\n**Check**:\n1. Does the extractor function have a `return` statement\n2. Does the response data structure match the extractor\n3. Use \"Test Script\" to view the raw response\n\n### Format Failed\n\nWhen there is a script syntax error, clicking the \"Format\" button will indicate the error location.\n\n## Notes\n\n- Usage queries consume a small amount of API request quota\n- Set a reasonable auto query interval to avoid frequent requests\n- Sensitive information (API Key, Token) is securely stored locally\n"
  },
  {
    "path": "docs/user-manual/en/3-extensions/3.1-mcp.md",
    "content": "# 3.1 MCP Server Management\n\n## What is MCP\n\nMCP (Model Context Protocol) is a protocol that allows AI tools to access external data sources and tools. Through MCP servers, you can enable AI to:\n\n- Access file systems\n- Make network requests\n- Query databases\n- Call external APIs\n\n## Open the MCP Panel\n\nClick the **MCP** button in the top navigation bar.\n\n## Panel Overview\n\n![image-20260108005723522](../../assets/image-20260108005723522.png)\n\n## Add MCP Server\n\n### Using Preset Templates\n\n1. Click the **+** button in the top-right corner\n2. Select a template from the \"Preset\" dropdown\n3. Modify the configuration as needed\n4. Click \"Save\"\n\n![image-20260108005739731](../../assets/image-20260108005739731.png)\n\n### Common Presets\n\n| Preset | Package Name | Description |\n|--------|-------------|-------------|\n| fetch | mcp-server-fetch | HTTP request tool that enables AI to fetch web content |\n| time | @modelcontextprotocol/server-time | Time tool that provides current time information |\n| memory | @modelcontextprotocol/server-memory | Memory tool that enables AI to store and retrieve information |\n| sequential-thinking | @modelcontextprotocol/server-sequential-thinking | Chain-of-thought tool that enhances AI reasoning |\n| context7 | @upstash/context7-mcp | Documentation search tool for querying technical docs |\n\n### Custom Configuration\n\nAfter selecting \"Custom\", fill in:\n\n| Field | Required | Description |\n|-------|----------|-------------|\n| Server ID | Yes | Unique identifier |\n| Name | No | Display name |\n| Description | No | Function description |\n| Transport Type | Yes | stdio / http / sse |\n| Command | Yes* | Required for stdio type |\n| Arguments | No | Command-line arguments |\n| URL | Yes* | Required for http/sse type |\n| Headers | No | Request headers for http/sse type |\n| Environment Variables | No | Environment variables passed to the server |\n\n## Transport Types\n\n### stdio (Standard I/O)\n\nThe most common type, communicating by launching a local process.\n\n```json\n{\n  \"command\": \"uvx\",\n  \"args\": [\"mcp-server-fetch\"],\n  \"env\": {}\n}\n```\n\n**Requirements**:\n- The corresponding command must be installed (e.g., `uvx`, `npx`)\n- The server program must be in PATH\n\n### http\n\nCommunicates with a remote server via HTTP protocol.\n\n```json\n{\n  \"url\": \"http://localhost:8080/mcp\"\n}\n```\n\n### sse (Server-Sent Events)\n\nCommunicates with a server via SSE protocol, supporting real-time push.\n\n```json\n{\n  \"url\": \"http://localhost:8080/sse\"\n}\n```\n\n## App Binding\n\nEach MCP server can independently control which apps it is enabled for.\n\n### Toggle Description\n\n| Toggle | Effect | Configuration File Path |\n|--------|--------|------------------------|\n| Claude | Sync to Claude Code | `~/.claude.json`'s `mcpServers` |\n| Codex | Sync to Codex | `~/.codex/config.toml`'s `[mcp_servers]` |\n| Gemini | Sync to Gemini CLI | `~/.gemini/settings.json`'s `mcpServers` |\n| OpenCode | Sync to OpenCode | `~/.opencode/config.json`'s `mcpServers` |\n\n> **Note**: OpenClaw does not currently support MCP server management. MCP functionality is currently only supported for Claude, Codex, Gemini, and OpenCode.\n\n### Toggle Implementation\n\nWhen enabling an app's toggle, CC Switch will:\n\n1. **Update database**: Set the server's `apps.claude/codex/gemini/opencode` status to `true`\n2. **Sync to live configuration**: Write the server configuration to the corresponding app's configuration file\n3. **Take effect immediately**: The new MCP server is automatically loaded the next time the CLI tool starts\n\nWhen disabling an app's toggle, CC Switch will:\n\n1. **Update database**: Set the corresponding app status to `false`\n2. **Remove from live configuration**: Delete the server from the app's configuration file\n3. **Take effect immediately**: The MCP server is no longer loaded the next time the CLI tool starts\n\n### Sync Conditions\n\nMCP server sync only executes when the corresponding app is installed:\n\n- **Claude**: Requires `~/.claude/` directory or `~/.claude.json` file to exist\n- **Codex**: Requires `~/.codex/` directory to exist\n- **Gemini**: Requires `~/.gemini/` directory to exist\n- **OpenCode**: Requires `~/.opencode/` directory to exist\n\n> **Tip**: If a CLI tool is not installed, enabling its toggle will not cause an error, but the configuration will not be written.\n\nWhen the toggle is disabled, the configuration is removed from the file.\n\n## Edit Server\n\n1. Click the \"Edit\" button on the right side of the server row\n2. Modify the configuration\n3. Click \"Save\"\n\nChanges are immediately synced to enabled app configuration files.\n\n## Delete Server\n\n1. Click the \"Delete\" button on the right side of the server row\n2. Confirm deletion\n\nAfter deletion, the configuration is removed from all app configuration files.\n\n## Import Existing Configurations\n\nIf you have already configured MCP servers in CLI tools, you can import them into CC Switch:\n\n1. Click the \"Import\" button\n2. Select the app to import from (Claude/Codex/Gemini/OpenCode)\n3. CC Switch reads the existing configuration and imports it\n\n## Configuration File Formats\n\n### Claude (`~/.claude.json`)\n\n```json\n{\n  \"mcpServers\": {\n    \"mcp-fetch\": {\n      \"command\": \"uvx\",\n      \"args\": [\"mcp-server-fetch\"]\n    }\n  }\n}\n```\n\n### Codex (`~/.codex/config.toml`)\n\n```toml\n[mcp_servers.mcp-fetch]\ncommand = \"uvx\"\nargs = [\"mcp-server-fetch\"]\n```\n\n### Gemini (`~/.gemini/settings.json`)\n\n```json\n{\n  \"mcpServers\": {\n    \"mcp-fetch\": {\n      \"command\": \"uvx\",\n      \"args\": [\"mcp-server-fetch\"]\n    }\n  }\n}\n```\n\n## FAQ\n\n### Server Fails to Start\n\nCheck:\n- Is the command properly installed (e.g., `uvx`)\n- Is the command in PATH\n- Are the arguments correct\n\n### Configuration Not Taking Effect\n\nEnsure:\n- The corresponding app toggle is enabled\n- The CLI tool has been restarted\n"
  },
  {
    "path": "docs/user-manual/en/3-extensions/3.2-prompts.md",
    "content": "# 3.2 Prompts Management\n\n## Overview\n\nThe Prompts feature manages system prompt presets. System prompts influence the AI's behavior and response style.\n\nWith CC Switch, you can:\n\n- Create multiple prompt presets\n- Quickly switch prompts for different scenarios\n- Sync prompt configurations across devices\n\n## Open the Prompts Panel\n\nClick the **Prompts** button in the top navigation bar.\n\n## Panel Overview\n\n![image-20260108010110382](../../assets/image-20260108010110382.png)\n\n## Create a Preset\n\n### Steps\n\n1. Click the **+** button in the top-right corner\n2. Enter a preset name\n3. Write the prompt in the Markdown editor\n4. Click \"Save\"\n\n### Markdown Editor\n\nThe editor provides:\n\n- Syntax highlighting\n- Live preview\n- Common format shortcuts\n\n### Prompt Writing Tips\n\n**Structured format**:\n\n```markdown\n# Role Definition\n\nYou are a professional code review expert.\n\n## Core Capabilities\n\n- Code quality analysis\n- Performance optimization suggestions\n- Security vulnerability detection\n\n## Response Style\n\n- Clear and concise\n- Provide specific examples\n- Give improvement suggestions\n\n## Notes\n\n- Do not modify business logic\n- Maintain consistent code style\n```\n\n## Activate a Preset\n\n### How to Activate\n\nClick the toggle switch on the preset item to change its activation status.\n\n### Single Activation\n\nOnly one preset can be active at a time. Activating a new preset automatically deactivates the previous one.\n\n### Sync Target\n\nAfter activation, the prompt is written to the corresponding app's file:\n\n| Application | File Path |\n|-------------|-----------|\n| Claude | `~/.claude/CLAUDE.md` |\n| Codex | `~/.codex/AGENTS.md` |\n| Gemini | `~/.gemini/GEMINI.md` |\n| OpenCode | `~/.opencode/AGENTS.md` |\n| OpenClaw | `~/.openclaw/AGENTS.md` |\n\n## Edit a Preset\n\n1. Click the \"Edit\" button on the preset item\n2. Modify the name or content\n3. Click \"Save\"\n\nIf the currently active preset is edited, changes are immediately synced to the configuration file.\n\n## Delete a Preset\n\n1. Click the \"Delete\" button on the preset item\n2. Confirm deletion\n\nActive presets cannot be deleted. Deactivate the preset first before deleting.\n\n## Smart Backfill\n\nCC Switch provides a smart backfill protection mechanism to ensure your manual modifications are not lost.\n\n### How It Works\n\n1. Before switching presets, automatically reads the current configuration file content\n2. Compares file content with the preset in the database\n3. If the content differs, it means the user has manually modified it\n4. Saves the manually modified content to the current preset\n5. Then switches to the new preset\n\n### Protection Scenarios\n\n| Scenario | Handling |\n|----------|----------|\n| Directly editing `CLAUDE.md` in CLI | Changes auto-saved to current preset |\n| Modifying config file with external editor | Changes auto-saved to current preset |\n| Switching to another preset | Current changes saved first, then switched |\n\n### Technical Details\n\nThe backfill mechanism triggers at these moments:\n\n- **When switching presets**: Saves current live file content to the current preset\n- **When editing the current preset**: Reads latest content from the live file\n- **On first launch**: Automatically imports existing live file content\n\n### Notes\n\n- Backfill only triggers when switching to a different preset\n- If no preset is currently active, backfill is not triggered\n- Backfill failure does not affect the switching process\n\n## Cross-app Usage\n\nPrompts are managed separately per app:\n\n- When switched to Claude, Claude's presets are shown\n- When switched to Codex, Codex's presets are shown\n- When switched to Gemini, Gemini's presets are shown\n- When switched to OpenCode, OpenCode's presets are shown\n- When switched to OpenClaw, OpenClaw's presets are shown\n\nTo use the same prompt across multiple apps, you need to create them separately.\n\n## Import & Export\n\n### Share via Deep Link\n\nYou can generate deep links to share presets:\n\n```\nccswitch://import/prompt?data=<base64-encoded preset>\n```\n\n### Via Configuration Export\n\nExporting configuration includes all presets, which can be restored upon import.\n"
  },
  {
    "path": "docs/user-manual/en/3-extensions/3.3-skills.md",
    "content": "# 3.3 Skills Management\n\n## Overview\n\nSkills are reusable capability extensions that give AI tools specialized abilities in specific domains.\n\nSkills exist as folders containing:\n\n- Prompt templates\n- Tool definitions\n- Example code\n\n## Supported Applications\n\nSkills are supported across all four applications:\n\n- **Claude Code**\n- **Codex**\n- **Gemini CLI**\n- **OpenCode**\n\n## Open the Skills Page\n\nClick the **Skills** button in the top navigation bar.\n\n> Note: The Skills button is visible in all app modes.\n\n## Page Overview\n\n![image-20260108010253926](../../assets/image-20260108010253926.png)\n\n## Discover Skills\n\n### Pre-configured Repositories\n\nCC Switch comes pre-configured with the following GitHub repositories:\n\n| Repository | Description |\n|------------|-------------|\n| Anthropic Official | Official skills provided by Anthropic |\n| ComposioHQ | Community-maintained skill collection |\n| Community Picks | Curated high-quality skills |\n\n![image-20260108010308060](../../assets/image-20260108010308060.png)\n\n### Search & Filter\n\nCC Switch provides powerful search and filter features:\n\n#### Search Box\n\n- Search by skill name\n- Search by skill description\n- Search by directory name\n- Real-time filtering, results update as you type\n\n#### Status Filter\n\nUse the dropdown menu to filter by installation status:\n\n| Option | Description |\n|--------|-------------|\n| All | Show all skills |\n| Installed | Show only installed skills |\n| Not Installed | Show only uninstalled skills |\n\n![image-20260108010324583](../../assets/image-20260108010324583.png)\n\n#### Combined Use\n\nSearch and filter can be combined:\n\n- Select \"Installed\" filter first\n- Then enter keywords to search\n- Results show the match count\n\n### Refresh List\n\nClick the \"Refresh\" button to re-scan repositories for the latest skills.\n\n## Install Skills\n\n### Steps\n\n1. Find the skill card you want to install\n2. Click the \"Install\" button\n3. Wait for installation to complete\n\n### Installation Location\n\n| Application | Install Directory |\n|-------------|-------------------|\n| Claude | `~/.claude/skills/` |\n| Codex | `~/.codex/skills/` |\n| Gemini | `~/.gemini/skills/` |\n| OpenCode | `~/.opencode/skills/` |\n\n### Installation Contents\n\nInstallation copies the skill folder to your local machine:\n\n```\n~/.claude/skills/\n└── skill-name/\n    ├── README.md\n    ├── prompt.md\n    └── tools/\n        └── ...\n```\n\n## Uninstall Skills\n\n### Steps\n\n1. Find the installed skill card\n2. Click the \"Uninstall\" button\n3. Confirm uninstallation\n\n### Uninstall Effect\n\n- Deletes the local skill folder\n- Updates installation status\n\n## Repository Management\n\n### Open Repository Management\n\nClick the \"Repository Management\" button at the top of the page.\n\n### Add Custom Repository\n\n1. Click \"Add Repository\"\n2. Fill in repository information:\n   - Owner: GitHub username or organization name\n   - Name: Repository name\n   - Branch: Branch name (default: main)\n   - Subdirectory: Subdirectory containing skills (optional)\n3. Click \"Add\"\n\n### Repository Format\n\n```\nhttps://github.com/{owner}/{name}/tree/{branch}/{subdirectory}\n```\n\nExample:\n\n```\nOwner: anthropics\nName: claude-skills\nBranch: main\nSubdirectory: skills\n```\n\n### Delete Repository\n\n1. Find the repository in the repository list\n2. Click the \"Delete\" button\n3. Confirm deletion\n\nAfter deleting a repository, its skills will not disappear from the list, but they can no longer be updated.\n\n## Skill Card Information\n\nEach skill card displays:\n\n| Information | Description |\n|-------------|-------------|\n| Name | Skill name |\n| Description | Function description |\n| Source | Source repository |\n| Status | Installed / Not Installed |\n\n## Skill Updates\n\nAutomatic updates are not currently supported. To update a skill:\n\n1. Uninstall the existing skill\n2. Refresh the list\n3. Reinstall\n\n### Empty Skill List\n\nPossible causes:\n\n- Network issues preventing GitHub access\n- Incorrect repository configuration\n\nSolutions:\n\n- Check network connection\n- Click \"Refresh\" to retry\n- Verify repository configuration\n\n### Installation Failed\n\nPossible causes:\n\n- Network issues\n- Insufficient disk space\n- Permission issues\n\nSolutions:\n\n- Check network connection\n- Check disk space\n- Check directory permissions\n"
  },
  {
    "path": "docs/user-manual/en/4-proxy/4.1-service.md",
    "content": "# 4.1 Proxy Service\n\n## Overview\n\nThe proxy service starts a local HTTP proxy through which all API requests are forwarded.\n\n**Primary uses**:\n- Record request logs\n- Track API usage\n- Support failover\n- Centrally manage requests from multiple applications\n\n## Start the Proxy\n\n### Option 1: Main Interface Toggle\n\nClick the **Proxy Toggle** button at the top of the main interface.\n\nToggle states:\n- White: Proxy not running\n- Green: Proxy running\n\n![image-20260108011353927](../../assets/image-20260108011353927.png)\n\n### Option 2: Settings Page\n\n1. Open \"Settings > Advanced > Proxy Service\"\n2. Click the toggle in the top-right corner\n\n![image-20260108011338922](../../assets/image-20260108011338922.png)\n\n## Proxy Configuration\n\n### Basic Configuration\n\n| Setting | Description | Default |\n|---------|-------------|---------|\n| Listen Address | IP address the proxy binds to | `127.0.0.1` |\n| Listen Port | Port the proxy listens on | `15721` |\n| Enable Logging | Whether to record request logs | Enabled |\n\n### Modify Configuration\n\n1. **Stop the proxy service** (must stop first)\n2. Modify the listen address or port\n3. Click \"Save\"\n4. Restart the proxy\n\n> Modifying address/port requires stopping the proxy service first\n\n### Listen Address Options\n\n| Address | Description |\n|---------|-------------|\n| `127.0.0.1` | Only accessible from local machine (recommended) |\n| `0.0.0.0` | Allow LAN access |\n\n## Running Status\n\nWhen the proxy is running, the panel displays the following information:\n\n### Service Address\n\n```\nhttp://127.0.0.1:15721\n```\n\nClick the \"Copy\" button to copy the address.\n\n### Current Providers\n\nDisplays the currently used provider for each app:\n\n```\nClaude: PackyCode\nCodex: AIGoCode\nGemini: Google Official\n```\n\n### Statistics\n\n| Metric | Description |\n|--------|-------------|\n| Active Connections | Number of requests currently being processed |\n| Total Requests | Total number of requests since startup |\n| Success Rate | Percentage of successful requests (>90% green, <=90% yellow) |\n| Uptime | How long the proxy has been running |\n\n### Failover Queue\n\nThe proxy panel displays the failover queue by app type:\n\n```\nClaude\n├── 1. PackyCode      [Currently Using] ●\n├── 2. AIGoCode                          ●\n└── 3. Backup Provider                   ○\n\nCodex\n├── 1. AIGoCode       [Currently Using] ●\n└── 2. Backup Provider                   ●\n```\n\nQueue details:\n- Numbers indicate priority order\n- \"Currently Using\" label indicates the active provider\n- Health badges show provider status:\n  - Green: Healthy (0 consecutive failures)\n  - Yellow: Degraded (1-2 consecutive failures)\n  - Red: Unhealthy (3+ consecutive failures)\n\n## How It Works\n\n### Request Flow\n\n```mermaid\nsequenceDiagram\n    participant CLI as CLI Tool (Claude)\n    participant Proxy as Local Proxy (CC Switch)\n    participant API as API Provider (Anthropic)\n    participant DB as Data Store (Logger)\n\n    CLI->>Proxy: Send API request\n    Proxy->>DB: Record request log / track usage\n    Proxy->>API: Forward request\n    API-->>Proxy: Return response\n    Proxy-->>CLI: Return response\n```\n\n### Configuration Changes\n\nAfter starting the proxy and enabling app takeover, CC Switch modifies app configurations:\n\n**Claude**:\n```json\n{\n  \"env\": {\n    \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\"\n  }\n}\n```\n\n**Codex**:\n```toml\nbase_url = \"http://127.0.0.1:15721/v1\"\n```\n\n**Gemini**:\n```\nGOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15721\n```\n\n## Stop the Proxy\n\n### Option 1: Main Interface Toggle\n\nClick the proxy toggle button to turn it off.\n\n### Option 2: Settings Page\n\nTurn off the toggle in the proxy service panel.\n\n### Post-stop Processing\n\nWhen stopping the proxy, CC Switch will:\n\n1. Restore app configurations to their original state\n2. Save request logs\n3. Close all connections\n\n## Log Recording\n\n### Enable Logging\n\nEnable the \"Enable Logging\" toggle in the proxy panel.\n\n### Log Contents\n\nEach request record includes:\n\n| Field | Description |\n|-------|-------------|\n| Time | Request time |\n| App | Claude / Codex / Gemini |\n| Provider | Provider used |\n| Model | Requested model |\n| Tokens | Input/output token count |\n| Latency | Request duration |\n| Status | Success/failure |\n\n### View Logs\n\nView request logs in the \"Settings > Usage\" tab.\n\n## FAQ\n\n### Port Already in Use\n\nError message: `Address already in use`\n\nSolution:\n1. Change the port (e.g., to 5001)\n2. Or close the program occupying the port\n\n### Proxy Fails to Start\n\nCheck:\n- Is the port occupied\n- Are there sufficient permissions\n- Is the firewall blocking it\n\n### Request Timeout\n\nPossible causes:\n- Network issues\n- Provider server issues\n- Incorrect proxy configuration\n\nSolutions:\n- Check network connection\n- Try accessing the provider API directly\n- Check provider configuration\n"
  },
  {
    "path": "docs/user-manual/en/4-proxy/4.2-takeover.md",
    "content": "# 4.2 App Takeover\n\n## Overview\n\nApp takeover means letting CC Switch's proxy intercept and forward a specific application's API requests.\n\nWhen takeover is enabled:\n- The app's API requests are forwarded through the local proxy\n- Request logs and usage statistics can be recorded\n- Failover functionality becomes available\n\n## Prerequisites\n\nThe proxy service must be started before using the app takeover feature.\n\n## Enable Takeover\n\n### Location\n\nSettings > Advanced > Proxy Service > App Takeover area\n\n### Steps\n\n1. Ensure the proxy service is started\n2. Find the \"App Takeover\" area\n3. Enable the toggle for the desired apps\n\n### Takeover Toggles\n\n| Toggle | Effect |\n|--------|--------|\n| Claude Takeover | Intercept Claude Code requests |\n| Codex Takeover | Intercept Codex requests |\n| Gemini Takeover | Intercept Gemini CLI requests |\n\nMultiple app takeovers can be enabled simultaneously.\n\n## How Takeover Works\n\n### Configuration Changes\n\nWhen takeover is enabled, CC Switch modifies the app's configuration file to point the API endpoint to the local proxy.\n\n**Claude configuration change**:\n\n```json\n// Before takeover\n{\n  \"env\": {\n    \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\"\n  }\n}\n\n// After takeover\n{\n  \"env\": {\n    \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\"\n  }\n}\n```\n\n**Codex configuration change**:\n\n```toml\n# Before takeover\nbase_url = \"https://api.openai.com/v1\"\n\n# After takeover\nbase_url = \"http://127.0.0.1:15721/v1\"\n```\n\n**Gemini configuration change**:\n\n```bash\n# Before takeover\nGOOGLE_GEMINI_BASE_URL=https://generativelanguage.googleapis.com\n\n# After takeover\nGOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15721\n```\n\n### Request Forwarding\n\nWhen the proxy receives a request:\n\n1. Identifies the request source (Claude/Codex/Gemini)\n2. Looks up the currently enabled provider for that app\n3. Forwards the request to the provider's actual endpoint\n4. Records the request log\n5. Returns the response to the app\n\n## Takeover Status Indicators\n\n### Main Interface Indicators\n\nWhen takeover is enabled, the main interface shows the following changes:\n\n- **Proxy logo color**: Changes from colorless to green\n- **Provider cards**: The currently active provider shows a green border\n\n### Provider Card States\n\n| State | Border Color | Description |\n|-------|--------------|-------------|\n| Currently Active | Blue | Provider in the config file (non-proxy mode) |\n| Proxy Active | Green | Provider actually used by the proxy |\n| Normal | Default | Unused provider |\n\n## Disable Takeover\n\n### Steps\n\n1. Turn off the corresponding app's takeover toggle in the proxy panel\n2. Or directly stop the proxy service\n\n### Configuration Restoration\n\nWhen disabling takeover, CC Switch will:\n\n1. Restore the app configuration to its pre-takeover state\n2. Save current request logs\n\n## Takeover and Provider Switching\n\n### Switching Providers in Takeover Mode\n\nWhen switching providers in takeover mode:\n\n1. Click the \"Enable\" button on a provider in the main interface\n2. The proxy immediately uses the new provider to forward requests\n3. **No need to restart the CLI tool**\n\nThis is a major advantage of takeover mode: provider switching takes effect instantly.\n\n### Switching Without Takeover\n\nWhen switching providers without takeover:\n\n1. Configuration file is modified\n2. CLI tool must be restarted for changes to take effect\n\n## Multi-app Takeover\n\nMultiple apps can be taken over simultaneously, each managed independently:\n\n- Independent provider configurations\n- Independent failover queues\n- Independent request statistics\n\n## Use Cases\n\n### Scenario 1: Usage Monitoring\n\nEnable takeover + log recording to monitor API usage.\n\n### Scenario 2: Quick Switching\n\nWith takeover enabled, switching providers does not require restarting CLI tools.\n\n### Scenario 3: Failover\n\nEnabling takeover is a prerequisite for using the failover feature.\n\n## Notes\n\n### Performance Impact\n\nThe proxy adds minimal latency (typically < 10ms), negligible for most scenarios.\n\n### Network Requirements\n\nIn takeover mode, CLI tools must be able to access the local proxy address.\n\n### Configuration Backup\n\nBefore enabling takeover, CC Switch backs up the original configuration and restores it when disabled.\n\n## FAQ\n\n### Requests Fail After Takeover\n\nCheck:\n- Is the proxy service running normally\n- Is the provider configuration correct\n- Is the network working properly\n\n### Configuration Not Restored After Disabling Takeover\n\nPossible causes:\n- Proxy exited abnormally\n- Configuration file was modified by another program\n\nSolutions:\n- Manually edit the provider and re-save\n- Or re-enable and then disable takeover\n"
  },
  {
    "path": "docs/user-manual/en/4-proxy/4.3-failover.md",
    "content": "# 4.3 Failover\n\n## Overview\n\nThe failover feature automatically switches to a backup provider when the primary provider's request fails, ensuring uninterrupted service.\n\n**Applicable scenarios**:\n- Unstable provider services\n- High availability requirements\n- Long-running tasks\n\n## Prerequisites\n\nUsing the failover feature requires:\n\n1. Proxy service started\n2. App takeover enabled\n3. Failover queue configured\n4. Auto failover enabled\n\n## Configure the Failover Queue\n\n### Open Configuration Page\n\nSettings > Advanced > Failover\n\n### Select Application\n\nThree tabs at the top of the page:\n- Claude\n- Codex\n- Gemini\n\nSelect the application to configure.\n\n### Add Backup Providers\n\n1. In the \"Failover Queue\" area\n2. Click \"Add Provider\"\n3. Select a provider from the dropdown list\n4. The provider is added to the end of the queue\n\n### Adjust Priority\n\nDrag providers to adjust their order:\n- Lower numbers mean higher priority\n- After the primary provider fails, backup providers are tried in order\n\n### Remove Provider\n\nClick the \"Remove\" button to the right of the provider.\n\n## Main Interface Quick Actions\n\nWhen both proxy and failover are enabled, provider cards display a failover toggle.\n\n### Add to Queue\n\n1. Find the provider card\n2. Enable the failover toggle\n3. The provider is automatically added to the queue\n\n### Remove from Queue\n\n1. Disable the failover toggle on the provider card\n2. The provider is removed from the queue\n\n## Enable Auto Failover\n\n### Steps\n\n1. On the failover configuration page\n2. Enable the \"Auto Failover\" toggle\n\n### Toggle Description\n\n| State | Behavior |\n|-------|----------|\n| Off | Only records failures, no automatic switching |\n| On | Automatically switches to the next provider on failure |\n\n## Failover Flow\n\n```mermaid\ngraph TD\n    Start[Request arrives at proxy] --> Send[Send to current provider]\n    Send --> CheckSuccess{Success?}\n    CheckSuccess -- Yes --> Return[Return response]\n    CheckSuccess -- No --> LogFail[Record failure]\n    LogFail --> CheckCircuit{Check circuit breaker}\n    CheckCircuit -- Tripped --> Skip[Skip this provider]\n    CheckCircuit -- Not tripped --> IncFail[Increment failure count]\n    Skip --> Next{Next in queue?}\n    IncFail --> Next\n    Next -- Yes --> Switch[Switch provider]\n    Switch --> Retry[Retry request]\n    Retry --> Send\n    Next -- No --> Error[Return error]\n```\n\n## Circuit Breaker Configuration\n\nThe circuit breaker prevents frequent retries against failing providers.\n\n### Configuration Items\n\nDifferent apps have independent default configurations. Below are general defaults; Claude has its own relaxed configuration.\n\n| Setting | Description | General Default | Claude Default | Range |\n|---------|-------------|-----------------|----------------|-------|\n| Failure Threshold | Consecutive failures to trigger circuit breaker | 4 | 8 | 1-20 |\n| Recovery Success Threshold | Successes needed in half-open state to close breaker | 2 | 3 | 1-10 |\n| Recovery Wait Time | Time before attempting recovery after tripping (seconds) | 60 | 90 | 0-300 |\n| Error Rate Threshold | Error rate that opens the circuit breaker | 60% | 70% | 0-100% |\n| Minimum Requests | Minimum requests before calculating error rate | 10 | 15 | 5-100 |\n\n> Claude has more relaxed default settings due to longer request times, tolerating more failures.\n\n### Timeout Configuration\n\n| Setting | Description | General Default | Claude Default | Range |\n|---------|-------------|-----------------|----------------|-------|\n| Stream First Byte Timeout | Max wait time for first data chunk (seconds) | 60 | 90 | 1-120 |\n| Stream Idle Timeout | Max interval between data chunks (seconds) | 120 | 180 | 60-600 (0 to disable) |\n| Non-stream Timeout | Total timeout for non-streaming requests (seconds) | 600 | 600 | 60-1200 |\n\n### Retry Configuration\n\n| Setting | Description | General Default | Claude Default | Range |\n|---------|-------------|-----------------|----------------|-------|\n| Max Retries | Number of retries on request failure | 3 | 6 | 0-10 |\n\n> Gemini's default max retries is 5.\n\n### Circuit Breaker States\n\n| State | Description |\n|-------|-------------|\n| Closed | Normal state, requests allowed |\n| Open | Circuit broken, this provider is skipped |\n| Half-Open | Attempting recovery, sending probe requests |\n\n### State Transitions\n\n```mermaid\nstateDiagram-v2\n    [*] --> Closed: Initialize\n    Closed --> Open: Failures >= threshold\n    Open --> HalfOpen: Recovery wait time expires\n    HalfOpen --> Closed: Probe successes >= recovery threshold\n    HalfOpen --> Open: Probe failed\n```\n\n## Health Status Indicators\n\n### Provider Cards\n\nCards display health status badges:\n\n| Badge | Status | Description |\n|-------|--------|-------------|\n| Green | Healthy | 0 consecutive failures |\n| Yellow | Warning | Has failures but circuit not tripped |\n| Red | Circuit Broken | Circuit breaker tripped, temporarily skipped |\n\n### Queue List\n\nThe failover queue also displays each provider's health status.\n\n## Failover Logs\n\nEach failover event records:\n\n| Information | Description |\n|-------------|-------------|\n| Time | When it occurred |\n| Original Provider | The provider that failed |\n| New Provider | The provider switched to |\n| Failure Reason | Error message |\n\nViewable in the request logs within usage statistics.\n\n## Best Practices\n\n### Queue Configuration Recommendations\n\n1. **Primary provider**: The most stable and fastest provider\n2. **First backup**: Second-best choice\n3. **Second backup**: Last resort\n\n### Circuit Breaker Configuration Recommendations\n\n| Scenario | Failure Threshold | Recovery Wait |\n|----------|-------------------|---------------|\n| High availability requirement | 2 | 30 seconds |\n| General scenario | 3 | 60 seconds |\n| Tolerant of occasional failures | 5 | 120 seconds |\n\n### Monitoring Recommendations\n\nPeriodically check:\n- Health status of each provider\n- Failover frequency\n- Circuit breaker trigger frequency\n\n## FAQ\n\n### Failover Not Triggering\n\nCheck:\n1. Is the proxy service running\n2. Is app takeover enabled\n3. Is auto failover enabled\n4. Are there backup providers in the queue\n\n### Failover Triggering Too Frequently\n\nPossible causes:\n- Unstable primary provider\n- Network issues\n- Configuration errors\n\nSolutions:\n- Check primary provider status\n- Adjust circuit breaker parameters\n- Consider changing the primary provider\n\n### All Providers Circuit-Broken\n\nWait for the recovery wait time to expire for automatic recovery, or:\n1. Manually restart the proxy service\n2. Reset circuit breaker states\n"
  },
  {
    "path": "docs/user-manual/en/4-proxy/4.4-usage.md",
    "content": "# 4.4 Usage Statistics\n\n## Overview\n\nThe usage statistics feature records and analyzes API request data, helping you:\n\n- Understand API usage patterns\n- Estimate cost expenditure\n- Analyze usage patterns\n- Troubleshoot issues\n\n## Prerequisites\n\nUsing the usage statistics feature requires:\n\n1. Proxy service started\n2. App takeover enabled\n3. Log recording enabled\n\n## Open Usage Statistics\n\nSettings > Usage Tab\n\n## Statistics Overview\n\n### Summary Cards\n\nKey metrics displayed at the top of the page:\n\n| Metric | Description |\n|--------|-------------|\n| Total Requests | Total number of requests in the time period |\n| Total Tokens | Total input + output tokens |\n| Estimated Cost | Cost calculated based on pricing configuration |\n| Success Rate | Percentage of successful requests |\n\n### Time Range\n\nSelect the time range for statistics:\n\n| Option | Range |\n|--------|-------|\n| Today | From 00:00 today to now |\n| Last 7 Days | Past 7 days |\n| Last 30 Days | Past 30 days |\n\n![image-20260108011730105](../../assets/image-20260108011730105.png)\n\n## Trend Charts\n\n### Request Trend\n\nLine chart showing the trend of request counts:\n\n- X-axis: Time\n- Y-axis: Request count\n- Viewable by hour/day\n- Supports zoom and drag\n\n### Token Trend\n\nShows token usage trends:\n\n- Input Tokens (blue) - Prompt content sent by the user\n- Output Tokens (green) - Response content generated by AI\n- Cache Creation Tokens (orange) - Tokens consumed when first creating cache\n- Cache Hit Tokens (purple) - Tokens saved by reusing cache\n- Cost (red dashed line, right Y-axis) - Estimated cost\n\n> **Cache Token explanation**: Anthropic API supports Prompt Caching. Creating cache incurs a higher fee (typically 1.25x input price), but subsequent cache hits only charge 0.1x, significantly reducing costs for repeated requests.\n\n### Time Granularity\n\n- **Today**: Displayed by hour (24 data points)\n- **7 Days/30 Days**: Displayed by day\n\n\n\n![image-20260108011742847](../../assets/image-20260108011742847.png)\n\n## Detailed Data\n\nThree data tabs at the bottom of the page:\n\n### Request Logs\n\nDetailed record of each request:\n\n| Field | Description |\n|-------|-------------|\n| Time | Request time |\n| Provider | Provider name used |\n| Model | Requested model (billing model) |\n| Input Tokens | Number of input tokens |\n| Output Tokens | Number of output tokens |\n| Cache Read | Cache hit token count |\n| Cache Creation | Cache creation token count |\n| Total Cost | Estimated cost (USD) |\n| Timing Info | Request duration, time to first token, streaming/non-streaming |\n| Status | HTTP status code |\n\n#### Timing Information\n\nThe timing info column displays multiple badges:\n\n| Badge | Description | Color Rules |\n|-------|-------------|-------------|\n| Total Duration | Total request time (seconds) | <=5s green, <=120s orange, >120s red |\n| First Token | Time to first token in streaming requests | <=5s green, <=120s orange, >120s red |\n| Stream/Non-stream | Request type | Streaming blue, non-streaming purple |\n\n#### View Details\n\nClick a request row to view detailed information:\n\n- Complete request parameters\n- Response content summary\n- Error messages (if failed)\n\n#### Filter Logs\n\nSupports filtering by the following criteria:\n\n| Filter | Options |\n|--------|---------|\n| App Type | All / Claude / Codex / Gemini |\n| Status Code | All / 200 / 400 / 401 / 429 / 500 |\n| Provider | Text search |\n| Model | Text search |\n| Time Range | Start time - End time (datetime picker) |\n\nAction buttons:\n- **Search**: Apply filter criteria\n- **Reset**: Restore defaults (past 24 hours)\n- **Refresh**: Reload data\n\n![image-20260108011859974](../../assets/image-20260108011859974.png)\n\n### Provider Statistics\n\nStatistics grouped by provider:\n\n| Field | Description |\n|-------|-------------|\n| Provider | Provider name |\n| Requests | Total requests for this provider |\n| Successes | Number of successful requests |\n| Failures | Number of failed requests |\n| Success Rate | Success percentage |\n| Total Tokens | Total token usage |\n| Estimated Cost | Cost for this provider |\n\n![image-20260108011907928](../../assets/image-20260108011907928.png)\n\n### Model Statistics\n\nStatistics grouped by model:\n\n| Field | Description |\n|-------|-------------|\n| Model | Model name |\n| Requests | Total requests for this model |\n| Input Tokens | Total input tokens |\n| Output Tokens | Total output tokens |\n| Avg Latency | Average response time |\n| Estimated Cost | Cost for this model |\n\n![image-20260108011915381](../../assets/image-20260108011915381.png)\n\n## Pricing Configuration\n\n### Open Pricing Configuration\n\nSettings > Advanced > Pricing Configuration\n\n### Configure Model Prices\n\nSet prices for each model (per million tokens):\n\n| Field | Description |\n|-------|-------------|\n| Model ID | Model identifier (e.g., claude-3-sonnet) |\n| Display Name | Custom display name |\n| Input Price | Price per million input tokens |\n| Output Price | Price per million output tokens |\n| Cache Read Price | Price per million cache hit tokens |\n| Cache Creation Price | Price per million cache creation tokens |\n\n### Operations\n\n- **Add**: Click the \"Add\" button to add model pricing\n- **Edit**: Click the edit icon at the end of the row to modify\n- **Delete**: Click the delete icon at the end of the row to remove\n\n![image-20260108011933565](../../assets/image-20260108011933565.png)\n\n### Preset Prices\n\nCC Switch includes preset official prices for common models (per million tokens):\n\n**Claude Series (USD)**:\n\n| Model | Input | Output | Cache Read | Cache Creation |\n|-------|-------|--------|------------|----------------|\n| **Claude 4.5 Series** | | | | |\n| claude-opus-4-5 | $5 | $25 | $0.50 | $6.25 |\n| claude-sonnet-4-5 | $3 | $15 | $0.30 | $3.75 |\n| claude-haiku-4-5 | $1 | $5 | $0.10 | $1.25 |\n| **Claude 4 Series** | | | | |\n| claude-opus-4 | $15 | $75 | $1.50 | $18.75 |\n| claude-opus-4-1 | $15 | $75 | $1.50 | $18.75 |\n| claude-sonnet-4 | $3 | $15 | $0.30 | $3.75 |\n| **Claude 3.5 Series** | | | | |\n| claude-3-5-sonnet | $3 | $15 | $0.30 | $3.75 |\n| claude-3-5-haiku | $0.80 | $4 | $0.08 | $1.00 |\n\n**OpenAI Series / Codex (USD)**:\n\n| Model | Input | Output | Cache Read |\n|-------|-------|--------|------------|\n| **GPT-5.2 Series** | | | |\n| gpt-5.2 | $1.75 | $14 | $0.175 |\n| **GPT-5.1 Series** | | | |\n| gpt-5.1 | $1.25 | $10 | $0.125 |\n| **GPT-5 Series** | | | |\n| gpt-5 | $1.25 | $10 | $0.125 |\n\n> Note: Codex presets include low/medium/high variants with prices identical to the base model.\n\n**Gemini Series (USD)**:\n\n| Model | Input | Output | Cache Read |\n|-------|-------|--------|------------|\n| **Gemini 3 Series** | | | |\n| gemini-3-pro-preview | $2 | $12 | $0.20 |\n| gemini-3-flash-preview | $0.50 | $3 | $0.05 |\n| **Gemini 2.5 Series** | | | |\n| gemini-2.5-pro | $1.25 | $10 | $0.125 |\n| gemini-2.5-flash | $0.30 | $2.50 | $0.03 |\n\n**Chinese Provider Models**:\n\n> Note: Currency follows each provider's official pricing page. StepFun is currently listed in USD.\n\n| Model | Input | Output | Cache Read |\n|-------|-------|--------|------------|\n| **StepFun** | | | |\n| step-3.5-flash | $0.10 | $0.30 | $0.02 |\n| **DeepSeek** | | | |\n| deepseek-v3.2 | ¥2.00 | ¥3.00 | ¥0.40 |\n| deepseek-v3.1 | ¥4.00 | ¥12.00 | ¥0.80 |\n| deepseek-v3 | ¥2.00 | ¥8.00 | ¥0.40 |\n| **Kimi (Moonshot)** | | | |\n| kimi-k2-thinking | ¥4.00 | ¥16.00 | ¥1.00 |\n| kimi-k2 | ¥4.00 | ¥16.00 | ¥1.00 |\n| kimi-k2-turbo | ¥8.00 | ¥58.00 | ¥1.00 |\n| **MiniMax** | | | |\n| minimax-m2.1 | ¥2.10 | ¥8.40 | ¥0.21 |\n| minimax-m2.1-lightning | ¥2.10 | ¥16.80 | ¥0.21 |\n| **Others** | | | |\n| glm-4.7 | ¥2.00 | ¥8.00 | ¥0.40 |\n| doubao-seed-code | ¥1.20 | ¥8.00 | ¥0.24 |\n| mimo-v2-flash | Free | Free | - |\n\n### Custom Prices\n\nIf using proxy services, prices may differ:\n\n1. Click the \"Edit\" button\n2. Modify prices\n3. Save\n\n## FAQ\n\n### Statistics Data Is Empty\n\nCheck:\n- Is the proxy service running\n- Is app takeover enabled\n- Is log recording enabled\n- Have requests been going through the proxy\n\n### Cost Estimates Are Inaccurate\n\nPossible causes:\n- Pricing configuration doesn't match actual prices\n- Using a proxy service with special pricing\n\nSolutions:\n- Update pricing configuration\n- Refer to the provider's actual invoices\n\n### Token Count Differs from Provider\n\nCC Switch uses its own method to estimate token counts, which may slightly differ from the provider's calculation. Refer to the provider's invoice for authoritative numbers.\n"
  },
  {
    "path": "docs/user-manual/en/4-proxy/4.5-model-test.md",
    "content": "# 4.5 Model Test\n\n## Overview\n\nThe model test feature verifies whether a provider's configured model is available by sending actual API requests to test:\n\n- Whether the model exists\n- Whether the API Key is valid\n- Whether the endpoint responds normally\n- Whether the response latency is acceptable\n\n## Open Configuration\n\nSettings > Advanced > Model Test Config\n\n## Test Model Configuration\n\nConfigure the model used for testing per application:\n\n| Application | Setting | Default | Notes |\n|-------------|---------|---------|-------|\n| Claude | Claude Model | System default | Recommend using Haiku series (low cost, fast) |\n| Codex | Codex Model | System default | Recommend using mini series |\n| Gemini | Gemini Model | System default | Recommend using Flash series |\n\n### Model Selection Tips\n\nWhen choosing a test model, consider:\n\n1. **Cost**: Choose lower-priced models (e.g., Haiku, Mini, Flash)\n2. **Speed**: Choose fast-responding models\n3. **Availability**: Choose models supported by the provider\n\n## Test Parameter Configuration\n\n### Timeout\n\n| Parameter | Description | Default | Range |\n|-----------|-------------|---------|-------|\n| Timeout | Single request timeout | 45 seconds | 10-120 seconds |\n\nSetting it too short may cause false negatives; too long delays fault detection.\n\n### Retries\n\n| Parameter | Description | Default | Range |\n|-----------|-------------|---------|-------|\n| Max Retries | Retries after failure | 2 times | 0-5 times |\n\nIncrease retries when the network is unstable.\n\n### Degradation Threshold\n\n| Parameter | Description | Default | Range |\n|-----------|-------------|---------|-------|\n| Degradation Threshold | Responses exceeding this time are marked as degraded | 6000ms | 1000-30000ms |\n\nProviders exceeding the threshold are marked as \"degraded\" but remain usable.\n\n## Execute Model Test\n\n### Manual Test\n\nClick the \"Test\" button on the provider card:\n\n1. Sends a test request to the configured endpoint\n2. Uses the configured test model\n3. Waits for response or timeout\n4. Displays the test result\n\n### Test Content\n\nThe test request:\n- Sends a short prompt (e.g., \"Hi\")\n- Limits maximum output tokens (typically 10-50)\n- Uses streaming response to detect time to first byte\n\n## Test Results\n\n### Health Status\n\n| Status | Icon | Description |\n|--------|------|-------------|\n| Healthy | Green | Normal response, latency within threshold |\n| Degraded | Yellow | Normal response, but latency exceeds threshold |\n| Unavailable | Red | Request failed or timed out |\n\n### Result Information\n\nAfter testing completes, displays:\n- Response latency (milliseconds)\n- Time to first byte (TTFB)\n- Error message (if failed)\n\n## Integration with Failover\n\nModel testing works in conjunction with the failover feature:\n\n### Health Checks\n\nAfter enabling the proxy service, the system periodically performs health checks on providers in the failover queue:\n\n1. Sends a request using the configured test model\n2. Updates health status based on the response\n3. Unhealthy providers are temporarily skipped\n\n### Circuit Breaker Recovery\n\nWhen a provider recovers from a circuit-broken state:\n\n1. Performs a model test to verify availability\n2. If the test passes, normal status is restored\n3. If the test fails, the circuit breaker remains active\n\n## FAQ\n\n### Test Fails But Actually Available\n\n**Possible causes**:\n- The test model differs from the actually used model\n- The provider doesn't support the configured test model\n\n**Solutions**:\n- Change the test model to one supported by the provider\n- Check the provider's model list\n\n### High Latency\n\n**Possible causes**:\n- Network latency\n- High server load on the provider\n- Slow model response\n\n**Solutions**:\n- Use a faster test model\n- Adjust the degradation threshold\n- Consider using mirror endpoints\n\n### Frequent Timeouts\n\n**Possible causes**:\n- Timeout set too short\n- Unstable network\n- Unstable provider service\n\n**Solutions**:\n- Increase the timeout\n- Increase retry count\n- Check network connection\n\n## Notes\n\n- Model testing consumes a small amount of API quota\n- Recommend using low-cost models for testing\n- Testing frequency should not be too high to avoid wasting quota\n- Different providers may support different models\n"
  },
  {
    "path": "docs/user-manual/en/5-faq/5.1-config-files.md",
    "content": "# 5.1 Configuration Files\n\n## CC Switch Data Storage\n\n### Storage Directory\n\nDefault location: `~/.cc-switch/`\n\nCustomizable location in settings (for cloud sync).\n\n### Directory Structure\n\n```\n~/.cc-switch/\n├── cc-switch.db      # SQLite database\n├── settings.json     # Device-level settings\n└── backups/          # Automatic backups\n    ├── backup-20251230-120000.json\n    ├── backup-20251229-180000.json\n    └── ...\n```\n\n### Database Contents\n\n`cc-switch.db` is a SQLite database that stores:\n\n| Table | Contents |\n|-------|----------|\n| providers | Provider configurations |\n| provider_endpoints | Provider endpoint candidate list |\n| mcp_servers | MCP server configurations |\n| prompts | Prompt presets |\n| skills | Skill installation status |\n| skill_repos | Skill repository configurations |\n| proxy_config | Proxy configuration |\n| proxy_request_logs | Proxy request logs |\n| provider_health | Provider health status |\n| model_pricing | Model pricing |\n| settings | App settings |\n\n### Device Settings\n\n`settings.json` stores device-level settings:\n\n```json\n{\n  \"language\": \"zh\",\n  \"theme\": \"system\",\n  \"windowBehavior\": \"minimize\",\n  \"autoStart\": false,\n  \"claudeConfigDir\": null,\n  \"codexConfigDir\": null,\n  \"geminiConfigDir\": null,\n  \"opencodeConfigDir\": null,\n  \"openclawConfigDir\": null\n}\n```\n\nThese settings are not synced across devices.\n\n### Automatic Backups\n\nThe `backups/` directory stores automatic backups:\n\n- Automatically created before each configuration import\n- Retains the most recent 10 backups\n- File names include timestamps\n\n## Claude Code Configuration\n\n### Configuration Directory\n\nDefault: `~/.claude/`\n\n### Key Files\n\n```\n~/.claude/\n├── settings.json     # Main configuration file\n├── CLAUDE.md         # System prompt\n└── skills/           # Skills directory\n    └── ...\n```\n\n### settings.json\n\n```json\n{\n  \"env\": {\n    \"ANTHROPIC_API_KEY\": \"sk-xxx\",\n    \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\"\n  },\n  \"permissions\": {\n    \"allow_file_access\": true\n  }\n}\n```\n\n| Field | Description |\n|-------|-------------|\n| `env.ANTHROPIC_API_KEY` | API key |\n| `env.ANTHROPIC_BASE_URL` | API endpoint (optional) |\n| `env.ANTHROPIC_AUTH_TOKEN` | Alternative authentication method |\n\n### MCP Configuration\n\nMCP server configuration is in `~/.claude.json`:\n\n```json\n{\n  \"mcpServers\": {\n    \"mcp-fetch\": {\n      \"command\": \"uvx\",\n      \"args\": [\"mcp-server-fetch\"]\n    }\n  }\n}\n```\n\n## Codex Configuration\n\n### Configuration Directory\n\nDefault: `~/.codex/`\n\n### Key Files\n\n```\n~/.codex/\n├── auth.json         # Authentication configuration\n├── config.toml       # Main configuration + MCP\n└── AGENTS.md         # System prompt\n```\n\n### auth.json\n\n```json\n{\n  \"OPENAI_API_KEY\": \"sk-xxx\"\n}\n```\n\n### config.toml\n\n```toml\n# Basic configuration\nbase_url = \"https://api.openai.com/v1\"\nmodel = \"gpt-4\"\n\n# MCP servers\n[mcp_servers.mcp-fetch]\ncommand = \"uvx\"\nargs = [\"mcp-server-fetch\"]\n```\n\n## Gemini CLI Configuration\n\n### Configuration Directory\n\nDefault: `~/.gemini/`\n\n### Key Files\n\n```\n~/.gemini/\n├── .env              # Environment variables (API Key)\n├── settings.json     # Main configuration + MCP\n└── GEMINI.md         # System prompt\n```\n\n### .env\n\n```bash\nGEMINI_API_KEY=xxx\nGOOGLE_GEMINI_BASE_URL=https://generativelanguage.googleapis.com\nGEMINI_MODEL=gemini-pro\n```\n\n### settings.json\n\n```json\n{\n  \"mcpServers\": {\n    \"mcp-fetch\": {\n      \"command\": \"uvx\",\n      \"args\": [\"mcp-server-fetch\"]\n    }\n  }\n}\n```\n\n| Field | Description |\n|-------|-------------|\n| `mcpServers` | MCP server configuration |\n\n## OpenCode Configuration\n\n### Configuration Directory\n\nDefault: `~/.opencode/`\n\n### Key Files\n\n```\n~/.opencode/\n├── config.json       # Main configuration file\n├── AGENTS.md         # System prompt\n└── skills/           # Skills directory\n    └── ...\n```\n\n## OpenClaw Configuration\n\n### Configuration Directory\n\nDefault: `~/.openclaw/`\n\n### Key Files\n\n```\n~/.openclaw/\n├── openclaw.json     # Main configuration file (JSON5 format)\n├── AGENTS.md         # System prompt\n└── skills/           # Skills directory\n    └── ...\n```\n\n### openclaw.json\n\nOpenClaw uses a JSON5 format configuration file with the following main sections:\n\n```json5\n{\n  // Model provider configuration\n  models: {\n    mode: \"merge\",\n    providers: {\n      \"custom-provider\": {\n        baseUrl: \"https://api.example.com/v1\",\n        apiKey: \"your-api-key\",\n        api: \"openai-completions\",\n        models: [{ id: \"model-id\", name: \"Model Name\" }]\n      }\n    }\n  },\n  // Environment variables\n  env: {\n    ANTHROPIC_API_KEY: \"sk-...\"\n  },\n  // Agent default model configuration\n  agents: {\n    defaults: {\n      model: {\n        primary: \"provider/model\"\n      }\n    }\n  },\n  // Tool configuration\n  tools: {},\n  // Workspace file configuration\n  workspace: {}\n}\n```\n\n| Field | Description |\n|-------|-------------|\n| `models.providers` | Provider configuration (mapped to CC Switch's \"providers\") |\n| `env` | Environment variable configuration |\n| `agents.defaults` | Agent default model settings |\n| `tools` | Tool configuration |\n| `workspace` | Workspace file management |\n\n## Configuration Priority\n\nCC Switch's priority when modifying configurations:\n\n1. **CC Switch Database** - Single source of truth (SSOT)\n2. **Live Configuration Files** - Written when switching providers\n3. **Backfill Mechanism** - Reads from live files when editing the current provider\n\n## Manual Configuration Editing\n\n### Safe to Edit Manually\n\n- CLI tool configuration files (will be backfilled by CC Switch)\n- CC Switch's `settings.json`\n\n### Not Recommended to Edit Manually\n\n- `cc-switch.db` database file\n- Backup files\n\n### Sync After Editing\n\nIf you manually edit CLI tool configurations:\n\n1. Open CC Switch\n2. Edit the corresponding provider\n3. You will see the manual changes have been backfilled\n4. Save to sync to the database\n\n## Configuration Migration\n\n### Migrating from Older Versions\n\nCC Switch v3.7.0 migrated from JSON files to SQLite:\n\n- Automatic migration on first launch\n- Displays a notification upon successful migration\n- Old configuration files are retained as backups\n\n### Cross-device Migration\n\n1. Export configuration on the source device\n2. Import configuration on the target device\n3. Or use the cloud sync feature\n\n## Configuration Backup Recommendations\n\n### Regular Backups\n\nIt is recommended to regularly export configurations:\n\n1. Settings > Advanced > Data Management\n2. Click \"Export\"\n3. Save to a secure location\n\n### Backup Contents\n\nThe export file includes:\n\n- All provider configurations\n- MCP server configurations\n- Prompt presets\n- App settings\n\n### Not Included\n\n- Usage logs (large data volume)\n- Device-level settings (not suitable for cross-device)\n"
  },
  {
    "path": "docs/user-manual/en/5-faq/5.2-questions.md",
    "content": "# 5.2 Frequently Asked Questions\n\n## Installation Issues\n\n### macOS Shows \"Unidentified Developer\"\n\n**Problem**: First launch shows \"Cannot open because it is from an unidentified developer\"\n\n**Solution 1**: Via System Settings\n1. Close the warning dialog\n2. Open \"System Settings\" > \"Privacy & Security\"\n3. Find the CC Switch prompt\n4. Click \"Open Anyway\"\n5. Reopen the app\n\n**Solution 2**: Via Terminal command (recommended)\n```bash\nsudo xattr -dr com.apple.quarantine /Applications/CC\\ Switch.app/\n```\n\nThe app can be opened normally after running this command.\n\n### Windows: App Doesn't Launch After Installation\n\n**Possible causes**:\n- Missing WebView2 runtime\n- Antivirus software blocking\n\n**Solutions**:\n1. Install [Microsoft Edge WebView2](https://developer.microsoft.com/en-us/microsoft-edge/webview2/)\n2. Add CC Switch to your antivirus software's whitelist\n\n### Linux: Startup Error\n\n**Problem**: AppImage won't start\n\n**Solution**:\n```bash\n# Add execute permission\nchmod +x CC-Switch-*.AppImage\n\n# If it still fails, try\n./CC-Switch-*.AppImage --no-sandbox\n```\n\n## Provider Issues\n\n### Provider Switch Doesn't Take Effect\n\n**Cause**: The CLI tool needs to reload its configuration\n\n**Solutions**:\n- Claude Code: Close and reopen the terminal, or restart the IDE\n- Codex: Close and reopen the terminal\n- Gemini: Tray switching takes effect immediately, no restart needed\n\n### API Key Invalid\n\n**Troubleshooting steps**:\n1. Confirm the API Key is copied correctly (no extra spaces)\n2. Confirm the API Key hasn't expired\n3. Confirm the endpoint URL is correct\n4. Use the speed test to verify connectivity\n\n### How to Restore Official Login\n\n**Steps**:\n1. Select the \"Official Login\" preset (Claude/Codex) or \"Google Official\" preset (Gemini)\n2. Click \"Enable\"\n3. Restart the corresponding CLI tool\n4. Follow the CLI tool's login flow\n\n## Proxy Issues\n\n### Proxy Service Fails to Start\n\n**Possible cause**: Port is occupied\n\n**Solution**:\n1. Check port usage:\n   ```bash\n   # macOS/Linux\n   lsof -i :49152\n\n   # Windows\n   netstat -ano | findstr :49152\n   ```\n2. Close the program occupying the port\n3. Or try modifying the configuration to restore the default port:\n   - Open \"Settings > Proxy Service\"\n   - Click the \"Reset to Default\" button\n\n### Request Timeout in Proxy Mode\n\n**Possible causes**:\n- Network issues\n- Provider server issues\n- Incorrect proxy configuration\n\n**Solutions**:\n1. Check network connection\n2. Try accessing the provider API directly (disable proxy)\n3. Check if provider configuration is correct\n\n### Configuration Not Restored After Disabling Proxy\n\n**Possible cause**: Proxy exited abnormally\n\n**Solution**:\n1. Edit the current provider\n2. Check if the endpoint URL is correct\n3. Save to update the configuration\n\n## Failover Issues\n\n### Failover Not Triggering\n\n**Checklist**:\n- [ ] Is the proxy service running\n- [ ] Is app takeover enabled\n- [ ] Is auto failover enabled\n- [ ] Are there backup providers in the queue\n\n### Failover Triggering Too Frequently\n\n**Possible causes**:\n- Unstable primary provider\n- Circuit breaker threshold set too low\n\n**Solutions**:\n1. Check primary provider status\n2. Increase the failure threshold (e.g., from 3 to 5)\n3. Consider changing the primary provider\n\n### All Providers Are Circuit-Broken\n\n**Solutions**:\n1. Wait for the recovery wait time to expire (default 60 seconds)\n2. Or restart the proxy service to reset states\n\n## Data Issues\n\n### Configuration Lost\n\n**Possible causes**:\n- Configuration directory was deleted\n- Database corruption\n\n**Solutions**:\n1. Check if the `~/.cc-switch/` directory exists\n2. Restore from backup: `~/.cc-switch/backups/`\n3. Or import from a previously exported configuration file\n\n### Import Configuration Failed\n\n**Possible causes**:\n- Incorrect file format\n- Version incompatibility\n\n**Solutions**:\n1. Confirm the file was exported by CC Switch\n2. Check if the file content is complete\n3. Try opening with a text editor to check format\n\n### Usage Statistics Data Is Empty\n\n**Checklist**:\n- [ ] Is the proxy service running\n- [ ] Is app takeover enabled\n- [ ] Is log recording enabled\n- [ ] Have requests been going through the proxy\n\n## Other Issues\n\n### Tray Icon Not Showing\n\n**macOS**:\n- Check menu bar icon settings in System Settings\n\n**Windows**:\n- Check taskbar settings to ensure the CC Switch icon is not hidden\n\n**Linux**:\n- System tray support may need to be installed (e.g., `libappindicator`)\n\n### UI Display Issues\n\n**Solutions**:\n1. Try switching themes (light/dark)\n2. Restart the app\n3. Delete `~/.cc-switch/settings.json` to reset settings\n\n### Update Failed\n\n**Solutions**:\n1. Check network connection\n2. Manually download and install the latest version\n3. If using Homebrew: `brew upgrade --cask cc-switch`\n\n## Getting Help\n\n### Submit an Issue\n\nIf none of the above solutions work:\n\n1. Visit [GitHub Issues](https://github.com/farion1231/cc-switch/issues)\n2. Search for similar issues\n3. If none found, create a new Issue\n4. Provide the following information:\n   - Operating system and version\n   - CC Switch version\n   - Problem description and reproduction steps\n   - Error messages (if any)\n\n### Log Files\n\nAttach log files when submitting an Issue:\n\n- macOS/Linux: `~/.cc-switch/logs/`\n- Windows: `%APPDATA%\\cc-switch\\logs\\`\n"
  },
  {
    "path": "docs/user-manual/en/5-faq/5.3-deeplink.md",
    "content": "# 5.3 Deep Link Protocol\n\n## Overview\n\nCC Switch supports the `ccswitch://` deep link protocol, enabling one-click configuration import via links.\n\n**Use cases**:\n- Team configuration sharing\n- One-click setup in tutorials\n- Quick sync across devices\n\n## Online Generator Tool\n\nCC Switch provides an online deep link generator tool:\n\n**URL**: [https://farion1231.github.io/cc-switch/deplink.html](https://farion1231.github.io/cc-switch/deplink.html)\n\n### How to Use\n\n1. Open the above URL\n2. Select the import type (Provider/MCP/Prompt)\n3. Fill in the configuration information\n4. Click \"Generate Link\"\n5. Copy the generated deep link\n6. Share with others or use on other devices\n\n## Protocol Format\n\n### V1 Protocol\n\nUses URL parameter format, easy to read and generate:\n\n```\nccswitch://v1/import?resource={type}&app={app}&name={name}&...\n```\n\n**Common parameters**:\n\n| Parameter | Required | Description |\n|-----------|----------|-------------|\n| `resource` | Yes | Resource type: `provider` / `mcp` / `prompt` / `skill` |\n| `app` | Yes | App type: `claude` / `codex` / `gemini` / `opencode` / `openclaw` |\n| `name` | Yes | Name |\n\n**Provider parameters** (resource=provider):\n\n| Parameter | Required | Description |\n|-----------|----------|-------------|\n| `endpoint` | No | API endpoint URL (supports comma-separated multiple URLs) |\n| `apiKey` | No | API key |\n| `homepage` | No | Provider website |\n| `model` | No | Default model |\n| `haikuModel` | No | Haiku model (Claude only) |\n| `sonnetModel` | No | Sonnet model (Claude only) |\n| `opusModel` | No | Opus model (Claude only) |\n| `notes` | No | Notes |\n| `icon` | No | Icon |\n| `config` | No | Base64-encoded configuration content |\n| `configFormat` | No | Configuration format: `json` / `toml` |\n| `configUrl` | No | Remote configuration URL |\n| `enabled` | No | Whether to enable (boolean) |\n| `usageScript` | No | Usage query script |\n| `usageEnabled` | No | Whether to enable usage query (default true) |\n| `usageApiKey` | No | Usage query API Key |\n| `usageBaseUrl` | No | Usage query base URL |\n| `usageAccessToken` | No | Usage query access token |\n| `usageUserId` | No | Usage query user ID |\n| `usageAutoInterval` | No | Auto query interval (minutes) |\n\n**Prompt parameters** (resource=prompt):\n\n| Parameter | Required | Description |\n|-----------|----------|-------------|\n| `content` | Yes | Prompt content |\n| `description` | No | Description |\n| `enabled` | No | Whether to enable (boolean) |\n\n**MCP parameters** (resource=mcp):\n\n| Parameter | Required | Description |\n|-----------|----------|-------------|\n| `apps` | Yes | App list (comma-separated, e.g., `claude,codex,gemini,opencode`) |\n| `config` | Yes | MCP server configuration (JSON format) |\n| `enabled` | No | Whether to enable (boolean) |\n\n**Skill parameters** (resource=skill):\n\n| Parameter | Required | Description |\n|-----------|----------|-------------|\n| `repo` | Yes | Repository (format: `owner/name`) |\n| `directory` | No | Directory path |\n| `branch` | No | Git branch |\n\n**Example**:\n```\nccswitch://v1/import?resource=provider&app=claude&name=My%20Provider&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-xxx\n```\n\n## Import Type Examples\n\n### Import Provider\n\n```\nccswitch://v1/import?resource=provider&app=claude&name=My%20Provider&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-xxx\n```\n\n### Import MCP Server\n\n```\nccswitch://v1/import?resource=mcp&apps=claude,codex&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22mcp-server-fetch%22%5D%7D&name=mcp-fetch\n```\n\n### Import Prompt Preset\n\n```\nccswitch://v1/import?resource=prompt&app=claude&name=%E4%BB%A3%E7%A0%81%E5%AE%A1%E6%9F%A5&content=%23%20%E8%A7%92%E8%89%B2%0A%E4%BD%A0%E6%98%AF%E4%B8%80%E4%B8%AA%E4%B8%93%E4%B8%9A%E7%9A%84%E4%BB%A3%E7%A0%81%E5%AE%A1%E6%9F%A5%E4%B8%93%E5%AE%B6\n```\n\n### Import Skill\n\n```\nccswitch://v1/import?resource=skill&name=my-skill&repo=owner/repo&directory=skills/my-skill&branch=main\n```\n\n## Generate Deep Links\n\n### Manual Generation\n\n1. Prepare parameters\n2. Assemble the URL following V1 protocol format\n3. URL-encode special characters\n\n**Example**:\n\n```javascript\nconst params = new URLSearchParams({\n  resource: 'provider',\n  app: 'claude',\n  name: 'My Provider',\n  endpoint: 'https://api.example.com',\n  apiKey: 'sk-xxx'\n});\n\nconst url = `ccswitch://v1/import?${params.toString()}`;\n```\n\n### Online Tool\n\nUsing CC Switch's official online deep link generator tool is more convenient.\n\n## Using Deep Links\n\n### Click the Link\n\nClick a deep link in a browser or other application:\n\n1. The system asks whether to open CC Switch\n2. After confirming, CC Switch opens\n3. An import confirmation dialog is displayed\n4. Confirm the import\n\n### Import Confirmation\n\nA confirmation dialog is shown before import, containing:\n\n- Import type\n- Configuration preview\n- Confirm/Cancel buttons\n\n**Security tip**: Only import configurations from trusted sources.\n\n## Protocol Registration\n\n### Automatic Registration\n\nCC Switch automatically registers the `ccswitch://` protocol during installation.\n\n### Manual Registration\n\nIf the protocol is not registered correctly:\n\n**macOS**:\nReinstall the app, or run:\n```bash\n/usr/bin/open -a \"CC Switch\" --args --register-protocol\n```\n\n**Windows**:\nReinstall the app, or check the registry:\n```\nHKEY_CLASSES_ROOT\\ccswitch\n```\n\n**Linux**:\nCheck the `MimeType` configuration in the `.desktop` file.\n\n## Security Considerations\n\n### Sensitive Information\n\nDeep links may contain sensitive information (e.g., API Keys):\n\n- Do not share links containing API Keys in public\n- Remove or replace sensitive information before sharing\n- Use secure channels to transmit links\n\n### Source Verification\n\nBefore import, CC Switch will:\n\n1. Validate the data format\n2. Display a configuration preview\n3. Require user confirmation\n\n### Malicious Link Protection\n\nCC Switch checks:\n\n- Whether the data format is valid\n- Whether required fields are complete\n- Whether configuration values are within reasonable ranges\n\n## Example Links\n\n### Example: Import Claude Provider\n\n```\nccswitch://v1/import?resource=provider&app=claude&name=Test%20Provider&apiKey=sk-xxx&endpoint=https%3A%2F%2Fapi.example.com\n```\n\n### Example: Import MCP Server\n\n```\nccswitch://v1/import?resource=mcp&name=mcp-fetch&apps=claude,codex,gemini&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22mcp-server-fetch%22%5D%7D\n```\n\n## Troubleshooting\n\n### Link Won't Open\n\n**Check**:\n1. Is CC Switch installed\n2. Is the protocol registered correctly\n3. Is the link format correct\n\n### Import Failed\n\n**Possible causes**:\n- Base64 encoding error\n- JSON format error\n- Missing required fields\n\n**Solutions**:\n1. Check the original JSON format\n2. Re-encode in Base64\n3. Ensure all required fields are present\n"
  },
  {
    "path": "docs/user-manual/en/5-faq/5.4-env-conflict.md",
    "content": "# 5.4 Environment Variable Conflicts\n\n## Overview\n\nCC Switch automatically detects conflicts between system environment variables and app configurations, preventing configurations from being unexpectedly overridden.\n\n**Detected environment variables**:\n- `ANTHROPIC_API_KEY` - Claude API key\n- `ANTHROPIC_BASE_URL` - Claude API endpoint\n- `OPENAI_API_KEY` - OpenAI API key\n- `GEMINI_API_KEY` - Gemini API key\n- Other related environment variables\n\n## Conflict Warning\n\nWhen a conflict is detected, a yellow warning banner appears at the top of the interface:\n\n```\nWarning: Environment variable conflict detected\nFound X environment variables that may conflict with CC Switch configuration\n[Expand] [Dismiss]\n```\n\n## View Conflict Details\n\nClick the \"Expand\" button to view detailed information:\n\n| Field | Description |\n|-------|-------------|\n| Variable Name | Environment variable name |\n| Variable Value | Currently set value |\n| Source | Where the variable originates from |\n\n### Source Types\n\n| Source | Description |\n|--------|-------------|\n| User Registry | Windows user-level environment variable |\n| System Registry | Windows system-level environment variable |\n| Shell Configuration | macOS/Linux shell configuration file |\n| System Environment | System-level environment variable |\n\n## Resolve Conflicts\n\n### Select Variables to Remove\n\n1. Check the environment variables you want to remove\n2. Or click \"Select All\" to select all conflicting variables\n\n### Remove Variables\n\n1. Click the \"Remove Selected\" button\n2. Confirm the removal operation\n3. CC Switch will automatically back up and remove the selected variables\n\n### Automatic Backup\n\nA backup is automatically created before removal:\n\n- Backup location: `~/.cc-switch/env-backups/`\n- Backup format: JSON file\n- Includes variable name, value, source, and other information\n\n## Dismiss Warning\n\nIf you confirm the conflict does not affect usage, you can:\n\n1. Click the \"Dismiss\" button on the right side of the warning banner\n2. The warning will be temporarily hidden\n3. Detection will run again on next launch\n\n## Manual Resolution\n\nIf you prefer not to use CC Switch to remove variables, you can handle them manually:\n\n### Windows\n\n1. Open \"System Properties > Advanced > Environment Variables\"\n2. Find the conflicting variable in User or System variables\n3. Delete or modify the variable\n\n### macOS / Linux\n\n1. Edit the shell configuration file (e.g., `~/.zshrc`, `~/.bashrc`)\n2. Delete or comment out the relevant `export` statements\n3. Reload the configuration: `source ~/.zshrc`\n\n## Why Do Conflicts Occur\n\nEnvironment variables typically take priority over configuration files, which may cause:\n\n- CC Switch provider configurations being overridden\n- API requests being sent to the wrong endpoint\n- Using the wrong API key\n\n## Best Practices\n\n1. **Use CC Switch to manage configurations**: Avoid setting API keys in system environment variables\n2. **Check regularly**: Pay attention to conflict warnings and address them promptly\n3. **Back up important variables**: Confirm backups exist before removal\n\n## Restore Deleted Variables\n\nIf you accidentally deleted environment variables:\n\n1. Find the backup file: `~/.cc-switch/env-backups/`\n2. Open the corresponding JSON file\n3. Manually restore the variable to the system environment\n"
  },
  {
    "path": "docs/user-manual/en/README.md",
    "content": "# CC Switch User Manual\n\n> All-in-One Assistant for Claude Code / Codex / Gemini CLI / OpenCode / OpenClaw\n\n## Table of Contents\n\n```\nCC Switch User Manual\n│\n├── 1. Getting Started\n│   ├── 1.1 Introduction\n│   ├── 1.2 Installation Guide\n│   ├── 1.3 Interface Overview\n│   ├── 1.4 Quick Start\n│   └── 1.5 Personalization\n│\n├── 2. Provider Management\n│   ├── 2.1 Add Provider\n│   ├── 2.2 Switch Provider\n│   ├── 2.3 Edit Provider\n│   ├── 2.4 Sort & Duplicate\n│   └── 2.5 Usage Query\n│\n├── 3. Extensions\n│   ├── 3.1 MCP Server Management\n│   ├── 3.2 Prompts Management\n│   └── 3.3 Skills Management\n│\n├── 4. Proxy & High Availability\n│   ├── 4.1 Proxy Service\n│   ├── 4.2 App Takeover\n│   ├── 4.3 Failover\n│   ├── 4.4 Usage Statistics\n│   └── 4.5 Model Test\n│\n└── 5. FAQ\n    ├── 5.1 Configuration Files\n    ├── 5.2 FAQ\n    ├── 5.3 Deep Link Protocol\n    └── 5.4 Environment Variable Conflicts\n```\n\n## File List\n\n### 1. Getting Started\n\n| File | Description |\n|------|-------------|\n| [1.1-introduction.md](./1-getting-started/1.1-introduction.md) | Introduction, core features, supported platforms |\n| [1.2-installation.md](./1-getting-started/1.2-installation.md) | Windows/macOS/Linux installation guide |\n| [1.3-interface.md](./1-getting-started/1.3-interface.md) | Interface layout, navigation bar, provider cards |\n| [1.4-quickstart.md](./1-getting-started/1.4-quickstart.md) | 5-minute quick start tutorial |\n| [1.5-settings.md](./1-getting-started/1.5-settings.md) | Language, theme, directories, cloud sync settings |\n\n### 2. Provider Management\n\n| File | Description |\n|------|-------------|\n| [2.1-add.md](./2-providers/2.1-add.md) | Using presets, custom configuration, universal providers |\n| [2.2-switch.md](./2-providers/2.2-switch.md) | Main UI switching, tray switching, activation methods |\n| [2.3-edit.md](./2-providers/2.3-edit.md) | Edit configuration, modify API Key, backfill mechanism |\n| [2.4-sort-duplicate.md](./2-providers/2.4-sort-duplicate.md) | Drag-to-reorder, duplicate provider, delete |\n| [2.5-usage-query.md](./2-providers/2.5-usage-query.md) | Usage query, remaining balance, multi-plan display |\n\n### 3. Extensions\n\n| File | Description |\n|------|-------------|\n| [3.1-mcp.md](./3-extensions/3.1-mcp.md) | MCP protocol, add servers, app binding |\n| [3.2-prompts.md](./3-extensions/3.2-prompts.md) | Create presets, activate/switch, smart backfill |\n| [3.3-skills.md](./3-extensions/3.3-skills.md) | Discover skills, install/uninstall, repository management |\n\n### 4. Proxy & High Availability\n\n| File | Description |\n|------|-------------|\n| [4.1-service.md](./4-proxy/4.1-service.md) | Start proxy, configuration, running status |\n| [4.2-takeover.md](./4-proxy/4.2-takeover.md) | App takeover, configuration changes, status indicators |\n| [4.3-failover.md](./4-proxy/4.3-failover.md) | Failover queue, circuit breaker, health status |\n| [4.4-usage.md](./4-proxy/4.4-usage.md) | Usage statistics, trend charts, pricing configuration |\n| [4.5-model-test.md](./4-proxy/4.5-model-test.md) | Model test, health check, latency testing |\n\n### 5. FAQ\n\n| File | Description |\n|------|-------------|\n| [5.1-config-files.md](./5-faq/5.1-config-files.md) | CC Switch storage, CLI configuration file formats |\n| [5.2-questions.md](./5-faq/5.2-questions.md) | Frequently asked questions |\n| [5.3-deeplink.md](./5-faq/5.3-deeplink.md) | Deep link protocol, generation and usage |\n| [5.4-env-conflict.md](./5-faq/5.4-env-conflict.md) | Environment variable conflict detection and resolution |\n\n## Quick Links\n\n- **New users**: Start with [1.1 Introduction](./1-getting-started/1.1-introduction.md)\n- **Installation issues**: See [1.2 Installation Guide](./1-getting-started/1.2-installation.md)\n- **Configure providers**: See [2.1 Add Provider](./2-providers/2.1-add.md)\n- **Using proxy**: See [4.1 Proxy Service](./4-proxy/4.1-service.md)\n- **Having trouble**: See [5.2 FAQ](./5-faq/5.2-questions.md)\n\n## Version Information\n\n- Documentation version: v3.12.0\n- Last updated: 2026-03-09\n- Applicable to CC Switch v3.12.0+\n\n## Contributing\n\nFeel free to submit Issues or PRs to improve the documentation:\n\n- [GitHub Issues](https://github.com/farion1231/cc-switch/issues)\n- [GitHub Repository](https://github.com/farion1231/cc-switch)\n"
  },
  {
    "path": "docs/user-manual/ja/1-getting-started/1.1-introduction.md",
    "content": "# 1.1 ソフトウェア紹介\n\n## CC Switch とは\n\nCC Switch はクロスプラットフォームのデスクトップアプリケーションで、AI プログラミングツールを使用する開発者向けに設計されています。**Claude Code**、**Codex**、**Gemini CLI**、**OpenCode**、**OpenClaw** の 5 つの AI プログラミングツールの設定を統一的に管理できます。\n\n## どのような問題を解決するか\n\n日常の開発で、以下のような課題に直面することがあります：\n\n- **複数プロバイダーの切り替えが面倒**：異なる API プロバイダー（公式、中継サービスなど）を使用する際、設定ファイルを手動で変更する必要がある\n- **設定が分散して管理しづらい**：Claude、Codex、Gemini、OpenCode、OpenClaw がそれぞれ独立した設定ファイルを持ち、フォーマットも異なる\n- **使用量を監視できない**：API をどれだけ呼び出したか、いくらかかったかが分からない\n- **サービスが不安定**：単一プロバイダーに問題が発生すると、ワークフロー全体が中断する\n\nCC Switch は統一されたインターフェースでこれらの問題を解決します。\n\n## 主要機能\n\n### プロバイダー管理\n- ワンクリックで複数の API プロバイダー設定を切り替え\n- プリセットテンプレートで一般的なプロバイダーを素早く追加\n- 統一プロバイダー機能で、アプリ間で設定を共有\n- 使用量クエリと残額表示\n- エンドポイント速度テスト\n\n### 拡張機能\n- **MCP サーバー**：Model Context Protocol サーバーを管理し、AI の機能を拡張\n- **Prompts**：システムプロンプトのプリセットを管理し、さまざまなシーンで素早く切り替え\n- **Skills**：スキル拡張のインストールと管理\n\n### プロキシと高可用性\n- ローカルプロキシサービスで、リクエストログと使用量統計を記録\n- 自動フェイルオーバー、メインプロバイダーの障害時にバックアップへ自動切り替え\n- サーキットブレーカー機能で、障害プロバイダーへの頻繁なリトライを防止\n- 詳細な Token 使用量トラッキングとコスト見積もり\n\n## 対応アプリケーション\n\n| アプリ | 説明 |\n|------|------|\n| **Claude Code** | Anthropic 公式の AI プログラミングアシスタント |\n| **Codex** | OpenAI のコード生成ツール |\n| **Gemini CLI** | Google の AI コマンドラインツール |\n| **OpenCode** | オープンソース AI プログラミングターミナルツール |\n| **OpenClaw** | オープンソース AI プログラミングアシスタント（マルチプロバイダーゲートウェイ） |\n\n## 対応プラットフォーム\n\n- **Windows** 10 以上\n- **macOS** 10.15 (Catalina) 以上\n- **Linux** Ubuntu 22.04+ / Debian 11+ / Fedora 34+\n\n## 技術アーキテクチャ\n\nCC Switch はモダンな技術スタックで構築されています：\n\n- **フロントエンド**：React 18 + TypeScript + Tailwind CSS\n- **バックエンド**：Tauri 2 + Rust\n- **データストレージ**：SQLite（プロバイダー、MCP、Prompts）+ JSON（デバイス設定）\n\nこのアーキテクチャにより：\n- クロスプラットフォームでの一貫した体験\n- ネイティブレベルのパフォーマンス\n- 安全なローカルデータストレージ\n"
  },
  {
    "path": "docs/user-manual/ja/1-getting-started/1.2-installation.md",
    "content": "# 1.2 インストールガイド\n\n## 前提条件\n\n### Node.js のインストール\n\nCC Switch が管理する CLI ツール（Claude Code、Codex、Gemini CLI）には Node.js 環境が必要です。\n\n**推奨バージョン**：Node.js 18 LTS 以上\n\n#### Windows\n\n1. [Node.js 公式サイト](https://nodejs.org/) にアクセス\n\n2. LTS バージョンのインストーラーをダウンロード\n\n3. インストーラーを実行し、指示に従ってインストール\n\n4. インストールの確認：\n\n ```bash\n node --version\n npm --version\n ```\n\n#### macOS\n\n```bash\n# Homebrew でインストール\nbrew install node\n\n# または nvm を使用（推奨）\ncurl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash\nnvm install --lts\n```\n\n#### Linux\n\n```bash\n# Ubuntu/Debian\ncurl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -\nsudo apt-get install -y nodejs\n\n# または nvm を使用\ncurl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash\nnvm install --lts\n```\n\n### CLI ツールのインストール\n\n#### Claude Code\n\n**方法 1：Homebrew（macOS 推奨）**\n\n```bash\nbrew install claude-code\n```\n\n**方法 2：npm**\n\n```bash\nnpm install -g @anthropic-ai/claude-code\n```\n\n#### Codex\n\n**方法 1：Homebrew（macOS 推奨）**\n\n```bash\nbrew install codex\n```\n\n**方法 2：npm**\n\n```bash\nnpm install -g @openai/codex\n```\n\n#### Gemini CLI\n\n**方法 1：Homebrew（macOS 推奨）**\n\n```bash\nbrew install gemini-cli\n```\n\n**方法 2：npm**\n\n```bash\nnpm install -g @google/gemini-cli\n```\n\n---\n\n## Windows\n\n### インストーラー方式\n\n1. [Releases ページ](https://github.com/farion1231/cc-switch/releases) にアクセス\n2. `CC-Switch-v{バージョン}-Windows.msi` をダウンロード\n3. インストーラーをダブルクリックして実行\n4. 指示に従ってインストール\n\n### ポータブル版（インストール不要）\n\n1. `CC-Switch-v{バージョン}-Windows-Portable.zip` をダウンロード\n2. 任意のディレクトリに展開\n3. `CC-Switch.exe` を実行\n\n## macOS\n\n### 方法 1：Homebrew（推奨）\n\n```bash\n# tap を追加\nbrew tap farion1231/ccswitch\n\n# インストール\nbrew install --cask cc-switch\n```\n\n最新バージョンに更新：\n\n```bash\nbrew upgrade --cask cc-switch\n```\n\n### 方法 2：手動ダウンロード\n\n1. `CC-Switch-v{バージョン}-macOS.zip` をダウンロード\n2. 展開して `CC Switch.app` を取得\n3. 「アプリケーション」フォルダにドラッグ\n\n### 初回起動時の警告\n\n開発者が Apple 開発者アカウントを持っていないため、初回起動時に「不明な開発者」の警告が表示される場合があります：\n\n**推奨される解決方法**：\nターミナルで以下のコマンドを実行してください：\n```bash\nsudo xattr -dr com.apple.quarantine /Applications/CC\\ Switch.app/\n```\n\n**別の解決方法（システム設定から）**：\n1. 警告ダイアログを閉じる\n2. 「システム設定」→「プライバシーとセキュリティ」を開く\n3. CC Switch に関する表示を見つけ、「このまま開く」をクリック\n4. 再度アプリを開くと正常に使用可能\n\n## Linux\n\n### ArchLinux\n\nAUR ヘルパーを使用してインストール：\n\n```bash\n# paru を使用\nparu -S cc-switch-bin\n\n# または yay を使用\nyay -S cc-switch-bin\n```\n\n### Debian / Ubuntu\n\n1. `CC-Switch-v{バージョン}-Linux.deb` をダウンロード\n2. インストール：\n\n```bash\nsudo dpkg -i CC-Switch-v{バージョン}-Linux.deb\n\n# 依存関係に問題がある場合\nsudo apt-get install -f\n```\n\n### AppImage（汎用）\n\n1. `CC-Switch-v{バージョン}-Linux.AppImage` をダウンロード\n2. 実行権限を追加：\n\n```bash\nchmod +x CC-Switch-v{バージョン}-Linux.AppImage\n```\n\n3. 実行：\n\n```bash\n./CC-Switch-v{バージョン}-Linux.AppImage\n```\n\n## インストールの確認\n\nインストール完了後、CC Switch を起動します：\n\n1. アプリウィンドウが正常に表示される\n2. システムトレイに CC Switch のアイコンが表示される\n3. Claude / Codex / Gemini の 3 つのアプリを切り替えられる\n\n## 自動更新\n\nCC Switch には自動更新機能が内蔵されています：\n\n- 起動時に自動で更新を確認\n- 新しいバージョンがある場合、画面に更新通知を表示\n- クリックするとダウンロードしてインストール\n\n「設定 → バージョン情報」から手動で更新を確認することもできます。\n\n## アンインストール\n\n### Windows\n\n- 「設定 → アプリ」からアンインストール\n- またはインストールディレクトリのアンインストーラーを実行\n\n### macOS\n\n- `CC Switch.app` をゴミ箱に移動\n- オプション：設定ディレクトリ `~/.cc-switch/` を削除\n\n### Linux\n\n```bash\n# Debian/Ubuntu\nsudo apt remove cc-switch\n\n# ArchLinux\nparu -R cc-switch-bin\n```\n"
  },
  {
    "path": "docs/user-manual/ja/1-getting-started/1.3-interface.md",
    "content": "# 1.3 インターフェース概要\n\n## メイン画面のレイアウト\n\n![image-20260108001629138](../../assets/image-20260108001629138.png)\n\n## 上部ナビゲーションバー\n\n| 番号 | 要素 | 機能説明 |\n|------|------|----------|\n| ① | Logo | クリックで GitHub プロジェクトページにアクセス |\n| ② | 設定ボタン | 設定ページを開く（ショートカット `Cmd/Ctrl + ,`） |\n| ③ | プロキシスイッチ | ローカルプロキシサービスの起動/停止 |\n| ④ | アプリ切り替え | Claude / Codex / Gemini / OpenCode / OpenClaw を切り替え |\n| ⑤ | 機能エリア | Skills / Prompts / MCP の入口 |\n| ⑥ | 追加ボタン | 新しいプロバイダーを追加 |\n\n### アプリ切り替え\n\nドロップダウンメニューをクリックして、現在管理するアプリを切り替えます：\n\n- **Claude** - Claude Code の設定を管理\n- **Codex** - Codex の設定を管理\n- **Gemini** - Gemini CLI の設定を管理\n- **OpenCode** - OpenCode の設定を管理\n- **OpenClaw** - OpenClaw の設定を管理\n\n切り替え後、プロバイダーリストに対応アプリの設定が表示されます。\n\n### 機能エリアボタン\n\n| ボタン | 機能 | 表示条件 |\n|------|------|----------|\n| Skills | スキル拡張管理 | 常に表示 |\n| Prompts | システムプロンプト管理 | 常に表示 |\n| MCP | MCP サーバー管理 | 常に表示 |\n\n## プロバイダーカード\n\n各プロバイダーはカード形式で表示されます。左から右へ以下の要素が含まれています：\n\n### カード要素（左から右）\n\n| 番号 | 要素 | アイコン | 機能説明 |\n|------|------|------|----------|\n| ① | ドラッグハンドル | ≡ | 長押しして上下にドラッグしてプロバイダーの順序を調整 |\n| ② | プロバイダーアイコン | - | プロバイダーのブランドアイコンを表示、カラーのカスタマイズ可能 |\n| ③ | プロバイダー情報 | - | 名前、メモ/エンドポイントアドレス（クリックで公式サイトを開く） |\n| ④ | 使用量情報 | - | 残額を表示、複数プランの場合はプラン数を表示 |\n| ⑤ | 有効化ボタン | ▶ | 現在使用中のプロバイダーに切り替え |\n| ⑥ | 編集ボタン | ✏️ | プロバイダー設定を編集 |\n| ⑦ | 複製ボタン | - | プロバイダーを複製（コピーを作成） |\n| ⑧ | テストボタン | - | モデルの可用性と応答速度をテスト |\n| ⑨ | 使用量クエリ | - | 使用量クエリスクリプトを設定 |\n| ⑩ | 削除ボタン | - | プロバイダーを削除（現在有効な場合は無効） |\n\n> **ヒント**：操作ボタンエリア（⑤-⑩）はマウスホバー時に表示され、通常は非表示で画面をすっきり保ちます。\n\n### ボタンの詳細説明\n\n| ボタン | 状態変化 | 説明 |\n|------|----------|------|\n| **有効化** | 有効化済みの場合は ✓ を表示して無効化 | フェイルオーバーモードでは「参加/参加済み」に変化 |\n| **編集** | 常に使用可能 | 編集パネルを開いて設定を変更 |\n| **複製** | 常に使用可能 | プロバイダーのコピーを作成、名前に `copy` が付加 |\n| **テスト** | テスト中はローディングアニメーション | プロキシサービス実行中のみ使用可能 |\n| **使用量クエリ** | 常に使用可能 | カスタム使用量クエリスクリプトを設定 |\n| **削除** | 現在有効な場合は半透明で無効 | 先に他のプロバイダーに切り替える必要あり |\n\n### カードの状態\n\n| 状態 | 枠の色 | 説明 |\n|------|----------|------|\n| **現在有効** | 青い枠 | 通常モードで現在使用中のプロバイダー |\n| **プロキシアクティブ** | 緑の枠 | プロキシ接管モードで実際に使用中のプロバイダー |\n| **通常状態** | デフォルトの枠 | 有効化されていないプロバイダー |\n| **フェイルオーバー中** | 優先度バッジを表示 | P1、P2 などのフェイルオーバー優先度を表示 |\n\n### ヘルスステータスバッジ\n\nプロキシモードでは、フェイルオーバーキューに参加しているプロバイダーにヘルスステータスが表示されます：\n\n| バッジ | 色 | 説明 |\n|------|------|------|\n| 健康 | 緑 | 連続失敗回数 0 |\n| 警告 | 黄 | 連続失敗回数 1-2 |\n| 不健康 | 赤 | 連続失敗回数 ≥3、サーキットブレーカーが発動する可能性あり |\n\n\n## システムトレイ\n\nCC Switch はシステムトレイにアイコンを表示し、クイック操作の入口を提供します。\n\n### トレイメニュー構造\n\n![image-20260108002153668](../../assets/image-20260108002153668.png)\n\n### メニュー機能\n\n| メニュー項目 | 機能 |\n|--------|------|\n| メインウィンドウを開く | メインウィンドウを表示してフォーカス |\n| アプリグループ | Claude/Codex/Gemini/OpenCode/OpenClaw ごとにプロバイダーを表示 |\n| プロバイダーリスト | クリックで切り替え、現在有効なものにはチェックマークを表示 |\n| 終了 | アプリを完全に終了 |\n\n### 多言語対応\n\nトレイメニューは 3 つの言語に対応し、設定に応じて自動的に切り替わります：\n\n| 言語 | メインウィンドウを開く | 終了 |\n|------|-----------|------|\n| 中文 | 打开主界面 | 退出 |\n| English | Open main window | Quit |\n| 日本語 | メインウィンドウを開く | 終了 |\n\n### 使用シーン\n\nトレイからのプロバイダー切り替えはメイン画面を開く必要がなく、以下の場面に適しています：\n\n- 頻繁にプロバイダーを切り替える場合\n- メインウィンドウが最小化されているときの素早い操作\n- バックグラウンド実行中の設定管理\n\n## 設定ページ\n\n設定ページは複数のタブに分かれています：\n\n### 一般タブ\n\n- 言語設定（中文/English/日本語）\n- テーマ設定（システムに合わせる/ライト/ダーク）\n- ウィンドウ動作（起動時に自動実行、閉じる動作）\n\n### 詳細タブ\n\n- 設定ディレクトリの設定\n- プロキシサービスの設定\n- フェイルオーバーの設定\n- 料金設定\n- データのインポート/エクスポート\n\n### 使用量タブ\n\n- リクエスト統計の概要\n- トレンドグラフ\n- リクエストログ\n- プロバイダー/モデル統計\n\n### バージョン情報タブ\n\n- バージョン情報\n- 更新の確認\n- オープンソースライセンス\n\n## ショートカットキー\n\n| ショートカット | 機能 |\n|--------|------|\n| `Cmd/Ctrl + ,` | 設定を開く |\n| `Cmd/Ctrl + F` | プロバイダーを検索 |\n| `Esc` | ダイアログ/検索を閉じる |\n\n## 検索機能\n\n`Cmd/Ctrl + F` で検索ボックスを開きます：\n\n- 名前、メモ、URL で検索可能\n- プロバイダーリストをリアルタイムでフィルタリング\n- `Esc` で検索を閉じる\n"
  },
  {
    "path": "docs/user-manual/ja/1-getting-started/1.4-quickstart.md",
    "content": "# 1.4 クイックスタート\n\nこのセクションでは、5 分で初回設定を完了する方法を説明します。\n\n## ステップ 1：プロバイダーの追加\n\n1. メイン画面右上の **+** ボタンをクリック\n2. 「プリセット」ドロップダウンからプロバイダーを選択\n   - よく使われるプリセット：智谱 GLM、MiniMax、DeepSeek、Kimi、PackyCode\n   - または「カスタム」を選択して手動設定\n3. **API Key** を入力\n4. 「追加」をクリック\n\n![image-20260108002807657](../../assets/image-20260108002807657.png)\n\n> **ヒント**：プリセットではエンドポイントアドレスが自動入力されるため、API Key のみ入力すれば使用できます。\n\n## ステップ 2：プロバイダーの切り替え\n\n追加が完了すると、プロバイダーがリストに表示されます。\n\n**方法 1：メイン画面で切り替え**\n- プロバイダーカードの「有効化」ボタンをクリック\n\n**方法 2：トレイで素早く切り替え**\n- システムトレイアイコンを右クリック\n- プロバイダー名を直接クリック\n\n## ステップ 3：反映方法\n\nプロバイダーを切り替えた後、各 CLI ツールの反映方法は異なります：\n\n| アプリ | 反映方法 |\n|------|----------|\n| Claude Code | 即時反映（ホットリロード対応） |\n| Codex | ターミナルを閉じて再度開く必要あり |\n| Gemini | 即時反映（リクエストごとに設定を再読み込み） |\n\n### Claude Code の初回インストール時の注意\n\nClaude Code を初めて起動するときに**ログイン**の要求や初期化ガイドが表示される場合は、CC Switch で「Claude Code の初回確認をスキップ」オプションを有効にしてください：\n\n1. CC Switch の「設定 → 一般」を開く\n2. 「Claude Code の初回確認をスキップ」スイッチをオンにする\n3. Claude Code を再起動\n\n![image-20260108002626389](../../assets/image-20260108002626389.png)\n\n> **注意**：このオプションは `~/.claude/settings.json` の `skipIntroduction` フィールドに書き込まれ、公式の初回ガイドフローをスキップします。\n\n## 設定の確認\n\n再起動後、対応する CLI ツールを起動して簡単な質問でテストします：\n\n```bash\n# Claude Code - 起動後にテスト質問を入力\nclaude\n> こんにちは、簡単に自己紹介してください\n\n# Codex - 起動後にテスト質問を入力\ncodex\n> こんにちは、簡単に自己紹介してください\n\n# Gemini - 起動後にテスト質問を入力\ngemini\n> こんにちは、簡単に自己紹介してください\n```\n\nAI が正常に回答すれば、設定は成功です。\n\n## 次のステップ\n\n基本設定が完了しました。次に以下のことができます：\n\n- [プロバイダーの追加](../2-providers/2.1-add.md) - 複数のプロバイダーを設定して簡単に切り替え\n- [MCP サーバーの設定](../3-extensions/3.1-mcp.md) - AI ツールの機能を拡張\n- [システムプロンプトの設定](../3-extensions/3.2-prompts.md) - AI の動作をカスタマイズ\n- [プロキシサービスの有効化](../4-proxy/4.1-service.md) - 使用量の監視と自動フェイルオーバー\n\n## よくある質問\n\n### 切り替えても反映されない場合\n\nターミナルまたは CLI ツールを再起動してください。設定ファイルは切り替え時に更新されますが、実行中のプログラムは自動的に設定を再読み込みしません。\n\n### プリセットが見つからない場合\n\nプロバイダーがプリセットリストにない場合は、「カスタム」を選択して手動設定してください。設定形式については [プロバイダーの追加](../2-providers/2.1-add.md) を参照してください。\n\n### 公式ログインに戻すには\n\n「公式ログイン」プリセット（Claude/Codex）または「Google 公式」プリセット（Gemini）を選択し、クライアントを再起動してログインフローに従ってください。\n"
  },
  {
    "path": "docs/user-manual/ja/1-getting-started/1.5-settings.md",
    "content": "# 1.5 個人設定\n\nこのセクションでは、個人の好みに合わせて CC Switch を設定する方法を説明します。\n\n## 設定を開く\n\n- 左上の **⚙️** ボタンをクリック\n- またはショートカット `Cmd/Ctrl + ,`\n\n## 言語設定\n\nCC Switch は 3 つの言語に対応しています：\n\n| 言語     | 説明     |\n| -------- | -------- |\n| 簡体中文 | デフォルト言語 |\n| English  | 英語インターフェース |\n| 日本語   | 日本語インターフェース |\n\n言語を切り替えると即座に反映され、再起動は不要です。\n\n## テーマ設定\n\n| オプション | 説明                        |\n| -------- | --------------------------- |\n| システムに合わせる | システムのダーク/ライトモードに自動的に合わせる |\n| ライト     | 常にライトテーマを使用            |\n| ダーク     | 常にダークテーマを使用            |\n\n## ウィンドウ動作\n\n### 起動時に自動実行\n\n有効にすると、システム起動時に CC Switch が自動的に起動します。\n\n- **Windows**：レジストリを使用\n- **macOS**：LaunchAgent を使用\n- **Linux**：XDG autostart を使用\n\n### 閉じる動作\n\n| オプション         | 説明                         |\n| ------------ | ---------------------------- |\n| トレイへ最小化 | 閉じるボタンをクリックするとシステムトレイに隠す |\n| 直接終了     | 閉じるボタンをクリックするとアプリを完全に終了   |\n\nトレイからプロバイダーを素早く切り替えられるため、「トレイへ最小化」の使用を推奨します。\n\n### Claude プラグイン連携\n\n有効にすると、CC Switch はプロバイダー切り替え時に VS Code の Claude Code 拡張に設定を自動同期します（`~/.claude/config.json` の `primaryApiKey` に書き込み）。\n\n> **使用シーン**：Claude Code CLI と VS Code プラグインを同時に使用する場合、このオプションを有効にすると両方の設定を一致させることができます。\n\n### Claude 初回ガイドのスキップ\n\n有効にすると、Claude Code の初回ガイドフローをスキップします。Claude Code に慣れているユーザー向けです。\n\n> **注意**：このオプションは `~/.claude/settings.json` の `skipIntroduction` フィールドに書き込まれます。\n\n### アプリの表示設定\n\nアプリ切り替えに表示するアプリを選択します。各アプリを個別にオン/オフできますが、少なくとも 1 つは有効にする必要があります。\n\n設定可能なアプリ：Claude、Codex、Gemini、OpenCode、OpenClaw。\n\n> **使用シーン**：Claude Code と Codex CLI のみを使用する場合、他のアプリを非表示にしてインターフェースをシンプルに保てます。\n\n### Skills 同期方式\n\nスキルを各アプリディレクトリにインストールする際の同期方式を設定します：\n\n| 方式              | 説明                                                 |\n| ----------------- | ---------------------------------------------------- |\n| シンボリックリンク | スキルのソースファイルへのシンボリックリンクを作成、容量が少なく、リアルタイム同期 |\n| ファイルコピー      | スキルファイルをターゲットディレクトリに完全コピー                         |\n\n> **推奨**：デフォルトではシンボリックリンク方式を使用します。権限の問題が発生する場合は、コピー方式に切り替えてください。\n\n### ターミナル設定\n\nCC Switch がターミナルを開く際に使用するターミナルアプリケーションを選択します。\n\n対応ターミナル（プラットフォーム別）：\n\n| プラットフォーム    | ターミナル選択肢                                                           |\n| ------- | ------------------------------------------------------------------ |\n| macOS   | Terminal、iTerm2、Alacritty、Kitty、Ghostty、WezTerm               |\n| Windows | CMD、PowerShell、Windows Terminal                                  |\n| Linux   | GNOME Terminal、Konsole、Xfce4 Terminal、Alacritty、Kitty、Ghostty |\n\n## ディレクトリ設定\n\n### アプリ設定ディレクトリ\n\nCC Switch 自体のデータの保存場所で、デフォルトは `~/.cc-switch/` です。\n\n### CLI ツールディレクトリ\n\n各 CLI ツールの設定ディレクトリをカスタマイズできます：\n\n| 設定          | デフォルト値         | 説明                 |\n| ------------- | -------------- | -------------------- |\n| Claude ディレクトリ   | `~/.claude/`   | Claude Code 設定ディレクトリ |\n| Codex ディレクトリ    | `~/.codex/`    | Codex 設定ディレクトリ       |\n| Gemini ディレクトリ   | `~/.gemini/`   | Gemini CLI 設定ディレクトリ  |\n| OpenCode ディレクトリ | `~/.opencode/` | OpenCode 設定ディレクトリ    |\n| OpenClaw ディレクトリ | `~/.openclaw/` | OpenClaw 設定ディレクトリ    |\n\n> **注意**：ディレクトリを変更した後はアプリの再起動が必要で、対応する CLI ツールも同じディレクトリを設定する必要があります。\n\n## データ管理\n\n### 設定のエクスポート\n\n「エクスポート」ボタンをクリックして、以下の内容を含むバックアップファイルを保存します：\n\n- すべてのプロバイダー設定\n- MCP サーバー設定\n- Prompts プリセット\n- アプリ設定\n\nバックアップファイルは JSON 形式で、テキストエディタで確認できます。\n\n### 設定のインポート\n\n1. 「ファイルを選択」をクリック\n2. 以前にエクスポートしたバックアップファイルを選択\n3. 「インポート」をクリック\n4. 既存の設定の上書きを確認\n\n> **注意**：インポートは既存の設定を上書きするため、事前に現在の設定をエクスポートしてバックアップすることをお勧めします。\n\n## プロキシ設定\n\n設定 → プロキシ タブ\n\nプロキシ タブではすべてのプロキシ関連機能を集中管理します：\n\n### ローカルプロキシ\n\nローカルプロキシサービスの起動/停止、リスニングアドレスとポートの設定。詳しくは [4.1 プロキシサービス](../4-proxy/4.1-service.md) をご覧ください。\n\n### フェイルオーバー\n\nアプリ（Claude/Codex/Gemini）ごとにフェイルオーバーキューと自動切り替え戦略を設定。詳しくは [4.3 フェイルオーバー](../4-proxy/4.3-failover.md) をご覧ください。\n\n### 料金補正器\n\nモデル料金補正ルールを設定し、プロキシの課金統計を調整します。\n\n### グローバル送信プロキシ\n\nCC Switch の送信 HTTP/HTTPS プロキシを設定します。外部 API にプロキシ経由でアクセスする必要がある場合に使用します。\n\n## 詳細設定\n\n設定 → 詳細 タブ\n\n### 設定ディレクトリ\n\n各アプリの設定ファイルディレクトリをカスタマイズ。下記の「ディレクトリ設定」セクションを参照してください。\n\n### データ管理\n\n設定バックアップのインポート/エクスポート。下記の「データ管理」セクションを参照してください。\n\n### バックアップと復元\n\n自動バックアップの管理：\n\n| 設定     | 説明                       |\n| -------- | -------------------------- |\n| バックアップ間隔 | 自動バックアップの時間間隔（時間） |\n| 保持数量 | 保持するバックアップの数             |\n\nバックアップリストの表示とバックアップからの復元をサポートします。\n\n### クラウド同期（WebDAV）\n\nWebDAV プロトコルを使用して複数のデバイス間で設定を同期します。\n\n| 設定項目   | 説明                                  |\n| -------- | ------------------------------------- |\n| サービスプリセット | 坚果云 / Nextcloud / Synology / カスタム    |\n| サーバー URL | WebDAV サーバー URL                     |\n| ユーザー名   | ログインユーザー名                            |\n| パスワード   | ログインパスワード（アプリ専用パスワード）              |\n| リモートディレクトリ | リモート保存パス（デフォルト `cc-switch-sync`） |\n| プロファイル名 | デバイスプロファイル名（デフォルト `default`）      |\n| 自動同期 | 有効にすると変更を自動アップロード                    |\n\n操作：\n\n- **接続テスト**：WebDAV 設定が正しいか確認\n- **保存**：設定を保存して自動テスト\n- **アップロード**：ローカルデータをリモートにアップロード\n- **ダウンロード**：リモートからローカルにデータをダウンロード\n\n> **注意**：アップロードはリモートデータを、ダウンロードはローカルデータを上書きします。操作前にご確認ください。\n\n### ログ設定\n\n| 設定項目   | 説明                                |\n| -------- | ----------------------------------- |\n| ログを有効化 | アプリのログ記録のオン/オフ               |\n| ログレベル | error / warn / info / debug / trace |\n\nログレベルの説明：\n\n- **error** - エラーのみ記録\n- **warn** - 警告とエラーを記録\n- **info** - 一般情報を記録（推奨）\n- **debug** - デバッグ情報を記録\n- **trace** - すべての詳細情報を記録\n\n## バージョン情報ページ\n\n設定 → バージョン情報 タブ\n\n### バージョン情報\n\n現在の CC Switch バージョン番号を表示し、以下をサポートします：\n\n- リリースノートの表示\n- 更新の確認\n- 新バージョンのダウンロードとインストール\n\n### ローカル環境チェック\n\nインストール済みの CLI ツールのバージョンを自動検出：\n\n| ツール     | 検出内容           |\n| -------- | ------------------ |\n| Claude   | 現在のバージョン、最新バージョン |\n| Codex    | 現在のバージョン、最新バージョン |\n| Gemini   | 現在のバージョン、最新バージョン |\n| OpenCode | 現在のバージョン、最新バージョン |\n| OpenClaw | 現在のバージョン、最新バージョン |\n\n「更新」ボタンをクリックして再検出できます。\n\n### ワンクリックインストールコマンド\n\nCLI ツールを素早くインストール/更新するコマンドを提供：\n\n```bash\nnpm i -g @anthropic-ai/claude-code@latest\nnpm i -g @openai/codex@latest\nnpm i -g @google/gemini-cli@latest\nnpm i -g opencode@latest\nnpm i -g openclaw@latest\n```\n\n「コピー」ボタンでクリップボードにコピーできます。\n"
  },
  {
    "path": "docs/user-manual/ja/2-providers/2.1-add.md",
    "content": "# 2.1 プロバイダーの追加\n\n## 追加パネルを開く\n\nメイン画面右上の **+** ボタンをクリックして、プロバイダー追加パネルを開きます。\n\nパネルは 2 つのタブに分かれています：\n- **アプリ専用プロバイダー**：現在選択中のアプリ（Claude/Codex/Gemini/OpenCode/OpenClaw）専用\n- **統一プロバイダー**：アプリ間で共有する設定\n\n## プリセットで追加\n\nプリセットは事前に設定されたプロバイダーテンプレートで、API Key を入力するだけで使用できます。\n\n### 操作手順\n\n1. 「プリセット」ドロップダウンからプロバイダーを選択\n2. 名前とエンドポイントが自動入力される\n3. **API Key** を入力\n4. （任意）メモを入力\n5. 「追加」をクリック\n\n### 主なプリセット\n\n#### Claude プリセット\n\n| プリセット名 | 説明 |\n|----------|------|\n| Claude 公式 | Anthropic 公式アカウントでログイン |\n| DeepSeek | DeepSeek モデル |\n| 智谱 GLM | 智谱 AI の GLM モデル |\n| 智谱 GLM en | 智谱 AI（英語版） |\n| 百炼 | アリクラウド百炼（通义千問） |\n| Kimi | Moonshot Kimi モデル |\n| Kimi For Coding | Kimi プログラミング専用モデル |\n| StepFun | StepFun モデル |\n| ModelScope | 魔搭コミュニティ |\n| KAT-Coder | KAT-Coder モデル |\n| Longcat | Longcat AI |\n| MiniMax | MiniMax モデル |\n| MiniMax en | MiniMax（英語版） |\n| DouBaoSeed | 豆包 Seed モデル |\n| BaiLing | 百灵 AI |\n| AiHubMix | AiHubMix 統合サービス |\n| SiliconFlow | SiliconFlow |\n| SiliconFlow en | SiliconFlow（英語版） |\n| DMXAPI | DMXAPI 中継サービス |\n| PackyCode | PackyCode 中継サービス ⭐ |\n| Cubence | Cubence サービス |\n| AIGoCode | AIGoCode サービス |\n| RightCode | RightCode サービス |\n| AICodeMirror | AICodeMirror サービス |\n| OpenRouter | 統合ルーティングサービス |\n| Nvidia | Nvidia AI サービス |\n| Xiaomi MiMo | Xiaomi MiMo モデル |\n\n> ⭐ は公式パートナーを示します。プリセットリストはバージョンの更新に伴い変更される場合があります。アプリ内の実際の表示を基準にしてください。\n\n#### Codex プリセット\n\n| プリセット名 | 説明 |\n|----------|------|\n| OpenAI 公式 | OpenAI 公式アカウントでログイン |\n| Azure OpenAI | Azure OpenAI サービス |\n| AiHubMix | AiHubMix 統合サービス |\n| DMXAPI | DMXAPI 中継サービス |\n| PackyCode | PackyCode 中継サービス |\n| Cubence | Cubence サービス |\n| AIGoCode | AIGoCode サービス |\n| RightCode | RightCode サービス |\n| AICodeMirror | AICodeMirror サービス |\n| OpenRouter | 統合ルーティングサービス |\n\n#### Gemini プリセット\n\n| プリセット名 | 説明 |\n|----------|------|\n| Google 公式 | Google OAuth でログイン |\n| PackyCode | PackyCode 中継サービス |\n| Cubence | Cubence サービス |\n| AIGoCode | AIGoCode サービス |\n| AICodeMirror | AICodeMirror サービス |\n| OpenRouter | 統合ルーティングサービス |\n| カスタム | すべてのパラメータを手動設定 |\n\n#### OpenCode プリセット\n\n| プリセット名 | 説明 |\n|----------|------|\n| DeepSeek | DeepSeek モデル |\n| 智谱 GLM | 智谱 AI の GLM モデル |\n| 智谱 GLM en | 智谱 AI（英語版） |\n| 百炼 | アリクラウド百炼 |\n| Kimi k2.5 | Moonshot Kimi-k2.5 モデル |\n| Kimi For Coding | Kimi プログラミング専用モデル |\n| StepFun | StepFun モデル |\n| ModelScope | 魔搭コミュニティ |\n| KAT-Coder | KAT-Coder モデル |\n| Longcat | Longcat AI |\n| MiniMax | MiniMax モデル |\n| MiniMax en | MiniMax（英語版） |\n| DouBaoSeed | 豆包 Seed モデル |\n| BaiLing | 百灵 AI |\n| Xiaomi MiMo | Xiaomi MiMo モデル |\n| AiHubMix | AiHubMix 統合サービス |\n| DMXAPI | DMXAPI 中継サービス |\n| OpenRouter | 統合ルーティングサービス |\n| Nvidia | Nvidia AI サービス |\n| PackyCode | PackyCode 中継サービス |\n| Cubence | Cubence サービス |\n| AIGoCode | AIGoCode サービス |\n| RightCode | RightCode サービス |\n| AICodeMirror | AICodeMirror サービス |\n| OpenAI Compatible | OpenAI 互換インターフェース |\n| Oh My OpenCode | Oh My OpenCode サービス |\n\n> プリセットリストは継続的に更新されています。アプリ内の実際の表示を基準にしてください。\n\n#### OpenClaw プリセット\n\n| プリセット名 | 説明 |\n|----------|------|\n| DeepSeek | DeepSeek モデル |\n| 智谱 GLM | 智谱 AI の GLM モデル |\n| 智谱 GLM en | 智谱 AI（英語版） |\n| Qwen Coder | 通义千問コーディングモデル |\n| Kimi k2.5 | Moonshot Kimi-k2.5 モデル |\n| Kimi For Coding | Kimi プログラミング専用モデル |\n| StepFun | StepFun モデル |\n| MiniMax | MiniMax モデル |\n| MiniMax en | MiniMax（英語版） |\n| KAT-Coder | KAT-Coder モデル |\n| Longcat | Longcat AI |\n| DouBaoSeed | 豆包 Seed モデル |\n| BaiLing | 百灵 AI |\n| Xiaomi MiMo | Xiaomi MiMo モデル |\n| AiHubMix | AiHubMix 統合サービス |\n| DMXAPI | DMXAPI 中継サービス |\n| OpenRouter | 統合ルーティングサービス |\n| ModelScope | 魔搭コミュニティ |\n| SiliconFlow | SiliconFlow |\n| SiliconFlow en | SiliconFlow（英語版） |\n| Nvidia | Nvidia AI サービス |\n| PackyCode | PackyCode 中継サービス |\n| Cubence | Cubence サービス |\n| AIGoCode | AIGoCode サービス |\n| RightCode | RightCode サービス |\n| AICodeMirror | AICodeMirror サービス |\n| AICoding | AICoding サービス |\n| CrazyRouter | CrazyRouter サービス |\n| SSSAiCode | SSSAiCode サービス |\n| AWS Bedrock | AWS Bedrock サービス |\n| OpenAI Compatible | OpenAI 互換インターフェース |\n\n## カスタム設定\n\n「カスタム」プリセットを選択した場合、JSON 設定を手動で編集する必要があります。\n\n### Claude 設定形式\n\n```json\n{\n  \"env\": {\n    \"ANTHROPIC_API_KEY\": \"your-api-key\",\n    \"ANTHROPIC_BASE_URL\": \"https://api.example.com\"\n  }\n}\n```\n\n| フィールド | 必須 | 説明 |\n|------|------|------|\n| `ANTHROPIC_API_KEY` | はい | API キー |\n| `ANTHROPIC_BASE_URL` | いいえ | カスタムエンドポイントアドレス |\n| `ANTHROPIC_AUTH_TOKEN` | いいえ | API_KEY の代替認証方式 |\n\n### Codex 設定形式\n\nCodex は 2 つの設定ファイルを使用します：\n\n**1. auth.json**（`~/.codex/auth.json`）- API キーを保存：\n\n```json\n{\n  \"OPENAI_API_KEY\": \"your-api-key\"\n}\n```\n\n**2. config.toml**（`~/.codex/config.toml`）- モデルとエンドポイントの設定を保存：\n\n```toml\n# 基本設定\nmodel_provider = \"custom\"\nmodel = \"gpt-5.2\"\nmodel_reasoning_effort = \"high\"\ndisable_response_storage = true\n\n# カスタムプロバイダー設定\n[model_providers.custom]\nname = \"custom\"\nbase_url = \"https://api.example.com/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n```\n\n**auth.json フィールド説明**：\n\n| フィールド | 必須 | 説明 |\n|------|------|------|\n| `OPENAI_API_KEY` | はい | API キー |\n\n**config.toml フィールド説明**：\n\n| フィールド | 必須 | 説明 |\n|------|------|------|\n| `model_provider` | はい | モデルプロバイダー名（`[model_providers.xxx]` と一致する必要あり） |\n| `model` | はい | 使用するモデル（例：`gpt-5.2`、`gpt-4o`） |\n| `model_reasoning_effort` | いいえ | 推論強度：`low` / `medium` / `high` |\n| `disable_response_storage` | いいえ | レスポンス保存を無効にするかどうか |\n| `base_url` | はい | API エンドポイントアドレス |\n| `wire_api` | いいえ | API プロトコルタイプ（通常 `responses`） |\n| `requires_openai_auth` | いいえ | OpenAI 認証方式を使用するかどうか |\n\n\n### Gemini 設定形式\n\n```json\n{\n  \"env\": {\n    \"GEMINI_API_KEY\": \"your-api-key\",\n    \"GOOGLE_GEMINI_BASE_URL\": \"https://api.example.com\"\n  }\n}\n```\n\n| フィールド | 必須 | 説明 |\n|------|------|------|\n| `GEMINI_API_KEY` | はい | API キー |\n| `GOOGLE_GEMINI_BASE_URL` | いいえ | カスタムエンドポイントアドレス |\n| `GEMINI_MODEL` | いいえ | モデルの指定 |\n\n> 認証タイプは CC Switch が自動的に検出します（PackyCode API プロキシ / Google OAuth / 汎用 API Key）。手動設定は不要です。\n\n## 統一プロバイダー\n\n統一プロバイダーは Claude/Codex/Gemini/OpenCode/OpenClaw 間で設定を共有でき、複数の API 形式をサポートする中継サービスに適しています。\n\n### 統一プロバイダーの作成\n\n1. 「統一プロバイダー」タブに切り替え\n2. 「統一プロバイダーを追加」をクリック\n3. 共通設定を入力：\n   - 名前\n   - API Key\n   - エンドポイントアドレス\n4. 同期するアプリにチェック（Claude/Codex/Gemini/OpenCode/OpenClaw）\n5. 保存\n\n### 同期の仕組み\n\n統一プロバイダーはチェックしたアプリに自動的に同期されます：\n\n- 統一プロバイダーを変更すると、関連するすべてのアプリの設定が同期更新される\n- 統一プロバイダーを削除すると、関連するアプリの設定も削除される\n\n### 保存して同期\n\n統一プロバイダーの編集時に選択できます：\n\n| 操作 | 説明 |\n|------|------|\n| 保存 | 設定のみ保存、すぐに同期しない |\n| 保存して同期 | 設定を保存し、有効なすべてのアプリに即座に同期 |\n\n### 手動同期\n\n手動で同期をトリガーする場合：\n\n1. 統一プロバイダーカードの「同期」ボタンをクリック\n2. 同期操作を確認\n3. 各アプリの関連プロバイダーの設定が上書きされる\n\n## プロバイダーのインポート\n\nCC Switch は 2 つの方法でプロバイダー設定をインポートできます：\n\n### 方法 1：ディープリンクでインポート\n\n`ccswitch://` プロトコルリンクでワンクリックインポート：\n\n1. ディープリンクをクリックまたはアクセス\n2. CC Switch が自動的に開き、インポート確認を表示\n3. 設定情報をプレビュー\n4. 「インポートを確認」をクリック\n\n**ディープリンクの取得方法**：\n- 他の人からの共有で取得\n- [オンライン生成ツール](https://farion1231.github.io/cc-switch/deplink.html) で作成\n\n### 方法 2：データベースバックアップからインポート\n\nSQL バックアップファイルから一括インポート：\n\n1. 「設定 → 詳細 → データ管理」を開く\n2. 「ファイルを選択」をクリック\n3. 以前にエクスポートした `.sql` バックアップファイルを選択\n4. 「インポート」をクリック\n5. 既存の設定の上書きを確認\n\n**インポート内容**：\n- すべてのプロバイダー設定\n- MCP サーバー設定\n- Prompts プリセット\n- 使用量ログ\n\n> **注意**：インポートは既存のデータベースを上書きするため、事前に現在の設定をエクスポートしてバックアップすることをお勧めします。エクスポートファイル名の形式は `cc-switch-export-{タイムスタンプ}.sql` です。\n\n## 高度なオプション\n\n### カスタムアイコン\n\n名前の左側にあるアイコンエリアをクリックすると：\n\n- プリセットアイコンを選択\n- アイコンの色をカスタマイズ\n\n### Web サイトリンク\n\nプロバイダーの公式サイトやコンソールのアドレスを入力して、素早くアクセスできます：\n\n- プロバイダーカードのリンクアイコンをクリックすると直接開く\n- 残額の確認や API Key の取得などに使用\n\n### メモ\n\n以下のようなメモ情報を追加できます：\n\n- アカウントの用途（個人/仕事）\n- プランの情報\n- 有効期限\n\nメモはプロバイダーカードに表示され、検索にも対応しています。\n\n### エンドポイント速度テスト\n\nプロバイダーを追加した後、API エンドポイントの速度テストができます：\n\n1. プロバイダーカードの「テスト」ボタンをクリック\n2. テストパネルで複数のエンドポイント URL を追加\n3. 「テスト」をクリックして実行\n4. レイテンシが最も低いエンドポイントを選択\n\n**テスト結果**：\n- 緑：レイテンシ < 500ms（優秀）\n- 黄：レイテンシ 500-1000ms（普通）\n- 赤：レイテンシ > 1000ms（遅い）\n\n![image-20260108005327817](../../assets/image-20260108005327817.png)\n"
  },
  {
    "path": "docs/user-manual/ja/2-providers/2.2-switch.md",
    "content": "# 2.2 プロバイダーの切り替え\n\n## メイン画面での切り替え\n\nプロバイダーリストで、対象のプロバイダーカードの「有効化」ボタンをクリックします。\n\n### 切り替えフロー\n\n1. 「有効化」ボタンをクリック\n2. CC Switch が設定ファイルを更新\n3. カードのステータスが「現在有効」に変更\n4. Claude/Gemini は即時反映、Codex はターミナルの再起動が必要\n\n### ステータス表示\n\n| ステータス | 表示 | 説明 |\n|------|------|------|\n| 現在有効 | 青い枠 + ラベル | 設定ファイル内の現在のプロバイダー |\n| プロキシアクティブ | 緑の枠 | プロキシモードで実際に使用中のプロバイダー |\n| 通常 | デフォルトのスタイル | 有効化されていないプロバイダー |\n\n## トレイでの素早い切り替え\n\nシステムトレイから素早く切り替えられ、メイン画面を開く必要がありません。\n\n### 操作手順\n\n1. システムトレイの CC Switch アイコンを右クリック\n2. メニューで対応するアプリ（Claude/Codex/Gemini/OpenCode）を見つける\n3. 切り替えたいプロバイダー名をクリック\n4. 切り替え完了、トレイに短い通知が表示\n\n### トレイメニュー構造\n\n![image-20260108004348993](../../assets/image-20260108004348993.png)\n\n## 反映方法\n\n### Claude Code\n\n**切り替え後に即時反映**、再起動は不要です。\n\nClaude Code はホットリロードに対応しており、設定ファイルの変更を自動検出して再読み込みします。\n\n### Codex\n\n切り替え後は再起動が必要：\n- 現在のターミナルウィンドウを閉じる\n- ターミナルを再度開く\n\n### Gemini CLI\n\n**切り替え後に即時反映**、再起動は不要です。\n\nGemini CLI はリクエストごとに `.env` ファイルを再読み込みします。\n\n## 設定ファイルの変更\n\nプロバイダーを切り替える際、CC Switch は以下のファイルを変更します：\n\n### Claude\n\n```\n~/.claude/settings.json\n```\n\n変更内容：\n```json\n{\n  \"env\": {\n    \"ANTHROPIC_API_KEY\": \"新しい API Key\",\n    \"ANTHROPIC_BASE_URL\": \"新しいエンドポイント\"\n  }\n}\n```\n\n### Codex\n\n```\n~/.codex/auth.json\n~/.codex/config.toml（追加設定がある場合）\n```\n\n### Gemini\n\n```\n~/.gemini/.env\n~/.gemini/settings.json\n```\n\n## 切り替え失敗時の対処\n\n切り替えに失敗した場合、考えられる原因：\n\n### 設定ファイルがロックされている\n\n他のプログラムが設定ファイルを使用中です。\n\n**解決方法**：実行中の CLI ツールを閉じてから、再度切り替えを試みてください。\n\n### 権限不足\n\n設定ファイルへの書き込み権限がありません。\n\n**解決方法**：設定ディレクトリの権限設定を確認してください。\n\n### 設定形式エラー\n\nプロバイダー設定の JSON 形式に誤りがあります。\n\n**解決方法**：プロバイダーを編集して、JSON 形式を確認・修正してください。\n"
  },
  {
    "path": "docs/user-manual/ja/2-providers/2.3-edit.md",
    "content": "# 2.3 プロバイダーの編集\n\n## 編集パネルを開く\n\n1. 編集したいプロバイダーカードを見つける\n2. カードにマウスをホバーして操作ボタンを表示\n3. 「編集」ボタンをクリック\n\n## 編集可能な内容\n\n### 基本情報\n\n| フィールド | 説明 |\n|------|------|\n| 名前 | プロバイダーの表示名 |\n| メモ | 付加情報 |\n| Web サイトリンク | プロバイダーの公式サイトまたはコンソールアドレス |\n| アイコン | カスタムアイコンと色 |\n\n### アイコンのカスタマイズ\n\nCC Switch は豊富なアイコンカスタマイズ機能を提供しています：\n\n#### アイコン選択画面\n\n1. アイコンエリアをクリックしてアイコン選択画面を開く\n2. 検索ボックスで名前からアイコンを検索\n3. クリックしてアイコンを選択\n\nアイコンライブラリには一般的な AI サービスプロバイダーと技術アイコンが含まれており、以下をサポートします：\n- 名前によるあいまい検索\n- アイコン名のツールチップ表示\n- 選択結果のリアルタイムプレビュー\n\n![image-20260108004734882](../../assets/image-20260108004734882.png)\n\n### 設定情報\n\nJSON 形式の設定内容（以下を含む）：\n\n- API Key\n- エンドポイントアドレス\n- その他の環境変数\n\n### 現在有効なプロバイダーの編集\n\n現在有効なプロバイダーを編集する場合、特別な「バックフィル」機能があります：\n\n1. 編集パネルを開くと、live 設定ファイルから最新の内容を読み取る\n2. CLI ツールで手動で設定を変更した場合、その変更が同期される\n3. 保存すると、変更が live 設定ファイルに書き込まれる\n\nこれにより、CC Switch と CLI ツールの設定が常に同期されます。\n\n## API Key の変更\n\nプロバイダーの編集時に、**API Key** 入力ボックスから直接変更できます：\n\n1. プロバイダーカードの「編集」ボタンをクリック\n2. 「API Key」入力ボックスに新しいキーを入力\n3. 「保存」をクリック\n\n> **ヒント**：API Key 入力ボックスは表示/非表示の切り替えに対応しています。右側の目のアイコンをクリックするとキーの全文を確認できます。\n\n## エンドポイントアドレスの変更\n\nプロバイダーの編集時に、**エンドポイントアドレス** 入力ボックスから直接変更できます：\n\n1. プロバイダーカードの「編集」ボタンをクリック\n2. 「エンドポイントアドレス」入力ボックスに新しい URL を入力\n3. 「保存」をクリック\n\n### エンドポイントアドレスの形式\n\n| アプリ | 形式の例 |\n|------|----------|\n| Claude | `https://api.example.com` |\n| Codex | `https://api.example.com/v1` |\n| Gemini | `https://api.example.com` |\n\n## カスタムエンドポイントの追加\n\nプロバイダーには複数のエンドポイントを設定でき、以下の用途に使用します：\n\n- 速度テスト時に複数のアドレスをテスト\n- フェイルオーバー時のバックアップエンドポイント\n\n### 自動収集\n\nプロバイダーの追加時に、CC Switch は設定からエンドポイントアドレスを自動抽出します。\n\n### 手動追加\n\nプロバイダーの編集時に、「エンドポイント管理」エリアで以下の操作が可能です：\n\n- 新しいエンドポイントの追加\n- 既存のエンドポイントの削除\n- デフォルトエンドポイントの設定\n\n## JSON エディタ\n\n設定は JSON 形式を使用し、エディタは以下を提供します：\n\n- シンタックスハイライト\n- フォーマット検証\n- エラー通知\n\n### よくあるエラー\n\n**引用符の欠落**：\n```json\n// ❌ 間違い\n{ env: { KEY: \"value\" } }\n\n// ✅ 正しい\n{ \"env\": { \"KEY\": \"value\" } }\n```\n\n**余分なカンマ**：\n```json\n// ❌ 間違い\n{ \"env\": { \"KEY\": \"value\", } }\n\n// ✅ 正しい\n{ \"env\": { \"KEY\": \"value\" } }\n```\n\n**閉じ括弧の欠落**：\n```json\n// ❌ 間違い\n{ \"env\": { \"KEY\": \"value\" }\n\n// ✅ 正しい\n{ \"env\": { \"KEY\": \"value\" } }\n```\n\n## 保存と反映\n\n1. 「保存」ボタンをクリック\n2. 現在有効なプロバイダーの場合、設定は即座に live ファイルに書き込まれる\n3. CLI ツールを再起動して反映\n\n## 編集のキャンセル\n\n「キャンセル」ボタンをクリックするか `Esc` キーを押して編集パネルを閉じると、すべての変更は保存されません。\n"
  },
  {
    "path": "docs/user-manual/ja/2-providers/2.4-sort-duplicate.md",
    "content": "# 2.4 並べ替えと複製\n\n## ドラッグで並べ替え\n\nドラッグでプロバイダーの表示順序を調整します。\n\n### 操作手順\n\n1. プロバイダーカード左側の **≡** ドラッグハンドルにマウスを合わせる\n2. マウスの左ボタンを押し続ける\n3. 目的の位置まで上下にドラッグ\n4. マウスを離して並べ替え完了\n\n### 並べ替えの用途\n\n- **よく使うものを優先**：よく使うプロバイダーをリストの上部に配置\n- **フェイルオーバーの順序**：並び順はフェイルオーバーキューのデフォルト順序に影響\n\n## プロバイダーの複製\n\nプロバイダーのコピーを素早く作成します。以下のような場面に適しています：\n\n- 既存の設定をベースにバリエーションを作成\n- 現在の設定をバックアップ\n- テスト用の設定を作成\n\n### 操作手順\n\n1. プロバイダーカードにマウスをホバーして操作ボタンを表示\n2. 「複製」ボタンをクリック\n3. 名前に `copy` が付加されたコピーが自動作成\n4. コピーを編集して設定を変更\n\n### コピーされる内容\n\nコピーは完全な複製が作成され、以下を含みます：\n\n| 内容 | コピーされるか |\n|------|----------|\n| 名前 | コピーされる（`copy` が付加） |\n| 設定 | 完全にコピー |\n| メモ | コピーされる |\n| Web サイトリンク | コピーされる |\n| アイコン | コピーされる |\n| エンドポイントリスト | コピーされる |\n| 並び順の位置 | 元のプロバイダーの下に挿入 |\n\n### コピー後の編集\n\nコピー完了後、通常は以下を変更します：\n\n1. **名前**：意味のある名前に変更\n2. **API Key**：異なるアカウントの場合\n3. **エンドポイント**：異なるサービスの場合\n\n## プロバイダーの削除\n\n### 操作手順\n\n1. プロバイダーカードにマウスをホバーして操作ボタンを表示\n2. 「削除」ボタンをクリック\n3. 削除を確認\n\n### 削除の確認\n\n削除前に確認ダイアログが表示され、以下が表示されます：\n\n- プロバイダー名\n- 削除後は元に戻せないという注意\n\n### 削除の制限\n\n- **現在有効なプロバイダー**：削除可能ですが、先に他のプロバイダーに切り替えることをお勧めします\n- **統一プロバイダー**：削除すると、関連するアプリの設定も削除されます\n\n![image-20260108004946288](../../assets/image-20260108004946288.png)\n"
  },
  {
    "path": "docs/user-manual/ja/2-providers/2.5-usage-query.md",
    "content": "# 2.5 使用量クエリ\n\n## 機能説明\n\n使用量クエリ機能により、カスタムスクリプトを設定して、プロバイダーの残額や使用量などの情報をリアルタイムでクエリできます。\n\n**使用シーン**：\n- API アカウントの残額確認\n- プランの使用状況の監視\n- 複数プランの残額を集約表示\n\n## 設定を開く\n\n1. プロバイダーカードにマウスをホバーして操作ボタンを表示\n2. 「使用量クエリ」ボタンをクリック\n3. 使用量クエリ設定パネルが開く\n\n## 使用量クエリの有効化\n\n設定パネル上部の「使用量クエリを有効にする」スイッチをオンにします。\n\n## プリセットテンプレート\n\nCC Switch は 3 種類のプリセットテンプレートを提供しています：\n\n### カスタムテンプレート\n\nリクエストと抽出ロジックを完全にカスタマイズします。特殊な API 形式に対応します。\n\n### 汎用テンプレート\n\nほとんどの標準的な API 形式のプロバイダーに適しています：\n\n```javascript\n({\n  request: {\n    url: \"{{baseUrl}}/user/balance\",\n    method: \"GET\",\n    headers: {\n      \"Authorization\": \"Bearer {{apiKey}}\",\n      \"User-Agent\": \"cc-switch/1.0\"\n    }\n  },\n  extractor: function(response) {\n    return {\n      isValid: response.is_active || true,\n      remaining: response.balance,\n      unit: \"USD\"\n    };\n  }\n})\n```\n\n**設定パラメータ**：\n| パラメータ | 説明 |\n|------|------|\n| API Key | 認証用のキー（任意、空欄の場合はプロバイダーに設定されたキーを使用） |\n| Base URL | API ベースアドレス（任意、空欄の場合はプロバイダーのエンドポイントを使用） |\n\n### New API テンプレート\n\nNew API タイプの中継サービス専用に設計されています：\n\n```javascript\n({\n  request: {\n    url: \"{{baseUrl}}/api/user/self\",\n    method: \"GET\",\n    headers: {\n      \"Content-Type\": \"application/json\",\n      \"Authorization\": \"Bearer {{accessToken}}\",\n      \"New-Api-User\": \"{{userId}}\"\n    },\n  },\n  extractor: function (response) {\n    if (response.success && response.data) {\n      return {\n        planName: response.data.group || \"デフォルトプラン\",\n        remaining: response.data.quota / 500000,\n        used: response.data.used_quota / 500000,\n        total: (response.data.quota + response.data.used_quota) / 500000,\n        unit: \"USD\",\n      };\n    }\n    return {\n      isValid: false,\n      invalidMessage: response.message || \"クエリ失敗\"\n    };\n  },\n})\n```\n\n**設定パラメータ**：\n| パラメータ | 説明 |\n|------|------|\n| Base URL | New API サービスアドレス |\n| Access Token | アクセストークン |\n| User ID | ユーザー ID |\n\n## 共通設定\n\n### タイムアウト時間\n\nリクエストのタイムアウト時間（秒）、デフォルトは 10 秒。\n\n### 自動クエリ間隔\n\n使用量データの自動更新間隔（分）：\n- `0` に設定すると自動クエリを無効化\n- 範囲：0-1440 分（最長 24 時間）\n- プロバイダーが「現在有効」のときのみ動作\n\n## エクストラクターの戻り値形式\n\nエクストラクター関数は以下のフィールドを含むオブジェクトを返す必要があります：\n\n| フィールド | 型 | 必須 | 説明 |\n|------|------|------|------|\n| `isValid` | boolean | いいえ | アカウントが有効かどうか、デフォルト true |\n| `invalidMessage` | string | いいえ | 無効時の通知メッセージ |\n| `remaining` | number | はい | 残額 |\n| `unit` | string | はい | 単位（例：USD、CNY、回） |\n| `planName` | string | いいえ | プラン名（複数プラン対応） |\n| `total` | number | いいえ | 総額 |\n| `used` | number | いいえ | 使用済み額 |\n| `extra` | object | いいえ | 追加情報 |\n\n## スクリプトのテスト\n\n設定完了後、「スクリプトをテスト」ボタンをクリックして確認します：\n\n1. 設定された URL にリクエストを送信\n2. エクストラクター関数を実行\n3. 結果またはエラー情報を表示\n\n## 表示効果\n\n設定が成功すると、プロバイダーカードに以下が表示されます：\n\n- **単一プラン**：残額を直接表示\n- **複数プラン**：プラン数を表示、クリックで詳細を展開\n\n## 変数プレースホルダー\n\nスクリプト内で以下のプレースホルダーを使用でき、実行時に自動的に置換されます：\n\n| プレースホルダー | 説明 |\n|--------|------|\n| `{{apiKey}}` | 設定された API Key |\n| `{{baseUrl}}` | 設定された Base URL |\n| `{{accessToken}}` | 設定された Access Token（New API） |\n| `{{userId}}` | 設定された User ID（New API） |\n\n## 一般的なプロバイダーの設定例\n\n### トラブルシューティング\n\n### クエリ失敗\n\n**確認事項**：\n1. API Key が正しいか\n2. Base URL が正しいか\n3. ネットワークがアクセス可能か\n4. タイムアウト時間が十分か\n\n### 返却データが空\n\n**確認事項**：\n1. エクストラクター関数に `return` 文があるか\n2. レスポンスのデータ構造がエクストラクターと一致しているか\n3. 「スクリプトをテスト」で生のレスポンスを確認\n\n### フォーマット失敗\n\nスクリプトに構文エラーがある場合、「フォーマット」ボタンをクリックするとエラー箇所が表示されます。\n\n## 注意事項\n\n- 使用量クエリは少量の API リクエスト枠を消費します\n- 頻繁なリクエストを避けるため、適切な自動クエリ間隔を設定してください\n- 機密情報（API Key、Token）はローカルに安全に保存されます\n"
  },
  {
    "path": "docs/user-manual/ja/3-extensions/3.1-mcp.md",
    "content": "# 3.1 MCP サーバー管理\n\n## MCP とは\n\nMCP (Model Context Protocol) は、AI ツールが外部データソースやツールにアクセスできるようにするプロトコルです。MCP サーバーにより、AI は以下のことが可能になります：\n\n- ファイルシステムへのアクセス\n- ネットワークリクエストの実行\n- データベースのクエリ\n- 外部 API の呼び出し\n\n## MCP パネルを開く\n\n上部ナビゲーションバーの **MCP** ボタンをクリックします。\n\n## パネル概要\n\n![image-20260108005723522](../../assets/image-20260108005723522.png)\n\n## MCP サーバーの追加\n\n### プリセットテンプレートを使用\n\n1. 右上の **+** ボタンをクリック\n2. 「プリセット」ドロップダウンからテンプレートを選択\n3. 必要に応じて設定を変更\n4. 「保存」をクリック\n\n![image-20260108005739731](../../assets/image-20260108005739731.png)\n\n### 主なプリセット\n\n| プリセット | パッケージ名 | 機能説明 |\n|------|------|----------|\n| fetch | mcp-server-fetch | HTTP リクエストツール、AI が Web コンテンツを取得可能に |\n| time | @modelcontextprotocol/server-time | 時間ツール、現在の時刻情報を提供 |\n| memory | @modelcontextprotocol/server-memory | メモリツール、AI が情報を保存・検索可能に |\n| sequential-thinking | @modelcontextprotocol/server-sequential-thinking | 思考連鎖ツール、AI の推論能力を強化 |\n| context7 | @upstash/context7-mcp | ドキュメント検索ツール、技術ドキュメントをクエリ |\n\n### カスタム設定\n\n「カスタム」を選択した場合、以下を入力する必要があります：\n\n| フィールド | 必須 | 説明 |\n|------|------|------|\n| サーバー ID | はい | 一意な識別子 |\n| 名前 | いいえ | 表示名 |\n| 説明 | いいえ | 機能の説明 |\n| 転送タイプ | はい | stdio / http / sse |\n| コマンド | はい* | stdio タイプの場合は必須 |\n| 引数 | いいえ | コマンドライン引数 |\n| URL | はい* | http/sse タイプの場合は必須 |\n| Headers | いいえ | http/sse タイプのリクエストヘッダー |\n| 環境変数 | いいえ | サーバーに渡す環境変数 |\n\n## 転送タイプ\n\n### stdio（標準入出力）\n\n最も一般的なタイプで、ローカルプロセスを起動して通信します。\n\n```json\n{\n  \"command\": \"uvx\",\n  \"args\": [\"mcp-server-fetch\"],\n  \"env\": {}\n}\n```\n\n**要件**：\n- 対応するコマンド（例：`uvx`、`npx`）がインストールされている必要あり\n- サーバープログラムが PATH に含まれている必要あり\n\n### http\n\nHTTP プロトコルでリモートサーバーと通信します。\n\n```json\n{\n  \"url\": \"http://localhost:8080/mcp\"\n}\n```\n\n### sse（Server-Sent Events）\n\nSSE プロトコルでサーバーと通信し、リアルタイムプッシュをサポートします。\n\n```json\n{\n  \"url\": \"http://localhost:8080/sse\"\n}\n```\n\n## アプリバインド\n\n各 MCP サーバーは、有効にするアプリを個別に制御できます。\n\n### スイッチの説明\n\n| スイッチ | 作用 | 設定ファイルパス |\n|------|------|--------------|\n| Claude | Claude Code に同期 | `~/.claude.json` の `mcpServers` |\n| Codex | Codex に同期 | `~/.codex/config.toml` の `[mcp_servers]` |\n| Gemini | Gemini CLI に同期 | `~/.gemini/settings.json` の `mcpServers` |\n| OpenCode | OpenCode に同期 | `~/.opencode/config.json` の `mcpServers` |\n\n> **注意**：OpenClaw は現在 MCP サーバー管理に対応していません。MCP 機能は現在 Claude、Codex、Gemini、OpenCode の 4 つのアプリのみサポートしています。\n\n### スイッチの動作\n\nあるアプリのスイッチをオンにすると、CC Switch は以下を実行します：\n\n1. **データベースの更新**：サーバーの `apps.claude/codex/gemini/opencode` のステータスを `true` に設定\n2. **Live 設定に同期**：サーバー設定を対応アプリの設定ファイルに書き込み\n3. **即時反映**：次回 CLI ツール起動時に新しい MCP サーバーが自動的にロード\n\nあるアプリのスイッチをオフにすると、CC Switch は以下を実行します：\n\n1. **データベースの更新**：対応アプリのステータスを `false` に設定\n2. **Live 設定から削除**：アプリの設定ファイルからそのサーバーを削除\n3. **即時反映**：次回 CLI ツール起動時にその MCP サーバーはロードされない\n\n### 同期条件\n\nMCP サーバーの同期は、対応アプリがインストールされている場合のみ実行されます：\n\n- **Claude**：`~/.claude/` ディレクトリまたは `~/.claude.json` ファイルが存在する必要あり\n- **Codex**：`~/.codex/` ディレクトリが存在する必要あり\n- **Gemini**：`~/.gemini/` ディレクトリが存在する必要あり\n- **OpenCode**：`~/.opencode/` ディレクトリが存在する必要あり\n\n> **ヒント**：CLI ツールがインストールされていない場合、対応するスイッチをオンにしてもエラーにはなりませんが、設定は書き込まれません。\n\nスイッチをオフにすると、設定はファイルから削除されます。\n\n## サーバーの編集\n\n1. サーバー行の右側にある「編集」ボタンをクリック\n2. 設定を変更\n3. 「保存」をクリック\n\n変更は有効になっているアプリの設定ファイルに即座に同期されます。\n\n## サーバーの削除\n\n1. サーバー行の右側にある「削除」ボタンをクリック\n2. 削除を確認\n\n削除後、設定はすべてのアプリの設定ファイルから削除されます。\n\n## 既存の設定のインポート\n\nCLI ツールで既に MCP サーバーを設定している場合、CC Switch にインポートできます：\n\n1. 「インポート」ボタンをクリック\n2. インポートするアプリを選択（Claude/Codex/Gemini/OpenCode）\n3. CC Switch が既存の設定を読み取ってインポート\n\n## 設定ファイル形式\n\n### Claude (`~/.claude.json`)\n\n```json\n{\n  \"mcpServers\": {\n    \"mcp-fetch\": {\n      \"command\": \"uvx\",\n      \"args\": [\"mcp-server-fetch\"]\n    }\n  }\n}\n```\n\n### Codex (`~/.codex/config.toml`)\n\n```toml\n[mcp_servers.mcp-fetch]\ncommand = \"uvx\"\nargs = [\"mcp-server-fetch\"]\n```\n\n### Gemini (`~/.gemini/settings.json`)\n\n```json\n{\n  \"mcpServers\": {\n    \"mcp-fetch\": {\n      \"command\": \"uvx\",\n      \"args\": [\"mcp-server-fetch\"]\n    }\n  }\n}\n```\n\n## よくある質問\n\n### サーバーの起動に失敗する\n\n確認事項：\n- コマンドが正しくインストールされているか（例：`uvx`）\n- コマンドが PATH に含まれているか\n- 引数が正しいか\n\n### 設定が反映されない\n\n確認事項：\n- 対応するアプリのスイッチがオンになっているか\n- CLI ツールを再起動したか\n"
  },
  {
    "path": "docs/user-manual/ja/3-extensions/3.2-prompts.md",
    "content": "# 3.2 Prompts プロンプト管理\n\n## 機能説明\n\nPrompts 機能は、システムプロンプトのプリセットを管理します。システムプロンプトは AI の動作や回答スタイルに影響します。\n\nCC Switch を使用すると：\n\n- 複数のプロンプトプリセットを作成\n- さまざまなシーンのプロンプトを素早く切り替え\n- デバイス間でプロンプト設定を同期\n\n## Prompts パネルを開く\n\n上部ナビゲーションバーの **Prompts** ボタンをクリックします。\n\n## パネル概要\n\n![image-20260108010110382](../../assets/image-20260108010110382.png)\n\n## プリセットの作成\n\n### 操作手順\n\n1. 右上の **+** ボタンをクリック\n2. プリセット名を入力\n3. Markdown エディタでプロンプトを作成\n4. 「保存」をクリック\n\n### Markdown エディタ\n\nエディタは以下を提供します：\n\n- シンタックスハイライト\n- リアルタイムプレビュー\n- よく使うフォーマットのショートカットキー\n\n### プロンプトの書き方のヒント\n\n**構造化フォーマット**：\n\n```markdown\n# 役割定義\n\nあなたはプロのコードレビュー専門家です。\n\n## コア能力\n\n- コード品質分析\n- パフォーマンス最適化の提案\n- セキュリティ脆弱性の検出\n\n## 回答スタイル\n\n- 簡潔明瞭\n- 具体的な例を提供\n- 改善提案を提示\n\n## 注意事項\n\n- ビジネスロジックを変更しない\n- コードスタイルの一貫性を保つ\n```\n\n## プリセットの有効化\n\n### 操作方法\n\nプリセット項目のスイッチボタンをクリックして、有効/無効を切り替えます。\n\n### 単一有効化\n\n同時に有効にできるプリセットは 1 つだけです。新しいプリセットを有効にすると、以前のプリセットは自動的に無効になります。\n\n### 同期先\n\n有効化後、プロンプトは対応するアプリのファイルに書き込まれます：\n\n| アプリ | ファイルパス |\n|------|----------|\n| Claude | `~/.claude/CLAUDE.md` |\n| Codex | `~/.codex/AGENTS.md` |\n| Gemini | `~/.gemini/GEMINI.md` |\n| OpenCode | `~/.opencode/AGENTS.md` |\n| OpenClaw | `~/.openclaw/AGENTS.md` |\n\n## プリセットの編集\n\n1. プリセット項目の「編集」ボタンをクリック\n2. 名前や内容を変更\n3. 「保存」をクリック\n\n現在有効なプリセットを編集した場合、保存後に設定ファイルに即座に同期されます。\n\n## プリセットの削除\n\n1. プリセット項目の「削除」ボタンをクリック\n2. 削除を確認\n\n有効になっているプリセットは削除できません。先に無効にしてから削除してください。\n\n## スマートバックフィル\n\nCC Switch は、手動での変更を失わないようにスマートバックフィル保護機能を提供しています。\n\n### 動作原理\n\n1. プリセットを切り替える前に、現在の設定ファイルの内容を自動的に読み取る\n2. ファイルの内容とデータベース内のプリセットを比較\n3. 内容が異なる場合、ユーザーが手動で変更したことを示す\n4. 手動変更の内容を現在のプリセットに保存\n5. その後、新しいプリセットに切り替え\n\n### 保護シーン\n\n| シーン | 処理方法 |\n|------|----------|\n| CLI 内で `CLAUDE.md` を直接編集 | 変更が自動的に現在のプリセットに保存 |\n| 外部エディタで設定ファイルを変更 | 変更が自動的に現在のプリセットに保存 |\n| 別のプリセットに切り替え | 現在の変更を保存してから切り替え |\n\n### 技術的な詳細\n\nバックフィル機能は以下のタイミングでトリガーされます：\n\n- **プリセットの切り替え時**：現在の live ファイルの内容を現在のプリセットに保存\n- **現在のプリセットの編集時**：live ファイルから最新の内容を読み取り\n- **初回起動時**：既存の live ファイルの内容を自動インポート\n\n### 注意事項\n\n- バックフィルは異なるプリセットに切り替えるときにのみトリガーされる\n- 現在有効なプリセットがない場合、バックフィルはトリガーされない\n- バックフィルの失敗は切り替えフローに影響しない\n\n## アプリ間での使用\n\nPrompts はアプリごとに個別に管理されます：\n\n- Claude に切り替えると、Claude のプリセットが表示\n- Codex に切り替えると、Codex のプリセットが表示\n- Gemini に切り替えると、Gemini のプリセットが表示\n- OpenCode に切り替えると、OpenCode のプリセットが表示\n- OpenClaw に切り替えると、OpenClaw のプリセットが表示\n\n複数のアプリで同じプロンプトを使用する場合は、それぞれで作成する必要があります。\n\n## インポート・エクスポート\n\n### ディープリンクで共有\n\nディープリンクを生成してプリセットを共有できます：\n\n```\nccswitch://import/prompt?data=<Base64 エンコードされたプリセット>\n```\n\n### 設定のエクスポートで共有\n\n設定をエクスポートするとすべてのプリセットが含まれ、インポートで復元できます。\n"
  },
  {
    "path": "docs/user-manual/ja/3-extensions/3.3-skills.md",
    "content": "# 3.3 Skills スキル管理\n\n## 機能説明\n\nSkills は再利用可能な機能拡張で、AI ツールに特定分野の専門的な能力を与えます。\n\nスキルはフォルダ形式で存在し、以下を含みます：\n\n- プロンプトテンプレート\n- ツール定義\n- サンプルコード\n\n## 対応アプリ\n\nSkills 機能は以下の 4 つのアプリに対応しています：\n\n- **Claude Code**\n- **Codex**\n- **Gemini CLI**\n- **OpenCode**\n\n## Skills ページを開く\n\n上部ナビゲーションバーの **Skills** ボタンをクリックします。\n\n> 注意：Skills ボタンはすべてのアプリモードで表示されます。\n\n## ページ概要\n\n![image-20260108010253926](../../assets/image-20260108010253926.png)\n\n## スキルの発見\n\n### プリセットリポジトリ\n\nCC Switch は以下の GitHub リポジトリをプリセットとして設定しています：\n\n| リポジトリ           | 説明                     |\n| -------------- | ------------------------ |\n| Anthropic 公式 | Anthropic 提供の公式スキル |\n| ComposioHQ     | コミュニティが管理するスキルコレクション       |\n| コミュニティ精選       | 厳選された高品質スキル         |\n\n![image-20260108010308060](../../assets/image-20260108010308060.png)\n\n### 検索とフィルタリング\n\nCC Switch は強力な検索とフィルタリング機能を提供しています：\n\n#### 検索ボックス\n\n- スキル名で検索\n- スキルの説明で検索\n- ディレクトリ名で検索\n- リアルタイムフィルタリング、入力と同時に検索\n\n#### ステータスフィルタ\n\nドロップダウンメニューでインストール状態別にフィルタリング：\n\n| オプション   | 説明               |\n| ------ | ------------------ |\n| すべて   | すべてのスキルを表示       |\n| インストール済み | インストール済みのスキルのみ表示 |\n| 未インストール | 未インストールのスキルのみ表示 |\n\n![image-20260108010324583](../../assets/image-20260108010324583.png)\n\n#### 組み合わせて使用\n\n検索とフィルタリングは組み合わせて使用できます：\n\n- まず「インストール済み」でフィルタリング\n- 次にキーワードで検索\n- 結果にマッチ数が表示\n\n### リストの更新\n\n「更新」ボタンをクリックしてリポジトリを再スキャンし、最新のスキルを取得します。\n\n## スキルのインストール\n\n### 操作手順\n\n1. インストールしたいスキルカードを見つける\n2. 「インストール」ボタンをクリック\n3. インストール完了を待つ\n\n### インストール先\n\n| アプリ     | インストールディレクトリ              |\n| -------- | --------------------- |\n| Claude   | `~/.claude/skills/`   |\n| Codex    | `~/.codex/skills/`    |\n| Gemini   | `~/.gemini/skills/`   |\n| OpenCode | `~/.opencode/skills/` |\n\n### インストール内容\n\nインストールによりスキルフォルダがローカルにコピーされます：\n\n```\n~/.claude/skills/\n└── skill-name/\n    ├── README.md\n    ├── prompt.md\n    └── tools/\n        └── ...\n```\n\n## スキルのアンインストール\n\n### 操作手順\n\n1. インストール済みのスキルカードを見つける\n2. 「アンインストール」ボタンをクリック\n3. アンインストールを確認\n\n### アンインストールの効果\n\n- ローカルのスキルフォルダを削除\n- インストール状態を更新\n\n## リポジトリ管理\n\n### リポジトリ管理を開く\n\nページ上部の「リポジトリ管理」ボタンをクリックします。\n\n### カスタムリポジトリの追加\n\n1. 「リポジトリを追加」をクリック\n2. リポジトリ情報を入力：\n   - Owner：GitHub ユーザー名または組織名\n   - Name：リポジトリ名\n   - Branch：ブランチ名（デフォルト main）\n   - Subdirectory：スキルがあるサブディレクトリ（任意）\n3. 「追加」をクリック\n\n### リポジトリの形式\n\n```\nhttps://github.com/{owner}/{name}/tree/{branch}/{subdirectory}\n```\n\n例：\n\n```\nOwner: anthropics\nName: claude-skills\nBranch: main\nSubdirectory: skills\n```\n\n### リポジトリの削除\n\n1. リポジトリリストで削除するリポジトリを見つける\n2. 「削除」ボタンをクリック\n3. 削除を確認\n\nリポジトリを削除しても、そのリポジトリのスキルはリストから消えませんが、更新はできなくなります。\n\n## スキルカードの情報\n\n各スキルカードには以下が表示されます：\n\n| 情報 | 説明            |\n| ---- | --------------- |\n| 名前 | スキル名        |\n| 説明 | 機能の説明        |\n| ソース | 所属リポジトリ        |\n| ステータス | インストール済み / 未インストール |\n\n## スキルの更新\n\n現在、自動更新には対応していません。スキルを更新するには：\n\n1. 既存のスキルをアンインストール\n2. リストを更新\n3. 再度インストール\n\n### スキルリストが空の場合\n\n考えられる原因：\n\n- ネットワークの問題で GitHub にアクセスできない\n- リポジトリ設定のエラー\n\n解決方法：\n\n- ネットワーク接続を確認\n- 「更新」をクリックしてリトライ\n- リポジトリ設定を確認\n\n### インストールに失敗する場合\n\n考えられる原因：\n\n- ネットワークの問題\n- ディスク容量不足\n- 権限の問題\n\n解決方法：\n\n- ネットワーク接続を確認\n- ディスク容量を確認\n- ディレクトリの権限を確認\n"
  },
  {
    "path": "docs/user-manual/ja/4-proxy/4.1-service.md",
    "content": "# 4.1 プロキシサービス\n\n## 機能説明\n\nプロキシサービスは、ローカルで HTTP プロキシを起動し、すべての API リクエストをプロキシ経由で転送します。\n\n**主な用途**：\n- リクエストログの記録\n- API 使用量の統計\n- フェイルオーバーのサポート\n- 複数アプリのリクエストを一元管理\n\n## プロキシの起動\n\n### 方法 1：メイン画面のスイッチ\n\nメイン画面上部の **プロキシスイッチ** ボタンをクリックします。\n\nスイッチの状態：\n- 白：プロキシ停止中\n- 緑：プロキシ実行中\n\n![image-20260108011353927](../../assets/image-20260108011353927.png)\n\n### 方法 2：設定ページ\n\n1. 「設定 → 詳細 → プロキシサービス」を開く\n2. 右上のスイッチをクリック\n\n![image-20260108011338922](../../assets/image-20260108011338922.png)\n\n## プロキシ設定\n\n### 基本設定\n\n| 設定項目 | 説明 | デフォルト値 |\n|--------|------|--------|\n| リスニングアドレス | プロキシがバインドする IP アドレス | `127.0.0.1` |\n| リスニングポート | プロキシがリスニングするポート | `15721` |\n| ログを有効化 | リクエストログを記録するかどうか | オン |\n\n### 設定の変更\n\n1. **プロキシサービスを停止**（先に停止する必要あり）\n2. リスニングアドレスまたはポートを変更\n3. 「保存」をクリック\n4. プロキシを再起動\n\n> アドレス/ポートの変更には、先にプロキシサービスの停止が必要です\n\n### リスニングアドレスの説明\n\n| アドレス | 説明 |\n|------|------|\n| `127.0.0.1` | ローカルマシンのみアクセス可能（推奨） |\n| `0.0.0.0` | LAN からのアクセスを許可 |\n\n## 実行状態\n\nプロキシ実行中、パネルには以下の情報が表示されます：\n\n### サービスアドレス\n\n```\nhttp://127.0.0.1:15721\n```\n\n「コピー」ボタンでアドレスをコピーできます。\n\n### 現在のプロバイダー\n\n各アプリが現在使用しているプロバイダーを表示：\n\n```\nClaude: PackyCode\nCodex: AIGoCode\nGemini: Google 公式\n```\n\n### 統計データ\n\n| 指標 | 説明 |\n|------|------|\n| アクティブ接続 | 現在処理中のリクエスト数 |\n| 総リクエスト数 | 起動以来の総リクエスト数 |\n| 成功率 | リクエスト成功の割合（>90% 緑、≤90% 黄） |\n| 実行時間 | プロキシの稼働時間 |\n\n### フェイルオーバーキュー\n\nプロキシパネルにはアプリタイプごとにフェイルオーバーキューが表示されます：\n\n```\nClaude\n├── 1. PackyCode      [使用中] ●\n├── 2. AIGoCode                ●\n└── 3. バックアップ              ○\n\nCodex\n├── 1. AIGoCode       [使用中] ●\n└── 2. バックアップ              ●\n```\n\nキューの説明：\n- 数字は優先順位を示す\n- 「使用中」ラベルは現在使用しているプロバイダーを示す\n- ヘルスバッジはプロバイダーの状態を示す：\n  - 緑：健康（連続失敗 0 回）\n  - 黄：低下（連続失敗 1-2 回）\n  - 赤：不健康（連続失敗 ≥3 回）\n\n## 動作原理\n\n### リクエストフロー\n\n```mermaid\nsequenceDiagram\n    participant CLI as CLI ツール (Claude)\n    participant Proxy as ローカルプロキシ (CC Switch)\n    participant API as API プロバイダー (Anthropic)\n    participant DB as データストレージ (Logger)\n\n    CLI->>Proxy: API リクエストを送信\n    Proxy->>DB: リクエストログの記録/使用量の統計\n    Proxy->>API: リクエストを転送\n    API-->>Proxy: レスポンスを返却\n    Proxy-->>CLI: レスポンスを返却\n```\n\n### 設定の変更\n\nプロキシを起動してアプリケーション接管を有効にすると、CC Switch はアプリの設定を変更します：\n\n**Claude**：\n```json\n{\n  \"env\": {\n    \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\"\n  }\n}\n```\n\n**Codex**：\n```toml\nbase_url = \"http://127.0.0.1:15721/v1\"\n```\n\n**Gemini**：\n```\nGOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15721\n```\n\n## プロキシの停止\n\n### 方法 1：メイン画面のスイッチ\n\nプロキシスイッチボタンをクリックしてオフにします。\n\n### 方法 2：設定ページ\n\nプロキシサービスパネルでスイッチをオフにします。\n\n### 停止後の処理\n\nプロキシの停止時、CC Switch は以下を実行します：\n\n1. アプリの設定を元の状態に復元\n2. リクエストログを保存\n3. すべての接続を閉じる\n\n## ログ記録\n\n### ログの有効化\n\nプロキシパネルの「ログを有効化」スイッチをオンにします。\n\n### ログの内容\n\n各リクエスト記録には以下が含まれます：\n\n| フィールド | 説明 |\n|------|------|\n| 時間 | リクエスト時刻 |\n| アプリ | Claude / Codex / Gemini |\n| プロバイダー | 使用されたプロバイダー |\n| モデル | リクエストされたモデル |\n| Token | 入力/出力の Token 数 |\n| レイテンシ | リクエストにかかった時間 |\n| ステータス | 成功/失敗 |\n\n### ログの表示\n\n「設定 → 使用量」タブでリクエストログを表示できます。\n\n## よくある質問\n\n### ポートが使用中\n\nエラーメッセージ：`Address already in use`\n\n解決方法：\n1. ポートを変更する（例：5001）\n2. またはそのポートを使用しているプログラムを終了する\n\n### プロキシの起動に失敗する\n\n確認事項：\n- ポートが使用中でないか\n- 十分な権限があるか\n- ファイアウォールがブロックしていないか\n\n### リクエストがタイムアウトする\n\n考えられる原因：\n- ネットワークの問題\n- プロバイダーのサーバーの問題\n- プロキシ設定のエラー\n\n解決方法：\n- ネットワーク接続を確認\n- プロバイダーの API に直接アクセスを試みる\n- プロバイダーの設定を確認\n"
  },
  {
    "path": "docs/user-manual/ja/4-proxy/4.2-takeover.md",
    "content": "# 4.2 アプリケーション接管\n\n## 機能説明\n\nアプリケーション接管とは、CC Switch のプロキシが特定アプリの API リクエストを接管することです。\n\n接管を有効にすると：\n- アプリの API リクエストがローカルプロキシ経由で転送される\n- リクエストログと使用量の統計を記録できる\n- フェイルオーバー機能を使用できる\n\n## 前提条件\n\nアプリケーション接管機能を使用する前に、プロキシサービスを起動する必要があります。\n\n## 接管の有効化\n\n### 操作場所\n\n設定 → 詳細 → プロキシサービス → アプリケーション接管エリア\n\n### 操作手順\n\n1. プロキシサービスが起動していることを確認\n2. 「アプリケーション接管」エリアを見つける\n3. 必要なアプリのスイッチをオンにする\n\n### 接管スイッチ\n\n| スイッチ | 作用 |\n|------|------|\n| Claude 接管 | Claude Code のリクエストを接管 |\n| Codex 接管 | Codex のリクエストを接管 |\n| Gemini 接管 | Gemini CLI のリクエストを接管 |\n\n複数のアプリの接管を同時に有効にできます。\n\n## 接管の仕組み\n\n### 設定の変更\n\n接管を有効にすると、CC Switch はアプリの設定ファイルを変更し、API エンドポイントをローカルプロキシに向けます。\n\n**Claude 設定の変更**：\n\n```json\n// 接管前\n{\n  \"env\": {\n    \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\"\n  }\n}\n\n// 接管後\n{\n  \"env\": {\n    \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\"\n  }\n}\n```\n\n**Codex 設定の変更**：\n\n```toml\n# 接管前\nbase_url = \"https://api.openai.com/v1\"\n\n# 接管後\nbase_url = \"http://127.0.0.1:15721/v1\"\n```\n\n**Gemini 設定の変更**：\n\n```bash\n# 接管前\nGOOGLE_GEMINI_BASE_URL=https://generativelanguage.googleapis.com\n\n# 接管後\nGOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15721\n```\n\n### リクエストの転送\n\nプロキシがリクエストを受信すると：\n\n1. リクエスト元を識別（Claude/Codex/Gemini）\n2. そのアプリで現在有効なプロバイダーを検索\n3. プロバイダーの実際のエンドポイントにリクエストを転送\n4. リクエストログを記録\n5. アプリにレスポンスを返却\n\n## 接管ステータスの表示\n\n### メイン画面の表示\n\n接管を有効にすると、メイン画面に以下の変化があります：\n\n- **プロキシ Logo の色**：無色から緑に変化\n- **プロバイダーカード**：現在アクティブなプロバイダーに緑の枠が表示\n\n### プロバイダーカードの状態\n\n| 状態 | 枠の色 | 説明 |\n|------|----------|------|\n| 現在有効 | 青 | 設定ファイル内のプロバイダー（非プロキシモード） |\n| プロキシアクティブ | 緑 | プロキシが実際に使用しているプロバイダー |\n| 通常 | デフォルト | 使用されていないプロバイダー |\n\n## 接管の無効化\n\n### 操作手順\n\n1. プロキシパネルで対応するアプリの接管スイッチをオフにする\n2. またはプロキシサービスを直接停止\n\n### 設定の復元\n\n接管を無効にすると、CC Switch は以下を実行します：\n\n1. アプリの設定を接管前の状態に復元\n2. 現在のリクエストログを保存\n\n## 接管とプロバイダーの切り替え\n\n### 接管モードでのプロバイダー切り替え\n\n接管モードでプロバイダーを切り替える場合：\n\n1. メイン画面でプロバイダーの「有効化」ボタンをクリック\n2. プロキシが新しいプロバイダーを使用してリクエストを即座に転送\n3. **CLI ツールの再起動は不要**\n\nこれが接管モードの大きなメリットです：プロバイダーの切り替えが即座に反映されます。\n\n### 非接管モードでのプロバイダー切り替え\n\n非接管モードでプロバイダーを切り替える場合：\n\n1. 設定ファイルを変更\n2. CLI ツールの再起動が必要\n\n## 複数アプリの接管\n\n複数のアプリを同時に接管でき、それぞれ独立して管理されます：\n\n- 独立したプロバイダー設定\n- 独立したフェイルオーバーキュー\n- 独立したリクエスト統計\n\n## 使用シーン\n\n### シーン 1：使用量の監視\n\n接管 + ログ記録を有効にして、API の使用状況を監視します。\n\n### シーン 2：素早い切り替え\n\n接管を有効にすると、プロバイダーの切り替えに CLI ツールの再起動が不要になります。\n\n### シーン 3：フェイルオーバー\n\n接管の有効化はフェイルオーバー機能を使用するための前提条件です。\n\n## 注意事項\n\n### パフォーマンスへの影響\n\nプロキシにより少量のレイテンシ（通常 < 10ms）が追加されますが、ほとんどのシーンでは無視できます。\n\n### ネットワーク要件\n\n接管モードでは、CLI ツールがローカルプロキシアドレスにアクセスできる必要があります。\n\n### 設定のバックアップ\n\n接管を有効にする前に、CC Switch は元の設定をバックアップし、無効化時に復元します。\n\n## よくある質問\n\n### 接管後にリクエストが失敗する\n\n確認事項：\n- プロキシサービスが正常に実行されているか\n- プロバイダーの設定が正しいか\n- ネットワークが正常か\n\n### 接管を無効にしても設定が復元されない\n\n考えられる原因：\n- プロキシの異常終了\n- 設定ファイルが他のプログラムに変更された\n\n解決方法：\n- プロバイダーを手動で編集して保存し直す\n- または接管を再度有効にしてから無効にする\n"
  },
  {
    "path": "docs/user-manual/ja/4-proxy/4.3-failover.md",
    "content": "# 4.3 フェイルオーバー\n\n## 機能説明\n\nフェイルオーバー機能は、メインプロバイダーのリクエストが失敗した場合に、自動的にバックアッププロバイダーに切り替えてサービスの中断を防ぎます。\n\n**適用シーン**：\n- プロバイダーのサービスが不安定な場合\n- 高可用性が必要な場合\n- 長時間実行するタスク\n\n## 前提条件\n\nフェイルオーバー機能を使用するには：\n\n1. プロキシサービスを起動\n2. アプリケーション接管を有効化\n3. フェイルオーバーキューを設定\n4. 自動フェイルオーバーを有効化\n\n## フェイルオーバーキューの設定\n\n### 設定ページを開く\n\n設定 → 詳細 → フェイルオーバー\n\n### アプリの選択\n\nページ上部に 3 つのタブがあります：\n- Claude\n- Codex\n- Gemini\n\n設定するアプリを選択します。\n\n### バックアッププロバイダーの追加\n\n1. 「フェイルオーバーキュー」エリアで\n2. 「プロバイダーを追加」をクリック\n3. ドロップダウンリストからプロバイダーを選択\n4. プロバイダーがキューの末尾に追加\n\n### 優先順位の調整\n\nプロバイダーをドラッグして順序を調整：\n- 番号が小さいほど優先度が高い\n- メインプロバイダーが失敗すると、順番にバックアッププロバイダーを試行\n\n### プロバイダーの削除\n\nプロバイダーの右側にある「削除」ボタンをクリックします。\n\n## メイン画面でのクイック操作\n\nプロキシとフェイルオーバーがどちらも有効な場合、プロバイダーカードにフェイルオーバースイッチが表示されます。\n\n### キューに追加\n\n1. プロバイダーカードを見つける\n2. フェイルオーバースイッチをオンにする\n3. プロバイダーが自動的にキューに追加\n\n### キューから削除\n\n1. プロバイダーカードのフェイルオーバースイッチをオフにする\n2. プロバイダーがキューから削除\n\n## 自動フェイルオーバーの有効化\n\n### 操作手順\n\n1. フェイルオーバー設定ページで\n2. 「自動フェイルオーバー」スイッチをオンにする\n\n### スイッチの説明\n\n| 状態 | 動作 |\n|------|------|\n| オフ | 失敗を記録するのみ、自動切り替えなし |\n| オン | 失敗時に自動的に次のプロバイダーに切り替え |\n\n## フェイルオーバーのフロー\n\n```mermaid\ngraph TD\n    Start[リクエストがプロキシに到達] --> Send[現在のプロバイダーに送信]\n    Send --> CheckSuccess{成功？}\n    CheckSuccess -- はい --> Return[レスポンスを返却]\n    CheckSuccess -- いいえ --> LogFail[失敗を記録]\n    LogFail --> CheckCircuit{サーキットブレーカーの状態確認}\n    CheckCircuit -- 発動中 --> Skip[このプロバイダーをスキップ]\n    CheckCircuit -- 未発動 --> IncFail[失敗カウントを増加]\n    Skip --> Next{キューに次がある？}\n    IncFail --> Next\n    Next -- あり --> Switch[プロバイダーを切り替え]\n    Switch --> Retry[リクエストをリトライ]\n    Retry --> Send\n    Next -- なし --> Error[エラーを返却]\n```\n\n## サーキットブレーカーの設定\n\nサーキットブレーカーは、失敗したプロバイダーへの頻繁なリトライを防止します。\n\n### 設定項目\n\nアプリごとに独立したデフォルト設定があります。以下は共通のデフォルト値で、Claude には独自の緩やかな設定があります。\n\n| 設定 | 説明 | 共通デフォルト | Claude デフォルト | 範囲 |\n|------|------|--------|--------|------|\n| 失敗閾値 | 連続何回失敗でサーキットブレーカーが発動 | 4 | 8 | 1-20 |\n| 復旧成功閾値 | ハーフオープン状態で何回成功したら閉じるか | 2 | 3 | 1-10 |\n| 復旧待機時間 | サーキットブレーカー発動後の復旧試行までの時間（秒） | 60 | 90 | 0-300 |\n| エラー率閾値 | この値を超えるとサーキットブレーカーが発動 | 60% | 70% | 0-100% |\n| 最小リクエスト数 | エラー率計算前の最小リクエスト数 | 10 | 15 | 5-100 |\n\n> Claude はリクエストに時間がかかるため、デフォルト設定はより緩やかで、多くの失敗を許容します。\n\n### タイムアウト設定\n\n| 設定 | 説明 | 共通デフォルト | Claude デフォルト | 範囲 |\n|------|------|--------|--------|------|\n| ストリーム初バイトタイムアウト | 最初のデータチャンクの最大待機時間（秒） | 60 | 90 | 1-120 |\n| ストリームサイレントタイムアウト | データチャンク間の最大間隔（秒） | 120 | 180 | 60-600（0 で無効化） |\n| 非ストリームタイムアウト | 非ストリームリクエストの総タイムアウト時間（秒） | 600 | 600 | 60-1200 |\n\n### リトライ設定\n\n| 設定 | 説明 | 共通デフォルト | Claude デフォルト | 範囲 |\n|------|------|--------|--------|------|\n| 最大リトライ回数 | リクエスト失敗時のリトライ回数 | 3 | 6 | 0-10 |\n\n> Gemini のデフォルト最大リトライ回数は 5 です。\n\n### サーキットブレーカーの状態\n\n| 状態 | 説明 |\n|------|------|\n| 閉（Closed） | 正常状態、リクエストを許可 |\n| 開（Open） | サーキットブレーカー発動中、このプロバイダーをスキップ |\n| 半開（Half-Open） | 復旧試行中、探査リクエストを送信 |\n\n### 状態遷移\n\n```mermaid\nstateDiagram-v2\n    [*] --> Closed: 初期化\n    Closed --> Open: 失敗回数 >= 閾値\n    Open --> HalfOpen: サーキットブレーカー期間満了\n    HalfOpen --> Closed: 探査成功 (>= 復旧閾値)\n    HalfOpen --> Open: 探査失敗\n```\n\n## ヘルスステータスの表示\n\n### プロバイダーカード\n\nカードにヘルスステータスバッジが表示されます：\n\n| バッジ | 状態 | 説明 |\n|------|------|------|\n| 緑 | 健康 | 連続失敗回数 0 |\n| 黄 | 警告 | 失敗はあるがサーキットブレーカー未発動 |\n| 赤 | サーキットブレーカー発動 | 一時的にスキップ |\n\n### キューリスト\n\nフェイルオーバーキューにも各プロバイダーのヘルスステータスが表示されます。\n\n## フェイルオーバーログ\n\n各フェイルオーバーの記録内容：\n\n| 情報 | 説明 |\n|------|------|\n| 時間 | 発生時刻 |\n| 元のプロバイダー | 失敗したプロバイダー |\n| 新しいプロバイダー | 切り替え先のプロバイダー |\n| 失敗理由 | エラー情報 |\n\n使用量統計のリクエストログで確認できます。\n\n## ベストプラクティス\n\n### キュー設定のアドバイス\n\n1. **メインプロバイダー**：最も安定で高速なプロバイダー\n2. **第 1 バックアップ**：次善の選択\n3. **第 2 バックアップ**：最後の手段\n\n### サーキットブレーカー設定のアドバイス\n\n| シーン | 失敗閾値 | サーキットブレーカー期間 |\n|------|----------|----------|\n| 高可用性要件 | 2 | 30 秒 |\n| 一般的なシーン | 3 | 60 秒 |\n| 偶発的な失敗を許容 | 5 | 120 秒 |\n\n### 監視のアドバイス\n\n定期的に確認：\n- 各プロバイダーのヘルスステータス\n- フェイルオーバーの発生頻度\n- サーキットブレーカーの発動状況\n\n## よくある質問\n\n### フェイルオーバーがトリガーされない\n\n確認事項：\n1. プロキシサービスが実行中か\n2. アプリケーション接管が有効か\n3. 自動フェイルオーバーが有効か\n4. キューにバックアッププロバイダーがあるか\n\n### フェイルオーバーが頻繁にトリガーされる\n\n考えられる原因：\n- メインプロバイダーが不安定\n- ネットワークの問題\n- 設定のエラー\n\n解決方法：\n- メインプロバイダーの状態を確認\n- サーキットブレーカーのパラメータを調整\n- メインプロバイダーの変更を検討\n\n### すべてのプロバイダーがサーキットブレーカー発動中\n\nサーキットブレーカー期間満了後に自動復旧を待つか、以下を実行：\n1. プロキシサービスを手動で再起動\n2. サーキットブレーカーの状態をリセット\n"
  },
  {
    "path": "docs/user-manual/ja/4-proxy/4.4-usage.md",
    "content": "# 4.4 使用量統計\n\n## 機能説明\n\n使用量統計機能は、API リクエストデータを記録・分析して、以下をサポートします：\n\n- API の使用状況の把握\n- 費用支出の見積もり\n- 使用パターンの分析\n- 問題のトラブルシューティング\n\n## 前提条件\n\n使用量統計機能を使用するには：\n\n1. プロキシサービスを起動\n2. アプリケーション接管を有効化\n3. ログ記録を有効化\n\n## 使用量統計を開く\n\n設定 → 使用量 タブ\n\n## 統計概要\n\n### 集計カード\n\nページ上部に主要指標が表示されます：\n\n| 指標 | 説明 |\n|------|------|\n| 総リクエスト数 | 統計期間内のリクエスト総数 |\n| 総 Token | 入力 + 出力 Token の合計 |\n| 推定費用 | 料金設定に基づいて計算された費用 |\n| 成功率 | 成功したリクエストの割合 |\n\n### 期間\n\n統計の期間を選択できます：\n\n| オプション | 範囲 |\n|------|------|\n| 今日 | 当日 00:00 から現在まで |\n| 過去 7 日間 | 直近 7 日間 |\n| 過去 30 日間 | 直近 30 日間 |\n\n![image-20260108011730105](../../assets/image-20260108011730105.png)\n\n## トレンドグラフ\n\n### リクエストトレンド\n\n折れ線グラフでリクエスト数の変化傾向を表示：\n\n- X 軸：時間\n- Y 軸：リクエスト数\n- 時間単位/日単位で表示可能\n- ズームとドラッグに対応\n\n### Token トレンド\n\nToken 使用量の変化を表示：\n\n- 入力 Token（青）- ユーザーが送信した prompt の内容\n- 出力 Token（緑）- AI が生成した回答の内容\n- キャッシュ作成 Token（オレンジ）- 初回キャッシュ作成で消費された Token\n- キャッシュヒット Token（紫）- キャッシュ再利用で節約された Token\n- コスト（赤い破線、右側 Y 軸）- 推定費用\n\n> **キャッシュ Token の説明**：Anthropic API は Prompt Caching 機能をサポートしています。キャッシュ作成時は高い料金（通常、入力価格の 1.25 倍）がかかりますが、その後のキャッシュヒット時は 0.1 倍の価格のみで、繰り返しリクエストのコストを大幅に削減できます。\n\n### 時間粒度\n\n- **今日**：時間単位で表示（24 データポイント）\n- **7 日間/30 日間**：日単位で表示\n\n\n\n![image-20260108011742847](../../assets/image-20260108011742847.png)\n\n## 詳細データ\n\nページ下部に 3 つのデータタブがあります：\n\n### リクエストログ\n\n各リクエストの詳細記録：\n\n| フィールド | 説明 |\n|------|------|\n| 時間 | リクエスト時刻 |\n| プロバイダー | 使用されたプロバイダー名 |\n| モデル | リクエストされたモデル（課金モデル） |\n| 入力 Token | 入力の Token 数 |\n| 出力 Token | 出力の Token 数 |\n| キャッシュ読取 | キャッシュヒットの Token 数 |\n| キャッシュ作成 | キャッシュ作成の Token 数 |\n| 総費用 | 推定費用（ドル） |\n| 所要時間情報 | リクエスト時間、初回 Token 時間、ストリーム/非ストリーム |\n| ステータス | HTTP ステータスコード |\n\n#### 所要時間情報の説明\n\n所要時間情報列には複数のバッジが表示されます：\n\n| バッジ | 説明 | 色のルール |\n|------|------|----------|\n| 総所要時間 | リクエストの総時間（秒） | ≤5s 緑、≤120s オレンジ、>120s 赤 |\n| 初回 Token | ストリームリクエストの最初の Token 時間 | ≤5s 緑、≤120s オレンジ、>120s 赤 |\n| ストリーム/非ストリーム | リクエストタイプ | ストリーム：青、非ストリーム：紫 |\n\n#### 詳細の表示\n\nリクエスト行をクリックすると詳細情報を表示：\n\n- 完全なリクエストパラメータ\n- レスポンス内容のサマリー\n- エラー情報（失敗した場合）\n\n#### ログのフィルタリング\n\n以下の条件でフィルタリングできます：\n\n| フィルタ項目 | オプション |\n|--------|------|\n| アプリタイプ | すべて / Claude / Codex / Gemini |\n| ステータスコード | すべて / 200 / 400 / 401 / 429 / 500 |\n| プロバイダー | テキスト検索 |\n| モデル | テキスト検索 |\n| 期間 | 開始時刻 - 終了時刻（日時ピッカー） |\n\n操作ボタン：\n- **検索**：フィルタ条件を適用\n- **リセット**：デフォルトに戻す（過去 24 時間）\n- **更新**：データを再読み込み\n\n![image-20260108011859974](../../assets/image-20260108011859974.png)\n\n### プロバイダー統計\n\nプロバイダー別の集計データ：\n\n| フィールド | 説明 |\n|------|------|\n| プロバイダー | プロバイダー名 |\n| リクエスト数 | そのプロバイダーの総リクエスト数 |\n| 成功数 | 成功したリクエスト数 |\n| 失敗数 | 失敗したリクエスト数 |\n| 成功率 | 成功の割合 |\n| 総 Token | Token 使用量の合計 |\n| 推定費用 | そのプロバイダーの費用 |\n\n![image-20260108011907928](../../assets/image-20260108011907928.png)\n\n### モデル統計\n\nモデル別の集計データ：\n\n| フィールド | 説明 |\n|------|------|\n| モデル | モデル名 |\n| リクエスト数 | そのモデルの総リクエスト数 |\n| 入力 Token | 入力 Token の合計 |\n| 出力 Token | 出力 Token の合計 |\n| 平均レイテンシ | 平均応答時間 |\n| 推定費用 | そのモデルの費用 |\n\n![image-20260108011915381](../../assets/image-20260108011915381.png)\n\n## 料金設定\n\n### 料金設定を開く\n\n設定 → 詳細 → 料金設定\n\n### モデル価格の設定\n\n各モデルの価格を設定（100 万 Token あたり）：\n\n| フィールド | 説明 |\n|------|------|\n| モデル ID | モデル識別子（例：claude-3-sonnet） |\n| 表示名 | カスタム表示名 |\n| 入力価格 | 100 万入力 Token あたりの価格 |\n| 出力価格 | 100 万出力 Token あたりの価格 |\n| キャッシュ読取価格 | 100 万キャッシュヒット Token あたりの価格 |\n| キャッシュ作成価格 | 100 万キャッシュ作成 Token あたりの価格 |\n\n### 操作\n\n- **追加**：「追加」ボタンで新しいモデル価格を追加\n- **編集**：行末の編集アイコンで変更\n- **削除**：行末の削除アイコンで削除\n\n![image-20260108011933565](../../assets/image-20260108011933565.png)\n\n### プリセット価格\n\nCC Switch は一般的なモデルの公式価格（100 万 Token あたり）をプリセットしています：\n\n**Claude シリーズ（ドル）**：\n\n| モデル | 入力 | 出力 | キャッシュ読取 | キャッシュ作成 |\n|------|------|------|----------|----------|\n| **Claude 4.5 シリーズ** | | | | |\n| claude-opus-4-5 | $5 | $25 | $0.50 | $6.25 |\n| claude-sonnet-4-5 | $3 | $15 | $0.30 | $3.75 |\n| claude-haiku-4-5 | $1 | $5 | $0.10 | $1.25 |\n| **Claude 4 シリーズ** | | | | |\n| claude-opus-4 | $15 | $75 | $1.50 | $18.75 |\n| claude-opus-4-1 | $15 | $75 | $1.50 | $18.75 |\n| claude-sonnet-4 | $3 | $15 | $0.30 | $3.75 |\n| **Claude 3.5 シリーズ** | | | | |\n| claude-3-5-sonnet | $3 | $15 | $0.30 | $3.75 |\n| claude-3-5-haiku | $0.80 | $4 | $0.08 | $1.00 |\n\n**OpenAI シリーズ / Codex（ドル）**：\n\n| モデル | 入力 | 出力 | キャッシュ読取 |\n|------|------|------|----------|\n| **GPT-5.2 シリーズ** | | | |\n| gpt-5.2 | $1.75 | $14 | $0.175 |\n| **GPT-5.1 シリーズ** | | | |\n| gpt-5.1 | $1.25 | $10 | $0.125 |\n| **GPT-5 シリーズ** | | | |\n| gpt-5 | $1.25 | $10 | $0.125 |\n\n> 注：Codex プリセットには low/medium/high などの変種が含まれており、価格はベースモデルと同一です。\n\n**Gemini シリーズ（ドル）**：\n\n| モデル | 入力 | 出力 | キャッシュ読取 |\n|------|------|------|----------|\n| **Gemini 3 シリーズ** | | | |\n| gemini-3-pro-preview | $2 | $12 | $0.20 |\n| gemini-3-flash-preview | $0.50 | $3 | $0.05 |\n| **Gemini 2.5 シリーズ** | | | |\n| gemini-2.5-pro | $1.25 | $10 | $0.125 |\n| gemini-2.5-flash | $0.30 | $2.50 | $0.03 |\n\n**中国メーカーのモデル**：\n\n> 注: 通貨は各プロバイダーの公式料金ページに従います。StepFun は現在 USD 表記です。\n\n| モデル | 入力 | 出力 | キャッシュ読取 |\n|------|------|------|----------|\n| **StepFun** | | | |\n| step-3.5-flash | $0.10 | $0.30 | $0.02 |\n| **DeepSeek** | | | |\n| deepseek-v3.2 | ¥2.00 | ¥3.00 | ¥0.40 |\n| deepseek-v3.1 | ¥4.00 | ¥12.00 | ¥0.80 |\n| deepseek-v3 | ¥2.00 | ¥8.00 | ¥0.40 |\n| **Kimi (月之暗面)** | | | |\n| kimi-k2-thinking | ¥4.00 | ¥16.00 | ¥1.00 |\n| kimi-k2 | ¥4.00 | ¥16.00 | ¥1.00 |\n| kimi-k2-turbo | ¥8.00 | ¥58.00 | ¥1.00 |\n| **MiniMax** | | | |\n| minimax-m2.1 | ¥2.10 | ¥8.40 | ¥0.21 |\n| minimax-m2.1-lightning | ¥2.10 | ¥16.80 | ¥0.21 |\n| **その他** | | | |\n| glm-4.7 | ¥2.00 | ¥8.00 | ¥0.40 |\n| doubao-seed-code | ¥1.20 | ¥8.00 | ¥0.24 |\n| mimo-v2-flash | 無料 | 無料 | - |\n\n### カスタム価格\n\n中継サービスを使用する場合、価格が異なる場合があります：\n\n1. 「編集」ボタンをクリック\n2. 価格を変更\n3. 保存\n\n## よくある質問\n\n### 統計データが空\n\n確認事項：\n- プロキシサービスが実行中か\n- アプリケーション接管が有効か\n- ログ記録が有効か\n- プロキシ経由でリクエストがあったか\n\n### 費用の見積もりが不正確\n\n考えられる原因：\n- 料金設定が実際と異なる\n- 中継サービスの特別な料金体系を使用\n\n解決方法：\n- 料金設定を更新\n- プロバイダーの実際の請求書を参照\n\n### Token 数がプロバイダーと一致しない\n\nCC Switch は独自の方法で Token 数を推定しており、プロバイダーの計算方法と若干の差異が生じる場合があります。プロバイダーの請求書を基準にしてください。\n"
  },
  {
    "path": "docs/user-manual/ja/4-proxy/4.5-model-test.md",
    "content": "# 4.5 モデルテスト\n\n## 機能説明\n\nモデルテスト機能は、プロバイダーに設定されたモデルが使用可能かどうかを確認するために、実際の API リクエストを送信してテストします：\n\n- モデルが存在するか\n- API Key が有効か\n- エンドポイントが正常に応答するか\n- 応答レイテンシが正常か\n\n## 設定を開く\n\n設定 → 詳細 → モデルテスト\n\n## テストモデルの設定\n\n各アプリのテスト用モデルを設定します：\n\n| アプリ | 設定項目 | デフォルト値 | 説明 |\n|------|--------|--------|------|\n| Claude | Claude モデル | システムデフォルト | Haiku シリーズの使用を推奨（低コスト・高速） |\n| Codex | Codex モデル | システムデフォルト | mini シリーズの使用を推奨 |\n| Gemini | Gemini モデル | システムデフォルト | Flash シリーズの使用を推奨 |\n\n### モデル選択のアドバイス\n\nテストモデルを選択する際の考慮事項：\n\n1. **コスト**：低価格のモデルを選択（例：Haiku、Mini、Flash）\n2. **速度**：応答が速いモデルを選択\n3. **可用性**：プロバイダーがサポートしているモデルを選択\n\n## テストパラメータの設定\n\n### タイムアウト時間\n\n| パラメータ | 説明 | デフォルト値 | 範囲 |\n|------|------|--------|------|\n| タイムアウト時間 | 1 回のリクエストのタイムアウト | 45 秒 | 10-120 秒 |\n\n短すぎると誤判定の可能性があり、長すぎると障害検出が遅れます。\n\n### リトライ回数\n\n| パラメータ | 説明 | デフォルト値 | 範囲 |\n|------|------|--------|------|\n| 最大リトライ | 失敗時のリトライ回数 | 2 回 | 0-5 回 |\n\nネットワークが不安定な場合はリトライ回数を増やすことを推奨します。\n\n### デグレード閾値\n\n| パラメータ | 説明 | デフォルト値 | 範囲 |\n|------|------|--------|------|\n| デグレード閾値 | この時間を超えるとデグレードとマーク | 6000ms | 1000-30000ms |\n\n閾値を超えたプロバイダーは「デグレード」状態としてマークされますが、引き続き使用可能です。\n\n## モデルテストの実行\n\n### 手動テスト\n\nプロバイダーカードの「テスト」ボタンをクリックします：\n\n1. 設定されたエンドポイントにテストリクエストを送信\n2. 設定されたテストモデルを使用\n3. レスポンスまたはタイムアウトを待機\n4. テスト結果を表示\n\n### テスト内容\n\nテストリクエストは：\n- 短い prompt（例：\"Hi\"）を送信\n- 最大出力 Token を制限（通常 10-50）\n- ストリームレスポンスで初バイト時間を検出\n\n## テスト結果\n\n### ヘルスステータス\n\n| ステータス | アイコン | 説明 |\n|------|------|------|\n| 健康 | 緑 | レスポンス正常、レイテンシが閾値内 |\n| デグレード | 黄 | レスポンス正常だが、レイテンシが閾値超過 |\n| 利用不可 | 赤 | リクエスト失敗またはタイムアウト |\n\n### 結果情報\n\nテスト完了後に表示：\n- 応答レイテンシ（ミリ秒）\n- 初バイト時間（TTFB）\n- エラー情報（失敗した場合）\n\n## フェイルオーバーとの連携\n\nモデルテストはフェイルオーバー機能と連携して使用します：\n\n### ヘルスチェック\n\nプロキシサービスを有効にすると、システムはフェイルオーバーキュー内のプロバイダーに対して定期的にヘルスチェックを実行します：\n\n1. 設定されたテストモデルでリクエストを送信\n2. レスポンスに基づいてヘルスステータスを更新\n3. 不健康なプロバイダーは一時的にスキップ\n\n### サーキットブレーカーからの復旧\n\nプロバイダーがサーキットブレーカー状態から復旧する際：\n\n1. モデルテストで可用性を確認\n2. テスト合格後、正常状態に復旧\n3. テスト不合格の場合、サーキットブレーカーを継続\n\n## よくある質問\n\n### テストは失敗するが実際には使用可能\n\n**考えられる原因**：\n- テストモデルと実際に使用するモデルが異なる\n- プロバイダーが設定されたテストモデルをサポートしていない\n\n**解決方法**：\n- テストモデルをプロバイダーがサポートするモデルに変更\n- プロバイダーのモデルリストを確認\n\n### レイテンシが高すぎる\n\n**考えられる原因**：\n- ネットワークレイテンシ\n- プロバイダーのサーバー負荷が高い\n- モデルの応答が遅い\n\n**解決方法**：\n- より高速なテストモデルを使用\n- デグレード閾値を調整\n- ミラーエンドポイントの使用を検討\n\n### 頻繁にタイムアウトする\n\n**考えられる原因**：\n- タイムアウト時間の設定が短すぎる\n- ネットワークが不安定\n- プロバイダーのサービスが不安定\n\n**解決方法**：\n- タイムアウト時間を延長\n- リトライ回数を増加\n- ネットワーク接続を確認\n\n## 注意事項\n\n- モデルテストは少量の API 枠を消費します\n- テストには低コストのモデルの使用を推奨\n- テスト頻度は高すぎないように、枠の浪費を避けてください\n- プロバイダーごとにサポートするモデルが異なる場合があります\n"
  },
  {
    "path": "docs/user-manual/ja/5-faq/5.1-config-files.md",
    "content": "# 5.1 設定ファイルの説明\n\n## CC Switch のデータストレージ\n\n### ストレージディレクトリ\n\nデフォルトの場所：`~/.cc-switch/`\n\n設定で場所をカスタマイズ可能です（クラウド同期用）。\n\n### ディレクトリ構造\n\n```\n~/.cc-switch/\n├── cc-switch.db      # SQLite データベース\n├── settings.json     # デバイスレベルの設定\n└── backups/          # 自動バックアップ\n    ├── backup-20251230-120000.json\n    ├── backup-20251229-180000.json\n    └── ...\n```\n\n### データベースの内容\n\n`cc-switch.db` は SQLite データベースで、以下を保存しています：\n\n| テーブル | 内容 |\n|-----|------|\n| providers | プロバイダー設定 |\n| provider_endpoints | プロバイダーエンドポイント候補リスト |\n| mcp_servers | MCP サーバー設定 |\n| prompts | プロンプトプリセット |\n| skills | スキルのインストール状態 |\n| skill_repos | スキルリポジトリ設定 |\n| proxy_config | プロキシ設定 |\n| proxy_request_logs | プロキシリクエストログ |\n| provider_health | プロバイダーヘルスステータス |\n| model_pricing | モデル料金 |\n| settings | アプリ設定 |\n\n### デバイス設定\n\n`settings.json` はデバイスレベルの設定を保存します：\n\n```json\n{\n  \"language\": \"zh\",\n  \"theme\": \"system\",\n  \"windowBehavior\": \"minimize\",\n  \"autoStart\": false,\n  \"claudeConfigDir\": null,\n  \"codexConfigDir\": null,\n  \"geminiConfigDir\": null,\n  \"opencodeConfigDir\": null,\n  \"openclawConfigDir\": null\n}\n```\n\nこれらの設定はデバイス間で同期されません。\n\n### 自動バックアップ\n\n`backups/` ディレクトリに自動バックアップが保存されます：\n\n- 設定インポートのたびに自動作成\n- 最新の 10 件のバックアップを保持\n- ファイル名にタイムスタンプを含む\n\n## Claude Code の設定\n\n### 設定ディレクトリ\n\nデフォルト：`~/.claude/`\n\n### 主要ファイル\n\n```\n~/.claude/\n├── settings.json     # メイン設定ファイル\n├── CLAUDE.md         # システムプロンプト\n└── skills/           # スキルディレクトリ\n    └── ...\n```\n\n### settings.json\n\n```json\n{\n  \"env\": {\n    \"ANTHROPIC_API_KEY\": \"sk-xxx\",\n    \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\"\n  },\n  \"permissions\": {\n    \"allow_file_access\": true\n  }\n}\n```\n\n| フィールド | 説明 |\n|------|------|\n| `env.ANTHROPIC_API_KEY` | API キー |\n| `env.ANTHROPIC_BASE_URL` | API エンドポイント（任意） |\n| `env.ANTHROPIC_AUTH_TOKEN` | 代替認証方式 |\n\n### MCP 設定\n\nMCP サーバーの設定は `~/.claude.json` にあります：\n\n```json\n{\n  \"mcpServers\": {\n    \"mcp-fetch\": {\n      \"command\": \"uvx\",\n      \"args\": [\"mcp-server-fetch\"]\n    }\n  }\n}\n```\n\n## Codex の設定\n\n### 設定ディレクトリ\n\nデフォルト：`~/.codex/`\n\n### 主要ファイル\n\n```\n~/.codex/\n├── auth.json         # 認証設定\n├── config.toml       # メイン設定 + MCP\n└── AGENTS.md         # システムプロンプト\n```\n\n### auth.json\n\n```json\n{\n  \"OPENAI_API_KEY\": \"sk-xxx\"\n}\n```\n\n### config.toml\n\n```toml\n# 基本設定\nbase_url = \"https://api.openai.com/v1\"\nmodel = \"gpt-4\"\n\n# MCP サーバー\n[mcp_servers.mcp-fetch]\ncommand = \"uvx\"\nargs = [\"mcp-server-fetch\"]\n```\n\n## Gemini CLI の設定\n\n### 設定ディレクトリ\n\nデフォルト：`~/.gemini/`\n\n### 主要ファイル\n\n```\n~/.gemini/\n├── .env              # 環境変数（API Key）\n├── settings.json     # メイン設定 + MCP\n└── GEMINI.md         # システムプロンプト\n```\n\n### .env\n\n```bash\nGEMINI_API_KEY=xxx\nGOOGLE_GEMINI_BASE_URL=https://generativelanguage.googleapis.com\nGEMINI_MODEL=gemini-pro\n```\n\n### settings.json\n\n```json\n{\n  \"mcpServers\": {\n    \"mcp-fetch\": {\n      \"command\": \"uvx\",\n      \"args\": [\"mcp-server-fetch\"]\n    }\n  }\n}\n```\n\n| フィールド | 説明 |\n|------|------|\n| `mcpServers` | MCP サーバー設定 |\n\n## OpenCode の設定\n\n### 設定ディレクトリ\n\nデフォルト：`~/.opencode/`\n\n### 主要ファイル\n\n```\n~/.opencode/\n├── config.json       # メイン設定ファイル\n├── AGENTS.md         # システムプロンプト\n└── skills/           # スキルディレクトリ\n    └── ...\n```\n\n## OpenClaw の設定\n\n### 設定ディレクトリ\n\nデフォルト：`~/.openclaw/`\n\n### 主要ファイル\n\n```\n~/.openclaw/\n├── openclaw.json     # メイン設定ファイル（JSON5 形式）\n├── AGENTS.md         # システムプロンプト\n└── skills/           # スキルディレクトリ\n    └── ...\n```\n\n### openclaw.json\n\nOpenClaw は JSON5 形式の設定ファイルを使用し、主に以下のセクションを含みます：\n\n```json5\n{\n  // モデルプロバイダー設定\n  models: {\n    mode: \"merge\",\n    providers: {\n      \"custom-provider\": {\n        baseUrl: \"https://api.example.com/v1\",\n        apiKey: \"your-api-key\",\n        api: \"openai-completions\",\n        models: [{ id: \"model-id\", name: \"Model Name\" }]\n      }\n    }\n  },\n  // 環境変数\n  env: {\n    ANTHROPIC_API_KEY: \"sk-...\"\n  },\n  // Agent デフォルトモデル設定\n  agents: {\n    defaults: {\n      model: {\n        primary: \"provider/model\"\n      }\n    }\n  },\n  // ツール設定\n  tools: {},\n  // ワークスペースファイル設定\n  workspace: {}\n}\n```\n\n| フィールド | 説明 |\n|------|------|\n| `models.providers` | プロバイダー設定（CC Switch の「プロバイダー」にマッピング） |\n| `env` | 環境変数設定 |\n| `agents.defaults` | Agent デフォルトモデル設定 |\n| `tools` | ツール設定 |\n| `workspace` | ワークスペースファイル管理 |\n\n## 設定の優先順位\n\nCC Switch が設定を変更する際の優先順位：\n\n1. **CC Switch データベース** - 単一事実源 (SSOT)\n2. **Live 設定ファイル** - プロバイダー切り替え時に書き込み\n3. **バックフィル機能** - 現在のプロバイダーの編集時に Live ファイルから読み取り\n\n## 手動での設定編集\n\n### 手動編集可能なもの\n\n- CLI ツールの設定ファイル（CC Switch がバックフィルする）\n- CC Switch の `settings.json`\n\n### 手動編集を推奨しないもの\n\n- `cc-switch.db` データベースファイル\n- バックアップファイル\n\n### 編集後の同期\n\nCLI ツールの設定を手動で編集した場合：\n\n1. CC Switch を開く\n2. 対応するプロバイダーを編集\n3. 手動変更の内容がバックフィルされていることを確認\n4. 保存してデータベースに同期\n\n## 設定の移行\n\n### 旧バージョンからの移行\n\nCC Switch v3.7.0 で JSON ファイルから SQLite に移行しました：\n\n- 初回起動時に自動的に移行\n- 移行成功後に通知を表示\n- 旧設定ファイルはバックアップとして保持\n\n### デバイス間の移行\n\n1. 移行元のデバイスで設定をエクスポート\n2. 移行先のデバイスで設定をインポート\n3. またはクラウド同期機能を使用\n\n## 設定のバックアップに関するアドバイス\n\n### 定期的なバックアップ\n\n定期的に設定をエクスポートすることを推奨します：\n\n1. 設定 → 詳細 → データ管理\n2. 「エクスポート」をクリック\n3. 安全な場所に保存\n\n### バックアップに含まれる内容\n\nエクスポートファイルには以下が含まれます：\n\n- すべてのプロバイダー設定\n- MCP サーバー設定\n- Prompts プリセット\n- アプリ設定\n\n### 含まれない内容\n\n- 使用量ログ（データ量が大きいため）\n- デバイスレベルの設定（デバイス間の移動に適さないため）\n"
  },
  {
    "path": "docs/user-manual/ja/5-faq/5.2-questions.md",
    "content": "# 5.2 よくある質問 FAQ\n\n## インストールに関する問題\n\n### macOS で「不明な開発者」と表示される\n\n**問題**：初回起動時に「開けません。身元不明の開発者のものです」と表示される\n\n**解決方法 1**：システム設定から\n1. 警告ダイアログを閉じる\n2. 「システム設定」→「プライバシーとセキュリティ」を開く\n3. CC Switch に関する表示を見つける\n4. 「このまま開く」をクリック\n5. 再度アプリを開く\n\n**解決方法 2**：ターミナルコマンドから（推奨）\n```bash\nsudo xattr -dr com.apple.quarantine /Applications/CC\\ Switch.app/\n```\n\n実行後、正常にアプリを開けるようになります。\n\n### Windows でインストール後に起動できない\n\n**考えられる原因**：\n- WebView2 ランタイムが不足\n- ウイルス対策ソフトによるブロック\n\n**解決方法**：\n1. [Microsoft Edge WebView2](https://developer.microsoft.com/en-us/microsoft-edge/webview2/) をインストール\n2. CC Switch をウイルス対策ソフトのホワイトリストに追加\n\n### Linux で起動エラー\n\n**問題**：AppImage が起動しない\n\n**解決方法**：\n```bash\n# 実行権限を追加\nchmod +x CC-Switch-*.AppImage\n\n# それでも失敗する場合\n./CC-Switch-*.AppImage --no-sandbox\n```\n\n## プロバイダーに関する問題\n\n### プロバイダーを切り替えても反映されない\n\n**原因**：CLI ツールが設定を再読み込みする必要がある\n\n**解決方法**：\n- Claude Code：ターミナルを閉じて再度開く、または IDE を再起動\n- Codex：ターミナルを閉じて再度開く\n- Gemini：トレイからの切り替えで即時反映、再起動不要\n\n### API Key が無効\n\n**確認手順**：\n1. API Key が正しくコピーされているか（余分なスペースがないか）\n2. API Key が期限切れでないか\n3. エンドポイントアドレスが正しいか\n4. 速度テストで接続を確認\n\n### 公式ログインに戻すには\n\n**操作手順**：\n1. 「公式ログイン」プリセット（Claude/Codex）または「Google 公式」プリセット（Gemini）を選択\n2. 「有効化」をクリック\n3. 対応する CLI ツールを再起動\n4. CLI ツールのログインフローに従って操作\n\n## プロキシに関する問題\n\n### プロキシサービスの起動に失敗する\n\n**考えられる原因**：ポートが使用中\n\n**解決方法**：\n1. ポートの使用状況を確認：\n   ```bash\n   # macOS/Linux\n   lsof -i :49152\n\n   # Windows\n   netstat -ano | findstr :49152\n   ```\n2. ポートを使用しているプログラムを終了\n3. または設定を変更してデフォルトポートに復旧：\n   - 「設定 → プロキシサービス」を開く\n   - 「デフォルトに戻す」ボタンをクリック\n\n### プロキシモードでリクエストがタイムアウトする\n\n**考えられる原因**：\n- ネットワークの問題\n- プロバイダーのサーバーの問題\n- プロキシ設定のエラー\n\n**解決方法**：\n1. ネットワーク接続を確認\n2. プロバイダーの API に直接アクセスを試みる（プロキシを無効にして）\n3. プロバイダーの設定が正しいか確認\n\n### プロキシを無効にしても設定が復元されない\n\n**考えられる原因**：プロキシの異常終了\n\n**解決方法**：\n1. 現在のプロバイダーを編集\n2. エンドポイントアドレスが正しいか確認\n3. 保存して設定を更新\n\n## フェイルオーバーに関する問題\n\n### フェイルオーバーがトリガーされない\n\n**チェックリスト**：\n- [ ] プロキシサービスが実行中か\n- [ ] アプリケーション接管が有効か\n- [ ] 自動フェイルオーバーが有効か\n- [ ] キューにバックアッププロバイダーがあるか\n\n### フェイルオーバーが頻繁にトリガーされる\n\n**考えられる原因**：\n- メインプロバイダーが不安定\n- サーキットブレーカーの閾値が低すぎる\n\n**解決方法**：\n1. メインプロバイダーの状態を確認\n2. 失敗閾値を引き上げる（例：3 → 5）\n3. メインプロバイダーの変更を検討\n\n### すべてのプロバイダーがサーキットブレーカー発動中\n\n**解決方法**：\n1. サーキットブレーカー期間満了を待つ（デフォルト 60 秒）\n2. またはプロキシサービスを再起動して状態をリセット\n\n## データに関する問題\n\n### 設定が消えた\n\n**考えられる原因**：\n- 設定ディレクトリが削除された\n- データベースが破損\n\n**解決方法**：\n1. `~/.cc-switch/` ディレクトリが存在するか確認\n2. バックアップから復元：`~/.cc-switch/backups/`\n3. または以前にエクスポートした設定ファイルからインポート\n\n### 設定のインポートに失敗する\n\n**考えられる原因**：\n- ファイル形式のエラー\n- バージョンの非互換性\n\n**解決方法**：\n1. ファイルが CC Switch からエクスポートされた JSON ファイルであることを確認\n2. ファイル内容が完全であるか確認\n3. テキストエディタで開いてフォーマットを確認\n\n### 使用量統計のデータが空\n\n**チェックリスト**：\n- [ ] プロキシサービスが実行中か\n- [ ] アプリケーション接管が有効か\n- [ ] ログ記録が有効か\n- [ ] プロキシ経由でリクエストがあったか\n\n## その他の問題\n\n### トレイアイコンが表示されない\n\n**macOS**：\n- システム設定のメニューバーアイコン設定を確認\n\n**Windows**：\n- タスクバーの設定で、CC Switch のアイコンが非表示になっていないか確認\n\n**Linux**：\n- システムトレイのサポート（例：`libappindicator`）がインストールされている必要あり\n\n### インターフェースの表示が異常\n\n**解決方法**：\n1. テーマを切り替えてみる（ライト/ダーク）\n2. アプリを再起動\n3. `~/.cc-switch/settings.json` を削除して設定をリセット\n\n### 更新に失敗する\n\n**解決方法**：\n1. ネットワーク接続を確認\n2. 最新版を手動でダウンロードしてインストール\n3. Homebrew を使用する場合：`brew upgrade --cask cc-switch`\n\n## ヘルプの入手\n\n### Issue の提出\n\n上記の方法で問題が解決しない場合：\n\n1. [GitHub Issues](https://github.com/farion1231/cc-switch/issues) にアクセス\n2. 類似の問題がないか検索\n3. なければ新しい Issue を作成\n4. 以下の情報を提供：\n   - オペレーティングシステムとバージョン\n   - CC Switch のバージョン\n   - 問題の説明と再現手順\n   - エラーメッセージ（ある場合）\n\n### ログファイル\n\nIssue を提出する際にログファイルを添付できます：\n\n- macOS/Linux：`~/.cc-switch/logs/`\n- Windows：`%APPDATA%\\cc-switch\\logs\\`\n"
  },
  {
    "path": "docs/user-manual/ja/5-faq/5.3-deeplink.md",
    "content": "# 5.3 ディープリンクプロトコル\n\n## 機能説明\n\nCC Switch は `ccswitch://` ディープリンクプロトコルをサポートしており、リンクからワンクリックで設定をインポートできます。\n\n**使用シーン**：\n- チーム内での設定共有\n- チュートリアルでのワンクリック設定\n- デバイス間の素早い同期\n\n## オンライン生成ツール\n\nCC Switch はオンラインのディープリンク生成ツールを提供しています：\n\n**アクセス先**：[https://farion1231.github.io/cc-switch/deplink.html](https://farion1231.github.io/cc-switch/deplink.html)\n\n### 使用方法\n\n1. 上記の Web ページを開く\n2. インポートタイプを選択（プロバイダー/MCP/Prompt）\n3. 設定情報を入力\n4. 「リンクを生成」をクリック\n5. 生成されたディープリンクをコピー\n6. 他の人に共有するか、別のデバイスで使用\n\n## プロトコル形式\n\n### V1 プロトコル\n\nURL パラメータ形式で、読みやすく生成しやすい形式です：\n\n```\nccswitch://v1/import?resource={type}&app={app}&name={name}&...\n```\n\n**共通パラメータ**：\n\n| パラメータ | 必須 | 説明 |\n|------|------|------|\n| `resource` | はい | リソースタイプ：`provider` / `mcp` / `prompt` / `skill` |\n| `app` | はい | アプリタイプ：`claude` / `codex` / `gemini` / `opencode` / `openclaw` |\n| `name` | はい | 名前 |\n\n**プロバイダーパラメータ**（resource=provider）：\n\n| パラメータ | 必須 | 説明 |\n|------|------|------|\n| `endpoint` | いいえ | API エンドポイントアドレス（カンマ区切りで複数 URL 対応） |\n| `apiKey` | いいえ | API キー |\n| `homepage` | いいえ | プロバイダー公式サイト |\n| `model` | いいえ | デフォルトモデル |\n| `haikuModel` | いいえ | Haiku モデル（Claude のみ） |\n| `sonnetModel` | いいえ | Sonnet モデル（Claude のみ） |\n| `opusModel` | いいえ | Opus モデル（Claude のみ） |\n| `notes` | いいえ | メモ |\n| `icon` | いいえ | アイコン |\n| `config` | いいえ | Base64 エンコードされた設定内容 |\n| `configFormat` | いいえ | 設定形式：`json` / `toml` |\n| `configUrl` | いいえ | リモート設定 URL |\n| `enabled` | いいえ | 有効にするかどうか（ブール値） |\n| `usageScript` | いいえ | 使用量クエリスクリプト |\n| `usageEnabled` | いいえ | 使用量クエリを有効にするか（デフォルト true） |\n| `usageApiKey` | いいえ | 使用量クエリ専用 API Key |\n| `usageBaseUrl` | いいえ | 使用量クエリ専用アドレス |\n| `usageAccessToken` | いいえ | 使用量クエリアクセストークン |\n| `usageUserId` | いいえ | 使用量クエリユーザー ID |\n| `usageAutoInterval` | いいえ | 自動クエリ間隔（分） |\n\n**プロンプトパラメータ**（resource=prompt）：\n\n| パラメータ | 必須 | 説明 |\n|------|------|------|\n| `content` | はい | プロンプト内容 |\n| `description` | いいえ | 説明 |\n| `enabled` | いいえ | 有効にするかどうか（ブール値） |\n\n**MCP パラメータ**（resource=mcp）：\n\n| パラメータ | 必須 | 説明 |\n|------|------|------|\n| `apps` | はい | アプリリスト（カンマ区切り、例：`claude,codex,gemini,opencode`） |\n| `config` | はい | MCP サーバー設定（JSON 形式） |\n| `enabled` | いいえ | 有効にするかどうか（ブール値） |\n\n**Skill パラメータ**（resource=skill）：\n\n| パラメータ | 必須 | 説明 |\n|------|------|------|\n| `repo` | はい | リポジトリ（形式：`owner/name`） |\n| `directory` | いいえ | ディレクトリパス |\n| `branch` | いいえ | Git ブランチ |\n\n**例**：\n```\nccswitch://v1/import?resource=provider&app=claude&name=My%20Provider&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-xxx\n```\n\n## インポートタイプの例\n\n### プロバイダーのインポート\n\n```\nccswitch://v1/import?resource=provider&app=claude&name=My%20Provider&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-xxx\n```\n\n### MCP サーバーのインポート\n\n```\nccswitch://v1/import?resource=mcp&apps=claude,codex&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22mcp-server-fetch%22%5D%7D&name=mcp-fetch\n```\n\n### Prompt プリセットのインポート\n\n```\nccswitch://v1/import?resource=prompt&app=claude&name=%E4%BB%A3%E7%A0%81%E5%AE%A1%E6%9F%A5&content=%23%20%E8%A7%92%E8%89%B2%0A%E4%BD%A0%E6%98%AF%E4%B8%80%E4%B8%AA%E4%B8%93%E4%B8%9A%E7%9A%84%E4%BB%A3%E7%A0%81%E5%AE%A1%E6%9F%A5%E4%B8%93%E5%AE%B6\n```\n\n### Skill のインポート\n\n```\nccswitch://v1/import?resource=skill&name=my-skill&repo=owner/repo&directory=skills/my-skill&branch=main\n```\n\n## ディープリンクの生成\n\n### 手動生成\n\n1. パラメータを準備\n2. V1 プロトコル形式で URL を組み立て\n3. 特殊文字を URL エンコード\n\n**例**：\n\n```javascript\nconst params = new URLSearchParams({\n  resource: 'provider',\n  app: 'claude',\n  name: 'My Provider',\n  endpoint: 'https://api.example.com',\n  apiKey: 'sk-xxx'\n});\n\nconst url = `ccswitch://v1/import?${params.toString()}`;\n```\n\n### オンラインツール\n\nCC Switch 公式のオンラインディープリンク生成ツールを使用するとより便利です。\n\n## ディープリンクの使用\n\n### リンクのクリック\n\nブラウザや他のアプリでディープリンクをクリック：\n\n1. システムが CC Switch を開くかどうかを確認\n2. 確認後、CC Switch が起動\n3. インポート確認ダイアログを表示\n4. インポートを確認\n\n### インポートの確認\n\nインポート前に確認ダイアログが表示され、以下が含まれます：\n\n- インポートタイプ\n- 設定のプレビュー\n- 確認/キャンセルボタン\n\n**セキュリティ上の注意**：信頼できるソースからの設定のみインポートしてください。\n\n## プロトコルの登録\n\n### 自動登録\n\nCC Switch のインストール時に `ccswitch://` プロトコルが自動登録されます。\n\n### 手動登録\n\nプロトコルが正しく登録されていない場合：\n\n**macOS**：\nアプリを再インストールするか、以下を実行：\n```bash\n/usr/bin/open -a \"CC Switch\" --args --register-protocol\n```\n\n**Windows**：\nアプリを再インストールするか、レジストリを確認：\n```\nHKEY_CLASSES_ROOT\\ccswitch\n```\n\n**Linux**：\n`.desktop` ファイルの `MimeType` 設定を確認。\n\n## セキュリティに関する考慮事項\n\n### 機密情報\n\nディープリンクには機密情報（API Key など）が含まれる場合があります：\n\n- API Key を含むリンクを公開の場で共有しない\n- 共有前に機密情報を削除または置換\n- 安全なチャネルでリンクを送信\n\n### ソースの確認\n\nインポート前に CC Switch は以下を実行します：\n\n1. データ形式の検証\n2. 設定のプレビュー表示\n3. ユーザーの確認を要求\n\n### 悪意のあるリンクからの防護\n\nCC Switch は以下を確認します：\n\n- データ形式が正当か\n- 必須フィールドが揃っているか\n- 設定値が妥当な範囲内か\n\n## サンプルリンク\n\n### 例：Claude プロバイダーのインポート\n\n```\nccswitch://v1/import?resource=provider&app=claude&name=Test%20Provider&apiKey=sk-xxx&endpoint=https%3A%2F%2Fapi.example.com\n```\n\n### 例：MCP サーバーのインポート\n\n```\nccswitch://v1/import?resource=mcp&name=mcp-fetch&apps=claude,codex,gemini&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22mcp-server-fetch%22%5D%7D\n```\n\n## トラブルシューティング\n\n### リンクが開けない\n\n**確認事項**：\n1. CC Switch がインストールされているか\n2. プロトコルが正しく登録されているか\n3. リンクの形式が正しいか\n\n### インポートに失敗する\n\n**考えられる原因**：\n- Base64 エンコードのエラー\n- JSON 形式のエラー\n- 必須フィールドの不足\n\n**解決方法**：\n1. 元の JSON 形式を確認\n2. Base64 エンコードをやり直す\n3. すべての必須フィールドが存在することを確認\n"
  },
  {
    "path": "docs/user-manual/ja/5-faq/5.4-env-conflict.md",
    "content": "# 5.4 環境変数の競合\n\n## 機能説明\n\nCC Switch は、システム環境変数とアプリ設定の競合を自動的に検出し、設定が意図せず上書きされるのを防ぎます。\n\n**検出される環境変数**：\n- `ANTHROPIC_API_KEY` - Claude API キー\n- `ANTHROPIC_BASE_URL` - Claude API エンドポイント\n- `OPENAI_API_KEY` - OpenAI API キー\n- `GEMINI_API_KEY` - Gemini API キー\n- その他の関連環境変数\n\n## 競合の警告\n\n競合が検出されると、画面の上部に黄色い警告バナーが表示されます：\n\n```\n⚠️ 環境変数の競合を検出\nX 個の環境変数が CC Switch の設定と競合する可能性があります\n[展開] [閉じる]\n```\n\n## 競合の詳細を確認\n\n「展開」ボタンをクリックして詳細情報を表示します：\n\n| フィールド | 説明 |\n|------|------|\n| 変数名 | 環境変数名 |\n| 変数値 | 現在設定されている値 |\n| ソース | 変数の出処 |\n\n### ソースの種類\n\n| ソース | 説明 |\n|------|------|\n| ユーザーレジストリ | Windows のユーザーレベル環境変数 |\n| システムレジストリ | Windows のシステムレベル環境変数 |\n| Shell 設定 | macOS/Linux の Shell 設定ファイル |\n| システム環境 | システムレベルの環境変数 |\n\n## 競合の処理\n\n### 削除する変数の選択\n\n1. 削除する環境変数にチェックを入れる\n2. または「すべて選択」ですべての競合変数を選択\n\n### 変数の削除\n\n1. 「選択を削除」ボタンをクリック\n2. 削除操作を確認\n3. CC Switch が自動的にバックアップしてから、選択した変数を削除\n\n### 自動バックアップ\n\n削除前に自動的にバックアップが作成されます：\n\n- バックアップの場所：`~/.cc-switch/env-backups/`\n- バックアップ形式：JSON ファイル\n- 変数名、値、ソースなどの情報を含む\n\n## 警告を無視する\n\n競合が使用に影響しないことが確認できる場合：\n\n1. 警告バナーの右側にある「閉じる」ボタンをクリック\n2. 警告が一時的に非表示になる\n3. 次回の起動時に再度検出が行われる\n\n## 手動での処理\n\nCC Switch 経由で削除したくない場合は、手動で処理できます：\n\n### Windows\n\n1. 「システムのプロパティ → 詳細 → 環境変数」を開く\n2. ユーザー変数またはシステム変数で競合する変数を見つける\n3. 変数を削除または変更\n\n### macOS / Linux\n\n1. Shell 設定ファイル（例：`~/.zshrc`、`~/.bashrc`）を編集\n2. 関連する `export` 文を削除またはコメントアウト\n3. 設定を再読み込み：`source ~/.zshrc`\n\n## なぜ競合が発生するのか\n\n環境変数の優先度は通常、設定ファイルよりも高く、以下の問題を引き起こす可能性があります：\n\n- CC Switch で設定したプロバイダー設定が上書きされる\n- API リクエストが間違ったエンドポイントに送信される\n- 間違った API キーが使用される\n\n## ベストプラクティス\n\n1. **CC Switch で設定を管理**：システム環境変数に API キーを設定しないようにする\n2. **定期的に確認**：競合の警告に注意し、速やかに対処\n3. **重要な変数のバックアップ**：削除前にバックアップを確認\n\n## 削除した変数の復元\n\n環境変数を誤って削除した場合：\n\n1. バックアップファイルを見つける：`~/.cc-switch/env-backups/`\n2. 対応する JSON ファイルを開く\n3. システム環境に手動で変数を復元\n"
  },
  {
    "path": "docs/user-manual/ja/README.md",
    "content": "# CC Switch ユーザーマニュアル\n\n> Claude Code / Codex / Gemini CLI / OpenCode / OpenClaw オールインワンアシスタント\n\n## 目次構成\n\n```\nCC Switch ユーザーマニュアル\n│\n├── 1. はじめに\n│   ├── 1.1 ソフトウェア紹介\n│   ├── 1.2 インストールガイド\n│   ├── 1.3 インターフェース概要\n│   ├── 1.4 クイックスタート\n│   └── 1.5 個人設定\n│\n├── 2. プロバイダー管理\n│   ├── 2.1 プロバイダーの追加\n│   ├── 2.2 プロバイダーの切り替え\n│   ├── 2.3 プロバイダーの編集\n│   ├── 2.4 並べ替えと複製\n│   └── 2.5 使用量クエリ\n│\n├── 3. 拡張機能\n│   ├── 3.1 MCP サーバー管理\n│   ├── 3.2 Prompts プロンプト管理\n│   └── 3.3 Skills スキル管理\n│\n├── 4. プロキシと高可用性\n│   ├── 4.1 プロキシサービス\n│   ├── 4.2 アプリケーション接管\n│   ├── 4.3 フェイルオーバー\n│   ├── 4.4 使用量統計\n│   └── 4.5 モデルテスト\n│\n└── 5. よくある質問\n    ├── 5.1 設定ファイルの説明\n    ├── 5.2 FAQ\n    ├── 5.3 ディープリンクプロトコル\n    └── 5.4 環境変数の競合\n```\n\n## ファイル一覧\n\n### 1. はじめに\n\n| ファイル | 内容 |\n|------|------|\n| [1.1-introduction.md](./1-getting-started/1.1-introduction.md) | ソフトウェア紹介、主要機能、対応プラットフォーム |\n| [1.2-installation.md](./1-getting-started/1.2-installation.md) | Windows/macOS/Linux インストールガイド |\n| [1.3-interface.md](./1-getting-started/1.3-interface.md) | インターフェースレイアウト、ナビゲーションバー、プロバイダーカードの説明 |\n| [1.4-quickstart.md](./1-getting-started/1.4-quickstart.md) | 5 分でできるクイックスタートチュートリアル |\n| [1.5-settings.md](./1-getting-started/1.5-settings.md) | 言語、テーマ、ディレクトリ、クラウド同期の設定 |\n\n### 2. プロバイダー管理\n\n| ファイル | 内容 |\n|------|------|\n| [2.1-add.md](./2-providers/2.1-add.md) | プリセットの使用、カスタム設定、統一プロバイダー |\n| [2.2-switch.md](./2-providers/2.2-switch.md) | メイン画面での切り替え、トレイでの切り替え、反映方法 |\n| [2.3-edit.md](./2-providers/2.3-edit.md) | 設定の編集、API Key の変更、バックフィル機能 |\n| [2.4-sort-duplicate.md](./2-providers/2.4-sort-duplicate.md) | ドラッグで並べ替え、プロバイダーの複製、削除 |\n| [2.5-usage-query.md](./2-providers/2.5-usage-query.md) | 使用量クエリ、残額表示、複数プラン表示 |\n\n### 3. 拡張機能\n\n| ファイル | 内容 |\n|------|------|\n| [3.1-mcp.md](./3-extensions/3.1-mcp.md) | MCP プロトコル、サーバーの追加、アプリバインド |\n| [3.2-prompts.md](./3-extensions/3.2-prompts.md) | プリセットの作成、有効化の切り替え、スマートバックフィル |\n| [3.3-skills.md](./3-extensions/3.3-skills.md) | スキルの発見、インストール・アンインストール、リポジトリ管理 |\n\n### 4. プロキシと高可用性\n\n| ファイル | 内容 |\n|------|------|\n| [4.1-service.md](./4-proxy/4.1-service.md) | プロキシの起動、設定項目、実行状態 |\n| [4.2-takeover.md](./4-proxy/4.2-takeover.md) | アプリケーション接管、設定変更、ステータス表示 |\n| [4.3-failover.md](./4-proxy/4.3-failover.md) | フェイルオーバーキュー、サーキットブレーカー、ヘルスステータス |\n| [4.4-usage.md](./4-proxy/4.4-usage.md) | 使用量統計、トレンドグラフ、料金設定 |\n| [4.5-model-test.md](./4-proxy/4.5-model-test.md) | モデルテスト、ヘルスチェック、レイテンシテスト |\n\n### 5. よくある質問\n\n| ファイル | 内容 |\n|------|------|\n| [5.1-config-files.md](./5-faq/5.1-config-files.md) | CC Switch のストレージ、CLI 設定ファイル形式 |\n| [5.2-questions.md](./5-faq/5.2-questions.md) | よくある質問と回答 |\n| [5.3-deeplink.md](./5-faq/5.3-deeplink.md) | ディープリンクプロトコル、生成と使用方法 |\n| [5.4-env-conflict.md](./5-faq/5.4-env-conflict.md) | 環境変数の競合検出と対処 |\n\n## クイックリンク\n\n- **初めての方**：[1.1 ソフトウェア紹介](./1-getting-started/1.1-introduction.md) からお読みください\n- **インストールの問題**：[1.2 インストールガイド](./1-getting-started/1.2-installation.md) をご確認ください\n- **プロバイダーの設定**：[2.1 プロバイダーの追加](./2-providers/2.1-add.md) をご確認ください\n- **プロキシの使用**：[4.1 プロキシサービス](./4-proxy/4.1-service.md) をご確認ください\n- **お困りの方**：[5.2 FAQ](./5-faq/5.2-questions.md) をご確認ください\n\n## バージョン情報\n\n- ドキュメントバージョン：v3.12.0\n- 最終更新：2026-03-09\n- CC Switch v3.12.0+ 対応\n\n## コントリビュート\n\nIssue や PR でドキュメントの改善にご協力ください：\n\n- [GitHub Issues](https://github.com/farion1231/cc-switch/issues)\n- [GitHub Repository](https://github.com/farion1231/cc-switch)\n"
  },
  {
    "path": "docs/user-manual/zh/1-getting-started/1.1-introduction.md",
    "content": "# 1.1 软件介绍\n\n## 什么是 CC Switch\n\nCC Switch 是一款跨平台桌面应用，专为使用 AI 编程工具的开发者设计。它帮助你统一管理 **Claude Code**、**Codex**、**Gemini CLI**、**OpenCode** 和 **OpenClaw** 五大 AI 编程工具的配置。\n\n## 解决什么问题\n\n在日常开发中，你可能会遇到这些痛点：\n\n- **多供应商切换麻烦**：使用不同的 API 供应商（官方、中转服务商），需要手动修改配置文件\n- **配置分散难管理**：Claude、Codex、Gemini、OpenCode、OpenClaw 各有独立的配置文件，格式不同\n- **无法监控用量**：不知道 API 调用了多少次，花了多少钱\n- **服务不稳定**：单一供应商出问题时，整个工作流中断\n\nCC Switch 通过统一的界面解决这些问题。\n\n## 核心功能\n\n### 供应商管理\n- 一键切换多个 API 供应商配置\n- 支持预设模板，快速添加常用供应商\n- 统一供应商功能，跨应用共享配置\n- 用量查询与余额显示\n- 端点速度测试\n\n### 扩展功能\n- **MCP 服务器**：管理 Model Context Protocol 服务器，扩展 AI 能力\n- **Prompts**：管理系统提示词预设，快速切换不同场景\n- **Skills**：安装和管理技能扩展\n\n### 代理与高可用\n- 本地代理服务，记录请求日志和用量统计\n- 自动故障转移，主供应商失败时自动切换备用\n- 熔断器机制，防止频繁重试失败的供应商\n- 详细的 Token 用量追踪与成本估算\n\n## 支持的应用\n\n| 应用 | 说明 |\n|------|------|\n| **Claude Code** | Anthropic 官方的 AI 编程助手 |\n| **Codex** | OpenAI 的代码生成工具 |\n| **Gemini CLI** | Google 的 AI 命令行工具 |\n| **OpenCode** | 开源 AI 编程终端工具 |\n| **OpenClaw** | 开源 AI 编程助手（多供应商网关） |\n\n## 支持的平台\n\n- **Windows** 10 及以上\n- **macOS** 10.15 (Catalina) 及以上\n- **Linux** Ubuntu 22.04+ / Debian 11+ / Fedora 34+\n\n## 技术架构\n\nCC Switch 使用现代化的技术栈构建：\n\n- **前端**：React 18 + TypeScript + Tailwind CSS\n- **后端**：Tauri 2 + Rust\n- **数据存储**：SQLite（供应商、MCP、Prompts）+ JSON（设备设置）\n\n这种架构确保了：\n- 跨平台一致的体验\n- 原生级别的性能\n- 安全的本地数据存储\n"
  },
  {
    "path": "docs/user-manual/zh/1-getting-started/1.2-installation.md",
    "content": "# 1.2 安装指南\n\n## 前置要求\n\n### 安装 Node.js\n\nCC Switch 管理的 CLI 工具（Claude Code、Codex、Gemini CLI）需要 Node.js 环境。\n\n**推荐版本**：Node.js 18 LTS 或更高版本\n\n#### Windows\n\n1. 访问 [Node.js 官网](https://nodejs.org/)\n\n2. 下载 LTS 版本安装包\n\n3. 运行安装程序，按提示完成安装\n\n4. 验证安装：\n\n ```bash\n node --version\n npm --version\n ```\n\n#### macOS\n\n```bash\n# 使用 Homebrew 安装\nbrew install node\n\n# 或使用 nvm（推荐）\ncurl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash\nnvm install --lts\n```\n\n#### Linux\n\n```bash\n# Ubuntu/Debian\ncurl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -\nsudo apt-get install -y nodejs\n\n# 或使用 nvm\ncurl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash\nnvm install --lts\n```\n\n### 安装 CLI 工具\n\n#### Claude Code\n\n**方式一：Homebrew（macOS 推荐）**\n\n```bash\nbrew install claude-code\n```\n\n**方式二：npm**\n\n```bash\nnpm install -g @anthropic-ai/claude-code\n\n# 国内用户如下载慢，使用镜像源\nnpm install -g @anthropic-ai/claude-code --registry=https://registry.npmmirror.com\n```\n\n#### Codex\n\n**方式一：Homebrew（macOS 推荐）**\n\n```bash\nbrew install codex\n```\n\n**方式二：npm**\n\n```bash\nnpm install -g @openai/codex\n\n# 国内用户如下载慢，使用镜像源\nnpm install -g @openai/codex --registry=https://registry.npmmirror.com\n```\n\n#### Gemini CLI\n\n**方式一：Homebrew（macOS 推荐）**\n\n```bash\nbrew install gemini-cli\n```\n\n**方式二：npm**\n\n```bash\nnpm install -g @google/gemini-cli\n\n# 国内用户如下载慢，使用镜像源\nnpm install -g @google/gemini-cli --registry=https://registry.npmmirror.com\n```\n\n> 💡 **提示**：如果经常遇到下载慢的问题，可以全局设置镜像源：\n> ```bash\n> npm config set registry https://registry.npmmirror.com\n> ```\n\n---\n\n## Windows\n\n### 安装包方式\n\n1. 访问 [Releases 页面](https://github.com/farion1231/cc-switch/releases)\n2. 下载 `CC-Switch-v{版本号}-Windows.msi`\n3. 双击运行安装程序\n4. 按提示完成安装\n\n### 绿色版（免安装）\n\n1. 下载 `CC-Switch-v{版本号}-Windows-Portable.zip`\n2. 解压到任意目录\n3. 运行 `CC-Switch.exe`\n\n## macOS\n\n### 方式一：Homebrew（推荐）\n\n```bash\n# 添加 tap\nbrew tap farion1231/ccswitch\n\n# 安装\nbrew install --cask cc-switch\n```\n\n更新到最新版本：\n\n```bash\nbrew upgrade --cask cc-switch\n```\n\n### 方式二：手动下载\n\n1. 下载 `CC-Switch-v{版本号}-macOS.zip`\n2. 解压得到 `CC Switch.app`\n3. 拖动到「应用程序」文件夹\n\n### 首次打开提示\n\n由于开发者没有 Apple 开发者账号，首次打开可能出现「未知开发者」警告：\n\n**推荐解决方法**：\n打开终端执行以下命令：\n```bash\nsudo xattr -dr com.apple.quarantine /Applications/CC\\ Switch.app/\n```\n\n**备选解决方法（通过系统设置）**：\n1. 关闭警告弹窗\n2. 打开「系统设置」→「隐私与安全性」\n3. 找到 CC Switch 相关提示，点击「仍要打开」\n4. 再次打开应用即可正常使用\n\n## Linux\n\n### ArchLinux\n\n使用 AUR 助手安装：\n\n```bash\n# 使用 paru\nparu -S cc-switch-bin\n\n# 或使用 yay\nyay -S cc-switch-bin\n```\n\n### Debian / Ubuntu\n\n1. 下载 `CC-Switch-v{版本号}-Linux.deb`\n2. 安装：\n\n```bash\nsudo dpkg -i CC-Switch-v{版本号}-Linux.deb\n\n# 如果有依赖问题\nsudo apt-get install -f\n```\n\n### AppImage（通用）\n\n1. 下载 `CC-Switch-v{版本号}-Linux.AppImage`\n2. 添加执行权限：\n\n```bash\nchmod +x CC-Switch-v{版本号}-Linux.AppImage\n```\n\n3. 运行：\n\n```bash\n./CC-Switch-v{版本号}-Linux.AppImage\n```\n\n## 验证安装\n\n安装完成后，启动 CC Switch：\n\n1. 应用窗口正常显示\n2. 系统托盘出现 CC Switch 图标\n3. 能够切换 Claude / Codex / Gemini 三个应用\n\n## 自动更新\n\nCC Switch 内置自动更新功能：\n\n- 启动时自动检查更新\n- 有新版本时在界面显示更新提示\n- 点击即可下载并安装\n\n也可以在「设置 → 关于」中手动检查更新。\n\n## 卸载\n\n### Windows\n\n- 通过「设置 → 应用」卸载\n- 或运行安装目录下的卸载程序\n\n### macOS\n\n- 将 `CC Switch.app` 移到废纸篓\n- 可选：删除配置目录 `~/.cc-switch/`\n\n### Linux\n\n```bash\n# Debian/Ubuntu\nsudo apt remove cc-switch\n\n# ArchLinux\nparu -R cc-switch-bin\n```\n"
  },
  {
    "path": "docs/user-manual/zh/1-getting-started/1.3-interface.md",
    "content": "# 1.3 界面概览\n\n## 主界面布局\n\n![image-20260108001629138](../../assets/image-20260108001629138.png)\n\n## 顶部导航栏\n\n| 序号 | 元素 | 功能说明 |\n|------|------|----------|\n| ① | Logo | 点击访问 GitHub 项目页 |\n| ② | 设置按钮 | 打开设置页面（快捷键 `Cmd/Ctrl + ,`） |\n| ③ | 代理开关 | 启动/停止本地代理服务 |\n| ④ | 应用切换器 | 切换 Claude / Codex / Gemini / OpenCode / OpenClaw |\n| ⑤ | 功能区 | Skills / Prompts / MCP 入口 |\n| ⑥ | 添加按钮 | 添加新供应商 |\n\n### 应用切换器\n\n点击下拉菜单切换当前管理的应用：\n\n- **Claude** - 管理 Claude Code 配置\n- **Codex** - 管理 Codex 配置\n- **Gemini** - 管理 Gemini CLI 配置\n- **OpenCode** - 管理 OpenCode 配置\n- **OpenClaw** - 管理 OpenClaw 配置\n\n切换后，供应商列表会显示对应应用的配置。\n\n### 功能区按钮\n\n| 按钮 | 功能 | 可见条件 |\n|------|------|----------|\n| Skills | 技能扩展管理 | 始终可见 |\n| Prompts | 系统提示词管理 | 始终可见 |\n| MCP | MCP 服务器管理 | 始终可见 |\n\n## 供应商卡片\n\n每个供应商以卡片形式展示，从左到右依次包含以下元素：\n\n### 卡片元素（从左到右）\n\n| 序号 | 元素 | 图标 | 功能说明 |\n|------|------|------|----------|\n| ① | 拖拽手柄 | ≡ | 按住上下拖动调整供应商顺序 |\n| ② | 供应商图标 | 🔷 | 显示供应商品牌图标，可自定义颜色 |\n| ③ | 供应商信息 | - | 名称、备注/端点地址（可点击打开官网） |\n| ④ | 用量信息 | - | 显示剩余额度，多套餐时显示套餐数量 |\n| ⑤ | 启用按钮 | ▶ | 切换为当前使用的供应商 |\n| ⑥ | 编辑按钮 | ✏️ | 编辑供应商配置 |\n| ⑦ | 复制按钮 | 📋 | 复制供应商（创建副本） |\n| ⑧ | 测速按钮 | 🧪 | 测试模型可用性和响应速度 |\n| ⑨ | 用量查询 | 📊 | 配置用量查询脚本 |\n| ⑩ | 删除按钮 | 🗑️ | 删除供应商（当前启用时禁用） |\n\n> 💡 **提示**：操作按钮区域（⑤-⑩）在鼠标悬停时显示，平时隐藏以保持界面简洁。\n\n### 按钮详细说明\n\n| 按钮 | 状态变化 | 说明 |\n|------|----------|------|\n| **启用** | 已启用时显示 ✓ 并禁用 | 故障转移模式下变为「加入/已加入」 |\n| **编辑** | 始终可用 | 打开编辑面板修改配置 |\n| **复制** | 始终可用 | 创建供应商副本，名称后缀 `copy` |\n| **测速** | 测试中显示加载动画 | 仅代理服务运行时可用 |\n| **用量查询** | 始终可用 | 配置自定义用量查询脚本 |\n| **删除** | 当前启用时半透明禁用 | 需先切换到其他供应商才能删除 |\n\n### 卡片状态\n\n| 状态 | 边框颜色 | 说明 |\n|------|----------|------|\n| **当前启用** | 🔵 蓝色边框 | 普通模式下当前使用的供应商 |\n| **代理活跃** | 🟢 绿色边框 | 代理接管模式下实际使用的供应商 |\n| **普通状态** | 默认边框 | 未启用的供应商 |\n| **故障转移中** | 显示优先级徽章 | 如 P1、P2 表示故障转移优先级 |\n\n### 健康状态徽章\n\n在代理模式下，加入故障转移队列的供应商会显示健康状态：\n\n| 徽章 | 颜色 | 说明 |\n|------|------|------|\n| 健康 | 🟢 绿色 | 连续失败 0 次 |\n| 警告 | 🟡 黄色 | 连续失败 1-2 次 |\n| 不健康 | 🔴 红色 | 连续失败 ≥3 次，可能触发熔断 |\n\n\n## 系统托盘\n\nCC Switch 在系统托盘显示图标，提供快速操作入口。\n\n### 托盘菜单结构\n\n![image-20260108002153668](../../assets/image-20260108002153668.png)\n\n### 菜单功能\n\n| 菜单项 | 功能 |\n|--------|------|\n| 打开主界面 | 显示主窗口并聚焦 |\n| 应用分组 | 按 Claude/Codex/Gemini/OpenCode/OpenClaw 分组显示供应商 |\n| 供应商列表 | 点击切换，当前启用的显示勾选标记 |\n| 退出 | 完全退出应用 |\n\n### 多语言支持\n\n托盘菜单支持三种语言，根据设置自动切换：\n\n| 语言 | 打开主界面 | 退出 |\n|------|-----------|------|\n| 中文 | 打开主界面 | 退出 |\n| English | Open main window | Quit |\n| 日本語 | メインウィンドウを開く | 終了 |\n\n### 使用场景\n\n托盘切换供应商无需打开主界面，适合：\n\n- 频繁切换供应商\n- 主窗口最小化时快速操作\n- 后台运行时管理配置\n\n## 设置页面\n\n设置页面分为多个 Tab：\n\n### 通用 Tab\n\n- 语言设置（中文/English/日本語）\n- 主题设置（跟随系统/浅色/深色）\n- 窗口行为（开机自启、关闭行为）\n\n### 高级 Tab\n\n- 配置目录设置\n- 代理服务配置\n- 故障转移设置\n- 定价配置\n- 数据导入导出\n\n### 用量 Tab\n\n- 请求统计概览\n- 趋势图表\n- 请求日志\n- 供应商/模型统计\n\n### 关于 Tab\n\n- 版本信息\n- 更新检查\n- 开源协议\n\n## 快捷键\n\n| 快捷键 | 功能 |\n|--------|------|\n| `Cmd/Ctrl + ,` | 打开设置 |\n| `Cmd/Ctrl + F` | 搜索供应商 |\n| `Esc` | 关闭弹窗/搜索 |\n\n## 搜索功能\n\n按 `Cmd/Ctrl + F` 打开搜索框：\n\n- 支持按名称、备注、URL 搜索\n- 实时过滤供应商列表\n- 按 `Esc` 关闭搜索\n"
  },
  {
    "path": "docs/user-manual/zh/1-getting-started/1.4-quickstart.md",
    "content": "# 1.4 快速上手\n\n本节帮助你在 5 分钟内完成首次配置。\n\n## 第一步：添加供应商\n\n1. 点击主界面右上角的 **+** 按钮\n2. 在「预设」下拉框中选择你的供应商\n   - 常用预设：智谱 GLM、MiniMax、DeepSeek、Kimi、PackyCode\n   - 或选择「自定义」手动配置\n3. 填写 **API Key**\n4. 点击「添加」\n\n![image-20260108002807657](../../assets/image-20260108002807657.png)\n\n> 💡 **提示**：预设会自动填充端点地址，你只需要填写 API Key。\n\n## 第二步：切换供应商\n\n添加完成后，供应商会出现在列表中。\n\n**方式一：主界面切换**\n- 点击供应商卡片的「启用」按钮\n\n**方式二：托盘快速切换**\n- 右键系统托盘图标\n- 直接点击供应商名称\n\n## 第三步：生效方式\n\n切换供应商后，各 CLI 工具的生效方式不同：\n\n| 应用 | 生效方式 |\n|------|----------|\n| Claude Code | ✅ 即时生效（支持热重载） |\n| Codex | 需关闭并重新打开终端 |\n| Gemini | ✅ 即时生效（每次请求重新读取配置） |\n\n### Claude Code 首次安装提示\n\n如果 Claude Code 首次启动时提示需要**登录**或显示初始化引导，请在 CC Switch 中开启「跳过 Claude Code 初次安装确认」选项：\n\n1. 打开 CC Switch「设置 → 通用」\n2. 开启「跳过 Claude Code 初次安装确认」开关\n3. 重新启动 Claude Code\n\n![image-20260108002626389](../../assets/image-20260108002626389.png)\n\n> ⚠️ **注意**：此选项会写入 `~/.claude/settings.json` 的 `skipIntroduction` 字段，跳过官方的新手引导流程。\n\n## 验证配置\n\n重启后，启动对应的 CLI 工具并输入简单的问题进行测试：\n\n```bash\n# Claude Code - 启动后输入测试问题\nclaude\n> 你好，请简单介绍一下自己\n\n# Codex - 启动后输入测试问题\ncodex\n> 你好，请简单介绍一下自己\n\n# Gemini - 启动后输入测试问题\ngemini\n> 你好，请简单介绍一下自己\n```\n\n如果 AI 能正常回复，说明配置成功。\n\n## 下一步\n\n恭喜！你已经完成了基础配置。接下来可以：\n\n- [添加更多供应商](../2-providers/2.1-add.md) - 配置多个供应商方便切换\n- [配置 MCP 服务器](../3-extensions/3.1-mcp.md) - 扩展 AI 工具的能力\n- [设置系统提示词](../3-extensions/3.2-prompts.md) - 自定义 AI 的行为\n- [开启代理服务](../4-proxy/4.1-service.md) - 监控用量和自动故障转移\n\n## 常见问题\n\n### 切换后不生效？\n\n确保重启了终端或 CLI 工具。配置文件在切换时已经更新，但运行中的程序不会自动重新加载。\n\n### 找不到预设？\n\n如果你的供应商不在预设列表中，选择「自定义」手动配置。参考 [添加供应商](../2-providers/2.1-add.md) 了解配置格式。\n\n### 如何恢复官方登录？\n\n选择「官方登录」预设（Claude/Codex）或「Google 官方」预设（Gemini），重启客户端后按登录流程操作。\n"
  },
  {
    "path": "docs/user-manual/zh/1-getting-started/1.5-settings.md",
    "content": "# 1.5 个性化配置\n\n本节介绍如何根据个人偏好配置 CC Switch。\n\n## 打开设置\n\n- 点击左上角 **⚙️** 按钮\n- 或使用快捷键 `Cmd/Ctrl + ,`\n\n## 语言设置\n\nCC Switch 支持三种语言：\n\n| 语言     | 说明     |\n| -------- | -------- |\n| 简体中文 | 默认语言 |\n| English  | 英文界面 |\n| 日本語   | 日文界面 |\n\n切换语言后立即生效，无需重启。\n\n## 主题设置\n\n| 选项     | 说明                        |\n| -------- | --------------------------- |\n| 跟随系统 | 自动匹配系统的深色/浅色模式 |\n| 浅色     | 始终使用浅色主题            |\n| 深色     | 始终使用深色主题            |\n\n## 窗口行为\n\n### 开机自启\n\n开启后，系统启动时自动运行 CC Switch。\n\n- **Windows**：通过注册表实现\n- **macOS**：通过 LaunchAgent 实现\n- **Linux**：通过 XDG autostart 实现\n\n### 关闭行为\n\n| 选项         | 说明                         |\n| ------------ | ---------------------------- |\n| 最小化到托盘 | 点击关闭按钮时隐藏到系统托盘 |\n| 直接退出     | 点击关闭按钮时完全退出应用   |\n\n推荐使用「最小化到托盘」，方便通过托盘快速切换供应商。\n\n### Claude 插件集成\n\n开启后，CC Switch 在切换供应商时会自动同步配置到 VS Code 中的 Claude Code 插件（写入 `~/.claude/config.json` 的 `primaryApiKey`）。\n\n> 💡 **使用场景**：如果你同时使用 Claude Code CLI 和 VS Code 插件，开启此选项可以保持两者配置一致。\n\n### 跳过 Claude 引导\n\n开启后，跳过 Claude Code 的新手引导流程，适合已熟悉 Claude Code 的用户。\n\n> ⚠️ **注意**：此选项会写入 `~/.claude/settings.json` 的 `skipIntroduction` 字段。\n\n### 应用可见性\n\n选择在应用切换器中显示哪些应用。每个应用可以独立开关，但至少保留一个。\n\n可配置的应用：Claude、Codex、Gemini、OpenCode、OpenClaw。\n\n> 💡 **使用场景**：如果你只使用 Claude Code 和 Codex CLI，可以隐藏其他应用，保持界面简洁。\n\n### Skills 同步方式\n\n设置技能安装到各应用目录时的同步方式：\n\n| 方式              | 说明                                                 |\n| ----------------- | ---------------------------------------------------- |\n| 软链接（Symlink） | 创建符号链接指向技能源文件，占用空间小，更新实时同步 |\n| 复制（Copy）      | 将技能文件完整复制到目标目录                         |\n\n> 💡 **推荐**：默认使用软链接方式。如果遇到权限问题，可切换为复制方式。\n\n### 终端设置\n\n选择 CC Switch 打开终端时使用的终端应用程序。\n\n支持的终端（按平台）：\n\n| 平台    | 终端选项                                                           |\n| ------- | ------------------------------------------------------------------ |\n| macOS   | Terminal、iTerm2、Alacritty、Kitty、Ghostty、WezTerm               |\n| Windows | CMD、PowerShell、Windows Terminal                                  |\n| Linux   | GNOME Terminal、Konsole、Xfce4 Terminal、Alacritty、Kitty、Ghostty |\n\n## 目录配置\n\n### 应用配置目录\n\nCC Switch 自身数据的存储位置，默认为 `~/.cc-switch/`。\n\n### CLI 工具目录\n\n可以自定义各 CLI 工具的配置目录：\n\n| 配置          | 默认值         | 说明                 |\n| ------------- | -------------- | -------------------- |\n| Claude 目录   | `~/.claude/`   | Claude Code 配置目录 |\n| Codex 目录    | `~/.codex/`    | Codex 配置目录       |\n| Gemini 目录   | `~/.gemini/`   | Gemini CLI 配置目录  |\n| OpenCode 目录 | `~/.opencode/` | OpenCode 配置目录    |\n| OpenClaw 目录 | `~/.openclaw/` | OpenClaw 配置目录    |\n\n> ⚠️ **注意**：修改目录后需要重启应用，且对应的 CLI 工具也需要配置相同的目录。\n\n## 数据管理\n\n### 导出配置\n\n点击「导出」按钮，保存包含以下内容的备份文件：\n\n- 所有供应商配置\n- MCP 服务器配置\n- Prompts 预设\n- 应用设置\n\n备份文件格式为 JSON，可以用文本编辑器查看。\n\n### 导入配置\n\n1. 点击「选择文件」\n2. 选择之前导出的备份文件\n3. 点击「导入」\n4. 确认覆盖现有配置\n\n> ⚠️ **注意**：导入会覆盖现有配置，建议先导出当前配置作为备份。\n\n## 代理设置\n\n设置 → 代理 Tab\n\n代理 Tab 集中管理所有代理相关功能：\n\n### 本地代理\n\n启动/停止本地代理服务，配置监听地址和端口。详见 [4.1 代理服务](../4-proxy/4.1-service.md)。\n\n### 故障转移\n\n按应用（Claude/Codex/Gemini）配置故障转移队列和自动切换策略。详见 [4.3 故障转移](../4-proxy/4.3-failover.md)。\n\n### 定价矫正器\n\n配置模型定价矫正规则，用于代理计费统计的校准。\n\n### 全局出站代理\n\n配置 CC Switch 的出站 HTTP/HTTPS 代理，适用于需要通过代理访问外部 API 的场景。\n\n## 高级设置\n\n设置 → 高级 Tab\n\n### 配置目录\n\n自定义各应用的配置文件目录。详见下方「目录配置」章节。\n\n### 数据管理\n\n导入/导出配置备份。详见下方「数据管理」章节。\n\n### 备份与恢复\n\n管理自动备份：\n\n| 配置     | 说明                       |\n| -------- | -------------------------- |\n| 备份间隔 | 自动备份的时间间隔（小时） |\n| 保留数量 | 保留的备份份数             |\n\n支持查看备份列表和从备份恢复。\n\n### 云同步（WebDAV）\n\n通过 WebDAV 协议在多台设备间同步配置。\n\n| 配置项   | 说明                                  |\n| -------- | ------------------------------------- |\n| 服务预设 | 坚果云 / Nextcloud / 群晖 / 自定义    |\n| 服务地址 | WebDAV 服务器 URL                     |\n| 用户名   | 登录用户名                            |\n| 密码     | 登录密码（应用专用密码）              |\n| 远程目录 | 远程存储路径（默认 `cc-switch-sync`） |\n| 配置名称 | 设备配置文件名（默认 `default`）      |\n| 自动同步 | 开启后自动上传变更                    |\n\n操作：\n\n- **测试连接**：验证 WebDAV 配置是否正确\n- **保存**：保存配置并自动测试\n- **上传**：将本地数据上传到远程\n- **下载**：从远程下载数据到本地\n\n> ⚠️ **注意**：上传会覆盖远程数据，下载会覆盖本地数据。操作前请确认。\n\n### 日志配置\n\n| 配置项   | 说明                                |\n| -------- | ----------------------------------- |\n| 启用日志 | 开启/关闭应用日志记录               |\n| 日志级别 | error / warn / info / debug / trace |\n\n日志级别说明：\n\n- **error** - 仅记录错误\n- **warn** - 记录警告和错误\n- **info** - 记录一般信息（推荐）\n- **debug** - 记录调试信息\n- **trace** - 记录所有详细信息\n\n## 关于页面\n\n设置 → 关于 Tab\n\n### 版本信息\n\n显示当前 CC Switch 版本号，支持：\n\n- 查看发布说明\n- 检查更新\n- 下载并安装新版本\n\n### 本地环境检查\n\n自动检测已安装的 CLI 工具版本：\n\n| 工具     | 检测内容           |\n| -------- | ------------------ |\n| Claude   | 当前版本、最新版本 |\n| Codex    | 当前版本、最新版本 |\n| Gemini   | 当前版本、最新版本 |\n| OpenCode | 当前版本、最新版本 |\n| OpenClaw | 当前版本、最新版本 |\n\n点击「刷新」按钮可重新检测。\n\n### 一键安装命令\n\n提供快速安装/更新 CLI 工具的命令：\n\n```bash\nnpm i -g @anthropic-ai/claude-code@latest\nnpm i -g @openai/codex@latest\nnpm i -g @google/gemini-cli@latest\nnpm i -g opencode@latest\nnpm i -g openclaw@latest\n```\n\n点击「复制」按钮可复制到剪贴板。\n"
  },
  {
    "path": "docs/user-manual/zh/2-providers/2.1-add.md",
    "content": "# 2.1 添加供应商\n\n## 打开添加面板\n\n点击主界面右上角的 **+** 按钮，打开添加供应商面板。\n\n面板分为两个 Tab：\n- **应用专属供应商**：仅用于当前选中的应用（Claude/Codex/Gemini/OpenCode/OpenClaw）\n- **统一供应商**：跨应用共享的配置\n\n## 使用预设添加\n\n预设是预先配置好的供应商模板，只需填写 API Key 即可使用。\n\n### 操作步骤\n\n1. 在「预设」下拉框中选择供应商\n2. 名称和端点会自动填充\n3. 填写你的 **API Key**\n4. （可选）填写备注\n5. 点击「添加」\n\n### 常用预设\n\n#### Claude 预设\n\n| 预设名称 | 说明 |\n|----------|------|\n| Claude 官方 | 使用 Anthropic 官方账号登录 |\n| DeepSeek | DeepSeek 模型 |\n| 智谱 GLM | 智谱 AI 的 GLM 模型 |\n| 智谱 GLM en | 智谱 AI（英文版） |\n| 百炼 | 阿里云百炼（通义千问） |\n| Kimi | Moonshot Kimi 模型 |\n| Kimi For Coding | Kimi 编程专用模型 |\n| StepFun | 阶跃星辰 Step模型 |\n| ModelScope | 魔搭社区 |\n| KAT-Coder | KAT-Coder 模型 |\n| Longcat | Longcat AI |\n| MiniMax | MiniMax 模型 |\n| MiniMax en | MiniMax（英文版） |\n| DouBaoSeed | 豆包 Seed 模型 |\n| BaiLing | 百灵 AI |\n| AiHubMix | AiHubMix 聚合服务 |\n| SiliconFlow | 硅基流动 |\n| SiliconFlow en | 硅基流动（英文版） |\n| DMXAPI | DMXAPI 中转服务 |\n| PackyCode | PackyCode 中转服务 ⭐ |\n| Cubence | Cubence 服务 |\n| AIGoCode | AIGoCode 服务 |\n| RightCode | RightCode 服务 |\n| AICodeMirror | AICodeMirror 服务 |\n| OpenRouter | 聚合路由服务 |\n| Nvidia | Nvidia AI 服务 |\n| Xiaomi MiMo | 小米 MiMo 模型 |\n\n> ⭐ 标注为官方合作伙伴。预设列表可能随版本更新，以应用内实际显示为准。\n\n#### Codex 预设\n\n| 预设名称 | 说明 |\n|----------|------|\n| OpenAI 官方 | 使用 OpenAI 官方账号登录 |\n| Azure OpenAI | Azure OpenAI 服务 |\n| AiHubMix | AiHubMix 聚合服务 |\n| DMXAPI | DMXAPI 中转服务 |\n| PackyCode | PackyCode 中转服务 |\n| Cubence | Cubence 服务 |\n| AIGoCode | AIGoCode 服务 |\n| RightCode | RightCode 服务 |\n| AICodeMirror | AICodeMirror 服务 |\n| OpenRouter | 聚合路由服务 |\n\n#### Gemini 预设\n\n| 预设名称 | 说明 |\n|----------|------|\n| Google 官方 | 使用 Google OAuth 登录 |\n| PackyCode | PackyCode 中转服务 |\n| Cubence | Cubence 服务 |\n| AIGoCode | AIGoCode 服务 |\n| AICodeMirror | AICodeMirror 服务 |\n| OpenRouter | 聚合路由服务 |\n| 自定义 | 手动配置所有参数 |\n\n#### OpenCode 预设\n\n| 预设名称 | 说明 |\n|----------|------|\n| DeepSeek | DeepSeek 模型 |\n| 智谱 GLM | 智谱 AI 的 GLM 模型 |\n| 智谱 GLM en | 智谱 AI（英文版） |\n| 百炼 | 阿里云百炼 |\n| Kimi k2.5 | Moonshot Kimi-k2.5 模型 |\n| Kimi For Coding | Kimi 编程专用模型 |\n| StepFun | 阶跃星辰 Step模型 |\n| ModelScope | 魔搭社区 |\n| KAT-Coder | KAT-Coder 模型 |\n| Longcat | Longcat AI |\n| MiniMax | MiniMax 模型 |\n| MiniMax en | MiniMax（英文版） |\n| DouBaoSeed | 豆包 Seed 模型 |\n| BaiLing | 百灵 AI |\n| Xiaomi MiMo | 小米 MiMo 模型 |\n| AiHubMix | AiHubMix 聚合服务 |\n| DMXAPI | DMXAPI 中转服务 |\n| OpenRouter | 聚合路由服务 |\n| Nvidia | Nvidia AI 服务 |\n| PackyCode | PackyCode 中转服务 |\n| Cubence | Cubence 服务 |\n| AIGoCode | AIGoCode 服务 |\n| RightCode | RightCode 服务 |\n| AICodeMirror | AICodeMirror 服务 |\n| OpenAI Compatible | OpenAI 兼容接口 |\n| Oh My OpenCode | Oh My OpenCode 服务 |\n\n> 💡 预设列表持续更新中，以应用内实际显示为准。\n\n#### OpenClaw 预设\n\n| 预设名称 | 说明 |\n|----------|------|\n| DeepSeek | DeepSeek 模型 |\n| 智谱 GLM | 智谱 AI 的 GLM 模型 |\n| 智谱 GLM en | 智谱 AI（英文版） |\n| Qwen Coder | 通义千问编码模型 |\n| Kimi k2.5 | Moonshot Kimi-k2.5 模型 |\n| Kimi For Coding | Kimi 编程专用模型 |\n| StepFun | 阶跃星辰 Step模型 |\n| MiniMax | MiniMax 模型 |\n| MiniMax en | MiniMax（英文版） |\n| KAT-Coder | KAT-Coder 模型 |\n| Longcat | Longcat AI |\n| DouBaoSeed | 豆包 Seed 模型 |\n| BaiLing | 百灵 AI |\n| Xiaomi MiMo | 小米 MiMo 模型 |\n| AiHubMix | AiHubMix 聚合服务 |\n| DMXAPI | DMXAPI 中转服务 |\n| OpenRouter | 聚合路由服务 |\n| ModelScope | 魔搭社区 |\n| SiliconFlow | 硅基流动 |\n| SiliconFlow en | 硅基流动（英文版） |\n| Nvidia | Nvidia AI 服务 |\n| PackyCode | PackyCode 中转服务 |\n| Cubence | Cubence 服务 |\n| AIGoCode | AIGoCode 服务 |\n| RightCode | RightCode 服务 |\n| AICodeMirror | AICodeMirror 服务 |\n| AICoding | AICoding 服务 |\n| CrazyRouter | CrazyRouter 服务 |\n| SSSAiCode | SSSAiCode 服务 |\n| AWS Bedrock | AWS Bedrock 服务 |\n| OpenAI Compatible | OpenAI 兼容接口 |\n\n## 自定义配置\n\n选择「自定义」预设后，需要手动编辑 JSON 配置。\n\n### Claude 配置格式\n\n```json\n{\n  \"env\": {\n    \"ANTHROPIC_API_KEY\": \"your-api-key\",\n    \"ANTHROPIC_BASE_URL\": \"https://api.example.com\"\n  }\n}\n```\n\n| 字段 | 必填 | 说明 |\n|------|------|------|\n| `ANTHROPIC_API_KEY` | 是 | API 密钥 |\n| `ANTHROPIC_BASE_URL` | 否 | 自定义端点地址 |\n| `ANTHROPIC_AUTH_TOKEN` | 否 | 替代 API_KEY 的认证方式 |\n\n### Codex 配置格式\n\nCodex 使用两个配置文件：\n\n**1. auth.json**（`~/.codex/auth.json`）- 存储 API 密钥：\n\n```json\n{\n  \"OPENAI_API_KEY\": \"your-api-key\"\n}\n```\n\n**2. config.toml**（`~/.codex/config.toml`）- 存储模型和端点配置：\n\n```toml\n# 基础配置\nmodel_provider = \"custom\"\nmodel = \"gpt-5.2\"\nmodel_reasoning_effort = \"high\"\ndisable_response_storage = true\n\n# 自定义供应商配置\n[model_providers.custom]\nname = \"custom\"\nbase_url = \"https://api.example.com/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n```\n\n**auth.json 字段说明**：\n\n| 字段 | 必填 | 说明 |\n|------|------|------|\n| `OPENAI_API_KEY` | 是 | API 密钥 |\n\n**config.toml 字段说明**：\n\n| 字段 | 必填 | 说明 |\n|------|------|------|\n| `model_provider` | 是 | 模型提供商名称（需与 `[model_providers.xxx]` 匹配） |\n| `model` | 是 | 使用的模型（如 `gpt-5.2`、`gpt-4o`） |\n| `model_reasoning_effort` | 否 | 推理强度：`low` / `medium` / `high` |\n| `disable_response_storage` | 否 | 是否禁用响应存储 |\n| `base_url` | 是 | API 端点地址 |\n| `wire_api` | 否 | API 协议类型（通常为 `responses`） |\n| `requires_openai_auth` | 否 | 是否使用 OpenAI 认证方式 |\n\n\n### Gemini 配置格式\n\n```json\n{\n  \"env\": {\n    \"GEMINI_API_KEY\": \"your-api-key\",\n    \"GOOGLE_GEMINI_BASE_URL\": \"https://api.example.com\"\n  }\n}\n```\n\n| 字段 | 必填 | 说明 |\n|------|------|------|\n| `GEMINI_API_KEY` | 是 | API 密钥 |\n| `GOOGLE_GEMINI_BASE_URL` | 否 | 自定义端点地址 |\n| `GEMINI_MODEL` | 否 | 指定模型 |\n\n> 💡 认证类型由 CC Switch 自动检测（PackyCode API 代理 / Google OAuth / 通用 API Key），无需手动配置。\n\n## 统一供应商\n\n统一供应商可以跨 Claude/Codex/Gemini/OpenCode/OpenClaw 共享配置，适用于支持多种 API 格式的中转服务。\n\n### 创建统一供应商\n\n1. 切换到「统一供应商」Tab\n2. 点击「添加统一供应商」\n3. 填写通用配置：\n   - 名称\n   - API Key\n   - 端点地址\n4. 勾选要同步的应用（Claude/Codex/Gemini/OpenCode/OpenClaw）\n5. 保存\n\n### 同步机制\n\n统一供应商会自动同步到勾选的应用：\n\n- 修改统一供应商后，所有关联应用的配置同步更新\n- 删除统一供应商后，关联的应用配置也会删除\n\n### 保存并同步\n\n编辑统一供应商时，可以选择：\n\n| 操作 | 说明 |\n|------|------|\n| 保存 | 仅保存配置，不立即同步 |\n| 保存并同步 | 保存配置并立即同步到所有启用的应用 |\n\n### 手动同步\n\n如果需要手动触发同步：\n\n1. 在统一供应商卡片上点击「同步」按钮\n2. 确认同步操作\n3. 配置会覆盖各应用中关联的供应商\n\n## 导入供应商\n\nCC Switch 支持两种方式导入供应商配置：\n\n### 方式一：深度链接导入\n\n通过 `ccswitch://` 协议链接一键导入：\n\n1. 点击或访问深度链接\n2. CC Switch 自动打开并显示导入确认\n3. 预览配置信息\n4. 点击「确认导入」\n\n**获取深度链接**：\n- 从他人分享获取\n- 使用 [在线生成工具](https://farion1231.github.io/cc-switch/deplink.html) 创建\n\n### 方式二：数据库备份导入\n\n从 SQL 备份文件批量导入：\n\n1. 打开「设置 → 高级 → 数据管理」\n2. 点击「选择文件」\n3. 选择之前导出的 `.sql` 备份文件\n4. 点击「导入」\n5. 确认覆盖现有配置\n\n**导入内容**：\n- 所有供应商配置\n- MCP 服务器配置\n- Prompts 预设\n- 用量日志\n\n> ⚠️ **注意**：导入会覆盖现有数据库，建议先导出当前配置作为备份。导出的文件名格式为 `cc-switch-export-{时间戳}.sql`。\n\n## 高级选项\n\n### 自定义图标\n\n点击名称左侧的图标区域，可以：\n\n- 选择预设图标\n- 自定义图标颜色\n\n### 网站链接\n\n填写供应商的官网或控制台地址，方便快速访问：\n\n- 点击供应商卡片的链接图标可直接打开\n- 用于查看余额、获取 API Key 等\n\n### 备注\n\n添加备注信息，如：\n\n- 账号用途（个人/工作）\n- 套餐信息\n- 到期时间\n\n备注会显示在供应商卡片上，也支持搜索。\n\n### 端点测速\n\n添加供应商后，可以对 API 端点进行速度测试：\n\n1. 点击供应商卡片的「测速」按钮\n2. 在测速面板中添加多个端点 URL\n3. 点击「测速」执行测试\n4. 选择延迟最低的端点\n\n**测速结果**：\n- 🟢 绿色：延迟 < 500ms（优秀）\n- 🟡 黄色：延迟 500-1000ms（一般）\n- 🔴 红色：延迟 > 1000ms（较慢）\n\n![image-20260108005327817](../../assets/image-20260108005327817.png)\n"
  },
  {
    "path": "docs/user-manual/zh/2-providers/2.2-switch.md",
    "content": "# 2.2 切换供应商\n\n## 主界面切换\n\n在供应商列表中，点击目标供应商卡片的「启用」按钮。\n\n### 切换流程\n\n1. 点击「启用」按钮\n2. CC Switch 更新配置文件\n3. 卡片状态变为「当前启用」\n4. Claude/Gemini 即时生效，Codex 需重启终端\n\n### 状态指示\n\n| 状态 | 显示 | 说明 |\n|------|------|------|\n| 当前启用 | 蓝色边框 + 标签 | 配置文件中的当前供应商 |\n| 代理活跃 | 绿色边框 | 代理模式下实际使用的供应商 |\n| 普通 | 默认样式 | 未启用的供应商 |\n\n## 托盘快速切换\n\n通过系统托盘可以快速切换，无需打开主界面。\n\n### 操作步骤\n\n1. 右键点击系统托盘的 CC Switch 图标\n2. 在菜单中找到对应应用（Claude/Codex/Gemini/OpenCode）\n3. 点击要切换的供应商名称\n4. 切换完成，托盘会短暂提示\n\n### 托盘菜单结构\n\n![image-20260108004348993](../../assets/image-20260108004348993.png)\n\n## 生效方式\n\n### Claude Code\n\n**切换后即时生效**，无需重启。\n\nClaude Code 支持热重载，会自动检测配置文件变更并重新加载。\n\n### Codex\n\n切换后需要重启：\n- 关闭当前终端窗口\n- 重新打开终端\n\n### Gemini CLI\n\n**切换后即时生效**，无需重启。\n\nGemini CLI 每次请求都会重新读取 `.env` 文件。\n\n## 配置文件变更\n\n切换供应商时，CC Switch 会修改以下文件：\n\n### Claude\n\n```\n~/.claude/settings.json\n```\n\n修改内容：\n```json\n{\n  \"env\": {\n    \"ANTHROPIC_API_KEY\": \"新的 API Key\",\n    \"ANTHROPIC_BASE_URL\": \"新的端点\"\n  }\n}\n```\n\n### Codex\n\n```\n~/.codex/auth.json\n~/.codex/config.toml（如有额外配置）\n```\n\n### Gemini\n\n```\n~/.gemini/.env\n~/.gemini/settings.json\n```\n\n## 切换失败处理\n\n如果切换失败，可能的原因：\n\n### 配置文件被锁定\n\n其他程序正在使用配置文件。\n\n**解决方法**：关闭正在运行的 CLI 工具，再尝试切换。\n\n### 权限不足\n\n没有写入配置文件的权限。\n\n**解决方法**：检查配置目录的权限设置。\n\n### 配置格式错误\n\n供应商配置的 JSON 格式有误。\n\n**解决方法**：编辑供应商，检查并修复 JSON 格式。\n"
  },
  {
    "path": "docs/user-manual/zh/2-providers/2.3-edit.md",
    "content": "# 2.3 编辑供应商\n\n## 打开编辑面板\n\n1. 找到要编辑的供应商卡片\n2. 鼠标悬停在卡片上，显示操作按钮\n3. 点击「编辑」按钮\n\n## 可编辑内容\n\n### 基本信息\n\n| 字段 | 说明 |\n|------|------|\n| 名称 | 供应商显示名称 |\n| 备注 | 附加说明信息 |\n| 网站链接 | 供应商官网或控制台地址 |\n| 图标 | 自定义图标和颜色 |\n\n### 图标自定义\n\nCC Switch 提供丰富的图标自定义功能：\n\n#### 图标选择器\n\n1. 点击图标区域打开图标选择器\n2. 使用搜索框按名称搜索图标\n3. 点击选择想要的图标\n\n图标库包含常见的 AI 服务商和技术图标，支持：\n- 按名称模糊搜索\n- 显示图标名称提示\n- 实时预览选中效果\n\n![image-20260108004734882](../../assets/image-20260108004734882.png)\n\n### 配置信息\n\nJSON 格式的配置内容，包括：\n\n- API Key\n- 端点地址\n- 其他环境变量\n\n### 编辑当前启用的供应商\n\n编辑当前启用的供应商时，有特殊的「回填」机制：\n\n1. 打开编辑面板时，会从 live 配置文件读取最新内容\n2. 如果你在 CLI 工具中手动修改过配置，这些修改会被同步回来\n3. 保存后，修改会写入 live 配置文件\n\n这确保了 CC Switch 和 CLI 工具的配置始终同步。\n\n## 修改 API Key\n\n编辑供应商时，可以直接在 **API Key** 输入框中修改：\n\n1. 点击供应商卡片的「编辑」按钮\n2. 在「API Key」输入框中输入新的密钥\n3. 点击「保存」\n\n> 💡 **提示**：API Key 输入框支持显示/隐藏切换，点击右侧的眼睛图标可查看完整密钥。\n\n## 修改端点地址\n\n编辑供应商时，可以直接在 **端点地址** 输入框中修改：\n\n1. 点击供应商卡片的「编辑」按钮\n2. 在「端点地址」输入框中输入新的 URL\n3. 点击「保存」\n\n### 端点地址格式\n\n| 应用 | 格式示例 |\n|------|----------|\n| Claude | `https://api.example.com` |\n| Codex | `https://api.example.com/v1` |\n| Gemini | `https://api.example.com` |\n\n## 添加自定义端点\n\n供应商可以配置多个端点，用于：\n\n- 速度测试时测试多个地址\n- 故障转移时的备用端点\n\n### 自动收集\n\n添加供应商时，CC Switch 会自动从配置中提取端点地址。\n\n### 手动添加\n\n编辑供应商时，在「端点管理」区域可以：\n\n- 添加新端点\n- 删除现有端点\n- 设置默认端点\n\n## JSON 编辑器\n\n配置使用 JSON 格式，编辑器提供：\n\n- 语法高亮\n- 格式校验\n- 错误提示\n\n### 常见错误\n\n**缺少引号**：\n```json\n// ❌ 错误\n{ env: { KEY: \"value\" } }\n\n// ✅ 正确\n{ \"env\": { \"KEY\": \"value\" } }\n```\n\n**多余逗号**：\n```json\n// ❌ 错误\n{ \"env\": { \"KEY\": \"value\", } }\n\n// ✅ 正确\n{ \"env\": { \"KEY\": \"value\" } }\n```\n\n**未闭合括号**：\n```json\n// ❌ 错误\n{ \"env\": { \"KEY\": \"value\" }\n\n// ✅ 正确\n{ \"env\": { \"KEY\": \"value\" } }\n```\n\n## 保存与生效\n\n1. 点击「保存」按钮\n2. 如果是当前启用的供应商，配置立即写入 live 文件\n3. 重启 CLI 工具生效\n\n## 取消编辑\n\n点击「取消」或按 `Esc` 键关闭编辑面板，所有修改都不会保存。\n"
  },
  {
    "path": "docs/user-manual/zh/2-providers/2.4-sort-duplicate.md",
    "content": "# 2.4 排序与复制\n\n## 拖拽排序\n\n通过拖拽调整供应商的显示顺序。\n\n### 操作步骤\n\n1. 将鼠标移到供应商卡片左侧的 **≡** 拖拽手柄\n2. 按住鼠标左键\n3. 上下拖动到目标位置\n4. 松开鼠标完成排序\n\n### 排序用途\n\n- **常用优先**：将常用的供应商放在列表顶部\n- **故障转移顺序**：排序会影响故障转移队列的默认顺序\n\n## 复制供应商\n\n快速创建供应商的副本，适用于：\n\n- 基于现有配置创建变体\n- 备份当前配置\n- 创建测试用配置\n\n### 操作步骤\n\n1. 鼠标悬停在供应商卡片上，显示操作按钮\n2. 点击「复制」按钮\n3. 自动创建副本，名称后缀 `copy`\n4. 编辑副本修改配置\n\n### 复制内容\n\n复制会创建完整的副本，包括：\n\n| 内容 | 是否复制 |\n|------|----------|\n| 名称 | ✅ 复制（添加 `copy` 后缀） |\n| 配置 | ✅ 完整复制 |\n| 备注 | ✅ 复制 |\n| 网站链接 | ✅ 复制 |\n| 图标 | ✅ 复制 |\n| 端点列表 | ✅ 复制 |\n| 排序位置 | ✅ 插入到原供应商下方 |\n\n### 复制后编辑\n\n复制完成后，通常需要修改：\n\n1. **名称**：改为有意义的名称\n2. **API Key**：如果是不同账号\n3. **端点**：如果是不同服务\n\n## 删除供应商\n\n### 操作步骤\n\n1. 鼠标悬停在供应商卡片上，显示操作按钮\n2. 点击「删除」按钮\n3. 确认删除\n\n### 删除确认\n\n删除前会弹出确认对话框，显示：\n\n- 供应商名称\n- 删除后无法恢复的提示\n\n### 删除限制\n\n- **当前启用的供应商**：可以删除，但建议先切换到其他供应商\n- **统一供应商**：删除后，关联的应用配置也会被删除\n\n![image-20260108004946288](../../assets/image-20260108004946288.png)\n"
  },
  {
    "path": "docs/user-manual/zh/2-providers/2.5-usage-query.md",
    "content": "# 2.5 用量查询\n\n## 功能说明\n\n用量查询功能允许你配置自定义脚本，实时查询供应商的剩余额度、已用量等信息。\n\n**使用场景**：\n- 查看 API 账户剩余余额\n- 监控套餐使用情况\n- 多套餐额度汇总显示\n\n## 打开配置\n\n1. 鼠标悬停在供应商卡片上，显示操作按钮\n2. 点击「用量查询」按钮（📊 图标）\n3. 打开用量查询配置面板\n\n## 启用用量查询\n\n在配置面板顶部，开启「启用用量查询」开关。\n\n## 预设模板\n\nCC Switch 提供三种预设模板：\n\n### 自定义模板\n\n完全自定义请求和提取逻辑，适用于特殊 API 格式。\n\n### 通用模板\n\n适用于大多数标准 API 格式的供应商：\n\n```javascript\n({\n  request: {\n    url: \"{{baseUrl}}/user/balance\",\n    method: \"GET\",\n    headers: {\n      \"Authorization\": \"Bearer {{apiKey}}\",\n      \"User-Agent\": \"cc-switch/1.0\"\n    }\n  },\n  extractor: function(response) {\n    return {\n      isValid: response.is_active || true,\n      remaining: response.balance,\n      unit: \"USD\"\n    };\n  }\n})\n```\n\n**配置参数**：\n| 参数 | 说明 |\n|------|------|\n| API Key | 用于认证的密钥（可选，留空则使用供应商配置的 Key） |\n| Base URL | API 基础地址（可选，留空则使用供应商端点） |\n\n### New API 模板\n\n专为 New API 类型的中转服务设计：\n\n```javascript\n({\n  request: {\n    url: \"{{baseUrl}}/api/user/self\",\n    method: \"GET\",\n    headers: {\n      \"Content-Type\": \"application/json\",\n      \"Authorization\": \"Bearer {{accessToken}}\",\n      \"New-Api-User\": \"{{userId}}\"\n    },\n  },\n  extractor: function (response) {\n    if (response.success && response.data) {\n      return {\n        planName: response.data.group || \"默认套餐\",\n        remaining: response.data.quota / 500000,\n        used: response.data.used_quota / 500000,\n        total: (response.data.quota + response.data.used_quota) / 500000,\n        unit: \"USD\",\n      };\n    }\n    return {\n      isValid: false,\n      invalidMessage: response.message || \"查询失败\"\n    };\n  },\n})\n```\n\n**配置参数**：\n| 参数 | 说明 |\n|------|------|\n| Base URL | New API 服务地址 |\n| Access Token | 访问令牌 |\n| User ID | 用户 ID |\n\n## 通用配置\n\n### 超时时间\n\n请求超时时间（秒），默认 10 秒。\n\n### 自动查询间隔\n\n自动刷新用量数据的间隔（分钟）：\n- 设为 `0` 表示禁用自动查询\n- 范围：0-1440 分钟（最长 24 小时）\n- 仅当供应商处于「当前启用」状态时生效\n\n## 提取器返回格式\n\n提取器函数需要返回包含以下字段的对象：\n\n| 字段 | 类型 | 必填 | 说明 |\n|------|------|------|------|\n| `isValid` | boolean | 否 | 账户是否有效，默认 true |\n| `invalidMessage` | string | 否 | 无效时的提示信息 |\n| `remaining` | number | 是 | 剩余额度 |\n| `unit` | string | 是 | 单位（如 USD、CNY、次） |\n| `planName` | string | 否 | 套餐名称（支持多套餐） |\n| `total` | number | 否 | 总额度 |\n| `used` | number | 否 | 已使用额度 |\n| `extra` | object | 否 | 额外信息 |\n\n## 测试脚本\n\n配置完成后，点击「测试脚本」按钮验证：\n\n1. 发送请求到配置的 URL\n2. 执行提取器函数\n3. 显示返回结果或错误信息\n\n## 显示效果\n\n配置成功后，供应商卡片上会显示：\n\n- **单套餐**：直接显示剩余额度\n- **多套餐**：显示套餐数量，点击展开查看详情\n\n## 变量占位符\n\n脚本中可使用以下占位符，运行时自动替换：\n\n| 占位符 | 说明 |\n|--------|------|\n| `{{apiKey}}` | 配置的 API Key |\n| `{{baseUrl}}` | 配置的 Base URL |\n| `{{accessToken}}` | 配置的 Access Token（New API） |\n| `{{userId}}` | 配置的 User ID（New API） |\n\n## 常见供应商配置示例\n\n### 故障排除\n\n### 查询失败\n\n**检查**：\n1. API Key 是否正确\n2. Base URL 是否正确\n3. 网络是否可访问\n4. 超时时间是否足够\n\n### 返回数据为空\n\n**检查**：\n1. 提取器函数是否有 `return` 语句\n2. 响应数据结构是否与提取器匹配\n3. 使用「测试脚本」查看原始响应\n\n### 格式化失败\n\n脚本语法错误时，点击「格式化」按钮会提示错误位置。\n\n## 注意事项\n\n- 用量查询会消耗少量 API 请求配额\n- 建议设置合理的自动查询间隔，避免频繁请求\n- 敏感信息（API Key、Token）会安全存储在本地\n"
  },
  {
    "path": "docs/user-manual/zh/3-extensions/3.1-mcp.md",
    "content": "# 3.1 MCP 服务器管理\n\n## 什么是 MCP\n\nMCP (Model Context Protocol) 是一种协议，允许 AI 工具访问外部数据源和工具。通过 MCP 服务器，你可以让 AI：\n\n- 访问文件系统\n- 执行网络请求\n- 查询数据库\n- 调用外部 API\n\n## 打开 MCP 面板\n\n点击顶部导航栏的 **MCP** 按钮。\n\n## 面板概览\n\n![image-20260108005723522](../../assets/image-20260108005723522.png)\n\n## 添加 MCP 服务器\n\n### 使用预设模板\n\n1. 点击右上角 **+** 按钮\n2. 在「预设」下拉框中选择模板\n3. 根据需要修改配置\n4. 点击「保存」\n\n![image-20260108005739731](../../assets/image-20260108005739731.png)\n\n### 常用预设\n\n| 预设 | 包名 | 功能说明 |\n|------|------|----------|\n| fetch | mcp-server-fetch | HTTP 请求工具，让 AI 能够获取网页内容 |\n| time | @modelcontextprotocol/server-time | 时间工具，提供当前时间信息 |\n| memory | @modelcontextprotocol/server-memory | 记忆工具，让 AI 能够存储和检索信息 |\n| sequential-thinking | @modelcontextprotocol/server-sequential-thinking | 思维链工具，增强 AI 推理能力 |\n| context7 | @upstash/context7-mcp | 文档搜索工具，查询技术文档 |\n\n### 自定义配置\n\n选择「自定义」后，需要填写：\n\n| 字段 | 必填 | 说明 |\n|------|------|------|\n| 服务器 ID | 是 | 唯一标识符 |\n| 名称 | 否 | 显示名称 |\n| 描述 | 否 | 功能说明 |\n| 传输类型 | 是 | stdio / http / sse |\n| 命令 | 是* | stdio 类型必填 |\n| 参数 | 否 | 命令行参数 |\n| URL | 是* | http/sse 类型必填 |\n| Headers | 否 | http/sse 类型的请求头 |\n| 环境变量 | 否 | 传递给服务器的环境变量 |\n\n## 传输类型\n\n### stdio（标准输入输出）\n\n最常用的类型，通过启动本地进程通信。\n\n```json\n{\n  \"command\": \"uvx\",\n  \"args\": [\"mcp-server-fetch\"],\n  \"env\": {}\n}\n```\n\n**要求**：\n- 需要安装对应的命令（如 `uvx`、`npx`）\n- 服务器程序需要在 PATH 中\n\n### http\n\n通过 HTTP 协议与远程服务器通信。\n\n```json\n{\n  \"url\": \"http://localhost:8080/mcp\"\n}\n```\n\n### sse（Server-Sent Events）\n\n通过 SSE 协议与服务器通信，支持实时推送。\n\n```json\n{\n  \"url\": \"http://localhost:8080/sse\"\n}\n```\n\n## 应用绑定\n\n每个 MCP 服务器可以独立控制启用的应用。\n\n### 开关说明\n\n| 开关 | 作用 | 配置文件路径 |\n|------|------|--------------|\n| Claude | 同步到 Claude Code | `~/.claude.json` 的 `mcpServers` |\n| Codex | 同步到 Codex | `~/.codex/config.toml` 的 `[mcp_servers]` |\n| Gemini | 同步到 Gemini CLI | `~/.gemini/settings.json` 的 `mcpServers` |\n| OpenCode | 同步到 OpenCode | `~/.opencode/config.json` 的 `mcpServers` |\n\n> ⚠️ **注意**：OpenClaw 暂不支持 MCP 服务器管理。MCP 功能目前仅支持 Claude、Codex、Gemini 和 OpenCode 四个应用。\n\n### 开关实现机制\n\n当开启某个应用的开关时，CC Switch 会：\n\n1. **更新数据库**：将服务器的 `apps.claude/codex/gemini/opencode` 状态设为 `true`\n2. **同步到 Live 配置**：将服务器配置写入对应应用的配置文件\n3. **即时生效**：下次启动 CLI 工具时自动加载新的 MCP 服务器\n\n当关闭某个应用的开关时，CC Switch 会：\n\n1. **更新数据库**：将对应应用状态设为 `false`\n2. **从 Live 配置移除**：从应用配置文件中删除该服务器\n3. **即时生效**：下次启动 CLI 工具时不再加载该 MCP 服务器\n\n### 同步条件\n\nMCP 服务器同步仅在对应应用已安装时执行：\n\n- **Claude**：需存在 `~/.claude/` 目录或 `~/.claude.json` 文件\n- **Codex**：需存在 `~/.codex/` 目录\n- **Gemini**：需存在 `~/.gemini/` 目录\n- **OpenCode**：需存在 `~/.opencode/` 目录\n\n> 💡 **提示**：如果某个 CLI 工具未安装，开启对应开关不会报错，但配置不会写入。\n\n关闭开关后，配置会从文件中移除。\n\n## 编辑服务器\n\n1. 点击服务器行右侧的「编辑」按钮\n2. 修改配置\n3. 点击「保存」\n\n修改会立即同步到已启用的应用配置文件。\n\n## 删除服务器\n\n1. 点击服务器行右侧的「删除」按钮\n2. 确认删除\n\n删除后，配置会从所有应用的配置文件中移除。\n\n## 导入现有配置\n\n如果你已经在 CLI 工具中配置了 MCP 服务器，可以导入到 CC Switch：\n\n1. 点击「导入」按钮\n2. 选择要导入的应用（Claude/Codex/Gemini/OpenCode）\n3. CC Switch 会读取现有配置并导入\n\n## 配置文件格式\n\n### Claude (`~/.claude.json`)\n\n```json\n{\n  \"mcpServers\": {\n    \"mcp-fetch\": {\n      \"command\": \"uvx\",\n      \"args\": [\"mcp-server-fetch\"]\n    }\n  }\n}\n```\n\n### Codex (`~/.codex/config.toml`)\n\n```toml\n[mcp_servers.mcp-fetch]\ncommand = \"uvx\"\nargs = [\"mcp-server-fetch\"]\n```\n\n### Gemini (`~/.gemini/settings.json`)\n\n```json\n{\n  \"mcpServers\": {\n    \"mcp-fetch\": {\n      \"command\": \"uvx\",\n      \"args\": [\"mcp-server-fetch\"]\n    }\n  }\n}\n```\n\n## 常见问题\n\n### 服务器启动失败\n\n检查：\n- 命令是否正确安装（如 `uvx`）\n- 命令是否在 PATH 中\n- 参数是否正确\n\n### 配置不生效\n\n确保：\n- 对应应用的开关已开启\n- 重启了 CLI 工具\n"
  },
  {
    "path": "docs/user-manual/zh/3-extensions/3.2-prompts.md",
    "content": "# 3.2 Prompts 提示词管理\n\n## 功能说明\n\nPrompts 功能用于管理系统提示词预设。系统提示词会影响 AI 的行为和回复风格。\n\n通过 CC Switch，你可以：\n\n- 创建多个提示词预设\n- 快速切换不同场景的提示词\n- 跨设备同步提示词配置\n\n## 打开 Prompts 面板\n\n点击顶部导航栏的 **Prompts** 按钮。\n\n## 面板概览\n\n![image-20260108010110382](../../assets/image-20260108010110382.png)\n\n## 创建预设\n\n### 操作步骤\n\n1. 点击右上角 **+** 按钮\n2. 输入预设名称\n3. 在 Markdown 编辑器中编写提示词\n4. 点击「保存」\n\n### Markdown 编辑器\n\n编辑器提供：\n\n- 语法高亮\n- 实时预览\n- 常用格式快捷键\n\n### 提示词编写建议\n\n**结构化格式**：\n\n```markdown\n# 角色定义\n\n你是一个专业的代码审查专家。\n\n## 核心能力\n\n- 代码质量分析\n- 性能优化建议\n- 安全漏洞检测\n\n## 回复风格\n\n- 简洁明了\n- 提供具体示例\n- 给出改进建议\n\n## 注意事项\n\n- 不要修改业务逻辑\n- 保持代码风格一致\n```\n\n## 激活预设\n\n### 操作方式\n\n点击预设项的开关按钮，切换启用状态。\n\n### 单一激活\n\n同一时间只能激活一个预设。激活新预设时，之前的预设会自动停用。\n\n### 同步目标\n\n激活后，提示词会写入对应应用的文件：\n\n| 应用 | 文件路径 |\n|------|----------|\n| Claude | `~/.claude/CLAUDE.md` |\n| Codex | `~/.codex/AGENTS.md` |\n| Gemini | `~/.gemini/GEMINI.md` |\n| OpenCode | `~/.opencode/AGENTS.md` |\n| OpenClaw | `~/.openclaw/AGENTS.md` |\n\n## 编辑预设\n\n1. 点击预设项的「编辑」按钮\n2. 修改名称或内容\n3. 点击「保存」\n\n如果编辑的是当前激活的预设，保存后会立即同步到配置文件。\n\n## 删除预设\n\n1. 点击预设项的「删除」按钮\n2. 确认删除\n\n已启用的预设不允许删除，需先停用后再删除。\n\n## 智能回填\n\nCC Switch 提供智能回填保护机制，确保你的手动修改不会丢失。\n\n### 工作原理\n\n1. 切换预设前，自动读取当前配置文件内容\n2. 比较文件内容与数据库中的预设\n3. 如果内容不同，说明用户手动修改过\n4. 将手动修改的内容保存到当前预设\n5. 然后再切换到新预设\n\n### 保护场景\n\n| 场景 | 处理方式 |\n|------|----------|\n| CLI 中直接编辑 `CLAUDE.md` | 修改自动保存到当前预设 |\n| 外部编辑器修改配置文件 | 修改自动保存到当前预设 |\n| 切换到其他预设 | 先保存当前修改，再切换 |\n\n### 技术细节\n\n回填机制在以下时机触发：\n\n- **切换预设时**：保存当前 live 文件内容到当前预设\n- **编辑当前预设时**：从 live 文件读取最新内容\n- **首次启动时**：自动导入现有 live 文件内容\n\n### 注意事项\n\n- 回填仅在切换到不同预设时触发\n- 如果当前没有激活的预设，不会触发回填\n- 回填失败不会影响切换流程\n\n## 跨应用使用\n\nPrompts 是按应用分开管理的：\n\n- 切换到 Claude 时，显示 Claude 的预设\n- 切换到 Codex 时，显示 Codex 的预设\n- 切换到 Gemini 时，显示 Gemini 的预设\n- 切换到 OpenCode 时，显示 OpenCode 的预设\n- 切换到 OpenClaw 时，显示 OpenClaw 的预设\n\n如需在多个应用使用相同的提示词，需要分别创建。\n\n## 导入导出\n\n### 通过深度链接分享\n\n可以生成深度链接分享预设：\n\n```\nccswitch://import/prompt?data=<base64编码的预设>\n```\n\n### 通过配置导出\n\n导出配置时会包含所有预设，导入后可恢复。\n"
  },
  {
    "path": "docs/user-manual/zh/3-extensions/3.3-skills.md",
    "content": "# 3.3 Skills 技能管理\n\n## 功能说明\n\nSkills 是可复用的能力扩展，让 AI 工具获得特定领域的专业能力。\n\n技能以文件夹形式存在，包含：\n\n- 提示词模板\n- 工具定义\n- 示例代码\n\n## 支持的应用\n\nSkills 功能支持所有四种应用：\n\n- **Claude Code**\n- **Codex**\n- **Gemini CLI**\n- **OpenCode**\n\n## 打开 Skills 页面\n\n点击顶部导航栏的 **Skills** 按钮。\n\n> 注意：Skills 按钮在所有应用模式下均可见。\n\n## 页面概览\n\n![image-20260108010253926](../../assets/image-20260108010253926.png)\n\n## 发现技能\n\n### 预配置仓库\n\nCC Switch 预配置了以下 GitHub 仓库：\n\n| 仓库           | 说明                     |\n| -------------- | ------------------------ |\n| Anthropic 官方 | Anthropic 提供的官方技能 |\n| ComposioHQ     | 社区维护的技能集合       |\n| 社区精选       | 精选的高质量技能         |\n\n![image-20260108010308060](../../assets/image-20260108010308060.png)\n\n### 搜索过滤\n\nCC Switch 提供强大的搜索和过滤功能：\n\n#### 搜索框\n\n- 支持按技能名称搜索\n- 支持按技能描述搜索\n- 支持按目录名称搜索\n- 实时过滤，输入即搜索\n\n#### 状态过滤\n\n使用下拉菜单按安装状态过滤：\n\n| 选项   | 说明               |\n| ------ | ------------------ |\n| 全部   | 显示所有技能       |\n| 已安装 | 仅显示已安装的技能 |\n| 未安装 | 仅显示未安装的技能 |\n\n![image-20260108010324583](../../assets/image-20260108010324583.png)\n\n#### 组合使用\n\n搜索和过滤可以组合使用：\n\n- 先选择「已安装」过滤\n- 再输入关键词搜索\n- 结果显示匹配数量\n\n### 刷新列表\n\n点击「刷新」按钮重新扫描仓库，获取最新技能。\n\n## 安装技能\n\n### 操作步骤\n\n1. 找到要安装的技能卡片\n2. 点击「安装」按钮\n3. 等待安装完成\n\n### 安装位置\n\n| 应用     | 安装目录              |\n| -------- | --------------------- |\n| Claude   | `~/.claude/skills/`   |\n| Codex    | `~/.codex/skills/`    |\n| Gemini   | `~/.gemini/skills/`   |\n| OpenCode | `~/.opencode/skills/` |\n\n### 安装内容\n\n安装会将技能文件夹复制到本地：\n\n```\n~/.claude/skills/\n└── skill-name/\n    ├── README.md\n    ├── prompt.md\n    └── tools/\n        └── ...\n```\n\n## 卸载技能\n\n### 操作步骤\n\n1. 找到已安装的技能卡片\n2. 点击「卸载」按钮\n3. 确认卸载\n\n### 卸载效果\n\n- 删除本地技能文件夹\n- 更新安装状态\n\n## 仓库管理\n\n### 打开仓库管理\n\n点击页面顶部的「仓库管理」按钮。\n\n### 添加自定义仓库\n\n1. 点击「添加仓库」\n2. 填写仓库信息：\n   - Owner：GitHub 用户名或组织名\n   - Name：仓库名称\n   - Branch：分支名（默认 main）\n   - Subdirectory：技能所在子目录（可选）\n3. 点击「添加」\n\n### 仓库格式\n\n```\nhttps://github.com/{owner}/{name}/tree/{branch}/{subdirectory}\n```\n\n示例：\n\n```\nOwner: anthropics\nName: claude-skills\nBranch: main\nSubdirectory: skills\n```\n\n### 删除仓库\n\n1. 在仓库列表中找到要删除的仓库\n2. 点击「删除」按钮\n3. 确认删除\n\n删除仓库后，该仓库的技能不会从列表中消失，但无法再更新。\n\n## 技能卡片信息\n\n每个技能卡片显示：\n\n| 信息 | 说明            |\n| ---- | --------------- |\n| 名称 | 技能名称        |\n| 描述 | 功能说明        |\n| 来源 | 所属仓库        |\n| 状态 | 已安装 / 未安装 |\n\n## 技能更新\n\n目前不支持自动更新。如需更新技能：\n\n1. 卸载现有技能\n2. 刷新列表\n3. 重新安装\n\n### 技能列表为空\n\n可能原因：\n\n- 网络问题，无法访问 GitHub\n- 仓库配置错误\n\n解决方法：\n\n- 检查网络连接\n- 点击「刷新」重试\n- 检查仓库配置\n\n### 安装失败\n\n可能原因：\n\n- 网络问题\n- 磁盘空间不足\n- 权限问题\n\n解决方法：\n\n- 检查网络连接\n- 检查磁盘空间\n- 检查目录权限\n"
  },
  {
    "path": "docs/user-manual/zh/4-proxy/4.1-service.md",
    "content": "# 4.1 代理服务\n\n## 功能说明\n\n代理服务在本地启动一个 HTTP 代理，所有 API 请求都通过代理转发。\n\n**主要用途**：\n- 记录请求日志\n- 统计 API 用量\n- 支持故障转移\n- 集中管理多个应用的请求\n\n## 启动代理\n\n### 方式一：主界面开关\n\n点击主界面顶部的 **代理开关** 按钮。\n\n开关状态：\n- 🔴 白色：代理未运行\n- 🟢 绿色：代理运行中\n\n![image-20260108011353927](../../assets/image-20260108011353927.png)\n\n### 方式二：设置页面\n\n1. 打开「设置 → 高级 → 代理服务」\n2. 点击右上角的开关\n\n![image-20260108011338922](../../assets/image-20260108011338922.png)\n\n## 代理配置\n\n### 基础配置\n\n| 配置项 | 说明 | 默认值 |\n|--------|------|--------|\n| 监听地址 | 代理绑定的 IP 地址 | `127.0.0.1` |\n| 监听端口 | 代理监听的端口 | `15721` |\n| 启用日志 | 是否记录请求日志 | 开启 |\n\n### 修改配置\n\n1. **停止代理服务**（必须先停止）\n2. 修改监听地址或端口\n3. 点击「保存」\n4. 重新启动代理\n\n> ⚠️ 修改地址/端口需要先停止代理服务\n\n### 监听地址说明\n\n| 地址 | 说明 |\n|------|------|\n| `127.0.0.1` | 仅本机可访问（推荐） |\n| `0.0.0.0` | 允许局域网访问 |\n\n## 运行状态\n\n代理运行时，面板显示以下信息：\n\n### 服务地址\n\n```\nhttp://127.0.0.1:15721\n```\n\n点击「复制」按钮可复制地址。\n\n### 当前供应商\n\n显示各应用当前使用的供应商：\n\n```\nClaude: PackyCode\nCodex: AIGoCode\nGemini: Google 官方\n```\n\n### 统计数据\n\n| 指标 | 说明 |\n|------|------|\n| 活跃连接 | 当前正在处理的请求数 |\n| 总请求数 | 启动以来的总请求数 |\n| 成功率 | 请求成功的百分比（>90% 绿色，≤90% 黄色） |\n| 运行时间 | 代理已运行的时长 |\n\n### 故障转移队列\n\n代理面板会按应用类型显示故障转移队列：\n\n```\nClaude\n├── 1. PackyCode      [当前使用] ●\n├── 2. AIGoCode                  ●\n└── 3. 备用供应商                 ○\n\nCodex\n├── 1. AIGoCode       [当前使用] ●\n└── 2. 备用供应商                 ●\n```\n\n队列说明：\n- 数字表示优先级顺序\n- 「当前使用」标签表示正在使用的供应商\n- 健康徽章显示供应商状态：\n  - 🟢 绿色：健康（连续失败 0 次）\n  - 🟡 黄色：降级（连续失败 1-2 次）\n  - 🔴 红色：不健康（连续失败 ≥3 次）\n\n## 工作原理\n\n### 请求流程\n\n```mermaid\nsequenceDiagram\n    participant CLI as CLI 工具 (Claude)\n    participant Proxy as 本地代理 (CC Switch)\n    participant API as API 供应商 (Anthropic)\n    participant DB as 数据存储 (Logger)\n\n    CLI->>Proxy: 发送 API 请求\n    Proxy->>DB: 记录请求日志/统计用量\n    Proxy->>API: 转发请求\n    API-->>Proxy: 返回响应\n    Proxy-->>CLI: 返回响应\n```\n\n### 配置修改\n\n启动代理并开启应用接管后，CC Switch 会修改应用配置：\n\n**Claude**：\n```json\n{\n  \"env\": {\n    \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\"\n  }\n}\n```\n\n**Codex**：\n```toml\nbase_url = \"http://127.0.0.1:15721/v1\"\n```\n\n**Gemini**：\n```\nGOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15721\n```\n\n## 停止代理\n\n### 方式一：主界面开关\n\n点击代理开关按钮关闭。\n\n### 方式二：设置页面\n\n在代理服务面板中关闭开关。\n\n### 停止后的处理\n\n停止代理时，CC Switch 会：\n\n1. 恢复应用配置到原始状态\n2. 保存请求日志\n3. 关闭所有连接\n\n## 日志记录\n\n### 开启日志\n\n在代理面板中开启「启用日志」开关。\n\n### 日志内容\n\n每条请求记录包含：\n\n| 字段 | 说明 |\n|------|------|\n| 时间 | 请求时间 |\n| 应用 | Claude / Codex / Gemini |\n| 供应商 | 使用的供应商 |\n| 模型 | 请求的模型 |\n| Token | 输入/输出 token 数 |\n| 延迟 | 请求耗时 |\n| 状态 | 成功/失败 |\n\n### 查看日志\n\n在「设置 → 用量」Tab 中查看请求日志。\n\n## 常见问题\n\n### 端口被占用\n\n错误信息：`Address already in use`\n\n解决方法：\n1. 更换端口（如 5001）\n2. 或关闭占用端口的程序\n\n### 代理启动失败\n\n检查：\n- 端口是否被占用\n- 是否有足够权限\n- 防火墙是否阻止\n\n### 请求超时\n\n可能原因：\n- 网络问题\n- 供应商服务器问题\n- 代理配置错误\n\n解决方法：\n- 检查网络连接\n- 尝试直接访问供应商 API\n- 检查供应商配置\n"
  },
  {
    "path": "docs/user-manual/zh/4-proxy/4.2-takeover.md",
    "content": "# 4.2 应用接管\n\n## 功能说明\n\n应用接管是指让 CC Switch 代理接管特定应用的 API 请求。\n\n开启接管后：\n- 应用的 API 请求会通过本地代理转发\n- 可以记录请求日志和统计用量\n- 可以使用故障转移功能\n\n## 前提条件\n\n使用应用接管功能前，需要先启动代理服务。\n\n## 开启接管\n\n### 操作位置\n\n设置 → 高级 → 代理服务 → 应用接管区域\n\n### 操作步骤\n\n1. 确保代理服务已启动\n2. 找到「应用接管」区域\n3. 为需要的应用开启开关\n\n### 接管开关\n\n| 开关 | 作用 |\n|------|------|\n| Claude 接管 | 接管 Claude Code 的请求 |\n| Codex 接管 | 接管 Codex 的请求 |\n| Gemini 接管 | 接管 Gemini CLI 的请求 |\n\n可以同时开启多个应用的接管。\n\n## 接管原理\n\n### 配置修改\n\n开启接管后，CC Switch 会修改应用的配置文件，将 API 端点指向本地代理。\n\n**Claude 配置变更**：\n\n```json\n// 接管前\n{\n  \"env\": {\n    \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\"\n  }\n}\n\n// 接管后\n{\n  \"env\": {\n    \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\"\n  }\n}\n```\n\n**Codex 配置变更**：\n\n```toml\n# 接管前\nbase_url = \"https://api.openai.com/v1\"\n\n# 接管后\nbase_url = \"http://127.0.0.1:15721/v1\"\n```\n\n**Gemini 配置变更**：\n\n```bash\n# 接管前\nGOOGLE_GEMINI_BASE_URL=https://generativelanguage.googleapis.com\n\n# 接管后\nGOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15721\n```\n\n### 请求转发\n\n代理收到请求后：\n\n1. 识别请求来源（Claude/Codex/Gemini）\n2. 查找该应用当前启用的供应商\n3. 将请求转发到供应商的实际端点\n4. 记录请求日志\n5. 返回响应给应用\n\n## 接管状态指示\n\n### 主界面指示\n\n开启接管后，主界面会有以下变化：\n\n- **代理 Logo 颜色**：从无色变为绿色\n- **供应商卡片**：当前活跃的供应商显示绿色边框\n\n### 供应商卡片状态\n\n| 状态 | 边框颜色 | 说明 |\n|------|----------|------|\n| 当前启用 | 蓝色 | 配置文件中的供应商（非代理模式） |\n| 代理活跃 | 绿色 | 代理实际使用的供应商 |\n| 普通 | 默认 | 未使用的供应商 |\n\n## 关闭接管\n\n### 操作步骤\n\n1. 在代理面板中关闭对应应用的接管开关\n2. 或直接停止代理服务\n\n### 配置恢复\n\n关闭接管时，CC Switch 会：\n\n1. 将应用配置恢复到接管前的状态\n2. 保存当前的请求日志\n\n## 接管与供应商切换\n\n### 接管模式下切换供应商\n\n在接管模式下切换供应商：\n\n1. 在主界面点击供应商的「启用」按钮\n2. 代理立即使用新供应商转发请求\n3. **无需重启 CLI 工具**\n\n这是接管模式的一大优势：切换供应商即时生效。\n\n### 非接管模式下切换\n\n在非接管模式下切换供应商：\n\n1. 修改配置文件\n2. 需要重启 CLI 工具才能生效\n\n## 多应用接管\n\n可以同时接管多个应用，每个应用独立管理：\n\n- 独立的供应商配置\n- 独立的故障转移队列\n- 独立的请求统计\n\n## 使用场景\n\n### 场景一：用量监控\n\n开启接管 + 日志记录，监控 API 使用情况。\n\n### 场景二：快速切换\n\n开启接管后，切换供应商无需重启 CLI 工具。\n\n### 场景三：故障转移\n\n开启接管是使用故障转移功能的前提。\n\n## 注意事项\n\n### 性能影响\n\n代理会增加少量延迟（通常 < 10ms），对于大多数场景可以忽略。\n\n### 网络要求\n\n接管模式下，CLI 工具需要能够访问本地代理地址。\n\n### 配置备份\n\n开启接管前，CC Switch 会备份原始配置，关闭时恢复。\n\n## 常见问题\n\n### 接管后请求失败\n\n检查：\n- 代理服务是否正常运行\n- 供应商配置是否正确\n- 网络是否正常\n\n### 关闭接管后配置未恢复\n\n可能原因：\n- 代理异常退出\n- 配置文件被其他程序修改\n\n解决方法：\n- 手动编辑供应商，重新保存\n- 或重新启用再关闭接管\n"
  },
  {
    "path": "docs/user-manual/zh/4-proxy/4.3-failover.md",
    "content": "# 4.3 故障转移\n\n## 功能说明\n\n故障转移功能在主供应商请求失败时，自动切换到备用供应商，确保服务不中断。\n\n**适用场景**：\n- 供应商服务不稳定\n- 需要高可用性\n- 长时间运行的任务\n\n## 前提条件\n\n使用故障转移功能需要：\n\n1. ✅ 启动代理服务\n2. ✅ 开启应用接管\n3. ✅ 配置故障转移队列\n4. ✅ 开启自动故障转移\n\n## 配置故障转移队列\n\n### 打开配置页面\n\n设置 → 高级 → 故障转移\n\n### 选择应用\n\n页面顶部有三个 Tab：\n- Claude\n- Codex\n- Gemini\n\n选择要配置的应用。\n\n### 添加备用供应商\n\n1. 在「故障转移队列」区域\n2. 点击「添加供应商」\n3. 从下拉列表选择供应商\n4. 供应商会添加到队列末尾\n\n### 调整优先级\n\n拖拽供应商调整顺序：\n- 序号越小，优先级越高\n- 主供应商失败后，按顺序尝试备用供应商\n\n### 移除供应商\n\n点击供应商右侧的「移除」按钮。\n\n## 主界面快捷操作\n\n当代理和故障转移都开启时，供应商卡片会显示故障转移开关。\n\n### 添加到队列\n\n1. 找到供应商卡片\n2. 开启故障转移开关\n3. 供应商自动添加到队列\n\n### 从队列移除\n\n1. 关闭供应商卡片的故障转移开关\n2. 供应商从队列中移除\n\n## 开启自动故障转移\n\n### 操作步骤\n\n1. 在故障转移配置页面\n2. 开启「自动故障转移」开关\n\n### 开关说明\n\n| 状态 | 行为 |\n|------|------|\n| 关闭 | 仅记录失败，不自动切换 |\n| 开启 | 失败时自动切换到下一个供应商 |\n\n## 故障转移流程\n\n```mermaid\ngraph TD\n    Start[请求到达代理] --> Send[发送到当前供应商]\n    Send --> CheckSuccess{成功?}\n    CheckSuccess -- 是 --> Return[返回响应]\n    CheckSuccess -- 否 --> LogFail[记录失败]\n    LogFail --> CheckCircuit{检查熔断状态}\n    CheckCircuit -- 熔断 --> Skip[跳过此供应商]\n    CheckCircuit -- 未熔断 --> IncFail[增加失败计数]\n    Skip --> Next{队列中下一个?}\n    IncFail --> Next\n    Next -- 有 --> Switch[切换供应商]\n    Switch --> Retry[重试请求]\n    Retry --> Send\n    Next -- 无 --> Error[返回错误]\n```\n\n## 熔断器配置\n\n熔断器防止频繁重试失败的供应商。\n\n### 配置项\n\n不同应用有独立的默认配置。以下为通用默认值，Claude 有独立的宽松配置。\n\n| 配置 | 说明 | 通用默认值 | Claude 默认值 | 范围 |\n|------|------|--------|--------|------|\n| 失败阈值 | 连续失败多少次触发熔断 | 4 | 8 | 1-20 |\n| 恢复成功阈值 | 半开状态下成功多少次后关闭熔断器 | 2 | 3 | 1-10 |\n| 恢复等待时间 | 熔断后多久尝试恢复（秒） | 60 | 90 | 0-300 |\n| 错误率阈值 | 错误率超过此值时打开熔断器 | 60% | 70% | 0-100% |\n| 最小请求数 | 计算错误率前的最小请求数 | 10 | 15 | 5-100 |\n\n> 💡 Claude 由于请求耗时较长，默认配置更为宽松，容忍更多失败次数。\n\n### 超时配置\n\n| 配置 | 说明 | 通用默认值 | Claude 默认值 | 范围 |\n|------|------|--------|--------|------|\n| 流式首字节超时 | 等待首个数据块的最大时间（秒） | 60 | 90 | 1-120 |\n| 流式静默超时 | 数据块之间的最大间隔（秒） | 120 | 180 | 60-600（填 0 禁用） |\n| 非流式超时 | 非流式请求的总超时时间（秒） | 600 | 600 | 60-1200 |\n\n### 重试配置\n\n| 配置 | 说明 | 通用默认值 | Claude 默认值 | 范围 |\n|------|------|--------|--------|------|\n| 最大重试次数 | 请求失败时的重试次数 | 3 | 6 | 0-10 |\n\n> 💡 Gemini 的默认最大重试次数为 5。\n\n### 熔断状态\n\n| 状态 | 说明 |\n|------|------|\n| 关闭 | 正常状态，允许请求 |\n| 开启 | 熔断状态，跳过此供应商 |\n| 半开 | 尝试恢复，发送试探请求 |\n\n### 状态转换\n\n```mermaid\nstateDiagram-v2\n    [*] --> Closed: 初始化\n    Closed --> Open: 失败次数 >= 阈值\n    Open --> HalfOpen: 熔断时长到期\n    HalfOpen --> Closed: 试探成功 (>= 恢复阈值)\n    HalfOpen --> Open: 试探失败\n```\n\n## 健康状态指示\n\n### 供应商卡片\n\n卡片上显示健康状态徽章：\n\n| 徽章 | 状态 | 说明 |\n|------|------|------|\n| 🟢 | 健康 | 连续失败次数为 0 |\n| 🟡 | 警告 | 有失败但未触发熔断 |\n| 🔴 | 熔断 | 已触发熔断，暂时跳过 |\n\n### 队列列表\n\n故障转移队列中也显示每个供应商的健康状态。\n\n## 故障转移日志\n\n每次故障转移会记录：\n\n| 信息 | 说明 |\n|------|------|\n| 时间 | 发生时间 |\n| 原供应商 | 失败的供应商 |\n| 新供应商 | 切换到的供应商 |\n| 失败原因 | 错误信息 |\n\n在用量统计的请求日志中可以查看。\n\n## 最佳实践\n\n### 队列配置建议\n\n1. **主供应商**：最稳定、最快的供应商\n2. **第一备用**：次优选择\n3. **第二备用**：保底选择\n\n### 熔断器配置建议\n\n| 场景 | 失败阈值 | 熔断时长 |\n|------|----------|----------|\n| 高可用要求 | 2 | 30 秒 |\n| 一般场景 | 3 | 60 秒 |\n| 容忍偶发失败 | 5 | 120 秒 |\n\n### 监控建议\n\n定期检查：\n- 各供应商的健康状态\n- 故障转移发生频率\n- 熔断触发情况\n\n## 常见问题\n\n### 故障转移没有触发\n\n检查：\n1. 代理服务是否运行\n2. 应用接管是否开启\n3. 自动故障转移是否开启\n4. 队列中是否有备用供应商\n\n### 频繁触发故障转移\n\n可能原因：\n- 主供应商不稳定\n- 网络问题\n- 配置错误\n\n解决方法：\n- 检查主供应商状态\n- 调整熔断器参数\n- 考虑更换主供应商\n\n### 所有供应商都熔断\n\n等待熔断时长到期后自动恢复，或：\n1. 手动重启代理服务\n2. 重置熔断状态\n"
  },
  {
    "path": "docs/user-manual/zh/4-proxy/4.4-usage.md",
    "content": "# 4.4 用量统计\n\n## 功能说明\n\n用量统计功能记录和分析 API 请求数据，帮助你：\n\n- 了解 API 使用情况\n- 估算费用支出\n- 分析使用模式\n- 排查问题\n\n## 前提条件\n\n使用用量统计功能需要：\n\n1. ✅ 启动代理服务\n2. ✅ 开启应用接管\n3. ✅ 开启日志记录\n\n## 打开用量统计\n\n设置 → 用量 Tab\n\n## 统计概览\n\n### 汇总卡片\n\n页面顶部显示关键指标：\n\n| 指标 | 说明 |\n|------|------|\n| 总请求数 | 统计周期内的请求总数 |\n| 总 Token | 输入 + 输出 Token 总数 |\n| 估算费用 | 基于定价配置计算的费用 |\n| 成功率 | 成功请求的百分比 |\n\n### 时间范围\n\n可选择统计的时间范围：\n\n| 选项 | 范围 |\n|------|------|\n| 今日 | 当天 00:00 至今 |\n| 最近 7 天 | 过去 7 天 |\n| 最近 30 天 | 过去 30 天 |\n\n![image-20260108011730105](../../assets/image-20260108011730105.png)\n\n## 趋势图表\n\n### 请求趋势\n\n折线图展示请求数量的变化趋势：\n\n- X 轴：时间\n- Y 轴：请求数量\n- 可按小时/天查看\n- 支持缩放和拖拽\n\n### Token 趋势\n\n展示 Token 使用量的变化：\n\n- 输入 Token（蓝色）- 用户发送的 prompt 内容\n- 输出 Token（绿色）- AI 生成的回复内容\n- 缓存创建 Token（橙色）- 首次创建缓存消耗的 Token\n- 缓存命中 Token（紫色）- 复用缓存节省的 Token\n- 成本（红色虚线，右侧 Y 轴）- 估算费用\n\n> 💡 **缓存 Token 说明**：Anthropic API 支持 Prompt Caching 功能。缓存创建时收取较高费用（通常为输入价格的 1.25 倍），但后续命中缓存时只收取 0.1 倍的价格，可大幅降低重复请求的成本。\n\n### 时间粒度\n\n- **今日**：按小时显示（24 个数据点）\n- **7 天/30 天**：按天显示\n\n\n\n![image-20260108011742847](../../assets/image-20260108011742847.png)\n\n## 详细数据\n\n页面下方有三个数据 Tab：\n\n### 请求日志\n\n每条请求的详细记录：\n\n| 字段 | 说明 |\n|------|------|\n| 时间 | 请求时间 |\n| 供应商 | 使用的供应商名称 |\n| 模型 | 请求的模型（计费模型） |\n| 输入 Token | 输入的 Token 数 |\n| 输出 Token | 输出的 Token 数 |\n| 缓存读取 | 缓存命中的 Token 数 |\n| 缓存创建 | 缓存创建的 Token 数 |\n| 总费用 | 估算费用（美元） |\n| 耗时信息 | 请求耗时、首 Token 时间、流式/非流式 |\n| 状态 | HTTP 状态码 |\n\n#### 耗时信息说明\n\n耗时信息列显示多个徽章：\n\n| 徽章 | 说明 | 颜色规则 |\n|------|------|----------|\n| 总耗时 | 请求总时长（秒） | ≤5s 绿色，≤120s 橙色，>120s 红色 |\n| 首 Token | 流式请求首个 Token 时间 | ≤5s 绿色，≤120s 橙色，>120s 红色 |\n| 流式/非流式 | 请求类型 | 流式蓝色，非流式紫色 |\n\n#### 查看详情\n\n点击请求行可查看详细信息：\n\n- 完整的请求参数\n- 响应内容摘要\n- 错误信息（如果失败）\n\n#### 筛选日志\n\n支持按以下条件筛选：\n\n| 筛选项 | 选项 |\n|--------|------|\n| 应用类型 | 全部 / Claude / Codex / Gemini |\n| 状态码 | 全部 / 200 / 400 / 401 / 429 / 500 |\n| 供应商 | 文本搜索 |\n| 模型 | 文本搜索 |\n| 时间范围 | 开始时间 - 结束时间（日期时间选择器） |\n\n操作按钮：\n- **搜索**：应用筛选条件\n- **重置**：恢复默认（过去 24 小时）\n- **刷新**：重新加载数据\n\n![image-20260108011859974](../../assets/image-20260108011859974.png)\n\n### 供应商统计\n\n按供应商分组的统计数据：\n\n| 字段 | 说明 |\n|------|------|\n| 供应商 | 供应商名称 |\n| 请求数 | 该供应商的请求总数 |\n| 成功数 | 成功的请求数 |\n| 失败数 | 失败的请求数 |\n| 成功率 | 成功百分比 |\n| 总 Token | Token 使用总量 |\n| 估算费用 | 该供应商的费用 |\n\n![image-20260108011907928](../../assets/image-20260108011907928.png)\n\n### 模型统计\n\n按模型分组的统计数据：\n\n| 字段 | 说明 |\n|------|------|\n| 模型 | 模型名称 |\n| 请求数 | 该模型的请求总数 |\n| 输入 Token | 输入 Token 总量 |\n| 输出 Token | 输出 Token 总量 |\n| 平均延迟 | 平均响应时间 |\n| 估算费用 | 该模型的费用 |\n\n![image-20260108011915381](../../assets/image-20260108011915381.png)\n\n## 定价配置\n\n### 打开定价配置\n\n设置 → 高级 → 定价配置\n\n### 配置模型价格\n\n为每个模型设置价格（每百万 Token）：\n\n| 字段 | 说明 |\n|------|------|\n| 模型 ID | 模型标识符（如 claude-3-sonnet） |\n| 显示名称 | 自定义显示名称 |\n| 输入价格 | 每百万输入 Token 的价格 |\n| 输出价格 | 每百万输出 Token 的价格 |\n| 缓存读取价格 | 每百万缓存命中 Token 的价格 |\n| 缓存创建价格 | 每百万缓存创建 Token 的价格 |\n\n### 操作\n\n- **添加**：点击「添加」按钮新增模型定价\n- **编辑**：点击行末的编辑图标修改\n- **删除**：点击行末的删除图标移除\n\n![image-20260108011933565](../../assets/image-20260108011933565.png)\n\n### 预设价格\n\nCC Switch 预设了常用模型的官方价格（每百万 Token）：\n\n**Claude 系列（美元）**：\n\n| 模型 | 输入 | 输出 | 缓存读取 | 缓存创建 |\n|------|------|------|----------|----------|\n| **Claude 4.5 系列** | | | | |\n| claude-opus-4-5 | $5 | $25 | $0.50 | $6.25 |\n| claude-sonnet-4-5 | $3 | $15 | $0.30 | $3.75 |\n| claude-haiku-4-5 | $1 | $5 | $0.10 | $1.25 |\n| **Claude 4 系列** | | | | |\n| claude-opus-4 | $15 | $75 | $1.50 | $18.75 |\n| claude-opus-4-1 | $15 | $75 | $1.50 | $18.75 |\n| claude-sonnet-4 | $3 | $15 | $0.30 | $3.75 |\n| **Claude 3.5 系列** | | | | |\n| claude-3-5-sonnet | $3 | $15 | $0.30 | $3.75 |\n| claude-3-5-haiku | $0.80 | $4 | $0.08 | $1.00 |\n\n**OpenAI 系列 / Codex（美元）**：\n\n| 模型 | 输入 | 输出 | 缓存读取 |\n|------|------|------|----------|\n| **GPT-5.2 系列** | | | |\n| gpt-5.2 | $1.75 | $14 | $0.175 |\n| **GPT-5.1 系列** | | | |\n| gpt-5.1 | $1.25 | $10 | $0.125 |\n| **GPT-5 系列** | | | |\n| gpt-5 | $1.25 | $10 | $0.125 |\n\n> 注：Codex 预设包含了 low/medium/high 等变体，价格与基础模型一致。\n\n**Gemini 系列（美元）**：\n\n| 模型 | 输入 | 输出 | 缓存读取 |\n|------|------|------|----------|\n| **Gemini 3 系列** | | | |\n| gemini-3-pro-preview | $2 | $12 | $0.20 |\n| gemini-3-flash-preview | $0.50 | $3 | $0.05 |\n| **Gemini 2.5 系列** | | | |\n| gemini-2.5-pro | $1.25 | $10 | $0.125 |\n| gemini-2.5-flash | $0.30 | $2.50 | $0.03 |\n\n**中国厂商模型**：\n\n> 注：币种遵循各供应商官方定价页面。StepFun 当前按美元列出。\n\n| 模型 | 输入 | 输出 | 缓存读取 |\n|------|------|------|----------|\n| **StepFun** | | | |\n| step-3.5-flash | $0.10 | $0.30 | $0.02 |\n| **DeepSeek** | | | |\n| deepseek-v3.2 | ¥2.00 | ¥3.00 | ¥0.40 |\n| deepseek-v3.1 | ¥4.00 | ¥12.00 | ¥0.80 |\n| deepseek-v3 | ¥2.00 | ¥8.00 | ¥0.40 |\n| **Kimi (月之暗面)** | | | |\n| kimi-k2-thinking | ¥4.00 | ¥16.00 | ¥1.00 |\n| kimi-k2 | ¥4.00 | ¥16.00 | ¥1.00 |\n| kimi-k2-turbo | ¥8.00 | ¥58.00 | ¥1.00 |\n| **MiniMax** | | | |\n| minimax-m2.1 | ¥2.10 | ¥8.40 | ¥0.21 |\n| minimax-m2.1-lightning | ¥2.10 | ¥16.80 | ¥0.21 |\n| **其他** | | | |\n| glm-4.7 | ¥2.00 | ¥8.00 | ¥0.40 |\n| doubao-seed-code | ¥1.20 | ¥8.00 | ¥0.24 |\n| mimo-v2-flash | 免费 | 免费 | - |\n\n### 自定义价格\n\n如果使用中转服务，价格可能不同：\n\n1. 点击「编辑」按钮\n2. 修改价格\n3. 保存\n\n## 常见问题\n\n### 统计数据为空\n\n检查：\n- 代理服务是否运行\n- 应用接管是否开启\n- 日志记录是否开启\n- 是否有请求通过代理\n\n### 费用估算不准确\n\n可能原因：\n- 定价配置与实际不符\n- 使用了中转服务的特殊定价\n\n解决方法：\n- 更新定价配置\n- 参考供应商的实际账单\n\n### Token 数量与供应商不一致\n\nCC Switch 使用自己的方式估算 Token 数，可能与供应商的计算方式略有差异。以供应商账单为准。\n"
  },
  {
    "path": "docs/user-manual/zh/4-proxy/4.5-model-test.md",
    "content": "# 4.5 模型检查\n\n## 功能说明\n\n模型检查功能用于验证供应商配置的模型是否可用，通过发送实际的 API 请求来测试：\n\n- 模型是否存在\n- API Key 是否有效\n- 端点是否正常响应\n- 响应延迟是否正常\n\n## 打开配置\n\n设置 → 高级 → 模型测试\n\n## 测试模型配置\n\n为每个应用配置用于测试的模型：\n\n| 应用 | 配置项 | 默认值 | 说明 |\n|------|--------|--------|------|\n| Claude | Claude 模型 | 系统默认 | 建议使用 Haiku 系列（成本低、速度快） |\n| Codex | Codex 模型 | 系统默认 | 建议使用 mini 系列 |\n| Gemini | Gemini 模型 | 系统默认 | 建议使用 Flash 系列 |\n\n### 模型选择建议\n\n选择测试模型时考虑：\n\n1. **成本**：选择价格较低的模型（如 Haiku、Mini、Flash）\n2. **速度**：选择响应快的模型\n3. **可用性**：选择供应商支持的模型\n\n## 检查参数配置\n\n### 超时时间\n\n| 参数 | 说明 | 默认值 | 范围 |\n|------|------|--------|------|\n| 超时时间 | 单次请求超时 | 45 秒 | 10-120 秒 |\n\n设置过短可能导致误判，设置过长会延迟故障检测。\n\n### 重试次数\n\n| 参数 | 说明 | 默认值 | 范围 |\n|------|------|--------|------|\n| 最大重试 | 失败后重试次数 | 2 次 | 0-5 次 |\n\n网络不稳定时建议增加重试次数。\n\n### 降级阈值\n\n| 参数 | 说明 | 默认值 | 范围 |\n|------|------|--------|------|\n| 降级阈值 | 响应超过此时间标记为降级 | 6000ms | 1000-30000ms |\n\n超过阈值的供应商会被标记为「降级」状态，但仍可使用。\n\n## 执行模型检查\n\n### 手动测试\n\n在供应商卡片上点击「测试」按钮：\n\n1. 发送测试请求到配置的端点\n2. 使用配置的测试模型\n3. 等待响应或超时\n4. 显示测试结果\n\n### 测试内容\n\n测试请求会：\n- 发送简短的 prompt（如 \"Hi\"）\n- 限制最大输出 token（通常 10-50）\n- 使用流式响应检测首字节时间\n\n## 测试结果\n\n### 健康状态\n\n| 状态 | 图标 | 说明 |\n|------|------|------|\n| 健康 | 🟢 | 响应正常，延迟在阈值内 |\n| 降级 | 🟡 | 响应正常，但延迟超过阈值 |\n| 不可用 | 🔴 | 请求失败或超时 |\n\n### 结果信息\n\n测试完成后显示：\n- 响应延迟（毫秒）\n- 首字节时间（TTFB）\n- 错误信息（如果失败）\n\n## 与故障转移集成\n\n模型检查与故障转移功能配合使用：\n\n### 健康检查\n\n开启代理服务后，系统会定期对故障转移队列中的供应商执行健康检查：\n\n1. 使用配置的测试模型发送请求\n2. 根据响应更新健康状态\n3. 不健康的供应商会被暂时跳过\n\n### 熔断恢复\n\n当供应商从熔断状态恢复时：\n\n1. 执行模型检查验证可用性\n2. 检查通过后恢复正常状态\n3. 检查失败则继续熔断\n\n## 常见问题\n\n### 测试失败但实际可用\n\n**可能原因**：\n- 测试模型与实际使用的模型不同\n- 供应商不支持配置的测试模型\n\n**解决方法**：\n- 修改测试模型为供应商支持的模型\n- 检查供应商的模型列表\n\n### 延迟过高\n\n**可能原因**：\n- 网络延迟\n- 供应商服务器负载高\n- 模型响应慢\n\n**解决方法**：\n- 使用更快的测试模型\n- 调整降级阈值\n- 考虑使用镜像端点\n\n### 频繁超时\n\n**可能原因**：\n- 超时时间设置过短\n- 网络不稳定\n- 供应商服务不稳定\n\n**解决方法**：\n- 增加超时时间\n- 增加重试次数\n- 检查网络连接\n\n## 注意事项\n\n- 模型检查会消耗少量 API 配额\n- 建议使用低成本模型进行测试\n- 测试频率不宜过高，避免浪费配额\n- 不同供应商支持的模型可能不同\n"
  },
  {
    "path": "docs/user-manual/zh/5-faq/5.1-config-files.md",
    "content": "# 5.1 配置文件说明\n\n## CC Switch 数据存储\n\n### 存储目录\n\n默认位置：`~/.cc-switch/`\n\n可在设置中自定义位置（用于云同步）。\n\n### 目录结构\n\n```\n~/.cc-switch/\n├── cc-switch.db      # SQLite 数据库\n├── settings.json     # 设备级设置\n└── backups/          # 自动备份\n    ├── backup-20251230-120000.json\n    ├── backup-20251229-180000.json\n    └── ...\n```\n\n### 数据库内容\n\n`cc-switch.db` 是 SQLite 数据库，存储：\n\n| 表 | 内容 |\n|-----|------|\n| providers | 供应商配置 |\n| provider_endpoints | 供应商端点候选列表 |\n| mcp_servers | MCP 服务器配置 |\n| prompts | 提示词预设 |\n| skills | 技能安装状态 |\n| skill_repos | 技能仓库配置 |\n| proxy_config | 代理配置 |\n| proxy_request_logs | 代理请求日志 |\n| provider_health | 供应商健康状态 |\n| model_pricing | 模型定价 |\n| settings | 应用设置 |\n\n### 设备设置\n\n`settings.json` 存储设备级设置：\n\n```json\n{\n  \"language\": \"zh\",\n  \"theme\": \"system\",\n  \"windowBehavior\": \"minimize\",\n  \"autoStart\": false,\n  \"claudeConfigDir\": null,\n  \"codexConfigDir\": null,\n  \"geminiConfigDir\": null,\n  \"opencodeConfigDir\": null,\n  \"openclawConfigDir\": null\n}\n```\n\n这些设置不会跨设备同步。\n\n### 自动备份\n\n`backups/` 目录存储自动备份：\n\n- 每次导入配置前自动创建\n- 保留最近 10 个备份\n- 文件名包含时间戳\n\n## Claude Code 配置\n\n### 配置目录\n\n默认：`~/.claude/`\n\n### 主要文件\n\n```\n~/.claude/\n├── settings.json     # 主配置文件\n├── CLAUDE.md         # 系统提示词\n└── skills/           # 技能目录\n    └── ...\n```\n\n### settings.json\n\n```json\n{\n  \"env\": {\n    \"ANTHROPIC_API_KEY\": \"sk-xxx\",\n    \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\"\n  },\n  \"permissions\": {\n    \"allow_file_access\": true\n  }\n}\n```\n\n| 字段 | 说明 |\n|------|------|\n| `env.ANTHROPIC_API_KEY` | API 密钥 |\n| `env.ANTHROPIC_BASE_URL` | API 端点（可选） |\n| `env.ANTHROPIC_AUTH_TOKEN` | 替代认证方式 |\n\n### MCP 配置\n\nMCP 服务器配置在 `~/.claude.json`：\n\n```json\n{\n  \"mcpServers\": {\n    \"mcp-fetch\": {\n      \"command\": \"uvx\",\n      \"args\": [\"mcp-server-fetch\"]\n    }\n  }\n}\n```\n\n## Codex 配置\n\n### 配置目录\n\n默认：`~/.codex/`\n\n### 主要文件\n\n```\n~/.codex/\n├── auth.json         # 认证配置\n├── config.toml       # 主配置 + MCP\n└── AGENTS.md         # 系统提示词\n```\n\n### auth.json\n\n```json\n{\n  \"OPENAI_API_KEY\": \"sk-xxx\"\n}\n```\n\n### config.toml\n\n```toml\n# 基础配置\nbase_url = \"https://api.openai.com/v1\"\nmodel = \"gpt-4\"\n\n# MCP 服务器\n[mcp_servers.mcp-fetch]\ncommand = \"uvx\"\nargs = [\"mcp-server-fetch\"]\n```\n\n## Gemini CLI 配置\n\n### 配置目录\n\n默认：`~/.gemini/`\n\n### 主要文件\n\n```\n~/.gemini/\n├── .env              # 环境变量（API Key）\n├── settings.json     # 主配置 + MCP\n└── GEMINI.md         # 系统提示词\n```\n\n### .env\n\n```bash\nGEMINI_API_KEY=xxx\nGOOGLE_GEMINI_BASE_URL=https://generativelanguage.googleapis.com\nGEMINI_MODEL=gemini-pro\n```\n\n### settings.json\n\n```json\n{\n  \"mcpServers\": {\n    \"mcp-fetch\": {\n      \"command\": \"uvx\",\n      \"args\": [\"mcp-server-fetch\"]\n    }\n  }\n}\n```\n\n| 字段 | 说明 |\n|------|------|\n| `mcpServers` | MCP 服务器配置 |\n\n## OpenCode 配置\n\n### 配置目录\n\n默认：`~/.opencode/`\n\n### 主要文件\n\n```\n~/.opencode/\n├── config.json       # 主配置文件\n├── AGENTS.md         # 系统提示词\n└── skills/           # 技能目录\n    └── ...\n```\n\n## OpenClaw 配置\n\n### 配置目录\n\n默认：`~/.openclaw/`\n\n### 主要文件\n\n```\n~/.openclaw/\n├── openclaw.json     # 主配置文件（JSON5 格式）\n├── AGENTS.md         # 系统提示词\n└── skills/           # 技能目录\n    └── ...\n```\n\n### openclaw.json\n\nOpenClaw 使用 JSON5 格式配置文件，主要包含以下部分：\n\n```json5\n{\n  // 模型供应商配置\n  models: {\n    mode: \"merge\",\n    providers: {\n      \"custom-provider\": {\n        baseUrl: \"https://api.example.com/v1\",\n        apiKey: \"your-api-key\",\n        api: \"openai-completions\",\n        models: [{ id: \"model-id\", name: \"Model Name\" }]\n      }\n    }\n  },\n  // 环境变量\n  env: {\n    ANTHROPIC_API_KEY: \"sk-...\"\n  },\n  // Agent 默认模型配置\n  agents: {\n    defaults: {\n      model: {\n        primary: \"provider/model\"\n      }\n    }\n  },\n  // 工具配置\n  tools: {},\n  // 工作区文件配置\n  workspace: {}\n}\n```\n\n| 字段 | 说明 |\n|------|------|\n| `models.providers` | 供应商配置（映射为 CC Switch 的\"供应商\"） |\n| `env` | 环境变量配置 |\n| `agents.defaults` | Agent 默认模型设置 |\n| `tools` | 工具配置 |\n| `workspace` | 工作区文件管理 |\n\n## 配置优先级\n\nCC Switch 修改配置时的优先级：\n\n1. **CC Switch 数据库** - 单一事实源 (SSOT)\n2. **Live 配置文件** - 切换供应商时写入\n3. **回填机制** - 编辑当前供应商时从 Live 文件读取\n\n## 手动编辑配置\n\n### 可以手动编辑\n\n- CLI 工具的配置文件（会被 CC Switch 回填）\n- CC Switch 的 `settings.json`\n\n### 不建议手动编辑\n\n- `cc-switch.db` 数据库文件\n- 备份文件\n\n### 编辑后同步\n\n如果手动编辑了 CLI 工具的配置：\n\n1. 打开 CC Switch\n2. 编辑对应的供应商\n3. 会看到手动修改的内容已回填\n4. 保存以同步到数据库\n\n## 配置迁移\n\n### 从旧版本迁移\n\nCC Switch v3.7.0 从 JSON 文件迁移到 SQLite：\n\n- 首次启动自动迁移\n- 迁移成功后显示提示\n- 旧配置文件保留作为备份\n\n### 跨设备迁移\n\n1. 在源设备导出配置\n2. 在目标设备导入配置\n3. 或使用云同步功能\n\n## 配置备份建议\n\n### 定期备份\n\n建议定期导出配置：\n\n1. 设置 → 高级 → 数据管理\n2. 点击「导出」\n3. 保存到安全位置\n\n### 备份内容\n\n导出文件包含：\n\n- 所有供应商配置\n- MCP 服务器配置\n- Prompts 预设\n- 应用设置\n\n### 不包含的内容\n\n- 用量日志（数据量大）\n- 设备级设置（不适合跨设备）\n"
  },
  {
    "path": "docs/user-manual/zh/5-faq/5.2-questions.md",
    "content": "# 5.2 常见问题 FAQ\n\n## 安装问题\n\n### macOS 提示「未知开发者」\n\n**问题**：首次打开时提示「无法打开，因为它来自身份不明的开发者」\n\n**解决方法一**：通过系统设置\n1. 关闭警告弹窗\n2. 打开「系统设置」→「隐私与安全性」\n3. 找到 CC Switch 相关提示\n4. 点击「仍要打开」\n5. 再次打开应用\n\n**解决方法二**：通过终端命令（推荐）\n```bash\nsudo xattr -dr com.apple.quarantine /Applications/CC\\ Switch.app/\n```\n\n执行后即可正常打开应用。\n\n### Windows 安装后无法启动\n\n**可能原因**：\n- 缺少 WebView2 运行时\n- 杀毒软件拦截\n\n**解决方法**：\n1. 安装 [Microsoft Edge WebView2](https://developer.microsoft.com/en-us/microsoft-edge/webview2/)\n2. 将 CC Switch 添加到杀毒软件白名单\n\n### Linux 启动报错\n\n**问题**：AppImage 无法启动\n\n**解决方法**：\n```bash\n# 添加执行权限\nchmod +x CC-Switch-*.AppImage\n\n# 如果仍然失败，尝试\n./CC-Switch-*.AppImage --no-sandbox\n```\n\n## 供应商问题\n\n### 切换供应商后不生效\n\n**原因**：CLI 工具需要重新加载配置\n\n**解决方法**：\n- Claude Code：关闭并重新打开终端，或重启 IDE\n- Codex：关闭并重新打开终端\n- Gemini：托盘切换可即时生效，无需重启\n\n### API Key 无效\n\n**检查步骤**：\n1. 确认 API Key 正确复制（无多余空格）\n2. 确认 API Key 未过期\n3. 确认端点地址正确\n4. 使用速度测试验证连接\n\n### 如何恢复官方登录\n\n**操作步骤**：\n1. 选择「官方登录」预设（Claude/Codex）或「Google 官方」预设（Gemini）\n2. 点击「启用」\n3. 重启对应的 CLI 工具\n4. 按照 CLI 工具的登录流程操作\n\n## 代理问题\n\n### 代理服务启动失败\n\n**可能原因**：端口被占用\n\n**解决方法**：\n1. 检查端口占用：\n   ```bash\n   # macOS/Linux\n   lsof -i :49152\n   \n   # Windows\n   netstat -ano | findstr :49152\n   ```\n2. 关闭占用端口的程序\n3. 或尝试修改配置恢复默认端口：\n   - 打开「设置 → 代理服务」\n   - 点击「恢复默认」按钮\n\n### 代理模式下请求超时\n\n**可能原因**：\n- 网络问题\n- 供应商服务器问题\n- 代理配置错误\n\n**解决方法**：\n1. 检查网络连接\n2. 尝试直接访问供应商 API（关闭代理）\n3. 检查供应商配置是否正确\n\n### 关闭代理后配置未恢复\n\n**可能原因**：代理异常退出\n\n**解决方法**：\n1. 编辑当前供应商\n2. 检查端点地址是否正确\n3. 保存以更新配置\n\n## 故障转移问题\n\n### 故障转移没有触发\n\n**检查清单**：\n- [ ] 代理服务是否运行\n- [ ] 应用接管是否开启\n- [ ] 自动故障转移是否开启\n- [ ] 队列中是否有备用供应商\n\n### 频繁触发故障转移\n\n**可能原因**：\n- 主供应商不稳定\n- 熔断器阈值设置过低\n\n**解决方法**：\n1. 检查主供应商状态\n2. 调高失败阈值（如从 3 改为 5）\n3. 考虑更换主供应商\n\n### 所有供应商都熔断了\n\n**解决方法**：\n1. 等待熔断时长到期（默认 60 秒）\n2. 或重启代理服务重置状态\n\n## 数据问题\n\n### 配置丢失\n\n**可能原因**：\n- 配置目录被删除\n- 数据库损坏\n\n**解决方法**：\n1. 检查 `~/.cc-switch/` 目录是否存在\n2. 从备份恢复：`~/.cc-switch/backups/`\n3. 或从之前导出的配置文件导入\n\n### 导入配置失败\n\n**可能原因**：\n- 文件格式错误\n- 版本不兼容\n\n**解决方法**：\n1. 确认文件是 CC Switch 导出的 JSON 文件\n2. 检查文件内容是否完整\n3. 尝试用文本编辑器打开检查格式\n\n### 用量统计数据为空\n\n**检查清单**：\n- [ ] 代理服务是否运行\n- [ ] 应用接管是否开启\n- [ ] 日志记录是否开启\n- [ ] 是否有请求通过代理\n\n## 其他问题\n\n### 托盘图标不显示\n\n**macOS**：\n- 检查系统设置中的菜单栏图标设置\n\n**Windows**：\n- 检查任务栏设置，确保 CC Switch 图标未被隐藏\n\n**Linux**：\n- 需要安装系统托盘支持（如 `libappindicator`）\n\n### 界面显示异常\n\n**解决方法**：\n1. 尝试切换主题（浅色/深色）\n2. 重启应用\n3. 删除 `~/.cc-switch/settings.json` 重置设置\n\n### 更新失败\n\n**解决方法**：\n1. 检查网络连接\n2. 手动下载最新版本安装\n3. 如使用 Homebrew：`brew upgrade --cask cc-switch`\n\n## 获取帮助\n\n### 提交 Issue\n\n如果以上方法都无法解决问题：\n\n1. 访问 [GitHub Issues](https://github.com/farion1231/cc-switch/issues)\n2. 搜索是否有类似问题\n3. 如果没有，创建新 Issue\n4. 提供以下信息：\n   - 操作系统和版本\n   - CC Switch 版本\n   - 问题描述和复现步骤\n   - 错误信息（如有）\n\n### 日志文件\n\n提交 Issue 时可附上日志文件：\n\n- macOS/Linux：`~/.cc-switch/logs/`\n- Windows：`%APPDATA%\\cc-switch\\logs\\`\n"
  },
  {
    "path": "docs/user-manual/zh/5-faq/5.3-deeplink.md",
    "content": "# 5.3 深度链接协议\n\n## 功能说明\n\nCC Switch 支持 `ccswitch://` 深度链接协议，可以通过链接一键导入配置。\n\n**使用场景**：\n- 团队共享配置\n- 教程中的一键配置\n- 跨设备快速同步\n\n## 在线生成工具\n\nCC Switch 提供在线深度链接生成工具：\n\n**访问地址**：[https://farion1231.github.io/cc-switch/deplink.html](https://farion1231.github.io/cc-switch/deplink.html)\n\n### 使用方法\n\n1. 打开上述网页\n2. 选择导入类型（供应商/MCP/Prompt）\n3. 填写配置信息\n4. 点击「生成链接」\n5. 复制生成的深度链接\n6. 分享给他人或在其他设备使用\n\n## 协议格式\n\n### V1 协议\n\n使用 URL 参数格式，易读易生成：\n\n```\nccswitch://v1/import?resource={type}&app={app}&name={name}&...\n```\n\n**通用参数**：\n\n| 参数 | 必填 | 说明 |\n|------|------|------|\n| `resource` | 是 | 资源类型：`provider` / `mcp` / `prompt` / `skill` |\n| `app` | 是 | 应用类型：`claude` / `codex` / `gemini` / `opencode` / `openclaw` |\n| `name` | 是 | 名称 |\n\n**供应商参数**（resource=provider）：\n\n| 参数 | 必填 | 说明 |\n|------|------|------|\n| `endpoint` | 否 | API 端点地址（支持逗号分隔多个 URL） |\n| `apiKey` | 否 | API 密钥 |\n| `homepage` | 否 | 供应商官网 |\n| `model` | 否 | 默认模型 |\n| `haikuModel` | 否 | Haiku 模型（仅 Claude） |\n| `sonnetModel` | 否 | Sonnet 模型（仅 Claude） |\n| `opusModel` | 否 | Opus 模型（仅 Claude） |\n| `notes` | 否 | 备注 |\n| `icon` | 否 | 图标 |\n| `config` | 否 | Base64 编码的配置内容 |\n| `configFormat` | 否 | 配置格式：`json` / `toml` |\n| `configUrl` | 否 | 远程配置 URL |\n| `enabled` | 否 | 是否启用（布尔值） |\n| `usageScript` | 否 | 用量查询脚本 |\n| `usageEnabled` | 否 | 是否启用用量查询（默认 true） |\n| `usageApiKey` | 否 | 用量查询专用 API Key |\n| `usageBaseUrl` | 否 | 用量查询专用地址 |\n| `usageAccessToken` | 否 | 用量查询访问令牌 |\n| `usageUserId` | 否 | 用量查询用户 ID |\n| `usageAutoInterval` | 否 | 自动查询间隔（分钟） |\n\n**提示词参数**（resource=prompt）：\n\n| 参数 | 必填 | 说明 |\n|------|------|------|\n| `content` | 是 | 提示词内容 |\n| `description` | 否 | 描述 |\n| `enabled` | 否 | 是否启用（布尔值） |\n\n**MCP 参数**（resource=mcp）：\n\n| 参数 | 必填 | 说明 |\n|------|------|------|\n| `apps` | 是 | 应用列表（逗号分隔，如 `claude,codex,gemini,opencode`） |\n| `config` | 是 | MCP 服务器配置（JSON 格式） |\n| `enabled` | 否 | 是否启用（布尔值） |\n\n**Skill 参数**（resource=skill）：\n\n| 参数 | 必填 | 说明 |\n|------|------|------|\n| `repo` | 是 | 仓库（格式：`owner/name`） |\n| `directory` | 否 | 目录路径 |\n| `branch` | 否 | Git 分支 |\n\n**示例**：\n```\nccswitch://v1/import?resource=provider&app=claude&name=My%20Provider&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-xxx\n```\n\n## 导入类型示例\n\n### 导入供应商\n\n```\nccswitch://v1/import?resource=provider&app=claude&name=My%20Provider&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-xxx\n```\n\n### 导入 MCP 服务器\n\n```\nccswitch://v1/import?resource=mcp&apps=claude,codex&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22mcp-server-fetch%22%5D%7D&name=mcp-fetch\n```\n\n### 导入 Prompt 预设\n\n```\nccswitch://v1/import?resource=prompt&app=claude&name=%E4%BB%A3%E7%A0%81%E5%AE%A1%E6%9F%A5&content=%23%20%E8%A7%92%E8%89%B2%0A%E4%BD%A0%E6%98%AF%E4%B8%80%E4%B8%AA%E4%B8%93%E4%B8%9A%E7%9A%84%E4%BB%A3%E7%A0%81%E5%AE%A1%E6%9F%A5%E4%B8%93%E5%AE%B6\n```\n\n### 导入 Skill\n\n```\nccswitch://v1/import?resource=skill&name=my-skill&repo=owner/repo&directory=skills/my-skill&branch=main\n```\n\n## 生成深度链接\n\n### 手动生成\n\n1. 准备参数\n2. 按 V1 协议格式拼接 URL\n3. URL 编码特殊字符\n\n**示例**：\n\n```javascript\nconst params = new URLSearchParams({\n  resource: 'provider',\n  app: 'claude',\n  name: 'My Provider',\n  endpoint: 'https://api.example.com',\n  apiKey: 'sk-xxx'\n});\n\nconst url = `ccswitch://v1/import?${params.toString()}`;\n```\n\n### 在线工具\n\n使用 CC Switch 官方提供的在线深度链接生成工具更方便。\n\n## 使用深度链接\n\n### 点击链接\n\n在浏览器或其他应用中点击深度链接：\n\n1. 系统会询问是否打开 CC Switch\n2. 确认后 CC Switch 打开\n3. 显示导入确认对话框\n4. 确认导入\n\n### 导入确认\n\n导入前会显示确认对话框，包含：\n\n- 导入类型\n- 配置预览\n- 确认/取消按钮\n\n**安全提示**：只导入来自可信来源的配置。\n\n## 协议注册\n\n### 自动注册\n\nCC Switch 安装时会自动注册 `ccswitch://` 协议。\n\n### 手动注册\n\n如果协议未正确注册：\n\n**macOS**：\n重新安装应用，或运行：\n```bash\n/usr/bin/open -a \"CC Switch\" --args --register-protocol\n```\n\n**Windows**：\n重新安装应用，或检查注册表：\n```\nHKEY_CLASSES_ROOT\\ccswitch\n```\n\n**Linux**：\n检查 `.desktop` 文件中的 `MimeType` 配置。\n\n## 安全考虑\n\n### 敏感信息\n\n深度链接中可能包含敏感信息（如 API Key）：\n\n- 不要在公开场合分享包含 API Key 的链接\n- 分享前移除或替换敏感信息\n- 使用安全渠道传输链接\n\n### 验证来源\n\n导入前 CC Switch 会：\n\n1. 验证数据格式\n2. 显示配置预览\n3. 要求用户确认\n\n### 恶意链接防护\n\nCC Switch 会检查：\n\n- 数据格式是否合法\n- 必填字段是否完整\n- 配置值是否在合理范围\n\n## 示例链接\n\n### 示例：导入 Claude 供应商\n\n```\nccswitch://v1/import?resource=provider&app=claude&name=Test%20Provider&apiKey=sk-xxx&endpoint=https%3A%2F%2Fapi.example.com\n```\n\n### 示例：导入 MCP 服务器\n\n```\nccswitch://v1/import?resource=mcp&name=mcp-fetch&apps=claude,codex,gemini&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22mcp-server-fetch%22%5D%7D\n```\n\n## 故障排除\n\n### 链接无法打开\n\n**检查**：\n1. CC Switch 是否已安装\n2. 协议是否正确注册\n3. 链接格式是否正确\n\n### 导入失败\n\n**可能原因**：\n- Base64 编码错误\n- JSON 格式错误\n- 缺少必填字段\n\n**解决方法**：\n1. 检查原始 JSON 格式\n2. 重新进行 Base64 编码\n3. 确保所有必填字段都存在\n"
  },
  {
    "path": "docs/user-manual/zh/5-faq/5.4-env-conflict.md",
    "content": "# 5.4 环境变量冲突\n\n## 功能说明\n\nCC Switch 会自动检测系统环境变量与应用配置的冲突，避免配置被意外覆盖。\n\n**检测的环境变量**：\n- `ANTHROPIC_API_KEY` - Claude API 密钥\n- `ANTHROPIC_BASE_URL` - Claude API 端点\n- `OPENAI_API_KEY` - OpenAI API 密钥\n- `GEMINI_API_KEY` - Gemini API 密钥\n- 其他相关环境变量\n\n## 冲突警告\n\n当检测到冲突时，界面顶部会显示黄色警告横幅：\n\n```\n⚠️ 检测到环境变量冲突\n发现 X 个环境变量可能与 CC Switch 配置冲突\n[展开] [关闭]\n```\n\n## 查看冲突详情\n\n点击「展开」按钮查看详细信息：\n\n| 字段 | 说明 |\n|------|------|\n| 变量名 | 环境变量名称 |\n| 变量值 | 当前设置的值 |\n| 来源 | 变量的来源位置 |\n\n### 来源类型\n\n| 来源 | 说明 |\n|------|------|\n| 用户注册表 | Windows 用户级环境变量 |\n| 系统注册表 | Windows 系统级环境变量 |\n| Shell 配置 | macOS/Linux 的 shell 配置文件 |\n| 系统环境 | 系统级环境变量 |\n\n## 处理冲突\n\n### 选择要删除的变量\n\n1. 勾选要删除的环境变量\n2. 或点击「全选」选择所有冲突变量\n\n### 删除变量\n\n1. 点击「删除选中」按钮\n2. 确认删除操作\n3. CC Switch 会自动备份并删除选中的变量\n\n### 自动备份\n\n删除前会自动备份：\n\n- 备份位置：`~/.cc-switch/env-backups/`\n- 备份格式：JSON 文件\n- 包含变量名、值、来源等信息\n\n## 忽略警告\n\n如果确认冲突不影响使用，可以：\n\n1. 点击警告横幅右侧的「关闭」按钮\n2. 警告会暂时隐藏\n3. 下次启动时会重新检测\n\n## 手动处理\n\n如果不想通过 CC Switch 删除，可以手动处理：\n\n### Windows\n\n1. 打开「系统属性 → 高级 → 环境变量」\n2. 在用户变量或系统变量中找到冲突变量\n3. 删除或修改变量\n\n### macOS / Linux\n\n1. 编辑 shell 配置文件（如 `~/.zshrc`、`~/.bashrc`）\n2. 删除或注释掉相关的 `export` 语句\n3. 重新加载配置：`source ~/.zshrc`\n\n## 为什么会冲突\n\n环境变量的优先级通常高于配置文件，可能导致：\n\n- CC Switch 设置的供应商配置被覆盖\n- API 请求发送到错误的端点\n- 使用错误的 API 密钥\n\n## 最佳实践\n\n1. **使用 CC Switch 管理配置**：避免在系统环境变量中设置 API 密钥\n2. **定期检查**：关注冲突警告，及时处理\n3. **备份重要变量**：删除前确认已备份\n\n## 恢复已删除的变量\n\n如果误删了环境变量：\n\n1. 找到备份文件：`~/.cc-switch/env-backups/`\n2. 打开对应的 JSON 文件\n3. 手动恢复变量到系统环境\n"
  },
  {
    "path": "docs/user-manual/zh/README.md",
    "content": "# CC Switch 用户手册\n\n> Claude Code / Codex / Gemini CLI / OpenCode / OpenClaw 全方位辅助工具\n\n## 目录结构\n\n```\n📚 CC Switch 用户手册\n│\n├── 1. 快速入门\n│   ├── 1.1 软件介绍\n│   ├── 1.2 安装指南\n│   ├── 1.3 界面概览\n│   ├── 1.4 快速上手\n│   └── 1.5 个性化配置\n│\n├── 2. 供应商管理\n│   ├── 2.1 添加供应商\n│   ├── 2.2 切换供应商\n│   ├── 2.3 编辑供应商\n│   ├── 2.4 排序与复制\n│   └── 2.5 用量查询\n│\n├── 3. 扩展功能\n│   ├── 3.1 MCP 服务器管理\n│   ├── 3.2 Prompts 提示词管理\n│   └── 3.3 Skills 技能管理\n│\n├── 4. 代理与高可用\n│   ├── 4.1 代理服务\n│   ├── 4.2 应用接管\n│   ├── 4.3 故障转移\n│   ├── 4.4 用量统计\n│   └── 4.5 模型检查\n│\n└── 5. 常见问题\n    ├── 5.1 配置文件说明\n    ├── 5.2 FAQ\n    ├── 5.3 深度链接协议\n    └── 5.4 环境变量冲突\n```\n\n## 文件列表\n\n### 1. 快速入门\n\n| 文件 | 内容 |\n|------|------|\n| [1.1-introduction.md](./1-getting-started/1.1-introduction.md) | 软件介绍、核心功能、支持平台 |\n| [1.2-installation.md](./1-getting-started/1.2-installation.md) | Windows/macOS/Linux 安装指南 |\n| [1.3-interface.md](./1-getting-started/1.3-interface.md) | 界面布局、导航栏、供应商卡片说明 |\n| [1.4-quickstart.md](./1-getting-started/1.4-quickstart.md) | 5 分钟快速上手教程 |\n| [1.5-settings.md](./1-getting-started/1.5-settings.md) | 语言、主题、目录、云同步配置 |\n\n### 2. 供应商管理\n\n| 文件 | 内容 |\n|------|------|\n| [2.1-add.md](./2-providers/2.1-add.md) | 使用预设、自定义配置、统一供应商 |\n| [2.2-switch.md](./2-providers/2.2-switch.md) | 主界面切换、托盘切换、生效方式 |\n| [2.3-edit.md](./2-providers/2.3-edit.md) | 编辑配置、修改 API Key、回填机制 |\n| [2.4-sort-duplicate.md](./2-providers/2.4-sort-duplicate.md) | 拖拽排序、复制供应商、删除 |\n| [2.5-usage-query.md](./2-providers/2.5-usage-query.md) | 用量查询、剩余额度、多套餐显示 |\n\n### 3. 扩展功能\n\n| 文件 | 内容 |\n|------|------|\n| [3.1-mcp.md](./3-extensions/3.1-mcp.md) | MCP 协议、添加服务器、应用绑定 |\n| [3.2-prompts.md](./3-extensions/3.2-prompts.md) | 创建预设、激活切换、智能回填 |\n| [3.3-skills.md](./3-extensions/3.3-skills.md) | 发现技能、安装卸载、仓库管理 |\n\n### 4. 代理与高可用\n\n| 文件 | 内容 |\n|------|------|\n| [4.1-service.md](./4-proxy/4.1-service.md) | 启动代理、配置项、运行状态 |\n| [4.2-takeover.md](./4-proxy/4.2-takeover.md) | 应用接管、配置修改、状态指示 |\n| [4.3-failover.md](./4-proxy/4.3-failover.md) | 故障转移队列、熔断器、健康状态 |\n| [4.4-usage.md](./4-proxy/4.4-usage.md) | 用量统计、趋势图表、定价配置 |\n| [4.5-model-test.md](./4-proxy/4.5-model-test.md) | 模型检查、健康检测、延迟测试 |\n\n### 5. 常见问题\n\n| 文件 | 内容 |\n|------|------|\n| [5.1-config-files.md](./5-faq/5.1-config-files.md) | CC Switch 存储、CLI 配置文件格式 |\n| [5.2-questions.md](./5-faq/5.2-questions.md) | 常见问题解答 |\n| [5.3-deeplink.md](./5-faq/5.3-deeplink.md) | 深度链接协议、生成和使用方法 |\n| [5.4-env-conflict.md](./5-faq/5.4-env-conflict.md) | 环境变量冲突检测与处理 |\n\n## 快速链接\n\n- **新用户**：从 [1.1 软件介绍](./1-getting-started/1.1-introduction.md) 开始\n- **安装问题**：查看 [1.2 安装指南](./1-getting-started/1.2-installation.md)\n- **配置供应商**：查看 [2.1 添加供应商](./2-providers/2.1-add.md)\n- **使用代理**：查看 [4.1 代理服务](./4-proxy/4.1-service.md)\n- **遇到问题**：查看 [5.2 FAQ](./5-faq/5.2-questions.md)\n\n## 版本信息\n\n- 文档版本：v3.12.0\n- 最后更新：2026-03-09\n- 适用于 CC Switch v3.12.0+\n\n## 贡献\n\n欢迎提交 Issue 或 PR 改进文档：\n\n- [GitHub Issues](https://github.com/farion1231/cc-switch/issues)\n- [GitHub Repository](https://github.com/farion1231/cc-switch)\n"
  },
  {
    "path": "flatpak/README.md",
    "content": "# Flatpak Build Guide\n\nThis directory contains the Flatpak manifest (`com.ccswitch.desktop`) for CC Switch, used to convert the generated `.deb` artifact into an installable `.flatpak` package via CI or local builds.\n\n## Dependencies\n\n- `flatpak`\n- `flatpak-builder`\n- Flathub remote (for installing `org.gnome.Platform//46` runtime)\n\nFor Ubuntu/Debian:\n\n```bash\nsudo apt install flatpak flatpak-builder\nflatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo\nflatpak install -y --user flathub org.gnome.Platform//46 org.gnome.Sdk//46\n```\n\n## Local Build (Generate .flatpak from .deb)\n\n1) Build the deb on Linux first:\n\n```bash\npnpm tauri build -- --bundles deb\n```\n\n2) Copy the generated deb to this directory:\n\n```bash\ncp \"$(find src-tauri/target/release/bundle -name '*.deb' | head -n 1)\" flatpak/cc-switch.deb\n```\n\n3) Build the local Flatpak repository and export the `.flatpak`:\n\n```bash\nflatpak-builder --force-clean --user --disable-cache --repo flatpak-repo flatpak-build flatpak/com.ccswitch.desktop.yml\nflatpak build-bundle --runtime-repo=https://flathub.org/repo/flathub.flatpakrepo flatpak-repo CC-Switch-Linux.flatpak com.ccswitch.desktop\n```\n\n4) Install and run:\n\n```bash\nflatpak install --user ./CC-Switch-Linux.flatpak\nflatpak run com.ccswitch.desktop\n```\n\n## Permissions Note\n\nThe current manifest uses `--filesystem=home` by default for \"download and run\" convenience, allowing the app to directly read/write CLI configuration files and app data on the host (and supporting the \"directory override\" feature).\n\nIf you prefer minimal permissions (e.g., for Flathub submission or security concerns), you can replace `--filesystem=home` in `flatpak/com.ccswitch.desktop.yml` with more precise grants:\n\n```yaml\n  - --filesystem=~/.cc-switch:create\n  - --filesystem=~/.claude:create\n  - --filesystem=~/.claude.json\n  - --filesystem=~/.codex:create\n  - --filesystem=~/.gemini:create\n```\n\nNote: Flatpak's `:create` modifier only works with directories, not files. Therefore, `~/.claude.json` cannot use `:create`. If this file doesn't exist on the user's machine, the app may not be able to create it with restricted permissions. Users should either run Claude Code once to generate it, or manually create an empty JSON file (content: `{}`).\n\nIf you plan to publish on Flathub or want stricter permission control, adjust the `finish-args` in `flatpak/com.ccswitch.desktop.yml` accordingly.\n"
  },
  {
    "path": "flatpak/com.ccswitch.desktop.desktop",
    "content": "[Desktop Entry]\nType=Application\nName=CC Switch\nComment=All-in-One Assistant for Claude Code, Codex & Gemini CLI\nExec=cc-switch\nIcon=com.ccswitch.desktop\nTerminal=false\nCategories=Utility;Development;\nStartupNotify=true\n"
  },
  {
    "path": "flatpak/com.ccswitch.desktop.metainfo.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<component type=\"desktop-application\">\n  <id>com.ccswitch.desktop</id>\n  <name>CC Switch</name>\n  <summary>All-in-One Assistant for Claude Code, Codex &amp; Gemini CLI</summary>\n  <metadata_license>CC0-1.0</metadata_license>\n  <project_license>MIT</project_license>\n\n  <description>\n    <p>CC Switch is a cross-platform desktop app for managing and switching provider configurations for Claude Code, Codex, and Gemini CLI.</p>\n    <ul>\n      <li>Manage multiple provider configurations and endpoints</li>\n      <li>One-click switch and sync to client live configurations</li>\n      <li>MCP servers and Prompt/Skills management</li>\n    </ul>\n  </description>\n\n  <launchable type=\"desktop-id\">com.ccswitch.desktop.desktop</launchable>\n  <provides>\n    <binary>cc-switch</binary>\n  </provides>\n\n  <url type=\"homepage\">https://github.com/farion1231/cc-switch</url>\n  <url type=\"bugtracker\">https://github.com/farion1231/cc-switch/issues</url>\n</component>\n"
  },
  {
    "path": "flatpak/com.ccswitch.desktop.yml",
    "content": "id: com.ccswitch.desktop\n\nruntime: org.gnome.Platform\nruntime-version: '46'\nsdk: org.gnome.Sdk\n\ncommand: cc-switch\n\nfinish-args:\n  - --share=ipc\n  - --share=network\n  - --socket=wayland\n  - --socket=fallback-x11\n  - --device=dri\n  # Tray icon permissions (required by Tauri tray-icon)\n  - --talk-name=org.kde.StatusNotifierWatcher\n  - --filesystem=xdg-run/tray-icon:create\n  # GitHub Releases scenario: Users download and install manually.\n  # For \"download and run\" convenience (needs read/write access to ~/.cc-switch, ~/.claude, ~/.claude.json, ~/.codex, ~/.gemini,\n  # and supports custom directory overrides), we grant full Home access by default.\n  # If you plan to publish on Flathub or prefer minimal permissions, replace this with more precise directory grants (see flatpak/README.md).\n  - --filesystem=home\n\nmodules:\n  # Required for libdbusmenu build (intltool was removed from GNOME SDK since 2019)\n  - name: intltool\n    cleanup:\n      - \"*\"\n    sources:\n      - type: archive\n        url: https://launchpad.net/intltool/trunk/0.51.0/+download/intltool-0.51.0.tar.gz\n        sha256: 67c74d94196b153b774ab9f89b2fa6c6ba79352407037c8c14d5aeb334e959cd\n\n  # Required for tray icon support\n  - name: libayatana-ido\n    buildsystem: cmake-ninja\n    config-opts:\n      - -DENABLE_TESTS=NO\n    sources:\n      - type: git\n        url: https://github.com/AyatanaIndicators/ayatana-ido.git\n        tag: 0.10.4\n\n  - name: libdbusmenu-gtk3\n    buildsystem: autotools\n    build-options:\n      cflags: -Wno-error\n    config-opts:\n      - --with-gtk=3\n      - --disable-dumper\n      - --disable-static\n      - --disable-nls\n    sources:\n      - type: archive\n        url: https://launchpad.net/libdbusmenu/16.04/16.04.0/+download/libdbusmenu-16.04.0.tar.gz\n        sha256: b9cc4a2acd74509435892823607d966d424bd9ad5d0b00938f27240a1bfa878a\n\n  - name: libayatana-indicator\n    buildsystem: cmake-ninja\n    config-opts:\n      - -DENABLE_TESTS=NO\n      - -DENABLE_IDO=YES\n    sources:\n      - type: git\n        url: https://github.com/AyatanaIndicators/libayatana-indicator.git\n        tag: 0.9.4\n\n  - name: libayatana-appindicator\n    buildsystem: cmake-ninja\n    config-opts:\n      - -DENABLE_BINDINGS_MONO=NO\n      - -DENABLE_BINDINGS_VALA=NO\n    sources:\n      - type: git\n        url: https://github.com/AyatanaIndicators/libayatana-appindicator.git\n        tag: 0.5.93\n\n  - name: cc-switch\n    buildsystem: simple\n    sources:\n      # Placed in flatpak/ directory by CI or local build script\n      - type: file\n        path: cc-switch.deb\n      - type: file\n        path: com.ccswitch.desktop.desktop\n      - type: file\n        path: com.ccswitch.desktop.metainfo.xml\n      - type: file\n        path: ../src-tauri/icons/128x128.png\n    build-commands:\n      - ar -x *.deb\n      - tar -xf data.tar.*\n      - cp -a usr/* /app/\n      # Use our own desktop/metainfo/icon to align with Flatpak app id\n      - rm -f /app/share/applications/*.desktop\n      - install -Dm644 com.ccswitch.desktop.desktop /app/share/applications/com.ccswitch.desktop.desktop\n      - install -Dm644 com.ccswitch.desktop.metainfo.xml /app/share/metainfo/com.ccswitch.desktop.metainfo.xml\n      - install -Dm644 128x128.png /app/share/icons/hicolor/128x128/apps/com.ccswitch.desktop.png\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"cc-switch\",\n  \"version\": \"3.12.3\",\n  \"description\": \"All-in-One Assistant for Claude Code, Codex & Gemini CLI\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"pnpm tauri dev\",\n    \"build\": \"pnpm tauri build\",\n    \"tauri\": \"tauri\",\n    \"dev:renderer\": \"vite\",\n    \"build:renderer\": \"vite build\",\n    \"typecheck\": \"tsc --noEmit\",\n    \"format\": \"prettier --write \\\"src/**/*.{js,jsx,ts,tsx,css,json}\\\"\",\n    \"format:check\": \"prettier --check \\\"src/**/*.{js,jsx,ts,tsx,css,json}\\\"\",\n    \"test:unit\": \"vitest run\",\n    \"test:unit:watch\": \"vitest watch\"\n  },\n  \"keywords\": [],\n  \"author\": \"Jason Young\",\n  \"license\": \"MIT\",\n  \"devDependencies\": {\n    \"@tauri-apps/cli\": \"^2.8.0\",\n    \"@testing-library/jest-dom\": \"^6.6.3\",\n    \"@testing-library/react\": \"^16.0.1\",\n    \"@testing-library/user-event\": \"^14.5.2\",\n    \"@types/node\": \"^20.0.0\",\n    \"@types/react\": \"^18.2.0\",\n    \"@types/react-dom\": \"^18.2.0\",\n    \"@vitejs/plugin-react\": \"^4.2.0\",\n    \"autoprefixer\": \"^10.4.20\",\n    \"code-inspector-plugin\": \"^1.3.3\",\n    \"cross-fetch\": \"^4.1.0\",\n    \"jsdom\": \"^25.0.0\",\n    \"msw\": \"^2.11.6\",\n    \"postcss\": \"^8.4.49\",\n    \"prettier\": \"^3.6.2\",\n    \"tailwindcss\": \"^3.4.17\",\n    \"typescript\": \"^5.3.0\",\n    \"vite\": \"^7.3.0\",\n    \"vitest\": \"^2.0.5\"\n  },\n  \"dependencies\": {\n    \"@codemirror/lang-javascript\": \"^6.2.4\",\n    \"@codemirror/lang-json\": \"^6.0.2\",\n    \"@codemirror/lang-markdown\": \"^6.5.0\",\n    \"@codemirror/lint\": \"^6.8.5\",\n    \"@codemirror/state\": \"^6.5.2\",\n    \"@codemirror/theme-one-dark\": \"^6.1.3\",\n    \"@codemirror/view\": \"^6.38.2\",\n    \"@dnd-kit/core\": \"^6.3.1\",\n    \"@dnd-kit/sortable\": \"^10.0.0\",\n    \"@dnd-kit/utilities\": \"^3.2.2\",\n    \"@hookform/resolvers\": \"^5.2.2\",\n    \"@lobehub/icons-static-svg\": \"^1.73.0\",\n    \"@radix-ui/react-accordion\": \"^1.2.12\",\n    \"@radix-ui/react-checkbox\": \"^1.3.3\",\n    \"@radix-ui/react-collapsible\": \"^1.1.12\",\n    \"@radix-ui/react-dialog\": \"^1.1.15\",\n    \"@radix-ui/react-dropdown-menu\": \"^2.1.16\",\n    \"@radix-ui/react-label\": \"^2.1.7\",\n    \"@radix-ui/react-popover\": \"^1.1.15\",\n    \"@radix-ui/react-scroll-area\": \"^1.2.10\",\n    \"@radix-ui/react-select\": \"^2.2.6\",\n    \"@radix-ui/react-slot\": \"^1.2.3\",\n    \"@radix-ui/react-switch\": \"^1.2.6\",\n    \"@radix-ui/react-tabs\": \"^1.1.13\",\n    \"@radix-ui/react-tooltip\": \"^1.2.8\",\n    \"@radix-ui/react-visually-hidden\": \"^1.2.4\",\n    \"@tanstack/react-query\": \"^5.90.3\",\n    \"@tauri-apps/api\": \"^2.8.0\",\n    \"@tauri-apps/plugin-dialog\": \"^2.4.0\",\n    \"@tauri-apps/plugin-process\": \"^2.0.0\",\n    \"@tauri-apps/plugin-store\": \"^2.0.0\",\n    \"@tauri-apps/plugin-updater\": \"^2.0.0\",\n    \"class-variance-authority\": \"^0.7.1\",\n    \"clsx\": \"^2.1.1\",\n    \"cmdk\": \"^1.1.1\",\n    \"codemirror\": \"^6.0.2\",\n    \"flexsearch\": \"^0.8.212\",\n    \"framer-motion\": \"^12.23.25\",\n    \"i18next\": \"^25.5.2\",\n    \"jsonc-parser\": \"^3.2.1\",\n    \"lucide-react\": \"^0.542.0\",\n    \"react\": \"^18.2.0\",\n    \"react-dom\": \"^18.2.0\",\n    \"react-hook-form\": \"^7.65.0\",\n    \"react-i18next\": \"^16.0.0\",\n    \"recharts\": \"^3.5.1\",\n    \"smol-toml\": \"^1.4.2\",\n    \"sonner\": \"^2.0.7\",\n    \"tailwind-merge\": \"^3.3.1\",\n    \"zod\": \"^4.1.12\"\n  }\n}\n"
  },
  {
    "path": "pnpm-workspace.yaml",
    "content": "packages: []\n\nonlyBuiltDependencies:\n  - '@tailwindcss/oxide'\n"
  },
  {
    "path": "postcss.config.cjs",
    "content": "module.exports = {\n  plugins: {\n    tailwindcss: {},\n    autoprefixer: {},\n  },\n};\n\n"
  },
  {
    "path": "scripts/extract-icons.js",
    "content": "const fs = require('fs');\nconst path = require('path');\n\n// 要提取的图标列表（按分类组织）\nconst ICONS_TO_EXTRACT = {\n  // AI 服务商（必需）\n  aiProviders: [\n    'openai', 'anthropic', 'claude', 'google', 'gemini',\n    'deepseek', 'kimi', 'moonshot', 'stepfun', 'zhipu', 'minimax',\n    'baidu', 'alibaba', 'tencent', 'meta', 'microsoft',\n    'cohere', 'perplexity', 'mistral', 'huggingface'\n  ],\n\n  // 云平台\n  cloudPlatforms: [\n    'aws', 'azure', 'huawei', 'cloudflare'\n  ],\n\n  // 开发工具\n  devTools: [\n    'github', 'gitlab', 'docker', 'kubernetes', 'vscode'\n  ],\n\n  // 其他\n  others: [\n    'settings', 'folder', 'file', 'link'\n  ]\n};\n\n// 合并所有图标\nconst ALL_ICONS = [\n  ...ICONS_TO_EXTRACT.aiProviders,\n  ...ICONS_TO_EXTRACT.cloudPlatforms,\n  ...ICONS_TO_EXTRACT.devTools,\n  ...ICONS_TO_EXTRACT.others\n];\n\n// 提取逻辑\nconst OUTPUT_DIR = path.join(__dirname, '../src/icons/extracted');\nconst SOURCE_DIR = path.join(__dirname, '../node_modules/@lobehub/icons-static-svg/icons');\n\n// 确保输出目录存在\nif (!fs.existsSync(OUTPUT_DIR)) {\n  fs.mkdirSync(OUTPUT_DIR, { recursive: true });\n}\n\nconsole.log('🎨 CC-Switch Icon Extractor\\n');\nconsole.log('========================================');\nconsole.log('📦 Extracting icons...\\n');\n\nlet extracted = 0;\nlet notFound = [];\n\n// 提取图标\nALL_ICONS.forEach(iconName => {\n  const sourceFile = path.join(SOURCE_DIR, `${iconName}.svg`);\n  const targetFile = path.join(OUTPUT_DIR, `${iconName}.svg`);\n\n  if (fs.existsSync(sourceFile)) {\n    fs.copyFileSync(sourceFile, targetFile);\n    console.log(`  ✓ ${iconName}.svg`);\n    extracted++;\n  } else if (fs.existsSync(targetFile)) {\n    console.log(`  ✓ ${iconName}.svg (kept local custom icon)`);\n    extracted++;\n  } else {\n    console.log(`  ✗ ${iconName}.svg (not found)`);\n    notFound.push(iconName);\n  }\n});\n\n// 生成索引文件\nconsole.log('\\n📝 Generating index file...\\n');\n\nconst indexContent = `// Auto-generated icon index\n// Do not edit manually\n\nexport const icons: Record<string, string> = {\n${ALL_ICONS.filter(name => !notFound.includes(name))\n  .map(name => {\n    const svg = fs.readFileSync(path.join(OUTPUT_DIR, `${name}.svg`), 'utf-8');\n    const escaped = svg.replace(/`/g, '\\\\`').replace(/\\$/g, '\\\\$');\n    return `  '${name}': \\`${escaped}\\`,`;\n  })\n  .join('\\n')}\n};\n\nexport const iconList = Object.keys(icons);\n\nexport function getIcon(name: string): string {\n  return icons[name.toLowerCase()] || '';\n}\n\nexport function hasIcon(name: string): boolean {\n  return name.toLowerCase() in icons;\n}\n`;\n\nfs.writeFileSync(path.join(OUTPUT_DIR, 'index.ts'), indexContent);\nconsole.log('✓ Generated: src/icons/extracted/index.ts');\n\n// 生成图标元数据\nconst metadataContent = `// Icon metadata for search and categorization\nimport { IconMetadata } from '@/types/icon';\n\nexport const iconMetadata: Record<string, IconMetadata> = {\n  // AI Providers\n  openai: { name: 'openai', displayName: 'OpenAI', category: 'ai-provider', keywords: ['gpt', 'chatgpt'], defaultColor: '#00A67E' },\n  anthropic: { name: 'anthropic', displayName: 'Anthropic', category: 'ai-provider', keywords: ['claude'], defaultColor: '#D4915D' },\n  claude: { name: 'claude', displayName: 'Claude', category: 'ai-provider', keywords: ['anthropic'], defaultColor: '#D4915D' },\n  google: { name: 'google', displayName: 'Google', category: 'ai-provider', keywords: ['gemini', 'bard'], defaultColor: '#4285F4' },\n  gemini: { name: 'gemini', displayName: 'Gemini', category: 'ai-provider', keywords: ['google'], defaultColor: '#4285F4' },\n  deepseek: { name: 'deepseek', displayName: 'DeepSeek', category: 'ai-provider', keywords: ['deep', 'seek'], defaultColor: '#1E88E5' },\n  moonshot: { name: 'moonshot', displayName: 'Moonshot', category: 'ai-provider', keywords: ['kimi', 'moonshot'], defaultColor: '#6366F1' },\n  kimi: { name: 'kimi', displayName: 'Kimi', category: 'ai-provider', keywords: ['moonshot'], defaultColor: '#6366F1' },\n  stepfun: { name: 'stepfun', displayName: 'StepFun', category: 'ai-provider', keywords: ['stepfun', 'step', 'jieyue', '阶跃星辰'], defaultColor: '#005AFF' },\n  zhipu: { name: 'zhipu', displayName: 'Zhipu AI', category: 'ai-provider', keywords: ['chatglm', 'glm'], defaultColor: '#0F62FE' },\n  minimax: { name: 'minimax', displayName: 'MiniMax', category: 'ai-provider', keywords: ['minimax'], defaultColor: '#FF6B6B' },\n  baidu: { name: 'baidu', displayName: 'Baidu', category: 'ai-provider', keywords: ['ernie', 'wenxin'], defaultColor: '#2932E1' },\n  alibaba: { name: 'alibaba', displayName: 'Alibaba', category: 'ai-provider', keywords: ['qwen', 'tongyi'], defaultColor: '#FF6A00' },\n  tencent: { name: 'tencent', displayName: 'Tencent', category: 'ai-provider', keywords: ['hunyuan'], defaultColor: '#00A4FF' },\n  meta: { name: 'meta', displayName: 'Meta', category: 'ai-provider', keywords: ['facebook', 'llama'], defaultColor: '#0081FB' },\n  microsoft: { name: 'microsoft', displayName: 'Microsoft', category: 'ai-provider', keywords: ['copilot', 'azure'], defaultColor: '#00A4EF' },\n  cohere: { name: 'cohere', displayName: 'Cohere', category: 'ai-provider', keywords: ['cohere'], defaultColor: '#39594D' },\n  perplexity: { name: 'perplexity', displayName: 'Perplexity', category: 'ai-provider', keywords: ['perplexity'], defaultColor: '#20808D' },\n  mistral: { name: 'mistral', displayName: 'Mistral', category: 'ai-provider', keywords: ['mistral'], defaultColor: '#FF7000' },\n  huggingface: { name: 'huggingface', displayName: 'Hugging Face', category: 'ai-provider', keywords: ['huggingface', 'hf'], defaultColor: '#FFD21E' },\n\n  // Cloud Platforms\n  aws: { name: 'aws', displayName: 'AWS', category: 'cloud', keywords: ['amazon', 'cloud'], defaultColor: '#FF9900' },\n  azure: { name: 'azure', displayName: 'Azure', category: 'cloud', keywords: ['microsoft', 'cloud'], defaultColor: '#0078D4' },\n  huawei: { name: 'huawei', displayName: 'Huawei', category: 'cloud', keywords: ['huawei', 'cloud'], defaultColor: '#FF0000' },\n  cloudflare: { name: 'cloudflare', displayName: 'Cloudflare', category: 'cloud', keywords: ['cloudflare', 'cdn'], defaultColor: '#F38020' },\n\n  // Dev Tools\n  github: { name: 'github', displayName: 'GitHub', category: 'tool', keywords: ['git', 'version control'], defaultColor: '#181717' },\n  gitlab: { name: 'gitlab', displayName: 'GitLab', category: 'tool', keywords: ['git', 'version control'], defaultColor: '#FC6D26' },\n  docker: { name: 'docker', displayName: 'Docker', category: 'tool', keywords: ['container'], defaultColor: '#2496ED' },\n  kubernetes: { name: 'kubernetes', displayName: 'Kubernetes', category: 'tool', keywords: ['k8s', 'container'], defaultColor: '#326CE5' },\n  vscode: { name: 'vscode', displayName: 'VS Code', category: 'tool', keywords: ['editor', 'ide'], defaultColor: '#007ACC' },\n\n  // Others\n  settings: { name: 'settings', displayName: 'Settings', category: 'other', keywords: ['config', 'preferences'], defaultColor: '#6B7280' },\n  folder: { name: 'folder', displayName: 'Folder', category: 'other', keywords: ['directory'], defaultColor: '#6B7280' },\n  file: { name: 'file', displayName: 'File', category: 'other', keywords: ['document'], defaultColor: '#6B7280' },\n  link: { name: 'link', displayName: 'Link', category: 'other', keywords: ['url', 'hyperlink'], defaultColor: '#6B7280' },\n};\n\nexport function getIconMetadata(name: string): IconMetadata | undefined {\n  return iconMetadata[name.toLowerCase()];\n}\n\nexport function searchIcons(query: string): string[] {\n  const lowerQuery = query.toLowerCase();\n  return Object.values(iconMetadata)\n    .filter(meta =>\n      meta.name.includes(lowerQuery) ||\n      meta.displayName.toLowerCase().includes(lowerQuery) ||\n      meta.keywords.some(k => k.includes(lowerQuery))\n    )\n    .map(meta => meta.name);\n}\n`;\n\nfs.writeFileSync(path.join(OUTPUT_DIR, 'metadata.ts'), metadataContent);\nconsole.log('✓ Generated: src/icons/extracted/metadata.ts');\n\n// 生成 README\nconst readmeContent = `# Extracted Icons\n\nThis directory contains extracted icons from @lobehub/icons-static-svg.\n\n## Statistics\n- Total extracted: ${extracted} icons\n- Not found: ${notFound.length} icons\n\n## Extracted Icons\n${ALL_ICONS.filter(name => !notFound.includes(name)).map(name => `- ${name}`).join('\\n')}\n\n${notFound.length > 0 ? `\\n## Not Found\\n${notFound.map(name => `- ${name}`).join('\\n')}` : ''}\n\n## Usage\n\n\\`\\`\\`typescript\nimport { getIcon, hasIcon, iconList } from './extracted';\n\n// Get icon SVG\nconst svg = getIcon('openai');\n\n// Check if icon exists\nif (hasIcon('openai')) {\n  // ...\n}\n\n// Get all available icons\nconsole.log(iconList);\n\\`\\`\\`\n\n---\nLast updated: ${new Date().toISOString()}\nGenerated by: scripts/extract-icons.js\n`;\n\nfs.writeFileSync(path.join(OUTPUT_DIR, 'README.md'), readmeContent);\nconsole.log('✓ Generated: src/icons/extracted/README.md');\n\nconsole.log('\\n========================================');\nconsole.log('✅ Extraction complete!\\n');\nconsole.log(`   ✓ Extracted: ${extracted} icons`);\nconsole.log(`   ✗ Not found: ${notFound.length} icons`);\nconsole.log(`   📉 Bundle size reduction: ~${Math.round((1 - extracted / 723) * 100)}%`);\nconsole.log('========================================\\n');\n"
  },
  {
    "path": "scripts/filter-icons.js",
    "content": "const fs = require('fs');\nconst path = require('path');\n\nconst ICONS_DIR = path.join(__dirname, '../src/icons/extracted');\n\n// List of \"Famous\" icons to keep\n// Based on common AI providers and tools\nconst KEEP_LIST = [\n    // AI Providers\n    'openai', 'anthropic', 'claude', 'google', 'gemini', 'gemma', 'palm',\n    'microsoft', 'azure', 'copilot', 'meta', 'llama',\n    'alibaba', 'qwen', 'tencent', 'hunyuan', 'baidu', 'wenxin',\n    'bytedance', 'doubao', 'deepseek', 'moonshot', 'kimi', 'stepfun',\n    'zhipu', 'chatglm', 'glm', 'minimax', 'mistral', 'cohere',\n    'perplexity', 'huggingface', 'midjourney', 'stability',\n    'xai', 'grok', 'yi', 'zeroone', 'ollama',\n    'packycode',\n\n    // Cloud/Tools\n    'aws', 'googlecloud', 'huawei', 'cloudflare',\n    'github', 'githubcopilot', 'vercel', 'notion', 'discord',\n    'gitlab', 'docker', 'kubernetes', 'vscode', 'settings', 'folder', 'file', 'link'\n];\n\n// Get all SVG files\nconst files = fs.readdirSync(ICONS_DIR).filter(file => file.endsWith('.svg'));\n\nconsole.log(`Scanning ${files.length} files...`);\n\nlet keptCount = 0;\nlet deletedCount = 0;\nlet renamedCount = 0;\n\n// First pass: Identify files to keep and prefer color versions\nconst fileMap = {}; // name -> { hasColor: bool, hasMono: bool }\n\nfiles.forEach(file => {\n    const isColor = file.endsWith('-color.svg');\n    const baseName = isColor ? file.replace('-color.svg', '') : file.replace('.svg', '');\n\n    if (!fileMap[baseName]) {\n        fileMap[baseName] = { hasColor: false, hasMono: false };\n    }\n\n    if (isColor) {\n        fileMap[baseName].hasColor = true;\n    } else {\n        fileMap[baseName].hasMono = true;\n    }\n});\n\n// Second pass: Process files\nObject.keys(fileMap).forEach(baseName => {\n    const info = fileMap[baseName];\n    const shouldKeep = KEEP_LIST.includes(baseName);\n\n    if (!shouldKeep) {\n        // Delete both versions if not in keep list\n        if (info.hasColor) {\n            fs.unlinkSync(path.join(ICONS_DIR, `${baseName}-color.svg`));\n            deletedCount++;\n        }\n        if (info.hasMono) {\n            fs.unlinkSync(path.join(ICONS_DIR, `${baseName}.svg`));\n            deletedCount++;\n        }\n        return;\n    }\n\n    // If keeping, prefer color\n    if (info.hasColor) {\n        // Rename color version to base version (overwrite mono if exists)\n        const colorPath = path.join(ICONS_DIR, `${baseName}-color.svg`);\n        const targetPath = path.join(ICONS_DIR, `${baseName}.svg`);\n\n        try {\n            // If mono exists, it will be overwritten/replaced\n            fs.renameSync(colorPath, targetPath);\n            renamedCount++;\n            keptCount++;\n        } catch (e) {\n            console.error(`Error renaming ${baseName}:`, e);\n        }\n    } else if (info.hasMono) {\n        // Keep mono if no color version\n        keptCount++;\n    }\n});\n\nconsole.log(`\\nCleanup complete:`);\nconsole.log(`- Kept: ${keptCount}`);\nconsole.log(`- Deleted: ${deletedCount}`);\nconsole.log(`- Renamed (Color -> Standard): ${renamedCount}`);\n\n// Regenerate index and metadata\nrequire('./generate-icon-index.js');\n"
  },
  {
    "path": "scripts/generate-icon-index.js",
    "content": "const fs = require('fs');\nconst path = require('path');\n\nconst ICONS_DIR = path.join(__dirname, '../src/icons/extracted');\nconst INDEX_FILE = path.join(ICONS_DIR, 'index.ts');\nconst METADATA_FILE = path.join(ICONS_DIR, 'metadata.ts');\n\n// Known metadata from previous configuration\nconst KNOWN_METADATA = {\n  openai: { name: 'openai', displayName: 'OpenAI', category: 'ai-provider', keywords: ['gpt', 'chatgpt'], defaultColor: '#00A67E' },\n  anthropic: { name: 'anthropic', displayName: 'Anthropic', category: 'ai-provider', keywords: ['claude'], defaultColor: '#D4915D' },\n  claude: { name: 'claude', displayName: 'Claude', category: 'ai-provider', keywords: ['anthropic'], defaultColor: '#D4915D' },\n  google: { name: 'google', displayName: 'Google', category: 'ai-provider', keywords: ['gemini', 'bard'], defaultColor: '#4285F4' },\n  gemini: { name: 'gemini', displayName: 'Gemini', category: 'ai-provider', keywords: ['google'], defaultColor: '#4285F4' },\n  deepseek: { name: 'deepseek', displayName: 'DeepSeek', category: 'ai-provider', keywords: ['deep', 'seek'], defaultColor: '#1E88E5' },\n  moonshot: { name: 'moonshot', displayName: 'Moonshot', category: 'ai-provider', keywords: ['kimi', 'moonshot'], defaultColor: '#6366F1' },\n  kimi: { name: 'kimi', displayName: 'Kimi', category: 'ai-provider', keywords: ['moonshot'], defaultColor: '#6366F1' },\n  stepfun: { name: 'stepfun', displayName: 'StepFun', category: 'ai-provider', keywords: ['stepfun', 'step', 'jieyue', '阶跃星辰'], defaultColor: '#005AFF' },\n  zhipu: { name: 'zhipu', displayName: 'Zhipu AI', category: 'ai-provider', keywords: ['chatglm', 'glm'], defaultColor: '#0F62FE' },\n  minimax: { name: 'minimax', displayName: 'MiniMax', category: 'ai-provider', keywords: ['minimax'], defaultColor: '#FF6B6B' },\n  baidu: { name: 'baidu', displayName: 'Baidu', category: 'ai-provider', keywords: ['ernie', 'wenxin'], defaultColor: '#2932E1' },\n  alibaba: { name: 'alibaba', displayName: 'Alibaba', category: 'ai-provider', keywords: ['qwen', 'tongyi'], defaultColor: '#FF6A00' },\n  tencent: { name: 'tencent', displayName: 'Tencent', category: 'ai-provider', keywords: ['hunyuan'], defaultColor: '#00A4FF' },\n  meta: { name: 'meta', displayName: 'Meta', category: 'ai-provider', keywords: ['facebook', 'llama'], defaultColor: '#0081FB' },\n  microsoft: { name: 'microsoft', displayName: 'Microsoft', category: 'ai-provider', keywords: ['copilot', 'azure'], defaultColor: '#00A4EF' },\n  cohere: { name: 'cohere', displayName: 'Cohere', category: 'ai-provider', keywords: ['cohere'], defaultColor: '#39594D' },\n  perplexity: { name: 'perplexity', displayName: 'Perplexity', category: 'ai-provider', keywords: ['perplexity'], defaultColor: '#20808D' },\n  packycode: { name: 'packycode', displayName: 'PackyCode', category: 'ai-provider', keywords: ['packycode', 'packy', 'packyapi'], defaultColor: 'currentColor' },\n  mistral: { name: 'mistral', displayName: 'Mistral', category: 'ai-provider', keywords: ['mistral'], defaultColor: '#FF7000' },\n  huggingface: { name: 'huggingface', displayName: 'Hugging Face', category: 'ai-provider', keywords: ['huggingface', 'hf'], defaultColor: '#FFD21E' },\n  aws: { name: 'aws', displayName: 'AWS', category: 'cloud', keywords: ['amazon', 'cloud'], defaultColor: '#FF9900' },\n  azure: { name: 'azure', displayName: 'Azure', category: 'cloud', keywords: ['microsoft', 'cloud'], defaultColor: '#0078D4' },\n  huawei: { name: 'huawei', displayName: 'Huawei', category: 'cloud', keywords: ['huawei', 'cloud'], defaultColor: '#FF0000' },\n  cloudflare: { name: 'cloudflare', displayName: 'Cloudflare', category: 'cloud', keywords: ['cloudflare', 'cdn'], defaultColor: '#F38020' },\n  github: { name: 'github', displayName: 'GitHub', category: 'tool', keywords: ['git', 'version control'], defaultColor: '#181717' },\n  gitlab: { name: 'gitlab', displayName: 'GitLab', category: 'tool', keywords: ['git', 'version control'], defaultColor: '#FC6D26' },\n  docker: { name: 'docker', displayName: 'Docker', category: 'tool', keywords: ['container'], defaultColor: '#2496ED' },\n  kubernetes: { name: 'kubernetes', displayName: 'Kubernetes', category: 'tool', keywords: ['k8s', 'container'], defaultColor: '#326CE5' },\n  vscode: { name: 'vscode', displayName: 'VS Code', category: 'tool', keywords: ['editor', 'ide'], defaultColor: '#007ACC' },\n  settings: { name: 'settings', displayName: 'Settings', category: 'other', keywords: ['config', 'preferences'], defaultColor: '#6B7280' },\n  folder: { name: 'folder', displayName: 'Folder', category: 'other', keywords: ['directory'], defaultColor: '#6B7280' },\n  file: { name: 'file', displayName: 'File', category: 'other', keywords: ['document'], defaultColor: '#6B7280' },\n  link: { name: 'link', displayName: 'Link', category: 'other', keywords: ['url', 'hyperlink'], defaultColor: '#6B7280' },\n};\n\n// Get all SVG files\nconst files = fs.readdirSync(ICONS_DIR).filter(file => file.endsWith('.svg'));\n\nconsole.log(`Found ${files.length} SVG files.`);\n\n// Generate index.ts\nconst indexContent = `// Auto-generated icon index\n// Do not edit manually\n\nexport const icons: Record<string, string> = {\n${files.map(file => {\n  const name = path.basename(file, '.svg');\n  const svg = fs.readFileSync(path.join(ICONS_DIR, file), 'utf-8');\n  const escaped = svg.replace(/`/g, '\\\\`').replace(/\\$/g, '\\\\$');\n  return `  '${name}': \\`${escaped}\\`,`;\n}).join('\\n')}\n};\n\nexport const iconList = Object.keys(icons);\n\nexport function getIcon(name: string): string {\n  return icons[name.toLowerCase()] || '';\n}\n\nexport function hasIcon(name: string): boolean {\n  return name.toLowerCase() in icons;\n}\n`;\n\nfs.writeFileSync(INDEX_FILE, indexContent);\nconsole.log(`Generated ${INDEX_FILE}`);\n\n// Generate metadata.ts\nconst metadataEntries = files.map(file => {\n  const name = path.basename(file, '.svg').toLowerCase();\n  const known = KNOWN_METADATA[name];\n  \n  if (known) {\n    return `  ${name}: ${JSON.stringify(known)},`;\n  }\n  \n  // Default metadata for unknown icons\n  return `  '${name}': { name: '${name}', displayName: '${name}', category: 'other', keywords: [], defaultColor: 'currentColor' },`;\n});\n\nconst metadataContent = `// Icon metadata for search and categorization\nimport { IconMetadata } from '@/types/icon';\n\nexport const iconMetadata: Record<string, IconMetadata> = {\n${metadataEntries.join('\\n')}\n};\n\nexport function getIconMetadata(name: string): IconMetadata | undefined {\n  return iconMetadata[name.toLowerCase()];\n}\n\nexport function searchIcons(query: string): string[] {\n  const lowerQuery = query.toLowerCase();\n  return Object.values(iconMetadata)\n    .filter(meta =>\n      meta.name.includes(lowerQuery) ||\n      meta.displayName.toLowerCase().includes(lowerQuery) ||\n      meta.keywords.some(k => k.includes(lowerQuery))\n    )\n    .map(meta => meta.name);\n}\n`;\n\nfs.writeFileSync(METADATA_FILE, metadataContent);\nconsole.log(`Generated ${METADATA_FILE}`);\n"
  },
  {
    "path": "session-manager.md",
    "content": "# 会话管理（Session Manager）需求文档（PRD / Markdown）\n\n> 目标：对 **Codex / Claude Code** 的本地会话记录进行可视化管理，并提供“一键复制 / 一键终端恢复”能力。\n> 范围：**v1 仅 macOS**，但必须预留多平台扩展入口。\n\n---\n\n## 1. 背景与问题\n\n开发者同时使用 Codex CLI、Claude Code 时，常见痛点：\n- 会话记录落在本地不同位置，**难以发现/检索**\n- 找到会话后，恢复命令需要记忆或翻历史，**恢复成本高**\n- 恢复时经常忘了当时的工作目录，导致命令在错误目录运行\n- 希望在常用终端（macOS Terminal、kitty 等）中直接恢复，提高效率\n\n---\n\n## 2. 目标与非目标\n\n### 2.1 Goals（v1 必达）\n1. 扫描并展示本机所有 Codex / Claude Code 会话：列表 + 详情（会话内容）\n2. 支持恢复会话：\n   - 复制恢复命令（按钮）\n   - 复制会话目录（按钮，若能获取/推断）\n   - 可选：直接在终端执行恢复（macOS Terminal、kitty；可扩展）\n3. 仅 macOS 支持，但代码结构需支持未来扩展 Windows/Linux\n\n### 2.2 Non-Goals（v1 不做）\n- 不新增/依赖云端 API；默认不上传任何内容\n- 不承诺解析所有 provider 的全部内部格式（尽量兼容、可配置、可降级）\n- 不做复杂的团队协作/分享/同步（后续版本再考虑）\n\n---\n\n## 3. 用户画像与使用场景\n\n### 3.1 典型用户\n- 高频使用多个 AI 编程工具的工程师/技术负责人/PM\n- 多项目、多分支并行，频繁“中断—恢复—继续推进”\n\n### 3.2 核心场景（Top）\n1. **找回会话**：我记得一个会话讨论过某段逻辑 → 搜索关键词 → 打开详情\n2. **快速恢复**：我想继续昨天的会话 → 复制恢复命令 / 一键在终端恢复\n3. **回到正确目录**：恢复前先复制目录或自动 cd 到目录\n\n---\n\n## 4. 产品形态与信息架构\n\n### 4.1 信息架构\n- Session Manager\n  - 会话列表（List）\n  - 会话详情（Detail）\n  - 设置（Settings）\n    - Provider 配置（路径/启用禁用）\n    - 终端集成（默认终端、权限提示、降级策略）\n    - 索引与隐私选项（是否缓存、缓存大小、敏感信息遮罩）\n\n---\n\n## 5. 功能需求（Functional Requirements）\n\n### 5.1 会话发现与索引（Discovery & Indexing）\n**FR-1** 扫描本地会话数据源，生成统一的 Session 列表\n- 支持 Provider：Codex、Claude Code（可扩展）\n- 支持全量扫描 + 增量更新\n- 支持缺失/异常文件的容错（不中断 UI）\n\n**FR-2** 本地索引（Cache/DB）\n- 用于加速列表加载与搜索\n- 索引字段至少包含：sessionId、provider、lastActiveAt、projectDir(可空)、summary(可空)、filePath(可空)\n\n**FR-3** 数据源路径探测（可配置 + 多候选）\n- 默认使用常见路径；允许用户在 Settings 覆盖\n- 若无法探测到 provider 安装/数据目录：在 UI 显示未启用/不可用状态，但不报错崩溃\n\n---\n\n### 5.2 会话列表（List）\n**FR-4** 列表展示字段（建议最小集）\n- Provider（Codex / Claude）\n- Session 标识（id/short id）\n- 最近活跃时间（lastActiveAt）\n- 目录（projectDir，若未知显示 “Unknown”）\n- 摘要（summary：最后一条/首条截断或规则生成）\n\n**FR-5** 列表交互\n- 搜索（跨会话，关键词匹配 transcript/summary/目录）\n- 过滤：Provider、是否有目录、时间范围\n- 排序：最近活跃（默认）、最早、按目录\n\n**FR-6** 空态/异常态\n- 未发现任何会话：给出“如何启用/设置路径”的指引\n- 发现会话但无法解析内容：列表仍可显示基本信息，并在详情页提示“解析失败”\n\n---\n\n### 5.3 会话详情（Detail）\n**FR-7** 会话内容展示\n- 时间线展示消息（role：user/assistant/tool 等）\n- 支持在当前会话内搜索 + 高亮\n- 展示元信息：\n  - provider、sessionId、创建/最近活跃时间\n  - projectDir（可空）\n  - 原始文件路径（可选显示，便于 debug）\n\n**FR-8** 性能策略\n- 默认按需加载（打开详情才加载全文）\n- 对超长 transcript 支持分页/虚拟列表（防止卡顿）\n\n---\n\n### 5.4 恢复能力（Resume / Restore）\n#### 5.4.1 复制恢复命令（必做）\n**FR-9** “复制恢复命令”按钮\n- 根据 provider 生成恢复命令（模板可配置）\n- 点击后写入剪贴板，并 toast 提示成功\n\n> 说明：不同版本 CLI 命令可能略有差异，建议将命令模板做成可配置项（Settings），默认提供推荐模板。\n\n#### 5.4.2 复制会话目录（尽量做）\n**FR-10** “复制会话目录”按钮\n- 当 projectDir 可得时启用；不可得时置灰，并提示原因（无法推断目录）\n- 复制内容为可直接 `cd` 的绝对路径（或原样）\n\n#### 5.4.3 一键终端恢复（可选但强烈建议）\n**FR-11** “在终端恢复”按钮（或下拉菜单）\n- 默认目标：macOS Terminal\n- 支持 kitty（v1 要求）\n- 执行策略：\n  - `cd \"<projectDir>\" && <resumeCommand>`（若 projectDir 为空则仅执行 resumeCommand）\n- 失败降级：\n  - 无权限/终端不可用 → 自动降级为“仅复制命令”，并提示用户如何修复（例如开启 Automation 权限、kitty remote control）\n\n**FR-12** 终端目标选择与记忆\n- 下拉选择：Terminal / kitty /（预留 iTerm2）/ 仅复制\n- 记住上次选择作为默认\n\n---\n\n## 6. 平台与扩展性设计（macOS v1 + Future-proof）\n\n### 6.1 Provider Adapter 抽象（必须）\n统一接口（示例）：\n- `detect(): boolean`\n- `scanSessions(): SessionMeta[]`\n- `loadTranscript(sessionId): Message[]`\n- `getResumeCommand(sessionId): string`\n- `getProjectDir(sessionId): string | null`\n\n### 6.2 Terminal Launcher 抽象（必须）\n- `launch(command: string, cwd?: string, targetTerminal: TerminalKind): Result`\n- macOS v1 实现：TerminalLauncherMac\n- Future：TerminalLauncherWindows / TerminalLauncherLinux\n\n### 6.3 Path Resolver（必须）\n- `resolveProviderDataPaths(providerId): string[]`\n- v1 返回 macOS 默认候选；允许 Settings 覆盖\n\n---\n\n## 7. 隐私与安全（Privacy & Security）\n\n**默认原则：全本地、只读、不上传。**\n- transcript 默认不出网\n- 本地索引默认仅存必要字段（可选：是否缓存全文内容）\n- 提供“敏感信息遮罩”（可选）：\n  - 简单正则：token/key/password 等\n- 提示用户：会话内容可能包含敏感信息，导出/复制时注意\n\n---\n\n## 8. 非功能需求（Non-Functional Requirements）\n\n### 8.1 性能\n- 首次打开：列表可在 1s 内展示（允许先展示缓存，再后台增量刷新）\n- 搜索：在 1k 会话量级可用（建立索引或增量缓存）\n- 详情页：打开后 300ms 内渲染骨架屏，内容流式/分段加载\n\n### 8.2 稳定性\n- 任一 provider 数据源损坏不影响整体（隔离失败）\n- 扫描过程可中断/可重试\n\n### 8.3 可观测性（可选）\n- 本地日志：扫描耗时、解析失败原因、终端启动失败原因（便于 debug）\n\n---\n\n## 9. 关键数据结构（建议）\n\n### 9.1 SessionMeta\n- `providerId: \"codex\" | \"claude\" | string`\n- `sessionId: string`\n- `title?: string`\n- `summary?: string`\n- `projectDir?: string | null`\n- `createdAt?: number`\n- `lastActiveAt?: number`\n- `sourcePath?: string`\n\n### 9.2 Message\n- `role: \"user\" | \"assistant\" | \"tool\" | \"system\" | string`\n- `content: string`\n- `ts?: number`\n- `raw?: any`（保留原始字段，便于兼容未来格式）\n\n---\n\n## 10. 交互流程（UX Flows）\n\n### 10.1 Flow A：搜索并查看\n1) 打开 Session Manager → 看到列表\n2) 输入关键词搜索 → 命中会话\n3) 点击会话 → 进入详情 → 浏览内容 / 在会话内搜索\n\n### 10.2 Flow B：复制恢复命令\n1) 列表或详情页点击“复制恢复命令”\n2) toast 成功 → 用户粘贴到终端执行\n\n### 10.3 Flow C：一键终端恢复\n1) 详情页点击“在终端恢复”（默认 Terminal）\n2) 系统打开终端新窗口/新 tab\n3) 自动执行：`cd projectDir && resumeCommand`\n4) 失败 → toast 提示，并提供“复制命令”降级路径\n\n---\n\n## 11. 边界情况与降级策略\n\n- 无法获取 projectDir：仍可恢复（只执行 resume），目录按钮置灰\n- 无法解析 transcript：列表仍显示，详情提示“无法解析”，可提供“打开原始文件路径”\n- CLI 命令模板不匹配：允许 Settings 自定义模板；默认模板可更新\n- 终端权限问题（Automation）：提示用户在系统设置中开启对应权限，并允许降级为复制命令\n- kitty 未开启 remote control：提示如何配置，降级为复制命令\n\n---\n\n## 12. 里程碑与交付（建议）\n\n### M1（核心可用）\n- Provider 扫描：Codex / Claude\n- 列表 + 详情（可读）\n- 复制恢复命令\n- 复制目录（若可得）\n\n### M2（效率提升）\n- 跨会话搜索、过滤/排序\n- 增量索引与文件监听（可选）\n- “在 macOS Terminal 恢复”\n\n### M3（终端覆盖与可扩展）\n- “在 kitty 恢复”\n- 终端目标下拉与记忆\n- 插件化接口/扩展点文档\n\n---\n\n## 13. 后续功能候选（Backlog / Ideas）\n\n- 收藏/Pin 会话\n- 会话标签（项目/主题/状态）\n- 会话摘要（本地生成）\n- Fork 会话继续（避免污染原会话）\n- 导出 Markdown/JSONL\n- 按项目聚合（Repo 视图）\n- 会话清理/归档（磁盘管理）\n\n---\n"
  },
  {
    "path": "src/App.tsx",
    "content": "import { useEffect, useMemo, useState, useRef } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { motion, AnimatePresence } from \"framer-motion\";\nimport { toast } from \"sonner\";\nimport { invoke } from \"@tauri-apps/api/core\";\nimport { listen } from \"@tauri-apps/api/event\";\nimport { useQueryClient } from \"@tanstack/react-query\";\nimport {\n  Plus,\n  Settings,\n  ArrowLeft,\n  Book,\n  Wrench,\n  RefreshCw,\n  History,\n  BarChart2,\n  Download,\n  FolderArchive,\n  Search,\n  FolderOpen,\n  KeyRound,\n  Shield,\n  Cpu,\n} from \"lucide-react\";\nimport type { Provider, VisibleApps } from \"@/types\";\nimport type { EnvConflict } from \"@/types/env\";\nimport { useProvidersQuery, useSettingsQuery } from \"@/lib/query\";\nimport {\n  providersApi,\n  settingsApi,\n  type AppId,\n  type ProviderSwitchEvent,\n} from \"@/lib/api\";\nimport { checkAllEnvConflicts, checkEnvConflicts } from \"@/lib/api/env\";\nimport { useProviderActions } from \"@/hooks/useProviderActions\";\nimport { openclawKeys, useOpenClawHealth } from \"@/hooks/useOpenClaw\";\nimport { useProxyStatus } from \"@/hooks/useProxyStatus\";\nimport { useAutoCompact } from \"@/hooks/useAutoCompact\";\nimport { useLastValidValue } from \"@/hooks/useLastValidValue\";\nimport { extractErrorMessage } from \"@/utils/errorUtils\";\nimport { isTextEditableTarget } from \"@/utils/domUtils\";\nimport { cn } from \"@/lib/utils\";\nimport { isWindows, isLinux } from \"@/lib/platform\";\nimport { AppSwitcher } from \"@/components/AppSwitcher\";\nimport { ProviderList } from \"@/components/providers/ProviderList\";\nimport { AddProviderDialog } from \"@/components/providers/AddProviderDialog\";\nimport { EditProviderDialog } from \"@/components/providers/EditProviderDialog\";\nimport { ConfirmDialog } from \"@/components/ConfirmDialog\";\nimport { SettingsPage } from \"@/components/settings/SettingsPage\";\nimport { UpdateBadge } from \"@/components/UpdateBadge\";\nimport { EnvWarningBanner } from \"@/components/env/EnvWarningBanner\";\nimport { ProxyToggle } from \"@/components/proxy/ProxyToggle\";\nimport { FailoverToggle } from \"@/components/proxy/FailoverToggle\";\nimport UsageScriptModal from \"@/components/UsageScriptModal\";\nimport UnifiedMcpPanel from \"@/components/mcp/UnifiedMcpPanel\";\nimport PromptPanel from \"@/components/prompts/PromptPanel\";\nimport { SkillsPage } from \"@/components/skills/SkillsPage\";\nimport UnifiedSkillsPanel from \"@/components/skills/UnifiedSkillsPanel\";\nimport { DeepLinkImportDialog } from \"@/components/DeepLinkImportDialog\";\nimport { AgentsPanel } from \"@/components/agents/AgentsPanel\";\nimport { UniversalProviderPanel } from \"@/components/universal\";\nimport { McpIcon } from \"@/components/BrandIcons\";\nimport { Button } from \"@/components/ui/button\";\nimport { SessionManagerPage } from \"@/components/sessions/SessionManagerPage\";\nimport {\n  useDisableCurrentOmo,\n  useDisableCurrentOmoSlim,\n} from \"@/lib/query/omo\";\nimport WorkspaceFilesPanel from \"@/components/workspace/WorkspaceFilesPanel\";\nimport EnvPanel from \"@/components/openclaw/EnvPanel\";\nimport ToolsPanel from \"@/components/openclaw/ToolsPanel\";\nimport AgentsDefaultsPanel from \"@/components/openclaw/AgentsDefaultsPanel\";\nimport OpenClawHealthBanner from \"@/components/openclaw/OpenClawHealthBanner\";\n\ntype View =\n  | \"providers\"\n  | \"settings\"\n  | \"prompts\"\n  | \"skills\"\n  | \"skillsDiscovery\"\n  | \"mcp\"\n  | \"agents\"\n  | \"universal\"\n  | \"sessions\"\n  | \"workspace\"\n  | \"openclawEnv\"\n  | \"openclawTools\"\n  | \"openclawAgents\";\n\ninterface WebDavSyncStatusUpdatedPayload {\n  source?: string;\n  status?: string;\n  error?: string;\n}\n\nconst DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px\nconst HEADER_HEIGHT = 64; // px\nconst CONTENT_TOP_OFFSET = DRAG_BAR_HEIGHT + HEADER_HEIGHT;\n\nconst STORAGE_KEY = \"cc-switch-last-app\";\nconst VALID_APPS: AppId[] = [\n  \"claude\",\n  \"codex\",\n  \"gemini\",\n  \"opencode\",\n  \"openclaw\",\n];\n\nconst getInitialApp = (): AppId => {\n  const saved = localStorage.getItem(STORAGE_KEY) as AppId | null;\n  if (saved && VALID_APPS.includes(saved)) {\n    return saved;\n  }\n  return \"claude\";\n};\n\nconst VIEW_STORAGE_KEY = \"cc-switch-last-view\";\nconst VALID_VIEWS: View[] = [\n  \"providers\",\n  \"settings\",\n  \"prompts\",\n  \"skills\",\n  \"skillsDiscovery\",\n  \"mcp\",\n  \"agents\",\n  \"universal\",\n  \"sessions\",\n  \"workspace\",\n  \"openclawEnv\",\n  \"openclawTools\",\n  \"openclawAgents\",\n];\n\nconst getInitialView = (): View => {\n  const saved = localStorage.getItem(VIEW_STORAGE_KEY) as View | null;\n  if (saved && VALID_VIEWS.includes(saved)) {\n    return saved;\n  }\n  return \"providers\";\n};\n\nfunction App() {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n\n  const [activeApp, setActiveApp] = useState<AppId>(getInitialApp);\n  const [currentView, setCurrentView] = useState<View>(getInitialView);\n  const [settingsDefaultTab, setSettingsDefaultTab] = useState(\"general\");\n  const [isAddOpen, setIsAddOpen] = useState(false);\n\n  useEffect(() => {\n    localStorage.setItem(VIEW_STORAGE_KEY, currentView);\n  }, [currentView]);\n\n  const { data: settingsData } = useSettingsQuery();\n  const visibleApps: VisibleApps = settingsData?.visibleApps ?? {\n    claude: true,\n    codex: true,\n    gemini: true,\n    opencode: true,\n    openclaw: true,\n  };\n\n  const getFirstVisibleApp = (): AppId => {\n    if (visibleApps.claude) return \"claude\";\n    if (visibleApps.codex) return \"codex\";\n    if (visibleApps.gemini) return \"gemini\";\n    if (visibleApps.opencode) return \"opencode\";\n    if (visibleApps.openclaw) return \"openclaw\";\n    return \"claude\"; // fallback\n  };\n\n  useEffect(() => {\n    if (!visibleApps[activeApp]) {\n      setActiveApp(getFirstVisibleApp());\n    }\n  }, [visibleApps, activeApp]);\n\n  // Fallback from sessions view when switching to an app without session support\n  useEffect(() => {\n    if (\n      currentView === \"sessions\" &&\n      activeApp !== \"claude\" &&\n      activeApp !== \"codex\" &&\n      activeApp !== \"opencode\" &&\n      activeApp !== \"openclaw\" &&\n      activeApp !== \"gemini\"\n    ) {\n      setCurrentView(\"providers\");\n    }\n  }, [activeApp, currentView]);\n\n  const [editingProvider, setEditingProvider] = useState<Provider | null>(null);\n  const [usageProvider, setUsageProvider] = useState<Provider | null>(null);\n  const [confirmAction, setConfirmAction] = useState<{\n    provider: Provider;\n    action: \"remove\" | \"delete\";\n  } | null>(null);\n  const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]);\n  const [showEnvBanner, setShowEnvBanner] = useState(false);\n\n  const effectiveEditingProvider = useLastValidValue(editingProvider);\n  const effectiveUsageProvider = useLastValidValue(usageProvider);\n\n  const toolbarRef = useRef<HTMLDivElement>(null);\n  const isToolbarCompact = useAutoCompact(toolbarRef);\n\n  const promptPanelRef = useRef<any>(null);\n  const mcpPanelRef = useRef<any>(null);\n  const skillsPageRef = useRef<any>(null);\n  const unifiedSkillsPanelRef = useRef<any>(null);\n  const addActionButtonClass =\n    \"bg-orange-500 hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-600 text-white shadow-lg shadow-orange-500/30 dark:shadow-orange-500/40 rounded-full w-8 h-8\";\n\n  const {\n    isRunning: isProxyRunning,\n    takeoverStatus,\n    status: proxyStatus,\n  } = useProxyStatus();\n  const isCurrentAppTakeoverActive = takeoverStatus?.[activeApp] || false;\n  const activeProviderId = useMemo(() => {\n    const target = proxyStatus?.active_targets?.find(\n      (t) => t.app_type === activeApp,\n    );\n    return target?.provider_id;\n  }, [proxyStatus?.active_targets, activeApp]);\n\n  const { data, isLoading, refetch } = useProvidersQuery(activeApp, {\n    isProxyRunning,\n  });\n  const providers = useMemo(() => data?.providers ?? {}, [data]);\n  const currentProviderId = data?.currentProviderId ?? \"\";\n  const isOpenClawView =\n    activeApp === \"openclaw\" &&\n    (currentView === \"providers\" ||\n      currentView === \"workspace\" ||\n      currentView === \"sessions\" ||\n      currentView === \"openclawEnv\" ||\n      currentView === \"openclawTools\" ||\n      currentView === \"openclawAgents\");\n  const { data: openclawHealthWarnings = [] } =\n    useOpenClawHealth(isOpenClawView);\n  const hasSkillsSupport = true;\n  const hasSessionSupport =\n    activeApp === \"claude\" ||\n    activeApp === \"codex\" ||\n    activeApp === \"opencode\" ||\n    activeApp === \"openclaw\" ||\n    activeApp === \"gemini\";\n\n  const {\n    addProvider,\n    updateProvider,\n    switchProvider,\n    deleteProvider,\n    saveUsageScript,\n    setAsDefaultModel,\n  } = useProviderActions(activeApp);\n\n  const disableOmoMutation = useDisableCurrentOmo();\n  const handleDisableOmo = () => {\n    disableOmoMutation.mutate(undefined, {\n      onSuccess: () => {\n        toast.success(t(\"omo.disabled\", { defaultValue: \"OMO 已停用\" }));\n      },\n      onError: (error: Error) => {\n        toast.error(\n          t(\"omo.disableFailed\", {\n            defaultValue: \"停用 OMO 失败: {{error}}\",\n            error: extractErrorMessage(error),\n          }),\n        );\n      },\n    });\n  };\n\n  const disableOmoSlimMutation = useDisableCurrentOmoSlim();\n  const handleDisableOmoSlim = () => {\n    disableOmoSlimMutation.mutate(undefined, {\n      onSuccess: () => {\n        toast.success(t(\"omo.disabled\", { defaultValue: \"OMO 已停用\" }));\n      },\n      onError: (error: Error) => {\n        toast.error(\n          t(\"omo.disableFailed\", {\n            defaultValue: \"停用 OMO 失败: {{error}}\",\n            error: extractErrorMessage(error),\n          }),\n        );\n      },\n    });\n  };\n\n  useEffect(() => {\n    let unsubscribe: (() => void) | undefined;\n\n    const setupListener = async () => {\n      try {\n        unsubscribe = await providersApi.onSwitched(\n          async (event: ProviderSwitchEvent) => {\n            if (event.appType === activeApp) {\n              await refetch();\n            }\n          },\n        );\n      } catch (error) {\n        console.error(\"[App] Failed to subscribe provider switch event\", error);\n      }\n    };\n\n    setupListener();\n    return () => {\n      unsubscribe?.();\n    };\n  }, [activeApp, refetch]);\n\n  useEffect(() => {\n    let unsubscribe: (() => void) | undefined;\n\n    const setupListener = async () => {\n      try {\n        const { listen } = await import(\"@tauri-apps/api/event\");\n        unsubscribe = await listen(\"universal-provider-synced\", async () => {\n          await queryClient.invalidateQueries({ queryKey: [\"providers\"] });\n          try {\n            await providersApi.updateTrayMenu();\n          } catch (error) {\n            console.error(\"[App] Failed to update tray menu\", error);\n          }\n        });\n      } catch (error) {\n        console.error(\n          \"[App] Failed to subscribe universal-provider-synced event\",\n          error,\n        );\n      }\n    };\n\n    setupListener();\n    return () => {\n      unsubscribe?.();\n    };\n  }, [queryClient]);\n\n  useEffect(() => {\n    let unsubscribe: (() => void) | undefined;\n    let active = true;\n\n    const setupListener = async () => {\n      try {\n        const off = await listen(\n          \"webdav-sync-status-updated\",\n          async (event) => {\n            const payload = (event.payload ??\n              {}) as WebDavSyncStatusUpdatedPayload;\n            await queryClient.invalidateQueries({ queryKey: [\"settings\"] });\n\n            if (payload.source !== \"auto\" || payload.status !== \"error\") {\n              return;\n            }\n\n            toast.error(\n              t(\"settings.webdavSync.autoSyncFailedToast\", {\n                error: payload.error || t(\"common.unknown\"),\n              }),\n            );\n          },\n        );\n        if (!active) {\n          off();\n          return;\n        }\n        unsubscribe = off;\n      } catch (error) {\n        console.error(\n          \"[App] Failed to subscribe webdav-sync-status-updated event\",\n          error,\n        );\n      }\n    };\n\n    void setupListener();\n    return () => {\n      active = false;\n      unsubscribe?.();\n    };\n  }, [queryClient, t]);\n\n  useEffect(() => {\n    const checkEnvOnStartup = async () => {\n      try {\n        const allConflicts = await checkAllEnvConflicts();\n        const flatConflicts = Object.values(allConflicts).flat();\n\n        if (flatConflicts.length > 0) {\n          setEnvConflicts(flatConflicts);\n          const dismissed = sessionStorage.getItem(\"env_banner_dismissed\");\n          if (!dismissed) {\n            setShowEnvBanner(true);\n          }\n        }\n      } catch (error) {\n        console.error(\n          \"[App] Failed to check environment conflicts on startup:\",\n          error,\n        );\n      }\n    };\n\n    checkEnvOnStartup();\n  }, []);\n\n  useEffect(() => {\n    const checkMigration = async () => {\n      try {\n        const migrated = await invoke<boolean>(\"get_migration_result\");\n        if (migrated) {\n          toast.success(\n            t(\"migration.success\", { defaultValue: \"配置迁移成功\" }),\n            { closeButton: true },\n          );\n        }\n      } catch (error) {\n        console.error(\"[App] Failed to check migration result:\", error);\n      }\n    };\n\n    checkMigration();\n  }, [t]);\n\n  useEffect(() => {\n    const checkSkillsMigration = async () => {\n      try {\n        const result = await invoke<{ count: number; error?: string } | null>(\n          \"get_skills_migration_result\",\n        );\n        if (result?.error) {\n          toast.error(t(\"migration.skillsFailed\"), {\n            description: t(\"migration.skillsFailedDescription\"),\n            closeButton: true,\n          });\n          console.error(\"[App] Skills SSOT migration failed:\", result.error);\n          return;\n        }\n        if (result && result.count > 0) {\n          toast.success(t(\"migration.skillsSuccess\", { count: result.count }), {\n            closeButton: true,\n          });\n          await queryClient.invalidateQueries({ queryKey: [\"skills\"] });\n        }\n      } catch (error) {\n        console.error(\"[App] Failed to check skills migration result:\", error);\n      }\n    };\n\n    checkSkillsMigration();\n  }, [t, queryClient]);\n\n  useEffect(() => {\n    const checkEnvOnSwitch = async () => {\n      try {\n        const conflicts = await checkEnvConflicts(activeApp);\n\n        if (conflicts.length > 0) {\n          setEnvConflicts((prev) => {\n            const existingKeys = new Set(\n              prev.map((c) => `${c.varName}:${c.sourcePath}`),\n            );\n            const newConflicts = conflicts.filter(\n              (c) => !existingKeys.has(`${c.varName}:${c.sourcePath}`),\n            );\n            return [...prev, ...newConflicts];\n          });\n          const dismissed = sessionStorage.getItem(\"env_banner_dismissed\");\n          if (!dismissed) {\n            setShowEnvBanner(true);\n          }\n        }\n      } catch (error) {\n        console.error(\n          \"[App] Failed to check environment conflicts on app switch:\",\n          error,\n        );\n      }\n    };\n\n    checkEnvOnSwitch();\n  }, [activeApp]);\n\n  const currentViewRef = useRef(currentView);\n\n  useEffect(() => {\n    currentViewRef.current = currentView;\n  }, [currentView]);\n\n  useEffect(() => {\n    const handleKeyDown = (event: KeyboardEvent) => {\n      if (event.key === \",\" && (event.metaKey || event.ctrlKey)) {\n        event.preventDefault();\n        setCurrentView(\"settings\");\n        return;\n      }\n\n      if (event.key !== \"Escape\" || event.defaultPrevented) return;\n\n      if (document.body.style.overflow === \"hidden\") return;\n\n      const view = currentViewRef.current;\n      if (view === \"providers\") return;\n\n      if (isTextEditableTarget(event.target)) return;\n\n      event.preventDefault();\n      setCurrentView(view === \"skillsDiscovery\" ? \"skills\" : \"providers\");\n    };\n\n    window.addEventListener(\"keydown\", handleKeyDown);\n    return () => {\n      window.removeEventListener(\"keydown\", handleKeyDown);\n    };\n  }, []);\n\n  const handleOpenWebsite = async (url: string) => {\n    try {\n      await settingsApi.openExternal(url);\n    } catch (error) {\n      const detail =\n        extractErrorMessage(error) ||\n        t(\"notifications.openLinkFailed\", {\n          defaultValue: \"链接打开失败\",\n        });\n      toast.error(detail);\n    }\n  };\n\n  const handleEditProvider = async (provider: Provider) => {\n    await updateProvider(provider);\n    setEditingProvider(null);\n  };\n\n  const handleConfirmAction = async () => {\n    if (!confirmAction) return;\n    const { provider, action } = confirmAction;\n\n    if (action === \"remove\") {\n      // Remove from live config only (for additive mode apps like OpenCode/OpenClaw)\n      // Does NOT delete from database - provider remains in the list\n      await providersApi.removeFromLiveConfig(provider.id, activeApp);\n      // Invalidate queries to refresh the isInConfig state\n      if (activeApp === \"opencode\") {\n        await queryClient.invalidateQueries({\n          queryKey: [\"opencodeLiveProviderIds\"],\n        });\n      } else if (activeApp === \"openclaw\") {\n        await queryClient.invalidateQueries({\n          queryKey: openclawKeys.liveProviderIds,\n        });\n        await queryClient.invalidateQueries({\n          queryKey: openclawKeys.health,\n        });\n      }\n      toast.success(\n        t(\"notifications.removeFromConfigSuccess\", {\n          defaultValue: \"已从配置移除\",\n        }),\n        { closeButton: true },\n      );\n    } else {\n      await deleteProvider(provider.id);\n    }\n    setConfirmAction(null);\n  };\n\n  const generateUniqueOpencodeKey = (\n    originalKey: string,\n    existingKeys: string[],\n  ): string => {\n    const baseKey = `${originalKey}-copy`;\n\n    if (!existingKeys.includes(baseKey)) {\n      return baseKey;\n    }\n\n    let counter = 2;\n    while (existingKeys.includes(`${baseKey}-${counter}`)) {\n      counter++;\n    }\n    return `${baseKey}-${counter}`;\n  };\n\n  const handleDuplicateProvider = async (provider: Provider) => {\n    const newSortIndex =\n      provider.sortIndex !== undefined ? provider.sortIndex + 1 : undefined;\n\n    const duplicatedProvider: Omit<Provider, \"id\" | \"createdAt\"> & {\n      providerKey?: string;\n    } = {\n      name: `${provider.name} copy`,\n      settingsConfig: JSON.parse(JSON.stringify(provider.settingsConfig)), // 深拷贝\n      websiteUrl: provider.websiteUrl,\n      category: provider.category,\n      sortIndex: newSortIndex, // 复制原 sortIndex + 1\n      meta: provider.meta\n        ? JSON.parse(JSON.stringify(provider.meta))\n        : undefined, // 深拷贝\n      icon: provider.icon,\n      iconColor: provider.iconColor,\n    };\n\n    if (activeApp === \"opencode\") {\n      const existingKeys = Object.keys(providers);\n      duplicatedProvider.providerKey = generateUniqueOpencodeKey(\n        provider.id,\n        existingKeys,\n      );\n    }\n\n    if (provider.sortIndex !== undefined) {\n      const updates = Object.values(providers)\n        .filter(\n          (p) =>\n            p.sortIndex !== undefined &&\n            p.sortIndex >= newSortIndex! &&\n            p.id !== provider.id,\n        )\n        .map((p) => ({\n          id: p.id,\n          sortIndex: p.sortIndex! + 1,\n        }));\n\n      if (updates.length > 0) {\n        try {\n          await providersApi.updateSortOrder(updates, activeApp);\n        } catch (error) {\n          console.error(\"[App] Failed to update sort order\", error);\n          toast.error(\n            t(\"provider.sortUpdateFailed\", {\n              defaultValue: \"排序更新失败\",\n            }),\n          );\n          return; // 如果排序更新失败，不继续添加\n        }\n      }\n    }\n\n    await addProvider(duplicatedProvider);\n  };\n\n  const handleOpenTerminal = async (provider: Provider) => {\n    try {\n      await providersApi.openTerminal(provider.id, activeApp);\n      toast.success(\n        t(\"provider.terminalOpened\", {\n          defaultValue: \"终端已打开\",\n        }),\n      );\n    } catch (error) {\n      console.error(\"[App] Failed to open terminal\", error);\n      const errorMessage = extractErrorMessage(error);\n      toast.error(\n        t(\"provider.terminalOpenFailed\", {\n          defaultValue: \"打开终端失败\",\n        }) + (errorMessage ? `: ${errorMessage}` : \"\"),\n      );\n    }\n  };\n\n  const handleImportSuccess = async () => {\n    try {\n      await queryClient.invalidateQueries({\n        queryKey: [\"providers\"],\n        refetchType: \"all\",\n      });\n      await queryClient.refetchQueries({\n        queryKey: [\"providers\"],\n        type: \"all\",\n      });\n    } catch (error) {\n      console.error(\"[App] Failed to refresh providers after import\", error);\n      await refetch();\n    }\n    try {\n      await providersApi.updateTrayMenu();\n    } catch (error) {\n      console.error(\"[App] Failed to refresh tray menu\", error);\n    }\n  };\n\n  const renderContent = () => {\n    const content = (() => {\n      switch (currentView) {\n        case \"settings\":\n          return (\n            <SettingsPage\n              open={true}\n              onOpenChange={() => setCurrentView(\"providers\")}\n              onImportSuccess={handleImportSuccess}\n              defaultTab={settingsDefaultTab}\n            />\n          );\n        case \"prompts\":\n          return (\n            <PromptPanel\n              ref={promptPanelRef}\n              open={true}\n              onOpenChange={() => setCurrentView(\"providers\")}\n              appId={activeApp}\n            />\n          );\n        case \"skills\":\n          return (\n            <UnifiedSkillsPanel\n              ref={unifiedSkillsPanelRef}\n              onOpenDiscovery={() => setCurrentView(\"skillsDiscovery\")}\n              currentApp={activeApp === \"openclaw\" ? \"claude\" : activeApp}\n            />\n          );\n        case \"skillsDiscovery\":\n          return (\n            <SkillsPage\n              ref={skillsPageRef}\n              initialApp={activeApp === \"openclaw\" ? \"claude\" : activeApp}\n            />\n          );\n        case \"mcp\":\n          return (\n            <UnifiedMcpPanel\n              ref={mcpPanelRef}\n              onOpenChange={() => setCurrentView(\"providers\")}\n            />\n          );\n        case \"agents\":\n          return (\n            <AgentsPanel onOpenChange={() => setCurrentView(\"providers\")} />\n          );\n        case \"universal\":\n          return (\n            <div className=\"px-6 pt-4\">\n              <UniversalProviderPanel />\n            </div>\n          );\n\n        case \"sessions\":\n          return <SessionManagerPage key={activeApp} appId={activeApp} />;\n        case \"workspace\":\n          return <WorkspaceFilesPanel />;\n        case \"openclawEnv\":\n          return <EnvPanel />;\n        case \"openclawTools\":\n          return <ToolsPanel />;\n        case \"openclawAgents\":\n          return <AgentsDefaultsPanel />;\n        default:\n          return (\n            <div className=\"px-6 flex flex-col flex-1 min-h-0 overflow-hidden\">\n              <div className=\"flex-1 overflow-y-auto overflow-x-hidden pb-12 px-1\">\n                <AnimatePresence mode=\"wait\">\n                  <motion.div\n                    key={activeApp}\n                    initial={{ opacity: 0 }}\n                    animate={{ opacity: 1 }}\n                    exit={{ opacity: 0 }}\n                    transition={{ duration: 0.15 }}\n                    className=\"space-y-4\"\n                  >\n                    <ProviderList\n                      providers={providers}\n                      currentProviderId={currentProviderId}\n                      appId={activeApp}\n                      isLoading={isLoading}\n                      isProxyRunning={isProxyRunning}\n                      isProxyTakeover={\n                        isProxyRunning && isCurrentAppTakeoverActive\n                      }\n                      activeProviderId={activeProviderId}\n                      onSwitch={switchProvider}\n                      onEdit={(provider) => {\n                        setEditingProvider(provider);\n                      }}\n                      onDelete={(provider) =>\n                        setConfirmAction({ provider, action: \"delete\" })\n                      }\n                      onRemoveFromConfig={\n                        activeApp === \"opencode\" || activeApp === \"openclaw\"\n                          ? (provider) =>\n                              setConfirmAction({ provider, action: \"remove\" })\n                          : undefined\n                      }\n                      onDisableOmo={\n                        activeApp === \"opencode\" ? handleDisableOmo : undefined\n                      }\n                      onDisableOmoSlim={\n                        activeApp === \"opencode\"\n                          ? handleDisableOmoSlim\n                          : undefined\n                      }\n                      onDuplicate={handleDuplicateProvider}\n                      onConfigureUsage={setUsageProvider}\n                      onOpenWebsite={handleOpenWebsite}\n                      onOpenTerminal={\n                        activeApp === \"claude\" ? handleOpenTerminal : undefined\n                      }\n                      onCreate={() => setIsAddOpen(true)}\n                      onSetAsDefault={\n                        activeApp === \"openclaw\" ? setAsDefaultModel : undefined\n                      }\n                    />\n                  </motion.div>\n                </AnimatePresence>\n              </div>\n            </div>\n          );\n      }\n    })();\n\n    return (\n      <AnimatePresence mode=\"wait\">\n        <motion.div\n          key={currentView}\n          className=\"flex-1 min-h-0\"\n          initial={{ opacity: 0 }}\n          animate={{ opacity: 1 }}\n          exit={{ opacity: 0 }}\n          transition={{ duration: 0.2 }}\n        >\n          {content}\n        </motion.div>\n      </AnimatePresence>\n    );\n  };\n\n  return (\n    <div\n      className=\"flex flex-col h-screen overflow-hidden bg-background text-foreground selection:bg-primary/30\"\n      style={{ overflowX: \"hidden\", paddingTop: CONTENT_TOP_OFFSET }}\n    >\n      <div\n        className=\"fixed top-0 left-0 right-0 z-[60]\"\n        data-tauri-drag-region\n        style={{ WebkitAppRegion: \"drag\", height: DRAG_BAR_HEIGHT } as any}\n      />\n      {showEnvBanner && envConflicts.length > 0 && (\n        <EnvWarningBanner\n          conflicts={envConflicts}\n          onDismiss={() => {\n            setShowEnvBanner(false);\n            sessionStorage.setItem(\"env_banner_dismissed\", \"true\");\n          }}\n          onDeleted={async () => {\n            try {\n              const allConflicts = await checkAllEnvConflicts();\n              const flatConflicts = Object.values(allConflicts).flat();\n              setEnvConflicts(flatConflicts);\n              if (flatConflicts.length === 0) {\n                setShowEnvBanner(false);\n              }\n            } catch (error) {\n              console.error(\n                \"[App] Failed to re-check conflicts after deletion:\",\n                error,\n              );\n            }\n          }}\n        />\n      )}\n\n      <header\n        className=\"fixed z-50 w-full transition-all duration-300 bg-background/80 backdrop-blur-md\"\n        data-tauri-drag-region\n        style={\n          {\n            WebkitAppRegion: \"drag\",\n            top: DRAG_BAR_HEIGHT,\n            height: HEADER_HEIGHT,\n          } as any\n        }\n      >\n        <div\n          className=\"flex h-full items-center justify-between gap-2 px-6\"\n          data-tauri-drag-region\n          style={{ WebkitAppRegion: \"drag\" } as any}\n        >\n          <div\n            className=\"flex items-center gap-1\"\n            style={{ WebkitAppRegion: \"no-drag\" } as any}\n          >\n            {currentView !== \"providers\" ? (\n              <div className=\"flex items-center gap-2\">\n                <Button\n                  variant=\"outline\"\n                  size=\"icon\"\n                  onClick={() =>\n                    setCurrentView(\n                      currentView === \"skillsDiscovery\"\n                        ? \"skills\"\n                        : \"providers\",\n                    )\n                  }\n                  className=\"mr-2 rounded-lg\"\n                >\n                  <ArrowLeft className=\"w-4 h-4\" />\n                </Button>\n                <h1 className=\"text-lg font-semibold\">\n                  {currentView === \"settings\" && t(\"settings.title\")}\n                  {currentView === \"prompts\" &&\n                    t(\"prompts.title\", { appName: t(`apps.${activeApp}`) })}\n                  {currentView === \"skills\" && t(\"skills.title\")}\n                  {currentView === \"skillsDiscovery\" && t(\"skills.title\")}\n                  {currentView === \"mcp\" && t(\"mcp.unifiedPanel.title\")}\n                  {currentView === \"agents\" && t(\"agents.title\")}\n                  {currentView === \"universal\" &&\n                    t(\"universalProvider.title\", {\n                      defaultValue: \"统一供应商\",\n                    })}\n                  {currentView === \"sessions\" && t(\"sessionManager.title\")}\n                  {currentView === \"workspace\" && t(\"workspace.title\")}\n                  {currentView === \"openclawEnv\" && t(\"openclaw.env.title\")}\n                  {currentView === \"openclawTools\" && t(\"openclaw.tools.title\")}\n                  {currentView === \"openclawAgents\" &&\n                    t(\"openclaw.agents.title\")}\n                </h1>\n              </div>\n            ) : (\n              <div className=\"flex items-center gap-2\">\n                <div className=\"relative inline-flex items-center\">\n                  <a\n                    href=\"https://github.com/farion1231/cc-switch\"\n                    target=\"_blank\"\n                    rel=\"noreferrer\"\n                    className={cn(\n                      \"text-xl font-semibold transition-colors\",\n                      isProxyRunning && isCurrentAppTakeoverActive\n                        ? \"text-emerald-500 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-300\"\n                        : \"text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300\",\n                    )}\n                  >\n                    CC Switch\n                  </a>\n                </div>\n                <Button\n                  variant=\"ghost\"\n                  size=\"icon\"\n                  onClick={() => {\n                    setSettingsDefaultTab(\"general\");\n                    setCurrentView(\"settings\");\n                  }}\n                  title={t(\"common.settings\")}\n                  className=\"hover:bg-black/5 dark:hover:bg-white/5\"\n                >\n                  <Settings className=\"w-4 h-4\" />\n                </Button>\n                <UpdateBadge\n                  onClick={() => {\n                    setSettingsDefaultTab(\"about\");\n                    setCurrentView(\"settings\");\n                  }}\n                />\n                {isCurrentAppTakeoverActive && (\n                  <Button\n                    variant=\"ghost\"\n                    size=\"icon\"\n                    onClick={() => {\n                      setSettingsDefaultTab(\"usage\");\n                      setCurrentView(\"settings\");\n                    }}\n                    title={t(\"usage.title\", {\n                      defaultValue: \"使用统计\",\n                    })}\n                    className=\"hover:bg-black/5 dark:hover:bg-white/5\"\n                  >\n                    <BarChart2 className=\"w-4 h-4\" />\n                  </Button>\n                )}\n              </div>\n            )}\n          </div>\n\n          <div className=\"flex flex-1 min-w-0 items-center justify-end gap-1.5\">\n            {currentView === \"providers\" &&\n              activeApp !== \"opencode\" &&\n              activeApp !== \"openclaw\" && (\n                <div\n                  className=\"flex shrink-0 items-center gap-1.5\"\n                  style={{ WebkitAppRegion: \"no-drag\" } as any}\n                >\n                  {settingsData?.enableLocalProxy && (\n                    <ProxyToggle activeApp={activeApp} />\n                  )}\n                  {settingsData?.enableFailoverToggle && (\n                    <FailoverToggle activeApp={activeApp} />\n                  )}\n                </div>\n              )}\n            <div\n              ref={toolbarRef}\n              className=\"flex flex-1 min-w-0 overflow-x-hidden items-center\"\n            >\n              <div\n                className=\"flex shrink-0 items-center gap-1.5 ml-auto\"\n                style={{ WebkitAppRegion: \"no-drag\" } as any}\n              >\n                {currentView === \"prompts\" && (\n                  <Button\n                    variant=\"ghost\"\n                    size=\"sm\"\n                    onClick={() => promptPanelRef.current?.openAdd()}\n                    className=\"hover:bg-black/5 dark:hover:bg-white/5\"\n                  >\n                    <Plus className=\"w-4 h-4 mr-2\" />\n                    {t(\"prompts.add\")}\n                  </Button>\n                )}\n                {currentView === \"mcp\" && (\n                  <>\n                    <Button\n                      variant=\"ghost\"\n                      size=\"sm\"\n                      onClick={() => mcpPanelRef.current?.openImport()}\n                      className=\"hover:bg-black/5 dark:hover:bg-white/5\"\n                    >\n                      <Download className=\"w-4 h-4 mr-2\" />\n                      {t(\"mcp.importExisting\")}\n                    </Button>\n                    <Button\n                      variant=\"ghost\"\n                      size=\"sm\"\n                      onClick={() => mcpPanelRef.current?.openAdd()}\n                      className=\"hover:bg-black/5 dark:hover:bg-white/5\"\n                    >\n                      <Plus className=\"w-4 h-4 mr-2\" />\n                      {t(\"mcp.addMcp\")}\n                    </Button>\n                  </>\n                )}\n                {currentView === \"skills\" && (\n                  <>\n                    <Button\n                      variant=\"ghost\"\n                      size=\"sm\"\n                      onClick={() =>\n                        unifiedSkillsPanelRef.current?.openRestoreFromBackup()\n                      }\n                      className=\"hover:bg-black/5 dark:hover:bg-white/5\"\n                    >\n                      <History className=\"w-4 h-4 mr-2\" />\n                      {t(\"skills.restoreFromBackup.button\")}\n                    </Button>\n                    <Button\n                      variant=\"ghost\"\n                      size=\"sm\"\n                      onClick={() =>\n                        unifiedSkillsPanelRef.current?.openInstallFromZip()\n                      }\n                      className=\"hover:bg-black/5 dark:hover:bg-white/5\"\n                    >\n                      <FolderArchive className=\"w-4 h-4 mr-2\" />\n                      {t(\"skills.installFromZip.button\")}\n                    </Button>\n                    <Button\n                      variant=\"ghost\"\n                      size=\"sm\"\n                      onClick={() =>\n                        unifiedSkillsPanelRef.current?.openImport()\n                      }\n                      className=\"hover:bg-black/5 dark:hover:bg-white/5\"\n                    >\n                      <Download className=\"w-4 h-4 mr-2\" />\n                      {t(\"skills.import\")}\n                    </Button>\n                    <Button\n                      variant=\"ghost\"\n                      size=\"sm\"\n                      onClick={() => setCurrentView(\"skillsDiscovery\")}\n                      className=\"hover:bg-black/5 dark:hover:bg-white/5\"\n                    >\n                      <Search className=\"w-4 h-4 mr-2\" />\n                      {t(\"skills.discover\")}\n                    </Button>\n                  </>\n                )}\n                {currentView === \"skillsDiscovery\" && (\n                  <>\n                    <Button\n                      variant=\"ghost\"\n                      size=\"sm\"\n                      onClick={() => skillsPageRef.current?.refresh()}\n                      className=\"hover:bg-black/5 dark:hover:bg-white/5\"\n                    >\n                      <RefreshCw className=\"w-4 h-4 mr-2\" />\n                      {t(\"skills.refresh\")}\n                    </Button>\n                    <Button\n                      variant=\"ghost\"\n                      size=\"sm\"\n                      onClick={() => skillsPageRef.current?.openRepoManager()}\n                      className=\"hover:bg-black/5 dark:hover:bg-white/5\"\n                    >\n                      <Settings className=\"w-4 h-4 mr-2\" />\n                      {t(\"skills.repoManager\")}\n                    </Button>\n                  </>\n                )}\n                {currentView === \"providers\" && (\n                  <>\n                    <AppSwitcher\n                      activeApp={activeApp}\n                      onSwitch={setActiveApp}\n                      visibleApps={visibleApps}\n                      compact={isToolbarCompact}\n                    />\n\n                    <div className=\"flex items-center gap-1 p-1 bg-muted rounded-xl\">\n                      <AnimatePresence mode=\"wait\">\n                        <motion.div\n                          key={\n                            activeApp === \"openclaw\" ? \"openclaw\" : \"default\"\n                          }\n                          className=\"flex items-center gap-1\"\n                          initial={{ opacity: 0 }}\n                          animate={{ opacity: 1 }}\n                          exit={{ opacity: 0 }}\n                          transition={{ duration: 0.15 }}\n                        >\n                          {activeApp === \"openclaw\" ? (\n                            <>\n                              <Button\n                                variant=\"ghost\"\n                                size=\"sm\"\n                                onClick={() => setCurrentView(\"workspace\")}\n                                className=\"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5\"\n                                title={t(\"workspace.manage\")}\n                              >\n                                <FolderOpen className=\"w-4 h-4\" />\n                              </Button>\n                              <Button\n                                variant=\"ghost\"\n                                size=\"sm\"\n                                onClick={() => setCurrentView(\"openclawEnv\")}\n                                className=\"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5\"\n                                title={t(\"openclaw.env.title\")}\n                              >\n                                <KeyRound className=\"w-4 h-4\" />\n                              </Button>\n                              <Button\n                                variant=\"ghost\"\n                                size=\"sm\"\n                                onClick={() => setCurrentView(\"openclawTools\")}\n                                className=\"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5\"\n                                title={t(\"openclaw.tools.title\")}\n                              >\n                                <Shield className=\"w-4 h-4\" />\n                              </Button>\n                              <Button\n                                variant=\"ghost\"\n                                size=\"sm\"\n                                onClick={() => setCurrentView(\"openclawAgents\")}\n                                className=\"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5\"\n                                title={t(\"openclaw.agents.title\")}\n                              >\n                                <Cpu className=\"w-4 h-4\" />\n                              </Button>\n                              <Button\n                                variant=\"ghost\"\n                                size=\"sm\"\n                                onClick={() => setCurrentView(\"sessions\")}\n                                className=\"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5\"\n                                title={t(\"sessionManager.title\")}\n                              >\n                                <History className=\"w-4 h-4\" />\n                              </Button>\n                            </>\n                          ) : (\n                            <>\n                              <Button\n                                variant=\"ghost\"\n                                size=\"sm\"\n                                onClick={() => setCurrentView(\"skills\")}\n                                className={cn(\n                                  \"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5\",\n                                  \"transition-all duration-200 ease-in-out overflow-hidden\",\n                                  hasSkillsSupport\n                                    ? \"opacity-100 w-8 scale-100 px-2\"\n                                    : \"opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1\",\n                                )}\n                                title={t(\"skills.manage\")}\n                              >\n                                <Wrench className=\"flex-shrink-0 w-4 h-4\" />\n                              </Button>\n                              <Button\n                                variant=\"ghost\"\n                                size=\"sm\"\n                                onClick={() => setCurrentView(\"prompts\")}\n                                className=\"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5\"\n                                title={t(\"prompts.manage\")}\n                              >\n                                <Book className=\"w-4 h-4\" />\n                              </Button>\n                              <Button\n                                variant=\"ghost\"\n                                size=\"sm\"\n                                onClick={() => setCurrentView(\"sessions\")}\n                                className={cn(\n                                  \"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5\",\n                                  \"transition-all duration-200 ease-in-out overflow-hidden\",\n                                  hasSessionSupport\n                                    ? \"opacity-100 w-8 scale-100 px-2\"\n                                    : \"opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1\",\n                                )}\n                                title={t(\"sessionManager.title\")}\n                              >\n                                <History className=\"flex-shrink-0 w-4 h-4\" />\n                              </Button>\n                              <Button\n                                variant=\"ghost\"\n                                size=\"sm\"\n                                onClick={() => setCurrentView(\"mcp\")}\n                                className=\"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5\"\n                                title={t(\"mcp.title\")}\n                              >\n                                <McpIcon size={16} />\n                              </Button>\n                            </>\n                          )}\n                        </motion.div>\n                      </AnimatePresence>\n                    </div>\n\n                    <Button\n                      onClick={() => setIsAddOpen(true)}\n                      size=\"icon\"\n                      className={`ml-2 ${addActionButtonClass}`}\n                    >\n                      <Plus className=\"w-5 h-5\" />\n                    </Button>\n                  </>\n                )}\n              </div>\n            </div>\n          </div>\n        </div>\n      </header>\n\n      <main className=\"flex-1 min-h-0 flex flex-col overflow-y-auto animate-fade-in\">\n        {isOpenClawView && openclawHealthWarnings.length > 0 && (\n          <OpenClawHealthBanner warnings={openclawHealthWarnings} />\n        )}\n        {renderContent()}\n      </main>\n\n      <AddProviderDialog\n        open={isAddOpen}\n        onOpenChange={setIsAddOpen}\n        appId={activeApp}\n        onSubmit={addProvider}\n      />\n\n      <EditProviderDialog\n        open={Boolean(editingProvider)}\n        provider={effectiveEditingProvider}\n        onOpenChange={(open) => {\n          if (!open) {\n            setEditingProvider(null);\n          }\n        }}\n        onSubmit={handleEditProvider}\n        appId={activeApp}\n        isProxyTakeover={isProxyRunning && isCurrentAppTakeoverActive}\n      />\n\n      {effectiveUsageProvider && (\n        <UsageScriptModal\n          key={effectiveUsageProvider.id}\n          provider={effectiveUsageProvider}\n          appId={activeApp}\n          isOpen={Boolean(usageProvider)}\n          onClose={() => setUsageProvider(null)}\n          onSave={(script) => {\n            if (usageProvider) {\n              void saveUsageScript(usageProvider, script);\n            }\n          }}\n        />\n      )}\n\n      <ConfirmDialog\n        isOpen={Boolean(confirmAction)}\n        title={\n          confirmAction?.action === \"remove\"\n            ? t(\"confirm.removeProvider\")\n            : t(\"confirm.deleteProvider\")\n        }\n        message={\n          confirmAction\n            ? confirmAction.action === \"remove\"\n              ? t(\"confirm.removeProviderMessage\", {\n                  name: confirmAction.provider.name,\n                })\n              : t(\"confirm.deleteProviderMessage\", {\n                  name: confirmAction.provider.name,\n                })\n            : \"\"\n        }\n        onConfirm={() => void handleConfirmAction()}\n        onCancel={() => setConfirmAction(null)}\n      />\n\n      <DeepLinkImportDialog />\n    </div>\n  );\n}\n\nexport default App;\n"
  },
  {
    "path": "src/components/AppSwitcher.tsx",
    "content": "import type { AppId } from \"@/lib/api\";\nimport type { VisibleApps } from \"@/types\";\nimport { ProviderIcon } from \"@/components/ProviderIcon\";\nimport { cn } from \"@/lib/utils\";\n\ninterface AppSwitcherProps {\n  activeApp: AppId;\n  onSwitch: (app: AppId) => void;\n  visibleApps?: VisibleApps;\n  compact?: boolean;\n}\n\nconst ALL_APPS: AppId[] = [\"claude\", \"codex\", \"gemini\", \"opencode\", \"openclaw\"];\nconst STORAGE_KEY = \"cc-switch-last-app\";\n\nexport function AppSwitcher({\n  activeApp,\n  onSwitch,\n  visibleApps,\n  compact,\n}: AppSwitcherProps) {\n  const handleSwitch = (app: AppId) => {\n    if (app === activeApp) return;\n    localStorage.setItem(STORAGE_KEY, app);\n    onSwitch(app);\n  };\n  const iconSize = 20;\n  const appIconName: Record<AppId, string> = {\n    claude: \"claude\",\n    codex: \"openai\",\n    gemini: \"gemini\",\n    opencode: \"opencode\",\n    openclaw: \"openclaw\",\n  };\n  const appDisplayName: Record<AppId, string> = {\n    claude: \"Claude\",\n    codex: \"Codex\",\n    gemini: \"Gemini\",\n    opencode: \"OpenCode\",\n    openclaw: \"OpenClaw\",\n  };\n\n  // Filter apps based on visibility settings (default all visible)\n  const appsToShow = ALL_APPS.filter((app) => {\n    if (!visibleApps) return true;\n    return visibleApps[app];\n  });\n\n  return (\n    <div className=\"inline-flex bg-muted rounded-xl p-1 gap-1\">\n      {appsToShow.map((app) => (\n        <button\n          key={app}\n          type=\"button\"\n          onClick={() => handleSwitch(app)}\n          className={cn(\n            \"group inline-flex items-center px-3 h-8 rounded-md text-sm font-medium transition-all duration-200\",\n            activeApp === app\n              ? \"bg-background text-foreground shadow-sm\"\n              : \"text-muted-foreground hover:text-foreground hover:bg-background/50\",\n          )}\n        >\n          <ProviderIcon\n            icon={appIconName[app]}\n            name={appDisplayName[app]}\n            size={iconSize}\n          />\n          <span\n            className={cn(\n              \"transition-all duration-200 whitespace-nowrap overflow-hidden\",\n              compact\n                ? \"max-w-0 opacity-0 ml-0\"\n                : \"max-w-[80px] opacity-100 ml-2\",\n            )}\n          >\n            {appDisplayName[app]}\n          </span>\n        </button>\n      ))}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/BrandIcons.tsx",
    "content": "interface IconProps {\n  size?: number;\n  className?: string;\n}\n\n// 导入本地 SVG 图标\nimport ClaudeSvg from \"@/icons/extracted/claude.svg?url\";\nimport OpenAISvg from \"@/icons/extracted/openai.svg?url\";\nimport GeminiSvg from \"@/icons/extracted/gemini.svg?url\";\nimport OpenClawSvg from \"@/icons/extracted/claw.svg?url\";\n\nexport function ClaudeIcon({ size = 16, className = \"\" }: IconProps) {\n  return (\n    <img\n      src={ClaudeSvg}\n      width={size}\n      height={size}\n      className={className}\n      alt=\"Claude\"\n      loading=\"lazy\"\n    />\n  );\n}\n\nexport function CodexIcon({ size = 16, className = \"\" }: IconProps) {\n  return (\n    <img\n      src={OpenAISvg}\n      width={size}\n      height={size}\n      className={`dark:brightness-0 dark:invert ${className}`}\n      alt=\"Codex\"\n      loading=\"lazy\"\n    />\n  );\n}\n\nexport function GeminiIcon({ size = 16, className = \"\" }: IconProps) {\n  return (\n    <img\n      src={GeminiSvg}\n      width={size}\n      height={size}\n      className={className}\n      alt=\"Gemini\"\n      loading=\"lazy\"\n    />\n  );\n}\n\nexport function OpenClawIcon({ size = 16, className = \"\" }: IconProps) {\n  return (\n    <img\n      src={OpenClawSvg}\n      width={size}\n      height={size}\n      className={className}\n      alt=\"OpenClaw\"\n      loading=\"lazy\"\n    />\n  );\n}\n\n// MCP icon uses inline SVG to support currentColor for hover effects\nexport function McpIcon({ size = 16, className = \"\" }: IconProps) {\n  return (\n    <svg\n      fill=\"currentColor\"\n      fillRule=\"evenodd\"\n      height={size}\n      width={size}\n      className={className}\n      viewBox=\"0 0 24 24\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <path d=\"M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z\" />\n      <path d=\"M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "src/components/ColorPicker.tsx",
    "content": "import React from \"react\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { cn } from \"@/lib/utils\";\nimport { useTranslation } from \"react-i18next\";\n\ninterface ColorPickerProps {\n  value?: string;\n  onValueChange: (color: string) => void;\n  label?: string;\n  presets?: string[];\n}\n\nconst DEFAULT_PRESETS = [\n  \"#00A67E\",\n  \"#D4915D\",\n  \"#4285F4\",\n  \"#FF6A00\",\n  \"#00A4FF\",\n  \"#FF9900\",\n  \"#0078D4\",\n  \"#FF0000\",\n  \"#1E88E5\",\n  \"#6366F1\",\n  \"#0F62FE\",\n  \"#2932E1\",\n];\n\nexport const ColorPicker: React.FC<ColorPickerProps> = ({\n  value = \"#4285F4\",\n  onValueChange,\n  label,\n  presets = DEFAULT_PRESETS,\n}) => {\n  const { t } = useTranslation();\n  const displayLabel = label ?? t(\"providerIcon.color\", \"图标颜色\");\n  return (\n    <div className=\"space-y-3\">\n      <Label>{displayLabel}</Label>\n\n      {/* 颜色预设 */}\n      <div className=\"grid grid-cols-6 gap-2\">\n        {presets.map((color) => (\n          <button\n            key={color}\n            type=\"button\"\n            onClick={() => onValueChange(color)}\n            className={cn(\n              \"w-full aspect-square rounded-lg border-2 transition-all\",\n              \"hover:scale-110 hover:shadow-lg\",\n              value === color\n                ? \"border-primary ring-2 ring-primary/20\"\n                : \"border-border\",\n            )}\n            style={{ backgroundColor: color }}\n            title={color}\n          />\n        ))}\n      </div>\n\n      {/* 自定义颜色输入 */}\n      <div className=\"flex items-center gap-2\">\n        <Input\n          type=\"color\"\n          value={value}\n          onChange={(e) => onValueChange(e.target.value)}\n          className=\"w-16 h-10 p-1 cursor-pointer\"\n        />\n        <Input\n          type=\"text\"\n          value={value}\n          onChange={(e) => onValueChange(e.target.value)}\n          placeholder=\"#4285F4\"\n          className=\"flex-1 font-mono\"\n        />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "src/components/ConfirmDialog.tsx",
    "content": "import {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { Button } from \"@/components/ui/button\";\nimport { AlertTriangle, Info } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\n\ninterface ConfirmDialogProps {\n  isOpen: boolean;\n  title: string;\n  message: string;\n  confirmText?: string;\n  cancelText?: string;\n  variant?: \"destructive\" | \"info\";\n  zIndex?: \"base\" | \"nested\" | \"alert\" | \"top\";\n  onConfirm: () => void;\n  onCancel: () => void;\n}\n\nexport function ConfirmDialog({\n  isOpen,\n  title,\n  message,\n  confirmText,\n  cancelText,\n  variant = \"destructive\",\n  zIndex = \"alert\",\n  onConfirm,\n  onCancel,\n}: ConfirmDialogProps) {\n  const { t } = useTranslation();\n\n  const IconComponent = variant === \"info\" ? Info : AlertTriangle;\n  const iconClass =\n    variant === \"info\" ? \"h-5 w-5 text-blue-500\" : \"h-5 w-5 text-destructive\";\n\n  return (\n    <Dialog\n      open={isOpen}\n      onOpenChange={(open) => {\n        if (!open) {\n          onCancel();\n        }\n      }}\n    >\n      <DialogContent className=\"max-w-sm\" zIndex={zIndex}>\n        <DialogHeader className=\"space-y-3 border-b-0 bg-transparent pb-0\">\n          <DialogTitle className=\"flex items-center gap-2 text-lg font-semibold\">\n            <IconComponent className={iconClass} />\n            {title}\n          </DialogTitle>\n          <DialogDescription className=\"whitespace-pre-line text-sm leading-relaxed\">\n            {message}\n          </DialogDescription>\n        </DialogHeader>\n        <DialogFooter className=\"flex gap-2 border-t-0 bg-transparent pt-2 sm:justify-end\">\n          <Button variant=\"outline\" onClick={onCancel}>\n            {cancelText || t(\"common.cancel\")}\n          </Button>\n          <Button\n            variant={variant === \"info\" ? \"default\" : \"destructive\"}\n            onClick={onConfirm}\n          >\n            {confirmText || t(\"common.confirm\")}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "src/components/DeepLinkImportDialog.tsx",
    "content": "import { useState, useEffect, useMemo } from \"react\";\nimport { listen } from \"@tauri-apps/api/event\";\nimport { DeepLinkImportRequest, deeplinkApi } from \"@/lib/api/deeplink\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { Button } from \"@/components/ui/button\";\nimport { toast } from \"sonner\";\nimport { useTranslation } from \"react-i18next\";\nimport { useQueryClient } from \"@tanstack/react-query\";\nimport { PromptConfirmation } from \"./deeplink/PromptConfirmation\";\nimport { McpConfirmation } from \"./deeplink/McpConfirmation\";\nimport { SkillConfirmation } from \"./deeplink/SkillConfirmation\";\nimport { ProviderIcon } from \"./ProviderIcon\";\n\ninterface DeeplinkError {\n  url: string;\n  error: string;\n}\n\nexport function DeepLinkImportDialog() {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const [request, setRequest] = useState<DeepLinkImportRequest | null>(null);\n  const [isImporting, setIsImporting] = useState(false);\n  const [isOpen, setIsOpen] = useState(false);\n\n  // 容错判断：MCP 导入结果可能缺少 type 字段\n  const isMcpImportResult = (\n    value: unknown,\n  ): value is {\n    importedCount: number;\n    importedIds: string[];\n    failed: Array<{ id: string; error: string }>;\n    type?: \"mcp\";\n  } => {\n    if (!value || typeof value !== \"object\") return false;\n    const v = value as Record<string, unknown>;\n    return (\n      typeof v.importedCount === \"number\" &&\n      Array.isArray(v.importedIds) &&\n      Array.isArray(v.failed)\n    );\n  };\n\n  useEffect(() => {\n    // Listen for deep link import events\n    const unlistenImport = listen<DeepLinkImportRequest>(\n      \"deeplink-import\",\n      async (event) => {\n        // If config is present, merge it to get the complete configuration\n        if (event.payload.config || event.payload.configUrl) {\n          try {\n            const mergedRequest = await deeplinkApi.mergeDeeplinkConfig(\n              event.payload,\n            );\n            setRequest(mergedRequest);\n          } catch (error) {\n            console.error(\"Failed to merge config:\", error);\n            toast.error(t(\"deeplink.configMergeError\"), {\n              description:\n                error instanceof Error ? error.message : String(error),\n            });\n            // Fall back to original request\n            setRequest(event.payload);\n          }\n        } else {\n          setRequest(event.payload);\n        }\n\n        setIsOpen(true);\n      },\n    );\n\n    // Listen for deep link error events\n    const unlistenError = listen<DeeplinkError>(\"deeplink-error\", (event) => {\n      console.error(\"Deep link error:\", event.payload);\n      toast.error(t(\"deeplink.parseError\"), {\n        description: event.payload.error,\n      });\n    });\n\n    return () => {\n      unlistenImport.then((fn) => fn());\n      unlistenError.then((fn) => fn());\n    };\n  }, [t]);\n\n  const handleImport = async () => {\n    if (!request) return;\n\n    setIsImporting(true);\n\n    try {\n      const result = await deeplinkApi.importFromDeeplink(request);\n      const refreshMcp = async (summary: {\n        importedCount: number;\n        importedIds: string[];\n        failed: Array<{ id: string; error: string }>;\n      }) => {\n        // 强制刷新 MCP 相关缓存，确保管理页重新从数据库加载\n        await queryClient.invalidateQueries({\n          queryKey: [\"mcp\", \"all\"],\n          refetchType: \"all\",\n        });\n        await queryClient.refetchQueries({\n          queryKey: [\"mcp\", \"all\"],\n          type: \"all\",\n        });\n\n        if (summary.failed.length > 0) {\n          toast.warning(t(\"deeplink.mcpPartialSuccess\"), {\n            description: t(\"deeplink.mcpPartialSuccessDescription\", {\n              success: summary.importedCount,\n              failed: summary.failed.length,\n            }),\n          });\n        } else {\n          toast.success(t(\"deeplink.mcpImportSuccess\"), {\n            description: t(\"deeplink.mcpImportSuccessDescription\", {\n              count: summary.importedCount,\n            }),\n            closeButton: true,\n          });\n        }\n      };\n\n      // Handle different result types\n      if (\"type\" in result) {\n        if (result.type === \"provider\") {\n          await queryClient.invalidateQueries({\n            queryKey: [\"providers\", request.app],\n          });\n          toast.success(t(\"deeplink.importSuccess\"), {\n            description: t(\"deeplink.importSuccessDescription\", {\n              name: request.name,\n            }),\n            closeButton: true,\n          });\n        } else if (result.type === \"prompt\") {\n          // Prompts don't use React Query, trigger a custom event for refresh\n          window.dispatchEvent(\n            new CustomEvent(\"prompt-imported\", {\n              detail: { app: request.app },\n            }),\n          );\n          toast.success(t(\"deeplink.promptImportSuccess\"), {\n            description: t(\"deeplink.promptImportSuccessDescription\", {\n              name: request.name,\n            }),\n            closeButton: true,\n          });\n        } else if (result.type === \"mcp\") {\n          await refreshMcp(result);\n        } else if (result.type === \"skill\") {\n          // Refresh Skills with aggressive strategy\n          queryClient.invalidateQueries({\n            queryKey: [\"skills\"],\n            refetchType: \"all\",\n          });\n          await queryClient.refetchQueries({\n            queryKey: [\"skills\"],\n            type: \"all\",\n          });\n          toast.success(t(\"deeplink.skillImportSuccess\"), {\n            description: t(\"deeplink.skillImportSuccessDescription\", {\n              repo: request.repo,\n            }),\n            closeButton: true,\n          });\n        }\n      } else if (isMcpImportResult(result)) {\n        // 兜底处理：旧版本后端可能未返回 type 字段\n        await refreshMcp(result);\n      } else {\n        // Legacy return type (string ID) - assume provider\n        await queryClient.invalidateQueries({\n          queryKey: [\"providers\", request.app],\n        });\n        toast.success(t(\"deeplink.importSuccess\"), {\n          description: t(\"deeplink.importSuccessDescription\", {\n            name: request.name,\n          }),\n          closeButton: true,\n        });\n      }\n\n      // Close dialog after all refreshes complete\n      setIsOpen(false);\n    } catch (error) {\n      console.error(\"Failed to import from deep link:\", error);\n      toast.error(t(\"deeplink.importError\"), {\n        description: error instanceof Error ? error.message : String(error),\n      });\n    } finally {\n      setIsImporting(false);\n    }\n  };\n\n  const handleCancel = () => {\n    setIsOpen(false);\n  };\n\n  // Mask API key for display (show first 4 chars + ***)\n  const maskedApiKey =\n    request?.apiKey && request.apiKey.length > 4\n      ? `${request.apiKey.substring(0, 4)}${\"*\".repeat(20)}`\n      : \"****\";\n\n  // Check if config file is present\n  const hasConfigFile = !!(request?.config || request?.configUrl);\n  const configSource = request?.config\n    ? \"base64\"\n    : request?.configUrl\n      ? \"url\"\n      : null;\n\n  // Parse config file content for display\n  interface ParsedConfig {\n    type: \"claude\" | \"codex\" | \"gemini\";\n    env?: Record<string, string>;\n    auth?: Record<string, string>;\n    tomlConfig?: string;\n    raw: Record<string, unknown>;\n  }\n\n  // Helper to decode base64 with UTF-8 support\n  const b64ToUtf8 = (str: string): string => {\n    try {\n      const binString = atob(str);\n      const bytes = Uint8Array.from(binString, (m) => m.codePointAt(0) || 0);\n      return new TextDecoder().decode(bytes);\n    } catch (e) {\n      console.error(\"Failed to decode base64:\", e);\n      return atob(str);\n    }\n  };\n\n  const parsedConfig = useMemo((): ParsedConfig | null => {\n    if (!request?.config) return null;\n    try {\n      const decoded = b64ToUtf8(request.config);\n      const parsed = JSON.parse(decoded) as Record<string, unknown>;\n\n      if (request.app === \"claude\") {\n        // Claude 格式: { env: { ANTHROPIC_AUTH_TOKEN: ..., ... } }\n        return {\n          type: \"claude\",\n          env: (parsed.env as Record<string, string>) || {},\n          raw: parsed,\n        };\n      } else if (request.app === \"codex\") {\n        // Codex 格式: { auth: { OPENAI_API_KEY: ... }, config: \"TOML string\" }\n        return {\n          type: \"codex\",\n          auth: (parsed.auth as Record<string, string>) || {},\n          tomlConfig: (parsed.config as string) || \"\",\n          raw: parsed,\n        };\n      } else if (request.app === \"gemini\") {\n        // Gemini 格式: 扁平结构 { GEMINI_API_KEY: ..., GEMINI_BASE_URL: ... }\n        return {\n          type: \"gemini\",\n          env: parsed as Record<string, string>,\n          raw: parsed,\n        };\n      }\n      return null;\n    } catch (e) {\n      console.error(\"Failed to parse config:\", e);\n      return null;\n    }\n  }, [request?.config, request?.app]);\n\n  // Helper to mask sensitive values\n  const maskValue = (key: string, value: string): string => {\n    const sensitiveKeys = [\"TOKEN\", \"KEY\", \"SECRET\", \"PASSWORD\"];\n    const isSensitive = sensitiveKeys.some((k) =>\n      key.toUpperCase().includes(k),\n    );\n    if (isSensitive && value.length > 8) {\n      return `${value.substring(0, 8)}${\"*\".repeat(12)}`;\n    }\n    return value;\n  };\n\n  const getTitle = () => {\n    if (!request) return t(\"deeplink.confirmImport\");\n    switch (request.resource) {\n      case \"prompt\":\n        return t(\"deeplink.importPrompt\");\n      case \"mcp\":\n        return t(\"deeplink.importMcp\");\n      case \"skill\":\n        return t(\"deeplink.importSkill\");\n      default:\n        return t(\"deeplink.confirmImport\");\n    }\n  };\n\n  const getDescription = () => {\n    if (!request) return t(\"deeplink.confirmImportDescription\");\n    switch (request.resource) {\n      case \"prompt\":\n        return t(\"deeplink.importPromptDescription\");\n      case \"mcp\":\n        return t(\"deeplink.importMcpDescription\");\n      case \"skill\":\n        return t(\"deeplink.importSkillDescription\");\n      default:\n        return t(\"deeplink.confirmImportDescription\");\n    }\n  };\n\n  return (\n    <Dialog open={isOpen && !!request} onOpenChange={setIsOpen}>\n      <DialogContent className=\"sm:max-w-[500px]\" zIndex=\"top\">\n        {request && (\n          <>\n            {/* 标题显式左对齐，避免默认居中样式影响 */}\n            <DialogHeader className=\"text-left sm:text-left\">\n              <DialogTitle>{getTitle()}</DialogTitle>\n              <DialogDescription>{getDescription()}</DialogDescription>\n            </DialogHeader>\n\n            {/* 主体内容整体右移，略大于标题内边距，让内容看起来不贴边 */}\n            <div className=\"space-y-4 px-8 py-4 max-h-[60vh] overflow-y-auto [scrollbar-width:thin] [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar]:block [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-200 dark:[&::-webkit-scrollbar-thumb]:bg-gray-700\">\n              {request.resource === \"prompt\" && (\n                <PromptConfirmation request={request} />\n              )}\n              {request.resource === \"mcp\" && (\n                <McpConfirmation request={request} />\n              )}\n              {request.resource === \"skill\" && (\n                <SkillConfirmation request={request} />\n              )}\n\n              {/* Legacy Provider View */}\n              {(request.resource === \"provider\" || !request.resource) && (\n                <>\n                  {/* Provider Icon - enlarge and center near the top */}\n                  {request.icon && (\n                    <div className=\"flex justify-center pt-2 pb-1\">\n                      <ProviderIcon\n                        icon={request.icon}\n                        name={request.name || request.icon}\n                        size={80}\n                        className=\"drop-shadow-sm\"\n                      />\n                    </div>\n                  )}\n\n                  {/* App Type */}\n                  <div className=\"grid grid-cols-3 items-center gap-4\">\n                    <div className=\"font-medium text-sm text-muted-foreground\">\n                      {t(\"deeplink.app\")}\n                    </div>\n                    <div className=\"col-span-2 text-sm font-medium capitalize\">\n                      {request.app}\n                    </div>\n                  </div>\n\n                  {/* Provider Name */}\n                  <div className=\"grid grid-cols-3 items-center gap-4\">\n                    <div className=\"font-medium text-sm text-muted-foreground\">\n                      {t(\"deeplink.providerName\")}\n                    </div>\n                    <div className=\"col-span-2 text-sm font-medium\">\n                      {request.name}\n                    </div>\n                  </div>\n\n                  {/* Homepage */}\n                  <div className=\"grid grid-cols-3 items-center gap-4\">\n                    <div className=\"font-medium text-sm text-muted-foreground\">\n                      {t(\"deeplink.homepage\")}\n                    </div>\n                    <div className=\"col-span-2 text-sm break-all text-blue-600 dark:text-blue-400\">\n                      {request.homepage}\n                    </div>\n                  </div>\n\n                  {/* API Endpoint */}\n                  <div className=\"grid grid-cols-3 items-start gap-4\">\n                    <div className=\"font-medium text-sm text-muted-foreground pt-0.5\">\n                      {t(\"deeplink.endpoint\")}\n                    </div>\n                    <div className=\"col-span-2 text-sm break-all space-y-1\">\n                      {request.endpoint?.split(\",\").map((ep, idx) => (\n                        <div\n                          key={idx}\n                          className={\n                            idx === 0 ? \"font-medium\" : \"text-muted-foreground\"\n                          }\n                        >\n                          {idx === 0 ? \"🔹 \" : \"└ \"}\n                          {ep.trim()}\n                          {idx === 0 && request.endpoint?.includes(\",\") && (\n                            <span className=\"text-xs text-muted-foreground ml-2\">\n                              ({t(\"deeplink.primaryEndpoint\")})\n                            </span>\n                          )}\n                        </div>\n                      ))}\n                    </div>\n                  </div>\n\n                  {/* API Key (masked) */}\n                  <div className=\"grid grid-cols-3 items-center gap-4\">\n                    <div className=\"font-medium text-sm text-muted-foreground\">\n                      {t(\"deeplink.apiKey\")}\n                    </div>\n                    <div className=\"col-span-2 text-sm font-mono text-muted-foreground\">\n                      {maskedApiKey}\n                    </div>\n                  </div>\n\n                  {/* Model Fields - 根据应用类型显示不同的模型字段 */}\n                  {request.app === \"claude\" ? (\n                    <>\n                      {/* Claude 四种模型字段 */}\n                      {request.haikuModel && (\n                        <div className=\"grid grid-cols-3 items-center gap-4\">\n                          <div className=\"font-medium text-sm text-muted-foreground\">\n                            {t(\"deeplink.haikuModel\")}\n                          </div>\n                          <div className=\"col-span-2 text-sm font-mono\">\n                            {request.haikuModel}\n                          </div>\n                        </div>\n                      )}\n                      {request.sonnetModel && (\n                        <div className=\"grid grid-cols-3 items-center gap-4\">\n                          <div className=\"font-medium text-sm text-muted-foreground\">\n                            {t(\"deeplink.sonnetModel\")}\n                          </div>\n                          <div className=\"col-span-2 text-sm font-mono\">\n                            {request.sonnetModel}\n                          </div>\n                        </div>\n                      )}\n                      {request.opusModel && (\n                        <div className=\"grid grid-cols-3 items-center gap-4\">\n                          <div className=\"font-medium text-sm text-muted-foreground\">\n                            {t(\"deeplink.opusModel\")}\n                          </div>\n                          <div className=\"col-span-2 text-sm font-mono\">\n                            {request.opusModel}\n                          </div>\n                        </div>\n                      )}\n                      {request.model && (\n                        <div className=\"grid grid-cols-3 items-center gap-4\">\n                          <div className=\"font-medium text-sm text-muted-foreground\">\n                            {t(\"deeplink.multiModel\")}\n                          </div>\n                          <div className=\"col-span-2 text-sm font-mono\">\n                            {request.model}\n                          </div>\n                        </div>\n                      )}\n                    </>\n                  ) : (\n                    <>\n                      {/* Codex 和 Gemini 使用通用 model 字段 */}\n                      {request.model && (\n                        <div className=\"grid grid-cols-3 items-center gap-4\">\n                          <div className=\"font-medium text-sm text-muted-foreground\">\n                            {t(\"deeplink.model\")}\n                          </div>\n                          <div className=\"col-span-2 text-sm font-mono\">\n                            {request.model}\n                          </div>\n                        </div>\n                      )}\n                    </>\n                  )}\n\n                  {/* Notes (if present) */}\n                  {request.notes && (\n                    <div className=\"grid grid-cols-3 items-start gap-4\">\n                      <div className=\"font-medium text-sm text-muted-foreground\">\n                        {t(\"deeplink.notes\")}\n                      </div>\n                      <div className=\"col-span-2 text-sm text-muted-foreground\">\n                        {request.notes}\n                      </div>\n                    </div>\n                  )}\n\n                  {/* Config File Details (v3.8+) */}\n                  {hasConfigFile && (\n                    <div className=\"space-y-3 pt-2 border-t border-border-default\">\n                      <div className=\"grid grid-cols-3 items-center gap-4\">\n                        <div className=\"font-medium text-sm text-muted-foreground\">\n                          {t(\"deeplink.configSource\")}\n                        </div>\n                        <div className=\"col-span-2 text-sm\">\n                          <span className=\"inline-flex items-center px-2 py-0.5 rounded-md bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 text-xs font-medium\">\n                            {configSource === \"base64\"\n                              ? t(\"deeplink.configEmbedded\")\n                              : t(\"deeplink.configRemote\")}\n                          </span>\n                          {request.configFormat && (\n                            <span className=\"ml-2 text-xs text-muted-foreground uppercase\">\n                              {request.configFormat}\n                            </span>\n                          )}\n                        </div>\n                      </div>\n\n                      {/* Parsed Config Details */}\n                      {parsedConfig && (\n                        <div className=\"rounded-lg bg-muted/50 p-3 space-y-2\">\n                          <div className=\"text-xs font-medium text-muted-foreground uppercase tracking-wide\">\n                            {t(\"deeplink.configDetails\")}\n                          </div>\n\n                          {/* Claude config */}\n                          {parsedConfig.type === \"claude\" &&\n                            parsedConfig.env && (\n                              <div className=\"space-y-1.5\">\n                                {Object.entries(parsedConfig.env).map(\n                                  ([key, value]) => (\n                                    <div\n                                      key={key}\n                                      className=\"grid grid-cols-2 gap-2 text-xs\"\n                                    >\n                                      <span className=\"font-mono text-muted-foreground truncate\">\n                                        {key}\n                                      </span>\n                                      <span className=\"font-mono truncate\">\n                                        {maskValue(key, String(value))}\n                                      </span>\n                                    </div>\n                                  ),\n                                )}\n                              </div>\n                            )}\n\n                          {/* Codex config */}\n                          {parsedConfig.type === \"codex\" && (\n                            <div className=\"space-y-2\">\n                              {parsedConfig.auth &&\n                                Object.keys(parsedConfig.auth).length > 0 && (\n                                  <div className=\"space-y-1.5\">\n                                    <div className=\"text-xs text-muted-foreground\">\n                                      Auth:\n                                    </div>\n                                    {Object.entries(parsedConfig.auth).map(\n                                      ([key, value]) => (\n                                        <div\n                                          key={key}\n                                          className=\"grid grid-cols-2 gap-2 text-xs pl-2\"\n                                        >\n                                          <span className=\"font-mono text-muted-foreground truncate\">\n                                            {key}\n                                          </span>\n                                          <span className=\"font-mono truncate\">\n                                            {maskValue(key, String(value))}\n                                          </span>\n                                        </div>\n                                      ),\n                                    )}\n                                  </div>\n                                )}\n                              {parsedConfig.tomlConfig && (\n                                <div className=\"space-y-1\">\n                                  <div className=\"text-xs text-muted-foreground\">\n                                    TOML Config:\n                                  </div>\n                                  <pre className=\"text-xs font-mono bg-background p-2 rounded overflow-x-auto max-h-24 whitespace-pre-wrap\">\n                                    {parsedConfig.tomlConfig.substring(0, 300)}\n                                    {parsedConfig.tomlConfig.length > 300 &&\n                                      \"...\"}\n                                  </pre>\n                                </div>\n                              )}\n                            </div>\n                          )}\n\n                          {/* Gemini config */}\n                          {parsedConfig.type === \"gemini\" &&\n                            parsedConfig.env && (\n                              <div className=\"space-y-1.5\">\n                                {Object.entries(parsedConfig.env).map(\n                                  ([key, value]) => (\n                                    <div\n                                      key={key}\n                                      className=\"grid grid-cols-2 gap-2 text-xs\"\n                                    >\n                                      <span className=\"font-mono text-muted-foreground truncate\">\n                                        {key}\n                                      </span>\n                                      <span className=\"font-mono truncate\">\n                                        {maskValue(key, String(value))}\n                                      </span>\n                                    </div>\n                                  ),\n                                )}\n                              </div>\n                            )}\n                        </div>\n                      )}\n\n                      {/* Config URL (if remote) */}\n                      {request.configUrl && (\n                        <div className=\"grid grid-cols-3 items-center gap-4\">\n                          <div className=\"font-medium text-sm text-muted-foreground\">\n                            {t(\"deeplink.configUrl\")}\n                          </div>\n                          <div className=\"col-span-2 text-sm font-mono text-muted-foreground break-all\">\n                            {request.configUrl}\n                          </div>\n                        </div>\n                      )}\n                    </div>\n                  )}\n\n                  {/* Usage Script Configuration (v3.9+) */}\n                  {request.usageScript && (\n                    <div className=\"space-y-3 pt-2 border-t border-border-default\">\n                      <div className=\"grid grid-cols-3 items-center gap-4\">\n                        <div className=\"font-medium text-sm text-muted-foreground\">\n                          {t(\"deeplink.usageScript\", {\n                            defaultValue: \"用量查询\",\n                          })}\n                        </div>\n                        <div className=\"col-span-2 text-sm\">\n                          <span\n                            className={`inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium ${\n                              request.usageEnabled !== false\n                                ? \"bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300\"\n                                : \"bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400\"\n                            }`}\n                          >\n                            {request.usageEnabled !== false\n                              ? t(\"deeplink.usageScriptEnabled\", {\n                                  defaultValue: \"已启用\",\n                                })\n                              : t(\"deeplink.usageScriptDisabled\", {\n                                  defaultValue: \"未启用\",\n                                })}\n                          </span>\n                        </div>\n                      </div>\n\n                      {/* Usage API Key (if different from provider) */}\n                      {request.usageApiKey &&\n                        request.usageApiKey !== request.apiKey && (\n                          <div className=\"grid grid-cols-3 items-center gap-4\">\n                            <div className=\"font-medium text-sm text-muted-foreground\">\n                              {t(\"deeplink.usageApiKey\", {\n                                defaultValue: \"用量 API Key\",\n                              })}\n                            </div>\n                            <div className=\"col-span-2 text-sm font-mono text-muted-foreground\">\n                              {request.usageApiKey.length > 4\n                                ? `${request.usageApiKey.substring(0, 4)}${\"*\".repeat(12)}`\n                                : \"****\"}\n                            </div>\n                          </div>\n                        )}\n\n                      {/* Usage Base URL (if different from provider) */}\n                      {request.usageBaseUrl &&\n                        request.usageBaseUrl !== request.endpoint && (\n                          <div className=\"grid grid-cols-3 items-center gap-4\">\n                            <div className=\"font-medium text-sm text-muted-foreground\">\n                              {t(\"deeplink.usageBaseUrl\", {\n                                defaultValue: \"用量查询地址\",\n                              })}\n                            </div>\n                            <div className=\"col-span-2 text-sm break-all\">\n                              {request.usageBaseUrl}\n                            </div>\n                          </div>\n                        )}\n\n                      {/* Auto Query Interval */}\n                      {request.usageAutoInterval &&\n                        request.usageAutoInterval > 0 && (\n                          <div className=\"grid grid-cols-3 items-center gap-4\">\n                            <div className=\"font-medium text-sm text-muted-foreground\">\n                              {t(\"deeplink.usageAutoInterval\", {\n                                defaultValue: \"自动查询\",\n                              })}\n                            </div>\n                            <div className=\"col-span-2 text-sm\">\n                              {t(\"deeplink.usageAutoIntervalValue\", {\n                                defaultValue: \"每 {{minutes}} 分钟\",\n                                minutes: request.usageAutoInterval,\n                              })}\n                            </div>\n                          </div>\n                        )}\n                    </div>\n                  )}\n\n                  {/* Warning */}\n                  <div className=\"rounded-lg bg-yellow-50 dark:bg-yellow-900/20 p-3 text-sm text-yellow-800 dark:text-yellow-200\">\n                    {t(\"deeplink.warning\")}\n                  </div>\n                </>\n              )}\n            </div>\n\n            <DialogFooter>\n              <Button\n                variant=\"outline\"\n                onClick={handleCancel}\n                disabled={isImporting}\n              >\n                {t(\"common.cancel\")}\n              </Button>\n              <Button onClick={handleImport} disabled={isImporting}>\n                {isImporting ? t(\"deeplink.importing\") : t(\"deeplink.import\")}\n              </Button>\n            </DialogFooter>\n          </>\n        )}\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "src/components/IconPicker.tsx",
    "content": "import React, { useState, useMemo } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { ProviderIcon } from \"./ProviderIcon\";\nimport { iconList } from \"@/icons/extracted\";\nimport { searchIcons, getIconMetadata } from \"@/icons/extracted/metadata\";\nimport { cn } from \"@/lib/utils\";\n\ninterface IconPickerProps {\n  value?: string; // 当前选中的图标\n  onValueChange: (icon: string) => void; // 选择回调\n  color?: string; // 预览颜色\n}\n\nexport const IconPicker: React.FC<IconPickerProps> = ({\n  value,\n  onValueChange,\n}) => {\n  const { t } = useTranslation();\n  const [searchQuery, setSearchQuery] = useState(\"\");\n\n  // 过滤图标列表\n  const filteredIcons = useMemo(() => {\n    if (!searchQuery) return iconList;\n    return searchIcons(searchQuery);\n  }, [searchQuery]);\n\n  return (\n    <div className=\"space-y-4\">\n      <div>\n        <Label htmlFor=\"icon-search\">\n          {t(\"iconPicker.search\", { defaultValue: \"搜索图标\" })}\n        </Label>\n        <Input\n          id=\"icon-search\"\n          type=\"text\"\n          placeholder={t(\"iconPicker.searchPlaceholder\", {\n            defaultValue: \"输入图标名称...\",\n          })}\n          value={searchQuery}\n          onChange={(e) => setSearchQuery(e.target.value)}\n          className=\"mt-2\"\n        />\n      </div>\n\n      <div className=\"max-h-[65vh] overflow-y-auto pr-1\">\n        <div className=\"grid grid-cols-6 sm:grid-cols-8 lg:grid-cols-10 gap-2\">\n          {filteredIcons.map((iconName) => {\n            const meta = getIconMetadata(iconName);\n            const isSelected = value === iconName;\n\n            return (\n              <button\n                key={iconName}\n                type=\"button\"\n                onClick={() => onValueChange(iconName)}\n                className={cn(\n                  \"flex flex-col items-center gap-1 p-3 rounded-lg\",\n                  \"border-2 transition-all duration-200\",\n                  \"hover:bg-accent hover:border-primary/50\",\n                  isSelected\n                    ? \"border-primary bg-primary/10\"\n                    : \"border-transparent\",\n                )}\n                title={meta?.displayName || iconName}\n              >\n                <ProviderIcon icon={iconName} name={iconName} size={32} />\n                <span className=\"text-xs text-muted-foreground truncate w-full text-center\">\n                  {meta?.displayName || iconName}\n                </span>\n              </button>\n            );\n          })}\n        </div>\n      </div>\n\n      {filteredIcons.length === 0 && (\n        <div className=\"text-center py-8 text-muted-foreground\">\n          {t(\"iconPicker.noResults\", { defaultValue: \"未找到匹配的图标\" })}\n        </div>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "src/components/JsonEditor.tsx",
    "content": "import React, { useRef, useEffect, useMemo } from \"react\";\nimport { EditorView, basicSetup } from \"codemirror\";\nimport { json } from \"@codemirror/lang-json\";\nimport { javascript } from \"@codemirror/lang-javascript\";\nimport { oneDark } from \"@codemirror/theme-one-dark\";\nimport { EditorState } from \"@codemirror/state\";\nimport { placeholder } from \"@codemirror/view\";\nimport { linter, Diagnostic } from \"@codemirror/lint\";\nimport { useTranslation } from \"react-i18next\";\nimport { Wand2 } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { formatJSON } from \"@/utils/formatters\";\n\ninterface JsonEditorProps {\n  id?: string;\n  value: string;\n  onChange: (value: string) => void;\n  placeholder?: string;\n  darkMode?: boolean;\n  rows?: number;\n  showValidation?: boolean;\n  language?: \"json\" | \"javascript\";\n  height?: string | number;\n  showMinimap?: boolean; // 添加此属性以防未来使用\n}\n\nconst JsonEditor: React.FC<JsonEditorProps> = ({\n  value,\n  onChange,\n  placeholder: placeholderText = \"\",\n  darkMode = false,\n  rows = 12,\n  showValidation = true,\n  language = \"json\",\n  height,\n}) => {\n  const { t } = useTranslation();\n  const editorRef = useRef<HTMLDivElement>(null);\n  const viewRef = useRef<EditorView | null>(null);\n\n  // JSON linter 函数\n  const jsonLinter = useMemo(\n    () =>\n      linter((view) => {\n        const diagnostics: Diagnostic[] = [];\n        if (!showValidation || language !== \"json\") return diagnostics;\n\n        const doc = view.state.doc.toString();\n        if (!doc.trim()) return diagnostics;\n\n        try {\n          const parsed = JSON.parse(doc);\n          // 检查是否是JSON对象\n          if (parsed && typeof parsed === \"object\" && !Array.isArray(parsed)) {\n            // 格式正确\n          } else {\n            diagnostics.push({\n              from: 0,\n              to: doc.length,\n              severity: \"error\",\n              message: t(\"jsonEditor.mustBeObject\"),\n            });\n          }\n        } catch (e) {\n          // 简单处理JSON解析错误\n          const message =\n            e instanceof SyntaxError ? e.message : t(\"jsonEditor.invalidJson\");\n          diagnostics.push({\n            from: 0,\n            to: doc.length,\n            severity: \"error\",\n            message,\n          });\n        }\n\n        return diagnostics;\n      }),\n    [showValidation, language, t],\n  );\n\n  useEffect(() => {\n    if (!editorRef.current) return;\n\n    // 创建编辑器扩展\n    const minHeightPx = height ? undefined : Math.max(1, rows) * 18;\n\n    // 使用 baseTheme 定义基础样式，优先级低于 oneDark，但可以正确响应主题\n    const baseTheme = EditorView.baseTheme({\n      \".cm-editor\": {\n        border: \"1px solid hsl(var(--border))\",\n        borderRadius: \"0.5rem\",\n        background: \"transparent\",\n      },\n      \".cm-editor.cm-focused\": {\n        outline: \"none\",\n        borderColor: \"hsl(var(--primary))\",\n      },\n      \".cm-scroller\": {\n        background: \"transparent\",\n      },\n      \".cm-gutters\": {\n        background: \"transparent\",\n        borderRight: \"1px solid hsl(var(--border))\",\n        color: \"hsl(var(--muted-foreground))\",\n      },\n      \".cm-selectionBackground, .cm-content ::selection\": {\n        background: \"hsl(var(--primary) / 0.18)\",\n      },\n      \".cm-selectionMatch\": {\n        background: \"hsl(var(--primary) / 0.12)\",\n      },\n      \".cm-activeLine\": {\n        background: \"hsl(var(--primary) / 0.08)\",\n      },\n      \".cm-activeLineGutter\": {\n        background: \"hsl(var(--primary) / 0.08)\",\n      },\n    });\n\n    // 使用 theme 定义尺寸和字体样式\n    const heightValue = height\n      ? typeof height === \"number\"\n        ? `${height}px`\n        : height\n      : undefined;\n    const sizingTheme = EditorView.theme({\n      \"&\": heightValue\n        ? { height: heightValue }\n        : { minHeight: `${minHeightPx}px` },\n      \".cm-scroller\": { overflow: \"auto\" },\n      \".cm-content\": {\n        fontFamily:\n          \"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace\",\n        fontSize: \"14px\",\n      },\n    });\n\n    const extensions = [\n      basicSetup,\n      language === \"javascript\" ? javascript() : json(),\n      placeholder(placeholderText || \"\"),\n      baseTheme,\n      sizingTheme,\n      jsonLinter,\n      EditorView.updateListener.of((update) => {\n        if (update.docChanged) {\n          const newValue = update.state.doc.toString();\n          onChange(newValue);\n        }\n      }),\n    ];\n\n    // 如果启用深色模式，添加深色主题\n    if (darkMode) {\n      extensions.push(oneDark);\n      // 在 oneDark 之后强制覆盖边框样式\n      extensions.push(\n        EditorView.theme({\n          \".cm-editor\": {\n            border: \"1px solid hsl(var(--border))\",\n            borderRadius: \"0.5rem\",\n            background: \"transparent\",\n          },\n          \".cm-editor.cm-focused\": {\n            outline: \"none\",\n            borderColor: \"hsl(var(--primary))\",\n          },\n          \".cm-scroller\": {\n            background: \"transparent\",\n          },\n          \".cm-gutters\": {\n            background: \"transparent\",\n            borderRight: \"1px solid hsl(var(--border))\",\n            color: \"hsl(var(--muted-foreground))\",\n          },\n          \".cm-selectionBackground, .cm-content ::selection\": {\n            background: \"hsl(var(--primary) / 0.18)\",\n          },\n          \".cm-selectionMatch\": {\n            background: \"hsl(var(--primary) / 0.12)\",\n          },\n          \".cm-activeLine\": {\n            background: \"hsl(var(--primary) / 0.08)\",\n          },\n          \".cm-activeLineGutter\": {\n            background: \"hsl(var(--primary) / 0.08)\",\n          },\n        }),\n      );\n    }\n\n    // 创建初始状态\n    const state = EditorState.create({\n      doc: value,\n      extensions,\n    });\n\n    // 创建编辑器视图\n    const view = new EditorView({\n      state,\n      parent: editorRef.current,\n    });\n\n    viewRef.current = view;\n\n    // 清理函数\n    return () => {\n      view.destroy();\n      viewRef.current = null;\n    };\n  }, [darkMode, rows, height, language, jsonLinter]); // 依赖项中不包含 onChange 和 placeholder，避免不必要的重建\n\n  // 当 value 从外部改变时更新编辑器内容\n  useEffect(() => {\n    if (viewRef.current && viewRef.current.state.doc.toString() !== value) {\n      const transaction = viewRef.current.state.update({\n        changes: {\n          from: 0,\n          to: viewRef.current.state.doc.length,\n          insert: value,\n        },\n      });\n      viewRef.current.dispatch(transaction);\n    }\n  }, [value]);\n\n  // 格式化处理函数\n  const handleFormat = () => {\n    if (!viewRef.current) return;\n\n    const currentValue = viewRef.current.state.doc.toString();\n    if (!currentValue.trim()) return;\n\n    try {\n      const formatted = formatJSON(currentValue);\n      onChange(formatted);\n      toast.success(t(\"common.formatSuccess\", { defaultValue: \"格式化成功\" }), {\n        closeButton: true,\n      });\n    } catch (error) {\n      const errorMessage =\n        error instanceof Error ? error.message : String(error);\n      toast.error(\n        t(\"common.formatError\", {\n          defaultValue: \"格式化失败：{{error}}\",\n          error: errorMessage,\n        }),\n      );\n    }\n  };\n\n  const isFullHeight = height === \"100%\";\n\n  return (\n    <div\n      style={{ width: \"100%\", height: isFullHeight ? \"100%\" : \"auto\" }}\n      className={isFullHeight ? \"flex flex-col\" : \"\"}\n    >\n      <div\n        ref={editorRef}\n        style={{ width: \"100%\", height: isFullHeight ? undefined : \"auto\" }}\n        className={isFullHeight ? \"flex-1 min-h-0\" : \"\"}\n      />\n      {language === \"json\" && (\n        <button\n          type=\"button\"\n          onClick={handleFormat}\n          className={`${isFullHeight ? \"mt-2 flex-shrink-0\" : \"mt-2\"} inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors`}\n        >\n          <Wand2 className=\"w-3.5 h-3.5\" />\n          {t(\"common.format\", { defaultValue: \"格式化\" })}\n        </button>\n      )}\n    </div>\n  );\n};\n\nexport default JsonEditor;\n"
  },
  {
    "path": "src/components/MarkdownEditor.tsx",
    "content": "import React, { useRef, useEffect } from \"react\";\nimport { EditorView, basicSetup } from \"codemirror\";\nimport { markdown } from \"@codemirror/lang-markdown\";\nimport { oneDark } from \"@codemirror/theme-one-dark\";\nimport { EditorState } from \"@codemirror/state\";\nimport { placeholder as placeholderExt } from \"@codemirror/view\";\n\ninterface MarkdownEditorProps {\n  value: string;\n  onChange?: (value: string) => void;\n  placeholder?: string;\n  darkMode?: boolean;\n  readOnly?: boolean;\n  className?: string;\n  minHeight?: string;\n  maxHeight?: string;\n}\n\nconst MarkdownEditor: React.FC<MarkdownEditorProps> = ({\n  value,\n  onChange,\n  placeholder: placeholderText = \"\",\n  darkMode = false,\n  readOnly = false,\n  className = \"\",\n  minHeight = \"300px\",\n  maxHeight,\n}) => {\n  const editorRef = useRef<HTMLDivElement>(null);\n  const viewRef = useRef<EditorView | null>(null);\n\n  useEffect(() => {\n    if (!editorRef.current) return;\n\n    // 定义基础主题\n    const baseTheme = EditorView.baseTheme({\n      \"&\": {\n        height: \"100%\",\n        minHeight,\n        maxHeight: maxHeight || \"none\",\n      },\n      \".cm-scroller\": {\n        overflow: \"auto\",\n        fontFamily:\n          \"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace\",\n        fontSize: \"14px\",\n      },\n      \"&light .cm-content, &dark .cm-content\": {\n        padding: \"12px 0\",\n      },\n      \"&light .cm-editor, &dark .cm-editor\": {\n        backgroundColor: \"transparent\",\n      },\n      \"&.cm-focused\": {\n        outline: \"none\",\n      },\n    });\n\n    const extensions = [\n      basicSetup,\n      markdown(),\n      baseTheme,\n      EditorView.lineWrapping,\n      EditorState.readOnly.of(readOnly),\n    ];\n\n    if (!readOnly) {\n      extensions.push(\n        placeholderExt(placeholderText),\n        EditorView.updateListener.of((update) => {\n          if (update.docChanged && onChange) {\n            onChange(update.state.doc.toString());\n          }\n        }),\n      );\n    } else {\n      // 只读模式下隐藏光标和高亮行\n      extensions.push(\n        EditorView.theme({\n          \".cm-cursor, .cm-dropCursor\": { border: \"none\" },\n          \".cm-activeLine\": { backgroundColor: \"transparent !important\" },\n          \".cm-activeLineGutter\": { backgroundColor: \"transparent !important\" },\n        }),\n      );\n    }\n\n    // 如果启用深色模式，添加深色主题\n    if (darkMode) {\n      extensions.push(oneDark);\n    } else {\n      // 浅色模式下的简单样式调整，使其更融入 UI\n      extensions.push(\n        EditorView.theme(\n          {\n            \"&\": {\n              backgroundColor: \"transparent\",\n            },\n            \".cm-content\": {\n              color: \"#374151\", // text-gray-700\n            },\n            \".cm-gutters\": {\n              backgroundColor: \"#f9fafb\", // bg-gray-50\n              color: \"#9ca3af\", // text-gray-400\n              borderRight: \"1px solid #e5e7eb\", // border-gray-200\n            },\n            \".cm-activeLineGutter\": {\n              backgroundColor: \"#e5e7eb\",\n            },\n          },\n          { dark: false },\n        ),\n      );\n    }\n\n    // 创建初始状态\n    const state = EditorState.create({\n      doc: value,\n      extensions,\n    });\n\n    // 创建编辑器视图\n    const view = new EditorView({\n      state,\n      parent: editorRef.current,\n    });\n\n    viewRef.current = view;\n\n    return () => {\n      view.destroy();\n      viewRef.current = null;\n    };\n  }, [darkMode, readOnly, minHeight, maxHeight, placeholderText]); // 添加 placeholderText 依赖以支持国际化切换\n\n  // 当 value 从外部改变时更新编辑器内容\n  useEffect(() => {\n    if (viewRef.current && viewRef.current.state.doc.toString() !== value) {\n      const transaction = viewRef.current.state.update({\n        changes: {\n          from: 0,\n          to: viewRef.current.state.doc.length,\n          insert: value,\n        },\n      });\n      viewRef.current.dispatch(transaction);\n    }\n  }, [value]);\n\n  return (\n    <div\n      ref={editorRef}\n      className={`border rounded-md overflow-hidden ${\n        darkMode ? \"border-gray-800\" : \"border-gray-200\"\n      } ${className}`}\n    />\n  );\n};\n\nexport default MarkdownEditor;\n"
  },
  {
    "path": "src/components/ProviderIcon.tsx",
    "content": "import React, { useMemo } from \"react\";\nimport { getIcon, hasIcon, getIconMetadata } from \"@/icons/extracted\";\nimport { cn } from \"@/lib/utils\";\n\ninterface ProviderIconProps {\n  icon?: string; // 图标名称\n  name: string; // 供应商名称（用于 fallback）\n  color?: string; // 自定义颜色 (Deprecated, kept for compatibility but ignored for SVG)\n  size?: number | string; // 尺寸\n  className?: string;\n  showFallback?: boolean; // 是否显示 fallback\n}\n\nexport const ProviderIcon: React.FC<ProviderIconProps> = ({\n  icon,\n  name,\n  color,\n  size = 32,\n  className,\n  showFallback = true,\n}) => {\n  // 获取图标 SVG\n  const iconSvg = useMemo(() => {\n    if (icon && hasIcon(icon)) {\n      return getIcon(icon);\n    }\n    return \"\";\n  }, [icon]);\n\n  // 计算尺寸样式\n  const sizeStyle = useMemo(() => {\n    const sizeValue = typeof size === \"number\" ? `${size}px` : size;\n    return {\n      width: sizeValue,\n      height: sizeValue,\n      // 内嵌 SVG 使用 1em 作为尺寸基准，这里同步 fontSize 让图标实际跟随 size 放大\n      fontSize: sizeValue,\n      lineHeight: 1,\n    };\n  }, [size]);\n\n  // 获取有效颜色：优先使用传入的有效 color，否则从元数据获取 defaultColor\n  const effectiveColor = useMemo(() => {\n    // 只有当 color 是有效的非空字符串时才使用\n    if (color && typeof color === \"string\" && color.trim() !== \"\") {\n      return color;\n    }\n    // 否则从元数据获取 defaultColor\n    if (icon) {\n      const metadata = getIconMetadata(icon);\n      // 只有当 defaultColor 不是 currentColor 时才使用\n      if (metadata?.defaultColor && metadata.defaultColor !== \"currentColor\") {\n        return metadata.defaultColor;\n      }\n    }\n    return undefined;\n  }, [color, icon]);\n\n  // 如果有图标，显示图标\n  if (iconSvg) {\n    return (\n      <span\n        className={cn(\n          \"inline-flex items-center justify-center flex-shrink-0\",\n          className,\n        )}\n        style={{ ...sizeStyle, color: effectiveColor }}\n        dangerouslySetInnerHTML={{ __html: iconSvg }}\n      />\n    );\n  }\n\n  // Fallback：显示首字母\n  if (showFallback) {\n    const initials = name\n      .split(\" \")\n      .map((word) => word[0])\n      .join(\"\")\n      .toUpperCase()\n      .slice(0, 2);\n    const fallbackFontSize =\n      typeof size === \"number\" ? `${Math.max(size * 0.5, 12)}px` : \"0.5em\";\n    return (\n      <span\n        className={cn(\n          \"inline-flex items-center justify-center flex-shrink-0 rounded-lg\",\n          \"bg-muted text-muted-foreground font-semibold\",\n          className,\n        )}\n        style={sizeStyle}\n      >\n        <span\n          style={{\n            fontSize: fallbackFontSize,\n          }}\n        >\n          {initials}\n        </span>\n      </span>\n    );\n  }\n\n  return null;\n};\n"
  },
  {
    "path": "src/components/UpdateBadge.tsx",
    "content": "import { useUpdate } from \"@/contexts/UpdateContext\";\nimport { useTranslation } from \"react-i18next\";\nimport { Button } from \"@/components/ui/button\";\nimport { ArrowUpCircle } from \"lucide-react\";\n\ninterface UpdateBadgeProps {\n  className?: string;\n  onClick?: () => void;\n}\n\nexport function UpdateBadge({ className = \"\", onClick }: UpdateBadgeProps) {\n  const { hasUpdate, updateInfo } = useUpdate();\n  const { t } = useTranslation();\n  const isActive = hasUpdate && updateInfo;\n  const title = isActive\n    ? t(\"settings.updateAvailable\", {\n        version: updateInfo?.availableVersion ?? \"\",\n      })\n    : t(\"settings.checkForUpdates\");\n\n  if (!isActive) {\n    return null;\n  }\n\n  return (\n    <Button\n      type=\"button\"\n      variant=\"ghost\"\n      size=\"icon\"\n      title={title}\n      aria-label={title}\n      onClick={onClick}\n      className={`\n        relative h-8 w-8 rounded-full\n        ${isActive ? \"text-green-600 dark:text-green-400 hover:bg-green-50 dark:hover:bg-green-500/10\" : \"text-muted-foreground hover:bg-muted/60\"}\n        ${className}\n      `}\n    >\n      <ArrowUpCircle className=\"h-5 w-5\" />\n    </Button>\n  );\n}\n"
  },
  {
    "path": "src/components/UsageFooter.tsx",
    "content": "import React from \"react\";\nimport { RefreshCw, AlertCircle, Clock } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\nimport { type AppId } from \"@/lib/api\";\nimport { useUsageQuery } from \"@/lib/query/queries\";\nimport { UsageData, Provider } from \"@/types\";\n\ninterface UsageFooterProps {\n  provider: Provider;\n  providerId: string;\n  appId: AppId;\n  usageEnabled: boolean; // 是否启用了用量查询\n  isCurrent: boolean; // 是否为当前激活的供应商\n  isInConfig?: boolean; // OpenCode: 是否已添加到配置\n  inline?: boolean; // 是否内联显示（在按钮左侧）\n}\n\nconst UsageFooter: React.FC<UsageFooterProps> = ({\n  provider,\n  providerId,\n  appId,\n  usageEnabled,\n  isCurrent,\n  isInConfig = false,\n  inline = false,\n}) => {\n  const { t } = useTranslation();\n\n  // 统一的用量查询（自动查询仅对当前激活的供应商启用）\n  // OpenCode（累加模式）：使用 isInConfig 代替 isCurrent\n  const shouldAutoQuery = appId === \"opencode\" ? isInConfig : isCurrent;\n  const autoQueryInterval = shouldAutoQuery\n    ? provider.meta?.usage_script?.autoQueryInterval || 0\n    : 0;\n\n  const {\n    data: usage,\n    isFetching: loading,\n    lastQueriedAt,\n    refetch,\n  } = useUsageQuery(providerId, appId, {\n    enabled: usageEnabled,\n    autoQueryInterval,\n  });\n\n  // 🆕 定期更新当前时间，用于刷新相对时间显示\n  const [now, setNow] = React.useState(Date.now());\n\n  React.useEffect(() => {\n    if (!lastQueriedAt) return;\n\n    // 每30秒更新一次当前时间，触发相对时间显示的刷新\n    const interval = setInterval(() => {\n      setNow(Date.now());\n    }, 30000); // 30秒\n\n    return () => clearInterval(interval);\n  }, [lastQueriedAt]);\n\n  // 只在启用用量查询且有数据时显示\n  if (!usageEnabled || !usage) return null;\n\n  // 错误状态\n  if (!usage.success) {\n    if (inline) {\n      return (\n        <div className=\"inline-flex items-center gap-2 text-xs rounded-lg border border-border-default bg-card px-3 py-2 shadow-sm\">\n          <div className=\"flex items-center gap-1.5 text-red-500 dark:text-red-400\">\n            <AlertCircle size={12} />\n            <span>{t(\"usage.queryFailed\")}</span>\n          </div>\n          <button\n            onClick={() => refetch()}\n            disabled={loading}\n            className=\"p-1 rounded hover:bg-muted transition-colors disabled:opacity-50 flex-shrink-0\"\n            title={t(\"usage.refreshUsage\")}\n          >\n            <RefreshCw size={12} className={loading ? \"animate-spin\" : \"\"} />\n          </button>\n        </div>\n      );\n    }\n\n    return (\n      <div className=\"mt-3 rounded-xl border border-border-default bg-card px-4 py-3 shadow-sm\">\n        <div className=\"flex items-center justify-between gap-2 text-xs\">\n          <div className=\"flex items-center gap-2 text-red-500 dark:text-red-400\">\n            <AlertCircle size={14} />\n            <span>{usage.error || t(\"usage.queryFailed\")}</span>\n          </div>\n\n          {/* 刷新按钮 */}\n          <button\n            onClick={() => refetch()}\n            disabled={loading}\n            className=\"p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50 flex-shrink-0\"\n            title={t(\"usage.refreshUsage\")}\n          >\n            <RefreshCw size={12} className={loading ? \"animate-spin\" : \"\"} />\n          </button>\n        </div>\n      </div>\n    );\n  }\n\n  const usageDataList = usage.data || [];\n\n  // 无数据时不显示\n  if (usageDataList.length === 0) return null;\n\n  // 内联模式：仅显示第一个套餐的核心数据（分上下两行）\n  if (inline) {\n    const firstUsage = usageDataList[0];\n    const isExpired = firstUsage.isValid === false;\n\n    return (\n      <div className=\"flex flex-col items-end gap-1 text-xs whitespace-nowrap flex-shrink-0\">\n        {/* 第一行：更新时间和刷新按钮 */}\n        <div className=\"flex items-center gap-2 justify-end\">\n          {/* 上次查询时间 */}\n          <span className=\"text-[10px] text-muted-foreground/70 flex items-center gap-1\">\n            <Clock size={10} />\n            {lastQueriedAt\n              ? formatRelativeTime(lastQueriedAt, now, t)\n              : t(\"usage.never\", { defaultValue: \"从未更新\" })}\n          </span>\n\n          {/* 刷新按钮 */}\n          <button\n            onClick={(e) => {\n              e.stopPropagation();\n              refetch();\n            }}\n            disabled={loading}\n            className=\"p-1 rounded hover:bg-muted transition-colors disabled:opacity-50 flex-shrink-0 text-muted-foreground\"\n            title={t(\"usage.refreshUsage\")}\n          >\n            <RefreshCw size={12} className={loading ? \"animate-spin\" : \"\"} />\n          </button>\n        </div>\n\n        {/* 第二行：用量和剩余 */}\n        <div className=\"flex items-center gap-2\">\n          {/* 已用 */}\n          {firstUsage.used !== undefined && (\n            <div className=\"flex items-center gap-0.5\">\n              <span className=\"text-gray-500 dark:text-gray-400\">\n                {t(\"usage.used\")}\n              </span>\n              <span className=\"tabular-nums text-gray-600 dark:text-gray-400 font-medium\">\n                {firstUsage.used.toFixed(2)}\n              </span>\n            </div>\n          )}\n\n          {/* 剩余 */}\n          {firstUsage.remaining !== undefined && (\n            <div className=\"flex items-center gap-0.5\">\n              <span className=\"text-gray-500 dark:text-gray-400\">\n                {t(\"usage.remaining\")}\n              </span>\n              <span\n                className={`font-semibold tabular-nums ${\n                  isExpired\n                    ? \"text-red-500 dark:text-red-400\"\n                    : firstUsage.remaining <\n                        (firstUsage.total || firstUsage.remaining) * 0.1\n                      ? \"text-orange-500 dark:text-orange-400\"\n                      : \"text-green-600 dark:text-green-400\"\n                }`}\n              >\n                {firstUsage.remaining.toFixed(2)}\n              </span>\n            </div>\n          )}\n\n          {/* 单位 */}\n          {firstUsage.unit && (\n            <span className=\"text-gray-500 dark:text-gray-400\">\n              {firstUsage.unit}\n            </span>\n          )}\n\n          {/* 扩展字段 extra */}\n          {firstUsage.extra && (\n            <span\n              className=\"text-gray-500 dark:text-gray-400 truncate max-w-[150px]\"\n              title={firstUsage.extra}\n            >\n              {firstUsage.extra}\n            </span>\n          )}\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"mt-3 rounded-xl border border-border-default bg-card px-4 py-3 shadow-sm\">\n      {/* 标题行：包含刷新按钮和自动查询时间 */}\n      <div className=\"flex items-center justify-between mb-2\">\n        <span className=\"text-xs text-gray-500 dark:text-gray-400 font-medium\">\n          {t(\"usage.planUsage\")}\n        </span>\n        <div className=\"flex items-center gap-2\">\n          {/* 自动查询时间提示 */}\n          {lastQueriedAt && (\n            <span className=\"text-[10px] text-muted-foreground/70 flex items-center gap-1\">\n              <Clock size={10} />\n              {formatRelativeTime(lastQueriedAt, now, t)}\n            </span>\n          )}\n          <button\n            onClick={() => refetch()}\n            disabled={loading}\n            className=\"p-1 rounded hover:bg-muted transition-colors disabled:opacity-50\"\n            title={t(\"usage.refreshUsage\")}\n          >\n            <RefreshCw size={12} className={loading ? \"animate-spin\" : \"\"} />\n          </button>\n        </div>\n      </div>\n\n      {/* 套餐列表 */}\n      <div className=\"flex flex-col gap-3\">\n        {usageDataList.map((usageData, index) => (\n          <UsagePlanItem key={index} data={usageData} />\n        ))}\n      </div>\n    </div>\n  );\n};\n\n// 单个套餐数据展示组件\nconst UsagePlanItem: React.FC<{ data: UsageData }> = ({ data }) => {\n  const { t } = useTranslation();\n  const {\n    planName,\n    extra,\n    isValid,\n    invalidMessage,\n    total,\n    used,\n    remaining,\n    unit,\n  } = data;\n\n  // 判断套餐是否失效（isValid 为 false 或未定义时视为有效）\n  const isExpired = isValid === false;\n\n  return (\n    <div className=\"flex items-center gap-3\">\n      {/* 标题部分：25% */}\n      <div\n        className=\"text-xs text-gray-500 dark:text-gray-400 min-w-0\"\n        style={{ width: \"25%\" }}\n      >\n        {planName ? (\n          <span\n            className={`font-medium truncate block ${isExpired ? \"text-red-500 dark:text-red-400\" : \"\"}`}\n            title={planName}\n          >\n            💰 {planName}\n          </span>\n        ) : (\n          <span className=\"opacity-50\">—</span>\n        )}\n      </div>\n\n      {/* 扩展字段：30% */}\n      <div\n        className=\"text-xs text-gray-500 dark:text-gray-400 min-w-0 flex items-center gap-2\"\n        style={{ width: \"30%\" }}\n      >\n        {extra && (\n          <span\n            className={`truncate ${isExpired ? \"text-red-500 dark:text-red-400\" : \"\"}`}\n            title={extra}\n          >\n            {extra}\n          </span>\n        )}\n        {isExpired && (\n          <span className=\"text-red-500 dark:text-red-400 font-medium text-[10px] px-1.5 py-0.5 bg-red-50 dark:bg-red-900/20 rounded flex-shrink-0\">\n            {invalidMessage || t(\"usage.invalid\")}\n          </span>\n        )}\n      </div>\n\n      {/* 用量信息：45% */}\n      <div\n        className=\"flex items-center justify-end gap-2 text-xs flex-shrink-0\"\n        style={{ width: \"45%\" }}\n      >\n        {/* 总额度 */}\n        {total !== undefined && (\n          <>\n            <span className=\"text-gray-500 dark:text-gray-400\">\n              {t(\"usage.total\")}\n            </span>\n            <span className=\"tabular-nums text-gray-600 dark:text-gray-400\">\n              {total === -1 ? \"∞\" : total.toFixed(2)}\n            </span>\n            <span className=\"text-gray-400 dark:text-gray-600\">|</span>\n          </>\n        )}\n\n        {/* 已用额度 */}\n        {used !== undefined && (\n          <>\n            <span className=\"text-gray-500 dark:text-gray-400\">\n              {t(\"usage.used\")}\n            </span>\n            <span className=\"tabular-nums text-gray-600 dark:text-gray-400\">\n              {used.toFixed(2)}\n            </span>\n            <span className=\"text-gray-400 dark:text-gray-600\">|</span>\n          </>\n        )}\n\n        {/* 剩余额度 - 突出显示 */}\n        {remaining !== undefined && (\n          <>\n            <span className=\"text-gray-500 dark:text-gray-400\">\n              {t(\"usage.remaining\")}\n            </span>\n            <span\n              className={`font-semibold tabular-nums ${\n                isExpired\n                  ? \"text-red-500 dark:text-red-400\"\n                  : remaining < (total || remaining) * 0.1\n                    ? \"text-orange-500 dark:text-orange-400\"\n                    : \"text-green-600 dark:text-green-400\"\n              }`}\n            >\n              {remaining.toFixed(2)}\n            </span>\n          </>\n        )}\n\n        {unit && (\n          <span className=\"text-gray-500 dark:text-gray-400\">{unit}</span>\n        )}\n      </div>\n    </div>\n  );\n};\n\n// 格式化相对时间\nfunction formatRelativeTime(\n  timestamp: number,\n  now: number,\n  t: (key: string, options?: { count?: number }) => string,\n): string {\n  const diff = Math.floor((now - timestamp) / 1000); // 秒\n\n  if (diff < 60) {\n    return t(\"usage.justNow\");\n  } else if (diff < 3600) {\n    const minutes = Math.floor(diff / 60);\n    return t(\"usage.minutesAgo\", { count: minutes });\n  } else if (diff < 86400) {\n    const hours = Math.floor(diff / 3600);\n    return t(\"usage.hoursAgo\", { count: hours });\n  } else {\n    const days = Math.floor(diff / 86400);\n    return t(\"usage.daysAgo\", { count: days });\n  }\n}\n\nexport default UsageFooter;\n"
  },
  {
    "path": "src/components/UsageScriptModal.tsx",
    "content": "import React, { useState } from \"react\";\nimport { Play, Wand2, Eye, EyeOff, Save } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { useTranslation } from \"react-i18next\";\nimport { useQueryClient } from \"@tanstack/react-query\";\nimport { Provider, UsageScript, UsageData } from \"@/types\";\nimport { usageApi, settingsApi, type AppId } from \"@/lib/api\";\nimport { copilotGetUsage, copilotGetUsageForAccount } from \"@/lib/api/copilot\";\nimport { useSettingsQuery } from \"@/lib/query\";\nimport { resolveManagedAccountId } from \"@/lib/authBinding\";\nimport { extractCodexBaseUrl } from \"@/utils/providerConfigUtils\";\nimport JsonEditor from \"./JsonEditor\";\nimport * as prettier from \"prettier/standalone\";\nimport * as parserBabel from \"prettier/parser-babel\";\nimport * as pluginEstree from \"prettier/plugins/estree\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { Switch } from \"@/components/ui/switch\";\nimport { FullScreenPanel } from \"@/components/common/FullScreenPanel\";\nimport { ConfirmDialog } from \"@/components/ConfirmDialog\";\nimport { cn } from \"@/lib/utils\";\nimport { TEMPLATE_TYPES, PROVIDER_TYPES } from \"@/config/constants\";\n\ninterface UsageScriptModalProps {\n  provider: Provider;\n  appId: AppId;\n  isOpen: boolean;\n  onClose: () => void;\n  onSave: (script: UsageScript) => void;\n}\n\n// 生成预设模板的函数（支持国际化）\nconst generatePresetTemplates = (\n  t: (key: string) => string,\n): Record<string, string> => ({\n  [TEMPLATE_TYPES.CUSTOM]: `({\n  request: {\n    url: \"\",\n    method: \"GET\",\n    headers: {}\n  },\n  extractor: function(response) {\n    return {\n      remaining: 0,\n      unit: \"USD\"\n    };\n  }\n})`,\n\n  [TEMPLATE_TYPES.GENERAL]: `({\n  request: {\n    url: \"{{baseUrl}}/user/balance\",\n    method: \"GET\",\n    headers: {\n      \"Authorization\": \"Bearer {{apiKey}}\",\n      \"User-Agent\": \"cc-switch/1.0\"\n    }\n  },\n  extractor: function(response) {\n    return {\n      isValid: response.is_active || true,\n      remaining: response.balance,\n      unit: \"USD\"\n    };\n  }\n})`,\n\n  [TEMPLATE_TYPES.NEW_API]: `({\n  request: {\n    url: \"{{baseUrl}}/api/user/self\",\n    method: \"GET\",\n    headers: {\n      \"Content-Type\": \"application/json\",\n      \"Authorization\": \"Bearer {{accessToken}}\",\n      \"New-Api-User\": \"{{userId}}\"\n    },\n  },\n  extractor: function (response) {\n    if (response.success && response.data) {\n      return {\n        planName: response.data.group || \"${t(\"usageScript.defaultPlan\")}\",\n        remaining: response.data.quota / 500000,\n        used: response.data.used_quota / 500000,\n        total: (response.data.quota + response.data.used_quota) / 500000,\n        unit: \"USD\",\n      };\n    }\n    return {\n      isValid: false,\n      invalidMessage: response.message || \"${t(\"usageScript.queryFailedMessage\")}\"\n    };\n  },\n})`,\n\n  // GitHub Copilot 模板不需要脚本，使用专用 API\n  [TEMPLATE_TYPES.GITHUB_COPILOT]: \"\",\n});\n\n// 模板名称国际化键映射\nconst TEMPLATE_NAME_KEYS: Record<string, string> = {\n  [TEMPLATE_TYPES.CUSTOM]: \"usageScript.templateCustom\",\n  [TEMPLATE_TYPES.GENERAL]: \"usageScript.templateGeneral\",\n  [TEMPLATE_TYPES.NEW_API]: \"usageScript.templateNewAPI\",\n  [TEMPLATE_TYPES.GITHUB_COPILOT]: \"usageScript.templateCopilot\",\n};\n\nconst UsageScriptModal: React.FC<UsageScriptModalProps> = ({\n  provider,\n  appId,\n  isOpen,\n  onClose,\n  onSave,\n}) => {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const { data: settingsData } = useSettingsQuery();\n  const [showUsageConfirm, setShowUsageConfirm] = useState(false);\n\n  // 生成带国际化的预设模板\n  const PRESET_TEMPLATES = generatePresetTemplates(t);\n\n  // 从 provider 的 settingsConfig 中提取 API Key 和 Base URL\n  const getProviderCredentials = (): {\n    apiKey: string | undefined;\n    baseUrl: string | undefined;\n  } => {\n    try {\n      const config = provider.settingsConfig;\n      if (!config) return { apiKey: undefined, baseUrl: undefined };\n\n      // 处理不同应用的配置格式\n      if (appId === \"claude\") {\n        // Claude: { env: { ANTHROPIC_AUTH_TOKEN | ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL } }\n        const env = (config as any).env || {};\n        return {\n          apiKey: env.ANTHROPIC_AUTH_TOKEN || env.ANTHROPIC_API_KEY,\n          baseUrl: env.ANTHROPIC_BASE_URL,\n        };\n      } else if (appId === \"codex\") {\n        // Codex: { auth: { OPENAI_API_KEY }, config: TOML string with base_url }\n        const auth = (config as any).auth || {};\n        const configToml = (config as any).config || \"\";\n        return {\n          apiKey: auth.OPENAI_API_KEY,\n          baseUrl: extractCodexBaseUrl(configToml),\n        };\n      } else if (appId === \"gemini\") {\n        // Gemini: { env: { GEMINI_API_KEY, GOOGLE_GEMINI_BASE_URL } }\n        const env = (config as any).env || {};\n        return {\n          apiKey: env.GEMINI_API_KEY,\n          baseUrl: env.GOOGLE_GEMINI_BASE_URL,\n        };\n      }\n      return { apiKey: undefined, baseUrl: undefined };\n    } catch (error) {\n      console.error(\"Failed to extract provider credentials:\", error);\n      return { apiKey: undefined, baseUrl: undefined };\n    }\n  };\n\n  const providerCredentials = getProviderCredentials();\n\n  const [script, setScript] = useState<UsageScript>(() => {\n    const savedScript = provider.meta?.usage_script;\n    const defaultScript = {\n      enabled: false,\n      language: \"javascript\" as const,\n      code: PRESET_TEMPLATES[TEMPLATE_TYPES.GENERAL],\n      timeout: 10,\n    };\n\n    if (!savedScript) {\n      return defaultScript;\n    }\n\n    return savedScript;\n  });\n\n  const [testing, setTesting] = useState(false);\n\n  // 🔧 失焦时的验证（严格）- 仅确保有效整数\n  const validateTimeout = (value: string): number => {\n    const num = Number(value);\n    if (isNaN(num) || value.trim() === \"\") {\n      return 10;\n    }\n    if (!Number.isInteger(num)) {\n      toast.warning(\n        t(\"usageScript.timeoutMustBeInteger\") || \"超时时间必须为整数\",\n      );\n    }\n    if (num < 0) {\n      toast.error(\n        t(\"usageScript.timeoutCannotBeNegative\") || \"超时时间不能为负数\",\n      );\n      return 10;\n    }\n    return Math.floor(num);\n  };\n\n  // 🔧 失焦时的验证（严格）- 自动查询间隔\n  const validateAndClampInterval = (value: string): number => {\n    const num = Number(value);\n    if (isNaN(num) || value.trim() === \"\") {\n      return 0;\n    }\n    if (!Number.isInteger(num)) {\n      toast.warning(\n        t(\"usageScript.intervalMustBeInteger\") || \"自动查询间隔必须为整数\",\n      );\n    }\n    if (num < 0) {\n      toast.error(\n        t(\"usageScript.intervalCannotBeNegative\") || \"自动查询间隔不能为负数\",\n      );\n      return 0;\n    }\n    const clamped = Math.max(0, Math.min(1440, Math.floor(num)));\n    if (clamped !== num && num > 0) {\n      toast.info(\n        t(\"usageScript.intervalAdjusted\", { value: clamped }) ||\n          `自动查询间隔已调整为 ${clamped} 分钟`,\n      );\n    }\n    return clamped;\n  };\n\n  const [selectedTemplate, setSelectedTemplate] = useState<string | null>(\n    () => {\n      const existingScript = provider.meta?.usage_script;\n      // Copilot 供应商默认使用 Copilot 模板\n      if (provider.meta?.providerType === PROVIDER_TYPES.GITHUB_COPILOT) {\n        return TEMPLATE_TYPES.GITHUB_COPILOT;\n      }\n      // 优先使用保存的 templateType\n      if (existingScript?.templateType) {\n        return existingScript.templateType;\n      }\n      // 向后兼容：根据字段推断模板类型\n      // 检测 NEW_API 模板（有 accessToken 或 userId）\n      if (existingScript?.accessToken || existingScript?.userId) {\n        return TEMPLATE_TYPES.NEW_API;\n      }\n      // 检测 GENERAL 模板（有 apiKey 或 baseUrl）\n      if (existingScript?.apiKey || existingScript?.baseUrl) {\n        return TEMPLATE_TYPES.GENERAL;\n      }\n      // 新配置或无凭证：默认使用 GENERAL（与默认代码模板一致）\n      return TEMPLATE_TYPES.GENERAL;\n    },\n  );\n\n  const [showApiKey, setShowApiKey] = useState(false);\n  const [showAccessToken, setShowAccessToken] = useState(false);\n\n  const handleEnableToggle = (checked: boolean) => {\n    if (checked && !settingsData?.usageConfirmed) {\n      setShowUsageConfirm(true);\n    } else {\n      setScript({ ...script, enabled: checked });\n    }\n  };\n\n  const handleUsageConfirm = async () => {\n    setShowUsageConfirm(false);\n    try {\n      if (settingsData) {\n        await settingsApi.save({ ...settingsData, usageConfirmed: true });\n        await queryClient.invalidateQueries({ queryKey: [\"settings\"] });\n      }\n    } catch (error) {\n      console.error(\"Failed to save usage confirmed:\", error);\n    }\n    setScript({ ...script, enabled: true });\n  };\n\n  const handleSave = () => {\n    // Copilot 模板不需要脚本验证\n    if (selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT) {\n      if (script.enabled && !script.code.trim()) {\n        toast.error(t(\"usageScript.scriptEmpty\"));\n        return;\n      }\n      if (script.enabled && !script.code.includes(\"return\")) {\n        toast.error(t(\"usageScript.mustHaveReturn\"), { duration: 5000 });\n        return;\n      }\n    }\n    // 保存时记录当前选择的模板类型\n    const scriptWithTemplate = {\n      ...script,\n      templateType: selectedTemplate as\n        | \"custom\"\n        | \"general\"\n        | \"newapi\"\n        | \"github_copilot\"\n        | undefined,\n    };\n    onSave(scriptWithTemplate);\n    onClose();\n  };\n\n  const handleTest = async () => {\n    setTesting(true);\n    try {\n      // Copilot 模板使用专用 API\n      if (selectedTemplate === TEMPLATE_TYPES.GITHUB_COPILOT) {\n        const accountId = resolveManagedAccountId(\n          provider.meta,\n          PROVIDER_TYPES.GITHUB_COPILOT,\n        );\n        const usage = accountId\n          ? await copilotGetUsageForAccount(accountId)\n          : await copilotGetUsage();\n        const premium = usage.quota_snapshots.premium_interactions;\n        const used = premium.entitlement - premium.remaining;\n        const summary = `[${usage.copilot_plan}] ${t(\"usage.remaining\")} ${premium.remaining}/${premium.entitlement} (${t(\"usageScript.resetDate\")}: ${usage.quota_reset_date})`;\n        toast.success(`${t(\"usageScript.testSuccess\")}${summary}`, {\n          duration: 3000,\n          closeButton: true,\n        });\n        // 更新缓存\n        queryClient.setQueryData([\"usage\", provider.id, appId], {\n          success: true,\n          data: [\n            {\n              planName: usage.copilot_plan,\n              remaining: premium.remaining,\n              total: premium.entitlement,\n              used: used,\n              unit: t(\"usageScript.premiumRequests\"),\n            },\n          ],\n        });\n        return;\n      }\n\n      const result = await usageApi.testScript(\n        provider.id,\n        appId,\n        script.code,\n        script.timeout,\n        script.apiKey,\n        script.baseUrl,\n        script.accessToken,\n        script.userId,\n        selectedTemplate as \"custom\" | \"general\" | \"newapi\" | undefined,\n      );\n      if (result.success && result.data && result.data.length > 0) {\n        const summary = result.data\n          .map((plan: UsageData) => {\n            const planInfo = plan.planName ? `[${plan.planName}]` : \"\";\n            return `${planInfo} ${t(\"usage.remaining\")} ${plan.remaining} ${plan.unit}`;\n          })\n          .join(\", \");\n        toast.success(`${t(\"usageScript.testSuccess\")}${summary}`, {\n          duration: 3000,\n          closeButton: true,\n        });\n\n        // 🔧 测试成功后，更新主界面列表的用量查询缓存\n        queryClient.setQueryData([\"usage\", provider.id, appId], result);\n      } else {\n        toast.error(\n          `${t(\"usageScript.testFailed\")}: ${result.error || t(\"endpointTest.noResult\")}`,\n          {\n            duration: 5000,\n          },\n        );\n      }\n    } catch (error: any) {\n      toast.error(\n        `${t(\"usageScript.testFailed\")}: ${error?.message || t(\"common.unknown\")}`,\n        {\n          duration: 5000,\n        },\n      );\n    } finally {\n      setTesting(false);\n    }\n  };\n\n  const handleFormat = async () => {\n    try {\n      const formatted = await prettier.format(script.code, {\n        parser: \"babel\",\n        plugins: [parserBabel as any, pluginEstree as any],\n        semi: true,\n        singleQuote: false,\n        tabWidth: 2,\n        printWidth: 80,\n      });\n      setScript({ ...script, code: formatted.trim() });\n      toast.success(t(\"usageScript.formatSuccess\"), {\n        duration: 1000,\n        closeButton: true,\n      });\n    } catch (error: any) {\n      toast.error(\n        `${t(\"usageScript.formatFailed\")}: ${error?.message || t(\"jsonEditor.invalidJson\")}`,\n        {\n          duration: 3000,\n        },\n      );\n    }\n  };\n\n  const handleUsePreset = (presetName: string) => {\n    const preset = PRESET_TEMPLATES[presetName];\n    if (preset) {\n      if (presetName === TEMPLATE_TYPES.CUSTOM) {\n        // 🔧 自定义模式：用户应该在脚本中直接写完整 URL 和凭证，而不是依赖变量替换\n        // 这样可以避免同源检查导致的问题\n        // 如果用户想使用变量，需要手动在配置中设置 baseUrl/apiKey\n        setScript({\n          ...script,\n          code: preset,\n          // 清除凭证，用户可选择手动输入或保持空\n          apiKey: undefined,\n          baseUrl: undefined,\n          accessToken: undefined,\n          userId: undefined,\n        });\n      } else if (presetName === TEMPLATE_TYPES.GENERAL) {\n        setScript({\n          ...script,\n          code: preset,\n          accessToken: undefined,\n          userId: undefined,\n        });\n      } else if (presetName === TEMPLATE_TYPES.NEW_API) {\n        setScript({\n          ...script,\n          code: preset,\n          apiKey: undefined,\n        });\n      } else if (presetName === TEMPLATE_TYPES.GITHUB_COPILOT) {\n        // Copilot 模板不需要脚本和凭证，使用专用 API\n        setScript({\n          ...script,\n          code: \"\",\n          apiKey: undefined,\n          baseUrl: undefined,\n          accessToken: undefined,\n          userId: undefined,\n        });\n      }\n      setSelectedTemplate(presetName);\n    }\n  };\n\n  const shouldShowCredentialsConfig =\n    selectedTemplate === TEMPLATE_TYPES.GENERAL ||\n    selectedTemplate === TEMPLATE_TYPES.NEW_API;\n\n  const footer = (\n    <>\n      <div className=\"flex gap-2\">\n        <Button\n          variant=\"secondary\"\n          size=\"sm\"\n          onClick={handleTest}\n          disabled={!script.enabled || testing}\n        >\n          <Play size={14} className=\"mr-1\" />\n          {testing ? t(\"usageScript.testing\") : t(\"usageScript.testScript\")}\n        </Button>\n        <Button\n          variant=\"outline\"\n          size=\"sm\"\n          onClick={handleFormat}\n          disabled={!script.enabled}\n          title={t(\"usageScript.format\")}\n        >\n          <Wand2 size={14} className=\"mr-1\" />\n          {t(\"usageScript.format\")}\n        </Button>\n      </div>\n\n      <div className=\"flex gap-2\">\n        <Button\n          variant=\"outline\"\n          onClick={onClose}\n          className=\"border-border/20 hover:bg-accent hover:text-accent-foreground\"\n        >\n          {t(\"common.cancel\")}\n        </Button>\n        <Button\n          onClick={handleSave}\n          className=\"bg-primary text-primary-foreground hover:bg-primary/90\"\n        >\n          <Save size={16} className=\"mr-2\" />\n          {t(\"usageScript.saveConfig\")}\n        </Button>\n      </div>\n    </>\n  );\n\n  return (\n    <FullScreenPanel\n      isOpen={isOpen}\n      title={`${t(\"usageScript.title\")} - ${provider.name}`}\n      onClose={onClose}\n      footer={footer}\n    >\n      <div className=\"glass rounded-xl border border-white/10 px-6 py-4 flex items-center justify-between gap-4\">\n        <p className=\"text-base font-medium leading-none text-foreground\">\n          {t(\"usageScript.enableUsageQuery\")}\n        </p>\n        <Switch\n          checked={script.enabled}\n          onCheckedChange={handleEnableToggle}\n          aria-label={t(\"usageScript.enableUsageQuery\")}\n        />\n      </div>\n\n      {script.enabled && (\n        <div className=\"space-y-6\">\n          {/* 预设模板选择 */}\n          <div className=\"space-y-4 glass rounded-xl border border-white/10 p-6\">\n            <Label className=\"text-base font-medium\">\n              {t(\"usageScript.presetTemplate\")}\n            </Label>\n            <div className=\"flex gap-2 flex-wrap\">\n              {Object.keys(PRESET_TEMPLATES)\n                .filter((name) => {\n                  const isCopilotProvider =\n                    provider.meta?.providerType === \"github_copilot\";\n                  // Copilot 供应商只显示 copilot 模板，其他供应商不显示 copilot 模板\n                  if (isCopilotProvider) {\n                    return name === TEMPLATE_TYPES.GITHUB_COPILOT;\n                  }\n                  return name !== TEMPLATE_TYPES.GITHUB_COPILOT;\n                })\n                .map((name) => {\n                  const isSelected = selectedTemplate === name;\n                  return (\n                    <Button\n                      key={name}\n                      type=\"button\"\n                      variant={isSelected ? \"default\" : \"outline\"}\n                      size=\"sm\"\n                      className={cn(\n                        \"rounded-lg border\",\n                        isSelected\n                          ? \"shadow-sm\"\n                          : \"bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground\",\n                      )}\n                      onClick={() => handleUsePreset(name)}\n                    >\n                      {t(TEMPLATE_NAME_KEYS[name])}\n                    </Button>\n                  );\n                })}\n            </div>\n\n            {/* 自定义模式：变量提示和具体值 */}\n            {selectedTemplate === TEMPLATE_TYPES.CUSTOM && (\n              <div className=\"space-y-2 border-t border-white/10 pt-3\">\n                <h4 className=\"text-sm font-medium text-foreground\">\n                  {t(\"usageScript.supportedVariables\")}\n                </h4>\n                <div className=\"space-y-1 text-xs\">\n                  {/* baseUrl */}\n                  <div className=\"flex items-center gap-2 py-1\">\n                    <code className=\"text-emerald-500 dark:text-emerald-400 font-mono shrink-0\">\n                      {\"{{baseUrl}}\"}\n                    </code>\n                    <span className=\"text-muted-foreground/50\">=</span>\n                    {providerCredentials.baseUrl ? (\n                      <code className=\"text-foreground/70 break-all font-mono\">\n                        {providerCredentials.baseUrl}\n                      </code>\n                    ) : (\n                      <span className=\"text-muted-foreground/50 italic\">\n                        {t(\"common.notSet\") || \"未设置\"}\n                      </span>\n                    )}\n                  </div>\n\n                  {/* apiKey */}\n                  <div className=\"flex items-center gap-2 py-1\">\n                    <code className=\"text-emerald-500 dark:text-emerald-400 font-mono shrink-0\">\n                      {\"{{apiKey}}\"}\n                    </code>\n                    <span className=\"text-muted-foreground/50\">=</span>\n                    {providerCredentials.apiKey ? (\n                      <>\n                        {showApiKey ? (\n                          <code className=\"text-foreground/70 break-all font-mono\">\n                            {providerCredentials.apiKey}\n                          </code>\n                        ) : (\n                          <code className=\"text-foreground/70 font-mono\">\n                            ••••••••\n                          </code>\n                        )}\n                        <button\n                          type=\"button\"\n                          onClick={() => setShowApiKey(!showApiKey)}\n                          className=\"text-muted-foreground hover:text-foreground transition-colors ml-1\"\n                          aria-label={\n                            showApiKey\n                              ? t(\"apiKeyInput.hide\")\n                              : t(\"apiKeyInput.show\")\n                          }\n                        >\n                          {showApiKey ? (\n                            <EyeOff size={12} />\n                          ) : (\n                            <Eye size={12} />\n                          )}\n                        </button>\n                      </>\n                    ) : (\n                      <span className=\"text-muted-foreground/50 italic\">\n                        {t(\"common.notSet\") || \"未设置\"}\n                      </span>\n                    )}\n                  </div>\n                </div>\n              </div>\n            )}\n\n            {/* Copilot 模式：自动认证提示 */}\n            {selectedTemplate === TEMPLATE_TYPES.GITHUB_COPILOT && (\n              <div className=\"space-y-2 border-t border-white/10 pt-3\">\n                <p className=\"text-sm text-muted-foreground\">\n                  {t(\"usageScript.copilotAutoAuth\")}\n                </p>\n              </div>\n            )}\n\n            {/* 凭证配置 */}\n            {shouldShowCredentialsConfig && (\n              <div className=\"space-y-4\">\n                <div className=\"flex items-start justify-between\">\n                  <h4 className=\"text-sm font-medium text-foreground\">\n                    {t(\"usageScript.credentialsConfig\")}\n                  </h4>\n                  <p className=\"text-xs text-muted-foreground\">\n                    {t(\"usageScript.credentialsHint\")}\n                  </p>\n                </div>\n\n                <div className=\"grid gap-4 md:grid-cols-2\">\n                  {selectedTemplate === TEMPLATE_TYPES.GENERAL && (\n                    <>\n                      <div className=\"space-y-2\">\n                        <Label htmlFor=\"usage-api-key\">\n                          API Key{\" \"}\n                          <span className=\"text-xs text-muted-foreground font-normal\">\n                            ({t(\"usageScript.optional\")})\n                          </span>\n                        </Label>\n                        <div className=\"relative\">\n                          <Input\n                            id=\"usage-api-key\"\n                            type={showApiKey ? \"text\" : \"password\"}\n                            value={script.apiKey || \"\"}\n                            onChange={(e) =>\n                              setScript({ ...script, apiKey: e.target.value })\n                            }\n                            placeholder={t(\"usageScript.apiKeyPlaceholder\")}\n                            autoComplete=\"off\"\n                            className=\"border-white/10\"\n                          />\n                          {script.apiKey && (\n                            <button\n                              type=\"button\"\n                              onClick={() => setShowApiKey(!showApiKey)}\n                              className=\"absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors\"\n                              aria-label={\n                                showApiKey\n                                  ? t(\"apiKeyInput.hide\")\n                                  : t(\"apiKeyInput.show\")\n                              }\n                            >\n                              {showApiKey ? (\n                                <EyeOff size={16} />\n                              ) : (\n                                <Eye size={16} />\n                              )}\n                            </button>\n                          )}\n                        </div>\n                      </div>\n\n                      <div className=\"space-y-2\">\n                        <Label htmlFor=\"usage-base-url\">\n                          {t(\"usageScript.baseUrl\")}{\" \"}\n                          <span className=\"text-xs text-muted-foreground font-normal\">\n                            ({t(\"usageScript.optional\")})\n                          </span>\n                        </Label>\n                        <Input\n                          id=\"usage-base-url\"\n                          type=\"text\"\n                          value={script.baseUrl || \"\"}\n                          onChange={(e) =>\n                            setScript({ ...script, baseUrl: e.target.value })\n                          }\n                          placeholder={t(\"usageScript.baseUrlPlaceholder\")}\n                          autoComplete=\"off\"\n                          className=\"border-white/10\"\n                        />\n                      </div>\n                    </>\n                  )}\n\n                  {selectedTemplate === TEMPLATE_TYPES.NEW_API && (\n                    <>\n                      <div className=\"space-y-2\">\n                        <Label htmlFor=\"usage-newapi-base-url\">\n                          {t(\"usageScript.baseUrl\")}\n                        </Label>\n                        <Input\n                          id=\"usage-newapi-base-url\"\n                          type=\"text\"\n                          value={script.baseUrl || \"\"}\n                          onChange={(e) =>\n                            setScript({ ...script, baseUrl: e.target.value })\n                          }\n                          placeholder=\"https://api.newapi.com\"\n                          autoComplete=\"off\"\n                          className=\"border-white/10\"\n                        />\n                      </div>\n\n                      <div className=\"space-y-2\">\n                        <Label htmlFor=\"usage-access-token\">\n                          {t(\"usageScript.accessToken\")}\n                        </Label>\n                        <div className=\"relative\">\n                          <Input\n                            id=\"usage-access-token\"\n                            type={showAccessToken ? \"text\" : \"password\"}\n                            value={script.accessToken || \"\"}\n                            onChange={(e) =>\n                              setScript({\n                                ...script,\n                                accessToken: e.target.value,\n                              })\n                            }\n                            placeholder={t(\n                              \"usageScript.accessTokenPlaceholder\",\n                            )}\n                            autoComplete=\"off\"\n                            className=\"border-white/10\"\n                          />\n                          {script.accessToken && (\n                            <button\n                              type=\"button\"\n                              onClick={() =>\n                                setShowAccessToken(!showAccessToken)\n                              }\n                              className=\"absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors\"\n                              aria-label={\n                                showAccessToken\n                                  ? t(\"apiKeyInput.hide\")\n                                  : t(\"apiKeyInput.show\")\n                              }\n                            >\n                              {showAccessToken ? (\n                                <EyeOff size={16} />\n                              ) : (\n                                <Eye size={16} />\n                              )}\n                            </button>\n                          )}\n                        </div>\n                      </div>\n\n                      <div className=\"space-y-2\">\n                        <Label htmlFor=\"usage-user-id\">\n                          {t(\"usageScript.userId\")}\n                        </Label>\n                        <Input\n                          id=\"usage-user-id\"\n                          type=\"text\"\n                          value={script.userId || \"\"}\n                          onChange={(e) =>\n                            setScript({ ...script, userId: e.target.value })\n                          }\n                          placeholder={t(\"usageScript.userIdPlaceholder\")}\n                          autoComplete=\"off\"\n                          className=\"border-white/10\"\n                        />\n                      </div>\n                    </>\n                  )}\n                </div>\n              </div>\n            )}\n\n            {/* 通用配置（始终显示） */}\n            <div className=\"grid gap-4 md:grid-cols-2 pt-4 border-t border-white/10\">\n              {/* 超时时间 */}\n              <div className=\"space-y-2\">\n                <Label htmlFor=\"usage-timeout\">\n                  {t(\"usageScript.timeoutSeconds\")}\n                </Label>\n                <Input\n                  id=\"usage-timeout\"\n                  type=\"number\"\n                  min={0}\n                  value={script.timeout ?? 10}\n                  onChange={(e) =>\n                    setScript({\n                      ...script,\n                      timeout: validateTimeout(e.target.value),\n                    })\n                  }\n                  onBlur={(e) =>\n                    setScript({\n                      ...script,\n                      timeout: validateTimeout(e.target.value),\n                    })\n                  }\n                  className=\"border-white/10\"\n                />\n              </div>\n\n              {/* 自动查询间隔 */}\n              <div className=\"space-y-2\">\n                <Label htmlFor=\"usage-interval\">\n                  {t(\"usageScript.autoIntervalMinutes\")}\n                </Label>\n                <Input\n                  id=\"usage-interval\"\n                  type=\"number\"\n                  min={0}\n                  max={1440}\n                  value={\n                    script.autoQueryInterval ?? script.autoIntervalMinutes ?? 0\n                  }\n                  onChange={(e) =>\n                    setScript({\n                      ...script,\n                      autoQueryInterval: validateAndClampInterval(\n                        e.target.value,\n                      ),\n                    })\n                  }\n                  onBlur={(e) =>\n                    setScript({\n                      ...script,\n                      autoQueryInterval: validateAndClampInterval(\n                        e.target.value,\n                      ),\n                    })\n                  }\n                  className=\"border-white/10\"\n                />\n              </div>\n            </div>\n          </div>\n\n          {/* 提取器代码 - Copilot 模板不需要 */}\n          {selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT && (\n            <div className=\"space-y-4 glass rounded-xl border border-white/10 p-6\">\n              <div className=\"flex items-center justify-between\">\n                <Label className=\"text-base font-medium\">\n                  {t(\"usageScript.extractorCode\")}\n                </Label>\n                <div className=\"text-xs text-muted-foreground\">\n                  {t(\"usageScript.extractorHint\")}\n                </div>\n              </div>\n              <JsonEditor\n                id=\"usage-code\"\n                value={script.code || \"\"}\n                onChange={(value) => setScript({ ...script, code: value })}\n                height={480}\n                language=\"javascript\"\n                showMinimap={false}\n              />\n            </div>\n          )}\n\n          {/* 帮助信息 - Copilot 模板不需要 */}\n          {selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT && (\n            <div className=\"glass rounded-xl border border-white/10 p-6 text-sm text-foreground/90\">\n              <h4 className=\"font-medium mb-2\">\n                {t(\"usageScript.scriptHelp\")}\n              </h4>\n              <div className=\"space-y-3 text-xs\">\n                <div>\n                  <strong>{t(\"usageScript.configFormat\")}</strong>\n                  <pre className=\"mt-1 p-2 bg-black/20 text-foreground rounded border border-white/10 text-[10px] overflow-x-auto\">\n                    {`({\n  request: {\n    url: \"{{baseUrl}}/api/usage\",\n    method: \"POST\",\n    headers: {\n      \"Authorization\": \"Bearer {{apiKey}}\",\n      \"User-Agent\": \"cc-switch/1.0\"\n    }\n  },\n  extractor: function(response) {\n    return {\n      isValid: !response.error,\n      remaining: response.balance,\n      unit: \"USD\"\n    };\n  }\n})`}\n                  </pre>\n                </div>\n\n                <div>\n                  <strong>{t(\"usageScript.extractorFormat\")}</strong>\n                  <ul className=\"mt-1 space-y-0.5 ml-2\">\n                    <li>{t(\"usageScript.fieldIsValid\")}</li>\n                    <li>{t(\"usageScript.fieldInvalidMessage\")}</li>\n                    <li>{t(\"usageScript.fieldRemaining\")}</li>\n                    <li>{t(\"usageScript.fieldUnit\")}</li>\n                    <li>{t(\"usageScript.fieldPlanName\")}</li>\n                    <li>{t(\"usageScript.fieldTotal\")}</li>\n                    <li>{t(\"usageScript.fieldUsed\")}</li>\n                    <li>{t(\"usageScript.fieldExtra\")}</li>\n                  </ul>\n                </div>\n\n                <div className=\"text-muted-foreground\">\n                  <strong>{t(\"usageScript.tips\")}</strong>\n                  <ul className=\"mt-1 space-y-0.5 ml-2\">\n                    <li>\n                      {t(\"usageScript.tip1\", {\n                        apiKey: \"{{apiKey}}\",\n                        baseUrl: \"{{baseUrl}}\",\n                      })}\n                    </li>\n                    <li>{t(\"usageScript.tip2\")}</li>\n                    <li>{t(\"usageScript.tip3\")}</li>\n                  </ul>\n                </div>\n              </div>\n            </div>\n          )}\n        </div>\n      )}\n\n      <ConfirmDialog\n        isOpen={showUsageConfirm}\n        variant=\"info\"\n        title={t(\"confirm.usage.title\")}\n        message={t(\"confirm.usage.message\")}\n        confirmText={t(\"confirm.usage.confirm\")}\n        onConfirm={() => void handleUsageConfirm()}\n        onCancel={() => setShowUsageConfirm(false)}\n      />\n    </FullScreenPanel>\n  );\n};\n\nexport default UsageScriptModal;\n"
  },
  {
    "path": "src/components/agents/AgentsPanel.tsx",
    "content": "import { Bot } from \"lucide-react\";\n\ninterface AgentsPanelProps {\n  onOpenChange: (open: boolean) => void;\n}\n\nexport function AgentsPanel({}: AgentsPanelProps) {\n  return (\n    <div className=\"px-6 flex flex-col flex-1 min-h-0\">\n      <div className=\"flex-1 glass-card rounded-xl p-8 flex flex-col items-center justify-center text-center space-y-4\">\n        <div className=\"w-20 h-20 rounded-full bg-white/5 flex items-center justify-center mb-4 animate-pulse-slow\">\n          <Bot className=\"w-10 h-10 text-muted-foreground\" />\n        </div>\n        <h3 className=\"text-xl font-semibold\">Coming Soon</h3>\n        <p className=\"text-muted-foreground max-w-md\">\n          The Agents management feature is currently under development. Stay\n          tuned for powerful autonomous capabilities.\n        </p>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/common/AppCountBar.tsx",
    "content": "import React from \"react\";\nimport { Badge } from \"@/components/ui/badge\";\nimport type { AppId } from \"@/lib/api/types\";\nimport { APP_IDS, APP_ICON_MAP } from \"@/config/appConfig\";\n\ninterface AppCountBarProps {\n  totalLabel: string;\n  counts: Record<AppId, number>;\n  appIds?: AppId[];\n}\n\nexport const AppCountBar: React.FC<AppCountBarProps> = ({\n  totalLabel,\n  counts,\n  appIds = APP_IDS,\n}) => {\n  return (\n    <div className=\"flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6 flex items-center justify-between gap-4\">\n      <Badge variant=\"outline\" className=\"bg-background/50 h-7 px-3\">\n        {totalLabel}\n      </Badge>\n      <div className=\"flex items-center gap-2 overflow-x-auto no-scrollbar\">\n        {appIds.map((app) => (\n          <Badge\n            key={app}\n            variant=\"secondary\"\n            className={APP_ICON_MAP[app].badgeClass}\n          >\n            <span className=\"opacity-75\">{APP_ICON_MAP[app].label}:</span>\n            <span className=\"font-bold ml-1\">{counts[app]}</span>\n          </Badge>\n        ))}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "src/components/common/AppToggleGroup.tsx",
    "content": "import React from \"react\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport type { AppId } from \"@/lib/api/types\";\nimport { APP_IDS, APP_ICON_MAP } from \"@/config/appConfig\";\n\ninterface AppToggleGroupProps {\n  apps: Record<AppId, boolean>;\n  onToggle: (app: AppId, enabled: boolean) => void;\n  appIds?: AppId[];\n}\n\nexport const AppToggleGroup: React.FC<AppToggleGroupProps> = ({\n  apps,\n  onToggle,\n  appIds = APP_IDS,\n}) => {\n  return (\n    <div className=\"flex items-center gap-1.5 flex-shrink-0\">\n      {appIds.map((app) => {\n        const { label, icon, activeClass } = APP_ICON_MAP[app];\n        const enabled = apps[app];\n        return (\n          <Tooltip key={app}>\n            <TooltipTrigger asChild>\n              <button\n                type=\"button\"\n                onClick={() => onToggle(app, !enabled)}\n                className={`w-7 h-7 rounded-lg flex items-center justify-center transition-all ${\n                  enabled ? activeClass : \"opacity-35 hover:opacity-70\"\n                }`}\n              >\n                {icon}\n              </button>\n            </TooltipTrigger>\n            <TooltipContent side=\"bottom\">\n              <p>\n                {label}\n                {enabled ? \" ✓\" : \"\"}\n              </p>\n            </TooltipContent>\n          </Tooltip>\n        );\n      })}\n    </div>\n  );\n};\n"
  },
  {
    "path": "src/components/common/FullScreenPanel.tsx",
    "content": "import React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { motion, AnimatePresence } from \"framer-motion\";\nimport { ArrowLeft } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { isWindows, isLinux } from \"@/lib/platform\";\nimport { isTextEditableTarget } from \"@/utils/domUtils\";\n\ninterface FullScreenPanelProps {\n  isOpen: boolean;\n  title: string;\n  onClose: () => void;\n  children: React.ReactNode;\n  footer?: React.ReactNode;\n}\n\nconst DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px - match App.tsx\nconst HEADER_HEIGHT = 64; // px - match App.tsx\n\n/**\n * Reusable full-screen panel component\n * Handles portal rendering, header with back button, and footer\n * Uses solid theme colors without transparency\n */\nexport const FullScreenPanel: React.FC<FullScreenPanelProps> = ({\n  isOpen,\n  title,\n  onClose,\n  children,\n  footer,\n}) => {\n  React.useEffect(() => {\n    if (isOpen) {\n      document.body.style.overflow = \"hidden\";\n    }\n    return () => {\n      document.body.style.overflow = \"\";\n    };\n  }, [isOpen]);\n\n  // ESC 键关闭面板\n  const onCloseRef = React.useRef(onClose);\n\n  React.useEffect(() => {\n    onCloseRef.current = onClose;\n  }, [onClose]);\n\n  React.useEffect(() => {\n    if (!isOpen) return;\n\n    const handleKeyDown = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") {\n        // 子组件（例如 Radix 的 Select/Dialog/Dropdown）如果已经消费了 ESC，就不要再关闭整个面板\n        if (event.defaultPrevented) {\n          return;\n        }\n\n        if (isTextEditableTarget(event.target)) {\n          return; // 让输入框自己处理 ESC（比如清空、失焦等）\n        }\n\n        event.stopPropagation(); // 阻止事件继续冒泡到 window，避免触发 App.tsx 的全局监听\n        onCloseRef.current();\n      }\n    };\n\n    // 使用冒泡阶段监听，让子组件（如 Radix UI）优先处理 ESC\n    window.addEventListener(\"keydown\", handleKeyDown, false);\n    return () => {\n      window.removeEventListener(\"keydown\", handleKeyDown, false);\n    };\n  }, [isOpen]);\n\n  return createPortal(\n    <AnimatePresence>\n      {isOpen && (\n        <motion.div\n          initial={{ opacity: 0 }}\n          animate={{ opacity: 1 }}\n          exit={{ opacity: 0 }}\n          transition={{ duration: 0.2 }}\n          className=\"fixed inset-0 z-[60] flex flex-col\"\n          style={{ backgroundColor: \"hsl(var(--background))\" }}\n        >\n          {/* Drag region - match App.tsx */}\n          <div\n            data-tauri-drag-region\n            style={\n              {\n                WebkitAppRegion: \"drag\",\n                height: DRAG_BAR_HEIGHT,\n              } as React.CSSProperties\n            }\n          />\n\n          {/* Header - match App.tsx */}\n          <div\n            className=\"flex-shrink-0 flex items-center\"\n            data-tauri-drag-region\n            style={\n              {\n                WebkitAppRegion: \"drag\",\n                backgroundColor: \"hsl(var(--background))\",\n                height: HEADER_HEIGHT,\n              } as React.CSSProperties\n            }\n          >\n            <div\n              className=\"px-6 w-full flex items-center gap-4\"\n              data-tauri-drag-region\n              style={{ WebkitAppRegion: \"drag\" } as React.CSSProperties}\n            >\n              <Button\n                type=\"button\"\n                variant=\"outline\"\n                size=\"icon\"\n                onClick={onClose}\n                className=\"rounded-lg select-none\"\n                style={{ WebkitAppRegion: \"no-drag\" } as React.CSSProperties}\n              >\n                <ArrowLeft className=\"h-4 w-4\" />\n              </Button>\n              <h2 className=\"text-lg font-semibold text-foreground select-none\">\n                {title}\n              </h2>\n            </div>\n          </div>\n\n          {/* Content */}\n          <div className=\"flex-1 overflow-y-auto scroll-overlay\">\n            <div className=\"px-6 py-6 space-y-6 w-full\">{children}</div>\n          </div>\n\n          {/* Footer */}\n          {footer && (\n            <div\n              className=\"flex-shrink-0 py-4 border-t border-border-default\"\n              style={{ backgroundColor: \"hsl(var(--background))\" }}\n            >\n              <div className=\"px-6 flex items-center justify-end gap-3\">\n                {footer}\n              </div>\n            </div>\n          )}\n        </motion.div>\n      )}\n    </AnimatePresence>,\n    document.body,\n  );\n};\n"
  },
  {
    "path": "src/components/common/ListItemRow.tsx",
    "content": "import React from \"react\";\n\ninterface ListItemRowProps {\n  isLast?: boolean;\n  children: React.ReactNode;\n}\n\nexport const ListItemRow: React.FC<ListItemRowProps> = ({\n  isLast,\n  children,\n}) => {\n  return (\n    <div\n      className={`group flex items-center gap-3 px-4 py-2.5 hover:bg-muted/50 transition-colors ${\n        !isLast ? \"border-b border-border-default\" : \"\"\n      }`}\n    >\n      {children}\n    </div>\n  );\n};\n"
  },
  {
    "path": "src/components/deeplink/McpConfirmation.tsx",
    "content": "import { useMemo } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { DeepLinkImportRequest } from \"../../lib/api/deeplink\";\nimport { decodeBase64Utf8 } from \"../../lib/utils/base64\";\n\nexport function McpConfirmation({\n  request,\n}: {\n  request: DeepLinkImportRequest;\n}) {\n  const { t } = useTranslation();\n\n  const mcpServers = useMemo(() => {\n    if (!request.config) return null;\n    try {\n      const decoded = decodeBase64Utf8(request.config);\n      const parsed = JSON.parse(decoded);\n      return parsed.mcpServers || {};\n    } catch (e) {\n      console.error(\"Failed to parse MCP config:\", e);\n      return null;\n    }\n  }, [request.config]);\n\n  const targetApps = request.apps?.split(\",\") || [];\n  const serverCount = Object.keys(mcpServers || {}).length;\n\n  return (\n    <div className=\"space-y-4\">\n      <h3 className=\"text-lg font-semibold\">{t(\"deeplink.mcp.title\")}</h3>\n\n      <div>\n        <label className=\"block text-sm font-medium text-muted-foreground\">\n          {t(\"deeplink.mcp.targetApps\")}\n        </label>\n        <div className=\"mt-1 flex gap-2 flex-wrap\">\n          {targetApps.map((app) => (\n            <span\n              key={app}\n              className=\"px-2 py-1 bg-primary/10 text-primary text-xs rounded capitalize\"\n            >\n              {app.trim()}\n            </span>\n          ))}\n        </div>\n      </div>\n\n      <div>\n        <label className=\"block text-sm font-medium text-muted-foreground\">\n          {t(\"deeplink.mcp.serverCount\", { count: serverCount })}\n        </label>\n        <div className=\"mt-1 space-y-2 max-h-64 overflow-auto border rounded p-2 bg-muted/30\">\n          {mcpServers &&\n            Object.entries(mcpServers).map(([id, spec]: [string, any]) => (\n              <div key={id} className=\"p-2 bg-background rounded border\">\n                <div className=\"font-semibold text-sm\">{id}</div>\n                <div className=\"text-xs text-muted-foreground mt-1 font-mono truncate\">\n                  {spec.command\n                    ? `Command: ${spec.command} `\n                    : `URL: ${spec.url} `}\n                </div>\n              </div>\n            ))}\n        </div>\n      </div>\n\n      {request.enabled && (\n        <div className=\"text-yellow-600 dark:text-yellow-500 text-sm flex items-center gap-2\">\n          <span>⚠️</span>\n          <span>{t(\"deeplink.mcp.enabledWarning\")}</span>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/deeplink/PromptConfirmation.tsx",
    "content": "import { useMemo } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { DeepLinkImportRequest } from \"../../lib/api/deeplink\";\nimport { decodeBase64Utf8 } from \"../../lib/utils/base64\";\n\nexport function PromptConfirmation({\n  request,\n}: {\n  request: DeepLinkImportRequest;\n}) {\n  const { t } = useTranslation();\n\n  const decodedContent = useMemo(() => {\n    if (!request.content) return \"\";\n    return decodeBase64Utf8(request.content);\n  }, [request.content]);\n\n  return (\n    <div className=\"space-y-4\">\n      <h3 className=\"text-lg font-semibold\">{t(\"deeplink.prompt.title\")}</h3>\n\n      <div>\n        <label className=\"block text-sm font-medium text-muted-foreground\">\n          {t(\"deeplink.prompt.app\")}\n        </label>\n        <div className=\"mt-1 text-sm capitalize\">{request.app}</div>\n      </div>\n\n      <div>\n        <label className=\"block text-sm font-medium text-muted-foreground\">\n          {t(\"deeplink.prompt.name\")}\n        </label>\n        <div className=\"mt-1 text-sm\">{request.name}</div>\n      </div>\n\n      {request.description && (\n        <div>\n          <label className=\"block text-sm font-medium text-muted-foreground\">\n            {t(\"deeplink.prompt.description\")}\n          </label>\n          <div className=\"mt-1 text-sm\">{request.description}</div>\n        </div>\n      )}\n\n      <div>\n        <label className=\"block text-sm font-medium text-muted-foreground\">\n          {t(\"deeplink.prompt.contentPreview\")}\n        </label>\n        <pre className=\"mt-1 max-h-48 overflow-auto bg-muted/50 p-2 rounded text-xs whitespace-pre-wrap border\">\n          {decodedContent.substring(0, 500)}\n          {decodedContent.length > 500 && \"...\"}\n        </pre>\n      </div>\n\n      {request.enabled && (\n        <div className=\"text-yellow-600 dark:text-yellow-500 text-sm flex items-center gap-2\">\n          <span>⚠️</span>\n          <span>{t(\"deeplink.prompt.enabledWarning\")}</span>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/deeplink/SkillConfirmation.tsx",
    "content": "import { useTranslation } from \"react-i18next\";\nimport { DeepLinkImportRequest } from \"../../lib/api/deeplink\";\n\nexport function SkillConfirmation({\n  request,\n}: {\n  request: DeepLinkImportRequest;\n}) {\n  const { t } = useTranslation();\n\n  return (\n    <div className=\"space-y-4\">\n      <h3 className=\"text-lg font-semibold\">{t(\"deeplink.skill.title\")}</h3>\n\n      <div>\n        <label className=\"block text-sm font-medium text-muted-foreground\">\n          {t(\"deeplink.skill.repo\")}\n        </label>\n        <div className=\"mt-1 text-sm font-mono bg-muted/50 p-2 rounded border\">\n          {request.repo}\n        </div>\n      </div>\n\n      <div>\n        <label className=\"block text-sm font-medium text-muted-foreground\">\n          {t(\"deeplink.skill.directory\")}\n        </label>\n        <div className=\"mt-1 text-sm font-mono bg-muted/50 p-2 rounded border\">\n          {request.directory}\n        </div>\n      </div>\n\n      <div>\n        <label className=\"block text-sm font-medium text-muted-foreground\">\n          {t(\"deeplink.skill.branch\")}\n        </label>\n        <div className=\"mt-1 text-sm\">{request.branch || \"main\"}</div>\n      </div>\n\n      <div className=\"text-blue-600 dark:text-blue-400 text-sm bg-blue-50 dark:bg-blue-950/30 p-3 rounded border border-blue-200 dark:border-blue-800\">\n        <p>ℹ️ {t(\"deeplink.skill.hint\")}</p>\n        <p className=\"mt-1\">{t(\"deeplink.skill.hintDetail\")}</p>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/env/EnvWarningBanner.tsx",
    "content": "import { useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { AlertTriangle, ChevronDown, ChevronUp, X, Trash2 } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport type { EnvConflict } from \"@/types/env\";\nimport { deleteEnvVars } from \"@/lib/api/env\";\nimport { toast } from \"sonner\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\n\ninterface EnvWarningBannerProps {\n  conflicts: EnvConflict[];\n  onDismiss: () => void;\n  onDeleted: () => void;\n}\n\nexport function EnvWarningBanner({\n  conflicts,\n  onDismiss,\n  onDeleted,\n}: EnvWarningBannerProps) {\n  const { t } = useTranslation();\n  const [isExpanded, setIsExpanded] = useState(false);\n  const [selectedConflicts, setSelectedConflicts] = useState<Set<string>>(\n    new Set(),\n  );\n  const [isDeleting, setIsDeleting] = useState(false);\n  const [showConfirmDialog, setShowConfirmDialog] = useState(false);\n\n  if (conflicts.length === 0) {\n    return null;\n  }\n\n  const toggleSelection = (key: string) => {\n    const newSelection = new Set(selectedConflicts);\n    if (newSelection.has(key)) {\n      newSelection.delete(key);\n    } else {\n      newSelection.add(key);\n    }\n    setSelectedConflicts(newSelection);\n  };\n\n  const toggleSelectAll = () => {\n    if (selectedConflicts.size === conflicts.length) {\n      setSelectedConflicts(new Set());\n    } else {\n      setSelectedConflicts(\n        new Set(conflicts.map((c) => `${c.varName}:${c.sourcePath}`)),\n      );\n    }\n  };\n\n  const handleDelete = async () => {\n    setShowConfirmDialog(false);\n    setIsDeleting(true);\n\n    try {\n      const conflictsToDelete = conflicts.filter((c) =>\n        selectedConflicts.has(`${c.varName}:${c.sourcePath}`),\n      );\n\n      if (conflictsToDelete.length === 0) {\n        toast.warning(t(\"env.error.noSelection\"));\n        return;\n      }\n\n      const backupInfo = await deleteEnvVars(conflictsToDelete);\n\n      toast.success(t(\"env.delete.success\"), {\n        description: t(\"env.backup.location\", {\n          path: backupInfo.backupPath,\n        }),\n        duration: 5000,\n        closeButton: true,\n      });\n\n      // 清空选择并通知父组件\n      setSelectedConflicts(new Set());\n      onDeleted();\n    } catch (error) {\n      console.error(\"删除环境变量失败:\", error);\n      toast.error(t(\"env.delete.error\"), {\n        description: String(error),\n      });\n    } finally {\n      setIsDeleting(false);\n    }\n  };\n\n  const getSourceDescription = (conflict: EnvConflict): string => {\n    if (conflict.sourceType === \"system\") {\n      if (conflict.sourcePath.includes(\"HKEY_CURRENT_USER\")) {\n        return t(\"env.source.userRegistry\");\n      } else if (conflict.sourcePath.includes(\"HKEY_LOCAL_MACHINE\")) {\n        return t(\"env.source.systemRegistry\");\n      } else {\n        return t(\"env.source.systemEnv\");\n      }\n    } else {\n      return conflict.sourcePath;\n    }\n  };\n\n  return (\n    <>\n      <div className=\"fixed top-0 left-0 right-0 z-[100] bg-yellow-50 dark:bg-yellow-950 border-b border-yellow-200 dark:border-yellow-900 shadow-lg animate-slide-down\">\n        <div className=\"container mx-auto px-4 py-3\">\n          <div className=\"flex items-start gap-3\">\n            <AlertTriangle className=\"h-5 w-5 text-yellow-600 dark:text-yellow-500 flex-shrink-0 mt-0.5\" />\n\n            <div className=\"flex-1 min-w-0\">\n              <div className=\"flex items-center justify-between gap-3\">\n                <div>\n                  <h3 className=\"text-sm font-semibold text-yellow-900 dark:text-yellow-100\">\n                    {t(\"env.warning.title\")}\n                  </h3>\n                  <p className=\"text-sm text-yellow-800 dark:text-yellow-200 mt-0.5\">\n                    {t(\"env.warning.description\", { count: conflicts.length })}\n                  </p>\n                </div>\n\n                <div className=\"flex items-center gap-2 flex-shrink-0\">\n                  <Button\n                    variant=\"ghost\"\n                    size=\"sm\"\n                    onClick={() => setIsExpanded(!isExpanded)}\n                    className=\"text-yellow-900 dark:text-yellow-100 hover:bg-yellow-100 dark:hover:bg-yellow-900/50\"\n                  >\n                    {isExpanded ? (\n                      <>\n                        {t(\"env.actions.collapse\")}\n                        <ChevronUp className=\"h-4 w-4 ml-1\" />\n                      </>\n                    ) : (\n                      <>\n                        {t(\"env.actions.expand\")}\n                        <ChevronDown className=\"h-4 w-4 ml-1\" />\n                      </>\n                    )}\n                  </Button>\n\n                  <Button\n                    variant=\"ghost\"\n                    size=\"icon\"\n                    onClick={onDismiss}\n                    className=\"text-yellow-900 dark:text-yellow-100 hover:bg-yellow-100 dark:hover:bg-yellow-900/50\"\n                  >\n                    <X className=\"h-4 w-4\" />\n                  </Button>\n                </div>\n              </div>\n\n              {isExpanded && (\n                <div className=\"mt-4 space-y-3\">\n                  <div className=\"flex items-center gap-2 pb-2 border-b border-yellow-200 dark:border-yellow-900/50\">\n                    <Checkbox\n                      id=\"select-all\"\n                      checked={selectedConflicts.size === conflicts.length}\n                      onCheckedChange={toggleSelectAll}\n                    />\n                    <label\n                      htmlFor=\"select-all\"\n                      className=\"text-sm font-medium text-yellow-900 dark:text-yellow-100 cursor-pointer\"\n                    >\n                      {t(\"env.actions.selectAll\")}\n                    </label>\n                  </div>\n\n                  <div className=\"max-h-96 overflow-y-auto space-y-2\">\n                    {conflicts.map((conflict) => {\n                      const key = `${conflict.varName}:${conflict.sourcePath}`;\n                      return (\n                        <div\n                          key={key}\n                          className=\"flex items-start gap-3 p-3 bg-white dark:bg-gray-900 rounded-md border border-yellow-200 dark:border-yellow-900/50\"\n                        >\n                          <Checkbox\n                            id={key}\n                            checked={selectedConflicts.has(key)}\n                            onCheckedChange={() => toggleSelection(key)}\n                          />\n\n                          <div className=\"flex-1 min-w-0\">\n                            <label\n                              htmlFor={key}\n                              className=\"block text-sm font-medium text-foreground cursor-pointer\"\n                            >\n                              {conflict.varName}\n                            </label>\n                            <p className=\"text-xs text-muted-foreground mt-1 break-all\">\n                              {t(\"env.field.value\")}: {conflict.varValue}\n                            </p>\n                            <p className=\"text-xs text-muted-foreground mt-1\">\n                              {t(\"env.field.source\")}:{\" \"}\n                              {getSourceDescription(conflict)}\n                            </p>\n                          </div>\n                        </div>\n                      );\n                    })}\n                  </div>\n\n                  <div className=\"flex items-center justify-end gap-2 pt-2 border-t border-yellow-200 dark:border-yellow-900/50\">\n                    <Button\n                      variant=\"outline\"\n                      size=\"sm\"\n                      onClick={() => setSelectedConflicts(new Set())}\n                      disabled={selectedConflicts.size === 0}\n                      className=\"text-yellow-900 dark:text-yellow-100 border-yellow-300 dark:border-yellow-800\"\n                    >\n                      {t(\"env.actions.clearSelection\")}\n                    </Button>\n\n                    <Button\n                      variant=\"destructive\"\n                      size=\"sm\"\n                      onClick={() => setShowConfirmDialog(true)}\n                      disabled={selectedConflicts.size === 0 || isDeleting}\n                      className=\"gap-1\"\n                    >\n                      <Trash2 className=\"h-4 w-4\" />\n                      {isDeleting\n                        ? t(\"env.actions.deleting\")\n                        : t(\"env.actions.deleteSelected\", {\n                            count: selectedConflicts.size,\n                          })}\n                    </Button>\n                  </div>\n                </div>\n              )}\n            </div>\n          </div>\n        </div>\n      </div>\n\n      <Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>\n        <DialogContent className=\"max-w-md\" zIndex=\"top\">\n          <DialogHeader>\n            <DialogTitle className=\"flex items-center gap-2\">\n              <AlertTriangle className=\"h-5 w-5 text-destructive\" />\n              {t(\"env.confirm.title\")}\n            </DialogTitle>\n            <DialogDescription className=\"space-y-2\">\n              <p>\n                {t(\"env.confirm.message\", { count: selectedConflicts.size })}\n              </p>\n              <p className=\"text-sm text-muted-foreground\">\n                {t(\"env.confirm.backupNotice\")}\n              </p>\n            </DialogDescription>\n          </DialogHeader>\n          <DialogFooter>\n            <Button\n              variant=\"outline\"\n              onClick={() => setShowConfirmDialog(false)}\n            >\n              {t(\"common.cancel\")}\n            </Button>\n            <Button variant=\"destructive\" onClick={handleDelete}>\n              {t(\"env.confirm.confirm\")}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    </>\n  );\n}\n"
  },
  {
    "path": "src/components/icons/TerminalIcons.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function ITermIcon(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      viewBox=\"0 0 24 24\"\n      fill=\"currentColor\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <title>iTerm2</title>\n      <path d=\"M24 5.359v13.282A5.36 5.36 0 0 1 18.641 24H5.359A5.36 5.36 0 0 1 0 18.641V5.359A5.36 5.36 0 0 1 5.359 0h13.282A5.36 5.36 0 0 1 24 5.359m-.932-.233A4.196 4.196 0 0 0 18.874.932H5.126A4.196 4.196 0 0 0 .932 5.126v13.748a4.196 4.196 0 0 0 4.194 4.194h13.748a4.196 4.196 0 0 0 4.194-4.194zm-.816.233v13.282a3.613 3.613 0 0 1-3.611 3.611H5.359a3.613 3.613 0 0 1-3.611-3.611V5.359a3.613 3.613 0 0 1 3.611-3.611h13.282a3.613 3.613 0 0 1 3.611 3.611M8.854 4.194v6.495h.962V4.194zM5.483 9.493v1.085h.597V9.48q.283-.037.508-.133.373-.165.575-.448.208-.284.208-.649a.9.9 0 0 0-.171-.568 1.4 1.4 0 0 0-.426-.388 3 3 0 0 0-.544-.261 32 32 0 0 0-.545-.209 1.8 1.8 0 0 1-.426-.216q-.164-.12-.164-.284 0-.223.179-.351.18-.126.485-.127.344 0 .575.105.239.105.5.298l.433-.5a2.3 2.3 0 0 0-.605-.433 1.6 1.6 0 0 0-.582-.159v-.968h-.597v.978a2 2 0 0 0-.477.127 1.2 1.2 0 0 0-.545.411q-.194.268-.194.634 0 .335.164.56.164.224.418.38a4 4 0 0 0 .552.262q.291.104.545.209.261.104.425.238a.39.39 0 0 1 .165.321q0 .225-.187.359-.18.134-.537.134-.381 0-.717-.134a4.4 4.4 0 0 1-.649-.351l-.388.589q.209.173.477.306.276.135.575.217.191.046.373.064\" />\n    </svg>\n  );\n}\n\nexport function AlacrittyIcon(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      viewBox=\"0 0 24 24\"\n      fill=\"currentColor\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <title>Alacritty</title>\n      <path d=\"m10.065 0-8.57 21.269h3.595l6.91-16.244 6.91 16.244h3.594l-8.57-21.269zm1.935 9.935c-0.76666 1.8547-1.5334 3.7094-2.298 5.565 1.475 4.54 1.475 4.54 2.298 8.5 0.823-3.96 0.823-3.96 2.297-8.5-0.76637-1.8547-1.5315-3.7099-2.297-5.565z\" />\n    </svg>\n  );\n}\n\nexport function WezTermIcon(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      viewBox=\"0 0 24 24\"\n      fill=\"currentColor\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <title>WezTerm</title>\n      <path d=\"M3.27 8.524c0-.623.62-1.007 2.123-1.007l-.5 2.757c-.931-.623-1.624-1.199-1.624-1.75zm4.008 6.807c0 .647-.644 1.079-2.123 1.15l.524-2.924c.931.624 1.6 1.175 1.6 1.774zm-2.625 5.992.454-2.708c3.603-.336 5.01-1.798 5.01-3.404 0-1.653-2.004-2.948-3.841-4.074l.668-3.548c.764.072 1.67.216 2.744.432l.31-2.469c-.81-.12-1.575-.168-2.29-.216L8.257 2.7l-2.363-.024-.453 2.684C1.838 5.648.43 7.158.43 8.764c0 1.63 2.004 2.876 3.841 3.954l-.668 3.716c-.859-.048-1.908-.192-3.125-.408L0 18.495c1.026.12 1.98.192 2.84.216l-.525 2.588zm15.553-1.894h2.673c.334-2.804.81-8.46 1.121-14.86h-2.553c-.071 1.51-.334 10.498-.43 11.241h-.071c-.644-2.42-1.169-4.386-1.813-6.782h-1.456c-.62 2.396-1.05 4.194-1.694 6.782h-.096c-.071-.743-.477-9.73-.525-11.24h-2.648c.31 6.399.763 12.055 1.097 14.86h2.625l1.838-7.12z\" />\n    </svg>\n  );\n}\n\nexport function GhosttyIcon(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      viewBox=\"0 0 24 24\"\n      fill=\"currentColor\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <title>Ghostty</title>\n      <path d=\"M12 0C6.7 0 2.4 4.3 2.4 9.6v11.146c0 1.772 1.45 3.267 3.222 3.254a3.18 3.18 0 0 0 1.955-.686 1.96 1.96 0 0 1 2.444 0 3.18 3.18 0 0 0 1.976.686c.75 0 1.436-.257 1.98-.686.715-.563 1.71-.587 2.419-.018.59.476 1.355.743 2.182.699 1.705-.094 3.022-1.537 3.022-3.244V9.601C21.6 4.3 17.302 0 12 0M6.069 6.562a1 1 0 0 1 .46.131l3.578 2.065v.002a.974.974 0 0 1 0 1.687L6.53 12.512a.975.975 0 0 1-.976-1.687L7.67 9.602 5.553 8.38a.975.975 0 0 1 .515-1.818m7.438 2.063h4.7a.975.975 0 1 1 0 1.95h-4.7a.975.975 0 0 1 0-1.95\" />\n    </svg>\n  );\n}\n\nexport function KittyIcon(props: SVGProps<SVGSVGElement>) {\n  // Official icon is complex and has fixed width/height/viewBox in original.\n  // Simplifying viewBox to 0 0 256 256 effectively as original was 240x240 but translated.\n  // Original viewBox=\"0 0 240 240\" with g transform=\"translate(0 -812.362)\" and elements around y=850.\n  // 850 - 812 = 38. So it's confusing.\n  // Let's copy the raw SVG content but adapt it to be a component.\n  // To make it behave like an icon, we should probably set viewBox=\"0 0 240 240\" and keep the transform.\n  // It relies on fill colors. If we want it to be monochrome (currentColor), we should remove fills or set them to currentColor.\n  // However, official icons often have brand colors. The user said \"official icon\", which implies color.\n  // But usually in a dropdown we might want monochrome or original color.\n  // simple-icons are usually monochrome.\n  // Let's keep Kitty as original color since it's complex, OR mono if it works?\n  // The kitty icon has multiple paths with different colors. I'll preserve them for now as it's \"official\".\n  // If it looks weird in dark mode/light mode, we might need to adjust.\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      viewBox=\"0 0 240 240\"\n      {...props} // Allow overriding width/height\n    >\n      <g transform=\"translate(0 -812.362)\">\n        <rect\n          width=\"100.446\"\n          height=\"161.551\"\n          x=\"72.824\"\n          y=\"850.13\"\n          ry=\"0\"\n          style={{\n            fill: \"#ddd\",\n            fillOpacity: 1,\n            fillRule: \"evenodd\",\n            stroke: \"none\",\n            strokeWidth: 5.86876726,\n            strokeLinecap: \"round\",\n            strokeLinejoin: \"round\",\n            strokeMiterlimit: 4,\n            strokeDasharray: \"none\",\n            strokeOpacity: 1,\n          }}\n        />\n        <path\n          d=\"M67.896 1029.71h104.208a7.065 7.065 0 0 0 7.065-7.066V918.436a7.065 7.065 0 0 0-7.065-7.065H67.896a7.065 7.065 0 0 0-7.065 7.065v104.208a7.065 7.065 0 0 0 7.065 7.065m55.813-38.35h37.444a4.239 4.239 0 0 1 0 8.479H123.71a4.239 4.239 0 0 1 0-8.478m-45.032-45.71a4.239 4.239 0 0 1 5.991-5.99l26.48 26.464a4.24 4.24 0 0 1 0 5.992l-26.48 26.48a4.239 4.239 0 0 1-5.991-5.992l23.484-23.484z\"\n          style={{ strokeWidth: 1.41299629 }}\n        />\n        <path\n          d=\"M96.085 898.143c1.881 0 3.386-3.574 3.386-8.17 0-4.595-1.505-8.169-3.386-8.169-1.88 0-3.385 3.574-3.385 8.17 0 4.595 1.504 8.17 3.385 8.17\"\n          style={{\n            clipRule: \"evenodd\",\n            fill: \"#c0c81f\",\n            fillOpacity: 1,\n            fillRule: \"evenodd\",\n            strokeWidth: 3.09913683,\n          }}\n        />\n        <path\n          d=\"M193.128 836.886c-4.596-4.85-25.53 1.022-38.295 8.936-9.957-5.106-21.956-8.17-34.721-8.17-13.02 0-25.02 3.064-34.977 8.17-12.765-7.914-33.955-14.042-38.295-8.936-4.595 5.106 3.32 26.296 12.765 38.04-.766 3.064-1.276 6.128-1.276 9.446 0 10.212 4.34 19.659 11.744 27.318h42.124c-1.276-2.553.511-4.085 8.17-4.085 7.659.255 9.19 1.532 8.17 4.085h42.124c7.404-7.66 11.744-17.36 11.744-27.318 0-3.318-.51-6.382-1.276-9.446 8.935-11.744 16.594-33.189 11.999-38.04m-97.015 67.4c-8.935 0-16.339-7.404-16.339-16.34s7.404-16.339 16.34-16.339 16.339 7.404 16.339 16.34-7.404 16.339-16.34 16.339m47.997 0c-8.936 0-16.34-7.404-16.34-16.34s7.404-16.339 16.34-16.339 16.34 7.404 16.34 16.34-7.15 16.339-16.34 16.339\"\n          style={{\n            clipRule: \"evenodd\",\n            fill: \"#784421\",\n            fillOpacity: 1,\n            fillRule: \"evenodd\",\n            strokeWidth: 2.55301046,\n          }}\n        />\n        <g style={{ fill: \"#2b1100\", fillOpacity: 1 }}>\n          <path\n            d=\"M168.507 903.265c15.318-19.148 46.72-28.339 67.655-15.063-24.509-3.83-46.72 2.553-67.655 15.063\"\n            style={{\n              clipRule: \"evenodd\",\n              fillRule: \"evenodd\",\n              strokeWidth: 2.55301046,\n              fill: \"#2b1100\",\n              fillOpacity: 1,\n            }}\n          />\n          <path\n            d=\"M167.486 898.67c8.68-20.425 34.466-33.7 55.145-26.552-21.7 2.808-39.316 11.233-55.145 26.551m-.256 9.957c15.83-15.063 50.806-20.169 61.528-4.34-21.7-6.893-40.593-3.83-61.527 4.34\"\n            style={{\n              clipRule: \"evenodd\",\n              fillRule: \"evenodd\",\n              strokeWidth: 2.55301046,\n              fill: \"#2b1100\",\n              fillOpacity: 1,\n            }}\n          />\n        </g>\n        <g style={{ fill: \"#2b1100\", fillOpacity: 1 }}>\n          <path\n            d=\"M71.493 903.265c-15.318-19.148-46.72-28.339-67.655-15.063 24.509-3.83 46.72 2.553 67.655 15.063\"\n            style={{\n              clipRule: \"evenodd\",\n              fillRule: \"evenodd\",\n              strokeWidth: 2.55301046,\n              fill: \"#2b1100\",\n              fillOpacity: 1,\n            }}\n          />\n          <path\n            d=\"M72.514 898.67c-8.68-20.425-34.466-33.7-55.145-26.552 21.7 2.808 39.316 11.233 55.145 26.551m.256 9.957c-15.83-15.063-50.806-20.169-61.528-4.34 21.7-6.893 40.593-3.83 61.527 4.34\"\n            style={{\n              clipRule: \"evenodd\",\n              fillRule: \"evenodd\",\n              strokeWidth: 2.55301046,\n              fill: \"#2b1100\",\n              fillOpacity: 1,\n            }}\n          />\n        </g>\n        <path\n          d=\"M52.6 893.563c-6.382 0-11.743 3.32-14.296 8.425h-.766c-6.893 0-12.765 5.106-12.765 11.489 0 8.935 9.19 13.786 17.615 10.722 5.106 7.404 16.084 7.915 20.17 0 6.126-.255 16.083-1.276 17.615-10.722 1.021-6.383-5.617-11.489-12.765-11.489h-.766c-2.042-5.106-7.659-8.425-14.041-8.425m134.8 0c6.382 0 11.743 3.32 14.296 8.425h.766c3.574 0 12.765 5.106 12.765 11.489 0 8.935-9.19 13.786-17.615 10.722-5.107 7.404-16.084 7.915-20.17 0-6.126-.255-16.083-1.276-17.615-10.722-1.021-6.383 9.19-11.489 12.765-11.489h.766c2.042-5.106 7.659-8.425 14.041-8.425\"\n          style={{\n            clipRule: \"evenodd\",\n            fill: \"#483737\",\n            fillOpacity: 1,\n            fillRule: \"evenodd\",\n            strokeWidth: 2.55301046,\n          }}\n        />\n        <path\n          d=\"M143.542 898.143c1.881 0 3.386-3.574 3.386-8.17 0-4.595-1.505-8.169-3.386-8.169-1.88 0-3.386 3.574-3.386 8.17 0 4.595 1.505 8.17 3.386 8.17\"\n          style={{\n            clipRule: \"evenodd\",\n            fill: \"#c0c81f\",\n            fillOpacity: 1,\n            fillRule: \"evenodd\",\n            strokeWidth: 3.09913683,\n          }}\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "src/components/mcp/McpFormModal.tsx",
    "content": "import React, { useMemo, useState, useEffect } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { toast } from \"sonner\";\nimport { Save, Plus, AlertCircle, ChevronDown, ChevronUp } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport { Input } from \"@/components/ui/input\";\nimport JsonEditor from \"@/components/JsonEditor\";\nimport type { AppId } from \"@/lib/api/types\";\nimport { McpServer, McpServerSpec } from \"@/types\";\nimport { mcpPresets, getMcpPresetWithDescription } from \"@/config/mcpPresets\";\nimport McpWizardModal from \"./McpWizardModal\";\nimport {\n  extractErrorMessage,\n  translateMcpBackendError,\n} from \"@/utils/errorUtils\";\nimport {\n  tomlToMcpServer,\n  extractIdFromToml,\n  mcpServerToToml,\n} from \"@/utils/tomlUtils\";\nimport { normalizeTomlText } from \"@/utils/textNormalization\";\nimport { parseSmartMcpJson } from \"@/utils/formatters\";\nimport { useMcpValidation } from \"./useMcpValidation\";\nimport { useUpsertMcpServer } from \"@/hooks/useMcp\";\nimport { FullScreenPanel } from \"@/components/common/FullScreenPanel\";\n\ninterface McpFormModalProps {\n  editingId?: string;\n  initialData?: McpServer;\n  onSave: () => Promise<void>;\n  onClose: () => void;\n  existingIds?: string[];\n  defaultFormat?: \"json\" | \"toml\";\n  defaultEnabledApps?: AppId[];\n}\n\nconst McpFormModal: React.FC<McpFormModalProps> = ({\n  editingId,\n  initialData,\n  onSave,\n  onClose,\n  existingIds = [],\n  defaultFormat = \"json\",\n  defaultEnabledApps = [\"claude\", \"codex\", \"gemini\"],\n}) => {\n  const { t } = useTranslation();\n  const { formatTomlError, validateTomlConfig, validateJsonConfig } =\n    useMcpValidation();\n\n  const upsertMutation = useUpsertMcpServer();\n\n  const [formId, setFormId] = useState(\n    () => editingId || initialData?.id || \"\",\n  );\n  const [formName, setFormName] = useState(initialData?.name || \"\");\n  const [formDescription, setFormDescription] = useState(\n    initialData?.description || \"\",\n  );\n  const [formHomepage, setFormHomepage] = useState(initialData?.homepage || \"\");\n  const [formDocs, setFormDocs] = useState(initialData?.docs || \"\");\n  const [formTags, setFormTags] = useState(initialData?.tags?.join(\", \") || \"\");\n\n  const [enabledApps, setEnabledApps] = useState<{\n    claude: boolean;\n    codex: boolean;\n    gemini: boolean;\n    opencode: boolean;\n    openclaw: boolean;\n  }>(() => {\n    if (initialData?.apps) {\n      return { ...initialData.apps };\n    }\n    return {\n      claude: defaultEnabledApps.includes(\"claude\"),\n      codex: defaultEnabledApps.includes(\"codex\"),\n      gemini: defaultEnabledApps.includes(\"gemini\"),\n      opencode: defaultEnabledApps.includes(\"opencode\"),\n      openclaw: defaultEnabledApps.includes(\"openclaw\"),\n    };\n  });\n\n  const isEditing = !!editingId;\n\n  const hasAdditionalInfo = !!(\n    initialData?.description ||\n    initialData?.tags?.length ||\n    initialData?.homepage ||\n    initialData?.docs\n  );\n\n  const [showMetadata, setShowMetadata] = useState(\n    isEditing ? hasAdditionalInfo : false,\n  );\n\n  const useTomlFormat = useMemo(() => {\n    if (initialData?.server) {\n      return defaultFormat === \"toml\";\n    }\n    return defaultFormat === \"toml\";\n  }, [defaultFormat, initialData]);\n\n  const [formConfig, setFormConfig] = useState(() => {\n    const spec = initialData?.server;\n    if (!spec) return \"\";\n    if (useTomlFormat) {\n      return mcpServerToToml(spec);\n    }\n    return JSON.stringify(spec, null, 2);\n  });\n\n  const [configError, setConfigError] = useState(\"\");\n  const [saving, setSaving] = useState(false);\n  const [isWizardOpen, setIsWizardOpen] = useState(false);\n  const [idError, setIdError] = useState(\"\");\n  const [isDarkMode, setIsDarkMode] = useState(false);\n\n  useEffect(() => {\n    setIsDarkMode(document.documentElement.classList.contains(\"dark\"));\n\n    const observer = new MutationObserver(() => {\n      setIsDarkMode(document.documentElement.classList.contains(\"dark\"));\n    });\n\n    observer.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n\n    return () => observer.disconnect();\n  }, []);\n\n  const useToml = useTomlFormat;\n\n  const wizardInitialSpec = useMemo(() => {\n    const fallback = initialData?.server;\n    if (!formConfig.trim()) {\n      return fallback;\n    }\n\n    if (useToml) {\n      try {\n        return tomlToMcpServer(formConfig);\n      } catch {\n        return fallback;\n      }\n    }\n\n    try {\n      const parsed = JSON.parse(formConfig);\n      if (parsed && typeof parsed === \"object\" && !Array.isArray(parsed)) {\n        return parsed as McpServerSpec;\n      }\n      return fallback;\n    } catch {\n      return fallback;\n    }\n  }, [formConfig, initialData, useToml]);\n\n  const [selectedPreset, setSelectedPreset] = useState<number | null>(\n    isEditing ? null : -1,\n  );\n\n  const handleIdChange = (value: string) => {\n    setFormId(value);\n    if (!isEditing) {\n      const exists = existingIds.includes(value.trim());\n      setIdError(exists ? t(\"mcp.error.idExists\") : \"\");\n    }\n  };\n\n  const ensureUniqueId = (base: string): string => {\n    let candidate = base.trim();\n    if (!candidate) candidate = \"mcp-server\";\n    if (!existingIds.includes(candidate)) return candidate;\n    let i = 1;\n    while (existingIds.includes(`${candidate}-${i}`)) i++;\n    return `${candidate}-${i}`;\n  };\n\n  const applyPreset = (index: number) => {\n    if (index < 0 || index >= mcpPresets.length) return;\n    const preset = mcpPresets[index];\n    const presetWithDesc = getMcpPresetWithDescription(preset, t);\n\n    const id = ensureUniqueId(presetWithDesc.id);\n    setFormId(id);\n    setFormName(presetWithDesc.name || presetWithDesc.id);\n    setFormDescription(presetWithDesc.description || \"\");\n    setFormHomepage(presetWithDesc.homepage || \"\");\n    setFormDocs(presetWithDesc.docs || \"\");\n    setFormTags(presetWithDesc.tags?.join(\", \") || \"\");\n\n    if (useToml) {\n      const toml = mcpServerToToml(presetWithDesc.server);\n      setFormConfig(toml);\n      setConfigError(validateTomlConfig(toml));\n    } else {\n      const json = JSON.stringify(presetWithDesc.server, null, 2);\n      setFormConfig(json);\n      setConfigError(validateJsonConfig(json));\n    }\n    setSelectedPreset(index);\n  };\n\n  const applyCustom = () => {\n    setSelectedPreset(-1);\n    setFormId(\"\");\n    setFormName(\"\");\n    setFormDescription(\"\");\n    setFormHomepage(\"\");\n    setFormDocs(\"\");\n    setFormTags(\"\");\n    setFormConfig(\"\");\n    setConfigError(\"\");\n  };\n\n  const handleConfigChange = (value: string) => {\n    const nextValue = useToml ? normalizeTomlText(value) : value;\n    setFormConfig(nextValue);\n\n    if (useToml) {\n      const err = validateTomlConfig(nextValue);\n      if (err) {\n        setConfigError(err);\n        return;\n      }\n\n      if (nextValue.trim() && !formId.trim()) {\n        const extractedId = extractIdFromToml(nextValue);\n        if (extractedId) {\n          setFormId(extractedId);\n        }\n      }\n    } else {\n      try {\n        const result = parseSmartMcpJson(value);\n        const configJson = JSON.stringify(result.config);\n        const validationErr = validateJsonConfig(configJson);\n\n        if (validationErr) {\n          setConfigError(validationErr);\n          return;\n        }\n\n        if (result.id && !formId.trim() && !isEditing) {\n          const uniqueId = ensureUniqueId(result.id);\n          setFormId(uniqueId);\n\n          if (!formName.trim()) {\n            setFormName(result.id);\n          }\n        }\n\n        setConfigError(\"\");\n      } catch (err: any) {\n        const errorMessage = err?.message || String(err);\n        setConfigError(t(\"mcp.error.jsonInvalid\") + \": \" + errorMessage);\n      }\n    }\n  };\n\n  const handleWizardApply = (title: string, json: string) => {\n    setFormId(title);\n    if (!formName.trim()) {\n      setFormName(title);\n    }\n    if (useToml) {\n      try {\n        const server = JSON.parse(json) as McpServerSpec;\n        const toml = mcpServerToToml(server);\n        setFormConfig(toml);\n        setConfigError(validateTomlConfig(toml));\n      } catch (e: any) {\n        setConfigError(t(\"mcp.error.jsonInvalid\"));\n      }\n    } else {\n      setFormConfig(json);\n      setConfigError(validateJsonConfig(json));\n    }\n  };\n\n  const handleSubmit = async () => {\n    const trimmedId = formId.trim();\n    if (!trimmedId) {\n      toast.error(t(\"mcp.error.idRequired\"), { duration: 3000 });\n      return;\n    }\n\n    if (!isEditing && existingIds.includes(trimmedId)) {\n      setIdError(t(\"mcp.error.idExists\"));\n      return;\n    }\n\n    let serverSpec: McpServerSpec;\n\n    if (useToml) {\n      const tomlError = validateTomlConfig(formConfig);\n      setConfigError(tomlError);\n      if (tomlError) {\n        toast.error(t(\"mcp.error.tomlInvalid\"), { duration: 3000 });\n        return;\n      }\n\n      if (!formConfig.trim()) {\n        serverSpec = {\n          type: \"stdio\",\n          command: \"\",\n          args: [],\n        };\n      } else {\n        try {\n          serverSpec = tomlToMcpServer(formConfig);\n        } catch (e: any) {\n          const msg = e?.message || String(e);\n          setConfigError(formatTomlError(msg));\n          toast.error(t(\"mcp.error.tomlInvalid\"), { duration: 4000 });\n          return;\n        }\n      }\n    } else {\n      if (!formConfig.trim()) {\n        serverSpec = {\n          type: \"stdio\",\n          command: \"\",\n          args: [],\n        };\n      } else {\n        try {\n          const result = parseSmartMcpJson(formConfig);\n          serverSpec = result.config as McpServerSpec;\n        } catch (e: any) {\n          const errorMessage = e?.message || String(e);\n          setConfigError(t(\"mcp.error.jsonInvalid\") + \": \" + errorMessage);\n          toast.error(t(\"mcp.error.jsonInvalid\"), { duration: 4000 });\n          return;\n        }\n      }\n    }\n\n    if (serverSpec?.type === \"stdio\" && !serverSpec?.command?.trim()) {\n      toast.error(t(\"mcp.error.commandRequired\"), { duration: 3000 });\n      return;\n    }\n    if (\n      (serverSpec?.type === \"http\" || serverSpec?.type === \"sse\") &&\n      !serverSpec?.url?.trim()\n    ) {\n      toast.error(t(\"mcp.wizard.urlRequired\"), { duration: 3000 });\n      return;\n    }\n\n    setSaving(true);\n    try {\n      const nameTrimmed = (formName || trimmedId).trim();\n      const finalName = nameTrimmed || trimmedId;\n\n      const entry: McpServer = {\n        ...(initialData ? { ...initialData } : {}),\n        id: trimmedId,\n        name: finalName,\n        server: serverSpec,\n        apps: enabledApps,\n      };\n\n      const descriptionTrimmed = formDescription.trim();\n      if (descriptionTrimmed) {\n        entry.description = descriptionTrimmed;\n      } else {\n        delete entry.description;\n      }\n\n      const homepageTrimmed = formHomepage.trim();\n      if (homepageTrimmed) {\n        entry.homepage = homepageTrimmed;\n      } else {\n        delete entry.homepage;\n      }\n\n      const docsTrimmed = formDocs.trim();\n      if (docsTrimmed) {\n        entry.docs = docsTrimmed;\n      } else {\n        delete entry.docs;\n      }\n\n      const parsedTags = formTags\n        .split(\",\")\n        .map((tag) => tag.trim())\n        .filter((tag) => tag.length > 0);\n      if (parsedTags.length > 0) {\n        entry.tags = parsedTags;\n      } else {\n        delete entry.tags;\n      }\n\n      await upsertMutation.mutateAsync(entry);\n      toast.success(t(\"common.success\"), { closeButton: true });\n      await onSave();\n    } catch (error: any) {\n      const detail = extractErrorMessage(error);\n      const mapped = translateMcpBackendError(detail, t);\n      const msg = mapped || detail || t(\"mcp.error.saveFailed\");\n      toast.error(msg, { duration: mapped || detail ? 6000 : 4000 });\n    } finally {\n      setSaving(false);\n    }\n  };\n\n  const getFormTitle = () => {\n    return isEditing ? t(\"mcp.editServer\") : t(\"mcp.addServer\");\n  };\n\n  return (\n    <>\n      <FullScreenPanel\n        isOpen={true}\n        title={getFormTitle()}\n        onClose={onClose}\n        footer={\n          <Button\n            type=\"button\"\n            onClick={handleSubmit}\n            disabled={saving || (!isEditing && !!idError)}\n            className=\"bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed\"\n          >\n            {isEditing ? <Save size={16} /> : <Plus size={16} />}\n            {saving\n              ? t(\"common.saving\")\n              : isEditing\n                ? t(\"common.save\")\n                : t(\"common.add\")}\n          </Button>\n        }\n      >\n        <div className=\"flex flex-col h-full gap-6\">\n          {/* 上半部分：表单字段 */}\n          <div className=\"glass rounded-xl p-6 border border-white/10 space-y-6 flex-shrink-0\">\n            {/* 预设选择（仅新增时展示） */}\n            {!isEditing && (\n              <div>\n                <label className=\"block text-sm font-medium text-foreground mb-3\">\n                  {t(\"mcp.presets.title\")}\n                </label>\n                <div className=\"flex flex-wrap gap-2\">\n                  <button\n                    type=\"button\"\n                    onClick={applyCustom}\n                    className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${\n                      selectedPreset === -1\n                        ? \"bg-emerald-500 text-white dark:bg-emerald-600\"\n                        : \"bg-accent text-muted-foreground hover:bg-accent/80\"\n                    }`}\n                  >\n                    {t(\"presetSelector.custom\")}\n                  </button>\n                  {mcpPresets.map((preset, idx) => {\n                    const descriptionKey = `mcp.presets.${preset.id}.description`;\n                    return (\n                      <button\n                        key={preset.id}\n                        type=\"button\"\n                        onClick={() => applyPreset(idx)}\n                        className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${\n                          selectedPreset === idx\n                            ? \"bg-emerald-500 text-white dark:bg-emerald-600\"\n                            : \"bg-accent text-muted-foreground hover:bg-accent/80\"\n                        }`}\n                        title={t(descriptionKey)}\n                      >\n                        {preset.id}\n                      </button>\n                    );\n                  })}\n                </div>\n              </div>\n            )}\n\n            {/* ID (标题) */}\n            <div>\n              <div className=\"flex items-center justify-between mb-2\">\n                <label className=\"block text-sm font-medium text-foreground\">\n                  {t(\"mcp.form.title\")} <span className=\"text-red-500\">*</span>\n                </label>\n                {!isEditing && idError && (\n                  <span className=\"text-xs text-red-500 dark:text-red-400\">\n                    {idError}\n                  </span>\n                )}\n              </div>\n              <Input\n                type=\"text\"\n                placeholder={t(\"mcp.form.titlePlaceholder\")}\n                value={formId}\n                onChange={(e) => handleIdChange(e.target.value)}\n                disabled={isEditing}\n              />\n            </div>\n\n            {/* Name */}\n            <div>\n              <label className=\"block text-sm font-medium text-foreground mb-2\">\n                {t(\"mcp.form.name\")}\n              </label>\n              <Input\n                type=\"text\"\n                placeholder={t(\"mcp.form.namePlaceholder\")}\n                value={formName}\n                onChange={(e) => setFormName(e.target.value)}\n              />\n            </div>\n\n            {/* 启用到哪些应用 */}\n            <div>\n              <label className=\"block text-sm font-medium text-foreground mb-3\">\n                {t(\"mcp.form.enabledApps\")}\n              </label>\n              <div className=\"flex flex-wrap gap-4\">\n                <div className=\"flex items-center gap-2\">\n                  <Checkbox\n                    id=\"enable-claude\"\n                    checked={enabledApps.claude}\n                    onCheckedChange={(checked: boolean) =>\n                      setEnabledApps({ ...enabledApps, claude: checked })\n                    }\n                  />\n                  <label\n                    htmlFor=\"enable-claude\"\n                    className=\"text-sm text-foreground cursor-pointer select-none\"\n                  >\n                    {t(\"mcp.unifiedPanel.apps.claude\")}\n                  </label>\n                </div>\n\n                <div className=\"flex items-center gap-2\">\n                  <Checkbox\n                    id=\"enable-codex\"\n                    checked={enabledApps.codex}\n                    onCheckedChange={(checked: boolean) =>\n                      setEnabledApps({ ...enabledApps, codex: checked })\n                    }\n                  />\n                  <label\n                    htmlFor=\"enable-codex\"\n                    className=\"text-sm text-foreground cursor-pointer select-none\"\n                  >\n                    {t(\"mcp.unifiedPanel.apps.codex\")}\n                  </label>\n                </div>\n\n                <div className=\"flex items-center gap-2\">\n                  <Checkbox\n                    id=\"enable-gemini\"\n                    checked={enabledApps.gemini}\n                    onCheckedChange={(checked: boolean) =>\n                      setEnabledApps({ ...enabledApps, gemini: checked })\n                    }\n                  />\n                  <label\n                    htmlFor=\"enable-gemini\"\n                    className=\"text-sm text-foreground cursor-pointer select-none\"\n                  >\n                    {t(\"mcp.unifiedPanel.apps.gemini\")}\n                  </label>\n                </div>\n\n                <div className=\"flex items-center gap-2\">\n                  <Checkbox\n                    id=\"enable-opencode\"\n                    checked={enabledApps.opencode}\n                    onCheckedChange={(checked: boolean) =>\n                      setEnabledApps({ ...enabledApps, opencode: checked })\n                    }\n                  />\n                  <label\n                    htmlFor=\"enable-opencode\"\n                    className=\"text-sm text-foreground cursor-pointer select-none\"\n                  >\n                    {t(\"mcp.unifiedPanel.apps.opencode\")}\n                  </label>\n                </div>\n              </div>\n            </div>\n\n            {/* 可折叠的附加信息按钮 */}\n            <div>\n              <button\n                type=\"button\"\n                onClick={() => setShowMetadata(!showMetadata)}\n                className=\"flex items-center gap-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors\"\n              >\n                {showMetadata ? (\n                  <ChevronUp size={16} />\n                ) : (\n                  <ChevronDown size={16} />\n                )}\n                {t(\"mcp.form.additionalInfo\")}\n              </button>\n            </div>\n\n            {/* 附加信息区域（可折叠） */}\n            {showMetadata && (\n              <>\n                <div>\n                  <label className=\"block text-sm font-medium text-foreground mb-2\">\n                    {t(\"mcp.form.description\")}\n                  </label>\n                  <Input\n                    type=\"text\"\n                    placeholder={t(\"mcp.form.descriptionPlaceholder\")}\n                    value={formDescription}\n                    onChange={(e) => setFormDescription(e.target.value)}\n                  />\n                </div>\n\n                <div>\n                  <label className=\"block text-sm font-medium text-foreground mb-2\">\n                    {t(\"mcp.form.tags\")}\n                  </label>\n                  <Input\n                    type=\"text\"\n                    placeholder={t(\"mcp.form.tagsPlaceholder\")}\n                    value={formTags}\n                    onChange={(e) => setFormTags(e.target.value)}\n                  />\n                </div>\n\n                <div>\n                  <label className=\"block text-sm font-medium text-foreground mb-2\">\n                    {t(\"mcp.form.homepage\")}\n                  </label>\n                  <Input\n                    type=\"text\"\n                    placeholder={t(\"mcp.form.homepagePlaceholder\")}\n                    value={formHomepage}\n                    onChange={(e) => setFormHomepage(e.target.value)}\n                  />\n                </div>\n\n                <div>\n                  <label className=\"block text-sm font-medium text-foreground mb-2\">\n                    {t(\"mcp.form.docs\")}\n                  </label>\n                  <Input\n                    type=\"text\"\n                    placeholder={t(\"mcp.form.docsPlaceholder\")}\n                    value={formDocs}\n                    onChange={(e) => setFormDocs(e.target.value)}\n                  />\n                </div>\n              </>\n            )}\n          </div>\n\n          {/* 下半部分：JSON 配置编辑器 - 自适应剩余高度 */}\n          <div className=\"glass rounded-xl p-6 border border-white/10 flex flex-col flex-1 min-h-0\">\n            <div className=\"flex items-center justify-between mb-4 flex-shrink-0\">\n              <label className=\"text-sm font-medium text-foreground\">\n                {useToml ? t(\"mcp.form.tomlConfig\") : t(\"mcp.form.jsonConfig\")}\n              </label>\n              {(isEditing || selectedPreset === -1) && (\n                <button\n                  type=\"button\"\n                  onClick={() => setIsWizardOpen(true)}\n                  className=\"text-sm text-blue-500 dark:text-blue-400 hover:text-blue-600 dark:hover:text-blue-300 transition-colors\"\n                >\n                  {t(\"mcp.form.useWizard\")}\n                </button>\n              )}\n            </div>\n            <div className=\"flex-1 min-h-0 flex flex-col\">\n              <div className=\"flex-1 min-h-0\">\n                <JsonEditor\n                  value={formConfig}\n                  onChange={handleConfigChange}\n                  placeholder={\n                    useToml\n                      ? t(\"mcp.form.tomlPlaceholder\")\n                      : t(\"mcp.form.jsonPlaceholder\")\n                  }\n                  darkMode={isDarkMode}\n                  rows={12}\n                  showValidation={!useToml}\n                  language={useToml ? \"javascript\" : \"json\"}\n                  height=\"100%\"\n                />\n              </div>\n              {configError && (\n                <div className=\"flex items-center gap-2 mt-2 text-red-500 dark:text-red-400 text-sm flex-shrink-0\">\n                  <AlertCircle size={16} />\n                  <span>{configError}</span>\n                </div>\n              )}\n            </div>\n          </div>\n        </div>\n      </FullScreenPanel>\n\n      {/* Wizard Modal */}\n      <McpWizardModal\n        isOpen={isWizardOpen}\n        onClose={() => setIsWizardOpen(false)}\n        onApply={handleWizardApply}\n        initialTitle={formId}\n        initialServer={wizardInitialSpec}\n      />\n    </>\n  );\n};\n\nexport default McpFormModal;\n"
  },
  {
    "path": "src/components/mcp/McpWizardModal.tsx",
    "content": "import React, { useEffect, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { toast } from \"sonner\";\nimport { Save } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogFooter,\n} from \"@/components/ui/dialog\";\nimport { McpServerSpec } from \"@/types\";\n\ninterface McpWizardModalProps {\n  isOpen: boolean;\n  onClose: () => void;\n  onApply: (title: string, json: string) => void;\n  initialTitle?: string;\n  initialServer?: McpServerSpec;\n}\n\n/**\n * 解析环境变量文本为对象\n */\nconst parseEnvText = (text: string): Record<string, string> => {\n  const lines = text\n    .split(\"\\n\")\n    .map((l) => l.trim())\n    .filter((l) => l.length > 0);\n  const env: Record<string, string> = {};\n  for (const l of lines) {\n    const idx = l.indexOf(\"=\");\n    if (idx > 0) {\n      const k = l.slice(0, idx).trim();\n      const v = l.slice(idx + 1).trim();\n      if (k) env[k] = v;\n    }\n  }\n  return env;\n};\n\n/**\n * 解析headers文本为对象（支持 KEY: VALUE 或 KEY=VALUE）\n */\nconst parseHeadersText = (text: string): Record<string, string> => {\n  const lines = text\n    .split(\"\\n\")\n    .map((l) => l.trim())\n    .filter((l) => l.length > 0);\n  const headers: Record<string, string> = {};\n  for (const l of lines) {\n    // 支持 KEY: VALUE 或 KEY=VALUE\n    const colonIdx = l.indexOf(\":\");\n    const equalIdx = l.indexOf(\"=\");\n    let idx = -1;\n    if (colonIdx > 0 && (equalIdx === -1 || colonIdx < equalIdx)) {\n      idx = colonIdx;\n    } else if (equalIdx > 0) {\n      idx = equalIdx;\n    }\n    if (idx > 0) {\n      const k = l.slice(0, idx).trim();\n      const v = l.slice(idx + 1).trim();\n      if (k) headers[k] = v;\n    }\n  }\n  return headers;\n};\n\n/**\n * MCP 配置向导模态框\n * 帮助用户快速生成 MCP JSON 配置\n */\nconst McpWizardModal: React.FC<McpWizardModalProps> = ({\n  isOpen,\n  onClose,\n  onApply,\n  initialTitle,\n  initialServer,\n}) => {\n  const { t } = useTranslation();\n  const [wizardType, setWizardType] = useState<\"stdio\" | \"http\" | \"sse\">(\n    \"stdio\",\n  );\n  const [wizardTitle, setWizardTitle] = useState(\"\");\n  // stdio 字段\n  const [wizardCommand, setWizardCommand] = useState(\"\");\n  const [wizardArgs, setWizardArgs] = useState(\"\");\n  const [wizardEnv, setWizardEnv] = useState(\"\");\n  // http 和 sse 字段\n  const [wizardUrl, setWizardUrl] = useState(\"\");\n  const [wizardHeaders, setWizardHeaders] = useState(\"\");\n\n  // 生成预览 JSON\n  const generatePreview = (): string => {\n    const config: McpServerSpec = {\n      type: wizardType,\n    };\n\n    if (wizardType === \"stdio\") {\n      // stdio 类型必需字段\n      config.command = wizardCommand.trim();\n\n      // 可选字段\n      if (wizardArgs.trim()) {\n        config.args = wizardArgs\n          .split(\"\\n\")\n          .map((s) => s.trim())\n          .filter((s) => s.length > 0);\n      }\n\n      if (wizardEnv.trim()) {\n        const env = parseEnvText(wizardEnv);\n        if (Object.keys(env).length > 0) {\n          config.env = env;\n        }\n      }\n    } else {\n      // http 和 sse 类型必需字段\n      config.url = wizardUrl.trim();\n\n      // 可选字段\n      if (wizardHeaders.trim()) {\n        const headers = parseHeadersText(wizardHeaders);\n        if (Object.keys(headers).length > 0) {\n          config.headers = headers;\n        }\n      }\n    }\n\n    return JSON.stringify(config, null, 2);\n  };\n\n  const handleApply = () => {\n    if (!wizardTitle.trim()) {\n      toast.error(t(\"mcp.error.idRequired\"), { duration: 3000 });\n      return;\n    }\n    if (wizardType === \"stdio\" && !wizardCommand.trim()) {\n      toast.error(t(\"mcp.error.commandRequired\"), { duration: 3000 });\n      return;\n    }\n    if ((wizardType === \"http\" || wizardType === \"sse\") && !wizardUrl.trim()) {\n      toast.error(t(\"mcp.wizard.urlRequired\"), { duration: 3000 });\n      return;\n    }\n\n    const json = generatePreview();\n    onApply(wizardTitle.trim(), json);\n    handleClose();\n  };\n\n  const handleClose = () => {\n    // 重置表单\n    setWizardType(\"stdio\");\n    setWizardTitle(\"\");\n    setWizardCommand(\"\");\n    setWizardArgs(\"\");\n    setWizardEnv(\"\");\n    setWizardUrl(\"\");\n    setWizardHeaders(\"\");\n    onClose();\n  };\n\n  const handleKeyDown = (e: React.KeyboardEvent) => {\n    if (e.key === \"Enter\" && e.metaKey) {\n      e.preventDefault();\n      handleApply();\n    }\n  };\n\n  useEffect(() => {\n    if (!isOpen) return;\n\n    const title = initialTitle ?? \"\";\n    setWizardTitle(title);\n\n    const resolvedType =\n      initialServer?.type ?? (initialServer?.url ? \"http\" : \"stdio\");\n\n    setWizardType(resolvedType);\n\n    if (resolvedType === \"http\" || resolvedType === \"sse\") {\n      setWizardUrl(initialServer?.url ?? \"\");\n      const headersCandidate = initialServer?.headers;\n      const headers =\n        headersCandidate && typeof headersCandidate === \"object\"\n          ? headersCandidate\n          : undefined;\n      setWizardHeaders(\n        headers\n          ? Object.entries(headers)\n              .map(([k, v]) => `${k}: ${v ?? \"\"}`)\n              .join(\"\\n\")\n          : \"\",\n      );\n      setWizardCommand(\"\");\n      setWizardArgs(\"\");\n      setWizardEnv(\"\");\n      return;\n    }\n\n    setWizardCommand(initialServer?.command ?? \"\");\n    const argsValue = initialServer?.args;\n    setWizardArgs(Array.isArray(argsValue) ? argsValue.join(\"\\n\") : \"\");\n    const envCandidate = initialServer?.env;\n    const env =\n      envCandidate && typeof envCandidate === \"object\"\n        ? envCandidate\n        : undefined;\n    setWizardEnv(\n      env\n        ? Object.entries(env)\n            .map(([k, v]) => `${k}=${v ?? \"\"}`)\n            .join(\"\\n\")\n        : \"\",\n    );\n    setWizardUrl(\"\");\n    setWizardHeaders(\"\");\n  }, [isOpen]);\n\n  const preview = generatePreview();\n\n  return (\n    <Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>\n      <DialogContent\n        className=\"max-w-2xl max-h-[90vh] flex flex-col\"\n        zIndex=\"alert\"\n      >\n        <DialogHeader className=\"space-y-3 border-b-0 bg-transparent pb-0\">\n          <DialogTitle className=\"text-lg font-semibold\">\n            {t(\"mcp.wizard.title\")}\n          </DialogTitle>\n        </DialogHeader>\n\n        {/* Content */}\n        <div className=\"flex-1 overflow-y-auto px-6 py-4 space-y-4\">\n          {/* Hint */}\n          <div className=\"rounded-lg border border-border-default bg-gray-100/50 dark:bg-gray-800/50 p-3\">\n            <p className=\"text-sm text-muted-foreground\">\n              {t(\"mcp.wizard.hint\")}\n            </p>\n          </div>\n\n          {/* Form Fields */}\n          <div className=\"space-y-4 min-h-[400px]\">\n            {/* Type */}\n            <div>\n              <label className=\"mb-2 block text-sm font-medium text-foreground\">\n                {t(\"mcp.wizard.type\")} <span className=\"text-red-500\">*</span>\n              </label>\n              <div className=\"flex gap-4\">\n                <label className=\"inline-flex items-center gap-2 cursor-pointer\">\n                  <input\n                    type=\"radio\"\n                    value=\"stdio\"\n                    checked={wizardType === \"stdio\"}\n                    onChange={(e) =>\n                      setWizardType(e.target.value as \"stdio\" | \"http\" | \"sse\")\n                    }\n                    className=\"w-4 h-4 accent-blue-500\"\n                  />\n                  <span className=\"text-sm text-foreground\">\n                    {t(\"mcp.wizard.typeStdio\")}\n                  </span>\n                </label>\n                <label className=\"inline-flex items-center gap-2 cursor-pointer\">\n                  <input\n                    type=\"radio\"\n                    value=\"http\"\n                    checked={wizardType === \"http\"}\n                    onChange={(e) =>\n                      setWizardType(e.target.value as \"stdio\" | \"http\" | \"sse\")\n                    }\n                    className=\"w-4 h-4 accent-blue-500\"\n                  />\n                  <span className=\"text-sm text-foreground\">\n                    {t(\"mcp.wizard.typeHttp\")}\n                  </span>\n                </label>\n                <label className=\"inline-flex items-center gap-2 cursor-pointer\">\n                  <input\n                    type=\"radio\"\n                    value=\"sse\"\n                    checked={wizardType === \"sse\"}\n                    onChange={(e) =>\n                      setWizardType(e.target.value as \"stdio\" | \"http\" | \"sse\")\n                    }\n                    className=\"w-4 h-4 accent-blue-500\"\n                  />\n                  <span className=\"text-sm text-foreground\">\n                    {t(\"mcp.wizard.typeSse\")}\n                  </span>\n                </label>\n              </div>\n            </div>\n\n            {/* Title */}\n            <div>\n              <label className=\"mb-1 block text-sm font-medium text-foreground\">\n                {t(\"mcp.form.title\")} <span className=\"text-red-500\">*</span>\n              </label>\n              <Input\n                type=\"text\"\n                value={wizardTitle}\n                onChange={(e) => setWizardTitle(e.target.value)}\n                onKeyDown={handleKeyDown}\n                placeholder={t(\"mcp.form.titlePlaceholder\")}\n                className=\"font-mono\"\n              />\n            </div>\n\n            {/* Stdio 类型字段 */}\n            {wizardType === \"stdio\" && (\n              <>\n                {/* Command */}\n                <div>\n                  <label className=\"mb-1 block text-sm font-medium text-foreground\">\n                    {t(\"mcp.wizard.command\")}{\" \"}\n                    <span className=\"text-red-500\">*</span>\n                  </label>\n                  <Input\n                    type=\"text\"\n                    value={wizardCommand}\n                    onChange={(e) => setWizardCommand(e.target.value)}\n                    onKeyDown={handleKeyDown}\n                    placeholder={t(\"mcp.wizard.commandPlaceholder\")}\n                    className=\"font-mono\"\n                  />\n                </div>\n\n                {/* Args */}\n                <div>\n                  <label className=\"mb-1 block text-sm font-medium text-foreground\">\n                    {t(\"mcp.wizard.args\")}\n                  </label>\n                  <textarea\n                    value={wizardArgs}\n                    onChange={(e) => setWizardArgs(e.target.value)}\n                    placeholder={t(\"mcp.wizard.argsPlaceholder\")}\n                    rows={3}\n                    className=\"w-full rounded-md border border-border-default bg-background px-3 py-2 text-sm font-mono text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-blue-500/20 resize-y\"\n                  />\n                </div>\n\n                {/* Env */}\n                <div>\n                  <label className=\"mb-1 block text-sm font-medium text-foreground\">\n                    {t(\"mcp.wizard.env\")}\n                  </label>\n                  <textarea\n                    value={wizardEnv}\n                    onChange={(e) => setWizardEnv(e.target.value)}\n                    placeholder={t(\"mcp.wizard.envPlaceholder\")}\n                    rows={3}\n                    className=\"w-full rounded-md border border-border-default bg-background px-3 py-2 text-sm font-mono text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-blue-500/20 resize-y\"\n                  />\n                </div>\n              </>\n            )}\n\n            {/* HTTP 和 SSE 类型字段 */}\n            {(wizardType === \"http\" || wizardType === \"sse\") && (\n              <>\n                {/* URL */}\n                <div>\n                  <label className=\"mb-1 block text-sm font-medium text-foreground\">\n                    {t(\"mcp.wizard.url\")}{\" \"}\n                    <span className=\"text-red-500\">*</span>\n                  </label>\n                  <Input\n                    type=\"text\"\n                    value={wizardUrl}\n                    onChange={(e) => setWizardUrl(e.target.value)}\n                    onKeyDown={handleKeyDown}\n                    placeholder={t(\"mcp.wizard.urlPlaceholder\")}\n                    className=\"font-mono\"\n                  />\n                </div>\n\n                {/* Headers */}\n                <div>\n                  <label className=\"mb-1 block text-sm font-medium text-foreground\">\n                    {t(\"mcp.wizard.headers\")}\n                  </label>\n                  <textarea\n                    value={wizardHeaders}\n                    onChange={(e) => setWizardHeaders(e.target.value)}\n                    placeholder={t(\"mcp.wizard.headersPlaceholder\")}\n                    rows={3}\n                    className=\"w-full rounded-md border border-border-default bg-background px-3 py-2 text-sm font-mono text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-blue-500/20 resize-y\"\n                  />\n                </div>\n              </>\n            )}\n          </div>\n\n          {/* Preview */}\n          {(wizardCommand ||\n            wizardArgs ||\n            wizardEnv ||\n            wizardUrl ||\n            wizardHeaders) && (\n            <div className=\"space-y-2 border-t border-border-default pt-4\">\n              <h3 className=\"text-sm font-medium text-foreground\">\n                {t(\"mcp.wizard.preview\")}\n              </h3>\n              <pre className=\"overflow-x-auto rounded-lg bg-gray-100 dark:bg-gray-800 p-3 text-xs font-mono text-gray-700 dark:text-gray-300\">\n                {preview}\n              </pre>\n            </div>\n          )}\n        </div>\n\n        {/* Footer */}\n        <DialogFooter className=\"flex gap-2 border-t-0 bg-transparent pt-2 sm:justify-end\">\n          <Button variant=\"outline\" onClick={handleClose}>\n            {t(\"common.cancel\")}\n          </Button>\n          <Button variant=\"mcp\" onClick={handleApply}>\n            <Save className=\"h-4 w-4\" />\n            {t(\"mcp.wizard.apply\")}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n};\n\nexport default McpWizardModal;\n"
  },
  {
    "path": "src/components/mcp/UnifiedMcpPanel.tsx",
    "content": "import React, { useMemo, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Server } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { TooltipProvider } from \"@/components/ui/tooltip\";\nimport {\n  useAllMcpServers,\n  useToggleMcpApp,\n  useDeleteMcpServer,\n  useImportMcpFromApps,\n} from \"@/hooks/useMcp\";\nimport type { McpServer } from \"@/types\";\nimport type { AppId } from \"@/lib/api/types\";\nimport McpFormModal from \"./McpFormModal\";\nimport { ConfirmDialog } from \"../ConfirmDialog\";\nimport { Edit3, Trash2, ExternalLink } from \"lucide-react\";\nimport { settingsApi } from \"@/lib/api\";\nimport { mcpPresets } from \"@/config/mcpPresets\";\nimport { toast } from \"sonner\";\nimport { MCP_SKILLS_APP_IDS } from \"@/config/appConfig\";\nimport { AppCountBar } from \"@/components/common/AppCountBar\";\nimport { AppToggleGroup } from \"@/components/common/AppToggleGroup\";\nimport { ListItemRow } from \"@/components/common/ListItemRow\";\n\ninterface UnifiedMcpPanelProps {\n  onOpenChange: (open: boolean) => void;\n}\n\nexport interface UnifiedMcpPanelHandle {\n  openAdd: () => void;\n  openImport: () => void;\n}\n\nconst UnifiedMcpPanel = React.forwardRef<\n  UnifiedMcpPanelHandle,\n  UnifiedMcpPanelProps\n>(({ onOpenChange: _onOpenChange }, ref) => {\n  const { t } = useTranslation();\n  const [isFormOpen, setIsFormOpen] = useState(false);\n  const [editingId, setEditingId] = useState<string | null>(null);\n  const [confirmDialog, setConfirmDialog] = useState<{\n    isOpen: boolean;\n    title: string;\n    message: string;\n    onConfirm: () => void;\n  } | null>(null);\n\n  const { data: serversMap, isLoading } = useAllMcpServers();\n  const toggleAppMutation = useToggleMcpApp();\n  const deleteServerMutation = useDeleteMcpServer();\n  const importMutation = useImportMcpFromApps();\n\n  const serverEntries = useMemo((): Array<[string, McpServer]> => {\n    if (!serversMap) return [];\n    return Object.entries(serversMap);\n  }, [serversMap]);\n\n  const enabledCounts = useMemo(() => {\n    const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0, openclaw: 0 };\n    serverEntries.forEach(([_, server]) => {\n      for (const app of MCP_SKILLS_APP_IDS) {\n        if (server.apps[app]) counts[app]++;\n      }\n    });\n    return counts;\n  }, [serverEntries]);\n\n  const handleToggleApp = async (\n    serverId: string,\n    app: AppId,\n    enabled: boolean,\n  ) => {\n    try {\n      await toggleAppMutation.mutateAsync({ serverId, app, enabled });\n    } catch (error) {\n      toast.error(t(\"common.error\"), { description: String(error) });\n    }\n  };\n\n  const handleEdit = (id: string) => {\n    setEditingId(id);\n    setIsFormOpen(true);\n  };\n\n  const handleAdd = () => {\n    setEditingId(null);\n    setIsFormOpen(true);\n  };\n\n  const handleImport = async () => {\n    try {\n      const count = await importMutation.mutateAsync();\n      if (count === 0) {\n        toast.success(t(\"mcp.unifiedPanel.noImportFound\"), {\n          closeButton: true,\n        });\n      } else {\n        toast.success(t(\"mcp.unifiedPanel.importSuccess\", { count }), {\n          closeButton: true,\n        });\n      }\n    } catch (error) {\n      toast.error(t(\"common.error\"), { description: String(error) });\n    }\n  };\n\n  React.useImperativeHandle(ref, () => ({\n    openAdd: handleAdd,\n    openImport: handleImport,\n  }));\n\n  const handleDelete = (id: string) => {\n    setConfirmDialog({\n      isOpen: true,\n      title: t(\"mcp.unifiedPanel.deleteServer\"),\n      message: t(\"mcp.unifiedPanel.deleteConfirm\", { id }),\n      onConfirm: async () => {\n        try {\n          await deleteServerMutation.mutateAsync(id);\n          setConfirmDialog(null);\n          toast.success(t(\"common.success\"), { closeButton: true });\n        } catch (error) {\n          toast.error(t(\"common.error\"), { description: String(error) });\n        }\n      },\n    });\n  };\n\n  const handleCloseForm = () => {\n    setIsFormOpen(false);\n    setEditingId(null);\n  };\n\n  return (\n    <div className=\"px-6 flex flex-col flex-1 min-h-0 overflow-hidden\">\n      <AppCountBar\n        totalLabel={t(\"mcp.serverCount\", { count: serverEntries.length })}\n        counts={enabledCounts}\n        appIds={MCP_SKILLS_APP_IDS}\n      />\n\n      <div className=\"flex-1 overflow-y-auto overflow-x-hidden pb-24\">\n        {isLoading ? (\n          <div className=\"text-center py-12 text-muted-foreground\">\n            {t(\"mcp.loading\")}\n          </div>\n        ) : serverEntries.length === 0 ? (\n          <div className=\"text-center py-12\">\n            <div className=\"w-16 h-16 mx-auto mb-4 bg-muted rounded-full flex items-center justify-center\">\n              <Server size={24} className=\"text-muted-foreground\" />\n            </div>\n            <h3 className=\"text-lg font-medium text-foreground mb-2\">\n              {t(\"mcp.unifiedPanel.noServers\")}\n            </h3>\n            <p className=\"text-muted-foreground text-sm\">\n              {t(\"mcp.emptyDescription\")}\n            </p>\n          </div>\n        ) : (\n          <TooltipProvider delayDuration={300}>\n            <div className=\"rounded-xl border border-border-default overflow-hidden\">\n              {serverEntries.map(([id, server], index) => (\n                <UnifiedMcpListItem\n                  key={id}\n                  id={id}\n                  server={server}\n                  onToggleApp={handleToggleApp}\n                  onEdit={handleEdit}\n                  onDelete={handleDelete}\n                  isLast={index === serverEntries.length - 1}\n                />\n              ))}\n            </div>\n          </TooltipProvider>\n        )}\n      </div>\n\n      {isFormOpen && (\n        <McpFormModal\n          editingId={editingId || undefined}\n          initialData={\n            editingId && serversMap ? serversMap[editingId] : undefined\n          }\n          existingIds={serversMap ? Object.keys(serversMap) : []}\n          defaultFormat=\"json\"\n          onSave={async () => {\n            setIsFormOpen(false);\n            setEditingId(null);\n          }}\n          onClose={handleCloseForm}\n        />\n      )}\n\n      {confirmDialog && (\n        <ConfirmDialog\n          isOpen={confirmDialog.isOpen}\n          title={confirmDialog.title}\n          message={confirmDialog.message}\n          onConfirm={confirmDialog.onConfirm}\n          onCancel={() => setConfirmDialog(null)}\n        />\n      )}\n    </div>\n  );\n});\n\nUnifiedMcpPanel.displayName = \"UnifiedMcpPanel\";\n\ninterface UnifiedMcpListItemProps {\n  id: string;\n  server: McpServer;\n  onToggleApp: (serverId: string, app: AppId, enabled: boolean) => void;\n  onEdit: (id: string) => void;\n  onDelete: (id: string) => void;\n  isLast?: boolean;\n}\n\nconst UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({\n  id,\n  server,\n  onToggleApp,\n  onEdit,\n  onDelete,\n  isLast,\n}) => {\n  const { t } = useTranslation();\n  const name = server.name || id;\n  const description = server.description || \"\";\n\n  const meta = mcpPresets.find((p) => p.id === id);\n  const docsUrl = server.docs || meta?.docs;\n  const homepageUrl = server.homepage || meta?.homepage;\n  const tags = server.tags || meta?.tags;\n\n  const openDocs = async () => {\n    const url = docsUrl || homepageUrl;\n    if (!url) return;\n    try {\n      await settingsApi.openExternal(url);\n    } catch {\n      // ignore\n    }\n  };\n\n  return (\n    <ListItemRow isLast={isLast}>\n      <div className=\"flex-1 min-w-0\">\n        <div className=\"flex items-center gap-1.5\">\n          <span className=\"font-medium text-sm text-foreground truncate\">\n            {name}\n          </span>\n          {docsUrl && (\n            <button\n              type=\"button\"\n              onClick={openDocs}\n              className=\"text-muted-foreground/60 hover:text-foreground flex-shrink-0\"\n              title={t(\"mcp.presets.docs\")}\n            >\n              <ExternalLink size={12} />\n            </button>\n          )}\n        </div>\n        {description && (\n          <p\n            className=\"text-xs text-muted-foreground truncate\"\n            title={description}\n          >\n            {description}\n          </p>\n        )}\n        {!description && tags && tags.length > 0 && (\n          <p className=\"text-xs text-muted-foreground/60 truncate\">\n            {tags.join(\", \")}\n          </p>\n        )}\n      </div>\n\n      <AppToggleGroup\n        apps={server.apps}\n        onToggle={(app, enabled) => onToggleApp(id, app, enabled)}\n        appIds={MCP_SKILLS_APP_IDS}\n      />\n\n      <div className=\"flex items-center gap-0.5 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity\">\n        <Button\n          type=\"button\"\n          variant=\"ghost\"\n          size=\"icon\"\n          className=\"h-7 w-7\"\n          onClick={() => onEdit(id)}\n          title={t(\"common.edit\")}\n        >\n          <Edit3 size={14} />\n        </Button>\n        <Button\n          type=\"button\"\n          variant=\"ghost\"\n          size=\"icon\"\n          className=\"h-7 w-7 hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10\"\n          onClick={() => onDelete(id)}\n          title={t(\"common.delete\")}\n        >\n          <Trash2 size={14} />\n        </Button>\n      </div>\n    </ListItemRow>\n  );\n};\n\nexport default UnifiedMcpPanel;\n"
  },
  {
    "path": "src/components/mcp/useMcpValidation.ts",
    "content": "import { useTranslation } from \"react-i18next\";\nimport { validateToml, tomlToMcpServer } from \"@/utils/tomlUtils\";\n\nexport function useMcpValidation() {\n  const { t } = useTranslation();\n\n  // JSON basic validation (returns i18n text)\n  const validateJson = (text: string): string => {\n    if (!text.trim()) return \"\";\n    try {\n      const parsed = JSON.parse(text);\n      if (!parsed || typeof parsed !== \"object\" || Array.isArray(parsed)) {\n        return t(\"mcp.error.jsonInvalid\");\n      }\n      return \"\";\n    } catch {\n      return t(\"mcp.error.jsonInvalid\");\n    }\n  };\n\n  // Unified TOML error formatting (localization + details)\n  const formatTomlError = (err: string): string => {\n    if (!err) return \"\";\n    if (err === \"mustBeObject\" || err === \"parseError\") {\n      return t(\"mcp.error.tomlInvalid\");\n    }\n    return `${t(\"mcp.error.tomlInvalid\")}: ${err}`;\n  };\n\n  // Full TOML validation (including required field checks)\n  const validateTomlConfig = (value: string): string => {\n    const err = validateToml(value);\n    if (err) {\n      return formatTomlError(err);\n    }\n\n    // Try to parse and check required fields\n    if (value.trim()) {\n      try {\n        const server = tomlToMcpServer(value);\n        if (server.type === \"stdio\" && !server.command?.trim()) {\n          return t(\"mcp.error.commandRequired\");\n        }\n        if (\n          (server.type === \"http\" || server.type === \"sse\") &&\n          !server.url?.trim()\n        ) {\n          return t(\"mcp.wizard.urlRequired\");\n        }\n      } catch (e: any) {\n        const msg = e?.message || String(e);\n        return formatTomlError(msg);\n      }\n    }\n\n    return \"\";\n  };\n\n  // Full JSON validation (including structure checks)\n  const validateJsonConfig = (value: string): string => {\n    const baseErr = validateJson(value);\n    if (baseErr) {\n      return baseErr;\n    }\n\n    // Further structure validation\n    if (value.trim()) {\n      try {\n        const obj = JSON.parse(value);\n        if (obj && typeof obj === \"object\") {\n          if (Object.prototype.hasOwnProperty.call(obj, \"mcpServers\")) {\n            return t(\"mcp.error.singleServerObjectRequired\");\n          }\n\n          const typ = (obj as any)?.type;\n          if (typ === \"stdio\" && !(obj as any)?.command?.trim()) {\n            return t(\"mcp.error.commandRequired\");\n          }\n          if ((typ === \"http\" || typ === \"sse\") && !(obj as any)?.url?.trim()) {\n            return t(\"mcp.wizard.urlRequired\");\n          }\n        }\n      } catch {\n        // Parse errors already covered by base validation\n      }\n    }\n\n    return \"\";\n  };\n\n  return {\n    validateJson,\n    formatTomlError,\n    validateTomlConfig,\n    validateJsonConfig,\n  };\n}\n"
  },
  {
    "path": "src/components/mode-toggle.tsx",
    "content": "import { Moon, Sun } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Button } from \"@/components/ui/button\";\nimport { useTheme } from \"@/components/theme-provider\";\n\nexport function ModeToggle() {\n  const { theme, setTheme } = useTheme();\n  const { t } = useTranslation();\n\n  const toggleTheme = (event: React.MouseEvent) => {\n    // 如果当前是 dark 或 system（且系统是暗色），切换到 light\n    // 否则切换到 dark\n    if (theme === \"dark\") {\n      setTheme(\"light\", event);\n    } else {\n      setTheme(\"dark\", event);\n    }\n  };\n\n  return (\n    <Button variant=\"outline\" size=\"icon\" onClick={toggleTheme}>\n      <Sun className=\"h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0\" />\n      <Moon className=\"absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100\" />\n      <span className=\"sr-only\">{t(\"common.toggleTheme\")}</span>\n    </Button>\n  );\n}\n"
  },
  {
    "path": "src/components/openclaw/AgentsDefaultsPanel.tsx",
    "content": "import React, { useState, useEffect, useMemo } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Save, Plus, Trash2, TriangleAlert } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport {\n  useOpenClawAgentsDefaults,\n  useSaveOpenClawAgentsDefaults,\n} from \"@/hooks/useOpenClaw\";\nimport { extractErrorMessage } from \"@/utils/errorUtils\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { Alert, AlertDescription, AlertTitle } from \"@/components/ui/alert\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport type { OpenClawAgentsDefaults } from \"@/types\";\nimport { useOpenClawModelOptions } from \"./hooks/useOpenClawModelOptions\";\nimport { getOpenClawTimeoutInputValue } from \"./utils\";\n\nconst UNSET_SENTINEL = \"__unset__\";\n\nconst AgentsDefaultsPanel: React.FC = () => {\n  const { t } = useTranslation();\n  const { data: agentsData, isLoading } = useOpenClawAgentsDefaults();\n  const saveAgentsMutation = useSaveOpenClawAgentsDefaults();\n  const { options: modelOptions, isLoading: modelsLoading } =\n    useOpenClawModelOptions();\n\n  const [defaults, setDefaults] = useState<OpenClawAgentsDefaults | null>(null);\n  const [primaryModel, setPrimaryModel] = useState(\"\");\n  const [fallbacks, setFallbacks] = useState<string[]>([]);\n\n  // Extra known fields from agents.defaults\n  const [workspace, setWorkspace] = useState(\"\");\n  const [timeout, setTimeout_] = useState(\"\");\n  const [contextTokens, setContextTokens] = useState(\"\");\n  const [maxConcurrent, setMaxConcurrent] = useState(\"\");\n\n  useEffect(() => {\n    // agentsData is undefined while loading, null when config section is absent\n    if (agentsData === undefined) return;\n    setDefaults(agentsData);\n\n    if (agentsData) {\n      setPrimaryModel(agentsData.model?.primary ?? \"\");\n      setFallbacks(agentsData.model?.fallbacks ?? []);\n\n      // Extract known extra fields\n      setWorkspace(String(agentsData.workspace ?? \"\"));\n      setTimeout_(getOpenClawTimeoutInputValue(agentsData));\n      setContextTokens(String(agentsData.contextTokens ?? \"\"));\n      setMaxConcurrent(String(agentsData.maxConcurrent ?? \"\"));\n    } else {\n      setPrimaryModel(\"\");\n      setFallbacks([]);\n      setWorkspace(\"\");\n      setTimeout_(\"\");\n      setContextTokens(\"\");\n      setMaxConcurrent(\"\");\n    }\n  }, [agentsData]);\n\n  // Build primary options, including a \"not in list\" entry if current value is missing\n  const primaryOptions = useMemo(() => {\n    const result = [...modelOptions];\n    if (\n      primaryModel &&\n      !modelOptions.some((opt) => opt.value === primaryModel)\n    ) {\n      result.unshift({\n        value: primaryModel,\n        label: t(\"openclaw.agents.notInList\", {\n          value: primaryModel,\n          defaultValue: \"{{value}} (not configured)\",\n        }),\n      });\n    }\n    return result;\n  }, [modelOptions, primaryModel, t]);\n\n  // For each fallback row, compute available options (exclude primary + other fallbacks)\n  const getFallbackOptions = (currentIndex: number) => {\n    const usedValues = new Set<string>();\n    if (primaryModel) usedValues.add(primaryModel);\n    fallbacks.forEach((fb, idx) => {\n      if (idx !== currentIndex && fb) usedValues.add(fb);\n    });\n\n    const filtered = modelOptions.filter((opt) => !usedValues.has(opt.value));\n\n    // If current fallback value is not in modelOptions, add a \"not in list\" entry\n    const currentValue = fallbacks[currentIndex];\n    if (\n      currentValue &&\n      !modelOptions.some((opt) => opt.value === currentValue)\n    ) {\n      filtered.unshift({\n        value: currentValue,\n        label: t(\"openclaw.agents.notInList\", {\n          value: currentValue,\n          defaultValue: \"{{value}} (not configured)\",\n        }),\n      });\n    }\n\n    return filtered;\n  };\n\n  const handleAddFallback = () => {\n    setFallbacks((prev) => [...prev, \"\"]);\n  };\n\n  const handleRemoveFallback = (index: number) => {\n    setFallbacks((prev) => prev.filter((_, i) => i !== index));\n  };\n\n  const handleFallbackChange = (index: number, value: string) => {\n    setFallbacks((prev) => {\n      const next = [...prev];\n      next[index] = value;\n      return next;\n    });\n  };\n\n  const handleSave = async () => {\n    try {\n      // Preserve all unknown fields from original data\n      const updated: OpenClawAgentsDefaults = { ...defaults };\n\n      // Model configuration\n      const fallbackList = fallbacks.filter(Boolean);\n\n      if (primaryModel) {\n        updated.model = {\n          primary: primaryModel,\n          ...(fallbackList.length > 0 ? { fallbacks: fallbackList } : {}),\n        };\n      } else if (fallbackList.length > 0) {\n        updated.model = { primary: \"\", fallbacks: fallbackList };\n      }\n\n      // Optional fields\n      if (workspace.trim()) updated.workspace = workspace.trim();\n      else delete updated.workspace;\n\n      // Numeric fields: validate before saving to avoid NaN\n      const parseNum = (v: string) => {\n        const n = Number(v);\n        return !isNaN(n) && isFinite(n) ? n : undefined;\n      };\n\n      const timeoutNum = timeout.trim() ? parseNum(timeout) : undefined;\n      if (timeoutNum !== undefined) updated.timeoutSeconds = timeoutNum;\n      else delete updated.timeoutSeconds;\n      delete updated.timeout;\n\n      const ctxNum = contextTokens.trim() ? parseNum(contextTokens) : undefined;\n      if (ctxNum !== undefined) updated.contextTokens = ctxNum;\n      else delete updated.contextTokens;\n\n      const concNum = maxConcurrent.trim()\n        ? parseNum(maxConcurrent)\n        : undefined;\n      if (concNum !== undefined) updated.maxConcurrent = concNum;\n      else delete updated.maxConcurrent;\n\n      await saveAgentsMutation.mutateAsync(updated);\n      toast.success(t(\"openclaw.agents.saveSuccess\"));\n    } catch (error) {\n      const detail = extractErrorMessage(error);\n      toast.error(t(\"openclaw.agents.saveFailed\"), {\n        description: detail || undefined,\n      });\n    }\n  };\n\n  if (isLoading) {\n    return (\n      <div className=\"px-6 pt-4 pb-8 flex items-center justify-center min-h-[200px]\">\n        <div className=\"text-sm text-muted-foreground\">\n          {t(\"common.loading\")}\n        </div>\n      </div>\n    );\n  }\n\n  const noModels = modelOptions.length === 0 && !modelsLoading;\n  const hasLegacyTimeout =\n    agentsData !== undefined &&\n    agentsData !== null &&\n    typeof agentsData.timeout === \"number\" &&\n    typeof agentsData.timeoutSeconds !== \"number\";\n\n  return (\n    <div className=\"px-6 pt-4 pb-8\">\n      <p className=\"text-sm text-muted-foreground mb-6\">\n        {t(\"openclaw.agents.description\")}\n      </p>\n\n      {hasLegacyTimeout && (\n        <Alert className=\"mb-4 border-amber-500/30 bg-amber-500/5\">\n          <TriangleAlert className=\"h-4 w-4\" />\n          <AlertTitle>\n            {t(\"openclaw.agents.legacyTimeoutTitle\", {\n              defaultValue: \"Legacy timeout detected\",\n            })}\n          </AlertTitle>\n          <AlertDescription>\n            {t(\"openclaw.agents.legacyTimeoutDescription\", {\n              defaultValue:\n                \"This config still uses agents.defaults.timeout. Saving here will migrate it to timeoutSeconds.\",\n            })}\n          </AlertDescription>\n        </Alert>\n      )}\n\n      {/* Model Configuration Card */}\n      <div className=\"rounded-xl border border-border bg-card p-5 mb-4\">\n        <h3 className=\"text-sm font-medium mb-4\">\n          {t(\"openclaw.agents.modelSection\")}\n        </h3>\n\n        <div className=\"space-y-4\">\n          {/* Primary Model */}\n          <div>\n            <Label className=\"mb-1.5 block\">\n              {t(\"openclaw.agents.primaryModel\")}\n            </Label>\n            {noModels ? (\n              <p className=\"text-xs text-muted-foreground italic\">\n                {t(\"openclaw.agents.noModels\", {\n                  defaultValue:\n                    \"No configured provider models. Please add an OpenClaw provider first.\",\n                })}\n              </p>\n            ) : (\n              <Select\n                value={primaryModel || UNSET_SENTINEL}\n                onValueChange={(v) =>\n                  setPrimaryModel(v === UNSET_SENTINEL ? \"\" : v)\n                }\n              >\n                <SelectTrigger className=\"font-mono text-xs\">\n                  <SelectValue />\n                </SelectTrigger>\n                <SelectContent>\n                  <SelectItem value={UNSET_SENTINEL}>\n                    {t(\"openclaw.agents.notSet\")}\n                  </SelectItem>\n                  {primaryOptions.map((opt) => (\n                    <SelectItem key={opt.value} value={opt.value}>\n                      {opt.label}\n                    </SelectItem>\n                  ))}\n                </SelectContent>\n              </Select>\n            )}\n            <p className=\"text-xs text-muted-foreground mt-1\">\n              {t(\"openclaw.agents.primaryModelHint\")}\n            </p>\n          </div>\n\n          {/* Fallback Models */}\n          <div>\n            <Label className=\"mb-1.5 block\">\n              {t(\"openclaw.agents.fallbackModels\")}\n            </Label>\n\n            {fallbacks.length === 0 && !noModels && (\n              <p className=\"text-xs text-muted-foreground italic mb-2\">\n                {t(\"openclaw.agents.fallbackModelsHint\")}\n              </p>\n            )}\n\n            <div className=\"space-y-2\">\n              {fallbacks.map((fb, index) => {\n                const opts = getFallbackOptions(index);\n                return (\n                  <div key={index} className=\"flex items-center gap-2\">\n                    <Select\n                      value={fb || UNSET_SENTINEL}\n                      onValueChange={(v) =>\n                        handleFallbackChange(\n                          index,\n                          v === UNSET_SENTINEL ? \"\" : v,\n                        )\n                      }\n                    >\n                      <SelectTrigger className=\"font-mono text-xs flex-1\">\n                        <SelectValue />\n                      </SelectTrigger>\n                      <SelectContent>\n                        <SelectItem value={UNSET_SENTINEL}>\n                          {t(\"openclaw.agents.notSet\")}\n                        </SelectItem>\n                        {opts.map((opt) => (\n                          <SelectItem key={opt.value} value={opt.value}>\n                            {opt.label}\n                          </SelectItem>\n                        ))}\n                      </SelectContent>\n                    </Select>\n                    <Button\n                      variant=\"ghost\"\n                      size=\"icon\"\n                      className=\"h-9 w-9 shrink-0 text-muted-foreground hover:text-destructive\"\n                      onClick={() => handleRemoveFallback(index)}\n                    >\n                      <Trash2 className=\"w-4 h-4\" />\n                    </Button>\n                  </div>\n                );\n              })}\n            </div>\n\n            {!noModels && (\n              <Button\n                variant=\"outline\"\n                size=\"sm\"\n                className=\"mt-2\"\n                onClick={handleAddFallback}\n              >\n                <Plus className=\"w-4 h-4 mr-1\" />\n                {t(\"openclaw.agents.addFallback\", {\n                  defaultValue: \"Add fallback model\",\n                })}\n              </Button>\n            )}\n          </div>\n        </div>\n      </div>\n\n      {/* Runtime Parameters Card */}\n      <div className=\"rounded-xl border border-border bg-card p-5 mb-4\">\n        <h3 className=\"text-sm font-medium mb-4\">\n          {t(\"openclaw.agents.runtimeSection\")}\n        </h3>\n\n        <div className=\"grid grid-cols-1 sm:grid-cols-2 gap-4\">\n          <div>\n            <Label className=\"mb-1.5 block\">\n              {t(\"openclaw.agents.workspace\")}\n            </Label>\n            <Input\n              value={workspace}\n              onChange={(e) => setWorkspace(e.target.value)}\n              placeholder=\"~/projects\"\n              className=\"font-mono text-xs\"\n            />\n          </div>\n\n          <div>\n            <Label className=\"mb-1.5 block\">\n              {t(\"openclaw.agents.timeout\")}\n            </Label>\n            <Input\n              type=\"number\"\n              value={timeout}\n              onChange={(e) => setTimeout_(e.target.value)}\n              placeholder=\"300\"\n              className=\"font-mono text-xs\"\n            />\n          </div>\n\n          <div>\n            <Label className=\"mb-1.5 block\">\n              {t(\"openclaw.agents.contextTokens\")}\n            </Label>\n            <Input\n              type=\"number\"\n              value={contextTokens}\n              onChange={(e) => setContextTokens(e.target.value)}\n              placeholder=\"200000\"\n              className=\"font-mono text-xs\"\n            />\n          </div>\n\n          <div>\n            <Label className=\"mb-1.5 block\">\n              {t(\"openclaw.agents.maxConcurrent\")}\n            </Label>\n            <Input\n              type=\"number\"\n              value={maxConcurrent}\n              onChange={(e) => setMaxConcurrent(e.target.value)}\n              placeholder=\"4\"\n              className=\"font-mono text-xs\"\n            />\n          </div>\n        </div>\n      </div>\n\n      {/* Save button */}\n      <div className=\"flex justify-end\">\n        <Button\n          size=\"sm\"\n          onClick={handleSave}\n          disabled={saveAgentsMutation.isPending}\n        >\n          <Save className=\"w-4 h-4 mr-1\" />\n          {saveAgentsMutation.isPending ? t(\"common.saving\") : t(\"common.save\")}\n        </Button>\n      </div>\n    </div>\n  );\n};\n\nexport default AgentsDefaultsPanel;\n"
  },
  {
    "path": "src/components/openclaw/EnvPanel.tsx",
    "content": "import React, { useEffect, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Save } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { useOpenClawEnv, useSaveOpenClawEnv } from \"@/hooks/useOpenClaw\";\nimport { extractErrorMessage } from \"@/utils/errorUtils\";\nimport { Button } from \"@/components/ui/button\";\nimport JsonEditor from \"@/components/JsonEditor\";\nimport { parseOpenClawEnvEditorValue } from \"./utils\";\n\nconst EnvPanel: React.FC = () => {\n  const { t } = useTranslation();\n  const { data: envData, isLoading } = useOpenClawEnv();\n  const saveEnvMutation = useSaveOpenClawEnv();\n  const [editorValue, setEditorValue] = useState(\"{}\");\n  const [isDarkMode, setIsDarkMode] = useState(false);\n\n  useEffect(() => {\n    const nextValue =\n      envData && Object.keys(envData).length > 0\n        ? JSON.stringify(envData, null, 2)\n        : \"{}\";\n    setEditorValue(nextValue);\n  }, [envData]);\n\n  useEffect(() => {\n    setIsDarkMode(document.documentElement.classList.contains(\"dark\"));\n\n    const observer = new MutationObserver(() => {\n      setIsDarkMode(document.documentElement.classList.contains(\"dark\"));\n    });\n\n    observer.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n\n    return () => observer.disconnect();\n  }, []);\n\n  const handleSave = async () => {\n    try {\n      const env = parseOpenClawEnvEditorValue(editorValue);\n      await saveEnvMutation.mutateAsync(env);\n      toast.success(t(\"openclaw.env.saveSuccess\"));\n    } catch (error) {\n      const detail = extractErrorMessage(error);\n      let description = detail || undefined;\n      if (detail === \"OPENCLAW_ENV_EMPTY\") {\n        description = t(\"openclaw.env.empty\", {\n          defaultValue:\n            \"OpenClaw env cannot be empty. Use {} for an empty object.\",\n        });\n      } else if (detail === \"OPENCLAW_ENV_INVALID_JSON\") {\n        description = t(\"openclaw.env.invalidJson\", {\n          defaultValue: \"OpenClaw env must be valid JSON.\",\n        });\n      } else if (detail === \"OPENCLAW_ENV_OBJECT_REQUIRED\") {\n        description = t(\"openclaw.env.objectRequired\", {\n          defaultValue: \"OpenClaw env must be a JSON object.\",\n        });\n      }\n      toast.error(t(\"openclaw.env.saveFailed\"), {\n        description,\n      });\n    }\n  };\n\n  if (isLoading) {\n    return (\n      <div className=\"px-6 pt-4 pb-8 flex items-center justify-center min-h-[200px]\">\n        <div className=\"text-sm text-muted-foreground\">\n          {t(\"common.loading\")}\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"px-6 pt-4 pb-8\">\n      <p className=\"text-sm text-muted-foreground mb-4\">\n        {t(\"openclaw.env.description\")}\n      </p>\n      <p className=\"text-xs text-muted-foreground mb-4\">\n        {t(\"openclaw.env.editorHint\", {\n          defaultValue:\n            \"Edit the full env section as JSON. Nested objects such as env.vars and env.shellEnv are supported.\",\n        })}\n      </p>\n\n      <JsonEditor\n        value={editorValue}\n        onChange={setEditorValue}\n        darkMode={isDarkMode}\n        rows={18}\n        showValidation={true}\n        language=\"json\"\n      />\n\n      <div className=\"flex justify-end mt-4\">\n        <Button\n          size=\"sm\"\n          onClick={handleSave}\n          disabled={saveEnvMutation.isPending}\n        >\n          <Save className=\"w-4 h-4 mr-1\" />\n          {saveEnvMutation.isPending ? t(\"common.saving\") : t(\"common.save\")}\n        </Button>\n      </div>\n    </div>\n  );\n};\n\nexport default EnvPanel;\n"
  },
  {
    "path": "src/components/openclaw/OpenClawHealthBanner.tsx",
    "content": "import React, { useMemo } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { TriangleAlert } from \"lucide-react\";\nimport { Alert, AlertDescription, AlertTitle } from \"@/components/ui/alert\";\nimport type { OpenClawHealthWarning } from \"@/types\";\n\ninterface OpenClawHealthBannerProps {\n  warnings: OpenClawHealthWarning[];\n}\n\nfunction getWarningText(\n  code: string,\n  fallback: string,\n  t: ReturnType<typeof useTranslation>[\"t\"],\n) {\n  switch (code) {\n    case \"invalid_tools_profile\":\n      return t(\"openclaw.health.invalidToolsProfile\", {\n        defaultValue:\n          \"tools.profile contains an unsupported value. OpenClaw currently expects minimal, coding, messaging, or full.\",\n      });\n    case \"legacy_agents_timeout\":\n      return t(\"openclaw.health.legacyTimeout\", {\n        defaultValue:\n          \"agents.defaults.timeout is deprecated. Save the Agents panel to migrate it to timeoutSeconds.\",\n      });\n    case \"stringified_env_vars\":\n      return t(\"openclaw.health.stringifiedEnvVars\", {\n        defaultValue:\n          \"env.vars should be an object, but the current value looks stringified or malformed.\",\n      });\n    case \"stringified_env_shell_env\":\n      return t(\"openclaw.health.stringifiedShellEnv\", {\n        defaultValue:\n          \"env.shellEnv should be an object, but the current value looks stringified or malformed.\",\n      });\n    case \"config_parse_failed\":\n      return t(\"openclaw.health.parseFailed\", {\n        defaultValue:\n          \"openclaw.json could not be parsed as valid JSON5. Fix the file before editing it here.\",\n      });\n    default:\n      return fallback;\n  }\n}\n\nconst OpenClawHealthBanner: React.FC<OpenClawHealthBannerProps> = ({\n  warnings,\n}) => {\n  const { t } = useTranslation();\n\n  const items = useMemo(\n    () =>\n      warnings.map((warning) => ({\n        ...warning,\n        text: getWarningText(warning.code, warning.message, t),\n      })),\n    [t, warnings],\n  );\n\n  if (warnings.length === 0) {\n    return null;\n  }\n\n  return (\n    <div className=\"px-6 pt-4\">\n      <Alert className=\"border-amber-500/30 bg-amber-500/5\">\n        <TriangleAlert className=\"h-4 w-4\" />\n        <AlertTitle>\n          {t(\"openclaw.health.title\", {\n            defaultValue: \"OpenClaw config warnings detected\",\n          })}\n        </AlertTitle>\n        <AlertDescription>\n          <ul className=\"list-disc space-y-1 pl-5\">\n            {items.map((warning) => (\n              <li key={`${warning.code}:${warning.path ?? warning.message}`}>\n                {warning.text}\n                {warning.path ? ` (${warning.path})` : \"\"}\n              </li>\n            ))}\n          </ul>\n        </AlertDescription>\n      </Alert>\n    </div>\n  );\n};\n\nexport default OpenClawHealthBanner;\n"
  },
  {
    "path": "src/components/openclaw/ToolsPanel.tsx",
    "content": "import React, { useEffect, useMemo, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Plus, Trash2, Save, TriangleAlert } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { useOpenClawTools, useSaveOpenClawTools } from \"@/hooks/useOpenClaw\";\nimport { extractErrorMessage } from \"@/utils/errorUtils\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { Alert, AlertDescription, AlertTitle } from \"@/components/ui/alert\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport type { OpenClawToolsConfig, OpenClawToolsProfile } from \"@/types\";\nimport {\n  getOpenClawToolsProfileSelectValue,\n  getOpenClawUnsupportedProfile,\n  OPENCLAW_TOOL_PROFILES,\n  OPENCLAW_UNSET_PROFILE,\n  OPENCLAW_UNSUPPORTED_PROFILE,\n} from \"./utils\";\n\ninterface ListItem {\n  id: string;\n  value: string;\n}\n\nconst ToolsPanel: React.FC = () => {\n  const { t } = useTranslation();\n  const { data: toolsData, isLoading } = useOpenClawTools();\n  const saveToolsMutation = useSaveOpenClawTools();\n  const [config, setConfig] = useState<OpenClawToolsConfig>({});\n  const [allowList, setAllowList] = useState<ListItem[]>([]);\n  const [denyList, setDenyList] = useState<ListItem[]>([]);\n\n  useEffect(() => {\n    if (toolsData) {\n      setConfig(toolsData);\n      setAllowList(\n        (toolsData.allow ?? []).map((value) => ({\n          id: crypto.randomUUID(),\n          value,\n        })),\n      );\n      setDenyList(\n        (toolsData.deny ?? []).map((value) => ({\n          id: crypto.randomUUID(),\n          value,\n        })),\n      );\n    }\n  }, [toolsData]);\n\n  const unsupportedProfile = getOpenClawUnsupportedProfile(config.profile);\n\n  const profileLabels = useMemo<Record<OpenClawToolsProfile, string>>(\n    () => ({\n      minimal: t(\"openclaw.tools.profileMinimal\", {\n        defaultValue: \"Minimal\",\n      }),\n      coding: t(\"openclaw.tools.profileCoding\", {\n        defaultValue: \"Coding\",\n      }),\n      messaging: t(\"openclaw.tools.profileMessaging\", {\n        defaultValue: \"Messaging\",\n      }),\n      full: t(\"openclaw.tools.profileFull\", {\n        defaultValue: \"Full\",\n      }),\n    }),\n    [t],\n  );\n\n  const handleSave = async () => {\n    try {\n      const { profile, allow, deny, ...other } = config;\n      const newConfig: OpenClawToolsConfig = {\n        ...other,\n        profile,\n        allow: allowList.map((item) => item.value).filter((s) => s.trim()),\n        deny: denyList.map((item) => item.value).filter((s) => s.trim()),\n      };\n\n      await saveToolsMutation.mutateAsync(newConfig);\n      toast.success(t(\"openclaw.tools.saveSuccess\"));\n    } catch (error) {\n      const detail = extractErrorMessage(error);\n      toast.error(t(\"openclaw.tools.saveFailed\"), {\n        description: detail || undefined,\n      });\n    }\n  };\n\n  const updateListItem = (\n    setList: React.Dispatch<React.SetStateAction<ListItem[]>>,\n    index: number,\n    value: string,\n  ) => {\n    setList((prev) =>\n      prev.map((item, i) => (i === index ? { ...item, value } : item)),\n    );\n  };\n\n  const removeListItem = (\n    setList: React.Dispatch<React.SetStateAction<ListItem[]>>,\n    index: number,\n  ) => {\n    setList((prev) => prev.filter((_, i) => i !== index));\n  };\n\n  if (isLoading) {\n    return (\n      <div className=\"px-6 pt-4 pb-8 flex items-center justify-center min-h-[200px]\">\n        <div className=\"text-sm text-muted-foreground\">\n          {t(\"common.loading\")}\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"px-6 pt-4 pb-8\">\n      <p className=\"text-sm text-muted-foreground mb-6\">\n        {t(\"openclaw.tools.description\")}\n      </p>\n\n      {unsupportedProfile && (\n        <Alert className=\"mb-6 border-amber-500/30 bg-amber-500/5\">\n          <TriangleAlert className=\"h-4 w-4\" />\n          <AlertTitle>\n            {t(\"openclaw.tools.unsupportedProfileTitle\", {\n              defaultValue: \"Unsupported tools profile\",\n            })}\n          </AlertTitle>\n          <AlertDescription>\n            {t(\"openclaw.tools.unsupportedProfileDescription\", {\n              value: unsupportedProfile,\n              defaultValue:\n                \"The current tools.profile value '{{value}}' is not in the supported OpenClaw list. It will be preserved until you choose a new value.\",\n            })}\n          </AlertDescription>\n        </Alert>\n      )}\n\n      <div className=\"mb-6\">\n        <Label className=\"mb-2 block\">{t(\"openclaw.tools.profile\")}</Label>\n        <Select\n          value={getOpenClawToolsProfileSelectValue(config.profile)}\n          onValueChange={(value) => {\n            if (value === OPENCLAW_UNSUPPORTED_PROFILE) return;\n            if (value === OPENCLAW_UNSET_PROFILE) {\n              setConfig((prev) => ({ ...prev, profile: undefined }));\n              return;\n            }\n            setConfig((prev) => ({ ...prev, profile: value }));\n          }}\n        >\n          <SelectTrigger className=\"w-[220px]\">\n            <SelectValue />\n          </SelectTrigger>\n          <SelectContent>\n            <SelectItem value={OPENCLAW_UNSET_PROFILE}>\n              {t(\"openclaw.tools.profileUnset\", {\n                defaultValue: \"Not set\",\n              })}\n            </SelectItem>\n            {unsupportedProfile && (\n              <SelectItem\n                value={OPENCLAW_UNSUPPORTED_PROFILE}\n                disabled={true}\n              >{`${unsupportedProfile} (${t(\n                \"openclaw.tools.unsupportedProfileLabel\",\n                {\n                  defaultValue: \"unsupported\",\n                },\n              )})`}</SelectItem>\n            )}\n            {OPENCLAW_TOOL_PROFILES.map((profile) => (\n              <SelectItem key={profile} value={profile}>\n                {profileLabels[profile]}\n              </SelectItem>\n            ))}\n          </SelectContent>\n        </Select>\n      </div>\n\n      <div className=\"mb-6\">\n        <Label className=\"mb-2 block\">{t(\"openclaw.tools.allowList\")}</Label>\n        <div className=\"space-y-2\">\n          {allowList.map((item, index) => (\n            <div key={item.id} className=\"flex items-center gap-2\">\n              <Input\n                value={item.value}\n                onChange={(e) =>\n                  updateListItem(setAllowList, index, e.target.value)\n                }\n                placeholder={t(\"openclaw.tools.patternPlaceholder\")}\n                className=\"font-mono text-xs\"\n              />\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                className=\"flex-shrink-0 h-9 w-9 text-muted-foreground hover:text-destructive\"\n                onClick={() => removeListItem(setAllowList, index)}\n              >\n                <Trash2 className=\"w-4 h-4\" />\n              </Button>\n            </div>\n          ))}\n          <Button\n            variant=\"outline\"\n            size=\"sm\"\n            onClick={() =>\n              setAllowList((prev) => [\n                ...prev,\n                { id: crypto.randomUUID(), value: \"\" },\n              ])\n            }\n          >\n            <Plus className=\"w-4 h-4 mr-1\" />\n            {t(\"openclaw.tools.addAllow\")}\n          </Button>\n        </div>\n      </div>\n\n      <div className=\"mb-6\">\n        <Label className=\"mb-2 block\">{t(\"openclaw.tools.denyList\")}</Label>\n        <div className=\"space-y-2\">\n          {denyList.map((item, index) => (\n            <div key={item.id} className=\"flex items-center gap-2\">\n              <Input\n                value={item.value}\n                onChange={(e) =>\n                  updateListItem(setDenyList, index, e.target.value)\n                }\n                placeholder={t(\"openclaw.tools.patternPlaceholder\")}\n                className=\"font-mono text-xs\"\n              />\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                className=\"flex-shrink-0 h-9 w-9 text-muted-foreground hover:text-destructive\"\n                onClick={() => removeListItem(setDenyList, index)}\n              >\n                <Trash2 className=\"w-4 h-4\" />\n              </Button>\n            </div>\n          ))}\n          <Button\n            variant=\"outline\"\n            size=\"sm\"\n            onClick={() =>\n              setDenyList((prev) => [\n                ...prev,\n                { id: crypto.randomUUID(), value: \"\" },\n              ])\n            }\n          >\n            <Plus className=\"w-4 h-4 mr-1\" />\n            {t(\"openclaw.tools.addDeny\")}\n          </Button>\n        </div>\n      </div>\n\n      <div className=\"flex justify-end\">\n        <Button\n          size=\"sm\"\n          onClick={handleSave}\n          disabled={saveToolsMutation.isPending}\n        >\n          <Save className=\"w-4 h-4 mr-1\" />\n          {saveToolsMutation.isPending ? t(\"common.saving\") : t(\"common.save\")}\n        </Button>\n      </div>\n    </div>\n  );\n};\n\nexport default ToolsPanel;\n"
  },
  {
    "path": "src/components/openclaw/hooks/useOpenClawModelOptions.ts",
    "content": "import { useMemo } from \"react\";\nimport { useProvidersQuery } from \"@/lib/query/queries\";\nimport type { OpenClawProviderConfig } from \"@/types\";\n\nexport interface ModelOption {\n  value: string; // \"providerId/modelId\"\n  label: string; // \"Provider Name / Model Name\"\n}\n\nexport function useOpenClawModelOptions(): {\n  options: ModelOption[];\n  isLoading: boolean;\n} {\n  const { data: providersData, isLoading } = useProvidersQuery(\"openclaw\");\n\n  const options = useMemo<ModelOption[]>(() => {\n    const allProviders = providersData?.providers;\n    if (!allProviders) return [];\n\n    const dedupedOptions = new Map<string, string>();\n\n    for (const [providerKey, provider] of Object.entries(allProviders)) {\n      let config: OpenClawProviderConfig;\n      try {\n        config =\n          typeof provider.settingsConfig === \"string\"\n            ? (JSON.parse(provider.settingsConfig) as OpenClawProviderConfig)\n            : (provider.settingsConfig as OpenClawProviderConfig);\n      } catch {\n        continue;\n      }\n\n      const models = config.models;\n      if (!Array.isArray(models)) continue;\n\n      const providerDisplayName =\n        typeof provider.name === \"string\" && provider.name.trim()\n          ? provider.name\n          : providerKey;\n\n      for (const model of models) {\n        if (!model.id) continue;\n        const value = `${providerKey}/${model.id}`;\n        const modelDisplayName =\n          typeof model.name === \"string\" && model.name.trim()\n            ? model.name\n            : model.id;\n        const label = `${providerDisplayName} / ${modelDisplayName}`;\n\n        if (!dedupedOptions.has(value)) {\n          dedupedOptions.set(value, label);\n        }\n      }\n    }\n\n    return Array.from(dedupedOptions.entries())\n      .map(([value, label]) => ({ value, label }))\n      .sort((a, b) => a.label.localeCompare(b.label, \"zh-CN\"));\n  }, [providersData?.providers]);\n\n  return { options, isLoading };\n}\n"
  },
  {
    "path": "src/components/openclaw/utils.ts",
    "content": "import type {\n  OpenClawAgentsDefaults,\n  OpenClawEnvConfig,\n  OpenClawToolsProfile,\n} from \"@/types\";\n\nexport const OPENCLAW_TOOL_PROFILES: OpenClawToolsProfile[] = [\n  \"minimal\",\n  \"coding\",\n  \"messaging\",\n  \"full\",\n];\n\nexport const OPENCLAW_UNSUPPORTED_PROFILE = \"__unsupported_profile__\";\nexport const OPENCLAW_UNSET_PROFILE = \"__unset_profile__\";\n\nexport function parseOpenClawEnvEditorValue(raw: string): OpenClawEnvConfig {\n  if (!raw.trim()) {\n    throw new Error(\"OPENCLAW_ENV_EMPTY\");\n  }\n\n  let parsed: unknown;\n  try {\n    parsed = JSON.parse(raw);\n  } catch {\n    throw new Error(\"OPENCLAW_ENV_INVALID_JSON\");\n  }\n\n  if (!parsed || typeof parsed !== \"object\" || Array.isArray(parsed)) {\n    throw new Error(\"OPENCLAW_ENV_OBJECT_REQUIRED\");\n  }\n  return parsed as OpenClawEnvConfig;\n}\n\nexport function isOpenClawToolsProfile(\n  profile?: string,\n): profile is OpenClawToolsProfile {\n  return (\n    typeof profile === \"string\" &&\n    OPENCLAW_TOOL_PROFILES.includes(profile as OpenClawToolsProfile)\n  );\n}\n\nexport function getOpenClawToolsProfileSelectValue(profile?: string): string {\n  if (!profile) {\n    return OPENCLAW_UNSET_PROFILE;\n  }\n  return isOpenClawToolsProfile(profile)\n    ? profile\n    : OPENCLAW_UNSUPPORTED_PROFILE;\n}\n\nexport function getOpenClawUnsupportedProfile(profile?: string): string | null {\n  if (!profile || isOpenClawToolsProfile(profile)) {\n    return null;\n  }\n  return profile;\n}\n\nexport function getOpenClawTimeoutInputValue(\n  defaults?: OpenClawAgentsDefaults | null,\n): string {\n  const timeoutSeconds =\n    typeof defaults?.timeoutSeconds === \"number\"\n      ? defaults.timeoutSeconds\n      : undefined;\n  const legacyTimeout =\n    typeof defaults?.timeout === \"number\" ? defaults.timeout : undefined;\n  const value = timeoutSeconds ?? legacyTimeout;\n  return value === undefined ? \"\" : String(value);\n}\n"
  },
  {
    "path": "src/components/prompts/PromptFormModal.tsx",
    "content": "import React, { useState, useEffect } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport MarkdownEditor from \"@/components/MarkdownEditor\";\nimport type { Prompt, AppId } from \"@/lib/api\";\n\ninterface PromptFormModalProps {\n  appId: AppId;\n  editingId?: string;\n  initialData?: Prompt;\n  onSave: (id: string, prompt: Prompt) => Promise<void>;\n  onClose: () => void;\n}\n\nconst PromptFormModal: React.FC<PromptFormModalProps> = ({\n  appId,\n  editingId,\n  initialData,\n  onSave,\n  onClose,\n}) => {\n  const { t } = useTranslation();\n  const appName = t(`apps.${appId}`);\n  const filenameMap: Record<Exclude<AppId, \"openclaw\">, string> = {\n    claude: \"CLAUDE.md\",\n    codex: \"AGENTS.md\",\n    gemini: \"GEMINI.md\",\n    opencode: \"AGENTS.md\",\n  };\n  const filename = filenameMap[appId as Exclude<AppId, \"openclaw\">];\n  const [name, setName] = useState(\"\");\n  const [description, setDescription] = useState(\"\");\n  const [content, setContent] = useState(\"\");\n  const [saving, setSaving] = useState(false);\n  const [isDarkMode, setIsDarkMode] = useState(false);\n\n  useEffect(() => {\n    // 检测初始暗色模式状态\n    setIsDarkMode(document.documentElement.classList.contains(\"dark\"));\n\n    // 监听 html 元素的 class 变化以实时响应主题切换\n    const observer = new MutationObserver(() => {\n      setIsDarkMode(document.documentElement.classList.contains(\"dark\"));\n    });\n\n    observer.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n\n    return () => observer.disconnect();\n  }, []);\n\n  useEffect(() => {\n    if (initialData) {\n      setName(initialData.name);\n      setDescription(initialData.description || \"\");\n      setContent(initialData.content);\n    }\n  }, [initialData]);\n\n  const handleSave = async () => {\n    if (!name.trim()) {\n      return;\n    }\n\n    setSaving(true);\n    try {\n      const id = editingId || `prompt-${Date.now()}`;\n      const timestamp = Math.floor(Date.now() / 1000);\n      const prompt: Prompt = {\n        id,\n        name: name.trim(),\n        description: description.trim() || undefined,\n        content: content.trim(),\n        enabled: initialData?.enabled || false,\n        createdAt: initialData?.createdAt || timestamp,\n        updatedAt: timestamp,\n      };\n      await onSave(id, prompt);\n      onClose();\n    } catch (error) {\n      // Error handled by hook\n    } finally {\n      setSaving(false);\n    }\n  };\n\n  return (\n    <Dialog open onOpenChange={onClose}>\n      <DialogContent className=\"max-w-2xl max-h-[85vh] flex flex-col\">\n        <DialogHeader>\n          <DialogTitle>\n            {editingId\n              ? t(\"prompts.editTitle\", { appName })\n              : t(\"prompts.addTitle\", { appName })}\n          </DialogTitle>\n        </DialogHeader>\n\n        <div className=\"flex-1 overflow-y-auto space-y-4 px-6 py-4\">\n          <div>\n            <Label htmlFor=\"name\">{t(\"prompts.name\")}</Label>\n            <Input\n              id=\"name\"\n              value={name}\n              onChange={(e) => setName(e.target.value)}\n              placeholder={t(\"prompts.namePlaceholder\")}\n            />\n          </div>\n\n          <div>\n            <Label htmlFor=\"description\">{t(\"prompts.description\")}</Label>\n            <Input\n              id=\"description\"\n              value={description}\n              onChange={(e) => setDescription(e.target.value)}\n              placeholder={t(\"prompts.descriptionPlaceholder\")}\n            />\n          </div>\n\n          <div>\n            <Label htmlFor=\"content\" className=\"mb-2 block\">\n              {t(\"prompts.content\")}\n            </Label>\n            <MarkdownEditor\n              value={content}\n              onChange={setContent}\n              placeholder={t(\"prompts.contentPlaceholder\", { filename })}\n              darkMode={isDarkMode}\n              minHeight=\"300px\"\n            />\n          </div>\n        </div>\n\n        <DialogFooter>\n          <Button type=\"button\" variant=\"outline\" onClick={onClose}>\n            {t(\"common.cancel\")}\n          </Button>\n          <Button\n            type=\"button\"\n            onClick={handleSave}\n            disabled={!name.trim() || saving}\n          >\n            {saving ? t(\"common.saving\") : t(\"common.save\")}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n};\n\nexport default PromptFormModal;\n"
  },
  {
    "path": "src/components/prompts/PromptFormPanel.tsx",
    "content": "import React, { useState, useEffect } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport MarkdownEditor from \"@/components/MarkdownEditor\";\nimport { FullScreenPanel } from \"@/components/common/FullScreenPanel\";\nimport type { Prompt, AppId } from \"@/lib/api\";\n\ninterface PromptFormPanelProps {\n  appId: AppId;\n  editingId?: string;\n  initialData?: Prompt;\n  onSave: (id: string, prompt: Prompt) => Promise<void>;\n  onClose: () => void;\n}\n\nconst PromptFormPanel: React.FC<PromptFormPanelProps> = ({\n  appId,\n  editingId,\n  initialData,\n  onSave,\n  onClose,\n}) => {\n  const { t } = useTranslation();\n  const appName = t(`apps.${appId}`);\n  const filenameMap: Record<AppId, string> = {\n    claude: \"CLAUDE.md\",\n    codex: \"AGENTS.md\",\n    gemini: \"GEMINI.md\",\n    opencode: \"AGENTS.md\",\n    openclaw: \"AGENTS.md\",\n  };\n  const filename = filenameMap[appId];\n  const [name, setName] = useState(\"\");\n  const [description, setDescription] = useState(\"\");\n  const [content, setContent] = useState(\"\");\n  const [saving, setSaving] = useState(false);\n  const [isDarkMode, setIsDarkMode] = useState(false);\n\n  useEffect(() => {\n    setIsDarkMode(document.documentElement.classList.contains(\"dark\"));\n\n    const observer = new MutationObserver(() => {\n      setIsDarkMode(document.documentElement.classList.contains(\"dark\"));\n    });\n\n    observer.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n\n    return () => observer.disconnect();\n  }, []);\n\n  useEffect(() => {\n    if (initialData) {\n      setName(initialData.name);\n      setDescription(initialData.description || \"\");\n      setContent(initialData.content);\n    }\n  }, [initialData]);\n\n  const handleSave = async () => {\n    if (!name.trim()) {\n      return;\n    }\n\n    setSaving(true);\n    try {\n      const id = editingId || `prompt-${Date.now()}`;\n      const timestamp = Math.floor(Date.now() / 1000);\n      const prompt: Prompt = {\n        id,\n        name: name.trim(),\n        description: description.trim() || undefined,\n        content: content.trim(),\n        enabled: initialData?.enabled || false,\n        createdAt: initialData?.createdAt || timestamp,\n        updatedAt: timestamp,\n      };\n      await onSave(id, prompt);\n      onClose();\n    } catch (error) {\n      // Error handled by hook\n    } finally {\n      setSaving(false);\n    }\n  };\n\n  const title = editingId\n    ? t(\"prompts.editTitle\", { appName })\n    : t(\"prompts.addTitle\", { appName });\n\n  return (\n    <FullScreenPanel\n      isOpen={true}\n      title={title}\n      onClose={onClose}\n      footer={\n        <Button\n          type=\"button\"\n          onClick={handleSave}\n          disabled={!name.trim() || saving}\n          className=\"bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed\"\n        >\n          {saving ? t(\"common.saving\") : t(\"common.save\")}\n        </Button>\n      }\n    >\n      <div className=\"glass rounded-xl p-6 border border-white/10 space-y-6\">\n        <div>\n          <Label htmlFor=\"name\" className=\"text-foreground\">\n            {t(\"prompts.name\")}\n          </Label>\n          <Input\n            id=\"name\"\n            value={name}\n            onChange={(e) => setName(e.target.value)}\n            placeholder={t(\"prompts.namePlaceholder\")}\n            className=\"mt-2\"\n          />\n        </div>\n\n        <div>\n          <Label htmlFor=\"description\" className=\"text-foreground\">\n            {t(\"prompts.description\")}\n          </Label>\n          <Input\n            id=\"description\"\n            value={description}\n            onChange={(e) => setDescription(e.target.value)}\n            placeholder={t(\"prompts.descriptionPlaceholder\")}\n            className=\"mt-2\"\n          />\n        </div>\n\n        <div>\n          <Label htmlFor=\"content\" className=\"block mb-2 text-foreground\">\n            {t(\"prompts.content\")}\n          </Label>\n          <MarkdownEditor\n            value={content}\n            onChange={setContent}\n            placeholder={t(\"prompts.contentPlaceholder\", { filename })}\n            darkMode={isDarkMode}\n            minHeight=\"167px\"\n          />\n        </div>\n      </div>\n    </FullScreenPanel>\n  );\n};\n\nexport default PromptFormPanel;\n"
  },
  {
    "path": "src/components/prompts/PromptListItem.tsx",
    "content": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Edit3, Trash2 } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport type { Prompt } from \"@/lib/api\";\nimport PromptToggle from \"./PromptToggle\";\n\ninterface PromptListItemProps {\n  id: string;\n  prompt: Prompt;\n  onToggle: (id: string, enabled: boolean) => void;\n  onEdit: (id: string) => void;\n  onDelete: (id: string) => void;\n}\n\nconst PromptListItem: React.FC<PromptListItemProps> = ({\n  id,\n  prompt,\n  onToggle,\n  onEdit,\n  onDelete,\n}) => {\n  const { t } = useTranslation();\n\n  const enabled = prompt.enabled === true;\n\n  return (\n    <div className=\"group relative h-16 rounded-xl border border-border-default bg-muted/50 p-4 transition-all duration-300 hover:bg-muted hover:border-border-default/80 hover:shadow-sm\">\n      <div className=\"flex items-center gap-4 h-full\">\n        {/* Toggle 开关 */}\n        <div className=\"flex-shrink-0\">\n          <PromptToggle\n            enabled={enabled}\n            onChange={(newEnabled) => onToggle(id, newEnabled)}\n          />\n        </div>\n\n        <div className=\"flex-1 min-w-0\">\n          <h3 className=\"font-medium text-foreground mb-1\">{prompt.name}</h3>\n          {prompt.description && (\n            <p className=\"text-sm text-muted-foreground truncate\">\n              {prompt.description}\n            </p>\n          )}\n        </div>\n\n        <div className=\"flex items-center gap-2 flex-shrink-0\">\n          <Button\n            type=\"button\"\n            variant=\"ghost\"\n            size=\"icon\"\n            onClick={() => onEdit(id)}\n            title={t(\"common.edit\")}\n          >\n            <Edit3 size={16} />\n          </Button>\n          <Button\n            type=\"button\"\n            variant=\"ghost\"\n            size=\"icon\"\n            onClick={() => onDelete(id)}\n            className=\"hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10\"\n            title={t(\"common.delete\")}\n          >\n            <Trash2 size={16} />\n          </Button>\n        </div>\n      </div>\n    </div>\n  );\n};\n\nexport default PromptListItem;\n"
  },
  {
    "path": "src/components/prompts/PromptPanel.tsx",
    "content": "import React, { useEffect, useMemo, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { FileText } from \"lucide-react\";\nimport { type AppId } from \"@/lib/api\";\nimport { usePromptActions } from \"@/hooks/usePromptActions\";\nimport PromptListItem from \"./PromptListItem\";\nimport PromptFormPanel from \"./PromptFormPanel\";\nimport { ConfirmDialog } from \"../ConfirmDialog\";\n\ninterface PromptPanelProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  appId: AppId;\n}\n\nexport interface PromptPanelHandle {\n  openAdd: () => void;\n}\n\nconst PromptPanel = React.forwardRef<PromptPanelHandle, PromptPanelProps>(\n  ({ open, appId }, ref) => {\n    const { t } = useTranslation();\n    const [isFormOpen, setIsFormOpen] = useState(false);\n    const [editingId, setEditingId] = useState<string | null>(null);\n    const [confirmDialog, setConfirmDialog] = useState<{\n      isOpen: boolean;\n      titleKey: string;\n      messageKey: string;\n      messageParams?: Record<string, unknown>;\n      onConfirm: () => void;\n    } | null>(null);\n\n    const {\n      prompts,\n      loading,\n      reload,\n      savePrompt,\n      deletePrompt,\n      toggleEnabled,\n    } = usePromptActions(appId);\n\n    useEffect(() => {\n      if (open) reload();\n    }, [open, reload]);\n\n    // Listen for prompt import events from deep link\n    useEffect(() => {\n      const handlePromptImported = (event: Event) => {\n        const customEvent = event as CustomEvent;\n        // Reload if the import is for this app\n        if (customEvent.detail?.app === appId) {\n          reload();\n        }\n      };\n\n      window.addEventListener(\"prompt-imported\", handlePromptImported);\n      return () => {\n        window.removeEventListener(\"prompt-imported\", handlePromptImported);\n      };\n    }, [appId, reload]);\n\n    const handleAdd = () => {\n      setEditingId(null);\n      setIsFormOpen(true);\n    };\n\n    React.useImperativeHandle(ref, () => ({\n      openAdd: handleAdd,\n    }));\n\n    const handleEdit = (id: string) => {\n      setEditingId(id);\n      setIsFormOpen(true);\n    };\n\n    const handleDelete = (id: string) => {\n      const prompt = prompts[id];\n      setConfirmDialog({\n        isOpen: true,\n        titleKey: \"prompts.confirm.deleteTitle\",\n        messageKey: \"prompts.confirm.deleteMessage\",\n        messageParams: { name: prompt?.name },\n        onConfirm: async () => {\n          try {\n            await deletePrompt(id);\n            setConfirmDialog(null);\n          } catch (e) {\n            // Error handled by hook\n          }\n        },\n      });\n    };\n\n    const promptEntries = useMemo(() => Object.entries(prompts), [prompts]);\n\n    const enabledPrompt = promptEntries.find(([_, p]) => p.enabled);\n\n    return (\n      <div className=\"flex flex-col flex-1 min-h-0 px-6\">\n        <div className=\"flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6\">\n          <div className=\"text-sm text-muted-foreground\">\n            {t(\"prompts.count\", { count: promptEntries.length })} ·{\" \"}\n            {enabledPrompt\n              ? t(\"prompts.enabledName\", { name: enabledPrompt[1].name })\n              : t(\"prompts.noneEnabled\")}\n          </div>\n        </div>\n\n        <div className=\"flex-1 overflow-y-auto pb-16\">\n          {loading ? (\n            <div className=\"text-center py-12 text-muted-foreground\">\n              {t(\"prompts.loading\")}\n            </div>\n          ) : promptEntries.length === 0 ? (\n            <div className=\"text-center py-12\">\n              <div className=\"w-16 h-16 mx-auto mb-4 bg-muted rounded-full flex items-center justify-center\">\n                <FileText size={24} className=\"text-muted-foreground\" />\n              </div>\n              <h3 className=\"text-lg font-medium text-foreground mb-2\">\n                {t(\"prompts.empty\")}\n              </h3>\n              <p className=\"text-muted-foreground text-sm\">\n                {t(\"prompts.emptyDescription\")}\n              </p>\n            </div>\n          ) : (\n            <div className=\"space-y-3\">\n              {promptEntries.map(([id, prompt]) => (\n                <PromptListItem\n                  key={id}\n                  id={id}\n                  prompt={prompt}\n                  onToggle={toggleEnabled}\n                  onEdit={handleEdit}\n                  onDelete={handleDelete}\n                />\n              ))}\n            </div>\n          )}\n        </div>\n\n        {isFormOpen && (\n          <PromptFormPanel\n            appId={appId}\n            editingId={editingId || undefined}\n            initialData={editingId ? prompts[editingId] : undefined}\n            onSave={savePrompt}\n            onClose={() => setIsFormOpen(false)}\n          />\n        )}\n\n        {confirmDialog && (\n          <ConfirmDialog\n            isOpen={confirmDialog.isOpen}\n            title={t(confirmDialog.titleKey)}\n            message={t(confirmDialog.messageKey, confirmDialog.messageParams)}\n            onConfirm={confirmDialog.onConfirm}\n            onCancel={() => setConfirmDialog(null)}\n          />\n        )}\n      </div>\n    );\n  },\n);\n\nPromptPanel.displayName = \"PromptPanel\";\n\nexport default PromptPanel;\n"
  },
  {
    "path": "src/components/prompts/PromptToggle.tsx",
    "content": "import React from \"react\";\n\ninterface PromptToggleProps {\n  enabled: boolean;\n  onChange: (enabled: boolean) => void;\n  disabled?: boolean;\n}\n\n/**\n * Toggle 开关组件（提示词专用）\n * 启用时为绿色，禁用时为灰色\n */\nconst PromptToggle: React.FC<PromptToggleProps> = ({\n  enabled,\n  onChange,\n  disabled = false,\n}) => {\n  return (\n    <button\n      type=\"button\"\n      role=\"switch\"\n      aria-checked={enabled}\n      disabled={disabled}\n      onClick={() => onChange(!enabled)}\n      className={`\n        relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500/20\n        ${enabled ? \"bg-emerald-500 dark:bg-emerald-600\" : \"bg-gray-300 dark:bg-gray-600\"}\n        ${disabled ? \"opacity-50 cursor-not-allowed\" : \"cursor-pointer\"}\n      `}\n    >\n      <span\n        className={`\n          inline-block h-4 w-4 transform rounded-full bg-white transition-transform\n          ${enabled ? \"translate-x-6\" : \"translate-x-1\"}\n        `}\n      />\n    </button>\n  );\n};\n\nexport default PromptToggle;\n"
  },
  {
    "path": "src/components/providers/AddProviderDialog.tsx",
    "content": "import { useCallback, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Plus } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { Button } from \"@/components/ui/button\";\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from \"@/components/ui/tabs\";\nimport { FullScreenPanel } from \"@/components/common/FullScreenPanel\";\nimport type { Provider, CustomEndpoint, UniversalProvider } from \"@/types\";\nimport type { AppId } from \"@/lib/api\";\nimport { universalProvidersApi } from \"@/lib/api\";\nimport {\n  ProviderForm,\n  type ProviderFormValues,\n} from \"@/components/providers/forms/ProviderForm\";\nimport { UniversalProviderFormModal } from \"@/components/universal/UniversalProviderFormModal\";\nimport { UniversalProviderPanel } from \"@/components/universal\";\nimport { AuthCenterPanel } from \"@/components/settings/AuthCenterPanel\";\nimport { providerPresets } from \"@/config/claudeProviderPresets\";\nimport { codexProviderPresets } from \"@/config/codexProviderPresets\";\nimport { geminiProviderPresets } from \"@/config/geminiProviderPresets\";\nimport { extractCodexBaseUrl } from \"@/utils/providerConfigUtils\";\nimport type { OpenClawSuggestedDefaults } from \"@/config/openclawProviderPresets\";\nimport type { UniversalProviderPreset } from \"@/config/universalProviderPresets\";\n\ninterface AddProviderDialogProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  appId: AppId;\n  onSubmit: (\n    provider: Omit<Provider, \"id\"> & {\n      providerKey?: string;\n      suggestedDefaults?: OpenClawSuggestedDefaults;\n    },\n  ) => Promise<void> | void;\n}\n\nexport function AddProviderDialog({\n  open,\n  onOpenChange,\n  appId,\n  onSubmit,\n}: AddProviderDialogProps) {\n  const { t } = useTranslation();\n  // OpenCode and OpenClaw don't support universal providers\n  const showUniversalTab = appId !== \"opencode\" && appId !== \"openclaw\";\n  const [activeTab, setActiveTab] = useState<\n    \"app-specific\" | \"universal\" | \"oauth\"\n  >(\"app-specific\");\n  const [universalFormOpen, setUniversalFormOpen] = useState(false);\n  const [selectedUniversalPreset, setSelectedUniversalPreset] =\n    useState<UniversalProviderPreset | null>(null);\n  const [isFormSubmitting, setIsFormSubmitting] = useState(false);\n\n  const handleUniversalProviderSave = useCallback(\n    async (provider: UniversalProvider) => {\n      try {\n        await universalProvidersApi.upsert(provider);\n        toast.success(\n          t(\"universalProvider.addSuccess\", {\n            defaultValue: \"统一供应商添加成功\",\n          }),\n        );\n        setUniversalFormOpen(false);\n        setSelectedUniversalPreset(null);\n        onOpenChange(false);\n      } catch (error) {\n        console.error(\n          \"[AddProviderDialog] Failed to save universal provider\",\n          error,\n        );\n        toast.error(\n          t(\"universalProvider.addFailed\", {\n            defaultValue: \"统一供应商添加失败\",\n          }),\n        );\n      }\n    },\n    [t, onOpenChange],\n  );\n\n  const handleUniversalFormClose = useCallback(() => {\n    setUniversalFormOpen(false);\n    setSelectedUniversalPreset(null);\n  }, []);\n\n  const handleSubmit = useCallback(\n    async (values: ProviderFormValues) => {\n      const parsedConfig = JSON.parse(values.settingsConfig) as Record<\n        string,\n        unknown\n      >;\n\n      // 构造基础提交数据\n      const providerData: Omit<Provider, \"id\"> & {\n        providerKey?: string;\n        suggestedDefaults?: OpenClawSuggestedDefaults;\n      } = {\n        name: values.name.trim(),\n        notes: values.notes?.trim() || undefined,\n        websiteUrl: values.websiteUrl?.trim() || undefined,\n        settingsConfig: parsedConfig,\n        icon: values.icon?.trim() || undefined,\n        iconColor: values.iconColor?.trim() || undefined,\n        ...(values.presetCategory ? { category: values.presetCategory } : {}),\n        ...(values.meta ? { meta: values.meta } : {}),\n      };\n\n      // OpenCode/OpenClaw: pass providerKey for ID generation\n      if (\n        (appId === \"opencode\" || appId === \"openclaw\") &&\n        values.providerKey\n      ) {\n        providerData.providerKey = values.providerKey;\n      }\n\n      const hasCustomEndpoints =\n        providerData.meta?.custom_endpoints &&\n        Object.keys(providerData.meta.custom_endpoints).length > 0;\n\n      if (!hasCustomEndpoints && values.presetCategory !== \"omo\") {\n        const urlSet = new Set<string>();\n\n        const addUrl = (rawUrl?: string) => {\n          const url = (rawUrl || \"\").trim().replace(/\\/+$/, \"\");\n          if (url && url.startsWith(\"http\")) {\n            urlSet.add(url);\n          }\n        };\n\n        if (values.presetId) {\n          if (appId === \"claude\") {\n            const presets = providerPresets;\n            const presetIndex = parseInt(\n              values.presetId.replace(\"claude-\", \"\"),\n            );\n            if (\n              !isNaN(presetIndex) &&\n              presetIndex >= 0 &&\n              presetIndex < presets.length\n            ) {\n              const preset = presets[presetIndex];\n              if (preset?.endpointCandidates) {\n                preset.endpointCandidates.forEach(addUrl);\n              }\n            }\n          } else if (appId === \"codex\") {\n            const presets = codexProviderPresets;\n            const presetIndex = parseInt(values.presetId.replace(\"codex-\", \"\"));\n            if (\n              !isNaN(presetIndex) &&\n              presetIndex >= 0 &&\n              presetIndex < presets.length\n            ) {\n              const preset = presets[presetIndex];\n              if (Array.isArray(preset.endpointCandidates)) {\n                preset.endpointCandidates.forEach(addUrl);\n              }\n            }\n          } else if (appId === \"gemini\") {\n            const presets = geminiProviderPresets;\n            const presetIndex = parseInt(\n              values.presetId.replace(\"gemini-\", \"\"),\n            );\n            if (\n              !isNaN(presetIndex) &&\n              presetIndex >= 0 &&\n              presetIndex < presets.length\n            ) {\n              const preset = presets[presetIndex];\n              if (Array.isArray(preset.endpointCandidates)) {\n                preset.endpointCandidates.forEach(addUrl);\n              }\n            }\n          }\n        }\n\n        if (appId === \"claude\") {\n          const env = parsedConfig.env as Record<string, any> | undefined;\n          if (env?.ANTHROPIC_BASE_URL) {\n            addUrl(env.ANTHROPIC_BASE_URL);\n          }\n        } else if (appId === \"codex\") {\n          const config = parsedConfig.config as string | undefined;\n          if (config) {\n            const extractedBaseUrl = extractCodexBaseUrl(config);\n            if (extractedBaseUrl) {\n              addUrl(extractedBaseUrl);\n            }\n          }\n        } else if (appId === \"gemini\") {\n          const env = parsedConfig.env as Record<string, any> | undefined;\n          if (env?.GOOGLE_GEMINI_BASE_URL) {\n            addUrl(env.GOOGLE_GEMINI_BASE_URL);\n          }\n        } else if (appId === \"opencode\") {\n          const options = parsedConfig.options as\n            | Record<string, any>\n            | undefined;\n          if (options?.baseURL) {\n            addUrl(options.baseURL);\n          }\n        } else if (appId === \"openclaw\") {\n          // OpenClaw uses baseUrl directly\n          if (parsedConfig.baseUrl) {\n            addUrl(parsedConfig.baseUrl as string);\n          }\n        }\n\n        const urls = Array.from(urlSet);\n        if (urls.length > 0) {\n          const now = Date.now();\n          const customEndpoints: Record<string, CustomEndpoint> = {};\n          urls.forEach((url) => {\n            customEndpoints[url] = {\n              url,\n              addedAt: now,\n              lastUsed: undefined,\n            };\n          });\n\n          providerData.meta = {\n            ...(providerData.meta ?? {}),\n            custom_endpoints: customEndpoints,\n          };\n        }\n      }\n\n      // OpenClaw: pass suggestedDefaults for model registration\n      if (appId === \"openclaw\" && values.suggestedDefaults) {\n        providerData.suggestedDefaults = values.suggestedDefaults;\n      }\n\n      await onSubmit(providerData);\n      onOpenChange(false);\n    },\n    [appId, onSubmit, onOpenChange],\n  );\n\n  const footer =\n    !showUniversalTab || activeTab === \"app-specific\" ? (\n      <>\n        <Button\n          variant=\"outline\"\n          onClick={() => onOpenChange(false)}\n          className=\"border-border/20 hover:bg-accent hover:text-accent-foreground\"\n        >\n          {t(\"common.cancel\")}\n        </Button>\n        <Button\n          type=\"submit\"\n          form=\"provider-form\"\n          disabled={isFormSubmitting}\n          className=\"bg-primary text-primary-foreground hover:bg-primary/90\"\n        >\n          <Plus className=\"h-4 w-4 mr-2\" />\n          {t(\"common.add\")}\n        </Button>\n      </>\n    ) : activeTab === \"oauth\" ? (\n      <Button\n        variant=\"outline\"\n        onClick={() => onOpenChange(false)}\n        className=\"border-border/20 hover:bg-accent hover:text-accent-foreground\"\n      >\n        {t(\"common.close\", { defaultValue: \"关闭\" })}\n      </Button>\n    ) : (\n      <>\n        <Button\n          variant=\"outline\"\n          onClick={() => onOpenChange(false)}\n          className=\"border-border/20 hover:bg-accent hover:text-accent-foreground\"\n        >\n          {t(\"common.cancel\")}\n        </Button>\n        <Button\n          onClick={() => setUniversalFormOpen(true)}\n          className=\"bg-primary text-primary-foreground hover:bg-primary/90\"\n        >\n          <Plus className=\"h-4 w-4 mr-2\" />\n          {t(\"universalProvider.add\")}\n        </Button>\n      </>\n    );\n\n  return (\n    <FullScreenPanel\n      isOpen={open}\n      title={t(\"provider.addNewProvider\")}\n      onClose={() => onOpenChange(false)}\n      footer={footer}\n    >\n      {showUniversalTab ? (\n        <Tabs\n          value={activeTab}\n          onValueChange={(v) =>\n            setActiveTab(v as \"app-specific\" | \"universal\" | \"oauth\")\n          }\n        >\n          <TabsList className=\"grid w-full grid-cols-3 mb-6\">\n            <TabsTrigger value=\"app-specific\">\n              {t(`apps.${appId}`)} {t(\"provider.tabProvider\")}\n            </TabsTrigger>\n            <TabsTrigger value=\"universal\">\n              {t(\"provider.tabUniversal\")}\n            </TabsTrigger>\n            <TabsTrigger value=\"oauth\">\n              {t(\"provider.tabOAuth\", { defaultValue: \"OAuth 认证源\" })}\n            </TabsTrigger>\n          </TabsList>\n\n          <TabsContent value=\"app-specific\" className=\"mt-0\">\n            <ProviderForm\n              appId={appId}\n              submitLabel={t(\"common.add\")}\n              onSubmit={handleSubmit}\n              onCancel={() => onOpenChange(false)}\n              onSubmittingChange={setIsFormSubmitting}\n              showButtons={false}\n            />\n          </TabsContent>\n\n          <TabsContent value=\"universal\" className=\"mt-0\">\n            <UniversalProviderPanel />\n          </TabsContent>\n\n          <TabsContent value=\"oauth\" className=\"mt-0\">\n            <AuthCenterPanel />\n          </TabsContent>\n        </Tabs>\n      ) : (\n        // OpenCode/OpenClaw: directly show form without tabs\n        <ProviderForm\n          appId={appId}\n          submitLabel={t(\"common.add\")}\n          onSubmit={handleSubmit}\n          onCancel={() => onOpenChange(false)}\n          onSubmittingChange={setIsFormSubmitting}\n          showButtons={false}\n        />\n      )}\n\n      {showUniversalTab && (\n        <UniversalProviderFormModal\n          isOpen={universalFormOpen}\n          onClose={handleUniversalFormClose}\n          onSave={handleUniversalProviderSave}\n          initialPreset={selectedUniversalPreset}\n        />\n      )}\n    </FullScreenPanel>\n  );\n}\n"
  },
  {
    "path": "src/components/providers/EditProviderDialog.tsx",
    "content": "import { useCallback, useEffect, useMemo, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Save } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { FullScreenPanel } from \"@/components/common/FullScreenPanel\";\nimport type { Provider } from \"@/types\";\nimport {\n  ProviderForm,\n  type ProviderFormValues,\n} from \"@/components/providers/forms/ProviderForm\";\nimport { openclawApi, providersApi, vscodeApi, type AppId } from \"@/lib/api\";\n\ninterface EditProviderDialogProps {\n  open: boolean;\n  provider: Provider | null;\n  onOpenChange: (open: boolean) => void;\n  onSubmit: (provider: Provider) => Promise<void> | void;\n  appId: AppId;\n  isProxyTakeover?: boolean; // 代理接管模式下不读取 live（避免显示被接管后的代理配置）\n}\n\nexport function EditProviderDialog({\n  open,\n  provider,\n  onOpenChange,\n  onSubmit,\n  appId,\n  isProxyTakeover = false,\n}: EditProviderDialogProps) {\n  const { t } = useTranslation();\n  const [isFormSubmitting, setIsFormSubmitting] = useState(false);\n\n  // 默认使用传入的 provider.settingsConfig，若当前编辑对象是\"当前生效供应商\"，则尝试读取实时配置替换初始值\n  const [liveSettings, setLiveSettings] = useState<Record<\n    string,\n    unknown\n  > | null>(null);\n\n  // 使用 ref 标记是否已经加载过，防止重复读取覆盖用户编辑\n  const [hasLoadedLive, setHasLoadedLive] = useState(false);\n\n  useEffect(() => {\n    let cancelled = false;\n    const load = async () => {\n      if (!open || !provider) {\n        setLiveSettings(null);\n        setHasLoadedLive(false);\n        return;\n      }\n\n      // 关键修复：只在首次打开时加载一次\n      if (hasLoadedLive) {\n        return;\n      }\n\n      // 代理接管模式：Live 配置已被代理改写，读取 live 会导致编辑界面展示代理地址/占位符等内容\n      // 因此直接回退到 SSOT（数据库）配置，避免用户困惑与误保存\n      if (isProxyTakeover) {\n        if (!cancelled) {\n          setLiveSettings(null);\n          setHasLoadedLive(true);\n        }\n        return;\n      }\n\n      // OpenCode uses additive mode - each provider's config is stored independently in DB\n      // Reading live config would return the full opencode.json (with $schema, provider, mcp etc.)\n      // instead of just the provider fragment, causing incorrect nested structure on save\n      if (appId === \"opencode\") {\n        if (!cancelled) {\n          setLiveSettings(null);\n          setHasLoadedLive(true);\n        }\n        return;\n      }\n\n      if (appId === \"openclaw\") {\n        try {\n          const live = await openclawApi.getLiveProvider(provider.id);\n          if (!cancelled && live && typeof live === \"object\") {\n            setLiveSettings(live);\n          } else if (!cancelled) {\n            setLiveSettings(null);\n          }\n        } catch {\n          if (!cancelled) {\n            setLiveSettings(null);\n          }\n        } finally {\n          if (!cancelled) {\n            setHasLoadedLive(true);\n          }\n        }\n        return;\n      }\n\n      try {\n        const currentId = await providersApi.getCurrent(appId);\n        if (currentId && provider.id === currentId) {\n          try {\n            const live = (await vscodeApi.getLiveProviderSettings(\n              appId,\n            )) as Record<string, unknown>;\n            if (!cancelled && live && typeof live === \"object\") {\n              setLiveSettings(live);\n              setHasLoadedLive(true);\n            }\n          } catch {\n            // 读取实时配置失败则回退到 SSOT（不打断编辑流程）\n            if (!cancelled) {\n              setLiveSettings(null);\n              setHasLoadedLive(true);\n            }\n          }\n        } else {\n          if (!cancelled) {\n            setLiveSettings(null);\n            setHasLoadedLive(true);\n          }\n        }\n      } finally {\n        // no-op\n      }\n    };\n    void load();\n    return () => {\n      cancelled = true;\n    };\n  }, [open, provider?.id, appId, hasLoadedLive, isProxyTakeover]); // 只依赖 provider.id，不依赖整个 provider 对象\n\n  const initialSettingsConfig = useMemo(() => {\n    return (liveSettings ?? provider?.settingsConfig ?? {}) as Record<\n      string,\n      unknown\n    >;\n  }, [liveSettings, provider?.settingsConfig]); // 只依赖 settingsConfig，不依赖整个 provider\n\n  // 固定 initialData，防止 provider 对象更新时重置表单\n  const initialData = useMemo(() => {\n    if (!provider) return null;\n    return {\n      name: provider.name,\n      notes: provider.notes,\n      websiteUrl: provider.websiteUrl,\n      settingsConfig: initialSettingsConfig,\n      category: provider.category,\n      meta: provider.meta,\n      icon: provider.icon,\n      iconColor: provider.iconColor,\n    };\n  }, [\n    open, // 修复：编辑保存后再次打开显示旧数据，依赖 open 确保每次打开时重新读取最新 provider 数据\n    provider?.id, // 只依赖 ID，provider 对象更新不会触发重新计算\n    provider?.meta, // 需要依赖 meta 以便正确初始化 testConfig 和 proxyConfig\n    initialSettingsConfig,\n  ]);\n\n  const handleSubmit = useCallback(\n    async (values: ProviderFormValues) => {\n      if (!provider) return;\n\n      // 注意：values.settingsConfig 已经是最终的配置字符串\n      // ProviderForm 已经为不同的 app 类型（Claude/Codex/Gemini）正确组装了配置\n      const parsedConfig = JSON.parse(values.settingsConfig) as Record<\n        string,\n        unknown\n      >;\n\n      const updatedProvider: Provider = {\n        ...provider,\n        name: values.name.trim(),\n        notes: values.notes?.trim() || undefined,\n        websiteUrl: values.websiteUrl?.trim() || undefined,\n        settingsConfig: parsedConfig,\n        icon: values.icon?.trim() || undefined,\n        iconColor: values.iconColor?.trim() || undefined,\n        ...(values.presetCategory ? { category: values.presetCategory } : {}),\n        // 保留或更新 meta 字段\n        ...(values.meta ? { meta: values.meta } : {}),\n      };\n\n      await onSubmit(updatedProvider);\n      onOpenChange(false);\n    },\n    [onSubmit, onOpenChange, provider],\n  );\n\n  if (!provider || !initialData) {\n    return null;\n  }\n\n  return (\n    <FullScreenPanel\n      isOpen={open}\n      title={t(\"provider.editProvider\")}\n      onClose={() => onOpenChange(false)}\n      footer={\n        <Button\n          type=\"submit\"\n          form=\"provider-form\"\n          disabled={isFormSubmitting}\n          className=\"bg-primary text-primary-foreground hover:bg-primary/90\"\n        >\n          <Save className=\"h-4 w-4 mr-2\" />\n          {t(\"common.save\")}\n        </Button>\n      }\n    >\n      <ProviderForm\n        appId={appId}\n        providerId={provider.id}\n        submitLabel={t(\"common.save\")}\n        onSubmit={handleSubmit}\n        onCancel={() => onOpenChange(false)}\n        onSubmittingChange={setIsFormSubmitting}\n        initialData={initialData}\n        showButtons={false}\n      />\n    </FullScreenPanel>\n  );\n}\n"
  },
  {
    "path": "src/components/providers/FailoverPriorityBadge.tsx",
    "content": "import { cn } from \"@/lib/utils\";\nimport { useTranslation } from \"react-i18next\";\n\ninterface FailoverPriorityBadgeProps {\n  priority: number; // 1, 2, 3, ...\n  className?: string;\n}\n\n/**\n * 故障转移优先级徽章\n * 显示供应商在故障转移队列中的优先级顺序\n */\nexport function FailoverPriorityBadge({\n  priority,\n  className,\n}: FailoverPriorityBadgeProps) {\n  const { t } = useTranslation();\n\n  return (\n    <div\n      className={cn(\n        \"inline-flex items-center px-1.5 py-0.5 rounded text-xs font-semibold\",\n        \"bg-emerald-500/10 text-emerald-600 dark:text-emerald-400\",\n        className,\n      )}\n      title={t(\"failover.priority.tooltip\", {\n        priority,\n        defaultValue: `故障转移优先级 ${priority}`,\n      })}\n    >\n      P{priority}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/providers/HealthStatusIndicator.tsx",
    "content": "import React from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport type { HealthStatus } from \"@/lib/api/model-test\";\nimport { useTranslation } from \"react-i18next\";\n\ninterface HealthStatusIndicatorProps {\n  status: HealthStatus;\n  responseTimeMs?: number;\n  className?: string;\n}\n\nconst statusConfig = {\n  operational: {\n    color: \"bg-emerald-500\",\n    labelKey: \"health.operational\",\n    labelFallback: \"正常\",\n    textColor: \"text-emerald-600 dark:text-emerald-400\",\n  },\n  degraded: {\n    color: \"bg-yellow-500\",\n    labelKey: \"health.degraded\",\n    labelFallback: \"降级\",\n    textColor: \"text-yellow-600 dark:text-yellow-400\",\n  },\n  failed: {\n    color: \"bg-red-500\",\n    labelKey: \"health.failed\",\n    labelFallback: \"失败\",\n    textColor: \"text-red-600 dark:text-red-400\",\n  },\n};\n\nexport const HealthStatusIndicator: React.FC<HealthStatusIndicatorProps> = ({\n  status,\n  responseTimeMs,\n  className,\n}) => {\n  const { t } = useTranslation();\n  const config = statusConfig[status];\n  const label = t(config.labelKey, { defaultValue: config.labelFallback });\n\n  return (\n    <div className={cn(\"flex items-center gap-2\", className)}>\n      <div className={cn(\"w-2 h-2 rounded-full\", config.color)} />\n      <span className={cn(\"text-xs font-medium\", config.textColor)}>\n        {label}\n        {responseTimeMs !== undefined && ` (${responseTimeMs}ms)`}\n      </span>\n    </div>\n  );\n};\n"
  },
  {
    "path": "src/components/providers/ProviderActions.tsx",
    "content": "import {\n  BarChart3,\n  Check,\n  Copy,\n  Edit,\n  Loader2,\n  Minus,\n  Play,\n  Plus,\n  Terminal,\n  TestTube2,\n  Trash2,\n  Zap,\n} from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\nimport type { AppId } from \"@/lib/api\";\n\ninterface ProviderActionsProps {\n  appId?: AppId;\n  isCurrent: boolean;\n  isInConfig?: boolean;\n  isTesting?: boolean;\n  isProxyTakeover?: boolean;\n  isOmo?: boolean;\n  onSwitch: () => void;\n  onEdit: () => void;\n  onDuplicate: () => void;\n  onTest?: () => void;\n  onConfigureUsage: () => void;\n  onDelete: () => void;\n  onRemoveFromConfig?: () => void;\n  onDisableOmo?: () => void;\n  onOpenTerminal?: () => void;\n  isAutoFailoverEnabled?: boolean;\n  isInFailoverQueue?: boolean;\n  onToggleFailover?: (enabled: boolean) => void;\n  // OpenClaw: default model\n  isDefaultModel?: boolean;\n  onSetAsDefault?: () => void;\n}\n\nexport function ProviderActions({\n  appId,\n  isCurrent,\n  isInConfig = false,\n  isTesting,\n  isProxyTakeover = false,\n  isOmo = false,\n  onSwitch,\n  onEdit,\n  onDuplicate,\n  onTest,\n  onConfigureUsage,\n  onDelete,\n  onRemoveFromConfig,\n  onDisableOmo,\n  onOpenTerminal,\n  isAutoFailoverEnabled = false,\n  isInFailoverQueue = false,\n  onToggleFailover,\n  // OpenClaw: default model\n  isDefaultModel = false,\n  onSetAsDefault,\n}: ProviderActionsProps) {\n  const { t } = useTranslation();\n  const iconButtonClass = \"h-8 w-8 p-1\";\n\n  // 累加模式应用（OpenCode 非 OMO 和 OpenClaw）\n  const isAdditiveMode =\n    (appId === \"opencode\" && !isOmo) || appId === \"openclaw\";\n\n  // 故障转移模式下的按钮逻辑（累加模式和 OMO 应用不支持故障转移）\n  const isFailoverMode =\n    !isAdditiveMode && !isOmo && isAutoFailoverEnabled && onToggleFailover;\n\n  const handleMainButtonClick = () => {\n    if (isOmo) {\n      if (isCurrent) {\n        onDisableOmo?.();\n      } else {\n        onSwitch();\n      }\n    } else if (isAdditiveMode) {\n      // 累加模式：切换配置状态（添加/移除）\n      if (isInConfig) {\n        if (onRemoveFromConfig) {\n          onRemoveFromConfig();\n        } else {\n          onDelete();\n        }\n      } else {\n        onSwitch(); // 添加到配置\n      }\n    } else if (isFailoverMode) {\n      onToggleFailover(!isInFailoverQueue);\n    } else {\n      onSwitch();\n    }\n  };\n\n  const getMainButtonState = () => {\n    if (isOmo) {\n      if (isCurrent) {\n        return {\n          disabled: false,\n          variant: \"secondary\" as const,\n          className:\n            \"bg-gray-200 text-muted-foreground hover:bg-gray-200 hover:text-muted-foreground dark:bg-gray-700 dark:hover:bg-gray-700\",\n          icon: <Check className=\"h-4 w-4\" />,\n          text: t(\"provider.inUse\"),\n        };\n      }\n      return {\n        disabled: false,\n        variant: \"default\" as const,\n        className: \"\",\n        icon: <Play className=\"h-4 w-4\" />,\n        text: t(\"provider.enable\"),\n      };\n    }\n\n    // 累加模式（OpenCode 非 OMO / OpenClaw）\n    if (isAdditiveMode) {\n      if (isInConfig) {\n        return {\n          disabled: isDefaultModel === true,\n          variant: \"secondary\" as const,\n          className: cn(\n            \"bg-orange-100 text-orange-600 hover:bg-orange-200 dark:bg-orange-900/50 dark:text-orange-400 dark:hover:bg-orange-900/70\",\n            isDefaultModel && \"opacity-40 cursor-not-allowed\",\n          ),\n          icon: <Minus className=\"h-4 w-4\" />,\n          text: t(\"provider.removeFromConfig\", { defaultValue: \"移除\" }),\n        };\n      }\n      return {\n        disabled: false,\n        variant: \"default\" as const,\n        className:\n          \"bg-emerald-500 hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700\",\n        icon: <Plus className=\"h-4 w-4\" />,\n        text: t(\"provider.addToConfig\", { defaultValue: \"添加\" }),\n      };\n    }\n\n    if (isFailoverMode) {\n      if (isInFailoverQueue) {\n        return {\n          disabled: false,\n          variant: \"secondary\" as const,\n          className:\n            \"bg-blue-100 text-blue-600 hover:bg-blue-200 dark:bg-blue-900/50 dark:text-blue-400 dark:hover:bg-blue-900/70\",\n          icon: <Check className=\"h-4 w-4\" />,\n          text: t(\"failover.inQueue\", { defaultValue: \"已加入\" }),\n        };\n      }\n      return {\n        disabled: false,\n        variant: \"default\" as const,\n        className:\n          \"bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700\",\n        icon: <Plus className=\"h-4 w-4\" />,\n        text: t(\"failover.addQueue\", { defaultValue: \"加入\" }),\n      };\n    }\n\n    if (isCurrent) {\n      return {\n        disabled: true,\n        variant: \"secondary\" as const,\n        className:\n          \"bg-gray-200 text-muted-foreground hover:bg-gray-200 hover:text-muted-foreground dark:bg-gray-700 dark:hover:bg-gray-700\",\n        icon: <Check className=\"h-4 w-4\" />,\n        text: t(\"provider.inUse\"),\n      };\n    }\n\n    return {\n      disabled: false,\n      variant: \"default\" as const,\n      className: isProxyTakeover\n        ? \"bg-emerald-500 hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700\"\n        : \"\",\n      icon: <Play className=\"h-4 w-4\" />,\n      text: t(\"provider.enable\"),\n    };\n  };\n\n  const buttonState = getMainButtonState();\n\n  const canDelete = isOmo || isAdditiveMode ? true : !isCurrent;\n\n  return (\n    <div className=\"flex items-center gap-1.5\">\n      {appId === \"openclaw\" && isInConfig && onSetAsDefault && (\n        <Button\n          size=\"sm\"\n          variant={isDefaultModel ? \"secondary\" : \"default\"}\n          onClick={isDefaultModel ? undefined : onSetAsDefault}\n          disabled={isDefaultModel}\n          className={cn(\n            \"w-fit px-2.5\",\n            isDefaultModel\n              ? \"bg-gray-200 text-muted-foreground dark:bg-gray-700 opacity-60 cursor-not-allowed\"\n              : \"bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700\",\n          )}\n        >\n          <Zap className=\"h-4 w-4\" />\n          {isDefaultModel\n            ? t(\"provider.isDefault\", { defaultValue: \"当前默认\" })\n            : t(\"provider.setAsDefault\", { defaultValue: \"设为默认\" })}\n        </Button>\n      )}\n\n      <Button\n        size=\"sm\"\n        variant={buttonState.variant}\n        onClick={handleMainButtonClick}\n        disabled={buttonState.disabled}\n        className={cn(\"w-[4.5rem] px-2.5\", buttonState.className)}\n      >\n        {buttonState.icon}\n        {buttonState.text}\n      </Button>\n\n      <div className=\"flex items-center gap-1\">\n        <Button\n          size=\"icon\"\n          variant=\"ghost\"\n          onClick={onEdit}\n          title={t(\"common.edit\")}\n          className={iconButtonClass}\n        >\n          <Edit className=\"h-4 w-4\" />\n        </Button>\n\n        <Button\n          size=\"icon\"\n          variant=\"ghost\"\n          onClick={onDuplicate}\n          title={t(\"provider.duplicate\")}\n          className={iconButtonClass}\n        >\n          <Copy className=\"h-4 w-4\" />\n        </Button>\n\n        {onTest && (\n          <Button\n            size=\"icon\"\n            variant=\"ghost\"\n            onClick={onTest}\n            disabled={isTesting}\n            title={t(\"modelTest.testProvider\", \"测试模型\")}\n            className={iconButtonClass}\n          >\n            {isTesting ? (\n              <Loader2 className=\"h-4 w-4 animate-spin\" />\n            ) : (\n              <TestTube2 className=\"h-4 w-4\" />\n            )}\n          </Button>\n        )}\n\n        <Button\n          size=\"icon\"\n          variant=\"ghost\"\n          onClick={onConfigureUsage}\n          title={t(\"provider.configureUsage\")}\n          className={iconButtonClass}\n        >\n          <BarChart3 className=\"h-4 w-4\" />\n        </Button>\n\n        {onOpenTerminal && (\n          <Button\n            size=\"icon\"\n            variant=\"ghost\"\n            onClick={onOpenTerminal}\n            title={t(\"provider.openTerminal\", \"打开终端\")}\n            className={cn(\n              iconButtonClass,\n              \"hover:text-emerald-600 dark:hover:text-emerald-400\",\n            )}\n          >\n            <Terminal className=\"h-4 w-4\" />\n          </Button>\n        )}\n\n        <Button\n          size=\"icon\"\n          variant=\"ghost\"\n          onClick={canDelete ? onDelete : undefined}\n          title={t(\"common.delete\")}\n          className={cn(\n            iconButtonClass,\n            canDelete && \"hover:text-red-500 dark:hover:text-red-400\",\n            !canDelete && \"opacity-40 cursor-not-allowed text-muted-foreground\",\n          )}\n        >\n          <Trash2 className=\"h-4 w-4\" />\n        </Button>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/providers/ProviderCard.tsx",
    "content": "import { useMemo, useState, useEffect, useRef } from \"react\";\nimport { GripVertical, ChevronDown, ChevronUp } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\nimport type {\n  DraggableAttributes,\n  DraggableSyntheticListeners,\n} from \"@dnd-kit/core\";\nimport type { Provider } from \"@/types\";\nimport type { AppId } from \"@/lib/api\";\nimport { cn } from \"@/lib/utils\";\nimport { ProviderActions } from \"@/components/providers/ProviderActions\";\nimport { ProviderIcon } from \"@/components/ProviderIcon\";\nimport UsageFooter from \"@/components/UsageFooter\";\nimport { ProviderHealthBadge } from \"@/components/providers/ProviderHealthBadge\";\nimport { FailoverPriorityBadge } from \"@/components/providers/FailoverPriorityBadge\";\nimport { extractCodexBaseUrl } from \"@/utils/providerConfigUtils\";\nimport { useProviderHealth } from \"@/lib/query/failover\";\nimport { useUsageQuery } from \"@/lib/query/queries\";\n\ninterface DragHandleProps {\n  attributes: DraggableAttributes;\n  listeners: DraggableSyntheticListeners;\n  isDragging: boolean;\n}\n\ninterface ProviderCardProps {\n  provider: Provider;\n  isCurrent: boolean;\n  appId: AppId;\n  isInConfig?: boolean; // OpenCode: 是否已添加到 opencode.json\n  isOmo?: boolean;\n  isOmoSlim?: boolean;\n  onSwitch: (provider: Provider) => void;\n  onEdit: (provider: Provider) => void;\n  onDelete: (provider: Provider) => void;\n  onRemoveFromConfig?: (provider: Provider) => void;\n  onDisableOmo?: () => void;\n  onDisableOmoSlim?: () => void;\n  onConfigureUsage: (provider: Provider) => void;\n  onOpenWebsite: (url: string) => void;\n  onDuplicate: (provider: Provider) => void;\n  onTest?: (provider: Provider) => void;\n  onOpenTerminal?: (provider: Provider) => void;\n  isTesting?: boolean;\n  isProxyRunning: boolean;\n  isProxyTakeover?: boolean; // 代理接管模式（Live配置已被接管，切换为热切换）\n  dragHandleProps?: DragHandleProps;\n  isAutoFailoverEnabled?: boolean; // 是否开启自动故障转移\n  failoverPriority?: number; // 故障转移优先级（1 = P1, 2 = P2, ...）\n  isInFailoverQueue?: boolean; // 是否在故障转移队列中\n  onToggleFailover?: (enabled: boolean) => void; // 切换故障转移队列\n  activeProviderId?: string; // 代理当前实际使用的供应商 ID（用于故障转移模式下标注绿色边框）\n  // OpenClaw: default model\n  isDefaultModel?: boolean;\n  onSetAsDefault?: () => void;\n}\n\nconst extractApiUrl = (provider: Provider, fallbackText: string) => {\n  if (provider.notes?.trim()) {\n    return provider.notes.trim();\n  }\n\n  if (provider.websiteUrl) {\n    return provider.websiteUrl;\n  }\n\n  const config = provider.settingsConfig;\n\n  if (config && typeof config === \"object\") {\n    const envBase =\n      (config as Record<string, any>)?.env?.ANTHROPIC_BASE_URL ||\n      (config as Record<string, any>)?.env?.GOOGLE_GEMINI_BASE_URL;\n    if (typeof envBase === \"string\" && envBase.trim()) {\n      return envBase;\n    }\n\n    const baseUrl = (config as Record<string, any>)?.config;\n\n    if (typeof baseUrl === \"string\" && baseUrl.includes(\"base_url\")) {\n      const extractedBaseUrl = extractCodexBaseUrl(baseUrl);\n      if (extractedBaseUrl) {\n        return extractedBaseUrl;\n      }\n    }\n  }\n\n  return fallbackText;\n};\n\nexport function ProviderCard({\n  provider,\n  isCurrent,\n  appId,\n  isInConfig = true,\n  isOmo = false,\n  isOmoSlim = false,\n  onSwitch,\n  onEdit,\n  onDelete,\n  onRemoveFromConfig,\n  onDisableOmo,\n  onDisableOmoSlim,\n  onConfigureUsage,\n  onOpenWebsite,\n  onDuplicate,\n  onTest,\n  onOpenTerminal,\n  isTesting,\n  isProxyRunning,\n  isProxyTakeover = false,\n  dragHandleProps,\n  isAutoFailoverEnabled = false,\n  failoverPriority,\n  isInFailoverQueue = false,\n  onToggleFailover,\n  activeProviderId,\n  // OpenClaw: default model\n  isDefaultModel,\n  onSetAsDefault,\n}: ProviderCardProps) {\n  const { t } = useTranslation();\n\n  // OMO and OMO Slim share the same card behavior\n  const isAnyOmo = isOmo || isOmoSlim;\n  const handleDisableAnyOmo = isOmoSlim ? onDisableOmoSlim : onDisableOmo;\n\n  const { data: health } = useProviderHealth(provider.id, appId);\n\n  const fallbackUrlText = t(\"provider.notConfigured\", {\n    defaultValue: \"未配置接口地址\",\n  });\n\n  const displayUrl = useMemo(() => {\n    return extractApiUrl(provider, fallbackUrlText);\n  }, [provider, fallbackUrlText]);\n\n  const isClickableUrl = useMemo(() => {\n    if (provider.notes?.trim()) {\n      return false;\n    }\n    if (displayUrl === fallbackUrlText) {\n      return false;\n    }\n    return true;\n  }, [provider.notes, displayUrl, fallbackUrlText]);\n\n  const usageEnabled = provider.meta?.usage_script?.enabled ?? false;\n\n  // 获取用量数据以判断是否有多套餐\n  // 累加模式应用（OpenCode/OpenClaw）：使用 isInConfig 代替 isCurrent\n  const shouldAutoQuery =\n    appId === \"opencode\" || appId === \"openclaw\" ? isInConfig : isCurrent;\n  const autoQueryInterval = shouldAutoQuery\n    ? provider.meta?.usage_script?.autoQueryInterval || 0\n    : 0;\n\n  const { data: usage } = useUsageQuery(provider.id, appId, {\n    enabled: usageEnabled,\n    autoQueryInterval,\n  });\n\n  const hasMultiplePlans =\n    usage?.success && usage.data && usage.data.length > 1;\n\n  const [isExpanded, setIsExpanded] = useState(false);\n\n  const actionsRef = useRef<HTMLDivElement>(null);\n  const [actionsWidth, setActionsWidth] = useState(0);\n\n  useEffect(() => {\n    if (hasMultiplePlans) {\n      setIsExpanded(true);\n    }\n  }, [hasMultiplePlans]);\n\n  useEffect(() => {\n    if (actionsRef.current) {\n      const updateWidth = () => {\n        const width = actionsRef.current?.offsetWidth || 0;\n        setActionsWidth(width);\n      };\n      updateWidth();\n      window.addEventListener(\"resize\", updateWidth);\n      return () => window.removeEventListener(\"resize\", updateWidth);\n    }\n  }, [onTest, onOpenTerminal]); // 按钮数量可能变化时重新计算\n\n  const handleOpenWebsite = () => {\n    if (!isClickableUrl) {\n      return;\n    }\n    onOpenWebsite(displayUrl);\n  };\n\n  // 判断是否是\"当前使用中\"的供应商\n  // - OMO/OMO Slim 供应商：使用 isCurrent\n  // - OpenClaw：使用默认模型归属的 provider 作为当前项（蓝色边框）\n  // - OpenCode（非 OMO）：不存在\"当前\"概念，返回 false\n  // - 故障转移模式：代理实际使用的供应商（activeProviderId）\n  // - 普通模式：isCurrent\n  const isActiveProvider = isAnyOmo\n    ? isCurrent\n    : appId === \"openclaw\"\n      ? Boolean(isDefaultModel)\n      : appId === \"opencode\"\n        ? false\n        : isAutoFailoverEnabled\n          ? activeProviderId === provider.id\n          : isCurrent;\n\n  const shouldUseGreen = !isAnyOmo && isProxyTakeover && isActiveProvider;\n  const shouldUseBlue =\n    (isAnyOmo && isActiveProvider) ||\n    (!isAnyOmo && !isProxyTakeover && isActiveProvider);\n\n  return (\n    <div\n      className={cn(\n        \"relative overflow-hidden rounded-xl border border-border p-4 transition-all duration-300\",\n        \"bg-card text-card-foreground group\",\n        isAutoFailoverEnabled || isProxyTakeover\n          ? \"hover:border-emerald-500/50\"\n          : \"hover:border-border-active\",\n        shouldUseGreen &&\n          \"border-emerald-500/60 shadow-sm shadow-emerald-500/10\",\n        shouldUseBlue && \"border-blue-500/60 shadow-sm shadow-blue-500/10\",\n        !isActiveProvider && \"hover:shadow-sm\",\n        dragHandleProps?.isDragging &&\n          \"cursor-grabbing border-primary shadow-lg scale-105 z-10\",\n      )}\n    >\n      <div\n        className={cn(\n          \"absolute inset-0 bg-gradient-to-r to-transparent transition-opacity duration-500 pointer-events-none\",\n          shouldUseGreen && \"from-emerald-500/10\",\n          shouldUseBlue && \"from-blue-500/10\",\n          !isActiveProvider && \"from-primary/10\",\n          isActiveProvider ? \"opacity-100\" : \"opacity-0\",\n        )}\n      />\n      <div className=\"relative flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between\">\n        <div className=\"flex flex-1 items-center gap-2\">\n          <button\n            type=\"button\"\n            className={cn(\n              \"-ml-1.5 flex-shrink-0 cursor-grab active:cursor-grabbing p-1.5\",\n              \"text-muted-foreground/50 hover:text-muted-foreground transition-colors\",\n              dragHandleProps?.isDragging && \"cursor-grabbing\",\n            )}\n            aria-label={t(\"provider.dragHandle\")}\n            {...(dragHandleProps?.attributes ?? {})}\n            {...(dragHandleProps?.listeners ?? {})}\n          >\n            <GripVertical className=\"h-4 w-4\" />\n          </button>\n\n          <div className=\"h-8 w-8 rounded-lg bg-muted flex items-center justify-center border border-border group-hover:scale-105 transition-transform duration-300\">\n            <ProviderIcon\n              icon={provider.icon}\n              name={provider.name}\n              color={provider.iconColor}\n              size={20}\n            />\n          </div>\n\n          <div className=\"space-y-1\">\n            <div className=\"flex flex-wrap items-center gap-2 min-h-7\">\n              <h3 className=\"text-base font-semibold leading-none\">\n                {provider.name}\n              </h3>\n\n              {isOmo && (\n                <span className=\"inline-flex items-center rounded-md bg-violet-100 px-1.5 py-0.5 text-[10px] font-semibold text-violet-700 dark:bg-violet-900/40 dark:text-violet-300\">\n                  OMO\n                </span>\n              )}\n\n              {isOmoSlim && (\n                <span className=\"inline-flex items-center rounded-md bg-indigo-100 px-1.5 py-0.5 text-[10px] font-semibold text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300\">\n                  Slim\n                </span>\n              )}\n\n              {isProxyRunning && isInFailoverQueue && health && (\n                <ProviderHealthBadge\n                  consecutiveFailures={health.consecutive_failures}\n                />\n              )}\n\n              {isAutoFailoverEnabled &&\n                isInFailoverQueue &&\n                failoverPriority && (\n                  <FailoverPriorityBadge priority={failoverPriority} />\n                )}\n\n              {provider.category === \"third_party\" &&\n                provider.meta?.isPartner && (\n                  <span\n                    className=\"text-yellow-500 dark:text-yellow-400\"\n                    title={t(\"provider.officialPartner\", {\n                      defaultValue: \"官方合作伙伴\",\n                    })}\n                  >\n                    ⭐\n                  </span>\n                )}\n            </div>\n\n            {displayUrl && (\n              <button\n                type=\"button\"\n                onClick={handleOpenWebsite}\n                className={cn(\n                  \"inline-flex items-center text-sm max-w-[280px]\",\n                  isClickableUrl\n                    ? \"text-blue-500 transition-colors hover:underline dark:text-blue-400 cursor-pointer\"\n                    : \"text-muted-foreground cursor-default\",\n                )}\n                title={displayUrl}\n                disabled={!isClickableUrl}\n              >\n                <span className=\"truncate\">{displayUrl}</span>\n              </button>\n            )}\n          </div>\n        </div>\n\n        <div\n          className=\"relative flex items-center ml-auto min-w-0 gap-3\"\n          style={\n            {\n              \"--actions-width\": `${actionsWidth || 320}px`,\n            } as React.CSSProperties\n          }\n        >\n          <div className=\"ml-auto\">\n            <div className=\"flex items-center gap-1 transition-transform duration-200 group-hover:-translate-x-[var(--actions-width)] group-focus-within:-translate-x-[var(--actions-width)]\">\n              {hasMultiplePlans ? (\n                <div className=\"flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400\">\n                  <span className=\"font-medium\">\n                    {t(\"usage.multiplePlans\", {\n                      count: usage?.data?.length || 0,\n                      defaultValue: `${usage?.data?.length || 0} 个套餐`,\n                    })}\n                  </span>\n                </div>\n              ) : (\n                <UsageFooter\n                  provider={provider}\n                  providerId={provider.id}\n                  appId={appId}\n                  usageEnabled={usageEnabled}\n                  isCurrent={isCurrent}\n                  isInConfig={isInConfig}\n                  inline={true}\n                />\n              )}\n              {hasMultiplePlans && (\n                <button\n                  onClick={(e) => {\n                    e.stopPropagation();\n                    setIsExpanded(!isExpanded);\n                  }}\n                  className=\"p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-gray-500 dark:text-gray-400 flex-shrink-0\"\n                  title={\n                    isExpanded\n                      ? t(\"usage.collapse\", { defaultValue: \"收起\" })\n                      : t(\"usage.expand\", { defaultValue: \"展开\" })\n                  }\n                >\n                  {isExpanded ? (\n                    <ChevronUp size={14} />\n                  ) : (\n                    <ChevronDown size={14} />\n                  )}\n                </button>\n              )}\n            </div>\n          </div>\n\n          <div\n            ref={actionsRef}\n            className=\"absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1.5 pl-3 opacity-0 pointer-events-none group-hover:opacity-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-all duration-200 translate-x-2 group-hover:translate-x-0 group-focus-within:translate-x-0\"\n          >\n            <ProviderActions\n              appId={appId}\n              isCurrent={isCurrent}\n              isInConfig={isInConfig}\n              isTesting={isTesting}\n              isProxyTakeover={isProxyTakeover}\n              isOmo={isAnyOmo}\n              onSwitch={() => onSwitch(provider)}\n              onEdit={() => onEdit(provider)}\n              onDuplicate={() => onDuplicate(provider)}\n              onTest={onTest ? () => onTest(provider) : undefined}\n              onConfigureUsage={() => onConfigureUsage(provider)}\n              onDelete={() => onDelete(provider)}\n              onRemoveFromConfig={\n                onRemoveFromConfig\n                  ? () => onRemoveFromConfig(provider)\n                  : undefined\n              }\n              onDisableOmo={handleDisableAnyOmo}\n              onOpenTerminal={\n                onOpenTerminal ? () => onOpenTerminal(provider) : undefined\n              }\n              isAutoFailoverEnabled={isAutoFailoverEnabled}\n              isInFailoverQueue={isInFailoverQueue}\n              onToggleFailover={onToggleFailover}\n              // OpenClaw: default model\n              isDefaultModel={isDefaultModel}\n              onSetAsDefault={onSetAsDefault}\n            />\n          </div>\n        </div>\n      </div>\n\n      {isExpanded && hasMultiplePlans && (\n        <div className=\"mt-4 pt-4 border-t border-border-default\">\n          <UsageFooter\n            provider={provider}\n            providerId={provider.id}\n            appId={appId}\n            usageEnabled={usageEnabled}\n            isCurrent={isCurrent}\n            isInConfig={isInConfig}\n            inline={false}\n          />\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/providers/ProviderEmptyState.tsx",
    "content": "import { Download, Users } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Button } from \"@/components/ui/button\";\nimport type { AppId } from \"@/lib/api/types\";\n\ninterface ProviderEmptyStateProps {\n  appId: AppId;\n  onCreate?: () => void;\n  onImport?: () => void;\n}\n\nexport function ProviderEmptyState({\n  appId,\n  onCreate,\n  onImport,\n}: ProviderEmptyStateProps) {\n  const { t } = useTranslation();\n  const showSnippetHint =\n    appId === \"claude\" || appId === \"codex\" || appId === \"gemini\";\n\n  return (\n    <div className=\"flex flex-col items-center justify-center rounded-lg border border-dashed border-border p-10 text-center\">\n      <div className=\"mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted\">\n        <Users className=\"h-7 w-7 text-muted-foreground\" />\n      </div>\n      <h3 className=\"text-lg font-semibold\">{t(\"provider.noProviders\")}</h3>\n      <p className=\"mt-2 max-w-lg text-sm text-muted-foreground\">\n        {t(\"provider.noProvidersDescription\")}\n      </p>\n      {showSnippetHint && (\n        <p className=\"mt-1 max-w-lg text-sm text-muted-foreground\">\n          {t(\"provider.noProvidersDescriptionSnippet\")}\n        </p>\n      )}\n      <div className=\"mt-6 flex flex-col gap-2\">\n        {onImport && (\n          <Button onClick={onImport}>\n            <Download className=\"mr-2 h-4 w-4\" />\n            {t(\"provider.importCurrent\")}\n          </Button>\n        )}\n        {onCreate && (\n          <Button variant={onImport ? \"outline\" : \"default\"} onClick={onCreate}>\n            {t(\"provider.addProvider\")}\n          </Button>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/providers/ProviderHealthBadge.tsx",
    "content": "import { cn } from \"@/lib/utils\";\nimport { ProviderHealthStatus } from \"@/types/proxy\";\nimport { useTranslation } from \"react-i18next\";\n\ninterface ProviderHealthBadgeProps {\n  consecutiveFailures: number;\n  className?: string;\n}\n\n/**\n * 供应商健康状态徽章\n * 根据连续失败次数显示不同颜色的状态指示器\n */\nexport function ProviderHealthBadge({\n  consecutiveFailures,\n  className,\n}: ProviderHealthBadgeProps) {\n  const { t } = useTranslation();\n\n  // 根据失败次数计算状态\n  const getStatus = () => {\n    if (consecutiveFailures === 0) {\n      return {\n        labelKey: \"health.operational\",\n        labelFallback: \"正常\",\n        status: ProviderHealthStatus.Healthy,\n        color: \"bg-green-500\",\n        // 使用更深/柔和的背景色，去除可能的白色内容感\n        bgColor: \"bg-green-500/10\",\n        textColor: \"text-green-600 dark:text-green-400\",\n      };\n    } else if (consecutiveFailures < 5) {\n      return {\n        labelKey: \"health.degraded\",\n        labelFallback: \"降级\",\n        status: ProviderHealthStatus.Degraded,\n        color: \"bg-yellow-500\",\n        bgColor: \"bg-yellow-500/10\",\n        textColor: \"text-yellow-600 dark:text-yellow-400\",\n      };\n    } else {\n      return {\n        labelKey: \"health.circuitOpen\",\n        labelFallback: \"熔断\",\n        status: ProviderHealthStatus.Failed,\n        color: \"bg-red-500\",\n        bgColor: \"bg-red-500/10\",\n        textColor: \"text-red-600 dark:text-red-400\",\n      };\n    }\n  };\n\n  const statusConfig = getStatus();\n  const label = t(statusConfig.labelKey, {\n    defaultValue: statusConfig.labelFallback,\n  });\n\n  return (\n    <div\n      className={cn(\n        \"inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium\",\n        statusConfig.bgColor,\n        statusConfig.textColor,\n        className,\n      )}\n      title={t(\"health.consecutiveFailures\", {\n        count: consecutiveFailures,\n        defaultValue: `连续失败 ${consecutiveFailures} 次`,\n      })}\n    >\n      <div className={cn(\"w-2 h-2 rounded-full\", statusConfig.color)} />\n      <span>{label}</span>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/providers/ProviderList.tsx",
    "content": "import { CSS } from \"@dnd-kit/utilities\";\nimport { DndContext, closestCenter } from \"@dnd-kit/core\";\nimport {\n  SortableContext,\n  useSortable,\n  verticalListSortingStrategy,\n} from \"@dnd-kit/sortable\";\nimport {\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n  type CSSProperties,\n} from \"react\";\nimport { AnimatePresence, motion } from \"framer-motion\";\nimport { Search, X } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\nimport { useQuery, useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { toast } from \"sonner\";\nimport type { Provider } from \"@/types\";\nimport type { AppId } from \"@/lib/api\";\nimport { providersApi } from \"@/lib/api/providers\";\nimport { useDragSort } from \"@/hooks/useDragSort\";\nimport {\n  useOpenClawLiveProviderIds,\n  useOpenClawDefaultModel,\n} from \"@/hooks/useOpenClaw\";\nimport { useStreamCheck } from \"@/hooks/useStreamCheck\";\nimport { ProviderCard } from \"@/components/providers/ProviderCard\";\nimport { ProviderEmptyState } from \"@/components/providers/ProviderEmptyState\";\nimport {\n  useAutoFailoverEnabled,\n  useFailoverQueue,\n  useAddToFailoverQueue,\n  useRemoveFromFailoverQueue,\n} from \"@/lib/query/failover\";\nimport {\n  useCurrentOmoProviderId,\n  useCurrentOmoSlimProviderId,\n} from \"@/lib/query/omo\";\nimport { useCallback } from \"react\";\nimport { Input } from \"@/components/ui/input\";\nimport { Button } from \"@/components/ui/button\";\nimport { ConfirmDialog } from \"@/components/ConfirmDialog\";\nimport { settingsApi } from \"@/lib/api/settings\";\n\ninterface ProviderListProps {\n  providers: Record<string, Provider>;\n  currentProviderId: string;\n  appId: AppId;\n  onSwitch: (provider: Provider) => void;\n  onEdit: (provider: Provider) => void;\n  onDelete: (provider: Provider) => void;\n  onRemoveFromConfig?: (provider: Provider) => void;\n  onDisableOmo?: () => void;\n  onDisableOmoSlim?: () => void;\n  onDuplicate: (provider: Provider) => void;\n  onConfigureUsage?: (provider: Provider) => void;\n  onOpenWebsite: (url: string) => void;\n  onOpenTerminal?: (provider: Provider) => void;\n  onCreate?: () => void;\n  isLoading?: boolean;\n  isProxyRunning?: boolean; // 代理服务运行状态\n  isProxyTakeover?: boolean; // 代理接管模式（Live配置已被接管）\n  activeProviderId?: string; // 代理当前实际使用的供应商 ID（用于故障转移模式下标注绿色边框）\n  onSetAsDefault?: (provider: Provider) => void; // OpenClaw: set as default model\n}\n\nexport function ProviderList({\n  providers,\n  currentProviderId,\n  appId,\n  onSwitch,\n  onEdit,\n  onDelete,\n  onRemoveFromConfig,\n  onDisableOmo,\n  onDisableOmoSlim,\n  onDuplicate,\n  onConfigureUsage,\n  onOpenWebsite,\n  onOpenTerminal,\n  onCreate,\n  isLoading = false,\n  isProxyRunning = false,\n  isProxyTakeover = false,\n  activeProviderId,\n  onSetAsDefault,\n}: ProviderListProps) {\n  const { t } = useTranslation();\n  const { checkProvider, isChecking } = useStreamCheck(appId);\n  const { sortedProviders, sensors, handleDragEnd } = useDragSort(\n    providers,\n    appId,\n  );\n\n  const { data: opencodeLiveIds } = useQuery({\n    queryKey: [\"opencodeLiveProviderIds\"],\n    queryFn: () => providersApi.getOpenCodeLiveProviderIds(),\n    enabled: appId === \"opencode\",\n  });\n\n  // OpenClaw: 查询 live 配置中的供应商 ID 列表，用于判断 isInConfig\n  const { data: openclawLiveIds } = useOpenClawLiveProviderIds(\n    appId === \"openclaw\",\n  );\n\n  // 判断供应商是否已添加到配置（累加模式应用：OpenCode/OpenClaw）\n  const isProviderInConfig = useCallback(\n    (providerId: string): boolean => {\n      if (appId === \"opencode\") {\n        return opencodeLiveIds?.includes(providerId) ?? false;\n      }\n      if (appId === \"openclaw\") {\n        return openclawLiveIds?.includes(providerId) ?? false;\n      }\n      return true; // 其他应用始终返回 true\n    },\n    [appId, opencodeLiveIds, openclawLiveIds],\n  );\n\n  // OpenClaw: query default model to determine which provider is default\n  const { data: openclawDefaultModel } = useOpenClawDefaultModel(\n    appId === \"openclaw\",\n  );\n\n  const isProviderDefaultModel = useCallback(\n    (providerId: string): boolean => {\n      if (appId !== \"openclaw\" || !openclawDefaultModel?.primary) return false;\n      return openclawDefaultModel.primary.startsWith(providerId + \"/\");\n    },\n    [appId, openclawDefaultModel],\n  );\n\n  // 故障转移相关\n  const { data: isAutoFailoverEnabled } = useAutoFailoverEnabled(appId);\n  const { data: failoverQueue } = useFailoverQueue(appId);\n  const addToQueue = useAddToFailoverQueue();\n  const removeFromQueue = useRemoveFromFailoverQueue();\n\n  const isFailoverModeActive =\n    isProxyTakeover === true && isAutoFailoverEnabled === true;\n\n  const isOpenCode = appId === \"opencode\";\n  const { data: currentOmoId } = useCurrentOmoProviderId(isOpenCode);\n  const { data: currentOmoSlimId } = useCurrentOmoSlimProviderId(isOpenCode);\n\n  const getFailoverPriority = useCallback(\n    (providerId: string): number | undefined => {\n      if (!isFailoverModeActive || !failoverQueue) return undefined;\n      const index = failoverQueue.findIndex(\n        (item) => item.providerId === providerId,\n      );\n      return index >= 0 ? index + 1 : undefined;\n    },\n    [isFailoverModeActive, failoverQueue],\n  );\n\n  const isInFailoverQueue = useCallback(\n    (providerId: string): boolean => {\n      if (!isFailoverModeActive || !failoverQueue) return false;\n      return failoverQueue.some((item) => item.providerId === providerId);\n    },\n    [isFailoverModeActive, failoverQueue],\n  );\n\n  const handleToggleFailover = useCallback(\n    (providerId: string, enabled: boolean) => {\n      if (enabled) {\n        addToQueue.mutate({ appType: appId, providerId });\n      } else {\n        removeFromQueue.mutate({ appType: appId, providerId });\n      }\n    },\n    [appId, addToQueue, removeFromQueue],\n  );\n\n  const [searchTerm, setSearchTerm] = useState(\"\");\n  const [isSearchOpen, setIsSearchOpen] = useState(false);\n  const searchInputRef = useRef<HTMLInputElement>(null);\n  const [showStreamCheckConfirm, setShowStreamCheckConfirm] = useState(false);\n  const [pendingTestProvider, setPendingTestProvider] =\n    useState<Provider | null>(null);\n\n  // Query settings for streamCheckConfirmed flag\n  const { data: settings } = useQuery({\n    queryKey: [\"settings\"],\n    queryFn: () => settingsApi.get(),\n  });\n\n  const handleTest = useCallback(\n    (provider: Provider) => {\n      if (!settings?.streamCheckConfirmed) {\n        setPendingTestProvider(provider);\n        setShowStreamCheckConfirm(true);\n      } else {\n        checkProvider(provider.id, provider.name);\n      }\n    },\n    [checkProvider, settings?.streamCheckConfirmed],\n  );\n\n  const handleStreamCheckConfirm = async () => {\n    setShowStreamCheckConfirm(false);\n    try {\n      if (settings) {\n        await settingsApi.save({ ...settings, streamCheckConfirmed: true });\n        await queryClient.invalidateQueries({ queryKey: [\"settings\"] });\n      }\n    } catch (error) {\n      console.error(\"Failed to save stream check confirmed:\", error);\n    }\n    if (pendingTestProvider) {\n      checkProvider(pendingTestProvider.id, pendingTestProvider.name);\n      setPendingTestProvider(null);\n    }\n  };\n\n  // Import current live config as default provider\n  const queryClient = useQueryClient();\n  const importMutation = useMutation({\n    mutationFn: async (): Promise<boolean> => {\n      if (appId === \"opencode\") {\n        const count = await providersApi.importOpenCodeFromLive();\n        return count > 0;\n      }\n      if (appId === \"openclaw\") {\n        const count = await providersApi.importOpenClawFromLive();\n        return count > 0;\n      }\n      return providersApi.importDefault(appId);\n    },\n    onSuccess: (imported) => {\n      if (imported) {\n        queryClient.invalidateQueries({ queryKey: [\"providers\", appId] });\n        toast.success(t(\"provider.importCurrentDescription\"));\n      } else {\n        toast.info(t(\"provider.noProviders\"));\n      }\n    },\n    onError: (error: Error) => {\n      toast.error(error.message);\n    },\n  });\n\n  useEffect(() => {\n    const handleKeyDown = (event: KeyboardEvent) => {\n      const key = event.key.toLowerCase();\n      if ((event.metaKey || event.ctrlKey) && key === \"f\") {\n        event.preventDefault();\n        setIsSearchOpen(true);\n        return;\n      }\n\n      if (key === \"escape\") {\n        setIsSearchOpen(false);\n      }\n    };\n\n    window.addEventListener(\"keydown\", handleKeyDown);\n    return () => window.removeEventListener(\"keydown\", handleKeyDown);\n  }, []);\n\n  useEffect(() => {\n    if (isSearchOpen) {\n      const frame = requestAnimationFrame(() => {\n        searchInputRef.current?.focus();\n        searchInputRef.current?.select();\n      });\n      return () => cancelAnimationFrame(frame);\n    }\n  }, [isSearchOpen]);\n\n  const filteredProviders = useMemo(() => {\n    const keyword = searchTerm.trim().toLowerCase();\n    if (!keyword) return sortedProviders;\n    return sortedProviders.filter((provider) => {\n      const fields = [provider.name, provider.notes, provider.websiteUrl];\n      return fields.some((field) =>\n        field?.toString().toLowerCase().includes(keyword),\n      );\n    });\n  }, [searchTerm, sortedProviders]);\n\n  if (isLoading) {\n    return (\n      <div className=\"space-y-3\">\n        {[0, 1, 2].map((index) => (\n          <div\n            key={index}\n            className=\"w-full border border-dashed rounded-lg h-28 border-muted-foreground/40 bg-muted/40\"\n          />\n        ))}\n      </div>\n    );\n  }\n\n  if (sortedProviders.length === 0) {\n    return (\n      <ProviderEmptyState\n        appId={appId}\n        onCreate={onCreate}\n        onImport={() => importMutation.mutate()}\n      />\n    );\n  }\n\n  const renderProviderList = () => (\n    <DndContext\n      sensors={sensors}\n      collisionDetection={closestCenter}\n      onDragEnd={handleDragEnd}\n    >\n      <SortableContext\n        items={filteredProviders.map((provider) => provider.id)}\n        strategy={verticalListSortingStrategy}\n      >\n        <div className=\"space-y-3\">\n          {filteredProviders.map((provider) => {\n            const isOmo = provider.category === \"omo\";\n            const isOmoSlim = provider.category === \"omo-slim\";\n            const isOmoCurrent = isOmo && provider.id === (currentOmoId || \"\");\n            const isOmoSlimCurrent =\n              isOmoSlim && provider.id === (currentOmoSlimId || \"\");\n            return (\n              <SortableProviderCard\n                key={provider.id}\n                provider={provider}\n                isCurrent={\n                  isOmo\n                    ? isOmoCurrent\n                    : isOmoSlim\n                      ? isOmoSlimCurrent\n                      : provider.id === currentProviderId\n                }\n                appId={appId}\n                isInConfig={isProviderInConfig(provider.id)}\n                isOmo={isOmo}\n                isOmoSlim={isOmoSlim}\n                onSwitch={onSwitch}\n                onEdit={onEdit}\n                onDelete={onDelete}\n                onRemoveFromConfig={onRemoveFromConfig}\n                onDisableOmo={onDisableOmo}\n                onDisableOmoSlim={onDisableOmoSlim}\n                onDuplicate={onDuplicate}\n                onConfigureUsage={onConfigureUsage}\n                onOpenWebsite={onOpenWebsite}\n                onOpenTerminal={onOpenTerminal}\n                onTest={\n                  appId !== \"opencode\" && appId !== \"openclaw\"\n                    ? handleTest\n                    : undefined\n                }\n                isTesting={isChecking(provider.id)}\n                isProxyRunning={isProxyRunning}\n                isProxyTakeover={isProxyTakeover}\n                isAutoFailoverEnabled={isFailoverModeActive}\n                failoverPriority={getFailoverPriority(provider.id)}\n                isInFailoverQueue={isInFailoverQueue(provider.id)}\n                onToggleFailover={(enabled) =>\n                  handleToggleFailover(provider.id, enabled)\n                }\n                activeProviderId={activeProviderId}\n                // OpenClaw: default model\n                isDefaultModel={isProviderDefaultModel(provider.id)}\n                onSetAsDefault={\n                  onSetAsDefault ? () => onSetAsDefault(provider) : undefined\n                }\n              />\n            );\n          })}\n        </div>\n      </SortableContext>\n    </DndContext>\n  );\n\n  return (\n    <div className=\"mt-4 space-y-4\">\n      <AnimatePresence>\n        {isSearchOpen && (\n          <motion.div\n            key=\"provider-search\"\n            initial={{ opacity: 0, y: -8, scale: 0.98 }}\n            animate={{ opacity: 1, y: 0, scale: 1 }}\n            exit={{ opacity: 0, y: -8, scale: 0.98 }}\n            transition={{ duration: 0.18, ease: \"easeOut\" }}\n            className=\"fixed left-1/2 top-[6.5rem] z-40 w-[min(90vw,26rem)] -translate-x-1/2 sm:right-6 sm:left-auto sm:translate-x-0\"\n          >\n            <div className=\"p-4 space-y-3 border shadow-md rounded-2xl border-white/10 bg-background/95 shadow-black/20 backdrop-blur-md\">\n              <div className=\"relative flex items-center gap-2\">\n                <Search className=\"absolute w-4 h-4 -translate-y-1/2 pointer-events-none left-3 top-1/2 text-muted-foreground\" />\n                <Input\n                  ref={searchInputRef}\n                  value={searchTerm}\n                  onChange={(event) => setSearchTerm(event.target.value)}\n                  placeholder={t(\"provider.searchPlaceholder\", {\n                    defaultValue: \"Search name, notes, or URL...\",\n                  })}\n                  aria-label={t(\"provider.searchAriaLabel\", {\n                    defaultValue: \"Search providers\",\n                  })}\n                  className=\"pr-16 pl-9\"\n                />\n                {searchTerm && (\n                  <Button\n                    variant=\"ghost\"\n                    size=\"sm\"\n                    className=\"absolute text-xs -translate-y-1/2 right-11 top-1/2\"\n                    onClick={() => setSearchTerm(\"\")}\n                  >\n                    {t(\"common.clear\", { defaultValue: \"Clear\" })}\n                  </Button>\n                )}\n                <Button\n                  variant=\"ghost\"\n                  size=\"icon\"\n                  className=\"ml-auto\"\n                  onClick={() => setIsSearchOpen(false)}\n                  aria-label={t(\"provider.searchCloseAriaLabel\", {\n                    defaultValue: \"Close provider search\",\n                  })}\n                >\n                  <X className=\"w-4 h-4\" />\n                </Button>\n              </div>\n              <div className=\"flex flex-wrap items-center justify-between gap-2 text-[11px] text-muted-foreground\">\n                <span>\n                  {t(\"provider.searchScopeHint\", {\n                    defaultValue: \"Matches provider name, notes, and URL.\",\n                  })}\n                </span>\n                <span>\n                  {t(\"provider.searchCloseHint\", {\n                    defaultValue: \"Press Esc to close\",\n                  })}\n                </span>\n              </div>\n            </div>\n          </motion.div>\n        )}\n      </AnimatePresence>\n\n      {filteredProviders.length === 0 ? (\n        <div className=\"px-6 py-8 text-sm text-center border border-dashed rounded-lg border-border text-muted-foreground\">\n          {t(\"provider.noSearchResults\", {\n            defaultValue: \"No providers match your search.\",\n          })}\n        </div>\n      ) : (\n        renderProviderList()\n      )}\n\n      <ConfirmDialog\n        isOpen={showStreamCheckConfirm}\n        variant=\"info\"\n        title={t(\"confirm.streamCheck.title\")}\n        message={t(\"confirm.streamCheck.message\")}\n        confirmText={t(\"confirm.streamCheck.confirm\")}\n        onConfirm={() => void handleStreamCheckConfirm()}\n        onCancel={() => {\n          setShowStreamCheckConfirm(false);\n          setPendingTestProvider(null);\n        }}\n      />\n    </div>\n  );\n}\n\ninterface SortableProviderCardProps {\n  provider: Provider;\n  isCurrent: boolean;\n  appId: AppId;\n  isInConfig: boolean;\n  isOmo: boolean;\n  isOmoSlim: boolean;\n  onSwitch: (provider: Provider) => void;\n  onEdit: (provider: Provider) => void;\n  onDelete: (provider: Provider) => void;\n  onRemoveFromConfig?: (provider: Provider) => void;\n  onDisableOmo?: () => void;\n  onDisableOmoSlim?: () => void;\n  onDuplicate: (provider: Provider) => void;\n  onConfigureUsage?: (provider: Provider) => void;\n  onOpenWebsite: (url: string) => void;\n  onOpenTerminal?: (provider: Provider) => void;\n  onTest?: (provider: Provider) => void;\n  isTesting: boolean;\n  isProxyRunning: boolean;\n  isProxyTakeover: boolean;\n  isAutoFailoverEnabled: boolean;\n  failoverPriority?: number;\n  isInFailoverQueue: boolean;\n  onToggleFailover: (enabled: boolean) => void;\n  activeProviderId?: string;\n  // OpenClaw: default model\n  isDefaultModel?: boolean;\n  onSetAsDefault?: () => void;\n}\n\nfunction SortableProviderCard({\n  provider,\n  isCurrent,\n  appId,\n  isInConfig,\n  isOmo,\n  isOmoSlim,\n  onSwitch,\n  onEdit,\n  onDelete,\n  onRemoveFromConfig,\n  onDisableOmo,\n  onDisableOmoSlim,\n  onDuplicate,\n  onConfigureUsage,\n  onOpenWebsite,\n  onOpenTerminal,\n  onTest,\n  isTesting,\n  isProxyRunning,\n  isProxyTakeover,\n  isAutoFailoverEnabled,\n  failoverPriority,\n  isInFailoverQueue,\n  onToggleFailover,\n  activeProviderId,\n  isDefaultModel,\n  onSetAsDefault,\n}: SortableProviderCardProps) {\n  const {\n    setNodeRef,\n    attributes,\n    listeners,\n    transform,\n    transition,\n    isDragging,\n  } = useSortable({ id: provider.id });\n\n  const style: CSSProperties = {\n    transform: CSS.Transform.toString(transform),\n    transition,\n  };\n\n  return (\n    <div ref={setNodeRef} style={style}>\n      <ProviderCard\n        provider={provider}\n        isCurrent={isCurrent}\n        appId={appId}\n        isInConfig={isInConfig}\n        isOmo={isOmo}\n        isOmoSlim={isOmoSlim}\n        onSwitch={onSwitch}\n        onEdit={onEdit}\n        onDelete={onDelete}\n        onRemoveFromConfig={onRemoveFromConfig}\n        onDisableOmo={onDisableOmo}\n        onDisableOmoSlim={onDisableOmoSlim}\n        onDuplicate={onDuplicate}\n        onConfigureUsage={\n          onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined\n        }\n        onOpenWebsite={onOpenWebsite}\n        onOpenTerminal={onOpenTerminal}\n        onTest={onTest}\n        isTesting={isTesting}\n        isProxyRunning={isProxyRunning}\n        isProxyTakeover={isProxyTakeover}\n        dragHandleProps={{\n          attributes,\n          listeners,\n          isDragging,\n        }}\n        isAutoFailoverEnabled={isAutoFailoverEnabled}\n        failoverPriority={failoverPriority}\n        isInFailoverQueue={isInFailoverQueue}\n        onToggleFailover={onToggleFailover}\n        activeProviderId={activeProviderId}\n        // OpenClaw: default model\n        isDefaultModel={isDefaultModel}\n        onSetAsDefault={onSetAsDefault}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/providers/forms/ApiKeyInput.tsx",
    "content": "import React, { useState } from \"react\";\nimport { Eye, EyeOff } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\n\ninterface ApiKeyInputProps {\n  value: string;\n  onChange: (value: string) => void;\n  placeholder?: string;\n  disabled?: boolean;\n  required?: boolean;\n  label?: string;\n  id?: string;\n}\n\nconst ApiKeyInput: React.FC<ApiKeyInputProps> = ({\n  value,\n  onChange,\n  placeholder,\n  disabled = false,\n  required = false,\n  label = \"API Key\",\n  id = \"apiKey\",\n}) => {\n  const { t } = useTranslation();\n  const [showKey, setShowKey] = useState(false);\n\n  const toggleShowKey = () => {\n    setShowKey(!showKey);\n  };\n\n  const inputClass = `w-full px-3 py-2 pr-10 border rounded-lg text-sm transition-colors ${\n    disabled\n      ? \"bg-muted border-border-default text-muted-foreground cursor-not-allowed\"\n      : \"border-border-default bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20\"\n  }`;\n\n  return (\n    <div className=\"space-y-2\">\n      <label htmlFor={id} className=\"block text-sm font-medium text-foreground\">\n        {label} {required && \"*\"}\n      </label>\n      <div className=\"relative\">\n        <input\n          type={showKey ? \"text\" : \"password\"}\n          id={id}\n          value={value}\n          onChange={(e) => onChange(e.target.value)}\n          placeholder={placeholder ?? t(\"apiKeyInput.placeholder\")}\n          disabled={disabled}\n          required={required}\n          autoComplete=\"off\"\n          className={inputClass}\n        />\n        {!disabled && value && (\n          <button\n            type=\"button\"\n            onClick={toggleShowKey}\n            className=\"absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors\"\n            aria-label={showKey ? t(\"apiKeyInput.hide\") : t(\"apiKeyInput.show\")}\n          >\n            {showKey ? <EyeOff size={16} /> : <Eye size={16} />}\n          </button>\n        )}\n      </div>\n    </div>\n  );\n};\n\nexport default ApiKeyInput;\n"
  },
  {
    "path": "src/components/providers/forms/BasicFormFields.tsx",
    "content": "import { useTranslation } from \"react-i18next\";\nimport { useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport {\n  FormControl,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormMessage,\n} from \"@/components/ui/form\";\nimport { Input } from \"@/components/ui/input\";\nimport { Button } from \"@/components/ui/button\";\nimport { ArrowLeft } from \"lucide-react\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogTrigger,\n  DialogClose,\n} from \"@/components/ui/dialog\";\nimport { ProviderIcon } from \"@/components/ProviderIcon\";\nimport { IconPicker } from \"@/components/IconPicker\";\nimport { getIconMetadata } from \"@/icons/extracted/metadata\";\nimport type { UseFormReturn } from \"react-hook-form\";\nimport type { ProviderFormData } from \"@/lib/schemas/provider\";\n\ninterface BasicFormFieldsProps {\n  form: UseFormReturn<ProviderFormData>;\n  /** Slot to render content between icon and name fields */\n  beforeNameSlot?: ReactNode;\n}\n\nexport function BasicFormFields({\n  form,\n  beforeNameSlot,\n}: BasicFormFieldsProps) {\n  const { t } = useTranslation();\n  const [iconDialogOpen, setIconDialogOpen] = useState(false);\n\n  const currentIcon = form.watch(\"icon\");\n  const currentIconColor = form.watch(\"iconColor\");\n  const providerName = form.watch(\"name\") || \"Provider\";\n  const effectiveIconColor =\n    currentIconColor ||\n    (currentIcon ? getIconMetadata(currentIcon)?.defaultColor : undefined);\n\n  const handleIconSelect = (icon: string) => {\n    const meta = getIconMetadata(icon);\n    form.setValue(\"icon\", icon);\n    form.setValue(\"iconColor\", meta?.defaultColor ?? \"\");\n  };\n\n  return (\n    <>\n      {/* 图标选择区域 - 顶部居中，可选 */}\n      <div className=\"flex justify-center mb-6\">\n        <Dialog open={iconDialogOpen} onOpenChange={setIconDialogOpen}>\n          <DialogTrigger asChild>\n            <button\n              type=\"button\"\n              className=\"w-20 h-20 p-3 rounded-xl border-2 border-muted hover:border-primary transition-colors cursor-pointer bg-muted/30 hover:bg-muted/50 flex items-center justify-center\"\n              title={\n                currentIcon\n                  ? t(\"providerIcon.clickToChange\", {\n                      defaultValue: \"点击更换图标\",\n                    })\n                  : t(\"providerIcon.clickToSelect\", {\n                      defaultValue: \"点击选择图标\",\n                    })\n              }\n            >\n              <ProviderIcon\n                icon={currentIcon}\n                name={providerName}\n                color={effectiveIconColor}\n                size={48}\n              />\n            </button>\n          </DialogTrigger>\n          <DialogContent\n            variant=\"fullscreen\"\n            zIndex=\"top\"\n            overlayClassName=\"bg-[hsl(var(--background))] backdrop-blur-0\"\n            className=\"p-0 sm:rounded-none\"\n          >\n            <div className=\"flex h-full flex-col\">\n              <div className=\"flex-shrink-0 py-4 border-b border-border-default bg-muted/40\">\n                <div className=\"px-6 flex items-center gap-4\">\n                  <DialogClose asChild>\n                    <Button type=\"button\" variant=\"outline\" size=\"icon\">\n                      <ArrowLeft className=\"h-4 w-4\" />\n                    </Button>\n                  </DialogClose>\n                  <p className=\"text-lg font-semibold leading-tight\">\n                    {t(\"providerIcon.selectIcon\", {\n                      defaultValue: \"选择图标\",\n                    })}\n                  </p>\n                </div>\n              </div>\n              <div className=\"flex-1 overflow-y-auto\">\n                <div className=\"space-y-2 px-6 py-6 w-full\">\n                  <IconPicker\n                    value={currentIcon}\n                    onValueChange={handleIconSelect}\n                    color={effectiveIconColor}\n                  />\n                  <div className=\"flex justify-end gap-2\">\n                    <DialogClose asChild>\n                      <Button type=\"button\" variant=\"outline\">\n                        {t(\"common.done\", { defaultValue: \"完成\" })}\n                      </Button>\n                    </DialogClose>\n                  </div>\n                </div>\n              </div>\n            </div>\n          </DialogContent>\n        </Dialog>\n      </div>\n\n      {/* Slot for additional fields between icon and name */}\n      {beforeNameSlot}\n\n      {/* 基础信息 - 网格布局 */}\n      <div className=\"grid grid-cols-1 md:grid-cols-2 gap-4\">\n        <FormField\n          control={form.control}\n          name=\"name\"\n          render={({ field }) => (\n            <FormItem>\n              <FormLabel>{t(\"provider.name\")}</FormLabel>\n              <FormControl>\n                <Input {...field} placeholder={t(\"provider.namePlaceholder\")} />\n              </FormControl>\n              <FormMessage />\n            </FormItem>\n          )}\n        />\n\n        <FormField\n          control={form.control}\n          name=\"notes\"\n          render={({ field }) => (\n            <FormItem>\n              <FormLabel>{t(\"provider.notes\")}</FormLabel>\n              <FormControl>\n                <Input\n                  {...field}\n                  placeholder={t(\"provider.notesPlaceholder\")}\n                />\n              </FormControl>\n              <FormMessage />\n            </FormItem>\n          )}\n        />\n      </div>\n\n      <FormField\n        control={form.control}\n        name=\"websiteUrl\"\n        render={({ field }) => (\n          <FormItem>\n            <FormLabel>{t(\"provider.websiteUrl\")}</FormLabel>\n            <FormControl>\n              <Input\n                {...field}\n                placeholder={t(\"providerForm.websiteUrlPlaceholder\")}\n              />\n            </FormControl>\n            <FormMessage />\n          </FormItem>\n        )}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "src/components/providers/forms/ClaudeFormFields.tsx",
    "content": "import { useEffect, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Collapsible,\n  CollapsibleContent,\n  CollapsibleTrigger,\n} from \"@/components/ui/collapsible\";\nimport { toast } from \"sonner\";\nimport { FormLabel } from \"@/components/ui/form\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { ChevronDown, ChevronRight, Loader2 } from \"lucide-react\";\nimport EndpointSpeedTest from \"./EndpointSpeedTest\";\nimport { ApiKeySection, EndpointField } from \"./shared\";\nimport { CopilotAuthSection } from \"./CopilotAuthSection\";\nimport {\n  copilotGetModels,\n  copilotGetModelsForAccount,\n} from \"@/lib/api/copilot\";\nimport type { CopilotModel } from \"@/lib/api/copilot\";\nimport type {\n  ProviderCategory,\n  ClaudeApiFormat,\n  ClaudeApiKeyField,\n} from \"@/types\";\nimport type { TemplateValueConfig } from \"@/config/claudeProviderPresets\";\n\ninterface EndpointCandidate {\n  url: string;\n}\n\ninterface ClaudeFormFieldsProps {\n  providerId?: string;\n  // API Key\n  shouldShowApiKey: boolean;\n  apiKey: string;\n  onApiKeyChange: (key: string) => void;\n  category?: ProviderCategory;\n  shouldShowApiKeyLink: boolean;\n  websiteUrl: string;\n  isPartner?: boolean;\n  partnerPromotionKey?: string;\n\n  // GitHub Copilot OAuth\n  isCopilotPreset?: boolean;\n  usesOAuth?: boolean;\n  isCopilotAuthenticated?: boolean;\n  /** 当前选中的 GitHub 账号 ID（多账号支持） */\n  selectedGitHubAccountId?: string | null;\n  /** GitHub 账号选择回调（多账号支持） */\n  onGitHubAccountSelect?: (accountId: string | null) => void;\n\n  // Template Values\n  templateValueEntries: Array<[string, TemplateValueConfig]>;\n  templateValues: Record<string, TemplateValueConfig>;\n  templatePresetName: string;\n  onTemplateValueChange: (key: string, value: string) => void;\n\n  // Base URL\n  shouldShowSpeedTest: boolean;\n  baseUrl: string;\n  onBaseUrlChange: (url: string) => void;\n  isEndpointModalOpen: boolean;\n  onEndpointModalToggle: (open: boolean) => void;\n  onCustomEndpointsChange?: (endpoints: string[]) => void;\n  autoSelect: boolean;\n  onAutoSelectChange: (checked: boolean) => void;\n\n  // Model Selector\n  shouldShowModelSelector: boolean;\n  claudeModel: string;\n  reasoningModel: string;\n  defaultHaikuModel: string;\n  defaultSonnetModel: string;\n  defaultOpusModel: string;\n  onModelChange: (\n    field:\n      | \"ANTHROPIC_MODEL\"\n      | \"ANTHROPIC_REASONING_MODEL\"\n      | \"ANTHROPIC_DEFAULT_HAIKU_MODEL\"\n      | \"ANTHROPIC_DEFAULT_SONNET_MODEL\"\n      | \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n    value: string,\n  ) => void;\n\n  // Speed Test Endpoints\n  speedTestEndpoints: EndpointCandidate[];\n\n  // API Format (for third-party providers that use OpenAI Chat Completions format)\n  apiFormat: ClaudeApiFormat;\n  onApiFormatChange: (format: ClaudeApiFormat) => void;\n\n  // Auth Field (ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY)\n  apiKeyField: ClaudeApiKeyField;\n  onApiKeyFieldChange: (field: ClaudeApiKeyField) => void;\n}\n\nexport function ClaudeFormFields({\n  providerId,\n  shouldShowApiKey,\n  apiKey,\n  onApiKeyChange,\n  category,\n  shouldShowApiKeyLink,\n  websiteUrl,\n  isPartner,\n  partnerPromotionKey,\n  isCopilotPreset,\n  usesOAuth,\n  isCopilotAuthenticated,\n  selectedGitHubAccountId,\n  onGitHubAccountSelect,\n  templateValueEntries,\n  templateValues,\n  templatePresetName,\n  onTemplateValueChange,\n  shouldShowSpeedTest,\n  baseUrl,\n  onBaseUrlChange,\n  isEndpointModalOpen,\n  onEndpointModalToggle,\n  onCustomEndpointsChange,\n  autoSelect,\n  onAutoSelectChange,\n  shouldShowModelSelector,\n  claudeModel,\n  reasoningModel,\n  defaultHaikuModel,\n  defaultSonnetModel,\n  defaultOpusModel,\n  onModelChange,\n  speedTestEndpoints,\n  apiFormat,\n  onApiFormatChange,\n  apiKeyField,\n  onApiKeyFieldChange,\n}: ClaudeFormFieldsProps) {\n  const { t } = useTranslation();\n  const hasAnyAdvancedValue = !!(\n    claudeModel ||\n    reasoningModel ||\n    defaultHaikuModel ||\n    defaultSonnetModel ||\n    defaultOpusModel ||\n    apiFormat !== \"anthropic\" ||\n    apiKeyField !== \"ANTHROPIC_AUTH_TOKEN\"\n  );\n  const [advancedExpanded, setAdvancedExpanded] =\n    useState(hasAnyAdvancedValue);\n\n  // 预设填充高级值后自动展开（仅从折叠→展开，不会自动折叠）\n  useEffect(() => {\n    if (hasAnyAdvancedValue) {\n      setAdvancedExpanded(true);\n    }\n  }, [hasAnyAdvancedValue]);\n\n  // Copilot 可用模型列表\n  const [copilotModels, setCopilotModels] = useState<CopilotModel[]>([]);\n  const [modelsLoading, setModelsLoading] = useState(false);\n\n  // 当 Copilot 预设且已认证时，加载可用模型\n  useEffect(() => {\n    // 如果不是 Copilot 预设或未认证，清空模型列表\n    if (!isCopilotPreset || !isCopilotAuthenticated) {\n      setCopilotModels([]);\n      setModelsLoading(false);\n      return;\n    }\n\n    let cancelled = false;\n    setModelsLoading(true);\n    const fetchModels = selectedGitHubAccountId\n      ? copilotGetModelsForAccount(selectedGitHubAccountId)\n      : copilotGetModels();\n\n    fetchModels\n      .then((models) => {\n        if (!cancelled) setCopilotModels(models);\n      })\n      .catch((err) => {\n        console.warn(\"[Copilot] Failed to fetch models:\", err);\n        if (!cancelled) {\n          toast.error(\n            t(\"copilot.loadModelsFailed\", {\n              defaultValue: \"加载 Copilot 模型列表失败\",\n            }),\n          );\n        }\n      })\n      .finally(() => {\n        if (!cancelled) setModelsLoading(false);\n      });\n    return () => {\n      cancelled = true;\n    };\n  }, [isCopilotPreset, isCopilotAuthenticated, selectedGitHubAccountId]);\n\n  // 模型输入框：支持手动输入 + 下拉选择\n  const renderModelInput = (\n    id: string,\n    value: string,\n    field: ClaudeFormFieldsProps[\"onModelChange\"] extends (\n      f: infer F,\n      v: string,\n    ) => void\n      ? F\n      : never,\n    placeholder?: string,\n  ) => {\n    if (isCopilotPreset && copilotModels.length > 0) {\n      // 按 vendor 分组\n      const grouped: Record<string, CopilotModel[]> = {};\n      for (const model of copilotModels) {\n        const vendor = model.vendor || \"Other\";\n        if (!grouped[vendor]) grouped[vendor] = [];\n        grouped[vendor].push(model);\n      }\n      const vendors = Object.keys(grouped).sort();\n\n      return (\n        <div className=\"flex gap-1\">\n          <Input\n            id={id}\n            type=\"text\"\n            value={value}\n            onChange={(e) => onModelChange(field, e.target.value)}\n            placeholder={placeholder}\n            autoComplete=\"off\"\n            className=\"flex-1\"\n          />\n          <DropdownMenu>\n            <DropdownMenuTrigger asChild>\n              <Button variant=\"outline\" size=\"icon\" className=\"shrink-0\">\n                <ChevronDown className=\"h-4 w-4\" />\n              </Button>\n            </DropdownMenuTrigger>\n            <DropdownMenuContent\n              align=\"end\"\n              className=\"max-h-64 overflow-y-auto z-[200]\"\n            >\n              {vendors.map((vendor, vi) => (\n                <div key={vendor}>\n                  {vi > 0 && <DropdownMenuSeparator />}\n                  <DropdownMenuLabel>{vendor}</DropdownMenuLabel>\n                  {grouped[vendor].map((model) => (\n                    <DropdownMenuItem\n                      key={model.id}\n                      onSelect={() => onModelChange(field, model.id)}\n                    >\n                      {model.id}\n                    </DropdownMenuItem>\n                  ))}\n                </div>\n              ))}\n            </DropdownMenuContent>\n          </DropdownMenu>\n        </div>\n      );\n    }\n\n    if (isCopilotPreset && modelsLoading) {\n      return (\n        <div className=\"flex gap-1\">\n          <Input\n            id={id}\n            type=\"text\"\n            value={value}\n            onChange={(e) => onModelChange(field, e.target.value)}\n            placeholder={placeholder}\n            autoComplete=\"off\"\n            className=\"flex-1\"\n          />\n          <Button variant=\"outline\" size=\"icon\" className=\"shrink-0\" disabled>\n            <Loader2 className=\"h-4 w-4 animate-spin\" />\n          </Button>\n        </div>\n      );\n    }\n\n    return (\n      <Input\n        id={id}\n        type=\"text\"\n        value={value}\n        onChange={(e) => onModelChange(field, e.target.value)}\n        placeholder={placeholder}\n        autoComplete=\"off\"\n      />\n    );\n  };\n\n  return (\n    <>\n      {/* GitHub Copilot OAuth 认证 */}\n      {isCopilotPreset && (\n        <CopilotAuthSection\n          selectedAccountId={selectedGitHubAccountId}\n          onAccountSelect={onGitHubAccountSelect}\n        />\n      )}\n\n      {/* API Key 输入框（非 OAuth 预设时显示） */}\n      {shouldShowApiKey && !usesOAuth && (\n        <ApiKeySection\n          value={apiKey}\n          onChange={onApiKeyChange}\n          category={category}\n          shouldShowLink={shouldShowApiKeyLink}\n          websiteUrl={websiteUrl}\n          isPartner={isPartner}\n          partnerPromotionKey={partnerPromotionKey}\n        />\n      )}\n\n      {/* 模板变量输入 */}\n      {templateValueEntries.length > 0 && (\n        <div className=\"space-y-3\">\n          <FormLabel>\n            {t(\"providerForm.parameterConfig\", {\n              name: templatePresetName,\n              defaultValue: `${templatePresetName} 参数配置`,\n            })}\n          </FormLabel>\n          <div className=\"space-y-4\">\n            {templateValueEntries.map(([key, config]) => (\n              <div key={key} className=\"space-y-2\">\n                <FormLabel htmlFor={`template-${key}`}>\n                  {config.label}\n                </FormLabel>\n                <Input\n                  id={`template-${key}`}\n                  type=\"text\"\n                  required\n                  value={\n                    templateValues[key]?.editorValue ??\n                    config.editorValue ??\n                    config.defaultValue ??\n                    \"\"\n                  }\n                  onChange={(e) => onTemplateValueChange(key, e.target.value)}\n                  placeholder={config.placeholder || config.label}\n                  autoComplete=\"off\"\n                />\n              </div>\n            ))}\n          </div>\n        </div>\n      )}\n\n      {/* Base URL 输入框 */}\n      {shouldShowSpeedTest && (\n        <EndpointField\n          id=\"baseUrl\"\n          label={t(\"providerForm.apiEndpoint\")}\n          value={baseUrl}\n          onChange={onBaseUrlChange}\n          placeholder={t(\"providerForm.apiEndpointPlaceholder\")}\n          hint={\n            apiFormat === \"openai_responses\"\n              ? t(\"providerForm.apiHintResponses\")\n              : apiFormat === \"openai_chat\"\n                ? t(\"providerForm.apiHintOAI\")\n                : t(\"providerForm.apiHint\")\n          }\n          onManageClick={() => onEndpointModalToggle(true)}\n        />\n      )}\n\n      {/* 端点测速弹窗 */}\n      {shouldShowSpeedTest && isEndpointModalOpen && (\n        <EndpointSpeedTest\n          appId=\"claude\"\n          providerId={providerId}\n          value={baseUrl}\n          onChange={onBaseUrlChange}\n          initialEndpoints={speedTestEndpoints}\n          visible={isEndpointModalOpen}\n          onClose={() => onEndpointModalToggle(false)}\n          autoSelect={autoSelect}\n          onAutoSelectChange={onAutoSelectChange}\n          onCustomEndpointsChange={onCustomEndpointsChange}\n        />\n      )}\n\n      {/* 高级选项（API 格式 + 认证字段 + 模型映射） */}\n      {shouldShowModelSelector && (\n        <Collapsible\n          open={advancedExpanded}\n          onOpenChange={setAdvancedExpanded}\n        >\n          <CollapsibleTrigger asChild>\n            <Button\n              type=\"button\"\n              variant={null}\n              size=\"sm\"\n              className=\"h-8 gap-1.5 px-0 text-sm font-medium text-foreground hover:opacity-70\"\n            >\n              {advancedExpanded ? (\n                <ChevronDown className=\"h-4 w-4\" />\n              ) : (\n                <ChevronRight className=\"h-4 w-4\" />\n              )}\n              {t(\"providerForm.advancedOptionsToggle\")}\n            </Button>\n          </CollapsibleTrigger>\n          {!advancedExpanded && (\n            <p className=\"text-xs text-muted-foreground mt-1 ml-1\">\n              {t(\"providerForm.advancedOptionsHint\")}\n            </p>\n          )}\n          <CollapsibleContent className=\"space-y-4 pt-2\">\n            {/* API 格式选择（仅非云服务商显示） */}\n            {category !== \"cloud_provider\" && (\n              <div className=\"space-y-2\">\n                <FormLabel htmlFor=\"apiFormat\">\n                  {t(\"providerForm.apiFormat\", { defaultValue: \"API 格式\" })}\n                </FormLabel>\n                <Select value={apiFormat} onValueChange={onApiFormatChange}>\n                  <SelectTrigger id=\"apiFormat\" className=\"w-full\">\n                    <SelectValue />\n                  </SelectTrigger>\n                  <SelectContent>\n                    <SelectItem value=\"anthropic\">\n                      {t(\"providerForm.apiFormatAnthropic\", {\n                        defaultValue: \"Anthropic Messages (原生)\",\n                      })}\n                    </SelectItem>\n                    <SelectItem value=\"openai_chat\">\n                      {t(\"providerForm.apiFormatOpenAIChat\", {\n                        defaultValue: \"OpenAI Chat Completions (需转换)\",\n                      })}\n                    </SelectItem>\n                    <SelectItem value=\"openai_responses\">\n                      {t(\"providerForm.apiFormatOpenAIResponses\", {\n                        defaultValue: \"OpenAI Responses API (需转换)\",\n                      })}\n                    </SelectItem>\n                  </SelectContent>\n                </Select>\n                <p className=\"text-xs text-muted-foreground\">\n                  {t(\"providerForm.apiFormatHint\", {\n                    defaultValue: \"选择供应商 API 的输入格式\",\n                  })}\n                </p>\n              </div>\n            )}\n\n            {/* 认证字段选择器 */}\n            <div className=\"space-y-2\">\n              <FormLabel>\n                {t(\"providerForm.authField\", { defaultValue: \"认证字段\" })}\n              </FormLabel>\n              <Select\n                value={apiKeyField}\n                onValueChange={(v) =>\n                  onApiKeyFieldChange(v as ClaudeApiKeyField)\n                }\n              >\n                <SelectTrigger>\n                  <SelectValue />\n                </SelectTrigger>\n                <SelectContent>\n                  <SelectItem value=\"ANTHROPIC_AUTH_TOKEN\">\n                    {t(\"providerForm.authFieldAuthToken\", {\n                      defaultValue: \"ANTHROPIC_AUTH_TOKEN（默认）\",\n                    })}\n                  </SelectItem>\n                  <SelectItem value=\"ANTHROPIC_API_KEY\">\n                    {t(\"providerForm.authFieldApiKey\", {\n                      defaultValue: \"ANTHROPIC_API_KEY\",\n                    })}\n                  </SelectItem>\n                </SelectContent>\n              </Select>\n              <p className=\"text-xs text-muted-foreground\">\n                {t(\"providerForm.authFieldHint\", {\n                  defaultValue: \"选择写入配置的认证环境变量名\",\n                })}\n              </p>\n            </div>\n\n            {/* 模型映射 */}\n            <div className=\"space-y-1 pt-2 border-t\">\n              <FormLabel>{t(\"providerForm.modelMappingLabel\")}</FormLabel>\n              <p className=\"text-xs text-muted-foreground\">\n                {t(\"providerForm.modelMappingHint\")}\n              </p>\n            </div>\n            <div className=\"grid grid-cols-1 md:grid-cols-2 gap-4\">\n              {/* 主模型 */}\n              <div className=\"space-y-2\">\n                <FormLabel htmlFor=\"claudeModel\">\n                  {t(\"providerForm.anthropicModel\", {\n                    defaultValue: \"主模型\",\n                  })}\n                </FormLabel>\n                {renderModelInput(\n                  \"claudeModel\",\n                  claudeModel,\n                  \"ANTHROPIC_MODEL\",\n                  t(\"providerForm.modelPlaceholder\", { defaultValue: \"\" }),\n                )}\n              </div>\n\n              {/* 推理模型 */}\n              <div className=\"space-y-2\">\n                <FormLabel htmlFor=\"reasoningModel\">\n                  {t(\"providerForm.anthropicReasoningModel\")}\n                </FormLabel>\n                {renderModelInput(\n                  \"reasoningModel\",\n                  reasoningModel,\n                  \"ANTHROPIC_REASONING_MODEL\",\n                )}\n              </div>\n\n              {/* 默认 Haiku */}\n              <div className=\"space-y-2\">\n                <FormLabel htmlFor=\"claudeDefaultHaikuModel\">\n                  {t(\"providerForm.anthropicDefaultHaikuModel\", {\n                    defaultValue: \"Haiku 默认模型\",\n                  })}\n                </FormLabel>\n                {renderModelInput(\n                  \"claudeDefaultHaikuModel\",\n                  defaultHaikuModel,\n                  \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n                  t(\"providerForm.haikuModelPlaceholder\", { defaultValue: \"\" }),\n                )}\n              </div>\n\n              {/* 默认 Sonnet */}\n              <div className=\"space-y-2\">\n                <FormLabel htmlFor=\"claudeDefaultSonnetModel\">\n                  {t(\"providerForm.anthropicDefaultSonnetModel\", {\n                    defaultValue: \"Sonnet 默认模型\",\n                  })}\n                </FormLabel>\n                {renderModelInput(\n                  \"claudeDefaultSonnetModel\",\n                  defaultSonnetModel,\n                  \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n                  t(\"providerForm.modelPlaceholder\", { defaultValue: \"\" }),\n                )}\n              </div>\n\n              {/* 默认 Opus */}\n              <div className=\"space-y-2\">\n                <FormLabel htmlFor=\"claudeDefaultOpusModel\">\n                  {t(\"providerForm.anthropicDefaultOpusModel\", {\n                    defaultValue: \"Opus 默认模型\",\n                  })}\n                </FormLabel>\n                {renderModelInput(\n                  \"claudeDefaultOpusModel\",\n                  defaultOpusModel,\n                  \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n                  t(\"providerForm.modelPlaceholder\", { defaultValue: \"\" }),\n                )}\n              </div>\n            </div>\n          </CollapsibleContent>\n        </Collapsible>\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "src/components/providers/forms/CodexCommonConfigModal.tsx",
    "content": "import React, { useEffect, useState } from \"react\";\nimport { Save, Download, Loader2 } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\nimport { FullScreenPanel } from \"@/components/common/FullScreenPanel\";\nimport { Button } from \"@/components/ui/button\";\nimport JsonEditor from \"@/components/JsonEditor\";\n\ninterface CodexCommonConfigModalProps {\n  isOpen: boolean;\n  onClose: () => void;\n  value: string;\n  onSave: (value: string) => boolean;\n  error?: string;\n  onExtract?: () => void;\n  isExtracting?: boolean;\n}\n\n/**\n * CodexCommonConfigModal - Common Codex configuration editor modal\n * Allows editing of common TOML configuration shared across providers\n */\nexport const CodexCommonConfigModal: React.FC<CodexCommonConfigModalProps> = ({\n  isOpen,\n  onClose,\n  value,\n  onSave,\n  error,\n  onExtract,\n  isExtracting,\n}) => {\n  const { t } = useTranslation();\n  const [isDarkMode, setIsDarkMode] = useState(false);\n  const [draftValue, setDraftValue] = useState(value);\n\n  useEffect(() => {\n    setIsDarkMode(document.documentElement.classList.contains(\"dark\"));\n\n    const observer = new MutationObserver(() => {\n      setIsDarkMode(document.documentElement.classList.contains(\"dark\"));\n    });\n\n    observer.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n\n    return () => observer.disconnect();\n  }, []);\n\n  useEffect(() => {\n    if (isOpen) {\n      setDraftValue(value);\n    }\n  }, [isOpen, value]);\n\n  const handleClose = () => {\n    setDraftValue(value);\n    onClose();\n  };\n\n  const handleSave = () => {\n    if (onSave(draftValue)) {\n      onClose();\n    }\n  };\n\n  return (\n    <FullScreenPanel\n      isOpen={isOpen}\n      title={t(\"codexConfig.editCommonConfigTitle\")}\n      onClose={handleClose}\n      footer={\n        <>\n          {onExtract && (\n            <Button\n              type=\"button\"\n              variant=\"outline\"\n              onClick={onExtract}\n              disabled={isExtracting}\n              className=\"gap-2\"\n            >\n              {isExtracting ? (\n                <Loader2 className=\"w-4 h-4 animate-spin\" />\n              ) : (\n                <Download className=\"w-4 h-4\" />\n              )}\n              {t(\"codexConfig.extractFromCurrent\", {\n                defaultValue: \"从编辑内容提取\",\n              })}\n            </Button>\n          )}\n          <Button type=\"button\" variant=\"outline\" onClick={handleClose}>\n            {t(\"common.cancel\")}\n          </Button>\n          <Button type=\"button\" onClick={handleSave} className=\"gap-2\">\n            <Save className=\"w-4 h-4\" />\n            {t(\"common.save\")}\n          </Button>\n        </>\n      }\n    >\n      <div className=\"space-y-4\">\n        <p className=\"text-sm text-muted-foreground\">\n          {t(\"codexConfig.commonConfigHint\")}\n        </p>\n\n        <JsonEditor\n          value={draftValue}\n          onChange={setDraftValue}\n          placeholder={`# Common Codex config\n\n# Add your common TOML configuration here`}\n          darkMode={isDarkMode}\n          rows={16}\n          showValidation={false}\n          language=\"javascript\"\n        />\n\n        {error && (\n          <p className=\"text-sm text-red-500 dark:text-red-400\">{error}</p>\n        )}\n      </div>\n    </FullScreenPanel>\n  );\n};\n"
  },
  {
    "path": "src/components/providers/forms/CodexConfigEditor.tsx",
    "content": "import React, { useState } from \"react\";\nimport { CodexAuthSection, CodexConfigSection } from \"./CodexConfigSections\";\nimport { CodexCommonConfigModal } from \"./CodexCommonConfigModal\";\n\ninterface CodexConfigEditorProps {\n  authValue: string;\n\n  configValue: string;\n\n  onAuthChange: (value: string) => void;\n\n  onConfigChange: (value: string) => void;\n\n  onAuthBlur?: () => void;\n\n  useCommonConfig: boolean;\n\n  onCommonConfigToggle: (checked: boolean) => void;\n\n  commonConfigSnippet: string;\n\n  onCommonConfigSnippetChange: (value: string) => boolean;\n\n  onCommonConfigErrorClear: () => void;\n\n  commonConfigError: string;\n\n  authError: string;\n\n  configError: string; // config.toml 错误提示\n\n  onExtract?: () => void;\n\n  isExtracting?: boolean;\n}\n\nconst CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({\n  authValue,\n  configValue,\n  onAuthChange,\n  onConfigChange,\n  onAuthBlur,\n  useCommonConfig,\n  onCommonConfigToggle,\n  commonConfigSnippet,\n  onCommonConfigSnippetChange,\n  onCommonConfigErrorClear,\n  commonConfigError,\n  authError,\n  configError,\n  onExtract,\n  isExtracting,\n}) => {\n  const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);\n\n  const handleCloseCommonConfigModal = () => {\n    onCommonConfigErrorClear();\n    setIsCommonConfigModalOpen(false);\n  };\n\n  return (\n    <div className=\"space-y-6\">\n      {/* Auth JSON Section */}\n      <CodexAuthSection\n        value={authValue}\n        onChange={onAuthChange}\n        onBlur={onAuthBlur}\n        error={authError}\n      />\n\n      {/* Config TOML Section */}\n      <CodexConfigSection\n        value={configValue}\n        onChange={onConfigChange}\n        useCommonConfig={useCommonConfig}\n        onCommonConfigToggle={onCommonConfigToggle}\n        onEditCommonConfig={() => setIsCommonConfigModalOpen(true)}\n        commonConfigError={commonConfigError}\n        configError={configError}\n      />\n\n      {/* Common Config Modal */}\n      <CodexCommonConfigModal\n        isOpen={isCommonConfigModalOpen}\n        onClose={handleCloseCommonConfigModal}\n        value={commonConfigSnippet}\n        onSave={onCommonConfigSnippetChange}\n        error={commonConfigError}\n        onExtract={onExtract}\n        isExtracting={isExtracting}\n      />\n    </div>\n  );\n};\n\nexport default CodexConfigEditor;\n"
  },
  {
    "path": "src/components/providers/forms/CodexConfigSections.tsx",
    "content": "import React, { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport JsonEditor from \"@/components/JsonEditor\";\nimport {\n  extractCodexTopLevelInt,\n  setCodexTopLevelInt,\n  removeCodexTopLevelField,\n} from \"@/utils/providerConfigUtils\";\n\ninterface CodexAuthSectionProps {\n  value: string;\n  onChange: (value: string) => void;\n  onBlur?: () => void;\n  error?: string;\n}\n\n/**\n * CodexAuthSection - Auth JSON editor section\n */\nexport const CodexAuthSection: React.FC<CodexAuthSectionProps> = ({\n  value,\n  onChange,\n  onBlur,\n  error,\n}) => {\n  const { t } = useTranslation();\n  const [isDarkMode, setIsDarkMode] = useState(false);\n\n  useEffect(() => {\n    setIsDarkMode(document.documentElement.classList.contains(\"dark\"));\n\n    const observer = new MutationObserver(() => {\n      setIsDarkMode(document.documentElement.classList.contains(\"dark\"));\n    });\n\n    observer.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n\n    return () => observer.disconnect();\n  }, []);\n\n  const handleChange = (newValue: string) => {\n    onChange(newValue);\n    if (onBlur) {\n      onBlur();\n    }\n  };\n\n  return (\n    <div className=\"space-y-2\">\n      <label\n        htmlFor=\"codexAuth\"\n        className=\"block text-sm font-medium text-foreground\"\n      >\n        {t(\"codexConfig.authJson\")}\n      </label>\n\n      <JsonEditor\n        value={value}\n        onChange={handleChange}\n        placeholder={t(\"codexConfig.authJsonPlaceholder\")}\n        darkMode={isDarkMode}\n        rows={6}\n        showValidation={true}\n        language=\"json\"\n      />\n\n      {error && (\n        <p className=\"text-xs text-red-500 dark:text-red-400\">{error}</p>\n      )}\n\n      {!error && (\n        <p className=\"text-xs text-muted-foreground\">\n          {t(\"codexConfig.authJsonHint\")}\n        </p>\n      )}\n    </div>\n  );\n};\n\ninterface CodexConfigSectionProps {\n  value: string;\n  onChange: (value: string) => void;\n  useCommonConfig: boolean;\n  onCommonConfigToggle: (checked: boolean) => void;\n  onEditCommonConfig: () => void;\n  commonConfigError?: string;\n  configError?: string;\n}\n\n/**\n * CodexConfigSection - Config TOML editor section\n */\nexport const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({\n  value,\n  onChange,\n  useCommonConfig,\n  onCommonConfigToggle,\n  onEditCommonConfig,\n  commonConfigError,\n  configError,\n}) => {\n  const { t } = useTranslation();\n  const [isDarkMode, setIsDarkMode] = useState(false);\n\n  useEffect(() => {\n    setIsDarkMode(document.documentElement.classList.contains(\"dark\"));\n\n    const observer = new MutationObserver(() => {\n      setIsDarkMode(document.documentElement.classList.contains(\"dark\"));\n    });\n\n    observer.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n\n    return () => observer.disconnect();\n  }, []);\n\n  // Mirror value prop to local state (same pattern as CommonConfigEditor)\n  const [localValue, setLocalValue] = useState(value);\n  const localValueRef = useRef(value);\n  useEffect(() => {\n    setLocalValue(value);\n    localValueRef.current = value;\n  }, [value]);\n\n  const handleLocalChange = useCallback(\n    (newValue: string) => {\n      if (newValue === localValueRef.current) return;\n      localValueRef.current = newValue;\n      setLocalValue(newValue);\n      onChange(newValue);\n    },\n    [onChange],\n  );\n\n  // Parse toggle states from TOML text\n  const toggleStates = useMemo(() => {\n    const contextWindow = extractCodexTopLevelInt(\n      localValue,\n      \"model_context_window\",\n    );\n    const compactLimit = extractCodexTopLevelInt(\n      localValue,\n      \"model_auto_compact_token_limit\",\n    );\n    return {\n      contextWindow1M: contextWindow === 1000000,\n      compactLimit: compactLimit ?? 900000,\n    };\n  }, [localValue]);\n\n  // Debounce timer for compact limit input\n  const compactTimerRef = useRef<ReturnType<typeof setTimeout>>();\n\n  const handleContextWindowToggle = useCallback(\n    (checked: boolean) => {\n      let toml = localValueRef.current || \"\";\n      if (checked) {\n        toml = setCodexTopLevelInt(toml, \"model_context_window\", 1000000);\n        // Auto-set compact limit if not already present\n        if (\n          extractCodexTopLevelInt(toml, \"model_auto_compact_token_limit\") ===\n          undefined\n        ) {\n          toml = setCodexTopLevelInt(\n            toml,\n            \"model_auto_compact_token_limit\",\n            900000,\n          );\n        }\n      } else {\n        toml = removeCodexTopLevelField(toml, \"model_context_window\");\n        toml = removeCodexTopLevelField(\n          toml,\n          \"model_auto_compact_token_limit\",\n        );\n      }\n      handleLocalChange(toml);\n    },\n    [handleLocalChange],\n  );\n\n  const handleCompactLimitChange = useCallback(\n    (inputValue: string) => {\n      clearTimeout(compactTimerRef.current);\n      compactTimerRef.current = setTimeout(() => {\n        const num = parseInt(inputValue, 10);\n        if (!Number.isNaN(num) && num > 0) {\n          handleLocalChange(\n            setCodexTopLevelInt(\n              localValueRef.current || \"\",\n              \"model_auto_compact_token_limit\",\n              num,\n            ),\n          );\n        }\n      }, 500);\n    },\n    [handleLocalChange],\n  );\n\n  // Cleanup debounce timer\n  useEffect(() => {\n    return () => clearTimeout(compactTimerRef.current);\n  }, []);\n\n  return (\n    <div className=\"space-y-2\">\n      <div className=\"flex items-center justify-between\">\n        <label\n          htmlFor=\"codexConfig\"\n          className=\"block text-sm font-medium text-foreground\"\n        >\n          {t(\"codexConfig.configToml\")}\n        </label>\n\n        <label className=\"inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer\">\n          <input\n            type=\"checkbox\"\n            checked={useCommonConfig}\n            onChange={(e) => onCommonConfigToggle(e.target.checked)}\n            className=\"w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default  rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2\"\n          />\n          {t(\"codexConfig.writeCommonConfig\")}\n        </label>\n      </div>\n\n      <div className=\"flex items-center justify-end\">\n        <button\n          type=\"button\"\n          onClick={onEditCommonConfig}\n          className=\"text-xs text-blue-500 dark:text-blue-400 hover:underline\"\n        >\n          {t(\"codexConfig.editCommonConfig\")}\n        </button>\n      </div>\n\n      {commonConfigError && (\n        <p className=\"text-xs text-red-500 dark:text-red-400 text-right\">\n          {commonConfigError}\n        </p>\n      )}\n\n      <div className=\"flex flex-wrap items-center gap-x-4 gap-y-1\">\n        <label className=\"inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer\">\n          <input\n            type=\"checkbox\"\n            checked={toggleStates.contextWindow1M}\n            onChange={(e) => handleContextWindowToggle(e.target.checked)}\n            className=\"w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2\"\n          />\n          <span>{t(\"codexConfig.contextWindow1M\")}</span>\n        </label>\n        <label className=\"inline-flex items-center gap-2 text-sm text-muted-foreground\">\n          <span>{t(\"codexConfig.autoCompactLimit\")}:</span>\n          <input\n            type=\"text\"\n            inputMode=\"numeric\"\n            pattern=\"[0-9]*\"\n            key={toggleStates.compactLimit}\n            defaultValue={toggleStates.compactLimit}\n            disabled={!toggleStates.contextWindow1M}\n            onChange={(e) => handleCompactLimitChange(e.target.value)}\n            className=\"w-28 h-7 px-2 text-sm rounded border border-border bg-background text-foreground disabled:opacity-50 disabled:cursor-not-allowed\"\n          />\n        </label>\n      </div>\n\n      <JsonEditor\n        value={localValue}\n        onChange={handleLocalChange}\n        placeholder=\"\"\n        darkMode={isDarkMode}\n        rows={8}\n        showValidation={false}\n        language=\"javascript\"\n      />\n\n      {configError && (\n        <p className=\"text-xs text-red-500 dark:text-red-400\">{configError}</p>\n      )}\n\n      {!configError && (\n        <p className=\"text-xs text-muted-foreground\">\n          {t(\"codexConfig.configTomlHint\")}\n        </p>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "src/components/providers/forms/CodexFormFields.tsx",
    "content": "import { useTranslation } from \"react-i18next\";\nimport EndpointSpeedTest from \"./EndpointSpeedTest\";\nimport { ApiKeySection, EndpointField } from \"./shared\";\nimport type { ProviderCategory } from \"@/types\";\n\ninterface EndpointCandidate {\n  url: string;\n}\n\ninterface CodexFormFieldsProps {\n  providerId?: string;\n  // API Key\n  codexApiKey: string;\n  onApiKeyChange: (key: string) => void;\n  category?: ProviderCategory;\n  shouldShowApiKeyLink: boolean;\n  websiteUrl: string;\n  isPartner?: boolean;\n  partnerPromotionKey?: string;\n\n  // Base URL\n  shouldShowSpeedTest: boolean;\n  codexBaseUrl: string;\n  onBaseUrlChange: (url: string) => void;\n  isEndpointModalOpen: boolean;\n  onEndpointModalToggle: (open: boolean) => void;\n  onCustomEndpointsChange?: (endpoints: string[]) => void;\n  autoSelect: boolean;\n  onAutoSelectChange: (checked: boolean) => void;\n\n  // Model Name\n  shouldShowModelField?: boolean;\n  modelName?: string;\n  onModelNameChange?: (model: string) => void;\n\n  // Speed Test Endpoints\n  speedTestEndpoints: EndpointCandidate[];\n}\n\nexport function CodexFormFields({\n  providerId,\n  codexApiKey,\n  onApiKeyChange,\n  category,\n  shouldShowApiKeyLink,\n  websiteUrl,\n  isPartner,\n  partnerPromotionKey,\n  shouldShowSpeedTest,\n  codexBaseUrl,\n  onBaseUrlChange,\n  isEndpointModalOpen,\n  onEndpointModalToggle,\n  onCustomEndpointsChange,\n  autoSelect,\n  onAutoSelectChange,\n  shouldShowModelField = true,\n  modelName = \"\",\n  onModelNameChange,\n  speedTestEndpoints,\n}: CodexFormFieldsProps) {\n  const { t } = useTranslation();\n\n  return (\n    <>\n      {/* Codex API Key 输入框 */}\n      <ApiKeySection\n        id=\"codexApiKey\"\n        label=\"API Key\"\n        value={codexApiKey}\n        onChange={onApiKeyChange}\n        category={category}\n        shouldShowLink={shouldShowApiKeyLink}\n        websiteUrl={websiteUrl}\n        isPartner={isPartner}\n        partnerPromotionKey={partnerPromotionKey}\n        placeholder={{\n          official: t(\"providerForm.codexOfficialNoApiKey\", {\n            defaultValue: \"官方供应商无需 API Key\",\n          }),\n          thirdParty: t(\"providerForm.codexApiKeyAutoFill\", {\n            defaultValue: \"输入 API Key，将自动填充到配置\",\n          }),\n        }}\n      />\n\n      {/* Codex Base URL 输入框 */}\n      {shouldShowSpeedTest && (\n        <EndpointField\n          id=\"codexBaseUrl\"\n          label={t(\"codexConfig.apiUrlLabel\")}\n          value={codexBaseUrl}\n          onChange={onBaseUrlChange}\n          placeholder={t(\"providerForm.codexApiEndpointPlaceholder\")}\n          hint={t(\"providerForm.codexApiHint\")}\n          onManageClick={() => onEndpointModalToggle(true)}\n        />\n      )}\n\n      {/* Codex Model Name 输入框 */}\n      {shouldShowModelField && onModelNameChange && (\n        <div className=\"space-y-2\">\n          <label\n            htmlFor=\"codexModelName\"\n            className=\"block text-sm font-medium text-foreground\"\n          >\n            {t(\"codexConfig.modelName\", { defaultValue: \"模型名称\" })}\n          </label>\n          <input\n            id=\"codexModelName\"\n            type=\"text\"\n            value={modelName}\n            onChange={(e) => onModelNameChange(e.target.value)}\n            placeholder={t(\"codexConfig.modelNamePlaceholder\", {\n              defaultValue: \"例如: gpt-5.4\",\n            })}\n            className=\"w-full px-3 py-2 border border-border-default bg-background text-foreground rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors\"\n          />\n          <p className=\"text-xs text-muted-foreground\">\n            {modelName.trim()\n              ? t(\"codexConfig.modelNameHint\", {\n                  defaultValue: \"指定使用的模型，将自动更新到 config.toml 中\",\n                })\n              : t(\"providerForm.modelHint\", {\n                  defaultValue: \"💡 留空将使用供应商的默认模型\",\n                })}\n          </p>\n        </div>\n      )}\n\n      {/* 端点测速弹窗 - Codex */}\n      {shouldShowSpeedTest && isEndpointModalOpen && (\n        <EndpointSpeedTest\n          appId=\"codex\"\n          providerId={providerId}\n          value={codexBaseUrl}\n          onChange={onBaseUrlChange}\n          initialEndpoints={speedTestEndpoints}\n          visible={isEndpointModalOpen}\n          onClose={() => onEndpointModalToggle(false)}\n          autoSelect={autoSelect}\n          onAutoSelectChange={onAutoSelectChange}\n          onCustomEndpointsChange={onCustomEndpointsChange}\n        />\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "src/components/providers/forms/CommonConfigEditor.tsx",
    "content": "import { useTranslation } from \"react-i18next\";\nimport { useEffect, useState, useCallback, useMemo } from \"react\";\nimport { FullScreenPanel } from \"@/components/common/FullScreenPanel\";\nimport { Label } from \"@/components/ui/label\";\nimport { Button } from \"@/components/ui/button\";\nimport { Save, Download, Loader2 } from \"lucide-react\";\nimport JsonEditor from \"@/components/JsonEditor\";\n\ninterface CommonConfigEditorProps {\n  value: string;\n  onChange: (value: string) => void;\n  useCommonConfig: boolean;\n  onCommonConfigToggle: (checked: boolean) => void;\n  commonConfigSnippet: string;\n  onCommonConfigSnippetChange: (value: string) => void;\n  commonConfigError: string;\n  onEditClick: () => void;\n  isModalOpen: boolean;\n  onModalClose: () => void;\n  onExtract?: () => void;\n  isExtracting?: boolean;\n}\n\nexport function CommonConfigEditor({\n  value,\n  onChange,\n  useCommonConfig,\n  onCommonConfigToggle,\n  commonConfigSnippet,\n  onCommonConfigSnippetChange,\n  commonConfigError,\n  onEditClick,\n  isModalOpen,\n  onModalClose,\n  onExtract,\n  isExtracting,\n}: CommonConfigEditorProps) {\n  const { t } = useTranslation();\n  const [isDarkMode, setIsDarkMode] = useState(false);\n\n  useEffect(() => {\n    setIsDarkMode(document.documentElement.classList.contains(\"dark\"));\n\n    const observer = new MutationObserver(() => {\n      setIsDarkMode(document.documentElement.classList.contains(\"dark\"));\n    });\n\n    observer.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n\n    return () => observer.disconnect();\n  }, []);\n\n  // Mirror value prop to local state so checkbox toggles and JsonEditor stay in sync\n  // (parent uses form.getValues which doesn't trigger re-renders)\n  const [localValue, setLocalValue] = useState(value);\n\n  useEffect(() => {\n    setLocalValue(value);\n  }, [value]);\n\n  const handleLocalChange = useCallback(\n    (newValue: string) => {\n      setLocalValue(newValue);\n      onChange(newValue);\n    },\n    [onChange],\n  );\n\n  const toggleStates = useMemo(() => {\n    try {\n      const config = JSON.parse(localValue);\n      return {\n        hideAttribution:\n          config?.attribution?.commit === \"\" && config?.attribution?.pr === \"\",\n        teammates:\n          config?.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === \"1\" ||\n          config?.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === 1,\n        enableToolSearch:\n          config?.env?.ENABLE_TOOL_SEARCH === \"true\" ||\n          config?.env?.ENABLE_TOOL_SEARCH === \"1\",\n        effortHigh: config?.effortLevel === \"high\",\n      };\n    } catch {\n      return {\n        hideAttribution: false,\n        teammates: false,\n        enableToolSearch: false,\n        effortHigh: false,\n      };\n    }\n  }, [localValue]);\n\n  const handleToggle = useCallback(\n    (toggleKey: string, checked: boolean) => {\n      try {\n        const config = JSON.parse(localValue || \"{}\");\n\n        switch (toggleKey) {\n          case \"hideAttribution\":\n            if (checked) {\n              config.attribution = { commit: \"\", pr: \"\" };\n            } else {\n              delete config.attribution;\n            }\n            break;\n          case \"teammates\":\n            if (!config.env) config.env = {};\n            if (checked) {\n              config.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = \"1\";\n            } else {\n              delete config.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS;\n              if (Object.keys(config.env).length === 0) delete config.env;\n            }\n            break;\n          case \"enableToolSearch\":\n            if (!config.env) config.env = {};\n            if (checked) {\n              config.env.ENABLE_TOOL_SEARCH = \"true\";\n            } else {\n              delete config.env.ENABLE_TOOL_SEARCH;\n              if (Object.keys(config.env).length === 0) delete config.env;\n            }\n            break;\n          case \"effortHigh\":\n            if (checked) {\n              config.effortLevel = \"high\";\n            } else {\n              delete config.effortLevel;\n            }\n            break;\n        }\n\n        handleLocalChange(JSON.stringify(config, null, 2));\n      } catch {\n        // Don't modify if JSON is invalid\n      }\n    },\n    [localValue, handleLocalChange],\n  );\n\n  return (\n    <>\n      <div className=\"space-y-2\">\n        <div className=\"flex items-center justify-between\">\n          <Label htmlFor=\"settingsConfig\">{t(\"provider.configJson\")}</Label>\n          <div className=\"flex items-center gap-2\">\n            <label className=\"inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer\">\n              <input\n                type=\"checkbox\"\n                id=\"useCommonConfig\"\n                checked={useCommonConfig}\n                onChange={(e) => onCommonConfigToggle(e.target.checked)}\n                className=\"w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2\"\n              />\n              <span>\n                {t(\"claudeConfig.writeCommonConfig\", {\n                  defaultValue: \"写入通用配置\",\n                })}\n              </span>\n            </label>\n          </div>\n        </div>\n        <div className=\"flex items-center justify-end\">\n          <button\n            type=\"button\"\n            onClick={onEditClick}\n            className=\"text-xs text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors\"\n          >\n            {t(\"claudeConfig.editCommonConfig\", {\n              defaultValue: \"编辑通用配置\",\n            })}\n          </button>\n        </div>\n        {commonConfigError && !isModalOpen && (\n          <p className=\"text-xs text-red-500 dark:text-red-400 text-right\">\n            {commonConfigError}\n          </p>\n        )}\n        <div className=\"flex flex-wrap items-center gap-x-4 gap-y-1\">\n          <label className=\"inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer\">\n            <input\n              type=\"checkbox\"\n              checked={toggleStates.hideAttribution}\n              onChange={(e) =>\n                handleToggle(\"hideAttribution\", e.target.checked)\n              }\n              className=\"w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2\"\n            />\n            <span>{t(\"claudeConfig.hideAttribution\")}</span>\n          </label>\n          <label className=\"inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer\">\n            <input\n              type=\"checkbox\"\n              checked={toggleStates.teammates}\n              onChange={(e) => handleToggle(\"teammates\", e.target.checked)}\n              className=\"w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2\"\n            />\n            <span>{t(\"claudeConfig.enableTeammates\")}</span>\n          </label>\n          <label className=\"inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer\">\n            <input\n              type=\"checkbox\"\n              checked={toggleStates.enableToolSearch}\n              onChange={(e) =>\n                handleToggle(\"enableToolSearch\", e.target.checked)\n              }\n              className=\"w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2\"\n            />\n            <span>{t(\"claudeConfig.enableToolSearch\")}</span>\n          </label>\n          <label className=\"inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer\">\n            <input\n              type=\"checkbox\"\n              checked={toggleStates.effortHigh}\n              onChange={(e) => handleToggle(\"effortHigh\", e.target.checked)}\n              className=\"w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2\"\n            />\n            <span>{t(\"claudeConfig.effortHigh\")}</span>\n          </label>\n        </div>\n        <JsonEditor\n          value={localValue}\n          onChange={handleLocalChange}\n          placeholder={`{\n  \"env\": {\n    \"ANTHROPIC_BASE_URL\": \"https://your-api-endpoint.com\",\n    \"ANTHROPIC_AUTH_TOKEN\": \"your-api-key-here\"\n  }\n}`}\n          darkMode={isDarkMode}\n          rows={14}\n          showValidation={true}\n          language=\"json\"\n        />\n      </div>\n\n      <FullScreenPanel\n        isOpen={isModalOpen}\n        title={t(\"claudeConfig.editCommonConfigTitle\", {\n          defaultValue: \"编辑通用配置片段\",\n        })}\n        onClose={onModalClose}\n        footer={\n          <>\n            {onExtract && (\n              <Button\n                type=\"button\"\n                variant=\"outline\"\n                onClick={onExtract}\n                disabled={isExtracting}\n                className=\"gap-2\"\n              >\n                {isExtracting ? (\n                  <Loader2 className=\"w-4 h-4 animate-spin\" />\n                ) : (\n                  <Download className=\"w-4 h-4\" />\n                )}\n                {t(\"claudeConfig.extractFromCurrent\", {\n                  defaultValue: \"从编辑内容提取\",\n                })}\n              </Button>\n            )}\n            <Button type=\"button\" variant=\"outline\" onClick={onModalClose}>\n              {t(\"common.cancel\")}\n            </Button>\n            <Button type=\"button\" onClick={onModalClose} className=\"gap-2\">\n              <Save className=\"w-4 h-4\" />\n              {t(\"common.save\")}\n            </Button>\n          </>\n        }\n      >\n        <div className=\"space-y-4\">\n          <p className=\"text-sm text-muted-foreground\">\n            {t(\"claudeConfig.commonConfigHint\", {\n              defaultValue: \"通用配置片段将合并到所有启用它的供应商配置中\",\n            })}\n          </p>\n          <JsonEditor\n            value={commonConfigSnippet}\n            onChange={onCommonConfigSnippetChange}\n            placeholder={`{\n  \"env\": {\n    \"ANTHROPIC_BASE_URL\": \"https://your-api-endpoint.com\"\n  }\n}`}\n            darkMode={isDarkMode}\n            rows={16}\n            showValidation={true}\n            language=\"json\"\n          />\n          {commonConfigError && (\n            <p className=\"text-sm text-red-500 dark:text-red-400\">\n              {commonConfigError}\n            </p>\n          )}\n        </div>\n      </FullScreenPanel>\n    </>\n  );\n}\n"
  },
  {
    "path": "src/components/providers/forms/CopilotAuthSection.tsx",
    "content": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport {\n  Loader2,\n  Github,\n  LogOut,\n  Copy,\n  Check,\n  ExternalLink,\n  Plus,\n  X,\n  User,\n} from \"lucide-react\";\nimport { useCopilotAuth } from \"./hooks/useCopilotAuth\";\nimport type { GitHubAccount } from \"@/lib/api\";\n\ninterface CopilotAuthSectionProps {\n  className?: string;\n  /** 当前选中的 GitHub 账号 ID */\n  selectedAccountId?: string | null;\n  /** 账号选择回调 */\n  onAccountSelect?: (accountId: string | null) => void;\n}\n\n/**\n * Copilot OAuth 认证区块\n *\n * 显示 GitHub Copilot 的认证状态，支持多账号管理和选择。\n */\nexport const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({\n  className,\n  selectedAccountId,\n  onAccountSelect,\n}) => {\n  const { t } = useTranslation();\n  const [copied, setCopied] = React.useState(false);\n\n  const {\n    accounts,\n    defaultAccountId,\n    migrationError,\n    hasAnyAccount,\n    pollingState,\n    deviceCode,\n    error,\n    isPolling,\n    isAddingAccount,\n    isRemovingAccount,\n    isSettingDefaultAccount,\n    addAccount,\n    removeAccount,\n    setDefaultAccount,\n    cancelAuth,\n    logout,\n  } = useCopilotAuth();\n\n  // 复制用户码\n  const copyUserCode = async () => {\n    if (deviceCode?.user_code) {\n      await navigator.clipboard.writeText(deviceCode.user_code);\n      setCopied(true);\n      setTimeout(() => setCopied(false), 2000);\n    }\n  };\n\n  // 处理账号选择\n  const handleAccountSelect = (value: string) => {\n    onAccountSelect?.(value === \"none\" ? null : value);\n  };\n\n  // 处理移除账号\n  const handleRemoveAccount = (accountId: string, e: React.MouseEvent) => {\n    e.stopPropagation();\n    e.preventDefault();\n    removeAccount(accountId);\n    // 如果移除的是当前选中的账号，清除选择\n    if (selectedAccountId === accountId) {\n      onAccountSelect?.(null);\n    }\n  };\n\n  // 渲染账号头像\n  const renderAvatar = (account: GitHubAccount) => {\n    return <CopilotAccountAvatar account={account} />;\n  };\n\n  return (\n    <div className={`space-y-4 ${className || \"\"}`}>\n      {/* 认证状态标题 */}\n      <div className=\"flex items-center justify-between\">\n        <Label>{t(\"copilot.authStatus\", \"GitHub Copilot 认证\")}</Label>\n        <Badge\n          variant={hasAnyAccount ? \"default\" : \"secondary\"}\n          className={hasAnyAccount ? \"bg-green-500 hover:bg-green-600\" : \"\"}\n        >\n          {hasAnyAccount\n            ? t(\"copilot.accountCount\", {\n                count: accounts.length,\n                defaultValue: `${accounts.length} 个账号`,\n              })\n            : t(\"copilot.notAuthenticated\", \"未认证\")}\n        </Badge>\n      </div>\n\n      {migrationError && (\n        <p className=\"text-sm text-amber-600 dark:text-amber-400\">\n          {t(\"copilot.migrationFailed\", {\n            error: migrationError,\n            defaultValue: `旧认证数据迁移失败：${migrationError}`,\n          })}\n        </p>\n      )}\n\n      {/* 账号选择器（有账号时显示） */}\n      {hasAnyAccount && onAccountSelect && (\n        <div className=\"space-y-2\">\n          <Label className=\"text-sm text-muted-foreground\">\n            {t(\"copilot.selectAccount\", \"选择账号\")}\n          </Label>\n          <Select\n            value={selectedAccountId || \"none\"}\n            onValueChange={handleAccountSelect}\n          >\n            <SelectTrigger>\n              <SelectValue\n                placeholder={t(\n                  \"copilot.selectAccountPlaceholder\",\n                  \"选择一个 GitHub 账号\",\n                )}\n              />\n            </SelectTrigger>\n            <SelectContent>\n              <SelectItem value=\"none\">\n                <span className=\"text-muted-foreground\">\n                  {t(\"copilot.useDefaultAccount\", \"使用默认账号\")}\n                </span>\n              </SelectItem>\n              {accounts.map((account) => (\n                <SelectItem key={account.id} value={account.id}>\n                  <div className=\"flex items-center gap-2\">\n                    {renderAvatar(account)}\n                    <span>{account.login}</span>\n                  </div>\n                </SelectItem>\n              ))}\n            </SelectContent>\n          </Select>\n        </div>\n      )}\n\n      {/* 已登录账号列表 */}\n      {hasAnyAccount && (\n        <div className=\"space-y-2\">\n          <Label className=\"text-sm text-muted-foreground\">\n            {t(\"copilot.loggedInAccounts\", \"已登录账号\")}\n          </Label>\n          <div className=\"space-y-1\">\n            {accounts.map((account) => (\n              <div\n                key={account.id}\n                className=\"flex items-center justify-between p-2 rounded-md border bg-muted/30\"\n              >\n                <div className=\"flex items-center gap-2\">\n                  {renderAvatar(account)}\n                  <span className=\"text-sm font-medium\">{account.login}</span>\n                  {defaultAccountId === account.id && (\n                    <Badge variant=\"secondary\" className=\"text-xs\">\n                      {t(\"copilot.defaultAccount\", \"默认\")}\n                    </Badge>\n                  )}\n                  {selectedAccountId === account.id && (\n                    <Badge variant=\"outline\" className=\"text-xs\">\n                      {t(\"copilot.selected\", \"已选中\")}\n                    </Badge>\n                  )}\n                </div>\n                <div className=\"flex items-center gap-1\">\n                  {defaultAccountId !== account.id && (\n                    <Button\n                      type=\"button\"\n                      variant=\"ghost\"\n                      size=\"sm\"\n                      className=\"h-7 px-2 text-xs text-muted-foreground\"\n                      onClick={() => setDefaultAccount(account.id)}\n                      disabled={isSettingDefaultAccount}\n                    >\n                      {t(\"copilot.setAsDefault\", \"设为默认\")}\n                    </Button>\n                  )}\n                  <Button\n                    type=\"button\"\n                    variant=\"ghost\"\n                    size=\"icon\"\n                    className=\"h-7 w-7 text-muted-foreground hover:text-red-500\"\n                    onClick={(e) => handleRemoveAccount(account.id, e)}\n                    disabled={isRemovingAccount}\n                    title={t(\"copilot.removeAccount\", \"移除账号\")}\n                  >\n                    <X className=\"h-4 w-4\" />\n                  </Button>\n                </div>\n              </div>\n            ))}\n          </div>\n        </div>\n      )}\n\n      {/* 未认证状态 - 登录按钮 */}\n      {!hasAnyAccount && pollingState === \"idle\" && (\n        <Button\n          type=\"button\"\n          onClick={addAccount}\n          className=\"w-full\"\n          variant=\"outline\"\n        >\n          <Github className=\"mr-2 h-4 w-4\" />\n          {t(\"copilot.loginWithGitHub\", \"使用 GitHub 登录\")}\n        </Button>\n      )}\n\n      {/* 已有账号 - 添加更多账号按钮 */}\n      {hasAnyAccount && pollingState === \"idle\" && (\n        <Button\n          type=\"button\"\n          onClick={addAccount}\n          className=\"w-full\"\n          variant=\"outline\"\n          disabled={isAddingAccount}\n        >\n          <Plus className=\"mr-2 h-4 w-4\" />\n          {t(\"copilot.addAnotherAccount\", \"添加其他账号\")}\n        </Button>\n      )}\n\n      {/* 轮询中状态 */}\n      {isPolling && deviceCode && (\n        <div className=\"space-y-3 p-4 rounded-lg border border-border bg-muted/50\">\n          <div className=\"flex items-center justify-center gap-2 text-sm text-muted-foreground\">\n            <Loader2 className=\"h-4 w-4 animate-spin\" />\n            {t(\"copilot.waitingForAuth\", \"等待授权中...\")}\n          </div>\n\n          {/* 用户码 */}\n          <div className=\"text-center\">\n            <p className=\"text-xs text-muted-foreground mb-1\">\n              {t(\"copilot.enterCode\", \"在浏览器中输入以下代码：\")}\n            </p>\n            <div className=\"flex items-center justify-center gap-2\">\n              <code className=\"text-2xl font-mono font-bold tracking-wider bg-background px-4 py-2 rounded border\">\n                {deviceCode.user_code}\n              </code>\n              <Button\n                type=\"button\"\n                size=\"icon\"\n                variant=\"ghost\"\n                onClick={copyUserCode}\n                title={t(\"copilot.copyCode\", \"复制代码\")}\n              >\n                {copied ? (\n                  <Check className=\"h-4 w-4 text-green-500\" />\n                ) : (\n                  <Copy className=\"h-4 w-4\" />\n                )}\n              </Button>\n            </div>\n          </div>\n\n          {/* 验证链接 */}\n          <div className=\"text-center\">\n            <a\n              href={deviceCode.verification_uri}\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"inline-flex items-center gap-1 text-sm text-blue-500 hover:underline\"\n            >\n              {deviceCode.verification_uri}\n              <ExternalLink className=\"h-3 w-3\" />\n            </a>\n          </div>\n\n          {/* 取消按钮 */}\n          <div className=\"text-center\">\n            <Button\n              type=\"button\"\n              variant=\"ghost\"\n              size=\"sm\"\n              onClick={cancelAuth}\n            >\n              {t(\"common.cancel\", \"取消\")}\n            </Button>\n          </div>\n        </div>\n      )}\n\n      {/* 错误状态 */}\n      {pollingState === \"error\" && error && (\n        <div className=\"space-y-2\">\n          <p className=\"text-sm text-red-500\">{error}</p>\n          <div className=\"flex gap-2\">\n            <Button\n              type=\"button\"\n              onClick={addAccount}\n              variant=\"outline\"\n              size=\"sm\"\n            >\n              {t(\"copilot.retry\", \"重试\")}\n            </Button>\n            <Button\n              type=\"button\"\n              onClick={cancelAuth}\n              variant=\"ghost\"\n              size=\"sm\"\n            >\n              {t(\"common.cancel\", \"取消\")}\n            </Button>\n          </div>\n        </div>\n      )}\n\n      {/* 注销所有账号按钮 */}\n      {hasAnyAccount && accounts.length > 1 && (\n        <Button\n          type=\"button\"\n          variant=\"outline\"\n          onClick={logout}\n          className=\"w-full text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950\"\n        >\n          <LogOut className=\"mr-2 h-4 w-4\" />\n          {t(\"copilot.logoutAll\", \"注销所有账号\")}\n        </Button>\n      )}\n    </div>\n  );\n};\n\nconst CopilotAccountAvatar: React.FC<{ account: GitHubAccount }> = ({\n  account,\n}) => {\n  const [failed, setFailed] = React.useState(false);\n\n  if (!account.avatar_url || failed) {\n    return <User className=\"h-5 w-5 text-muted-foreground\" />;\n  }\n\n  return (\n    <img\n      src={account.avatar_url}\n      alt={account.login}\n      className=\"h-5 w-5 rounded-full\"\n      loading=\"lazy\"\n      referrerPolicy=\"no-referrer\"\n      onError={() => setFailed(true)}\n    />\n  );\n};\n\nexport default CopilotAuthSection;\n"
  },
  {
    "path": "src/components/providers/forms/EndpointSpeedTest.tsx",
    "content": "import React, { useCallback, useEffect, useMemo, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Zap, Loader2, Plus, X, AlertCircle, Save } from \"lucide-react\";\nimport type { AppId } from \"@/lib/api\";\nimport { vscodeApi } from \"@/lib/api/vscode\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { FullScreenPanel } from \"@/components/common/FullScreenPanel\";\nimport type { CustomEndpoint, EndpointCandidate } from \"@/types\";\n\n// 端点测速超时配置（秒）\nconst ENDPOINT_TIMEOUT_SECS: Record<AppId, number> = {\n  codex: 12,\n  claude: 8,\n  gemini: 8,\n  opencode: 8,\n  openclaw: 8,\n};\n\ninterface TestResult {\n  url: string;\n  latency: number | null;\n  status?: number;\n  error?: string | null;\n}\n\ninterface EndpointSpeedTestProps {\n  appId: AppId;\n  providerId?: string;\n  value: string;\n  onChange: (url: string) => void;\n  initialEndpoints: EndpointCandidate[];\n  visible?: boolean;\n  onClose: () => void;\n  autoSelect: boolean;\n  onAutoSelectChange: (checked: boolean) => void;\n  // 新建模式：当自定义端点列表变化时回传（仅包含 isCustom 的条目）\n  // 编辑模式：不使用此回调，端点直接保存到后端\n  onCustomEndpointsChange?: (urls: string[]) => void;\n}\n\ninterface EndpointEntry extends EndpointCandidate {\n  id: string;\n  latency: number | null;\n  status?: number;\n  error?: string | null;\n}\n\nconst randomId = () => `ep_${Math.random().toString(36).slice(2, 9)}`;\n\nconst normalizeEndpointUrl = (url: string): string =>\n  url.trim().replace(/\\/+$/, \"\");\n\nconst buildInitialEntries = (\n  candidates: EndpointCandidate[],\n  selected: string,\n): EndpointEntry[] => {\n  const map = new Map<string, EndpointEntry>();\n  const addCandidate = (candidate: EndpointCandidate) => {\n    const sanitized = candidate.url ? normalizeEndpointUrl(candidate.url) : \"\";\n    if (!sanitized) return;\n    if (map.has(sanitized)) return;\n\n    map.set(sanitized, {\n      id: candidate.id ?? randomId(),\n      url: sanitized,\n      isCustom: candidate.isCustom ?? false,\n      latency: null,\n      status: undefined,\n      error: null,\n    });\n  };\n\n  candidates.forEach(addCandidate);\n\n  const selectedUrl = normalizeEndpointUrl(selected);\n  if (selectedUrl && !map.has(selectedUrl)) {\n    addCandidate({ url: selectedUrl, isCustom: true });\n  }\n\n  return Array.from(map.values());\n};\n\nconst EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({\n  appId,\n  providerId,\n  value,\n  onChange,\n  initialEndpoints,\n  visible = true,\n  onClose,\n  autoSelect,\n  onAutoSelectChange,\n  onCustomEndpointsChange,\n}) => {\n  const { t } = useTranslation();\n  const [entries, setEntries] = useState<EndpointEntry[]>(() =>\n    buildInitialEntries(initialEndpoints, value),\n  );\n  const [customUrl, setCustomUrl] = useState(\"\");\n  const [addError, setAddError] = useState<string | null>(null);\n  const [isTesting, setIsTesting] = useState(false);\n  const [lastError, setLastError] = useState<string | null>(null);\n  const [isSaving, setIsSaving] = useState(false);\n\n  // 记录初始的自定义端点，用于对比变化\n  const [initialCustomUrls, setInitialCustomUrls] = useState<Set<string>>(\n    new Set(),\n  );\n\n  const normalizedSelected = normalizeEndpointUrl(value);\n\n  const hasEndpoints = entries.length > 0;\n  const isEditMode = Boolean(providerId); // 编辑模式有 providerId\n\n  // 编辑模式：加载已保存的自定义端点\n  useEffect(() => {\n    let cancelled = false;\n\n    const loadCustomEndpoints = async () => {\n      try {\n        if (!providerId) return; // 新建模式不加载\n\n        const customEndpoints = await vscodeApi.getCustomEndpoints(\n          appId,\n          providerId,\n        );\n\n        if (cancelled) return;\n\n        const candidates: EndpointCandidate[] = customEndpoints.map(\n          (ep: CustomEndpoint) => ({\n            url: ep.url,\n            isCustom: true,\n          }),\n        );\n\n        // 记录初始的自定义端点\n        const customUrls = new Set(\n          customEndpoints.map((ep) => normalizeEndpointUrl(ep.url)),\n        );\n        setInitialCustomUrls(customUrls);\n\n        // 合并自定义端点与初始端点\n        setEntries((prev) => {\n          const map = new Map<string, EndpointEntry>();\n\n          // 先添加现有端点（来自预设，isCustom 可能为 false）\n          prev.forEach((entry) => {\n            map.set(entry.url, entry);\n          });\n\n          // 合并从后端加载的自定义端点\n          // 关键：如果 URL 已存在（与预设重合），需要将 isCustom 更新为 true\n          // 因为它存在于数据库中，需要在 handleSave 时被正确识别\n          candidates.forEach((candidate) => {\n            const sanitized = normalizeEndpointUrl(candidate.url);\n            if (!sanitized) return;\n\n            const existing = map.get(sanitized);\n            if (existing) {\n              // URL 已存在，更新 isCustom 为 true（因为它在数据库中）\n              existing.isCustom = true;\n            } else {\n              // URL 不存在，添加新条目\n              map.set(sanitized, {\n                id: randomId(),\n                url: sanitized,\n                isCustom: true,\n                latency: null,\n                status: undefined,\n                error: null,\n              });\n            }\n          });\n\n          return Array.from(map.values());\n        });\n      } catch (error) {\n        if (!cancelled) {\n          console.error(t(\"endpointTest.loadEndpointsFailed\"), error);\n        }\n      }\n    };\n\n    // 只在编辑模式下加载\n    if (providerId) {\n      loadCustomEndpoints();\n    }\n\n    return () => {\n      cancelled = true;\n    };\n  }, [appId, providerId, t, initialEndpoints]);\n\n  // 新建模式：将自定义端点变化透传给父组件（仅限 isCustom）\n  // 编辑模式：不使用此回调，端点已通过 API 直接保存\n  useEffect(() => {\n    if (!onCustomEndpointsChange || isEditMode) return; // 编辑模式不使用回调\n    try {\n      const customUrls = Array.from(\n        new Set(\n          entries\n            .filter((e) => e.isCustom)\n            .map((e) => (e.url ? normalizeEndpointUrl(e.url) : \"\"))\n            .filter(Boolean),\n        ),\n      );\n      onCustomEndpointsChange(customUrls);\n    } catch (err) {\n      // ignore\n    }\n  }, [entries, onCustomEndpointsChange, isEditMode]);\n\n  const sortedEntries = useMemo(() => {\n    return entries.slice().sort((a: TestResult, b: TestResult) => {\n      const aLatency = a.latency ?? Number.POSITIVE_INFINITY;\n      const bLatency = b.latency ?? Number.POSITIVE_INFINITY;\n      if (aLatency === bLatency) {\n        return a.url.localeCompare(b.url);\n      }\n      return aLatency - bLatency;\n    });\n  }, [entries]);\n\n  const handleAddEndpoint = useCallback(async () => {\n    const candidate = customUrl.trim();\n    let errorMsg: string | null = null;\n\n    if (!candidate) {\n      errorMsg = t(\"endpointTest.enterValidUrl\");\n    }\n\n    let parsed: URL | null = null;\n    if (!errorMsg) {\n      try {\n        parsed = new URL(candidate);\n      } catch {\n        errorMsg = t(\"endpointTest.invalidUrlFormat\");\n      }\n    }\n\n    // 明确只允许 http: 和 https:\n    const allowedProtocols = [\"http:\", \"https:\"];\n    if (!errorMsg && parsed && !allowedProtocols.includes(parsed.protocol)) {\n      errorMsg = t(\"endpointTest.onlyHttps\");\n    }\n\n    let sanitized = \"\";\n    if (!errorMsg && parsed) {\n      sanitized = normalizeEndpointUrl(parsed.toString());\n      // 使用当前 entries 做去重校验\n      const isDuplicate = entries.some((entry) => entry.url === sanitized);\n      if (isDuplicate) {\n        errorMsg = t(\"endpointTest.urlExists\");\n      }\n    }\n\n    if (errorMsg) {\n      setAddError(errorMsg);\n      return;\n    }\n\n    setAddError(null);\n    setLastError(null);\n\n    // 更新本地状态（延迟保存，点击保存按钮时统一处理）\n    setEntries((prev) => {\n      if (prev.some((e) => e.url === sanitized)) return prev;\n      return [\n        ...prev,\n        {\n          id: randomId(),\n          url: sanitized,\n          isCustom: true,\n          latency: null,\n          status: undefined,\n          error: null,\n        },\n      ];\n    });\n\n    if (!normalizedSelected) {\n      onChange(sanitized);\n    }\n\n    setCustomUrl(\"\");\n  }, [customUrl, entries, normalizedSelected, onChange, t]);\n\n  const handleRemoveEndpoint = useCallback(\n    (entry: EndpointEntry) => {\n      // 清空之前的错误提示\n      setLastError(null);\n\n      // 更新本地状态（延迟保存，点击保存按钮时统一处理）\n      setEntries((prev) => {\n        const next = prev.filter((item) => item.id !== entry.id);\n        if (entry.url === normalizedSelected) {\n          const fallback = next[0];\n          onChange(fallback ? fallback.url : \"\");\n        }\n        return next;\n      });\n    },\n    [normalizedSelected, onChange],\n  );\n\n  const runSpeedTest = useCallback(async () => {\n    const urls = entries.map((entry) => entry.url);\n    if (urls.length === 0) {\n      setLastError(t(\"endpointTest.pleaseAddEndpoint\"));\n      return;\n    }\n\n    setIsTesting(true);\n    setLastError(null);\n\n    // 清空所有延迟数据，显示 loading 状态\n    setEntries((prev) =>\n      prev.map((entry) => ({\n        ...entry,\n        latency: null,\n        status: undefined,\n        error: null,\n      })),\n    );\n\n    try {\n      const results = await vscodeApi.testApiEndpoints(urls, {\n        timeoutSecs: ENDPOINT_TIMEOUT_SECS[appId],\n      });\n\n      const resultMap = new Map(\n        results.map((item) => [normalizeEndpointUrl(item.url), item]),\n      );\n\n      setEntries((prev) =>\n        prev.map((entry) => {\n          const match = resultMap.get(entry.url);\n          if (!match) {\n            return {\n              ...entry,\n              latency: null,\n              status: undefined,\n              error: t(\"endpointTest.noResult\"),\n            };\n          }\n          return {\n            ...entry,\n            latency:\n              typeof match.latency === \"number\"\n                ? Math.round(match.latency)\n                : null,\n            status: match.status,\n            error: match.error ?? null,\n          };\n        }),\n      );\n\n      if (autoSelect) {\n        const successful = results\n          .filter(\n            (item) => typeof item.latency === \"number\" && item.latency !== null,\n          )\n          .sort((a, b) => (a.latency! || 0) - (b.latency! || 0));\n        const best = successful[0];\n        if (best && best.url && best.url !== normalizedSelected) {\n          onChange(best.url);\n        }\n      }\n    } catch (error) {\n      const message =\n        error instanceof Error\n          ? error.message\n          : `${t(\"endpointTest.testFailed\", { error: String(error) })}`;\n      setLastError(message);\n    } finally {\n      setIsTesting(false);\n    }\n  }, [entries, autoSelect, appId, normalizedSelected, onChange, t]);\n\n  const handleSelect = useCallback(\n    (url: string) => {\n      if (!url || url === normalizedSelected) return;\n      onChange(url);\n    },\n    [normalizedSelected, onChange],\n  );\n\n  // 保存端点变更\n  const handleSave = useCallback(async () => {\n    // 编辑模式：对比初始端点和当前端点，批量保存变更\n    if (isEditMode && providerId) {\n      setIsSaving(true);\n      setLastError(null);\n\n      try {\n        // 获取当前的自定义端点\n        const currentCustomUrls = new Set(\n          entries\n            .filter((e) => e.isCustom)\n            .map((e) => normalizeEndpointUrl(e.url)),\n        );\n\n        // 找出新增的端点\n        const toAdd = Array.from(currentCustomUrls).filter(\n          (url) => !initialCustomUrls.has(url),\n        );\n\n        // 找出删除的端点\n        const toRemove = Array.from(initialCustomUrls).filter(\n          (url) => !currentCustomUrls.has(url),\n        );\n\n        // 批量添加\n        for (const url of toAdd) {\n          await vscodeApi.addCustomEndpoint(appId, providerId, url);\n        }\n\n        // 批量删除\n        for (const url of toRemove) {\n          await vscodeApi.removeCustomEndpoint(appId, providerId, url);\n        }\n\n        // 更新初始端点列表\n        setInitialCustomUrls(currentCustomUrls);\n      } catch (error) {\n        const message =\n          error instanceof Error ? error.message : t(\"endpointTest.saveFailed\");\n        setLastError(message);\n        setIsSaving(false);\n        return;\n      } finally {\n        setIsSaving(false);\n      }\n    }\n\n    // 关闭弹窗\n    onClose();\n  }, [isEditMode, providerId, entries, initialCustomUrls, appId, onClose, t]);\n\n  if (!visible) return null;\n\n  const footer = (\n    <div className=\"flex items-center gap-2\">\n      <Button\n        type=\"button\"\n        variant=\"outline\"\n        onClick={(event) => {\n          event.preventDefault();\n          onClose();\n        }}\n        disabled={isSaving}\n      >\n        {t(\"common.cancel\")}\n      </Button>\n      <Button\n        type=\"button\"\n        onClick={handleSave}\n        disabled={isSaving}\n        className=\"gap-2\"\n      >\n        {isSaving ? (\n          <>\n            <Loader2 className=\"w-4 h-4 animate-spin\" />\n            {t(\"common.saving\")}\n          </>\n        ) : (\n          <>\n            <Save className=\"w-4 h-4\" />\n            {t(\"common.save\")}\n          </>\n        )}\n      </Button>\n    </div>\n  );\n\n  return (\n    <FullScreenPanel\n      isOpen={visible}\n      title={t(\"endpointTest.title\")}\n      onClose={onClose}\n      footer={footer}\n    >\n      <div className=\"glass rounded-xl p-6 border border-white/10 flex flex-col gap-6\">\n        {/* 测速控制栏 */}\n        <div className=\"flex items-center justify-between\">\n          <div className=\"text-sm text-muted-foreground\">\n            {entries.length} {t(\"endpointTest.endpoints\")}\n          </div>\n          <div className=\"flex items-center gap-3\">\n            <label className=\"flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400\">\n              <input\n                type=\"checkbox\"\n                checked={autoSelect}\n                onChange={(event) => {\n                  onAutoSelectChange(event.target.checked);\n                }}\n                className=\"h-3.5 w-3.5 rounded border-border-default bg-background text-primary focus:ring-2 focus:ring-primary/20\"\n              />\n              {t(\"endpointTest.autoSelect\")}\n            </label>\n            <Button\n              type=\"button\"\n              onClick={runSpeedTest}\n              disabled={isTesting || !hasEndpoints}\n              size=\"sm\"\n              className=\"h-7 w-24 gap-1.5 text-xs bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-60\"\n            >\n              {isTesting ? (\n                <>\n                  <Loader2 className=\"h-3.5 w-3.5 animate-spin\" />\n                  {t(\"endpointTest.testing\")}\n                </>\n              ) : (\n                <>\n                  <Zap className=\"h-3.5 w-3.5\" />\n                  {t(\"endpointTest.testSpeed\")}\n                </>\n              )}\n            </Button>\n          </div>\n        </div>\n\n        {/* 添加输入 */}\n        <div className=\"space-y-1.5\">\n          <div className=\"flex gap-2\">\n            <Input\n              type=\"text\"\n              value={customUrl}\n              placeholder={t(\"endpointTest.addEndpointPlaceholder\")}\n              onChange={(event) => setCustomUrl(event.target.value)}\n              onKeyDown={(event) => {\n                if (event.key === \"Enter\") {\n                  event.preventDefault();\n                  handleAddEndpoint();\n                }\n              }}\n              className=\"flex-1\"\n            />\n            <Button\n              type=\"button\"\n              onClick={handleAddEndpoint}\n              variant=\"outline\"\n              size=\"icon\"\n            >\n              <Plus className=\"h-4 w-4\" />\n            </Button>\n          </div>\n          {addError && (\n            <div className=\"flex items-center gap-1.5 text-xs text-red-600 dark:text-red-400\">\n              <AlertCircle className=\"h-3 w-3\" />\n              {addError}\n            </div>\n          )}\n        </div>\n\n        {/* 端点列表 */}\n        {hasEndpoints ? (\n          <div className=\"space-y-2\">\n            {sortedEntries.map((entry) => {\n              const isSelected = normalizedSelected === entry.url;\n              const latency = entry.latency;\n\n              return (\n                <div\n                  key={entry.id}\n                  onClick={() => handleSelect(entry.url)}\n                  className={`group flex cursor-pointer items-center justify-between px-3 py-2.5 rounded-lg border transition text-foreground ${\n                    isSelected\n                      ? \"border-primary/70 bg-primary/5 shadow-sm\"\n                      : \"border-border-default bg-background hover:bg-muted\"\n                  }`}\n                >\n                  <div className=\"flex min-w-0 flex-1 items-center gap-3\">\n                    {/* 选择指示器 */}\n                    <div\n                      className={`h-1.5 w-1.5 flex-shrink-0 rounded-full transition ${\n                        isSelected\n                          ? \"bg-blue-500 dark:bg-blue-400\"\n                          : \"bg-gray-300 dark:bg-gray-700\"\n                      }`}\n                    />\n\n                    {/* 内容 */}\n                    <div className=\"min-w-0 flex-1\">\n                      <div className=\"truncate text-sm text-foreground\">\n                        {entry.url}\n                      </div>\n                    </div>\n                  </div>\n\n                  {/* 右侧信息 */}\n                  <div className=\"flex items-center gap-2\">\n                    {latency !== null ? (\n                      <div className=\"text-right\">\n                        <div\n                          className={`font-mono text-sm font-medium ${\n                            latency < 300\n                              ? \"text-emerald-600 dark:text-emerald-400\"\n                              : latency < 500\n                                ? \"text-yellow-600 dark:text-yellow-400\"\n                                : latency < 800\n                                  ? \"text-orange-600 dark:text-orange-400\"\n                                  : \"text-red-600 dark:text-red-400\"\n                          }`}\n                        >\n                          {latency}ms\n                        </div>\n                      </div>\n                    ) : isTesting ? (\n                      <Loader2 className=\"h-4 w-4 animate-spin text-gray-400\" />\n                    ) : entry.error ? (\n                      <div className=\"text-xs text-gray-400\">\n                        {t(\"endpointTest.failed\")}\n                      </div>\n                    ) : (\n                      <div className=\"text-xs text-gray-400\">—</div>\n                    )}\n\n                    <button\n                      type=\"button\"\n                      onClick={(event) => {\n                        event.stopPropagation();\n                        handleRemoveEndpoint(entry);\n                      }}\n                      className=\"opacity-0 transition hover:text-red-600 group-hover:opacity-100 dark:hover:text-red-400\"\n                    >\n                      <X className=\"h-4 w-4\" />\n                    </button>\n                  </div>\n                </div>\n              );\n            })}\n          </div>\n        ) : (\n          <div className=\"rounded-md border border-dashed border-border-default bg-muted px-4 py-8 text-center text-sm text-muted-foreground\">\n            {t(\"endpointTest.empty\")}\n          </div>\n        )}\n\n        {/* 错误提示 */}\n        {lastError && (\n          <div className=\"flex items-center gap-1.5 text-xs text-red-600 dark:text-red-400\">\n            <AlertCircle className=\"h-3 w-3\" />\n            {lastError}\n          </div>\n        )}\n      </div>\n    </FullScreenPanel>\n  );\n};\n\nexport default EndpointSpeedTest;\n"
  },
  {
    "path": "src/components/providers/forms/GeminiCommonConfigModal.tsx",
    "content": "import React, { useEffect, useState } from \"react\";\nimport { Save, Download, Loader2 } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\nimport { FullScreenPanel } from \"@/components/common/FullScreenPanel\";\nimport { Button } from \"@/components/ui/button\";\nimport JsonEditor from \"@/components/JsonEditor\";\n\ninterface GeminiCommonConfigModalProps {\n  isOpen: boolean;\n  onClose: () => void;\n  value: string;\n  onSave: (value: string) => boolean;\n  error?: string;\n  onExtract?: () => void;\n  isExtracting?: boolean;\n}\n\n/**\n * GeminiCommonConfigModal - Common Gemini configuration editor modal\n * Allows editing of common env snippet shared across Gemini providers\n */\nexport const GeminiCommonConfigModal: React.FC<\n  GeminiCommonConfigModalProps\n> = ({ isOpen, onClose, value, onSave, error, onExtract, isExtracting }) => {\n  const { t } = useTranslation();\n  const [isDarkMode, setIsDarkMode] = useState(false);\n  const [draftValue, setDraftValue] = useState(value);\n\n  useEffect(() => {\n    setIsDarkMode(document.documentElement.classList.contains(\"dark\"));\n\n    const observer = new MutationObserver(() => {\n      setIsDarkMode(document.documentElement.classList.contains(\"dark\"));\n    });\n\n    observer.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n\n    return () => observer.disconnect();\n  }, []);\n\n  useEffect(() => {\n    if (isOpen) {\n      setDraftValue(value);\n    }\n  }, [isOpen, value]);\n\n  const handleClose = () => {\n    setDraftValue(value);\n    onClose();\n  };\n\n  const handleSave = () => {\n    if (onSave(draftValue)) {\n      onClose();\n    }\n  };\n\n  return (\n    <FullScreenPanel\n      isOpen={isOpen}\n      title={t(\"geminiConfig.editCommonConfigTitle\", {\n        defaultValue: \"编辑 Gemini 通用配置片段\",\n      })}\n      onClose={handleClose}\n      footer={\n        <>\n          {onExtract && (\n            <Button\n              type=\"button\"\n              variant=\"outline\"\n              onClick={onExtract}\n              disabled={isExtracting}\n              className=\"gap-2\"\n            >\n              {isExtracting ? (\n                <Loader2 className=\"w-4 h-4 animate-spin\" />\n              ) : (\n                <Download className=\"w-4 h-4\" />\n              )}\n              {t(\"geminiConfig.extractFromCurrent\", {\n                defaultValue: \"从编辑内容提取\",\n              })}\n            </Button>\n          )}\n          <Button type=\"button\" variant=\"outline\" onClick={handleClose}>\n            {t(\"common.cancel\")}\n          </Button>\n          <Button type=\"button\" onClick={handleSave} className=\"gap-2\">\n            <Save className=\"w-4 h-4\" />\n            {t(\"common.save\")}\n          </Button>\n        </>\n      }\n    >\n      <div className=\"space-y-4\">\n        <p className=\"text-sm text-muted-foreground\">\n          {t(\"geminiConfig.commonConfigHint\", {\n            defaultValue:\n              \"该片段会写入 Gemini 的 .env（不允许包含 GOOGLE_GEMINI_BASE_URL、GEMINI_API_KEY）\",\n          })}\n        </p>\n\n        <JsonEditor\n          value={draftValue}\n          onChange={setDraftValue}\n          placeholder={`{\n  \"GEMINI_MODEL\": \"gemini-3-pro-preview\"\n}`}\n          darkMode={isDarkMode}\n          rows={16}\n          showValidation={true}\n          language=\"json\"\n        />\n\n        {error && (\n          <p className=\"text-sm text-red-500 dark:text-red-400\">{error}</p>\n        )}\n      </div>\n    </FullScreenPanel>\n  );\n};\n"
  },
  {
    "path": "src/components/providers/forms/GeminiConfigEditor.tsx",
    "content": "import React, { useState } from \"react\";\nimport { GeminiEnvSection, GeminiConfigSection } from \"./GeminiConfigSections\";\nimport { GeminiCommonConfigModal } from \"./GeminiCommonConfigModal\";\n\ninterface GeminiConfigEditorProps {\n  envValue: string;\n  configValue: string;\n  onEnvChange: (value: string) => void;\n  onConfigChange: (value: string) => void;\n  onEnvBlur?: () => void;\n  useCommonConfig: boolean;\n  onCommonConfigToggle: (checked: boolean) => void;\n  commonConfigSnippet: string;\n  onCommonConfigSnippetChange: (value: string) => boolean;\n  onCommonConfigErrorClear: () => void;\n  commonConfigError: string;\n  envError: string;\n  configError: string;\n  onExtract?: () => void;\n  isExtracting?: boolean;\n}\n\nconst GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({\n  envValue,\n  configValue,\n  onEnvChange,\n  onConfigChange,\n  onEnvBlur,\n  useCommonConfig,\n  onCommonConfigToggle,\n  commonConfigSnippet,\n  onCommonConfigSnippetChange,\n  onCommonConfigErrorClear,\n  commonConfigError,\n  envError,\n  configError,\n  onExtract,\n  isExtracting,\n}) => {\n  const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);\n\n  const handleCloseCommonConfigModal = () => {\n    onCommonConfigErrorClear();\n    setIsCommonConfigModalOpen(false);\n  };\n\n  return (\n    <div className=\"space-y-6\">\n      {/* Env Section */}\n      <GeminiEnvSection\n        value={envValue}\n        onChange={onEnvChange}\n        onBlur={onEnvBlur}\n        error={envError}\n        useCommonConfig={useCommonConfig}\n        onCommonConfigToggle={onCommonConfigToggle}\n        onEditCommonConfig={() => setIsCommonConfigModalOpen(true)}\n        commonConfigError={commonConfigError}\n      />\n\n      {/* Config JSON Section */}\n      <GeminiConfigSection\n        value={configValue}\n        onChange={onConfigChange}\n        configError={configError}\n      />\n\n      {/* Common Config Modal */}\n      <GeminiCommonConfigModal\n        isOpen={isCommonConfigModalOpen}\n        onClose={handleCloseCommonConfigModal}\n        value={commonConfigSnippet}\n        onSave={onCommonConfigSnippetChange}\n        error={commonConfigError}\n        onExtract={onExtract}\n        isExtracting={isExtracting}\n      />\n    </div>\n  );\n};\n\nexport default GeminiConfigEditor;\n"
  },
  {
    "path": "src/components/providers/forms/GeminiConfigSections.tsx",
    "content": "import React, { useEffect, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport JsonEditor from \"@/components/JsonEditor\";\n\ninterface GeminiEnvSectionProps {\n  value: string;\n  onChange: (value: string) => void;\n  onBlur?: () => void;\n  error?: string;\n  useCommonConfig: boolean;\n  onCommonConfigToggle: (checked: boolean) => void;\n  onEditCommonConfig: () => void;\n  commonConfigError?: string;\n}\n\n/**\n * GeminiEnvSection - .env editor section for Gemini environment variables\n */\nexport const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({\n  value,\n  onChange,\n  onBlur,\n  error,\n  useCommonConfig,\n  onCommonConfigToggle,\n  onEditCommonConfig,\n  commonConfigError,\n}) => {\n  const { t } = useTranslation();\n  const [isDarkMode, setIsDarkMode] = useState(false);\n\n  useEffect(() => {\n    setIsDarkMode(document.documentElement.classList.contains(\"dark\"));\n\n    const observer = new MutationObserver(() => {\n      setIsDarkMode(document.documentElement.classList.contains(\"dark\"));\n    });\n\n    observer.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n\n    return () => observer.disconnect();\n  }, []);\n\n  const handleChange = (newValue: string) => {\n    onChange(newValue);\n    if (onBlur) {\n      onBlur();\n    }\n  };\n\n  return (\n    <div className=\"space-y-2\">\n      <div className=\"flex items-center justify-between\">\n        <label\n          htmlFor=\"geminiEnv\"\n          className=\"block text-sm font-medium text-foreground\"\n        >\n          {t(\"geminiConfig.envFile\", { defaultValue: \"环境变量 (.env)\" })}\n        </label>\n\n        <label className=\"inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer\">\n          <input\n            type=\"checkbox\"\n            checked={useCommonConfig}\n            onChange={(e) => onCommonConfigToggle(e.target.checked)}\n            className=\"w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2\"\n          />\n          {t(\"geminiConfig.writeCommonConfig\", {\n            defaultValue: \"写入通用配置\",\n          })}\n        </label>\n      </div>\n\n      <div className=\"flex items-center justify-end\">\n        <button\n          type=\"button\"\n          onClick={onEditCommonConfig}\n          className=\"text-xs text-blue-500 dark:text-blue-400 hover:underline\"\n        >\n          {t(\"geminiConfig.editCommonConfig\", {\n            defaultValue: \"编辑通用配置\",\n          })}\n        </button>\n      </div>\n\n      {commonConfigError && (\n        <p className=\"text-xs text-red-500 dark:text-red-400 text-right\">\n          {commonConfigError}\n        </p>\n      )}\n\n      <JsonEditor\n        value={value}\n        onChange={handleChange}\n        placeholder={`GOOGLE_GEMINI_BASE_URL=https://your-api-endpoint.com/\nGEMINI_API_KEY=sk-your-api-key-here\nGEMINI_MODEL=gemini-3-pro-preview`}\n        darkMode={isDarkMode}\n        rows={6}\n        showValidation={false}\n        language=\"javascript\"\n      />\n\n      {error && (\n        <p className=\"text-xs text-red-500 dark:text-red-400\">{error}</p>\n      )}\n\n      {!error && (\n        <p className=\"text-xs text-muted-foreground\">\n          {t(\"geminiConfig.envFileHint\", {\n            defaultValue: \"使用 .env 格式配置 Gemini 环境变量\",\n          })}\n        </p>\n      )}\n    </div>\n  );\n};\n\ninterface GeminiConfigSectionProps {\n  value: string;\n  onChange: (value: string) => void;\n  configError?: string;\n}\n\n/**\n * GeminiConfigSection - Config JSON editor section with common config support\n */\nexport const GeminiConfigSection: React.FC<GeminiConfigSectionProps> = ({\n  value,\n  onChange,\n  configError,\n}) => {\n  const { t } = useTranslation();\n  const [isDarkMode, setIsDarkMode] = useState(false);\n\n  useEffect(() => {\n    setIsDarkMode(document.documentElement.classList.contains(\"dark\"));\n\n    const observer = new MutationObserver(() => {\n      setIsDarkMode(document.documentElement.classList.contains(\"dark\"));\n    });\n\n    observer.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n\n    return () => observer.disconnect();\n  }, []);\n\n  return (\n    <div className=\"space-y-2\">\n      <label\n        htmlFor=\"geminiConfig\"\n        className=\"block text-sm font-medium text-foreground\"\n      >\n        {t(\"geminiConfig.configJson\", {\n          defaultValue: \"配置文件 (config.json)\",\n        })}\n      </label>\n\n      <JsonEditor\n        value={value}\n        onChange={onChange}\n        placeholder={`{\n  \"timeout\": 30000,\n  \"maxRetries\": 3\n}`}\n        darkMode={isDarkMode}\n        rows={8}\n        showValidation={true}\n        language=\"json\"\n      />\n\n      {configError && (\n        <p className=\"text-xs text-red-500 dark:text-red-400\">{configError}</p>\n      )}\n\n      {!configError && (\n        <p className=\"text-xs text-muted-foreground\">\n          {t(\"geminiConfig.configJsonHint\", {\n            defaultValue: \"使用 JSON 格式配置 Gemini 扩展参数（可选）\",\n          })}\n        </p>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "src/components/providers/forms/GeminiFormFields.tsx",
    "content": "import { useTranslation } from \"react-i18next\";\nimport { FormLabel } from \"@/components/ui/form\";\nimport { Input } from \"@/components/ui/input\";\nimport { Info } from \"lucide-react\";\nimport EndpointSpeedTest from \"./EndpointSpeedTest\";\nimport { ApiKeySection, EndpointField } from \"./shared\";\nimport type { ProviderCategory } from \"@/types\";\n\ninterface EndpointCandidate {\n  url: string;\n}\n\ninterface GeminiFormFieldsProps {\n  providerId?: string;\n  // API Key\n  shouldShowApiKey: boolean;\n  apiKey: string;\n  onApiKeyChange: (key: string) => void;\n  category?: ProviderCategory;\n  shouldShowApiKeyLink: boolean;\n  websiteUrl: string;\n  isPartner?: boolean;\n  partnerPromotionKey?: string;\n\n  // Base URL\n  shouldShowSpeedTest: boolean;\n  baseUrl: string;\n  onBaseUrlChange: (url: string) => void;\n  isEndpointModalOpen: boolean;\n  onEndpointModalToggle: (open: boolean) => void;\n  onCustomEndpointsChange: (endpoints: string[]) => void;\n  autoSelect: boolean;\n  onAutoSelectChange: (checked: boolean) => void;\n\n  // Model\n  shouldShowModelField: boolean;\n  model: string;\n  onModelChange: (value: string) => void;\n\n  // Speed Test Endpoints\n  speedTestEndpoints: EndpointCandidate[];\n}\n\nexport function GeminiFormFields({\n  providerId,\n  shouldShowApiKey,\n  apiKey,\n  onApiKeyChange,\n  category,\n  shouldShowApiKeyLink,\n  websiteUrl,\n  isPartner,\n  partnerPromotionKey,\n  shouldShowSpeedTest,\n  baseUrl,\n  onBaseUrlChange,\n  isEndpointModalOpen,\n  onEndpointModalToggle,\n  onCustomEndpointsChange,\n  autoSelect,\n  onAutoSelectChange,\n  shouldShowModelField,\n  model,\n  onModelChange,\n  speedTestEndpoints,\n}: GeminiFormFieldsProps) {\n  const { t } = useTranslation();\n\n  // 检测是否为 Google 官方（使用 OAuth）\n  const isGoogleOfficial =\n    partnerPromotionKey?.toLowerCase() === \"google-official\";\n\n  return (\n    <>\n      {/* Google OAuth 提示 */}\n      {isGoogleOfficial && (\n        <div className=\"rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-950\">\n          <div className=\"flex gap-3\">\n            <Info className=\"h-5 w-5 flex-shrink-0 text-blue-600 dark:text-blue-400\" />\n            <div className=\"space-y-1\">\n              <p className=\"text-sm font-medium text-blue-900 dark:text-blue-100\">\n                {t(\"provider.form.gemini.oauthTitle\", {\n                  defaultValue: \"OAuth 认证模式\",\n                })}\n              </p>\n              <p className=\"text-sm text-blue-700 dark:text-blue-300\">\n                {t(\"provider.form.gemini.oauthHint\", {\n                  defaultValue:\n                    \"Google 官方使用 OAuth 个人认证，无需填写 API Key。首次使用时会自动打开浏览器进行登录。\",\n                })}\n              </p>\n            </div>\n          </div>\n        </div>\n      )}\n\n      {/* API Key 输入框 */}\n      {shouldShowApiKey && !isGoogleOfficial && (\n        <ApiKeySection\n          value={apiKey}\n          onChange={onApiKeyChange}\n          category={category}\n          shouldShowLink={shouldShowApiKeyLink}\n          websiteUrl={websiteUrl}\n          isPartner={isPartner}\n          partnerPromotionKey={partnerPromotionKey}\n        />\n      )}\n\n      {/* Base URL 输入框（统一使用与 Codex 相同的样式与交互） */}\n      {shouldShowSpeedTest && (\n        <EndpointField\n          id=\"baseUrl\"\n          label={t(\"providerForm.apiEndpoint\", { defaultValue: \"API 端点\" })}\n          value={baseUrl}\n          onChange={onBaseUrlChange}\n          placeholder={t(\"providerForm.apiEndpointPlaceholder\", {\n            defaultValue: \"https://your-api-endpoint.com/\",\n          })}\n          onManageClick={() => onEndpointModalToggle(true)}\n        />\n      )}\n\n      {/* Model 输入框 */}\n      {shouldShowModelField && (\n        <div>\n          <FormLabel htmlFor=\"gemini-model\">\n            {t(\"provider.form.gemini.model\", { defaultValue: \"模型\" })}\n          </FormLabel>\n          <Input\n            id=\"gemini-model\"\n            value={model}\n            onChange={(e) => onModelChange(e.target.value)}\n            placeholder=\"gemini-3-pro-preview\"\n          />\n        </div>\n      )}\n\n      {/* 端点测速弹窗 */}\n      {shouldShowSpeedTest && isEndpointModalOpen && (\n        <EndpointSpeedTest\n          appId=\"gemini\"\n          providerId={providerId}\n          value={baseUrl}\n          onChange={onBaseUrlChange}\n          initialEndpoints={speedTestEndpoints}\n          visible={isEndpointModalOpen}\n          onClose={() => onEndpointModalToggle(false)}\n          autoSelect={autoSelect}\n          onAutoSelectChange={onAutoSelectChange}\n          onCustomEndpointsChange={onCustomEndpointsChange}\n        />\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "src/components/providers/forms/OmoFormFields.tsx",
    "content": "import { useState, useCallback, useEffect } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport {\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n} from \"@/components/ui/command\";\nimport {\n  Plus,\n  Trash2,\n  ChevronDown,\n  ChevronRight,\n  Wand2,\n  Settings,\n  FolderInput,\n  Loader2,\n  HelpCircle,\n  Check,\n  ChevronsUpDown,\n  X,\n} from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport { toast } from \"sonner\";\nimport { useReadOmoLocalFile, useReadOmoSlimLocalFile } from \"@/lib/query/omo\";\nimport {\n  OMO_BUILTIN_AGENTS,\n  OMO_BUILTIN_CATEGORIES,\n  OMO_SLIM_BUILTIN_AGENTS,\n  type OmoAgentDef,\n  type OmoCategoryDef,\n} from \"@/types/omo\";\n\nconst ADVANCED_PLACEHOLDER = `{\n  \"temperature\": 0.5,\n  \"top_p\": 0.9,\n  \"budgetTokens\": 20000,\n  \"prompt_append\": \"\",\n  \"permission\": { \"edit\": \"allow\", \"bash\": \"ask\" }\n}`;\n\ninterface OmoFormFieldsProps {\n  modelOptions: Array<{ value: string; label: string }>;\n  modelVariantsMap?: Record<string, string[]>;\n  presetMetaMap?: Record<\n    string,\n    {\n      options?: Record<string, unknown>;\n      limit?: { context?: number; output?: number };\n    }\n  >;\n  agents: Record<string, Record<string, unknown>>;\n  onAgentsChange: (agents: Record<string, Record<string, unknown>>) => void;\n  categories?: Record<string, Record<string, unknown>>;\n  onCategoriesChange?: (\n    categories: Record<string, Record<string, unknown>>,\n  ) => void;\n  otherFieldsStr: string;\n  onOtherFieldsStrChange: (value: string) => void;\n  isSlim?: boolean;\n}\n\nexport type CustomModelItem = {\n  key: string;\n  model: string;\n  sourceKey?: string;\n};\ntype BuiltinModelDef = Pick<\n  OmoAgentDef | OmoCategoryDef,\n  \"key\" | \"display\" | \"descKey\" | \"recommended\" | \"tooltipKey\"\n>;\ntype ModelOption = { value: string; label: string };\n\nfunction DeferredKeyInput({\n  value,\n  onCommit,\n  placeholder,\n  className,\n}: {\n  value: string;\n  onCommit: (value: string) => void;\n  placeholder?: string;\n  className?: string;\n}) {\n  const [draft, setDraft] = useState(value);\n\n  useEffect(() => {\n    setDraft(value);\n  }, [value]);\n\n  return (\n    <Input\n      value={draft}\n      onChange={(e) => setDraft(e.target.value)}\n      onBlur={() => {\n        if (draft !== value) {\n          onCommit(draft);\n        }\n      }}\n      placeholder={placeholder}\n      className={className}\n    />\n  );\n}\n\nconst BUILTIN_AGENT_KEYS = new Set(OMO_BUILTIN_AGENTS.map((a) => a.key));\nconst BUILTIN_AGENT_KEYS_SLIM = new Set(\n  OMO_SLIM_BUILTIN_AGENTS.map((a) => a.key),\n);\nconst BUILTIN_CATEGORY_KEYS = new Set(OMO_BUILTIN_CATEGORIES.map((c) => c.key));\nconst EMPTY_VARIANT_VALUE = \"__cc_switch_omo_variant_empty__\";\n\nfunction ModelCombobox({\n  value,\n  options,\n  recommended,\n  onChange,\n}: {\n  value: string;\n  options: ModelOption[];\n  recommended?: string;\n  onChange: (value: string) => void;\n}) {\n  const { t } = useTranslation();\n  const [open, setOpen] = useState(false);\n\n  const selectedLabel = options.find((o) => o.value === value)?.label;\n\n  const selectModelText = t(\"omo.selectModel\", {\n    defaultValue: \"Select configured model\",\n  });\n  const placeholderText = recommended\n    ? `${selectModelText} (${t(\"omo.recommendedHint\", { model: recommended, defaultValue: \"Recommended: {{model}}\" })})`\n    : selectModelText;\n\n  return (\n    <Popover modal open={open} onOpenChange={setOpen}>\n      <PopoverTrigger asChild>\n        <button\n          type=\"button\"\n          role=\"combobox\"\n          aria-expanded={open}\n          className=\"flex flex-1 h-8 items-center justify-between whitespace-nowrap rounded-md border border-border-default bg-background px-3 py-1 text-sm shadow-sm ring-offset-background focus:outline-none focus-visible:outline-none focus:border-border-default focus-visible:border-border-default focus:ring-0 focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-50\"\n        >\n          <span className={cn(\"truncate\", !value && \"text-muted-foreground\")}>\n            {selectedLabel || placeholderText}\n          </span>\n          <span className=\"flex items-center shrink-0 ml-1 gap-0.5\">\n            {value && (\n              <X\n                className=\"h-3.5 w-3.5 opacity-50 hover:opacity-100 cursor-pointer\"\n                onClick={(e) => {\n                  e.stopPropagation();\n                  onChange(\"\");\n                }}\n              />\n            )}\n            <ChevronsUpDown className=\"h-3.5 w-3.5 opacity-50\" />\n          </span>\n        </button>\n      </PopoverTrigger>\n      <PopoverContent\n        side=\"bottom\"\n        align=\"start\"\n        sideOffset={6}\n        avoidCollisions={true}\n        collisionPadding={8}\n        className=\"z-[1000] w-[var(--radix-popover-trigger-width)] p-0 border-border-default\"\n      >\n        <Command>\n          <CommandInput\n            placeholder={t(\"omo.searchModel\", {\n              defaultValue: \"Search model...\",\n            })}\n          />\n          <CommandList>\n            <CommandEmpty>\n              {t(\"omo.noEnabledModels\", {\n                defaultValue: \"No configured models\",\n              })}\n            </CommandEmpty>\n            <CommandGroup>\n              {options.map((option) => (\n                <CommandItem\n                  key={option.value}\n                  value={option.value}\n                  keywords={[option.label]}\n                  onSelect={() => {\n                    onChange(option.value);\n                    setOpen(false);\n                  }}\n                >\n                  <Check\n                    className={cn(\n                      \"mr-2 h-4 w-4\",\n                      value === option.value ? \"opacity-100\" : \"opacity-0\",\n                    )}\n                  />\n                  {option.label}\n                </CommandItem>\n              ))}\n            </CommandGroup>\n          </CommandList>\n        </Command>\n      </PopoverContent>\n    </Popover>\n  );\n}\n\nfunction getAdvancedStr(config: Record<string, unknown> | undefined): string {\n  if (!config) return \"\";\n  const adv: Record<string, unknown> = {};\n  for (const [k, v] of Object.entries(config)) {\n    if (k !== \"model\" && k !== \"variant\") adv[k] = v;\n  }\n  return Object.keys(adv).length > 0 ? JSON.stringify(adv, null, 2) : \"\";\n}\n\nfunction collectCustomModels(\n  store: Record<string, Record<string, unknown>>,\n  builtinKeys: Set<string>,\n): CustomModelItem[] {\n  const customs: CustomModelItem[] = [];\n  for (const [k, v] of Object.entries(store)) {\n    if (!builtinKeys.has(k) && typeof v === \"object\" && v !== null) {\n      customs.push({\n        key: k,\n        model: ((v as Record<string, unknown>).model as string) || \"\",\n        sourceKey: k,\n      });\n    }\n  }\n  return customs;\n}\n\nexport function mergeCustomModelsIntoStore(\n  store: Record<string, Record<string, unknown>>,\n  builtinKeys: Set<string>,\n  customs: CustomModelItem[],\n  modelVariantsMap: Record<string, string[]>,\n): Record<string, Record<string, unknown>> {\n  const updated: Record<string, Record<string, unknown>> = {};\n\n  for (const [key, value] of Object.entries(store)) {\n    if (builtinKeys.has(key)) {\n      updated[key] = { ...value };\n    }\n  }\n\n  for (const custom of customs) {\n    const targetKey = custom.key.trim();\n    if (!targetKey) continue;\n\n    const sourceKey = (custom.sourceKey || targetKey).trim();\n    const sourceEntry = store[sourceKey] ?? store[targetKey];\n    const nextEntry = {\n      ...(updated[targetKey] || {}),\n      ...(sourceEntry || {}),\n    };\n\n    if (custom.model.trim()) {\n      nextEntry.model = custom.model;\n      const currentVariant =\n        typeof nextEntry.variant === \"string\" ? nextEntry.variant : \"\";\n      if (currentVariant) {\n        const validVariants = modelVariantsMap[custom.model] || [];\n        if (!validVariants.includes(currentVariant)) {\n          delete nextEntry.variant;\n        }\n      }\n      updated[targetKey] = nextEntry;\n      continue;\n    }\n\n    delete nextEntry.model;\n    delete nextEntry.variant;\n    if (Object.keys(nextEntry).length > 0) {\n      updated[targetKey] = nextEntry;\n    } else {\n      delete updated[targetKey];\n    }\n  }\n  return updated;\n}\n\nexport function OmoFormFields({\n  modelOptions,\n  modelVariantsMap = {},\n  presetMetaMap: _presetMetaMap = {},\n  agents,\n  onAgentsChange,\n  categories = {},\n  onCategoriesChange,\n  otherFieldsStr,\n  onOtherFieldsStrChange,\n  isSlim = false,\n}: OmoFormFieldsProps) {\n  const { t } = useTranslation();\n\n  const builtinAgentDefs = isSlim\n    ? OMO_SLIM_BUILTIN_AGENTS\n    : OMO_BUILTIN_AGENTS;\n  const builtinAgentKeys = isSlim\n    ? BUILTIN_AGENT_KEYS_SLIM\n    : BUILTIN_AGENT_KEYS;\n\n  const [mainAgentsOpen, setMainAgentsOpen] = useState(true);\n  const [subAgentsOpen, setSubAgentsOpen] = useState(true);\n  const [categoriesOpen, setCategoriesOpen] = useState(true);\n  const [otherFieldsOpen, setOtherFieldsOpen] = useState(false);\n\n  const [expandedAgents, setExpandedAgents] = useState<Record<string, boolean>>(\n    {},\n  );\n  const [expandedCategories, setExpandedCategories] = useState<\n    Record<string, boolean>\n  >({});\n  const [agentAdvancedDrafts, setAgentAdvancedDrafts] = useState<\n    Record<string, string>\n  >({});\n  const [categoryAdvancedDrafts, setCategoryAdvancedDrafts] = useState<\n    Record<string, string>\n  >({});\n\n  const [customAgents, setCustomAgents] = useState<CustomModelItem[]>(() =>\n    collectCustomModels(agents, builtinAgentKeys),\n  );\n\n  const [customCategories, setCustomCategories] = useState<CustomModelItem[]>(\n    () => collectCustomModels(categories, BUILTIN_CATEGORY_KEYS),\n  );\n\n  useEffect(() => {\n    setCustomAgents(collectCustomModels(agents, builtinAgentKeys));\n  }, [agents]);\n\n  useEffect(() => {\n    setCustomCategories(collectCustomModels(categories, BUILTIN_CATEGORY_KEYS));\n  }, [categories]);\n\n  const syncCustomAgents = useCallback(\n    (customs: CustomModelItem[]) => {\n      onAgentsChange(\n        mergeCustomModelsIntoStore(\n          agents,\n          builtinAgentKeys,\n          customs,\n          modelVariantsMap,\n        ),\n      );\n    },\n    [agents, onAgentsChange, modelVariantsMap, builtinAgentKeys],\n  );\n\n  const syncCustomCategories = useCallback(\n    (customs: CustomModelItem[]) => {\n      if (!onCategoriesChange) return;\n      onCategoriesChange(\n        mergeCustomModelsIntoStore(\n          categories,\n          BUILTIN_CATEGORY_KEYS,\n          customs,\n          modelVariantsMap,\n        ),\n      );\n    },\n    [categories, onCategoriesChange, modelVariantsMap],\n  );\n\n  const buildEffectiveModelOptions = useCallback(\n    (currentModel: string): ModelOption[] => {\n      if (!currentModel) return modelOptions;\n      if (modelOptions.some((item) => item.value === currentModel)) {\n        return modelOptions;\n      }\n      return [\n        {\n          value: currentModel,\n          label: t(\"omo.currentValueNotEnabled\", {\n            value: currentModel,\n            defaultValue: \"{{value}} (current value, not enabled)\",\n          }),\n        },\n        ...modelOptions,\n      ];\n    },\n    [modelOptions, t],\n  );\n\n  const resolveRecommendedModel = useCallback(\n    (recommended?: string): string | undefined => {\n      if (!recommended || modelOptions.length === 0) return undefined;\n\n      const exact = modelOptions.find((item) => item.value === recommended);\n      if (exact) return exact.value;\n\n      const bySuffix = modelOptions.find((item) =>\n        item.value.endsWith(`/${recommended}`),\n      );\n      return bySuffix?.value;\n    },\n    [modelOptions],\n  );\n\n  const renderModelSelect = (\n    currentModel: string,\n    onChange: (value: string) => void,\n    recommended?: string,\n  ) => {\n    const options = buildEffectiveModelOptions(currentModel);\n    return (\n      <ModelCombobox\n        value={currentModel}\n        options={options}\n        recommended={recommended}\n        onChange={onChange}\n      />\n    );\n  };\n\n  const buildEffectiveVariantOptions = useCallback(\n    (currentModel: string, currentVariant: string): string[] => {\n      const variantKeys = modelVariantsMap[currentModel] || [];\n      if (!currentVariant || variantKeys.includes(currentVariant)) {\n        return variantKeys;\n      }\n      return [currentVariant, ...variantKeys];\n    },\n    [modelVariantsMap],\n  );\n\n  const renderVariantSelect = (\n    currentModel: string,\n    currentVariant: string,\n    onChange: (value: string) => void,\n  ) => {\n    const hasModel = Boolean(currentModel);\n    const modelVariantKeys = hasModel\n      ? modelVariantsMap[currentModel] || []\n      : [];\n    const hasVariants = modelVariantKeys.length > 0;\n    const shouldShow = hasModel && (hasVariants || Boolean(currentVariant));\n\n    if (!shouldShow) {\n      return null;\n    }\n\n    const variantOptions = buildEffectiveVariantOptions(\n      currentModel,\n      currentVariant,\n    );\n    const firstIsUnavailable =\n      Boolean(currentVariant) &&\n      !(modelVariantsMap[currentModel] || []).includes(currentVariant);\n\n    return (\n      <Select\n        value={currentVariant || EMPTY_VARIANT_VALUE}\n        onValueChange={(value) =>\n          onChange(value === EMPTY_VARIANT_VALUE ? \"\" : value)\n        }\n      >\n        <SelectTrigger className=\"w-28 h-8 text-xs shrink-0\">\n          <SelectValue\n            placeholder={t(\"omo.variantPlaceholder\", {\n              defaultValue: \"variant\",\n            })}\n          />\n        </SelectTrigger>\n        <SelectContent className=\"max-h-72\">\n          <SelectItem value={EMPTY_VARIANT_VALUE}>\n            {t(\"omo.defaultWrapped\", { defaultValue: \"(Default)\" })}\n          </SelectItem>\n          {variantOptions.map((variant, index) => (\n            <SelectItem key={`${variant}-${index}`} value={variant}>\n              {firstIsUnavailable && index === 0\n                ? t(\"omo.currentValueUnavailable\", {\n                    value: variant,\n                    defaultValue: \"{{value}} (current value, unavailable)\",\n                  })\n                : variant}\n            </SelectItem>\n          ))}\n        </SelectContent>\n      </Select>\n    );\n  };\n\n  const handleModelChange = (\n    key: string,\n    model: string,\n    store: Record<string, Record<string, unknown>>,\n    setter: (v: Record<string, Record<string, unknown>>) => void,\n  ) => {\n    if (model.trim()) {\n      const nextEntry: Record<string, unknown> = {\n        ...(store[key] || {}),\n        model,\n      };\n      const currentVariant =\n        typeof nextEntry.variant === \"string\" ? nextEntry.variant : \"\";\n      if (currentVariant) {\n        const validVariants = modelVariantsMap[model] || [];\n        if (!validVariants.includes(currentVariant)) {\n          delete nextEntry.variant;\n        }\n      }\n      setter({ ...store, [key]: nextEntry });\n    } else {\n      const existing = store[key];\n      if (existing) {\n        const adv = { ...existing };\n        delete adv.model;\n        delete adv.variant;\n        if (Object.keys(adv).length > 0) {\n          setter({ ...store, [key]: adv });\n        } else {\n          const next = { ...store };\n          delete next[key];\n          setter(next);\n        }\n      }\n    }\n  };\n\n  const handleVariantChange = (\n    key: string,\n    variant: string,\n    store: Record<string, Record<string, unknown>>,\n    setter: (v: Record<string, Record<string, unknown>>) => void,\n  ) => {\n    const existing = store[key];\n    if (variant.trim()) {\n      setter({ ...store, [key]: { ...existing, variant } });\n      return;\n    }\n\n    if (!existing) return;\n    const nextEntry = { ...existing };\n    delete nextEntry.variant;\n    if (Object.keys(nextEntry).length > 0) {\n      setter({ ...store, [key]: nextEntry });\n      return;\n    }\n\n    const next = { ...store };\n    delete next[key];\n    setter(next);\n  };\n\n  const handleAdvancedChange = (\n    key: string,\n    rawJson: string,\n    store: Record<string, Record<string, unknown>>,\n    setter: (v: Record<string, Record<string, unknown>>) => void,\n  ): boolean => {\n    const currentModel = (store[key]?.model as string) || \"\";\n    const currentVariant = (store[key]?.variant as string) || \"\";\n    if (!rawJson.trim()) {\n      if (currentModel || currentVariant) {\n        setter({\n          ...store,\n          [key]: {\n            ...(currentModel ? { model: currentModel } : {}),\n            ...(currentVariant ? { variant: currentVariant } : {}),\n          },\n        });\n      } else {\n        const next = { ...store };\n        delete next[key];\n        setter(next);\n      }\n      return true;\n    }\n    try {\n      const parsed = JSON.parse(rawJson);\n      if (\n        typeof parsed === \"object\" &&\n        parsed !== null &&\n        !Array.isArray(parsed)\n      ) {\n        const parsedAdvanced = { ...(parsed as Record<string, unknown>) };\n        delete parsedAdvanced.model;\n        delete parsedAdvanced.variant;\n        setter({\n          ...store,\n          [key]: {\n            ...(currentModel ? { model: currentModel } : {}),\n            ...(currentVariant ? { variant: currentVariant } : {}),\n            ...parsedAdvanced,\n          },\n        });\n        return true;\n      }\n      return false;\n    } catch {\n      return false;\n    }\n  };\n\n  type AdvancedScope = \"agent\" | \"category\";\n\n  const setAdvancedDraft = (\n    scope: AdvancedScope,\n    key: string,\n    value: string,\n  ) => {\n    if (scope === \"agent\") {\n      setAgentAdvancedDrafts((prev) => ({ ...prev, [key]: value }));\n      return;\n    }\n    setCategoryAdvancedDrafts((prev) => ({ ...prev, [key]: value }));\n  };\n\n  const removeAdvancedDraft = (scope: AdvancedScope, key: string) => {\n    if (scope === \"agent\") {\n      setAgentAdvancedDrafts((prev) => {\n        const copied = { ...prev };\n        delete copied[key];\n        return copied;\n      });\n      return;\n    }\n    setCategoryAdvancedDrafts((prev) => {\n      const copied = { ...prev };\n      delete copied[key];\n      return copied;\n    });\n  };\n\n  const toggleAdvancedEditor = (\n    scope: AdvancedScope,\n    key: string,\n    advStr: string,\n    isExpanded: boolean,\n  ) => {\n    const willOpen = !isExpanded;\n    if (scope === \"agent\") {\n      setExpandedAgents((prev) => ({ ...prev, [key]: willOpen }));\n      if (willOpen && agentAdvancedDrafts[key] === undefined) {\n        setAdvancedDraft(scope, key, advStr);\n      }\n      return;\n    }\n    setExpandedCategories((prev) => ({ ...prev, [key]: willOpen }));\n    if (willOpen && categoryAdvancedDrafts[key] === undefined) {\n      setAdvancedDraft(scope, key, advStr);\n    }\n  };\n\n  const renderAdvancedEditor = ({\n    scope,\n    draftKey,\n    configKey,\n    draftValue,\n    store,\n    setter,\n    showHint,\n  }: {\n    scope: AdvancedScope;\n    draftKey: string;\n    configKey: string;\n    draftValue: string;\n    store: Record<string, Record<string, unknown>>;\n    setter: (value: Record<string, Record<string, unknown>>) => void;\n    showHint?: boolean;\n  }) => (\n    <div className=\"pb-2 pl-2 pr-2\">\n      <Textarea\n        value={draftValue}\n        onChange={(e) => setAdvancedDraft(scope, draftKey, e.target.value)}\n        onBlur={(e) => {\n          if (!handleAdvancedChange(configKey, e.target.value, store, setter)) {\n            toast.error(\n              t(\"omo.advancedJsonInvalid\", {\n                defaultValue: \"Advanced JSON is invalid\",\n              }),\n            );\n          }\n        }}\n        placeholder={ADVANCED_PLACEHOLDER}\n        className=\"font-mono text-xs min-h-[130px] py-3\"\n      />\n      {showHint && (\n        <p className=\"text-[10px] text-muted-foreground mt-1\">\n          {t(\"omo.advancedJsonHint\", {\n            defaultValue:\n              \"temperature, top_p, budgetTokens, prompt_append, permission, etc. Leave empty for defaults\",\n          })}\n        </p>\n      )}\n    </div>\n  );\n\n  const handleFillAllRecommended = () => {\n    if (modelOptions.length === 0) {\n      toast.warning(\n        t(\"omo.noEnabledModelsWarning\", {\n          defaultValue:\n            \"No configured models available. Configure OpenCode models first.\",\n        }),\n      );\n      return;\n    }\n\n    let filledCount = 0;\n    let alreadySetCount = 0;\n    let unmatchedCount = 0;\n\n    const updatedAgents = { ...agents };\n    for (const agentDef of builtinAgentDefs) {\n      const recommendedValue = resolveRecommendedModel(agentDef.recommended);\n      if (!recommendedValue) {\n        unmatchedCount++;\n      } else if (updatedAgents[agentDef.key]?.model) {\n        alreadySetCount++;\n      } else {\n        updatedAgents[agentDef.key] = {\n          ...updatedAgents[agentDef.key],\n          model: recommendedValue,\n        };\n        filledCount++;\n      }\n    }\n    onAgentsChange(updatedAgents);\n\n    if (!isSlim && onCategoriesChange) {\n      const updatedCategories = { ...categories };\n      for (const catDef of OMO_BUILTIN_CATEGORIES) {\n        const recommendedValue = resolveRecommendedModel(catDef.recommended);\n        if (!recommendedValue) {\n          unmatchedCount++;\n        } else if (updatedCategories[catDef.key]?.model) {\n          alreadySetCount++;\n        } else {\n          updatedCategories[catDef.key] = {\n            ...updatedCategories[catDef.key],\n            model: recommendedValue,\n          };\n          filledCount++;\n        }\n      }\n      onCategoriesChange(updatedCategories);\n    }\n\n    if (filledCount > 0 && unmatchedCount === 0) {\n      toast.success(\n        t(\"omo.fillRecommendedSuccess\", {\n          defaultValue: \"Filled {{count}} recommended models\",\n          count: filledCount,\n        }),\n      );\n    } else if (filledCount > 0 && unmatchedCount > 0) {\n      toast.success(\n        t(\"omo.fillRecommendedPartial\", {\n          defaultValue:\n            \"Filled {{filled}} recommended models, {{unmatched}} unmatched\",\n          filled: filledCount,\n          unmatched: unmatchedCount,\n        }),\n      );\n    } else if (alreadySetCount > 0 && unmatchedCount === 0) {\n      toast.info(\n        t(\"omo.fillRecommendedAllSet\", {\n          defaultValue: \"All slots already have models configured\",\n        }),\n      );\n    } else {\n      toast.warning(\n        t(\"omo.fillRecommendedNoMatch\", {\n          defaultValue: \"Recommended models not found in configured providers\",\n        }),\n      );\n    }\n  };\n\n  const configuredAgentCount = Object.keys(agents).length;\n  const configuredCategoryCount = isSlim ? 0 : Object.keys(categories).length;\n  const mainAgents = builtinAgentDefs.filter((a) => a.group === \"main\");\n  const subAgents = builtinAgentDefs.filter((a) => a.group === \"sub\");\n\n  const readLocalFile = useReadOmoLocalFile();\n  const readSlimLocalFile = useReadOmoSlimLocalFile();\n  const [localFilePath, setLocalFilePath] = useState<string | null>(null);\n\n  const handleImportFromLocal = useCallback(async () => {\n    try {\n      const data = isSlim\n        ? await readSlimLocalFile.mutateAsync()\n        : await readLocalFile.mutateAsync();\n      const importedAgents =\n        (data.agents as Record<string, Record<string, unknown>> | undefined) ||\n        {};\n      const importedCategories =\n        (data.categories as\n          | Record<string, Record<string, unknown>>\n          | undefined) || {};\n\n      onAgentsChange(importedAgents);\n      if (!isSlim && onCategoriesChange) {\n        onCategoriesChange(importedCategories);\n      }\n      onOtherFieldsStrChange(\n        data.otherFields ? JSON.stringify(data.otherFields, null, 2) : \"\",\n      );\n      setAgentAdvancedDrafts({});\n      setCategoryAdvancedDrafts({});\n      setCustomAgents(collectCustomModels(importedAgents, builtinAgentKeys));\n      if (!isSlim) {\n        setCustomCategories(\n          collectCustomModels(importedCategories, BUILTIN_CATEGORY_KEYS),\n        );\n      }\n      setLocalFilePath(data.filePath);\n      toast.success(\n        t(\"omo.importLocalReplaceSuccess\", {\n          defaultValue:\n            \"Imported local file and replaced Agents/Categories/Other Fields\",\n        }),\n      );\n    } catch (err) {\n      toast.error(\n        t(\"omo.importLocalFailed\", {\n          error: String(err),\n          defaultValue: \"Failed to read local file: {{error}}\",\n        }),\n      );\n    }\n  }, [\n    readLocalFile,\n    onAgentsChange,\n    onCategoriesChange,\n    onOtherFieldsStrChange,\n    t,\n  ]);\n\n  const renderBuiltinModelRow = (\n    scope: AdvancedScope,\n    def: BuiltinModelDef,\n  ) => {\n    const isAgent = scope === \"agent\";\n    const store = isAgent ? agents : categories;\n    const setter = isAgent ? onAgentsChange : onCategoriesChange!;\n    const drafts = isAgent ? agentAdvancedDrafts : categoryAdvancedDrafts;\n    const expanded = isAgent ? expandedAgents : expandedCategories;\n\n    const key = def.key;\n    const currentModel = (store[key]?.model as string) || \"\";\n    const currentVariant = (store[key]?.variant as string) || \"\";\n    const advStr = getAdvancedStr(store[key]);\n    const draftValue = drafts[key] ?? advStr;\n    const isExpanded = expanded[key] ?? false;\n\n    return (\n      <div key={key} className=\"border-b border-border/30 last:border-b-0\">\n        <div className=\"flex items-center gap-2 py-1.5\">\n          <div className=\"w-32 shrink-0\">\n            <div className=\"flex items-center gap-1 text-sm font-medium\">\n              {def.display}\n              <span className=\"relative inline-flex group/tip\">\n                <HelpCircle className=\"h-3.5 w-3.5 text-muted-foreground/60 hover:text-muted-foreground cursor-help shrink-0\" />\n                <span className=\"invisible opacity-0 group-hover/tip:visible group-hover/tip:opacity-100 transition-opacity duration-150 absolute left-0 top-full mt-1 z-50 w-[260px] rounded-md bg-popover text-popover-foreground border border-border shadow-md px-3 py-2 text-xs leading-relaxed font-normal pointer-events-none\">\n                  {t(def.tooltipKey)}\n                </span>\n              </span>\n            </div>\n            <div className=\"text-xs text-muted-foreground truncate\">\n              {t(def.descKey)}\n            </div>\n          </div>\n          {renderModelSelect(\n            currentModel,\n            (value) => handleModelChange(key, value, store, setter),\n            def.recommended,\n          )}\n          {renderVariantSelect(currentModel, currentVariant, (value) =>\n            handleVariantChange(key, value, store, setter),\n          )}\n          <Button\n            type=\"button\"\n            variant={isExpanded ? \"secondary\" : \"ghost\"}\n            size=\"icon\"\n            className={cn(\"h-7 w-7 shrink-0\", advStr && \"text-primary\")}\n            onClick={() => toggleAdvancedEditor(scope, key, advStr, isExpanded)}\n            title={t(\"omo.advancedLabel\", { defaultValue: \"Advanced\" })}\n          >\n            <Settings className=\"h-3.5 w-3.5\" />\n          </Button>\n        </div>\n        {isExpanded &&\n          renderAdvancedEditor({\n            scope,\n            draftKey: key,\n            configKey: key,\n            draftValue,\n            store,\n            setter,\n            showHint: true,\n          })}\n      </div>\n    );\n  };\n\n  const renderAgentRow = (agentDef: OmoAgentDef) =>\n    renderBuiltinModelRow(\"agent\", agentDef);\n\n  const renderCategoryRow = (catDef: OmoCategoryDef) =>\n    renderBuiltinModelRow(\"category\", catDef);\n\n  const renderCustomModelRow = (\n    scope: AdvancedScope,\n    item: CustomModelItem,\n    index: number,\n  ) => {\n    const isAgent = scope === \"agent\";\n    const store = isAgent ? agents : categories;\n    const setter = isAgent ? onAgentsChange : onCategoriesChange!;\n    const drafts = isAgent ? agentAdvancedDrafts : categoryAdvancedDrafts;\n    const expanded = isAgent ? expandedAgents : expandedCategories;\n    const customs = isAgent ? customAgents : customCategories;\n    const setCustoms = isAgent ? setCustomAgents : setCustomCategories;\n    const syncCustoms = isAgent ? syncCustomAgents : syncCustomCategories;\n\n    const rowPrefix = isAgent ? \"custom-agent\" : \"custom-cat\";\n    const emptyKeyPrefix = isAgent ? \"__custom_agent_\" : \"__custom_cat_\";\n    const keyPlaceholder = isAgent\n      ? t(\"omo.agentKeyPlaceholder\", { defaultValue: \"agent key\" })\n      : t(\"omo.categoryKeyPlaceholder\", { defaultValue: \"category key\" });\n\n    const key = item.key || `${emptyKeyPrefix}${index}`;\n    const currentVariant =\n      item.key && typeof store[item.key]?.variant === \"string\"\n        ? (store[item.key]?.variant as string) || \"\"\n        : \"\";\n    const advStr = item.key ? getAdvancedStr(store[item.key]) : \"\";\n    const draftValue = drafts[key] ?? advStr;\n    const isExpanded = expanded[key] ?? false;\n\n    const updateCustom = (patch: Partial<CustomModelItem>) => {\n      const next = [...customs];\n      next[index] = { ...next[index], ...patch };\n      setCustoms(next);\n      syncCustoms(next);\n    };\n\n    return (\n      <div\n        key={`${rowPrefix}-${index}`}\n        className=\"border-b border-border/30 last:border-b-0\"\n      >\n        <div className=\"flex items-center gap-2 py-1.5\">\n          <DeferredKeyInput\n            value={item.key}\n            onCommit={(value) => updateCustom({ key: value })}\n            placeholder={keyPlaceholder}\n            className=\"w-32 shrink-0 h-8 text-sm text-primary\"\n          />\n          {renderModelSelect(item.model, (value) =>\n            updateCustom({ model: value }),\n          )}\n          {renderVariantSelect(item.model, currentVariant, (value) => {\n            if (!item.key) return;\n            handleVariantChange(item.key, value, store, setter);\n          })}\n          <Button\n            type=\"button\"\n            variant={isExpanded ? \"secondary\" : \"ghost\"}\n            size=\"icon\"\n            className={cn(\"h-7 w-7 shrink-0\", advStr && \"text-primary\")}\n            onClick={() => toggleAdvancedEditor(scope, key, advStr, isExpanded)}\n            title={t(\"omo.advancedLabel\", { defaultValue: \"Advanced\" })}\n          >\n            <Settings className=\"h-3.5 w-3.5\" />\n          </Button>\n          <Button\n            type=\"button\"\n            variant=\"ghost\"\n            size=\"icon\"\n            className=\"h-7 w-7 shrink-0 text-destructive\"\n            onClick={() => {\n              const next = customs.filter((_, idx) => idx !== index);\n              setCustoms(next);\n              syncCustoms(next);\n              removeAdvancedDraft(scope, key);\n            }}\n          >\n            <Trash2 className=\"h-3.5 w-3.5\" />\n          </Button>\n        </div>\n        {isExpanded &&\n          item.key &&\n          renderAdvancedEditor({\n            scope,\n            draftKey: key,\n            configKey: item.key,\n            draftValue,\n            store,\n            setter,\n          })}\n      </div>\n    );\n  };\n\n  const SectionHeader = ({\n    title,\n    isOpen,\n    onToggle,\n    badge,\n    action,\n  }: {\n    title: string;\n    isOpen: boolean;\n    onToggle: () => void;\n    badge?: React.ReactNode | string;\n    action?: React.ReactNode;\n  }) => (\n    <button\n      type=\"button\"\n      className=\"flex items-center justify-between w-full py-2 px-3 text-left\"\n      onClick={onToggle}\n    >\n      <div className=\"flex items-center gap-2\">\n        {isOpen ? (\n          <ChevronDown className=\"h-4 w-4 text-muted-foreground\" />\n        ) : (\n          <ChevronRight className=\"h-4 w-4 text-muted-foreground\" />\n        )}\n        <Label className=\"text-sm font-semibold cursor-pointer\">{title}</Label>\n        {typeof badge === \"string\" ? (\n          <Badge variant=\"outline\" className=\"text-[10px] h-5\">\n            {badge}\n          </Badge>\n        ) : (\n          badge\n        )}\n      </div>\n      {action && <div onClick={(e) => e.stopPropagation()}>{action}</div>}\n    </button>\n  );\n\n  const renderModelSection = ({\n    title,\n    isOpen,\n    onToggle,\n    badge,\n    action,\n    maxHeightClass = \"max-h-[5000px]\",\n    children,\n  }: {\n    title: string;\n    isOpen: boolean;\n    onToggle: () => void;\n    badge?: React.ReactNode | string;\n    action?: React.ReactNode;\n    maxHeightClass?: string;\n    children: React.ReactNode;\n  }) => (\n    <div className=\"rounded-lg border border-border/60\">\n      <SectionHeader\n        title={title}\n        isOpen={isOpen}\n        onToggle={onToggle}\n        badge={badge}\n        action={action}\n      />\n      <div\n        className={cn(\n          \"overflow-hidden transition-all duration-200\",\n          isOpen ? `${maxHeightClass} opacity-100` : \"max-h-0 opacity-0\",\n        )}\n      >\n        <div className=\"px-3 pb-3\">{children}</div>\n      </div>\n    </div>\n  );\n\n  const renderCustomAddButton = (onClick: () => void) => (\n    <Button\n      type=\"button\"\n      variant=\"ghost\"\n      size=\"sm\"\n      className=\"h-6 text-xs\"\n      onClick={onClick}\n    >\n      <Plus className=\"h-3.5 w-3.5 mr-1\" />\n      {t(\"omo.custom\", { defaultValue: \"Custom\" })}\n    </Button>\n  );\n\n  const renderCustomDivider = (label: string) => (\n    <div className=\"flex items-center gap-2 py-2\">\n      <div className=\"flex-1 border-t border-border/40\" />\n      <span className=\"text-[10px] text-muted-foreground\">{label}</span>\n      <div className=\"flex-1 border-t border-border/40\" />\n    </div>\n  );\n\n  const addCustomModel = (scope: AdvancedScope) => {\n    if (scope === \"agent\") {\n      setCustomAgents((prev) => [\n        ...prev,\n        { key: \"\", model: \"\", sourceKey: \"\" },\n      ]);\n      setSubAgentsOpen(true);\n      return;\n    }\n    setCustomCategories((prev) => [\n      ...prev,\n      { key: \"\", model: \"\", sourceKey: \"\" },\n    ]);\n    setCategoriesOpen(true);\n  };\n\n  return (\n    <div className=\"space-y-2\">\n      <div className=\"flex items-center justify-between\">\n        <Label className=\"text-sm font-semibold\">\n          {t(\"omo.modelConfiguration\", { defaultValue: \"Model Configuration\" })}\n        </Label>\n        <div className=\"flex items-center gap-1.5\">\n          <Button\n            type=\"button\"\n            variant=\"outline\"\n            size=\"sm\"\n            className=\"h-7 text-xs\"\n            disabled={readLocalFile.isPending}\n            onClick={handleImportFromLocal}\n          >\n            {readLocalFile.isPending ? (\n              <Loader2 className=\"h-3.5 w-3.5 mr-1 animate-spin\" />\n            ) : (\n              <FolderInput className=\"h-3.5 w-3.5 mr-1\" />\n            )}\n            {t(\"omo.importLocal\", { defaultValue: \"Import Local\" })}\n          </Button>\n          <Button\n            type=\"button\"\n            variant=\"outline\"\n            size=\"sm\"\n            className=\"h-7 text-xs\"\n            onClick={handleFillAllRecommended}\n          >\n            <Wand2 className=\"h-3.5 w-3.5 mr-1\" />\n            {t(\"omo.fillRecommended\", { defaultValue: \"Fill Recommended\" })}\n          </Button>\n        </div>\n      </div>\n\n      <div className=\"text-xs text-muted-foreground\">\n        {t(\"omo.configSummary\", {\n          agents: configuredAgentCount,\n          categories: configuredCategoryCount,\n          defaultValue:\n            \"{{agents}} agents, {{categories}} categories configured · Click ⚙ for advanced params\",\n        })}\n        <span className=\"ml-1\">\n          ·{\" \"}\n          {t(\"omo.enabledModelsCount\", {\n            count: modelOptions.length,\n            defaultValue: \"{{count}} configured models available\",\n          })}\n        </span>\n        {localFilePath && (\n          <span className=\"ml-1 text-primary/70\">\n            · {t(\"omo.source\", { defaultValue: \"from:\" })}{\" \"}\n            <span className=\"font-mono text-[10px]\">\n              {localFilePath.replace(/^.*\\//, \"\")}\n            </span>\n          </span>\n        )}\n      </div>\n\n      {renderModelSection({\n        title: t(\"omo.mainAgents\", { defaultValue: \"Main Agents\" }),\n        isOpen: mainAgentsOpen,\n        onToggle: () => setMainAgentsOpen(!mainAgentsOpen),\n        badge: `${mainAgents.length}`,\n        children: mainAgents.map(renderAgentRow),\n      })}\n\n      {renderModelSection({\n        title: t(\"omo.subAgents\", { defaultValue: \"Sub Agents\" }),\n        isOpen: subAgentsOpen,\n        onToggle: () => setSubAgentsOpen(!subAgentsOpen),\n        badge: `${subAgents.length + customAgents.length}`,\n        action: renderCustomAddButton(() => addCustomModel(\"agent\")),\n        children: (\n          <>\n            {subAgents.map(renderAgentRow)}\n            {customAgents.length > 0 && (\n              <>\n                {renderCustomDivider(\n                  t(\"omo.customAgents\", { defaultValue: \"Custom Agents\" }),\n                )}\n                {customAgents.map((a, i) =>\n                  renderCustomModelRow(\"agent\", a, i),\n                )}\n              </>\n            )}\n          </>\n        ),\n      })}\n\n      {!isSlim &&\n        renderModelSection({\n          title: t(\"omo.categories\", { defaultValue: \"Categories\" }),\n          isOpen: categoriesOpen,\n          onToggle: () => setCategoriesOpen(!categoriesOpen),\n          badge: `${OMO_BUILTIN_CATEGORIES.length + customCategories.length}`,\n          action: renderCustomAddButton(() => addCustomModel(\"category\")),\n          children: (\n            <>\n              {OMO_BUILTIN_CATEGORIES.map(renderCategoryRow)}\n              {customCategories.length > 0 && (\n                <>\n                  {renderCustomDivider(\n                    t(\"omo.customCategories\", {\n                      defaultValue: \"Custom Categories\",\n                    }),\n                  )}\n                  {customCategories.map((c, i) =>\n                    renderCustomModelRow(\"category\", c, i),\n                  )}\n                </>\n              )}\n            </>\n          ),\n        })}\n\n      {renderModelSection({\n        title: t(\"omo.otherFieldsJson\", {\n          defaultValue: \"Other Fields (JSON)\",\n        }),\n        isOpen: otherFieldsOpen,\n        onToggle: () => setOtherFieldsOpen(!otherFieldsOpen),\n        badge:\n          !otherFieldsOpen && otherFieldsStr.trim() ? (\n            <Badge\n              variant=\"secondary\"\n              className=\"text-[10px] h-5 font-mono max-w-[200px] truncate\"\n            >\n              {otherFieldsStr.trim().slice(0, 40)}\n              {otherFieldsStr.trim().length > 40 ? \"...\" : \"\"}\n            </Badge>\n          ) : undefined,\n        maxHeightClass: \"max-h-[500px]\",\n        children: (\n          <Textarea\n            value={otherFieldsStr}\n            onChange={(e) => onOtherFieldsStrChange(e.target.value)}\n            placeholder='{ \"custom_key\": \"value\" }'\n            className=\"font-mono text-xs min-h-[60px]\"\n          />\n        ),\n      })}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/providers/forms/OpenClawFormFields.tsx",
    "content": "import { useTranslation } from \"react-i18next\";\nimport { useState, useRef, useCallback } from \"react\";\nimport { FormLabel } from \"@/components/ui/form\";\nimport { Input } from \"@/components/ui/input\";\nimport { Button } from \"@/components/ui/button\";\nimport { Switch } from \"@/components/ui/switch\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport {\n  Collapsible,\n  CollapsibleContent,\n  CollapsibleTrigger,\n} from \"@/components/ui/collapsible\";\nimport { Plus, Trash2, ChevronDown, ChevronRight } from \"lucide-react\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport { ApiKeySection } from \"./shared\";\nimport { openclawApiProtocols } from \"@/config/openclawProviderPresets\";\nimport type { ProviderCategory, OpenClawModel } from \"@/types\";\n\ninterface OpenClawFormFieldsProps {\n  // Base URL\n  baseUrl: string;\n  onBaseUrlChange: (value: string) => void;\n\n  // API Key\n  apiKey: string;\n  onApiKeyChange: (value: string) => void;\n  category?: ProviderCategory;\n  shouldShowApiKeyLink: boolean;\n  websiteUrl: string;\n  isPartner?: boolean;\n  partnerPromotionKey?: string;\n\n  // API Protocol\n  api: string;\n  onApiChange: (value: string) => void;\n\n  // Models\n  models: OpenClawModel[];\n  onModelsChange: (models: OpenClawModel[]) => void;\n\n  // User-Agent\n  userAgent: boolean;\n  onUserAgentChange: (checked: boolean) => void;\n}\n\nexport function OpenClawFormFields({\n  baseUrl,\n  onBaseUrlChange,\n  apiKey,\n  onApiKeyChange,\n  category,\n  shouldShowApiKeyLink,\n  websiteUrl,\n  isPartner,\n  partnerPromotionKey,\n  api,\n  onApiChange,\n  models,\n  onModelsChange,\n  userAgent,\n  onUserAgentChange,\n}: OpenClawFormFieldsProps) {\n  const { t } = useTranslation();\n  const [expandedModels, setExpandedModels] = useState<Record<number, boolean>>(\n    {},\n  );\n\n  // Stable key tracking for models list\n  const modelKeysRef = useRef<string[]>([]);\n  const getModelKeys = useCallback(() => {\n    // Grow keys array if models were added externally\n    while (modelKeysRef.current.length < models.length) {\n      modelKeysRef.current.push(crypto.randomUUID());\n    }\n    // Shrink if models were removed externally\n    if (modelKeysRef.current.length > models.length) {\n      modelKeysRef.current.length = models.length;\n    }\n    return modelKeysRef.current;\n  }, [models.length]);\n  const modelKeys = getModelKeys();\n\n  // Toggle advanced section for a model\n  const toggleModelAdvanced = (index: number) => {\n    setExpandedModels((prev) => ({ ...prev, [index]: !prev[index] }));\n  };\n\n  // Add a new model entry\n  const handleAddModel = () => {\n    modelKeysRef.current.push(crypto.randomUUID());\n    onModelsChange([\n      ...models,\n      {\n        id: \"\",\n        name: \"\",\n        contextWindow: undefined,\n        maxTokens: undefined,\n        cost: undefined,\n        input: [\"text\"],\n      },\n    ]);\n  };\n\n  // Remove a model entry\n  const handleRemoveModel = (index: number) => {\n    modelKeysRef.current.splice(index, 1);\n    const newModels = [...models];\n    newModels.splice(index, 1);\n    onModelsChange(newModels);\n    // Clean up expanded state\n    setExpandedModels((prev) => {\n      const updated = { ...prev };\n      delete updated[index];\n      return updated;\n    });\n  };\n\n  // Update model field\n  const handleModelChange = (\n    index: number,\n    field: keyof OpenClawModel,\n    value: unknown,\n  ) => {\n    const newModels = [...models];\n    newModels[index] = { ...newModels[index], [field]: value };\n    onModelsChange(newModels);\n  };\n\n  // Update model cost\n  const handleCostChange = (\n    index: number,\n    costField: \"input\" | \"output\" | \"cacheRead\" | \"cacheWrite\",\n    value: string,\n  ) => {\n    const newModels = [...models];\n    const numValue = parseFloat(value);\n    const currentCost = newModels[index].cost || { input: 0, output: 0 };\n    newModels[index] = {\n      ...newModels[index],\n      cost: {\n        ...currentCost,\n        [costField]: isNaN(numValue) ? undefined : numValue,\n      },\n    };\n    onModelsChange(newModels);\n  };\n\n  return (\n    <>\n      {/* API Protocol Selector */}\n      <div className=\"space-y-2\">\n        <FormLabel htmlFor=\"openclaw-api\">\n          {t(\"openclaw.apiProtocol\", {\n            defaultValue: \"API 协议\",\n          })}\n        </FormLabel>\n        <Select value={api} onValueChange={onApiChange}>\n          <SelectTrigger id=\"openclaw-api\">\n            <SelectValue\n              placeholder={t(\"openclaw.selectProtocol\", {\n                defaultValue: \"选择 API 协议\",\n              })}\n            />\n          </SelectTrigger>\n          <SelectContent>\n            {openclawApiProtocols.map((protocol) => (\n              <SelectItem key={protocol.value} value={protocol.value}>\n                {protocol.label}\n              </SelectItem>\n            ))}\n          </SelectContent>\n        </Select>\n        <p className=\"text-xs text-muted-foreground\">\n          {t(\"openclaw.apiProtocolHint\", {\n            defaultValue:\n              \"选择与供应商 API 兼容的协议类型。大多数供应商使用 OpenAI Completions 格式。\",\n          })}\n        </p>\n      </div>\n\n      {/* Base URL */}\n      <div className=\"space-y-2\">\n        <FormLabel htmlFor=\"openclaw-baseurl\">\n          {t(\"openclaw.baseUrl\", { defaultValue: \"API 端点\" })}\n        </FormLabel>\n        <Input\n          id=\"openclaw-baseurl\"\n          value={baseUrl}\n          onChange={(e) => onBaseUrlChange(e.target.value)}\n          placeholder=\"https://api.example.com/v1\"\n        />\n        <p className=\"text-xs text-muted-foreground\">\n          {t(\"openclaw.baseUrlHint\", {\n            defaultValue: \"供应商的 API 端点地址。\",\n          })}\n        </p>\n      </div>\n\n      {/* API Key */}\n      <ApiKeySection\n        value={apiKey}\n        onChange={onApiKeyChange}\n        category={category}\n        shouldShowLink={shouldShowApiKeyLink}\n        websiteUrl={websiteUrl}\n        isPartner={isPartner}\n        partnerPromotionKey={partnerPromotionKey}\n      />\n\n      {/* User-Agent */}\n      <div className=\"flex items-center justify-between\">\n        <div className=\"space-y-0.5\">\n          <FormLabel>\n            {t(\"openclaw.userAgent\", { defaultValue: \"发送 User-Agent\" })}\n          </FormLabel>\n          <p className=\"text-xs text-muted-foreground\">\n            {t(\"openclaw.userAgentHint\", {\n              defaultValue: \"部分供应商需要浏览器 User-Agent 才能正常访问。\",\n            })}\n          </p>\n        </div>\n        <Switch checked={userAgent} onCheckedChange={onUserAgentChange} />\n      </div>\n\n      {/* Models Editor */}\n      <div className=\"space-y-3\">\n        <div className=\"flex items-center justify-between\">\n          <FormLabel>\n            {t(\"openclaw.models\", { defaultValue: \"模型列表\" })}\n          </FormLabel>\n          <Button\n            type=\"button\"\n            variant=\"outline\"\n            size=\"sm\"\n            onClick={handleAddModel}\n            className=\"h-7 gap-1\"\n          >\n            <Plus className=\"h-3.5 w-3.5\" />\n            {t(\"openclaw.addModel\", { defaultValue: \"添加模型\" })}\n          </Button>\n        </div>\n\n        {models.length === 0 ? (\n          <p className=\"text-sm text-muted-foreground py-2\">\n            {t(\"openclaw.noModels\", {\n              defaultValue: \"暂无模型配置。点击添加模型来配置可用模型。\",\n            })}\n          </p>\n        ) : (\n          <div className=\"space-y-4\">\n            {models.map((model, index) => (\n              <div\n                key={modelKeys[index]}\n                className=\"p-3 border border-border/50 rounded-lg space-y-3\"\n              >\n                {/* Role badge */}\n                <div className=\"flex items-center\">\n                  <span\n                    className={`text-[10px] font-medium px-1.5 py-0.5 rounded ${\n                      index === 0\n                        ? \"bg-blue-500/15 text-blue-600 dark:text-blue-400\"\n                        : \"bg-muted text-muted-foreground\"\n                    }`}\n                  >\n                    {index === 0\n                      ? t(\"openclaw.primaryModel\", {\n                          defaultValue: \"默认模型\",\n                        })\n                      : t(\"openclaw.fallbackModel\", {\n                          defaultValue: \"回退模型\",\n                        })}\n                  </span>\n                </div>\n                {/* Model ID and Name row */}\n                <div className=\"flex items-center gap-2\">\n                  <div className=\"flex-1 space-y-1\">\n                    <label className=\"text-xs text-muted-foreground\">\n                      {t(\"openclaw.modelId\", { defaultValue: \"模型 ID\" })}\n                    </label>\n                    <Input\n                      value={model.id}\n                      onChange={(e) =>\n                        handleModelChange(index, \"id\", e.target.value)\n                      }\n                      placeholder={t(\"openclaw.modelIdPlaceholder\", {\n                        defaultValue: \"claude-3-sonnet\",\n                      })}\n                    />\n                  </div>\n                  <div className=\"flex-1 space-y-1\">\n                    <label className=\"text-xs text-muted-foreground\">\n                      {t(\"openclaw.modelName\", { defaultValue: \"显示名称\" })}\n                    </label>\n                    <Input\n                      value={model.name}\n                      onChange={(e) =>\n                        handleModelChange(index, \"name\", e.target.value)\n                      }\n                      placeholder={t(\"openclaw.modelNamePlaceholder\", {\n                        defaultValue: \"Claude 3 Sonnet\",\n                      })}\n                    />\n                  </div>\n                  <Button\n                    type=\"button\"\n                    variant=\"ghost\"\n                    size=\"icon\"\n                    onClick={() => handleRemoveModel(index)}\n                    className=\"h-9 w-9 mt-5 text-muted-foreground hover:text-destructive\"\n                  >\n                    <Trash2 className=\"h-4 w-4\" />\n                  </Button>\n                </div>\n\n                {/* Advanced Options (Collapsible) */}\n                <Collapsible\n                  open={expandedModels[index] ?? false}\n                  onOpenChange={() => toggleModelAdvanced(index)}\n                >\n                  <CollapsibleTrigger asChild>\n                    <Button\n                      type=\"button\"\n                      variant=\"ghost\"\n                      size=\"sm\"\n                      className=\"h-7 gap-1 text-xs text-muted-foreground hover:text-foreground\"\n                    >\n                      {expandedModels[index] ? (\n                        <ChevronDown className=\"h-3.5 w-3.5\" />\n                      ) : (\n                        <ChevronRight className=\"h-3.5 w-3.5\" />\n                      )}\n                      {t(\"openclaw.advancedOptions\", {\n                        defaultValue: \"高级选项\",\n                      })}\n                    </Button>\n                  </CollapsibleTrigger>\n                  <CollapsibleContent className=\"space-y-3 pt-2\">\n                    {/* Reasoning, Input Types row */}\n                    <div className=\"flex items-center gap-2\">\n                      <div className=\"flex-1 space-y-1\">\n                        <label className=\"text-xs text-muted-foreground\">\n                          {t(\"openclaw.reasoning\", {\n                            defaultValue: \"推理模式\",\n                          })}\n                        </label>\n                        <div className=\"flex items-center h-9 gap-2\">\n                          <Switch\n                            checked={model.reasoning ?? false}\n                            onCheckedChange={(checked) =>\n                              handleModelChange(index, \"reasoning\", checked)\n                            }\n                          />\n                          <span className=\"text-xs text-muted-foreground\">\n                            {model.reasoning\n                              ? t(\"openclaw.reasoningOn\", {\n                                  defaultValue: \"启用\",\n                                })\n                              : t(\"openclaw.reasoningOff\", {\n                                  defaultValue: \"关闭\",\n                                })}\n                          </span>\n                        </div>\n                      </div>\n                      <div className=\"flex-1 space-y-1\">\n                        <label className=\"text-xs text-muted-foreground\">\n                          {t(\"openclaw.inputTypes\", {\n                            defaultValue: \"输入类型\",\n                          })}\n                        </label>\n                        {/* \"text\" is checked by default but can be unchecked —\n                            some models genuinely don't support text input, and\n                            OpenClaw works fine with an empty or image-only array. */}\n                        <div className=\"flex items-center gap-4 h-9\">\n                          {([\"text\", \"image\"] as const).map((type) => (\n                            <label\n                              key={type}\n                              className=\"flex items-center gap-1.5 cursor-pointer select-none\"\n                            >\n                              <Checkbox\n                                checked={(model.input ?? [\"text\"]).includes(\n                                  type,\n                                )}\n                                onCheckedChange={(checked) => {\n                                  const current = model.input ?? [\"text\"];\n                                  const next = checked\n                                    ? [...new Set([...current, type])]\n                                    : current.filter((v) => v !== type);\n                                  handleModelChange(index, \"input\", next);\n                                }}\n                              />\n                              <span className=\"text-xs\">{type}</span>\n                            </label>\n                          ))}\n                        </div>\n                      </div>\n                      <div className=\"flex-1\" />\n                    </div>\n\n                    {/* Context Window and Max Tokens row */}\n                    <div className=\"flex items-center gap-2\">\n                      <div className=\"flex-1 space-y-1\">\n                        <label className=\"text-xs text-muted-foreground\">\n                          {t(\"openclaw.contextWindow\", {\n                            defaultValue: \"上下文窗口\",\n                          })}\n                        </label>\n                        <Input\n                          type=\"number\"\n                          value={model.contextWindow ?? \"\"}\n                          onChange={(e) =>\n                            handleModelChange(\n                              index,\n                              \"contextWindow\",\n                              e.target.value\n                                ? parseInt(e.target.value)\n                                : undefined,\n                            )\n                          }\n                          placeholder=\"200000\"\n                        />\n                      </div>\n                      <div className=\"flex-1 space-y-1\">\n                        <label className=\"text-xs text-muted-foreground\">\n                          {t(\"openclaw.maxTokens\", {\n                            defaultValue: \"最大输出 Tokens\",\n                          })}\n                        </label>\n                        <Input\n                          type=\"number\"\n                          value={model.maxTokens ?? \"\"}\n                          onChange={(e) =>\n                            handleModelChange(\n                              index,\n                              \"maxTokens\",\n                              e.target.value\n                                ? parseInt(e.target.value)\n                                : undefined,\n                            )\n                          }\n                          placeholder=\"32000\"\n                        />\n                      </div>\n                      <div className=\"flex-1\" />\n                    </div>\n\n                    {/* Cost row */}\n                    <div className=\"flex items-center gap-2\">\n                      <div className=\"flex-1 space-y-1\">\n                        <label className=\"text-xs text-muted-foreground\">\n                          {t(\"openclaw.inputCost\", {\n                            defaultValue: \"输入价格 ($/M tokens)\",\n                          })}\n                        </label>\n                        <Input\n                          type=\"number\"\n                          step=\"0.001\"\n                          value={model.cost?.input ?? \"\"}\n                          onChange={(e) =>\n                            handleCostChange(index, \"input\", e.target.value)\n                          }\n                          placeholder=\"3\"\n                        />\n                      </div>\n                      <div className=\"flex-1 space-y-1\">\n                        <label className=\"text-xs text-muted-foreground\">\n                          {t(\"openclaw.outputCost\", {\n                            defaultValue: \"输出价格 ($/M tokens)\",\n                          })}\n                        </label>\n                        <Input\n                          type=\"number\"\n                          step=\"0.001\"\n                          value={model.cost?.output ?? \"\"}\n                          onChange={(e) =>\n                            handleCostChange(index, \"output\", e.target.value)\n                          }\n                          placeholder=\"15\"\n                        />\n                      </div>\n                      <div className=\"flex-1\" />\n                    </div>\n\n                    {/* Cache Cost row */}\n                    <div className=\"flex items-center gap-2\">\n                      <div className=\"flex-1 space-y-1\">\n                        <label className=\"text-xs text-muted-foreground\">\n                          {t(\"openclaw.cacheReadCost\", {\n                            defaultValue: \"缓存读取价格 ($/M tokens)\",\n                          })}\n                        </label>\n                        <Input\n                          type=\"number\"\n                          step=\"0.001\"\n                          value={model.cost?.cacheRead ?? \"\"}\n                          onChange={(e) =>\n                            handleCostChange(index, \"cacheRead\", e.target.value)\n                          }\n                          placeholder=\"0.3\"\n                        />\n                      </div>\n                      <div className=\"flex-1 space-y-1\">\n                        <label className=\"text-xs text-muted-foreground\">\n                          {t(\"openclaw.cacheWriteCost\", {\n                            defaultValue: \"缓存写入价格 ($/M tokens)\",\n                          })}\n                        </label>\n                        <Input\n                          type=\"number\"\n                          step=\"0.001\"\n                          value={model.cost?.cacheWrite ?? \"\"}\n                          onChange={(e) =>\n                            handleCostChange(\n                              index,\n                              \"cacheWrite\",\n                              e.target.value,\n                            )\n                          }\n                          placeholder=\"3.75\"\n                        />\n                      </div>\n                      <div className=\"flex-1\" />\n                    </div>\n                    <p className=\"text-xs text-muted-foreground\">\n                      {t(\"openclaw.cacheCostHint\", {\n                        defaultValue:\n                          \"缓存价格用于计算 Prompt Caching 的成本。如不使用缓存可留空。\",\n                      })}\n                    </p>\n                  </CollapsibleContent>\n                </Collapsible>\n              </div>\n            ))}\n          </div>\n        )}\n\n        <p className=\"text-xs text-muted-foreground\">\n          {t(\"openclaw.modelsHint\", {\n            defaultValue:\n              \"配置该供应商支持的模型。第一个模型为默认模型（Primary），其余为回退模型（Fallback）。拖拽或调整顺序可更改默认模型。\",\n          })}\n        </p>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "src/components/providers/forms/OpenCodeFormFields.tsx",
    "content": "import { useState, useEffect } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { FormLabel } from \"@/components/ui/form\";\nimport { Input } from \"@/components/ui/input\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { Plus, Trash2, ChevronRight } from \"lucide-react\";\nimport { ApiKeySection } from \"./shared\";\nimport { opencodeNpmPackages } from \"@/config/opencodeProviderPresets\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  getModelExtraFields,\n  isKnownModelKey,\n} from \"./helpers/opencodeFormUtils\";\nimport type { ProviderCategory, OpenCodeModel } from \"@/types\";\n\n/**\n * Model ID input with local state to prevent focus loss.\n * The key prop issue: when Model ID changes, React sees it as a new element\n * and unmounts/remounts the input, losing focus. Using local state + onBlur\n * keeps the key stable during editing.\n */\nfunction ModelIdInput({\n  modelId,\n  onChange,\n  placeholder,\n}: {\n  modelId: string;\n  onChange: (newId: string) => void;\n  placeholder?: string;\n}) {\n  const [localValue, setLocalValue] = useState(modelId);\n\n  // Sync when external modelId changes (e.g., undo operation)\n  useEffect(() => {\n    setLocalValue(modelId);\n  }, [modelId]);\n\n  return (\n    <Input\n      value={localValue}\n      onChange={(e) => setLocalValue(e.target.value)}\n      onBlur={() => {\n        if (localValue !== modelId && localValue.trim()) {\n          onChange(localValue);\n        }\n      }}\n      placeholder={placeholder}\n      className=\"flex-1\"\n    />\n  );\n}\n\n/**\n * Extra option key input with local state to prevent focus loss.\n * Same pattern as ModelIdInput - use local state during editing,\n * only commit changes on blur.\n */\nfunction ExtraOptionKeyInput({\n  optionKey,\n  onChange,\n  placeholder,\n}: {\n  optionKey: string;\n  onChange: (newKey: string) => void;\n  placeholder?: string;\n}) {\n  // For new options with placeholder keys like \"option-123\", show empty string\n  const displayValue = optionKey.startsWith(\"option-\") ? \"\" : optionKey;\n  const [localValue, setLocalValue] = useState(displayValue);\n\n  // Sync when external key changes\n  useEffect(() => {\n    setLocalValue(optionKey.startsWith(\"option-\") ? \"\" : optionKey);\n  }, [optionKey]);\n\n  return (\n    <Input\n      value={localValue}\n      onChange={(e) => setLocalValue(e.target.value)}\n      onBlur={() => {\n        const trimmed = localValue.trim();\n        if (trimmed && trimmed !== optionKey) {\n          onChange(trimmed);\n        }\n      }}\n      placeholder={placeholder}\n      className=\"flex-1\"\n    />\n  );\n}\n\n/**\n * Model option key input with local state to prevent focus loss.\n * Reuses the same pattern as ExtraOptionKeyInput.\n */\nfunction ModelOptionKeyInput({\n  optionKey,\n  onChange,\n  placeholder,\n}: {\n  optionKey: string;\n  onChange: (newKey: string) => void;\n  placeholder?: string;\n}) {\n  const displayValue = optionKey.startsWith(\"option-\") ? \"\" : optionKey;\n  const [localValue, setLocalValue] = useState(displayValue);\n\n  useEffect(() => {\n    setLocalValue(optionKey.startsWith(\"option-\") ? \"\" : optionKey);\n  }, [optionKey]);\n\n  return (\n    <Input\n      value={localValue}\n      onChange={(e) => setLocalValue(e.target.value)}\n      onBlur={() => {\n        const trimmed = localValue.trim();\n        if (trimmed && trimmed !== optionKey) {\n          onChange(trimmed);\n        }\n        // Reset to prop value: if parent accepted the rename, useEffect\n        // will update localValue when the new optionKey prop arrives;\n        // if parent rejected, this restores the correct display.\n        setLocalValue(optionKey.startsWith(\"option-\") ? \"\" : optionKey);\n      }}\n      placeholder={placeholder}\n      className=\"flex-1\"\n    />\n  );\n}\n\ninterface OpenCodeFormFieldsProps {\n  // NPM Package\n  npm: string;\n  onNpmChange: (value: string) => void;\n\n  // API Key\n  apiKey: string;\n  onApiKeyChange: (value: string) => void;\n  category?: ProviderCategory;\n  shouldShowApiKeyLink: boolean;\n  websiteUrl: string;\n  isPartner?: boolean;\n  partnerPromotionKey?: string;\n\n  // Base URL\n  baseUrl: string;\n  onBaseUrlChange: (value: string) => void;\n\n  // Models\n  models: Record<string, OpenCodeModel>;\n  onModelsChange: (models: Record<string, OpenCodeModel>) => void;\n\n  // Extra Options\n  extraOptions: Record<string, string>;\n  onExtraOptionsChange: (options: Record<string, string>) => void;\n}\n\nexport function OpenCodeFormFields({\n  npm,\n  onNpmChange,\n  apiKey,\n  onApiKeyChange,\n  category,\n  shouldShowApiKeyLink,\n  websiteUrl,\n  isPartner,\n  partnerPromotionKey,\n  baseUrl,\n  onBaseUrlChange,\n  models,\n  onModelsChange,\n  extraOptions,\n  onExtraOptionsChange,\n}: OpenCodeFormFieldsProps) {\n  const { t } = useTranslation();\n\n  // Track which models have expanded options panel\n  const [expandedModels, setExpandedModels] = useState<Set<string>>(new Set());\n\n  // Toggle model expand state\n  const toggleModelExpand = (key: string) => {\n    setExpandedModels((prev) => {\n      const next = new Set(prev);\n      if (next.has(key)) next.delete(key);\n      else next.add(key);\n      return next;\n    });\n  };\n\n  // Add a new model entry\n  const handleAddModel = () => {\n    const newKey = `model-${Date.now()}`;\n    onModelsChange({\n      ...models,\n      [newKey]: { name: \"\" },\n    });\n  };\n\n  // Remove a model entry\n  const handleRemoveModel = (key: string) => {\n    const newModels = { ...models };\n    delete newModels[key];\n    onModelsChange(newModels);\n    // Also remove from expanded set\n    setExpandedModels((prev) => {\n      const next = new Set(prev);\n      next.delete(key);\n      return next;\n    });\n  };\n\n  // Update model ID (key)\n  const handleModelIdChange = (oldKey: string, newKey: string) => {\n    if (oldKey === newKey || !newKey.trim()) return;\n    const newModels: Record<string, OpenCodeModel> = {};\n    for (const [k, v] of Object.entries(models)) {\n      if (k === oldKey) {\n        newModels[newKey] = v;\n      } else {\n        newModels[k] = v;\n      }\n    }\n    onModelsChange(newModels);\n    // Update expanded set if this model was expanded\n    if (expandedModels.has(oldKey)) {\n      setExpandedModels((prev) => {\n        const next = new Set(prev);\n        next.delete(oldKey);\n        next.add(newKey);\n        return next;\n      });\n    }\n  };\n\n  // Update model name\n  const handleModelNameChange = (key: string, name: string) => {\n    onModelsChange({\n      ...models,\n      [key]: { ...models[key], name },\n    });\n  };\n\n  // Model options handlers\n  const handleAddModelOption = (modelKey: string) => {\n    const model = models[modelKey];\n    const newOptionKey = `option-${Date.now()}`;\n    onModelsChange({\n      ...models,\n      [modelKey]: {\n        ...model,\n        options: { ...model.options, [newOptionKey]: \"\" },\n      },\n    });\n  };\n\n  const handleRemoveModelOption = (modelKey: string, optionKey: string) => {\n    const model = models[modelKey];\n    const newOptions = { ...model.options };\n    delete newOptions[optionKey];\n    onModelsChange({\n      ...models,\n      [modelKey]: {\n        ...model,\n        options: Object.keys(newOptions).length > 0 ? newOptions : undefined,\n      },\n    });\n  };\n\n  const handleModelOptionKeyChange = (\n    modelKey: string,\n    oldKey: string,\n    newKey: string,\n  ) => {\n    if (!newKey.trim() || oldKey === newKey) return;\n    const model = models[modelKey];\n    const newOptions: Record<string, unknown> = {};\n    for (const [k, v] of Object.entries(model.options || {})) {\n      if (k === oldKey) newOptions[newKey] = v;\n      else newOptions[k] = v;\n    }\n    onModelsChange({\n      ...models,\n      [modelKey]: { ...model, options: newOptions },\n    });\n  };\n\n  const handleModelOptionValueChange = (\n    modelKey: string,\n    optionKey: string,\n    value: string,\n  ) => {\n    const model = models[modelKey];\n    let parsedValue: unknown;\n    try {\n      parsedValue = JSON.parse(value);\n    } catch {\n      parsedValue = value;\n    }\n    onModelsChange({\n      ...models,\n      [modelKey]: {\n        ...model,\n        options: { ...model.options, [optionKey]: parsedValue },\n      },\n    });\n  };\n\n  // Model extra field handlers (top-level properties like variants, cost)\n  const handleAddModelExtraField = (modelKey: string) => {\n    const model = models[modelKey];\n    const newFieldKey = `option-${Date.now()}`;\n    onModelsChange({\n      ...models,\n      [modelKey]: { ...model, [newFieldKey]: \"\" },\n    });\n  };\n\n  const handleRemoveModelExtraField = (modelKey: string, fieldKey: string) => {\n    const model = models[modelKey];\n    const newModel = { ...model };\n    delete newModel[fieldKey];\n    onModelsChange({\n      ...models,\n      [modelKey]: newModel,\n    });\n  };\n\n  const handleModelExtraFieldKeyChange = (\n    modelKey: string,\n    oldKey: string,\n    newKey: string,\n  ) => {\n    if (!newKey.trim() || oldKey === newKey) return;\n    const model = models[modelKey];\n    // Reject reserved keys and duplicate extra field names\n    if (isKnownModelKey(newKey) || (newKey !== oldKey && newKey in model))\n      return;\n    const newModel: Record<string, unknown> = {};\n    for (const [k, v] of Object.entries(model)) {\n      if (k === oldKey) newModel[newKey] = v;\n      else newModel[k] = v;\n    }\n    onModelsChange({\n      ...models,\n      [modelKey]: newModel as OpenCodeModel,\n    });\n  };\n\n  const handleModelExtraFieldValueChange = (\n    modelKey: string,\n    fieldKey: string,\n    value: string,\n  ) => {\n    const model = models[modelKey];\n    let parsedValue: unknown;\n    try {\n      parsedValue = JSON.parse(value);\n    } catch {\n      parsedValue = value;\n    }\n    onModelsChange({\n      ...models,\n      [modelKey]: { ...model, [fieldKey]: parsedValue },\n    });\n  };\n\n  // Extra Options handlers\n  const handleAddExtraOption = () => {\n    const newKey = `option-${Date.now()}`;\n    onExtraOptionsChange({\n      ...extraOptions,\n      [newKey]: \"\",\n    });\n  };\n\n  const handleRemoveExtraOption = (key: string) => {\n    const newOptions = { ...extraOptions };\n    delete newOptions[key];\n    onExtraOptionsChange(newOptions);\n  };\n\n  const handleExtraOptionKeyChange = (oldKey: string, newKey: string) => {\n    if (oldKey === newKey) return;\n    const newOptions: Record<string, string> = {};\n    for (const [k, v] of Object.entries(extraOptions)) {\n      if (k === oldKey) {\n        newOptions[newKey.trim() || oldKey] = v;\n      } else {\n        newOptions[k] = v;\n      }\n    }\n    onExtraOptionsChange(newOptions);\n  };\n\n  const handleExtraOptionValueChange = (key: string, value: string) => {\n    onExtraOptionsChange({\n      ...extraOptions,\n      [key]: value,\n    });\n  };\n\n  return (\n    <>\n      {/* NPM Package Selector */}\n      <div className=\"space-y-2\">\n        <FormLabel htmlFor=\"opencode-npm\">\n          {t(\"opencode.npmPackage\", {\n            defaultValue: \"接口格式\",\n          })}\n        </FormLabel>\n        <Select value={npm} onValueChange={onNpmChange}>\n          <SelectTrigger id=\"opencode-npm\">\n            <SelectValue\n              placeholder={t(\"opencode.selectPackage\", {\n                defaultValue: \"Select a package\",\n              })}\n            />\n          </SelectTrigger>\n          <SelectContent>\n            {opencodeNpmPackages.map((pkg) => (\n              <SelectItem key={pkg.value} value={pkg.value}>\n                {pkg.label}\n              </SelectItem>\n            ))}\n          </SelectContent>\n        </Select>\n        <p className=\"text-xs text-muted-foreground\">\n          {t(\"opencode.npmPackageHint\", {\n            defaultValue:\n              \"Select the AI SDK package that matches your provider.\",\n          })}\n        </p>\n      </div>\n\n      {/* API Key */}\n      <ApiKeySection\n        value={apiKey}\n        onChange={onApiKeyChange}\n        category={category}\n        shouldShowLink={shouldShowApiKeyLink}\n        websiteUrl={websiteUrl}\n        isPartner={isPartner}\n        partnerPromotionKey={partnerPromotionKey}\n      />\n\n      {/* Base URL */}\n      <div className=\"space-y-2\">\n        <FormLabel htmlFor=\"opencode-baseurl\">\n          {t(\"opencode.baseUrl\", { defaultValue: \"Base URL\" })}\n        </FormLabel>\n        <Input\n          id=\"opencode-baseurl\"\n          value={baseUrl}\n          onChange={(e) => onBaseUrlChange(e.target.value)}\n          placeholder=\"https://api.example.com/v1\"\n        />\n        <p className=\"text-xs text-muted-foreground\">\n          {t(\"opencode.baseUrlHint\", {\n            defaultValue:\n              \"The base URL for the API endpoint. Leave empty to use the default endpoint for official SDKs.\",\n          })}\n        </p>\n      </div>\n\n      {/* Extra Options Editor */}\n      <div className=\"space-y-3\">\n        <div className=\"flex items-center justify-between\">\n          <FormLabel>\n            {t(\"opencode.extraOptions\", { defaultValue: \"额外选项\" })}\n          </FormLabel>\n          <Button\n            type=\"button\"\n            variant=\"outline\"\n            size=\"sm\"\n            onClick={handleAddExtraOption}\n            className=\"h-7 gap-1\"\n          >\n            <Plus className=\"h-3.5 w-3.5\" />\n            {t(\"opencode.addExtraOption\", { defaultValue: \"添加\" })}\n          </Button>\n        </div>\n\n        {Object.keys(extraOptions).length === 0 ? (\n          <p className=\"text-sm text-muted-foreground py-2\">\n            {t(\"opencode.noExtraOptions\", {\n              defaultValue: \"暂无额外选项\",\n            })}\n          </p>\n        ) : (\n          <div className=\"space-y-2\">\n            <div className=\"flex items-center gap-2 text-xs text-muted-foreground px-1 mb-1\">\n              <span className=\"flex-1\">\n                {t(\"opencode.extraOptionKey\", { defaultValue: \"键名\" })}\n              </span>\n              <span className=\"flex-1\">\n                {t(\"opencode.extraOptionValue\", { defaultValue: \"值\" })}\n              </span>\n              <span className=\"w-9\" />\n            </div>\n            {Object.entries(extraOptions).map(([key, value]) => (\n              <div key={key} className=\"flex items-center gap-2\">\n                <ExtraOptionKeyInput\n                  optionKey={key}\n                  onChange={(newKey) => handleExtraOptionKeyChange(key, newKey)}\n                  placeholder={t(\"opencode.extraOptionKeyPlaceholder\", {\n                    defaultValue: \"timeout\",\n                  })}\n                />\n                <Input\n                  value={value}\n                  onChange={(e) =>\n                    handleExtraOptionValueChange(key, e.target.value)\n                  }\n                  placeholder={t(\"opencode.extraOptionValuePlaceholder\", {\n                    defaultValue: \"600000\",\n                  })}\n                  className=\"flex-1\"\n                />\n                <Button\n                  type=\"button\"\n                  variant=\"ghost\"\n                  size=\"icon\"\n                  onClick={() => handleRemoveExtraOption(key)}\n                  className=\"h-9 w-9 text-muted-foreground hover:text-destructive\"\n                >\n                  <Trash2 className=\"h-4 w-4\" />\n                </Button>\n              </div>\n            ))}\n          </div>\n        )}\n\n        <p className=\"text-xs text-muted-foreground\">\n          {t(\"opencode.extraOptionsHint\", {\n            defaultValue:\n              \"配置额外的 SDK 选项，如 timeout、setCacheKey 等。值会自动解析类型（数字、布尔值等）。\",\n          })}\n        </p>\n      </div>\n\n      {/* Models Editor */}\n      <div className=\"space-y-3\">\n        <div className=\"flex items-center justify-between\">\n          <FormLabel>\n            {t(\"opencode.models\", { defaultValue: \"Models\" })}\n          </FormLabel>\n          <Button\n            type=\"button\"\n            variant=\"outline\"\n            size=\"sm\"\n            onClick={handleAddModel}\n            className=\"h-7 gap-1\"\n          >\n            <Plus className=\"h-3.5 w-3.5\" />\n            {t(\"opencode.addModel\", { defaultValue: \"Add\" })}\n          </Button>\n        </div>\n\n        {Object.keys(models).length === 0 ? (\n          <p className=\"text-sm text-muted-foreground py-2\">\n            {t(\"opencode.noModels\", {\n              defaultValue: \"No models configured. Click Add to add a model.\",\n            })}\n          </p>\n        ) : (\n          <div className=\"space-y-2\">\n            <div className=\"flex items-center gap-2 text-xs text-muted-foreground px-1 mb-1\">\n              <span className=\"w-9\" />\n              <span className=\"flex-1\">\n                {t(\"opencode.modelId\", { defaultValue: \"模型 ID\" })}\n              </span>\n              <span className=\"flex-1\">\n                {t(\"opencode.modelName\", { defaultValue: \"显示名称\" })}\n              </span>\n              <span className=\"w-9\" />\n            </div>\n            {Object.entries(models).map(([key, model]) => (\n              <div key={key} className=\"space-y-2\">\n                {/* Model row */}\n                <div className=\"flex items-center gap-2\">\n                  <Button\n                    type=\"button\"\n                    variant=\"ghost\"\n                    size=\"icon\"\n                    onClick={() => toggleModelExpand(key)}\n                    className=\"h-9 w-9 shrink-0\"\n                  >\n                    <ChevronRight\n                      className={cn(\n                        \"h-4 w-4 transition-transform\",\n                        expandedModels.has(key) && \"rotate-90\",\n                      )}\n                    />\n                  </Button>\n                  <ModelIdInput\n                    modelId={key}\n                    onChange={(newId) => handleModelIdChange(key, newId)}\n                    placeholder={t(\"opencode.modelId\", {\n                      defaultValue: \"Model ID\",\n                    })}\n                  />\n                  <Input\n                    value={model.name}\n                    onChange={(e) => handleModelNameChange(key, e.target.value)}\n                    placeholder={t(\"opencode.modelName\", {\n                      defaultValue: \"Display Name\",\n                    })}\n                    className=\"flex-1\"\n                  />\n                  <Button\n                    type=\"button\"\n                    variant=\"ghost\"\n                    size=\"icon\"\n                    onClick={() => handleRemoveModel(key)}\n                    className=\"h-9 w-9 text-muted-foreground hover:text-destructive\"\n                  >\n                    <Trash2 className=\"h-4 w-4\" />\n                  </Button>\n                </div>\n\n                {/* Expanded model details */}\n                {expandedModels.has(key) && (\n                  <div className=\"ml-9 pl-4 border-l-2 border-muted space-y-3\">\n                    {/* Model Properties (extra fields like variants, cost) */}\n                    <div className=\"space-y-2\">\n                      <div className=\"flex items-center justify-between\">\n                        <span className=\"text-xs font-medium text-muted-foreground\">\n                          {t(\"opencode.modelExtraFields\", {\n                            defaultValue: \"模型属性\",\n                          })}\n                        </span>\n                        <Button\n                          type=\"button\"\n                          variant=\"ghost\"\n                          size=\"sm\"\n                          onClick={() => handleAddModelExtraField(key)}\n                          className=\"h-6 px-2 gap-1\"\n                        >\n                          <Plus className=\"h-3 w-3\" />\n                        </Button>\n                      </div>\n                      {Object.keys(getModelExtraFields(model)).length === 0 ? (\n                        <p className=\"text-xs text-muted-foreground py-1\">\n                          {t(\"opencode.noModelExtraFields\", {\n                            defaultValue:\n                              \"模型属性 (variants, cost 等)，点击 + 添加\",\n                          })}\n                        </p>\n                      ) : (\n                        Object.entries(getModelExtraFields(model)).map(\n                          ([fKey, fValue]) => (\n                            <div key={fKey} className=\"flex items-center gap-2\">\n                              <ModelOptionKeyInput\n                                optionKey={fKey}\n                                onChange={(newKey) =>\n                                  handleModelExtraFieldKeyChange(\n                                    key,\n                                    fKey,\n                                    newKey,\n                                  )\n                                }\n                                placeholder={t(\n                                  \"opencode.modelExtraFieldKeyPlaceholder\",\n                                  {\n                                    defaultValue: \"variants\",\n                                  },\n                                )}\n                              />\n                              <Input\n                                value={fValue}\n                                onChange={(e) =>\n                                  handleModelExtraFieldValueChange(\n                                    key,\n                                    fKey,\n                                    e.target.value,\n                                  )\n                                }\n                                placeholder={t(\n                                  \"opencode.modelOptionValuePlaceholder\",\n                                  {\n                                    defaultValue: '{\"order\": [\"baseten\"]}',\n                                  },\n                                )}\n                                className=\"flex-1\"\n                              />\n                              <Button\n                                type=\"button\"\n                                variant=\"ghost\"\n                                size=\"icon\"\n                                onClick={() =>\n                                  handleRemoveModelExtraField(key, fKey)\n                                }\n                                className=\"h-9 w-9 text-muted-foreground hover:text-destructive\"\n                              >\n                                <Trash2 className=\"h-4 w-4\" />\n                              </Button>\n                            </div>\n                          ),\n                        )\n                      )}\n                    </div>\n\n                    {/* SDK Options (model.options) */}\n                    <div className=\"space-y-2\">\n                      <div className=\"flex items-center justify-between\">\n                        <span className=\"text-xs font-medium text-muted-foreground\">\n                          {t(\"opencode.sdkOptions\", {\n                            defaultValue: \"SDK 选项\",\n                          })}\n                        </span>\n                        <Button\n                          type=\"button\"\n                          variant=\"ghost\"\n                          size=\"sm\"\n                          onClick={() => handleAddModelOption(key)}\n                          className=\"h-6 px-2 gap-1\"\n                        >\n                          <Plus className=\"h-3 w-3\" />\n                        </Button>\n                      </div>\n                      {Object.keys(model.options || {}).length === 0 ? (\n                        <p className=\"text-xs text-muted-foreground py-1\">\n                          {t(\"opencode.noModelOptions\", {\n                            defaultValue: \"模型选项，点击 + 添加\",\n                          })}\n                        </p>\n                      ) : (\n                        Object.entries(model.options || {}).map(\n                          ([optKey, optValue]) => (\n                            <div\n                              key={optKey}\n                              className=\"flex items-center gap-2\"\n                            >\n                              <ModelOptionKeyInput\n                                optionKey={optKey}\n                                onChange={(newKey) =>\n                                  handleModelOptionKeyChange(\n                                    key,\n                                    optKey,\n                                    newKey,\n                                  )\n                                }\n                                placeholder={t(\n                                  \"opencode.modelOptionKeyPlaceholder\",\n                                  {\n                                    defaultValue: \"provider\",\n                                  },\n                                )}\n                              />\n                              <Input\n                                value={\n                                  typeof optValue === \"string\"\n                                    ? optValue\n                                    : JSON.stringify(optValue)\n                                }\n                                onChange={(e) =>\n                                  handleModelOptionValueChange(\n                                    key,\n                                    optKey,\n                                    e.target.value,\n                                  )\n                                }\n                                placeholder={t(\n                                  \"opencode.modelOptionValuePlaceholder\",\n                                  {\n                                    defaultValue: '{\"order\": [\"baseten\"]}',\n                                  },\n                                )}\n                                className=\"flex-1\"\n                              />\n                              <Button\n                                type=\"button\"\n                                variant=\"ghost\"\n                                size=\"icon\"\n                                onClick={() =>\n                                  handleRemoveModelOption(key, optKey)\n                                }\n                                className=\"h-9 w-9 text-muted-foreground hover:text-destructive\"\n                              >\n                                <Trash2 className=\"h-4 w-4\" />\n                              </Button>\n                            </div>\n                          ),\n                        )\n                      )}\n                    </div>\n                  </div>\n                )}\n              </div>\n            ))}\n          </div>\n        )}\n\n        <p className=\"text-xs text-muted-foreground\">\n          {t(\"opencode.modelsHint\", {\n            defaultValue:\n              \"Configure available models. Model ID is the API identifier, Display Name is shown in the UI.\",\n          })}\n        </p>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "src/components/providers/forms/ProviderAdvancedConfig.tsx",
    "content": "import { useTranslation } from \"react-i18next\";\nimport { useState, useEffect } from \"react\";\nimport {\n  ChevronDown,\n  ChevronRight,\n  FlaskConical,\n  Globe,\n  Coins,\n  Eye,\n  EyeOff,\n  X,\n} from \"lucide-react\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { Switch } from \"@/components/ui/switch\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { cn } from \"@/lib/utils\";\nimport type { ProviderTestConfig, ProviderProxyConfig } from \"@/types\";\n\nexport type PricingModelSourceOption = \"inherit\" | \"request\" | \"response\";\n\ninterface ProviderPricingConfig {\n  enabled: boolean;\n  costMultiplier?: string;\n  pricingModelSource: PricingModelSourceOption;\n}\n\ninterface ProviderAdvancedConfigProps {\n  testConfig: ProviderTestConfig;\n  proxyConfig: ProviderProxyConfig;\n  pricingConfig: ProviderPricingConfig;\n  onTestConfigChange: (config: ProviderTestConfig) => void;\n  onProxyConfigChange: (config: ProviderProxyConfig) => void;\n  onPricingConfigChange: (config: ProviderPricingConfig) => void;\n}\n\n/** 从 ProviderProxyConfig 构建完整 URL */\nfunction buildProxyUrl(config: ProviderProxyConfig): string {\n  if (!config.proxyHost) return \"\";\n\n  const protocol = config.proxyType || \"http\";\n  const host = config.proxyHost;\n  const port = config.proxyPort || (protocol === \"socks5\" ? 1080 : 7890);\n\n  return `${protocol}://${host}:${port}`;\n}\n\n/** 从完整 URL 解析为 ProviderProxyConfig */\nfunction parseProxyUrl(url: string): Partial<ProviderProxyConfig> {\n  if (!url.trim()) {\n    return { proxyHost: undefined, proxyPort: undefined, proxyType: undefined };\n  }\n\n  try {\n    const parsed = new URL(url);\n    const protocol = parsed.protocol.replace(\":\", \"\") as\n      | \"http\"\n      | \"https\"\n      | \"socks5\";\n    const host = parsed.hostname;\n    const port = parsed.port ? parseInt(parsed.port, 10) : undefined;\n\n    return {\n      proxyType: protocol,\n      proxyHost: host || undefined,\n      proxyPort: port,\n    };\n  } catch {\n    // 尝试简单解析（不是标准 URL 格式）\n    const match = url.match(/^(?:(\\w+):\\/\\/)?([^:]+)(?::(\\d+))?$/);\n    if (match) {\n      return {\n        proxyType: (match[1] as \"http\" | \"https\" | \"socks5\") || \"http\",\n        proxyHost: match[2] || undefined,\n        proxyPort: match[3] ? parseInt(match[3], 10) : undefined,\n      };\n    }\n    return {};\n  }\n}\n\nexport function ProviderAdvancedConfig({\n  testConfig,\n  proxyConfig,\n  pricingConfig,\n  onTestConfigChange,\n  onProxyConfigChange,\n  onPricingConfigChange,\n}: ProviderAdvancedConfigProps) {\n  const { t } = useTranslation();\n  const [isTestConfigOpen, setIsTestConfigOpen] = useState(testConfig.enabled);\n  const [isProxyConfigOpen, setIsProxyConfigOpen] = useState(\n    proxyConfig.enabled,\n  );\n  const [isPricingConfigOpen, setIsPricingConfigOpen] = useState(\n    pricingConfig.enabled,\n  );\n  const [showPassword, setShowPassword] = useState(false);\n\n  // 代理 URL 输入状态（仅在初始化时从 proxyConfig 构建）\n  const [proxyUrl, setProxyUrl] = useState(() => buildProxyUrl(proxyConfig));\n\n  // 标记是否为用户主动输入（用于区分外部更新和用户输入）\n  const [isUserTyping, setIsUserTyping] = useState(false);\n\n  useEffect(() => {\n    setIsTestConfigOpen(testConfig.enabled);\n  }, [testConfig.enabled]);\n\n  // 同步外部 proxyConfig.enabled 变化到展开状态\n  useEffect(() => {\n    setIsProxyConfigOpen(proxyConfig.enabled);\n  }, [proxyConfig.enabled]);\n\n  // 同步外部 pricingConfig.enabled 变化到展开状态\n  useEffect(() => {\n    setIsPricingConfigOpen(pricingConfig.enabled);\n  }, [pricingConfig.enabled]);\n\n  // 仅在外部 proxyConfig 变化且非用户输入时同步（如：重置表单、加载数据）\n  useEffect(() => {\n    if (!isUserTyping) {\n      const newUrl = buildProxyUrl(proxyConfig);\n      if (newUrl !== proxyUrl) {\n        setProxyUrl(newUrl);\n      }\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [proxyConfig.proxyType, proxyConfig.proxyHost, proxyConfig.proxyPort]);\n\n  // 处理代理 URL 变化（用户输入时不触发 URL 重建）\n  const handleProxyUrlChange = (value: string) => {\n    setIsUserTyping(true);\n    setProxyUrl(value);\n    const parsed = parseProxyUrl(value);\n    onProxyConfigChange({\n      ...proxyConfig,\n      ...parsed,\n    });\n  };\n\n  // 输入框失焦时结束用户输入状态\n  const handleProxyUrlBlur = () => {\n    setIsUserTyping(false);\n  };\n\n  // 清除代理配置\n  const handleClearProxy = () => {\n    setProxyUrl(\"\");\n    onProxyConfigChange({\n      ...proxyConfig,\n      proxyType: undefined,\n      proxyHost: undefined,\n      proxyPort: undefined,\n      proxyUsername: undefined,\n      proxyPassword: undefined,\n    });\n  };\n\n  return (\n    <div className=\"space-y-4\">\n      <div className=\"rounded-lg border border-border/50 bg-muted/20\">\n        <button\n          type=\"button\"\n          className=\"flex w-full items-center justify-between p-4 hover:bg-muted/30 transition-colors\"\n          onClick={() => setIsTestConfigOpen(!isTestConfigOpen)}\n        >\n          <div className=\"flex items-center gap-3\">\n            <FlaskConical className=\"h-4 w-4 text-muted-foreground\" />\n            <span className=\"font-medium\">\n              {t(\"providerAdvanced.testConfig\", {\n                defaultValue: \"模型测试配置\",\n              })}\n            </span>\n          </div>\n          <div className=\"flex items-center gap-3\">\n            <div\n              className=\"flex items-center gap-2\"\n              onClick={(e) => e.stopPropagation()}\n            >\n              <Label\n                htmlFor=\"test-config-enabled\"\n                className=\"text-sm text-muted-foreground\"\n              >\n                {t(\"providerAdvanced.useCustomConfig\", {\n                  defaultValue: \"使用单独配置\",\n                })}\n              </Label>\n              <Switch\n                id=\"test-config-enabled\"\n                checked={testConfig.enabled}\n                onCheckedChange={(checked) => {\n                  onTestConfigChange({ ...testConfig, enabled: checked });\n                  if (checked) setIsTestConfigOpen(true);\n                }}\n              />\n            </div>\n            {isTestConfigOpen ? (\n              <ChevronDown className=\"h-4 w-4 text-muted-foreground\" />\n            ) : (\n              <ChevronRight className=\"h-4 w-4 text-muted-foreground\" />\n            )}\n          </div>\n        </button>\n        <div\n          className={cn(\n            \"overflow-hidden transition-all duration-200\",\n            isTestConfigOpen\n              ? \"max-h-[500px] opacity-100\"\n              : \"max-h-0 opacity-0\",\n          )}\n        >\n          <div className=\"border-t border-border/50 p-4 space-y-4\">\n            <p className=\"text-sm text-muted-foreground\">\n              {t(\"providerAdvanced.testConfigDesc\", {\n                defaultValue:\n                  \"为此供应商配置单独的模型测试参数，不启用时使用全局配置。\",\n              })}\n            </p>\n            <div className=\"grid grid-cols-1 md:grid-cols-2 gap-4\">\n              <div className=\"space-y-2\">\n                <Label htmlFor=\"test-model\">\n                  {t(\"providerAdvanced.testModel\", {\n                    defaultValue: \"测试模型\",\n                  })}\n                </Label>\n                <Input\n                  id=\"test-model\"\n                  value={testConfig.testModel || \"\"}\n                  onChange={(e) =>\n                    onTestConfigChange({\n                      ...testConfig,\n                      testModel: e.target.value || undefined,\n                    })\n                  }\n                  placeholder={t(\"providerAdvanced.testModelPlaceholder\", {\n                    defaultValue: \"留空使用全局配置\",\n                  })}\n                  disabled={!testConfig.enabled}\n                />\n              </div>\n              <div className=\"space-y-2\">\n                <Label htmlFor=\"test-timeout\">\n                  {t(\"providerAdvanced.timeoutSecs\", {\n                    defaultValue: \"超时时间（秒）\",\n                  })}\n                </Label>\n                <Input\n                  id=\"test-timeout\"\n                  type=\"number\"\n                  min={1}\n                  max={300}\n                  value={testConfig.timeoutSecs || \"\"}\n                  onChange={(e) =>\n                    onTestConfigChange({\n                      ...testConfig,\n                      timeoutSecs: e.target.value\n                        ? parseInt(e.target.value, 10)\n                        : undefined,\n                    })\n                  }\n                  placeholder=\"45\"\n                  disabled={!testConfig.enabled}\n                />\n              </div>\n              <div className=\"space-y-2\">\n                <Label htmlFor=\"test-prompt\">\n                  {t(\"providerAdvanced.testPrompt\", {\n                    defaultValue: \"测试提示词\",\n                  })}\n                </Label>\n                <Input\n                  id=\"test-prompt\"\n                  value={testConfig.testPrompt || \"\"}\n                  onChange={(e) =>\n                    onTestConfigChange({\n                      ...testConfig,\n                      testPrompt: e.target.value || undefined,\n                    })\n                  }\n                  placeholder=\"Who are you?\"\n                  disabled={!testConfig.enabled}\n                />\n              </div>\n              <div className=\"space-y-2\">\n                <Label htmlFor=\"degraded-threshold\">\n                  {t(\"providerAdvanced.degradedThreshold\", {\n                    defaultValue: \"降级阈值（毫秒）\",\n                  })}\n                </Label>\n                <Input\n                  id=\"degraded-threshold\"\n                  type=\"number\"\n                  min={100}\n                  max={60000}\n                  value={testConfig.degradedThresholdMs || \"\"}\n                  onChange={(e) =>\n                    onTestConfigChange({\n                      ...testConfig,\n                      degradedThresholdMs: e.target.value\n                        ? parseInt(e.target.value, 10)\n                        : undefined,\n                    })\n                  }\n                  placeholder=\"6000\"\n                  disabled={!testConfig.enabled}\n                />\n              </div>\n              <div className=\"space-y-2\">\n                <Label htmlFor=\"max-retries\">\n                  {t(\"providerAdvanced.maxRetries\", {\n                    defaultValue: \"最大重试次数\",\n                  })}\n                </Label>\n                <Input\n                  id=\"max-retries\"\n                  type=\"number\"\n                  min={0}\n                  max={10}\n                  value={testConfig.maxRetries ?? \"\"}\n                  onChange={(e) =>\n                    onTestConfigChange({\n                      ...testConfig,\n                      maxRetries: e.target.value\n                        ? parseInt(e.target.value, 10)\n                        : undefined,\n                    })\n                  }\n                  placeholder=\"2\"\n                  disabled={!testConfig.enabled}\n                />\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      {/* 代理配置 */}\n      <div className=\"rounded-lg border border-border/50 bg-muted/20\">\n        <button\n          type=\"button\"\n          className=\"flex w-full items-center justify-between p-4 hover:bg-muted/30 transition-colors\"\n          onClick={() => setIsProxyConfigOpen(!isProxyConfigOpen)}\n        >\n          <div className=\"flex items-center gap-3\">\n            <Globe className=\"h-4 w-4 text-muted-foreground\" />\n            <span className=\"font-medium\">\n              {t(\"providerAdvanced.proxyConfig\", {\n                defaultValue: \"代理配置\",\n              })}\n            </span>\n          </div>\n          <div className=\"flex items-center gap-3\">\n            <div\n              className=\"flex items-center gap-2\"\n              onClick={(e) => e.stopPropagation()}\n            >\n              <Label\n                htmlFor=\"proxy-config-enabled\"\n                className=\"text-sm text-muted-foreground\"\n              >\n                {t(\"providerAdvanced.useCustomProxy\", {\n                  defaultValue: \"使用单独代理\",\n                })}\n              </Label>\n              <Switch\n                id=\"proxy-config-enabled\"\n                checked={proxyConfig.enabled}\n                onCheckedChange={(checked) => {\n                  onProxyConfigChange({ ...proxyConfig, enabled: checked });\n                  if (checked) setIsProxyConfigOpen(true);\n                }}\n              />\n            </div>\n            {isProxyConfigOpen ? (\n              <ChevronDown className=\"h-4 w-4 text-muted-foreground\" />\n            ) : (\n              <ChevronRight className=\"h-4 w-4 text-muted-foreground\" />\n            )}\n          </div>\n        </button>\n        <div\n          className={cn(\n            \"overflow-hidden transition-all duration-200\",\n            isProxyConfigOpen\n              ? \"max-h-[500px] opacity-100\"\n              : \"max-h-0 opacity-0\",\n          )}\n        >\n          <div className=\"border-t border-border/50 p-4 space-y-3\">\n            <p className=\"text-sm text-muted-foreground\">\n              {t(\"providerAdvanced.proxyConfigDesc\", {\n                defaultValue:\n                  \"为此供应商配置单独的网络代理，不启用时使用系统代理或全局设置。\",\n              })}\n            </p>\n\n            {/* 代理地址输入框（仿照全局代理样式） */}\n            <div className=\"flex gap-2\">\n              <Input\n                placeholder=\"http://127.0.0.1:7890 / socks5://127.0.0.1:1080\"\n                value={proxyUrl}\n                onChange={(e) => handleProxyUrlChange(e.target.value)}\n                onBlur={handleProxyUrlBlur}\n                className=\"font-mono text-sm flex-1\"\n                disabled={!proxyConfig.enabled}\n              />\n              <Button\n                type=\"button\"\n                variant=\"outline\"\n                size=\"icon\"\n                disabled={!proxyConfig.enabled || !proxyUrl}\n                onClick={handleClearProxy}\n                title={t(\"common.clear\", { defaultValue: \"清除\" })}\n              >\n                <X className=\"h-4 w-4\" />\n              </Button>\n            </div>\n\n            {/* 认证信息：用户名 + 密码（可选） */}\n            <div className=\"flex gap-2\">\n              <Input\n                placeholder={t(\"providerAdvanced.proxyUsername\", {\n                  defaultValue: \"用户名（可选）\",\n                })}\n                value={proxyConfig.proxyUsername || \"\"}\n                onChange={(e) =>\n                  onProxyConfigChange({\n                    ...proxyConfig,\n                    proxyUsername: e.target.value || undefined,\n                  })\n                }\n                className=\"font-mono text-sm flex-1\"\n                disabled={!proxyConfig.enabled}\n              />\n              <div className=\"relative flex-1\">\n                <Input\n                  type={showPassword ? \"text\" : \"password\"}\n                  placeholder={t(\"providerAdvanced.proxyPassword\", {\n                    defaultValue: \"密码（可选）\",\n                  })}\n                  value={proxyConfig.proxyPassword || \"\"}\n                  onChange={(e) =>\n                    onProxyConfigChange({\n                      ...proxyConfig,\n                      proxyPassword: e.target.value || undefined,\n                    })\n                  }\n                  className=\"font-mono text-sm pr-10\"\n                  disabled={!proxyConfig.enabled}\n                />\n                <Button\n                  type=\"button\"\n                  variant=\"ghost\"\n                  size=\"icon\"\n                  className=\"absolute right-0 top-0 h-full px-3 hover:bg-transparent\"\n                  onClick={() => setShowPassword(!showPassword)}\n                  tabIndex={-1}\n                  disabled={!proxyConfig.enabled}\n                >\n                  {showPassword ? (\n                    <EyeOff className=\"h-4 w-4 text-muted-foreground\" />\n                  ) : (\n                    <Eye className=\"h-4 w-4 text-muted-foreground\" />\n                  )}\n                </Button>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      {/* 计费配置 */}\n      <div className=\"rounded-lg border border-border/50 bg-muted/20\">\n        <button\n          type=\"button\"\n          className=\"flex w-full items-center justify-between p-4 hover:bg-muted/30 transition-colors\"\n          onClick={() => setIsPricingConfigOpen(!isPricingConfigOpen)}\n        >\n          <div className=\"flex items-center gap-3\">\n            <Coins className=\"h-4 w-4 text-muted-foreground\" />\n            <span className=\"font-medium\">\n              {t(\"providerAdvanced.pricingConfig\", {\n                defaultValue: \"计费配置\",\n              })}\n            </span>\n          </div>\n          <div className=\"flex items-center gap-3\">\n            <div\n              className=\"flex items-center gap-2\"\n              onClick={(e) => e.stopPropagation()}\n            >\n              <Label\n                htmlFor=\"pricing-config-enabled\"\n                className=\"text-sm text-muted-foreground\"\n              >\n                {t(\"providerAdvanced.useCustomPricing\", {\n                  defaultValue: \"使用单独配置\",\n                })}\n              </Label>\n              <Switch\n                id=\"pricing-config-enabled\"\n                checked={pricingConfig.enabled}\n                onCheckedChange={(checked) => {\n                  onPricingConfigChange({ ...pricingConfig, enabled: checked });\n                  if (checked) setIsPricingConfigOpen(true);\n                }}\n              />\n            </div>\n            {isPricingConfigOpen ? (\n              <ChevronDown className=\"h-4 w-4 text-muted-foreground\" />\n            ) : (\n              <ChevronRight className=\"h-4 w-4 text-muted-foreground\" />\n            )}\n          </div>\n        </button>\n        <div\n          className={cn(\n            \"overflow-hidden transition-all duration-200\",\n            isPricingConfigOpen\n              ? \"max-h-[500px] opacity-100\"\n              : \"max-h-0 opacity-0\",\n          )}\n        >\n          <div className=\"border-t border-border/50 p-4 space-y-4\">\n            <p className=\"text-sm text-muted-foreground\">\n              {t(\"providerAdvanced.pricingConfigDesc\", {\n                defaultValue:\n                  \"为此供应商配置单独的计费参数，不启用时使用全局默认配置。\",\n              })}\n            </p>\n            <div className=\"grid grid-cols-1 md:grid-cols-2 gap-4\">\n              <div className=\"space-y-2\">\n                <Label htmlFor=\"cost-multiplier\">\n                  {t(\"providerAdvanced.costMultiplier\", {\n                    defaultValue: \"成本倍率\",\n                  })}\n                </Label>\n                <Input\n                  id=\"cost-multiplier\"\n                  type=\"number\"\n                  step=\"0.01\"\n                  inputMode=\"decimal\"\n                  value={pricingConfig.costMultiplier || \"\"}\n                  onChange={(e) =>\n                    onPricingConfigChange({\n                      ...pricingConfig,\n                      costMultiplier: e.target.value || undefined,\n                    })\n                  }\n                  placeholder={t(\"providerAdvanced.costMultiplierPlaceholder\", {\n                    defaultValue: \"留空使用全局默认（1）\",\n                  })}\n                  disabled={!pricingConfig.enabled}\n                />\n                <p className=\"text-xs text-muted-foreground\">\n                  {t(\"providerAdvanced.costMultiplierHint\", {\n                    defaultValue: \"实际成本 = 基础成本 × 倍率，支持小数如 1.5\",\n                  })}\n                </p>\n              </div>\n              <div className=\"space-y-2\">\n                <Label htmlFor=\"pricing-model-source\">\n                  {t(\"providerAdvanced.pricingModelSourceLabel\", {\n                    defaultValue: \"计费模式\",\n                  })}\n                </Label>\n                <Select\n                  value={pricingConfig.pricingModelSource}\n                  onValueChange={(value) =>\n                    onPricingConfigChange({\n                      ...pricingConfig,\n                      pricingModelSource: value as PricingModelSourceOption,\n                    })\n                  }\n                  disabled={!pricingConfig.enabled}\n                >\n                  <SelectTrigger id=\"pricing-model-source\">\n                    <SelectValue />\n                  </SelectTrigger>\n                  <SelectContent>\n                    <SelectItem value=\"inherit\">\n                      {t(\"providerAdvanced.pricingModelSourceInherit\", {\n                        defaultValue: \"继承全局默认\",\n                      })}\n                    </SelectItem>\n                    <SelectItem value=\"request\">\n                      {t(\"providerAdvanced.pricingModelSourceRequest\", {\n                        defaultValue: \"请求模型\",\n                      })}\n                    </SelectItem>\n                    <SelectItem value=\"response\">\n                      {t(\"providerAdvanced.pricingModelSourceResponse\", {\n                        defaultValue: \"返回模型\",\n                      })}\n                    </SelectItem>\n                  </SelectContent>\n                </Select>\n                <p className=\"text-xs text-muted-foreground\">\n                  {t(\"providerAdvanced.pricingModelSourceHint\", {\n                    defaultValue: \"选择按请求模型还是返回模型进行定价匹配\",\n                  })}\n                </p>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/providers/forms/ProviderForm.tsx",
    "content": "import { useEffect, useMemo, useState, useCallback } from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport { useTranslation } from \"react-i18next\";\nimport { toast } from \"sonner\";\nimport { Button } from \"@/components/ui/button\";\nimport { Form, FormField, FormItem, FormMessage } from \"@/components/ui/form\";\nimport { Input } from \"@/components/ui/input\";\nimport { providerSchema, type ProviderFormData } from \"@/lib/schemas/provider\";\nimport type { AppId } from \"@/lib/api\";\nimport type {\n  ProviderCategory,\n  ProviderMeta,\n  ProviderTestConfig,\n  ProviderProxyConfig,\n  ClaudeApiFormat,\n  ClaudeApiKeyField,\n} from \"@/types\";\nimport {\n  providerPresets,\n  type ProviderPreset,\n} from \"@/config/claudeProviderPresets\";\nimport {\n  codexProviderPresets,\n  type CodexProviderPreset,\n} from \"@/config/codexProviderPresets\";\nimport {\n  geminiProviderPresets,\n  type GeminiProviderPreset,\n} from \"@/config/geminiProviderPresets\";\nimport {\n  opencodeProviderPresets,\n  type OpenCodeProviderPreset,\n} from \"@/config/opencodeProviderPresets\";\nimport {\n  openclawProviderPresets,\n  type OpenClawProviderPreset,\n  type OpenClawSuggestedDefaults,\n} from \"@/config/openclawProviderPresets\";\nimport { OpenCodeFormFields } from \"./OpenCodeFormFields\";\nimport { OpenClawFormFields } from \"./OpenClawFormFields\";\nimport type { UniversalProviderPreset } from \"@/config/universalProviderPresets\";\nimport {\n  applyTemplateValues,\n  hasApiKeyField,\n} from \"@/utils/providerConfigUtils\";\nimport { mergeProviderMeta } from \"@/utils/providerMetaUtils\";\nimport { getCodexCustomTemplate } from \"@/config/codexTemplates\";\nimport CodexConfigEditor from \"./CodexConfigEditor\";\nimport { CommonConfigEditor } from \"./CommonConfigEditor\";\nimport GeminiConfigEditor from \"./GeminiConfigEditor\";\nimport JsonEditor from \"@/components/JsonEditor\";\nimport { Label } from \"@/components/ui/label\";\nimport { ProviderPresetSelector } from \"./ProviderPresetSelector\";\nimport { BasicFormFields } from \"./BasicFormFields\";\nimport { ClaudeFormFields } from \"./ClaudeFormFields\";\nimport { CodexFormFields } from \"./CodexFormFields\";\nimport { GeminiFormFields } from \"./GeminiFormFields\";\nimport { OmoFormFields } from \"./OmoFormFields\";\nimport { parseOmoOtherFieldsObject } from \"@/types/omo\";\nimport {\n  ProviderAdvancedConfig,\n  type PricingModelSourceOption,\n} from \"./ProviderAdvancedConfig\";\nimport {\n  useProviderCategory,\n  useApiKeyState,\n  useBaseUrlState,\n  useModelState,\n  useCodexConfigState,\n  useApiKeyLink,\n  useTemplateValues,\n  useCommonConfigSnippet,\n  useCodexCommonConfig,\n  useSpeedTestEndpoints,\n  useCodexTomlValidation,\n  useGeminiConfigState,\n  useGeminiCommonConfig,\n  useOmoModelSource,\n  useOpencodeFormState,\n  useOmoDraftState,\n  useOpenclawFormState,\n  useCopilotAuth,\n} from \"./hooks\";\nimport {\n  CLAUDE_DEFAULT_CONFIG,\n  CODEX_DEFAULT_CONFIG,\n  GEMINI_DEFAULT_CONFIG,\n  OPENCODE_DEFAULT_CONFIG,\n  OPENCLAW_DEFAULT_CONFIG,\n  normalizePricingSource,\n} from \"./helpers/opencodeFormUtils\";\nimport { resolveManagedAccountId } from \"@/lib/authBinding\";\n\ntype PresetEntry = {\n  id: string;\n  preset:\n    | ProviderPreset\n    | CodexProviderPreset\n    | GeminiProviderPreset\n    | OpenCodeProviderPreset\n    | OpenClawProviderPreset;\n};\n\ninterface ProviderFormProps {\n  appId: AppId;\n  providerId?: string;\n  submitLabel: string;\n  onSubmit: (values: ProviderFormValues) => Promise<void> | void;\n  onCancel: () => void;\n  onUniversalPresetSelect?: (preset: UniversalProviderPreset) => void;\n  onManageUniversalProviders?: () => void;\n  onSubmittingChange?: (isSubmitting: boolean) => void;\n  initialData?: {\n    name?: string;\n    websiteUrl?: string;\n    notes?: string;\n    settingsConfig?: Record<string, unknown>;\n    category?: ProviderCategory;\n    meta?: ProviderMeta;\n    icon?: string;\n    iconColor?: string;\n  };\n  showButtons?: boolean;\n}\n\nexport function ProviderForm({\n  appId,\n  providerId,\n  submitLabel,\n  onSubmit,\n  onCancel,\n  onUniversalPresetSelect,\n  onManageUniversalProviders,\n  onSubmittingChange,\n  initialData,\n  showButtons = true,\n}: ProviderFormProps) {\n  const { t } = useTranslation();\n  const isEditMode = Boolean(initialData);\n\n  const [selectedPresetId, setSelectedPresetId] = useState<string | null>(\n    initialData ? null : \"custom\",\n  );\n  const [activePreset, setActivePreset] = useState<{\n    id: string;\n    category?: ProviderCategory;\n    isPartner?: boolean;\n    partnerPromotionKey?: string;\n    suggestedDefaults?: OpenClawSuggestedDefaults;\n  } | null>(null);\n  const [isEndpointModalOpen, setIsEndpointModalOpen] = useState(false);\n  const [isCodexEndpointModalOpen, setIsCodexEndpointModalOpen] =\n    useState(false);\n\n  const [draftCustomEndpoints, setDraftCustomEndpoints] = useState<string[]>(\n    () => {\n      if (initialData) return [];\n      return [];\n    },\n  );\n  const [endpointAutoSelect, setEndpointAutoSelect] = useState<boolean>(\n    () => initialData?.meta?.endpointAutoSelect ?? true,\n  );\n\n  const [testConfig, setTestConfig] = useState<ProviderTestConfig>(\n    () => initialData?.meta?.testConfig ?? { enabled: false },\n  );\n  const [proxyConfig, setProxyConfig] = useState<ProviderProxyConfig>(\n    () => initialData?.meta?.proxyConfig ?? { enabled: false },\n  );\n  const [pricingConfig, setPricingConfig] = useState<{\n    enabled: boolean;\n    costMultiplier?: string;\n    pricingModelSource: PricingModelSourceOption;\n  }>(() => ({\n    enabled:\n      initialData?.meta?.costMultiplier !== undefined ||\n      initialData?.meta?.pricingModelSource !== undefined,\n    costMultiplier: initialData?.meta?.costMultiplier,\n    pricingModelSource: normalizePricingSource(\n      initialData?.meta?.pricingModelSource,\n    ),\n  }));\n\n  const { category } = useProviderCategory({\n    appId,\n    selectedPresetId,\n    isEditMode,\n    initialCategory: initialData?.category,\n  });\n  const isOmoCategory = appId === \"opencode\" && category === \"omo\";\n  const isOmoSlimCategory = appId === \"opencode\" && category === \"omo-slim\";\n  const isAnyOmoCategory = isOmoCategory || isOmoSlimCategory;\n\n  useEffect(() => {\n    setSelectedPresetId(initialData ? null : \"custom\");\n    setActivePreset(null);\n\n    if (!initialData) {\n      setDraftCustomEndpoints([]);\n    }\n    setEndpointAutoSelect(initialData?.meta?.endpointAutoSelect ?? true);\n    setTestConfig(initialData?.meta?.testConfig ?? { enabled: false });\n    setProxyConfig(initialData?.meta?.proxyConfig ?? { enabled: false });\n    setPricingConfig({\n      enabled:\n        initialData?.meta?.costMultiplier !== undefined ||\n        initialData?.meta?.pricingModelSource !== undefined,\n      costMultiplier: initialData?.meta?.costMultiplier,\n      pricingModelSource: normalizePricingSource(\n        initialData?.meta?.pricingModelSource,\n      ),\n    });\n  }, [appId, initialData]);\n\n  const defaultValues: ProviderFormData = useMemo(\n    () => ({\n      name: initialData?.name ?? \"\",\n      websiteUrl: initialData?.websiteUrl ?? \"\",\n      notes: initialData?.notes ?? \"\",\n      settingsConfig: initialData?.settingsConfig\n        ? JSON.stringify(initialData.settingsConfig, null, 2)\n        : appId === \"codex\"\n          ? CODEX_DEFAULT_CONFIG\n          : appId === \"gemini\"\n            ? GEMINI_DEFAULT_CONFIG\n            : appId === \"opencode\"\n              ? OPENCODE_DEFAULT_CONFIG\n              : appId === \"openclaw\"\n                ? OPENCLAW_DEFAULT_CONFIG\n                : CLAUDE_DEFAULT_CONFIG,\n      icon: initialData?.icon ?? \"\",\n      iconColor: initialData?.iconColor ?? \"\",\n    }),\n    [initialData, appId],\n  );\n\n  const form = useForm<ProviderFormData>({\n    resolver: zodResolver(providerSchema),\n    defaultValues,\n    mode: \"onSubmit\",\n  });\n  const { isSubmitting } = form.formState;\n\n  const handleSettingsConfigChange = useCallback(\n    (config: string) => {\n      form.setValue(\"settingsConfig\", config);\n    },\n    [form],\n  );\n\n  const [localApiKeyField, setLocalApiKeyField] = useState<ClaudeApiKeyField>(\n    () => {\n      if (appId !== \"claude\") return \"ANTHROPIC_AUTH_TOKEN\";\n      if (initialData?.meta?.apiKeyField) return initialData.meta.apiKeyField;\n      // Infer from existing config env\n      const env = (initialData?.settingsConfig as Record<string, unknown>)\n        ?.env as Record<string, unknown> | undefined;\n      if (env?.ANTHROPIC_API_KEY !== undefined) return \"ANTHROPIC_API_KEY\";\n      return \"ANTHROPIC_AUTH_TOKEN\";\n    },\n  );\n\n  useEffect(() => {\n    onSubmittingChange?.(isSubmitting);\n  }, [isSubmitting, onSubmittingChange]);\n\n  const {\n    apiKey,\n    handleApiKeyChange,\n    showApiKey: shouldShowApiKey,\n  } = useApiKeyState({\n    initialConfig: form.getValues(\"settingsConfig\"),\n    onConfigChange: handleSettingsConfigChange,\n    selectedPresetId,\n    category,\n    appType: appId,\n    apiKeyField: appId === \"claude\" ? localApiKeyField : undefined,\n  });\n\n  const { baseUrl, handleClaudeBaseUrlChange } = useBaseUrlState({\n    appType: appId,\n    category,\n    settingsConfig: form.getValues(\"settingsConfig\"),\n    codexConfig: \"\",\n    onSettingsConfigChange: handleSettingsConfigChange,\n    onCodexConfigChange: () => {},\n  });\n\n  const {\n    claudeModel,\n    reasoningModel,\n    defaultHaikuModel,\n    defaultSonnetModel,\n    defaultOpusModel,\n    handleModelChange,\n  } = useModelState({\n    settingsConfig: form.getValues(\"settingsConfig\"),\n    onConfigChange: handleSettingsConfigChange,\n  });\n\n  const [localApiFormat, setLocalApiFormat] = useState<ClaudeApiFormat>(() => {\n    if (appId !== \"claude\") return \"anthropic\";\n    return initialData?.meta?.apiFormat ?? \"anthropic\";\n  });\n\n  const handleApiFormatChange = useCallback((format: ClaudeApiFormat) => {\n    setLocalApiFormat(format);\n  }, []);\n\n  const handleApiKeyFieldChange = useCallback(\n    (field: ClaudeApiKeyField) => {\n      const prev = localApiKeyField;\n      setLocalApiKeyField(field);\n\n      // Swap the env key name in settingsConfig\n      try {\n        const raw = form.getValues(\"settingsConfig\");\n        const config = JSON.parse(raw || \"{}\");\n        if (config?.env && prev in config.env) {\n          const value = config.env[prev];\n          delete config.env[prev];\n          config.env[field] = value;\n          const updated = JSON.stringify(config, null, 2);\n          form.setValue(\"settingsConfig\", updated);\n          handleSettingsConfigChange(updated);\n        }\n      } catch {\n        // ignore parse errors during editing\n      }\n    },\n    [localApiKeyField, form, handleSettingsConfigChange],\n  );\n\n  // Copilot OAuth 认证状态（仅 Claude 应用需要）\n  const { isAuthenticated: isCopilotAuthenticated } = useCopilotAuth();\n\n  // 选中的 GitHub 账号 ID（多账号支持）\n  const [selectedGitHubAccountId, setSelectedGitHubAccountId] = useState<\n    string | null\n  >(() => resolveManagedAccountId(initialData?.meta, \"github_copilot\"));\n\n  const {\n    codexAuth,\n    codexConfig,\n    codexApiKey,\n    codexBaseUrl,\n    codexModelName,\n    codexAuthError,\n    setCodexAuth,\n    handleCodexApiKeyChange,\n    handleCodexBaseUrlChange,\n    handleCodexModelNameChange,\n    handleCodexConfigChange: originalHandleCodexConfigChange,\n    resetCodexConfig,\n  } = useCodexConfigState({ initialData });\n\n  const { configError: codexConfigError, debouncedValidate } =\n    useCodexTomlValidation();\n\n  const handleCodexConfigChange = useCallback(\n    (value: string) => {\n      originalHandleCodexConfigChange(value);\n      debouncedValidate(value);\n    },\n    [originalHandleCodexConfigChange, debouncedValidate],\n  );\n\n  useEffect(() => {\n    if (appId === \"codex\" && !initialData && selectedPresetId === \"custom\") {\n      const template = getCodexCustomTemplate();\n      resetCodexConfig(template.auth, template.config);\n    }\n  }, [appId, initialData, selectedPresetId, resetCodexConfig]);\n\n  useEffect(() => {\n    form.reset(defaultValues);\n  }, [defaultValues, form]);\n\n  const presetCategoryLabels: Record<string, string> = useMemo(\n    () => ({\n      official: t(\"providerForm.categoryOfficial\", {\n        defaultValue: \"官方\",\n      }),\n      cn_official: t(\"providerForm.categoryCnOfficial\", {\n        defaultValue: \"国内官方\",\n      }),\n      aggregator: t(\"providerForm.categoryAggregation\", {\n        defaultValue: \"聚合服务\",\n      }),\n      third_party: t(\"providerForm.categoryThirdParty\", {\n        defaultValue: \"第三方\",\n      }),\n      omo: \"OMO\",\n    }),\n    [t],\n  );\n\n  const presetEntries = useMemo(() => {\n    if (appId === \"codex\") {\n      return codexProviderPresets.map<PresetEntry>((preset, index) => ({\n        id: `codex-${index}`,\n        preset,\n      }));\n    } else if (appId === \"gemini\") {\n      return geminiProviderPresets.map<PresetEntry>((preset, index) => ({\n        id: `gemini-${index}`,\n        preset,\n      }));\n    } else if (appId === \"opencode\") {\n      return opencodeProviderPresets.map<PresetEntry>((preset, index) => ({\n        id: `opencode-${index}`,\n        preset,\n      }));\n    } else if (appId === \"openclaw\") {\n      return openclawProviderPresets.map<PresetEntry>((preset, index) => ({\n        id: `openclaw-${index}`,\n        preset,\n      }));\n    }\n    return providerPresets.map<PresetEntry>((preset, index) => ({\n      id: `claude-${index}`,\n      preset,\n    }));\n  }, [appId]);\n\n  const {\n    templateValues,\n    templateValueEntries,\n    selectedPreset: templatePreset,\n    handleTemplateValueChange,\n    validateTemplateValues,\n  } = useTemplateValues({\n    selectedPresetId: appId === \"claude\" ? selectedPresetId : null,\n    presetEntries: appId === \"claude\" ? presetEntries : [],\n    settingsConfig: form.getValues(\"settingsConfig\"),\n    onConfigChange: handleSettingsConfigChange,\n  });\n\n  const {\n    useCommonConfig,\n    commonConfigSnippet,\n    commonConfigError,\n    handleCommonConfigToggle,\n    handleCommonConfigSnippetChange,\n    isExtracting: isClaudeExtracting,\n    handleExtract: handleClaudeExtract,\n  } = useCommonConfigSnippet({\n    settingsConfig: form.getValues(\"settingsConfig\"),\n    onConfigChange: handleSettingsConfigChange,\n    initialData: appId === \"claude\" ? initialData : undefined,\n    initialEnabled:\n      appId === \"claude\" ? initialData?.meta?.commonConfigEnabled : undefined,\n    selectedPresetId: selectedPresetId ?? undefined,\n    enabled: appId === \"claude\",\n  });\n\n  const {\n    useCommonConfig: useCodexCommonConfigFlag,\n    commonConfigSnippet: codexCommonConfigSnippet,\n    commonConfigError: codexCommonConfigError,\n    handleCommonConfigToggle: handleCodexCommonConfigToggle,\n    handleCommonConfigSnippetChange: handleCodexCommonConfigSnippetChange,\n    isExtracting: isCodexExtracting,\n    handleExtract: handleCodexExtract,\n    clearCommonConfigError: clearCodexCommonConfigError,\n  } = useCodexCommonConfig({\n    codexConfig,\n    onConfigChange: handleCodexConfigChange,\n    initialData: appId === \"codex\" ? initialData : undefined,\n    initialEnabled:\n      appId === \"codex\" ? initialData?.meta?.commonConfigEnabled : undefined,\n    selectedPresetId: selectedPresetId ?? undefined,\n  });\n\n  const {\n    geminiEnv,\n    geminiConfig,\n    geminiApiKey,\n    geminiBaseUrl,\n    geminiModel,\n    envError,\n    configError: geminiConfigError,\n    handleGeminiApiKeyChange: originalHandleGeminiApiKeyChange,\n    handleGeminiBaseUrlChange: originalHandleGeminiBaseUrlChange,\n    handleGeminiModelChange: originalHandleGeminiModelChange,\n    handleGeminiEnvChange,\n    handleGeminiConfigChange,\n    resetGeminiConfig,\n    envStringToObj,\n    envObjToString,\n  } = useGeminiConfigState({\n    initialData: appId === \"gemini\" ? initialData : undefined,\n  });\n\n  const updateGeminiEnvField = useCallback(\n    (\n      key: \"GEMINI_API_KEY\" | \"GOOGLE_GEMINI_BASE_URL\" | \"GEMINI_MODEL\",\n      value: string,\n    ) => {\n      try {\n        const config = JSON.parse(form.getValues(\"settingsConfig\") || \"{}\") as {\n          env?: Record<string, unknown>;\n        };\n        if (!config.env || typeof config.env !== \"object\") {\n          config.env = {};\n        }\n        config.env[key] = value;\n        form.setValue(\"settingsConfig\", JSON.stringify(config, null, 2));\n      } catch {}\n    },\n    [form],\n  );\n\n  const handleGeminiApiKeyChange = useCallback(\n    (key: string) => {\n      originalHandleGeminiApiKeyChange(key);\n      updateGeminiEnvField(\"GEMINI_API_KEY\", key.trim());\n    },\n    [originalHandleGeminiApiKeyChange, updateGeminiEnvField],\n  );\n\n  const handleGeminiBaseUrlChange = useCallback(\n    (url: string) => {\n      originalHandleGeminiBaseUrlChange(url);\n      updateGeminiEnvField(\n        \"GOOGLE_GEMINI_BASE_URL\",\n        url.trim().replace(/\\/+$/, \"\"),\n      );\n    },\n    [originalHandleGeminiBaseUrlChange, updateGeminiEnvField],\n  );\n\n  const handleGeminiModelChange = useCallback(\n    (model: string) => {\n      originalHandleGeminiModelChange(model);\n      updateGeminiEnvField(\"GEMINI_MODEL\", model.trim());\n    },\n    [originalHandleGeminiModelChange, updateGeminiEnvField],\n  );\n\n  const {\n    useCommonConfig: useGeminiCommonConfigFlag,\n    commonConfigSnippet: geminiCommonConfigSnippet,\n    commonConfigError: geminiCommonConfigError,\n    handleCommonConfigToggle: handleGeminiCommonConfigToggle,\n    handleCommonConfigSnippetChange: handleGeminiCommonConfigSnippetChange,\n    isExtracting: isGeminiExtracting,\n    handleExtract: handleGeminiExtract,\n    clearCommonConfigError: clearGeminiCommonConfigError,\n  } = useGeminiCommonConfig({\n    envValue: geminiEnv,\n    onEnvChange: handleGeminiEnvChange,\n    envStringToObj,\n    envObjToString,\n    initialData: appId === \"gemini\" ? initialData : undefined,\n    initialEnabled:\n      appId === \"gemini\" ? initialData?.meta?.commonConfigEnabled : undefined,\n    selectedPresetId: selectedPresetId ?? undefined,\n  });\n\n  // ── Extracted hooks: OpenCode / OMO / OpenClaw ─────────────────────\n\n  const {\n    omoModelOptions,\n    omoModelVariantsMap,\n    omoPresetMetaMap,\n    existingOpencodeKeys,\n  } = useOmoModelSource({ isOmoCategory: isAnyOmoCategory, providerId });\n\n  const opencodeForm = useOpencodeFormState({\n    initialData,\n    appId,\n    providerId,\n    onSettingsConfigChange: (config) => form.setValue(\"settingsConfig\", config),\n    getSettingsConfig: () => form.getValues(\"settingsConfig\"),\n  });\n\n  const initialOmoSettings =\n    appId === \"opencode\" &&\n    (initialData?.category === \"omo\" || initialData?.category === \"omo-slim\")\n      ? (initialData.settingsConfig as Record<string, unknown> | undefined)\n      : undefined;\n\n  const omoDraft = useOmoDraftState({\n    initialOmoSettings,\n    isEditMode,\n    appId,\n    category,\n  });\n\n  const openclawForm = useOpenclawFormState({\n    initialData,\n    appId,\n    providerId,\n    onSettingsConfigChange: (config) => form.setValue(\"settingsConfig\", config),\n    getSettingsConfig: () => form.getValues(\"settingsConfig\"),\n  });\n\n  const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);\n\n  const handleSubmit = async (values: ProviderFormData) => {\n    if (appId === \"claude\" && templateValueEntries.length > 0) {\n      const validation = validateTemplateValues();\n      if (!validation.isValid && validation.missingField) {\n        toast.error(\n          t(\"providerForm.fillParameter\", {\n            label: validation.missingField.label,\n            defaultValue: `请填写 ${validation.missingField.label}`,\n          }),\n        );\n        return;\n      }\n    }\n\n    if (!values.name.trim()) {\n      toast.error(\n        t(\"providerForm.fillSupplierName\", {\n          defaultValue: \"请填写供应商名称\",\n        }),\n      );\n      return;\n    }\n\n    if (appId === \"opencode\" && !isAnyOmoCategory) {\n      const keyPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/;\n      if (!opencodeForm.opencodeProviderKey.trim()) {\n        toast.error(t(\"opencode.providerKeyRequired\"));\n        return;\n      }\n      if (!keyPattern.test(opencodeForm.opencodeProviderKey)) {\n        toast.error(t(\"opencode.providerKeyInvalid\"));\n        return;\n      }\n      if (\n        !isEditMode &&\n        existingOpencodeKeys.includes(opencodeForm.opencodeProviderKey)\n      ) {\n        toast.error(t(\"opencode.providerKeyDuplicate\"));\n        return;\n      }\n      if (Object.keys(opencodeForm.opencodeModels).length === 0) {\n        toast.error(t(\"opencode.modelsRequired\"));\n        return;\n      }\n    }\n\n    // OpenClaw: validate provider key\n    if (appId === \"openclaw\") {\n      const keyPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/;\n      if (!openclawForm.openclawProviderKey.trim()) {\n        toast.error(t(\"openclaw.providerKeyRequired\"));\n        return;\n      }\n      if (!keyPattern.test(openclawForm.openclawProviderKey)) {\n        toast.error(t(\"openclaw.providerKeyInvalid\"));\n        return;\n      }\n      if (\n        !isEditMode &&\n        openclawForm.existingOpenclawKeys.includes(\n          openclawForm.openclawProviderKey,\n        )\n      ) {\n        toast.error(t(\"openclaw.providerKeyDuplicate\"));\n        return;\n      }\n    }\n\n    // 非官方供应商必填校验：端点和 API Key\n    // cloud_provider（如 Bedrock）通过模板变量处理认证，跳过通用校验\n    // GitHub Copilot 使用 OAuth 认证，不需要 API Key\n    const isCopilotProvider =\n      templatePreset?.providerType === \"github_copilot\" ||\n      initialData?.meta?.providerType === \"github_copilot\" ||\n      baseUrl.includes(\"githubcopilot.com\");\n    // GitHub Copilot 必须先登录才能添加\n    if (isCopilotProvider && !isCopilotAuthenticated) {\n      toast.error(\n        t(\"copilot.loginRequired\", {\n          defaultValue: \"请先登录 GitHub Copilot\",\n        }),\n      );\n      return;\n    }\n\n    if (category !== \"official\" && category !== \"cloud_provider\") {\n      if (appId === \"claude\") {\n        if (!baseUrl.trim()) {\n          toast.error(\n            t(\"providerForm.endpointRequired\", {\n              defaultValue: \"非官方供应商请填写 API 端点\",\n            }),\n          );\n          return;\n        }\n        if (!isCopilotProvider && !apiKey.trim()) {\n          toast.error(\n            t(\"providerForm.apiKeyRequired\", {\n              defaultValue: \"非官方供应商请填写 API Key\",\n            }),\n          );\n          return;\n        }\n      } else if (appId === \"codex\") {\n        if (!codexBaseUrl.trim()) {\n          toast.error(\n            t(\"providerForm.endpointRequired\", {\n              defaultValue: \"非官方供应商请填写 API 端点\",\n            }),\n          );\n          return;\n        }\n        if (!codexApiKey.trim()) {\n          toast.error(\n            t(\"providerForm.apiKeyRequired\", {\n              defaultValue: \"非官方供应商请填写 API Key\",\n            }),\n          );\n          return;\n        }\n      } else if (appId === \"gemini\") {\n        if (!geminiBaseUrl.trim()) {\n          toast.error(\n            t(\"providerForm.endpointRequired\", {\n              defaultValue: \"非官方供应商请填写 API 端点\",\n            }),\n          );\n          return;\n        }\n        if (!geminiApiKey.trim()) {\n          toast.error(\n            t(\"providerForm.apiKeyRequired\", {\n              defaultValue: \"非官方供应商请填写 API Key\",\n            }),\n          );\n          return;\n        }\n      }\n    }\n\n    let settingsConfig: string;\n\n    if (appId === \"codex\") {\n      try {\n        const authJson = JSON.parse(codexAuth);\n        const configObj = {\n          auth: authJson,\n          config: codexConfig ?? \"\",\n        };\n        settingsConfig = JSON.stringify(configObj);\n      } catch (err) {\n        settingsConfig = values.settingsConfig.trim();\n      }\n    } else if (appId === \"gemini\") {\n      try {\n        const envObj = envStringToObj(geminiEnv);\n        const configObj = geminiConfig.trim() ? JSON.parse(geminiConfig) : {};\n        const combined = {\n          env: envObj,\n          config: configObj,\n        };\n        settingsConfig = JSON.stringify(combined);\n      } catch (err) {\n        settingsConfig = values.settingsConfig.trim();\n      }\n    } else if (\n      appId === \"opencode\" &&\n      (category === \"omo\" || category === \"omo-slim\")\n    ) {\n      const omoConfig: Record<string, unknown> = {};\n      if (Object.keys(omoDraft.omoAgents).length > 0) {\n        omoConfig.agents = omoDraft.omoAgents;\n      }\n      if (\n        category === \"omo\" &&\n        Object.keys(omoDraft.omoCategories).length > 0\n      ) {\n        omoConfig.categories = omoDraft.omoCategories;\n      }\n      if (omoDraft.omoOtherFieldsStr.trim()) {\n        try {\n          const otherFields = parseOmoOtherFieldsObject(\n            omoDraft.omoOtherFieldsStr,\n          );\n          if (!otherFields) {\n            toast.error(\n              t(\"omo.jsonMustBeObject\", {\n                field: t(\"omo.otherFields\", {\n                  defaultValue: \"Other Config\",\n                }),\n                defaultValue: \"{{field}} must be a JSON object\",\n              }),\n            );\n            return;\n          }\n          omoConfig.otherFields = otherFields;\n        } catch {\n          toast.error(\n            t(\"omo.invalidJson\", {\n              defaultValue: \"Other Fields contains invalid JSON\",\n            }),\n          );\n          return;\n        }\n      }\n      settingsConfig = JSON.stringify(omoConfig);\n    } else {\n      settingsConfig = values.settingsConfig.trim();\n    }\n\n    const payload: ProviderFormValues = {\n      ...values,\n      name: values.name.trim(),\n      websiteUrl: values.websiteUrl?.trim() ?? \"\",\n      settingsConfig,\n    };\n\n    if (appId === \"opencode\") {\n      if (isAnyOmoCategory) {\n        if (!isEditMode) {\n          const prefix = category === \"omo\" ? \"omo\" : \"omo-slim\";\n          payload.providerKey = `${prefix}-${crypto.randomUUID().slice(0, 8)}`;\n        }\n      } else {\n        payload.providerKey = opencodeForm.opencodeProviderKey;\n      }\n    } else if (appId === \"openclaw\") {\n      payload.providerKey = openclawForm.openclawProviderKey;\n    }\n\n    if (isAnyOmoCategory && !payload.presetCategory) {\n      payload.presetCategory = category;\n    }\n\n    if (activePreset) {\n      payload.presetId = activePreset.id;\n      if (activePreset.category) {\n        payload.presetCategory = activePreset.category;\n      }\n      if (activePreset.isPartner) {\n        payload.isPartner = activePreset.isPartner;\n      }\n      // OpenClaw: 传递预设的 suggestedDefaults 到提交数据\n      if (activePreset.suggestedDefaults) {\n        payload.suggestedDefaults = activePreset.suggestedDefaults;\n      }\n    }\n\n    if (!isEditMode && draftCustomEndpoints.length > 0) {\n      const customEndpointsToSave: Record<\n        string,\n        import(\"@/types\").CustomEndpoint\n      > = draftCustomEndpoints.reduce(\n        (acc, url) => {\n          const now = Date.now();\n          acc[url] = { url, addedAt: now, lastUsed: undefined };\n          return acc;\n        },\n        {} as Record<string, import(\"@/types\").CustomEndpoint>,\n      );\n\n      const hadEndpoints =\n        initialData?.meta?.custom_endpoints &&\n        Object.keys(initialData.meta.custom_endpoints).length > 0;\n      const needsClearEndpoints =\n        hadEndpoints && draftCustomEndpoints.length === 0;\n\n      let mergedMeta = needsClearEndpoints\n        ? mergeProviderMeta(initialData?.meta, {})\n        : mergeProviderMeta(initialData?.meta, customEndpointsToSave);\n\n      if (activePreset?.isPartner) {\n        mergedMeta = {\n          ...(mergedMeta ?? {}),\n          isPartner: true,\n        };\n      }\n\n      if (activePreset?.partnerPromotionKey) {\n        mergedMeta = {\n          ...(mergedMeta ?? {}),\n          partnerPromotionKey: activePreset.partnerPromotionKey,\n        };\n      }\n\n      if (mergedMeta !== undefined) {\n        payload.meta = mergedMeta;\n      }\n    }\n\n    const baseMeta: ProviderMeta | undefined =\n      payload.meta ?? (initialData?.meta ? { ...initialData.meta } : undefined);\n\n    // 确定 providerType（新建时从预设获取，编辑时从现有数据获取）\n    const providerType =\n      templatePreset?.providerType || initialData?.meta?.providerType;\n\n    payload.meta = {\n      ...(baseMeta ?? {}),\n      commonConfigEnabled:\n        appId === \"claude\"\n          ? useCommonConfig\n          : appId === \"codex\"\n            ? useCodexCommonConfigFlag\n            : appId === \"gemini\"\n              ? useGeminiCommonConfigFlag\n              : undefined,\n      endpointAutoSelect,\n      // 保存 providerType（用于识别 Copilot 等特殊供应商）\n      providerType,\n      authBinding: isCopilotProvider\n        ? {\n            source: \"managed_account\",\n            authProvider: \"github_copilot\",\n            accountId: selectedGitHubAccountId ?? undefined,\n          }\n        : undefined,\n      // GitHub Copilot 多账号：保存关联的账号 ID\n      githubAccountId:\n        isCopilotProvider && selectedGitHubAccountId\n          ? selectedGitHubAccountId\n          : undefined,\n      testConfig: testConfig.enabled ? testConfig : undefined,\n      proxyConfig: proxyConfig.enabled ? proxyConfig : undefined,\n      costMultiplier: pricingConfig.enabled\n        ? pricingConfig.costMultiplier\n        : undefined,\n      pricingModelSource:\n        pricingConfig.enabled && pricingConfig.pricingModelSource !== \"inherit\"\n          ? pricingConfig.pricingModelSource\n          : undefined,\n      apiFormat:\n        appId === \"claude\" && category !== \"official\"\n          ? localApiFormat\n          : undefined,\n      apiKeyField:\n        appId === \"claude\" &&\n        category !== \"official\" &&\n        localApiKeyField !== \"ANTHROPIC_AUTH_TOKEN\"\n          ? localApiKeyField\n          : undefined,\n    };\n\n    await onSubmit(payload);\n  };\n\n  const groupedPresets = useMemo(() => {\n    return presetEntries.reduce<Record<string, PresetEntry[]>>((acc, entry) => {\n      const category = entry.preset.category ?? \"others\";\n      if (!acc[category]) {\n        acc[category] = [];\n      }\n      acc[category].push(entry);\n      return acc;\n    }, {});\n  }, [presetEntries]);\n\n  const categoryKeys = useMemo(() => {\n    return Object.keys(groupedPresets).filter(\n      (key) => key !== \"custom\" && groupedPresets[key]?.length,\n    );\n  }, [groupedPresets]);\n\n  const shouldShowSpeedTest =\n    category !== \"official\" && category !== \"cloud_provider\";\n\n  const {\n    shouldShowApiKeyLink: shouldShowClaudeApiKeyLink,\n    websiteUrl: claudeWebsiteUrl,\n    isPartner: isClaudePartner,\n    partnerPromotionKey: claudePartnerPromotionKey,\n  } = useApiKeyLink({\n    appId: \"claude\",\n    category,\n    selectedPresetId,\n    presetEntries,\n    formWebsiteUrl: form.watch(\"websiteUrl\") || \"\",\n  });\n\n  const {\n    shouldShowApiKeyLink: shouldShowCodexApiKeyLink,\n    websiteUrl: codexWebsiteUrl,\n    isPartner: isCodexPartner,\n    partnerPromotionKey: codexPartnerPromotionKey,\n  } = useApiKeyLink({\n    appId: \"codex\",\n    category,\n    selectedPresetId,\n    presetEntries,\n    formWebsiteUrl: form.watch(\"websiteUrl\") || \"\",\n  });\n\n  const {\n    shouldShowApiKeyLink: shouldShowGeminiApiKeyLink,\n    websiteUrl: geminiWebsiteUrl,\n    isPartner: isGeminiPartner,\n    partnerPromotionKey: geminiPartnerPromotionKey,\n  } = useApiKeyLink({\n    appId: \"gemini\",\n    category,\n    selectedPresetId,\n    presetEntries,\n    formWebsiteUrl: form.watch(\"websiteUrl\") || \"\",\n  });\n\n  const {\n    shouldShowApiKeyLink: shouldShowOpencodeApiKeyLink,\n    websiteUrl: opencodeWebsiteUrl,\n    isPartner: isOpencodePartner,\n    partnerPromotionKey: opencodePartnerPromotionKey,\n  } = useApiKeyLink({\n    appId: \"opencode\",\n    category,\n    selectedPresetId,\n    presetEntries,\n    formWebsiteUrl: form.watch(\"websiteUrl\") || \"\",\n  });\n\n  // 使用 API Key 链接 hook (OpenClaw)\n  const {\n    shouldShowApiKeyLink: shouldShowOpenclawApiKeyLink,\n    websiteUrl: openclawWebsiteUrl,\n    isPartner: isOpenclawPartner,\n    partnerPromotionKey: openclawPartnerPromotionKey,\n  } = useApiKeyLink({\n    appId: \"openclaw\",\n    category,\n    selectedPresetId,\n    presetEntries,\n    formWebsiteUrl: form.watch(\"websiteUrl\") || \"\",\n  });\n\n  // 使用端点测速候选 hook\n  const speedTestEndpoints = useSpeedTestEndpoints({\n    appId,\n    selectedPresetId,\n    presetEntries,\n    baseUrl,\n    codexBaseUrl,\n    initialData,\n  });\n\n  const handlePresetChange = (value: string) => {\n    setSelectedPresetId(value);\n    if (value === \"custom\") {\n      setActivePreset(null);\n      form.reset(defaultValues);\n\n      if (appId === \"codex\") {\n        const template = getCodexCustomTemplate();\n        resetCodexConfig(template.auth, template.config);\n      }\n      if (appId === \"gemini\") {\n        resetGeminiConfig({}, {});\n      }\n      if (appId === \"opencode\") {\n        opencodeForm.resetOpencodeState();\n        omoDraft.resetOmoDraftState();\n      }\n      // OpenClaw 自定义模式：重置为空配置\n      if (appId === \"openclaw\") {\n        openclawForm.resetOpenclawState();\n      }\n      return;\n    }\n\n    const entry = presetEntries.find((item) => item.id === value);\n    if (!entry) {\n      return;\n    }\n\n    setActivePreset({\n      id: value,\n      category: entry.preset.category,\n      isPartner: entry.preset.isPartner,\n      partnerPromotionKey: entry.preset.partnerPromotionKey,\n    });\n\n    if (appId === \"codex\") {\n      const preset = entry.preset as CodexProviderPreset;\n      const auth = preset.auth ?? {};\n      const config = preset.config ?? \"\";\n\n      resetCodexConfig(auth, config);\n\n      form.reset({\n        name: preset.nameKey ? t(preset.nameKey) : preset.name,\n        websiteUrl: preset.websiteUrl ?? \"\",\n        settingsConfig: JSON.stringify({ auth, config }, null, 2),\n        icon: preset.icon ?? \"\",\n        iconColor: preset.iconColor ?? \"\",\n      });\n      return;\n    }\n\n    if (appId === \"gemini\") {\n      const preset = entry.preset as GeminiProviderPreset;\n      const env = (preset.settingsConfig as any)?.env ?? {};\n      const config = (preset.settingsConfig as any)?.config ?? {};\n\n      resetGeminiConfig(env, config);\n\n      form.reset({\n        name: preset.nameKey ? t(preset.nameKey) : preset.name,\n        websiteUrl: preset.websiteUrl ?? \"\",\n        settingsConfig: JSON.stringify(preset.settingsConfig, null, 2),\n        icon: preset.icon ?? \"\",\n        iconColor: preset.iconColor ?? \"\",\n      });\n      return;\n    }\n\n    if (appId === \"opencode\") {\n      const preset = entry.preset as OpenCodeProviderPreset;\n      const config = preset.settingsConfig;\n\n      if (preset.category === \"omo\" || preset.category === \"omo-slim\") {\n        omoDraft.resetOmoDraftState();\n        form.reset({\n          name: preset.category === \"omo\" ? \"OMO\" : \"OMO Slim\",\n          websiteUrl: preset.websiteUrl ?? \"\",\n          settingsConfig: JSON.stringify({}, null, 2),\n          icon: preset.icon ?? \"\",\n          iconColor: preset.iconColor ?? \"\",\n        });\n        return;\n      }\n\n      opencodeForm.resetOpencodeState(config);\n\n      form.reset({\n        name: preset.nameKey ? t(preset.nameKey) : preset.name,\n        websiteUrl: preset.websiteUrl ?? \"\",\n        settingsConfig: JSON.stringify(config, null, 2),\n        icon: preset.icon ?? \"\",\n        iconColor: preset.iconColor ?? \"\",\n      });\n      return;\n    }\n\n    // OpenClaw preset handling\n    if (appId === \"openclaw\") {\n      const preset = entry.preset as OpenClawProviderPreset;\n      const config = preset.settingsConfig;\n\n      // Update activePreset with suggestedDefaults for OpenClaw\n      setActivePreset({\n        id: value,\n        category: preset.category,\n        isPartner: preset.isPartner,\n        partnerPromotionKey: preset.partnerPromotionKey,\n        suggestedDefaults: preset.suggestedDefaults,\n      });\n\n      openclawForm.resetOpenclawState(config);\n\n      // Update form fields\n      form.reset({\n        name: preset.nameKey ? t(preset.nameKey) : preset.name,\n        websiteUrl: preset.websiteUrl ?? \"\",\n        settingsConfig: JSON.stringify(config, null, 2),\n        icon: preset.icon ?? \"\",\n        iconColor: preset.iconColor ?? \"\",\n      });\n      return;\n    }\n\n    const preset = entry.preset as ProviderPreset;\n    const config = applyTemplateValues(\n      preset.settingsConfig,\n      preset.templateValues,\n    );\n\n    if (preset.apiFormat) {\n      setLocalApiFormat(preset.apiFormat);\n    } else {\n      setLocalApiFormat(\"anthropic\");\n    }\n\n    setLocalApiKeyField(preset.apiKeyField ?? \"ANTHROPIC_AUTH_TOKEN\");\n\n    form.reset({\n      name: preset.nameKey ? t(preset.nameKey) : preset.name,\n      websiteUrl: preset.websiteUrl ?? \"\",\n      settingsConfig: JSON.stringify(config, null, 2),\n      icon: preset.icon ?? \"\",\n      iconColor: preset.iconColor ?? \"\",\n    });\n  };\n\n  const settingsConfigErrorField = (\n    <FormField\n      control={form.control}\n      name=\"settingsConfig\"\n      render={() => (\n        <FormItem className=\"space-y-0\">\n          <FormMessage />\n        </FormItem>\n      )}\n    />\n  );\n\n  return (\n    <Form {...form}>\n      <form\n        id=\"provider-form\"\n        onSubmit={form.handleSubmit(handleSubmit)}\n        className=\"space-y-6 glass rounded-xl p-6 border border-white/10\"\n      >\n        {!initialData && (\n          <ProviderPresetSelector\n            selectedPresetId={selectedPresetId}\n            groupedPresets={groupedPresets}\n            categoryKeys={categoryKeys}\n            presetCategoryLabels={presetCategoryLabels}\n            onPresetChange={handlePresetChange}\n            onUniversalPresetSelect={onUniversalPresetSelect}\n            onManageUniversalProviders={onManageUniversalProviders}\n            category={category}\n          />\n        )}\n\n        <BasicFormFields\n          form={form}\n          beforeNameSlot={\n            appId === \"opencode\" && !isAnyOmoCategory ? (\n              <div className=\"space-y-2\">\n                <Label htmlFor=\"opencode-key\">\n                  {t(\"opencode.providerKey\")}\n                  <span className=\"text-destructive ml-1\">*</span>\n                </Label>\n                <Input\n                  id=\"opencode-key\"\n                  value={opencodeForm.opencodeProviderKey}\n                  onChange={(e) =>\n                    opencodeForm.setOpencodeProviderKey(\n                      e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, \"\"),\n                    )\n                  }\n                  placeholder={t(\"opencode.providerKeyPlaceholder\")}\n                  disabled={isEditMode}\n                  className={\n                    (existingOpencodeKeys.includes(\n                      opencodeForm.opencodeProviderKey,\n                    ) &&\n                      !isEditMode) ||\n                    (opencodeForm.opencodeProviderKey.trim() !== \"\" &&\n                      !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(\n                        opencodeForm.opencodeProviderKey,\n                      ))\n                      ? \"border-destructive\"\n                      : \"\"\n                  }\n                />\n                {existingOpencodeKeys.includes(\n                  opencodeForm.opencodeProviderKey,\n                ) &&\n                  !isEditMode && (\n                    <p className=\"text-xs text-destructive\">\n                      {t(\"opencode.providerKeyDuplicate\")}\n                    </p>\n                  )}\n                {opencodeForm.opencodeProviderKey.trim() !== \"\" &&\n                  !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(\n                    opencodeForm.opencodeProviderKey,\n                  ) && (\n                    <p className=\"text-xs text-destructive\">\n                      {t(\"opencode.providerKeyInvalid\")}\n                    </p>\n                  )}\n                {!(\n                  existingOpencodeKeys.includes(\n                    opencodeForm.opencodeProviderKey,\n                  ) && !isEditMode\n                ) &&\n                  (opencodeForm.opencodeProviderKey.trim() === \"\" ||\n                    /^[a-z0-9]+(-[a-z0-9]+)*$/.test(\n                      opencodeForm.opencodeProviderKey,\n                    )) && (\n                    <p className=\"text-xs text-muted-foreground\">\n                      {t(\"opencode.providerKeyHint\")}\n                    </p>\n                  )}\n              </div>\n            ) : appId === \"openclaw\" ? (\n              <div className=\"space-y-2\">\n                <Label htmlFor=\"openclaw-key\">\n                  {t(\"openclaw.providerKey\")}\n                  <span className=\"text-destructive ml-1\">*</span>\n                </Label>\n                <Input\n                  id=\"openclaw-key\"\n                  value={openclawForm.openclawProviderKey}\n                  onChange={(e) =>\n                    openclawForm.setOpenclawProviderKey(\n                      e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, \"\"),\n                    )\n                  }\n                  placeholder={t(\"openclaw.providerKeyPlaceholder\")}\n                  disabled={isEditMode}\n                  className={\n                    (openclawForm.existingOpenclawKeys.includes(\n                      openclawForm.openclawProviderKey,\n                    ) &&\n                      !isEditMode) ||\n                    (openclawForm.openclawProviderKey.trim() !== \"\" &&\n                      !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(\n                        openclawForm.openclawProviderKey,\n                      ))\n                      ? \"border-destructive\"\n                      : \"\"\n                  }\n                />\n                {openclawForm.existingOpenclawKeys.includes(\n                  openclawForm.openclawProviderKey,\n                ) &&\n                  !isEditMode && (\n                    <p className=\"text-xs text-destructive\">\n                      {t(\"openclaw.providerKeyDuplicate\")}\n                    </p>\n                  )}\n                {openclawForm.openclawProviderKey.trim() !== \"\" &&\n                  !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(\n                    openclawForm.openclawProviderKey,\n                  ) && (\n                    <p className=\"text-xs text-destructive\">\n                      {t(\"openclaw.providerKeyInvalid\")}\n                    </p>\n                  )}\n                {!(\n                  openclawForm.existingOpenclawKeys.includes(\n                    openclawForm.openclawProviderKey,\n                  ) && !isEditMode\n                ) &&\n                  (openclawForm.openclawProviderKey.trim() === \"\" ||\n                    /^[a-z0-9]+(-[a-z0-9]+)*$/.test(\n                      openclawForm.openclawProviderKey,\n                    )) && (\n                    <p className=\"text-xs text-muted-foreground\">\n                      {t(\"openclaw.providerKeyHint\")}\n                    </p>\n                  )}\n              </div>\n            ) : undefined\n          }\n        />\n\n        {appId === \"claude\" && (\n          <ClaudeFormFields\n            providerId={providerId}\n            shouldShowApiKey={\n              (category !== \"cloud_provider\" ||\n                hasApiKeyField(form.getValues(\"settingsConfig\"), \"claude\")) &&\n              shouldShowApiKey(form.getValues(\"settingsConfig\"), isEditMode)\n            }\n            apiKey={apiKey}\n            onApiKeyChange={handleApiKeyChange}\n            category={category}\n            shouldShowApiKeyLink={shouldShowClaudeApiKeyLink}\n            websiteUrl={claudeWebsiteUrl}\n            isPartner={isClaudePartner}\n            partnerPromotionKey={claudePartnerPromotionKey}\n            isCopilotPreset={\n              templatePreset?.providerType === \"github_copilot\" ||\n              initialData?.meta?.providerType === \"github_copilot\" ||\n              baseUrl.includes(\"githubcopilot.com\")\n            }\n            usesOAuth={\n              templatePreset?.requiresOAuth === true ||\n              templatePreset?.providerType === \"github_copilot\" ||\n              initialData?.meta?.providerType === \"github_copilot\" ||\n              baseUrl.includes(\"githubcopilot.com\")\n            }\n            isCopilotAuthenticated={isCopilotAuthenticated}\n            selectedGitHubAccountId={selectedGitHubAccountId}\n            onGitHubAccountSelect={setSelectedGitHubAccountId}\n            templateValueEntries={templateValueEntries}\n            templateValues={templateValues}\n            templatePresetName={templatePreset?.name || \"\"}\n            onTemplateValueChange={handleTemplateValueChange}\n            shouldShowSpeedTest={shouldShowSpeedTest}\n            baseUrl={baseUrl}\n            onBaseUrlChange={handleClaudeBaseUrlChange}\n            isEndpointModalOpen={isEndpointModalOpen}\n            onEndpointModalToggle={setIsEndpointModalOpen}\n            onCustomEndpointsChange={\n              isEditMode ? undefined : setDraftCustomEndpoints\n            }\n            autoSelect={endpointAutoSelect}\n            onAutoSelectChange={setEndpointAutoSelect}\n            shouldShowModelSelector={category !== \"official\"}\n            claudeModel={claudeModel}\n            reasoningModel={reasoningModel}\n            defaultHaikuModel={defaultHaikuModel}\n            defaultSonnetModel={defaultSonnetModel}\n            defaultOpusModel={defaultOpusModel}\n            onModelChange={handleModelChange}\n            speedTestEndpoints={speedTestEndpoints}\n            apiFormat={localApiFormat}\n            onApiFormatChange={handleApiFormatChange}\n            apiKeyField={localApiKeyField}\n            onApiKeyFieldChange={handleApiKeyFieldChange}\n          />\n        )}\n\n        {appId === \"codex\" && (\n          <CodexFormFields\n            providerId={providerId}\n            codexApiKey={codexApiKey}\n            onApiKeyChange={handleCodexApiKeyChange}\n            category={category}\n            shouldShowApiKeyLink={shouldShowCodexApiKeyLink}\n            websiteUrl={codexWebsiteUrl}\n            isPartner={isCodexPartner}\n            partnerPromotionKey={codexPartnerPromotionKey}\n            shouldShowSpeedTest={shouldShowSpeedTest}\n            codexBaseUrl={codexBaseUrl}\n            onBaseUrlChange={handleCodexBaseUrlChange}\n            isEndpointModalOpen={isCodexEndpointModalOpen}\n            onEndpointModalToggle={setIsCodexEndpointModalOpen}\n            onCustomEndpointsChange={\n              isEditMode ? undefined : setDraftCustomEndpoints\n            }\n            autoSelect={endpointAutoSelect}\n            onAutoSelectChange={setEndpointAutoSelect}\n            shouldShowModelField={category !== \"official\"}\n            modelName={codexModelName}\n            onModelNameChange={handleCodexModelNameChange}\n            speedTestEndpoints={speedTestEndpoints}\n          />\n        )}\n\n        {appId === \"gemini\" && (\n          <GeminiFormFields\n            providerId={providerId}\n            shouldShowApiKey={shouldShowApiKey(\n              form.getValues(\"settingsConfig\"),\n              isEditMode,\n            )}\n            apiKey={geminiApiKey}\n            onApiKeyChange={handleGeminiApiKeyChange}\n            category={category}\n            shouldShowApiKeyLink={shouldShowGeminiApiKeyLink}\n            websiteUrl={geminiWebsiteUrl}\n            isPartner={isGeminiPartner}\n            partnerPromotionKey={geminiPartnerPromotionKey}\n            shouldShowSpeedTest={shouldShowSpeedTest}\n            baseUrl={geminiBaseUrl}\n            onBaseUrlChange={handleGeminiBaseUrlChange}\n            isEndpointModalOpen={isEndpointModalOpen}\n            onEndpointModalToggle={setIsEndpointModalOpen}\n            onCustomEndpointsChange={setDraftCustomEndpoints}\n            autoSelect={endpointAutoSelect}\n            onAutoSelectChange={setEndpointAutoSelect}\n            shouldShowModelField={true}\n            model={geminiModel}\n            onModelChange={handleGeminiModelChange}\n            speedTestEndpoints={speedTestEndpoints}\n          />\n        )}\n\n        {appId === \"opencode\" && !isAnyOmoCategory && (\n          <OpenCodeFormFields\n            npm={opencodeForm.opencodeNpm}\n            onNpmChange={opencodeForm.handleOpencodeNpmChange}\n            apiKey={opencodeForm.opencodeApiKey}\n            onApiKeyChange={opencodeForm.handleOpencodeApiKeyChange}\n            category={category}\n            shouldShowApiKeyLink={shouldShowOpencodeApiKeyLink}\n            websiteUrl={opencodeWebsiteUrl}\n            isPartner={isOpencodePartner}\n            partnerPromotionKey={opencodePartnerPromotionKey}\n            baseUrl={opencodeForm.opencodeBaseUrl}\n            onBaseUrlChange={opencodeForm.handleOpencodeBaseUrlChange}\n            models={opencodeForm.opencodeModels}\n            onModelsChange={opencodeForm.handleOpencodeModelsChange}\n            extraOptions={opencodeForm.opencodeExtraOptions}\n            onExtraOptionsChange={opencodeForm.handleOpencodeExtraOptionsChange}\n          />\n        )}\n\n        {appId === \"opencode\" &&\n          (category === \"omo\" || category === \"omo-slim\") && (\n            <OmoFormFields\n              modelOptions={omoModelOptions}\n              modelVariantsMap={omoModelVariantsMap}\n              presetMetaMap={omoPresetMetaMap}\n              agents={omoDraft.omoAgents}\n              onAgentsChange={omoDraft.setOmoAgents}\n              categories={\n                category === \"omo\" ? omoDraft.omoCategories : undefined\n              }\n              onCategoriesChange={\n                category === \"omo\" ? omoDraft.setOmoCategories : undefined\n              }\n              otherFieldsStr={omoDraft.omoOtherFieldsStr}\n              onOtherFieldsStrChange={omoDraft.setOmoOtherFieldsStr}\n              isSlim={category === \"omo-slim\"}\n            />\n          )}\n\n        {/* OpenClaw 专属字段 */}\n        {appId === \"openclaw\" && (\n          <OpenClawFormFields\n            baseUrl={openclawForm.openclawBaseUrl}\n            onBaseUrlChange={openclawForm.handleOpenclawBaseUrlChange}\n            apiKey={openclawForm.openclawApiKey}\n            onApiKeyChange={openclawForm.handleOpenclawApiKeyChange}\n            category={category}\n            shouldShowApiKeyLink={shouldShowOpenclawApiKeyLink}\n            websiteUrl={openclawWebsiteUrl}\n            isPartner={isOpenclawPartner}\n            partnerPromotionKey={openclawPartnerPromotionKey}\n            api={openclawForm.openclawApi}\n            onApiChange={openclawForm.handleOpenclawApiChange}\n            models={openclawForm.openclawModels}\n            onModelsChange={openclawForm.handleOpenclawModelsChange}\n            userAgent={openclawForm.openclawUserAgent}\n            onUserAgentChange={openclawForm.handleOpenclawUserAgentChange}\n          />\n        )}\n\n        {/* 配置编辑器：Codex、Claude、Gemini 分别使用不同的编辑器 */}\n        {appId === \"codex\" ? (\n          <>\n            <CodexConfigEditor\n              authValue={codexAuth}\n              configValue={codexConfig}\n              onAuthChange={setCodexAuth}\n              onConfigChange={handleCodexConfigChange}\n              useCommonConfig={useCodexCommonConfigFlag}\n              onCommonConfigToggle={handleCodexCommonConfigToggle}\n              commonConfigSnippet={codexCommonConfigSnippet}\n              onCommonConfigSnippetChange={handleCodexCommonConfigSnippetChange}\n              onCommonConfigErrorClear={clearCodexCommonConfigError}\n              commonConfigError={codexCommonConfigError}\n              authError={codexAuthError}\n              configError={codexConfigError}\n              onExtract={handleCodexExtract}\n              isExtracting={isCodexExtracting}\n            />\n            {settingsConfigErrorField}\n          </>\n        ) : appId === \"gemini\" ? (\n          <>\n            <GeminiConfigEditor\n              envValue={geminiEnv}\n              configValue={geminiConfig}\n              onEnvChange={handleGeminiEnvChange}\n              onConfigChange={handleGeminiConfigChange}\n              useCommonConfig={useGeminiCommonConfigFlag}\n              onCommonConfigToggle={handleGeminiCommonConfigToggle}\n              commonConfigSnippet={geminiCommonConfigSnippet}\n              onCommonConfigSnippetChange={\n                handleGeminiCommonConfigSnippetChange\n              }\n              onCommonConfigErrorClear={clearGeminiCommonConfigError}\n              commonConfigError={geminiCommonConfigError}\n              envError={envError}\n              configError={geminiConfigError}\n              onExtract={handleGeminiExtract}\n              isExtracting={isGeminiExtracting}\n            />\n            {settingsConfigErrorField}\n          </>\n        ) : appId === \"opencode\" &&\n          (category === \"omo\" || category === \"omo-slim\") ? (\n          <div className=\"space-y-2\">\n            <Label>{t(\"provider.configJson\")}</Label>\n            <JsonEditor\n              value={omoDraft.mergedOmoJsonPreview}\n              onChange={() => {}}\n              rows={14}\n              showValidation={false}\n              language=\"json\"\n            />\n          </div>\n        ) : appId === \"opencode\" &&\n          category !== \"omo\" &&\n          category !== \"omo-slim\" ? (\n          <>\n            <div className=\"space-y-2\">\n              <Label htmlFor=\"settingsConfig\">{t(\"provider.configJson\")}</Label>\n              <JsonEditor\n                value={form.getValues(\"settingsConfig\")}\n                onChange={(config) => form.setValue(\"settingsConfig\", config)}\n                placeholder={`{\n  \"npm\": \"@ai-sdk/openai-compatible\",\n  \"options\": {\n    \"baseURL\": \"https://your-api-endpoint.com\",\n    \"apiKey\": \"your-api-key-here\"\n  },\n  \"models\": {}\n}`}\n                rows={14}\n                showValidation={true}\n                language=\"json\"\n              />\n            </div>\n            {settingsConfigErrorField}\n          </>\n        ) : appId === \"openclaw\" ? (\n          <>\n            <div className=\"space-y-2\">\n              <Label htmlFor=\"settingsConfig\">{t(\"provider.configJson\")}</Label>\n              <JsonEditor\n                value={form.getValues(\"settingsConfig\")}\n                onChange={(config) => form.setValue(\"settingsConfig\", config)}\n                placeholder={`{\n  \"baseUrl\": \"https://api.example.com/v1\",\n  \"apiKey\": \"your-api-key-here\",\n  \"api\": \"openai-completions\",\n  \"models\": []\n}`}\n                rows={14}\n                showValidation={true}\n                language=\"json\"\n              />\n            </div>\n            <FormField\n              control={form.control}\n              name=\"settingsConfig\"\n              render={() => (\n                <FormItem className=\"space-y-0\">\n                  <FormMessage />\n                </FormItem>\n              )}\n            />\n          </>\n        ) : (\n          <>\n            <CommonConfigEditor\n              value={form.getValues(\"settingsConfig\")}\n              onChange={(value) => form.setValue(\"settingsConfig\", value)}\n              useCommonConfig={useCommonConfig}\n              onCommonConfigToggle={handleCommonConfigToggle}\n              commonConfigSnippet={commonConfigSnippet}\n              onCommonConfigSnippetChange={handleCommonConfigSnippetChange}\n              commonConfigError={commonConfigError}\n              onEditClick={() => setIsCommonConfigModalOpen(true)}\n              isModalOpen={isCommonConfigModalOpen}\n              onModalClose={() => setIsCommonConfigModalOpen(false)}\n              onExtract={handleClaudeExtract}\n              isExtracting={isClaudeExtracting}\n            />\n            {settingsConfigErrorField}\n          </>\n        )}\n\n        {!isAnyOmoCategory && appId !== \"opencode\" && appId !== \"openclaw\" && (\n          <ProviderAdvancedConfig\n            testConfig={testConfig}\n            proxyConfig={proxyConfig}\n            pricingConfig={pricingConfig}\n            onTestConfigChange={setTestConfig}\n            onProxyConfigChange={setProxyConfig}\n            onPricingConfigChange={setPricingConfig}\n          />\n        )}\n\n        {showButtons && (\n          <div className=\"flex justify-end gap-2\">\n            <Button variant=\"outline\" type=\"button\" onClick={onCancel}>\n              {t(\"common.cancel\")}\n            </Button>\n            <Button type=\"submit\" disabled={isSubmitting}>\n              {submitLabel}\n            </Button>\n          </div>\n        )}\n      </form>\n    </Form>\n  );\n}\n\nexport type ProviderFormValues = ProviderFormData & {\n  presetId?: string;\n  presetCategory?: ProviderCategory;\n  isPartner?: boolean;\n  meta?: ProviderMeta;\n  providerKey?: string; // OpenCode/OpenClaw: user-defined provider key\n  suggestedDefaults?: OpenClawSuggestedDefaults; // OpenClaw: suggested default model configuration\n};\n"
  },
  {
    "path": "src/components/providers/forms/ProviderPresetSelector.tsx",
    "content": "import { useTranslation } from \"react-i18next\";\nimport { FormLabel } from \"@/components/ui/form\";\nimport { ClaudeIcon, CodexIcon, GeminiIcon } from \"@/components/BrandIcons\";\nimport { Zap, Star, Layers, Settings2 } from \"lucide-react\";\nimport type { ProviderPreset } from \"@/config/claudeProviderPresets\";\nimport type { CodexProviderPreset } from \"@/config/codexProviderPresets\";\nimport type { GeminiProviderPreset } from \"@/config/geminiProviderPresets\";\nimport type { ProviderCategory } from \"@/types\";\nimport {\n  universalProviderPresets,\n  type UniversalProviderPreset,\n} from \"@/config/universalProviderPresets\";\nimport { ProviderIcon } from \"@/components/ProviderIcon\";\n\ntype PresetEntry = {\n  id: string;\n  preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset;\n};\n\ninterface ProviderPresetSelectorProps {\n  selectedPresetId: string | null;\n  groupedPresets: Record<string, PresetEntry[]>;\n  categoryKeys: string[];\n  presetCategoryLabels: Record<string, string>;\n  onPresetChange: (value: string) => void;\n  onUniversalPresetSelect?: (preset: UniversalProviderPreset) => void;\n  onManageUniversalProviders?: () => void;\n  category?: ProviderCategory; // 当前选中的分类\n}\n\nexport function ProviderPresetSelector({\n  selectedPresetId,\n  groupedPresets,\n  categoryKeys,\n  presetCategoryLabels,\n  onPresetChange,\n  onUniversalPresetSelect,\n  onManageUniversalProviders,\n  category,\n}: ProviderPresetSelectorProps) {\n  const { t } = useTranslation();\n\n  const getCategoryHint = (): React.ReactNode => {\n    switch (category) {\n      case \"official\":\n        return t(\"providerForm.officialHint\", {\n          defaultValue: \"💡 官方供应商使用浏览器登录，无需配置 API Key\",\n        });\n      case \"cn_official\":\n        return t(\"providerForm.cnOfficialApiKeyHint\", {\n          defaultValue: \"💡 国产官方供应商只需填写 API Key，请求地址已预设\",\n        });\n      case \"aggregator\":\n        return t(\"providerForm.aggregatorApiKeyHint\", {\n          defaultValue: \"💡 聚合服务供应商只需填写 API Key 即可使用\",\n        });\n      case \"third_party\":\n        return t(\"providerForm.thirdPartyApiKeyHint\", {\n          defaultValue: \"💡 第三方供应商需要填写 API Key 和请求地址\",\n        });\n      case \"custom\":\n        return t(\"providerForm.customApiKeyHint\", {\n          defaultValue: \"💡 自定义配置需手动填写所有必要字段\",\n        });\n      case \"omo\":\n        return t(\"providerForm.omoHint\", {\n          defaultValue:\n            \"💡 OMO 配置管理 Agent 模型分配，写入 oh-my-opencode.jsonc\",\n        });\n      default:\n        return t(\"providerPreset.hint\", {\n          defaultValue: \"选择预设后可继续调整下方字段。\",\n        });\n    }\n  };\n\n  const renderPresetIcon = (\n    preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset,\n  ) => {\n    const iconType = preset.theme?.icon;\n    if (!iconType) return null;\n\n    switch (iconType) {\n      case \"claude\":\n        return <ClaudeIcon size={14} />;\n      case \"codex\":\n        return <CodexIcon size={14} />;\n      case \"gemini\":\n        return <GeminiIcon size={14} />;\n      case \"generic\":\n        return <Zap size={14} />;\n      default:\n        return null;\n    }\n  };\n\n  const getPresetButtonClass = (\n    isSelected: boolean,\n    preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset,\n  ) => {\n    const baseClass =\n      \"inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors\";\n\n    if (isSelected) {\n      if (preset.theme?.backgroundColor) {\n        return `${baseClass} text-white`;\n      }\n      return `${baseClass} bg-blue-500 text-white dark:bg-blue-600`;\n    }\n\n    return `${baseClass} bg-accent text-muted-foreground hover:bg-accent/80`;\n  };\n\n  const getPresetButtonStyle = (\n    isSelected: boolean,\n    preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset,\n  ) => {\n    if (!isSelected || !preset.theme?.backgroundColor) {\n      return undefined;\n    }\n\n    return {\n      backgroundColor: preset.theme.backgroundColor,\n      color: preset.theme.textColor || \"#FFFFFF\",\n    };\n  };\n\n  return (\n    <div className=\"space-y-3\">\n      <FormLabel>{t(\"providerPreset.label\")}</FormLabel>\n      <div className=\"flex flex-wrap gap-2\">\n        <button\n          type=\"button\"\n          onClick={() => onPresetChange(\"custom\")}\n          className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${\n            selectedPresetId === \"custom\"\n              ? \"bg-blue-500 text-white dark:bg-blue-600\"\n              : \"bg-accent text-muted-foreground hover:bg-accent/80\"\n          }`}\n        >\n          {t(\"providerPreset.custom\")}\n        </button>\n\n        {categoryKeys.map((category) => {\n          const entries = groupedPresets[category];\n          if (!entries || entries.length === 0) return null;\n          return entries.map((entry) => {\n            const isSelected = selectedPresetId === entry.id;\n            const isPartner = entry.preset.isPartner;\n            return (\n              <button\n                key={entry.id}\n                type=\"button\"\n                onClick={() => onPresetChange(entry.id)}\n                className={`${getPresetButtonClass(isSelected, entry.preset)} relative`}\n                style={getPresetButtonStyle(isSelected, entry.preset)}\n                title={\n                  presetCategoryLabels[category] ?? t(\"providerPreset.other\")\n                }\n              >\n                {renderPresetIcon(entry.preset)}\n                {entry.preset.nameKey\n                  ? t(entry.preset.nameKey)\n                  : entry.preset.name}\n                {isPartner && (\n                  <span className=\"absolute -top-1 -right-1 flex items-center gap-0.5 rounded-full bg-gradient-to-r from-amber-500 to-yellow-500 px-1.5 py-0.5 text-[10px] font-bold text-white shadow-md\">\n                    <Star className=\"h-2.5 w-2.5 fill-current\" />\n                  </span>\n                )}\n              </button>\n            );\n          });\n        })}\n      </div>\n\n      {onUniversalPresetSelect && universalProviderPresets.length > 0 && (\n        <>\n          <div className=\"flex flex-wrap items-center gap-2\">\n            {universalProviderPresets.map((preset) => (\n              <button\n                key={`universal-${preset.providerType}`}\n                type=\"button\"\n                onClick={() => onUniversalPresetSelect(preset)}\n                className=\"inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-muted-foreground hover:bg-accent/80 relative\"\n                title={t(\"universalProvider.hint\", {\n                  defaultValue:\n                    \"跨应用统一配置，自动同步到 Claude/Codex/Gemini\",\n                })}\n              >\n                <ProviderIcon icon={preset.icon} name={preset.name} size={14} />\n                {preset.name}\n                <span className=\"absolute -top-1 -right-1 flex items-center gap-0.5 rounded-full bg-gradient-to-r from-indigo-500 to-purple-500 px-1.5 py-0.5 text-[10px] font-bold text-white shadow-md\">\n                  <Layers className=\"h-2.5 w-2.5\" />\n                </span>\n              </button>\n            ))}\n            {onManageUniversalProviders && (\n              <button\n                type=\"button\"\n                onClick={onManageUniversalProviders}\n                className=\"inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-muted-foreground hover:bg-accent/80\"\n                title={t(\"universalProvider.manage\", {\n                  defaultValue: \"管理统一供应商\",\n                })}\n              >\n                <Settings2 className=\"h-4 w-4\" />\n                {t(\"universalProvider.manage\", {\n                  defaultValue: \"管理\",\n                })}\n              </button>\n            )}\n          </div>\n        </>\n      )}\n\n      <p className=\"text-xs text-muted-foreground\">{getCategoryHint()}</p>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/providers/forms/helpers/opencodeFormUtils.ts",
    "content": "import type { OpenCodeModel, OpenCodeProviderConfig } from \"@/types\";\nimport type { PricingModelSourceOption } from \"../ProviderAdvancedConfig\";\n\n// ── Default configs ──────────────────────────────────────────────────\n\nexport const CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {} }, null, 2);\nexport const CODEX_DEFAULT_CONFIG = JSON.stringify(\n  { auth: {}, config: \"\" },\n  null,\n  2,\n);\nexport const GEMINI_DEFAULT_CONFIG = JSON.stringify(\n  {\n    env: {\n      GOOGLE_GEMINI_BASE_URL: \"\",\n      GEMINI_API_KEY: \"\",\n      GEMINI_MODEL: \"gemini-3-pro-preview\",\n    },\n  },\n  null,\n  2,\n);\n\nexport const OPENCODE_DEFAULT_NPM = \"@ai-sdk/openai-compatible\";\nexport const OPENCODE_DEFAULT_CONFIG = JSON.stringify(\n  {\n    npm: OPENCODE_DEFAULT_NPM,\n    options: {\n      baseURL: \"\",\n      apiKey: \"\",\n      setCacheKey: true,\n    },\n    models: {},\n  },\n  null,\n  2,\n);\nexport const OPENCODE_KNOWN_OPTION_KEYS = [\n  \"baseURL\",\n  \"apiKey\",\n  \"headers\",\n] as const;\n\nexport const OPENCLAW_DEFAULT_CONFIG = JSON.stringify(\n  {\n    baseUrl: \"\",\n    apiKey: \"\",\n    api: \"openai-completions\",\n    models: [],\n  },\n  null,\n  2,\n);\n\n// ── Pure functions ───────────────────────────────────────────────────\n\nexport function isKnownOpencodeOptionKey(key: string): boolean {\n  return OPENCODE_KNOWN_OPTION_KEYS.includes(\n    key as (typeof OPENCODE_KNOWN_OPTION_KEYS)[number],\n  );\n}\n\nexport function parseOpencodeConfig(\n  settingsConfig?: Record<string, unknown>,\n): OpenCodeProviderConfig {\n  const normalize = (\n    parsed: Partial<OpenCodeProviderConfig>,\n  ): OpenCodeProviderConfig => ({\n    npm: parsed.npm || OPENCODE_DEFAULT_NPM,\n    options:\n      parsed.options && typeof parsed.options === \"object\"\n        ? (parsed.options as OpenCodeProviderConfig[\"options\"])\n        : {},\n    models:\n      parsed.models && typeof parsed.models === \"object\"\n        ? (parsed.models as Record<string, OpenCodeModel>)\n        : {},\n  });\n\n  try {\n    const parsed = JSON.parse(\n      settingsConfig ? JSON.stringify(settingsConfig) : OPENCODE_DEFAULT_CONFIG,\n    ) as Partial<OpenCodeProviderConfig>;\n    return normalize(parsed);\n  } catch {\n    return {\n      npm: OPENCODE_DEFAULT_NPM,\n      options: {},\n      models: {},\n    };\n  }\n}\n\nexport function parseOpencodeConfigStrict(\n  settingsConfig?: Record<string, unknown>,\n): OpenCodeProviderConfig {\n  const parsed = JSON.parse(\n    settingsConfig ? JSON.stringify(settingsConfig) : OPENCODE_DEFAULT_CONFIG,\n  ) as Partial<OpenCodeProviderConfig>;\n  return {\n    npm: parsed.npm || OPENCODE_DEFAULT_NPM,\n    options:\n      parsed.options && typeof parsed.options === \"object\"\n        ? (parsed.options as OpenCodeProviderConfig[\"options\"])\n        : {},\n    models:\n      parsed.models && typeof parsed.models === \"object\"\n        ? (parsed.models as Record<string, OpenCodeModel>)\n        : {},\n  };\n}\n\nexport const OPENCODE_KNOWN_MODEL_KEYS = [\"name\", \"limit\", \"options\"] as const;\n\nexport function isKnownModelKey(key: string): boolean {\n  return OPENCODE_KNOWN_MODEL_KEYS.includes(\n    key as (typeof OPENCODE_KNOWN_MODEL_KEYS)[number],\n  );\n}\n\nexport function getModelExtraFields(\n  model: OpenCodeModel,\n): Record<string, string> {\n  const extra: Record<string, string> = {};\n  for (const [k, v] of Object.entries(model)) {\n    if (!isKnownModelKey(k)) {\n      extra[k] = typeof v === \"string\" ? v : JSON.stringify(v);\n    }\n  }\n  return extra;\n}\n\nexport function toOpencodeExtraOptions(\n  options: OpenCodeProviderConfig[\"options\"],\n): Record<string, string> {\n  const extra: Record<string, string> = {};\n  for (const [k, v] of Object.entries(options || {})) {\n    if (!isKnownOpencodeOptionKey(k)) {\n      extra[k] = typeof v === \"string\" ? v : JSON.stringify(v);\n    }\n  }\n  return extra;\n}\n\nexport { buildOmoProfilePreview } from \"@/types/omo\";\n\nexport const normalizePricingSource = (\n  value?: string,\n): PricingModelSourceOption =>\n  value === \"request\" || value === \"response\" ? value : \"inherit\";\n"
  },
  {
    "path": "src/components/providers/forms/hooks/index.ts",
    "content": "export { useProviderCategory } from \"./useProviderCategory\";\nexport { useApiKeyState } from \"./useApiKeyState\";\nexport { useBaseUrlState } from \"./useBaseUrlState\";\nexport { useModelState } from \"./useModelState\";\nexport { useCodexConfigState } from \"./useCodexConfigState\";\nexport { useApiKeyLink } from \"./useApiKeyLink\";\nexport { useCustomEndpoints } from \"./useCustomEndpoints\";\nexport { useTemplateValues } from \"./useTemplateValues\";\nexport { useCommonConfigSnippet } from \"./useCommonConfigSnippet\";\nexport { useCodexCommonConfig } from \"./useCodexCommonConfig\";\nexport { useSpeedTestEndpoints } from \"./useSpeedTestEndpoints\";\nexport { useCodexTomlValidation } from \"./useCodexTomlValidation\";\nexport { useGeminiConfigState } from \"./useGeminiConfigState\";\nexport { useManagedAuth } from \"./useManagedAuth\";\nexport { useGeminiCommonConfig } from \"./useGeminiCommonConfig\";\nexport { useOmoModelSource } from \"./useOmoModelSource\";\nexport { useOpencodeFormState } from \"./useOpencodeFormState\";\nexport { useOmoDraftState } from \"./useOmoDraftState\";\nexport { useOpenclawFormState } from \"./useOpenclawFormState\";\nexport { useCopilotAuth } from \"./useCopilotAuth\";\n"
  },
  {
    "path": "src/components/providers/forms/hooks/useApiKeyLink.ts",
    "content": "import { useMemo } from \"react\";\nimport type { AppId } from \"@/lib/api\";\nimport type { ProviderCategory } from \"@/types\";\nimport type { ProviderPreset } from \"@/config/claudeProviderPresets\";\nimport type { CodexProviderPreset } from \"@/config/codexProviderPresets\";\nimport type { GeminiProviderPreset } from \"@/config/geminiProviderPresets\";\nimport type { OpenCodeProviderPreset } from \"@/config/opencodeProviderPresets\";\n\ntype PresetEntry = {\n  id: string;\n  preset:\n    | ProviderPreset\n    | CodexProviderPreset\n    | GeminiProviderPreset\n    | OpenCodeProviderPreset;\n};\n\ninterface UseApiKeyLinkProps {\n  appId: AppId;\n  category?: ProviderCategory;\n  selectedPresetId: string | null;\n  presetEntries: PresetEntry[];\n  formWebsiteUrl: string;\n}\n\n/**\n * 管理 API Key 获取链接的显示和 URL\n */\nexport function useApiKeyLink({\n  appId,\n  category,\n  selectedPresetId,\n  presetEntries,\n  formWebsiteUrl,\n}: UseApiKeyLinkProps) {\n  // 判断是否显示 API Key 获取链接\n  const shouldShowApiKeyLink = useMemo(() => {\n    return (\n      category !== \"official\" &&\n      (category === \"cn_official\" ||\n        category === \"aggregator\" ||\n        category === \"third_party\")\n    );\n  }, [category]);\n\n  // 获取当前预设条目\n  const currentPresetEntry = useMemo(() => {\n    if (selectedPresetId && selectedPresetId !== \"custom\") {\n      return presetEntries.find((item) => item.id === selectedPresetId);\n    }\n    return undefined;\n  }, [selectedPresetId, presetEntries]);\n\n  // 获取当前供应商的网址（用于 API Key 链接）\n  const getWebsiteUrl = useMemo(() => {\n    if (currentPresetEntry) {\n      const preset = currentPresetEntry.preset;\n      // 对于 cn_official、aggregator、third_party，优先使用 apiKeyUrl（可能包含推广参数）\n      if (\n        preset.category === \"cn_official\" ||\n        preset.category === \"aggregator\" ||\n        preset.category === \"third_party\"\n      ) {\n        return preset.apiKeyUrl || preset.websiteUrl || \"\";\n      }\n      return preset.websiteUrl || \"\";\n    }\n    return formWebsiteUrl || \"\";\n  }, [currentPresetEntry, formWebsiteUrl]);\n\n  // 提取合作伙伴信息\n  const isPartner = useMemo(() => {\n    return currentPresetEntry?.preset.isPartner ?? false;\n  }, [currentPresetEntry]);\n\n  const partnerPromotionKey = useMemo(() => {\n    return currentPresetEntry?.preset.partnerPromotionKey;\n  }, [currentPresetEntry]);\n\n  return {\n    shouldShowApiKeyLink:\n      appId === \"claude\" ||\n      appId === \"codex\" ||\n      appId === \"gemini\" ||\n      appId === \"opencode\"\n        ? shouldShowApiKeyLink\n        : false,\n    websiteUrl: getWebsiteUrl,\n    isPartner,\n    partnerPromotionKey,\n  };\n}\n"
  },
  {
    "path": "src/components/providers/forms/hooks/useApiKeyState.ts",
    "content": "import { useEffect, useState, useCallback } from \"react\";\nimport type { ProviderCategory } from \"@/types\";\nimport {\n  getApiKeyFromConfig,\n  setApiKeyInConfig,\n  hasApiKeyField,\n} from \"@/utils/providerConfigUtils\";\n\ninterface UseApiKeyStateProps {\n  initialConfig?: string;\n  onConfigChange: (config: string) => void;\n  selectedPresetId: string | null;\n  category?: ProviderCategory;\n  appType?: string;\n  apiKeyField?: string;\n}\n\n/**\n * 管理 API Key 输入状态\n * 自动同步 API Key 和 JSON 配置\n */\nexport function useApiKeyState({\n  initialConfig,\n  onConfigChange,\n  selectedPresetId,\n  category,\n  appType,\n  apiKeyField,\n}: UseApiKeyStateProps) {\n  const [apiKey, setApiKey] = useState(() => {\n    if (initialConfig) {\n      return getApiKeyFromConfig(initialConfig, appType);\n    }\n    return \"\";\n  });\n\n  // 当外部通过 form.reset / 读取 live 等方式更新配置时，同步回 API Key 状态\n  // - 仅在 JSON 可解析时同步，避免用户编辑 JSON 过程中因临时无效导致输入框闪烁\n  useEffect(() => {\n    if (!initialConfig) return;\n\n    try {\n      JSON.parse(initialConfig);\n    } catch {\n      return;\n    }\n\n    // 从配置中提取 API Key（如果不存在则返回空字符串）\n    const extracted = getApiKeyFromConfig(initialConfig, appType);\n    if (extracted !== apiKey) {\n      setApiKey(extracted);\n    }\n  }, [initialConfig, appType, apiKey]);\n\n  const handleApiKeyChange = useCallback(\n    (key: string) => {\n      setApiKey(key);\n\n      const configString = setApiKeyInConfig(\n        initialConfig || \"{}\",\n        key.trim(),\n        {\n          // 最佳实践：仅在\"新增模式\"且\"非官方类别\"时补齐缺失字段\n          // - 新增模式：selectedPresetId !== null\n          // - 非官方类别：category !== undefined && category !== \"official\"\n          // - 官方类别：不创建字段（UI 也会禁用输入框）\n          // - 未传入 category：不创建字段（避免意外行为）\n          createIfMissing:\n            selectedPresetId !== null &&\n            category !== undefined &&\n            category !== \"official\",\n          appType,\n          apiKeyField,\n        },\n      );\n\n      onConfigChange(configString);\n    },\n    [\n      initialConfig,\n      selectedPresetId,\n      category,\n      appType,\n      apiKeyField,\n      onConfigChange,\n    ],\n  );\n\n  const showApiKey = useCallback(\n    (config: string, isEditMode: boolean) => {\n      return (\n        selectedPresetId !== null ||\n        (isEditMode && hasApiKeyField(config, appType))\n      );\n    },\n    [selectedPresetId, appType],\n  );\n\n  return {\n    apiKey,\n    setApiKey,\n    handleApiKeyChange,\n    showApiKey,\n  };\n}\n"
  },
  {
    "path": "src/components/providers/forms/hooks/useBaseUrlState.ts",
    "content": "import { useState, useCallback, useRef, useEffect } from \"react\";\nimport {\n  extractCodexBaseUrl,\n  setCodexBaseUrl as setCodexBaseUrlInConfig,\n} from \"@/utils/providerConfigUtils\";\nimport type { ProviderCategory } from \"@/types\";\nimport type { AppId } from \"@/lib/api\";\n\ninterface UseBaseUrlStateProps {\n  appType: AppId;\n  category: ProviderCategory | undefined;\n  settingsConfig: string;\n  codexConfig?: string;\n  onSettingsConfigChange: (config: string) => void;\n  onCodexConfigChange?: (config: string) => void;\n}\n\n/**\n * 管理 Base URL 状态\n * 支持 Claude (JSON) 和 Codex (TOML) 两种格式\n */\nexport function useBaseUrlState({\n  appType,\n  category,\n  settingsConfig,\n  codexConfig,\n  onSettingsConfigChange,\n  onCodexConfigChange,\n}: UseBaseUrlStateProps) {\n  const [baseUrl, setBaseUrl] = useState(\"\");\n  const [codexBaseUrl, setCodexBaseUrl] = useState(\"\");\n  const [geminiBaseUrl, setGeminiBaseUrl] = useState(\"\");\n  const isUpdatingRef = useRef(false);\n\n  // 从配置同步到 state（Claude）\n  useEffect(() => {\n    if (appType !== \"claude\") return;\n    // 只有 official 类别不显示 Base URL 输入框，其他类别都需要回填\n    if (category === \"official\") return;\n    if (isUpdatingRef.current) return;\n\n    try {\n      const config = JSON.parse(settingsConfig || \"{}\");\n      const envUrl: unknown = config?.env?.ANTHROPIC_BASE_URL;\n      const nextUrl = typeof envUrl === \"string\" ? envUrl.trim() : \"\";\n      if (nextUrl !== baseUrl) {\n        setBaseUrl(nextUrl);\n      }\n    } catch {\n      // ignore\n    }\n  }, [appType, category, settingsConfig, baseUrl]);\n\n  // 从配置同步到 state（Codex）\n  useEffect(() => {\n    if (appType !== \"codex\") return;\n    // 只有 official 类别不显示 Base URL 输入框，其他类别都需要回填\n    if (category === \"official\") return;\n    if (isUpdatingRef.current) return;\n    if (!codexConfig) return;\n\n    const extracted = extractCodexBaseUrl(codexConfig) || \"\";\n    setCodexBaseUrl((prev) => (prev === extracted ? prev : extracted));\n  }, [appType, category, codexConfig]);\n\n  // 从Claude配置同步到 state（Gemini）\n  useEffect(() => {\n    if (appType !== \"gemini\") return;\n    // 只有 official 类别不显示 Base URL 输入框，其他类别都需要回填\n    if (category === \"official\") return;\n    if (isUpdatingRef.current) return;\n\n    try {\n      const config = JSON.parse(settingsConfig || \"{}\");\n      const envUrl: unknown = config?.env?.GOOGLE_GEMINI_BASE_URL;\n      const nextUrl = typeof envUrl === \"string\" ? envUrl.trim() : \"\";\n      if (nextUrl !== geminiBaseUrl) {\n        setGeminiBaseUrl(nextUrl);\n        setBaseUrl(nextUrl); // 也更新 baseUrl 用于 UI\n      }\n    } catch {\n      // ignore\n    }\n  }, [appType, category, settingsConfig, geminiBaseUrl]);\n\n  // 处理 Claude Base URL 变化\n  const handleClaudeBaseUrlChange = useCallback(\n    (url: string) => {\n      const sanitized = url.trim();\n      setBaseUrl(sanitized);\n      isUpdatingRef.current = true;\n\n      try {\n        const config = JSON.parse(settingsConfig || \"{}\");\n        if (!config.env) {\n          config.env = {};\n        }\n        config.env.ANTHROPIC_BASE_URL = sanitized;\n        onSettingsConfigChange(JSON.stringify(config, null, 2));\n      } catch {\n        // ignore\n      } finally {\n        setTimeout(() => {\n          isUpdatingRef.current = false;\n        }, 0);\n      }\n    },\n    [settingsConfig, onSettingsConfigChange],\n  );\n\n  // 处理 Codex Base URL 变化\n  const handleCodexBaseUrlChange = useCallback(\n    (url: string) => {\n      const sanitized = url.trim();\n      setCodexBaseUrl(sanitized);\n\n      if (!onCodexConfigChange) {\n        return;\n      }\n\n      isUpdatingRef.current = true;\n      const updatedConfig = setCodexBaseUrlInConfig(\n        codexConfig || \"\",\n        sanitized,\n      );\n      onCodexConfigChange(updatedConfig);\n\n      setTimeout(() => {\n        isUpdatingRef.current = false;\n      }, 0);\n    },\n    [codexConfig, onCodexConfigChange],\n  );\n\n  // 处理 Gemini Base URL 变化\n  const handleGeminiBaseUrlChange = useCallback(\n    (url: string) => {\n      const sanitized = url.trim();\n      setGeminiBaseUrl(sanitized);\n      setBaseUrl(sanitized); // 也更新 baseUrl 用于 UI\n      isUpdatingRef.current = true;\n\n      try {\n        const config = JSON.parse(settingsConfig || \"{}\");\n        if (!config.env) {\n          config.env = {};\n        }\n        config.env.GOOGLE_GEMINI_BASE_URL = sanitized;\n        onSettingsConfigChange(JSON.stringify(config, null, 2));\n      } catch {\n        // ignore\n      } finally {\n        setTimeout(() => {\n          isUpdatingRef.current = false;\n        }, 0);\n      }\n    },\n    [settingsConfig, onSettingsConfigChange],\n  );\n\n  return {\n    baseUrl,\n    setBaseUrl,\n    codexBaseUrl,\n    setCodexBaseUrl,\n    geminiBaseUrl,\n    setGeminiBaseUrl,\n    handleClaudeBaseUrlChange,\n    handleCodexBaseUrlChange,\n    handleGeminiBaseUrlChange,\n  };\n}\n"
  },
  {
    "path": "src/components/providers/forms/hooks/useCodexCommonConfig.ts",
    "content": "import { useState, useEffect, useCallback, useRef } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { parse as parseToml } from \"smol-toml\";\nimport {\n  updateTomlCommonConfigSnippet,\n  hasTomlCommonConfigSnippet,\n} from \"@/utils/providerConfigUtils\";\nimport { configApi } from \"@/lib/api\";\nimport { normalizeTomlText } from \"@/utils/textNormalization\";\n\nconst LEGACY_STORAGE_KEY = \"cc-switch:codex-common-config-snippet\";\nconst DEFAULT_CODEX_COMMON_CONFIG_SNIPPET = `# Common Codex config\n# Add your common TOML configuration here`;\n\ninterface UseCodexCommonConfigProps {\n  codexConfig: string;\n  onConfigChange: (config: string) => void;\n  initialData?: {\n    settingsConfig?: Record<string, unknown>;\n  };\n  initialEnabled?: boolean;\n  selectedPresetId?: string;\n}\n\n/**\n * 管理 Codex 通用配置片段 (TOML 格式)\n * 从 config.json 读取和保存，支持从 localStorage 平滑迁移\n */\nexport function useCodexCommonConfig({\n  codexConfig,\n  onConfigChange,\n  initialData,\n  initialEnabled,\n  selectedPresetId,\n}: UseCodexCommonConfigProps) {\n  const { t } = useTranslation();\n  const [useCommonConfig, setUseCommonConfig] = useState(false);\n  const [commonConfigSnippet, setCommonConfigSnippetState] = useState<string>(\n    DEFAULT_CODEX_COMMON_CONFIG_SNIPPET,\n  );\n  const [commonConfigError, setCommonConfigError] = useState(\"\");\n  const [isLoading, setIsLoading] = useState(true);\n  const [isExtracting, setIsExtracting] = useState(false);\n\n  // 用于跟踪是否正在通过通用配置更新\n  const isUpdatingFromCommonConfig = useRef(false);\n  // 用于跟踪新建模式是否已初始化默认勾选\n  const hasInitializedNewMode = useRef(false);\n  // 用于跟踪编辑模式是否已初始化显式开关/预览\n  const hasInitializedEditMode = useRef(false);\n\n  // 当预设变化时，重置初始化标记，使新预设能够重新触发初始化逻辑\n  useEffect(() => {\n    hasInitializedNewMode.current = false;\n    hasInitializedEditMode.current = false;\n  }, [selectedPresetId, initialEnabled]);\n\n  const parseCommonConfigSnippet = useCallback((snippetString: string) => {\n    const trimmed = snippetString.trim();\n    if (!trimmed) {\n      return {\n        hasContent: false,\n      };\n    }\n\n    try {\n      const parsed = parseToml(normalizeTomlText(snippetString)) as Record<\n        string,\n        unknown\n      >;\n      return {\n        hasContent: Object.keys(parsed).length > 0,\n      };\n    } catch (error) {\n      return {\n        hasContent: false,\n        error: error instanceof Error ? error.message : String(error),\n      };\n    }\n  }, []);\n\n  // 初始化：从 config.json 加载，支持从 localStorage 迁移\n  useEffect(() => {\n    let mounted = true;\n\n    const loadSnippet = async () => {\n      try {\n        // 使用统一 API 加载\n        const snippet = await configApi.getCommonConfigSnippet(\"codex\");\n\n        if (snippet && snippet.trim()) {\n          if (mounted) {\n            setCommonConfigSnippetState(snippet);\n          }\n        } else {\n          // 如果 config.json 中没有，尝试从 localStorage 迁移\n          if (typeof window !== \"undefined\") {\n            try {\n              const legacySnippet =\n                window.localStorage.getItem(LEGACY_STORAGE_KEY);\n              if (legacySnippet && legacySnippet.trim()) {\n                // 迁移到 config.json\n                await configApi.setCommonConfigSnippet(\"codex\", legacySnippet);\n                if (mounted) {\n                  setCommonConfigSnippetState(legacySnippet);\n                }\n                // 清理 localStorage\n                window.localStorage.removeItem(LEGACY_STORAGE_KEY);\n                console.log(\n                  \"[迁移] Codex 通用配置已从 localStorage 迁移到 config.json\",\n                );\n              }\n            } catch (e) {\n              console.warn(\"[迁移] 从 localStorage 迁移失败:\", e);\n            }\n          }\n        }\n      } catch (error) {\n        console.error(\"加载 Codex 通用配置失败:\", error);\n      } finally {\n        if (mounted) {\n          setIsLoading(false);\n        }\n      }\n    };\n\n    loadSnippet();\n\n    return () => {\n      mounted = false;\n    };\n  }, []);\n\n  // 初始化时检查通用配置片段（编辑模式）\n  useEffect(() => {\n    if (\n      !initialData?.settingsConfig ||\n      isLoading ||\n      hasInitializedEditMode.current\n    ) {\n      return;\n    }\n\n    hasInitializedEditMode.current = true;\n\n    const parsedSnippet = parseCommonConfigSnippet(commonConfigSnippet);\n    if (parsedSnippet.error) {\n      if (commonConfigSnippet.trim()) {\n        setCommonConfigError(parsedSnippet.error);\n      }\n      setUseCommonConfig(false);\n      return;\n    }\n\n    const config =\n      typeof initialData.settingsConfig.config === \"string\"\n        ? initialData.settingsConfig.config\n        : \"\";\n    const inferredHasCommon = hasTomlCommonConfigSnippet(\n      config,\n      commonConfigSnippet,\n    );\n    const hasCommon = initialEnabled ?? inferredHasCommon;\n\n    if (hasCommon && !inferredHasCommon) {\n      const { updatedConfig, error } = updateTomlCommonConfigSnippet(\n        codexConfig,\n        commonConfigSnippet,\n        true,\n      );\n      if (error) {\n        setCommonConfigError(error);\n        setUseCommonConfig(false);\n        return;\n      }\n\n      setCommonConfigError(\"\");\n      setUseCommonConfig(true);\n      isUpdatingFromCommonConfig.current = true;\n      onConfigChange(updatedConfig);\n      setTimeout(() => {\n        isUpdatingFromCommonConfig.current = false;\n      }, 0);\n      return;\n    }\n\n    setCommonConfigError(\"\");\n    setUseCommonConfig(hasCommon);\n  }, [\n    codexConfig,\n    commonConfigSnippet,\n    initialData,\n    initialEnabled,\n    isLoading,\n    onConfigChange,\n    parseCommonConfigSnippet,\n  ]);\n\n  // 新建模式：如果通用配置片段存在且有效，默认启用\n  useEffect(() => {\n    if (initialData || isLoading || hasInitializedNewMode.current) {\n      return;\n    }\n\n    hasInitializedNewMode.current = true;\n\n    const parsedSnippet = parseCommonConfigSnippet(commonConfigSnippet);\n    if (parsedSnippet.error) {\n      if (commonConfigSnippet.trim()) {\n        setCommonConfigError(parsedSnippet.error);\n      }\n      setUseCommonConfig(false);\n      return;\n    }\n    if (!parsedSnippet.hasContent) {\n      return;\n    }\n\n    const { updatedConfig, error } = updateTomlCommonConfigSnippet(\n      codexConfig,\n      commonConfigSnippet,\n      true,\n    );\n    if (error) {\n      setCommonConfigError(error);\n      setUseCommonConfig(false);\n      return;\n    }\n\n    setCommonConfigError(\"\");\n    setUseCommonConfig(true);\n    isUpdatingFromCommonConfig.current = true;\n    onConfigChange(updatedConfig);\n    setTimeout(() => {\n      isUpdatingFromCommonConfig.current = false;\n    }, 0);\n  }, [\n    initialData,\n    commonConfigSnippet,\n    isLoading,\n    codexConfig,\n    onConfigChange,\n    parseCommonConfigSnippet,\n  ]);\n\n  // 处理通用配置开关\n  const handleCommonConfigToggle = useCallback(\n    (checked: boolean) => {\n      const parsedSnippet = parseCommonConfigSnippet(commonConfigSnippet);\n      if (parsedSnippet.error) {\n        setCommonConfigError(parsedSnippet.error);\n        setUseCommonConfig(false);\n        return;\n      }\n      if (!parsedSnippet.hasContent) {\n        setCommonConfigError(\n          t(\"codexConfig.noCommonConfigToApply\", {\n            defaultValue: \"通用配置片段为空或没有可写入的内容\",\n          }),\n        );\n        setUseCommonConfig(false);\n        return;\n      }\n\n      const { updatedConfig, error: snippetError } =\n        updateTomlCommonConfigSnippet(\n          codexConfig,\n          commonConfigSnippet,\n          checked,\n        );\n\n      if (snippetError) {\n        setCommonConfigError(snippetError);\n        setUseCommonConfig(false);\n        return;\n      }\n\n      setCommonConfigError(\"\");\n      setUseCommonConfig(checked);\n      // 标记正在通过通用配置更新\n      isUpdatingFromCommonConfig.current = true;\n      onConfigChange(updatedConfig);\n      // 在下一个事件循环中重置标记\n      setTimeout(() => {\n        isUpdatingFromCommonConfig.current = false;\n      }, 0);\n    },\n    [\n      codexConfig,\n      commonConfigSnippet,\n      onConfigChange,\n      parseCommonConfigSnippet,\n      t,\n    ],\n  );\n\n  // 处理通用配置片段变化\n  const handleCommonConfigSnippetChange = useCallback(\n    (value: string): boolean => {\n      const previousSnippet = commonConfigSnippet;\n\n      if (!value.trim()) {\n        setCommonConfigError(\"\");\n\n        if (useCommonConfig) {\n          const previousParsed = parseCommonConfigSnippet(previousSnippet);\n          let updatedConfig = codexConfig;\n\n          if (!previousParsed.error && previousParsed.hasContent) {\n            const removeResult = updateTomlCommonConfigSnippet(\n              codexConfig,\n              previousSnippet,\n              false,\n            );\n            if (removeResult.error) {\n              setCommonConfigError(removeResult.error);\n              return false;\n            }\n            updatedConfig = removeResult.updatedConfig;\n          }\n\n          onConfigChange(updatedConfig);\n          setUseCommonConfig(false);\n        }\n\n        setCommonConfigSnippetState(\"\");\n        configApi\n          .setCommonConfigSnippet(\"codex\", \"\")\n          .catch((error: unknown) => {\n            console.error(\"保存 Codex 通用配置失败:\", error);\n            setCommonConfigError(\n              t(\"codexConfig.saveFailed\", { error: String(error) }),\n            );\n          });\n        return true;\n      }\n\n      const parsedNextSnippet = parseCommonConfigSnippet(value);\n      if (parsedNextSnippet.error) {\n        setCommonConfigError(parsedNextSnippet.error);\n        return false;\n      }\n\n      // 若当前启用通用配置，需要替换为最新片段\n      if (useCommonConfig) {\n        let nextConfig = codexConfig;\n        const previousParsed = parseCommonConfigSnippet(previousSnippet);\n\n        if (!previousParsed.error && previousParsed.hasContent) {\n          const removeResult = updateTomlCommonConfigSnippet(\n            codexConfig,\n            previousSnippet,\n            false,\n          );\n          if (removeResult.error) {\n            setCommonConfigError(removeResult.error);\n            return false;\n          }\n          nextConfig = removeResult.updatedConfig;\n        }\n\n        const addResult = updateTomlCommonConfigSnippet(\n          nextConfig,\n          value,\n          true,\n        );\n\n        if (addResult.error) {\n          setCommonConfigError(addResult.error);\n          return false;\n        }\n\n        // 标记正在通过通用配置更新，避免触发状态检查\n        isUpdatingFromCommonConfig.current = true;\n        onConfigChange(addResult.updatedConfig);\n        // 在下一个事件循环中重置标记\n        setTimeout(() => {\n          isUpdatingFromCommonConfig.current = false;\n        }, 0);\n      }\n\n      setCommonConfigError(\"\");\n      setCommonConfigSnippetState(value);\n      configApi\n        .setCommonConfigSnippet(\"codex\", value)\n        .catch((error: unknown) => {\n          console.error(\"保存 Codex 通用配置失败:\", error);\n          setCommonConfigError(\n            t(\"codexConfig.saveFailed\", { error: String(error) }),\n          );\n        });\n\n      return true;\n    },\n    [\n      commonConfigSnippet,\n      codexConfig,\n      onConfigChange,\n      parseCommonConfigSnippet,\n      t,\n      useCommonConfig,\n    ],\n  );\n\n  // 当配置变化时检查是否包含通用配置（但避免在通过通用配置更新时检查）\n  useEffect(() => {\n    if (isUpdatingFromCommonConfig.current || isLoading) {\n      return;\n    }\n    const parsedSnippet = parseCommonConfigSnippet(commonConfigSnippet);\n    if (parsedSnippet.error) {\n      setUseCommonConfig(false);\n      return;\n    }\n    const hasCommon = hasTomlCommonConfigSnippet(\n      codexConfig,\n      commonConfigSnippet,\n    );\n    setUseCommonConfig(hasCommon);\n  }, [codexConfig, commonConfigSnippet, isLoading, parseCommonConfigSnippet]);\n\n  // 从编辑器当前内容提取通用配置片段\n  const handleExtract = useCallback(async () => {\n    setIsExtracting(true);\n    setCommonConfigError(\"\");\n\n    try {\n      const extracted = await configApi.extractCommonConfigSnippet(\"codex\", {\n        settingsConfig: JSON.stringify({\n          config: codexConfig ?? \"\",\n        }),\n      });\n\n      if (!extracted || !extracted.trim()) {\n        setCommonConfigError(t(\"codexConfig.extractNoCommonConfig\"));\n        return;\n      }\n\n      // 更新片段状态\n      setCommonConfigSnippetState(extracted);\n\n      // 保存到后端\n      await configApi.setCommonConfigSnippet(\"codex\", extracted);\n    } catch (error) {\n      console.error(\"提取 Codex 通用配置失败:\", error);\n      setCommonConfigError(\n        t(\"codexConfig.extractFailed\", { error: String(error) }),\n      );\n    } finally {\n      setIsExtracting(false);\n    }\n  }, [codexConfig, t]);\n\n  const clearCommonConfigError = useCallback(() => {\n    setCommonConfigError(\"\");\n  }, []);\n\n  return {\n    useCommonConfig,\n    commonConfigSnippet,\n    commonConfigError,\n    isLoading,\n    isExtracting,\n    handleCommonConfigToggle,\n    handleCommonConfigSnippetChange,\n    handleExtract,\n    clearCommonConfigError,\n  };\n}\n"
  },
  {
    "path": "src/components/providers/forms/hooks/useCodexConfigState.ts",
    "content": "import { useState, useCallback, useEffect, useRef } from \"react\";\nimport {\n  extractCodexBaseUrl,\n  setCodexBaseUrl as setCodexBaseUrlInConfig,\n  extractCodexModelName,\n  setCodexModelName as setCodexModelNameInConfig,\n} from \"@/utils/providerConfigUtils\";\nimport { normalizeTomlText } from \"@/utils/textNormalization\";\n\ninterface UseCodexConfigStateProps {\n  initialData?: {\n    settingsConfig?: Record<string, unknown>;\n  };\n}\n\n/**\n * 管理 Codex 配置状态\n * Codex 配置包含两部分：auth.json (JSON) 和 config.toml (TOML 字符串)\n */\nexport function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {\n  const [codexAuth, setCodexAuthState] = useState(\"\");\n  const [codexConfig, setCodexConfigState] = useState(\"\");\n  const [codexApiKey, setCodexApiKey] = useState(\"\");\n  const [codexBaseUrl, setCodexBaseUrl] = useState(\"\");\n  const [codexModelName, setCodexModelName] = useState(\"\");\n  const [codexAuthError, setCodexAuthError] = useState(\"\");\n\n  const isUpdatingCodexBaseUrlRef = useRef(false);\n  const isUpdatingCodexModelNameRef = useRef(false);\n\n  // 初始化 Codex 配置（编辑模式）\n  useEffect(() => {\n    if (!initialData) return;\n\n    const config = initialData.settingsConfig;\n    if (typeof config === \"object\" && config !== null) {\n      // 设置 auth.json\n      const auth = (config as any).auth || {};\n      setCodexAuthState(JSON.stringify(auth, null, 2));\n\n      // 设置 config.toml\n      const configStr =\n        typeof (config as any).config === \"string\"\n          ? (config as any).config\n          : \"\";\n      setCodexConfigState(configStr);\n\n      // 提取 Base URL\n      const initialBaseUrl = extractCodexBaseUrl(configStr);\n      if (initialBaseUrl) {\n        setCodexBaseUrl(initialBaseUrl);\n      }\n\n      // 提取 Model Name\n      const initialModelName = extractCodexModelName(configStr);\n      if (initialModelName) {\n        setCodexModelName(initialModelName);\n      }\n\n      // 提取 API Key\n      try {\n        if (auth && typeof auth.OPENAI_API_KEY === \"string\") {\n          setCodexApiKey(auth.OPENAI_API_KEY);\n        }\n      } catch {\n        // ignore\n      }\n    }\n  }, [initialData]);\n\n  // 与 TOML 配置保持基础 URL 同步\n  useEffect(() => {\n    if (isUpdatingCodexBaseUrlRef.current) {\n      return;\n    }\n    const extracted = extractCodexBaseUrl(codexConfig) || \"\";\n    setCodexBaseUrl((prev) => (prev === extracted ? prev : extracted));\n  }, [codexConfig]);\n\n  // 与 TOML 配置保持模型名称同步\n  useEffect(() => {\n    if (isUpdatingCodexModelNameRef.current) {\n      return;\n    }\n    const extracted = extractCodexModelName(codexConfig) || \"\";\n    setCodexModelName((prev) => (prev === extracted ? prev : extracted));\n  }, [codexConfig]);\n\n  // 获取 API Key（从 auth JSON）\n  const getCodexAuthApiKey = useCallback((authString: string): string => {\n    try {\n      const auth = JSON.parse(authString || \"{}\");\n      return typeof auth.OPENAI_API_KEY === \"string\" ? auth.OPENAI_API_KEY : \"\";\n    } catch {\n      return \"\";\n    }\n  }, []);\n\n  // 从 codexAuth 中提取并同步 API Key\n  useEffect(() => {\n    const extractedKey = getCodexAuthApiKey(codexAuth);\n    if (extractedKey !== codexApiKey) {\n      setCodexApiKey(extractedKey);\n    }\n  }, [codexAuth, codexApiKey]);\n\n  // 验证 Codex Auth JSON\n  const validateCodexAuth = useCallback((value: string): string => {\n    if (!value.trim()) return \"\";\n    try {\n      const parsed = JSON.parse(value);\n      if (!parsed || typeof parsed !== \"object\" || Array.isArray(parsed)) {\n        return \"Auth JSON must be an object\";\n      }\n      return \"\";\n    } catch {\n      return \"Invalid JSON format\";\n    }\n  }, []);\n\n  // 设置 auth 并验证\n  const setCodexAuth = useCallback(\n    (value: string) => {\n      setCodexAuthState(value);\n      setCodexAuthError(validateCodexAuth(value));\n    },\n    [validateCodexAuth],\n  );\n\n  // 设置 config (支持函数更新)\n  const setCodexConfig = useCallback(\n    (value: string | ((prev: string) => string)) => {\n      setCodexConfigState((prev) =>\n        typeof value === \"function\"\n          ? (value as (input: string) => string)(prev)\n          : value,\n      );\n    },\n    [],\n  );\n\n  // 处理 Codex API Key 输入并写回 auth.json\n  const handleCodexApiKeyChange = useCallback(\n    (key: string) => {\n      const trimmed = key.trim();\n      setCodexApiKey(trimmed);\n      try {\n        const auth = JSON.parse(codexAuth || \"{}\");\n        auth.OPENAI_API_KEY = trimmed;\n        setCodexAuth(JSON.stringify(auth, null, 2));\n      } catch {\n        // ignore\n      }\n    },\n    [codexAuth, setCodexAuth],\n  );\n\n  // 处理 Codex Base URL 变化\n  const handleCodexBaseUrlChange = useCallback(\n    (url: string) => {\n      const sanitized = url.trim();\n      setCodexBaseUrl(sanitized);\n\n      isUpdatingCodexBaseUrlRef.current = true;\n      setCodexConfig((prev) => setCodexBaseUrlInConfig(prev, sanitized));\n      setTimeout(() => {\n        isUpdatingCodexBaseUrlRef.current = false;\n      }, 0);\n    },\n    [setCodexConfig],\n  );\n\n  // 处理 Codex Model Name 变化\n  const handleCodexModelNameChange = useCallback(\n    (modelName: string) => {\n      const trimmed = modelName.trim();\n      setCodexModelName(trimmed);\n\n      isUpdatingCodexModelNameRef.current = true;\n      setCodexConfig((prev) => setCodexModelNameInConfig(prev, trimmed));\n      setTimeout(() => {\n        isUpdatingCodexModelNameRef.current = false;\n      }, 0);\n    },\n    [setCodexConfig],\n  );\n\n  // 处理 config 变化（同步 Base URL 和 Model Name）\n  const handleCodexConfigChange = useCallback(\n    (value: string) => {\n      // 归一化中文/全角/弯引号，避免 TOML 解析报错\n      const normalized = normalizeTomlText(value);\n      setCodexConfig(normalized);\n\n      if (!isUpdatingCodexBaseUrlRef.current) {\n        const extracted = extractCodexBaseUrl(normalized) || \"\";\n        if (extracted !== codexBaseUrl) {\n          setCodexBaseUrl(extracted);\n        }\n      }\n\n      if (!isUpdatingCodexModelNameRef.current) {\n        const extractedModel = extractCodexModelName(normalized) || \"\";\n        if (extractedModel !== codexModelName) {\n          setCodexModelName(extractedModel);\n        }\n      }\n    },\n    [setCodexConfig, codexBaseUrl, codexModelName],\n  );\n\n  // 重置配置（用于预设切换）\n  const resetCodexConfig = useCallback(\n    (auth: Record<string, unknown>, config: string) => {\n      const authString = JSON.stringify(auth, null, 2);\n      setCodexAuth(authString);\n      setCodexConfig(config);\n\n      const baseUrl = extractCodexBaseUrl(config);\n      if (baseUrl) {\n        setCodexBaseUrl(baseUrl);\n      }\n\n      const modelName = extractCodexModelName(config);\n      if (modelName) {\n        setCodexModelName(modelName);\n      } else {\n        setCodexModelName(\"\");\n      }\n\n      // 提取 API Key\n      try {\n        if (auth && typeof auth.OPENAI_API_KEY === \"string\") {\n          setCodexApiKey(auth.OPENAI_API_KEY);\n        } else {\n          setCodexApiKey(\"\");\n        }\n      } catch {\n        setCodexApiKey(\"\");\n      }\n    },\n    [setCodexAuth, setCodexConfig],\n  );\n\n  return {\n    codexAuth,\n    codexConfig,\n    codexApiKey,\n    codexBaseUrl,\n    codexModelName,\n    codexAuthError,\n    setCodexAuth,\n    setCodexConfig,\n    handleCodexApiKeyChange,\n    handleCodexBaseUrlChange,\n    handleCodexModelNameChange,\n    handleCodexConfigChange,\n    resetCodexConfig,\n    getCodexAuthApiKey,\n    validateCodexAuth,\n  };\n}\n"
  },
  {
    "path": "src/components/providers/forms/hooks/useCodexTomlValidation.ts",
    "content": "import { useState, useCallback, useEffect, useRef } from \"react\";\nimport TOML from \"smol-toml\";\n\n/**\n * Codex config.toml 格式校验 Hook\n * 使用 smol-toml 进行实时 TOML 语法校验（带 debounce）\n */\nexport function useCodexTomlValidation() {\n  const [configError, setConfigError] = useState(\"\");\n  const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);\n\n  /**\n   * 校验 TOML 格式\n   * @param tomlText - 待校验的 TOML 文本\n   * @returns 是否校验通过\n   */\n  const validateToml = useCallback((tomlText: string): boolean => {\n    // 空字符串视为合法（允许为空）\n    if (!tomlText.trim()) {\n      setConfigError(\"\");\n      return true;\n    }\n\n    try {\n      TOML.parse(tomlText);\n      setConfigError(\"\");\n      return true;\n    } catch (error) {\n      const errorMessage =\n        error instanceof Error ? error.message : \"TOML 格式错误\";\n      setConfigError(errorMessage);\n      return false;\n    }\n  }, []);\n\n  /**\n   * 带 debounce 的校验函数（500ms 延迟）\n   * @param tomlText - 待校验的 TOML 文本\n   */\n  const debouncedValidate = useCallback(\n    (tomlText: string) => {\n      // 清除之前的定时器\n      if (debounceTimerRef.current) {\n        clearTimeout(debounceTimerRef.current);\n      }\n\n      // 设置新的定时器\n      debounceTimerRef.current = setTimeout(() => {\n        validateToml(tomlText);\n      }, 500);\n    },\n    [validateToml],\n  );\n\n  /**\n   * 清空错误信息\n   */\n  const clearError = useCallback(() => {\n    setConfigError(\"\");\n  }, []);\n\n  // 清理定时器\n  useEffect(() => {\n    return () => {\n      if (debounceTimerRef.current) {\n        clearTimeout(debounceTimerRef.current);\n      }\n    };\n  }, []);\n\n  return {\n    configError,\n    validateToml,\n    debouncedValidate,\n    clearError,\n  };\n}\n"
  },
  {
    "path": "src/components/providers/forms/hooks/useCommonConfigSnippet.ts",
    "content": "import { useState, useEffect, useCallback, useRef } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport {\n  updateCommonConfigSnippet,\n  hasCommonConfigSnippet,\n  validateJsonConfig,\n} from \"@/utils/providerConfigUtils\";\nimport { configApi } from \"@/lib/api\";\n\nconst LEGACY_STORAGE_KEY = \"cc-switch:common-config-snippet\";\nconst DEFAULT_COMMON_CONFIG_SNIPPET = `{\n  \"includeCoAuthoredBy\": false\n}`;\n\ninterface UseCommonConfigSnippetProps {\n  settingsConfig: string;\n  onConfigChange: (config: string) => void;\n  initialData?: {\n    settingsConfig?: Record<string, unknown>;\n  };\n  initialEnabled?: boolean;\n  selectedPresetId?: string;\n  /** When false, the hook skips all logic and returns disabled state. Default: true */\n  enabled?: boolean;\n}\n\n/**\n * 管理 Claude 通用配置片段\n * 从 config.json 读取和保存，支持从 localStorage 平滑迁移\n */\nexport function useCommonConfigSnippet({\n  settingsConfig,\n  onConfigChange,\n  initialData,\n  initialEnabled,\n  selectedPresetId,\n  enabled = true,\n}: UseCommonConfigSnippetProps) {\n  const { t } = useTranslation();\n  const [useCommonConfig, setUseCommonConfig] = useState(false);\n  const [commonConfigSnippet, setCommonConfigSnippetState] = useState<string>(\n    DEFAULT_COMMON_CONFIG_SNIPPET,\n  );\n  const [commonConfigError, setCommonConfigError] = useState(\"\");\n  const [isLoading, setIsLoading] = useState(true);\n  const [isExtracting, setIsExtracting] = useState(false);\n\n  // 用于跟踪是否正在通过通用配置更新\n  const isUpdatingFromCommonConfig = useRef(false);\n  // 用于跟踪新建模式是否已初始化默认勾选\n  const hasInitializedNewMode = useRef(false);\n  // 用于跟踪编辑模式是否已初始化显式开关/预览\n  const hasInitializedEditMode = useRef(false);\n\n  // 当预设变化时，重置初始化标记，使新预设能够重新触发初始化逻辑\n  useEffect(() => {\n    if (!enabled) return;\n    hasInitializedNewMode.current = false;\n    hasInitializedEditMode.current = false;\n  }, [selectedPresetId, enabled, initialEnabled]);\n\n  // 初始化：从 config.json 加载，支持从 localStorage 迁移\n  useEffect(() => {\n    if (!enabled) {\n      setIsLoading(false);\n      return;\n    }\n    let mounted = true;\n\n    const loadSnippet = async () => {\n      try {\n        // 使用统一 API 加载\n        const snippet = await configApi.getCommonConfigSnippet(\"claude\");\n\n        if (snippet && snippet.trim()) {\n          if (mounted) {\n            setCommonConfigSnippetState(snippet);\n          }\n        } else {\n          // 如果 config.json 中没有，尝试从 localStorage 迁移\n          if (typeof window !== \"undefined\") {\n            try {\n              const legacySnippet =\n                window.localStorage.getItem(LEGACY_STORAGE_KEY);\n              if (legacySnippet && legacySnippet.trim()) {\n                // 迁移到 config.json\n                await configApi.setCommonConfigSnippet(\"claude\", legacySnippet);\n                if (mounted) {\n                  setCommonConfigSnippetState(legacySnippet);\n                }\n                // 清理 localStorage\n                window.localStorage.removeItem(LEGACY_STORAGE_KEY);\n                console.log(\n                  \"[迁移] Claude 通用配置已从 localStorage 迁移到 config.json\",\n                );\n              }\n            } catch (e) {\n              console.warn(\"[迁移] 从 localStorage 迁移失败:\", e);\n            }\n          }\n        }\n      } catch (error) {\n        console.error(\"加载通用配置失败:\", error);\n      } finally {\n        if (mounted) {\n          setIsLoading(false);\n        }\n      }\n    };\n\n    loadSnippet();\n\n    return () => {\n      mounted = false;\n    };\n  }, [enabled]);\n\n  // 初始化时检查通用配置片段（编辑模式）\n  useEffect(() => {\n    if (!enabled) return;\n    if (initialData && !isLoading) {\n      const configString = JSON.stringify(initialData.settingsConfig, null, 2);\n      const inferredHasCommon = hasCommonConfigSnippet(\n        configString,\n        commonConfigSnippet,\n      );\n      const hasCommon = initialEnabled ?? inferredHasCommon;\n      setUseCommonConfig(hasCommon);\n\n      if (hasCommon && !inferredHasCommon && !hasInitializedEditMode.current) {\n        hasInitializedEditMode.current = true;\n        const { updatedConfig, error } = updateCommonConfigSnippet(\n          settingsConfig,\n          commonConfigSnippet,\n          true,\n        );\n        if (!error) {\n          isUpdatingFromCommonConfig.current = true;\n          onConfigChange(updatedConfig);\n          setTimeout(() => {\n            isUpdatingFromCommonConfig.current = false;\n          }, 0);\n        }\n      } else {\n        hasInitializedEditMode.current = true;\n      }\n    }\n  }, [\n    enabled,\n    initialData,\n    initialEnabled,\n    commonConfigSnippet,\n    isLoading,\n    onConfigChange,\n    settingsConfig,\n  ]);\n\n  // 新建模式：如果通用配置片段存在且有效，默认启用\n  useEffect(() => {\n    if (!enabled) return;\n    // 仅新建模式、加载完成、尚未初始化过\n    if (!initialData && !isLoading && !hasInitializedNewMode.current) {\n      hasInitializedNewMode.current = true;\n\n      // 检查片段是否有实质内容\n      try {\n        const snippetObj = JSON.parse(commonConfigSnippet);\n        const hasContent = Object.keys(snippetObj).length > 0;\n        if (hasContent) {\n          setUseCommonConfig(true);\n          // 合并通用配置到当前配置\n          const { updatedConfig, error } = updateCommonConfigSnippet(\n            settingsConfig,\n            commonConfigSnippet,\n            true,\n          );\n          if (!error) {\n            isUpdatingFromCommonConfig.current = true;\n            onConfigChange(updatedConfig);\n            setTimeout(() => {\n              isUpdatingFromCommonConfig.current = false;\n            }, 0);\n          }\n        }\n      } catch {\n        // ignore parse error\n      }\n    }\n  }, [\n    enabled,\n    initialData,\n    commonConfigSnippet,\n    isLoading,\n    settingsConfig,\n    onConfigChange,\n  ]);\n\n  // 处理通用配置开关\n  const handleCommonConfigToggle = useCallback(\n    (checked: boolean) => {\n      const { updatedConfig, error: snippetError } = updateCommonConfigSnippet(\n        settingsConfig,\n        commonConfigSnippet,\n        checked,\n      );\n\n      if (snippetError) {\n        setCommonConfigError(snippetError);\n        setUseCommonConfig(false);\n        return;\n      }\n\n      setCommonConfigError(\"\");\n      setUseCommonConfig(checked);\n      // 标记正在通过通用配置更新\n      isUpdatingFromCommonConfig.current = true;\n      onConfigChange(updatedConfig);\n      // 在下一个事件循环中重置标记\n      setTimeout(() => {\n        isUpdatingFromCommonConfig.current = false;\n      }, 0);\n    },\n    [settingsConfig, commonConfigSnippet, onConfigChange],\n  );\n\n  // 处理通用配置片段变化\n  const handleCommonConfigSnippetChange = useCallback(\n    (value: string) => {\n      const previousSnippet = commonConfigSnippet;\n      setCommonConfigSnippetState(value);\n\n      if (!value.trim()) {\n        setCommonConfigError(\"\");\n        // 保存到 config.json（清空）\n        configApi\n          .setCommonConfigSnippet(\"claude\", \"\")\n          .catch((error: unknown) => {\n            console.error(\"保存通用配置失败:\", error);\n            setCommonConfigError(\n              t(\"claudeConfig.saveFailed\", { error: String(error) }),\n            );\n          });\n\n        if (useCommonConfig) {\n          const { updatedConfig } = updateCommonConfigSnippet(\n            settingsConfig,\n            previousSnippet,\n            false,\n          );\n          onConfigChange(updatedConfig);\n          setUseCommonConfig(false);\n        }\n        return;\n      }\n\n      // 验证JSON格式\n      const validationError = validateJsonConfig(value, \"通用配置片段\");\n      if (validationError) {\n        setCommonConfigError(validationError);\n      } else {\n        setCommonConfigError(\"\");\n        // 保存到 config.json\n        configApi\n          .setCommonConfigSnippet(\"claude\", value)\n          .catch((error: unknown) => {\n            console.error(\"保存通用配置失败:\", error);\n            setCommonConfigError(\n              t(\"claudeConfig.saveFailed\", { error: String(error) }),\n            );\n          });\n      }\n\n      // 若当前启用通用配置且格式正确，需要替换为最新片段\n      if (useCommonConfig && !validationError) {\n        const removeResult = updateCommonConfigSnippet(\n          settingsConfig,\n          previousSnippet,\n          false,\n        );\n        if (removeResult.error) {\n          setCommonConfigError(removeResult.error);\n          return;\n        }\n        const addResult = updateCommonConfigSnippet(\n          removeResult.updatedConfig,\n          value,\n          true,\n        );\n\n        if (addResult.error) {\n          setCommonConfigError(addResult.error);\n          return;\n        }\n\n        // 标记正在通过通用配置更新，避免触发状态检查\n        isUpdatingFromCommonConfig.current = true;\n        onConfigChange(addResult.updatedConfig);\n        // 在下一个事件循环中重置标记\n        setTimeout(() => {\n          isUpdatingFromCommonConfig.current = false;\n        }, 0);\n      }\n    },\n    [commonConfigSnippet, settingsConfig, useCommonConfig, onConfigChange],\n  );\n\n  // 当配置变化时检查是否包含通用配置（但避免在通过通用配置更新时检查）\n  useEffect(() => {\n    if (!enabled) return;\n    if (isUpdatingFromCommonConfig.current || isLoading) {\n      return;\n    }\n    const hasCommon = hasCommonConfigSnippet(\n      settingsConfig,\n      commonConfigSnippet,\n    );\n    setUseCommonConfig(hasCommon);\n  }, [enabled, settingsConfig, commonConfigSnippet, isLoading]);\n\n  // 从编辑器当前内容提取通用配置片段\n  const handleExtract = useCallback(async () => {\n    setIsExtracting(true);\n    setCommonConfigError(\"\");\n\n    try {\n      const extracted = await configApi.extractCommonConfigSnippet(\"claude\", {\n        settingsConfig,\n      });\n\n      if (!extracted || extracted === \"{}\") {\n        setCommonConfigError(t(\"claudeConfig.extractNoCommonConfig\"));\n        return;\n      }\n\n      // 验证 JSON 格式\n      const validationError = validateJsonConfig(extracted, \"提取的配置\");\n      if (validationError) {\n        setCommonConfigError(validationError);\n        return;\n      }\n\n      // 更新片段状态\n      setCommonConfigSnippetState(extracted);\n\n      // 保存到后端\n      await configApi.setCommonConfigSnippet(\"claude\", extracted);\n    } catch (error) {\n      console.error(\"提取通用配置失败:\", error);\n      setCommonConfigError(\n        t(\"claudeConfig.extractFailed\", { error: String(error) }),\n      );\n    } finally {\n      setIsExtracting(false);\n    }\n  }, [settingsConfig, t]);\n\n  return {\n    useCommonConfig,\n    commonConfigSnippet,\n    commonConfigError,\n    isLoading,\n    isExtracting,\n    handleCommonConfigToggle,\n    handleCommonConfigSnippetChange,\n    handleExtract,\n  };\n}\n"
  },
  {
    "path": "src/components/providers/forms/hooks/useCopilotAuth.ts",
    "content": "import type { GitHubAccount } from \"@/lib/api\";\nimport { useManagedAuth } from \"./useManagedAuth\";\n\nexport function useCopilotAuth() {\n  const managedAuth = useManagedAuth(\"github_copilot\");\n  const defaultAccount =\n    managedAuth.accounts.find(\n      (account) => account.id === managedAuth.defaultAccountId,\n    ) ?? managedAuth.accounts[0];\n\n  return {\n    ...managedAuth,\n    authStatus: managedAuth.authStatus\n      ? {\n          authenticated: managedAuth.authStatus.authenticated,\n          username: defaultAccount?.login ?? null,\n          // Managed auth status does not expose a single provider-wide token expiry.\n          expires_at: null,\n          default_account_id: managedAuth.defaultAccountId,\n          migration_error: managedAuth.migrationError,\n          accounts: managedAuth.accounts as GitHubAccount[],\n        }\n      : undefined,\n    // Managed auth status no longer exposes a single default token expiry.\n    username: defaultAccount?.login ?? null,\n  };\n}\n"
  },
  {
    "path": "src/components/providers/forms/hooks/useCustomEndpoints.ts",
    "content": "import { useMemo } from \"react\";\nimport type { AppId } from \"@/lib/api\";\nimport type { CustomEndpoint } from \"@/types\";\nimport type { ProviderPreset } from \"@/config/claudeProviderPresets\";\nimport type { CodexProviderPreset } from \"@/config/codexProviderPresets\";\n\ntype PresetEntry = {\n  id: string;\n  preset: ProviderPreset | CodexProviderPreset;\n};\n\ninterface UseCustomEndpointsProps {\n  appId: AppId;\n  selectedPresetId: string | null;\n  presetEntries: PresetEntry[];\n  draftCustomEndpoints: string[];\n  baseUrl: string;\n  codexBaseUrl: string;\n}\n\n/**\n * 收集和管理自定义端点\n *\n * 收集来源：\n * 1. 用户在测速弹窗中新增的自定义端点\n * 2. 预设中的 endpointCandidates\n * 3. 当前选中的 Base URL\n */\nexport function useCustomEndpoints({\n  appId,\n  selectedPresetId,\n  presetEntries,\n  draftCustomEndpoints,\n  baseUrl,\n  codexBaseUrl,\n}: UseCustomEndpointsProps) {\n  const customEndpointsMap = useMemo(() => {\n    const urlSet = new Set<string>();\n\n    // 辅助函数：标准化并添加 URL\n    const push = (raw?: string) => {\n      const url = (raw || \"\").trim().replace(/\\/+$/, \"\");\n      if (url) urlSet.add(url);\n    };\n\n    // 1. 自定义端点（来自用户新增）\n    for (const u of draftCustomEndpoints) push(u);\n\n    // 2. 预设端点候选\n    if (selectedPresetId && selectedPresetId !== \"custom\") {\n      const entry = presetEntries.find((item) => item.id === selectedPresetId);\n      if (entry) {\n        const preset = entry.preset as any;\n        if (Array.isArray(preset?.endpointCandidates)) {\n          for (const u of preset.endpointCandidates as string[]) push(u);\n        }\n      }\n    }\n\n    // 3. 当前 Base URL\n    if (appId === \"codex\") {\n      push(codexBaseUrl);\n    } else {\n      push(baseUrl);\n    }\n\n    // 构建 CustomEndpoint map\n    const urls = Array.from(urlSet.values());\n    if (urls.length === 0) {\n      return null;\n    }\n\n    const now = Date.now();\n    const customMap: Record<string, CustomEndpoint> = {};\n    for (const url of urls) {\n      if (!customMap[url]) {\n        customMap[url] = { url, addedAt: now, lastUsed: undefined };\n      }\n    }\n\n    return customMap;\n  }, [\n    appId,\n    selectedPresetId,\n    presetEntries,\n    draftCustomEndpoints,\n    baseUrl,\n    codexBaseUrl,\n  ]);\n\n  return customEndpointsMap;\n}\n"
  },
  {
    "path": "src/components/providers/forms/hooks/useGeminiCommonConfig.ts",
    "content": "import { useState, useEffect, useCallback, useRef } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { configApi } from \"@/lib/api\";\n\nconst LEGACY_STORAGE_KEY = \"cc-switch:gemini-common-config-snippet\";\nconst DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET = \"{}\";\n\nconst GEMINI_COMMON_ENV_FORBIDDEN_KEYS = [\n  \"GOOGLE_GEMINI_BASE_URL\",\n  \"GEMINI_API_KEY\",\n] as const;\ntype GeminiForbiddenEnvKey = (typeof GEMINI_COMMON_ENV_FORBIDDEN_KEYS)[number];\n\ninterface UseGeminiCommonConfigProps {\n  envValue: string;\n  onEnvChange: (env: string) => void;\n  envStringToObj: (envString: string) => Record<string, string>;\n  envObjToString: (envObj: Record<string, unknown>) => string;\n  initialData?: {\n    settingsConfig?: Record<string, unknown>;\n  };\n  initialEnabled?: boolean;\n  selectedPresetId?: string;\n}\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n  return (\n    typeof value === \"object\" &&\n    value !== null &&\n    !Array.isArray(value) &&\n    Object.prototype.toString.call(value) === \"[object Object]\"\n  );\n}\n\n/**\n * 管理 Gemini 通用配置片段 (JSON 格式)\n * 写入 Gemini 的 .env，但会排除以下敏感字段：\n * - GOOGLE_GEMINI_BASE_URL\n * - GEMINI_API_KEY\n */\nexport function useGeminiCommonConfig({\n  envValue,\n  onEnvChange,\n  envStringToObj,\n  envObjToString,\n  initialData,\n  initialEnabled,\n  selectedPresetId,\n}: UseGeminiCommonConfigProps) {\n  const { t } = useTranslation();\n  const [useCommonConfig, setUseCommonConfig] = useState(false);\n  const [commonConfigSnippet, setCommonConfigSnippetState] = useState<string>(\n    DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET,\n  );\n  const [commonConfigError, setCommonConfigError] = useState(\"\");\n  const [isLoading, setIsLoading] = useState(true);\n  const [isExtracting, setIsExtracting] = useState(false);\n\n  // 用于跟踪是否正在通过通用配置更新\n  const isUpdatingFromCommonConfig = useRef(false);\n  // 用于跟踪新建模式是否已初始化默认勾选\n  const hasInitializedNewMode = useRef(false);\n  // 用于跟踪编辑模式是否已初始化显式开关/预览\n  const hasInitializedEditMode = useRef(false);\n\n  // 当预设变化时，重置初始化标记，使新预设能够重新触发初始化逻辑\n  useEffect(() => {\n    hasInitializedNewMode.current = false;\n    hasInitializedEditMode.current = false;\n  }, [selectedPresetId, initialEnabled]);\n\n  const parseSnippetEnv = useCallback(\n    (\n      snippetString: string,\n    ): { env: Record<string, string>; error?: string } => {\n      const trimmed = snippetString.trim();\n      if (!trimmed) {\n        return { env: {} };\n      }\n\n      let parsed: unknown;\n      try {\n        parsed = JSON.parse(trimmed);\n      } catch {\n        return { env: {}, error: t(\"geminiConfig.invalidJsonFormat\") };\n      }\n\n      if (!isPlainObject(parsed)) {\n        return { env: {}, error: t(\"geminiConfig.invalidJsonFormat\") };\n      }\n\n      const keys = Object.keys(parsed);\n      const forbiddenKeys = keys.filter((key) =>\n        GEMINI_COMMON_ENV_FORBIDDEN_KEYS.includes(key as GeminiForbiddenEnvKey),\n      );\n      if (forbiddenKeys.length > 0) {\n        return {\n          env: {},\n          error: t(\"geminiConfig.commonConfigInvalidKeys\", {\n            keys: forbiddenKeys.join(\", \"),\n          }),\n        };\n      }\n\n      const env: Record<string, string> = {};\n      for (const [key, value] of Object.entries(parsed)) {\n        if (typeof value !== \"string\") {\n          return {\n            env: {},\n            error: t(\"geminiConfig.commonConfigInvalidValues\"),\n          };\n        }\n        const normalized = value.trim();\n        if (!normalized) continue;\n        env[key] = normalized;\n      }\n\n      return { env };\n    },\n    [t],\n  );\n\n  const hasEnvCommonConfigSnippet = useCallback(\n    (envObj: Record<string, string>, snippetEnv: Record<string, string>) => {\n      const entries = Object.entries(snippetEnv);\n      if (entries.length === 0) return false;\n      return entries.every(([key, value]) => envObj[key] === value);\n    },\n    [],\n  );\n\n  const applySnippetToEnv = useCallback(\n    (envObj: Record<string, string>, snippetEnv: Record<string, string>) => {\n      const updated = { ...envObj };\n      for (const [key, value] of Object.entries(snippetEnv)) {\n        if (typeof value === \"string\") {\n          updated[key] = value;\n        }\n      }\n      return updated;\n    },\n    [],\n  );\n\n  const removeSnippetFromEnv = useCallback(\n    (envObj: Record<string, string>, snippetEnv: Record<string, string>) => {\n      const updated = { ...envObj };\n      for (const [key, value] of Object.entries(snippetEnv)) {\n        if (typeof value === \"string\" && updated[key] === value) {\n          delete updated[key];\n        }\n      }\n      return updated;\n    },\n    [],\n  );\n\n  // 初始化：从 config.json 加载，支持从 localStorage 迁移\n  useEffect(() => {\n    let mounted = true;\n\n    const loadSnippet = async () => {\n      try {\n        // 使用统一 API 加载\n        const snippet = await configApi.getCommonConfigSnippet(\"gemini\");\n\n        if (snippet && snippet.trim()) {\n          if (mounted) {\n            setCommonConfigSnippetState(snippet);\n          }\n        } else {\n          // 如果 config.json 中没有，尝试从 localStorage 迁移\n          if (typeof window !== \"undefined\") {\n            try {\n              const legacySnippet =\n                window.localStorage.getItem(LEGACY_STORAGE_KEY);\n              if (legacySnippet && legacySnippet.trim()) {\n                const parsed = parseSnippetEnv(legacySnippet);\n                if (parsed.error) {\n                  console.warn(\n                    \"[迁移] legacy Gemini 通用配置片段格式不符合当前规则，跳过迁移\",\n                  );\n                  return;\n                }\n                // 迁移到 config.json\n                await configApi.setCommonConfigSnippet(\"gemini\", legacySnippet);\n                if (mounted) {\n                  setCommonConfigSnippetState(legacySnippet);\n                }\n                // 清理 localStorage\n                window.localStorage.removeItem(LEGACY_STORAGE_KEY);\n                console.log(\n                  \"[迁移] Gemini 通用配置已从 localStorage 迁移到 config.json\",\n                );\n              }\n            } catch (e) {\n              console.warn(\"[迁移] 从 localStorage 迁移失败:\", e);\n            }\n          }\n        }\n      } catch (error) {\n        console.error(\"加载 Gemini 通用配置失败:\", error);\n      } finally {\n        if (mounted) {\n          setIsLoading(false);\n        }\n      }\n    };\n\n    loadSnippet();\n\n    return () => {\n      mounted = false;\n    };\n  }, [parseSnippetEnv]);\n\n  // 初始化时检查通用配置片段（编辑模式）\n  useEffect(() => {\n    if (\n      !initialData?.settingsConfig ||\n      isLoading ||\n      hasInitializedEditMode.current\n    ) {\n      return;\n    }\n\n    hasInitializedEditMode.current = true;\n\n    try {\n      const env =\n        isPlainObject(initialData.settingsConfig.env) &&\n        Object.keys(initialData.settingsConfig.env).length > 0\n          ? (initialData.settingsConfig.env as Record<string, string>)\n          : {};\n      const parsed = parseSnippetEnv(commonConfigSnippet);\n      if (parsed.error) {\n        if (commonConfigSnippet.trim()) {\n          setCommonConfigError(parsed.error);\n        }\n        setUseCommonConfig(false);\n        return;\n      }\n      const inferredHasCommon = hasEnvCommonConfigSnippet(\n        env,\n        parsed.env as Record<string, string>,\n      );\n      const hasCommon = initialEnabled ?? inferredHasCommon;\n\n      if (hasCommon && !inferredHasCommon) {\n        const currentEnv = envStringToObj(envValue);\n        const merged = applySnippetToEnv(currentEnv, parsed.env);\n        const nextEnvString = envObjToString(merged);\n\n        setCommonConfigError(\"\");\n        setUseCommonConfig(true);\n        isUpdatingFromCommonConfig.current = true;\n        onEnvChange(nextEnvString);\n        setTimeout(() => {\n          isUpdatingFromCommonConfig.current = false;\n        }, 0);\n        return;\n      }\n\n      setCommonConfigError(\"\");\n      setUseCommonConfig(hasCommon);\n    } catch {\n      // ignore parse error\n    }\n  }, [\n    applySnippetToEnv,\n    commonConfigSnippet,\n    envObjToString,\n    envStringToObj,\n    envValue,\n    hasEnvCommonConfigSnippet,\n    initialData,\n    initialEnabled,\n    isLoading,\n    onEnvChange,\n    parseSnippetEnv,\n  ]);\n\n  // 新建模式：如果通用配置片段存在且有效，默认启用\n  useEffect(() => {\n    if (initialData || isLoading || hasInitializedNewMode.current) {\n      return;\n    }\n\n    hasInitializedNewMode.current = true;\n\n    const parsed = parseSnippetEnv(commonConfigSnippet);\n    if (parsed.error) {\n      if (commonConfigSnippet.trim()) {\n        setCommonConfigError(parsed.error);\n      }\n      setUseCommonConfig(false);\n      return;\n    }\n    const hasContent = Object.keys(parsed.env).length > 0;\n    if (!hasContent) return;\n\n    setCommonConfigError(\"\");\n    setUseCommonConfig(true);\n    const currentEnv = envStringToObj(envValue);\n    const merged = applySnippetToEnv(currentEnv, parsed.env);\n    const nextEnvString = envObjToString(merged);\n\n    isUpdatingFromCommonConfig.current = true;\n    onEnvChange(nextEnvString);\n    setTimeout(() => {\n      isUpdatingFromCommonConfig.current = false;\n    }, 0);\n  }, [\n    initialData,\n    isLoading,\n    commonConfigSnippet,\n    envValue,\n    envStringToObj,\n    envObjToString,\n    applySnippetToEnv,\n    onEnvChange,\n    parseSnippetEnv,\n  ]);\n\n  // 处理通用配置开关\n  const handleCommonConfigToggle = useCallback(\n    (checked: boolean) => {\n      const parsed = parseSnippetEnv(commonConfigSnippet);\n      if (parsed.error) {\n        setCommonConfigError(parsed.error);\n        setUseCommonConfig(false);\n        return;\n      }\n      if (Object.keys(parsed.env).length === 0) {\n        setCommonConfigError(t(\"geminiConfig.noCommonConfigToApply\"));\n        setUseCommonConfig(false);\n        return;\n      }\n\n      const currentEnv = envStringToObj(envValue);\n      const updatedEnvObj = checked\n        ? applySnippetToEnv(currentEnv, parsed.env)\n        : removeSnippetFromEnv(currentEnv, parsed.env);\n\n      setCommonConfigError(\"\");\n      setUseCommonConfig(checked);\n\n      isUpdatingFromCommonConfig.current = true;\n      onEnvChange(envObjToString(updatedEnvObj));\n      setTimeout(() => {\n        isUpdatingFromCommonConfig.current = false;\n      }, 0);\n    },\n    [\n      applySnippetToEnv,\n      commonConfigSnippet,\n      envObjToString,\n      envStringToObj,\n      envValue,\n      onEnvChange,\n      parseSnippetEnv,\n      removeSnippetFromEnv,\n      t,\n    ],\n  );\n\n  // 处理通用配置片段变化\n  const handleCommonConfigSnippetChange = useCallback(\n    (value: string): boolean => {\n      const previousSnippet = commonConfigSnippet;\n\n      if (!value.trim()) {\n        setCommonConfigError(\"\");\n\n        if (useCommonConfig) {\n          const parsedPrevious = parseSnippetEnv(previousSnippet);\n          if (\n            !parsedPrevious.error &&\n            Object.keys(parsedPrevious.env).length > 0\n          ) {\n            const currentEnv = envStringToObj(envValue);\n            const updatedEnv = removeSnippetFromEnv(\n              currentEnv,\n              parsedPrevious.env,\n            );\n            onEnvChange(envObjToString(updatedEnv));\n          }\n          setUseCommonConfig(false);\n        }\n\n        setCommonConfigSnippetState(\"\");\n        configApi\n          .setCommonConfigSnippet(\"gemini\", \"\")\n          .catch((error: unknown) => {\n            console.error(\"保存 Gemini 通用配置失败:\", error);\n            setCommonConfigError(\n              t(\"geminiConfig.saveFailed\", { error: String(error) }),\n            );\n          });\n        return true;\n      }\n\n      // 校验 JSON 格式\n      const parsed = parseSnippetEnv(value);\n      if (parsed.error) {\n        setCommonConfigError(parsed.error);\n        return false;\n      }\n\n      // 若当前启用通用配置，需要替换为最新片段\n      if (useCommonConfig) {\n        const prevParsed = parseSnippetEnv(previousSnippet);\n        const prevEnv = prevParsed.error ? {} : prevParsed.env;\n        const nextEnv = parsed.env;\n        const currentEnv = envStringToObj(envValue);\n\n        const withoutOld =\n          Object.keys(prevEnv).length > 0\n            ? removeSnippetFromEnv(currentEnv, prevEnv)\n            : currentEnv;\n        const withNew =\n          Object.keys(nextEnv).length > 0\n            ? applySnippetToEnv(withoutOld, nextEnv)\n            : withoutOld;\n\n        isUpdatingFromCommonConfig.current = true;\n        onEnvChange(envObjToString(withNew));\n        setTimeout(() => {\n          isUpdatingFromCommonConfig.current = false;\n        }, 0);\n      }\n\n      setCommonConfigError(\"\");\n      setCommonConfigSnippetState(value);\n      configApi\n        .setCommonConfigSnippet(\"gemini\", value)\n        .catch((error: unknown) => {\n          console.error(\"保存 Gemini 通用配置失败:\", error);\n          setCommonConfigError(\n            t(\"geminiConfig.saveFailed\", { error: String(error) }),\n          );\n        });\n\n      return true;\n    },\n    [\n      applySnippetToEnv,\n      commonConfigSnippet,\n      envObjToString,\n      envStringToObj,\n      envValue,\n      onEnvChange,\n      parseSnippetEnv,\n      removeSnippetFromEnv,\n      t,\n      useCommonConfig,\n    ],\n  );\n\n  // 当 env 变化时检查是否包含通用配置（但避免在通过通用配置更新时检查）\n  useEffect(() => {\n    if (isUpdatingFromCommonConfig.current || isLoading) {\n      return;\n    }\n    const parsed = parseSnippetEnv(commonConfigSnippet);\n    if (parsed.error) return;\n    const envObj = envStringToObj(envValue);\n    setUseCommonConfig(\n      hasEnvCommonConfigSnippet(envObj, parsed.env as Record<string, string>),\n    );\n  }, [\n    envValue,\n    commonConfigSnippet,\n    envStringToObj,\n    hasEnvCommonConfigSnippet,\n    isLoading,\n    parseSnippetEnv,\n  ]);\n\n  // 从编辑器当前内容提取通用配置片段\n  const handleExtract = useCallback(async () => {\n    setIsExtracting(true);\n    setCommonConfigError(\"\");\n\n    try {\n      const extracted = await configApi.extractCommonConfigSnippet(\"gemini\", {\n        settingsConfig: JSON.stringify({\n          env: envStringToObj(envValue),\n        }),\n      });\n\n      if (!extracted || extracted === \"{}\") {\n        setCommonConfigError(t(\"geminiConfig.extractNoCommonConfig\"));\n        return;\n      }\n\n      // 验证 JSON 格式\n      const parsed = parseSnippetEnv(extracted);\n      if (parsed.error) {\n        setCommonConfigError(t(\"geminiConfig.extractedConfigInvalid\"));\n        return;\n      }\n\n      // 更新片段状态\n      setCommonConfigSnippetState(extracted);\n\n      // 保存到后端\n      await configApi.setCommonConfigSnippet(\"gemini\", extracted);\n    } catch (error) {\n      console.error(\"提取 Gemini 通用配置失败:\", error);\n      setCommonConfigError(\n        t(\"geminiConfig.extractFailed\", { error: String(error) }),\n      );\n    } finally {\n      setIsExtracting(false);\n    }\n  }, [envStringToObj, envValue, parseSnippetEnv, t]);\n\n  const clearCommonConfigError = useCallback(() => {\n    setCommonConfigError(\"\");\n  }, []);\n\n  return {\n    useCommonConfig,\n    commonConfigSnippet,\n    commonConfigError,\n    isLoading,\n    isExtracting,\n    handleCommonConfigToggle,\n    handleCommonConfigSnippetChange,\n    handleExtract,\n    clearCommonConfigError,\n  };\n}\n"
  },
  {
    "path": "src/components/providers/forms/hooks/useGeminiConfigState.ts",
    "content": "import { useState, useCallback, useEffect } from \"react\";\n\ninterface UseGeminiConfigStateProps {\n  initialData?: {\n    settingsConfig?: Record<string, unknown>;\n  };\n}\n\n/**\n * 管理 Gemini 配置状态\n * Gemini 配置包含两部分：env (环境变量) 和 config (扩展配置 JSON)\n */\nexport function useGeminiConfigState({\n  initialData,\n}: UseGeminiConfigStateProps) {\n  const [geminiEnv, setGeminiEnvState] = useState(\"\");\n  const [geminiConfig, setGeminiConfigState] = useState(\"\");\n  const [geminiApiKey, setGeminiApiKey] = useState(\"\");\n  const [geminiBaseUrl, setGeminiBaseUrl] = useState(\"\");\n  const [geminiModel, setGeminiModel] = useState(\"\");\n  const [envError, setEnvError] = useState(\"\");\n  const [configError, setConfigError] = useState(\"\");\n\n  // 将 JSON env 对象转换为 .env 格式字符串\n  // 保留所有环境变量，已知 key 优先显示\n  const envObjToString = useCallback(\n    (envObj: Record<string, unknown>): string => {\n      const priorityKeys = [\n        \"GOOGLE_GEMINI_BASE_URL\",\n        \"GEMINI_API_KEY\",\n        \"GEMINI_MODEL\",\n      ];\n      const lines: string[] = [];\n      const addedKeys = new Set<string>();\n\n      // 先添加已知 key（按顺序）\n      for (const key of priorityKeys) {\n        if (typeof envObj[key] === \"string\" && envObj[key]) {\n          lines.push(`${key}=${envObj[key]}`);\n          addedKeys.add(key);\n        }\n      }\n\n      // 再添加其他自定义 key（保留用户添加的环境变量）\n      for (const [key, value] of Object.entries(envObj)) {\n        if (!addedKeys.has(key) && typeof value === \"string\") {\n          lines.push(`${key}=${value}`);\n        }\n      }\n\n      return lines.join(\"\\n\");\n    },\n    [],\n  );\n\n  // 将 .env 格式字符串转换为 JSON env 对象\n  const envStringToObj = useCallback(\n    (envString: string): Record<string, string> => {\n      const env: Record<string, string> = {};\n      const lines = envString.split(\"\\n\");\n      lines.forEach((line) => {\n        const trimmed = line.trim();\n        if (!trimmed || trimmed.startsWith(\"#\")) return;\n        const equalIndex = trimmed.indexOf(\"=\");\n        if (equalIndex > 0) {\n          const key = trimmed.substring(0, equalIndex).trim();\n          const value = trimmed.substring(equalIndex + 1).trim();\n          env[key] = value;\n        }\n      });\n      return env;\n    },\n    [],\n  );\n\n  // 初始化 Gemini 配置（编辑模式）\n  useEffect(() => {\n    if (!initialData) return;\n\n    const config = initialData.settingsConfig;\n    if (typeof config === \"object\" && config !== null) {\n      // 设置 env\n      const env = (config as any).env || {};\n      setGeminiEnvState(envObjToString(env));\n\n      // 设置 config\n      const configObj = (config as any).config || {};\n      setGeminiConfigState(JSON.stringify(configObj, null, 2));\n\n      // 提取 API Key、Base URL 和 Model\n      if (typeof env.GEMINI_API_KEY === \"string\") {\n        setGeminiApiKey(env.GEMINI_API_KEY);\n      }\n      if (typeof env.GOOGLE_GEMINI_BASE_URL === \"string\") {\n        setGeminiBaseUrl(env.GOOGLE_GEMINI_BASE_URL);\n      }\n      if (typeof env.GEMINI_MODEL === \"string\") {\n        setGeminiModel(env.GEMINI_MODEL);\n      }\n    }\n  }, [initialData, envObjToString]);\n\n  // 从 geminiEnv 中提取并同步 API Key、Base URL 和 Model\n  useEffect(() => {\n    const envObj = envStringToObj(geminiEnv);\n    const extractedKey = envObj.GEMINI_API_KEY || \"\";\n    const extractedBaseUrl = envObj.GOOGLE_GEMINI_BASE_URL || \"\";\n    const extractedModel = envObj.GEMINI_MODEL || \"\";\n\n    if (extractedKey !== geminiApiKey) {\n      setGeminiApiKey(extractedKey);\n    }\n    if (extractedBaseUrl !== geminiBaseUrl) {\n      setGeminiBaseUrl(extractedBaseUrl);\n    }\n    if (extractedModel !== geminiModel) {\n      setGeminiModel(extractedModel);\n    }\n  }, [geminiEnv, envStringToObj, geminiApiKey, geminiBaseUrl, geminiModel]);\n\n  // 验证 Gemini Config JSON\n  const validateGeminiConfig = useCallback((value: string): string => {\n    if (!value.trim()) return \"\"; // 空值允许\n    try {\n      const parsed = JSON.parse(value);\n      if (parsed && typeof parsed === \"object\" && !Array.isArray(parsed)) {\n        return \"\";\n      }\n      return \"Config must be a JSON object\";\n    } catch {\n      return \"Invalid JSON format\";\n    }\n  }, []);\n\n  // 设置 env\n  const setGeminiEnv = useCallback((value: string) => {\n    setGeminiEnvState(value);\n    // .env 格式较宽松，不做严格校验\n    setEnvError(\"\");\n  }, []);\n\n  // 设置 config (支持函数更新)\n  const setGeminiConfig = useCallback(\n    (value: string | ((prev: string) => string)) => {\n      const newValue =\n        typeof value === \"function\" ? value(geminiConfig) : value;\n      setGeminiConfigState(newValue);\n      setConfigError(validateGeminiConfig(newValue));\n    },\n    [geminiConfig, validateGeminiConfig],\n  );\n\n  // 处理 Gemini API Key 输入并写回 env\n  const handleGeminiApiKeyChange = useCallback(\n    (key: string) => {\n      const trimmed = key.trim();\n      setGeminiApiKey(trimmed);\n\n      const envObj = envStringToObj(geminiEnv);\n      envObj.GEMINI_API_KEY = trimmed;\n      const newEnv = envObjToString(envObj);\n      setGeminiEnv(newEnv);\n    },\n    [geminiEnv, envStringToObj, envObjToString, setGeminiEnv],\n  );\n\n  // 处理 Gemini Base URL 变化\n  const handleGeminiBaseUrlChange = useCallback(\n    (url: string) => {\n      const sanitized = url.trim().replace(/\\/+$/, \"\");\n      setGeminiBaseUrl(sanitized);\n\n      const envObj = envStringToObj(geminiEnv);\n      envObj.GOOGLE_GEMINI_BASE_URL = sanitized;\n      const newEnv = envObjToString(envObj);\n      setGeminiEnv(newEnv);\n    },\n    [geminiEnv, envStringToObj, envObjToString, setGeminiEnv],\n  );\n\n  // 处理 Gemini Model 变化\n  const handleGeminiModelChange = useCallback(\n    (model: string) => {\n      const trimmed = model.trim();\n      setGeminiModel(trimmed);\n\n      const envObj = envStringToObj(geminiEnv);\n      envObj.GEMINI_MODEL = trimmed;\n      const newEnv = envObjToString(envObj);\n      setGeminiEnv(newEnv);\n    },\n    [geminiEnv, envStringToObj, envObjToString, setGeminiEnv],\n  );\n\n  // 处理 env 变化\n  const handleGeminiEnvChange = useCallback(\n    (value: string) => {\n      setGeminiEnv(value);\n    },\n    [setGeminiEnv],\n  );\n\n  // 处理 config 变化\n  const handleGeminiConfigChange = useCallback(\n    (value: string) => {\n      setGeminiConfig(value);\n    },\n    [setGeminiConfig],\n  );\n\n  // 重置配置（用于预设切换）\n  const resetGeminiConfig = useCallback(\n    (env: Record<string, unknown>, config: Record<string, unknown>) => {\n      const envString = envObjToString(env);\n      const configString = JSON.stringify(config, null, 2);\n\n      setGeminiEnv(envString);\n      setGeminiConfig(configString);\n\n      // 提取 API Key、Base URL 和 Model\n      if (typeof env.GEMINI_API_KEY === \"string\") {\n        setGeminiApiKey(env.GEMINI_API_KEY);\n      } else {\n        setGeminiApiKey(\"\");\n      }\n\n      if (typeof env.GOOGLE_GEMINI_BASE_URL === \"string\") {\n        setGeminiBaseUrl(env.GOOGLE_GEMINI_BASE_URL);\n      } else {\n        setGeminiBaseUrl(\"\");\n      }\n\n      if (typeof env.GEMINI_MODEL === \"string\") {\n        setGeminiModel(env.GEMINI_MODEL);\n      } else {\n        setGeminiModel(\"\");\n      }\n    },\n    [envObjToString, setGeminiEnv, setGeminiConfig],\n  );\n\n  return {\n    geminiEnv,\n    geminiConfig,\n    geminiApiKey,\n    geminiBaseUrl,\n    geminiModel,\n    envError,\n    configError,\n    setGeminiEnv,\n    setGeminiConfig,\n    handleGeminiApiKeyChange,\n    handleGeminiBaseUrlChange,\n    handleGeminiModelChange,\n    handleGeminiEnvChange,\n    handleGeminiConfigChange,\n    resetGeminiConfig,\n    envStringToObj,\n    envObjToString,\n  };\n}\n"
  },
  {
    "path": "src/components/providers/forms/hooks/useManagedAuth.ts",
    "content": "import { useState, useCallback, useRef, useEffect } from \"react\";\nimport { useQuery, useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { authApi, settingsApi } from \"@/lib/api\";\nimport type {\n  ManagedAuthProvider,\n  ManagedAuthStatus,\n  ManagedAuthDeviceCodeResponse,\n} from \"@/lib/api\";\n\ntype PollingState = \"idle\" | \"polling\" | \"success\" | \"error\";\n\nexport function useManagedAuth(authProvider: ManagedAuthProvider) {\n  const queryClient = useQueryClient();\n  const queryKey = [\"managed-auth-status\", authProvider];\n\n  const [pollingState, setPollingState] = useState<PollingState>(\"idle\");\n  const [deviceCode, setDeviceCode] =\n    useState<ManagedAuthDeviceCodeResponse | null>(null);\n  const [error, setError] = useState<string | null>(null);\n\n  const pollingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(\n    null,\n  );\n  const pollingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  const {\n    data: authStatus,\n    isLoading: isLoadingStatus,\n    refetch: refetchStatus,\n  } = useQuery<ManagedAuthStatus>({\n    queryKey,\n    queryFn: () => authApi.authGetStatus(authProvider),\n    staleTime: 30000,\n  });\n\n  const stopPolling = useCallback(() => {\n    if (pollingIntervalRef.current) {\n      clearInterval(pollingIntervalRef.current);\n      pollingIntervalRef.current = null;\n    }\n    if (pollingTimeoutRef.current) {\n      clearTimeout(pollingTimeoutRef.current);\n      pollingTimeoutRef.current = null;\n    }\n  }, []);\n\n  useEffect(() => {\n    return () => {\n      stopPolling();\n    };\n  }, [stopPolling]);\n\n  const startLoginMutation = useMutation({\n    mutationFn: () => authApi.authStartLogin(authProvider),\n    onSuccess: async (response) => {\n      setDeviceCode(response);\n      setPollingState(\"polling\");\n      setError(null);\n\n      try {\n        await navigator.clipboard.writeText(response.user_code);\n      } catch (e) {\n        console.debug(\"[ManagedAuth] Failed to copy user code:\", e);\n      }\n\n      try {\n        await settingsApi.openExternal(response.verification_uri);\n      } catch (e) {\n        console.debug(\"[ManagedAuth] Failed to open browser:\", e);\n      }\n\n      // Add a small buffer on top of GitHub's suggested interval to avoid\n      // hitting slow_down responses too aggressively during device polling.\n      const interval = Math.max((response.interval || 5) + 3, 8) * 1000;\n      const expiresAt = Date.now() + response.expires_in * 1000;\n\n      const pollOnce = async () => {\n        if (Date.now() > expiresAt) {\n          stopPolling();\n          setPollingState(\"error\");\n          setError(\"Device code expired. Please try again.\");\n          return;\n        }\n\n        try {\n          const newAccount = await authApi.authPollForAccount(\n            authProvider,\n            response.device_code,\n          );\n          if (newAccount) {\n            stopPolling();\n            setPollingState(\"success\");\n            await refetchStatus();\n            await queryClient.invalidateQueries({ queryKey });\n            setPollingState(\"idle\");\n            setDeviceCode(null);\n          }\n        } catch (e) {\n          const errorMessage = e instanceof Error ? e.message : String(e);\n          if (\n            !errorMessage.includes(\"pending\") &&\n            !errorMessage.includes(\"slow_down\")\n          ) {\n            stopPolling();\n            setPollingState(\"error\");\n            setError(errorMessage);\n          }\n        }\n      };\n\n      void pollOnce();\n      pollingIntervalRef.current = setInterval(pollOnce, interval);\n      pollingTimeoutRef.current = setTimeout(() => {\n        stopPolling();\n        setPollingState(\"error\");\n        setError(\"Device code expired. Please try again.\");\n      }, response.expires_in * 1000);\n    },\n    onError: (e) => {\n      setPollingState(\"error\");\n      setError(e instanceof Error ? e.message : String(e));\n    },\n  });\n\n  const logoutMutation = useMutation({\n    mutationFn: () => authApi.authLogout(authProvider),\n    onSuccess: async () => {\n      setPollingState(\"idle\");\n      setDeviceCode(null);\n      setError(null);\n      queryClient.setQueryData(queryKey, {\n        provider: authProvider,\n        authenticated: false,\n        default_account_id: null,\n        accounts: [],\n      });\n      await queryClient.invalidateQueries({ queryKey });\n    },\n    onError: async (e) => {\n      console.error(\"[ManagedAuth] Failed to logout:\", e);\n      setError(e instanceof Error ? e.message : String(e));\n      await refetchStatus();\n    },\n  });\n\n  const removeAccountMutation = useMutation({\n    mutationFn: (accountId: string) =>\n      authApi.authRemoveAccount(authProvider, accountId),\n    onSuccess: async () => {\n      setPollingState(\"idle\");\n      setDeviceCode(null);\n      setError(null);\n      await refetchStatus();\n      await queryClient.invalidateQueries({ queryKey });\n    },\n    onError: (e) => {\n      console.error(\"[ManagedAuth] Failed to remove account:\", e);\n      setError(e instanceof Error ? e.message : String(e));\n    },\n  });\n\n  const setDefaultAccountMutation = useMutation({\n    mutationFn: (accountId: string) =>\n      authApi.authSetDefaultAccount(authProvider, accountId),\n    onSuccess: async () => {\n      await refetchStatus();\n      await queryClient.invalidateQueries({ queryKey });\n    },\n    onError: (e) => {\n      console.error(\"[ManagedAuth] Failed to set default account:\", e);\n      setError(e instanceof Error ? e.message : String(e));\n    },\n  });\n\n  const startAuth = useCallback(() => {\n    setPollingState(\"idle\");\n    setDeviceCode(null);\n    setError(null);\n    stopPolling();\n    startLoginMutation.mutate();\n  }, [startLoginMutation, stopPolling]);\n\n  const cancelAuth = useCallback(() => {\n    stopPolling();\n    setPollingState(\"idle\");\n    setDeviceCode(null);\n    setError(null);\n  }, [stopPolling]);\n\n  const logout = useCallback(() => {\n    logoutMutation.mutate();\n  }, [logoutMutation]);\n\n  const removeAccount = useCallback(\n    (accountId: string) => {\n      removeAccountMutation.mutate(accountId);\n    },\n    [removeAccountMutation],\n  );\n\n  const setDefaultAccount = useCallback(\n    (accountId: string) => {\n      setDefaultAccountMutation.mutate(accountId);\n    },\n    [setDefaultAccountMutation],\n  );\n\n  const accounts = authStatus?.accounts ?? [];\n\n  return {\n    authStatus,\n    isLoadingStatus,\n    accounts,\n    hasAnyAccount: accounts.length > 0,\n    isAuthenticated: authStatus?.authenticated ?? false,\n    defaultAccountId: authStatus?.default_account_id ?? null,\n    migrationError: authStatus?.migration_error ?? null,\n    pollingState,\n    deviceCode,\n    error,\n    isPolling: pollingState === \"polling\",\n    isAddingAccount: startLoginMutation.isPending || pollingState === \"polling\",\n    isRemovingAccount: removeAccountMutation.isPending,\n    isSettingDefaultAccount: setDefaultAccountMutation.isPending,\n    startAuth,\n    addAccount: startAuth,\n    cancelAuth,\n    logout,\n    removeAccount,\n    setDefaultAccount,\n    refetchStatus,\n  };\n}\n"
  },
  {
    "path": "src/components/providers/forms/hooks/useModelState.ts",
    "content": "import { useState, useCallback, useEffect, useRef } from \"react\";\n\ninterface UseModelStateProps {\n  settingsConfig: string;\n  onConfigChange: (config: string) => void;\n}\n\n/**\n * Parse model values from settings config JSON\n */\nfunction parseModelsFromConfig(settingsConfig: string) {\n  try {\n    const cfg = settingsConfig ? JSON.parse(settingsConfig) : {};\n    const env = cfg?.env || {};\n    const model =\n      typeof env.ANTHROPIC_MODEL === \"string\" ? env.ANTHROPIC_MODEL : \"\";\n    const reasoning =\n      typeof env.ANTHROPIC_REASONING_MODEL === \"string\"\n        ? env.ANTHROPIC_REASONING_MODEL\n        : \"\";\n    const small =\n      typeof env.ANTHROPIC_SMALL_FAST_MODEL === \"string\"\n        ? env.ANTHROPIC_SMALL_FAST_MODEL\n        : \"\";\n    const haiku =\n      typeof env.ANTHROPIC_DEFAULT_HAIKU_MODEL === \"string\"\n        ? env.ANTHROPIC_DEFAULT_HAIKU_MODEL\n        : small || model;\n    const sonnet =\n      typeof env.ANTHROPIC_DEFAULT_SONNET_MODEL === \"string\"\n        ? env.ANTHROPIC_DEFAULT_SONNET_MODEL\n        : model || small;\n    const opus =\n      typeof env.ANTHROPIC_DEFAULT_OPUS_MODEL === \"string\"\n        ? env.ANTHROPIC_DEFAULT_OPUS_MODEL\n        : model || small;\n\n    return { model, reasoning, haiku, sonnet, opus };\n  } catch {\n    return { model: \"\", reasoning: \"\", haiku: \"\", sonnet: \"\", opus: \"\" };\n  }\n}\n\n/**\n * 管理模型选择状态\n * 支持 ANTHROPIC_MODEL, ANTHROPIC_REASONING_MODEL 和各类型默认模型\n */\nexport function useModelState({\n  settingsConfig,\n  onConfigChange,\n}: UseModelStateProps) {\n  // Initialize state by parsing config directly (fixes edit mode backfill)\n  const [claudeModel, setClaudeModel] = useState(\n    () => parseModelsFromConfig(settingsConfig).model,\n  );\n  const [reasoningModel, setReasoningModel] = useState(\n    () => parseModelsFromConfig(settingsConfig).reasoning,\n  );\n  const [defaultHaikuModel, setDefaultHaikuModel] = useState(\n    () => parseModelsFromConfig(settingsConfig).haiku,\n  );\n  const [defaultSonnetModel, setDefaultSonnetModel] = useState(\n    () => parseModelsFromConfig(settingsConfig).sonnet,\n  );\n  const [defaultOpusModel, setDefaultOpusModel] = useState(\n    () => parseModelsFromConfig(settingsConfig).opus,\n  );\n\n  const isUserEditingRef = useRef(false);\n  const lastConfigRef = useRef(settingsConfig);\n\n  // 初始化读取：读新键；若缺失，按兼容优先级回退\n  // Haiku: DEFAULT_HAIKU || SMALL_FAST || MODEL\n  // Sonnet: DEFAULT_SONNET || MODEL || SMALL_FAST\n  // Opus: DEFAULT_OPUS || MODEL || SMALL_FAST\n  // 仅在 settingsConfig 变化时同步一次（表单加载/切换预设时）\n  useEffect(() => {\n    if (lastConfigRef.current === settingsConfig) {\n      return;\n    }\n\n    if (isUserEditingRef.current) {\n      isUserEditingRef.current = false;\n      lastConfigRef.current = settingsConfig;\n      return;\n    }\n\n    lastConfigRef.current = settingsConfig;\n\n    try {\n      const cfg = settingsConfig ? JSON.parse(settingsConfig) : {};\n      const env = cfg?.env || {};\n      const model =\n        typeof env.ANTHROPIC_MODEL === \"string\" ? env.ANTHROPIC_MODEL : \"\";\n      const reasoning =\n        typeof env.ANTHROPIC_REASONING_MODEL === \"string\"\n          ? env.ANTHROPIC_REASONING_MODEL\n          : \"\";\n      const small =\n        typeof env.ANTHROPIC_SMALL_FAST_MODEL === \"string\"\n          ? env.ANTHROPIC_SMALL_FAST_MODEL\n          : \"\";\n      const haiku =\n        typeof env.ANTHROPIC_DEFAULT_HAIKU_MODEL === \"string\"\n          ? env.ANTHROPIC_DEFAULT_HAIKU_MODEL\n          : small || model;\n      const sonnet =\n        typeof env.ANTHROPIC_DEFAULT_SONNET_MODEL === \"string\"\n          ? env.ANTHROPIC_DEFAULT_SONNET_MODEL\n          : model || small;\n      const opus =\n        typeof env.ANTHROPIC_DEFAULT_OPUS_MODEL === \"string\"\n          ? env.ANTHROPIC_DEFAULT_OPUS_MODEL\n          : model || small;\n\n      setClaudeModel(model || \"\");\n      setReasoningModel(reasoning || \"\");\n      setDefaultHaikuModel(haiku || \"\");\n      setDefaultSonnetModel(sonnet || \"\");\n      setDefaultOpusModel(opus || \"\");\n    } catch {\n      // ignore\n    }\n  }, [settingsConfig]);\n\n  const handleModelChange = useCallback(\n    (\n      field:\n        | \"ANTHROPIC_MODEL\"\n        | \"ANTHROPIC_REASONING_MODEL\"\n        | \"ANTHROPIC_DEFAULT_HAIKU_MODEL\"\n        | \"ANTHROPIC_DEFAULT_SONNET_MODEL\"\n        | \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n      value: string,\n    ) => {\n      isUserEditingRef.current = true;\n\n      if (field === \"ANTHROPIC_MODEL\") setClaudeModel(value);\n      if (field === \"ANTHROPIC_REASONING_MODEL\") setReasoningModel(value);\n      if (field === \"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n        setDefaultHaikuModel(value);\n      if (field === \"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n        setDefaultSonnetModel(value);\n      if (field === \"ANTHROPIC_DEFAULT_OPUS_MODEL\") setDefaultOpusModel(value);\n\n      try {\n        const currentConfig = settingsConfig\n          ? JSON.parse(settingsConfig)\n          : { env: {} };\n        if (!currentConfig.env) currentConfig.env = {};\n\n        // 新键仅写入；旧键不再写入\n        const trimmed = value.trim();\n        if (trimmed) {\n          currentConfig.env[field] = trimmed;\n        } else {\n          delete currentConfig.env[field];\n        }\n        // 删除旧键\n        delete currentConfig.env[\"ANTHROPIC_SMALL_FAST_MODEL\"];\n\n        onConfigChange(JSON.stringify(currentConfig, null, 2));\n      } catch (err) {\n        console.error(\"Failed to update model config:\", err);\n      }\n    },\n    [settingsConfig, onConfigChange],\n  );\n\n  return {\n    claudeModel,\n    setClaudeModel,\n    reasoningModel,\n    setReasoningModel,\n    defaultHaikuModel,\n    setDefaultHaikuModel,\n    defaultSonnetModel,\n    setDefaultSonnetModel,\n    defaultOpusModel,\n    setDefaultOpusModel,\n    handleModelChange,\n  };\n}\n"
  },
  {
    "path": "src/components/providers/forms/hooks/useOmoDraftState.ts",
    "content": "import { useState, useCallback, useMemo } from \"react\";\nimport {\n  buildOmoSlimProfilePreview,\n  buildOmoProfilePreview,\n} from \"@/types/omo\";\n\ninterface UseOmoDraftStateParams {\n  initialOmoSettings: Record<string, unknown> | undefined;\n  isEditMode: boolean;\n  appId: string;\n  category?: string;\n}\n\nexport interface OmoDraftState {\n  omoAgents: Record<string, Record<string, unknown>>;\n  setOmoAgents: React.Dispatch<\n    React.SetStateAction<Record<string, Record<string, unknown>>>\n  >;\n  omoCategories: Record<string, Record<string, unknown>>;\n  setOmoCategories: React.Dispatch<\n    React.SetStateAction<Record<string, Record<string, unknown>>>\n  >;\n  omoOtherFieldsStr: string;\n  setOmoOtherFieldsStr: React.Dispatch<React.SetStateAction<string>>;\n  mergedOmoJsonPreview: string;\n  resetOmoDraftState: () => void;\n}\n\nexport function useOmoDraftState({\n  initialOmoSettings,\n  category,\n}: UseOmoDraftStateParams): OmoDraftState {\n  const isSlim = category === \"omo-slim\";\n\n  const [omoAgents, setOmoAgents] = useState<\n    Record<string, Record<string, unknown>>\n  >(\n    () =>\n      (initialOmoSettings?.agents as Record<string, Record<string, unknown>>) ||\n      {},\n  );\n  const [omoCategories, setOmoCategories] = useState<\n    Record<string, Record<string, unknown>>\n  >(\n    () =>\n      (initialOmoSettings?.categories as Record<\n        string,\n        Record<string, unknown>\n      >) || {},\n  );\n  const [omoOtherFieldsStr, setOmoOtherFieldsStr] = useState(() => {\n    const otherFields = initialOmoSettings?.otherFields;\n    return otherFields ? JSON.stringify(otherFields, null, 2) : \"\";\n  });\n\n  const mergedOmoJsonPreview = useMemo(() => {\n    if (isSlim) {\n      return JSON.stringify(\n        buildOmoSlimProfilePreview(omoAgents, omoOtherFieldsStr),\n        null,\n        2,\n      );\n    }\n    return JSON.stringify(\n      buildOmoProfilePreview(omoAgents, omoCategories, omoOtherFieldsStr),\n      null,\n      2,\n    );\n  }, [omoAgents, omoCategories, omoOtherFieldsStr, isSlim]);\n\n  const resetOmoDraftState = useCallback(() => {\n    setOmoAgents({});\n    setOmoCategories({});\n    setOmoOtherFieldsStr(\"\");\n  }, []);\n\n  return {\n    omoAgents,\n    setOmoAgents,\n    omoCategories,\n    setOmoCategories,\n    omoOtherFieldsStr,\n    setOmoOtherFieldsStr,\n    mergedOmoJsonPreview,\n    resetOmoDraftState,\n  };\n}\n"
  },
  {
    "path": "src/components/providers/forms/hooks/useOmoModelSource.ts",
    "content": "import { useEffect, useMemo, useRef, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { toast } from \"sonner\";\nimport { providersApi } from \"@/lib/api\";\nimport { useProvidersQuery } from \"@/lib/query/queries\";\nimport type { OpenCodeProviderConfig } from \"@/types\";\nimport { OPENCODE_PRESET_MODEL_VARIANTS } from \"@/config/opencodeProviderPresets\";\nimport { parseOpencodeConfigStrict } from \"../helpers/opencodeFormUtils\";\n\ninterface UseOmoModelSourceParams {\n  isOmoCategory: boolean;\n  providerId?: string;\n}\n\ninterface OmoModelBuild {\n  options: Array<{ value: string; label: string }>;\n  variantsMap: Record<string, string[]>;\n  presetMetaMap: Record<\n    string,\n    {\n      options?: Record<string, unknown>;\n      limit?: { context?: number; output?: number };\n    }\n  >;\n  parseFailedProviders: string[];\n  usedFallbackSource: boolean;\n}\n\nexport interface OmoModelSourceResult {\n  omoModelOptions: Array<{ value: string; label: string }>;\n  omoModelVariantsMap: Record<string, string[]>;\n  omoPresetMetaMap: Record<\n    string,\n    {\n      options?: Record<string, unknown>;\n      limit?: { context?: number; output?: number };\n    }\n  >;\n  existingOpencodeKeys: string[];\n}\n\nexport function useOmoModelSource({\n  isOmoCategory,\n  providerId,\n}: UseOmoModelSourceParams): OmoModelSourceResult {\n  const { t } = useTranslation();\n\n  const { data: opencodeProvidersData } = useProvidersQuery(\"opencode\");\n  const existingOpencodeKeys = useMemo(() => {\n    if (!opencodeProvidersData?.providers) return [];\n    return Object.keys(opencodeProvidersData.providers).filter(\n      (k) => k !== providerId,\n    );\n  }, [opencodeProvidersData?.providers, providerId]);\n\n  const [enabledOpencodeProviderIds, setEnabledOpencodeProviderIds] = useState<\n    string[] | null\n  >(null);\n  const [omoLiveIdsLoadFailed, setOmoLiveIdsLoadFailed] = useState(false);\n  const lastOmoModelSourceWarningRef = useRef<string>(\"\");\n\n  useEffect(() => {\n    let active = true;\n    if (!isOmoCategory) {\n      setEnabledOpencodeProviderIds(null);\n      setOmoLiveIdsLoadFailed(false);\n      return () => {\n        active = false;\n      };\n    }\n\n    setEnabledOpencodeProviderIds(null);\n    setOmoLiveIdsLoadFailed(false);\n\n    (async () => {\n      try {\n        const ids = await providersApi.getOpenCodeLiveProviderIds();\n        if (active) {\n          setEnabledOpencodeProviderIds(ids);\n        }\n      } catch (error) {\n        console.warn(\n          \"[OMO_MODEL_SOURCE_LIVE_IDS_FAILED] failed to load live provider ids\",\n          error,\n        );\n        if (active) {\n          setOmoLiveIdsLoadFailed(true);\n          setEnabledOpencodeProviderIds(null);\n        }\n      }\n    })();\n\n    return () => {\n      active = false;\n    };\n  }, [isOmoCategory]);\n\n  const omoModelBuild = useMemo<OmoModelBuild>(() => {\n    const empty: OmoModelBuild = {\n      options: [],\n      variantsMap: {},\n      presetMetaMap: {},\n      parseFailedProviders: [],\n      usedFallbackSource: false,\n    };\n    if (!isOmoCategory) {\n      return empty;\n    }\n\n    const allProviders = opencodeProvidersData?.providers;\n    if (!allProviders) {\n      return empty;\n    }\n\n    const shouldFilterByLive = !omoLiveIdsLoadFailed;\n    if (shouldFilterByLive && enabledOpencodeProviderIds === null) {\n      return empty;\n    }\n    const liveSet =\n      shouldFilterByLive && enabledOpencodeProviderIds\n        ? new Set(enabledOpencodeProviderIds)\n        : null;\n\n    const dedupedOptions = new Map<string, string>();\n    const variantsMap: Record<string, string[]> = {};\n    const presetMetaMap: Record<\n      string,\n      {\n        options?: Record<string, unknown>;\n        limit?: { context?: number; output?: number };\n      }\n    > = {};\n    const parseFailedProviders: string[] = [];\n\n    for (const [providerKey, provider] of Object.entries(allProviders)) {\n      if (provider.category === \"omo\" || provider.category === \"omo-slim\") {\n        continue;\n      }\n      if (liveSet && !liveSet.has(providerKey)) {\n        continue;\n      }\n\n      let parsedConfig: OpenCodeProviderConfig;\n      try {\n        parsedConfig = parseOpencodeConfigStrict(provider.settingsConfig);\n      } catch (error) {\n        parseFailedProviders.push(providerKey);\n        console.warn(\n          \"[OMO_MODEL_SOURCE_PARSE_FAILED] failed to parse provider settings\",\n          {\n            providerKey,\n            error,\n          },\n        );\n        continue;\n      }\n      for (const [modelId, model] of Object.entries(\n        parsedConfig.models || {},\n      )) {\n        const modelName =\n          typeof model.name === \"string\" && model.name.trim()\n            ? model.name\n            : modelId;\n        const providerDisplayName =\n          typeof provider.name === \"string\" && provider.name.trim()\n            ? provider.name\n            : providerKey;\n        const value = `${providerKey}/${modelId}`;\n        const label = `${providerDisplayName} / ${modelName} (${modelId})`;\n        if (!dedupedOptions.has(value)) {\n          dedupedOptions.set(value, label);\n        }\n\n        const rawVariants = model.variants;\n        if (\n          rawVariants &&\n          typeof rawVariants === \"object\" &&\n          !Array.isArray(rawVariants)\n        ) {\n          const variantKeys = Object.keys(rawVariants).filter(Boolean);\n          if (variantKeys.length > 0) {\n            variantsMap[value] = variantKeys;\n          }\n        }\n      }\n\n      // Preset fallback: for models without config-defined variants,\n      // check if the npm package has preset variant definitions.\n      // Also collect preset metadata (options, limit) for enrichment.\n      const presetModels = OPENCODE_PRESET_MODEL_VARIANTS[parsedConfig.npm];\n      if (presetModels) {\n        for (const modelId of Object.keys(parsedConfig.models || {})) {\n          const fullKey = `${providerKey}/${modelId}`;\n          const preset = presetModels.find((p) => p.id === modelId);\n          if (!preset) continue;\n\n          // Variant fallback\n          if (!variantsMap[fullKey] && preset.variants) {\n            const presetKeys = Object.keys(preset.variants).filter(Boolean);\n            if (presetKeys.length > 0) {\n              variantsMap[fullKey] = presetKeys;\n            }\n          }\n\n          // Collect preset metadata for model enrichment\n          const meta: (typeof presetMetaMap)[string] = {};\n          if (preset.options) meta.options = preset.options;\n          if (preset.contextLimit || preset.outputLimit) {\n            meta.limit = {};\n            if (preset.contextLimit) meta.limit.context = preset.contextLimit;\n            if (preset.outputLimit) meta.limit.output = preset.outputLimit;\n          }\n          if (Object.keys(meta).length > 0) {\n            presetMetaMap[fullKey] = meta;\n          }\n        }\n      }\n    }\n\n    return {\n      options: Array.from(dedupedOptions.entries())\n        .map(([value, label]) => ({ value, label }))\n        .sort((a, b) => a.label.localeCompare(b.label, \"zh-CN\")),\n      variantsMap,\n      presetMetaMap,\n      parseFailedProviders,\n      usedFallbackSource: omoLiveIdsLoadFailed,\n    };\n  }, [\n    isOmoCategory,\n    opencodeProvidersData?.providers,\n    enabledOpencodeProviderIds,\n    omoLiveIdsLoadFailed,\n  ]);\n\n  // Warning toast for parse failures / fallback\n  useEffect(() => {\n    if (!isOmoCategory) return;\n    const failed = omoModelBuild.parseFailedProviders;\n    const fallback = omoModelBuild.usedFallbackSource;\n    if (failed.length === 0 && !fallback) return;\n\n    const signature = `${fallback ? \"fallback:\" : \"\"}${failed\n      .slice()\n      .sort()\n      .join(\",\")}`;\n    if (lastOmoModelSourceWarningRef.current === signature) return;\n    lastOmoModelSourceWarningRef.current = signature;\n\n    if (failed.length > 0) {\n      toast.warning(\n        t(\"omo.modelSourcePartialWarning\", {\n          count: failed.length,\n          defaultValue:\n            \"Some provider model configs are invalid and were skipped.\",\n        }),\n      );\n    }\n    if (fallback) {\n      toast.warning(\n        t(\"omo.modelSourceFallbackWarning\", {\n          defaultValue:\n            \"Failed to load live provider state. Falling back to configured providers.\",\n        }),\n      );\n    }\n  }, [\n    isOmoCategory,\n    omoModelBuild.parseFailedProviders,\n    omoModelBuild.usedFallbackSource,\n    t,\n  ]);\n\n  return {\n    omoModelOptions: omoModelBuild.options,\n    omoModelVariantsMap: omoModelBuild.variantsMap,\n    omoPresetMetaMap: omoModelBuild.presetMetaMap,\n    existingOpencodeKeys,\n  };\n}\n"
  },
  {
    "path": "src/components/providers/forms/hooks/useOpenclawFormState.ts",
    "content": "import { useState, useCallback, useMemo } from \"react\";\nimport type { OpenClawModel, OpenClawProviderConfig } from \"@/types\";\nimport type { AppId } from \"@/lib/api\";\nimport { useProvidersQuery } from \"@/lib/query/queries\";\nimport { OPENCLAW_DEFAULT_CONFIG } from \"../helpers/opencodeFormUtils\";\n\ninterface UseOpenclawFormStateParams {\n  initialData?: {\n    settingsConfig?: Record<string, unknown>;\n  };\n  appId: AppId;\n  providerId?: string;\n  onSettingsConfigChange: (config: string) => void;\n  getSettingsConfig: () => string;\n}\n\nexport const OPENCLAW_DEFAULT_USER_AGENT =\n  \"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:148.0) Gecko/20100101 Firefox/148.0\";\n\nexport interface OpenclawFormState {\n  openclawProviderKey: string;\n  setOpenclawProviderKey: (key: string) => void;\n  openclawBaseUrl: string;\n  openclawApiKey: string;\n  openclawApi: string;\n  openclawModels: OpenClawModel[];\n  openclawUserAgent: boolean;\n  existingOpenclawKeys: string[];\n  handleOpenclawBaseUrlChange: (baseUrl: string) => void;\n  handleOpenclawApiKeyChange: (apiKey: string) => void;\n  handleOpenclawApiChange: (api: string) => void;\n  handleOpenclawModelsChange: (models: OpenClawModel[]) => void;\n  handleOpenclawUserAgentChange: (enabled: boolean) => void;\n  resetOpenclawState: (config?: OpenClawProviderConfig) => void;\n}\n\nfunction parseOpenclawField<T>(\n  initialData: UseOpenclawFormStateParams[\"initialData\"],\n  field: string,\n  fallback: T,\n): T {\n  try {\n    const config = JSON.parse(\n      initialData?.settingsConfig\n        ? JSON.stringify(initialData.settingsConfig)\n        : OPENCLAW_DEFAULT_CONFIG,\n    );\n    return (config[field] as T) || fallback;\n  } catch {\n    return fallback;\n  }\n}\n\nexport function useOpenclawFormState({\n  initialData,\n  appId,\n  providerId,\n  onSettingsConfigChange,\n  getSettingsConfig,\n}: UseOpenclawFormStateParams): OpenclawFormState {\n  // Query existing providers for duplicate key checking\n  const { data: openclawProvidersData } = useProvidersQuery(\"openclaw\");\n  const existingOpenclawKeys = useMemo(() => {\n    if (!openclawProvidersData?.providers) return [];\n    return Object.keys(openclawProvidersData.providers).filter(\n      (k) => k !== providerId,\n    );\n  }, [openclawProvidersData?.providers, providerId]);\n\n  const [openclawProviderKey, setOpenclawProviderKey] = useState<string>(() => {\n    if (appId !== \"openclaw\") return \"\";\n    return providerId || \"\";\n  });\n\n  const [openclawBaseUrl, setOpenclawBaseUrl] = useState<string>(() => {\n    if (appId !== \"openclaw\") return \"\";\n    return parseOpenclawField(initialData, \"baseUrl\", \"\");\n  });\n\n  const [openclawApiKey, setOpenclawApiKey] = useState<string>(() => {\n    if (appId !== \"openclaw\") return \"\";\n    return parseOpenclawField(initialData, \"apiKey\", \"\");\n  });\n\n  const [openclawApi, setOpenclawApi] = useState<string>(() => {\n    if (appId !== \"openclaw\") return \"openai-completions\";\n    return parseOpenclawField(initialData, \"api\", \"openai-completions\");\n  });\n\n  const [openclawModels, setOpenclawModels] = useState<OpenClawModel[]>(() => {\n    if (appId !== \"openclaw\") return [];\n    return parseOpenclawField<OpenClawModel[]>(initialData, \"models\", []);\n  });\n\n  const [openclawUserAgent, setOpenclawUserAgent] = useState<boolean>(() => {\n    if (appId !== \"openclaw\") return true;\n    const headers = parseOpenclawField<Record<string, string>>(\n      initialData,\n      \"headers\",\n      {},\n    );\n    return \"User-Agent\" in headers;\n  });\n\n  const updateOpenclawConfig = useCallback(\n    (updater: (config: Record<string, any>) => void) => {\n      try {\n        const config = JSON.parse(\n          getSettingsConfig() || OPENCLAW_DEFAULT_CONFIG,\n        );\n        updater(config);\n        onSettingsConfigChange(JSON.stringify(config, null, 2));\n      } catch {\n        // ignore\n      }\n    },\n    [getSettingsConfig, onSettingsConfigChange],\n  );\n\n  const handleOpenclawBaseUrlChange = useCallback(\n    (baseUrl: string) => {\n      setOpenclawBaseUrl(baseUrl);\n      updateOpenclawConfig((config) => {\n        config.baseUrl = baseUrl.trim().replace(/\\/+$/, \"\");\n      });\n    },\n    [updateOpenclawConfig],\n  );\n\n  const handleOpenclawApiKeyChange = useCallback(\n    (apiKey: string) => {\n      setOpenclawApiKey(apiKey);\n      updateOpenclawConfig((config) => {\n        config.apiKey = apiKey;\n      });\n    },\n    [updateOpenclawConfig],\n  );\n\n  const handleOpenclawApiChange = useCallback(\n    (api: string) => {\n      setOpenclawApi(api);\n      updateOpenclawConfig((config) => {\n        config.api = api;\n      });\n    },\n    [updateOpenclawConfig],\n  );\n\n  const handleOpenclawModelsChange = useCallback(\n    (models: OpenClawModel[]) => {\n      setOpenclawModels(models);\n      updateOpenclawConfig((config) => {\n        config.models = models;\n      });\n    },\n    [updateOpenclawConfig],\n  );\n\n  const handleOpenclawUserAgentChange = useCallback(\n    (enabled: boolean) => {\n      setOpenclawUserAgent(enabled);\n      updateOpenclawConfig((config) => {\n        if (enabled) {\n          config.headers = { \"User-Agent\": OPENCLAW_DEFAULT_USER_AGENT };\n        } else {\n          delete config.headers;\n        }\n      });\n    },\n    [updateOpenclawConfig],\n  );\n\n  const resetOpenclawState = useCallback((config?: OpenClawProviderConfig) => {\n    setOpenclawProviderKey(\"\");\n    setOpenclawBaseUrl(config?.baseUrl || \"\");\n    setOpenclawApiKey(config?.apiKey || \"\");\n    setOpenclawApi(config?.api || \"openai-completions\");\n    setOpenclawModels(config?.models || []);\n    const ua = config?.headers ? \"User-Agent\" in config.headers : false;\n    setOpenclawUserAgent(ua);\n  }, []);\n\n  return {\n    openclawProviderKey,\n    setOpenclawProviderKey,\n    openclawBaseUrl,\n    openclawApiKey,\n    openclawApi,\n    openclawModels,\n    openclawUserAgent,\n    existingOpenclawKeys,\n    handleOpenclawBaseUrlChange,\n    handleOpenclawApiKeyChange,\n    handleOpenclawApiChange,\n    handleOpenclawModelsChange,\n    handleOpenclawUserAgentChange,\n    resetOpenclawState,\n  };\n}\n"
  },
  {
    "path": "src/components/providers/forms/hooks/useOpencodeFormState.ts",
    "content": "import { useState, useCallback } from \"react\";\nimport type { OpenCodeModel, OpenCodeProviderConfig } from \"@/types\";\nimport {\n  OPENCODE_DEFAULT_NPM,\n  OPENCODE_DEFAULT_CONFIG,\n  isKnownOpencodeOptionKey,\n  parseOpencodeConfig,\n  toOpencodeExtraOptions,\n} from \"../helpers/opencodeFormUtils\";\n\ninterface UseOpencodeFormStateParams {\n  initialData?: {\n    settingsConfig?: Record<string, unknown>;\n  };\n  appId: string;\n  providerId?: string;\n  onSettingsConfigChange: (config: string) => void;\n  getSettingsConfig: () => string;\n}\n\nexport interface OpencodeFormState {\n  opencodeProviderKey: string;\n  setOpencodeProviderKey: (key: string) => void;\n  opencodeNpm: string;\n  opencodeApiKey: string;\n  opencodeBaseUrl: string;\n  opencodeModels: Record<string, OpenCodeModel>;\n  opencodeExtraOptions: Record<string, string>;\n  handleOpencodeNpmChange: (npm: string) => void;\n  handleOpencodeApiKeyChange: (apiKey: string) => void;\n  handleOpencodeBaseUrlChange: (baseUrl: string) => void;\n  handleOpencodeModelsChange: (models: Record<string, OpenCodeModel>) => void;\n  handleOpencodeExtraOptionsChange: (options: Record<string, string>) => void;\n  resetOpencodeState: (config?: OpenCodeProviderConfig) => void;\n}\n\nexport function useOpencodeFormState({\n  initialData,\n  appId,\n  providerId,\n  onSettingsConfigChange,\n  getSettingsConfig,\n}: UseOpencodeFormStateParams): OpencodeFormState {\n  const initialOpencodeConfig =\n    appId === \"opencode\"\n      ? parseOpencodeConfig(initialData?.settingsConfig)\n      : null;\n  const initialOpencodeOptions = initialOpencodeConfig?.options || {};\n\n  const [opencodeProviderKey, setOpencodeProviderKey] = useState<string>(() => {\n    if (appId !== \"opencode\") return \"\";\n    return providerId || \"\";\n  });\n\n  const [opencodeNpm, setOpencodeNpm] = useState<string>(() => {\n    if (appId !== \"opencode\") return OPENCODE_DEFAULT_NPM;\n    return initialOpencodeConfig?.npm || OPENCODE_DEFAULT_NPM;\n  });\n\n  const [opencodeApiKey, setOpencodeApiKey] = useState<string>(() => {\n    if (appId !== \"opencode\") return \"\";\n    const value = initialOpencodeOptions.apiKey;\n    return typeof value === \"string\" ? value : \"\";\n  });\n\n  const [opencodeBaseUrl, setOpencodeBaseUrl] = useState<string>(() => {\n    if (appId !== \"opencode\") return \"\";\n    const value = initialOpencodeOptions.baseURL;\n    return typeof value === \"string\" ? value : \"\";\n  });\n\n  const [opencodeModels, setOpencodeModels] = useState<\n    Record<string, OpenCodeModel>\n  >(() => {\n    if (appId !== \"opencode\") return {};\n    return initialOpencodeConfig?.models || {};\n  });\n\n  const [opencodeExtraOptions, setOpencodeExtraOptions] = useState<\n    Record<string, string>\n  >(() => {\n    if (appId !== \"opencode\") return {};\n    return toOpencodeExtraOptions(initialOpencodeOptions);\n  });\n\n  const updateOpencodeSettings = useCallback(\n    (updater: (config: Record<string, any>) => void) => {\n      try {\n        const config = JSON.parse(\n          getSettingsConfig() || OPENCODE_DEFAULT_CONFIG,\n        ) as Record<string, any>;\n        updater(config);\n        onSettingsConfigChange(JSON.stringify(config, null, 2));\n      } catch {}\n    },\n    [getSettingsConfig, onSettingsConfigChange],\n  );\n\n  const handleOpencodeNpmChange = useCallback(\n    (npm: string) => {\n      setOpencodeNpm(npm);\n      updateOpencodeSettings((config) => {\n        config.npm = npm;\n      });\n    },\n    [updateOpencodeSettings],\n  );\n\n  const handleOpencodeApiKeyChange = useCallback(\n    (apiKey: string) => {\n      setOpencodeApiKey(apiKey);\n      updateOpencodeSettings((config) => {\n        if (!config.options) config.options = {};\n        config.options.apiKey = apiKey;\n      });\n    },\n    [updateOpencodeSettings],\n  );\n\n  const handleOpencodeBaseUrlChange = useCallback(\n    (baseUrl: string) => {\n      setOpencodeBaseUrl(baseUrl);\n      updateOpencodeSettings((config) => {\n        if (!config.options) config.options = {};\n        config.options.baseURL = baseUrl.trim().replace(/\\/+$/, \"\");\n      });\n    },\n    [updateOpencodeSettings],\n  );\n\n  const handleOpencodeModelsChange = useCallback(\n    (models: Record<string, OpenCodeModel>) => {\n      setOpencodeModels(models);\n      updateOpencodeSettings((config) => {\n        config.models = models;\n      });\n    },\n    [updateOpencodeSettings],\n  );\n\n  const handleOpencodeExtraOptionsChange = useCallback(\n    (options: Record<string, string>) => {\n      setOpencodeExtraOptions(options);\n      updateOpencodeSettings((config) => {\n        if (!config.options) config.options = {};\n\n        for (const k of Object.keys(config.options)) {\n          if (!isKnownOpencodeOptionKey(k)) {\n            delete config.options[k];\n          }\n        }\n\n        for (const [k, v] of Object.entries(options)) {\n          const trimmedKey = k.trim();\n          if (trimmedKey && !trimmedKey.startsWith(\"option-\")) {\n            try {\n              config.options[trimmedKey] = JSON.parse(v);\n            } catch {\n              config.options[trimmedKey] = v;\n            }\n          }\n        }\n      });\n    },\n    [updateOpencodeSettings],\n  );\n\n  const resetOpencodeState = useCallback((config?: OpenCodeProviderConfig) => {\n    setOpencodeProviderKey(\"\");\n    setOpencodeNpm(config?.npm || OPENCODE_DEFAULT_NPM);\n    setOpencodeBaseUrl(config?.options?.baseURL || \"\");\n    setOpencodeApiKey(config?.options?.apiKey || \"\");\n    setOpencodeModels(config?.models || {});\n    setOpencodeExtraOptions(toOpencodeExtraOptions(config?.options || {}));\n  }, []);\n\n  return {\n    opencodeProviderKey,\n    setOpencodeProviderKey,\n    opencodeNpm,\n    opencodeApiKey,\n    opencodeBaseUrl,\n    opencodeModels,\n    opencodeExtraOptions,\n    handleOpencodeNpmChange,\n    handleOpencodeApiKeyChange,\n    handleOpencodeBaseUrlChange,\n    handleOpencodeModelsChange,\n    handleOpencodeExtraOptionsChange,\n    resetOpencodeState,\n  };\n}\n"
  },
  {
    "path": "src/components/providers/forms/hooks/useProviderCategory.ts",
    "content": "import { useState, useEffect } from \"react\";\nimport type { ProviderCategory } from \"@/types\";\nimport type { AppId } from \"@/lib/api\";\nimport { providerPresets } from \"@/config/claudeProviderPresets\";\nimport { codexProviderPresets } from \"@/config/codexProviderPresets\";\nimport { geminiProviderPresets } from \"@/config/geminiProviderPresets\";\nimport { opencodeProviderPresets } from \"@/config/opencodeProviderPresets\";\n\ninterface UseProviderCategoryProps {\n  appId: AppId;\n  selectedPresetId: string | null;\n  isEditMode: boolean;\n  initialCategory?: ProviderCategory;\n}\n\n/**\n * 管理供应商类别状态\n * 根据选择的预设自动更新类别\n */\nexport function useProviderCategory({\n  appId,\n  selectedPresetId,\n  isEditMode,\n  initialCategory,\n}: UseProviderCategoryProps) {\n  const [category, setCategory] = useState<ProviderCategory | undefined>(\n    // 编辑模式：使用 initialCategory\n    isEditMode ? initialCategory : undefined,\n  );\n\n  useEffect(() => {\n    // 编辑模式：只在初始化时设置，后续不自动更新\n    if (isEditMode) {\n      setCategory(initialCategory);\n      return;\n    }\n\n    if (selectedPresetId === \"custom\") {\n      setCategory(\"custom\");\n      return;\n    }\n\n    if (!selectedPresetId) return;\n\n    // 从预设 ID 提取索引\n    const match = selectedPresetId.match(\n      /^(claude|codex|gemini|opencode)-(\\d+)$/,\n    );\n    if (!match) return;\n\n    const [, type, indexStr] = match;\n    const index = parseInt(indexStr, 10);\n\n    if (type === \"codex\" && appId === \"codex\") {\n      const preset = codexProviderPresets[index];\n      if (preset) {\n        setCategory(\n          preset.category || (preset.isOfficial ? \"official\" : undefined),\n        );\n      }\n    } else if (type === \"claude\" && appId === \"claude\") {\n      const preset = providerPresets[index];\n      if (preset) {\n        setCategory(\n          preset.category || (preset.isOfficial ? \"official\" : undefined),\n        );\n      }\n    } else if (type === \"gemini\" && appId === \"gemini\") {\n      const preset = geminiProviderPresets[index];\n      if (preset) {\n        setCategory(preset.category || undefined);\n      }\n    } else if (type === \"opencode\" && appId === \"opencode\") {\n      const preset = opencodeProviderPresets[index];\n      if (preset) {\n        setCategory(preset.category || undefined);\n      }\n    }\n  }, [appId, selectedPresetId, isEditMode, initialCategory]);\n\n  return { category, setCategory };\n}\n"
  },
  {
    "path": "src/components/providers/forms/hooks/useSpeedTestEndpoints.ts",
    "content": "import { useMemo } from \"react\";\nimport type { AppId } from \"@/lib/api\";\nimport type { ProviderPreset } from \"@/config/claudeProviderPresets\";\nimport type { CodexProviderPreset } from \"@/config/codexProviderPresets\";\nimport type { ProviderMeta, EndpointCandidate } from \"@/types\";\nimport { extractCodexBaseUrl } from \"@/utils/providerConfigUtils\";\n\ntype PresetEntry = {\n  id: string;\n  preset: ProviderPreset | CodexProviderPreset;\n};\n\ninterface UseSpeedTestEndpointsProps {\n  appId: AppId;\n  selectedPresetId: string | null;\n  presetEntries: PresetEntry[];\n  baseUrl: string;\n  codexBaseUrl: string;\n  initialData?: {\n    settingsConfig?: Record<string, unknown>;\n    meta?: ProviderMeta;\n  };\n}\n\n/**\n * 收集端点测速弹窗的初始端点列表\n *\n * 收集来源：\n * 1. 当前选中的 Base URL\n * 2. 编辑模式下的初始数据 URL\n * 3. 预设中的 endpointCandidates\n *\n * 注意：已保存的自定义端点通过 getCustomEndpoints API 在 EndpointSpeedTest 组件中加载，\n * 不在此处读取，避免重复导入。\n */\nexport function useSpeedTestEndpoints({\n  appId,\n  selectedPresetId,\n  presetEntries,\n  baseUrl,\n  codexBaseUrl,\n  initialData,\n}: UseSpeedTestEndpointsProps) {\n  const claudeEndpoints = useMemo<EndpointCandidate[]>(() => {\n    // Reuse this branch for Claude and Gemini (non-Codex)\n    if (appId !== \"claude\" && appId !== \"gemini\") return [];\n\n    const map = new Map<string, EndpointCandidate>();\n    // 候选端点标记为 isCustom: false，表示来自预设或配置\n    // 已保存的自定义端点会在 EndpointSpeedTest 组件中通过 API 加载\n    const add = (url?: string, isCustom = false) => {\n      if (!url) return;\n      const sanitized = url.trim().replace(/\\/+$/, \"\");\n      if (!sanitized || map.has(sanitized)) return;\n      map.set(sanitized, { url: sanitized, isCustom });\n    };\n\n    // 1. 当前 Base URL\n    if (baseUrl) {\n      add(baseUrl);\n    }\n\n    // 2. 编辑模式：初始数据中的 URL\n    if (initialData && typeof initialData.settingsConfig === \"object\") {\n      const configEnv = initialData.settingsConfig as {\n        env?: { ANTHROPIC_BASE_URL?: string; GOOGLE_GEMINI_BASE_URL?: string };\n      };\n      const envUrls = [\n        configEnv.env?.ANTHROPIC_BASE_URL,\n        configEnv.env?.GOOGLE_GEMINI_BASE_URL,\n      ];\n      envUrls.forEach((u) => {\n        if (typeof u === \"string\") add(u);\n      });\n    }\n\n    // 3. 预设中的 endpointCandidates\n    if (selectedPresetId && selectedPresetId !== \"custom\") {\n      const entry = presetEntries.find((item) => item.id === selectedPresetId);\n      if (entry) {\n        const preset = entry.preset as ProviderPreset & {\n          settingsConfig?: { env?: { GOOGLE_GEMINI_BASE_URL?: string } };\n          endpointCandidates?: string[];\n        };\n        // 添加预设自己的 baseUrl（兼容 Claude/Gemini）\n        const presetEnv = preset.settingsConfig as {\n          env?: {\n            ANTHROPIC_BASE_URL?: string;\n            GOOGLE_GEMINI_BASE_URL?: string;\n          };\n        };\n        const presetUrls = [\n          presetEnv?.env?.ANTHROPIC_BASE_URL,\n          presetEnv?.env?.GOOGLE_GEMINI_BASE_URL,\n        ];\n        presetUrls.forEach((u) => add(u));\n        // 添加预设的候选端点\n        if (preset.endpointCandidates) {\n          preset.endpointCandidates.forEach((url) => add(url));\n        }\n      }\n    }\n\n    return Array.from(map.values());\n  }, [appId, baseUrl, initialData, selectedPresetId, presetEntries]);\n\n  const codexEndpoints = useMemo<EndpointCandidate[]>(() => {\n    if (appId !== \"codex\") return [];\n\n    const map = new Map<string, EndpointCandidate>();\n    // 候选端点标记为 isCustom: false，表示来自预设或配置\n    // 已保存的自定义端点会在 EndpointSpeedTest 组件中通过 API 加载\n    const add = (url?: string, isCustom = false) => {\n      if (!url) return;\n      const sanitized = url.trim().replace(/\\/+$/, \"\");\n      if (!sanitized || map.has(sanitized)) return;\n      map.set(sanitized, { url: sanitized, isCustom });\n    };\n\n    // 1. 当前 Codex Base URL\n    if (codexBaseUrl) {\n      add(codexBaseUrl);\n    }\n\n    // 2. 编辑模式：初始数据中的 URL\n    const initialCodexConfig = initialData?.settingsConfig as\n      | {\n          config?: string;\n        }\n      | undefined;\n    const configStr = initialCodexConfig?.config ?? \"\";\n    const extractedBaseUrl = extractCodexBaseUrl(configStr);\n    if (extractedBaseUrl) {\n      add(extractedBaseUrl);\n    }\n\n    // 3. 预设中的 endpointCandidates\n    if (selectedPresetId && selectedPresetId !== \"custom\") {\n      const entry = presetEntries.find((item) => item.id === selectedPresetId);\n      if (entry) {\n        const preset = entry.preset as CodexProviderPreset;\n        // 添加预设自己的 baseUrl\n        const presetConfig = preset.config || \"\";\n        const presetBaseUrl = extractCodexBaseUrl(presetConfig);\n        if (presetBaseUrl) {\n          add(presetBaseUrl);\n        }\n        // 添加预设的候选端点\n        if (preset.endpointCandidates) {\n          preset.endpointCandidates.forEach((url) => add(url));\n        }\n      }\n    }\n\n    return Array.from(map.values());\n  }, [appId, codexBaseUrl, initialData, selectedPresetId, presetEntries]);\n\n  return appId === \"codex\" ? codexEndpoints : claudeEndpoints;\n}\n"
  },
  {
    "path": "src/components/providers/forms/hooks/useTemplateValues.ts",
    "content": "import { useState, useEffect, useCallback, useMemo } from \"react\";\nimport type {\n  ProviderPreset,\n  TemplateValueConfig,\n} from \"@/config/claudeProviderPresets\";\nimport type { CodexProviderPreset } from \"@/config/codexProviderPresets\";\nimport { applyTemplateValues } from \"@/utils/providerConfigUtils\";\n\ntype TemplatePath = Array<string | number>;\ntype TemplateValueMap = Record<string, TemplateValueConfig>;\n\ninterface PresetEntry {\n  id: string;\n  preset: ProviderPreset | CodexProviderPreset;\n}\n\ninterface UseTemplateValuesProps {\n  selectedPresetId: string | null;\n  presetEntries: PresetEntry[];\n  settingsConfig: string;\n  onConfigChange: (config: string) => void;\n}\n\n/**\n * 收集配置中包含模板占位符的路径\n */\nconst collectTemplatePaths = (\n  source: unknown,\n  templateKeys: string[],\n  currentPath: TemplatePath = [],\n  acc: TemplatePath[] = [],\n): TemplatePath[] => {\n  if (typeof source === \"string\") {\n    const hasPlaceholder = templateKeys.some((key) =>\n      source.includes(`\\${${key}}`),\n    );\n    if (hasPlaceholder) {\n      acc.push([...currentPath]);\n    }\n    return acc;\n  }\n\n  if (Array.isArray(source)) {\n    source.forEach((item, index) =>\n      collectTemplatePaths(item, templateKeys, [...currentPath, index], acc),\n    );\n    return acc;\n  }\n\n  if (source && typeof source === \"object\") {\n    Object.entries(source).forEach(([key, value]) =>\n      collectTemplatePaths(value, templateKeys, [...currentPath, key], acc),\n    );\n  }\n\n  return acc;\n};\n\n/**\n * 根据路径获取值\n */\nconst getValueAtPath = (source: any, path: TemplatePath) => {\n  return path.reduce<any>((acc, key) => {\n    if (acc === undefined || acc === null) {\n      return undefined;\n    }\n    return acc[key as keyof typeof acc];\n  }, source);\n};\n\n/**\n * 根据路径设置值\n */\nconst setValueAtPath = (\n  target: any,\n  path: TemplatePath,\n  value: unknown,\n): any => {\n  if (path.length === 0) {\n    return value;\n  }\n\n  let current = target;\n\n  for (let i = 0; i < path.length - 1; i++) {\n    const key = path[i];\n    const nextKey = path[i + 1];\n    const isNextIndex = typeof nextKey === \"number\";\n\n    if (current[key as keyof typeof current] === undefined) {\n      current[key as keyof typeof current] = isNextIndex ? [] : {};\n    } else {\n      const currentValue = current[key as keyof typeof current];\n      if (isNextIndex && !Array.isArray(currentValue)) {\n        current[key as keyof typeof current] = [];\n      } else if (\n        !isNextIndex &&\n        (typeof currentValue !== \"object\" || currentValue === null)\n      ) {\n        current[key as keyof typeof current] = {};\n      }\n    }\n\n    current = current[key as keyof typeof current];\n  }\n\n  const finalKey = path[path.length - 1];\n  current[finalKey as keyof typeof current] = value;\n  return target;\n};\n\n/**\n * 应用模板值到配置字符串（只更新模板占位符所在的字段）\n */\nconst applyTemplateValuesToConfigString = (\n  presetConfig: any,\n  currentConfigString: string,\n  values: TemplateValueMap,\n) => {\n  const replacedConfig = applyTemplateValues(presetConfig, values);\n  const templateKeys = Object.keys(values);\n  if (templateKeys.length === 0) {\n    return JSON.stringify(replacedConfig, null, 2);\n  }\n\n  const placeholderPaths = collectTemplatePaths(presetConfig, templateKeys);\n\n  try {\n    const parsedConfig = currentConfigString.trim()\n      ? JSON.parse(currentConfigString)\n      : {};\n    let targetConfig: any;\n    if (Array.isArray(parsedConfig)) {\n      targetConfig = [...parsedConfig];\n    } else if (parsedConfig && typeof parsedConfig === \"object\") {\n      targetConfig = JSON.parse(JSON.stringify(parsedConfig));\n    } else {\n      targetConfig = {};\n    }\n\n    if (placeholderPaths.length === 0) {\n      return JSON.stringify(targetConfig, null, 2);\n    }\n\n    let mutatedConfig = targetConfig;\n\n    for (const path of placeholderPaths) {\n      const nextValue = getValueAtPath(replacedConfig, path);\n      if (path.length === 0) {\n        mutatedConfig = nextValue;\n      } else {\n        setValueAtPath(mutatedConfig, path, nextValue);\n      }\n    }\n\n    return JSON.stringify(mutatedConfig, null, 2);\n  } catch {\n    return JSON.stringify(replacedConfig, null, 2);\n  }\n};\n\n/**\n * 管理模板变量的状态和逻辑\n */\nexport function useTemplateValues({\n  selectedPresetId,\n  presetEntries,\n  settingsConfig,\n  onConfigChange,\n}: UseTemplateValuesProps) {\n  const [templateValues, setTemplateValues] = useState<TemplateValueMap>({});\n\n  // 获取当前选中的预设\n  const selectedPreset = useMemo(() => {\n    if (!selectedPresetId || selectedPresetId === \"custom\") {\n      return null;\n    }\n    const entry = presetEntries.find((item) => item.id === selectedPresetId);\n    // 只处理 ProviderPreset (Claude 预设)\n    if (entry && \"settingsConfig\" in entry.preset) {\n      return entry.preset as ProviderPreset;\n    }\n    return null;\n  }, [selectedPresetId, presetEntries]);\n\n  // 获取模板变量条目\n  const templateValueEntries = useMemo(() => {\n    if (!selectedPreset?.templateValues) {\n      return [];\n    }\n    return Object.entries(selectedPreset.templateValues) as Array<\n      [string, TemplateValueConfig]\n    >;\n  }, [selectedPreset]);\n\n  // 当选择预设时，初始化模板值\n  useEffect(() => {\n    if (selectedPreset?.templateValues) {\n      const initialValues = Object.fromEntries(\n        Object.entries(selectedPreset.templateValues).map(([key, config]) => [\n          key,\n          {\n            ...config,\n            editorValue: config.editorValue || config.defaultValue || \"\",\n          },\n        ]),\n      );\n      setTemplateValues(initialValues);\n    } else {\n      setTemplateValues({});\n    }\n  }, [selectedPreset]);\n\n  // 处理模板值变化\n  const handleTemplateValueChange = useCallback(\n    (key: string, value: string) => {\n      if (!selectedPreset?.templateValues) {\n        return;\n      }\n\n      const config = selectedPreset.templateValues[key];\n      if (!config) {\n        return;\n      }\n\n      setTemplateValues((prev) => {\n        const prevEntry = prev[key];\n        const nextEntry: TemplateValueConfig = {\n          ...config,\n          ...(prevEntry ?? {}),\n          editorValue: value,\n        };\n        const nextValues: TemplateValueMap = {\n          ...prev,\n          [key]: nextEntry,\n        };\n\n        // 应用模板值到配置\n        try {\n          const configString = applyTemplateValuesToConfigString(\n            selectedPreset.settingsConfig,\n            settingsConfig,\n            nextValues,\n          );\n          onConfigChange(configString);\n        } catch (err) {\n          console.error(\"更新模板值失败:\", err);\n        }\n\n        return nextValues;\n      });\n    },\n    [selectedPreset, settingsConfig, onConfigChange],\n  );\n\n  // 验证所有模板值是否已填写\n  const validateTemplateValues = useCallback((): {\n    isValid: boolean;\n    missingField?: { key: string; label: string };\n  } => {\n    if (templateValueEntries.length === 0) {\n      return { isValid: true };\n    }\n\n    for (const [key, config] of templateValueEntries) {\n      const entry = templateValues[key];\n      const resolvedValue = (\n        entry?.editorValue ??\n        entry?.defaultValue ??\n        config.defaultValue ??\n        \"\"\n      ).trim();\n      if (!resolvedValue) {\n        return {\n          isValid: false,\n          missingField: { key, label: config.label },\n        };\n      }\n    }\n\n    return { isValid: true };\n  }, [templateValueEntries, templateValues]);\n\n  return {\n    templateValues,\n    templateValueEntries,\n    selectedPreset,\n    handleTemplateValueChange,\n    validateTemplateValues,\n  };\n}\n"
  },
  {
    "path": "src/components/providers/forms/shared/ApiKeySection.tsx",
    "content": "import { useTranslation } from \"react-i18next\";\nimport ApiKeyInput from \"../ApiKeyInput\";\nimport type { ProviderCategory } from \"@/types\";\n\ninterface ApiKeySectionProps {\n  id?: string;\n  label?: string;\n  value: string;\n  onChange: (value: string) => void;\n  category?: ProviderCategory;\n  shouldShowLink: boolean;\n  websiteUrl: string;\n  placeholder?: {\n    official: string;\n    thirdParty: string;\n  };\n  disabled?: boolean;\n  isPartner?: boolean;\n  partnerPromotionKey?: string;\n}\n\nexport function ApiKeySection({\n  id,\n  label,\n  value,\n  onChange,\n  category,\n  shouldShowLink,\n  websiteUrl,\n  placeholder,\n  disabled,\n  isPartner,\n  partnerPromotionKey,\n}: ApiKeySectionProps) {\n  const { t } = useTranslation();\n\n  const defaultPlaceholder = {\n    official: t(\"providerForm.officialNoApiKey\", {\n      defaultValue: \"官方供应商无需 API Key\",\n    }),\n    thirdParty: t(\"providerForm.apiKeyAutoFill\", {\n      defaultValue: \"输入 API Key，将自动填充到配置\",\n    }),\n  };\n\n  const finalPlaceholder = placeholder || defaultPlaceholder;\n\n  return (\n    <div className=\"space-y-1\">\n      <ApiKeyInput\n        id={id}\n        label={label}\n        value={value}\n        onChange={onChange}\n        placeholder={\n          category === \"official\"\n            ? finalPlaceholder.official\n            : finalPlaceholder.thirdParty\n        }\n        disabled={disabled ?? category === \"official\"}\n      />\n      {/* API Key 获取链接 */}\n      {shouldShowLink && websiteUrl && (\n        <div className=\"space-y-2 -mt-1 pl-1\">\n          <a\n            href={websiteUrl}\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className=\"text-xs text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors\"\n          >\n            {t(\"providerForm.getApiKey\", {\n              defaultValue: \"获取 API Key\",\n            })}\n          </a>\n\n          {/* 合作伙伴促销信息 */}\n          {isPartner && partnerPromotionKey && (\n            <div className=\"rounded-md bg-blue-50 dark:bg-blue-950/30 p-2.5 border border-blue-200 dark:border-blue-800\">\n              <p className=\"text-xs leading-relaxed text-blue-700 dark:text-blue-300\">\n                💡{\" \"}\n                {t(`providerForm.partnerPromotion.${partnerPromotionKey}`, {\n                  defaultValue: \"\",\n                })}\n              </p>\n            </div>\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/providers/forms/shared/EndpointField.tsx",
    "content": "import { useTranslation } from \"react-i18next\";\nimport { FormLabel } from \"@/components/ui/form\";\nimport { Input } from \"@/components/ui/input\";\nimport { Zap } from \"lucide-react\";\n\ninterface EndpointFieldProps {\n  id: string;\n  label: string;\n  value: string;\n  onChange: (value: string) => void;\n  placeholder: string;\n  hint?: string;\n  showManageButton?: boolean;\n  onManageClick?: () => void;\n  manageButtonLabel?: string;\n}\n\nexport function EndpointField({\n  id,\n  label,\n  value,\n  onChange,\n  placeholder,\n  hint,\n  showManageButton = true,\n  onManageClick,\n  manageButtonLabel,\n}: EndpointFieldProps) {\n  const { t } = useTranslation();\n\n  const defaultManageLabel = t(\"providerForm.manageAndTest\", {\n    defaultValue: \"管理和测速\",\n  });\n\n  return (\n    <div className=\"space-y-2\">\n      <div className=\"flex items-center justify-between\">\n        <FormLabel htmlFor={id}>{label}</FormLabel>\n        {showManageButton && onManageClick && (\n          <button\n            type=\"button\"\n            onClick={onManageClick}\n            className=\"flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors\"\n          >\n            <Zap className=\"h-3.5 w-3.5\" />\n            {manageButtonLabel || defaultManageLabel}\n          </button>\n        )}\n      </div>\n      <Input\n        id={id}\n        type=\"text\"\n        value={value}\n        onChange={(e) => onChange(e.target.value)}\n        placeholder={placeholder}\n        autoComplete=\"off\"\n      />\n      {hint ? (\n        <div className=\"p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg\">\n          <p className=\"text-xs text-amber-600 dark:text-amber-400\">{hint}</p>\n        </div>\n      ) : null}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/providers/forms/shared/index.ts",
    "content": "export { ApiKeySection } from \"./ApiKeySection\";\nexport { EndpointField } from \"./EndpointField\";\n"
  },
  {
    "path": "src/components/proxy/AutoFailoverConfigPanel.tsx",
    "content": "import { useState, useEffect } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { Alert, AlertDescription } from \"@/components/ui/alert\";\nimport { Save, Loader2, Info } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { useAppProxyConfig, useUpdateAppProxyConfig } from \"@/lib/query/proxy\";\n\nexport interface AutoFailoverConfigPanelProps {\n  appType: string;\n  disabled?: boolean;\n}\n\nexport function AutoFailoverConfigPanel({\n  appType,\n  disabled = false,\n}: AutoFailoverConfigPanelProps) {\n  const { t } = useTranslation();\n  const { data: config, isLoading, error } = useAppProxyConfig(appType);\n  const updateConfig = useUpdateAppProxyConfig();\n\n  // 使用字符串状态以支持完全清空数字输入框\n  const [formData, setFormData] = useState({\n    autoFailoverEnabled: false,\n    maxRetries: \"3\",\n    streamingFirstByteTimeout: \"60\",\n    streamingIdleTimeout: \"120\",\n    nonStreamingTimeout: \"600\",\n    circuitFailureThreshold: \"5\",\n    circuitSuccessThreshold: \"2\",\n    circuitTimeoutSeconds: \"60\",\n    circuitErrorRateThreshold: \"50\", // 存储百分比值\n    circuitMinRequests: \"10\",\n  });\n\n  useEffect(() => {\n    if (config) {\n      setFormData({\n        autoFailoverEnabled: config.autoFailoverEnabled,\n        maxRetries: String(config.maxRetries),\n        streamingFirstByteTimeout: String(config.streamingFirstByteTimeout),\n        streamingIdleTimeout: String(config.streamingIdleTimeout),\n        nonStreamingTimeout: String(config.nonStreamingTimeout),\n        circuitFailureThreshold: String(config.circuitFailureThreshold),\n        circuitSuccessThreshold: String(config.circuitSuccessThreshold),\n        circuitTimeoutSeconds: String(config.circuitTimeoutSeconds),\n        circuitErrorRateThreshold: String(\n          Math.round(config.circuitErrorRateThreshold * 100),\n        ),\n        circuitMinRequests: String(config.circuitMinRequests),\n      });\n    }\n  }, [config]);\n\n  const handleSave = async () => {\n    if (!config) return;\n    // 解析数字，返回 NaN 表示无效输入\n    const parseNum = (val: string) => {\n      const trimmed = val.trim();\n      // 必须是纯数字\n      if (!/^-?\\d+$/.test(trimmed)) return NaN;\n      return parseInt(trimmed);\n    };\n\n    // 定义各字段的有效范围\n    const ranges = {\n      maxRetries: { min: 0, max: 10 },\n      streamingFirstByteTimeout: { min: 1, max: 120 },\n      streamingIdleTimeout: { min: 0, max: 600 },\n      nonStreamingTimeout: { min: 60, max: 1200 },\n      circuitFailureThreshold: { min: 1, max: 20 },\n      circuitSuccessThreshold: { min: 1, max: 10 },\n      circuitTimeoutSeconds: { min: 0, max: 300 },\n      circuitErrorRateThreshold: { min: 0, max: 100 },\n      circuitMinRequests: { min: 5, max: 100 },\n    };\n\n    // 解析原始值\n    const raw = {\n      maxRetries: parseNum(formData.maxRetries),\n      streamingFirstByteTimeout: parseNum(formData.streamingFirstByteTimeout),\n      streamingIdleTimeout: parseNum(formData.streamingIdleTimeout),\n      nonStreamingTimeout: parseNum(formData.nonStreamingTimeout),\n      circuitFailureThreshold: parseNum(formData.circuitFailureThreshold),\n      circuitSuccessThreshold: parseNum(formData.circuitSuccessThreshold),\n      circuitTimeoutSeconds: parseNum(formData.circuitTimeoutSeconds),\n      circuitErrorRateThreshold: parseNum(formData.circuitErrorRateThreshold),\n      circuitMinRequests: parseNum(formData.circuitMinRequests),\n    };\n\n    // 校验是否超出范围（NaN 也视为无效）\n    const errors: string[] = [];\n    const checkRange = (\n      value: number,\n      range: { min: number; max: number },\n      label: string,\n    ) => {\n      if (isNaN(value) || value < range.min || value > range.max) {\n        errors.push(`${label}: ${range.min}-${range.max}`);\n      }\n    };\n\n    checkRange(\n      raw.maxRetries,\n      ranges.maxRetries,\n      t(\"proxy.autoFailover.maxRetries\", \"最大重试次数\"),\n    );\n    checkRange(\n      raw.streamingFirstByteTimeout,\n      ranges.streamingFirstByteTimeout,\n      t(\"proxy.autoFailover.streamingFirstByte\", \"流式首字节超时\"),\n    );\n    checkRange(\n      raw.streamingIdleTimeout,\n      ranges.streamingIdleTimeout,\n      t(\"proxy.autoFailover.streamingIdle\", \"流式静默超时\"),\n    );\n    checkRange(\n      raw.nonStreamingTimeout,\n      ranges.nonStreamingTimeout,\n      t(\"proxy.autoFailover.nonStreaming\", \"非流式超时\"),\n    );\n    checkRange(\n      raw.circuitFailureThreshold,\n      ranges.circuitFailureThreshold,\n      t(\"proxy.autoFailover.failureThreshold\", \"失败阈值\"),\n    );\n    checkRange(\n      raw.circuitSuccessThreshold,\n      ranges.circuitSuccessThreshold,\n      t(\"proxy.autoFailover.successThreshold\", \"恢复成功阈值\"),\n    );\n    checkRange(\n      raw.circuitTimeoutSeconds,\n      ranges.circuitTimeoutSeconds,\n      t(\"proxy.autoFailover.timeout\", \"恢复等待时间\"),\n    );\n    checkRange(\n      raw.circuitErrorRateThreshold,\n      ranges.circuitErrorRateThreshold,\n      t(\"proxy.autoFailover.errorRate\", \"错误率阈值\"),\n    );\n    checkRange(\n      raw.circuitMinRequests,\n      ranges.circuitMinRequests,\n      t(\"proxy.autoFailover.minRequests\", \"最小请求数\"),\n    );\n\n    if (errors.length > 0) {\n      toast.error(\n        t(\"proxy.autoFailover.validationFailed\", {\n          fields: errors.join(\"; \"),\n          defaultValue: `以下字段超出有效范围: ${errors.join(\"; \")}`,\n        }),\n      );\n      return;\n    }\n\n    try {\n      await updateConfig.mutateAsync({\n        appType,\n        enabled: config.enabled,\n        autoFailoverEnabled: formData.autoFailoverEnabled,\n        maxRetries: raw.maxRetries,\n        streamingFirstByteTimeout: raw.streamingFirstByteTimeout,\n        streamingIdleTimeout: raw.streamingIdleTimeout,\n        nonStreamingTimeout: raw.nonStreamingTimeout,\n        circuitFailureThreshold: raw.circuitFailureThreshold,\n        circuitSuccessThreshold: raw.circuitSuccessThreshold,\n        circuitTimeoutSeconds: raw.circuitTimeoutSeconds,\n        circuitErrorRateThreshold: raw.circuitErrorRateThreshold / 100,\n        circuitMinRequests: raw.circuitMinRequests,\n      });\n      toast.success(\n        t(\"proxy.autoFailover.configSaved\", \"自动故障转移配置已保存\"),\n        { closeButton: true },\n      );\n    } catch (e) {\n      toast.error(\n        t(\"proxy.autoFailover.configSaveFailed\", \"保存失败\") + \": \" + String(e),\n      );\n    }\n  };\n\n  const handleReset = () => {\n    if (config) {\n      setFormData({\n        autoFailoverEnabled: config.autoFailoverEnabled,\n        maxRetries: String(config.maxRetries),\n        streamingFirstByteTimeout: String(config.streamingFirstByteTimeout),\n        streamingIdleTimeout: String(config.streamingIdleTimeout),\n        nonStreamingTimeout: String(config.nonStreamingTimeout),\n        circuitFailureThreshold: String(config.circuitFailureThreshold),\n        circuitSuccessThreshold: String(config.circuitSuccessThreshold),\n        circuitTimeoutSeconds: String(config.circuitTimeoutSeconds),\n        circuitErrorRateThreshold: String(\n          Math.round(config.circuitErrorRateThreshold * 100),\n        ),\n        circuitMinRequests: String(config.circuitMinRequests),\n      });\n    }\n  };\n\n  if (isLoading) {\n    return (\n      <div className=\"flex items-center justify-center p-4\">\n        <Loader2 className=\"h-6 w-6 animate-spin text-muted-foreground\" />\n      </div>\n    );\n  }\n\n  const isDisabled = disabled || updateConfig.isPending;\n\n  return (\n    <div className=\"border-0 rounded-none shadow-none bg-transparent\">\n      <div className=\"space-y-4\">\n        {error && (\n          <Alert variant=\"destructive\">\n            <AlertDescription>{String(error)}</AlertDescription>\n          </Alert>\n        )}\n\n        <Alert className=\"border-blue-500/40 bg-blue-500/10\">\n          <Info className=\"h-4 w-4\" />\n          <AlertDescription className=\"text-sm\">\n            {t(\n              \"proxy.autoFailover.info\",\n              \"当故障转移队列中配置了多个供应商时，系统会在请求失败时按优先级顺序依次尝试。当某个供应商连续失败达到阈值时，熔断器会打开并在一段时间内跳过该供应商。\",\n            )}\n          </AlertDescription>\n        </Alert>\n\n        {/* 重试与超时配置 */}\n        <div className=\"space-y-4 rounded-lg border border-white/10 bg-muted/30 p-4\">\n          <h4 className=\"text-sm font-semibold\">\n            {t(\"proxy.autoFailover.retrySettings\", \"重试与超时设置\")}\n          </h4>\n\n          <div className=\"grid grid-cols-1 md:grid-cols-2 gap-4\">\n            <div className=\"space-y-2\">\n              <Label htmlFor={`maxRetries-${appType}`}>\n                {t(\"proxy.autoFailover.maxRetries\", \"最大重试次数\")}\n              </Label>\n              <Input\n                id={`maxRetries-${appType}`}\n                type=\"number\"\n                min=\"0\"\n                max=\"10\"\n                value={formData.maxRetries}\n                onChange={(e) =>\n                  setFormData({ ...formData, maxRetries: e.target.value })\n                }\n                disabled={isDisabled}\n              />\n              <p className=\"text-xs text-muted-foreground\">\n                {t(\n                  \"proxy.autoFailover.maxRetriesHint\",\n                  \"请求失败时的重试次数（0-10）\",\n                )}\n              </p>\n            </div>\n\n            <div className=\"space-y-2\">\n              <Label htmlFor={`failureThreshold-${appType}`}>\n                {t(\"proxy.autoFailover.failureThreshold\", \"失败阈值\")}\n              </Label>\n              <Input\n                id={`failureThreshold-${appType}`}\n                type=\"number\"\n                min=\"1\"\n                max=\"20\"\n                value={formData.circuitFailureThreshold}\n                onChange={(e) =>\n                  setFormData({\n                    ...formData,\n                    circuitFailureThreshold: e.target.value,\n                  })\n                }\n                disabled={isDisabled}\n              />\n              <p className=\"text-xs text-muted-foreground\">\n                {t(\n                  \"proxy.autoFailover.failureThresholdHint\",\n                  \"连续失败多少次后打开熔断器（建议: 3-10）\",\n                )}\n              </p>\n            </div>\n          </div>\n        </div>\n\n        {/* 超时配置 */}\n        <div className=\"space-y-4 rounded-lg border border-white/10 bg-muted/30 p-4\">\n          <h4 className=\"text-sm font-semibold\">\n            {t(\"proxy.autoFailover.timeoutSettings\", \"超时配置\")}\n          </h4>\n\n          <div className=\"grid grid-cols-1 md:grid-cols-3 gap-4\">\n            <div className=\"space-y-2\">\n              <Label htmlFor={`streamingFirstByte-${appType}`}>\n                {t(\n                  \"proxy.autoFailover.streamingFirstByte\",\n                  \"流式首字节超时（秒）\",\n                )}\n              </Label>\n              <Input\n                id={`streamingFirstByte-${appType}`}\n                type=\"number\"\n                min=\"1\"\n                max=\"120\"\n                value={formData.streamingFirstByteTimeout}\n                onChange={(e) =>\n                  setFormData({\n                    ...formData,\n                    streamingFirstByteTimeout: e.target.value,\n                  })\n                }\n                disabled={isDisabled}\n              />\n              <p className=\"text-xs text-muted-foreground\">\n                {t(\n                  \"proxy.autoFailover.streamingFirstByteHint\",\n                  \"等待首个数据块的最大时间，范围 1-120 秒，默认 60 秒\",\n                )}\n              </p>\n            </div>\n\n            <div className=\"space-y-2\">\n              <Label htmlFor={`streamingIdle-${appType}`}>\n                {t(\"proxy.autoFailover.streamingIdle\", \"流式静默超时（秒）\")}\n              </Label>\n              <Input\n                id={`streamingIdle-${appType}`}\n                type=\"number\"\n                min=\"0\"\n                max=\"600\"\n                value={formData.streamingIdleTimeout}\n                onChange={(e) =>\n                  setFormData({\n                    ...formData,\n                    streamingIdleTimeout: e.target.value,\n                  })\n                }\n                disabled={isDisabled}\n              />\n              <p className=\"text-xs text-muted-foreground\">\n                {t(\n                  \"proxy.autoFailover.streamingIdleHint\",\n                  \"数据块之间的最大间隔，范围 60-600 秒，填 0 禁用（防止中途卡住）\",\n                )}\n              </p>\n            </div>\n\n            <div className=\"space-y-2\">\n              <Label htmlFor={`nonStreaming-${appType}`}>\n                {t(\"proxy.autoFailover.nonStreaming\", \"非流式超时（秒）\")}\n              </Label>\n              <Input\n                id={`nonStreaming-${appType}`}\n                type=\"number\"\n                min=\"60\"\n                max=\"1200\"\n                value={formData.nonStreamingTimeout}\n                onChange={(e) =>\n                  setFormData({\n                    ...formData,\n                    nonStreamingTimeout: e.target.value,\n                  })\n                }\n                disabled={isDisabled}\n              />\n              <p className=\"text-xs text-muted-foreground\">\n                {t(\n                  \"proxy.autoFailover.nonStreamingHint\",\n                  \"非流式请求的总超时时间，范围 60-1200 秒，默认 600 秒（10 分钟）\",\n                )}\n              </p>\n            </div>\n          </div>\n        </div>\n\n        {/* 熔断器配置 */}\n        <div className=\"space-y-4 rounded-lg border border-white/10 bg-muted/30 p-4\">\n          <h4 className=\"text-sm font-semibold\">\n            {t(\"proxy.autoFailover.circuitBreakerSettings\", \"熔断器配置\")}\n          </h4>\n\n          <div className=\"grid grid-cols-2 md:grid-cols-4 gap-4\">\n            <div className=\"space-y-2\">\n              <Label htmlFor={`successThreshold-${appType}`}>\n                {t(\"proxy.autoFailover.successThreshold\", \"恢复成功阈值\")}\n              </Label>\n              <Input\n                id={`successThreshold-${appType}`}\n                type=\"number\"\n                min=\"1\"\n                max=\"10\"\n                value={formData.circuitSuccessThreshold}\n                onChange={(e) =>\n                  setFormData({\n                    ...formData,\n                    circuitSuccessThreshold: e.target.value,\n                  })\n                }\n                disabled={isDisabled}\n              />\n              <p className=\"text-xs text-muted-foreground\">\n                {t(\n                  \"proxy.autoFailover.successThresholdHint\",\n                  \"半开状态下成功多少次后关闭熔断器\",\n                )}\n              </p>\n            </div>\n\n            <div className=\"space-y-2\">\n              <Label htmlFor={`timeoutSeconds-${appType}`}>\n                {t(\"proxy.autoFailover.timeout\", \"恢复等待时间（秒）\")}\n              </Label>\n              <Input\n                id={`timeoutSeconds-${appType}`}\n                type=\"number\"\n                min=\"0\"\n                max=\"300\"\n                value={formData.circuitTimeoutSeconds}\n                onChange={(e) =>\n                  setFormData({\n                    ...formData,\n                    circuitTimeoutSeconds: e.target.value,\n                  })\n                }\n                disabled={isDisabled}\n              />\n              <p className=\"text-xs text-muted-foreground\">\n                {t(\n                  \"proxy.autoFailover.timeoutHint\",\n                  \"熔断器打开后，等待多久后尝试恢复（建议: 30-120）\",\n                )}\n              </p>\n            </div>\n\n            <div className=\"space-y-2\">\n              <Label htmlFor={`errorRateThreshold-${appType}`}>\n                {t(\"proxy.autoFailover.errorRate\", \"错误率阈值 (%)\")}\n              </Label>\n              <Input\n                id={`errorRateThreshold-${appType}`}\n                type=\"number\"\n                min=\"0\"\n                max=\"100\"\n                step=\"5\"\n                value={formData.circuitErrorRateThreshold}\n                onChange={(e) =>\n                  setFormData({\n                    ...formData,\n                    circuitErrorRateThreshold: e.target.value,\n                  })\n                }\n                disabled={isDisabled}\n              />\n              <p className=\"text-xs text-muted-foreground\">\n                {t(\n                  \"proxy.autoFailover.errorRateHint\",\n                  \"错误率超过此值时打开熔断器\",\n                )}\n              </p>\n            </div>\n\n            <div className=\"space-y-2\">\n              <Label htmlFor={`minRequests-${appType}`}>\n                {t(\"proxy.autoFailover.minRequests\", \"最小请求数\")}\n              </Label>\n              <Input\n                id={`minRequests-${appType}`}\n                type=\"number\"\n                min=\"5\"\n                max=\"100\"\n                value={formData.circuitMinRequests}\n                onChange={(e) =>\n                  setFormData({\n                    ...formData,\n                    circuitMinRequests: e.target.value,\n                  })\n                }\n                disabled={isDisabled}\n              />\n              <p className=\"text-xs text-muted-foreground\">\n                {t(\n                  \"proxy.autoFailover.minRequestsHint\",\n                  \"计算错误率前的最小请求数\",\n                )}\n              </p>\n            </div>\n          </div>\n        </div>\n\n        {/* 操作按钮 */}\n        <div className=\"flex justify-end gap-3 pt-2\">\n          <Button variant=\"outline\" onClick={handleReset} disabled={isDisabled}>\n            {t(\"common.reset\", \"重置\")}\n          </Button>\n          <Button onClick={handleSave} disabled={isDisabled}>\n            {updateConfig.isPending ? (\n              <>\n                <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n                {t(\"common.saving\", \"保存中...\")}\n              </>\n            ) : (\n              <>\n                <Save className=\"mr-2 h-4 w-4\" />\n                {t(\"common.save\", \"保存\")}\n              </>\n            )}\n          </Button>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/proxy/CircuitBreakerConfigPanel.tsx",
    "content": "import {\n  useCircuitBreakerConfig,\n  useUpdateCircuitBreakerConfig,\n} from \"@/lib/query/failover\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { Button } from \"@/components/ui/button\";\nimport { useState, useEffect } from \"react\";\nimport { toast } from \"sonner\";\nimport { useTranslation } from \"react-i18next\";\n\n/**\n * 熔断器配置面板\n * 允许用户调整熔断器参数\n */\nexport function CircuitBreakerConfigPanel() {\n  const { t } = useTranslation();\n  const { data: config, isLoading } = useCircuitBreakerConfig();\n  const updateConfig = useUpdateCircuitBreakerConfig();\n\n  // 使用字符串状态以支持完全清空输入框\n  const [formData, setFormData] = useState({\n    failureThreshold: \"5\",\n    successThreshold: \"2\",\n    timeoutSeconds: \"60\",\n    errorRateThreshold: \"50\", // 存储百分比值\n    minRequests: \"10\",\n  });\n\n  // 当配置加载完成时更新表单数据\n  useEffect(() => {\n    if (config) {\n      setFormData({\n        failureThreshold: String(config.failureThreshold),\n        successThreshold: String(config.successThreshold),\n        timeoutSeconds: String(config.timeoutSeconds),\n        errorRateThreshold: String(Math.round(config.errorRateThreshold * 100)),\n        minRequests: String(config.minRequests),\n      });\n    }\n  }, [config]);\n\n  const handleSave = async () => {\n    // 解析数字，返回 NaN 表示无效输入\n    const parseNum = (val: string) => {\n      const trimmed = val.trim();\n      // 必须是纯数字\n      if (!/^-?\\d+$/.test(trimmed)) return NaN;\n      return parseInt(trimmed);\n    };\n\n    // 定义各字段的有效范围\n    const ranges = {\n      failureThreshold: { min: 1, max: 20 },\n      successThreshold: { min: 1, max: 10 },\n      timeoutSeconds: { min: 0, max: 300 },\n      errorRateThreshold: { min: 0, max: 100 },\n      minRequests: { min: 5, max: 100 },\n    };\n\n    // 解析原始值\n    const raw = {\n      failureThreshold: parseNum(formData.failureThreshold),\n      successThreshold: parseNum(formData.successThreshold),\n      timeoutSeconds: parseNum(formData.timeoutSeconds),\n      errorRateThreshold: parseNum(formData.errorRateThreshold),\n      minRequests: parseNum(formData.minRequests),\n    };\n\n    // 校验是否超出范围（NaN 也视为无效）\n    const errors: string[] = [];\n    const checkRange = (\n      value: number,\n      range: { min: number; max: number },\n      label: string,\n    ) => {\n      if (isNaN(value) || value < range.min || value > range.max) {\n        errors.push(`${label}: ${range.min}-${range.max}`);\n      }\n    };\n\n    checkRange(\n      raw.failureThreshold,\n      ranges.failureThreshold,\n      t(\"circuitBreaker.failureThreshold\", \"失败阈值\"),\n    );\n    checkRange(\n      raw.successThreshold,\n      ranges.successThreshold,\n      t(\"circuitBreaker.successThreshold\", \"成功阈值\"),\n    );\n    checkRange(\n      raw.timeoutSeconds,\n      ranges.timeoutSeconds,\n      t(\"circuitBreaker.timeoutSeconds\", \"超时时间\"),\n    );\n    checkRange(\n      raw.errorRateThreshold,\n      ranges.errorRateThreshold,\n      t(\"circuitBreaker.errorRateThreshold\", \"错误率阈值\"),\n    );\n    checkRange(\n      raw.minRequests,\n      ranges.minRequests,\n      t(\"circuitBreaker.minRequests\", \"最小请求数\"),\n    );\n\n    if (errors.length > 0) {\n      toast.error(\n        t(\"circuitBreaker.validationFailed\", {\n          fields: errors.join(\"; \"),\n          defaultValue: `以下字段超出有效范围: ${errors.join(\"; \")}`,\n        }),\n      );\n      return;\n    }\n\n    try {\n      await updateConfig.mutateAsync({\n        failureThreshold: raw.failureThreshold,\n        successThreshold: raw.successThreshold,\n        timeoutSeconds: raw.timeoutSeconds,\n        errorRateThreshold: raw.errorRateThreshold / 100,\n        minRequests: raw.minRequests,\n      });\n      toast.success(t(\"circuitBreaker.configSaved\", \"熔断器配置已保存\"), {\n        closeButton: true,\n      });\n    } catch (error) {\n      toast.error(\n        t(\"circuitBreaker.saveFailed\", \"保存失败\") + \": \" + String(error),\n      );\n    }\n  };\n\n  const handleReset = () => {\n    if (config) {\n      setFormData({\n        failureThreshold: String(config.failureThreshold),\n        successThreshold: String(config.successThreshold),\n        timeoutSeconds: String(config.timeoutSeconds),\n        errorRateThreshold: String(Math.round(config.errorRateThreshold * 100)),\n        minRequests: String(config.minRequests),\n      });\n    }\n  };\n\n  if (isLoading) {\n    return (\n      <div className=\"text-sm text-muted-foreground\">\n        {t(\"circuitBreaker.loading\", \"加载中...\")}\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"space-y-6\">\n      <div>\n        <h3 className=\"text-lg font-semibold\">\n          {t(\"circuitBreaker.title\", \"熔断器配置\")}\n        </h3>\n        <p className=\"text-sm text-muted-foreground mt-1\">\n          {t(\n            \"circuitBreaker.description\",\n            \"调整熔断器参数以控制故障检测和恢复行为\",\n          )}\n        </p>\n      </div>\n\n      <div className=\"h-px bg-border my-4\" />\n\n      <div className=\"grid grid-cols-1 md:grid-cols-2 gap-6\">\n        {/* 失败阈值 */}\n        <div className=\"space-y-2\">\n          <Label htmlFor=\"failureThreshold\">\n            {t(\"circuitBreaker.failureThreshold\", \"失败阈值\")}\n          </Label>\n          <Input\n            id=\"failureThreshold\"\n            type=\"number\"\n            min=\"1\"\n            max=\"20\"\n            value={formData.failureThreshold}\n            onChange={(e) =>\n              setFormData({ ...formData, failureThreshold: e.target.value })\n            }\n          />\n          <p className=\"text-xs text-muted-foreground\">\n            {t(\n              \"circuitBreaker.failureThresholdHint\",\n              \"连续失败多少次后打开熔断器\",\n            )}\n          </p>\n        </div>\n\n        {/* 超时时间 */}\n        <div className=\"space-y-2\">\n          <Label htmlFor=\"timeoutSeconds\">\n            {t(\"circuitBreaker.timeoutSeconds\", \"超时时间（秒）\")}\n          </Label>\n          <Input\n            id=\"timeoutSeconds\"\n            type=\"number\"\n            min=\"0\"\n            max=\"300\"\n            value={formData.timeoutSeconds}\n            onChange={(e) =>\n              setFormData({ ...formData, timeoutSeconds: e.target.value })\n            }\n          />\n          <p className=\"text-xs text-muted-foreground\">\n            {t(\n              \"circuitBreaker.timeoutSecondsHint\",\n              \"熔断器打开后多久尝试恢复（半开状态）\",\n            )}\n          </p>\n        </div>\n\n        {/* 成功阈值 */}\n        <div className=\"space-y-2\">\n          <Label htmlFor=\"successThreshold\">\n            {t(\"circuitBreaker.successThreshold\", \"成功阈值\")}\n          </Label>\n          <Input\n            id=\"successThreshold\"\n            type=\"number\"\n            min=\"1\"\n            max=\"10\"\n            value={formData.successThreshold}\n            onChange={(e) =>\n              setFormData({ ...formData, successThreshold: e.target.value })\n            }\n          />\n          <p className=\"text-xs text-muted-foreground\">\n            {t(\n              \"circuitBreaker.successThresholdHint\",\n              \"半开状态下成功多少次后关闭熔断器\",\n            )}\n          </p>\n        </div>\n\n        {/* 错误率阈值 */}\n        <div className=\"space-y-2\">\n          <Label htmlFor=\"errorRateThreshold\">\n            {t(\"circuitBreaker.errorRateThreshold\", \"错误率阈值 (%)\")}\n          </Label>\n          <Input\n            id=\"errorRateThreshold\"\n            type=\"number\"\n            min=\"0\"\n            max=\"100\"\n            step=\"5\"\n            value={formData.errorRateThreshold}\n            onChange={(e) =>\n              setFormData({ ...formData, errorRateThreshold: e.target.value })\n            }\n          />\n          <p className=\"text-xs text-muted-foreground\">\n            {t(\n              \"circuitBreaker.errorRateThresholdHint\",\n              \"错误率超过此值时打开熔断器\",\n            )}\n          </p>\n        </div>\n\n        {/* 最小请求数 */}\n        <div className=\"space-y-2\">\n          <Label htmlFor=\"minRequests\">\n            {t(\"circuitBreaker.minRequests\", \"最小请求数\")}\n          </Label>\n          <Input\n            id=\"minRequests\"\n            type=\"number\"\n            min=\"5\"\n            max=\"100\"\n            value={formData.minRequests}\n            onChange={(e) =>\n              setFormData({ ...formData, minRequests: e.target.value })\n            }\n          />\n          <p className=\"text-xs text-muted-foreground\">\n            {t(\"circuitBreaker.minRequestsHint\", \"计算错误率前的最小请求数\")}\n          </p>\n        </div>\n      </div>\n\n      <div className=\"flex gap-3\">\n        <Button onClick={handleSave} disabled={updateConfig.isPending}>\n          {updateConfig.isPending\n            ? t(\"common.saving\", \"保存中...\")\n            : t(\"circuitBreaker.saveConfig\", \"保存配置\")}\n        </Button>\n        <Button\n          variant=\"outline\"\n          onClick={handleReset}\n          disabled={updateConfig.isPending}\n        >\n          {t(\"common.reset\", \"重置\")}\n        </Button>\n      </div>\n\n      {/* 说明信息 */}\n      <div className=\"p-4 bg-muted/50 rounded-lg space-y-2 text-sm\">\n        <h4 className=\"font-medium\">\n          {t(\"circuitBreaker.instructionsTitle\", \"配置说明\")}\n        </h4>\n        <ul className=\"space-y-1 text-muted-foreground\">\n          <li>\n            •{\" \"}\n            <strong>{t(\"circuitBreaker.failureThreshold\", \"失败阈值\")}</strong>\n            ：\n            {t(\n              \"circuitBreaker.instructions.failureThreshold\",\n              \"连续失败达到此次数时，熔断器打开\",\n            )}\n          </li>\n          <li>\n            • <strong>{t(\"circuitBreaker.timeoutSeconds\", \"超时时间\")}</strong>\n            ：\n            {t(\n              \"circuitBreaker.instructions.timeout\",\n              \"熔断器打开后，等待此时间后尝试半开\",\n            )}\n          </li>\n          <li>\n            •{\" \"}\n            <strong>{t(\"circuitBreaker.successThreshold\", \"成功阈值\")}</strong>\n            ：\n            {t(\n              \"circuitBreaker.instructions.successThreshold\",\n              \"半开状态下，成功达到此次数时关闭熔断器\",\n            )}\n          </li>\n          <li>\n            •{\" \"}\n            <strong>\n              {t(\"circuitBreaker.errorRateThreshold\", \"错误率阈值\")}\n            </strong>\n            ：\n            {t(\n              \"circuitBreaker.instructions.errorRate\",\n              \"错误率超过此值时，熔断器打开\",\n            )}\n          </li>\n          <li>\n            • <strong>{t(\"circuitBreaker.minRequests\", \"最小请求数\")}</strong>：\n            {t(\n              \"circuitBreaker.instructions.minRequests\",\n              \"只有请求数达到此值后才计算错误率\",\n            )}\n          </li>\n        </ul>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/proxy/FailoverQueueManager.tsx",
    "content": "/**\n * 故障转移队列管理组件\n *\n * 允许用户管理代理模式下的故障转移队列，支持：\n * - 添加/移除供应商\n * - 队列顺序基于首页供应商列表的 sort_index\n */\n\nimport { useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { toast } from \"sonner\";\nimport { Plus, Trash2, Loader2, Info, AlertTriangle } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Switch } from \"@/components/ui/switch\";\nimport { Alert, AlertDescription } from \"@/components/ui/alert\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { cn } from \"@/lib/utils\";\nimport type { FailoverQueueItem } from \"@/types/proxy\";\nimport type { AppId } from \"@/lib/api\";\nimport {\n  useFailoverQueue,\n  useAvailableProvidersForFailover,\n  useAddToFailoverQueue,\n  useRemoveFromFailoverQueue,\n  useAutoFailoverEnabled,\n  useSetAutoFailoverEnabled,\n} from \"@/lib/query/failover\";\n\ninterface FailoverQueueManagerProps {\n  appType: AppId;\n  disabled?: boolean;\n}\n\nexport function FailoverQueueManager({\n  appType,\n  disabled = false,\n}: FailoverQueueManagerProps) {\n  const { t } = useTranslation();\n  const [selectedProviderId, setSelectedProviderId] = useState<string>(\"\");\n\n  // 故障转移开关状态（每个应用独立）\n  const { data: isFailoverEnabled = false } = useAutoFailoverEnabled(appType);\n  const setFailoverEnabled = useSetAutoFailoverEnabled();\n\n  // 查询数据\n  const {\n    data: queue,\n    isLoading: isQueueLoading,\n    error: queueError,\n  } = useFailoverQueue(appType);\n  const { data: availableProviders, isLoading: isProvidersLoading } =\n    useAvailableProvidersForFailover(appType);\n\n  // Mutations\n  const addToQueue = useAddToFailoverQueue();\n  const removeFromQueue = useRemoveFromFailoverQueue();\n\n  // 切换故障转移开关\n  const handleToggleFailover = (enabled: boolean) => {\n    setFailoverEnabled.mutate({ appType, enabled });\n  };\n\n  // 添加供应商到队列\n  const handleAddProvider = async () => {\n    if (!selectedProviderId) return;\n\n    try {\n      await addToQueue.mutateAsync({\n        appType,\n        providerId: selectedProviderId,\n      });\n      setSelectedProviderId(\"\");\n      toast.success(\n        t(\"proxy.failoverQueue.addSuccess\", \"已添加到故障转移队列\"),\n        { closeButton: true },\n      );\n    } catch (error) {\n      toast.error(\n        t(\"proxy.failoverQueue.addFailed\", \"添加失败\") + \": \" + String(error),\n      );\n    }\n  };\n\n  // 从队列移除供应商\n  const handleRemoveProvider = async (providerId: string) => {\n    try {\n      await removeFromQueue.mutateAsync({ appType, providerId });\n      toast.success(\n        t(\"proxy.failoverQueue.removeSuccess\", \"已从故障转移队列移除\"),\n        { closeButton: true },\n      );\n    } catch (error) {\n      toast.error(\n        t(\"proxy.failoverQueue.removeFailed\", \"移除失败\") +\n          \": \" +\n          String(error),\n      );\n    }\n  };\n\n  if (isQueueLoading) {\n    return (\n      <div className=\"flex items-center justify-center p-8\">\n        <Loader2 className=\"h-6 w-6 animate-spin text-muted-foreground\" />\n      </div>\n    );\n  }\n\n  if (queueError) {\n    return (\n      <Alert variant=\"destructive\">\n        <AlertTriangle className=\"h-4 w-4\" />\n        <AlertDescription>{String(queueError)}</AlertDescription>\n      </Alert>\n    );\n  }\n\n  return (\n    <div className=\"space-y-4\">\n      {/* 自动故障转移开关 */}\n      <div className=\"flex items-center justify-between p-4 rounded-lg bg-muted/50 border border-border/50\">\n        <div className=\"space-y-0.5\">\n          <div className=\"flex items-center gap-2\">\n            <span className=\"text-sm font-medium\">\n              {t(\"proxy.failover.autoSwitch\", {\n                defaultValue: \"自动故障转移\",\n              })}\n            </span>\n            {isFailoverEnabled && (\n              <span className=\"px-2 py-0.5 text-xs rounded-full bg-emerald-500/20 text-emerald-600 dark:text-emerald-400\">\n                {t(\"common.enabled\", { defaultValue: \"已开启\" })}\n              </span>\n            )}\n          </div>\n          <p className=\"text-xs text-muted-foreground\">\n            {t(\"proxy.failover.autoSwitchDescription\", {\n              defaultValue:\n                \"开启后将立即切换到队列 P1，并在请求失败时自动切换到队列中的下一个供应商\",\n            })}\n          </p>\n        </div>\n        <Switch\n          checked={isFailoverEnabled}\n          onCheckedChange={handleToggleFailover}\n          disabled={disabled || setFailoverEnabled.isPending}\n        />\n      </div>\n\n      {/* 说明信息 */}\n      <Alert className=\"border-blue-500/40 bg-blue-500/10\">\n        <Info className=\"h-4 w-4\" />\n        <AlertDescription className=\"text-sm\">\n          {t(\n            \"proxy.failoverQueue.info\",\n            \"队列顺序与首页供应商列表顺序一致。当请求失败时，系统会按顺序依次尝试队列中的供应商。\",\n          )}\n        </AlertDescription>\n      </Alert>\n\n      {/* 添加供应商 */}\n      <div className=\"flex items-center gap-2\">\n        <Select\n          value={selectedProviderId}\n          onValueChange={setSelectedProviderId}\n          disabled={disabled || isProvidersLoading}\n        >\n          <SelectTrigger className=\"flex-1\">\n            <SelectValue\n              placeholder={t(\n                \"proxy.failoverQueue.selectProvider\",\n                \"选择供应商添加到队列\",\n              )}\n            />\n          </SelectTrigger>\n          <SelectContent>\n            {availableProviders?.map((provider) => (\n              <SelectItem key={provider.id} value={provider.id}>\n                {provider.name}\n              </SelectItem>\n            ))}\n            {(!availableProviders || availableProviders.length === 0) && (\n              <div className=\"px-2 py-4 text-center text-sm text-muted-foreground\">\n                {t(\n                  \"proxy.failoverQueue.noAvailableProviders\",\n                  \"没有可添加的供应商\",\n                )}\n              </div>\n            )}\n          </SelectContent>\n        </Select>\n        <Button\n          onClick={handleAddProvider}\n          disabled={disabled || !selectedProviderId || addToQueue.isPending}\n          size=\"icon\"\n          variant=\"outline\"\n        >\n          {addToQueue.isPending ? (\n            <Loader2 className=\"h-4 w-4 animate-spin\" />\n          ) : (\n            <Plus className=\"h-4 w-4\" />\n          )}\n        </Button>\n      </div>\n\n      {/* 队列列表 */}\n      {!queue || queue.length === 0 ? (\n        <div className=\"rounded-lg border border-dashed border-muted-foreground/40 p-8 text-center\">\n          <p className=\"text-sm text-muted-foreground\">\n            {t(\n              \"proxy.failoverQueue.empty\",\n              \"故障转移队列为空。添加供应商以启用自动故障转移。\",\n            )}\n          </p>\n        </div>\n      ) : (\n        <div className=\"space-y-2\">\n          {queue.map((item, index) => (\n            <QueueItem\n              key={item.providerId}\n              item={item}\n              index={index}\n              disabled={disabled}\n              onRemove={handleRemoveProvider}\n              isRemoving={removeFromQueue.isPending}\n            />\n          ))}\n        </div>\n      )}\n\n      {/* 队列说明 */}\n      {queue && queue.length > 0 && (\n        <p className=\"text-xs text-muted-foreground\">\n          {t(\n            \"proxy.failoverQueue.orderHint\",\n            \"队列顺序与首页供应商列表顺序一致，可在首页拖拽调整顺序。\",\n          )}\n        </p>\n      )}\n    </div>\n  );\n}\n\ninterface QueueItemProps {\n  item: FailoverQueueItem;\n  index: number;\n  disabled: boolean;\n  onRemove: (providerId: string) => void;\n  isRemoving: boolean;\n}\n\nfunction QueueItem({\n  item,\n  index,\n  disabled,\n  onRemove,\n  isRemoving,\n}: QueueItemProps) {\n  const { t } = useTranslation();\n\n  return (\n    <div\n      className={cn(\n        \"flex items-center gap-3 rounded-lg border bg-card p-3 transition-colors\",\n      )}\n    >\n      {/* 序号 */}\n      <div className=\"flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs font-medium\">\n        {index + 1}\n      </div>\n\n      {/* 供应商名称 */}\n      <div className=\"flex-1 min-w-0\">\n        <span className=\"text-sm font-medium truncate block\">\n          {item.providerName}\n        </span>\n      </div>\n\n      {/* 删除按钮 */}\n      <Button\n        variant=\"ghost\"\n        size=\"icon\"\n        className=\"h-8 w-8 text-muted-foreground hover:text-destructive\"\n        onClick={() => onRemove(item.providerId)}\n        disabled={disabled || isRemoving}\n        aria-label={t(\"common.delete\", \"删除\")}\n      >\n        {isRemoving ? (\n          <Loader2 className=\"h-4 w-4 animate-spin\" />\n        ) : (\n          <Trash2 className=\"h-4 w-4\" />\n        )}\n      </Button>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/proxy/FailoverToggle.tsx",
    "content": "/**\n * 故障转移切换开关组件\n *\n * 放置在主界面头部，用于一键启用/关闭自动故障转移\n */\n\nimport { Shuffle, Loader2 } from \"lucide-react\";\nimport { Switch } from \"@/components/ui/switch\";\nimport {\n  useAutoFailoverEnabled,\n  useSetAutoFailoverEnabled,\n} from \"@/lib/query/failover\";\nimport { cn } from \"@/lib/utils\";\nimport { useTranslation } from \"react-i18next\";\nimport type { AppId } from \"@/lib/api\";\n\ninterface FailoverToggleProps {\n  className?: string;\n  activeApp: AppId;\n}\n\nexport function FailoverToggle({ className, activeApp }: FailoverToggleProps) {\n  const { t } = useTranslation();\n  const { data: isEnabled = false, isLoading } =\n    useAutoFailoverEnabled(activeApp);\n  const setEnabled = useSetAutoFailoverEnabled();\n\n  const handleToggle = (checked: boolean) => {\n    setEnabled.mutate({ appType: activeApp, enabled: checked });\n  };\n\n  const appLabel =\n    activeApp === \"claude\"\n      ? \"Claude\"\n      : activeApp === \"codex\"\n        ? \"Codex\"\n        : \"Gemini\";\n\n  const tooltipText = isEnabled\n    ? t(\"failover.tooltip.enabled\", {\n        app: appLabel,\n        defaultValue: `${appLabel} 故障转移已启用\\n按队列优先级（P1→P2→...）选择供应商`,\n      })\n    : t(\"failover.tooltip.disabled\", {\n        app: appLabel,\n        defaultValue: `启用 ${appLabel} 故障转移\\n将立即切换到队列 P1，并在失败时自动切换到下一个`,\n      });\n\n  return (\n    <div\n      className={cn(\n        \"flex items-center gap-1 px-1.5 h-8 rounded-lg bg-muted/50 transition-all\",\n        className,\n      )}\n      title={tooltipText}\n    >\n      {setEnabled.isPending || isLoading ? (\n        <Loader2 className=\"h-4 w-4 animate-spin text-muted-foreground\" />\n      ) : (\n        <Shuffle\n          className={cn(\n            \"h-4 w-4 transition-colors\",\n            isEnabled\n              ? \"text-emerald-500 animate-pulse\"\n              : \"text-muted-foreground\",\n          )}\n        />\n      )}\n      <Switch\n        checked={isEnabled}\n        onCheckedChange={handleToggle}\n        disabled={setEnabled.isPending || isLoading}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/proxy/ProxyPanel.tsx",
    "content": "import { useState, useEffect } from \"react\";\nimport {\n  Activity,\n  Clock,\n  TrendingUp,\n  Server,\n  ListOrdered,\n  Save,\n  Loader2,\n  Zap,\n  Power,\n} from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Switch } from \"@/components/ui/switch\";\nimport { Label } from \"@/components/ui/label\";\nimport { Input } from \"@/components/ui/input\";\nimport { ToggleRow } from \"@/components/ui/toggle-row\";\nimport { useProxyStatus } from \"@/hooks/useProxyStatus\";\nimport { toast } from \"sonner\";\nimport { useFailoverQueue } from \"@/lib/query/failover\";\nimport { ProviderHealthBadge } from \"@/components/providers/ProviderHealthBadge\";\nimport { useProviderHealth } from \"@/lib/query/failover\";\nimport {\n  useProxyTakeoverStatus,\n  useSetProxyTakeoverForApp,\n  useGlobalProxyConfig,\n  useUpdateGlobalProxyConfig,\n} from \"@/lib/query/proxy\";\nimport type { ProxyStatus } from \"@/types/proxy\";\nimport { useTranslation } from \"react-i18next\";\nimport { AnimatePresence, motion } from \"framer-motion\";\n\ninterface ProxyPanelProps {\n  enableLocalProxy: boolean;\n  onEnableLocalProxyChange: (checked: boolean) => void;\n  onToggleProxy: (checked: boolean) => Promise<void>;\n  isProxyPending: boolean;\n}\n\nexport function ProxyPanel({\n  enableLocalProxy,\n  onEnableLocalProxyChange,\n  onToggleProxy,\n  isProxyPending,\n}: ProxyPanelProps) {\n  const { t } = useTranslation();\n  const { status, isRunning } = useProxyStatus();\n\n  // 获取应用接管状态\n  const { data: takeoverStatus } = useProxyTakeoverStatus();\n  const setTakeoverForApp = useSetProxyTakeoverForApp();\n\n  // 获取全局代理配置\n  const { data: globalConfig } = useGlobalProxyConfig();\n  const updateGlobalConfig = useUpdateGlobalProxyConfig();\n\n  // 监听地址/端口的本地状态（端口用字符串以支持完全清空）\n  const [listenAddress, setListenAddress] = useState(\"127.0.0.1\");\n  const [listenPort, setListenPort] = useState(\"15721\");\n\n  // 同步全局配置到本地状态\n  useEffect(() => {\n    if (globalConfig) {\n      setListenAddress(globalConfig.listenAddress);\n      setListenPort(String(globalConfig.listenPort));\n    }\n  }, [globalConfig]);\n\n  // 获取所有三个应用类型的故障转移队列\n  // 启用自动故障转移后，将按队列优先级（P1→P2→...）选择供应商\n  const { data: claudeQueue = [] } = useFailoverQueue(\"claude\");\n  const { data: codexQueue = [] } = useFailoverQueue(\"codex\");\n  const { data: geminiQueue = [] } = useFailoverQueue(\"gemini\");\n\n  const handleTakeoverChange = async (appType: string, enabled: boolean) => {\n    try {\n      await setTakeoverForApp.mutateAsync({ appType, enabled });\n      toast.success(\n        enabled\n          ? t(\"proxy.takeover.enabled\", {\n              app: appType,\n              defaultValue: `${appType} 接管已启用`,\n            })\n          : t(\"proxy.takeover.disabled\", {\n              app: appType,\n              defaultValue: `${appType} 接管已关闭`,\n            }),\n        { closeButton: true },\n      );\n    } catch (error) {\n      toast.error(\n        t(\"proxy.takeover.failed\", {\n          defaultValue: \"切换接管状态失败\",\n        }),\n      );\n    }\n  };\n\n  const handleLoggingChange = async (enabled: boolean) => {\n    if (!globalConfig) return;\n    try {\n      await updateGlobalConfig.mutateAsync({\n        ...globalConfig,\n        enableLogging: enabled,\n      });\n      toast.success(\n        enabled\n          ? t(\"proxy.logging.enabled\", { defaultValue: \"日志记录已启用\" })\n          : t(\"proxy.logging.disabled\", { defaultValue: \"日志记录已关闭\" }),\n        { closeButton: true },\n      );\n    } catch (error) {\n      toast.error(\n        t(\"proxy.logging.failed\", { defaultValue: \"切换日志状态失败\" }),\n      );\n    }\n  };\n\n  const handleSaveBasicConfig = async () => {\n    if (!globalConfig) return;\n\n    // 校验地址格式（简单的 IP 地址或 localhost 校验）\n    const addressTrimmed = listenAddress.trim();\n    const ipv4Regex = /^(\\d{1,3}\\.){3}\\d{1,3}$/;\n    const isValidAddress =\n      addressTrimmed === \"localhost\" ||\n      addressTrimmed === \"0.0.0.0\" ||\n      (ipv4Regex.test(addressTrimmed) &&\n        addressTrimmed.split(\".\").every((n) => {\n          const num = parseInt(n);\n          return num >= 0 && num <= 255;\n        }));\n    if (!isValidAddress) {\n      toast.error(\n        t(\"proxy.settings.invalidAddress\", {\n          defaultValue:\n            \"地址无效，请输入有效的 IP 地址（如 127.0.0.1）或 localhost\",\n        }),\n      );\n      return;\n    }\n\n    // 严格校验端口：必须是纯数字\n    const portTrimmed = listenPort.trim();\n    if (!/^\\d+$/.test(portTrimmed)) {\n      toast.error(\n        t(\"proxy.settings.invalidPort\", {\n          defaultValue: \"端口无效，请输入 1024-65535 之间的数字\",\n        }),\n      );\n      return;\n    }\n    const port = parseInt(portTrimmed);\n    if (isNaN(port) || port < 1024 || port > 65535) {\n      toast.error(\n        t(\"proxy.settings.invalidPort\", {\n          defaultValue: \"端口无效，请输入 1024-65535 之间的数字\",\n        }),\n      );\n      return;\n    }\n    try {\n      await updateGlobalConfig.mutateAsync({\n        ...globalConfig,\n        listenAddress: addressTrimmed,\n        listenPort: port,\n      });\n      toast.success(\n        t(\"proxy.settings.configSaved\", { defaultValue: \"代理配置已保存\" }),\n        { closeButton: true },\n      );\n    } catch (error) {\n      toast.error(\n        t(\"proxy.settings.configSaveFailed\", { defaultValue: \"保存配置失败\" }),\n      );\n    }\n  };\n\n  const formatUptime = (seconds: number): string => {\n    const hours = Math.floor(seconds / 3600);\n    const minutes = Math.floor((seconds % 3600) / 60);\n    const secs = seconds % 60;\n\n    if (hours > 0) {\n      return `${hours}h ${minutes}m ${secs}s`;\n    } else if (minutes > 0) {\n      return `${minutes}m ${secs}s`;\n    } else {\n      return `${secs}s`;\n    }\n  };\n\n  // 格式化地址用于 URL（IPv6 需要方括号）\n  const formatAddressForUrl = (address: string, port: number): string => {\n    const isIPv6 = address.includes(\":\");\n    const host = isIPv6 ? `[${address}]` : address;\n    return `http://${host}:${port}`;\n  };\n\n  return (\n    <>\n      <section className=\"space-y-4\">\n        {/* [1] Enable proxy button on main page — always visible */}\n        <ToggleRow\n          icon={<Zap className=\"h-4 w-4 text-green-500\" />}\n          title={t(\"settings.advanced.proxy.enableFeature\")}\n          description={t(\"settings.advanced.proxy.enableFeatureDescription\")}\n          checked={enableLocalProxy}\n          onCheckedChange={onEnableLocalProxyChange}\n        />\n\n        {/* [2] Proxy service toggle — always visible */}\n        <div className=\"flex items-center justify-between rounded-xl border border-border bg-card/50 p-4 transition-colors hover:bg-muted/50\">\n          <div className=\"flex items-center gap-3\">\n            <div className=\"flex h-8 w-8 items-center justify-center rounded-lg bg-background ring-1 ring-border\">\n              <Power className=\"h-4 w-4 text-green-500\" />\n            </div>\n            <div className=\"space-y-1\">\n              <p className=\"text-sm font-medium leading-none\">\n                {t(\"proxyConfig.proxyEnabled\", {\n                  defaultValue: \"代理服务\",\n                })}\n              </p>\n              <p className=\"text-xs text-muted-foreground\">\n                {isRunning\n                  ? t(\"settings.advanced.proxy.running\")\n                  : t(\"settings.advanced.proxy.stopped\")}\n              </p>\n            </div>\n          </div>\n          <Switch\n            checked={isRunning}\n            onCheckedChange={onToggleProxy}\n            disabled={isProxyPending}\n          />\n        </div>\n\n        {/* [3] App takeover switches — animated, visible only when proxy is running */}\n        <AnimatePresence>\n          {isRunning && (\n            <motion.div\n              initial={{ opacity: 0, height: 0 }}\n              animate={{ opacity: 1, height: \"auto\" }}\n              exit={{ opacity: 0, height: 0 }}\n              transition={{ duration: 0.25, ease: \"easeInOut\" }}\n              className=\"overflow-hidden\"\n            >\n              <div className=\"rounded-xl border-2 border-primary/20 bg-primary/5 p-4 space-y-3\">\n                <p className=\"text-xs font-medium text-primary\">\n                  {t(\"proxyConfig.appTakeover\", {\n                    defaultValue: \"应用接管\",\n                  })}\n                </p>\n                <div className=\"grid gap-2 sm:grid-cols-3\">\n                  {([\"claude\", \"codex\", \"gemini\"] as const).map((appType) => {\n                    const isEnabled =\n                      takeoverStatus?.[\n                        appType as keyof typeof takeoverStatus\n                      ] ?? false;\n                    return (\n                      <div\n                        key={appType}\n                        className=\"flex items-center justify-between rounded-md border border-primary/20 bg-background/60 px-3 py-2\"\n                      >\n                        <span className=\"text-sm font-medium capitalize\">\n                          {appType}\n                        </span>\n                        <Switch\n                          checked={isEnabled}\n                          onCheckedChange={(checked) =>\n                            handleTakeoverChange(appType, checked)\n                          }\n                          disabled={setTakeoverForApp.isPending}\n                        />\n                      </div>\n                    );\n                  })}\n                </div>\n                <p className=\"text-xs text-muted-foreground\">\n                  {t(\"proxy.takeover.hint\", {\n                    defaultValue:\n                      \"选择要接管的应用，启用后该应用的请求将通过本地代理转发\",\n                  })}\n                </p>\n              </div>\n            </motion.div>\n          )}\n        </AnimatePresence>\n\n        {/* Running state: service info + stats */}\n        {isRunning && status ? (\n          <div className=\"space-y-6\">\n            {/* [4] Running info: address + current provider */}\n            <div className=\"rounded-lg border border-border bg-muted/40 p-4 space-y-4\">\n              <div>\n                <p className=\"text-xs text-muted-foreground mb-2\">\n                  {t(\"proxy.panel.serviceAddress\", {\n                    defaultValue: \"服务地址\",\n                  })}\n                </p>\n                <div className=\"flex flex-col gap-2 sm:flex-row sm:items-center\">\n                  <code className=\"flex-1 text-sm bg-background px-3 py-2 rounded border border-border/60\">\n                    {formatAddressForUrl(status.address, status.port)}\n                  </code>\n                  <Button\n                    size=\"sm\"\n                    variant=\"outline\"\n                    onClick={() => {\n                      navigator.clipboard.writeText(\n                        formatAddressForUrl(status.address, status.port),\n                      );\n                      toast.success(\n                        t(\"proxy.panel.addressCopied\", {\n                          defaultValue: \"地址已复制\",\n                        }),\n                        { closeButton: true },\n                      );\n                    }}\n                  >\n                    {t(\"common.copy\")}\n                  </Button>\n                </div>\n                <p className=\"text-xs text-muted-foreground mt-2\">\n                  {t(\"proxy.settings.restartRequired\", {\n                    defaultValue: \"修改监听地址/端口需要先停止代理服务\",\n                  })}\n                </p>\n              </div>\n\n              <div className=\"pt-3 border-t border-border space-y-2\">\n                <p className=\"text-xs text-muted-foreground\">\n                  {t(\"provider.inUse\")}\n                </p>\n                {status.active_targets && status.active_targets.length > 0 ? (\n                  <div className=\"grid gap-2 sm:grid-cols-2\">\n                    {status.active_targets.map((target) => (\n                      <div\n                        key={target.app_type}\n                        className=\"flex items-center justify-between rounded-md border border-border bg-background/60 px-2 py-1.5 text-xs\"\n                      >\n                        <span className=\"text-muted-foreground\">\n                          {target.app_type}\n                        </span>\n                        <span\n                          className=\"ml-2 font-medium truncate text-foreground\"\n                          title={target.provider_name}\n                        >\n                          {target.provider_name}\n                        </span>\n                      </div>\n                    ))}\n                  </div>\n                ) : status.current_provider ? (\n                  <p className=\"text-sm text-muted-foreground\">\n                    {t(\"proxy.panel.currentProvider\", {\n                      defaultValue: \"当前 Provider：\",\n                    })}{\" \"}\n                    <span className=\"font-medium text-foreground\">\n                      {status.current_provider}\n                    </span>\n                  </p>\n                ) : (\n                  <p className=\"text-sm text-yellow-600 dark:text-yellow-400\">\n                    {t(\"proxy.panel.waitingFirstRequest\", {\n                      defaultValue: \"当前 Provider：等待首次请求…\",\n                    })}\n                  </p>\n                )}\n              </div>\n\n              {/* [5] Logging toggle */}\n              <div className=\"pt-3 border-t border-border\">\n                <div className=\"flex items-center justify-between rounded-md border border-border bg-background/60 px-3 py-2\">\n                  <div className=\"space-y-0.5\">\n                    <Label className=\"text-sm font-medium\">\n                      {t(\"proxy.settings.fields.enableLogging.label\", {\n                        defaultValue: \"启用日志记录\",\n                      })}\n                    </Label>\n                    <p className=\"text-xs text-muted-foreground\">\n                      {t(\"proxy.settings.fields.enableLogging.description\", {\n                        defaultValue: \"记录所有代理请求，便于排查问题\",\n                      })}\n                    </p>\n                  </div>\n                  <Switch\n                    checked={globalConfig?.enableLogging ?? true}\n                    onCheckedChange={handleLoggingChange}\n                    disabled={updateGlobalConfig.isPending}\n                  />\n                </div>\n              </div>\n\n              {/* [6] Provider queues */}\n              {(claudeQueue.length > 0 ||\n                codexQueue.length > 0 ||\n                geminiQueue.length > 0) && (\n                <div className=\"pt-3 border-t border-border space-y-3\">\n                  <div className=\"flex items-center gap-2\">\n                    <ListOrdered className=\"h-3.5 w-3.5 text-muted-foreground\" />\n                    <p className=\"text-xs text-muted-foreground\">\n                      {t(\"proxy.failoverQueue.title\")}\n                    </p>\n                  </div>\n\n                  {claudeQueue.length > 0 && (\n                    <ProviderQueueGroup\n                      appType=\"claude\"\n                      appLabel=\"Claude\"\n                      targets={claudeQueue.map((item) => ({\n                        id: item.providerId,\n                        name: item.providerName,\n                      }))}\n                      status={status}\n                    />\n                  )}\n\n                  {codexQueue.length > 0 && (\n                    <ProviderQueueGroup\n                      appType=\"codex\"\n                      appLabel=\"Codex\"\n                      targets={codexQueue.map((item) => ({\n                        id: item.providerId,\n                        name: item.providerName,\n                      }))}\n                      status={status}\n                    />\n                  )}\n\n                  {geminiQueue.length > 0 && (\n                    <ProviderQueueGroup\n                      appType=\"gemini\"\n                      appLabel=\"Gemini\"\n                      targets={geminiQueue.map((item) => ({\n                        id: item.providerId,\n                        name: item.providerName,\n                      }))}\n                      status={status}\n                    />\n                  )}\n                </div>\n              )}\n            </div>\n\n            {/* [7] Stats cards */}\n            <div className=\"grid gap-3 md:grid-cols-4\">\n              <StatCard\n                icon={<Activity className=\"h-4 w-4\" />}\n                label={t(\"proxy.panel.stats.activeConnections\", {\n                  defaultValue: \"活跃连接\",\n                })}\n                value={status.active_connections}\n              />\n              <StatCard\n                icon={<TrendingUp className=\"h-4 w-4\" />}\n                label={t(\"proxy.panel.stats.totalRequests\", {\n                  defaultValue: \"总请求数\",\n                })}\n                value={status.total_requests}\n              />\n              <StatCard\n                icon={<Clock className=\"h-4 w-4\" />}\n                label={t(\"proxy.panel.stats.successRate\", {\n                  defaultValue: \"成功率\",\n                })}\n                value={`${status.success_rate.toFixed(1)}%`}\n                variant={status.success_rate > 90 ? \"success\" : \"warning\"}\n              />\n              <StatCard\n                icon={<Clock className=\"h-4 w-4\" />}\n                label={t(\"proxy.panel.stats.uptime\", {\n                  defaultValue: \"运行时间\",\n                })}\n                value={formatUptime(status.uptime_seconds)}\n              />\n            </div>\n          </div>\n        ) : (\n          <div className=\"space-y-6\">\n            {/* [8] Basic settings — address/port (only when stopped) */}\n            <div className=\"rounded-lg border border-border bg-muted/40 p-4 space-y-4\">\n              <div>\n                <h4 className=\"text-sm font-semibold\">\n                  {t(\"proxy.settings.basic.title\", {\n                    defaultValue: \"基础设置\",\n                  })}\n                </h4>\n                <p className=\"text-xs text-muted-foreground\">\n                  {t(\"proxy.settings.basic.description\", {\n                    defaultValue: \"配置代理服务监听的地址与端口。\",\n                  })}\n                </p>\n              </div>\n\n              <div className=\"grid gap-4 md:grid-cols-2\">\n                <div className=\"space-y-2\">\n                  <Label htmlFor=\"listen-address\">\n                    {t(\"proxy.settings.fields.listenAddress.label\", {\n                      defaultValue: \"监听地址\",\n                    })}\n                  </Label>\n                  <Input\n                    id=\"listen-address\"\n                    value={listenAddress}\n                    onChange={(e) => setListenAddress(e.target.value)}\n                    placeholder={t(\n                      \"proxy.settings.fields.listenAddress.placeholder\",\n                      {\n                        defaultValue: \"127.0.0.1\",\n                      },\n                    )}\n                  />\n                  <p className=\"text-xs text-muted-foreground\">\n                    {t(\"proxy.settings.fields.listenAddress.description\", {\n                      defaultValue:\n                        \"代理服务器监听的 IP 地址（推荐 127.0.0.1）\",\n                    })}\n                  </p>\n                </div>\n\n                <div className=\"space-y-2\">\n                  <Label htmlFor=\"listen-port\">\n                    {t(\"proxy.settings.fields.listenPort.label\", {\n                      defaultValue: \"监听端口\",\n                    })}\n                  </Label>\n                  <Input\n                    id=\"listen-port\"\n                    type=\"number\"\n                    value={listenPort}\n                    onChange={(e) => setListenPort(e.target.value)}\n                    placeholder={t(\n                      \"proxy.settings.fields.listenPort.placeholder\",\n                      {\n                        defaultValue: \"15721\",\n                      },\n                    )}\n                  />\n                  <p className=\"text-xs text-muted-foreground\">\n                    {t(\"proxy.settings.fields.listenPort.description\", {\n                      defaultValue: \"代理服务器监听的端口号（1024 ~ 65535）\",\n                    })}\n                  </p>\n                </div>\n              </div>\n\n              <div className=\"flex justify-end\">\n                <Button\n                  size=\"sm\"\n                  onClick={handleSaveBasicConfig}\n                  disabled={updateGlobalConfig.isPending}\n                >\n                  {updateGlobalConfig.isPending ? (\n                    <>\n                      <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n                      {t(\"common.saving\", { defaultValue: \"保存中...\" })}\n                    </>\n                  ) : (\n                    <>\n                      <Save className=\"mr-2 h-4 w-4\" />\n                      {t(\"common.save\", { defaultValue: \"保存\" })}\n                    </>\n                  )}\n                </Button>\n              </div>\n            </div>\n\n            {/* Stopped hint */}\n            <div className=\"text-center py-6 text-muted-foreground\">\n              <div className=\"mx-auto w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4\">\n                <Server className=\"h-8 w-8\" />\n              </div>\n              <p className=\"text-base font-medium text-foreground mb-1\">\n                {t(\"proxy.panel.stoppedTitle\", {\n                  defaultValue: \"代理服务已停止\",\n                })}\n              </p>\n              <p className=\"text-sm text-muted-foreground\">\n                {t(\"proxy.panel.stoppedDescription\", {\n                  defaultValue: \"使用上方开关即可启动服务\",\n                })}\n              </p>\n            </div>\n          </div>\n        )}\n      </section>\n    </>\n  );\n}\n\ninterface StatCardProps {\n  icon: React.ReactNode;\n  label: string;\n  value: string | number;\n  variant?: \"default\" | \"success\" | \"warning\";\n}\n\nfunction StatCard({ icon, label, value, variant = \"default\" }: StatCardProps) {\n  const variantStyles = {\n    default: \"\",\n    success: \"border-green-500/40 bg-green-500/5\",\n    warning: \"border-yellow-500/40 bg-yellow-500/5\",\n  };\n\n  return (\n    <div\n      className={`rounded-lg border border-border bg-card/60 p-4 text-sm text-muted-foreground ${variantStyles[variant]}`}\n    >\n      <div className=\"flex items-center gap-2 text-muted-foreground mb-2\">\n        {icon}\n        <span className=\"text-xs\">{label}</span>\n      </div>\n      <p className=\"text-xl font-semibold text-foreground\">{value}</p>\n    </div>\n  );\n}\n\ninterface ProviderQueueGroupProps {\n  appType: string;\n  appLabel: string;\n  targets: Array<{\n    id: string;\n    name: string;\n  }>;\n  status: ProxyStatus;\n}\n\nfunction ProviderQueueGroup({\n  appType,\n  appLabel,\n  targets,\n  status,\n}: ProviderQueueGroupProps) {\n  // 查找该应用类型的当前活跃目标\n  const activeTarget = status.active_targets?.find(\n    (t) => t.app_type === appType,\n  );\n\n  return (\n    <div className=\"space-y-2\">\n      {/* 应用类型标题 */}\n      <div className=\"flex items-center gap-2 px-2\">\n        <span className=\"text-xs font-semibold text-foreground/80\">\n          {appLabel}\n        </span>\n        <div className=\"flex-1 h-px bg-border/50\" />\n      </div>\n\n      {/* 供应商列表 */}\n      <div className=\"space-y-1.5\">\n        {targets.map((target, index) => (\n          <ProviderQueueItem\n            key={target.id}\n            provider={target}\n            priority={index + 1}\n            appType={appType}\n            isCurrent={activeTarget?.provider_id === target.id}\n          />\n        ))}\n      </div>\n    </div>\n  );\n}\n\ninterface ProviderQueueItemProps {\n  provider: {\n    id: string;\n    name: string;\n  };\n  priority: number;\n  appType: string;\n  isCurrent: boolean;\n}\n\nfunction ProviderQueueItem({\n  provider,\n  priority,\n  appType,\n  isCurrent,\n}: ProviderQueueItemProps) {\n  const { t } = useTranslation();\n  const { data: health } = useProviderHealth(provider.id, appType);\n\n  return (\n    <div\n      className={`flex items-center justify-between rounded-md border px-3 py-2 text-sm transition-colors ${\n        isCurrent\n          ? \"border-primary/40 bg-primary/10 text-primary font-medium\"\n          : \"border-border bg-background/60\"\n      }`}\n    >\n      <div className=\"flex items-center gap-2\">\n        <span\n          className={`flex-shrink-0 flex items-center justify-center w-5 h-5 rounded-full text-xs font-bold ${\n            isCurrent\n              ? \"bg-primary text-primary-foreground\"\n              : \"bg-muted text-muted-foreground\"\n          }`}\n        >\n          {priority}\n        </span>\n        <span className={isCurrent ? \"\" : \"text-foreground\"}>\n          {provider.name}\n        </span>\n        {isCurrent && (\n          <span className=\"text-xs px-1.5 py-0.5 rounded bg-primary/20 text-primary\">\n            {t(\"provider.inUse\")}\n          </span>\n        )}\n      </div>\n      {/* 健康徽章 */}\n      <ProviderHealthBadge\n        consecutiveFailures={health?.consecutive_failures ?? 0}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/proxy/ProxyToggle.tsx",
    "content": "/**\n * 代理模式切换开关组件\n *\n * 放置在主界面头部，用于一键启用/关闭代理模式\n * 启用时自动接管 Live 配置，关闭时恢复原始配置\n */\n\nimport { Radio, Loader2 } from \"lucide-react\";\nimport { Switch } from \"@/components/ui/switch\";\nimport { useProxyStatus } from \"@/hooks/useProxyStatus\";\nimport { cn } from \"@/lib/utils\";\nimport { useTranslation } from \"react-i18next\";\nimport type { AppId } from \"@/lib/api\";\n\ninterface ProxyToggleProps {\n  className?: string;\n  activeApp: AppId;\n}\n\nexport function ProxyToggle({ className, activeApp }: ProxyToggleProps) {\n  const { t } = useTranslation();\n  const { isRunning, takeoverStatus, setTakeoverForApp, isPending, status } =\n    useProxyStatus();\n\n  const handleToggle = async (checked: boolean) => {\n    try {\n      await setTakeoverForApp({ appType: activeApp, enabled: checked });\n    } catch (error) {\n      console.error(\"[ProxyToggle] Toggle takeover failed:\", error);\n    }\n  };\n\n  const takeoverEnabled = takeoverStatus?.[activeApp] || false;\n\n  const appLabel =\n    activeApp === \"claude\"\n      ? \"Claude\"\n      : activeApp === \"codex\"\n        ? \"Codex\"\n        : activeApp === \"gemini\"\n          ? \"Gemini\"\n          : \"OpenCode\";\n\n  const tooltipText = takeoverEnabled\n    ? isRunning\n      ? t(\"proxy.takeover.tooltip.active\", {\n          appLabel,\n          address: status?.address,\n          port: status?.port,\n          defaultValue: `${appLabel} 已接管 - ${status?.address}:${status?.port}\\n切换该应用供应商为热切换`,\n        })\n      : t(\"proxy.takeover.tooltip.broken\", {\n          appLabel,\n          defaultValue: `${appLabel} 已接管，但代理服务未运行`,\n        })\n    : t(\"proxy.takeover.tooltip.inactive\", {\n        appLabel,\n        defaultValue: `接管 ${appLabel} 的 Live 配置，让该应用请求走本地代理`,\n      });\n\n  return (\n    <div\n      className={cn(\n        \"flex items-center gap-1 px-1.5 h-8 rounded-lg bg-muted/50 transition-all\",\n        className,\n      )}\n      title={tooltipText}\n    >\n      {isPending ? (\n        <Loader2 className=\"h-4 w-4 animate-spin text-muted-foreground\" />\n      ) : (\n        <Radio\n          className={cn(\n            \"h-4 w-4 transition-colors\",\n            takeoverEnabled\n              ? \"text-emerald-500 animate-pulse\"\n              : \"text-muted-foreground\",\n          )}\n        />\n      )}\n      <Switch\n        checked={takeoverEnabled}\n        onCheckedChange={handleToggle}\n        disabled={isPending}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/proxy/index.ts",
    "content": "/**\n * 代理功能组件导出\n */\n\nexport { ProxyPanel } from \"./ProxyPanel\";\n"
  },
  {
    "path": "src/components/sessions/SessionItem.tsx",
    "content": "import { ChevronRight, Clock } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport { cn } from \"@/lib/utils\";\nimport { ProviderIcon } from \"@/components/ProviderIcon\";\nimport type { SessionMeta } from \"@/types\";\nimport {\n  formatRelativeTime,\n  formatSessionTitle,\n  getProviderIconName,\n  getProviderLabel,\n  getSessionKey,\n} from \"./utils\";\n\ninterface SessionItemProps {\n  session: SessionMeta;\n  isSelected: boolean;\n  onSelect: (key: string) => void;\n}\n\nexport function SessionItem({\n  session,\n  isSelected,\n  onSelect,\n}: SessionItemProps) {\n  const { t } = useTranslation();\n  const title = formatSessionTitle(session);\n  const lastActive = session.lastActiveAt || session.createdAt || undefined;\n  const sessionKey = getSessionKey(session);\n\n  return (\n    <button\n      type=\"button\"\n      onClick={() => onSelect(sessionKey)}\n      className={cn(\n        \"w-full text-left rounded-lg px-3 py-2.5 transition-all group\",\n        isSelected\n          ? \"bg-primary/10 border border-primary/30\"\n          : \"hover:bg-muted/60 border border-transparent\",\n      )}\n    >\n      <div className=\"flex items-center gap-2 mb-1\">\n        <Tooltip>\n          <TooltipTrigger asChild>\n            <span className=\"shrink-0\">\n              <ProviderIcon\n                icon={getProviderIconName(session.providerId)}\n                name={session.providerId}\n                size={18}\n              />\n            </span>\n          </TooltipTrigger>\n          <TooltipContent>\n            {getProviderLabel(session.providerId, t)}\n          </TooltipContent>\n        </Tooltip>\n        <span className=\"text-sm font-medium truncate flex-1\">{title}</span>\n        <ChevronRight\n          className={cn(\n            \"size-4 text-muted-foreground/50 shrink-0 transition-transform\",\n            isSelected && \"text-primary rotate-90\",\n          )}\n        />\n      </div>\n\n      <div className=\"flex items-center gap-1 text-[11px] text-muted-foreground\">\n        <Clock className=\"size-3\" />\n        <span>\n          {lastActive ? formatRelativeTime(lastActive, t) : t(\"common.unknown\")}\n        </span>\n      </div>\n    </button>\n  );\n}\n"
  },
  {
    "path": "src/components/sessions/SessionManagerPage.tsx",
    "content": "import { useEffect, useMemo, useRef, useState } from \"react\";\nimport { useSessionSearch } from \"@/hooks/useSessionSearch\";\nimport { useTranslation } from \"react-i18next\";\nimport { toast } from \"sonner\";\nimport {\n  Copy,\n  RefreshCw,\n  Search,\n  Play,\n  Trash2,\n  MessageSquare,\n  Clock,\n  FolderOpen,\n  X,\n} from \"lucide-react\";\nimport {\n  useDeleteSessionMutation,\n  useSessionMessagesQuery,\n  useSessionsQuery,\n} from \"@/lib/query\";\nimport { sessionsApi } from \"@/lib/api\";\nimport type { SessionMeta } from \"@/types\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Badge } from \"@/components/ui/badge\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n} from \"@/components/ui/select\";\nimport { Card, CardHeader, CardTitle, CardContent } from \"@/components/ui/card\";\nimport { ScrollArea } from \"@/components/ui/scroll-area\";\nimport { ConfirmDialog } from \"@/components/ConfirmDialog\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport { extractErrorMessage } from \"@/utils/errorUtils\";\nimport { isMac } from \"@/lib/platform\";\nimport { ProviderIcon } from \"@/components/ProviderIcon\";\nimport { SessionItem } from \"./SessionItem\";\nimport { SessionMessageItem } from \"./SessionMessageItem\";\nimport { SessionTocDialog, SessionTocSidebar } from \"./SessionToc\";\nimport {\n  formatSessionTitle,\n  formatTimestamp,\n  getBaseName,\n  getProviderIconName,\n  getProviderLabel,\n  getSessionKey,\n} from \"./utils\";\n\ntype ProviderFilter =\n  | \"all\"\n  | \"codex\"\n  | \"claude\"\n  | \"opencode\"\n  | \"openclaw\"\n  | \"gemini\";\n\nexport function SessionManagerPage({ appId }: { appId: string }) {\n  const { t } = useTranslation();\n  const { data, isLoading, refetch } = useSessionsQuery();\n  const sessions = data ?? [];\n  const detailRef = useRef<HTMLDivElement | null>(null);\n  const messagesEndRef = useRef<HTMLDivElement | null>(null);\n  const messageRefs = useRef<Map<number, HTMLDivElement>>(new Map());\n  const [activeMessageIndex, setActiveMessageIndex] = useState<number | null>(\n    null,\n  );\n  const [tocDialogOpen, setTocDialogOpen] = useState(false);\n  const [isSearchOpen, setIsSearchOpen] = useState(false);\n  const [deleteTarget, setDeleteTarget] = useState<SessionMeta | null>(null);\n  const searchInputRef = useRef<HTMLInputElement | null>(null);\n\n  const [search, setSearch] = useState(\"\");\n  const [providerFilter, setProviderFilter] = useState<ProviderFilter>(\n    appId as ProviderFilter,\n  );\n  const [selectedKey, setSelectedKey] = useState<string | null>(null);\n\n  // 使用 FlexSearch 全文搜索\n  const { search: searchSessions } = useSessionSearch({\n    sessions,\n    providerFilter,\n  });\n\n  const filteredSessions = useMemo(() => {\n    return searchSessions(search);\n  }, [searchSessions, search]);\n\n  useEffect(() => {\n    if (filteredSessions.length === 0) {\n      setSelectedKey(null);\n      return;\n    }\n    const exists = selectedKey\n      ? filteredSessions.some(\n          (session) => getSessionKey(session) === selectedKey,\n        )\n      : false;\n    if (!exists) {\n      setSelectedKey(getSessionKey(filteredSessions[0]));\n    }\n  }, [filteredSessions, selectedKey]);\n\n  const selectedSession = useMemo(() => {\n    if (!selectedKey) return null;\n    return (\n      filteredSessions.find(\n        (session) => getSessionKey(session) === selectedKey,\n      ) || null\n    );\n  }, [filteredSessions, selectedKey]);\n\n  const { data: messages = [], isLoading: isLoadingMessages } =\n    useSessionMessagesQuery(\n      selectedSession?.providerId,\n      selectedSession?.sourcePath,\n    );\n  const deleteSessionMutation = useDeleteSessionMutation();\n\n  // 提取用户消息用于目录\n  const userMessagesToc = useMemo(() => {\n    return messages\n      .map((msg, index) => ({ msg, index }))\n      .filter(({ msg }) => msg.role.toLowerCase() === \"user\")\n      .map(({ msg, index }) => ({\n        index,\n        preview:\n          msg.content.slice(0, 50) + (msg.content.length > 50 ? \"...\" : \"\"),\n        ts: msg.ts,\n      }));\n  }, [messages]);\n\n  const scrollToMessage = (index: number) => {\n    const el = messageRefs.current.get(index);\n    if (el) {\n      el.scrollIntoView({ behavior: \"smooth\", block: \"center\" });\n      setActiveMessageIndex(index);\n      setTocDialogOpen(false); // 关闭弹窗\n      // 清除高亮状态\n      setTimeout(() => setActiveMessageIndex(null), 2000);\n    }\n  };\n\n  // 清理定时器\n  useEffect(() => {\n    return () => {\n      // 这里的 setTimeout 其实无法直接清理，因为它在函数闭包里。\n      // 如果要严格清理，需要用 useRef 存 timer id。\n      // 但对于 2秒的高亮清除，通常不清理也没大问题。\n      // 为了代码规范，我们在组件卸载时将 activeMessageIndex 重置 (虽然 React 会处理)\n    };\n  }, []);\n\n  const handleCopy = async (text: string, successMessage: string) => {\n    try {\n      await navigator.clipboard.writeText(text);\n      toast.success(successMessage);\n    } catch (error) {\n      toast.error(\n        extractErrorMessage(error) ||\n          t(\"common.error\", { defaultValue: \"Copy failed\" }),\n      );\n    }\n  };\n\n  const handleResume = async () => {\n    if (!selectedSession?.resumeCommand) return;\n\n    if (!isMac()) {\n      await handleCopy(\n        selectedSession.resumeCommand,\n        t(\"sessionManager.resumeCommandCopied\"),\n      );\n      return;\n    }\n\n    try {\n      await sessionsApi.launchTerminal({\n        command: selectedSession.resumeCommand,\n        cwd: selectedSession.projectDir ?? undefined,\n      });\n      toast.success(t(\"sessionManager.terminalLaunched\"));\n    } catch (error) {\n      const fallback = selectedSession.resumeCommand;\n      await handleCopy(fallback, t(\"sessionManager.resumeFallbackCopied\"));\n      toast.error(extractErrorMessage(error) || t(\"sessionManager.openFailed\"));\n    }\n  };\n\n  const handleDeleteConfirm = async () => {\n    if (!deleteTarget?.sourcePath || deleteSessionMutation.isPending) {\n      return;\n    }\n\n    setDeleteTarget(null);\n    await deleteSessionMutation.mutateAsync({\n      providerId: deleteTarget.providerId,\n      sessionId: deleteTarget.sessionId,\n      sourcePath: deleteTarget.sourcePath,\n    });\n  };\n\n  return (\n    <TooltipProvider>\n      <div className=\"mx-auto px-4 sm:px-6 flex flex-col flex-1 min-h-0\">\n        <div className=\"flex-1 overflow-hidden flex flex-col gap-4\">\n          {/* 主内容区域 - 左右分栏 */}\n          <div className=\"flex-1 overflow-hidden grid gap-4 md:grid-cols-[320px_1fr]\">\n            {/* 左侧会话列表 */}\n            <Card className=\"flex flex-col overflow-hidden\">\n              <CardHeader className=\"py-2 px-3 border-b\">\n                {isSearchOpen ? (\n                  <div className=\"relative flex-1\">\n                    <Search className=\"absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground\" />\n                    <Input\n                      ref={searchInputRef}\n                      value={search}\n                      onChange={(event) => setSearch(event.target.value)}\n                      placeholder={t(\"sessionManager.searchPlaceholder\")}\n                      className=\"h-8 pl-8 pr-8 text-sm\"\n                      autoFocus\n                      onKeyDown={(e) => {\n                        if (e.key === \"Escape\") {\n                          setIsSearchOpen(false);\n                          setSearch(\"\");\n                        }\n                      }}\n                      onBlur={() => {\n                        if (search.trim() === \"\") {\n                          setIsSearchOpen(false);\n                        }\n                      }}\n                    />\n                    <Button\n                      variant=\"ghost\"\n                      size=\"icon\"\n                      className=\"absolute right-1 top-1/2 -translate-y-1/2 size-6\"\n                      onClick={() => {\n                        setIsSearchOpen(false);\n                        setSearch(\"\");\n                      }}\n                    >\n                      <X className=\"size-3\" />\n                    </Button>\n                  </div>\n                ) : (\n                  <div className=\"flex items-center justify-between gap-2\">\n                    <div className=\"flex items-center gap-2\">\n                      <CardTitle className=\"text-sm font-medium\">\n                        {t(\"sessionManager.sessionList\")}\n                      </CardTitle>\n                      <Badge variant=\"secondary\" className=\"text-xs\">\n                        {filteredSessions.length}\n                      </Badge>\n                    </div>\n                    <div className=\"flex items-center gap-1\">\n                      <Tooltip>\n                        <TooltipTrigger asChild>\n                          <Button\n                            variant=\"ghost\"\n                            size=\"icon\"\n                            className=\"size-7\"\n                            onClick={() => {\n                              setIsSearchOpen(true);\n                              setTimeout(\n                                () => searchInputRef.current?.focus(),\n                                0,\n                              );\n                            }}\n                          >\n                            <Search className=\"size-3.5\" />\n                          </Button>\n                        </TooltipTrigger>\n                        <TooltipContent>\n                          {t(\"sessionManager.searchSessions\")}\n                        </TooltipContent>\n                      </Tooltip>\n\n                      <Select\n                        value={providerFilter}\n                        onValueChange={(value) =>\n                          setProviderFilter(value as ProviderFilter)\n                        }\n                      >\n                        <Tooltip>\n                          <TooltipTrigger asChild>\n                            <SelectTrigger className=\"size-7 p-0 justify-center border-0 bg-transparent hover:bg-muted\">\n                              <ProviderIcon\n                                icon={\n                                  providerFilter === \"all\"\n                                    ? \"apps\"\n                                    : getProviderIconName(providerFilter)\n                                }\n                                name={providerFilter}\n                                size={14}\n                              />\n                            </SelectTrigger>\n                          </TooltipTrigger>\n                          <TooltipContent>\n                            {providerFilter === \"all\"\n                              ? t(\"sessionManager.providerFilterAll\")\n                              : providerFilter}\n                          </TooltipContent>\n                        </Tooltip>\n                        <SelectContent>\n                          <SelectItem value=\"all\">\n                            <div className=\"flex items-center gap-2\">\n                              <ProviderIcon icon=\"apps\" name=\"all\" size={14} />\n                              <span>\n                                {t(\"sessionManager.providerFilterAll\")}\n                              </span>\n                            </div>\n                          </SelectItem>\n                          <SelectItem value=\"codex\">\n                            <div className=\"flex items-center gap-2\">\n                              <ProviderIcon\n                                icon=\"openai\"\n                                name=\"codex\"\n                                size={14}\n                              />\n                              <span>Codex</span>\n                            </div>\n                          </SelectItem>\n                          <SelectItem value=\"claude\">\n                            <div className=\"flex items-center gap-2\">\n                              <ProviderIcon\n                                icon=\"claude\"\n                                name=\"claude\"\n                                size={14}\n                              />\n                              <span>Claude Code</span>\n                            </div>\n                          </SelectItem>\n                          <SelectItem value=\"opencode\">\n                            <div className=\"flex items-center gap-2\">\n                              <ProviderIcon\n                                icon=\"opencode\"\n                                name=\"opencode\"\n                                size={14}\n                              />\n                              <span>OpenCode</span>\n                            </div>\n                          </SelectItem>\n                          <SelectItem value=\"openclaw\">\n                            <div className=\"flex items-center gap-2\">\n                              <ProviderIcon\n                                icon=\"openclaw\"\n                                name=\"openclaw\"\n                                size={14}\n                              />\n                              <span>OpenClaw</span>\n                            </div>\n                          </SelectItem>\n                          <SelectItem value=\"gemini\">\n                            <div className=\"flex items-center gap-2\">\n                              <ProviderIcon\n                                icon=\"gemini\"\n                                name=\"gemini\"\n                                size={14}\n                              />\n                              <span>Gemini CLI</span>\n                            </div>\n                          </SelectItem>\n                        </SelectContent>\n                      </Select>\n\n                      <Tooltip>\n                        <TooltipTrigger asChild>\n                          <Button\n                            variant=\"ghost\"\n                            size=\"icon\"\n                            className=\"size-7\"\n                            onClick={() => void refetch()}\n                          >\n                            <RefreshCw className=\"size-3.5\" />\n                          </Button>\n                        </TooltipTrigger>\n                        <TooltipContent>{t(\"common.refresh\")}</TooltipContent>\n                      </Tooltip>\n                    </div>\n                  </div>\n                )}\n              </CardHeader>\n              <CardContent className=\"flex-1 overflow-hidden p-0\">\n                <ScrollArea className=\"h-full\">\n                  <div className=\"p-2\">\n                    {isLoading ? (\n                      <div className=\"flex items-center justify-center py-12\">\n                        <RefreshCw className=\"size-5 animate-spin text-muted-foreground\" />\n                      </div>\n                    ) : filteredSessions.length === 0 ? (\n                      <div className=\"flex flex-col items-center justify-center py-12 text-center\">\n                        <MessageSquare className=\"size-8 text-muted-foreground/50 mb-2\" />\n                        <p className=\"text-sm text-muted-foreground\">\n                          {t(\"sessionManager.noSessions\")}\n                        </p>\n                      </div>\n                    ) : (\n                      <div className=\"space-y-1\">\n                        {filteredSessions.map((session) => {\n                          const isSelected =\n                            selectedKey !== null &&\n                            getSessionKey(session) === selectedKey;\n\n                          return (\n                            <SessionItem\n                              key={getSessionKey(session)}\n                              session={session}\n                              isSelected={isSelected}\n                              onSelect={setSelectedKey}\n                            />\n                          );\n                        })}\n                      </div>\n                    )}\n                  </div>\n                </ScrollArea>\n              </CardContent>\n            </Card>\n\n            {/* 右侧会话详情 */}\n            <Card\n              className=\"flex flex-col overflow-hidden min-h-0\"\n              ref={detailRef}\n            >\n              {!selectedSession ? (\n                <div className=\"flex-1 flex flex-col items-center justify-center text-muted-foreground p-8\">\n                  <MessageSquare className=\"size-12 mb-3 opacity-30\" />\n                  <p className=\"text-sm\">{t(\"sessionManager.selectSession\")}</p>\n                </div>\n              ) : (\n                <>\n                  {/* 详情头部 */}\n                  <CardHeader className=\"py-3 px-4 border-b shrink-0\">\n                    <div className=\"flex items-start justify-between gap-4\">\n                      {/* 左侧：会话信息 */}\n                      <div className=\"min-w-0 flex-1\">\n                        <div className=\"flex items-center gap-2 mb-1\">\n                          <Tooltip>\n                            <TooltipTrigger asChild>\n                              <span className=\"shrink-0\">\n                                <ProviderIcon\n                                  icon={getProviderIconName(\n                                    selectedSession.providerId,\n                                  )}\n                                  name={selectedSession.providerId}\n                                  size={20}\n                                />\n                              </span>\n                            </TooltipTrigger>\n                            <TooltipContent>\n                              {getProviderLabel(selectedSession.providerId, t)}\n                            </TooltipContent>\n                          </Tooltip>\n                          <h2 className=\"text-base font-semibold truncate\">\n                            {formatSessionTitle(selectedSession)}\n                          </h2>\n                        </div>\n\n                        {/* 元信息 */}\n                        <div className=\"flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground\">\n                          <div className=\"flex items-center gap-1\">\n                            <Clock className=\"size-3\" />\n                            <span>\n                              {formatTimestamp(\n                                selectedSession.lastActiveAt ??\n                                  selectedSession.createdAt,\n                              )}\n                            </span>\n                          </div>\n                          {selectedSession.projectDir && (\n                            <Tooltip>\n                              <TooltipTrigger asChild>\n                                <button\n                                  type=\"button\"\n                                  onClick={() =>\n                                    void handleCopy(\n                                      selectedSession.projectDir!,\n                                      t(\"sessionManager.projectDirCopied\"),\n                                    )\n                                  }\n                                  className=\"flex items-center gap-1 hover:text-foreground transition-colors\"\n                                >\n                                  <FolderOpen className=\"size-3\" />\n                                  <span className=\"truncate max-w-[200px]\">\n                                    {getBaseName(selectedSession.projectDir)}\n                                  </span>\n                                </button>\n                              </TooltipTrigger>\n                              <TooltipContent\n                                side=\"bottom\"\n                                className=\"max-w-xs\"\n                              >\n                                <p className=\"font-mono text-xs break-all\">\n                                  {selectedSession.projectDir}\n                                </p>\n                                <p className=\"text-muted-foreground mt-1\">\n                                  {t(\"sessionManager.clickToCopyPath\")}\n                                </p>\n                              </TooltipContent>\n                            </Tooltip>\n                          )}\n                        </div>\n                      </div>\n\n                      {/* 右侧：操作按钮组 */}\n                      <div className=\"flex items-center gap-2 shrink-0\">\n                        {isMac() && (\n                          <Tooltip>\n                            <TooltipTrigger asChild>\n                              <Button\n                                size=\"sm\"\n                                className=\"gap-1.5\"\n                                onClick={() => void handleResume()}\n                                disabled={!selectedSession.resumeCommand}\n                              >\n                                <Play className=\"size-3.5\" />\n                                <span className=\"hidden sm:inline\">\n                                  {t(\"sessionManager.resume\", {\n                                    defaultValue: \"恢复会话\",\n                                  })}\n                                </span>\n                              </Button>\n                            </TooltipTrigger>\n                            <TooltipContent>\n                              {selectedSession.resumeCommand\n                                ? t(\"sessionManager.resumeTooltip\", {\n                                    defaultValue: \"在终端中恢复此会话\",\n                                  })\n                                : t(\"sessionManager.noResumeCommand\", {\n                                    defaultValue: \"此会话无法恢复\",\n                                  })}\n                            </TooltipContent>\n                          </Tooltip>\n                        )}\n                        <Tooltip>\n                          <TooltipTrigger asChild>\n                            <Button\n                              size=\"sm\"\n                              variant=\"destructive\"\n                              className=\"gap-1.5\"\n                              onClick={() => setDeleteTarget(selectedSession)}\n                              disabled={\n                                !selectedSession.sourcePath ||\n                                deleteSessionMutation.isPending\n                              }\n                            >\n                              <Trash2 className=\"size-3.5\" />\n                              <span className=\"hidden sm:inline\">\n                                {deleteSessionMutation.isPending\n                                  ? t(\"sessionManager.deleting\", {\n                                      defaultValue: \"删除中...\",\n                                    })\n                                  : t(\"sessionManager.delete\", {\n                                      defaultValue: \"删除会话\",\n                                    })}\n                              </span>\n                            </Button>\n                          </TooltipTrigger>\n                          <TooltipContent>\n                            {t(\"sessionManager.deleteTooltip\", {\n                              defaultValue: \"永久删除此本地会话记录\",\n                            })}\n                          </TooltipContent>\n                        </Tooltip>\n                      </div>\n                    </div>\n\n                    {/* 恢复命令预览 */}\n                    {selectedSession.resumeCommand && (\n                      <div className=\"mt-3 flex items-center gap-2\">\n                        <div className=\"flex-1 rounded-md bg-muted/60 px-3 py-1.5 font-mono text-xs text-muted-foreground truncate\">\n                          {selectedSession.resumeCommand}\n                        </div>\n                        <Tooltip>\n                          <TooltipTrigger asChild>\n                            <Button\n                              variant=\"ghost\"\n                              size=\"icon\"\n                              className=\"size-7 shrink-0\"\n                              onClick={() =>\n                                void handleCopy(\n                                  selectedSession.resumeCommand!,\n                                  t(\"sessionManager.resumeCommandCopied\"),\n                                )\n                              }\n                            >\n                              <Copy className=\"size-3.5\" />\n                            </Button>\n                          </TooltipTrigger>\n                          <TooltipContent>\n                            {t(\"sessionManager.copyCommand\", {\n                              defaultValue: \"复制命令\",\n                            })}\n                          </TooltipContent>\n                        </Tooltip>\n                      </div>\n                    )}\n                  </CardHeader>\n\n                  {/* 消息列表区域 */}\n                  <CardContent className=\"flex-1 overflow-hidden p-0\">\n                    <div className=\"flex h-full min-w-0\">\n                      {/* 消息列表 */}\n                      <ScrollArea className=\"flex-1 min-w-0\">\n                        <div className=\"p-4 min-w-0\">\n                          <div className=\"flex items-center gap-2 mb-3\">\n                            <MessageSquare className=\"size-4 text-muted-foreground\" />\n                            <span className=\"text-sm font-medium\">\n                              {t(\"sessionManager.conversationHistory\", {\n                                defaultValue: \"对话记录\",\n                              })}\n                            </span>\n                            <Badge variant=\"secondary\" className=\"text-xs\">\n                              {messages.length}\n                            </Badge>\n                          </div>\n\n                          {isLoadingMessages ? (\n                            <div className=\"flex items-center justify-center py-12\">\n                              <RefreshCw className=\"size-5 animate-spin text-muted-foreground\" />\n                            </div>\n                          ) : messages.length === 0 ? (\n                            <div className=\"flex flex-col items-center justify-center py-12 text-center\">\n                              <MessageSquare className=\"size-8 text-muted-foreground/50 mb-2\" />\n                              <p className=\"text-sm text-muted-foreground\">\n                                {t(\"sessionManager.emptySession\")}\n                              </p>\n                            </div>\n                          ) : (\n                            <div className=\"space-y-3\">\n                              {messages.map((message, index) => (\n                                <SessionMessageItem\n                                  key={`${message.role}-${index}`}\n                                  message={message}\n                                  index={index}\n                                  isActive={activeMessageIndex === index}\n                                  setRef={(el) => {\n                                    if (el) messageRefs.current.set(index, el);\n                                  }}\n                                  onCopy={(content) =>\n                                    handleCopy(\n                                      content,\n                                      t(\"sessionManager.messageCopied\", {\n                                        defaultValue: \"已复制消息内容\",\n                                      }),\n                                    )\n                                  }\n                                />\n                              ))}\n                              <div ref={messagesEndRef} />\n                            </div>\n                          )}\n                        </div>\n                      </ScrollArea>\n\n                      {/* 右侧目录 - 类似少数派 (大屏幕) */}\n                      <SessionTocSidebar\n                        items={userMessagesToc}\n                        onItemClick={scrollToMessage}\n                      />\n                    </div>\n\n                    {/* 浮动目录按钮 (小屏幕) */}\n                    <SessionTocDialog\n                      items={userMessagesToc}\n                      onItemClick={scrollToMessage}\n                      open={tocDialogOpen}\n                      onOpenChange={setTocDialogOpen}\n                    />\n                  </CardContent>\n                </>\n              )}\n            </Card>\n          </div>\n        </div>\n      </div>\n      <ConfirmDialog\n        isOpen={Boolean(deleteTarget)}\n        title={t(\"sessionManager.deleteConfirmTitle\", {\n          defaultValue: \"删除会话\",\n        })}\n        message={\n          deleteTarget\n            ? t(\"sessionManager.deleteConfirmMessage\", {\n                defaultValue:\n                  \"将永久删除本地会话“{{title}}”\\nSession ID: {{sessionId}}\\n\\n此操作不可恢复。\",\n                title: formatSessionTitle(deleteTarget),\n                sessionId: deleteTarget.sessionId,\n              })\n            : \"\"\n        }\n        confirmText={t(\"sessionManager.deleteConfirmAction\", {\n          defaultValue: \"删除会话\",\n        })}\n        cancelText={t(\"common.cancel\", { defaultValue: \"取消\" })}\n        variant=\"destructive\"\n        onConfirm={() => void handleDeleteConfirm()}\n        onCancel={() => {\n          if (!deleteSessionMutation.isPending) {\n            setDeleteTarget(null);\n          }\n        }}\n      />\n    </TooltipProvider>\n  );\n}\n"
  },
  {
    "path": "src/components/sessions/SessionMessageItem.tsx",
    "content": "import { Copy } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport { cn } from \"@/lib/utils\";\nimport type { SessionMessage } from \"@/types\";\nimport { formatTimestamp, getRoleLabel, getRoleTone } from \"./utils\";\n\ninterface SessionMessageItemProps {\n  message: SessionMessage;\n  index: number;\n  isActive: boolean;\n  setRef: (el: HTMLDivElement | null) => void;\n  onCopy: (content: string) => void;\n}\n\nexport function SessionMessageItem({\n  message,\n  isActive,\n  setRef,\n  onCopy,\n}: SessionMessageItemProps) {\n  const { t } = useTranslation();\n\n  return (\n    <div\n      ref={setRef}\n      className={cn(\n        \"rounded-lg border px-3 py-2.5 relative group transition-all min-w-0\",\n        message.role.toLowerCase() === \"user\"\n          ? \"bg-primary/5 border-primary/20 ml-8\"\n          : message.role.toLowerCase() === \"assistant\"\n            ? \"bg-blue-500/5 border-blue-500/20 mr-8\"\n            : \"bg-muted/40 border-border/60\",\n        isActive && \"ring-2 ring-primary ring-offset-2\",\n      )}\n    >\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <Button\n            variant=\"ghost\"\n            size=\"icon\"\n            className=\"absolute top-2 right-2 size-6 opacity-0 group-hover:opacity-100 transition-opacity\"\n            onClick={() => onCopy(message.content)}\n          >\n            <Copy className=\"size-3\" />\n          </Button>\n        </TooltipTrigger>\n        <TooltipContent>\n          {t(\"sessionManager.copyMessage\", {\n            defaultValue: \"复制内容\",\n          })}\n        </TooltipContent>\n      </Tooltip>\n      <div className=\"flex items-center justify-between text-xs mb-1.5 pr-6\">\n        <span className={cn(\"font-semibold\", getRoleTone(message.role))}>\n          {getRoleLabel(message.role, t)}\n        </span>\n        {message.ts && (\n          <span className=\"text-muted-foreground\">\n            {formatTimestamp(message.ts)}\n          </span>\n        )}\n      </div>\n      <div className=\"whitespace-pre-wrap break-words [overflow-wrap:anywhere] text-sm leading-relaxed min-w-0\">\n        {message.content}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/sessions/SessionToc.tsx",
    "content": "import { List, X } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\nimport { cn } from \"@/lib/utils\";\nimport { Button } from \"@/components/ui/button\";\nimport { ScrollArea } from \"@/components/ui/scroll-area\";\nimport {\n  Dialog,\n  DialogClose,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from \"@/components/ui/dialog\";\n\ninterface TocItem {\n  index: number;\n  preview: string;\n  ts?: number;\n}\n\ninterface SessionTocSidebarProps {\n  items: TocItem[];\n  onItemClick: (index: number) => void;\n}\n\nexport function SessionTocSidebar({\n  items,\n  onItemClick,\n}: SessionTocSidebarProps) {\n  const { t } = useTranslation();\n  if (items.length <= 2) return null;\n\n  return (\n    <div className=\"w-64 border-l shrink-0 hidden xl:block\">\n      <div className=\"p-3 border-b\">\n        <div className=\"flex items-center gap-1.5 text-xs font-medium text-muted-foreground\">\n          <List className=\"size-3.5\" />\n          <span>{t(\"sessionManager.tocTitle\")}</span>\n        </div>\n      </div>\n      <ScrollArea className=\"h-[calc(100%-40px)]\">\n        <div className=\"p-2 space-y-0.5\">\n          {items.map((item, tocIndex) => (\n            <button\n              key={item.index}\n              type=\"button\"\n              onClick={() => onItemClick(item.index)}\n              className={cn(\n                \"w-full text-left px-2 py-1.5 rounded text-xs transition-colors\",\n                \"hover:bg-muted/80 text-muted-foreground hover:text-foreground\",\n                \"flex items-start gap-2\",\n              )}\n            >\n              <span className=\"shrink-0 w-4 h-4 rounded-full bg-primary/10 text-primary text-[10px] flex items-center justify-center font-medium\">\n                {tocIndex + 1}\n              </span>\n              <span className=\"line-clamp-2 leading-snug\">{item.preview}</span>\n            </button>\n          ))}\n        </div>\n      </ScrollArea>\n    </div>\n  );\n}\n\ninterface SessionTocDialogProps {\n  items: TocItem[];\n  onItemClick: (index: number) => void;\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n}\n\nexport function SessionTocDialog({\n  items,\n  onItemClick,\n  open,\n  onOpenChange,\n}: SessionTocDialogProps) {\n  const { t } = useTranslation();\n  if (items.length <= 2) return null;\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogTrigger asChild>\n        <Button\n          size=\"icon\"\n          className=\"fixed bottom-20 right-4 xl:hidden size-10 rounded-full shadow-lg z-30\"\n        >\n          <List className=\"size-4\" />\n        </Button>\n      </DialogTrigger>\n      <DialogContent\n        className=\"max-w-md max-h-[70vh] flex flex-col p-0 gap-0\"\n        zIndex=\"alert\"\n        onInteractOutside={() => onOpenChange(false)}\n        onEscapeKeyDown={() => onOpenChange(false)}\n      >\n        <DialogHeader className=\"px-4 py-3 relative border-b\">\n          <DialogTitle className=\"flex items-center gap-2 text-base font-semibold\">\n            <List className=\"size-4 text-primary\" />\n            {t(\"sessionManager.tocTitle\")}\n          </DialogTitle>\n          <DialogClose\n            className=\"absolute right-3 top-1/2 -translate-y-1/2 rounded-full p-1.5 hover:bg-muted transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2\"\n            aria-label={t(\"common.close\")}\n          >\n            <X className=\"size-4 text-muted-foreground\" />\n          </DialogClose>\n        </DialogHeader>\n        <div className=\"overflow-y-auto max-h-[calc(70vh-80px)]\">\n          <div className=\"p-3 pb-4 space-y-1\">\n            {items.map((item, tocIndex) => (\n              <button\n                key={item.index}\n                type=\"button\"\n                onClick={() => onItemClick(item.index)}\n                className={cn(\n                  \"w-full text-left px-3 py-2.5 rounded-lg text-sm transition-all\",\n                  \"hover:bg-primary/10 text-foreground\",\n                  \"flex items-start gap-3\",\n                  \"focus:outline-none focus:ring-2 focus:ring-primary focus:ring-inset\",\n                )}\n              >\n                <span className=\"shrink-0 w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs flex items-center justify-center font-semibold\">\n                  {tocIndex + 1}\n                </span>\n                <span className=\"line-clamp-2 leading-relaxed pt-0.5\">\n                  {item.preview}\n                </span>\n              </button>\n            ))}\n          </div>\n        </div>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "src/components/sessions/utils.ts",
    "content": "import { SessionMeta } from \"@/types\";\n\nexport const getSessionKey = (session: SessionMeta) =>\n  `${session.providerId}:${session.sessionId}:${session.sourcePath ?? \"\"}`;\n\nexport const getBaseName = (value?: string | null) => {\n  if (!value) return \"\";\n  const trimmed = value.trim();\n  if (!trimmed) return \"\";\n  const normalized = trimmed.replace(/[\\\\/]+$/, \"\");\n  const parts = normalized.split(/[\\\\/]/).filter(Boolean);\n  return parts[parts.length - 1] || trimmed;\n};\n\nexport const formatTimestamp = (value?: number) => {\n  if (!value) return \"\";\n  return new Date(value).toLocaleString();\n};\n\nexport const formatRelativeTime = (\n  value: number | undefined,\n  t: (key: string, options?: Record<string, unknown>) => string,\n) => {\n  if (!value) return \"\";\n  const now = Date.now();\n  const diff = now - value;\n  const minutes = Math.floor(diff / 60000);\n  const hours = Math.floor(diff / 3600000);\n  const days = Math.floor(diff / 86400000);\n\n  if (minutes < 1) return t(\"sessionManager.justNow\");\n  if (minutes < 60) return t(\"sessionManager.minutesAgo\", { count: minutes });\n  if (hours < 24) return t(\"sessionManager.hoursAgo\", { count: hours });\n  if (days < 7) return t(\"sessionManager.daysAgo\", { count: days });\n  return new Date(value).toLocaleDateString();\n};\n\nexport const getProviderLabel = (\n  providerId: string,\n  t: (key: string) => string,\n) => {\n  const key = `apps.${providerId}`;\n  const translated = t(key);\n  return translated === key ? providerId : translated;\n};\n\n// 根据 providerId 获取对应的图标名称\nexport const getProviderIconName = (providerId: string) => {\n  if (providerId === \"codex\") return \"openai\";\n  if (providerId === \"claude\") return \"claude\";\n  if (providerId === \"opencode\") return \"opencode\";\n  if (providerId === \"openclaw\") return \"openclaw\";\n  return providerId;\n};\n\nexport const getRoleTone = (role: string) => {\n  const normalized = role.toLowerCase();\n  if (normalized === \"assistant\") return \"text-blue-500\";\n  if (normalized === \"user\") return \"text-emerald-500\";\n  if (normalized === \"system\") return \"text-amber-500\";\n  if (normalized === \"tool\") return \"text-purple-500\";\n  return \"text-muted-foreground\";\n};\n\nexport const getRoleLabel = (role: string, t: (key: string) => string) => {\n  const normalized = role.toLowerCase();\n  if (normalized === \"assistant\") return \"AI\";\n  if (normalized === \"user\") return t(\"sessionManager.roleUser\");\n  if (normalized === \"system\") return t(\"sessionManager.roleSystem\");\n  if (normalized === \"tool\") return t(\"sessionManager.roleTool\");\n  return role;\n};\n\nexport const formatSessionTitle = (session: SessionMeta) => {\n  return (\n    session.title ||\n    getBaseName(session.projectDir) ||\n    session.sessionId.slice(0, 8)\n  );\n};\n"
  },
  {
    "path": "src/components/settings/AboutSection.tsx",
    "content": "import { useCallback, useEffect, useState } from \"react\";\nimport {\n  Download,\n  Copy,\n  ExternalLink,\n  Info,\n  Loader2,\n  RefreshCw,\n  Terminal,\n  CheckCircle2,\n  AlertCircle,\n} from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { useTranslation } from \"react-i18next\";\nimport { toast } from \"sonner\";\nimport { getVersion } from \"@tauri-apps/api/app\";\nimport { settingsApi } from \"@/lib/api\";\nimport { useUpdate } from \"@/contexts/UpdateContext\";\nimport { relaunchApp } from \"@/lib/updater\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { motion } from \"framer-motion\";\nimport appIcon from \"@/assets/icons/app-icon.png\";\nimport { isWindows } from \"@/lib/platform\";\n\ninterface AboutSectionProps {\n  isPortable: boolean;\n}\n\ninterface ToolVersion {\n  name: string;\n  version: string | null;\n  latest_version: string | null;\n  error: string | null;\n  env_type: \"windows\" | \"wsl\" | \"macos\" | \"linux\" | \"unknown\";\n  wsl_distro: string | null;\n}\n\nconst TOOL_NAMES = [\"claude\", \"codex\", \"gemini\", \"opencode\"] as const;\ntype ToolName = (typeof TOOL_NAMES)[number];\n\ntype WslShellPreference = {\n  wslShell?: string | null;\n  wslShellFlag?: string | null;\n};\n\nconst WSL_SHELL_OPTIONS = [\"sh\", \"bash\", \"zsh\", \"fish\", \"dash\"] as const;\n// UI-friendly order: login shell first.\nconst WSL_SHELL_FLAG_OPTIONS = [\"-lic\", \"-lc\", \"-c\"] as const;\n\nconst ENV_BADGE_CONFIG: Record<\n  string,\n  { labelKey: string; className: string }\n> = {\n  wsl: {\n    labelKey: \"settings.envBadge.wsl\",\n    className:\n      \"bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/20\",\n  },\n  windows: {\n    labelKey: \"settings.envBadge.windows\",\n    className:\n      \"bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20\",\n  },\n  macos: {\n    labelKey: \"settings.envBadge.macos\",\n    className:\n      \"bg-gray-500/10 text-gray-600 dark:text-gray-400 border-gray-500/20\",\n  },\n  linux: {\n    labelKey: \"settings.envBadge.linux\",\n    className:\n      \"bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20\",\n  },\n};\n\nconst ONE_CLICK_INSTALL_COMMANDS = `# Claude Code (Native install - recommended)\ncurl -fsSL https://claude.ai/install.sh | bash\n# Codex\nnpm i -g @openai/codex@latest\n# Gemini CLI\nnpm i -g @google/gemini-cli@latest\n# OpenCode\ncurl -fsSL https://opencode.ai/install | bash`;\n\nexport function AboutSection({ isPortable }: AboutSectionProps) {\n  // ... (use hooks as before) ...\n  const { t } = useTranslation();\n  const [version, setVersion] = useState<string | null>(null);\n  const [isLoadingVersion, setIsLoadingVersion] = useState(true);\n  const [isDownloading, setIsDownloading] = useState(false);\n  const [toolVersions, setToolVersions] = useState<ToolVersion[]>([]);\n  const [isLoadingTools, setIsLoadingTools] = useState(true);\n\n  const {\n    hasUpdate,\n    updateInfo,\n    updateHandle,\n    checkUpdate,\n    resetDismiss,\n    isChecking,\n  } = useUpdate();\n\n  const [wslShellByTool, setWslShellByTool] = useState<\n    Record<string, WslShellPreference>\n  >({});\n  const [loadingTools, setLoadingTools] = useState<Record<string, boolean>>({});\n\n  const refreshToolVersions = useCallback(\n    async (\n      toolNames: ToolName[],\n      wslOverrides?: Record<string, WslShellPreference>,\n    ) => {\n      if (toolNames.length === 0) return;\n\n      // 单工具刷新使用统一后端入口（get_tool_versions）并带工具过滤。\n      setLoadingTools((prev) => {\n        const next = { ...prev };\n        for (const name of toolNames) next[name] = true;\n        return next;\n      });\n\n      try {\n        const updated = await settingsApi.getToolVersions(\n          toolNames,\n          wslOverrides,\n        );\n\n        setToolVersions((prev) => {\n          if (prev.length === 0) return updated;\n          const byName = new Map(updated.map((t) => [t.name, t]));\n          const merged = prev.map((t) => byName.get(t.name) ?? t);\n          const existing = new Set(prev.map((t) => t.name));\n          for (const u of updated) {\n            if (!existing.has(u.name)) merged.push(u);\n          }\n          return merged;\n        });\n      } catch (error) {\n        console.error(\"[AboutSection] Failed to refresh tools\", error);\n      } finally {\n        setLoadingTools((prev) => {\n          const next = { ...prev };\n          for (const name of toolNames) next[name] = false;\n          return next;\n        });\n      }\n    },\n    [],\n  );\n\n  const loadAllToolVersions = useCallback(async () => {\n    setIsLoadingTools(true);\n    try {\n      // Respect current UI overrides (shell / flag) when doing a full refresh.\n      const versions = await settingsApi.getToolVersions(\n        [...TOOL_NAMES],\n        wslShellByTool,\n      );\n      setToolVersions(versions);\n    } catch (error) {\n      console.error(\"[AboutSection] Failed to load tool versions\", error);\n    } finally {\n      setIsLoadingTools(false);\n    }\n  }, [wslShellByTool]);\n\n  const handleToolShellChange = async (toolName: ToolName, value: string) => {\n    const wslShell = value === \"auto\" ? null : value;\n    const nextPref: WslShellPreference = {\n      ...(wslShellByTool[toolName] ?? {}),\n      wslShell,\n    };\n    setWslShellByTool((prev) => ({ ...prev, [toolName]: nextPref }));\n    await refreshToolVersions([toolName], { [toolName]: nextPref });\n  };\n\n  const handleToolShellFlagChange = async (\n    toolName: ToolName,\n    value: string,\n  ) => {\n    const wslShellFlag = value === \"auto\" ? null : value;\n    const nextPref: WslShellPreference = {\n      ...(wslShellByTool[toolName] ?? {}),\n      wslShellFlag,\n    };\n    setWslShellByTool((prev) => ({ ...prev, [toolName]: nextPref }));\n    await refreshToolVersions([toolName], { [toolName]: nextPref });\n  };\n\n  useEffect(() => {\n    let active = true;\n    const load = async () => {\n      try {\n        const [appVersion] = await Promise.all([\n          getVersion(),\n          ...(isWindows() ? [] : [loadAllToolVersions()]),\n        ]);\n\n        if (active) {\n          setVersion(appVersion);\n        }\n      } catch (error) {\n        console.error(\"[AboutSection] Failed to load info\", error);\n        if (active) {\n          setVersion(null);\n        }\n      } finally {\n        if (active) {\n          setIsLoadingVersion(false);\n        }\n      }\n    };\n\n    void load();\n    return () => {\n      active = false;\n    };\n    // Mount-only: loadAllToolVersions is intentionally excluded to avoid\n    // re-fetching all tools whenever wslShellByTool changes. Single-tool\n    // refreshes are handled by refreshToolVersions in the shell/flag handlers.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  // ... (handlers like handleOpenReleaseNotes, handleCheckUpdate) ...\n\n  const handleOpenReleaseNotes = useCallback(async () => {\n    try {\n      const targetVersion = updateInfo?.availableVersion ?? version ?? \"\";\n      const displayVersion = targetVersion.startsWith(\"v\")\n        ? targetVersion\n        : targetVersion\n          ? `v${targetVersion}`\n          : \"\";\n\n      if (!displayVersion) {\n        await settingsApi.openExternal(\n          \"https://github.com/farion1231/cc-switch/releases\",\n        );\n        return;\n      }\n\n      await settingsApi.openExternal(\n        `https://github.com/farion1231/cc-switch/releases/tag/${displayVersion}`,\n      );\n    } catch (error) {\n      console.error(\"[AboutSection] Failed to open release notes\", error);\n      toast.error(t(\"settings.openReleaseNotesFailed\"));\n    }\n  }, [t, updateInfo?.availableVersion, version]);\n\n  const handleCheckUpdate = useCallback(async () => {\n    if (hasUpdate && updateHandle) {\n      if (isPortable) {\n        try {\n          await settingsApi.checkUpdates();\n        } catch (error) {\n          console.error(\"[AboutSection] Portable update failed\", error);\n        }\n        return;\n      }\n\n      setIsDownloading(true);\n      try {\n        resetDismiss();\n        await updateHandle.downloadAndInstall();\n        await relaunchApp();\n      } catch (error) {\n        console.error(\"[AboutSection] Update failed\", error);\n        toast.error(t(\"settings.updateFailed\"));\n        try {\n          await settingsApi.checkUpdates();\n        } catch (fallbackError) {\n          console.error(\n            \"[AboutSection] Failed to open fallback updater\",\n            fallbackError,\n          );\n        }\n      } finally {\n        setIsDownloading(false);\n      }\n      return;\n    }\n\n    try {\n      const available = await checkUpdate();\n      if (!available) {\n        toast.success(t(\"settings.upToDate\"), { closeButton: true });\n      }\n    } catch (error) {\n      console.error(\"[AboutSection] Check update failed\", error);\n      toast.error(t(\"settings.checkUpdateFailed\"));\n    }\n  }, [checkUpdate, hasUpdate, isPortable, resetDismiss, t, updateHandle]);\n\n  const handleCopyInstallCommands = useCallback(async () => {\n    try {\n      await navigator.clipboard.writeText(ONE_CLICK_INSTALL_COMMANDS);\n      toast.success(t(\"settings.installCommandsCopied\"), { closeButton: true });\n    } catch (error) {\n      console.error(\"[AboutSection] Failed to copy install commands\", error);\n      toast.error(t(\"settings.installCommandsCopyFailed\"));\n    }\n  }, [t]);\n\n  const displayVersion = version ?? t(\"common.unknown\");\n\n  return (\n    <motion.section\n      initial={{ opacity: 0, y: 10 }}\n      animate={{ opacity: 1, y: 0 }}\n      transition={{ duration: 0.3 }}\n      className=\"space-y-6\"\n    >\n      <header className=\"space-y-1\">\n        <h3 className=\"text-sm font-medium\">{t(\"common.about\")}</h3>\n        <p className=\"text-xs text-muted-foreground\">\n          {t(\"settings.aboutHint\")}\n        </p>\n      </header>\n\n      <motion.div\n        initial={{ opacity: 0, scale: 0.98 }}\n        animate={{ opacity: 1, scale: 1 }}\n        transition={{ duration: 0.3, delay: 0.1 }}\n        className=\"rounded-xl border border-border bg-gradient-to-br from-card/80 to-card/40 p-6 space-y-5 shadow-sm\"\n      >\n        <div className=\"flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between\">\n          <div className=\"space-y-2\">\n            <div className=\"flex items-center gap-2\">\n              <img src={appIcon} alt=\"CC Switch\" className=\"h-5 w-5\" />\n              <h4 className=\"text-lg font-semibold text-foreground\">\n                CC Switch\n              </h4>\n            </div>\n            <div className=\"flex items-center gap-2\">\n              <Badge variant=\"outline\" className=\"gap-1.5 bg-background/80\">\n                <span className=\"text-muted-foreground\">\n                  {t(\"common.version\")}\n                </span>\n                {isLoadingVersion ? (\n                  <Loader2 className=\"h-3 w-3 animate-spin\" />\n                ) : (\n                  <span className=\"font-medium\">{`v${displayVersion}`}</span>\n                )}\n              </Badge>\n              {isPortable && (\n                <Badge variant=\"secondary\" className=\"gap-1.5\">\n                  <Info className=\"h-3 w-3\" />\n                  {t(\"settings.portableMode\")}\n                </Badge>\n              )}\n            </div>\n          </div>\n\n          <div className=\"flex items-center gap-2\">\n            <Button\n              type=\"button\"\n              variant=\"outline\"\n              size=\"sm\"\n              onClick={handleOpenReleaseNotes}\n              className=\"h-8 gap-1.5 text-xs\"\n            >\n              <ExternalLink className=\"h-3.5 w-3.5\" />\n              {t(\"settings.releaseNotes\")}\n            </Button>\n            <Button\n              type=\"button\"\n              size=\"sm\"\n              onClick={handleCheckUpdate}\n              disabled={isChecking || isDownloading}\n              className=\"h-8 gap-1.5 text-xs\"\n            >\n              {isDownloading ? (\n                <>\n                  <Loader2 className=\"h-3.5 w-3.5 animate-spin\" />\n                  {t(\"settings.updating\")}\n                </>\n              ) : hasUpdate ? (\n                <>\n                  <Download className=\"h-3.5 w-3.5\" />\n                  {t(\"settings.updateTo\", {\n                    version: updateInfo?.availableVersion ?? \"\",\n                  })}\n                </>\n              ) : isChecking ? (\n                <>\n                  <RefreshCw className=\"h-3.5 w-3.5 animate-spin\" />\n                  {t(\"settings.checking\")}\n                </>\n              ) : (\n                <>\n                  <RefreshCw className=\"h-3.5 w-3.5\" />\n                  {t(\"settings.checkForUpdates\")}\n                </>\n              )}\n            </Button>\n          </div>\n        </div>\n\n        {hasUpdate && updateInfo && (\n          <motion.div\n            initial={{ opacity: 0, height: 0 }}\n            animate={{ opacity: 1, height: \"auto\" }}\n            className=\"rounded-lg bg-primary/10 border border-primary/20 px-4 py-3 text-sm\"\n          >\n            <p className=\"font-medium text-primary mb-1\">\n              {t(\"settings.updateAvailable\", {\n                version: updateInfo.availableVersion,\n              })}\n            </p>\n            {updateInfo.notes && (\n              <p className=\"text-muted-foreground line-clamp-3 leading-relaxed\">\n                {updateInfo.notes}\n              </p>\n            )}\n          </motion.div>\n        )}\n      </motion.div>\n\n      {!isWindows() && (\n        <div className=\"space-y-3\">\n          <div className=\"flex items-center justify-between px-1\">\n            <h3 className=\"text-sm font-medium\">\n              {t(\"settings.localEnvCheck\")}\n            </h3>\n            <Button\n              size=\"sm\"\n              variant=\"outline\"\n              className=\"h-7 gap-1.5 text-xs\"\n              onClick={() => loadAllToolVersions()}\n              disabled={isLoadingTools}\n            >\n              <RefreshCw\n                className={\n                  isLoadingTools ? \"h-3.5 w-3.5 animate-spin\" : \"h-3.5 w-3.5\"\n                }\n              />\n              {isLoadingTools ? t(\"common.refreshing\") : t(\"common.refresh\")}\n            </Button>\n          </div>\n\n          <div className=\"grid gap-3 sm:grid-cols-2 lg:grid-cols-4 px-1\">\n            {TOOL_NAMES.map((toolName, index) => {\n              const tool = toolVersions.find((item) => item.name === toolName);\n              // Special case for OpenCode (capital C), others use capitalize\n              const displayName =\n                toolName === \"opencode\"\n                  ? \"OpenCode\"\n                  : toolName.charAt(0).toUpperCase() + toolName.slice(1);\n              const title = tool?.version || tool?.error || t(\"common.unknown\");\n\n              return (\n                <motion.div\n                  key={toolName}\n                  initial={{ opacity: 0, y: 10 }}\n                  animate={{ opacity: 1, y: 0 }}\n                  transition={{ duration: 0.3, delay: 0.15 + index * 0.05 }}\n                  whileHover={{ scale: 1.02 }}\n                  className=\"flex flex-col gap-2 rounded-xl border border-border bg-gradient-to-br from-card/80 to-card/40 p-4 shadow-sm transition-colors hover:border-primary/30\"\n                >\n                  <div className=\"flex items-center justify-between\">\n                    <div className=\"flex items-center gap-2\">\n                      <Terminal className=\"h-4 w-4 text-muted-foreground\" />\n                      <span className=\"text-sm font-medium\">{displayName}</span>\n                      {/* Environment Badge */}\n                      {tool?.env_type && ENV_BADGE_CONFIG[tool.env_type] && (\n                        <span\n                          className={`text-[9px] px-1.5 py-0.5 rounded-full border ${ENV_BADGE_CONFIG[tool.env_type].className}`}\n                        >\n                          {t(ENV_BADGE_CONFIG[tool.env_type].labelKey)}\n                        </span>\n                      )}\n                      {/* WSL Shell Selector */}\n                      {tool?.env_type === \"wsl\" && (\n                        <Select\n                          value={wslShellByTool[toolName]?.wslShell || \"auto\"}\n                          onValueChange={(v) =>\n                            handleToolShellChange(toolName, v)\n                          }\n                          disabled={isLoadingTools || loadingTools[toolName]}\n                        >\n                          <SelectTrigger className=\"h-6 w-[70px] text-xs\">\n                            <SelectValue />\n                          </SelectTrigger>\n                          <SelectContent>\n                            <SelectItem value=\"auto\">\n                              {t(\"common.auto\")}\n                            </SelectItem>\n                            {WSL_SHELL_OPTIONS.map((shell) => (\n                              <SelectItem key={shell} value={shell}>\n                                {shell}\n                              </SelectItem>\n                            ))}\n                          </SelectContent>\n                        </Select>\n                      )}\n                      {/* WSL Shell Flag Selector */}\n                      {tool?.env_type === \"wsl\" && (\n                        <Select\n                          value={\n                            wslShellByTool[toolName]?.wslShellFlag || \"auto\"\n                          }\n                          onValueChange={(v) =>\n                            handleToolShellFlagChange(toolName, v)\n                          }\n                          disabled={isLoadingTools || loadingTools[toolName]}\n                        >\n                          <SelectTrigger className=\"h-6 w-[70px] text-xs\">\n                            <SelectValue />\n                          </SelectTrigger>\n                          <SelectContent>\n                            <SelectItem value=\"auto\">\n                              {t(\"common.auto\")}\n                            </SelectItem>\n                            {WSL_SHELL_FLAG_OPTIONS.map((flag) => (\n                              <SelectItem key={flag} value={flag}>\n                                {flag}\n                              </SelectItem>\n                            ))}\n                          </SelectContent>\n                        </Select>\n                      )}\n                    </div>\n                    {isLoadingTools || loadingTools[toolName] ? (\n                      <Loader2 className=\"h-4 w-4 animate-spin text-muted-foreground\" />\n                    ) : tool?.version ? (\n                      tool.latest_version &&\n                      tool.version !== tool.latest_version ? (\n                        <span className=\"text-[10px] px-1.5 py-0.5 rounded-full bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 border border-yellow-500/20\">\n                          {tool.latest_version}\n                        </span>\n                      ) : (\n                        <CheckCircle2 className=\"h-4 w-4 text-green-500\" />\n                      )\n                    ) : (\n                      <AlertCircle className=\"h-4 w-4 text-yellow-500\" />\n                    )}\n                  </div>\n                  <div\n                    className=\"text-xs font-mono text-muted-foreground truncate\"\n                    title={title}\n                  >\n                    {isLoadingTools\n                      ? t(\"common.loading\")\n                      : tool?.version\n                        ? tool.version\n                        : tool?.error || t(\"common.notInstalled\")}\n                  </div>\n                </motion.div>\n              );\n            })}\n          </div>\n        </div>\n      )}\n\n      {!isWindows() && (\n        <motion.div\n          initial={{ opacity: 0, y: 10 }}\n          animate={{ opacity: 1, y: 0 }}\n          transition={{ duration: 0.3, delay: 0.3 }}\n          className=\"space-y-3\"\n        >\n          <h3 className=\"text-sm font-medium px-1\">\n            {t(\"settings.oneClickInstall\")}\n          </h3>\n          <div className=\"rounded-xl border border-border bg-gradient-to-br from-card/80 to-card/40 p-4 space-y-3 shadow-sm\">\n            <div className=\"flex items-center justify-between gap-2\">\n              <p className=\"text-xs text-muted-foreground\">\n                {t(\"settings.oneClickInstallHint\")}\n              </p>\n              <Button\n                size=\"sm\"\n                variant=\"outline\"\n                onClick={handleCopyInstallCommands}\n                className=\"h-7 gap-1.5 text-xs\"\n              >\n                <Copy className=\"h-3.5 w-3.5\" />\n                {t(\"common.copy\")}\n              </Button>\n            </div>\n            <pre className=\"text-xs font-mono bg-background/80 px-3 py-2.5 rounded-lg border border-border/60 overflow-x-auto\">\n              {ONE_CLICK_INSTALL_COMMANDS}\n            </pre>\n          </div>\n        </motion.div>\n      )}\n    </motion.section>\n  );\n}\n"
  },
  {
    "path": "src/components/settings/AppVisibilitySettings.tsx",
    "content": "import { useTranslation } from \"react-i18next\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\nimport { ProviderIcon } from \"@/components/ProviderIcon\";\nimport type { SettingsFormState } from \"@/hooks/useSettings\";\nimport type { VisibleApps } from \"@/types\";\nimport type { AppId } from \"@/lib/api\";\n\ninterface AppVisibilitySettingsProps {\n  settings: SettingsFormState;\n  onChange: (updates: Partial<SettingsFormState>) => void;\n}\n\nconst APP_CONFIG: Array<{\n  id: AppId;\n  icon: string;\n  nameKey: string;\n}> = [\n  { id: \"claude\", icon: \"claude\", nameKey: \"apps.claude\" },\n  { id: \"codex\", icon: \"openai\", nameKey: \"apps.codex\" },\n  { id: \"gemini\", icon: \"gemini\", nameKey: \"apps.gemini\" },\n  { id: \"opencode\", icon: \"opencode\", nameKey: \"apps.opencode\" },\n  { id: \"openclaw\", icon: \"openclaw\", nameKey: \"apps.openclaw\" },\n];\n\nexport function AppVisibilitySettings({\n  settings,\n  onChange,\n}: AppVisibilitySettingsProps) {\n  const { t } = useTranslation();\n\n  const visibleApps: VisibleApps = settings.visibleApps ?? {\n    claude: true,\n    codex: true,\n    gemini: true,\n    opencode: true,\n    openclaw: true,\n  };\n\n  // Count how many apps are currently visible\n  const visibleCount = Object.values(visibleApps).filter(Boolean).length;\n\n  const handleToggle = (appId: AppId) => {\n    const isCurrentlyVisible = visibleApps[appId];\n    // Prevent disabling the last visible app\n    if (isCurrentlyVisible && visibleCount <= 1) return;\n\n    onChange({\n      visibleApps: {\n        ...visibleApps,\n        [appId]: !isCurrentlyVisible,\n      },\n    });\n  };\n\n  return (\n    <section className=\"space-y-2\">\n      <header className=\"space-y-1\">\n        <h3 className=\"text-sm font-medium\">\n          {t(\"settings.appVisibility.title\")}\n        </h3>\n        <p className=\"text-xs text-muted-foreground\">\n          {t(\"settings.appVisibility.description\")}\n        </p>\n      </header>\n      <div className=\"inline-flex gap-1 rounded-md border border-border-default bg-background p-1\">\n        {APP_CONFIG.map((app) => {\n          const isVisible = visibleApps[app.id];\n          // Disable button if this is the last visible app\n          const isDisabled = isVisible && visibleCount <= 1;\n\n          return (\n            <AppButton\n              key={app.id}\n              active={isVisible}\n              disabled={isDisabled}\n              onClick={() => handleToggle(app.id)}\n              icon={app.icon}\n              name={t(app.nameKey)}\n            >\n              {t(app.nameKey)}\n            </AppButton>\n          );\n        })}\n      </div>\n    </section>\n  );\n}\n\ninterface AppButtonProps {\n  active: boolean;\n  disabled?: boolean;\n  onClick: () => void;\n  icon: string;\n  name: string;\n  children: React.ReactNode;\n}\n\nfunction AppButton({\n  active,\n  disabled,\n  onClick,\n  icon,\n  name,\n  children,\n}: AppButtonProps) {\n  return (\n    <Button\n      type=\"button\"\n      onClick={onClick}\n      disabled={disabled}\n      size=\"sm\"\n      variant={active ? \"default\" : \"ghost\"}\n      className={cn(\n        \"w-[90px] gap-1.5\",\n        active\n          ? \"shadow-sm\"\n          : \"text-muted-foreground hover:text-foreground hover:bg-muted\",\n      )}\n    >\n      <ProviderIcon icon={icon} name={name} size={14} />\n      {children}\n    </Button>\n  );\n}\n"
  },
  {
    "path": "src/components/settings/AuthCenterPanel.tsx",
    "content": "import { Github, ShieldCheck } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { CopilotAuthSection } from \"@/components/providers/forms/CopilotAuthSection\";\n\nexport function AuthCenterPanel() {\n  const { t } = useTranslation();\n\n  return (\n    <div className=\"space-y-6\">\n      <section className=\"rounded-xl border border-border/60 bg-card/60 p-6\">\n        <div className=\"flex items-start justify-between gap-4\">\n          <div className=\"space-y-2\">\n            <div className=\"flex items-center gap-2\">\n              <ShieldCheck className=\"h-5 w-5 text-primary\" />\n              <h3 className=\"text-base font-semibold\">\n                {t(\"settings.authCenter.title\", {\n                  defaultValue: \"OAuth 认证中心\",\n                })}\n              </h3>\n            </div>\n            <p className=\"text-sm text-muted-foreground\">\n              {t(\"settings.authCenter.description\", {\n                defaultValue:\n                  \"集中管理跨应用复用的 OAuth 账号。Provider 只绑定这些认证源，不再重复登录。\",\n              })}\n            </p>\n          </div>\n          <Badge variant=\"secondary\">\n            {t(\"settings.authCenter.beta\", { defaultValue: \"Beta\" })}\n          </Badge>\n        </div>\n      </section>\n\n      <section className=\"rounded-xl border border-border/60 bg-card/60 p-6\">\n        <div className=\"mb-4 flex items-center gap-3\">\n          <div className=\"flex h-10 w-10 items-center justify-center rounded-xl bg-muted\">\n            <Github className=\"h-5 w-5\" />\n          </div>\n          <div>\n            <h4 className=\"font-medium\">GitHub Copilot</h4>\n            <p className=\"text-sm text-muted-foreground\">\n              {t(\"settings.authCenter.copilotDescription\", {\n                defaultValue:\n                  \"管理 GitHub Copilot 账号、默认账号以及供 Claude / Codex / Gemini 绑定的托管凭据。\",\n              })}\n            </p>\n          </div>\n        </div>\n\n        <CopilotAuthSection />\n      </section>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/settings/BackupListSection.tsx",
    "content": "import { useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { toast } from \"sonner\";\nimport { Pencil, RotateCcw, Check, X, Download, Trash2 } from \"lucide-react\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { useBackupManager } from \"@/hooks/useBackupManager\";\nimport { extractErrorMessage } from \"@/utils/errorUtils\";\n\ninterface BackupListSectionProps {\n  backupIntervalHours?: number;\n  backupRetainCount?: number;\n  onSettingsChange: (updates: {\n    backupIntervalHours?: number;\n    backupRetainCount?: number;\n  }) => void;\n}\n\nfunction formatBytes(bytes: number): string {\n  if (bytes < 1024) return `${bytes} B`;\n  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;\n  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;\n}\n\nfunction formatBackupDate(isoString: string): string {\n  try {\n    const date = new Date(isoString);\n    return date.toLocaleString();\n  } catch {\n    return isoString;\n  }\n}\n\n/** Parse display name from backup filename */\nfunction getDisplayName(filename: string): string {\n  // Try to parse db_backup_YYYYMMDD_HHMMSS format\n  const match = filename.match(\n    /^db_backup_(\\d{4})(\\d{2})(\\d{2})_(\\d{2})(\\d{2})(\\d{2})(?:_\\d+)?\\.db$/,\n  );\n  if (match) {\n    const [, y, m, d, hh, mm, ss] = match;\n    return `${y}-${m}-${d} ${hh}:${mm}:${ss}`;\n  }\n  // Otherwise show filename without .db suffix\n  return filename.replace(/\\.db$/, \"\");\n}\n\nexport function BackupListSection({\n  backupIntervalHours,\n  backupRetainCount,\n  onSettingsChange,\n}: BackupListSectionProps) {\n  const { t } = useTranslation();\n  const {\n    backups,\n    isLoading,\n    create,\n    isCreating,\n    restore,\n    isRestoring,\n    rename,\n    isRenaming,\n    remove,\n    isDeleting,\n  } = useBackupManager();\n  const [confirmFilename, setConfirmFilename] = useState<string | null>(null);\n  const [deleteFilename, setDeleteFilename] = useState<string | null>(null);\n  const [editingFilename, setEditingFilename] = useState<string | null>(null);\n  const [editValue, setEditValue] = useState(\"\");\n\n  const handleRestore = async () => {\n    if (!confirmFilename) return;\n    try {\n      const safetyId = await restore(confirmFilename);\n      setConfirmFilename(null);\n      toast.success(\n        t(\"settings.backupManager.restoreSuccess\", {\n          defaultValue: \"Restore successful! Safety backup created\",\n        }),\n        {\n          description: safetyId\n            ? `${t(\"settings.backupManager.safetyBackupId\", { defaultValue: \"Safety Backup ID\" })}: ${safetyId}`\n            : undefined,\n          duration: 6000,\n          closeButton: true,\n        },\n      );\n    } catch (error) {\n      const detail =\n        extractErrorMessage(error) ||\n        t(\"settings.backupManager.restoreFailed\", {\n          defaultValue: \"Restore failed\",\n        });\n      toast.error(detail);\n    }\n  };\n\n  const handleStartRename = (filename: string) => {\n    setEditingFilename(filename);\n    setEditValue(getDisplayName(filename));\n  };\n\n  const handleCancelRename = () => {\n    setEditingFilename(null);\n    setEditValue(\"\");\n  };\n\n  const handleDelete = async () => {\n    if (!deleteFilename) return;\n    try {\n      await remove(deleteFilename);\n      setDeleteFilename(null);\n      toast.success(\n        t(\"settings.backupManager.deleteSuccess\", {\n          defaultValue: \"Backup deleted\",\n        }),\n      );\n    } catch (error) {\n      const detail =\n        extractErrorMessage(error) ||\n        t(\"settings.backupManager.deleteFailed\", {\n          defaultValue: \"Delete failed\",\n        });\n      toast.error(detail);\n    }\n  };\n\n  const handleConfirmRename = async () => {\n    if (!editingFilename || !editValue.trim()) return;\n    try {\n      await rename({ oldFilename: editingFilename, newName: editValue.trim() });\n      setEditingFilename(null);\n      setEditValue(\"\");\n      toast.success(\n        t(\"settings.backupManager.renameSuccess\", {\n          defaultValue: \"Backup renamed\",\n        }),\n      );\n    } catch (error) {\n      const detail =\n        extractErrorMessage(error) ||\n        t(\"settings.backupManager.renameFailed\", {\n          defaultValue: \"Rename failed\",\n        });\n      toast.error(detail);\n    }\n  };\n\n  const intervalValue = String(backupIntervalHours ?? 24);\n  const retainValue = String(backupRetainCount ?? 10);\n\n  return (\n    <div className=\"space-y-4\">\n      {/* Backup policy settings */}\n      <div className=\"grid grid-cols-2 gap-4\">\n        <div className=\"space-y-2\">\n          <Label className=\"text-sm\">\n            {t(\"settings.backupManager.intervalLabel\", {\n              defaultValue: \"Auto-backup Interval\",\n            })}\n          </Label>\n          <Select\n            value={intervalValue}\n            onValueChange={(v) =>\n              onSettingsChange({ backupIntervalHours: Number(v) })\n            }\n          >\n            <SelectTrigger className=\"h-9\">\n              <SelectValue />\n            </SelectTrigger>\n            <SelectContent>\n              <SelectItem value=\"0\">\n                {t(\"settings.backupManager.intervalDisabled\", {\n                  defaultValue: \"Disabled\",\n                })}\n              </SelectItem>\n              <SelectItem value=\"6\">\n                {t(\"settings.backupManager.intervalHours\", {\n                  hours: 6,\n                  defaultValue: \"6 hours\",\n                })}\n              </SelectItem>\n              <SelectItem value=\"12\">\n                {t(\"settings.backupManager.intervalHours\", {\n                  hours: 12,\n                  defaultValue: \"12 hours\",\n                })}\n              </SelectItem>\n              <SelectItem value=\"24\">\n                {t(\"settings.backupManager.intervalHours\", {\n                  hours: 24,\n                  defaultValue: \"24 hours\",\n                })}\n              </SelectItem>\n              <SelectItem value=\"48\">\n                {t(\"settings.backupManager.intervalHours\", {\n                  hours: 48,\n                  defaultValue: \"48 hours\",\n                })}\n              </SelectItem>\n              <SelectItem value=\"168\">\n                {t(\"settings.backupManager.intervalDays\", {\n                  days: 7,\n                  defaultValue: \"7 days\",\n                })}\n              </SelectItem>\n            </SelectContent>\n          </Select>\n        </div>\n\n        <div className=\"space-y-2\">\n          <Label className=\"text-sm\">\n            {t(\"settings.backupManager.retainLabel\", {\n              defaultValue: \"Backup Retention\",\n            })}\n          </Label>\n          <Select\n            value={retainValue}\n            onValueChange={(v) =>\n              onSettingsChange({ backupRetainCount: Number(v) })\n            }\n          >\n            <SelectTrigger className=\"h-9\">\n              <SelectValue />\n            </SelectTrigger>\n            <SelectContent>\n              {[3, 5, 10, 15, 20, 30, 50].map((n) => (\n                <SelectItem key={n} value={String(n)}>\n                  {n}\n                </SelectItem>\n              ))}\n            </SelectContent>\n          </Select>\n        </div>\n      </div>\n\n      {/* Backup list */}\n      <div>\n        <div className=\"flex items-center justify-between mb-2\">\n          <h4 className=\"text-sm font-medium\">\n            {t(\"settings.backupManager.title\", {\n              defaultValue: \"Database Backups\",\n            })}\n          </h4>\n          <Button\n            variant=\"outline\"\n            size=\"sm\"\n            className=\"h-7 px-2 text-xs\"\n            disabled={isCreating || isRestoring}\n            onClick={async () => {\n              try {\n                await create();\n                toast.success(\n                  t(\"settings.backupManager.createSuccess\", {\n                    defaultValue: \"Backup created successfully\",\n                  }),\n                );\n              } catch (error) {\n                const detail =\n                  extractErrorMessage(error) ||\n                  t(\"settings.backupManager.createFailed\", {\n                    defaultValue: \"Backup failed\",\n                  });\n                toast.error(detail);\n              }\n            }}\n          >\n            <Download className=\"h-3 w-3 mr-1\" />\n            {isCreating\n              ? t(\"settings.backupManager.creating\", {\n                  defaultValue: \"Backing up...\",\n                })\n              : t(\"settings.backupManager.createBackup\", {\n                  defaultValue: \"Backup Now\",\n                })}\n          </Button>\n        </div>\n\n        {isLoading ? (\n          <div className=\"text-sm text-muted-foreground py-2\">Loading...</div>\n        ) : backups.length === 0 ? (\n          <div className=\"text-sm text-muted-foreground py-2\">\n            {t(\"settings.backupManager.empty\", {\n              defaultValue: \"No backups yet\",\n            })}\n          </div>\n        ) : (\n          <div className=\"space-y-1.5 max-h-48 overflow-y-auto\">\n            {backups.map((backup) => (\n              <div\n                key={backup.filename}\n                className=\"flex items-center justify-between gap-2 px-3 py-2 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors text-sm\"\n              >\n                <div className=\"flex-1 min-w-0\">\n                  {editingFilename === backup.filename ? (\n                    <div className=\"flex items-center gap-1.5\">\n                      <Input\n                        value={editValue}\n                        onChange={(e) => setEditValue(e.target.value)}\n                        onKeyDown={(e) => {\n                          if (e.key === \"Enter\") handleConfirmRename();\n                          if (e.key === \"Escape\") handleCancelRename();\n                        }}\n                        className=\"h-7 text-xs\"\n                        placeholder={t(\n                          \"settings.backupManager.namePlaceholder\",\n                          { defaultValue: \"Enter new name\" },\n                        )}\n                        autoFocus\n                        disabled={isRenaming}\n                      />\n                      <Button\n                        variant=\"ghost\"\n                        size=\"icon\"\n                        className=\"h-6 w-6 shrink-0\"\n                        onClick={handleConfirmRename}\n                        disabled={isRenaming || !editValue.trim()}\n                      >\n                        <Check className=\"h-3.5 w-3.5\" />\n                      </Button>\n                      <Button\n                        variant=\"ghost\"\n                        size=\"icon\"\n                        className=\"h-6 w-6 shrink-0\"\n                        onClick={handleCancelRename}\n                        disabled={isRenaming}\n                      >\n                        <X className=\"h-3.5 w-3.5\" />\n                      </Button>\n                    </div>\n                  ) : (\n                    <>\n                      <div className=\"font-mono text-xs truncate\">\n                        {getDisplayName(backup.filename)}\n                      </div>\n                      <div className=\"text-xs text-muted-foreground\">\n                        {formatBackupDate(backup.createdAt)} &middot;{\" \"}\n                        {formatBytes(backup.sizeBytes)}\n                      </div>\n                    </>\n                  )}\n                </div>\n                {editingFilename !== backup.filename && (\n                  <div className=\"flex items-center gap-1 shrink-0\">\n                    <Button\n                      variant=\"ghost\"\n                      size=\"icon\"\n                      className=\"h-7 w-7\"\n                      onClick={() => handleStartRename(backup.filename)}\n                      disabled={isRestoring || isRenaming || isDeleting}\n                      title={t(\"settings.backupManager.rename\", {\n                        defaultValue: \"Rename\",\n                      })}\n                    >\n                      <Pencil className=\"h-3 w-3\" />\n                    </Button>\n                    <Button\n                      variant=\"ghost\"\n                      size=\"icon\"\n                      className=\"h-7 w-7 text-destructive hover:text-destructive\"\n                      onClick={() => setDeleteFilename(backup.filename)}\n                      disabled={isRestoring || isDeleting}\n                      title={t(\"settings.backupManager.delete\", {\n                        defaultValue: \"Delete\",\n                      })}\n                    >\n                      <Trash2 className=\"h-3 w-3\" />\n                    </Button>\n                    <Button\n                      variant=\"ghost\"\n                      size=\"sm\"\n                      className=\"h-7 px-2 text-xs\"\n                      disabled={isRestoring || isDeleting}\n                      onClick={() => setConfirmFilename(backup.filename)}\n                    >\n                      <RotateCcw className=\"h-3 w-3 mr-1\" />\n                      {isRestoring\n                        ? t(\"settings.backupManager.restoring\", {\n                            defaultValue: \"Restoring...\",\n                          })\n                        : t(\"settings.backupManager.restore\", {\n                            defaultValue: \"Restore\",\n                          })}\n                    </Button>\n                  </div>\n                )}\n              </div>\n            ))}\n          </div>\n        )}\n      </div>\n\n      {/* Restore Confirmation Dialog */}\n      <Dialog\n        open={!!confirmFilename}\n        onOpenChange={(open) => !open && setConfirmFilename(null)}\n      >\n        <DialogContent className=\"max-w-md\" zIndex=\"alert\">\n          <DialogHeader>\n            <DialogTitle>\n              {t(\"settings.backupManager.confirmTitle\", {\n                defaultValue: \"Confirm Restore\",\n              })}\n            </DialogTitle>\n            <DialogDescription>\n              {t(\"settings.backupManager.confirmMessage\", {\n                defaultValue:\n                  \"Restoring this backup will overwrite the current database. A safety backup will be created first.\",\n              })}\n            </DialogDescription>\n          </DialogHeader>\n          <DialogFooter>\n            <Button\n              variant=\"outline\"\n              onClick={() => setConfirmFilename(null)}\n              disabled={isRestoring}\n            >\n              {t(\"common.cancel\", { defaultValue: \"Cancel\" })}\n            </Button>\n            <Button onClick={handleRestore} disabled={isRestoring}>\n              {isRestoring\n                ? t(\"settings.backupManager.restoring\", {\n                    defaultValue: \"Restoring...\",\n                  })\n                : t(\"settings.backupManager.restore\", {\n                    defaultValue: \"Restore\",\n                  })}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n\n      {/* Delete Confirmation Dialog */}\n      <Dialog\n        open={!!deleteFilename}\n        onOpenChange={(open) => !open && setDeleteFilename(null)}\n      >\n        <DialogContent className=\"max-w-md\" zIndex=\"alert\">\n          <DialogHeader>\n            <DialogTitle>\n              {t(\"settings.backupManager.deleteConfirmTitle\", {\n                defaultValue: \"Confirm Delete\",\n              })}\n            </DialogTitle>\n            <DialogDescription>\n              {t(\"settings.backupManager.deleteConfirmMessage\", {\n                defaultValue:\n                  \"This backup will be permanently deleted. This action cannot be undone.\",\n              })}\n            </DialogDescription>\n          </DialogHeader>\n          <DialogFooter>\n            <Button\n              variant=\"outline\"\n              onClick={() => setDeleteFilename(null)}\n              disabled={isDeleting}\n            >\n              {t(\"common.cancel\", { defaultValue: \"Cancel\" })}\n            </Button>\n            <Button\n              variant=\"destructive\"\n              onClick={handleDelete}\n              disabled={isDeleting}\n            >\n              {isDeleting\n                ? t(\"settings.backupManager.deleting\", {\n                    defaultValue: \"Deleting...\",\n                  })\n                : t(\"settings.backupManager.delete\", {\n                    defaultValue: \"Delete\",\n                  })}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/settings/DirectorySettings.tsx",
    "content": "import { useMemo } from \"react\";\nimport { FolderSearch, Undo2 } from \"lucide-react\";\nimport { Input } from \"@/components/ui/input\";\nimport { Button } from \"@/components/ui/button\";\nimport { useTranslation } from \"react-i18next\";\nimport type { AppId } from \"@/lib/api\";\nimport type { ResolvedDirectories } from \"@/hooks/useSettings\";\n\ninterface DirectorySettingsProps {\n  appConfigDir?: string;\n  resolvedDirs: ResolvedDirectories;\n  onAppConfigChange: (value?: string) => void;\n  onBrowseAppConfig: () => Promise<void>;\n  onResetAppConfig: () => Promise<void>;\n  claudeDir?: string;\n  codexDir?: string;\n  geminiDir?: string;\n  opencodeDir?: string;\n  onDirectoryChange: (app: AppId, value?: string) => void;\n  onBrowseDirectory: (app: AppId) => Promise<void>;\n  onResetDirectory: (app: AppId) => Promise<void>;\n}\n\nexport function DirectorySettings({\n  appConfigDir,\n  resolvedDirs,\n  onAppConfigChange,\n  onBrowseAppConfig,\n  onResetAppConfig,\n  claudeDir,\n  codexDir,\n  geminiDir,\n  opencodeDir,\n  onDirectoryChange,\n  onBrowseDirectory,\n  onResetDirectory,\n}: DirectorySettingsProps) {\n  const { t } = useTranslation();\n\n  return (\n    <div className=\"space-y-6\">\n      {/* CC Switch 配置目录 - 独立区块 */}\n      <section className=\"space-y-4\">\n        <header className=\"space-y-1\">\n          <h3 className=\"text-sm font-medium\">{t(\"settings.appConfigDir\")}</h3>\n          <p className=\"text-xs text-muted-foreground\">\n            {t(\"settings.appConfigDirDescription\")}\n          </p>\n        </header>\n\n        <div className=\"flex items-center gap-2\">\n          <Input\n            value={appConfigDir ?? resolvedDirs.appConfig ?? \"\"}\n            placeholder={t(\"settings.browsePlaceholderApp\")}\n            className=\"text-xs\"\n            onChange={(event) => onAppConfigChange(event.target.value)}\n          />\n          <Button\n            type=\"button\"\n            variant=\"outline\"\n            size=\"icon\"\n            onClick={onBrowseAppConfig}\n            title={t(\"settings.browseDirectory\")}\n          >\n            <FolderSearch className=\"h-4 w-4\" />\n          </Button>\n          <Button\n            type=\"button\"\n            variant=\"outline\"\n            size=\"icon\"\n            onClick={onResetAppConfig}\n            title={t(\"settings.resetDefault\")}\n          >\n            <Undo2 className=\"h-4 w-4\" />\n          </Button>\n        </div>\n      </section>\n\n      {/* Claude/Codex 配置目录 - 独立区块 */}\n      <section className=\"space-y-4\">\n        <header className=\"space-y-1\">\n          <h3 className=\"text-sm font-medium\">\n            {t(\"settings.configDirectoryOverride\")}\n          </h3>\n          <p className=\"text-xs text-muted-foreground\">\n            {t(\"settings.configDirectoryDescription\")}\n          </p>\n        </header>\n\n        <DirectoryInput\n          label={t(\"settings.claudeConfigDir\")}\n          description={undefined}\n          value={claudeDir}\n          resolvedValue={resolvedDirs.claude}\n          placeholder={t(\"settings.browsePlaceholderClaude\")}\n          onChange={(val) => onDirectoryChange(\"claude\", val)}\n          onBrowse={() => onBrowseDirectory(\"claude\")}\n          onReset={() => onResetDirectory(\"claude\")}\n        />\n\n        <DirectoryInput\n          label={t(\"settings.codexConfigDir\")}\n          description={undefined}\n          value={codexDir}\n          resolvedValue={resolvedDirs.codex}\n          placeholder={t(\"settings.browsePlaceholderCodex\")}\n          onChange={(val) => onDirectoryChange(\"codex\", val)}\n          onBrowse={() => onBrowseDirectory(\"codex\")}\n          onReset={() => onResetDirectory(\"codex\")}\n        />\n\n        <DirectoryInput\n          label={t(\"settings.geminiConfigDir\")}\n          description={undefined}\n          value={geminiDir}\n          resolvedValue={resolvedDirs.gemini}\n          placeholder={t(\"settings.browsePlaceholderGemini\")}\n          onChange={(val) => onDirectoryChange(\"gemini\", val)}\n          onBrowse={() => onBrowseDirectory(\"gemini\")}\n          onReset={() => onResetDirectory(\"gemini\")}\n        />\n\n        <DirectoryInput\n          label={t(\"settings.opencodeConfigDir\")}\n          description={undefined}\n          value={opencodeDir}\n          resolvedValue={resolvedDirs.opencode}\n          placeholder={t(\"settings.browsePlaceholderOpencode\")}\n          onChange={(val) => onDirectoryChange(\"opencode\", val)}\n          onBrowse={() => onBrowseDirectory(\"opencode\")}\n          onReset={() => onResetDirectory(\"opencode\")}\n        />\n      </section>\n    </div>\n  );\n}\n\ninterface DirectoryInputProps {\n  label: string;\n  description?: string;\n  value?: string;\n  resolvedValue: string;\n  placeholder?: string;\n  onChange: (value?: string) => void;\n  onBrowse: () => Promise<void>;\n  onReset: () => Promise<void>;\n}\n\nfunction DirectoryInput({\n  label,\n  description,\n  value,\n  resolvedValue,\n  placeholder,\n  onChange,\n  onBrowse,\n  onReset,\n}: DirectoryInputProps) {\n  const { t } = useTranslation();\n  const displayValue = useMemo(\n    () => value ?? resolvedValue ?? \"\",\n    [value, resolvedValue],\n  );\n\n  return (\n    <div className=\"space-y-1.5\">\n      <div className=\"space-y-1\">\n        <p className=\"text-xs font-medium text-foreground\">{label}</p>\n        {description ? (\n          <p className=\"text-xs text-muted-foreground\">{description}</p>\n        ) : null}\n      </div>\n      <div className=\"flex items-center gap-2\">\n        <Input\n          value={displayValue}\n          placeholder={placeholder}\n          className=\"text-xs\"\n          onChange={(event) => onChange(event.target.value)}\n        />\n        <Button\n          type=\"button\"\n          variant=\"outline\"\n          size=\"icon\"\n          onClick={onBrowse}\n          title={t(\"settings.browseDirectory\")}\n        >\n          <FolderSearch className=\"h-4 w-4\" />\n        </Button>\n        <Button\n          type=\"button\"\n          variant=\"outline\"\n          size=\"icon\"\n          onClick={onReset}\n          title={t(\"settings.resetDefault\")}\n        >\n          <Undo2 className=\"h-4 w-4\" />\n        </Button>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/settings/GlobalProxySettings.tsx",
    "content": "/**\n * 全局出站代理设置组件\n *\n * 提供配置全局代理的输入界面，支持用户名密码认证。\n */\n\nimport { useState, useEffect, useMemo } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Input } from \"@/components/ui/input\";\nimport { Button } from \"@/components/ui/button\";\nimport { Loader2, TestTube2, Search, Eye, EyeOff, X } from \"lucide-react\";\nimport {\n  useGlobalProxyUrl,\n  useSetGlobalProxyUrl,\n  useTestProxy,\n  useScanProxies,\n  type DetectedProxy,\n} from \"@/hooks/useGlobalProxy\";\n\n/** 从完整 URL 提取认证信息 */\nfunction extractAuth(url: string): {\n  baseUrl: string;\n  username: string;\n  password: string;\n} {\n  if (!url.trim()) return { baseUrl: \"\", username: \"\", password: \"\" };\n\n  try {\n    const parsed = new URL(url);\n    const username = decodeURIComponent(parsed.username || \"\");\n    const password = decodeURIComponent(parsed.password || \"\");\n    // 移除认证信息，获取基础 URL\n    parsed.username = \"\";\n    parsed.password = \"\";\n    return { baseUrl: parsed.toString(), username, password };\n  } catch {\n    return { baseUrl: url, username: \"\", password: \"\" };\n  }\n}\n\n/** 将认证信息合并到 URL */\nfunction mergeAuth(\n  baseUrl: string,\n  username: string,\n  password: string,\n): string {\n  if (!baseUrl.trim()) return \"\";\n  if (!username.trim()) return baseUrl;\n\n  try {\n    const parsed = new URL(baseUrl);\n    // URL 对象的 username/password setter 会自动进行 percent-encoding\n    // 不要使用 encodeURIComponent，否则会导致双重编码\n    parsed.username = username.trim();\n    if (password) {\n      parsed.password = password;\n    }\n    return parsed.toString();\n  } catch {\n    // URL 解析失败，尝试手动插入（此时需要手动编码）\n    const match = baseUrl.match(/^(\\w+:\\/\\/)(.+)$/);\n    if (match) {\n      const auth = password\n        ? `${encodeURIComponent(username.trim())}:${encodeURIComponent(password)}@`\n        : `${encodeURIComponent(username.trim())}@`;\n      return `${match[1]}${auth}${match[2]}`;\n    }\n    return baseUrl;\n  }\n}\n\nexport function GlobalProxySettings() {\n  const { t } = useTranslation();\n  const { data: savedUrl, isLoading } = useGlobalProxyUrl();\n  const setMutation = useSetGlobalProxyUrl();\n  const testMutation = useTestProxy();\n  const scanMutation = useScanProxies();\n\n  const [url, setUrl] = useState(\"\");\n  const [username, setUsername] = useState(\"\");\n  const [password, setPassword] = useState(\"\");\n  const [showPassword, setShowPassword] = useState(false);\n  const [dirty, setDirty] = useState(false);\n  const [detected, setDetected] = useState<DetectedProxy[]>([]);\n\n  // 计算完整 URL（含认证信息）\n  const fullUrl = useMemo(\n    () => mergeAuth(url, username, password),\n    [url, username, password],\n  );\n\n  // 同步远程配置\n  useEffect(() => {\n    if (savedUrl !== undefined) {\n      const { baseUrl, username: u, password: p } = extractAuth(savedUrl || \"\");\n      setUrl(baseUrl);\n      setUsername(u);\n      setPassword(p);\n      setDirty(false);\n    }\n  }, [savedUrl]);\n\n  const handleSave = async () => {\n    await setMutation.mutateAsync(fullUrl);\n    setDirty(false);\n  };\n\n  const handleTest = async () => {\n    if (fullUrl) {\n      await testMutation.mutateAsync(fullUrl);\n    }\n  };\n\n  const handleScan = async () => {\n    const result = await scanMutation.mutateAsync();\n    setDetected(result);\n  };\n\n  const handleSelect = (proxyUrl: string) => {\n    const { baseUrl, username: u, password: p } = extractAuth(proxyUrl);\n    setUrl(baseUrl);\n    setUsername(u);\n    setPassword(p);\n    setDirty(true);\n    setDetected([]);\n  };\n\n  const handleClear = () => {\n    setUrl(\"\");\n    setUsername(\"\");\n    setPassword(\"\");\n    setDirty(true);\n  };\n\n  const handleKeyDown = (e: React.KeyboardEvent) => {\n    if (e.key === \"Enter\" && dirty && !setMutation.isPending) {\n      handleSave();\n    }\n  };\n\n  // 只在首次加载且无数据时显示加载状态\n  if (isLoading && savedUrl === undefined) {\n    return (\n      <div className=\"flex items-center justify-center p-4\">\n        <Loader2 className=\"h-5 w-5 animate-spin text-muted-foreground\" />\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"space-y-3\">\n      {/* 描述 */}\n      <p className=\"text-sm text-muted-foreground\">\n        {t(\"settings.globalProxy.hint\")}\n      </p>\n\n      {/* 代理地址输入框和按钮 */}\n      <div className=\"flex gap-2\">\n        <Input\n          placeholder=\"http://127.0.0.1:7890 / socks5://127.0.0.1:1080\"\n          value={url}\n          onChange={(e) => {\n            setUrl(e.target.value);\n            setDirty(true);\n          }}\n          onKeyDown={handleKeyDown}\n          className=\"font-mono text-sm flex-1\"\n        />\n        <Button\n          variant=\"outline\"\n          size=\"icon\"\n          disabled={scanMutation.isPending}\n          onClick={handleScan}\n          title={t(\"settings.globalProxy.scan\")}\n        >\n          {scanMutation.isPending ? (\n            <Loader2 className=\"h-4 w-4 animate-spin\" />\n          ) : (\n            <Search className=\"h-4 w-4\" />\n          )}\n        </Button>\n        <Button\n          variant=\"outline\"\n          size=\"icon\"\n          disabled={!fullUrl || testMutation.isPending}\n          onClick={handleTest}\n          title={t(\"settings.globalProxy.test\")}\n        >\n          {testMutation.isPending ? (\n            <Loader2 className=\"h-4 w-4 animate-spin\" />\n          ) : (\n            <TestTube2 className=\"h-4 w-4\" />\n          )}\n        </Button>\n        <Button\n          variant=\"outline\"\n          size=\"icon\"\n          disabled={!url && !username && !password}\n          onClick={handleClear}\n          title={t(\"settings.globalProxy.clear\")}\n        >\n          <X className=\"h-4 w-4\" />\n        </Button>\n        <Button\n          onClick={handleSave}\n          disabled={!dirty || setMutation.isPending}\n          size=\"sm\"\n        >\n          {setMutation.isPending && (\n            <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n          )}\n          {t(\"common.save\")}\n        </Button>\n      </div>\n\n      {/* 认证信息：用户名 + 密码（可选） */}\n      <div className=\"flex gap-2\">\n        <Input\n          placeholder={t(\"settings.globalProxy.username\")}\n          value={username}\n          onChange={(e) => {\n            setUsername(e.target.value);\n            setDirty(true);\n          }}\n          onKeyDown={handleKeyDown}\n          className=\"font-mono text-sm flex-1\"\n        />\n        <div className=\"relative flex-1\">\n          <Input\n            type={showPassword ? \"text\" : \"password\"}\n            placeholder={t(\"settings.globalProxy.password\")}\n            value={password}\n            onChange={(e) => {\n              setPassword(e.target.value);\n              setDirty(true);\n            }}\n            onKeyDown={handleKeyDown}\n            className=\"font-mono text-sm pr-10\"\n          />\n          <Button\n            type=\"button\"\n            variant=\"ghost\"\n            size=\"icon\"\n            className=\"absolute right-0 top-0 h-full px-3 hover:bg-transparent\"\n            onClick={() => setShowPassword(!showPassword)}\n            tabIndex={-1}\n          >\n            {showPassword ? (\n              <EyeOff className=\"h-4 w-4 text-muted-foreground\" />\n            ) : (\n              <Eye className=\"h-4 w-4 text-muted-foreground\" />\n            )}\n          </Button>\n        </div>\n      </div>\n\n      {/* 扫描结果 */}\n      {detected.length > 0 && (\n        <div className=\"flex flex-wrap gap-2\">\n          {detected.map((p) => (\n            <Button\n              key={p.url}\n              variant=\"secondary\"\n              size=\"sm\"\n              onClick={() => handleSelect(p.url)}\n              className=\"font-mono text-xs\"\n            >\n              {p.url}\n            </Button>\n          ))}\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/settings/ImportExportSection.tsx",
    "content": "import { useMemo } from \"react\";\nimport {\n  AlertCircle,\n  CheckCircle2,\n  FolderOpen,\n  Loader2,\n  Save,\n  XCircle,\n} from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { useTranslation } from \"react-i18next\";\nimport type { ImportStatus } from \"@/hooks/useImportExport\";\n\ninterface ImportExportSectionProps {\n  status: ImportStatus;\n  selectedFile: string;\n  errorMessage: string | null;\n  backupId: string | null;\n  isImporting: boolean;\n  onSelectFile: () => Promise<void>;\n  onImport: () => Promise<void>;\n  onExport: () => Promise<void>;\n  onClear: () => void;\n}\n\nexport function ImportExportSection({\n  status,\n  selectedFile,\n  errorMessage,\n  backupId,\n  isImporting,\n  onSelectFile,\n  onImport,\n  onExport,\n  onClear,\n}: ImportExportSectionProps) {\n  const { t } = useTranslation();\n\n  const selectedFileName = useMemo(() => {\n    if (!selectedFile) return \"\";\n    const segments = selectedFile.split(/[\\\\/]/);\n    return segments[segments.length - 1] || selectedFile;\n  }, [selectedFile]);\n\n  return (\n    <section className=\"space-y-4\">\n      <header className=\"space-y-2\">\n        <h3 className=\"text-base font-semibold text-foreground\">\n          {t(\"settings.importExport\")}\n        </h3>\n        <p className=\"text-sm text-muted-foreground\">\n          {t(\"settings.importExportHint\")}\n        </p>\n      </header>\n\n      <div className=\"space-y-4 rounded-lg border border-border bg-muted/40 p-6\">\n        {/* Import and Export Buttons Side by Side */}\n        <div className=\"grid grid-cols-2 gap-4 items-stretch\">\n          {/* Import Button */}\n          <div className=\"relative\">\n            <Button\n              type=\"button\"\n              className={`w-full h-auto py-3 px-4 bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 text-white ${selectedFile && !isImporting ? \"flex-col items-start\" : \"items-center\"}`}\n              onClick={!selectedFile ? onSelectFile : onImport}\n              disabled={isImporting}\n            >\n              <div className=\"flex items-center gap-2 w-full justify-center\">\n                {isImporting ? (\n                  <Loader2 className=\"h-4 w-4 animate-spin flex-shrink-0\" />\n                ) : selectedFile ? (\n                  <CheckCircle2 className=\"h-4 w-4 flex-shrink-0\" />\n                ) : (\n                  <FolderOpen className=\"h-4 w-4 flex-shrink-0\" />\n                )}\n                <span className=\"font-medium\">\n                  {isImporting\n                    ? t(\"settings.importing\")\n                    : selectedFile\n                      ? t(\"settings.import\")\n                      : t(\"settings.selectConfigFile\")}\n                </span>\n              </div>\n              {selectedFile && !isImporting && (\n                <div className=\"mt-2 w-full text-left\">\n                  <p className=\"text-xs font-mono text-white/80 truncate\">\n                    📄 {selectedFileName}\n                  </p>\n                </div>\n              )}\n            </Button>\n            {selectedFile && (\n              <button\n                type=\"button\"\n                onClick={onClear}\n                className=\"absolute -top-2 -right-2 h-6 w-6 rounded-full bg-red-500 hover:bg-red-600 text-white flex items-center justify-center shadow-lg transition-colors z-10\"\n                aria-label={t(\"common.clear\")}\n              >\n                <XCircle className=\"h-4 w-4\" />\n              </button>\n            )}\n          </div>\n\n          {/* Export Button */}\n          <div>\n            <Button\n              type=\"button\"\n              className=\"w-full h-full py-3 px-4 bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 text-white items-center\"\n              onClick={onExport}\n            >\n              <Save className=\"mr-2 h-4 w-4\" />\n              {t(\"settings.exportConfig\")}\n            </Button>\n          </div>\n        </div>\n\n        <ImportStatusMessage\n          status={status}\n          errorMessage={errorMessage}\n          backupId={backupId}\n        />\n      </div>\n    </section>\n  );\n}\n\ninterface ImportStatusMessageProps {\n  status: ImportStatus;\n  errorMessage: string | null;\n  backupId: string | null;\n}\n\nfunction ImportStatusMessage({\n  status,\n  errorMessage,\n  backupId,\n}: ImportStatusMessageProps) {\n  const { t } = useTranslation();\n\n  if (status === \"idle\") {\n    return null;\n  }\n\n  const baseClass =\n    \"flex items-start gap-3 rounded-xl border p-4 text-sm leading-relaxed backdrop-blur-sm\";\n\n  if (status === \"importing\") {\n    return (\n      <div\n        className={`${baseClass} border-blue-500/30 bg-blue-500/10 text-blue-600 dark:text-blue-400`}\n      >\n        <Loader2 className=\"mt-0.5 h-5 w-5 flex-shrink-0 animate-spin\" />\n        <div>\n          <p className=\"font-semibold\">{t(\"settings.importing\")}</p>\n          <p className=\"text-blue-600/80 dark:text-blue-400/80\">\n            {t(\"common.loading\")}\n          </p>\n        </div>\n      </div>\n    );\n  }\n\n  if (status === \"success\") {\n    return (\n      <div\n        className={`${baseClass} border-green-500/30 bg-green-500/10 text-green-700 dark:text-green-400`}\n      >\n        <CheckCircle2 className=\"mt-0.5 h-5 w-5 flex-shrink-0\" />\n        <div className=\"space-y-1.5\">\n          <p className=\"font-semibold\">{t(\"settings.importSuccess\")}</p>\n          {backupId ? (\n            <p className=\"text-xs text-green-600/80 dark:text-green-400/80\">\n              {t(\"settings.backupId\")}: {backupId}\n            </p>\n          ) : null}\n          <p className=\"text-green-600/80 dark:text-green-400/80\">\n            {t(\"settings.autoReload\")}\n          </p>\n        </div>\n      </div>\n    );\n  }\n\n  if (status === \"partial-success\") {\n    return (\n      <div\n        className={`${baseClass} border-yellow-500/30 bg-yellow-500/10 text-yellow-700 dark:text-yellow-400`}\n      >\n        <AlertCircle className=\"mt-0.5 h-5 w-5 flex-shrink-0\" />\n        <div className=\"space-y-1.5\">\n          <p className=\"font-semibold\">{t(\"settings.importPartialSuccess\")}</p>\n          <p className=\"text-yellow-600/80 dark:text-yellow-400/80\">\n            {t(\"settings.importPartialHint\")}\n          </p>\n        </div>\n      </div>\n    );\n  }\n\n  const message = errorMessage || t(\"settings.importFailed\");\n\n  return (\n    <div\n      className={`${baseClass} border-red-500/30 bg-red-500/10 text-red-600 dark:text-red-400`}\n    >\n      <AlertCircle className=\"mt-0.5 h-5 w-5 flex-shrink-0\" />\n      <div className=\"space-y-1.5\">\n        <p className=\"font-semibold\">{t(\"settings.importFailed\")}</p>\n        <p className=\"text-red-600/80 dark:text-red-400/80\">{message}</p>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/settings/LanguageSettings.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\nimport { useTranslation } from \"react-i18next\";\n\ntype LanguageOption = \"zh\" | \"en\" | \"ja\";\n\ninterface LanguageSettingsProps {\n  value: LanguageOption;\n  onChange: (value: LanguageOption) => void;\n}\n\nexport function LanguageSettings({ value, onChange }: LanguageSettingsProps) {\n  const { t } = useTranslation();\n\n  return (\n    <section className=\"space-y-2\">\n      <header className=\"space-y-1\">\n        <h3 className=\"text-sm font-medium\">{t(\"settings.language\")}</h3>\n        <p className=\"text-xs text-muted-foreground\">\n          {t(\"settings.languageHint\")}\n        </p>\n      </header>\n      <div className=\"inline-flex gap-1 rounded-md border border-border-default bg-background p-1\">\n        <LanguageButton active={value === \"zh\"} onClick={() => onChange(\"zh\")}>\n          {t(\"settings.languageOptionChinese\")}\n        </LanguageButton>\n        <LanguageButton active={value === \"en\"} onClick={() => onChange(\"en\")}>\n          {t(\"settings.languageOptionEnglish\")}\n        </LanguageButton>\n        <LanguageButton active={value === \"ja\"} onClick={() => onChange(\"ja\")}>\n          {t(\"settings.languageOptionJapanese\")}\n        </LanguageButton>\n      </div>\n    </section>\n  );\n}\n\ninterface LanguageButtonProps {\n  active: boolean;\n  onClick: () => void;\n  children: React.ReactNode;\n}\n\nfunction LanguageButton({ active, onClick, children }: LanguageButtonProps) {\n  return (\n    <Button\n      type=\"button\"\n      onClick={onClick}\n      size=\"sm\"\n      variant={active ? \"default\" : \"ghost\"}\n      className={cn(\n        \"min-w-[96px]\",\n        active\n          ? \"shadow-sm\"\n          : \"text-muted-foreground hover:text-foreground hover:bg-muted\",\n      )}\n    >\n      {children}\n    </Button>\n  );\n}\n"
  },
  {
    "path": "src/components/settings/LogConfigPanel.tsx",
    "content": "import { useEffect, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { toast } from \"sonner\";\nimport { Switch } from \"@/components/ui/switch\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { settingsApi, type LogConfig } from \"@/lib/api/settings\";\n\nconst LOG_LEVELS = [\"error\", \"warn\", \"info\", \"debug\", \"trace\"] as const;\n\nexport function LogConfigPanel() {\n  const { t } = useTranslation();\n  const [config, setConfig] = useState<LogConfig>({\n    enabled: true,\n    level: \"info\",\n  });\n  const [isLoading, setIsLoading] = useState(true);\n\n  useEffect(() => {\n    settingsApi\n      .getLogConfig()\n      .then(setConfig)\n      .catch((e) => console.error(\"Failed to load log config:\", e))\n      .finally(() => setIsLoading(false));\n  }, []);\n\n  const handleChange = async (updates: Partial<LogConfig>) => {\n    const newConfig = { ...config, ...updates };\n    setConfig(newConfig);\n    try {\n      await settingsApi.setLogConfig(newConfig);\n    } catch (e) {\n      console.error(\"Failed to save log config:\", e);\n      toast.error(String(e));\n      setConfig(config);\n    }\n  };\n\n  if (isLoading) return null;\n\n  return (\n    <div className=\"space-y-6\">\n      <div className=\"flex items-center justify-between\">\n        <div className=\"space-y-0.5\">\n          <Label>{t(\"settings.advanced.logConfig.enabled\")}</Label>\n          <p className=\"text-xs text-muted-foreground\">\n            {t(\"settings.advanced.logConfig.enabledDescription\")}\n          </p>\n        </div>\n        <Switch\n          checked={config.enabled}\n          onCheckedChange={(checked) => handleChange({ enabled: checked })}\n        />\n      </div>\n\n      <div className=\"flex items-center justify-between\">\n        <div className=\"space-y-0.5\">\n          <Label>{t(\"settings.advanced.logConfig.level\")}</Label>\n          <p className=\"text-xs text-muted-foreground\">\n            {t(\"settings.advanced.logConfig.levelDescription\")}\n          </p>\n        </div>\n        <Select\n          value={config.level}\n          disabled={!config.enabled}\n          onValueChange={(value) =>\n            handleChange({ level: value as LogConfig[\"level\"] })\n          }\n        >\n          <SelectTrigger className=\"w-[120px]\">\n            <SelectValue />\n          </SelectTrigger>\n          <SelectContent>\n            {LOG_LEVELS.map((level) => (\n              <SelectItem key={level} value={level}>\n                {t(`settings.advanced.logConfig.levels.${level}`)}\n              </SelectItem>\n            ))}\n          </SelectContent>\n        </Select>\n      </div>\n\n      {/* 日志级别说明 */}\n      <div className=\"rounded-lg bg-muted/50 p-4 text-xs space-y-1.5\">\n        <p className=\"font-medium text-muted-foreground mb-2\">\n          {t(\"settings.advanced.logConfig.levelHint\")}\n        </p>\n        <div className=\"grid gap-1 text-muted-foreground\">\n          <p>\n            <span className=\"font-mono text-red-500\">error</span> -{\" \"}\n            {t(\"settings.advanced.logConfig.levelDesc.error\")}\n          </p>\n          <p>\n            <span className=\"font-mono text-orange-500\">warn</span> -{\" \"}\n            {t(\"settings.advanced.logConfig.levelDesc.warn\")}\n          </p>\n          <p>\n            <span className=\"font-mono text-blue-500\">info</span> -{\" \"}\n            {t(\"settings.advanced.logConfig.levelDesc.info\")}\n          </p>\n          <p>\n            <span className=\"font-mono text-green-500\">debug</span> -{\" \"}\n            {t(\"settings.advanced.logConfig.levelDesc.debug\")}\n          </p>\n          <p>\n            <span className=\"font-mono text-gray-500\">trace</span> -{\" \"}\n            {t(\"settings.advanced.logConfig.levelDesc.trace\")}\n          </p>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/settings/ProxyTabContent.tsx",
    "content": "import { useState } from \"react\";\nimport { Server, Activity, Zap, Globe, ShieldAlert } from \"lucide-react\";\nimport { motion } from \"framer-motion\";\nimport { useTranslation } from \"react-i18next\";\nimport {\n  Accordion,\n  AccordionContent,\n  AccordionItem,\n  AccordionTrigger,\n} from \"@/components/ui/accordion\";\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from \"@/components/ui/tabs\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { ProxyPanel } from \"@/components/proxy\";\nimport { AutoFailoverConfigPanel } from \"@/components/proxy/AutoFailoverConfigPanel\";\nimport { FailoverQueueManager } from \"@/components/proxy/FailoverQueueManager\";\nimport { RectifierConfigPanel } from \"@/components/settings/RectifierConfigPanel\";\nimport { GlobalProxySettings } from \"@/components/settings/GlobalProxySettings\";\nimport { ConfirmDialog } from \"@/components/ConfirmDialog\";\nimport { ToggleRow } from \"@/components/ui/toggle-row\";\nimport { useProxyStatus } from \"@/hooks/useProxyStatus\";\nimport type { SettingsFormState } from \"@/hooks/useSettings\";\n\ninterface ProxyTabContentProps {\n  settings: SettingsFormState;\n  onAutoSave: (updates: Partial<SettingsFormState>) => Promise<void>;\n}\n\nexport function ProxyTabContent({\n  settings,\n  onAutoSave,\n}: ProxyTabContentProps) {\n  const { t } = useTranslation();\n  const [showProxyConfirm, setShowProxyConfirm] = useState(false);\n  const [showFailoverConfirm, setShowFailoverConfirm] = useState(false);\n\n  const {\n    isRunning,\n    startProxyServer,\n    stopWithRestore,\n    isPending: isProxyPending,\n  } = useProxyStatus();\n\n  const handleToggleProxy = async (checked: boolean) => {\n    try {\n      if (!checked) {\n        await stopWithRestore();\n      } else if (!settings?.proxyConfirmed) {\n        setShowProxyConfirm(true);\n      } else {\n        await startProxyServer();\n      }\n    } catch (error) {\n      console.error(\"Toggle proxy failed:\", error);\n    }\n  };\n\n  const handleProxyConfirm = async () => {\n    setShowProxyConfirm(false);\n    try {\n      await onAutoSave({ proxyConfirmed: true });\n      await startProxyServer();\n    } catch (error) {\n      console.error(\"Proxy confirm failed:\", error);\n    }\n  };\n\n  const handleFailoverToggleChange = (checked: boolean) => {\n    if (checked && !settings?.failoverConfirmed) {\n      setShowFailoverConfirm(true);\n    } else {\n      void onAutoSave({ enableFailoverToggle: checked });\n    }\n  };\n\n  const handleFailoverConfirm = async () => {\n    setShowFailoverConfirm(false);\n    try {\n      await onAutoSave({ failoverConfirmed: true, enableFailoverToggle: true });\n    } catch (error) {\n      console.error(\"Failover confirm failed:\", error);\n    }\n  };\n\n  return (\n    <motion.div\n      initial={{ opacity: 0, y: 10 }}\n      animate={{ opacity: 1, y: 0 }}\n      transition={{ duration: 0.3 }}\n      className=\"space-y-4\"\n    >\n      <Accordion type=\"multiple\" defaultValue={[]} className=\"w-full space-y-4\">\n        {/* Local Proxy */}\n        <AccordionItem\n          value=\"proxy\"\n          className=\"rounded-xl glass-card overflow-hidden\"\n        >\n          <AccordionTrigger className=\"px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50\">\n            <div className=\"flex items-center gap-3\">\n              <Server className=\"h-5 w-5 text-green-500\" />\n              <div className=\"text-left\">\n                <h3 className=\"text-base font-semibold\">\n                  {t(\"settings.advanced.proxy.title\")}\n                </h3>\n                <p className=\"text-sm text-muted-foreground font-normal\">\n                  {t(\"settings.advanced.proxy.description\")}\n                </p>\n              </div>\n              <Badge\n                variant={isRunning ? \"default\" : \"secondary\"}\n                className=\"gap-1.5 h-6 ml-auto mr-2\"\n              >\n                <Activity\n                  className={`h-3 w-3 ${isRunning ? \"animate-pulse\" : \"\"}`}\n                />\n                {isRunning\n                  ? t(\"settings.advanced.proxy.running\")\n                  : t(\"settings.advanced.proxy.stopped\")}\n              </Badge>\n            </div>\n          </AccordionTrigger>\n          <AccordionContent className=\"px-6 pb-6 pt-4 border-t border-border/50\">\n            <ProxyPanel\n              enableLocalProxy={settings?.enableLocalProxy ?? false}\n              onEnableLocalProxyChange={(checked) =>\n                onAutoSave({ enableLocalProxy: checked })\n              }\n              onToggleProxy={handleToggleProxy}\n              isProxyPending={isProxyPending}\n            />\n          </AccordionContent>\n        </AccordionItem>\n\n        {/* Auto Failover */}\n        <AccordionItem\n          value=\"failover\"\n          className=\"rounded-xl glass-card overflow-hidden\"\n        >\n          <AccordionTrigger className=\"px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50\">\n            <div className=\"flex items-center gap-3\">\n              <Activity className=\"h-5 w-5 text-orange-500\" />\n              <div className=\"text-left\">\n                <h3 className=\"text-base font-semibold\">\n                  {t(\"settings.advanced.failover.title\")}\n                </h3>\n                <p className=\"text-sm text-muted-foreground font-normal\">\n                  {t(\"settings.advanced.failover.description\")}\n                </p>\n              </div>\n            </div>\n          </AccordionTrigger>\n          <AccordionContent className=\"px-6 pb-6 pt-4 border-t border-border/50\">\n            <div className=\"space-y-6\">\n              <ToggleRow\n                icon={<ShieldAlert className=\"h-4 w-4 text-orange-500\" />}\n                title={t(\"settings.advanced.proxy.enableFailoverToggle\")}\n                description={t(\n                  \"settings.advanced.proxy.enableFailoverToggleDescription\",\n                )}\n                checked={settings?.enableFailoverToggle ?? false}\n                onCheckedChange={handleFailoverToggleChange}\n              />\n\n              {!isRunning && (\n                <div className=\"p-4 rounded-lg bg-yellow-500/10 border border-yellow-500/20\">\n                  <p className=\"text-sm text-yellow-600 dark:text-yellow-400\">\n                    {t(\"proxy.failover.proxyRequired\", {\n                      defaultValue: \"需要先启动代理服务才能配置故障转移\",\n                    })}\n                  </p>\n                </div>\n              )}\n\n              <Tabs defaultValue=\"claude\" className=\"w-full\">\n                <TabsList className=\"grid w-full grid-cols-3\">\n                  <TabsTrigger value=\"claude\">Claude</TabsTrigger>\n                  <TabsTrigger value=\"codex\">Codex</TabsTrigger>\n                  <TabsTrigger value=\"gemini\">Gemini</TabsTrigger>\n                </TabsList>\n                <TabsContent value=\"claude\" className=\"mt-4 space-y-6\">\n                  <div className=\"space-y-4\">\n                    <div>\n                      <h4 className=\"text-sm font-semibold\">\n                        {t(\"proxy.failoverQueue.title\")}\n                      </h4>\n                      <p className=\"text-xs text-muted-foreground\">\n                        {t(\"proxy.failoverQueue.description\")}\n                      </p>\n                    </div>\n                    <FailoverQueueManager\n                      appType=\"claude\"\n                      disabled={!isRunning}\n                    />\n                  </div>\n                  <div className=\"border-t border-border/50 pt-6\">\n                    <AutoFailoverConfigPanel\n                      appType=\"claude\"\n                      disabled={!isRunning}\n                    />\n                  </div>\n                </TabsContent>\n                <TabsContent value=\"codex\" className=\"mt-4 space-y-6\">\n                  <div className=\"space-y-4\">\n                    <div>\n                      <h4 className=\"text-sm font-semibold\">\n                        {t(\"proxy.failoverQueue.title\")}\n                      </h4>\n                      <p className=\"text-xs text-muted-foreground\">\n                        {t(\"proxy.failoverQueue.description\")}\n                      </p>\n                    </div>\n                    <FailoverQueueManager\n                      appType=\"codex\"\n                      disabled={!isRunning}\n                    />\n                  </div>\n                  <div className=\"border-t border-border/50 pt-6\">\n                    <AutoFailoverConfigPanel\n                      appType=\"codex\"\n                      disabled={!isRunning}\n                    />\n                  </div>\n                </TabsContent>\n                <TabsContent value=\"gemini\" className=\"mt-4 space-y-6\">\n                  <div className=\"space-y-4\">\n                    <div>\n                      <h4 className=\"text-sm font-semibold\">\n                        {t(\"proxy.failoverQueue.title\")}\n                      </h4>\n                      <p className=\"text-xs text-muted-foreground\">\n                        {t(\"proxy.failoverQueue.description\")}\n                      </p>\n                    </div>\n                    <FailoverQueueManager\n                      appType=\"gemini\"\n                      disabled={!isRunning}\n                    />\n                  </div>\n                  <div className=\"border-t border-border/50 pt-6\">\n                    <AutoFailoverConfigPanel\n                      appType=\"gemini\"\n                      disabled={!isRunning}\n                    />\n                  </div>\n                </TabsContent>\n              </Tabs>\n            </div>\n          </AccordionContent>\n        </AccordionItem>\n\n        {/* Rectifier */}\n        <AccordionItem\n          value=\"rectifier\"\n          className=\"rounded-xl glass-card overflow-hidden\"\n        >\n          <AccordionTrigger className=\"px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50\">\n            <div className=\"flex items-center gap-3\">\n              <Zap className=\"h-5 w-5 text-purple-500\" />\n              <div className=\"text-left\">\n                <h3 className=\"text-base font-semibold\">\n                  {t(\"settings.advanced.rectifier.title\")}\n                </h3>\n                <p className=\"text-sm text-muted-foreground font-normal\">\n                  {t(\"settings.advanced.rectifier.description\")}\n                </p>\n              </div>\n            </div>\n          </AccordionTrigger>\n          <AccordionContent className=\"px-6 pb-6 pt-4 border-t border-border/50\">\n            <RectifierConfigPanel />\n          </AccordionContent>\n        </AccordionItem>\n\n        {/* Global Outbound Proxy */}\n        <AccordionItem\n          value=\"globalProxy\"\n          className=\"rounded-xl glass-card overflow-hidden\"\n        >\n          <AccordionTrigger className=\"px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50\">\n            <div className=\"flex items-center gap-3\">\n              <Globe className=\"h-5 w-5 text-cyan-500\" />\n              <div className=\"text-left\">\n                <h3 className=\"text-base font-semibold\">\n                  {t(\"settings.advanced.globalProxy.title\")}\n                </h3>\n                <p className=\"text-sm text-muted-foreground font-normal\">\n                  {t(\"settings.advanced.globalProxy.description\")}\n                </p>\n              </div>\n            </div>\n          </AccordionTrigger>\n          <AccordionContent className=\"px-6 pb-6 pt-4 border-t border-border/50\">\n            <GlobalProxySettings />\n          </AccordionContent>\n        </AccordionItem>\n      </Accordion>\n\n      <ConfirmDialog\n        isOpen={showProxyConfirm}\n        variant=\"info\"\n        title={t(\"confirm.proxy.title\")}\n        message={t(\"confirm.proxy.message\")}\n        confirmText={t(\"confirm.proxy.confirm\")}\n        onConfirm={() => void handleProxyConfirm()}\n        onCancel={() => setShowProxyConfirm(false)}\n      />\n\n      <ConfirmDialog\n        isOpen={showFailoverConfirm}\n        variant=\"info\"\n        title={t(\"confirm.failover.title\")}\n        message={t(\"confirm.failover.message\")}\n        confirmText={t(\"confirm.failover.confirm\")}\n        onConfirm={() => void handleFailoverConfirm()}\n        onCancel={() => setShowFailoverConfirm(false)}\n      />\n    </motion.div>\n  );\n}\n"
  },
  {
    "path": "src/components/settings/RectifierConfigPanel.tsx",
    "content": "import { useEffect, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { toast } from \"sonner\";\nimport { Switch } from \"@/components/ui/switch\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n  settingsApi,\n  type RectifierConfig,\n  type OptimizerConfig,\n} from \"@/lib/api/settings\";\n\nexport function RectifierConfigPanel() {\n  const { t } = useTranslation();\n  const [config, setConfig] = useState<RectifierConfig>({\n    enabled: true,\n    requestThinkingSignature: true,\n    requestThinkingBudget: true,\n  });\n  const [optimizerConfig, setOptimizerConfig] = useState<OptimizerConfig>({\n    enabled: false,\n    thinkingOptimizer: true,\n    cacheInjection: true,\n    cacheTtl: \"1h\",\n  });\n  const [isLoading, setIsLoading] = useState(true);\n\n  useEffect(() => {\n    settingsApi\n      .getRectifierConfig()\n      .then(setConfig)\n      .catch((e) => console.error(\"Failed to load rectifier config:\", e))\n      .finally(() => setIsLoading(false));\n    settingsApi\n      .getOptimizerConfig()\n      .then(setOptimizerConfig)\n      .catch((e) => console.error(\"Failed to load optimizer config:\", e));\n  }, []);\n\n  const handleChange = async (updates: Partial<RectifierConfig>) => {\n    const newConfig = { ...config, ...updates };\n    setConfig(newConfig);\n    try {\n      await settingsApi.setRectifierConfig(newConfig);\n    } catch (e) {\n      console.error(\"Failed to save rectifier config:\", e);\n      toast.error(String(e));\n      setConfig(config);\n    }\n  };\n\n  const handleOptimizerChange = async (updates: Partial<OptimizerConfig>) => {\n    const newConfig = { ...optimizerConfig, ...updates };\n    setOptimizerConfig(newConfig);\n    try {\n      await settingsApi.setOptimizerConfig(newConfig);\n    } catch (e) {\n      console.error(\"Failed to save optimizer config:\", e);\n      toast.error(String(e));\n      setOptimizerConfig(optimizerConfig);\n    }\n  };\n\n  if (isLoading) return null;\n\n  return (\n    <div className=\"space-y-6\">\n      <div className=\"flex items-center justify-between\">\n        <div className=\"space-y-0.5\">\n          <Label>{t(\"settings.advanced.rectifier.enabled\")}</Label>\n          <p className=\"text-xs text-muted-foreground\">\n            {t(\"settings.advanced.rectifier.enabledDescription\")}\n          </p>\n        </div>\n        <Switch\n          checked={config.enabled}\n          onCheckedChange={(checked) => handleChange({ enabled: checked })}\n        />\n      </div>\n\n      <div className=\"space-y-4\">\n        <h4 className=\"text-sm font-medium text-muted-foreground\">\n          {t(\"settings.advanced.rectifier.requestGroup\")}\n        </h4>\n        <div className=\"flex items-center justify-between pl-4\">\n          <div className=\"space-y-0.5\">\n            <Label>{t(\"settings.advanced.rectifier.thinkingSignature\")}</Label>\n            <p className=\"text-xs text-muted-foreground\">\n              {t(\"settings.advanced.rectifier.thinkingSignatureDescription\")}\n            </p>\n          </div>\n          <Switch\n            checked={config.requestThinkingSignature}\n            disabled={!config.enabled}\n            onCheckedChange={(checked) =>\n              handleChange({ requestThinkingSignature: checked })\n            }\n          />\n        </div>\n        <div className=\"flex items-center justify-between pl-4\">\n          <div className=\"space-y-0.5\">\n            <Label>{t(\"settings.advanced.rectifier.thinkingBudget\")}</Label>\n            <p className=\"text-xs text-muted-foreground\">\n              {t(\"settings.advanced.rectifier.thinkingBudgetDescription\")}\n            </p>\n          </div>\n          <Switch\n            checked={config.requestThinkingBudget}\n            disabled={!config.enabled}\n            onCheckedChange={(checked) =>\n              handleChange({ requestThinkingBudget: checked })\n            }\n          />\n        </div>\n      </div>\n\n      <div className=\"border-t pt-6 mt-6\">\n        <div className=\"space-y-1 mb-4\">\n          <h3 className=\"text-sm font-medium\">\n            {t(\"settings.advanced.optimizer.title\")}\n          </h3>\n          <p className=\"text-xs text-muted-foreground\">\n            {t(\"settings.advanced.optimizer.description\")}\n          </p>\n        </div>\n\n        <div className=\"space-y-4\">\n          <div className=\"flex items-center justify-between\">\n            <div className=\"space-y-0.5\">\n              <Label>{t(\"settings.advanced.optimizer.enabled\")}</Label>\n            </div>\n            <Switch\n              checked={optimizerConfig.enabled}\n              onCheckedChange={(checked) =>\n                handleOptimizerChange({ enabled: checked })\n              }\n            />\n          </div>\n\n          <div className=\"space-y-4 pl-4\">\n            <div className=\"flex items-center justify-between\">\n              <div className=\"space-y-0.5\">\n                <Label>\n                  {t(\"settings.advanced.optimizer.thinkingOptimizer\")}\n                </Label>\n                <p className=\"text-xs text-muted-foreground\">\n                  {t(\n                    \"settings.advanced.optimizer.thinkingOptimizerDescription\",\n                  )}\n                </p>\n              </div>\n              <Switch\n                checked={optimizerConfig.thinkingOptimizer}\n                disabled={!optimizerConfig.enabled}\n                onCheckedChange={(checked) =>\n                  handleOptimizerChange({ thinkingOptimizer: checked })\n                }\n              />\n            </div>\n\n            <div className=\"flex items-center justify-between\">\n              <div className=\"space-y-0.5\">\n                <Label>{t(\"settings.advanced.optimizer.cacheInjection\")}</Label>\n                <p className=\"text-xs text-muted-foreground\">\n                  {t(\"settings.advanced.optimizer.cacheInjectionDescription\")}\n                </p>\n              </div>\n              <Switch\n                checked={optimizerConfig.cacheInjection}\n                disabled={!optimizerConfig.enabled}\n                onCheckedChange={(checked) =>\n                  handleOptimizerChange({ cacheInjection: checked })\n                }\n              />\n            </div>\n\n            {optimizerConfig.cacheInjection && (\n              <div className=\"flex items-center justify-between\">\n                <div className=\"space-y-0.5\">\n                  <Label>{t(\"settings.advanced.optimizer.cacheTtl\")}</Label>\n                </div>\n                <select\n                  className=\"h-9 rounded-md border border-input bg-background px-3 text-sm\"\n                  value={optimizerConfig.cacheTtl}\n                  disabled={\n                    !optimizerConfig.enabled || !optimizerConfig.cacheInjection\n                  }\n                  onChange={(e) =>\n                    handleOptimizerChange({ cacheTtl: e.target.value })\n                  }\n                >\n                  <option value=\"5m\">\n                    {t(\"settings.advanced.optimizer.cacheTtl5m\")}\n                  </option>\n                  <option value=\"1h\">\n                    {t(\"settings.advanced.optimizer.cacheTtl1h\")}\n                  </option>\n                </select>\n              </div>\n            )}\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/settings/SettingsPage.tsx",
    "content": "import { useCallback, useEffect, useMemo, useState } from \"react\";\nimport { motion } from \"framer-motion\";\nimport {\n  Loader2,\n  Save,\n  FolderSearch,\n  Database,\n  Cloud,\n  ScrollText,\n  HardDriveDownload,\n  FlaskConical,\n  KeyRound,\n} from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport {\n  Accordion,\n  AccordionContent,\n  AccordionItem,\n  AccordionTrigger,\n} from \"@/components/ui/accordion\";\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from \"@/components/ui/tabs\";\nimport { Button } from \"@/components/ui/button\";\nimport { settingsApi } from \"@/lib/api\";\nimport { LanguageSettings } from \"@/components/settings/LanguageSettings\";\nimport { ThemeSettings } from \"@/components/settings/ThemeSettings\";\nimport { WindowSettings } from \"@/components/settings/WindowSettings\";\nimport { AppVisibilitySettings } from \"@/components/settings/AppVisibilitySettings\";\nimport { SkillSyncMethodSettings } from \"@/components/settings/SkillSyncMethodSettings\";\nimport { TerminalSettings } from \"@/components/settings/TerminalSettings\";\nimport { DirectorySettings } from \"@/components/settings/DirectorySettings\";\nimport { ImportExportSection } from \"@/components/settings/ImportExportSection\";\nimport { BackupListSection } from \"@/components/settings/BackupListSection\";\nimport { WebdavSyncSection } from \"@/components/settings/WebdavSyncSection\";\nimport { AboutSection } from \"@/components/settings/AboutSection\";\nimport { ProxyTabContent } from \"@/components/settings/ProxyTabContent\";\nimport { ModelTestConfigPanel } from \"@/components/usage/ModelTestConfigPanel\";\nimport { UsageDashboard } from \"@/components/usage/UsageDashboard\";\nimport { LogConfigPanel } from \"@/components/settings/LogConfigPanel\";\nimport { AuthCenterPanel } from \"@/components/settings/AuthCenterPanel\";\nimport { useSettings } from \"@/hooks/useSettings\";\nimport { useImportExport } from \"@/hooks/useImportExport\";\nimport { useTranslation } from \"react-i18next\";\nimport type { SettingsFormState } from \"@/hooks/useSettings\";\n\ninterface SettingsDialogProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  onImportSuccess?: () => void | Promise<void>;\n  defaultTab?: string;\n}\n\nexport function SettingsPage({\n  open,\n  onOpenChange,\n  onImportSuccess,\n  defaultTab = \"general\",\n}: SettingsDialogProps) {\n  const { t } = useTranslation();\n  const {\n    settings,\n    isLoading,\n    isSaving,\n    isPortable,\n    appConfigDir,\n    resolvedDirs,\n    updateSettings,\n    updateDirectory,\n    updateAppConfigDir,\n    browseDirectory,\n    browseAppConfigDir,\n    resetDirectory,\n    resetAppConfigDir,\n    saveSettings,\n    autoSaveSettings,\n    requiresRestart,\n    acknowledgeRestart,\n  } = useSettings();\n\n  const {\n    selectedFile,\n    status: importStatus,\n    errorMessage,\n    backupId,\n    isImporting,\n    selectImportFile,\n    importConfig,\n    exportConfig,\n    clearSelection,\n    resetStatus,\n  } = useImportExport({ onImportSuccess });\n\n  const [activeTab, setActiveTab] = useState<string>(\"general\");\n  const [showRestartPrompt, setShowRestartPrompt] = useState(false);\n\n  useEffect(() => {\n    if (open) {\n      setActiveTab(defaultTab);\n      resetStatus();\n    }\n  }, [open, resetStatus, defaultTab]);\n\n  useEffect(() => {\n    if (requiresRestart) {\n      setShowRestartPrompt(true);\n    }\n  }, [requiresRestart]);\n\n  const closeAfterSave = useCallback(() => {\n    // 保存成功后关闭：不再重置语言，避免需要“保存两次”才生效\n    acknowledgeRestart();\n    clearSelection();\n    resetStatus();\n    onOpenChange(false);\n  }, [acknowledgeRestart, clearSelection, onOpenChange, resetStatus]);\n\n  const handleSave = useCallback(async () => {\n    try {\n      const result = await saveSettings(undefined, { silent: false });\n      if (!result) return;\n      if (result.requiresRestart) {\n        setShowRestartPrompt(true);\n        return;\n      }\n      closeAfterSave();\n    } catch (error) {\n      console.error(\"[SettingsPage] Failed to save settings\", error);\n    }\n  }, [closeAfterSave, saveSettings]);\n\n  const handleRestartLater = useCallback(() => {\n    setShowRestartPrompt(false);\n    closeAfterSave();\n  }, [closeAfterSave]);\n\n  const handleRestartNow = useCallback(async () => {\n    setShowRestartPrompt(false);\n    if (import.meta.env.DEV) {\n      toast.success(t(\"settings.devModeRestartHint\"), { closeButton: true });\n      closeAfterSave();\n      return;\n    }\n\n    try {\n      await settingsApi.restart();\n    } catch (error) {\n      console.error(\"[SettingsPage] Failed to restart app\", error);\n      toast.error(t(\"settings.restartFailed\"));\n    } finally {\n      closeAfterSave();\n    }\n  }, [closeAfterSave, t]);\n\n  // 通用设置即时保存（无需手动点击）\n  // 使用 autoSaveSettings 避免误触发系统 API（开机自启、Claude 插件等）\n  const handleAutoSave = useCallback(\n    async (updates: Partial<SettingsFormState>) => {\n      if (!settings) return;\n      updateSettings(updates);\n      try {\n        await autoSaveSettings(updates);\n      } catch (error) {\n        console.error(\"[SettingsPage] Failed to autosave settings\", error);\n        toast.error(\n          t(\"settings.saveFailedGeneric\", {\n            defaultValue: \"保存失败，请重试\",\n          }),\n        );\n      }\n    },\n    [autoSaveSettings, settings, t, updateSettings],\n  );\n\n  const isBusy = useMemo(() => isLoading && !settings, [isLoading, settings]);\n\n  return (\n    <div className=\"flex flex-col h-full overflow-hidden px-6\">\n      {isBusy ? (\n        <div className=\"flex flex-1 items-center justify-center\">\n          <Loader2 className=\"h-8 w-8 animate-spin text-muted-foreground\" />\n        </div>\n      ) : (\n        <Tabs\n          value={activeTab}\n          onValueChange={setActiveTab}\n          className=\"flex flex-col h-full\"\n        >\n          <TabsList className=\"grid w-full grid-cols-6 mb-6 glass rounded-lg\">\n            <TabsTrigger value=\"general\">\n              {t(\"settings.tabGeneral\")}\n            </TabsTrigger>\n            <TabsTrigger value=\"proxy\">{t(\"settings.tabProxy\")}</TabsTrigger>\n            <TabsTrigger value=\"auth\">\n              {t(\"settings.tabAuth\", { defaultValue: \"认证\" })}\n            </TabsTrigger>\n            <TabsTrigger value=\"advanced\">\n              {t(\"settings.tabAdvanced\")}\n            </TabsTrigger>\n            <TabsTrigger value=\"usage\">{t(\"usage.title\")}</TabsTrigger>\n            <TabsTrigger value=\"about\">{t(\"common.about\")}</TabsTrigger>\n          </TabsList>\n\n          <div className=\"flex-1 min-h-0 flex flex-col\">\n            <div className=\"flex-1 overflow-y-auto overflow-x-hidden pr-2\">\n              <TabsContent value=\"general\" className=\"space-y-6 mt-0\">\n                {settings ? (\n                  <motion.div\n                    initial={{ opacity: 0, y: 10 }}\n                    animate={{ opacity: 1, y: 0 }}\n                    transition={{ duration: 0.3 }}\n                    className=\"space-y-6\"\n                  >\n                    <LanguageSettings\n                      value={settings.language}\n                      onChange={(lang) => handleAutoSave({ language: lang })}\n                    />\n                    <ThemeSettings />\n                    <AppVisibilitySettings\n                      settings={settings}\n                      onChange={handleAutoSave}\n                    />\n                    <WindowSettings\n                      settings={settings}\n                      onChange={handleAutoSave}\n                    />\n                    <SkillSyncMethodSettings\n                      value={settings.skillSyncMethod ?? \"auto\"}\n                      onChange={(method) =>\n                        handleAutoSave({ skillSyncMethod: method })\n                      }\n                    />\n                    <TerminalSettings\n                      value={settings.preferredTerminal}\n                      onChange={(terminal) =>\n                        handleAutoSave({ preferredTerminal: terminal })\n                      }\n                    />\n                  </motion.div>\n                ) : null}\n              </TabsContent>\n\n              <TabsContent value=\"proxy\" className=\"space-y-6 mt-0 pb-4\">\n                {settings ? (\n                  <ProxyTabContent\n                    settings={settings}\n                    onAutoSave={handleAutoSave}\n                  />\n                ) : null}\n              </TabsContent>\n\n              <TabsContent value=\"auth\" className=\"space-y-6 mt-0 pb-4\">\n                <motion.div\n                  initial={{ opacity: 0, y: 10 }}\n                  animate={{ opacity: 1, y: 0 }}\n                  transition={{ duration: 0.3 }}\n                  className=\"space-y-6\"\n                >\n                  <div className=\"flex items-center gap-3 px-1\">\n                    <KeyRound className=\"h-5 w-5 text-primary\" />\n                    <div>\n                      <h2 className=\"text-base font-semibold\">\n                        {t(\"settings.authCenter.heading\", {\n                          defaultValue: \"认证中心\",\n                        })}\n                      </h2>\n                      <p className=\"text-sm text-muted-foreground\">\n                        {t(\"settings.authCenter.headingDescription\", {\n                          defaultValue:\n                            \"统一管理可跨应用复用的 OAuth 账号和默认认证来源。\",\n                        })}\n                      </p>\n                    </div>\n                  </div>\n\n                  <AuthCenterPanel />\n                </motion.div>\n              </TabsContent>\n\n              <TabsContent value=\"advanced\" className=\"space-y-6 mt-0 pb-4\">\n                {settings ? (\n                  <motion.div\n                    initial={{ opacity: 0, y: 10 }}\n                    animate={{ opacity: 1, y: 0 }}\n                    transition={{ duration: 0.3 }}\n                    className=\"space-y-4\"\n                  >\n                    <Accordion\n                      type=\"multiple\"\n                      defaultValue={[]}\n                      className=\"w-full space-y-4\"\n                    >\n                      <AccordionItem\n                        value=\"directory\"\n                        className=\"rounded-xl glass-card overflow-hidden\"\n                      >\n                        <AccordionTrigger className=\"px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50\">\n                          <div className=\"flex items-center gap-3\">\n                            <FolderSearch className=\"h-5 w-5 text-primary\" />\n                            <div className=\"text-left\">\n                              <h3 className=\"text-base font-semibold\">\n                                {t(\"settings.advanced.configDir.title\")}\n                              </h3>\n                              <p className=\"text-sm text-muted-foreground font-normal\">\n                                {t(\"settings.advanced.configDir.description\")}\n                              </p>\n                            </div>\n                          </div>\n                        </AccordionTrigger>\n                        <AccordionContent className=\"px-6 pb-6 pt-4 border-t border-border/50\">\n                          <DirectorySettings\n                            appConfigDir={appConfigDir}\n                            resolvedDirs={resolvedDirs}\n                            onAppConfigChange={updateAppConfigDir}\n                            onBrowseAppConfig={browseAppConfigDir}\n                            onResetAppConfig={resetAppConfigDir}\n                            claudeDir={settings.claudeConfigDir}\n                            codexDir={settings.codexConfigDir}\n                            geminiDir={settings.geminiConfigDir}\n                            opencodeDir={settings.opencodeConfigDir}\n                            onDirectoryChange={updateDirectory}\n                            onBrowseDirectory={browseDirectory}\n                            onResetDirectory={resetDirectory}\n                          />\n                        </AccordionContent>\n                      </AccordionItem>\n\n                      <AccordionItem\n                        value=\"data\"\n                        className=\"rounded-xl glass-card overflow-hidden\"\n                      >\n                        <AccordionTrigger className=\"px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50\">\n                          <div className=\"flex items-center gap-3\">\n                            <Database className=\"h-5 w-5 text-blue-500\" />\n                            <div className=\"text-left\">\n                              <h3 className=\"text-base font-semibold\">\n                                {t(\"settings.advanced.data.title\")}\n                              </h3>\n                              <p className=\"text-sm text-muted-foreground font-normal\">\n                                {t(\"settings.advanced.data.description\")}\n                              </p>\n                            </div>\n                          </div>\n                        </AccordionTrigger>\n                        <AccordionContent className=\"px-6 pb-6 pt-4 border-t border-border/50\">\n                          <ImportExportSection\n                            status={importStatus}\n                            selectedFile={selectedFile}\n                            errorMessage={errorMessage}\n                            backupId={backupId}\n                            isImporting={isImporting}\n                            onSelectFile={selectImportFile}\n                            onImport={importConfig}\n                            onExport={exportConfig}\n                            onClear={clearSelection}\n                          />\n                        </AccordionContent>\n                      </AccordionItem>\n\n                      <AccordionItem\n                        value=\"backup\"\n                        className=\"rounded-xl glass-card overflow-hidden\"\n                      >\n                        <AccordionTrigger className=\"px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50\">\n                          <div className=\"flex items-center gap-3\">\n                            <HardDriveDownload className=\"h-5 w-5 text-amber-500\" />\n                            <div className=\"text-left\">\n                              <h3 className=\"text-base font-semibold\">\n                                {t(\"settings.advanced.backup.title\", {\n                                  defaultValue: \"Backup & Restore\",\n                                })}\n                              </h3>\n                              <p className=\"text-sm text-muted-foreground font-normal\">\n                                {t(\"settings.advanced.backup.description\", {\n                                  defaultValue:\n                                    \"Manage automatic backups, view and restore database snapshots\",\n                                })}\n                              </p>\n                            </div>\n                          </div>\n                        </AccordionTrigger>\n                        <AccordionContent className=\"px-6 pb-6 pt-4 border-t border-border/50\">\n                          <BackupListSection\n                            backupIntervalHours={settings.backupIntervalHours}\n                            backupRetainCount={settings.backupRetainCount}\n                            onSettingsChange={(updates) =>\n                              handleAutoSave(updates)\n                            }\n                          />\n                        </AccordionContent>\n                      </AccordionItem>\n\n                      <AccordionItem\n                        value=\"cloudSync\"\n                        className=\"rounded-xl glass-card overflow-hidden\"\n                      >\n                        <AccordionTrigger className=\"px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50\">\n                          <div className=\"flex items-center gap-3\">\n                            <Cloud className=\"h-5 w-5 text-blue-500\" />\n                            <div className=\"text-left\">\n                              <h3 className=\"text-base font-semibold\">\n                                {t(\"settings.advanced.cloudSync.title\")}\n                              </h3>\n                              <p className=\"text-sm text-muted-foreground font-normal\">\n                                {t(\"settings.advanced.cloudSync.description\")}\n                              </p>\n                            </div>\n                          </div>\n                        </AccordionTrigger>\n                        <AccordionContent className=\"px-6 pb-6 pt-4 border-t border-border/50\">\n                          <WebdavSyncSection\n                            config={settings?.webdavSync}\n                            settings={settings}\n                            onAutoSave={handleAutoSave}\n                          />\n                        </AccordionContent>\n                      </AccordionItem>\n\n                      <AccordionItem\n                        value=\"test\"\n                        className=\"rounded-xl glass-card overflow-hidden\"\n                      >\n                        <AccordionTrigger className=\"px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50\">\n                          <div className=\"flex items-center gap-3\">\n                            <FlaskConical className=\"h-5 w-5 text-emerald-500\" />\n                            <div className=\"text-left\">\n                              <h3 className=\"text-base font-semibold\">\n                                {t(\"settings.advanced.modelTest.title\")}\n                              </h3>\n                              <p className=\"text-sm text-muted-foreground font-normal\">\n                                {t(\"settings.advanced.modelTest.description\")}\n                              </p>\n                            </div>\n                          </div>\n                        </AccordionTrigger>\n                        <AccordionContent className=\"px-6 pb-6 pt-4 border-t border-border/50\">\n                          <ModelTestConfigPanel />\n                        </AccordionContent>\n                      </AccordionItem>\n\n                      <AccordionItem\n                        value=\"logConfig\"\n                        className=\"rounded-xl glass-card overflow-hidden\"\n                      >\n                        <AccordionTrigger className=\"px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50\">\n                          <div className=\"flex items-center gap-3\">\n                            <ScrollText className=\"h-5 w-5 text-cyan-500\" />\n                            <div className=\"text-left\">\n                              <h3 className=\"text-base font-semibold\">\n                                {t(\"settings.advanced.logConfig.title\")}\n                              </h3>\n                              <p className=\"text-sm text-muted-foreground font-normal\">\n                                {t(\"settings.advanced.logConfig.description\")}\n                              </p>\n                            </div>\n                          </div>\n                        </AccordionTrigger>\n                        <AccordionContent className=\"px-6 pb-6 pt-4 border-t border-border/50\">\n                          <LogConfigPanel />\n                        </AccordionContent>\n                      </AccordionItem>\n                    </Accordion>\n                  </motion.div>\n                ) : null}\n              </TabsContent>\n\n              <TabsContent value=\"about\" className=\"mt-0\">\n                <AboutSection isPortable={isPortable} />\n              </TabsContent>\n\n              <TabsContent value=\"usage\" className=\"mt-0\">\n                <UsageDashboard />\n              </TabsContent>\n            </div>\n\n            {activeTab === \"advanced\" && settings && (\n              <div\n                className=\"flex-shrink-0 py-4 border-t border-border-default\"\n                style={{ backgroundColor: \"hsl(var(--background))\" }}\n              >\n                <div className=\"px-6 flex items-center justify-end gap-3\">\n                  <Button onClick={handleSave} disabled={isSaving}>\n                    {isSaving ? (\n                      <span className=\"inline-flex items-center gap-2\">\n                        <Loader2 className=\"h-4 w-4 animate-spin\" />\n                        {t(\"settings.saving\")}\n                      </span>\n                    ) : (\n                      <>\n                        <Save className=\"mr-2 h-4 w-4\" />\n                        {t(\"common.save\")}\n                      </>\n                    )}\n                  </Button>\n                </div>\n              </div>\n            )}\n          </div>\n        </Tabs>\n      )}\n\n      <Dialog\n        open={showRestartPrompt}\n        onOpenChange={(open) => !open && handleRestartLater()}\n      >\n        <DialogContent zIndex=\"alert\" className=\"max-w-md glass border-border\">\n          <DialogHeader>\n            <DialogTitle>{t(\"settings.restartRequired\")}</DialogTitle>\n          </DialogHeader>\n          <div className=\"px-6\">\n            <p className=\"text-sm text-muted-foreground\">\n              {t(\"settings.restartRequiredMessage\")}\n            </p>\n          </div>\n          <DialogFooter>\n            <Button\n              variant=\"ghost\"\n              onClick={handleRestartLater}\n              className=\"hover:bg-muted/50\"\n            >\n              {t(\"settings.restartLater\")}\n            </Button>\n            <Button\n              onClick={handleRestartNow}\n              className=\"bg-primary hover:bg-primary/90\"\n            >\n              {t(\"settings.restartNow\")}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/settings/SkillSyncMethodSettings.tsx",
    "content": "import { useTranslation } from \"react-i18next\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\nimport type { SkillSyncMethod } from \"@/types\";\n\nexport interface SkillSyncMethodSettingsProps {\n  value: SkillSyncMethod;\n  onChange: (value: SkillSyncMethod) => void;\n}\n\nexport function SkillSyncMethodSettings({\n  value,\n  onChange,\n}: SkillSyncMethodSettingsProps) {\n  const { t } = useTranslation();\n\n  // Handle default values: undefined or \"auto\" defaults to symlink display\n  const displayValue = value === \"copy\" ? \"copy\" : \"symlink\";\n\n  return (\n    <section className=\"space-y-2\">\n      <header className=\"space-y-1\">\n        <h3 className=\"text-sm font-medium\">{t(\"settings.skillSync.title\")}</h3>\n        <p className=\"text-xs text-muted-foreground\">\n          {t(\"settings.skillSync.description\")}\n        </p>\n      </header>\n      <div className=\"inline-flex gap-1 rounded-md border border-border-default bg-background p-1\">\n        <SyncMethodButton\n          active={displayValue === \"symlink\"}\n          onClick={() => onChange(\"symlink\")}\n        >\n          {t(\"settings.skillSync.symlink\")}\n        </SyncMethodButton>\n        <SyncMethodButton\n          active={displayValue === \"copy\"}\n          onClick={() => onChange(\"copy\")}\n        >\n          {t(\"settings.skillSync.copy\")}\n        </SyncMethodButton>\n      </div>\n      {displayValue === \"symlink\" && (\n        <p className=\"text-xs text-muted-foreground\">\n          {t(\"settings.skillSync.symlinkHint\")}\n        </p>\n      )}\n    </section>\n  );\n}\n\ninterface SyncMethodButtonProps {\n  active: boolean;\n  onClick: () => void;\n  children: React.ReactNode;\n}\n\nfunction SyncMethodButton({\n  active,\n  onClick,\n  children,\n}: SyncMethodButtonProps) {\n  return (\n    <Button\n      type=\"button\"\n      onClick={onClick}\n      size=\"sm\"\n      variant={active ? \"default\" : \"ghost\"}\n      className={cn(\n        \"min-w-[96px]\",\n        active\n          ? \"shadow-sm\"\n          : \"text-muted-foreground hover:text-foreground hover:bg-muted\",\n      )}\n    >\n      {children}\n    </Button>\n  );\n}\n"
  },
  {
    "path": "src/components/settings/TerminalSettings.tsx",
    "content": "import { useTranslation } from \"react-i18next\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { isMac, isWindows, isLinux } from \"@/lib/platform\";\n\n// Terminal options per platform\nconst MACOS_TERMINALS = [\n  { value: \"terminal\", labelKey: \"settings.terminal.options.macos.terminal\" },\n  { value: \"iterm2\", labelKey: \"settings.terminal.options.macos.iterm2\" },\n  { value: \"alacritty\", labelKey: \"settings.terminal.options.macos.alacritty\" },\n  { value: \"kitty\", labelKey: \"settings.terminal.options.macos.kitty\" },\n  { value: \"ghostty\", labelKey: \"settings.terminal.options.macos.ghostty\" },\n  { value: \"wezterm\", labelKey: \"settings.terminal.options.macos.wezterm\" },\n] as const;\n\nconst WINDOWS_TERMINALS = [\n  { value: \"cmd\", labelKey: \"settings.terminal.options.windows.cmd\" },\n  {\n    value: \"powershell\",\n    labelKey: \"settings.terminal.options.windows.powershell\",\n  },\n  { value: \"wt\", labelKey: \"settings.terminal.options.windows.wt\" },\n] as const;\n\nconst LINUX_TERMINALS = [\n  {\n    value: \"gnome-terminal\",\n    labelKey: \"settings.terminal.options.linux.gnomeTerminal\",\n  },\n  { value: \"konsole\", labelKey: \"settings.terminal.options.linux.konsole\" },\n  {\n    value: \"xfce4-terminal\",\n    labelKey: \"settings.terminal.options.linux.xfce4Terminal\",\n  },\n  { value: \"alacritty\", labelKey: \"settings.terminal.options.linux.alacritty\" },\n  { value: \"kitty\", labelKey: \"settings.terminal.options.linux.kitty\" },\n  { value: \"ghostty\", labelKey: \"settings.terminal.options.linux.ghostty\" },\n] as const;\n\n// Get terminals for the current platform\nfunction getTerminalOptions() {\n  if (isMac()) {\n    return MACOS_TERMINALS;\n  }\n  if (isWindows()) {\n    return WINDOWS_TERMINALS;\n  }\n  if (isLinux()) {\n    return LINUX_TERMINALS;\n  }\n  // Fallback to macOS options\n  return MACOS_TERMINALS;\n}\n\n// Get default terminal for the current platform\nfunction getDefaultTerminal(): string {\n  if (isMac()) {\n    return \"terminal\";\n  }\n  if (isWindows()) {\n    return \"cmd\";\n  }\n  if (isLinux()) {\n    return \"gnome-terminal\";\n  }\n  return \"terminal\";\n}\n\nexport interface TerminalSettingsProps {\n  value?: string;\n  onChange: (value: string) => void;\n}\n\nexport function TerminalSettings({ value, onChange }: TerminalSettingsProps) {\n  const { t } = useTranslation();\n  const terminals = getTerminalOptions();\n  const defaultTerminal = getDefaultTerminal();\n\n  // Use value or default\n  const currentValue = value || defaultTerminal;\n\n  return (\n    <section className=\"space-y-2\">\n      <header className=\"space-y-1\">\n        <h3 className=\"text-sm font-medium\">{t(\"settings.terminal.title\")}</h3>\n        <p className=\"text-xs text-muted-foreground\">\n          {t(\"settings.terminal.description\")}\n        </p>\n      </header>\n      <Select value={currentValue} onValueChange={onChange}>\n        <SelectTrigger className=\"w-[200px]\">\n          <SelectValue />\n        </SelectTrigger>\n        <SelectContent>\n          {terminals.map((terminal) => (\n            <SelectItem key={terminal.value} value={terminal.value}>\n              {t(terminal.labelKey)}\n            </SelectItem>\n          ))}\n        </SelectContent>\n      </Select>\n      <p className=\"text-xs text-muted-foreground\">\n        {t(\"settings.terminal.fallbackHint\")}\n      </p>\n    </section>\n  );\n}\n"
  },
  {
    "path": "src/components/settings/ThemeSettings.tsx",
    "content": "import { Monitor, Moon, Sun } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\nimport { useTranslation } from \"react-i18next\";\nimport { useTheme } from \"@/components/theme-provider\";\n\nexport function ThemeSettings() {\n  const { t } = useTranslation();\n  const { theme, setTheme } = useTheme();\n\n  return (\n    <section className=\"space-y-2\">\n      <header className=\"space-y-1\">\n        <h3 className=\"text-sm font-medium\">{t(\"settings.theme\")}</h3>\n        <p className=\"text-xs text-muted-foreground\">\n          {t(\"settings.themeHint\")}\n        </p>\n      </header>\n      <div className=\"inline-flex gap-1 rounded-md border border-border-default bg-background p-1\">\n        <ThemeButton\n          active={theme === \"light\"}\n          onClick={(e) => setTheme(\"light\", e)}\n          icon={Sun}\n        >\n          {t(\"settings.themeLight\")}\n        </ThemeButton>\n        <ThemeButton\n          active={theme === \"dark\"}\n          onClick={(e) => setTheme(\"dark\", e)}\n          icon={Moon}\n        >\n          {t(\"settings.themeDark\")}\n        </ThemeButton>\n        <ThemeButton\n          active={theme === \"system\"}\n          onClick={(e) => setTheme(\"system\", e)}\n          icon={Monitor}\n        >\n          {t(\"settings.themeSystem\")}\n        </ThemeButton>\n      </div>\n    </section>\n  );\n}\n\ninterface ThemeButtonProps {\n  active: boolean;\n  onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;\n  icon: React.ComponentType<{ className?: string }>;\n  children: React.ReactNode;\n}\n\nfunction ThemeButton({\n  active,\n  onClick,\n  icon: Icon,\n  children,\n}: ThemeButtonProps) {\n  return (\n    <Button\n      type=\"button\"\n      onClick={onClick}\n      size=\"sm\"\n      variant={active ? \"default\" : \"ghost\"}\n      className={cn(\n        \"min-w-[96px] gap-1.5\",\n        active\n          ? \"shadow-sm\"\n          : \"text-muted-foreground hover:text-foreground hover:bg-muted\",\n      )}\n    >\n      <Icon className=\"h-3.5 w-3.5\" />\n      {children}\n    </Button>\n  );\n}\n"
  },
  {
    "path": "src/components/settings/WebdavSyncSection.tsx",
    "content": "import { useCallback, useEffect, useRef, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport {\n  Link2,\n  UploadCloud,\n  DownloadCloud,\n  Loader2,\n  Save,\n  Check,\n  Info,\n  AlertTriangle,\n} from \"lucide-react\";\nimport type { LucideIcon } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\nimport { useQueryClient } from \"@tanstack/react-query\";\nimport { toast } from \"sonner\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Switch } from \"@/components/ui/switch\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { settingsApi } from \"@/lib/api\";\nimport { ConfirmDialog } from \"@/components/ConfirmDialog\";\nimport type { SettingsFormState } from \"@/hooks/useSettings\";\nimport type { RemoteSnapshotInfo, WebDavSyncSettings } from \"@/types\";\n\n// ─── WebDAV service presets ─────────────────────────────────\n\ninterface WebDavPreset {\n  id: string;\n  label: string;\n  baseUrl: string;\n  hint: string;\n  matchPattern?: string; // substring match on URL\n}\n\nconst WEBDAV_PRESETS: WebDavPreset[] = [\n  {\n    id: \"jianguoyun\",\n    label: \"settings.webdavSync.presets.jianguoyun\",\n    baseUrl: \"https://dav.jianguoyun.com/dav/\",\n    hint: \"settings.webdavSync.presets.jianguoyunHint\",\n    matchPattern: \"jianguoyun.com\",\n  },\n  {\n    id: \"nextcloud\",\n    label: \"settings.webdavSync.presets.nextcloud\",\n    baseUrl: \"https://your-server/remote.php/dav/files/USERNAME/\",\n    hint: \"settings.webdavSync.presets.nextcloudHint\",\n    matchPattern: \"remote.php/dav\",\n  },\n  {\n    id: \"synology\",\n    label: \"settings.webdavSync.presets.synology\",\n    baseUrl: \"http://your-nas-ip:5005/\",\n    hint: \"settings.webdavSync.presets.synologyHint\",\n    matchPattern: \":5005\",\n  },\n  {\n    id: \"custom\",\n    label: \"settings.webdavSync.presets.custom\",\n    baseUrl: \"\",\n    hint: \"\",\n  },\n];\n\n/** Match a URL to one of the preset providers, or \"custom\". */\nfunction detectPreset(url: string): string {\n  if (!url) return \"custom\";\n  for (const preset of WEBDAV_PRESETS) {\n    if (preset.matchPattern && url.includes(preset.matchPattern)) {\n      return preset.id;\n    }\n  }\n  return \"custom\";\n}\n\n/** Format an RFC 3339 date string for display; falls back to raw string. */\nfunction formatDate(rfc3339: string): string {\n  const d = new Date(rfc3339);\n  return Number.isNaN(d.getTime()) ? rfc3339 : d.toLocaleString();\n}\n\nfunction formatDbCompatVersion(version?: number | null): string | null {\n  return typeof version === \"number\" ? `db-v${version}` : null;\n}\n\n// ─── Types ──────────────────────────────────────────────────\n\ntype ActionState =\n  | \"idle\"\n  | \"testing\"\n  | \"saving\"\n  | \"uploading\"\n  | \"downloading\"\n  | \"fetching_remote\";\n\ntype DialogType = \"upload\" | \"download\" | null;\n\ninterface WebdavSyncSectionProps {\n  config?: WebDavSyncSettings;\n  settings?: SettingsFormState;\n  onAutoSave?: (updates: Partial<SettingsFormState>) => Promise<unknown>;\n}\n\n// ─── ActionButton ───────────────────────────────────────────\n\n/** Reusable button with loading spinner. */\nfunction ActionButton({\n  actionState,\n  targetState,\n  alsoActiveFor,\n  icon: Icon,\n  activeLabel,\n  idleLabel,\n  disabled,\n  ...props\n}: {\n  actionState: ActionState;\n  targetState: ActionState;\n  alsoActiveFor?: ActionState[];\n  icon: LucideIcon;\n  activeLabel: ReactNode;\n  idleLabel: ReactNode;\n} & Omit<React.ComponentPropsWithoutRef<typeof Button>, \"children\">) {\n  const isActive =\n    actionState === targetState ||\n    (alsoActiveFor?.includes(actionState) ?? false);\n  return (\n    <Button {...props} disabled={actionState !== \"idle\" || disabled}>\n      <span className=\"inline-flex items-center gap-2\">\n        {isActive ? (\n          <Loader2 className=\"h-3.5 w-3.5 animate-spin\" />\n        ) : (\n          <Icon className=\"h-3.5 w-3.5\" />\n        )}\n        {isActive ? activeLabel : idleLabel}\n      </span>\n    </Button>\n  );\n}\n\n// ─── Main component ─────────────────────────────────────────\n\nexport function WebdavSyncSection({\n  config,\n  settings,\n  onAutoSave,\n}: WebdavSyncSectionProps) {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const [actionState, setActionState] = useState<ActionState>(\"idle\");\n  const [dirty, setDirty] = useState(false);\n  const [passwordTouched, setPasswordTouched] = useState(false);\n  const [justSaved, setJustSaved] = useState(false);\n  const justSavedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  // Local form state — credentials are only persisted on explicit \"Save\".\n  const [form, setForm] = useState(() => ({\n    baseUrl: config?.baseUrl ?? \"\",\n    username: config?.username ?? \"\",\n    password: config?.password ?? \"\",\n    remoteRoot: config?.remoteRoot ?? \"cc-switch-sync\",\n    profile: config?.profile ?? \"default\",\n    autoSync: config?.autoSync ?? false,\n  }));\n\n  // Preset selector — derived from initial URL, updated on user selection\n  const [presetId, setPresetId] = useState(() =>\n    detectPreset(config?.baseUrl ?? \"\"),\n  );\n\n  const activePreset = WEBDAV_PRESETS.find((p) => p.id === presetId);\n\n  // Confirmation dialog state\n  const [dialogType, setDialogType] = useState<DialogType>(null);\n  const [remoteInfo, setRemoteInfo] = useState<RemoteSnapshotInfo | null>(null);\n  const [showAutoSyncConfirm, setShowAutoSyncConfirm] = useState(false);\n\n  const closeDialog = useCallback(() => {\n    setDialogType(null);\n    setRemoteInfo(null);\n  }, []);\n\n  // Cleanup justSaved timer on unmount\n  useEffect(() => {\n    return () => {\n      if (justSavedTimerRef.current) clearTimeout(justSavedTimerRef.current);\n    };\n  }, []);\n\n  // Sync form when config is loaded/updated from backend, but not while user is editing\n  useEffect(() => {\n    if (!config || dirty) return;\n    setForm({\n      baseUrl: config.baseUrl ?? \"\",\n      username: config.username ?? \"\",\n      password: config.password ?? \"\",\n      remoteRoot: config.remoteRoot ?? \"cc-switch-sync\",\n      profile: config.profile ?? \"default\",\n      autoSync: config.autoSync ?? false,\n    });\n    setPasswordTouched(false);\n    setPresetId(detectPreset(config.baseUrl ?? \"\"));\n  }, [config, dirty]);\n\n  const updateField = useCallback((field: keyof typeof form, value: string) => {\n    setForm((prev) => ({ ...prev, [field]: value }));\n    if (field === \"password\") {\n      setPasswordTouched(true);\n    }\n    setDirty(true);\n    setJustSaved(false);\n    if (justSavedTimerRef.current) {\n      clearTimeout(justSavedTimerRef.current);\n      justSavedTimerRef.current = null;\n    }\n  }, []);\n\n  const handlePresetChange = useCallback((id: string) => {\n    setPresetId(id);\n    const preset = WEBDAV_PRESETS.find((p) => p.id === id);\n    if (preset?.baseUrl) {\n      setForm((prev) => ({ ...prev, baseUrl: preset.baseUrl }));\n      setDirty(true);\n      setJustSaved(false);\n      if (justSavedTimerRef.current) {\n        clearTimeout(justSavedTimerRef.current);\n        justSavedTimerRef.current = null;\n      }\n    }\n  }, []);\n\n  // When user edits the URL, check if it still matches the current preset on blur\n  const handleBaseUrlBlur = useCallback(() => {\n    if (presetId === \"custom\") return;\n    const detected = detectPreset(form.baseUrl);\n    if (detected !== presetId) {\n      setPresetId(\"custom\");\n    }\n  }, [form.baseUrl, presetId]);\n\n  const handleAutoSyncChange = useCallback(\n    (checked: boolean) => {\n      if (checked && !settings?.autoSyncConfirmed) {\n        setShowAutoSyncConfirm(true);\n        return;\n      }\n      setForm((prev) => ({ ...prev, autoSync: checked }));\n      setDirty(true);\n      setJustSaved(false);\n      if (justSavedTimerRef.current) {\n        clearTimeout(justSavedTimerRef.current);\n        justSavedTimerRef.current = null;\n      }\n    },\n    [settings?.autoSyncConfirmed],\n  );\n\n  const handleAutoSyncConfirm = useCallback(async () => {\n    setShowAutoSyncConfirm(false);\n    await onAutoSave?.({ autoSyncConfirmed: true });\n    setForm((prev) => ({ ...prev, autoSync: true }));\n    setDirty(true);\n    setJustSaved(false);\n    if (justSavedTimerRef.current) {\n      clearTimeout(justSavedTimerRef.current);\n      justSavedTimerRef.current = null;\n    }\n  }, [onAutoSave]);\n\n  const buildSettings = useCallback((): WebDavSyncSettings | null => {\n    const baseUrl = form.baseUrl.trim();\n    if (!baseUrl) return null;\n    return {\n      enabled: true,\n      baseUrl,\n      username: form.username.trim(),\n      password: form.password,\n      remoteRoot: form.remoteRoot.trim() || \"cc-switch-sync\",\n      profile: form.profile.trim() || \"default\",\n      autoSync: form.autoSync,\n    };\n  }, [form]);\n\n  // ─── Handlers ───────────────────────────────────────────\n\n  const handleTest = useCallback(async () => {\n    const settings = buildSettings();\n    if (!settings) {\n      toast.error(t(\"settings.webdavSync.missingUrl\"));\n      return;\n    }\n    setActionState(\"testing\");\n    try {\n      await settingsApi.webdavTestConnection(settings, !passwordTouched);\n      toast.success(t(\"settings.webdavSync.testSuccess\"));\n    } catch (error) {\n      toast.error(\n        t(\"settings.webdavSync.testFailed\", {\n          error: (error as Error)?.message ?? String(error),\n        }),\n      );\n    } finally {\n      setActionState(\"idle\");\n    }\n  }, [buildSettings, passwordTouched, t]);\n\n  const handleSave = useCallback(async () => {\n    const settings = buildSettings();\n    if (!settings) {\n      toast.error(t(\"settings.webdavSync.missingUrl\"));\n      return;\n    }\n    setActionState(\"saving\");\n    try {\n      await settingsApi.webdavSyncSaveSettings(settings, passwordTouched);\n      setDirty(false);\n      setPasswordTouched(false);\n      // Show \"saved\" indicator for 2 seconds\n      setJustSaved(true);\n      if (justSavedTimerRef.current) clearTimeout(justSavedTimerRef.current);\n      justSavedTimerRef.current = setTimeout(() => {\n        setJustSaved(false);\n        justSavedTimerRef.current = null;\n      }, 2000);\n      await queryClient.invalidateQueries();\n    } catch (error) {\n      toast.error(\n        t(\"settings.webdavSync.saveFailed\", {\n          error: (error as Error)?.message ?? String(error),\n        }),\n      );\n      setActionState(\"idle\");\n      return;\n    }\n\n    // Auto-test connection after save\n    setActionState(\"testing\");\n    try {\n      await settingsApi.webdavTestConnection(settings, true);\n      toast.success(t(\"settings.webdavSync.saveAndTestSuccess\"));\n    } catch (error) {\n      toast.warning(\n        t(\"settings.webdavSync.saveAndTestFailed\", {\n          error: (error as Error)?.message ?? String(error),\n        }),\n      );\n    } finally {\n      setActionState(\"idle\");\n    }\n  }, [buildSettings, passwordTouched, queryClient, t]);\n\n  /** Fetch remote info, then open upload confirmation dialog. */\n  const handleUploadClick = useCallback(async () => {\n    if (dirty) {\n      toast.error(t(\"settings.webdavSync.unsavedChanges\"));\n      return;\n    }\n    setActionState(\"fetching_remote\");\n    try {\n      const info = await settingsApi.webdavSyncFetchRemoteInfo();\n      if (\"empty\" in info) {\n        setRemoteInfo(null);\n      } else {\n        setRemoteInfo(info);\n      }\n      setDialogType(\"upload\");\n    } catch {\n      setRemoteInfo(null);\n      toast.error(t(\"settings.webdavSync.fetchRemoteFailed\"));\n      setActionState(\"idle\");\n      return;\n    }\n    setActionState(\"idle\");\n  }, [dirty, t]);\n\n  /** Actually perform the upload after user confirms. */\n  const handleUploadConfirm = useCallback(async () => {\n    if (dirty) {\n      toast.error(t(\"settings.webdavSync.unsavedChanges\"));\n      return;\n    }\n    closeDialog();\n    setActionState(\"uploading\");\n    try {\n      await settingsApi.webdavSyncUpload();\n      toast.success(t(\"settings.webdavSync.uploadSuccess\"));\n      await queryClient.invalidateQueries();\n    } catch (error) {\n      toast.error(\n        t(\"settings.webdavSync.uploadFailed\", {\n          error: (error as Error)?.message ?? String(error),\n        }),\n      );\n    } finally {\n      setActionState(\"idle\");\n    }\n  }, [closeDialog, dirty, queryClient, t]);\n\n  /** Fetch remote info, then open download confirmation dialog. */\n  const handleDownloadClick = useCallback(async () => {\n    if (dirty) {\n      toast.error(t(\"settings.webdavSync.unsavedChanges\"));\n      return;\n    }\n    setActionState(\"fetching_remote\");\n    try {\n      const info = await settingsApi.webdavSyncFetchRemoteInfo();\n      if (\"empty\" in info) {\n        toast.info(t(\"settings.webdavSync.noRemoteData\"));\n        return;\n      }\n      if (!info.compatible) {\n        toast.error(\n          t(\"settings.webdavSync.incompatibleVersion\", {\n            protocolVersion: info.protocolVersion,\n            dbCompatVersion:\n              formatDbCompatVersion(info.dbCompatVersion) ??\n              t(\"common.unknown\"),\n          }),\n        );\n        return;\n      }\n      setRemoteInfo(info);\n      setDialogType(\"download\");\n    } catch (error) {\n      toast.error(\n        t(\"settings.webdavSync.downloadFailed\", {\n          error: (error as Error)?.message ?? String(error),\n        }),\n      );\n    } finally {\n      setActionState(\"idle\");\n    }\n  }, [dirty, t]);\n\n  /** Actually perform the download after user confirms. */\n  const handleDownloadConfirm = useCallback(async () => {\n    if (dirty) {\n      toast.error(t(\"settings.webdavSync.unsavedChanges\"));\n      return;\n    }\n    closeDialog();\n    setActionState(\"downloading\");\n    try {\n      await settingsApi.webdavSyncDownload();\n      toast.success(t(\"settings.webdavSync.downloadSuccess\"));\n      await queryClient.invalidateQueries();\n    } catch (error) {\n      toast.error(\n        t(\"settings.webdavSync.downloadFailed\", {\n          error: (error as Error)?.message ?? String(error),\n        }),\n      );\n    } finally {\n      setActionState(\"idle\");\n    }\n  }, [closeDialog, dirty, queryClient, t]);\n\n  // ─── Derived state ──────────────────────────────────────\n\n  const isLoading = actionState !== \"idle\";\n  const hasSavedConfig = Boolean(\n    config?.baseUrl?.trim() && config?.username?.trim(),\n  );\n\n  const lastSyncAt = config?.status?.lastSyncAt;\n  const lastSyncDisplay = lastSyncAt\n    ? new Date(lastSyncAt * 1000).toLocaleString()\n    : null;\n  const lastError = config?.status?.lastError?.trim();\n  const showAutoSyncError =\n    !!lastError && config?.status?.lastErrorSource === \"auto\";\n  const currentRemotePath = `/${form.remoteRoot.trim() || \"cc-switch-sync\"}/v2/db-v6/${form.profile.trim() || \"default\"}`;\n  const remoteDbCompatDisplay = formatDbCompatVersion(\n    remoteInfo?.dbCompatVersion,\n  );\n  const remoteIsLegacy = remoteInfo?.layout === \"legacy\";\n\n  // ─── Render ─────────────────────────────────────────────\n\n  return (\n    <section className=\"space-y-4\">\n      <header className=\"space-y-2\">\n        <h3 className=\"text-base font-semibold text-foreground\">\n          {t(\"settings.webdavSync.title\")}\n        </h3>\n        <p className=\"text-sm text-muted-foreground\">\n          {t(\"settings.webdavSync.description\")}\n        </p>\n      </header>\n\n      <div className=\"space-y-4 rounded-lg border border-border bg-muted/40 p-6\">\n        {/* Config fields */}\n        <div className=\"space-y-3\">\n          {/* Service preset selector */}\n          <div className=\"flex items-center gap-4\">\n            <label className=\"w-40 text-xs font-medium text-foreground shrink-0\">\n              {t(\"settings.webdavSync.presets.label\")}\n            </label>\n            <Select\n              value={presetId}\n              onValueChange={handlePresetChange}\n              disabled={isLoading}\n            >\n              <SelectTrigger className=\"text-xs flex-1\">\n                <SelectValue />\n              </SelectTrigger>\n              <SelectContent>\n                {WEBDAV_PRESETS.map((preset) => (\n                  <SelectItem key={preset.id} value={preset.id}>\n                    {t(preset.label)}\n                  </SelectItem>\n                ))}\n              </SelectContent>\n            </Select>\n          </div>\n\n          {/* Server URL */}\n          <div className=\"flex items-center gap-4\">\n            <label className=\"w-40 text-xs font-medium text-foreground shrink-0\">\n              {t(\"settings.webdavSync.baseUrl\")}\n            </label>\n            <Input\n              value={form.baseUrl}\n              onChange={(e) => updateField(\"baseUrl\", e.target.value)}\n              onBlur={handleBaseUrlBlur}\n              placeholder={t(\"settings.webdavSync.baseUrlPlaceholder\")}\n              className=\"text-xs flex-1\"\n              disabled={isLoading}\n            />\n          </div>\n\n          {/* Username */}\n          <div className=\"flex items-center gap-4\">\n            <label className=\"w-40 text-xs font-medium text-foreground shrink-0\">\n              {t(\"settings.webdavSync.username\")}\n            </label>\n            <Input\n              value={form.username}\n              onChange={(e) => updateField(\"username\", e.target.value)}\n              placeholder={t(\"settings.webdavSync.usernamePlaceholder\")}\n              className=\"text-xs flex-1\"\n              disabled={isLoading}\n            />\n          </div>\n\n          {/* Password */}\n          <div className=\"flex items-center gap-4\">\n            <label className=\"w-40 text-xs font-medium text-foreground shrink-0\">\n              {t(\"settings.webdavSync.password\")}\n            </label>\n            <Input\n              type=\"password\"\n              value={form.password}\n              onChange={(e) => updateField(\"password\", e.target.value)}\n              placeholder={t(\"settings.webdavSync.passwordPlaceholder\")}\n              className=\"text-xs flex-1\"\n              autoComplete=\"off\"\n              disabled={isLoading}\n            />\n          </div>\n\n          {/* Preset hint */}\n          {activePreset?.hint && (\n            <div className=\"flex items-start gap-2 pl-44 text-xs text-muted-foreground\">\n              <Info className=\"h-3.5 w-3.5 shrink-0 mt-0.5\" />\n              <span>{t(activePreset.hint)}</span>\n            </div>\n          )}\n\n          {/* Remote Root */}\n          <div className=\"flex items-center gap-4\">\n            <label className=\"w-40 text-xs font-medium text-foreground shrink-0\">\n              {t(\"settings.webdavSync.remoteRoot\")}\n              <span className=\"block text-[10px] font-normal text-muted-foreground\">\n                {t(\"settings.webdavSync.remoteRootDefault\")}\n              </span>\n            </label>\n            <Input\n              value={form.remoteRoot}\n              onChange={(e) => updateField(\"remoteRoot\", e.target.value)}\n              placeholder=\"cc-switch-sync\"\n              className=\"text-xs flex-1\"\n              disabled={isLoading}\n            />\n          </div>\n\n          {/* Profile */}\n          <div className=\"flex items-center gap-4\">\n            <label className=\"w-40 text-xs font-medium text-foreground shrink-0\">\n              {t(\"settings.webdavSync.profile\")}\n              <span className=\"block text-[10px] font-normal text-muted-foreground\">\n                {t(\"settings.webdavSync.profileDefault\")}\n              </span>\n            </label>\n            <Input\n              value={form.profile}\n              onChange={(e) => updateField(\"profile\", e.target.value)}\n              placeholder=\"default\"\n              className=\"text-xs flex-1\"\n              disabled={isLoading}\n            />\n          </div>\n\n          <div className=\"flex items-start gap-4\">\n            <label className=\"w-40 text-xs font-medium text-foreground shrink-0\">\n              {t(\"settings.webdavSync.autoSync\")}\n              <span className=\"block text-[10px] font-normal text-muted-foreground\">\n                {t(\"settings.webdavSync.autoSyncHint\")}\n              </span>\n            </label>\n            <div className=\"pt-1\">\n              <Switch\n                checked={form.autoSync}\n                onCheckedChange={handleAutoSyncChange}\n                aria-label={t(\"settings.webdavSync.autoSync\")}\n                disabled={isLoading}\n              />\n            </div>\n          </div>\n        </div>\n\n        {/* Last sync time */}\n        {lastSyncDisplay && (\n          <p className=\"text-xs text-muted-foreground\">\n            {t(\"settings.webdavSync.lastSync\", { time: lastSyncDisplay })}\n          </p>\n        )}\n        {showAutoSyncError && (\n          <div className=\"rounded-lg border border-red-300/70 bg-red-50/80 px-3 py-2 text-xs text-red-900 dark:border-red-500/50 dark:bg-red-950/30 dark:text-red-200\">\n            <p className=\"font-medium\">\n              {t(\"settings.webdavSync.autoSyncLastErrorTitle\")}\n            </p>\n            <p className=\"mt-1 break-all whitespace-pre-wrap\">{lastError}</p>\n            <p className=\"mt-1 text-[11px] text-red-700/90 dark:text-red-300/80\">\n              {t(\"settings.webdavSync.autoSyncLastErrorHint\")}\n            </p>\n          </div>\n        )}\n\n        {/* Config buttons + save status */}\n        <div className=\"flex flex-wrap items-center gap-3 pt-2\">\n          <ActionButton\n            type=\"button\"\n            variant=\"outline\"\n            size=\"sm\"\n            onClick={handleTest}\n            actionState={actionState}\n            targetState=\"testing\"\n            icon={Link2}\n            activeLabel={t(\"settings.webdavSync.testing\")}\n            idleLabel={t(\"settings.webdavSync.test\")}\n          />\n          <ActionButton\n            type=\"button\"\n            variant=\"outline\"\n            size=\"sm\"\n            onClick={handleSave}\n            actionState={actionState}\n            targetState=\"saving\"\n            icon={Save}\n            activeLabel={t(\"settings.webdavSync.saving\")}\n            idleLabel={t(\"settings.webdavSync.save\")}\n          />\n\n          {/* Save status indicator */}\n          {dirty && (\n            <span className=\"inline-flex items-center gap-1.5 text-xs text-amber-500 dark:text-amber-400 animate-in fade-in duration-200\">\n              <span className=\"h-1.5 w-1.5 rounded-full bg-amber-500 dark:bg-amber-400\" />\n              {t(\"settings.webdavSync.unsaved\")}\n            </span>\n          )}\n          {!dirty && justSaved && (\n            <span className=\"inline-flex items-center gap-1.5 text-xs text-emerald-600 dark:text-emerald-400 animate-in fade-in duration-200\">\n              <Check className=\"h-3 w-3\" />\n              {t(\"settings.webdavSync.saved\")}\n            </span>\n          )}\n        </div>\n\n        {/* Sync buttons */}\n        <div className=\"flex flex-wrap items-center gap-3 border-t border-border pt-4\">\n          <ActionButton\n            type=\"button\"\n            size=\"sm\"\n            onClick={handleUploadClick}\n            disabled={!hasSavedConfig}\n            actionState={actionState}\n            targetState=\"uploading\"\n            alsoActiveFor={[\"fetching_remote\"]}\n            icon={UploadCloud}\n            activeLabel={\n              actionState === \"fetching_remote\"\n                ? t(\"settings.webdavSync.fetchingRemote\")\n                : t(\"settings.webdavSync.uploading\")\n            }\n            idleLabel={t(\"settings.webdavSync.upload\")}\n          />\n          <ActionButton\n            type=\"button\"\n            variant=\"secondary\"\n            size=\"sm\"\n            onClick={handleDownloadClick}\n            disabled={!hasSavedConfig}\n            actionState={actionState}\n            targetState=\"downloading\"\n            alsoActiveFor={[\"fetching_remote\"]}\n            icon={DownloadCloud}\n            activeLabel={\n              actionState === \"fetching_remote\"\n                ? t(\"settings.webdavSync.fetchingRemote\")\n                : t(\"settings.webdavSync.downloading\")\n            }\n            idleLabel={t(\"settings.webdavSync.download\")}\n          />\n        </div>\n        {!hasSavedConfig && (\n          <p className=\"text-xs text-muted-foreground\">\n            {t(\"settings.webdavSync.saveBeforeSync\")}\n          </p>\n        )}\n      </div>\n\n      {/* ─── Upload confirmation dialog ──────────────────── */}\n      <Dialog\n        open={dialogType === \"upload\"}\n        onOpenChange={(open) => {\n          if (!open) closeDialog();\n        }}\n      >\n        <DialogContent className=\"max-w-sm\" zIndex=\"alert\">\n          <DialogHeader className=\"space-y-3 border-b-0 bg-transparent pb-0\">\n            <DialogTitle className=\"flex items-center gap-2 text-lg font-semibold\">\n              <AlertTriangle className=\"h-5 w-5 text-destructive\" />\n              {t(\"settings.webdavSync.confirmUpload.title\")}\n            </DialogTitle>\n            <DialogDescription asChild>\n              <div className=\"space-y-3 text-sm leading-relaxed\">\n                <p>{t(\"settings.webdavSync.confirmUpload.content\")}</p>\n                <ul className=\"list-disc pl-5 space-y-1 text-muted-foreground\">\n                  <li>{t(\"settings.webdavSync.confirmUpload.dbItem\")}</li>\n                  <li>{t(\"settings.webdavSync.confirmUpload.skillsItem\")}</li>\n                </ul>\n                <p className=\"text-muted-foreground\">\n                  {t(\"settings.webdavSync.confirmUpload.targetPath\")}\n                  {\": \"}\n                  <code className=\"ml-1 text-xs bg-muted px-1.5 py-0.5 rounded\">\n                    {currentRemotePath}\n                  </code>\n                </p>\n                {remoteInfo && (\n                  <div className=\"rounded-lg border border-border bg-muted/50 p-3 space-y-2\">\n                    <p className=\"text-xs font-medium text-foreground\">\n                      {t(\"settings.webdavSync.confirmUpload.existingData\")}\n                    </p>\n                    <dl className=\"grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-xs text-muted-foreground\">\n                      <dt className=\"font-medium text-foreground\">\n                        {t(\"settings.webdavSync.confirmUpload.deviceName\")}\n                      </dt>\n                      <dd>\n                        <code className=\"bg-muted px-1.5 py-0.5 rounded\">\n                          {remoteInfo.deviceName}\n                        </code>\n                      </dd>\n                      <dt className=\"font-medium text-foreground\">\n                        {t(\"settings.webdavSync.confirmUpload.createdAt\")}\n                      </dt>\n                      <dd>{formatDate(remoteInfo.createdAt)}</dd>\n                      <dt className=\"font-medium text-foreground\">\n                        {t(\"settings.webdavSync.confirmUpload.path\")}\n                      </dt>\n                      <dd>\n                        <code className=\"bg-muted px-1.5 py-0.5 rounded\">\n                          {remoteInfo.remotePath}\n                        </code>\n                      </dd>\n                      {remoteDbCompatDisplay && (\n                        <>\n                          <dt className=\"font-medium text-foreground\">\n                            {t(\"settings.webdavSync.confirmUpload.dbCompat\")}\n                          </dt>\n                          <dd>{remoteDbCompatDisplay}</dd>\n                        </>\n                      )}\n                    </dl>\n                  </div>\n                )}\n                {remoteInfo && !remoteIsLegacy && (\n                  <p className=\"text-destructive font-medium\">\n                    {t(\"settings.webdavSync.confirmUpload.warning\")}\n                  </p>\n                )}\n                {remoteInfo && remoteIsLegacy && (\n                  <p className=\"font-medium text-amber-600 dark:text-amber-400\">\n                    {t(\"settings.webdavSync.confirmUpload.legacyNotice\")}\n                  </p>\n                )}\n              </div>\n            </DialogDescription>\n          </DialogHeader>\n          <DialogFooter className=\"flex gap-2 border-t-0 bg-transparent pt-2 sm:justify-end\">\n            <Button variant=\"outline\" onClick={closeDialog}>\n              {t(\"common.cancel\")}\n            </Button>\n            <Button variant=\"destructive\" onClick={handleUploadConfirm}>\n              {t(\"settings.webdavSync.confirmUpload.confirm\")}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n\n      {/* ─── Download confirmation dialog ────────────────── */}\n      <Dialog\n        open={dialogType === \"download\"}\n        onOpenChange={(open) => {\n          if (!open) closeDialog();\n        }}\n      >\n        <DialogContent className=\"max-w-sm\" zIndex=\"alert\">\n          <DialogHeader className=\"space-y-3 border-b-0 bg-transparent pb-0\">\n            <DialogTitle className=\"flex items-center gap-2 text-lg font-semibold\">\n              <AlertTriangle className=\"h-5 w-5 text-destructive\" />\n              {t(\"settings.webdavSync.confirmDownload.title\")}\n            </DialogTitle>\n            <DialogDescription asChild>\n              <div className=\"space-y-3 text-sm leading-relaxed\">\n                {remoteInfo && (\n                  <dl className=\"grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-muted-foreground\">\n                    <dt className=\"font-medium text-foreground\">\n                      {t(\"settings.webdavSync.confirmDownload.deviceName\")}\n                    </dt>\n                    <dd>\n                      <code className=\"text-xs bg-muted px-1.5 py-0.5 rounded\">\n                        {remoteInfo.deviceName}\n                      </code>\n                    </dd>\n                    <dt className=\"font-medium text-foreground\">\n                      {t(\"settings.webdavSync.confirmDownload.createdAt\")}\n                    </dt>\n                    <dd>{formatDate(remoteInfo.createdAt)}</dd>\n                    <dt className=\"font-medium text-foreground\">\n                      {t(\"settings.webdavSync.confirmDownload.path\")}\n                    </dt>\n                    <dd>\n                      <code className=\"text-xs bg-muted px-1.5 py-0.5 rounded\">\n                        {remoteInfo.remotePath}\n                      </code>\n                    </dd>\n                    {remoteDbCompatDisplay && (\n                      <>\n                        <dt className=\"font-medium text-foreground\">\n                          {t(\"settings.webdavSync.confirmDownload.dbCompat\")}\n                        </dt>\n                        <dd>{remoteDbCompatDisplay}</dd>\n                      </>\n                    )}\n                    <dt className=\"font-medium text-foreground\">\n                      {t(\"settings.webdavSync.confirmDownload.artifacts\")}\n                    </dt>\n                    <dd>{remoteInfo.artifacts.join(\", \")}</dd>\n                  </dl>\n                )}\n                {remoteInfo?.layout === \"legacy\" && (\n                  <p className=\"font-medium text-amber-600 dark:text-amber-400\">\n                    {t(\"settings.webdavSync.confirmDownload.legacyNotice\")}\n                  </p>\n                )}\n                <p className=\"text-destructive font-medium\">\n                  {t(\"settings.webdavSync.confirmDownload.warning\")}\n                </p>\n              </div>\n            </DialogDescription>\n          </DialogHeader>\n          <DialogFooter className=\"flex gap-2 border-t-0 bg-transparent pt-2 sm:justify-end\">\n            <Button variant=\"outline\" onClick={closeDialog}>\n              {t(\"common.cancel\")}\n            </Button>\n            <Button variant=\"destructive\" onClick={handleDownloadConfirm}>\n              {t(\"settings.webdavSync.confirmDownload.confirm\")}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n\n      {/* ─── Auto-sync confirmation dialog ────────────────── */}\n      <ConfirmDialog\n        isOpen={showAutoSyncConfirm}\n        variant=\"info\"\n        title={t(\"confirm.autoSync.title\")}\n        message={t(\"confirm.autoSync.message\")}\n        confirmText={t(\"confirm.autoSync.confirm\")}\n        onConfirm={() => void handleAutoSyncConfirm()}\n        onCancel={() => setShowAutoSyncConfirm(false)}\n      />\n    </section>\n  );\n}\n"
  },
  {
    "path": "src/components/settings/WindowSettings.tsx",
    "content": "import { useTranslation } from \"react-i18next\";\nimport type { SettingsFormState } from \"@/hooks/useSettings\";\nimport { AppWindow, MonitorUp, Power, EyeOff } from \"lucide-react\";\nimport { ToggleRow } from \"@/components/ui/toggle-row\";\nimport { AnimatePresence, motion } from \"framer-motion\";\n\ninterface WindowSettingsProps {\n  settings: SettingsFormState;\n  onChange: (updates: Partial<SettingsFormState>) => void;\n}\n\nexport function WindowSettings({ settings, onChange }: WindowSettingsProps) {\n  const { t } = useTranslation();\n\n  return (\n    <section className=\"space-y-4\">\n      <div className=\"flex items-center gap-2 pb-2 border-b border-border/40\">\n        <AppWindow className=\"h-4 w-4 text-primary\" />\n        <h3 className=\"text-sm font-medium\">{t(\"settings.windowBehavior\")}</h3>\n      </div>\n\n      <div className=\"space-y-3\">\n        <ToggleRow\n          icon={<Power className=\"h-4 w-4 text-orange-500\" />}\n          title={t(\"settings.launchOnStartup\")}\n          description={t(\"settings.launchOnStartupDescription\")}\n          checked={!!settings.launchOnStartup}\n          onCheckedChange={(value) => onChange({ launchOnStartup: value })}\n        />\n\n        <AnimatePresence initial={false}>\n          {settings.launchOnStartup && (\n            <motion.div\n              key=\"silent-startup\"\n              initial={{ opacity: 0, y: 10 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, y: 10 }}\n              transition={{ duration: 0.3 }}\n            >\n              <ToggleRow\n                icon={<EyeOff className=\"h-4 w-4 text-green-500\" />}\n                title={t(\"settings.silentStartup\")}\n                description={t(\"settings.silentStartupDescription\")}\n                checked={!!settings.silentStartup}\n                onCheckedChange={(value) => onChange({ silentStartup: value })}\n              />\n            </motion.div>\n          )}\n        </AnimatePresence>\n\n        <ToggleRow\n          icon={<MonitorUp className=\"h-4 w-4 text-purple-500\" />}\n          title={t(\"settings.enableClaudePluginIntegration\")}\n          description={t(\"settings.enableClaudePluginIntegrationDescription\")}\n          checked={!!settings.enableClaudePluginIntegration}\n          onCheckedChange={(value) =>\n            onChange({ enableClaudePluginIntegration: value })\n          }\n        />\n\n        <ToggleRow\n          icon={<MonitorUp className=\"h-4 w-4 text-cyan-500\" />}\n          title={t(\"settings.skipClaudeOnboarding\")}\n          description={t(\"settings.skipClaudeOnboardingDescription\")}\n          checked={!!settings.skipClaudeOnboarding}\n          onCheckedChange={(value) => onChange({ skipClaudeOnboarding: value })}\n        />\n\n        <ToggleRow\n          icon={<AppWindow className=\"h-4 w-4 text-blue-500\" />}\n          title={t(\"settings.minimizeToTray\")}\n          description={t(\"settings.minimizeToTrayDescription\")}\n          checked={settings.minimizeToTrayOnClose}\n          onCheckedChange={(value) =>\n            onChange({ minimizeToTrayOnClose: value })\n          }\n        />\n      </div>\n    </section>\n  );\n}\n"
  },
  {
    "path": "src/components/skills/RepoManager.tsx",
    "content": "import { useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { Trash2, ExternalLink, Plus } from \"lucide-react\";\nimport { settingsApi } from \"@/lib/api\";\nimport type { DiscoverableSkill, SkillRepo } from \"@/lib/api/skills\";\n\ninterface RepoManagerProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  repos: SkillRepo[];\n  skills: DiscoverableSkill[];\n  onAdd: (repo: SkillRepo) => Promise<void>;\n  onRemove: (owner: string, name: string) => Promise<void>;\n}\n\nexport function RepoManager({\n  open: isOpen,\n  onOpenChange,\n  repos,\n  skills,\n  onAdd,\n  onRemove,\n}: RepoManagerProps) {\n  const { t } = useTranslation();\n  const [repoUrl, setRepoUrl] = useState(\"\");\n  const [branch, setBranch] = useState(\"\");\n  const [error, setError] = useState(\"\");\n\n  const getSkillCount = (repo: SkillRepo) =>\n    skills.filter(\n      (skill) =>\n        skill.repoOwner === repo.owner &&\n        skill.repoName === repo.name &&\n        (skill.repoBranch || \"main\") === (repo.branch || \"main\"),\n    ).length;\n\n  const parseRepoUrl = (\n    url: string,\n  ): { owner: string; name: string } | null => {\n    // 支持格式:\n    // - https://github.com/owner/name\n    // - owner/name\n    // - https://github.com/owner/name.git\n\n    let cleaned = url.trim();\n    cleaned = cleaned.replace(/^https?:\\/\\/github\\.com\\//, \"\");\n    cleaned = cleaned.replace(/\\.git$/, \"\");\n\n    const parts = cleaned.split(\"/\");\n    if (parts.length === 2 && parts[0] && parts[1]) {\n      return { owner: parts[0], name: parts[1] };\n    }\n\n    return null;\n  };\n\n  const handleAdd = async () => {\n    setError(\"\");\n\n    const parsed = parseRepoUrl(repoUrl);\n    if (!parsed) {\n      setError(t(\"skills.repo.invalidUrl\"));\n      return;\n    }\n\n    try {\n      await onAdd({\n        owner: parsed.owner,\n        name: parsed.name,\n        branch: branch || \"main\",\n        enabled: true,\n      });\n\n      setRepoUrl(\"\");\n      setBranch(\"\");\n    } catch (e) {\n      setError(e instanceof Error ? e.message : t(\"skills.repo.addFailed\"));\n    }\n  };\n\n  const handleOpenRepo = async (owner: string, name: string) => {\n    try {\n      await settingsApi.openExternal(`https://github.com/${owner}/${name}`);\n    } catch (error) {\n      console.error(\"Failed to open URL:\", error);\n    }\n  };\n\n  return (\n    <Dialog open={isOpen} onOpenChange={onOpenChange}>\n      <DialogContent className=\"max-w-2xl max-h-[80vh] flex flex-col p-0\">\n        {/* 固定头部 */}\n        <DialogHeader className=\"flex-shrink-0 border-b border-border-default px-6 py-4\">\n          <DialogTitle>{t(\"skills.repo.title\")}</DialogTitle>\n          <DialogDescription>{t(\"skills.repo.description\")}</DialogDescription>\n        </DialogHeader>\n\n        {/* 可滚动内容区域 */}\n        <div className=\"flex-1 min-h-0 overflow-y-auto px-6 py-4\">\n          {/* 添加仓库表单 */}\n          <div className=\"space-y-5\">\n            <div className=\"space-y-2\">\n              <Label htmlFor=\"repo-url\">{t(\"skills.repo.url\")}</Label>\n              <div className=\"flex flex-col gap-3\">\n                <Input\n                  id=\"repo-url\"\n                  placeholder={t(\"skills.repo.urlPlaceholder\")}\n                  value={repoUrl}\n                  onChange={(e) => setRepoUrl(e.target.value)}\n                  className=\"flex-1\"\n                />\n                <div className=\"flex flex-col gap-3 sm:flex-row\">\n                  <Input\n                    id=\"branch\"\n                    placeholder={t(\"skills.repo.branchPlaceholder\")}\n                    value={branch}\n                    onChange={(e) => setBranch(e.target.value)}\n                    className=\"flex-1\"\n                  />\n                  <Button\n                    onClick={handleAdd}\n                    className=\"w-full sm:w-auto sm:px-4\"\n                    variant=\"mcp\"\n                    type=\"button\"\n                  >\n                    <Plus className=\"h-4 w-4 mr-2\" />\n                    {t(\"skills.repo.add\")}\n                  </Button>\n                </div>\n              </div>\n              {error && <p className=\"text-xs text-destructive\">{error}</p>}\n            </div>\n\n            {/* 仓库列表 */}\n            <div className=\"space-y-3\">\n              <h4 className=\"text-sm font-medium\">{t(\"skills.repo.list\")}</h4>\n              {repos.length === 0 ? (\n                <p className=\"text-sm text-muted-foreground\">\n                  {t(\"skills.repo.empty\")}\n                </p>\n              ) : (\n                <div className=\"space-y-3\">\n                  {repos.map((repo) => (\n                    <div\n                      key={`${repo.owner}/${repo.name}`}\n                      className=\"flex items-center justify-between rounded-xl border border-border-default bg-card px-4 py-3\"\n                    >\n                      <div>\n                        <div className=\"text-sm font-medium text-foreground\">\n                          {repo.owner}/{repo.name}\n                        </div>\n                        <div className=\"mt-1 text-xs text-muted-foreground\">\n                          {t(\"skills.repo.branch\")}: {repo.branch || \"main\"}\n                          <span className=\"ml-3 inline-flex items-center rounded-full border border-border-default px-2 py-0.5 text-[11px]\">\n                            {t(\"skills.repo.skillCount\", {\n                              count: getSkillCount(repo),\n                            })}\n                          </span>\n                        </div>\n                      </div>\n                      <div className=\"flex gap-2\">\n                        <Button\n                          variant=\"ghost\"\n                          size=\"icon\"\n                          type=\"button\"\n                          onClick={() => handleOpenRepo(repo.owner, repo.name)}\n                          title={t(\"common.view\", { defaultValue: \"查看\" })}\n                        >\n                          <ExternalLink className=\"h-4 w-4\" />\n                        </Button>\n                        <Button\n                          variant=\"ghost\"\n                          size=\"icon\"\n                          type=\"button\"\n                          onClick={() => onRemove(repo.owner, repo.name)}\n                          title={t(\"common.delete\")}\n                          className=\"hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10\"\n                        >\n                          <Trash2 className=\"h-4 w-4\" />\n                        </Button>\n                      </div>\n                    </div>\n                  ))}\n                </div>\n              )}\n            </div>\n          </div>\n        </div>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "src/components/skills/RepoManagerPanel.tsx",
    "content": "import { useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { Trash2, ExternalLink, Plus } from \"lucide-react\";\nimport { settingsApi } from \"@/lib/api\";\nimport { FullScreenPanel } from \"@/components/common/FullScreenPanel\";\nimport type { DiscoverableSkill, SkillRepo } from \"@/lib/api/skills\";\n\ninterface RepoManagerPanelProps {\n  repos: SkillRepo[];\n  skills: DiscoverableSkill[];\n  onAdd: (repo: SkillRepo) => Promise<void>;\n  onRemove: (owner: string, name: string) => Promise<void>;\n  onClose: () => void;\n}\n\nexport function RepoManagerPanel({\n  repos,\n  skills,\n  onAdd,\n  onRemove,\n  onClose,\n}: RepoManagerPanelProps) {\n  const { t } = useTranslation();\n  const [repoUrl, setRepoUrl] = useState(\"\");\n  const [branch, setBranch] = useState(\"\");\n  const [error, setError] = useState(\"\");\n\n  const getSkillCount = (repo: SkillRepo) =>\n    skills.filter(\n      (skill) =>\n        skill.repoOwner === repo.owner &&\n        skill.repoName === repo.name &&\n        (skill.repoBranch || \"main\") === (repo.branch || \"main\"),\n    ).length;\n\n  const parseRepoUrl = (\n    url: string,\n  ): { owner: string; name: string } | null => {\n    let cleaned = url.trim();\n    cleaned = cleaned.replace(/^https?:\\/\\/github\\.com\\//, \"\");\n    cleaned = cleaned.replace(/\\.git$/, \"\");\n\n    const parts = cleaned.split(\"/\");\n    if (parts.length === 2 && parts[0] && parts[1]) {\n      return { owner: parts[0], name: parts[1] };\n    }\n\n    return null;\n  };\n\n  const handleAdd = async () => {\n    setError(\"\");\n\n    const parsed = parseRepoUrl(repoUrl);\n    if (!parsed) {\n      setError(t(\"skills.repo.invalidUrl\"));\n      return;\n    }\n\n    try {\n      await onAdd({\n        owner: parsed.owner,\n        name: parsed.name,\n        branch: branch || \"main\",\n        enabled: true,\n      });\n\n      setRepoUrl(\"\");\n      setBranch(\"\");\n    } catch (e) {\n      setError(e instanceof Error ? e.message : t(\"skills.repo.addFailed\"));\n    }\n  };\n\n  const handleOpenRepo = async (owner: string, name: string) => {\n    try {\n      await settingsApi.openExternal(`https://github.com/${owner}/${name}`);\n    } catch (error) {\n      console.error(\"Failed to open URL:\", error);\n    }\n  };\n\n  return (\n    <FullScreenPanel\n      isOpen={true}\n      title={t(\"skills.repo.title\")}\n      onClose={onClose}\n    >\n      {/* 添加仓库表单 */}\n      <div className=\"space-y-4 glass-card rounded-xl p-6\">\n        <h3 className=\"text-base font-semibold text-foreground\">\n          {t(\"skills.addRepo\")}\n        </h3>\n        <div className=\"space-y-4\">\n          <div>\n            <Label htmlFor=\"repo-url\" className=\"text-foreground\">\n              {t(\"skills.repo.url\")}\n            </Label>\n            <Input\n              id=\"repo-url\"\n              placeholder={t(\"skills.repo.urlPlaceholder\")}\n              value={repoUrl}\n              onChange={(e) => setRepoUrl(e.target.value)}\n              className=\"mt-2\"\n            />\n          </div>\n          <div>\n            <Label htmlFor=\"branch\" className=\"text-foreground\">\n              {t(\"skills.repo.branch\")}\n            </Label>\n            <Input\n              id=\"branch\"\n              placeholder={t(\"skills.repo.branchPlaceholder\")}\n              value={branch}\n              onChange={(e) => setBranch(e.target.value)}\n              className=\"mt-2\"\n            />\n          </div>\n          {error && (\n            <p className=\"text-sm text-red-600 dark:text-red-400\">{error}</p>\n          )}\n          <Button\n            onClick={handleAdd}\n            className=\"bg-primary text-primary-foreground hover:bg-primary/90\"\n            type=\"button\"\n          >\n            <Plus className=\"h-4 w-4 mr-2\" />\n            {t(\"skills.repo.add\")}\n          </Button>\n        </div>\n      </div>\n\n      {/* 仓库列表 */}\n      <div className=\"space-y-4\">\n        <h3 className=\"text-base font-semibold text-foreground\">\n          {t(\"skills.repo.list\")}\n        </h3>\n        {repos.length === 0 ? (\n          <div className=\"text-center py-12 glass-card rounded-xl\">\n            <p className=\"text-sm text-muted-foreground\">\n              {t(\"skills.repo.empty\")}\n            </p>\n          </div>\n        ) : (\n          <div className=\"space-y-3\">\n            {repos.map((repo) => (\n              <div\n                key={`${repo.owner}/${repo.name}`}\n                className=\"flex items-center justify-between glass-card rounded-xl px-4 py-3\"\n              >\n                <div>\n                  <div className=\"text-sm font-medium text-foreground\">\n                    {repo.owner}/{repo.name}\n                  </div>\n                  <div className=\"mt-1 text-xs text-muted-foreground\">\n                    {t(\"skills.repo.branch\")}: {repo.branch || \"main\"}\n                    <span className=\"ml-3 inline-flex items-center rounded-full border border-border-default px-2 py-0.5 text-[11px]\">\n                      {t(\"skills.repo.skillCount\", {\n                        count: getSkillCount(repo),\n                      })}\n                    </span>\n                  </div>\n                </div>\n                <div className=\"flex gap-2\">\n                  <Button\n                    variant=\"ghost\"\n                    size=\"icon\"\n                    type=\"button\"\n                    onClick={() => handleOpenRepo(repo.owner, repo.name)}\n                    title={t(\"common.view\", { defaultValue: \"查看\" })}\n                    className=\"hover:bg-black/5 dark:hover:bg-white/5\"\n                  >\n                    <ExternalLink className=\"h-4 w-4\" />\n                  </Button>\n                  <Button\n                    variant=\"ghost\"\n                    size=\"icon\"\n                    type=\"button\"\n                    onClick={() => onRemove(repo.owner, repo.name)}\n                    title={t(\"common.delete\")}\n                    className=\"hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10\"\n                  >\n                    <Trash2 className=\"h-4 w-4\" />\n                  </Button>\n                </div>\n              </div>\n            ))}\n          </div>\n        )}\n      </div>\n    </FullScreenPanel>\n  );\n}\n"
  },
  {
    "path": "src/components/skills/SkillCard.tsx",
    "content": "import { useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { ExternalLink, Download, Trash2, Loader2 } from \"lucide-react\";\nimport { settingsApi } from \"@/lib/api\";\nimport type { DiscoverableSkill } from \"@/lib/api/skills\";\n\ntype SkillCardSkill = DiscoverableSkill & { installed: boolean };\n\ninterface SkillCardProps {\n  skill: SkillCardSkill;\n  onInstall: (directory: string) => Promise<void>;\n  onUninstall: (directory: string) => Promise<void>;\n}\n\nexport function SkillCard({ skill, onInstall, onUninstall }: SkillCardProps) {\n  const { t } = useTranslation();\n  const [loading, setLoading] = useState(false);\n\n  const handleInstall = async () => {\n    setLoading(true);\n    try {\n      await onInstall(skill.directory);\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  const handleUninstall = async () => {\n    setLoading(true);\n    try {\n      await onUninstall(skill.directory);\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  const handleOpenGithub = async () => {\n    if (skill.readmeUrl) {\n      try {\n        await settingsApi.openExternal(skill.readmeUrl);\n      } catch (error) {\n        console.error(\"Failed to open URL:\", error);\n      }\n    }\n  };\n\n  const showDirectory =\n    Boolean(skill.directory) &&\n    skill.directory.trim().toLowerCase() !== skill.name.trim().toLowerCase();\n\n  return (\n    <Card className=\"glass-card flex flex-col h-full transition-all duration-300 hover:shadow-lg group relative overflow-hidden\">\n      <div className=\"absolute inset-0 bg-gradient-to-br from-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none\" />\n      <CardHeader className=\"pb-3\">\n        <div className=\"flex items-start justify-between gap-2\">\n          <div className=\"flex-1 min-w-0\">\n            <CardTitle className=\"text-base font-semibold truncate\">\n              {skill.name}\n            </CardTitle>\n            <div className=\"flex items-center gap-2 mt-1.5\">\n              {showDirectory && (\n                <CardDescription className=\"text-xs truncate\">\n                  {skill.directory}\n                </CardDescription>\n              )}\n              {skill.repoOwner && skill.repoName && (\n                <Badge\n                  variant=\"outline\"\n                  className=\"shrink-0 text-[10px] px-1.5 py-0 h-4 border-border-default\"\n                >\n                  {skill.repoOwner}/{skill.repoName}\n                </Badge>\n              )}\n            </div>\n          </div>\n          {skill.installed && (\n            <Badge\n              variant=\"default\"\n              className=\"shrink-0 bg-green-600/90 hover:bg-green-600 dark:bg-green-700/90 dark:hover:bg-green-700 text-white border-0\"\n            >\n              {t(\"skills.installed\")}\n            </Badge>\n          )}\n        </div>\n      </CardHeader>\n      <CardContent className=\"flex-1 pt-0\">\n        <p className=\"text-sm text-muted-foreground/90 line-clamp-4 leading-relaxed\">\n          {skill.description || t(\"skills.noDescription\")}\n        </p>\n      </CardContent>\n      <CardFooter className=\"flex gap-2 pt-3 border-t border-border/50 relative z-10\">\n        {skill.readmeUrl && (\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            onClick={handleOpenGithub}\n            disabled={loading}\n            className=\"flex-1\"\n          >\n            <ExternalLink className=\"h-3.5 w-3.5 mr-1.5\" />\n            {t(\"skills.view\")}\n          </Button>\n        )}\n        {skill.installed ? (\n          <Button\n            variant=\"outline\"\n            size=\"sm\"\n            onClick={handleUninstall}\n            disabled={loading}\n            className=\"flex-1 border-red-200 text-red-600 hover:bg-red-50 hover:text-red-700 dark:border-red-900/50 dark:text-red-400 dark:hover:bg-red-950/50 dark:hover:text-red-300\"\n          >\n            {loading ? (\n              <Loader2 className=\"h-3.5 w-3.5 mr-1.5 animate-spin\" />\n            ) : (\n              <Trash2 className=\"h-3.5 w-3.5 mr-1.5\" />\n            )}\n            {loading ? t(\"skills.uninstalling\") : t(\"skills.uninstall\")}\n          </Button>\n        ) : (\n          <Button\n            variant=\"mcp\"\n            size=\"sm\"\n            onClick={handleInstall}\n            disabled={loading || !skill.repoOwner}\n            className=\"flex-1\"\n          >\n            {loading ? (\n              <Loader2 className=\"h-3.5 w-3.5 mr-1.5 animate-spin\" />\n            ) : (\n              <Download className=\"h-3.5 w-3.5 mr-1.5\" />\n            )}\n            {loading ? t(\"skills.installing\") : t(\"skills.install\")}\n          </Button>\n        )}\n      </CardFooter>\n    </Card>\n  );\n}\n"
  },
  {
    "path": "src/components/skills/SkillsPage.tsx",
    "content": "import { useState, useMemo, forwardRef, useImperativeHandle } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { RefreshCw, Search } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { SkillCard } from \"./SkillCard\";\nimport { RepoManagerPanel } from \"./RepoManagerPanel\";\nimport {\n  useDiscoverableSkills,\n  useInstalledSkills,\n  useInstallSkill,\n  useSkillRepos,\n  useAddSkillRepo,\n  useRemoveSkillRepo,\n} from \"@/hooks/useSkills\";\nimport type { AppId } from \"@/lib/api/types\";\nimport type { DiscoverableSkill, SkillRepo } from \"@/lib/api/skills\";\nimport { formatSkillError } from \"@/lib/errors/skillErrorParser\";\n\ninterface SkillsPageProps {\n  initialApp?: AppId;\n}\n\nexport interface SkillsPageHandle {\n  refresh: () => void;\n  openRepoManager: () => void;\n}\n\n/**\n * Skills 发现面板\n * 用于浏览和安装来自仓库的 Skills\n */\nexport const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(\n  ({ initialApp = \"claude\" }, ref) => {\n    const { t } = useTranslation();\n    const [repoManagerOpen, setRepoManagerOpen] = useState(false);\n    const [searchQuery, setSearchQuery] = useState(\"\");\n    const [filterRepo, setFilterRepo] = useState<string>(\"all\");\n    const [filterStatus, setFilterStatus] = useState<\n      \"all\" | \"installed\" | \"uninstalled\"\n    >(\"all\");\n\n    // currentApp 用于安装时的默认应用\n    const currentApp = initialApp;\n\n    // Queries\n    const {\n      data: discoverableSkills,\n      isLoading: loadingDiscoverable,\n      isFetching: fetchingDiscoverable,\n      refetch: refetchDiscoverable,\n    } = useDiscoverableSkills();\n    const { data: installedSkills } = useInstalledSkills();\n    const { data: repos = [], refetch: refetchRepos } = useSkillRepos();\n\n    // Mutations\n    const installMutation = useInstallSkill();\n    const addRepoMutation = useAddSkillRepo();\n    const removeRepoMutation = useRemoveSkillRepo();\n\n    // 已安装的 skill key 集合（使用 directory + repoOwner + repoName 组合判断）\n    const installedKeys = useMemo(() => {\n      if (!installedSkills) return new Set<string>();\n      return new Set(\n        installedSkills.map((s) => {\n          // 构建唯一 key：directory + repoOwner + repoName\n          const owner = s.repoOwner?.toLowerCase() || \"\";\n          const name = s.repoName?.toLowerCase() || \"\";\n          return `${s.directory.toLowerCase()}:${owner}:${name}`;\n        }),\n      );\n    }, [installedSkills]);\n\n    type DiscoverableSkillItem = DiscoverableSkill & { installed: boolean };\n\n    // 从可发现技能中提取所有仓库选项\n    const repoOptions = useMemo(() => {\n      if (!discoverableSkills) return [];\n      const repoSet = new Set<string>();\n      discoverableSkills.forEach((s) => {\n        if (s.repoOwner && s.repoName) {\n          repoSet.add(`${s.repoOwner}/${s.repoName}`);\n        }\n      });\n      return Array.from(repoSet).sort();\n    }, [discoverableSkills]);\n\n    // 为发现列表补齐 installed 状态，供 SkillCard 使用\n    const skills: DiscoverableSkillItem[] = useMemo(() => {\n      if (!discoverableSkills) return [];\n      return discoverableSkills.map((d) => {\n        // 同时处理 / 和 \\ 路径分隔符（兼容 Windows 和 Unix）\n        const installName =\n          d.directory.split(/[/\\\\]/).pop()?.toLowerCase() ||\n          d.directory.toLowerCase();\n        // 使用 directory + repoOwner + repoName 组合判断是否已安装\n        const key = `${installName}:${d.repoOwner.toLowerCase()}:${d.repoName.toLowerCase()}`;\n        return {\n          ...d,\n          installed: installedKeys.has(key),\n        };\n      });\n    }, [discoverableSkills, installedKeys]);\n\n    const loading = loadingDiscoverable || fetchingDiscoverable;\n\n    useImperativeHandle(ref, () => ({\n      refresh: () => {\n        refetchDiscoverable();\n        refetchRepos();\n      },\n      openRepoManager: () => setRepoManagerOpen(true),\n    }));\n\n    const handleInstall = async (directory: string) => {\n      // 找到对应的 DiscoverableSkill\n      const skill = discoverableSkills?.find(\n        (s) =>\n          s.directory === directory ||\n          s.directory.split(\"/\").pop() === directory,\n      );\n      if (!skill) {\n        toast.error(t(\"skills.notFound\"));\n        return;\n      }\n\n      try {\n        await installMutation.mutateAsync({\n          skill,\n          currentApp,\n        });\n        toast.success(t(\"skills.installSuccess\", { name: skill.name }), {\n          closeButton: true,\n        });\n      } catch (error) {\n        const errorMessage =\n          error instanceof Error ? error.message : String(error);\n        const { title, description } = formatSkillError(\n          errorMessage,\n          t,\n          \"skills.installFailed\",\n        );\n        toast.error(title, {\n          description,\n          duration: 10000,\n        });\n        console.error(\"Install skill failed:\", error);\n      }\n    };\n\n    const handleUninstall = async (_directory: string) => {\n      // 在发现面板中，不支持卸载，需要在主面板中操作\n      toast.info(t(\"skills.uninstallInMainPanel\"));\n    };\n\n    const handleAddRepo = async (repo: SkillRepo) => {\n      try {\n        await addRepoMutation.mutateAsync(repo);\n        // Await discovery so we can report the real count\n        const { data: freshSkills } = await refetchDiscoverable();\n        const count =\n          freshSkills?.filter(\n            (s) =>\n              s.repoOwner === repo.owner &&\n              s.repoName === repo.name &&\n              (s.repoBranch || \"main\") === (repo.branch || \"main\"),\n          ).length ?? 0;\n        toast.success(\n          t(\"skills.repo.addSuccess\", {\n            owner: repo.owner,\n            name: repo.name,\n            count,\n          }),\n          { closeButton: true },\n        );\n      } catch (error) {\n        toast.error(t(\"common.error\"), {\n          description: String(error),\n        });\n      }\n    };\n\n    const handleRemoveRepo = async (owner: string, name: string) => {\n      try {\n        await removeRepoMutation.mutateAsync({ owner, name });\n        toast.success(t(\"skills.repo.removeSuccess\", { owner, name }), {\n          closeButton: true,\n        });\n      } catch (error) {\n        toast.error(t(\"common.error\"), {\n          description: String(error),\n        });\n      }\n    };\n\n    // 过滤技能列表\n    const filteredSkills = useMemo(() => {\n      // 按仓库筛选\n      const byRepo = skills.filter((skill) => {\n        if (filterRepo === \"all\") return true;\n        const skillRepo = `${skill.repoOwner}/${skill.repoName}`;\n        return skillRepo === filterRepo;\n      });\n\n      // 按安装状态筛选\n      const byStatus = byRepo.filter((skill) => {\n        if (filterStatus === \"installed\") return skill.installed;\n        if (filterStatus === \"uninstalled\") return !skill.installed;\n        return true;\n      });\n\n      // 按搜索关键词筛选\n      if (!searchQuery.trim()) return byStatus;\n\n      const query = searchQuery.toLowerCase();\n      return byStatus.filter((skill) => {\n        const name = skill.name?.toLowerCase() || \"\";\n        const repo =\n          skill.repoOwner && skill.repoName\n            ? `${skill.repoOwner}/${skill.repoName}`.toLowerCase()\n            : \"\";\n\n        return name.includes(query) || repo.includes(query);\n      });\n    }, [skills, searchQuery, filterRepo, filterStatus]);\n\n    return (\n      <div className=\"px-6 flex flex-col flex-1 min-h-0 overflow-hidden bg-background/50\">\n        {/* 技能网格（可滚动详情区域） */}\n        <div className=\"flex-1 overflow-y-auto overflow-x-hidden animate-fade-in\">\n          <div className=\"py-4\">\n            {loading ? (\n              <div className=\"flex items-center justify-center h-64\">\n                <RefreshCw className=\"h-8 w-8 animate-spin text-muted-foreground\" />\n              </div>\n            ) : skills.length === 0 ? (\n              <div className=\"flex flex-col items-center justify-center h-64 text-center\">\n                <p className=\"text-lg font-medium text-foreground\">\n                  {t(\"skills.empty\")}\n                </p>\n                <p className=\"mt-2 text-sm text-muted-foreground\">\n                  {t(\"skills.emptyDescription\")}\n                </p>\n                <Button\n                  variant=\"link\"\n                  onClick={() => setRepoManagerOpen(true)}\n                  className=\"mt-3 text-sm font-normal\"\n                >\n                  {t(\"skills.addRepo\")}\n                </Button>\n              </div>\n            ) : (\n              <>\n                {/* 搜索框和筛选器 */}\n                <div className=\"mb-6 flex flex-col gap-3 md:flex-row md:items-center\">\n                  <div className=\"relative flex-1 min-w-0\">\n                    <Search className=\"absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground\" />\n                    <Input\n                      type=\"text\"\n                      placeholder={t(\"skills.searchPlaceholder\")}\n                      value={searchQuery}\n                      onChange={(e) => setSearchQuery(e.target.value)}\n                      className=\"pl-9 pr-3\"\n                    />\n                  </div>\n                  {/* 仓库筛选 */}\n                  <div className=\"w-full md:w-56\">\n                    <Select value={filterRepo} onValueChange={setFilterRepo}>\n                      <SelectTrigger className=\"bg-card border shadow-sm text-foreground\">\n                        <SelectValue\n                          placeholder={t(\"skills.filter.repo\")}\n                          className=\"text-left truncate\"\n                        />\n                      </SelectTrigger>\n                      <SelectContent className=\"bg-card text-foreground shadow-lg max-h-64 min-w-[var(--radix-select-trigger-width)]\">\n                        <SelectItem\n                          value=\"all\"\n                          className=\"text-left pr-3 [&[data-state=checked]>span:first-child]:hidden\"\n                        >\n                          {t(\"skills.filter.allRepos\")}\n                        </SelectItem>\n                        {repoOptions.map((repo) => (\n                          <SelectItem\n                            key={repo}\n                            value={repo}\n                            className=\"text-left pr-3 [&[data-state=checked]>span:first-child]:hidden\"\n                            title={repo}\n                          >\n                            <span className=\"truncate block max-w-[200px]\">\n                              {repo}\n                            </span>\n                          </SelectItem>\n                        ))}\n                      </SelectContent>\n                    </Select>\n                  </div>\n                  {/* 安装状态筛选 */}\n                  <div className=\"w-full md:w-36\">\n                    <Select\n                      value={filterStatus}\n                      onValueChange={(val) =>\n                        setFilterStatus(\n                          val as \"all\" | \"installed\" | \"uninstalled\",\n                        )\n                      }\n                    >\n                      <SelectTrigger className=\"bg-card border shadow-sm text-foreground\">\n                        <SelectValue\n                          placeholder={t(\"skills.filter.placeholder\")}\n                          className=\"text-left\"\n                        />\n                      </SelectTrigger>\n                      <SelectContent className=\"bg-card text-foreground shadow-lg\">\n                        <SelectItem\n                          value=\"all\"\n                          className=\"text-left pr-3 [&[data-state=checked]>span:first-child]:hidden\"\n                        >\n                          {t(\"skills.filter.all\")}\n                        </SelectItem>\n                        <SelectItem\n                          value=\"installed\"\n                          className=\"text-left pr-3 [&[data-state=checked]>span:first-child]:hidden\"\n                        >\n                          {t(\"skills.filter.installed\")}\n                        </SelectItem>\n                        <SelectItem\n                          value=\"uninstalled\"\n                          className=\"text-left pr-3 [&[data-state=checked]>span:first-child]:hidden\"\n                        >\n                          {t(\"skills.filter.uninstalled\")}\n                        </SelectItem>\n                      </SelectContent>\n                    </Select>\n                  </div>\n                  {searchQuery && (\n                    <p className=\"mt-2 text-sm text-muted-foreground\">\n                      {t(\"skills.count\", { count: filteredSkills.length })}\n                    </p>\n                  )}\n                </div>\n\n                {/* 技能列表或无结果提示 */}\n                {filteredSkills.length === 0 ? (\n                  <div className=\"flex flex-col items-center justify-center h-48 text-center\">\n                    <p className=\"text-lg font-medium text-foreground\">\n                      {t(\"skills.noResults\")}\n                    </p>\n                    <p className=\"mt-2 text-sm text-muted-foreground\">\n                      {t(\"skills.emptyDescription\")}\n                    </p>\n                  </div>\n                ) : (\n                  <div className=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4\">\n                    {filteredSkills.map((skill) => (\n                      <SkillCard\n                        key={skill.key}\n                        skill={skill}\n                        onInstall={handleInstall}\n                        onUninstall={handleUninstall}\n                      />\n                    ))}\n                  </div>\n                )}\n              </>\n            )}\n          </div>\n        </div>\n\n        {/* 仓库管理面板 */}\n        {repoManagerOpen && (\n          <RepoManagerPanel\n            repos={repos}\n            skills={skills}\n            onAdd={handleAddRepo}\n            onRemove={handleRemoveRepo}\n            onClose={() => setRepoManagerOpen(false)}\n          />\n        )}\n      </div>\n    );\n  },\n);\n\nSkillsPage.displayName = \"SkillsPage\";\n"
  },
  {
    "path": "src/components/skills/UnifiedSkillsPanel.tsx",
    "content": "import React, { useMemo, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Sparkles, Trash2, ExternalLink } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { TooltipProvider } from \"@/components/ui/tooltip\";\nimport {\n  type ImportSkillSelection,\n  type SkillBackupEntry,\n  useDeleteSkillBackup,\n  useInstalledSkills,\n  useSkillBackups,\n  useRestoreSkillBackup,\n  useToggleSkillApp,\n  useUninstallSkill,\n  useScanUnmanagedSkills,\n  useImportSkillsFromApps,\n  useInstallSkillsFromZip,\n  type InstalledSkill,\n} from \"@/hooks/useSkills\";\nimport type { AppId } from \"@/lib/api/types\";\nimport { ConfirmDialog } from \"@/components/ConfirmDialog\";\nimport { settingsApi, skillsApi } from \"@/lib/api\";\nimport { toast } from \"sonner\";\nimport { MCP_SKILLS_APP_IDS } from \"@/config/appConfig\";\nimport { AppCountBar } from \"@/components/common/AppCountBar\";\nimport { AppToggleGroup } from \"@/components/common/AppToggleGroup\";\nimport { ListItemRow } from \"@/components/common/ListItemRow\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\n\ninterface UnifiedSkillsPanelProps {\n  onOpenDiscovery: () => void;\n  currentApp: AppId;\n}\n\nexport interface UnifiedSkillsPanelHandle {\n  openDiscovery: () => void;\n  openImport: () => void;\n  openInstallFromZip: () => void;\n  openRestoreFromBackup: () => void;\n}\n\nfunction formatSkillBackupDate(unixSeconds: number): string {\n  const date = new Date(unixSeconds * 1000);\n  return Number.isNaN(date.getTime())\n    ? String(unixSeconds)\n    : date.toLocaleString();\n}\n\nconst UnifiedSkillsPanel = React.forwardRef<\n  UnifiedSkillsPanelHandle,\n  UnifiedSkillsPanelProps\n>(({ onOpenDiscovery, currentApp }, ref) => {\n  const { t } = useTranslation();\n  const [confirmDialog, setConfirmDialog] = useState<{\n    isOpen: boolean;\n    title: string;\n    message: string;\n    confirmText?: string;\n    variant?: \"destructive\" | \"info\";\n    onConfirm: () => void;\n  } | null>(null);\n  const [importDialogOpen, setImportDialogOpen] = useState(false);\n  const [restoreDialogOpen, setRestoreDialogOpen] = useState(false);\n\n  const { data: skills, isLoading } = useInstalledSkills();\n  const {\n    data: skillBackups = [],\n    refetch: refetchSkillBackups,\n    isFetching: isFetchingSkillBackups,\n  } = useSkillBackups();\n  const deleteBackupMutation = useDeleteSkillBackup();\n  const toggleAppMutation = useToggleSkillApp();\n  const uninstallMutation = useUninstallSkill();\n  const restoreBackupMutation = useRestoreSkillBackup();\n  const { data: unmanagedSkills, refetch: scanUnmanaged } =\n    useScanUnmanagedSkills();\n  const importMutation = useImportSkillsFromApps();\n  const installFromZipMutation = useInstallSkillsFromZip();\n\n  const enabledCounts = useMemo(() => {\n    const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0, openclaw: 0 };\n    if (!skills) return counts;\n    skills.forEach((skill) => {\n      for (const app of MCP_SKILLS_APP_IDS) {\n        if (skill.apps[app]) counts[app]++;\n      }\n    });\n    return counts;\n  }, [skills]);\n\n  const handleToggleApp = async (id: string, app: AppId, enabled: boolean) => {\n    try {\n      await toggleAppMutation.mutateAsync({ id, app, enabled });\n    } catch (error) {\n      toast.error(t(\"common.error\"), { description: String(error) });\n    }\n  };\n\n  const handleUninstall = (skill: InstalledSkill) => {\n    setConfirmDialog({\n      isOpen: true,\n      title: t(\"skills.uninstall\"),\n      message: t(\"skills.uninstallConfirm\", { name: skill.name }),\n      onConfirm: async () => {\n        try {\n          const result = await uninstallMutation.mutateAsync(skill.id);\n          setConfirmDialog(null);\n          toast.success(t(\"skills.uninstallSuccess\", { name: skill.name }), {\n            description: result.backupPath\n              ? t(\"skills.backup.location\", { path: result.backupPath })\n              : undefined,\n            closeButton: true,\n          });\n        } catch (error) {\n          toast.error(t(\"common.error\"), { description: String(error) });\n        }\n      },\n    });\n  };\n\n  const handleOpenImport = async () => {\n    try {\n      const result = await scanUnmanaged();\n      if (!result.data || result.data.length === 0) {\n        toast.success(t(\"skills.noUnmanagedFound\"), { closeButton: true });\n        return;\n      }\n      setImportDialogOpen(true);\n    } catch (error) {\n      toast.error(t(\"common.error\"), { description: String(error) });\n    }\n  };\n\n  const handleImport = async (imports: ImportSkillSelection[]) => {\n    try {\n      const imported = await importMutation.mutateAsync(imports);\n      setImportDialogOpen(false);\n      toast.success(t(\"skills.importSuccess\", { count: imported.length }), {\n        closeButton: true,\n      });\n    } catch (error) {\n      toast.error(t(\"common.error\"), { description: String(error) });\n    }\n  };\n\n  const handleInstallFromZip = async () => {\n    try {\n      const filePath = await skillsApi.openZipFileDialog();\n      if (!filePath) return;\n\n      const installed = await installFromZipMutation.mutateAsync({\n        filePath,\n        currentApp,\n      });\n\n      if (installed.length === 0) {\n        toast.info(t(\"skills.installFromZip.noSkillsFound\"), {\n          closeButton: true,\n        });\n      } else if (installed.length === 1) {\n        toast.success(\n          t(\"skills.installFromZip.successSingle\", { name: installed[0].name }),\n          { closeButton: true },\n        );\n      } else {\n        toast.success(\n          t(\"skills.installFromZip.successMultiple\", {\n            count: installed.length,\n          }),\n          { closeButton: true },\n        );\n      }\n    } catch (error) {\n      toast.error(t(\"skills.installFailed\"), { description: String(error) });\n    }\n  };\n\n  const handleOpenRestoreFromBackup = async () => {\n    setRestoreDialogOpen(true);\n    try {\n      await refetchSkillBackups();\n    } catch (error) {\n      toast.error(t(\"common.error\"), { description: String(error) });\n    }\n  };\n\n  const handleRestoreFromBackup = async (backupId: string) => {\n    try {\n      const restored = await restoreBackupMutation.mutateAsync({\n        backupId,\n        currentApp,\n      });\n      setRestoreDialogOpen(false);\n      toast.success(\n        t(\"skills.restoreFromBackup.success\", { name: restored.name }),\n        {\n          closeButton: true,\n        },\n      );\n    } catch (error) {\n      toast.error(t(\"skills.restoreFromBackup.failed\"), {\n        description: String(error),\n      });\n    }\n  };\n\n  const handleDeleteBackup = (backup: SkillBackupEntry) => {\n    setConfirmDialog({\n      isOpen: true,\n      title: t(\"skills.restoreFromBackup.deleteConfirmTitle\"),\n      message: t(\"skills.restoreFromBackup.deleteConfirmMessage\", {\n        name: backup.skill.name,\n      }),\n      confirmText: t(\"skills.restoreFromBackup.delete\"),\n      variant: \"destructive\",\n      onConfirm: async () => {\n        try {\n          await deleteBackupMutation.mutateAsync(backup.backupId);\n          await refetchSkillBackups();\n          setConfirmDialog(null);\n          toast.success(\n            t(\"skills.restoreFromBackup.deleteSuccess\", {\n              name: backup.skill.name,\n            }),\n            {\n              closeButton: true,\n            },\n          );\n        } catch (error) {\n          toast.error(t(\"skills.restoreFromBackup.deleteFailed\"), {\n            description: String(error),\n          });\n        }\n      },\n    });\n  };\n\n  React.useImperativeHandle(ref, () => ({\n    openDiscovery: onOpenDiscovery,\n    openImport: handleOpenImport,\n    openInstallFromZip: handleInstallFromZip,\n    openRestoreFromBackup: handleOpenRestoreFromBackup,\n  }));\n\n  return (\n    <div className=\"px-6 flex flex-col flex-1 min-h-0 overflow-hidden\">\n      <AppCountBar\n        totalLabel={t(\"skills.installed\", { count: skills?.length || 0 })}\n        counts={enabledCounts}\n        appIds={MCP_SKILLS_APP_IDS}\n      />\n\n      <div className=\"flex-1 overflow-y-auto overflow-x-hidden pb-24\">\n        {isLoading ? (\n          <div className=\"text-center py-12 text-muted-foreground\">\n            {t(\"skills.loading\")}\n          </div>\n        ) : !skills || skills.length === 0 ? (\n          <div className=\"text-center py-12\">\n            <div className=\"w-16 h-16 mx-auto mb-4 bg-muted rounded-full flex items-center justify-center\">\n              <Sparkles size={24} className=\"text-muted-foreground\" />\n            </div>\n            <h3 className=\"text-lg font-medium text-foreground mb-2\">\n              {t(\"skills.noInstalled\")}\n            </h3>\n            <p className=\"text-muted-foreground text-sm\">\n              {t(\"skills.noInstalledDescription\")}\n            </p>\n          </div>\n        ) : (\n          <TooltipProvider delayDuration={300}>\n            <div className=\"rounded-xl border border-border-default overflow-hidden\">\n              {skills.map((skill, index) => (\n                <InstalledSkillListItem\n                  key={skill.id}\n                  skill={skill}\n                  onToggleApp={handleToggleApp}\n                  onUninstall={() => handleUninstall(skill)}\n                  isLast={index === skills.length - 1}\n                />\n              ))}\n            </div>\n          </TooltipProvider>\n        )}\n      </div>\n\n      {confirmDialog && (\n        <ConfirmDialog\n          isOpen={confirmDialog.isOpen}\n          title={confirmDialog.title}\n          message={confirmDialog.message}\n          confirmText={confirmDialog.confirmText}\n          variant={confirmDialog.variant}\n          zIndex=\"top\"\n          onConfirm={confirmDialog.onConfirm}\n          onCancel={() => setConfirmDialog(null)}\n        />\n      )}\n\n      {importDialogOpen && unmanagedSkills && (\n        <ImportSkillsDialog\n          skills={unmanagedSkills}\n          onImport={handleImport}\n          onClose={() => setImportDialogOpen(false)}\n        />\n      )}\n\n      <RestoreSkillsDialog\n        backups={skillBackups}\n        isDeleting={deleteBackupMutation.isPending}\n        isLoading={isFetchingSkillBackups}\n        onDelete={handleDeleteBackup}\n        isRestoring={restoreBackupMutation.isPending}\n        onRestore={handleRestoreFromBackup}\n        onClose={() => setRestoreDialogOpen(false)}\n        open={restoreDialogOpen}\n      />\n    </div>\n  );\n});\n\nUnifiedSkillsPanel.displayName = \"UnifiedSkillsPanel\";\n\ninterface InstalledSkillListItemProps {\n  skill: InstalledSkill;\n  onToggleApp: (id: string, app: AppId, enabled: boolean) => void;\n  onUninstall: () => void;\n  isLast?: boolean;\n}\n\nconst InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({\n  skill,\n  onToggleApp,\n  onUninstall,\n  isLast,\n}) => {\n  const { t } = useTranslation();\n\n  const openDocs = async () => {\n    if (!skill.readmeUrl) return;\n    try {\n      await settingsApi.openExternal(skill.readmeUrl);\n    } catch {\n      // ignore\n    }\n  };\n\n  const sourceLabel = useMemo(() => {\n    if (skill.repoOwner && skill.repoName) {\n      return `${skill.repoOwner}/${skill.repoName}`;\n    }\n    return t(\"skills.local\");\n  }, [skill.repoOwner, skill.repoName, t]);\n\n  return (\n    <ListItemRow isLast={isLast}>\n      <div className=\"flex-1 min-w-0\">\n        <div className=\"flex items-center gap-1.5\">\n          <span className=\"font-medium text-sm text-foreground truncate\">\n            {skill.name}\n          </span>\n          {skill.readmeUrl && (\n            <button\n              type=\"button\"\n              onClick={openDocs}\n              className=\"text-muted-foreground/60 hover:text-foreground flex-shrink-0\"\n            >\n              <ExternalLink size={12} />\n            </button>\n          )}\n          <span className=\"text-xs text-muted-foreground/50 flex-shrink-0\">\n            {sourceLabel}\n          </span>\n        </div>\n        {skill.description && (\n          <p\n            className=\"text-xs text-muted-foreground truncate\"\n            title={skill.description}\n          >\n            {skill.description}\n          </p>\n        )}\n      </div>\n\n      <AppToggleGroup\n        apps={skill.apps}\n        onToggle={(app, enabled) => onToggleApp(skill.id, app, enabled)}\n        appIds={MCP_SKILLS_APP_IDS}\n      />\n\n      <div className=\"flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity\">\n        <Button\n          type=\"button\"\n          variant=\"ghost\"\n          size=\"icon\"\n          className=\"h-7 w-7 hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10\"\n          onClick={onUninstall}\n          title={t(\"skills.uninstall\")}\n        >\n          <Trash2 size={14} />\n        </Button>\n      </div>\n    </ListItemRow>\n  );\n};\n\ninterface ImportSkillsDialogProps {\n  skills: Array<{\n    directory: string;\n    name: string;\n    description?: string;\n    foundIn: string[];\n    path: string;\n  }>;\n  onImport: (imports: ImportSkillSelection[]) => void;\n  onClose: () => void;\n}\n\ninterface RestoreSkillsDialogProps {\n  backups: SkillBackupEntry[];\n  isDeleting: boolean;\n  isLoading: boolean;\n  isRestoring: boolean;\n  onDelete: (backup: SkillBackupEntry) => void;\n  onRestore: (backupId: string) => void;\n  onClose: () => void;\n  open: boolean;\n}\n\nconst RestoreSkillsDialog: React.FC<RestoreSkillsDialogProps> = ({\n  backups,\n  isDeleting,\n  isLoading,\n  isRestoring,\n  onDelete,\n  onRestore,\n  onClose,\n  open,\n}) => {\n  const { t } = useTranslation();\n\n  return (\n    <Dialog open={open} onOpenChange={(nextOpen) => !nextOpen && onClose()}>\n      <DialogContent\n        className=\"max-w-2xl max-h-[85vh] flex flex-col\"\n        zIndex=\"alert\"\n      >\n        <DialogHeader>\n          <DialogTitle>{t(\"skills.restoreFromBackup.title\")}</DialogTitle>\n          <DialogDescription>\n            {t(\"skills.restoreFromBackup.description\")}\n          </DialogDescription>\n        </DialogHeader>\n\n        <div className=\"flex-1 overflow-y-auto px-6 py-4\">\n          {isLoading ? (\n            <div className=\"py-10 text-center text-sm text-muted-foreground\">\n              {t(\"common.loading\")}\n            </div>\n          ) : backups.length === 0 ? (\n            <div className=\"py-10 text-center text-sm text-muted-foreground\">\n              {t(\"skills.restoreFromBackup.empty\")}\n            </div>\n          ) : (\n            <div className=\"space-y-3\">\n              {backups.map((backup) => (\n                <div\n                  key={backup.backupId}\n                  className=\"rounded-xl border border-border-default bg-background/70 p-4 shadow-sm\"\n                >\n                  <div className=\"flex items-start justify-between gap-4\">\n                    <div className=\"min-w-0 flex-1\">\n                      <div className=\"flex items-center gap-2\">\n                        <div className=\"font-medium text-sm text-foreground\">\n                          {backup.skill.name}\n                        </div>\n                        <div className=\"rounded-md bg-muted px-2 py-0.5 text-[11px] text-muted-foreground\">\n                          {backup.skill.directory}\n                        </div>\n                      </div>\n                      {backup.skill.description && (\n                        <div className=\"mt-2 text-sm text-muted-foreground\">\n                          {backup.skill.description}\n                        </div>\n                      )}\n                      <div className=\"mt-3 space-y-1.5 text-xs text-muted-foreground\">\n                        <div>\n                          {t(\"skills.restoreFromBackup.createdAt\")}:{\" \"}\n                          {formatSkillBackupDate(backup.createdAt)}\n                        </div>\n                        <div className=\"break-all\" title={backup.backupPath}>\n                          {t(\"skills.restoreFromBackup.path\")}:{\" \"}\n                          {backup.backupPath}\n                        </div>\n                      </div>\n                    </div>\n\n                    <div className=\"flex flex-col gap-2 sm:min-w-28\">\n                      <Button\n                        type=\"button\"\n                        variant=\"outline\"\n                        onClick={() => onRestore(backup.backupId)}\n                        disabled={isRestoring || isDeleting}\n                      >\n                        {isRestoring\n                          ? t(\"skills.restoreFromBackup.restoring\")\n                          : t(\"skills.restoreFromBackup.restore\")}\n                      </Button>\n                      <Button\n                        type=\"button\"\n                        variant=\"destructive\"\n                        onClick={() => onDelete(backup)}\n                        disabled={isRestoring || isDeleting}\n                      >\n                        {isDeleting\n                          ? t(\"skills.restoreFromBackup.deleting\")\n                          : t(\"skills.restoreFromBackup.delete\")}\n                      </Button>\n                    </div>\n                  </div>\n                </div>\n              ))}\n            </div>\n          )}\n        </div>\n\n        <DialogFooter>\n          <Button type=\"button\" variant=\"outline\" onClick={onClose}>\n            {t(\"common.close\")}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n};\n\nconst ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({\n  skills,\n  onImport,\n  onClose,\n}) => {\n  const { t } = useTranslation();\n  const [selected, setSelected] = useState<Set<string>>(\n    new Set(skills.map((s) => s.directory)),\n  );\n  const [selectedApps, setSelectedApps] = useState<\n    Record<string, ImportSkillSelection[\"apps\"]>\n  >(() =>\n    Object.fromEntries(\n      skills.map((skill) => [\n        skill.directory,\n        {\n          claude: skill.foundIn.includes(\"claude\"),\n          codex: skill.foundIn.includes(\"codex\"),\n          gemini: skill.foundIn.includes(\"gemini\"),\n          opencode: skill.foundIn.includes(\"opencode\"),\n          openclaw: false,\n        },\n      ]),\n    ),\n  );\n\n  const toggleSelect = (directory: string) => {\n    const newSelected = new Set(selected);\n    if (newSelected.has(directory)) {\n      newSelected.delete(directory);\n    } else {\n      newSelected.add(directory);\n    }\n    setSelected(newSelected);\n  };\n\n  const handleImport = () => {\n    onImport(\n      Array.from(selected).map((directory) => ({\n        directory,\n        apps: selectedApps[directory] ?? {\n          claude: false,\n          codex: false,\n          gemini: false,\n          opencode: false,\n          openclaw: false,\n        },\n      })),\n    );\n  };\n\n  return (\n    <TooltipProvider delayDuration={300}>\n      <div className=\"fixed inset-0 bg-black/50 flex items-center justify-center z-50\">\n        <div className=\"bg-background rounded-xl p-6 max-w-lg w-full mx-4 shadow-xl max-h-[80vh] flex flex-col\">\n          <h2 className=\"text-lg font-semibold mb-2\">{t(\"skills.import\")}</h2>\n          <p className=\"text-sm text-muted-foreground mb-4\">\n            {t(\"skills.importDescription\")}\n          </p>\n\n          <div className=\"flex-1 overflow-y-auto space-y-2 mb-4\">\n            {skills.map((skill) => (\n              <div\n                key={skill.directory}\n                className=\"flex items-start gap-3 p-3 rounded-lg border hover:bg-muted\"\n              >\n                <input\n                  type=\"checkbox\"\n                  checked={selected.has(skill.directory)}\n                  onChange={() => toggleSelect(skill.directory)}\n                  className=\"mt-1\"\n                />\n                <div className=\"flex-1 min-w-0\">\n                  <div className=\"font-medium\">{skill.name}</div>\n                  {skill.description && (\n                    <div className=\"text-sm text-muted-foreground line-clamp-1\">\n                      {skill.description}\n                    </div>\n                  )}\n                  <div className=\"mt-2\">\n                    <AppToggleGroup\n                      apps={\n                        selectedApps[skill.directory] ?? {\n                          claude: false,\n                          codex: false,\n                          gemini: false,\n                          opencode: false,\n                          openclaw: false,\n                        }\n                      }\n                      onToggle={(app, enabled) => {\n                        setSelectedApps((prev) => ({\n                          ...prev,\n                          [skill.directory]: {\n                            ...(prev[skill.directory] ?? {\n                              claude: false,\n                              codex: false,\n                              gemini: false,\n                              opencode: false,\n                              openclaw: false,\n                            }),\n                            [app]: enabled,\n                          },\n                        }));\n                      }}\n                      appIds={MCP_SKILLS_APP_IDS}\n                    />\n                  </div>\n                  <div\n                    className=\"text-xs text-muted-foreground/50 mt-1 truncate\"\n                    title={skill.path}\n                  >\n                    {skill.path}\n                  </div>\n                </div>\n              </div>\n            ))}\n          </div>\n\n          <div className=\"flex justify-end gap-3\">\n            <Button variant=\"outline\" onClick={onClose}>\n              {t(\"common.cancel\")}\n            </Button>\n            <Button onClick={handleImport} disabled={selected.size === 0}>\n              {t(\"skills.importSelected\", { count: selected.size })}\n            </Button>\n          </div>\n        </div>\n      </div>\n    </TooltipProvider>\n  );\n};\n\nexport default UnifiedSkillsPanel;\n"
  },
  {
    "path": "src/components/theme-provider.tsx",
    "content": "import React, {\n  createContext,\n  useContext,\n  useEffect,\n  useMemo,\n  useState,\n} from \"react\";\nimport { invoke } from \"@tauri-apps/api/core\";\n\ntype Theme = \"light\" | \"dark\" | \"system\";\n\ninterface ThemeProviderProps {\n  children: React.ReactNode;\n  defaultTheme?: Theme;\n  storageKey?: string;\n}\n\ninterface ThemeContextValue {\n  theme: Theme;\n  setTheme: (theme: Theme, event?: React.MouseEvent) => void;\n}\n\nconst ThemeProviderContext = createContext<ThemeContextValue | undefined>(\n  undefined,\n);\n\nexport function ThemeProvider({\n  children,\n  defaultTheme = \"system\",\n  storageKey = \"cc-switch-theme\",\n}: ThemeProviderProps) {\n  const getInitialTheme = () => {\n    if (typeof window === \"undefined\") {\n      return defaultTheme;\n    }\n\n    const stored = window.localStorage.getItem(storageKey) as Theme | null;\n    if (stored === \"light\" || stored === \"dark\" || stored === \"system\") {\n      return stored;\n    }\n\n    return defaultTheme;\n  };\n\n  const [theme, setThemeState] = useState<Theme>(getInitialTheme);\n\n  useEffect(() => {\n    if (typeof window === \"undefined\") {\n      return;\n    }\n\n    window.localStorage.setItem(storageKey, theme);\n  }, [theme, storageKey]);\n\n  useEffect(() => {\n    if (typeof window === \"undefined\") {\n      return;\n    }\n\n    const root = window.document.documentElement;\n    root.classList.remove(\"light\", \"dark\");\n\n    if (theme === \"system\") {\n      const isDark =\n        window.matchMedia &&\n        window.matchMedia(\"(prefers-color-scheme: dark)\").matches;\n      root.classList.add(isDark ? \"dark\" : \"light\");\n      return;\n    }\n\n    root.classList.add(theme);\n  }, [theme]);\n\n  useEffect(() => {\n    if (typeof window === \"undefined\") {\n      return;\n    }\n\n    const mediaQuery = window.matchMedia(\"(prefers-color-scheme: dark)\");\n    const handleChange = () => {\n      if (theme !== \"system\") {\n        return;\n      }\n\n      const root = window.document.documentElement;\n      root.classList.toggle(\"dark\", mediaQuery.matches);\n      root.classList.toggle(\"light\", !mediaQuery.matches);\n    };\n\n    if (theme === \"system\") {\n      handleChange();\n    }\n\n    mediaQuery.addEventListener(\"change\", handleChange);\n    return () => mediaQuery.removeEventListener(\"change\", handleChange);\n  }, [theme]);\n\n  // Sync native window theme (Windows/macOS title bar)\n  useEffect(() => {\n    if (typeof window === \"undefined\") {\n      return;\n    }\n\n    let isCancelled = false;\n\n    const updateNativeTheme = async (nativeTheme: string) => {\n      if (isCancelled) return;\n      try {\n        await invoke(\"set_window_theme\", { theme: nativeTheme });\n      } catch (e) {\n        // Ignore errors (e.g., when not running in Tauri)\n        console.debug(\"Failed to set native window theme:\", e);\n      }\n    };\n\n    // When \"system\", pass \"system\" so Tauri uses None (follows OS theme natively).\n    // This keeps the WebView's prefers-color-scheme in sync with the real OS theme,\n    // allowing effect #3's media query listener to fire on system theme changes.\n    if (theme === \"system\") {\n      updateNativeTheme(\"system\");\n    } else {\n      updateNativeTheme(theme);\n    }\n\n    return () => {\n      isCancelled = true;\n    };\n  }, [theme]);\n\n  const value = useMemo<ThemeContextValue>(\n    () => ({\n      theme,\n      setTheme: (nextTheme: Theme, event?: React.MouseEvent) => {\n        // Skip if same theme\n        if (nextTheme === theme) return;\n\n        // Set transition origin coordinates from click event\n        const x = event?.clientX ?? window.innerWidth / 2;\n        const y = event?.clientY ?? window.innerHeight / 2;\n        document.documentElement.style.setProperty(\n          \"--theme-transition-x\",\n          `${x}px`,\n        );\n        document.documentElement.style.setProperty(\n          \"--theme-transition-y\",\n          `${y}px`,\n        );\n\n        // Use View Transitions API if available, otherwise fall back to instant change\n        if (document.startViewTransition) {\n          document.startViewTransition(() => {\n            setThemeState(nextTheme);\n          });\n        } else {\n          setThemeState(nextTheme);\n        }\n      },\n    }),\n    [theme],\n  );\n\n  return (\n    <ThemeProviderContext.Provider value={value}>\n      {children}\n    </ThemeProviderContext.Provider>\n  );\n}\n\nexport function useTheme() {\n  const context = useContext(ThemeProviderContext);\n  if (context === undefined) {\n    throw new Error(\"useTheme must be used within a ThemeProvider\");\n  }\n  return context;\n}\n"
  },
  {
    "path": "src/components/ui/accordion.tsx",
    "content": "import * as React from \"react\";\nimport * as AccordionPrimitive from \"@radix-ui/react-accordion\";\nimport { ChevronDown } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst Accordion = AccordionPrimitive.Root;\n\nconst AccordionItem = React.forwardRef<\n  React.ElementRef<typeof AccordionPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>\n>(({ className, ...props }, ref) => (\n  <AccordionPrimitive.Item\n    ref={ref}\n    className={cn(\"border-b\", className)}\n    {...props}\n  />\n));\nAccordionItem.displayName = \"AccordionItem\";\n\nconst AccordionTrigger = React.forwardRef<\n  React.ElementRef<typeof AccordionPrimitive.Trigger>,\n  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>\n>(({ className, children, ...props }, ref) => (\n  <AccordionPrimitive.Header className=\"flex\">\n    <AccordionPrimitive.Trigger\n      ref={ref}\n      className={cn(\n        \"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180\",\n        className,\n      )}\n      {...props}\n    >\n      {children}\n      <ChevronDown className=\"h-4 w-4 shrink-0 transition-transform duration-200\" />\n    </AccordionPrimitive.Trigger>\n  </AccordionPrimitive.Header>\n));\nAccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;\n\nconst AccordionContent = React.forwardRef<\n  React.ElementRef<typeof AccordionPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>\n>(({ className, children, ...props }, ref) => (\n  <AccordionPrimitive.Content\n    ref={ref}\n    className=\"overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down\"\n    {...props}\n  >\n    <div className={cn(\"pb-4 pt-0\", className)}>{children}</div>\n  </AccordionPrimitive.Content>\n));\n\nAccordionContent.displayName = AccordionPrimitive.Content.displayName;\n\nexport { Accordion, AccordionItem, AccordionTrigger, AccordionContent };\n"
  },
  {
    "path": "src/components/ui/alert.tsx",
    "content": "import * as React from \"react\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst alertVariants = cva(\n  \"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7\",\n  {\n    variants: {\n      variant: {\n        default: \"bg-background text-foreground\",\n        destructive:\n          \"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n    },\n  },\n);\n\nconst Alert = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>\n>(({ className, variant, ...props }, ref) => (\n  <div\n    ref={ref}\n    role=\"alert\"\n    className={cn(alertVariants({ variant }), className)}\n    {...props}\n  />\n));\nAlert.displayName = \"Alert\";\n\nconst AlertTitle = React.forwardRef<\n  HTMLParagraphElement,\n  React.HTMLAttributes<HTMLHeadingElement>\n>(({ className, ...props }, ref) => (\n  <h5\n    ref={ref}\n    className={cn(\"mb-1 font-medium leading-none tracking-tight\", className)}\n    {...props}\n  />\n));\nAlertTitle.displayName = \"AlertTitle\";\n\nconst AlertDescription = React.forwardRef<\n  HTMLParagraphElement,\n  React.HTMLAttributes<HTMLParagraphElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn(\"text-sm [&_p]:leading-relaxed\", className)}\n    {...props}\n  />\n));\nAlertDescription.displayName = \"AlertDescription\";\n\nexport { Alert, AlertTitle, AlertDescription };\n"
  },
  {
    "path": "src/components/ui/badge.tsx",
    "content": "import * as React from \"react\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst badgeVariants = cva(\n  \"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2\",\n  {\n    variants: {\n      variant: {\n        default:\n          \"border-transparent bg-primary text-primary-foreground hover:bg-primary/80\",\n        secondary:\n          \"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80\",\n        destructive:\n          \"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80\",\n        outline: \"text-foreground\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n    },\n  },\n);\n\nexport interface BadgeProps\n  extends React.HTMLAttributes<HTMLDivElement>,\n    VariantProps<typeof badgeVariants> {}\n\nfunction Badge({ className, variant, ...props }: BadgeProps) {\n  return (\n    <div className={cn(badgeVariants({ variant }), className)} {...props} />\n  );\n}\n\nexport { Badge, badgeVariants };\n"
  },
  {
    "path": "src/components/ui/button.tsx",
    "content": "import * as React from \"react\";\nimport { Slot } from \"@radix-ui/react-slot\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport { cn } from \"@/lib/utils\";\n\nconst buttonVariants = cva(\n  \"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50\",\n  {\n    variants: {\n      variant: {\n        // 主按钮：蓝底白字（对应旧版 primary）\n        default:\n          \"bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700\",\n        // 危险按钮：红底白字（对应旧版 danger）\n        destructive:\n          \"bg-red-500 text-white hover:bg-red-600 dark:bg-red-600 dark:hover:bg-red-700\",\n        // 轮廓按钮\n        outline:\n          \"border border-border-default bg-background text-muted-foreground hover:bg-gray-100 hover:text-gray-900 hover:border-border-hover dark:hover:bg-gray-800 dark:hover:text-gray-100\",\n        // 次按钮：灰色（对应旧版 secondary）\n        secondary:\n          \"text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200\",\n        // 幽灵按钮（对应旧版 ghost）\n        ghost:\n          \"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\",\n        // MCP 专属按钮：祖母绿\n        mcp: \"bg-emerald-500 text-white hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700\",\n        // 链接按钮\n        link: \"text-blue-500 underline-offset-4 hover:underline dark:text-blue-400\",\n      },\n      size: {\n        default: \"h-9 px-4 py-2\",\n        sm: \"h-8 rounded-md px-3 text-xs\",\n        lg: \"h-10 rounded-md px-8\",\n        icon: \"h-9 w-9 p-1.5\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n      size: \"default\",\n    },\n  },\n);\n\nexport interface ButtonProps\n  extends React.ButtonHTMLAttributes<HTMLButtonElement>,\n    VariantProps<typeof buttonVariants> {\n  asChild?: boolean;\n}\n\nconst Button = React.forwardRef<HTMLButtonElement, ButtonProps>(\n  ({ className, variant, size, asChild = false, ...props }, ref) => {\n    const Comp = asChild ? Slot : \"button\";\n    return (\n      <Comp\n        className={cn(buttonVariants({ variant, size, className }))}\n        ref={ref}\n        {...props}\n      />\n    );\n  },\n);\nButton.displayName = \"Button\";\n\nexport { Button, buttonVariants };\n"
  },
  {
    "path": "src/components/ui/card.tsx",
    "content": "import * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst Card = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn(\n      \"rounded-lg border bg-card text-card-foreground shadow-sm\",\n      className,\n    )}\n    {...props}\n  />\n));\nCard.displayName = \"Card\";\n\nconst CardHeader = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn(\"flex flex-col space-y-1.5 p-6\", className)}\n    {...props}\n  />\n));\nCardHeader.displayName = \"CardHeader\";\n\nconst CardTitle = React.forwardRef<\n  HTMLParagraphElement,\n  React.HTMLAttributes<HTMLHeadingElement>\n>(({ className, ...props }, ref) => (\n  <h3\n    ref={ref}\n    className={cn(\n      \"text-2xl font-semibold leading-none tracking-tight\",\n      className,\n    )}\n    {...props}\n  />\n));\nCardTitle.displayName = \"CardTitle\";\n\nconst CardDescription = React.forwardRef<\n  HTMLParagraphElement,\n  React.HTMLAttributes<HTMLParagraphElement>\n>(({ className, ...props }, ref) => (\n  <p\n    ref={ref}\n    className={cn(\"text-sm text-muted-foreground\", className)}\n    {...props}\n  />\n));\nCardDescription.displayName = \"CardDescription\";\n\nconst CardContent = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div ref={ref} className={cn(\"p-6 pt-0\", className)} {...props} />\n));\nCardContent.displayName = \"CardContent\";\n\nconst CardFooter = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn(\"flex items-center p-6 pt-0\", className)}\n    {...props}\n  />\n));\nCardFooter.displayName = \"CardFooter\";\n\nexport {\n  Card,\n  CardHeader,\n  CardFooter,\n  CardTitle,\n  CardDescription,\n  CardContent,\n};\n"
  },
  {
    "path": "src/components/ui/checkbox.tsx",
    "content": "import * as React from \"react\";\nimport * as CheckboxPrimitive from \"@radix-ui/react-checkbox\";\nimport { Check } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst Checkbox = React.forwardRef<\n  React.ElementRef<typeof CheckboxPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>\n>(({ className, ...props }, ref) => (\n  <CheckboxPrimitive.Root\n    ref={ref}\n    className={cn(\n      \"grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground\",\n      className,\n    )}\n    {...props}\n  >\n    <CheckboxPrimitive.Indicator\n      className={cn(\"grid place-content-center text-current\")}\n    >\n      <Check className=\"h-4 w-4\" />\n    </CheckboxPrimitive.Indicator>\n  </CheckboxPrimitive.Root>\n));\nCheckbox.displayName = CheckboxPrimitive.Root.displayName;\n\nexport { Checkbox };\n"
  },
  {
    "path": "src/components/ui/collapsible.tsx",
    "content": "import * as CollapsiblePrimitive from \"@radix-ui/react-collapsible\";\n\nconst Collapsible = CollapsiblePrimitive.Root;\n\nconst CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;\n\nconst CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;\n\nexport { Collapsible, CollapsibleTrigger, CollapsibleContent };\n"
  },
  {
    "path": "src/components/ui/command.tsx",
    "content": "import * as React from \"react\";\nimport { Command as CommandPrimitive } from \"cmdk\";\nimport { Search } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\nconst Command = React.forwardRef<\n  React.ComponentRef<typeof CommandPrimitive>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive\n    ref={ref}\n    className={cn(\n      \"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground\",\n      className,\n    )}\n    {...props}\n  />\n));\nCommand.displayName = CommandPrimitive.displayName;\n\nconst CommandInput = React.forwardRef<\n  React.ComponentRef<typeof CommandPrimitive.Input>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>\n>(({ className, ...props }, ref) => (\n  <div\n    className=\"flex items-center border-b px-3 focus-within:outline-none focus-within:ring-0\"\n    cmdk-input-wrapper=\"\"\n  >\n    <Search className=\"mr-2 h-4 w-4 shrink-0 opacity-50\" />\n    <CommandPrimitive.Input\n      ref={ref}\n      className={cn(\n        \"flex h-9 w-full rounded-md bg-transparent py-3 text-sm outline-none ring-0 focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50\",\n        className,\n      )}\n      {...props}\n    />\n  </div>\n));\nCommandInput.displayName = CommandPrimitive.Input.displayName;\n\nconst CommandList = React.forwardRef<\n  React.ComponentRef<typeof CommandPrimitive.List>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive.List\n    ref={ref}\n    className={cn(\"max-h-[300px] overflow-y-auto overflow-x-hidden\", className)}\n    {...props}\n  />\n));\nCommandList.displayName = CommandPrimitive.List.displayName;\n\nconst CommandEmpty = React.forwardRef<\n  React.ComponentRef<typeof CommandPrimitive.Empty>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>\n>((props, ref) => (\n  <CommandPrimitive.Empty\n    ref={ref}\n    className=\"py-6 text-center text-sm\"\n    {...props}\n  />\n));\nCommandEmpty.displayName = CommandPrimitive.Empty.displayName;\n\nconst CommandGroup = React.forwardRef<\n  React.ComponentRef<typeof CommandPrimitive.Group>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive.Group\n    ref={ref}\n    className={cn(\n      \"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground\",\n      className,\n    )}\n    {...props}\n  />\n));\nCommandGroup.displayName = CommandPrimitive.Group.displayName;\n\nconst CommandItem = React.forwardRef<\n  React.ComponentRef<typeof CommandPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive.Item\n    ref={ref}\n    className={cn(\n      \"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0\",\n      className,\n    )}\n    {...props}\n  />\n));\nCommandItem.displayName = CommandPrimitive.Item.displayName;\n\nexport {\n  Command,\n  CommandInput,\n  CommandList,\n  CommandEmpty,\n  CommandGroup,\n  CommandItem,\n};\n"
  },
  {
    "path": "src/components/ui/dialog.tsx",
    "content": "import * as React from \"react\";\nimport * as DialogPrimitive from \"@radix-ui/react-dialog\";\nimport { cn } from \"@/lib/utils\";\n\nconst Dialog = DialogPrimitive.Root;\n\nconst DialogTrigger = DialogPrimitive.Trigger;\n\nconst DialogPortal = DialogPrimitive.Portal;\n\nconst DialogClose = DialogPrimitive.Close;\n\nconst DialogOverlay = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Overlay>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> & {\n    zIndex?: \"base\" | \"nested\" | \"alert\" | \"top\";\n  }\n>(({ className, zIndex = \"base\", ...props }, ref) => {\n  const zIndexMap = {\n    base: \"z-40\",\n    nested: \"z-50\",\n    alert: \"z-[60]\",\n    top: \"z-[110]\",\n  };\n\n  return (\n    <DialogPrimitive.Overlay\n      ref={ref}\n      className={cn(\n        \"fixed inset-0 bg-black/50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\",\n        zIndexMap[zIndex],\n        className,\n      )}\n      {...props}\n    />\n  );\n});\nDialogOverlay.displayName = DialogPrimitive.Overlay.displayName;\n\nconst DialogContent = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {\n    zIndex?: \"base\" | \"nested\" | \"alert\" | \"top\";\n    variant?: \"default\" | \"fullscreen\";\n    overlayClassName?: string;\n  }\n>(\n  (\n    {\n      className,\n      children,\n      zIndex = \"base\",\n      variant = \"default\",\n      overlayClassName,\n      ...props\n    },\n    ref,\n  ) => {\n    const zIndexMap = {\n      base: \"z-40\",\n      nested: \"z-50\",\n      alert: \"z-[60]\",\n      top: \"z-[110]\",\n    };\n\n    const variantClass = {\n      default:\n        \"fixed left-1/2 top-1/2 flex flex-col w-full max-w-lg max-h-[90vh] translate-x-[-50%] translate-y-[-50%] border border-border-default bg-background text-foreground shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg\",\n      fullscreen:\n        \"fixed inset-0 flex flex-col w-screen h-screen translate-x-0 translate-y-0 bg-background text-foreground p-0 sm:rounded-none shadow-none\",\n    }[variant];\n\n    return (\n      <DialogPortal>\n        <DialogOverlay zIndex={zIndex} className={overlayClassName} />\n        <DialogPrimitive.Content\n          ref={ref}\n          className={cn(variantClass, zIndexMap[zIndex], className)}\n          onInteractOutside={(e) => {\n            // 防止点击遮罩层关闭对话框\n            e.preventDefault();\n          }}\n          {...props}\n        >\n          {children}\n        </DialogPrimitive.Content>\n      </DialogPortal>\n    );\n  },\n);\nDialogContent.displayName = DialogPrimitive.Content.displayName;\n\nconst DialogHeader = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn(\n      \"flex flex-col space-y-1.5 text-center sm:text-left px-6 py-5 border-b border-border-default bg-muted/20 flex-shrink-0\",\n      className,\n    )}\n    {...props}\n  />\n);\nDialogHeader.displayName = \"DialogHeader\";\n\nconst DialogFooter = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn(\n      \"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end sm:items-center px-6 py-5 border-t border-border-default bg-muted/20 flex-shrink-0\",\n      className,\n    )}\n    {...props}\n  />\n);\nDialogFooter.displayName = \"DialogFooter\";\n\nconst DialogTitle = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Title>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>\n>(({ className, ...props }, ref) => (\n  <DialogPrimitive.Title\n    ref={ref}\n    className={cn(\n      \"text-lg font-semibold leading-tight tracking-tight\",\n      className,\n    )}\n    {...props}\n  />\n));\nDialogTitle.displayName = DialogPrimitive.Title.displayName;\n\nconst DialogDescription = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Description>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>\n>(({ className, ...props }, ref) => (\n  <DialogPrimitive.Description\n    ref={ref}\n    className={cn(\"text-sm text-muted-foreground\", className)}\n    {...props}\n  />\n));\nDialogDescription.displayName = DialogPrimitive.Description.displayName;\n\nexport {\n  Dialog,\n  DialogTrigger,\n  DialogContent,\n  DialogHeader,\n  DialogFooter,\n  DialogTitle,\n  DialogDescription,\n  DialogClose,\n};\n"
  },
  {
    "path": "src/components/ui/dropdown-menu.tsx",
    "content": "import * as React from \"react\";\nimport * as DropdownMenuPrimitive from \"@radix-ui/react-dropdown-menu\";\nimport { cn } from \"@/lib/utils\";\n\nconst DropdownMenu = DropdownMenuPrimitive.Root;\n\nconst DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;\n\nconst DropdownMenuGroup = DropdownMenuPrimitive.Group;\n\nconst DropdownMenuPortal = DropdownMenuPrimitive.Portal;\n\nconst DropdownMenuSub = DropdownMenuPrimitive.Sub;\n\nconst DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;\n\nconst DropdownMenuSubTrigger = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {\n    inset?: boolean;\n  }\n>(({ className, inset, ...props }, ref) => (\n  <DropdownMenuPrimitive.SubTrigger\n    ref={ref}\n    className={cn(\n      \"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent\",\n      inset && \"pl-8\",\n      className,\n    )}\n    {...props}\n  />\n));\nDropdownMenuSubTrigger.displayName =\n  DropdownMenuPrimitive.SubTrigger.displayName;\n\nconst DropdownMenuSubContent = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>\n>(({ className, ...props }, ref) => (\n  <DropdownMenuPrimitive.SubContent\n    ref={ref}\n    className={cn(\n      \"z-50 min-w-[8rem] rounded-md border border-border-default bg-popover p-1 text-popover-foreground shadow-md\",\n      className,\n    )}\n    {...props}\n  />\n));\nDropdownMenuSubContent.displayName =\n  DropdownMenuPrimitive.SubContent.displayName;\n\nconst DropdownMenuContent = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>\n>(({ className, sideOffset = 4, ...props }, ref) => (\n  <DropdownMenuPrimitive.Portal>\n    <DropdownMenuPrimitive.Content\n      ref={ref}\n      sideOffset={sideOffset}\n      className={cn(\n        \"z-50 min-w-[8rem] overflow-hidden rounded-md border border-border-default bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2\",\n        className,\n      )}\n      {...props}\n    />\n  </DropdownMenuPrimitive.Portal>\n));\nDropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;\n\nconst DropdownMenuItem = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {\n    inset?: boolean;\n  }\n>(({ className, inset, ...props }, ref) => (\n  <DropdownMenuPrimitive.Item\n    ref={ref}\n    className={cn(\n      \"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n      inset && \"pl-8\",\n      className,\n    )}\n    {...props}\n  />\n));\nDropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;\n\nconst DropdownMenuCheckboxItem = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>\n>(({ className, children, checked, ...props }, ref) => (\n  <DropdownMenuPrimitive.CheckboxItem\n    ref={ref}\n    className={cn(\n      \"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n      className,\n    )}\n    checked={checked}\n    {...props}\n  >\n    <span className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n      <DropdownMenuPrimitive.ItemIndicator>\n        <svg\n          className=\"h-4 w-4\"\n          viewBox=\"0 0 20 20\"\n          fill=\"none\"\n          xmlns=\"http://www.w3.org/2000/svg\"\n        >\n          <path\n            d=\"M16.704 5.292a1 1 0 0 1 .083 1.32l-.083.094-8 8a1 1 0 0 1-1.32.083l-.094-.083-4-4a1 1 0 0 1 1.32-1.497l.094.083L8 12.585l7.293-7.292a1 1 0 0 1 1.32-.083l.094.083Z\"\n            fill=\"currentColor\"\n          />\n        </svg>\n      </DropdownMenuPrimitive.ItemIndicator>\n    </span>\n    {children}\n  </DropdownMenuPrimitive.CheckboxItem>\n));\nDropdownMenuCheckboxItem.displayName =\n  DropdownMenuPrimitive.CheckboxItem.displayName;\n\nconst DropdownMenuRadioItem = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>\n>(({ className, children, ...props }, ref) => (\n  <DropdownMenuPrimitive.RadioItem\n    ref={ref}\n    className={cn(\n      \"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n      className,\n    )}\n    {...props}\n  >\n    <span className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n      <DropdownMenuPrimitive.ItemIndicator>\n        <div className=\"h-2 w-2 rounded-full bg-current\" />\n      </DropdownMenuPrimitive.ItemIndicator>\n    </span>\n    {children}\n  </DropdownMenuPrimitive.RadioItem>\n));\nDropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;\n\nconst DropdownMenuLabel = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.Label>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {\n    inset?: boolean;\n  }\n>(({ className, inset, ...props }, ref) => (\n  <DropdownMenuPrimitive.Label\n    ref={ref}\n    className={cn(\n      \"px-2 py-1.5 text-sm font-semibold text-muted-foreground\",\n      inset && \"pl-8\",\n      className,\n    )}\n    {...props}\n  />\n));\nDropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;\n\nconst DropdownMenuSeparator = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.Separator>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>\n>(({ className, ...props }, ref) => (\n  <DropdownMenuPrimitive.Separator\n    ref={ref}\n    className={cn(\"-mx-1 my-1 h-px bg-border\", className)}\n    {...props}\n  />\n));\nDropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;\n\nconst DropdownMenuShortcut = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLSpanElement>) => (\n  <span\n    className={cn(\n      \"ml-auto text-xs tracking-widest text-muted-foreground\",\n      className,\n    )}\n    {...props}\n  />\n);\nDropdownMenuShortcut.displayName = \"DropdownMenuShortcut\";\n\nexport {\n  DropdownMenu,\n  DropdownMenuTrigger,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuCheckboxItem,\n  DropdownMenuRadioItem,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuShortcut,\n  DropdownMenuGroup,\n  DropdownMenuPortal,\n  DropdownMenuSub,\n  DropdownMenuSubTrigger,\n  DropdownMenuSubContent,\n  DropdownMenuRadioGroup,\n};\n"
  },
  {
    "path": "src/components/ui/form.tsx",
    "content": "import * as React from \"react\";\nimport * as LabelPrimitive from \"@radix-ui/react-label\";\nimport { Slot } from \"@radix-ui/react-slot\";\nimport {\n  Controller,\n  FormProvider,\n  useFormContext,\n  type ControllerProps,\n  type FieldPath,\n  type FieldValues,\n} from \"react-hook-form\";\nimport { cn } from \"@/lib/utils\";\n\nconst Form = FormProvider;\n\ntype FormFieldContextValue<\n  TFieldValues extends FieldValues = FieldValues,\n  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,\n> = {\n  name: TName;\n};\n\nconst FormFieldContext = React.createContext<FormFieldContextValue>(\n  {} as FormFieldContextValue,\n);\n\nconst FormField = <\n  TFieldValues extends FieldValues = FieldValues,\n  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,\n>({\n  ...props\n}: ControllerProps<TFieldValues, TName>) => {\n  return (\n    <FormFieldContext.Provider value={{ name: props.name }}>\n      <Controller {...props} />\n    </FormFieldContext.Provider>\n  );\n};\n\nconst useFormField = () => {\n  const fieldContext = React.useContext(FormFieldContext);\n  const itemContext = React.useContext(FormItemContext);\n  const { getFieldState, formState } = useFormContext();\n\n  const fieldState = getFieldState(fieldContext.name, formState);\n\n  if (!fieldContext) {\n    throw new Error(\"useFormField should be used within <FormField>\");\n  }\n\n  const id = itemContext.id;\n\n  return {\n    id,\n    name: fieldContext.name,\n    formItemId: `${id}-form-item`,\n    formDescriptionId: `${id}-form-item-description`,\n    formMessageId: `${id}-form-item-message`,\n    ...fieldState,\n  };\n};\n\nconst FormItemContext = React.createContext<{ id: string }>(\n  {} as { id: string },\n);\n\nconst FormItem = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => {\n  const id = React.useId();\n\n  return (\n    <FormItemContext.Provider value={{ id }}>\n      <div ref={ref} className={cn(\"space-y-2\", className)} {...props} />\n    </FormItemContext.Provider>\n  );\n});\nFormItem.displayName = \"FormItem\";\n\nconst FormLabel = React.forwardRef<\n  React.ElementRef<typeof LabelPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>\n>(({ className, ...props }, ref) => {\n  const { error, formItemId } = useFormField();\n\n  return (\n    <LabelPrimitive.Root\n      ref={ref}\n      className={cn(error && \"text-destructive\", className)}\n      htmlFor={formItemId}\n      {...props}\n    />\n  );\n});\nFormLabel.displayName = LabelPrimitive.Root.displayName;\n\nconst FormControl = React.forwardRef<\n  React.ElementRef<typeof Slot>,\n  React.ComponentPropsWithoutRef<typeof Slot>\n>(({ ...props }, ref) => {\n  const { formItemId, formDescriptionId, formMessageId } = useFormField();\n\n  return (\n    <Slot\n      ref={ref}\n      id={formItemId}\n      aria-describedby={`${formDescriptionId} ${formMessageId}`}\n      {...props}\n    />\n  );\n});\nFormControl.displayName = \"FormControl\";\n\nconst FormDescription = React.forwardRef<\n  HTMLParagraphElement,\n  React.HTMLAttributes<HTMLParagraphElement>\n>(({ className, ...props }, ref) => {\n  const { formDescriptionId } = useFormField();\n\n  return (\n    <p\n      ref={ref}\n      id={formDescriptionId}\n      className={cn(\"text-sm text-muted-foreground\", className)}\n      {...props}\n    />\n  );\n});\nFormDescription.displayName = \"FormDescription\";\n\nconst FormMessage = React.forwardRef<\n  HTMLParagraphElement,\n  React.HTMLAttributes<HTMLParagraphElement>\n>(({ className, children, ...props }, ref) => {\n  const { error, formMessageId } = useFormField();\n  const body = error?.message ?? children;\n\n  if (!body) {\n    return null;\n  }\n\n  return (\n    <p\n      ref={ref}\n      id={formMessageId}\n      className={cn(\"text-sm font-medium text-destructive\", className)}\n      {...props}\n    >\n      {body}\n    </p>\n  );\n});\nFormMessage.displayName = \"FormMessage\";\n\nexport {\n  Form,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormControl,\n  FormDescription,\n  FormMessage,\n  useFormField,\n};\n"
  },
  {
    "path": "src/components/ui/input.tsx",
    "content": "import * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type InputProps = React.InputHTMLAttributes<HTMLInputElement>;\n\nconst Input = React.forwardRef<HTMLInputElement, InputProps>(\n  ({ className, type, ...props }, ref) => {\n    return (\n      <input\n        type={type}\n        className={cn(\n          \"flex h-9 w-full rounded-md border border-border-default bg-background text-foreground px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 disabled:cursor-not-allowed disabled:opacity-50\",\n          className,\n        )}\n        ref={ref}\n        {...props}\n      />\n    );\n  },\n);\nInput.displayName = \"Input\";\n\nexport { Input };\n"
  },
  {
    "path": "src/components/ui/label.tsx",
    "content": "import * as React from \"react\";\nimport * as LabelPrimitive from \"@radix-ui/react-label\";\nimport { cn } from \"@/lib/utils\";\n\nconst Label = React.forwardRef<\n  React.ElementRef<typeof LabelPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>\n>(({ className, ...props }, ref) => (\n  <LabelPrimitive.Root\n    ref={ref}\n    className={cn(\n      \"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70\",\n      className,\n    )}\n    {...props}\n  />\n));\nLabel.displayName = LabelPrimitive.Root.displayName;\n\nexport { Label };\n"
  },
  {
    "path": "src/components/ui/popover.tsx",
    "content": "import * as React from \"react\";\nimport * as PopoverPrimitive from \"@radix-ui/react-popover\";\nimport { cn } from \"@/lib/utils\";\n\nconst Popover = PopoverPrimitive.Root;\nconst PopoverTrigger = PopoverPrimitive.Trigger;\nconst PopoverAnchor = PopoverPrimitive.Anchor;\n\nconst PopoverContent = React.forwardRef<\n  React.ComponentRef<typeof PopoverPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>\n>(({ className, align = \"start\", sideOffset = 4, ...props }, ref) => (\n  <PopoverPrimitive.Portal>\n    <PopoverPrimitive.Content\n      ref={ref}\n      align={align}\n      sideOffset={sideOffset}\n      className={cn(\n        \"z-50 rounded-md border bg-popover text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2\",\n        className,\n      )}\n      {...props}\n    />\n  </PopoverPrimitive.Portal>\n));\nPopoverContent.displayName = PopoverPrimitive.Content.displayName;\n\nexport { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };\n"
  },
  {
    "path": "src/components/ui/scroll-area.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\nimport * as ScrollAreaPrimitive from \"@radix-ui/react-scroll-area\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst ScrollArea = React.forwardRef<\n  React.ElementRef<typeof ScrollAreaPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>\n>(({ className, children, ...props }, ref) => (\n  <ScrollAreaPrimitive.Root\n    ref={ref}\n    className={cn(\"relative overflow-hidden\", className)}\n    {...props}\n  >\n    <ScrollAreaPrimitive.Viewport className=\"h-full w-full rounded-[inherit]\">\n      {children}\n    </ScrollAreaPrimitive.Viewport>\n    <ScrollBar />\n    <ScrollAreaPrimitive.Corner />\n  </ScrollAreaPrimitive.Root>\n));\nScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;\n\nconst ScrollBar = React.forwardRef<\n  React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,\n  React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>\n>(({ className, orientation = \"vertical\", ...props }, ref) => (\n  <ScrollAreaPrimitive.ScrollAreaScrollbar\n    ref={ref}\n    orientation={orientation}\n    className={cn(\n      \"flex touch-none select-none transition-colors\",\n      orientation === \"vertical\" &&\n        \"h-full w-2.5 border-l border-l-transparent p-[1px]\",\n      orientation === \"horizontal\" &&\n        \"h-2.5 flex-col border-t border-t-transparent p-[1px]\",\n      className,\n    )}\n    {...props}\n  >\n    <ScrollAreaPrimitive.ScrollAreaThumb className=\"relative flex-1 rounded-full bg-border\" />\n  </ScrollAreaPrimitive.ScrollAreaScrollbar>\n));\nScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;\n\nexport { ScrollArea, ScrollBar };\n"
  },
  {
    "path": "src/components/ui/select.tsx",
    "content": "import * as React from \"react\";\nimport * as SelectPrimitive from \"@radix-ui/react-select\";\nimport { ChevronDown, ChevronUp } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\nconst Select = SelectPrimitive.Root;\n\nconst SelectGroup = SelectPrimitive.Group;\n\nconst SelectValue = SelectPrimitive.Value;\n\nconst SelectTrigger = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Trigger>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>\n>(({ className, children, ...props }, ref) => (\n  <SelectPrimitive.Trigger\n    ref={ref}\n    className={cn(\n      \"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-border-default bg-background px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:border-border-active disabled:cursor-not-allowed disabled:opacity-50\",\n      className,\n    )}\n    {...props}\n  >\n    {children}\n    <SelectPrimitive.Icon asChild>\n      <ChevronDown className=\"h-4 w-4 opacity-50\" />\n    </SelectPrimitive.Icon>\n  </SelectPrimitive.Trigger>\n));\nSelectTrigger.displayName = SelectPrimitive.Trigger.displayName;\n\nconst SelectContent = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>\n>(({ className, children, position = \"popper\", ...props }, ref) => (\n  <SelectPrimitive.Portal>\n    <SelectPrimitive.Content\n      ref={ref}\n      className={cn(\n        \"relative z-[100] min-w-[8rem] overflow-hidden rounded-md border border-border-default bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2\",\n        className,\n      )}\n      position={position}\n      {...props}\n    >\n      <SelectPrimitive.ScrollUpButton className=\"flex cursor-default items-center justify-center bg-popover py-1\">\n        <ChevronUp className=\"h-4 w-4\" />\n      </SelectPrimitive.ScrollUpButton>\n      <SelectPrimitive.Viewport\n        className={cn(\n          \"p-1\",\n          position === \"popper\" &&\n            \"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]\",\n        )}\n      >\n        {children}\n      </SelectPrimitive.Viewport>\n      <SelectPrimitive.ScrollDownButton className=\"flex cursor-default items-center justify-center bg-popover py-1\">\n        <ChevronDown className=\"h-4 w-4\" />\n      </SelectPrimitive.ScrollDownButton>\n    </SelectPrimitive.Content>\n  </SelectPrimitive.Portal>\n));\nSelectContent.displayName = SelectPrimitive.Content.displayName;\n\nconst SelectLabel = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Label>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>\n>(({ className, ...props }, ref) => (\n  <SelectPrimitive.Label\n    ref={ref}\n    className={cn(\"px-2 py-1.5 text-sm font-semibold\", className)}\n    {...props}\n  />\n));\nSelectLabel.displayName = SelectPrimitive.Label.displayName;\n\nconst SelectItem = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>\n>(({ className, children, ...props }, ref) => (\n  <SelectPrimitive.Item\n    ref={ref}\n    className={cn(\n      \"relative flex w-full cursor-default select-none items-center rounded-sm pl-7 pr-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n      className,\n    )}\n    {...props}\n  >\n    <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>\n  </SelectPrimitive.Item>\n));\nSelectItem.displayName = SelectPrimitive.Item.displayName;\n\nconst SelectSeparator = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Separator>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>\n>(({ className, ...props }, ref) => (\n  <SelectPrimitive.Separator\n    ref={ref}\n    className={cn(\"-mx-1 my-1 h-px bg-muted\", className)}\n    {...props}\n  />\n));\nSelectSeparator.displayName = SelectPrimitive.Separator.displayName;\n\nexport {\n  Select,\n  SelectGroup,\n  SelectValue,\n  SelectTrigger,\n  SelectContent,\n  SelectLabel,\n  SelectItem,\n  SelectSeparator,\n};\n"
  },
  {
    "path": "src/components/ui/sonner.tsx",
    "content": "import { Toaster as SonnerToaster } from \"sonner\";\nimport { useTheme } from \"@/components/theme-provider\";\n\nexport function Toaster() {\n  const { theme } = useTheme();\n\n  // 将应用主题映射到 Sonner 的主题\n  // 如果是 \"system\"，Sonner 会自己处理\n  const sonnerTheme = theme === \"system\" ? \"system\" : theme;\n\n  return (\n    <SonnerToaster\n      position=\"top-center\"\n      richColors\n      theme={sonnerTheme}\n      toastOptions={{\n        duration: 2000,\n        classNames: {\n          toast:\n            \"group rounded-md border bg-background text-foreground shadow-lg\",\n          title: \"text-sm font-semibold\",\n          description: \"text-sm text-muted-foreground\",\n          closeButton:\n            \"absolute right-2 top-2 rounded-full p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground\",\n          actionButton:\n            \"rounded-md bg-primary px-3 py-1 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90\",\n        },\n      }}\n    />\n  );\n}\n"
  },
  {
    "path": "src/components/ui/switch.tsx",
    "content": "import * as React from \"react\";\nimport * as SwitchPrimitives from \"@radix-ui/react-switch\";\nimport { cn } from \"@/lib/utils\";\n\nconst Switch = React.forwardRef<\n  React.ElementRef<typeof SwitchPrimitives.Root>,\n  React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>\n>(({ className, ...props }, ref) => (\n  <SwitchPrimitives.Root\n    ref={ref}\n    className={cn(\n      \"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-emerald-500 dark:data-[state=checked]:bg-emerald-600 data-[state=unchecked]:bg-gray-200 dark:data-[state=unchecked]:bg-gray-900\",\n      className,\n    )}\n    {...props}\n  >\n    <SwitchPrimitives.Thumb\n      className={cn(\n        \"pointer-events-none block h-5 w-5 rounded-full bg-white dark:bg-gray-400 shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0\",\n      )}\n    />\n  </SwitchPrimitives.Root>\n));\nSwitch.displayName = SwitchPrimitives.Root.displayName;\n\nexport { Switch };\n"
  },
  {
    "path": "src/components/ui/table.tsx",
    "content": "import * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst Table = React.forwardRef<\n  HTMLTableElement,\n  React.HTMLAttributes<HTMLTableElement>\n>(({ className, ...props }, ref) => (\n  <div className=\"relative w-full overflow-auto\">\n    <table\n      ref={ref}\n      className={cn(\"w-full caption-bottom text-sm\", className)}\n      {...props}\n    />\n  </div>\n));\nTable.displayName = \"Table\";\n\nconst TableHeader = React.forwardRef<\n  HTMLTableSectionElement,\n  React.HTMLAttributes<HTMLTableSectionElement>\n>(({ className, ...props }, ref) => (\n  <thead\n    ref={ref}\n    className={cn(\"[&_tr]:border-b [&_tr]:border-border-default\", className)}\n    {...props}\n  />\n));\nTableHeader.displayName = \"TableHeader\";\n\nconst TableBody = React.forwardRef<\n  HTMLTableSectionElement,\n  React.HTMLAttributes<HTMLTableSectionElement>\n>(({ className, ...props }, ref) => (\n  <tbody\n    ref={ref}\n    className={cn(\"[&_tr:last-child]:border-0\", className)}\n    {...props}\n  />\n));\nTableBody.displayName = \"TableBody\";\n\nconst TableFooter = React.forwardRef<\n  HTMLTableSectionElement,\n  React.HTMLAttributes<HTMLTableSectionElement>\n>(({ className, ...props }, ref) => (\n  <tfoot\n    ref={ref}\n    className={cn(\n      \"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0\",\n      className,\n    )}\n    {...props}\n  />\n));\nTableFooter.displayName = \"TableFooter\";\n\nconst TableRow = React.forwardRef<\n  HTMLTableRowElement,\n  React.HTMLAttributes<HTMLTableRowElement>\n>(({ className, ...props }, ref) => (\n  <tr\n    ref={ref}\n    className={cn(\n      \"border-b border-border-default transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted\",\n      className,\n    )}\n    {...props}\n  />\n));\nTableRow.displayName = \"TableRow\";\n\nconst TableHead = React.forwardRef<\n  HTMLTableCellElement,\n  React.ThHTMLAttributes<HTMLTableCellElement>\n>(({ className, ...props }, ref) => (\n  <th\n    ref={ref}\n    className={cn(\n      \"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0\",\n      className,\n    )}\n    {...props}\n  />\n));\nTableHead.displayName = \"TableHead\";\n\nconst TableCell = React.forwardRef<\n  HTMLTableCellElement,\n  React.TdHTMLAttributes<HTMLTableCellElement>\n>(({ className, ...props }, ref) => (\n  <td\n    ref={ref}\n    className={cn(\"p-4 align-middle [&:has([role=checkbox])]:pr-0\", className)}\n    {...props}\n  />\n));\nTableCell.displayName = \"TableCell\";\n\nconst TableCaption = React.forwardRef<\n  HTMLTableCaptionElement,\n  React.HTMLAttributes<HTMLTableCaptionElement>\n>(({ className, ...props }, ref) => (\n  <caption\n    ref={ref}\n    className={cn(\"mt-4 text-sm text-muted-foreground\", className)}\n    {...props}\n  />\n));\nTableCaption.displayName = \"TableCaption\";\n\nexport {\n  Table,\n  TableHeader,\n  TableBody,\n  TableFooter,\n  TableHead,\n  TableRow,\n  TableCell,\n  TableCaption,\n};\n"
  },
  {
    "path": "src/components/ui/tabs.tsx",
    "content": "import * as React from \"react\";\nimport * as TabsPrimitive from \"@radix-ui/react-tabs\";\nimport { cn } from \"@/lib/utils\";\n\nconst Tabs = TabsPrimitive.Root;\n\nconst TabsList = React.forwardRef<\n  React.ElementRef<typeof TabsPrimitive.List>,\n  React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>\n>(({ className, ...props }, ref) => (\n  <TabsPrimitive.List\n    ref={ref}\n    className={cn(\n      \"inline-flex items-center justify-center gap-1 rounded-md bg-muted p-1 text-muted-foreground\",\n      className,\n    )}\n    {...props}\n  />\n));\nTabsList.displayName = TabsPrimitive.List.displayName;\n\nconst TabsTrigger = React.forwardRef<\n  React.ElementRef<typeof TabsPrimitive.Trigger>,\n  React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>\n>(({ className, ...props }, ref) => (\n  <TabsPrimitive.Trigger\n    ref={ref}\n    className={cn(\n      \"inline-flex min-w-[120px] items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:shadow-sm dark:data-[state=active]:bg-blue-600 data-[state=inactive]:opacity-60 data-[state=inactive]:hover:opacity-100 data-[state=inactive]:hover:bg-muted/50\",\n      className,\n    )}\n    {...props}\n  />\n));\nTabsTrigger.displayName = TabsPrimitive.Trigger.displayName;\n\nconst TabsContent = React.forwardRef<\n  React.ElementRef<typeof TabsPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>\n>(({ className, ...props }, ref) => (\n  <TabsPrimitive.Content\n    ref={ref}\n    className={cn(\n      \"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n      className,\n    )}\n    {...props}\n  />\n));\nTabsContent.displayName = TabsPrimitive.Content.displayName;\n\nexport { Tabs, TabsList, TabsTrigger, TabsContent };\n"
  },
  {
    "path": "src/components/ui/textarea.tsx",
    "content": "import * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;\n\nconst Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(\n  ({ className, ...props }, ref) => {\n    return (\n      <textarea\n        className={cn(\n          \"flex min-h-[80px] w-full rounded-md border border-border-default bg-background px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 disabled:cursor-not-allowed disabled:opacity-50\",\n          className,\n        )}\n        autoComplete=\"off\"\n        autoCorrect=\"off\"\n        autoCapitalize=\"none\"\n        spellCheck={false}\n        ref={ref}\n        {...props}\n      />\n    );\n  },\n);\nTextarea.displayName = \"Textarea\";\n\nexport { Textarea };\n"
  },
  {
    "path": "src/components/ui/toggle-row.tsx",
    "content": "import { Switch } from \"@/components/ui/switch\";\n\nexport interface ToggleRowProps {\n  icon: React.ReactNode;\n  title: string;\n  description?: string;\n  checked: boolean;\n  onCheckedChange: (value: boolean) => void;\n  disabled?: boolean;\n}\n\nexport function ToggleRow({\n  icon,\n  title,\n  description,\n  checked,\n  onCheckedChange,\n  disabled,\n}: ToggleRowProps) {\n  return (\n    <div className=\"flex items-center justify-between gap-4 rounded-xl border border-border bg-card/50 p-4 transition-colors hover:bg-muted/50\">\n      <div className=\"flex items-center gap-3\">\n        <div className=\"flex h-8 w-8 items-center justify-center rounded-lg bg-background ring-1 ring-border\">\n          {icon}\n        </div>\n        <div className=\"space-y-1\">\n          <p className=\"text-sm font-medium leading-none\">{title}</p>\n          {description ? (\n            <p className=\"text-xs text-muted-foreground\">{description}</p>\n          ) : null}\n        </div>\n      </div>\n      <Switch\n        checked={checked}\n        onCheckedChange={onCheckedChange}\n        disabled={disabled}\n        aria-label={title}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/ui/tooltip.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\nimport * as TooltipPrimitive from \"@radix-ui/react-tooltip\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst TooltipProvider = TooltipPrimitive.Provider;\n\nconst Tooltip = TooltipPrimitive.Root;\n\nconst TooltipTrigger = TooltipPrimitive.Trigger;\n\nconst TooltipContent = React.forwardRef<\n  React.ElementRef<typeof TooltipPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>\n>(({ className, sideOffset = 4, ...props }, ref) => (\n  <TooltipPrimitive.Content\n    ref={ref}\n    sideOffset={sideOffset}\n    className={cn(\n      \"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2\",\n      className,\n    )}\n    {...props}\n  />\n));\nTooltipContent.displayName = TooltipPrimitive.Content.displayName;\n\nexport { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };\n"
  },
  {
    "path": "src/components/universal/UniversalProviderCard.tsx",
    "content": "import { useTranslation } from \"react-i18next\";\nimport { Edit2, Trash2, RefreshCw, Globe } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { ProviderIcon } from \"@/components/ProviderIcon\";\nimport type { UniversalProvider } from \"@/types\";\n\ninterface UniversalProviderCardProps {\n  provider: UniversalProvider;\n  onEdit: (provider: UniversalProvider) => void;\n  onDelete: (id: string) => void;\n  onSync: (id: string) => void;\n}\n\nexport function UniversalProviderCard({\n  provider,\n  onEdit,\n  onDelete,\n  onSync,\n}: UniversalProviderCardProps) {\n  const { t } = useTranslation();\n\n  // 获取启用的应用列表\n  const enabledApps: string[] = [\n    provider.apps.claude ? \"Claude\" : null,\n    provider.apps.codex ? \"Codex\" : null,\n    provider.apps.gemini ? \"Gemini\" : null,\n  ].filter((app): app is string => app !== null);\n\n  return (\n    <div className=\"group relative rounded-xl border border-border/50 bg-card p-4 transition-all hover:border-border hover:shadow-md\">\n      {/* 头部：图标和名称 */}\n      <div className=\"flex items-start justify-between\">\n        <div className=\"flex items-center gap-3\">\n          <div className=\"flex h-10 w-10 items-center justify-center rounded-lg bg-accent\">\n            <ProviderIcon icon={provider.icon} name={provider.name} size={24} />\n          </div>\n          <div>\n            <h3 className=\"font-semibold text-foreground\">{provider.name}</h3>\n            <p className=\"text-xs text-muted-foreground\">\n              {provider.providerType}\n            </p>\n          </div>\n        </div>\n\n        {/* 操作按钮 */}\n        <div className=\"flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100\">\n          <Button\n            variant=\"ghost\"\n            size=\"icon\"\n            className=\"h-8 w-8\"\n            onClick={() => onSync(provider.id)}\n            title={t(\"universalProvider.sync\", { defaultValue: \"同步到应用\" })}\n          >\n            <RefreshCw className=\"h-4 w-4\" />\n          </Button>\n          <Button\n            variant=\"ghost\"\n            size=\"icon\"\n            className=\"h-8 w-8\"\n            onClick={() => onEdit(provider)}\n            title={t(\"common.edit\", { defaultValue: \"编辑\" })}\n          >\n            <Edit2 className=\"h-4 w-4\" />\n          </Button>\n          <Button\n            variant=\"ghost\"\n            size=\"icon\"\n            className=\"h-8 w-8 text-destructive hover:text-destructive\"\n            onClick={() => onDelete(provider.id)}\n            title={t(\"common.delete\", { defaultValue: \"删除\" })}\n          >\n            <Trash2 className=\"h-4 w-4\" />\n          </Button>\n        </div>\n      </div>\n\n      {/* 配置信息 */}\n      <div className=\"mt-4 space-y-2\">\n        {/* Base URL */}\n        <div className=\"flex items-center gap-2 text-sm\">\n          <Globe className=\"h-3.5 w-3.5 text-muted-foreground\" />\n          <span className=\"truncate text-muted-foreground\">\n            {provider.baseUrl || \"-\"}\n          </span>\n        </div>\n\n        {/* 启用的应用 */}\n        <div className=\"flex flex-wrap gap-1.5\">\n          {enabledApps.map((app) => (\n            <span\n              key={app}\n              className=\"inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary\"\n            >\n              {app}\n            </span>\n          ))}\n          {enabledApps.length === 0 && (\n            <span className=\"text-xs text-muted-foreground\">\n              {t(\"universalProvider.noAppsEnabled\", {\n                defaultValue: \"未启用任何应用\",\n              })}\n            </span>\n          )}\n        </div>\n      </div>\n\n      {/* 备注 */}\n      {provider.notes && (\n        <p className=\"mt-3 text-xs text-muted-foreground line-clamp-2\">\n          {provider.notes}\n        </p>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/universal/UniversalProviderFormModal.tsx",
    "content": "import { useState, useEffect, useCallback, useMemo } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Eye, EyeOff, RefreshCw } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { Switch } from \"@/components/ui/switch\";\nimport { FullScreenPanel } from \"@/components/common/FullScreenPanel\";\nimport { ConfirmDialog } from \"@/components/ConfirmDialog\";\nimport { ProviderIcon } from \"@/components/ProviderIcon\";\nimport JsonEditor from \"@/components/JsonEditor\";\nimport type { UniversalProvider, UniversalProviderModels } from \"@/types\";\nimport {\n  universalProviderPresets,\n  createUniversalProviderFromPreset,\n  type UniversalProviderPreset,\n} from \"@/config/universalProviderPresets\";\n\ninterface UniversalProviderFormModalProps {\n  isOpen: boolean;\n  onClose: () => void;\n  onSave: (provider: UniversalProvider) => void;\n  onSaveAndSync?: (provider: UniversalProvider) => void;\n  editingProvider?: UniversalProvider | null;\n  initialPreset?: UniversalProviderPreset | null;\n}\n\nexport function UniversalProviderFormModal({\n  isOpen,\n  onClose,\n  onSave,\n  onSaveAndSync,\n  editingProvider,\n  initialPreset,\n}: UniversalProviderFormModalProps) {\n  const { t } = useTranslation();\n  const isEditMode = !!editingProvider;\n\n  // 表单状态\n  const [selectedPreset, setSelectedPreset] =\n    useState<UniversalProviderPreset | null>(null);\n  const [name, setName] = useState(\"\");\n  const [baseUrl, setBaseUrl] = useState(\"\");\n  const [apiKey, setApiKey] = useState(\"\");\n  const [showApiKey, setShowApiKey] = useState(false);\n  const [websiteUrl, setWebsiteUrl] = useState(\"\");\n  const [notes, setNotes] = useState(\"\");\n\n  // 应用启用状态\n  const [claudeEnabled, setClaudeEnabled] = useState(true);\n  const [codexEnabled, setCodexEnabled] = useState(true);\n  const [geminiEnabled, setGeminiEnabled] = useState(true);\n\n  // 模型配置\n  const [models, setModels] = useState<UniversalProviderModels>({});\n\n  // 保存并同步确认弹窗\n  const [syncConfirmOpen, setSyncConfirmOpen] = useState(false);\n  const [pendingProvider, setPendingProvider] =\n    useState<UniversalProvider | null>(null);\n\n  // 初始化表单\n  useEffect(() => {\n    if (editingProvider) {\n      // 编辑模式：加载现有数据\n      setName(editingProvider.name);\n      setBaseUrl(editingProvider.baseUrl);\n      setApiKey(editingProvider.apiKey);\n      setWebsiteUrl(editingProvider.websiteUrl || \"\");\n      setNotes(editingProvider.notes || \"\");\n      setClaudeEnabled(editingProvider.apps.claude);\n      setCodexEnabled(editingProvider.apps.codex);\n      setGeminiEnabled(editingProvider.apps.gemini);\n      setModels(editingProvider.models || {});\n\n      // 尝试匹配预设\n      const preset = universalProviderPresets.find(\n        (p) => p.providerType === editingProvider.providerType,\n      );\n      setSelectedPreset(preset || null);\n    } else {\n      // 新建模式：使用传入的预设或默认选择第一个预设\n      const defaultPreset = initialPreset || universalProviderPresets[0];\n      setSelectedPreset(defaultPreset);\n      setName(defaultPreset.name);\n      setBaseUrl(\"\");\n      setApiKey(\"\");\n      setWebsiteUrl(defaultPreset.websiteUrl || \"\");\n      setNotes(\"\");\n      setClaudeEnabled(defaultPreset.defaultApps.claude);\n      setCodexEnabled(defaultPreset.defaultApps.codex);\n      setGeminiEnabled(defaultPreset.defaultApps.gemini);\n      setModels(JSON.parse(JSON.stringify(defaultPreset.defaultModels)));\n    }\n  }, [editingProvider, initialPreset, isOpen]);\n\n  // 选择预设\n  const handlePresetSelect = useCallback(\n    (preset: UniversalProviderPreset) => {\n      setSelectedPreset(preset);\n      if (!isEditMode) {\n        setName(preset.name);\n        setClaudeEnabled(preset.defaultApps.claude);\n        setCodexEnabled(preset.defaultApps.codex);\n        setGeminiEnabled(preset.defaultApps.gemini);\n        setModels(JSON.parse(JSON.stringify(preset.defaultModels)));\n      }\n    },\n    [isEditMode],\n  );\n\n  // 更新模型配置\n  const updateModel = useCallback(\n    (app: \"claude\" | \"codex\" | \"gemini\", field: string, value: string) => {\n      setModels((prev) => ({\n        ...prev,\n        [app]: {\n          ...(prev[app] || {}),\n          [field]: value,\n        },\n      }));\n    },\n    [],\n  );\n\n  // 计算 Claude 配置 JSON 预览\n  const claudeConfigJson = useMemo(() => {\n    if (!claudeEnabled) return null;\n    const model = models.claude?.model || \"claude-sonnet-4-20250514\";\n    const haiku = models.claude?.haikuModel || \"claude-haiku-4-20250514\";\n    const sonnet = models.claude?.sonnetModel || \"claude-sonnet-4-20250514\";\n    const opus = models.claude?.opusModel || \"claude-sonnet-4-20250514\";\n    return {\n      env: {\n        ANTHROPIC_BASE_URL: baseUrl,\n        ANTHROPIC_AUTH_TOKEN: apiKey,\n        ANTHROPIC_MODEL: model,\n        ANTHROPIC_DEFAULT_HAIKU_MODEL: haiku,\n        ANTHROPIC_DEFAULT_SONNET_MODEL: sonnet,\n        ANTHROPIC_DEFAULT_OPUS_MODEL: opus,\n      },\n    };\n  }, [claudeEnabled, baseUrl, apiKey, models.claude]);\n\n  // 计算 Codex 配置 JSON 预览\n  const codexConfigJson = useMemo(() => {\n    if (!codexEnabled) return null;\n    const model = models.codex?.model || \"gpt-5.4\";\n    const reasoningEffort = models.codex?.reasoningEffort || \"high\";\n    // 确保 base_url 以 /v1 结尾（Codex 使用 OpenAI 兼容 API）\n    const codexBaseUrl = baseUrl.endsWith(\"/v1\")\n      ? baseUrl\n      : `${baseUrl.replace(/\\/+$/, \"\")}/v1`;\n    const configToml = `model_provider = \"newapi\"\nmodel = \"${model}\"\nmodel_reasoning_effort = \"${reasoningEffort}\"\ndisable_response_storage = true\n\n[model_providers.newapi]\nname = \"NewAPI\"\nbase_url = \"${codexBaseUrl}\"\nwire_api = \"responses\"\nrequires_openai_auth = true`;\n    return {\n      auth: {\n        OPENAI_API_KEY: apiKey,\n      },\n      config: configToml,\n    };\n  }, [codexEnabled, baseUrl, apiKey, models.codex]);\n\n  // 计算 Gemini 配置 JSON 预览\n  const geminiConfigJson = useMemo(() => {\n    if (!geminiEnabled) return null;\n    const model = models.gemini?.model || \"gemini-2.5-pro\";\n    return {\n      env: {\n        GOOGLE_GEMINI_BASE_URL: baseUrl,\n        GEMINI_API_KEY: apiKey,\n        GEMINI_MODEL: model,\n      },\n    };\n  }, [geminiEnabled, baseUrl, apiKey, models.gemini]);\n\n  // 提交表单\n  const handleSubmit = useCallback(() => {\n    if (!name.trim() || !baseUrl.trim() || !apiKey.trim()) {\n      return;\n    }\n\n    const provider: UniversalProvider = editingProvider\n      ? {\n          ...editingProvider,\n          name: name.trim(),\n          baseUrl: baseUrl.trim(),\n          apiKey: apiKey.trim(),\n          websiteUrl: websiteUrl.trim() || undefined,\n          notes: notes.trim() || undefined,\n          apps: {\n            claude: claudeEnabled,\n            codex: codexEnabled,\n            gemini: geminiEnabled,\n          },\n          models,\n        }\n      : createUniversalProviderFromPreset(\n          selectedPreset || universalProviderPresets[0],\n          crypto.randomUUID(),\n          baseUrl.trim(),\n          apiKey.trim(),\n          name.trim(),\n        );\n\n    // 如果是新建，更新应用启用状态和模型\n    if (!editingProvider) {\n      provider.apps = {\n        claude: claudeEnabled,\n        codex: codexEnabled,\n        gemini: geminiEnabled,\n      };\n      provider.models = models;\n      provider.websiteUrl = websiteUrl.trim() || undefined;\n      provider.notes = notes.trim() || undefined;\n    }\n\n    onSave(provider);\n    onClose();\n  }, [\n    editingProvider,\n    name,\n    baseUrl,\n    apiKey,\n    websiteUrl,\n    notes,\n    claudeEnabled,\n    codexEnabled,\n    geminiEnabled,\n    models,\n    selectedPreset,\n    onSave,\n    onClose,\n  ]);\n\n  // 构建 provider 对象的辅助函数\n  const buildProvider = useCallback((): UniversalProvider | null => {\n    if (!name.trim() || !baseUrl.trim() || !apiKey.trim()) {\n      return null;\n    }\n\n    const provider: UniversalProvider = editingProvider\n      ? {\n          ...editingProvider,\n          name: name.trim(),\n          baseUrl: baseUrl.trim(),\n          apiKey: apiKey.trim(),\n          websiteUrl: websiteUrl.trim() || undefined,\n          notes: notes.trim() || undefined,\n          apps: {\n            claude: claudeEnabled,\n            codex: codexEnabled,\n            gemini: geminiEnabled,\n          },\n          models,\n        }\n      : createUniversalProviderFromPreset(\n          selectedPreset || universalProviderPresets[0],\n          crypto.randomUUID(),\n          baseUrl.trim(),\n          apiKey.trim(),\n          name.trim(),\n        );\n\n    // 如果是新建，更新应用启用状态和模型\n    if (!editingProvider) {\n      provider.apps = {\n        claude: claudeEnabled,\n        codex: codexEnabled,\n        gemini: geminiEnabled,\n      };\n      provider.models = models;\n      provider.websiteUrl = websiteUrl.trim() || undefined;\n      provider.notes = notes.trim() || undefined;\n    }\n\n    return provider;\n  }, [\n    editingProvider,\n    name,\n    baseUrl,\n    apiKey,\n    websiteUrl,\n    notes,\n    claudeEnabled,\n    codexEnabled,\n    geminiEnabled,\n    models,\n    selectedPreset,\n  ]);\n\n  // 打开保存并同步确认弹窗\n  const handleSaveAndSyncClick = useCallback(() => {\n    const provider = buildProvider();\n    if (!provider || !onSaveAndSync) return;\n\n    setPendingProvider(provider);\n    setSyncConfirmOpen(true);\n  }, [buildProvider, onSaveAndSync]);\n\n  // 确认保存并同步\n  const confirmSaveAndSync = useCallback(() => {\n    if (!pendingProvider || !onSaveAndSync) return;\n\n    onSaveAndSync(pendingProvider);\n    setSyncConfirmOpen(false);\n    setPendingProvider(null);\n    onClose();\n  }, [pendingProvider, onSaveAndSync, onClose]);\n\n  const footer = (\n    <>\n      <Button variant=\"outline\" onClick={onClose}>\n        {t(\"common.cancel\", { defaultValue: \"取消\" })}\n      </Button>\n      {isEditMode && onSaveAndSync ? (\n        <Button\n          onClick={handleSaveAndSyncClick}\n          disabled={!name.trim() || !baseUrl.trim() || !apiKey.trim()}\n        >\n          <RefreshCw className=\"mr-1.5 h-4 w-4\" />\n          {t(\"universalProvider.saveAndSync\", { defaultValue: \"保存并同步\" })}\n        </Button>\n      ) : (\n        <Button\n          onClick={handleSubmit}\n          disabled={!name.trim() || !baseUrl.trim() || !apiKey.trim()}\n        >\n          {t(\"common.add\", { defaultValue: \"添加\" })}\n        </Button>\n      )}\n    </>\n  );\n\n  return (\n    <FullScreenPanel\n      isOpen={isOpen}\n      title={\n        isEditMode\n          ? t(\"universalProvider.edit\", { defaultValue: \"编辑统一供应商\" })\n          : t(\"universalProvider.add\", { defaultValue: \"添加统一供应商\" })\n      }\n      onClose={onClose}\n      footer={footer}\n    >\n      <div className=\"space-y-6\">\n        {/* 预设选择（仅新建模式） */}\n        {!isEditMode && (\n          <div className=\"space-y-3\">\n            <Label>\n              {t(\"universalProvider.selectPreset\", {\n                defaultValue: \"选择预设类型\",\n              })}\n            </Label>\n            <div className=\"flex flex-wrap gap-2\">\n              {universalProviderPresets.map((preset) => (\n                <button\n                  key={preset.providerType}\n                  type=\"button\"\n                  onClick={() => handlePresetSelect(preset)}\n                  className={`inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${\n                    selectedPreset?.providerType === preset.providerType\n                      ? \"bg-primary text-primary-foreground\"\n                      : \"bg-accent text-muted-foreground hover:bg-accent/80\"\n                  }`}\n                >\n                  <ProviderIcon\n                    icon={preset.icon}\n                    name={preset.name}\n                    size={16}\n                  />\n                  {preset.name}\n                </button>\n              ))}\n            </div>\n            {selectedPreset?.description && (\n              <p className=\"text-xs text-muted-foreground\">\n                {selectedPreset.description}\n              </p>\n            )}\n          </div>\n        )}\n\n        {/* 基本信息 */}\n        <div className=\"space-y-4\">\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"name\">\n              {t(\"universalProvider.name\", { defaultValue: \"名称\" })}\n            </Label>\n            <Input\n              id=\"name\"\n              value={name}\n              onChange={(e) => setName(e.target.value)}\n              placeholder={t(\"universalProvider.namePlaceholder\", {\n                defaultValue: \"例如：我的 NewAPI\",\n              })}\n            />\n          </div>\n\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"baseUrl\">\n              {t(\"universalProvider.baseUrl\", { defaultValue: \"API 地址\" })}\n            </Label>\n            <Input\n              id=\"baseUrl\"\n              value={baseUrl}\n              onChange={(e) => setBaseUrl(e.target.value)}\n              placeholder=\"https://api.example.com\"\n            />\n          </div>\n\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"apiKey\">\n              {t(\"universalProvider.apiKey\", { defaultValue: \"API Key\" })}\n            </Label>\n            <div className=\"relative\">\n              <Input\n                id=\"apiKey\"\n                type={showApiKey ? \"text\" : \"password\"}\n                value={apiKey}\n                onChange={(e) => setApiKey(e.target.value)}\n                placeholder=\"sk-...\"\n                className=\"pr-10\"\n              />\n              <Button\n                type=\"button\"\n                variant=\"ghost\"\n                size=\"icon\"\n                className=\"absolute right-0 top-0 h-full px-3\"\n                onClick={() => setShowApiKey(!showApiKey)}\n              >\n                {showApiKey ? (\n                  <EyeOff className=\"h-4 w-4\" />\n                ) : (\n                  <Eye className=\"h-4 w-4\" />\n                )}\n              </Button>\n            </div>\n          </div>\n\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"websiteUrl\">\n              {t(\"universalProvider.websiteUrl\", { defaultValue: \"官网地址\" })}\n            </Label>\n            <Input\n              id=\"websiteUrl\"\n              value={websiteUrl}\n              onChange={(e) => setWebsiteUrl(e.target.value)}\n              placeholder={t(\"universalProvider.websiteUrlPlaceholder\", {\n                defaultValue: \"https://example.com（可选，用于在列表中显示）\",\n              })}\n            />\n          </div>\n\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"notes\">\n              {t(\"universalProvider.notes\", { defaultValue: \"备注\" })}\n            </Label>\n            <Input\n              id=\"notes\"\n              value={notes}\n              onChange={(e) => setNotes(e.target.value)}\n              placeholder={t(\"universalProvider.notesPlaceholder\", {\n                defaultValue: \"可选：添加备注信息\",\n              })}\n            />\n          </div>\n        </div>\n\n        {/* 应用启用 */}\n        <div className=\"space-y-3\">\n          <Label>\n            {t(\"universalProvider.enabledApps\", { defaultValue: \"启用的应用\" })}\n          </Label>\n          <div className=\"flex flex-col gap-3\">\n            <div className=\"flex items-center justify-between rounded-lg border p-3\">\n              <div className=\"flex items-center gap-2\">\n                <ProviderIcon icon=\"claude\" name=\"Claude\" size={20} />\n                <span className=\"font-medium\">Claude Code</span>\n              </div>\n              <Switch\n                checked={claudeEnabled}\n                onCheckedChange={setClaudeEnabled}\n              />\n            </div>\n            <div className=\"flex items-center justify-between rounded-lg border p-3\">\n              <div className=\"flex items-center gap-2\">\n                <ProviderIcon icon=\"openai\" name=\"Codex\" size={20} />\n                <span className=\"font-medium\">OpenAI Codex</span>\n              </div>\n              <Switch\n                checked={codexEnabled}\n                onCheckedChange={setCodexEnabled}\n              />\n            </div>\n            <div className=\"flex items-center justify-between rounded-lg border p-3\">\n              <div className=\"flex items-center gap-2\">\n                <ProviderIcon icon=\"gemini\" name=\"Gemini\" size={20} />\n                <span className=\"font-medium\">Gemini CLI</span>\n              </div>\n              <Switch\n                checked={geminiEnabled}\n                onCheckedChange={setGeminiEnabled}\n              />\n            </div>\n          </div>\n        </div>\n\n        {/* 模型配置 */}\n        <div className=\"space-y-4\">\n          <Label>\n            {t(\"universalProvider.modelConfig\", { defaultValue: \"模型配置\" })}\n          </Label>\n\n          {/* Claude 模型 */}\n          {claudeEnabled && (\n            <div className=\"space-y-3 rounded-lg border p-4\">\n              <div className=\"flex items-center gap-2 font-medium\">\n                <ProviderIcon icon=\"claude\" name=\"Claude\" size={16} />\n                Claude\n              </div>\n              <div className=\"grid gap-3 sm:grid-cols-2\">\n                <div className=\"space-y-1\">\n                  <Label className=\"text-xs\">\n                    {t(\"universalProvider.model\", { defaultValue: \"主模型\" })}\n                  </Label>\n                  <Input\n                    value={models.claude?.model || \"\"}\n                    onChange={(e) =>\n                      updateModel(\"claude\", \"model\", e.target.value)\n                    }\n                    placeholder=\"claude-sonnet-4-20250514\"\n                  />\n                </div>\n                <div className=\"space-y-1\">\n                  <Label className=\"text-xs\">Haiku</Label>\n                  <Input\n                    value={models.claude?.haikuModel || \"\"}\n                    onChange={(e) =>\n                      updateModel(\"claude\", \"haikuModel\", e.target.value)\n                    }\n                    placeholder=\"claude-haiku-4-20250514\"\n                  />\n                </div>\n                <div className=\"space-y-1\">\n                  <Label className=\"text-xs\">Sonnet</Label>\n                  <Input\n                    value={models.claude?.sonnetModel || \"\"}\n                    onChange={(e) =>\n                      updateModel(\"claude\", \"sonnetModel\", e.target.value)\n                    }\n                    placeholder=\"claude-sonnet-4-20250514\"\n                  />\n                </div>\n                <div className=\"space-y-1\">\n                  <Label className=\"text-xs\">Opus</Label>\n                  <Input\n                    value={models.claude?.opusModel || \"\"}\n                    onChange={(e) =>\n                      updateModel(\"claude\", \"opusModel\", e.target.value)\n                    }\n                    placeholder=\"claude-sonnet-4-20250514\"\n                  />\n                </div>\n              </div>\n            </div>\n          )}\n\n          {/* Codex 模型 */}\n          {codexEnabled && (\n            <div className=\"space-y-3 rounded-lg border p-4\">\n              <div className=\"flex items-center gap-2 font-medium\">\n                <ProviderIcon icon=\"openai\" name=\"Codex\" size={16} />\n                Codex\n              </div>\n              <div className=\"grid gap-3 sm:grid-cols-2\">\n                <div className=\"space-y-1\">\n                  <Label className=\"text-xs\">\n                    {t(\"universalProvider.model\", { defaultValue: \"模型\" })}\n                  </Label>\n                  <Input\n                    value={models.codex?.model || \"\"}\n                    onChange={(e) =>\n                      updateModel(\"codex\", \"model\", e.target.value)\n                    }\n                    placeholder=\"gpt-5.4\"\n                  />\n                </div>\n                <div className=\"space-y-1\">\n                  <Label className=\"text-xs\">Reasoning Effort</Label>\n                  <Input\n                    value={models.codex?.reasoningEffort || \"\"}\n                    onChange={(e) =>\n                      updateModel(\"codex\", \"reasoningEffort\", e.target.value)\n                    }\n                    placeholder=\"high\"\n                  />\n                </div>\n              </div>\n            </div>\n          )}\n\n          {/* Gemini 模型 */}\n          {geminiEnabled && (\n            <div className=\"space-y-3 rounded-lg border p-4\">\n              <div className=\"flex items-center gap-2 font-medium\">\n                <ProviderIcon icon=\"gemini\" name=\"Gemini\" size={16} />\n                Gemini\n              </div>\n              <div className=\"space-y-1\">\n                <Label className=\"text-xs\">\n                  {t(\"universalProvider.model\", { defaultValue: \"模型\" })}\n                </Label>\n                <Input\n                  value={models.gemini?.model || \"\"}\n                  onChange={(e) =>\n                    updateModel(\"gemini\", \"model\", e.target.value)\n                  }\n                  placeholder=\"gemini-2.5-pro\"\n                />\n              </div>\n            </div>\n          )}\n        </div>\n\n        {/* 配置 JSON 预览 */}\n        {isEditMode && (claudeEnabled || codexEnabled || geminiEnabled) && (\n          <div className=\"space-y-4\">\n            <Label>\n              {t(\"universalProvider.configJsonPreview\", {\n                defaultValue: \"配置 JSON 预览\",\n              })}\n            </Label>\n            <p className=\"text-xs text-muted-foreground\">\n              {t(\"universalProvider.configJsonPreviewHint\", {\n                defaultValue:\n                  \"以下是将要同步到各应用的配置内容（仅覆盖显示的字段，保留其他自定义配置）\",\n              })}\n            </p>\n\n            {/* Claude JSON */}\n            {claudeConfigJson && (\n              <div className=\"space-y-2\">\n                <div className=\"flex items-center gap-2 text-sm font-medium\">\n                  <ProviderIcon icon=\"claude\" name=\"Claude\" size={16} />\n                  Claude\n                </div>\n                <JsonEditor\n                  value={JSON.stringify(claudeConfigJson, null, 2)}\n                  onChange={() => {}}\n                  height={180}\n                />\n              </div>\n            )}\n\n            {/* Codex JSON */}\n            {codexConfigJson && (\n              <div className=\"space-y-2\">\n                <div className=\"flex items-center gap-2 text-sm font-medium\">\n                  <ProviderIcon icon=\"openai\" name=\"Codex\" size={16} />\n                  Codex\n                </div>\n                <JsonEditor\n                  value={JSON.stringify(codexConfigJson, null, 2)}\n                  onChange={() => {}}\n                  height={280}\n                />\n              </div>\n            )}\n\n            {/* Gemini JSON */}\n            {geminiConfigJson && (\n              <div className=\"space-y-2\">\n                <div className=\"flex items-center gap-2 text-sm font-medium\">\n                  <ProviderIcon icon=\"gemini\" name=\"Gemini\" size={16} />\n                  Gemini\n                </div>\n                <JsonEditor\n                  value={JSON.stringify(geminiConfigJson, null, 2)}\n                  onChange={() => {}}\n                  height={140}\n                />\n              </div>\n            )}\n          </div>\n        )}\n      </div>\n\n      {/* 保存并同步确认弹窗 */}\n      <ConfirmDialog\n        isOpen={syncConfirmOpen}\n        title={t(\"universalProvider.syncConfirmTitle\", {\n          defaultValue: \"同步统一供应商\",\n        })}\n        message={t(\"universalProvider.syncConfirmDescription\", {\n          defaultValue: `同步 \"${name}\" 将会覆盖 Claude、Codex 和 Gemini 中关联的供应商配置。确定要继续吗？`,\n          name: name,\n        })}\n        confirmText={t(\"universalProvider.saveAndSync\", {\n          defaultValue: \"保存并同步\",\n        })}\n        onConfirm={confirmSaveAndSync}\n        onCancel={() => {\n          setSyncConfirmOpen(false);\n          setPendingProvider(null);\n        }}\n      />\n    </FullScreenPanel>\n  );\n}\n"
  },
  {
    "path": "src/components/universal/UniversalProviderPanel.tsx",
    "content": "import { useState, useCallback, useEffect } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Layers } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { ConfirmDialog } from \"@/components/ConfirmDialog\";\nimport { UniversalProviderCard } from \"./UniversalProviderCard\";\nimport { UniversalProviderFormModal } from \"./UniversalProviderFormModal\";\nimport { universalProvidersApi } from \"@/lib/api\";\nimport type { UniversalProvider, UniversalProvidersMap } from \"@/types\";\n\nexport function UniversalProviderPanel() {\n  const { t } = useTranslation();\n\n  // 状态\n  const [providers, setProviders] = useState<UniversalProvidersMap>({});\n  const [loading, setLoading] = useState(true);\n  const [isFormOpen, setIsFormOpen] = useState(false);\n  const [editingProvider, setEditingProvider] =\n    useState<UniversalProvider | null>(null);\n  const [deleteConfirm, setDeleteConfirm] = useState<{\n    open: boolean;\n    id: string;\n    name: string;\n  }>({ open: false, id: \"\", name: \"\" });\n  const [syncConfirm, setSyncConfirm] = useState<{\n    open: boolean;\n    id: string;\n    name: string;\n  }>({ open: false, id: \"\", name: \"\" });\n\n  // 加载数据\n  const loadProviders = useCallback(async () => {\n    try {\n      setLoading(true);\n      const data = await universalProvidersApi.getAll();\n      setProviders(data);\n    } catch (error) {\n      console.error(\"Failed to load universal providers:\", error);\n      toast.error(\n        t(\"universalProvider.loadError\", {\n          defaultValue: \"加载统一供应商失败\",\n        }),\n      );\n    } finally {\n      setLoading(false);\n    }\n  }, [t]);\n\n  useEffect(() => {\n    loadProviders();\n  }, [loadProviders]);\n\n  // 添加/编辑供应商\n  const handleSave = useCallback(\n    async (provider: UniversalProvider) => {\n      try {\n        await universalProvidersApi.upsert(provider);\n\n        // 新建模式下自动同步到各应用\n        if (!editingProvider) {\n          await universalProvidersApi.sync(provider.id);\n        }\n\n        toast.success(\n          editingProvider\n            ? t(\"universalProvider.updated\", {\n                defaultValue: \"统一供应商已更新\",\n              })\n            : t(\"universalProvider.addedAndSynced\", {\n                defaultValue: \"统一供应商已添加并同步\",\n              }),\n        );\n        loadProviders();\n        setEditingProvider(null);\n      } catch (error) {\n        console.error(\"Failed to save universal provider:\", error);\n        toast.error(\n          t(\"universalProvider.saveError\", {\n            defaultValue: \"保存统一供应商失败\",\n          }),\n        );\n      }\n    },\n    [editingProvider, loadProviders, t],\n  );\n\n  // 保存并同步供应商\n  const handleSaveAndSync = useCallback(\n    async (provider: UniversalProvider) => {\n      try {\n        await universalProvidersApi.upsert(provider);\n        await universalProvidersApi.sync(provider.id);\n        toast.success(\n          t(\"universalProvider.savedAndSynced\", {\n            defaultValue: \"已保存并同步到所有应用\",\n          }),\n        );\n        loadProviders();\n        setEditingProvider(null);\n      } catch (error) {\n        console.error(\"Failed to save and sync universal provider:\", error);\n        toast.error(\n          t(\"universalProvider.saveAndSyncError\", {\n            defaultValue: \"保存并同步失败\",\n          }),\n        );\n      }\n    },\n    [loadProviders, t],\n  );\n\n  // 删除供应商\n  const handleDelete = useCallback(async () => {\n    if (!deleteConfirm.id) return;\n\n    try {\n      await universalProvidersApi.delete(deleteConfirm.id);\n      toast.success(\n        t(\"universalProvider.deleted\", { defaultValue: \"统一供应商已删除\" }),\n      );\n      loadProviders();\n    } catch (error) {\n      console.error(\"Failed to delete universal provider:\", error);\n      toast.error(\n        t(\"universalProvider.deleteError\", {\n          defaultValue: \"删除统一供应商失败\",\n        }),\n      );\n    } finally {\n      setDeleteConfirm({ open: false, id: \"\", name: \"\" });\n    }\n  }, [deleteConfirm.id, loadProviders, t]);\n\n  // 同步供应商\n  const handleSync = useCallback(async () => {\n    if (!syncConfirm.id) return;\n\n    try {\n      await universalProvidersApi.sync(syncConfirm.id);\n      toast.success(\n        t(\"universalProvider.synced\", { defaultValue: \"已同步到所有应用\" }),\n      );\n    } catch (error) {\n      console.error(\"Failed to sync universal provider:\", error);\n      toast.error(\n        t(\"universalProvider.syncError\", {\n          defaultValue: \"同步统一供应商失败\",\n        }),\n      );\n    } finally {\n      setSyncConfirm({ open: false, id: \"\", name: \"\" });\n    }\n  }, [syncConfirm.id, t]);\n\n  // 打开同步确认\n  const handleSyncClick = useCallback(\n    (id: string) => {\n      const provider = providers[id];\n      setSyncConfirm({\n        open: true,\n        id,\n        name: provider?.name || id,\n      });\n    },\n    [providers],\n  );\n\n  // 打开编辑\n  const handleEdit = useCallback((provider: UniversalProvider) => {\n    setEditingProvider(provider);\n    setIsFormOpen(true);\n  }, []);\n\n  // 打开删除确认\n  const handleDeleteClick = useCallback(\n    (id: string) => {\n      const provider = providers[id];\n      setDeleteConfirm({\n        open: true,\n        id,\n        name: provider?.name || id,\n      });\n    },\n    [providers],\n  );\n\n  const providerList = Object.values(providers);\n\n  return (\n    <div className=\"space-y-4\">\n      {/* 头部 */}\n      <div className=\"flex items-center gap-2\">\n        <Layers className=\"h-5 w-5 text-primary\" />\n        <h2 className=\"text-lg font-semibold\">\n          {t(\"universalProvider.title\", { defaultValue: \"统一供应商\" })}\n        </h2>\n        <span className=\"rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground\">\n          {providerList.length}\n        </span>\n      </div>\n\n      {/* 描述 */}\n      <p className=\"text-sm text-muted-foreground\">\n        {t(\"universalProvider.description\", {\n          defaultValue:\n            \"统一供应商可以同时管理 Claude、Codex 和 Gemini 的配置。修改后会自动同步到所有启用的应用。\",\n        })}\n      </p>\n\n      {/* 供应商列表 */}\n      {loading ? (\n        <div className=\"flex items-center justify-center py-12\">\n          <div className=\"h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent\" />\n        </div>\n      ) : providerList.length === 0 ? (\n        <div className=\"flex flex-col items-center justify-center rounded-xl border border-dashed py-12 text-center\">\n          <Layers className=\"mb-3 h-10 w-10 text-muted-foreground/50\" />\n          <p className=\"text-sm text-muted-foreground\">\n            {t(\"universalProvider.empty\", {\n              defaultValue: \"还没有统一供应商\",\n            })}\n          </p>\n          <p className=\"mt-1 text-xs text-muted-foreground/70\">\n            {t(\"universalProvider.emptyHint\", {\n              defaultValue: \"点击下方「添加统一供应商」按钮创建一个\",\n            })}\n          </p>\n        </div>\n      ) : (\n        <div className=\"grid gap-4 sm:grid-cols-2 lg:grid-cols-3\">\n          {providerList.map((provider) => (\n            <UniversalProviderCard\n              key={provider.id}\n              provider={provider}\n              onEdit={handleEdit}\n              onDelete={handleDeleteClick}\n              onSync={handleSyncClick}\n            />\n          ))}\n        </div>\n      )}\n\n      {/* 表单模态框 */}\n      <UniversalProviderFormModal\n        isOpen={isFormOpen}\n        onClose={() => {\n          setIsFormOpen(false);\n          setEditingProvider(null);\n        }}\n        onSave={handleSave}\n        onSaveAndSync={handleSaveAndSync}\n        editingProvider={editingProvider}\n      />\n\n      {/* 删除确认对话框 */}\n      <ConfirmDialog\n        isOpen={deleteConfirm.open}\n        title={t(\"universalProvider.deleteConfirmTitle\", {\n          defaultValue: \"删除统一供应商\",\n        })}\n        message={t(\"universalProvider.deleteConfirmDescription\", {\n          defaultValue: `确定要删除 \"${deleteConfirm.name}\" 吗？这将同时删除它在各应用中生成的供应商配置。`,\n          name: deleteConfirm.name,\n        })}\n        confirmText={t(\"common.delete\", { defaultValue: \"删除\" })}\n        onConfirm={handleDelete}\n        onCancel={() => setDeleteConfirm({ open: false, id: \"\", name: \"\" })}\n      />\n\n      {/* 同步确认对话框 */}\n      <ConfirmDialog\n        isOpen={syncConfirm.open}\n        title={t(\"universalProvider.syncConfirmTitle\", {\n          defaultValue: \"同步统一供应商\",\n        })}\n        message={t(\"universalProvider.syncConfirmDescription\", {\n          defaultValue: `同步 \"${syncConfirm.name}\" 将会覆盖 Claude、Codex 和 Gemini 中关联的供应商配置。确定要继续吗？`,\n          name: syncConfirm.name,\n        })}\n        confirmText={t(\"universalProvider.syncConfirm\", {\n          defaultValue: \"同步\",\n        })}\n        onConfirm={handleSync}\n        onCancel={() => setSyncConfirm({ open: false, id: \"\", name: \"\" })}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/universal/index.ts",
    "content": "export { UniversalProviderPanel } from \"./UniversalProviderPanel\";\nexport { UniversalProviderCard } from \"./UniversalProviderCard\";\nexport { UniversalProviderFormModal } from \"./UniversalProviderFormModal\";\n"
  },
  {
    "path": "src/components/usage/ModelStatsTable.tsx",
    "content": "import { useTranslation } from \"react-i18next\";\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@/components/ui/table\";\nimport { useModelStats } from \"@/lib/query/usage\";\nimport { fmtUsd } from \"./format\";\n\ninterface ModelStatsTableProps {\n  refreshIntervalMs: number;\n}\n\nexport function ModelStatsTable({ refreshIntervalMs }: ModelStatsTableProps) {\n  const { t } = useTranslation();\n  const { data: stats, isLoading } = useModelStats({\n    refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,\n  });\n\n  if (isLoading) {\n    return <div className=\"h-[400px] animate-pulse rounded bg-gray-100\" />;\n  }\n\n  return (\n    <div className=\"rounded-lg border border-border/50 bg-card/40 backdrop-blur-sm overflow-hidden\">\n      <Table>\n        <TableHeader>\n          <TableRow>\n            <TableHead>{t(\"usage.model\", \"模型\")}</TableHead>\n            <TableHead className=\"text-right\">\n              {t(\"usage.requests\", \"请求数\")}\n            </TableHead>\n            <TableHead className=\"text-right\">\n              {t(\"usage.tokens\", \"Tokens\")}\n            </TableHead>\n            <TableHead className=\"text-right\">\n              {t(\"usage.totalCost\", \"总成本\")}\n            </TableHead>\n            <TableHead className=\"text-right\">\n              {t(\"usage.avgCost\", \"平均成本\")}\n            </TableHead>\n          </TableRow>\n        </TableHeader>\n        <TableBody>\n          {stats?.length === 0 ? (\n            <TableRow>\n              <TableCell\n                colSpan={5}\n                className=\"text-center text-muted-foreground\"\n              >\n                {t(\"usage.noData\", \"暂无数据\")}\n              </TableCell>\n            </TableRow>\n          ) : (\n            stats?.map((stat) => (\n              <TableRow key={stat.model}>\n                <TableCell className=\"font-mono text-sm\">\n                  {stat.model}\n                </TableCell>\n                <TableCell className=\"text-right\">\n                  {stat.requestCount.toLocaleString()}\n                </TableCell>\n                <TableCell className=\"text-right\">\n                  {stat.totalTokens.toLocaleString()}\n                </TableCell>\n                <TableCell className=\"text-right\">\n                  {fmtUsd(stat.totalCost, 4)}\n                </TableCell>\n                <TableCell className=\"text-right\">\n                  {fmtUsd(stat.avgCostPerRequest, 6)}\n                </TableCell>\n              </TableRow>\n            ))\n          )}\n        </TableBody>\n      </Table>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/usage/ModelTestConfigPanel.tsx",
    "content": "import { useState, useEffect } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { Label } from \"@/components/ui/label\";\nimport { Alert, AlertDescription } from \"@/components/ui/alert\";\nimport { Save, Loader2 } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport {\n  getStreamCheckConfig,\n  saveStreamCheckConfig,\n  type StreamCheckConfig,\n} from \"@/lib/api/model-test\";\n\nexport function ModelTestConfigPanel() {\n  const { t } = useTranslation();\n  const [isLoading, setIsLoading] = useState(true);\n  const [isSaving, setIsSaving] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n  // 使用字符串状态以支持完全清空数字输入框\n  const [config, setConfig] = useState({\n    timeoutSecs: \"45\",\n    maxRetries: \"2\",\n    degradedThresholdMs: \"6000\",\n    claudeModel: \"claude-haiku-4-5-20251001\",\n    codexModel: \"gpt-5.4@low\",\n    geminiModel: \"gemini-3-pro-preview\",\n    testPrompt: \"Who are you?\",\n  });\n\n  useEffect(() => {\n    loadConfig();\n  }, []);\n\n  async function loadConfig() {\n    try {\n      setIsLoading(true);\n      setError(null);\n      const data = await getStreamCheckConfig();\n      setConfig({\n        timeoutSecs: String(data.timeoutSecs),\n        maxRetries: String(data.maxRetries),\n        degradedThresholdMs: String(data.degradedThresholdMs),\n        claudeModel: data.claudeModel,\n        codexModel: data.codexModel,\n        geminiModel: data.geminiModel,\n        testPrompt: data.testPrompt || \"Who are you?\",\n      });\n    } catch (e) {\n      setError(String(e));\n    } finally {\n      setIsLoading(false);\n    }\n  }\n\n  async function handleSave() {\n    // 解析数字，空值使用默认值，0 是有效值\n    const parseNum = (val: string, defaultVal: number) => {\n      const n = parseInt(val);\n      return isNaN(n) ? defaultVal : n;\n    };\n    try {\n      setIsSaving(true);\n      const parsed: StreamCheckConfig = {\n        timeoutSecs: parseNum(config.timeoutSecs, 45),\n        maxRetries: parseNum(config.maxRetries, 2),\n        degradedThresholdMs: parseNum(config.degradedThresholdMs, 6000),\n        claudeModel: config.claudeModel,\n        codexModel: config.codexModel,\n        geminiModel: config.geminiModel,\n        testPrompt: config.testPrompt || \"Who are you?\",\n      };\n      await saveStreamCheckConfig(parsed);\n      toast.success(t(\"streamCheck.configSaved\"), {\n        closeButton: true,\n      });\n    } catch (e) {\n      toast.error(t(\"streamCheck.configSaveFailed\") + \": \" + String(e));\n    } finally {\n      setIsSaving(false);\n    }\n  }\n\n  if (isLoading) {\n    return (\n      <div className=\"flex items-center justify-center p-4\">\n        <Loader2 className=\"h-6 w-6 animate-spin text-muted-foreground\" />\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"space-y-6\">\n      {error && (\n        <Alert variant=\"destructive\">\n          <AlertDescription>{error}</AlertDescription>\n        </Alert>\n      )}\n\n      {/* 测试模型配置 */}\n      <div className=\"space-y-4\">\n        <h4 className=\"text-sm font-medium text-muted-foreground\">\n          {t(\"streamCheck.testModels\")}\n        </h4>\n        <div className=\"grid grid-cols-1 md:grid-cols-3 gap-4\">\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"claudeModel\">{t(\"streamCheck.claudeModel\")}</Label>\n            <Input\n              id=\"claudeModel\"\n              value={config.claudeModel}\n              onChange={(e) =>\n                setConfig({ ...config, claudeModel: e.target.value })\n              }\n              placeholder=\"claude-3-5-haiku-latest\"\n            />\n          </div>\n\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"codexModel\">{t(\"streamCheck.codexModel\")}</Label>\n            <Input\n              id=\"codexModel\"\n              value={config.codexModel}\n              onChange={(e) =>\n                setConfig({ ...config, codexModel: e.target.value })\n              }\n              placeholder=\"gpt-4o-mini\"\n            />\n          </div>\n\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"geminiModel\">{t(\"streamCheck.geminiModel\")}</Label>\n            <Input\n              id=\"geminiModel\"\n              value={config.geminiModel}\n              onChange={(e) =>\n                setConfig({ ...config, geminiModel: e.target.value })\n              }\n              placeholder=\"gemini-1.5-flash\"\n            />\n          </div>\n        </div>\n      </div>\n\n      {/* 检查参数配置 */}\n      <div className=\"space-y-4\">\n        <h4 className=\"text-sm font-medium text-muted-foreground\">\n          {t(\"streamCheck.checkParams\")}\n        </h4>\n        <div className=\"grid grid-cols-1 md:grid-cols-3 gap-4\">\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"timeoutSecs\">{t(\"streamCheck.timeout\")}</Label>\n            <Input\n              id=\"timeoutSecs\"\n              type=\"number\"\n              min={10}\n              max={120}\n              value={config.timeoutSecs}\n              onChange={(e) =>\n                setConfig({ ...config, timeoutSecs: e.target.value })\n              }\n            />\n          </div>\n\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"maxRetries\">{t(\"streamCheck.maxRetries\")}</Label>\n            <Input\n              id=\"maxRetries\"\n              type=\"number\"\n              min={0}\n              max={5}\n              value={config.maxRetries}\n              onChange={(e) =>\n                setConfig({ ...config, maxRetries: e.target.value })\n              }\n            />\n          </div>\n\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"degradedThresholdMs\">\n              {t(\"streamCheck.degradedThreshold\")}\n            </Label>\n            <Input\n              id=\"degradedThresholdMs\"\n              type=\"number\"\n              min={1000}\n              max={30000}\n              step={1000}\n              value={config.degradedThresholdMs}\n              onChange={(e) =>\n                setConfig({ ...config, degradedThresholdMs: e.target.value })\n              }\n            />\n          </div>\n        </div>\n\n        {/* 检查提示词配置 */}\n        <div className=\"space-y-2\">\n          <Label htmlFor=\"testPrompt\">{t(\"streamCheck.testPrompt\")}</Label>\n          <Textarea\n            id=\"testPrompt\"\n            value={config.testPrompt}\n            onChange={(e) =>\n              setConfig({ ...config, testPrompt: e.target.value })\n            }\n            placeholder=\"Who are you?\"\n            rows={2}\n            className=\"min-h-[60px]\"\n          />\n        </div>\n      </div>\n\n      <div className=\"flex justify-end\">\n        <Button onClick={handleSave} disabled={isSaving}>\n          {isSaving ? (\n            <>\n              <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n              {t(\"common.saving\")}\n            </>\n          ) : (\n            <>\n              <Save className=\"mr-2 h-4 w-4\" />\n              {t(\"common.save\")}\n            </>\n          )}\n        </Button>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/usage/PricingConfigPanel.tsx",
    "content": "import { useState, useEffect } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@/components/ui/table\";\nimport { Button } from \"@/components/ui/button\";\nimport { Alert, AlertDescription } from \"@/components/ui/alert\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { useModelPricing, useDeleteModelPricing } from \"@/lib/query/usage\";\nimport { PricingEditModal } from \"./PricingEditModal\";\nimport type { ModelPricing } from \"@/types/usage\";\nimport { Plus, Pencil, Trash2, Loader2 } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { proxyApi } from \"@/lib/api/proxy\";\n\nconst PRICING_APPS = [\"claude\", \"codex\", \"gemini\"] as const;\ntype PricingApp = (typeof PRICING_APPS)[number];\ntype PricingModelSource = \"request\" | \"response\";\n\ninterface AppConfig {\n  multiplier: string;\n  source: PricingModelSource;\n}\n\ntype AppConfigState = Record<PricingApp, AppConfig>;\n\nexport function PricingConfigPanel() {\n  const { t } = useTranslation();\n  const { data: pricing, isLoading, error } = useModelPricing();\n  const deleteMutation = useDeleteModelPricing();\n  const [editingModel, setEditingModel] = useState<ModelPricing | null>(null);\n  const [isAddingNew, setIsAddingNew] = useState(false);\n  const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);\n\n  // 三个应用的配置状态\n  const [appConfigs, setAppConfigs] = useState<AppConfigState>({\n    claude: { multiplier: \"1\", source: \"response\" },\n    codex: { multiplier: \"1\", source: \"response\" },\n    gemini: { multiplier: \"1\", source: \"response\" },\n  });\n  const [originalConfigs, setOriginalConfigs] = useState<AppConfigState | null>(\n    null,\n  );\n  const [isConfigLoading, setIsConfigLoading] = useState(true);\n  const [isSaving, setIsSaving] = useState(false);\n\n  // 检查是否有改动\n  const isDirty =\n    originalConfigs !== null &&\n    PRICING_APPS.some(\n      (app) =>\n        appConfigs[app].multiplier !== originalConfigs[app].multiplier ||\n        appConfigs[app].source !== originalConfigs[app].source,\n    );\n\n  // 加载所有应用的配置\n  useEffect(() => {\n    let isMounted = true;\n\n    const loadAllConfigs = async () => {\n      setIsConfigLoading(true);\n      try {\n        const results = await Promise.all(\n          PRICING_APPS.map(async (app) => {\n            const [multiplier, source] = await Promise.all([\n              proxyApi.getDefaultCostMultiplier(app),\n              proxyApi.getPricingModelSource(app),\n            ]);\n            return {\n              app,\n              multiplier,\n              source: (source === \"request\"\n                ? \"request\"\n                : \"response\") as PricingModelSource,\n            };\n          }),\n        );\n\n        if (!isMounted) return;\n\n        const newState: AppConfigState = {\n          claude: { multiplier: \"1\", source: \"response\" },\n          codex: { multiplier: \"1\", source: \"response\" },\n          gemini: { multiplier: \"1\", source: \"response\" },\n        };\n        for (const result of results) {\n          newState[result.app] = {\n            multiplier: result.multiplier,\n            source: result.source,\n          };\n        }\n        setAppConfigs(newState);\n        setOriginalConfigs(newState);\n      } catch (error) {\n        const message =\n          error instanceof Error\n            ? error.message\n            : typeof error === \"string\"\n              ? error\n              : \"Unknown error\";\n        toast.error(\n          t(\"settings.globalProxy.pricingLoadFailed\", { error: message }),\n        );\n      } finally {\n        if (isMounted) setIsConfigLoading(false);\n      }\n    };\n\n    loadAllConfigs();\n    return () => {\n      isMounted = false;\n    };\n  }, [t]);\n\n  // 保存所有配置\n  const handleSaveAll = async () => {\n    // 验证所有倍率\n    for (const app of PRICING_APPS) {\n      const trimmed = appConfigs[app].multiplier.trim();\n      if (!trimmed) {\n        toast.error(\n          `${t(`apps.${app}`)}: ${t(\"settings.globalProxy.defaultCostMultiplierRequired\")}`,\n        );\n        return;\n      }\n      if (!/^-?\\d+(?:\\.\\d+)?$/.test(trimmed)) {\n        toast.error(\n          `${t(`apps.${app}`)}: ${t(\"settings.globalProxy.defaultCostMultiplierInvalid\")}`,\n        );\n        return;\n      }\n    }\n\n    setIsSaving(true);\n    try {\n      await Promise.all(\n        PRICING_APPS.flatMap((app) => [\n          proxyApi.setDefaultCostMultiplier(\n            app,\n            appConfigs[app].multiplier.trim(),\n          ),\n          proxyApi.setPricingModelSource(app, appConfigs[app].source),\n        ]),\n      );\n      toast.success(t(\"settings.globalProxy.pricingSaved\"));\n      setOriginalConfigs({ ...appConfigs });\n    } catch (error) {\n      const message =\n        error instanceof Error\n          ? error.message\n          : typeof error === \"string\"\n            ? error\n            : \"Unknown error\";\n      toast.error(\n        t(\"settings.globalProxy.pricingSaveFailed\", { error: message }),\n      );\n    } finally {\n      setIsSaving(false);\n    }\n  };\n\n  const handleDelete = (modelId: string) => {\n    deleteMutation.mutate(modelId, {\n      onSuccess: () => setDeleteConfirm(null),\n    });\n  };\n\n  const handleAddNew = () => {\n    setIsAddingNew(true);\n    setEditingModel({\n      modelId: \"\",\n      displayName: \"\",\n      inputCostPerMillion: \"0\",\n      outputCostPerMillion: \"0\",\n      cacheReadCostPerMillion: \"0\",\n      cacheCreationCostPerMillion: \"0\",\n    });\n  };\n\n  if (isLoading) {\n    return (\n      <div className=\"flex items-center justify-center p-4\">\n        <Loader2 className=\"h-5 w-5 animate-spin text-muted-foreground\" />\n      </div>\n    );\n  }\n\n  if (error) {\n    return (\n      <Alert variant=\"destructive\">\n        <AlertDescription>\n          {t(\"usage.loadPricingError\")}: {String(error)}\n        </AlertDescription>\n      </Alert>\n    );\n  }\n\n  return (\n    <div className=\"space-y-6\">\n      {/* 全局计费默认配置 - 紧凑表格布局 */}\n      <div className=\"space-y-2\">\n        <div className=\"flex items-center justify-between\">\n          <div>\n            <h4 className=\"text-sm font-medium\">\n              {t(\"settings.globalProxy.pricingDefaultsTitle\")}\n            </h4>\n            <p className=\"text-xs text-muted-foreground\">\n              {t(\"settings.globalProxy.pricingDefaultsDescription\")}\n            </p>\n          </div>\n          <Button\n            onClick={handleSaveAll}\n            disabled={isConfigLoading || isSaving || !isDirty}\n            size=\"sm\"\n          >\n            {isSaving ? (\n              <>\n                <Loader2 className=\"mr-1.5 h-3.5 w-3.5 animate-spin\" />\n                {t(\"common.saving\")}\n              </>\n            ) : (\n              t(\"common.save\")\n            )}\n          </Button>\n        </div>\n\n        {isConfigLoading ? (\n          <div className=\"flex items-center justify-center py-4\">\n            <Loader2 className=\"h-4 w-4 animate-spin text-muted-foreground\" />\n          </div>\n        ) : (\n          <div className=\"rounded-md border border-border/50 overflow-hidden\">\n            <table className=\"w-full text-sm\">\n              <thead>\n                <tr className=\"border-b border-border/50 bg-muted/30\">\n                  <th className=\"px-3 py-2 text-left font-medium text-muted-foreground w-24\">\n                    {t(\"settings.globalProxy.pricingAppLabel\")}\n                  </th>\n                  <th className=\"px-3 py-2 text-left font-medium text-muted-foreground\">\n                    {t(\"settings.globalProxy.defaultCostMultiplierLabel\")}\n                  </th>\n                  <th className=\"px-3 py-2 text-left font-medium text-muted-foreground\">\n                    {t(\"settings.globalProxy.pricingModelSourceLabel\")}\n                  </th>\n                </tr>\n              </thead>\n              <tbody>\n                {PRICING_APPS.map((app, idx) => (\n                  <tr\n                    key={app}\n                    className={\n                      idx < PRICING_APPS.length - 1\n                        ? \"border-b border-border/30\"\n                        : \"\"\n                    }\n                  >\n                    <td className=\"px-3 py-1.5 font-medium\">\n                      {t(`apps.${app}`)}\n                    </td>\n                    <td className=\"px-3 py-1.5\">\n                      <Input\n                        type=\"number\"\n                        step=\"0.01\"\n                        inputMode=\"decimal\"\n                        value={appConfigs[app].multiplier}\n                        onChange={(e) =>\n                          setAppConfigs((prev) => ({\n                            ...prev,\n                            [app]: { ...prev[app], multiplier: e.target.value },\n                          }))\n                        }\n                        disabled={isSaving}\n                        placeholder=\"1\"\n                        className=\"h-7 w-24\"\n                      />\n                    </td>\n                    <td className=\"px-3 py-1.5\">\n                      <Select\n                        value={appConfigs[app].source}\n                        onValueChange={(value) =>\n                          setAppConfigs((prev) => ({\n                            ...prev,\n                            [app]: {\n                              ...prev[app],\n                              source: value as PricingModelSource,\n                            },\n                          }))\n                        }\n                        disabled={isSaving}\n                      >\n                        <SelectTrigger className=\"h-7 w-28\">\n                          <SelectValue />\n                        </SelectTrigger>\n                        <SelectContent>\n                          <SelectItem value=\"response\">\n                            {t(\n                              \"settings.globalProxy.pricingModelSourceResponse\",\n                            )}\n                          </SelectItem>\n                          <SelectItem value=\"request\">\n                            {t(\n                              \"settings.globalProxy.pricingModelSourceRequest\",\n                            )}\n                          </SelectItem>\n                        </SelectContent>\n                      </Select>\n                    </td>\n                  </tr>\n                ))}\n              </tbody>\n            </table>\n          </div>\n        )}\n      </div>\n\n      {/* 分隔线 */}\n      <div className=\"border-t border-border/50\" />\n\n      {/* 模型定价配置 */}\n      <div className=\"space-y-4\">\n        <div className=\"flex items-center justify-between\">\n          <h4 className=\"text-sm font-medium text-muted-foreground\">\n            {t(\"usage.modelPricingDesc\")} {t(\"usage.perMillion\")}\n          </h4>\n          <Button\n            onClick={(e) => {\n              e.stopPropagation();\n              handleAddNew();\n            }}\n            size=\"sm\"\n          >\n            <Plus className=\"mr-1 h-4 w-4\" />\n            {t(\"common.add\")}\n          </Button>\n        </div>\n\n        <div className=\"space-y-4\">\n          {!pricing || pricing.length === 0 ? (\n            <Alert>\n              <AlertDescription>{t(\"usage.noPricingData\")}</AlertDescription>\n            </Alert>\n          ) : (\n            <div className=\"rounded-md bg-card/60 shadow-sm\">\n              <Table>\n                <TableHeader>\n                  <TableRow>\n                    <TableHead>{t(\"usage.model\")}</TableHead>\n                    <TableHead>{t(\"usage.displayName\")}</TableHead>\n                    <TableHead className=\"text-right\">\n                      {t(\"usage.inputCost\")}\n                    </TableHead>\n                    <TableHead className=\"text-right\">\n                      {t(\"usage.outputCost\")}\n                    </TableHead>\n                    <TableHead className=\"text-right\">\n                      {t(\"usage.cacheReadCost\")}\n                    </TableHead>\n                    <TableHead className=\"text-right\">\n                      {t(\"usage.cacheWriteCost\")}\n                    </TableHead>\n                    <TableHead className=\"text-right\">\n                      {t(\"common.actions\")}\n                    </TableHead>\n                  </TableRow>\n                </TableHeader>\n                <TableBody>\n                  {pricing.map((model) => (\n                    <TableRow key={model.modelId}>\n                      <TableCell className=\"font-mono text-sm\">\n                        {model.modelId}\n                      </TableCell>\n                      <TableCell>{model.displayName}</TableCell>\n                      <TableCell className=\"text-right font-mono text-sm\">\n                        ${model.inputCostPerMillion}\n                      </TableCell>\n                      <TableCell className=\"text-right font-mono text-sm\">\n                        ${model.outputCostPerMillion}\n                      </TableCell>\n                      <TableCell className=\"text-right font-mono text-sm\">\n                        ${model.cacheReadCostPerMillion}\n                      </TableCell>\n                      <TableCell className=\"text-right font-mono text-sm\">\n                        ${model.cacheCreationCostPerMillion}\n                      </TableCell>\n                      <TableCell className=\"text-right\">\n                        <div className=\"flex justify-end gap-1\">\n                          <Button\n                            variant=\"ghost\"\n                            size=\"icon\"\n                            onClick={() => {\n                              setIsAddingNew(false);\n                              setEditingModel(model);\n                            }}\n                            title={t(\"common.edit\")}\n                          >\n                            <Pencil className=\"h-4 w-4\" />\n                          </Button>\n                          <Button\n                            variant=\"ghost\"\n                            size=\"icon\"\n                            onClick={() => setDeleteConfirm(model.modelId)}\n                            title={t(\"common.delete\")}\n                            className=\"text-destructive hover:text-destructive\"\n                          >\n                            <Trash2 className=\"h-4 w-4\" />\n                          </Button>\n                        </div>\n                      </TableCell>\n                    </TableRow>\n                  ))}\n                </TableBody>\n              </Table>\n            </div>\n          )}\n        </div>\n      </div>\n\n      {editingModel && (\n        <PricingEditModal\n          open={!!editingModel}\n          model={editingModel}\n          isNew={isAddingNew}\n          onClose={() => {\n            setEditingModel(null);\n            setIsAddingNew(false);\n          }}\n        />\n      )}\n\n      <Dialog\n        open={!!deleteConfirm}\n        onOpenChange={() => setDeleteConfirm(null)}\n      >\n        <DialogContent>\n          <DialogHeader>\n            <DialogTitle>{t(\"usage.deleteConfirmTitle\")}</DialogTitle>\n            <DialogDescription>\n              {t(\"usage.deleteConfirmDesc\")}\n            </DialogDescription>\n          </DialogHeader>\n          <DialogFooter>\n            <Button variant=\"outline\" onClick={() => setDeleteConfirm(null)}>\n              {t(\"common.cancel\")}\n            </Button>\n            <Button\n              variant=\"destructive\"\n              onClick={() => deleteConfirm && handleDelete(deleteConfirm)}\n              disabled={deleteMutation.isPending}\n            >\n              {deleteMutation.isPending\n                ? t(\"common.deleting\")\n                : t(\"common.delete\")}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/usage/PricingEditModal.tsx",
    "content": "import { useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { toast } from \"sonner\";\nimport { Save, Plus } from \"lucide-react\";\nimport { FullScreenPanel } from \"@/components/common/FullScreenPanel\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { useUpdateModelPricing } from \"@/lib/query/usage\";\nimport type { ModelPricing } from \"@/types/usage\";\n\ninterface PricingEditModalProps {\n  open: boolean;\n  model: ModelPricing;\n  isNew?: boolean;\n  onClose: () => void;\n}\n\nexport function PricingEditModal({\n  open,\n  model,\n  isNew = false,\n  onClose,\n}: PricingEditModalProps) {\n  const { t } = useTranslation();\n  const updatePricing = useUpdateModelPricing();\n\n  const [formData, setFormData] = useState({\n    modelId: model.modelId,\n    displayName: model.displayName,\n    inputCost: model.inputCostPerMillion,\n    outputCost: model.outputCostPerMillion,\n    cacheReadCost: model.cacheReadCostPerMillion,\n    cacheCreationCost: model.cacheCreationCostPerMillion,\n  });\n\n  const handleSubmit = async (e: React.FormEvent) => {\n    e.preventDefault();\n\n    // 验证模型 ID\n    if (isNew && !formData.modelId.trim()) {\n      toast.error(t(\"usage.modelIdRequired\", \"模型 ID 不能为空\"));\n      return;\n    }\n\n    // 验证非负数\n    const values = [\n      formData.inputCost,\n      formData.outputCost,\n      formData.cacheReadCost,\n      formData.cacheCreationCost,\n    ];\n\n    for (const value of values) {\n      const num = parseFloat(value);\n      if (isNaN(num) || num < 0) {\n        toast.error(t(\"usage.invalidPrice\", \"价格必须为非负数\"));\n        return;\n      }\n    }\n\n    try {\n      await updatePricing.mutateAsync({\n        modelId: isNew ? formData.modelId : model.modelId,\n        displayName: formData.displayName,\n        inputCost: formData.inputCost,\n        outputCost: formData.outputCost,\n        cacheReadCost: formData.cacheReadCost,\n        cacheCreationCost: formData.cacheCreationCost,\n      });\n\n      toast.success(\n        isNew\n          ? t(\"usage.pricingAdded\", \"定价已添加\")\n          : t(\"usage.pricingUpdated\", \"定价已更新\"),\n        { closeButton: true },\n      );\n\n      onClose();\n    } catch (error) {\n      toast.error(String(error));\n    }\n  };\n\n  return (\n    <FullScreenPanel\n      isOpen={open}\n      title={\n        isNew\n          ? t(\"usage.addPricing\", \"新增定价\")\n          : `${t(\"usage.editPricing\", \"编辑定价\")} - ${model.modelId}`\n      }\n      onClose={onClose}\n      footer={\n        <Button\n          type=\"submit\"\n          form=\"pricing-form\"\n          disabled={updatePricing.isPending}\n        >\n          {isNew ? (\n            <Plus className=\"h-4 w-4 mr-2\" />\n          ) : (\n            <Save className=\"h-4 w-4 mr-2\" />\n          )}\n          {updatePricing.isPending\n            ? t(\"common.saving\", \"保存中...\")\n            : isNew\n              ? t(\"common.add\", \"新增\")\n              : t(\"common.save\", \"保存\")}\n        </Button>\n      }\n    >\n      <form id=\"pricing-form\" onSubmit={handleSubmit} className=\"space-y-6\">\n        {isNew && (\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"modelId\">{t(\"usage.modelId\", \"模型 ID\")}</Label>\n            <Input\n              id=\"modelId\"\n              value={formData.modelId}\n              onChange={(e) =>\n                setFormData({ ...formData, modelId: e.target.value })\n              }\n              placeholder={t(\"usage.modelIdPlaceholder\", {\n                defaultValue: \"例如: claude-3-5-sonnet-20241022\",\n              })}\n              required\n            />\n          </div>\n        )}\n\n        <div className=\"space-y-2\">\n          <Label htmlFor=\"displayName\">\n            {t(\"usage.displayName\", \"显示名称\")}\n          </Label>\n          <Input\n            id=\"displayName\"\n            value={formData.displayName}\n            onChange={(e) =>\n              setFormData({ ...formData, displayName: e.target.value })\n            }\n            placeholder={t(\"usage.displayNamePlaceholder\", {\n              defaultValue: \"例如: Claude 3.5 Sonnet\",\n            })}\n            required\n          />\n        </div>\n\n        <div className=\"space-y-2\">\n          <Label htmlFor=\"inputCost\">\n            {t(\"usage.inputCostPerMillion\", \"输入成本 (每百万 tokens, USD)\")}\n          </Label>\n          <Input\n            id=\"inputCost\"\n            type=\"number\"\n            step=\"0.01\"\n            min=\"0\"\n            value={formData.inputCost}\n            onChange={(e) =>\n              setFormData({ ...formData, inputCost: e.target.value })\n            }\n            required\n          />\n        </div>\n\n        <div className=\"space-y-2\">\n          <Label htmlFor=\"outputCost\">\n            {t(\"usage.outputCostPerMillion\", \"输出成本 (每百万 tokens, USD)\")}\n          </Label>\n          <Input\n            id=\"outputCost\"\n            type=\"number\"\n            step=\"0.01\"\n            min=\"0\"\n            value={formData.outputCost}\n            onChange={(e) =>\n              setFormData({ ...formData, outputCost: e.target.value })\n            }\n            required\n          />\n        </div>\n\n        <div className=\"space-y-2\">\n          <Label htmlFor=\"cacheReadCost\">\n            {t(\n              \"usage.cacheReadCostPerMillion\",\n              \"缓存读取成本 (每百万 tokens, USD)\",\n            )}\n          </Label>\n          <Input\n            id=\"cacheReadCost\"\n            type=\"number\"\n            step=\"0.01\"\n            min=\"0\"\n            value={formData.cacheReadCost}\n            onChange={(e) =>\n              setFormData({ ...formData, cacheReadCost: e.target.value })\n            }\n            required\n          />\n        </div>\n\n        <div className=\"space-y-2\">\n          <Label htmlFor=\"cacheCreationCost\">\n            {t(\n              \"usage.cacheCreationCostPerMillion\",\n              \"缓存写入成本 (每百万 tokens, USD)\",\n            )}\n          </Label>\n          <Input\n            id=\"cacheCreationCost\"\n            type=\"number\"\n            step=\"0.01\"\n            min=\"0\"\n            value={formData.cacheCreationCost}\n            onChange={(e) =>\n              setFormData({ ...formData, cacheCreationCost: e.target.value })\n            }\n            required\n          />\n        </div>\n      </form>\n    </FullScreenPanel>\n  );\n}\n"
  },
  {
    "path": "src/components/usage/ProviderStatsTable.tsx",
    "content": "import { useTranslation } from \"react-i18next\";\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@/components/ui/table\";\nimport { useProviderStats } from \"@/lib/query/usage\";\nimport { fmtUsd } from \"./format\";\n\ninterface ProviderStatsTableProps {\n  refreshIntervalMs: number;\n}\n\nexport function ProviderStatsTable({\n  refreshIntervalMs,\n}: ProviderStatsTableProps) {\n  const { t } = useTranslation();\n  const { data: stats, isLoading } = useProviderStats({\n    refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,\n  });\n\n  if (isLoading) {\n    return <div className=\"h-[400px] animate-pulse rounded bg-gray-100\" />;\n  }\n\n  return (\n    <div className=\"rounded-lg border border-border/50 bg-card/40 backdrop-blur-sm overflow-hidden\">\n      <Table>\n        <TableHeader>\n          <TableRow>\n            <TableHead>{t(\"usage.provider\", \"Provider\")}</TableHead>\n            <TableHead className=\"text-right\">\n              {t(\"usage.requests\", \"请求数\")}\n            </TableHead>\n            <TableHead className=\"text-right\">\n              {t(\"usage.tokens\", \"Tokens\")}\n            </TableHead>\n            <TableHead className=\"text-right\">\n              {t(\"usage.cost\", \"成本\")}\n            </TableHead>\n            <TableHead className=\"text-right\">\n              {t(\"usage.successRate\", \"成功率\")}\n            </TableHead>\n            <TableHead className=\"text-right\">\n              {t(\"usage.avgLatency\", \"平均延迟\")}\n            </TableHead>\n          </TableRow>\n        </TableHeader>\n        <TableBody>\n          {stats?.length === 0 ? (\n            <TableRow>\n              <TableCell\n                colSpan={6}\n                className=\"text-center text-muted-foreground\"\n              >\n                {t(\"usage.noData\", \"暂无数据\")}\n              </TableCell>\n            </TableRow>\n          ) : (\n            stats?.map((stat) => (\n              <TableRow key={stat.providerId}>\n                <TableCell className=\"font-medium\">\n                  {stat.providerName}\n                </TableCell>\n                <TableCell className=\"text-right\">\n                  {stat.requestCount.toLocaleString()}\n                </TableCell>\n                <TableCell className=\"text-right\">\n                  {stat.totalTokens.toLocaleString()}\n                </TableCell>\n                <TableCell className=\"text-right\">\n                  {fmtUsd(stat.totalCost, 4)}\n                </TableCell>\n                <TableCell className=\"text-right\">\n                  {stat.successRate.toFixed(1)}%\n                </TableCell>\n                <TableCell className=\"text-right\">\n                  {stat.avgLatencyMs}ms\n                </TableCell>\n              </TableRow>\n            ))\n          )}\n        </TableBody>\n      </Table>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/usage/RequestDetailPanel.tsx",
    "content": "import { useTranslation } from \"react-i18next\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { useRequestDetail } from \"@/lib/query/usage\";\n\ninterface RequestDetailPanelProps {\n  requestId: string;\n  onClose: () => void;\n}\n\nexport function RequestDetailPanel({\n  requestId,\n  onClose,\n}: RequestDetailPanelProps) {\n  const { t, i18n } = useTranslation();\n  const { data: request, isLoading } = useRequestDetail(requestId);\n  const dateLocale =\n    i18n.language === \"zh\"\n      ? \"zh-CN\"\n      : i18n.language === \"ja\"\n        ? \"ja-JP\"\n        : \"en-US\";\n\n  if (isLoading) {\n    return (\n      <Dialog open onOpenChange={onClose}>\n        <DialogContent className=\"max-w-2xl\">\n          <div className=\"h-[400px] animate-pulse rounded bg-gray-100\" />\n        </DialogContent>\n      </Dialog>\n    );\n  }\n\n  if (!request) {\n    return (\n      <Dialog open onOpenChange={onClose}>\n        <DialogContent className=\"max-w-2xl\">\n          <DialogHeader>\n            <DialogTitle>{t(\"usage.requestDetail\", \"请求详情\")}</DialogTitle>\n          </DialogHeader>\n          <div className=\"text-center text-muted-foreground\">\n            {t(\"usage.requestNotFound\", \"请求未找到\")}\n          </div>\n        </DialogContent>\n      </Dialog>\n    );\n  }\n\n  return (\n    <Dialog open onOpenChange={onClose}>\n      <DialogContent className=\"max-w-2xl max-h-[80vh] overflow-y-auto\">\n        <DialogHeader>\n          <DialogTitle>{t(\"usage.requestDetail\", \"请求详情\")}</DialogTitle>\n        </DialogHeader>\n\n        <div className=\"space-y-4\">\n          {/* 基本信息 */}\n          <div className=\"rounded-lg border p-4\">\n            <h3 className=\"mb-3 font-semibold\">\n              {t(\"usage.basicInfo\", \"基本信息\")}\n            </h3>\n            <dl className=\"grid grid-cols-2 gap-3 text-sm\">\n              <div>\n                <dt className=\"text-muted-foreground\">\n                  {t(\"usage.requestId\", \"请求ID\")}\n                </dt>\n                <dd className=\"font-mono\">{request.requestId}</dd>\n              </div>\n              <div>\n                <dt className=\"text-muted-foreground\">\n                  {t(\"usage.time\", \"时间\")}\n                </dt>\n                <dd>\n                  {new Date(request.createdAt * 1000).toLocaleString(\n                    dateLocale,\n                  )}\n                </dd>\n              </div>\n              <div>\n                <dt className=\"text-muted-foreground\">\n                  {t(\"usage.provider\", \"供应商\")}\n                </dt>\n                <dd className=\"text-sm\">\n                  <span className=\"font-medium\">\n                    {request.providerName || t(\"usage.unknownProvider\", \"未知\")}\n                  </span>\n                  <span className=\"ml-2 font-mono text-xs text-muted-foreground\">\n                    {request.providerId}\n                  </span>\n                </dd>\n              </div>\n              <div>\n                <dt className=\"text-muted-foreground\">\n                  {t(\"usage.appType\", \"应用类型\")}\n                </dt>\n                <dd>{request.appType}</dd>\n              </div>\n              <div>\n                <dt className=\"text-muted-foreground\">\n                  {t(\"usage.model\", \"模型\")}\n                </dt>\n                <dd className=\"font-mono\">{request.model}</dd>\n              </div>\n              <div>\n                <dt className=\"text-muted-foreground\">\n                  {t(\"usage.status\", \"状态\")}\n                </dt>\n                <dd>\n                  <span\n                    className={`inline-flex rounded-full px-2 py-1 text-xs ${\n                      request.statusCode >= 200 && request.statusCode < 300\n                        ? \"bg-green-100 text-green-800\"\n                        : \"bg-red-100 text-red-800\"\n                    }`}\n                  >\n                    {request.statusCode}\n                  </span>\n                </dd>\n              </div>\n            </dl>\n          </div>\n\n          {/* Token 使用量 */}\n          <div className=\"rounded-lg border p-4\">\n            <h3 className=\"mb-3 font-semibold\">\n              {t(\"usage.tokenUsage\", \"Token 使用量\")}\n            </h3>\n            <dl className=\"grid grid-cols-2 gap-3 text-sm\">\n              <div>\n                <dt className=\"text-muted-foreground\">\n                  {t(\"usage.inputTokens\", \"输入 Tokens\")}\n                </dt>\n                <dd className=\"font-mono\">\n                  {request.inputTokens.toLocaleString()}\n                </dd>\n              </div>\n              <div>\n                <dt className=\"text-muted-foreground\">\n                  {t(\"usage.outputTokens\", \"输出 Tokens\")}\n                </dt>\n                <dd className=\"font-mono\">\n                  {request.outputTokens.toLocaleString()}\n                </dd>\n              </div>\n              <div>\n                <dt className=\"text-muted-foreground\">\n                  {t(\"usage.cacheReadTokens\", \"缓存读取\")}\n                </dt>\n                <dd className=\"font-mono\">\n                  {request.cacheReadTokens.toLocaleString()}\n                </dd>\n              </div>\n              <div>\n                <dt className=\"text-muted-foreground\">\n                  {t(\"usage.cacheCreationTokens\", \"缓存写入\")}\n                </dt>\n                <dd className=\"font-mono\">\n                  {request.cacheCreationTokens.toLocaleString()}\n                </dd>\n              </div>\n              <div className=\"col-span-2\">\n                <dt className=\"text-muted-foreground\">\n                  {t(\"usage.totalTokens\", \"总计\")}\n                </dt>\n                <dd className=\"text-lg font-semibold\">\n                  {(\n                    request.inputTokens + request.outputTokens\n                  ).toLocaleString()}\n                </dd>\n              </div>\n            </dl>\n          </div>\n\n          {/* 成本明细 */}\n          <div className=\"rounded-lg border p-4\">\n            <h3 className=\"mb-3 font-semibold\">\n              {t(\"usage.costBreakdown\", \"成本明细\")}\n            </h3>\n            <dl className=\"grid grid-cols-2 gap-3 text-sm\">\n              <div>\n                <dt className=\"text-muted-foreground\">\n                  {t(\"usage.inputCost\", \"输入成本\")}\n                  <span className=\"ml-1 text-xs\">\n                    ({t(\"usage.baseCost\", \"基础\")})\n                  </span>\n                </dt>\n                <dd className=\"font-mono\">\n                  ${parseFloat(request.inputCostUsd).toFixed(6)}\n                </dd>\n              </div>\n              <div>\n                <dt className=\"text-muted-foreground\">\n                  {t(\"usage.outputCost\", \"输出成本\")}\n                  <span className=\"ml-1 text-xs\">\n                    ({t(\"usage.baseCost\", \"基础\")})\n                  </span>\n                </dt>\n                <dd className=\"font-mono\">\n                  ${parseFloat(request.outputCostUsd).toFixed(6)}\n                </dd>\n              </div>\n              <div>\n                <dt className=\"text-muted-foreground\">\n                  {t(\"usage.cacheReadCost\", \"缓存读取成本\")}\n                  <span className=\"ml-1 text-xs\">\n                    ({t(\"usage.baseCost\", \"基础\")})\n                  </span>\n                </dt>\n                <dd className=\"font-mono\">\n                  ${parseFloat(request.cacheReadCostUsd).toFixed(6)}\n                </dd>\n              </div>\n              <div>\n                <dt className=\"text-muted-foreground\">\n                  {t(\"usage.cacheCreationCost\", \"缓存写入成本\")}\n                  <span className=\"ml-1 text-xs\">\n                    ({t(\"usage.baseCost\", \"基础\")})\n                  </span>\n                </dt>\n                <dd className=\"font-mono\">\n                  ${parseFloat(request.cacheCreationCostUsd).toFixed(6)}\n                </dd>\n              </div>\n              {/* 显示成本倍率（如果不等于1） */}\n              {request.costMultiplier &&\n                parseFloat(request.costMultiplier) !== 1 && (\n                  <div className=\"col-span-2 border-t pt-3\">\n                    <dt className=\"text-muted-foreground\">\n                      {t(\"usage.costMultiplier\", \"成本倍率\")}\n                    </dt>\n                    <dd className=\"font-mono\">×{request.costMultiplier}</dd>\n                  </div>\n                )}\n              <div\n                className={`col-span-2 ${request.costMultiplier && parseFloat(request.costMultiplier) !== 1 ? \"\" : \"border-t\"} pt-3`}\n              >\n                <dt className=\"text-muted-foreground\">\n                  {t(\"usage.totalCost\", \"总成本\")}\n                  {request.costMultiplier &&\n                    parseFloat(request.costMultiplier) !== 1 && (\n                      <span className=\"ml-1 text-xs\">\n                        ({t(\"usage.withMultiplier\", \"含倍率\")})\n                      </span>\n                    )}\n                </dt>\n                <dd className=\"text-lg font-semibold text-primary\">\n                  ${parseFloat(request.totalCostUsd).toFixed(6)}\n                </dd>\n              </div>\n            </dl>\n          </div>\n\n          {/* 性能信息 */}\n          <div className=\"rounded-lg border p-4\">\n            <h3 className=\"mb-3 font-semibold\">\n              {t(\"usage.performance\", \"性能信息\")}\n            </h3>\n            <dl className=\"grid grid-cols-2 gap-3 text-sm\">\n              <div>\n                <dt className=\"text-muted-foreground\">\n                  {t(\"usage.latency\", \"延迟\")}\n                </dt>\n                <dd className=\"font-mono\">{request.latencyMs}ms</dd>\n              </div>\n            </dl>\n          </div>\n\n          {/* 错误信息 */}\n          {request.errorMessage && (\n            <div className=\"rounded-lg border border-red-200 bg-red-50 p-4\">\n              <h3 className=\"mb-2 font-semibold text-red-800\">\n                {t(\"usage.errorMessage\", \"错误信息\")}\n              </h3>\n              <p className=\"text-sm text-red-700\">{request.errorMessage}</p>\n            </div>\n          )}\n        </div>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "src/components/usage/RequestLogTable.tsx",
    "content": "import { useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@/components/ui/table\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { useRequestLogs, usageKeys } from \"@/lib/query/usage\";\nimport { useQueryClient } from \"@tanstack/react-query\";\nimport type { LogFilters } from \"@/types/usage\";\nimport { ChevronLeft, ChevronRight, RefreshCw, Search, X } from \"lucide-react\";\nimport {\n  fmtInt,\n  fmtUsd,\n  getLocaleFromLanguage,\n  parseFiniteNumber,\n} from \"./format\";\n\ninterface RequestLogTableProps {\n  refreshIntervalMs: number;\n}\n\nconst ONE_DAY_SECONDS = 24 * 60 * 60;\nconst MAX_FIXED_RANGE_SECONDS = 30 * ONE_DAY_SECONDS;\n\ntype TimeMode = \"rolling\" | \"fixed\";\n\nexport function RequestLogTable({ refreshIntervalMs }: RequestLogTableProps) {\n  const { t, i18n } = useTranslation();\n  const queryClient = useQueryClient();\n\n  const getRollingRange = () => {\n    const now = Math.floor(Date.now() / 1000);\n    const oneDayAgo = now - ONE_DAY_SECONDS;\n    return { startDate: oneDayAgo, endDate: now };\n  };\n\n  const [appliedTimeMode, setAppliedTimeMode] = useState<TimeMode>(\"rolling\");\n  const [draftTimeMode, setDraftTimeMode] = useState<TimeMode>(\"rolling\");\n\n  const [appliedFilters, setAppliedFilters] = useState<LogFilters>({});\n  const [draftFilters, setDraftFilters] = useState<LogFilters>({});\n  const [page, setPage] = useState(0);\n  const pageSize = 20;\n  const [validationError, setValidationError] = useState<string | null>(null);\n\n  const { data: result, isLoading } = useRequestLogs({\n    filters: appliedFilters,\n    timeMode: appliedTimeMode,\n    rollingWindowSeconds: ONE_DAY_SECONDS,\n    page,\n    pageSize,\n    options: {\n      refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,\n    },\n  });\n\n  const logs = result?.data ?? [];\n  const total = result?.total ?? 0;\n  const totalPages = Math.ceil(total / pageSize);\n\n  const handleSearch = () => {\n    setValidationError(null);\n\n    if (draftTimeMode === \"fixed\") {\n      const start = draftFilters.startDate;\n      const end = draftFilters.endDate;\n\n      if (typeof start !== \"number\" || typeof end !== \"number\") {\n        setValidationError(\n          t(\"usage.invalidTimeRange\", \"请选择完整的开始/结束时间\"),\n        );\n        return;\n      }\n\n      if (start > end) {\n        setValidationError(\n          t(\"usage.invalidTimeRangeOrder\", \"开始时间不能晚于结束时间\"),\n        );\n        return;\n      }\n\n      if (end - start > MAX_FIXED_RANGE_SECONDS) {\n        setValidationError(\n          t(\"usage.timeRangeTooLarge\", \"时间范围过大，请缩小范围\"),\n        );\n        return;\n      }\n    }\n\n    setAppliedTimeMode(draftTimeMode);\n    setAppliedFilters((prev) => {\n      const next = { ...prev, ...draftFilters };\n      if (draftTimeMode === \"rolling\") {\n        delete next.startDate;\n        delete next.endDate;\n      }\n      return next;\n    });\n    setPage(0);\n  };\n\n  const handleReset = () => {\n    setValidationError(null);\n    setAppliedTimeMode(\"rolling\");\n    setDraftTimeMode(\"rolling\");\n    setDraftFilters({});\n    setAppliedFilters({});\n    setPage(0);\n  };\n\n  const handleRefresh = () => {\n    const key = {\n      timeMode: appliedTimeMode,\n      rollingWindowSeconds:\n        appliedTimeMode === \"rolling\" ? ONE_DAY_SECONDS : undefined,\n      appType: appliedFilters.appType,\n      providerName: appliedFilters.providerName,\n      model: appliedFilters.model,\n      statusCode: appliedFilters.statusCode,\n      startDate:\n        appliedTimeMode === \"fixed\" ? appliedFilters.startDate : undefined,\n      endDate: appliedTimeMode === \"fixed\" ? appliedFilters.endDate : undefined,\n    };\n\n    queryClient.invalidateQueries({\n      queryKey: usageKeys.logs(key, page, pageSize),\n    });\n  };\n\n  // 将 Unix 时间戳转换为本地时间的 datetime-local 格式\n  const timestampToLocalDatetime = (timestamp: number): string => {\n    const date = new Date(timestamp * 1000);\n    const year = date.getFullYear();\n    const month = String(date.getMonth() + 1).padStart(2, \"0\");\n    const day = String(date.getDate()).padStart(2, \"0\");\n    const hours = String(date.getHours()).padStart(2, \"0\");\n    const minutes = String(date.getMinutes()).padStart(2, \"0\");\n    return `${year}-${month}-${day}T${hours}:${minutes}`;\n  };\n\n  // 将 datetime-local 格式转换为 Unix 时间戳\n  const localDatetimeToTimestamp = (datetime: string): number | undefined => {\n    if (!datetime) return undefined;\n    // 验证格式是否完整 (YYYY-MM-DDTHH:mm)\n    if (datetime.length < 16) return undefined;\n    const timestamp = new Date(datetime).getTime();\n    // 验证是否为有效日期\n    if (isNaN(timestamp)) return undefined;\n    return Math.floor(timestamp / 1000);\n  };\n\n  const language = i18n.resolvedLanguage || i18n.language || \"en\";\n  const locale = getLocaleFromLanguage(language);\n\n  const rollingRangeForDisplay =\n    draftTimeMode === \"rolling\" ? getRollingRange() : null;\n\n  return (\n    <div className=\"space-y-4\">\n      {/* 筛选栏 */}\n      <div className=\"flex flex-col gap-4 rounded-lg border bg-card/50 p-4 backdrop-blur-sm\">\n        <div className=\"flex flex-wrap items-center gap-3\">\n          <Select\n            value={draftFilters.appType || \"all\"}\n            onValueChange={(v) =>\n              setDraftFilters({\n                ...draftFilters,\n                appType: v === \"all\" ? undefined : v,\n              })\n            }\n          >\n            <SelectTrigger className=\"w-[130px] bg-background\">\n              <SelectValue placeholder={t(\"usage.appType\")} />\n            </SelectTrigger>\n            <SelectContent>\n              <SelectItem value=\"all\">{t(\"usage.allApps\")}</SelectItem>\n              <SelectItem value=\"claude\">Claude</SelectItem>\n              <SelectItem value=\"codex\">Codex</SelectItem>\n              <SelectItem value=\"gemini\">Gemini</SelectItem>\n            </SelectContent>\n          </Select>\n\n          <Select\n            value={draftFilters.statusCode?.toString() || \"all\"}\n            onValueChange={(v) =>\n              setDraftFilters({\n                ...draftFilters,\n                statusCode:\n                  v === \"all\"\n                    ? undefined\n                    : Number.isFinite(Number.parseInt(v, 10))\n                      ? Number.parseInt(v, 10)\n                      : undefined,\n              })\n            }\n          >\n            <SelectTrigger className=\"w-[130px] bg-background\">\n              <SelectValue placeholder={t(\"usage.statusCode\")} />\n            </SelectTrigger>\n            <SelectContent>\n              <SelectItem value=\"all\">{t(\"common.all\")}</SelectItem>\n              <SelectItem value=\"200\">200 OK</SelectItem>\n              <SelectItem value=\"400\">400 Bad Request</SelectItem>\n              <SelectItem value=\"401\">401 Unauthorized</SelectItem>\n              <SelectItem value=\"429\">429 Rate Limit</SelectItem>\n              <SelectItem value=\"500\">500 Server Error</SelectItem>\n            </SelectContent>\n          </Select>\n\n          <div className=\"flex items-center gap-2 flex-1 min-w-[300px]\">\n            <div className=\"relative flex-1\">\n              <Search className=\"absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground\" />\n              <Input\n                placeholder={t(\"usage.searchProviderPlaceholder\")}\n                className=\"pl-9 bg-background\"\n                value={draftFilters.providerName || \"\"}\n                onChange={(e) =>\n                  setDraftFilters({\n                    ...draftFilters,\n                    providerName: e.target.value || undefined,\n                  })\n                }\n              />\n            </div>\n            <Input\n              placeholder={t(\"usage.searchModelPlaceholder\")}\n              className=\"w-[180px] bg-background\"\n              value={draftFilters.model || \"\"}\n              onChange={(e) =>\n                setDraftFilters({\n                  ...draftFilters,\n                  model: e.target.value || undefined,\n                })\n              }\n            />\n          </div>\n        </div>\n\n        <div className=\"flex flex-wrap items-center justify-between gap-3\">\n          <div className=\"flex items-center gap-2 text-sm text-muted-foreground\">\n            <span className=\"whitespace-nowrap\">{t(\"usage.timeRange\")}:</span>\n            <Input\n              type=\"datetime-local\"\n              className=\"h-8 w-[200px] bg-background\"\n              value={\n                (rollingRangeForDisplay?.startDate ?? draftFilters.startDate)\n                  ? timestampToLocalDatetime(\n                      (rollingRangeForDisplay?.startDate ??\n                        draftFilters.startDate) as number,\n                    )\n                  : \"\"\n              }\n              onChange={(e) => {\n                const timestamp = localDatetimeToTimestamp(e.target.value);\n                setDraftTimeMode(\"fixed\");\n                setDraftFilters({\n                  ...draftFilters,\n                  startDate: timestamp,\n                });\n              }}\n            />\n            <span>-</span>\n            <Input\n              type=\"datetime-local\"\n              className=\"h-8 w-[200px] bg-background\"\n              value={\n                (rollingRangeForDisplay?.endDate ?? draftFilters.endDate)\n                  ? timestampToLocalDatetime(\n                      (rollingRangeForDisplay?.endDate ??\n                        draftFilters.endDate) as number,\n                    )\n                  : \"\"\n              }\n              onChange={(e) => {\n                const timestamp = localDatetimeToTimestamp(e.target.value);\n                setDraftTimeMode(\"fixed\");\n                setDraftFilters({\n                  ...draftFilters,\n                  endDate: timestamp,\n                });\n              }}\n            />\n          </div>\n\n          <div className=\"flex items-center gap-2 ml-auto\">\n            <Button\n              size=\"sm\"\n              variant=\"default\"\n              onClick={handleSearch}\n              className=\"h-8\"\n            >\n              <Search className=\"mr-2 h-3.5 w-3.5\" />\n              {t(\"common.search\")}\n            </Button>\n            <Button\n              size=\"sm\"\n              variant=\"outline\"\n              onClick={handleReset}\n              className=\"h-8\"\n            >\n              <X className=\"mr-2 h-3.5 w-3.5\" />\n              {t(\"common.reset\")}\n            </Button>\n            <Button\n              size=\"sm\"\n              variant=\"ghost\"\n              onClick={handleRefresh}\n              className=\"h-8 px-2\"\n            >\n              <RefreshCw className=\"h-4 w-4\" />\n            </Button>\n          </div>\n        </div>\n\n        {validationError && (\n          <div className=\"text-sm text-red-600\">{validationError}</div>\n        )}\n      </div>\n\n      {isLoading ? (\n        <div className=\"h-[400px] animate-pulse rounded bg-gray-100\" />\n      ) : (\n        <>\n          <div className=\"rounded-lg border border-border/50 bg-card/40 backdrop-blur-sm overflow-x-auto\">\n            <Table>\n              <TableHeader>\n                <TableRow>\n                  <TableHead className=\"whitespace-nowrap\">\n                    {t(\"usage.time\")}\n                  </TableHead>\n                  <TableHead className=\"whitespace-nowrap\">\n                    {t(\"usage.provider\")}\n                  </TableHead>\n                  <TableHead className=\"min-w-[200px] whitespace-nowrap\">\n                    {t(\"usage.billingModel\")}\n                  </TableHead>\n                  <TableHead className=\"text-right whitespace-nowrap\">\n                    {t(\"usage.inputTokens\")}\n                  </TableHead>\n                  <TableHead className=\"text-right whitespace-nowrap\">\n                    {t(\"usage.outputTokens\")}\n                  </TableHead>\n                  <TableHead className=\"text-right min-w-[90px] whitespace-nowrap\">\n                    {t(\"usage.cacheReadTokens\")}\n                  </TableHead>\n                  <TableHead className=\"text-right min-w-[90px] whitespace-nowrap\">\n                    {t(\"usage.cacheCreationTokens\")}\n                  </TableHead>\n                  <TableHead className=\"text-right whitespace-nowrap\">\n                    {t(\"usage.multiplier\")}\n                  </TableHead>\n                  <TableHead className=\"text-right whitespace-nowrap\">\n                    {t(\"usage.totalCost\")}\n                  </TableHead>\n                  <TableHead className=\"text-center min-w-[140px] whitespace-nowrap\">\n                    {t(\"usage.timingInfo\")}\n                  </TableHead>\n                  <TableHead className=\"whitespace-nowrap\">\n                    {t(\"usage.status\")}\n                  </TableHead>\n                </TableRow>\n              </TableHeader>\n              <TableBody>\n                {logs.length === 0 ? (\n                  <TableRow>\n                    <TableCell\n                      colSpan={11}\n                      className=\"text-center text-muted-foreground\"\n                    >\n                      {t(\"usage.noData\")}\n                    </TableCell>\n                  </TableRow>\n                ) : (\n                  logs.map((log) => (\n                    <TableRow key={log.requestId}>\n                      <TableCell>\n                        {new Date(log.createdAt * 1000).toLocaleString(locale)}\n                      </TableCell>\n                      <TableCell>\n                        {log.providerName || t(\"usage.unknownProvider\")}\n                      </TableCell>\n                      <TableCell className=\"font-mono text-xs max-w-[200px]\">\n                        <div\n                          className=\"truncate\"\n                          title={\n                            log.requestModel && log.requestModel !== log.model\n                              ? `${t(\"usage.requestModel\")}: ${log.requestModel}\\n${t(\"usage.responseModel\")}: ${log.model}`\n                              : log.model\n                          }\n                        >\n                          {log.model}\n                        </div>\n                        {log.requestModel && log.requestModel !== log.model && (\n                          <div\n                            className=\"truncate text-muted-foreground text-[10px]\"\n                            title={log.requestModel}\n                          >\n                            ← {log.requestModel}\n                          </div>\n                        )}\n                      </TableCell>\n                      <TableCell className=\"text-right\">\n                        {fmtInt(log.inputTokens, locale)}\n                      </TableCell>\n                      <TableCell className=\"text-right\">\n                        {fmtInt(log.outputTokens, locale)}\n                      </TableCell>\n                      <TableCell className=\"text-right\">\n                        {fmtInt(log.cacheReadTokens, locale)}\n                      </TableCell>\n                      <TableCell className=\"text-right\">\n                        {fmtInt(log.cacheCreationTokens, locale)}\n                      </TableCell>\n                      <TableCell className=\"text-right font-mono text-xs\">\n                        {(parseFiniteNumber(log.costMultiplier) ?? 1) !== 1 ? (\n                          <span className=\"text-orange-600\">\n                            ×{log.costMultiplier}\n                          </span>\n                        ) : (\n                          <span className=\"text-muted-foreground\">×1</span>\n                        )}\n                      </TableCell>\n                      <TableCell className=\"text-right\">\n                        {fmtUsd(log.totalCostUsd, 6)}\n                      </TableCell>\n                      <TableCell>\n                        <div className=\"flex items-center justify-center gap-1\">\n                          {(() => {\n                            const durationMs =\n                              typeof log.durationMs === \"number\"\n                                ? log.durationMs\n                                : log.latencyMs;\n                            const durationSec = durationMs / 1000;\n                            const durationColor = Number.isFinite(durationSec)\n                              ? durationSec <= 5\n                                ? \"bg-green-100 text-green-800\"\n                                : durationSec <= 120\n                                  ? \"bg-orange-100 text-orange-800\"\n                                  : \"bg-red-200 text-red-900\"\n                              : \"bg-gray-100 text-gray-700\";\n                            return (\n                              <span\n                                className={`inline-flex items-center justify-center rounded-full px-2 py-0.5 text-xs ${durationColor}`}\n                              >\n                                {Number.isFinite(durationSec)\n                                  ? `${Math.round(durationSec)}s`\n                                  : \"--\"}\n                              </span>\n                            );\n                          })()}\n                          {log.isStreaming &&\n                            log.firstTokenMs != null &&\n                            (() => {\n                              const firstSec = log.firstTokenMs / 1000;\n                              const firstColor = Number.isFinite(firstSec)\n                                ? firstSec <= 5\n                                  ? \"bg-green-100 text-green-800\"\n                                  : firstSec <= 120\n                                    ? \"bg-orange-100 text-orange-800\"\n                                    : \"bg-red-200 text-red-900\"\n                                : \"bg-gray-100 text-gray-700\";\n                              return (\n                                <span\n                                  className={`inline-flex items-center justify-center rounded-full px-2 py-0.5 text-xs ${firstColor}`}\n                                >\n                                  {Number.isFinite(firstSec)\n                                    ? `${firstSec.toFixed(1)}s`\n                                    : \"--\"}\n                                </span>\n                              );\n                            })()}\n                          <span\n                            className={`inline-flex items-center justify-center rounded-full px-2 py-0.5 text-xs ${\n                              log.isStreaming\n                                ? \"bg-blue-100 text-blue-800\"\n                                : \"bg-purple-100 text-purple-800\"\n                            }`}\n                          >\n                            {log.isStreaming\n                              ? t(\"usage.stream\")\n                              : t(\"usage.nonStream\")}\n                          </span>\n                        </div>\n                      </TableCell>\n                      <TableCell>\n                        <span\n                          className={`inline-flex rounded-full px-2 py-1 text-xs ${\n                            log.statusCode >= 200 && log.statusCode < 300\n                              ? \"bg-green-100 text-green-800\"\n                              : \"bg-red-100 text-red-800\"\n                          }`}\n                        >\n                          {log.statusCode}\n                        </span>\n                      </TableCell>\n                    </TableRow>\n                  ))\n                )}\n              </TableBody>\n            </Table>\n          </div>\n\n          {/* 分页控件 */}\n          {total > 0 && (\n            <div className=\"flex items-center justify-between px-2\">\n              <span className=\"text-sm text-muted-foreground\">\n                {t(\"usage.totalRecords\", { total })}\n              </span>\n              <div className=\"flex items-center gap-1\">\n                <Button\n                  variant=\"outline\"\n                  size=\"sm\"\n                  onClick={() => setPage(Math.max(0, page - 1))}\n                  disabled={page === 0}\n                >\n                  <ChevronLeft className=\"h-4 w-4\" />\n                </Button>\n                {/* 页码按钮 */}\n                {(() => {\n                  const pages: (number | string)[] = [];\n                  if (totalPages <= 7) {\n                    for (let i = 0; i < totalPages; i++) pages.push(i);\n                  } else {\n                    pages.push(0);\n                    if (page > 2) pages.push(\"...\");\n                    for (\n                      let i = Math.max(1, page - 1);\n                      i <= Math.min(totalPages - 2, page + 1);\n                      i++\n                    ) {\n                      pages.push(i);\n                    }\n                    if (page < totalPages - 3) pages.push(\"...\");\n                    pages.push(totalPages - 1);\n                  }\n                  return pages.map((p, idx) =>\n                    typeof p === \"string\" ? (\n                      <span\n                        key={`ellipsis-${idx}`}\n                        className=\"px-2 text-muted-foreground\"\n                      >\n                        ...\n                      </span>\n                    ) : (\n                      <Button\n                        key={p}\n                        variant={p === page ? \"default\" : \"outline\"}\n                        size=\"sm\"\n                        className=\"h-8 w-8 p-0\"\n                        onClick={() => setPage(p)}\n                      >\n                        {p + 1}\n                      </Button>\n                    ),\n                  );\n                })()}\n                <Button\n                  variant=\"outline\"\n                  size=\"sm\"\n                  onClick={() => setPage(page + 1)}\n                  disabled={page >= totalPages - 1}\n                >\n                  <ChevronRight className=\"h-4 w-4\" />\n                </Button>\n              </div>\n            </div>\n          )}\n        </>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/usage/UsageDashboard.tsx",
    "content": "import { useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from \"@/components/ui/tabs\";\nimport { UsageSummaryCards } from \"./UsageSummaryCards\";\nimport { UsageTrendChart } from \"./UsageTrendChart\";\nimport { RequestLogTable } from \"./RequestLogTable\";\nimport { ProviderStatsTable } from \"./ProviderStatsTable\";\nimport { ModelStatsTable } from \"./ModelStatsTable\";\nimport type { TimeRange } from \"@/types/usage\";\nimport { motion } from \"framer-motion\";\nimport {\n  BarChart3,\n  ListFilter,\n  Activity,\n  RefreshCw,\n  Coins,\n} from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { useQueryClient } from \"@tanstack/react-query\";\nimport { usageKeys } from \"@/lib/query/usage\";\nimport {\n  Accordion,\n  AccordionContent,\n  AccordionItem,\n  AccordionTrigger,\n} from \"@/components/ui/accordion\";\nimport { PricingConfigPanel } from \"@/components/usage/PricingConfigPanel\";\n\nexport function UsageDashboard() {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const [timeRange, setTimeRange] = useState<TimeRange>(\"1d\");\n  const [refreshIntervalMs, setRefreshIntervalMs] = useState(30000);\n\n  const refreshIntervalOptionsMs = [0, 5000, 10000, 30000, 60000] as const;\n  const changeRefreshInterval = () => {\n    const currentIndex = refreshIntervalOptionsMs.indexOf(\n      refreshIntervalMs as (typeof refreshIntervalOptionsMs)[number],\n    );\n    const safeIndex = currentIndex >= 0 ? currentIndex : 3; // default 30s\n    const nextIndex = (safeIndex + 1) % refreshIntervalOptionsMs.length;\n    const next = refreshIntervalOptionsMs[nextIndex];\n    setRefreshIntervalMs(next);\n    queryClient.invalidateQueries({ queryKey: usageKeys.all });\n  };\n\n  const days = timeRange === \"1d\" ? 1 : timeRange === \"7d\" ? 7 : 30;\n\n  return (\n    <motion.div\n      initial={{ opacity: 0, y: 10 }}\n      animate={{ opacity: 1, y: 0 }}\n      transition={{ duration: 0.4 }}\n      className=\"space-y-8 pb-8\"\n    >\n      <div className=\"flex flex-col sm:flex-row sm:items-center justify-between gap-4\">\n        <div className=\"flex flex-col gap-1\">\n          <h2 className=\"text-2xl font-bold\">{t(\"usage.title\")}</h2>\n          <p className=\"text-sm text-muted-foreground\">{t(\"usage.subtitle\")}</p>\n        </div>\n\n        <Tabs\n          value={timeRange}\n          onValueChange={(v) => setTimeRange(v as TimeRange)}\n          className=\"w-full sm:w-auto\"\n        >\n          <div className=\"flex w-full sm:w-auto items-center gap-1\">\n            <Button\n              type=\"button\"\n              variant=\"ghost\"\n              size=\"sm\"\n              className=\"h-10 px-2 text-xs text-muted-foreground\"\n              title={t(\"common.refresh\", \"刷新\")}\n              onClick={changeRefreshInterval}\n            >\n              <RefreshCw className=\"mr-1 h-3.5 w-3.5\" />\n              {refreshIntervalMs > 0 ? `${refreshIntervalMs / 1000}s` : \"--\"}\n            </Button>\n            <TabsList className=\"flex w-full sm:w-auto bg-card/60 border border-border/50 backdrop-blur-sm shadow-sm h-10 p-1\">\n              <TabsTrigger\n                value=\"1d\"\n                className=\"flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors\"\n              >\n                {t(\"usage.today\")}\n              </TabsTrigger>\n              <TabsTrigger\n                value=\"7d\"\n                className=\"flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors\"\n              >\n                {t(\"usage.last7days\")}\n              </TabsTrigger>\n              <TabsTrigger\n                value=\"30d\"\n                className=\"flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors\"\n              >\n                {t(\"usage.last30days\")}\n              </TabsTrigger>\n            </TabsList>\n          </div>\n        </Tabs>\n      </div>\n\n      <UsageSummaryCards days={days} refreshIntervalMs={refreshIntervalMs} />\n\n      <UsageTrendChart days={days} refreshIntervalMs={refreshIntervalMs} />\n\n      <div className=\"space-y-4\">\n        <Tabs defaultValue=\"logs\" className=\"w-full\">\n          <div className=\"flex items-center justify-between mb-4\">\n            <TabsList className=\"bg-muted/50\">\n              <TabsTrigger value=\"logs\" className=\"gap-2\">\n                <ListFilter className=\"h-4 w-4\" />\n                {t(\"usage.requestLogs\")}\n              </TabsTrigger>\n              <TabsTrigger value=\"providers\" className=\"gap-2\">\n                <Activity className=\"h-4 w-4\" />\n                {t(\"usage.providerStats\")}\n              </TabsTrigger>\n              <TabsTrigger value=\"models\" className=\"gap-2\">\n                <BarChart3 className=\"h-4 w-4\" />\n                {t(\"usage.modelStats\")}\n              </TabsTrigger>\n            </TabsList>\n          </div>\n\n          <motion.div\n            initial={{ opacity: 0, y: 10 }}\n            animate={{ opacity: 1, y: 0 }}\n            transition={{ delay: 0.2 }}\n          >\n            <TabsContent value=\"logs\" className=\"mt-0\">\n              <RequestLogTable refreshIntervalMs={refreshIntervalMs} />\n            </TabsContent>\n\n            <TabsContent value=\"providers\" className=\"mt-0\">\n              <ProviderStatsTable refreshIntervalMs={refreshIntervalMs} />\n            </TabsContent>\n\n            <TabsContent value=\"models\" className=\"mt-0\">\n              <ModelStatsTable refreshIntervalMs={refreshIntervalMs} />\n            </TabsContent>\n          </motion.div>\n        </Tabs>\n      </div>\n\n      {/* Pricing Configuration */}\n      <Accordion type=\"multiple\" defaultValue={[]} className=\"w-full space-y-4\">\n        <AccordionItem\n          value=\"pricing\"\n          className=\"rounded-xl glass-card overflow-hidden\"\n        >\n          <AccordionTrigger className=\"px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50\">\n            <div className=\"flex items-center gap-3\">\n              <Coins className=\"h-5 w-5 text-yellow-500\" />\n              <div className=\"text-left\">\n                <h3 className=\"text-base font-semibold\">\n                  {t(\"settings.advanced.pricing.title\")}\n                </h3>\n                <p className=\"text-sm text-muted-foreground font-normal\">\n                  {t(\"settings.advanced.pricing.description\")}\n                </p>\n              </div>\n            </div>\n          </AccordionTrigger>\n          <AccordionContent className=\"px-6 pb-6 pt-4 border-t border-border/50\">\n            <PricingConfigPanel />\n          </AccordionContent>\n        </AccordionItem>\n      </Accordion>\n    </motion.div>\n  );\n}\n"
  },
  {
    "path": "src/components/usage/UsageSummaryCards.tsx",
    "content": "import { useMemo } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Card, CardContent } from \"@/components/ui/card\";\nimport { useUsageSummary } from \"@/lib/query/usage\";\nimport { Activity, DollarSign, Layers, Database, Loader2 } from \"lucide-react\";\nimport { motion } from \"framer-motion\";\nimport { fmtUsd, parseFiniteNumber } from \"./format\";\n\ninterface UsageSummaryCardsProps {\n  days: number;\n  refreshIntervalMs: number;\n}\n\nexport function UsageSummaryCards({\n  days,\n  refreshIntervalMs,\n}: UsageSummaryCardsProps) {\n  const { t } = useTranslation();\n\n  const { data: summary, isLoading } = useUsageSummary(days, {\n    refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,\n  });\n\n  const stats = useMemo(() => {\n    const totalRequests = summary?.totalRequests ?? 0;\n    const totalCost = parseFiniteNumber(summary?.totalCost);\n\n    const inputTokens = summary?.totalInputTokens ?? 0;\n    const outputTokens = summary?.totalOutputTokens ?? 0;\n    const totalTokens = inputTokens + outputTokens;\n\n    const cacheWriteTokens = summary?.totalCacheCreationTokens ?? 0;\n    const cacheReadTokens = summary?.totalCacheReadTokens ?? 0;\n    const totalCacheTokens = cacheWriteTokens + cacheReadTokens;\n\n    return [\n      {\n        title: t(\"usage.totalRequests\"),\n        value: totalRequests.toLocaleString(),\n        icon: Activity,\n        color: \"text-blue-500\",\n        bg: \"bg-blue-500/10\",\n        subValue: null,\n      },\n      {\n        title: t(\"usage.totalCost\"),\n        value: totalCost == null ? \"--\" : fmtUsd(totalCost, 4),\n        icon: DollarSign,\n        color: \"text-green-500\",\n        bg: \"bg-green-500/10\",\n        subValue: null,\n      },\n      {\n        title: t(\"usage.totalTokens\"),\n        value: totalTokens.toLocaleString(),\n        icon: Layers,\n        color: \"text-purple-500\",\n        bg: \"bg-purple-500/10\",\n        subValue: (\n          <div className=\"flex flex-col gap-1 text-xs text-muted-foreground mt-3 pt-3 border-t border-border/50\">\n            <div className=\"flex justify-between items-center\">\n              <span>{t(\"usage.input\")}</span>\n              <span className=\"text-foreground/80\">\n                {(inputTokens / 1000).toFixed(1)}k\n              </span>\n            </div>\n            <div className=\"flex justify-between items-center\">\n              <span>{t(\"usage.output\")}</span>\n              <span className=\"text-foreground/80\">\n                {(outputTokens / 1000).toFixed(1)}k\n              </span>\n            </div>\n          </div>\n        ),\n      },\n      {\n        title: t(\"usage.cacheTokens\"),\n        value: totalCacheTokens.toLocaleString(),\n        icon: Database,\n        color: \"text-orange-500\",\n        bg: \"bg-orange-500/10\",\n        subValue: (\n          <div className=\"flex flex-col gap-1 text-xs text-muted-foreground mt-3 pt-3 border-t border-border/50\">\n            <div className=\"flex justify-between items-center\">\n              <span>{t(\"usage.cacheWrite\")}</span>\n              <span className=\"text-foreground/80\">\n                {(cacheWriteTokens / 1000).toFixed(1)}k\n              </span>\n            </div>\n            <div className=\"flex justify-between items-center\">\n              <span>{t(\"usage.cacheRead\")}</span>\n              <span className=\"text-foreground/80\">\n                {(cacheReadTokens / 1000).toFixed(1)}k\n              </span>\n            </div>\n          </div>\n        ),\n      },\n    ];\n  }, [summary, t]);\n\n  const container = {\n    hidden: { opacity: 0 },\n    show: {\n      opacity: 1,\n      transition: {\n        staggerChildren: 0.1,\n      },\n    },\n  };\n\n  const item = {\n    hidden: { opacity: 0, y: 20 },\n    show: { opacity: 1, y: 0 },\n  };\n\n  if (isLoading) {\n    return (\n      <div className=\"grid gap-4 md:grid-cols-4\">\n        {[...Array(4)].map((_, i) => (\n          <Card\n            key={i}\n            className=\"border border-border/50 bg-card/40 backdrop-blur-sm shadow-sm\"\n          >\n            <CardContent className=\"p-6 flex items-center justify-center min-h-[160px]\">\n              <Loader2 className=\"h-6 w-6 animate-spin text-muted-foreground/50\" />\n            </CardContent>\n          </Card>\n        ))}\n      </div>\n    );\n  }\n\n  return (\n    <motion.div\n      variants={container}\n      initial=\"hidden\"\n      animate=\"show\"\n      className=\"grid gap-4 md:grid-cols-4\"\n    >\n      {stats.map((stat, i) => (\n        <motion.div key={i} variants={item}>\n          <Card className=\"relative h-full overflow-hidden border border-border/50 bg-gradient-to-br from-card/50 to-background/50 backdrop-blur-xl hover:from-card/60 hover:to-background/60 transition-all shadow-sm\">\n            <CardContent className=\"p-5\">\n              <div className=\"flex items-start justify-between mb-2\">\n                <p className=\"text-sm font-medium text-muted-foreground\">\n                  {stat.title}\n                </p>\n                <div className={`p-2 rounded-lg ${stat.bg}`}>\n                  <stat.icon className={`h-4 w-4 ${stat.color}`} />\n                </div>\n              </div>\n\n              <div className=\"space-y-1\">\n                <h3 className=\"text-2xl font-bold truncate\" title={stat.value}>\n                  {stat.value}\n                </h3>\n              </div>\n\n              {stat.subValue || (\n                /* Placeholder to properly align cards if no subvalue (first 2 cards) - effectively adding empty space or using flex-1 equivalent */\n                <div className=\"mt-3 pt-3 border-t border-transparent h-[52px]\"></div>\n              )}\n            </CardContent>\n          </Card>\n        </motion.div>\n      ))}\n    </motion.div>\n  );\n}\n"
  },
  {
    "path": "src/components/usage/UsageTrendChart.tsx",
    "content": "import { useTranslation } from \"react-i18next\";\nimport {\n  AreaChart,\n  Area,\n  XAxis,\n  YAxis,\n  CartesianGrid,\n  Tooltip,\n  ResponsiveContainer,\n  Legend,\n} from \"recharts\";\nimport { useUsageTrends } from \"@/lib/query/usage\";\nimport { Loader2 } from \"lucide-react\";\nimport {\n  fmtInt,\n  fmtUsd,\n  getLocaleFromLanguage,\n  parseFiniteNumber,\n} from \"./format\";\n\ninterface UsageTrendChartProps {\n  days: number;\n  refreshIntervalMs: number;\n}\n\nexport function UsageTrendChart({\n  days,\n  refreshIntervalMs,\n}: UsageTrendChartProps) {\n  const { t, i18n } = useTranslation();\n  const { data: trends, isLoading } = useUsageTrends(days, {\n    refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,\n  });\n\n  if (isLoading) {\n    return (\n      <div className=\"flex h-[350px] items-center justify-center rounded-xl bg-card/40 border border-border/50\">\n        <Loader2 className=\"h-8 w-8 animate-spin text-muted-foreground/30\" />\n      </div>\n    );\n  }\n\n  const isToday = days === 1;\n  const language = i18n.resolvedLanguage || i18n.language || \"en\";\n  const dateLocale = getLocaleFromLanguage(language);\n  const chartData =\n    trends?.map((stat) => {\n      const pointDate = new Date(stat.date);\n      const cost = parseFiniteNumber(stat.totalCost);\n      return {\n        rawDate: stat.date,\n        label: isToday\n          ? pointDate.toLocaleString(dateLocale, {\n              month: \"2-digit\",\n              day: \"2-digit\",\n              hour: \"2-digit\",\n              minute: \"2-digit\",\n            })\n          : pointDate.toLocaleDateString(dateLocale, {\n              month: \"2-digit\",\n              day: \"2-digit\",\n            }),\n        hour: pointDate.getHours(),\n        inputTokens: stat.totalInputTokens,\n        outputTokens: stat.totalOutputTokens,\n        cacheCreationTokens: stat.totalCacheCreationTokens,\n        cacheReadTokens: stat.totalCacheReadTokens,\n        cost: cost ?? null,\n      };\n    }) || [];\n\n  const displayData = chartData;\n\n  const CustomTooltip = ({ active, payload, label }: any) => {\n    if (active && payload && payload.length) {\n      return (\n        <div className=\"rounded-lg border bg-background/95 p-3 shadow-lg backdrop-blur-md\">\n          <p className=\"mb-2 font-medium\">{label}</p>\n          {payload.map((entry: any, index: number) => (\n            <div\n              key={index}\n              className=\"flex items-center gap-2 text-sm\"\n              style={{ color: entry.color }}\n            >\n              <div\n                className=\"h-2 w-2 rounded-full\"\n                style={{ backgroundColor: entry.color }}\n              />\n              <span className=\"font-medium\">{entry.name}:</span>\n              <span>\n                {entry.dataKey === \"cost\"\n                  ? fmtUsd(entry.value, 6)\n                  : fmtInt(entry.value, dateLocale)}\n              </span>\n            </div>\n          ))}\n        </div>\n      );\n    }\n    return null;\n  };\n\n  return (\n    <div className=\"rounded-xl border border-border/50 bg-card/40 p-6 backdrop-blur-sm\">\n      <div className=\"mb-6 flex items-center justify-between\">\n        <h3 className=\"text-lg font-semibold\">\n          {t(\"usage.trends\", \"使用趋势\")}\n        </h3>\n        <p className=\"text-sm text-muted-foreground\">\n          {isToday\n            ? t(\"usage.rangeToday\", \"今天 (按小时)\")\n            : days === 7\n              ? t(\"usage.rangeLast7Days\", \"过去 7 天\")\n              : t(\"usage.rangeLast30Days\", \"过去 30 天\")}\n        </p>\n      </div>\n\n      <div className=\"h-[350px] w-full\">\n        <ResponsiveContainer width=\"100%\" height=\"100%\">\n          <AreaChart\n            data={displayData}\n            margin={{ top: 10, right: 10, left: 0, bottom: 0 }}\n          >\n            <defs>\n              <linearGradient id=\"colorInput\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n                <stop offset=\"5%\" stopColor=\"#3b82f6\" stopOpacity={0.2} />\n                <stop offset=\"95%\" stopColor=\"#3b82f6\" stopOpacity={0} />\n              </linearGradient>\n              <linearGradient id=\"colorOutput\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n                <stop offset=\"5%\" stopColor=\"#22c55e\" stopOpacity={0.2} />\n                <stop offset=\"95%\" stopColor=\"#22c55e\" stopOpacity={0} />\n              </linearGradient>\n              <linearGradient\n                id=\"colorCacheCreation\"\n                x1=\"0\"\n                y1=\"0\"\n                x2=\"0\"\n                y2=\"1\"\n              >\n                <stop offset=\"5%\" stopColor=\"#f97316\" stopOpacity={0.2} />\n                <stop offset=\"95%\" stopColor=\"#f97316\" stopOpacity={0} />\n              </linearGradient>\n              <linearGradient id=\"colorCacheRead\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n                <stop offset=\"5%\" stopColor=\"#a855f7\" stopOpacity={0.2} />\n                <stop offset=\"95%\" stopColor=\"#a855f7\" stopOpacity={0} />\n              </linearGradient>\n            </defs>\n            <CartesianGrid\n              strokeDasharray=\"3 3\"\n              vertical={false}\n              stroke=\"hsl(var(--border))\"\n              opacity={0.4}\n            />\n            <XAxis\n              dataKey=\"label\"\n              axisLine={false}\n              tickLine={false}\n              tick={{ fill: \"hsl(var(--muted-foreground))\", fontSize: 12 }}\n              dy={10}\n            />\n            <YAxis\n              yAxisId=\"tokens\"\n              axisLine={false}\n              tickLine={false}\n              tick={{ fill: \"hsl(var(--muted-foreground))\", fontSize: 12 }}\n              tickFormatter={(value) => `${(value / 1000).toFixed(0)}k`}\n            />\n            <YAxis\n              yAxisId=\"cost\"\n              orientation=\"right\"\n              axisLine={false}\n              tickLine={false}\n              tick={{ fill: \"hsl(var(--muted-foreground))\", fontSize: 12 }}\n              tickFormatter={(value) => `$${value}`}\n            />\n            <Tooltip content={<CustomTooltip />} />\n            <Legend />\n            <Area\n              yAxisId=\"tokens\"\n              type=\"monotone\"\n              dataKey=\"inputTokens\"\n              name={t(\"usage.inputTokens\", \"输入 Tokens\")}\n              stroke=\"#3b82f6\"\n              fillOpacity={1}\n              fill=\"url(#colorInput)\"\n              strokeWidth={2}\n            />\n            <Area\n              yAxisId=\"tokens\"\n              type=\"monotone\"\n              dataKey=\"outputTokens\"\n              name={t(\"usage.outputTokens\", \"输出 Tokens\")}\n              stroke=\"#22c55e\"\n              fillOpacity={1}\n              fill=\"url(#colorOutput)\"\n              strokeWidth={2}\n            />\n            <Area\n              yAxisId=\"tokens\"\n              type=\"monotone\"\n              dataKey=\"cacheCreationTokens\"\n              name={t(\"usage.cacheCreationTokens\", \"缓存创建\")}\n              stroke=\"#f97316\"\n              fillOpacity={1}\n              fill=\"url(#colorCacheCreation)\"\n              strokeWidth={2}\n            />\n            <Area\n              yAxisId=\"tokens\"\n              type=\"monotone\"\n              dataKey=\"cacheReadTokens\"\n              name={t(\"usage.cacheReadTokens\", \"缓存命中\")}\n              stroke=\"#a855f7\"\n              fillOpacity={1}\n              fill=\"url(#colorCacheRead)\"\n              strokeWidth={2}\n            />\n            <Area\n              yAxisId=\"cost\"\n              type=\"monotone\"\n              dataKey=\"cost\"\n              name={t(\"usage.cost\", \"成本\")}\n              stroke=\"#f43f5e\"\n              fill=\"none\"\n              strokeWidth={2}\n              strokeDasharray=\"4 4\"\n            />\n          </AreaChart>\n        </ResponsiveContainer>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/usage/format.ts",
    "content": "export function parseFiniteNumber(value: unknown): number | null {\n  if (typeof value === \"number\") {\n    return Number.isFinite(value) ? value : null;\n  }\n\n  if (typeof value === \"string\") {\n    const parsed = Number.parseFloat(value);\n    return Number.isFinite(parsed) ? parsed : null;\n  }\n\n  return null;\n}\n\nexport function fmtInt(\n  value: unknown,\n  locale?: string,\n  fallback: string = \"--\",\n): string {\n  const num = parseFiniteNumber(value);\n  if (num == null) return fallback;\n  return new Intl.NumberFormat(locale).format(Math.trunc(num));\n}\n\nexport function fmtUsd(\n  value: unknown,\n  digits: number,\n  fallback: string = \"--\",\n): string {\n  const num = parseFiniteNumber(value);\n  if (num == null) return fallback;\n  return `$${num.toFixed(digits)}`;\n}\n\nexport function getLocaleFromLanguage(language: string): string {\n  if (!language) return \"en-US\";\n  if (language.startsWith(\"zh\")) return \"zh-CN\";\n  if (language.startsWith(\"ja\")) return \"ja-JP\";\n  return \"en-US\";\n}\n"
  },
  {
    "path": "src/components/workspace/DailyMemoryPanel.tsx",
    "content": "import React, {\n  useState,\n  useEffect,\n  useCallback,\n  useRef,\n  useMemo,\n} from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { toast } from \"sonner\";\nimport { Calendar, Trash2, Plus, Search, X, FolderOpen } from \"lucide-react\";\nimport { AnimatePresence, motion } from \"framer-motion\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { FullScreenPanel } from \"@/components/common/FullScreenPanel\";\nimport { ConfirmDialog } from \"@/components/ConfirmDialog\";\nimport MarkdownEditor from \"@/components/MarkdownEditor\";\nimport {\n  workspaceApi,\n  type DailyMemoryFileInfo,\n  type DailyMemorySearchResult,\n} from \"@/lib/api/workspace\";\n\ninterface DailyMemoryPanelProps {\n  isOpen: boolean;\n  onClose: () => void;\n}\n\nfunction getTodayFilename(): string {\n  const now = new Date();\n  const y = now.getFullYear();\n  const m = String(now.getMonth() + 1).padStart(2, \"0\");\n  const d = String(now.getDate()).padStart(2, \"0\");\n  return `${y}-${m}-${d}.md`;\n}\n\nfunction formatFileSize(bytes: number): string {\n  if (bytes < 1024) return `${bytes} B`;\n  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;\n  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;\n}\n\nconst DailyMemoryPanel: React.FC<DailyMemoryPanelProps> = ({\n  isOpen,\n  onClose,\n}) => {\n  const { t } = useTranslation();\n\n  // List state\n  const [files, setFiles] = useState<DailyMemoryFileInfo[]>([]);\n  const [loadingList, setLoadingList] = useState(false);\n\n  // Edit state\n  const [editingFile, setEditingFile] = useState<string | null>(null);\n  const [content, setContent] = useState(\"\");\n  const [loadingContent, setLoadingContent] = useState(false);\n  const [saving, setSaving] = useState(false);\n\n  // Delete state\n  const [deletingFile, setDeletingFile] = useState<string | null>(null);\n\n  // Search state\n  const [searchTerm, setSearchTerm] = useState(\"\");\n  const [isSearchOpen, setIsSearchOpen] = useState(false);\n  const [searchResults, setSearchResults] = useState<DailyMemorySearchResult[]>(\n    [],\n  );\n  const [searching, setSearching] = useState(false);\n  const searchInputRef = useRef<HTMLInputElement>(null);\n  const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  // Dark mode\n  const [isDarkMode, setIsDarkMode] = useState(false);\n\n  useEffect(() => {\n    setIsDarkMode(document.documentElement.classList.contains(\"dark\"));\n    const observer = new MutationObserver(() => {\n      setIsDarkMode(document.documentElement.classList.contains(\"dark\"));\n    });\n    observer.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n    return () => observer.disconnect();\n  }, []);\n\n  // Whether we are in active search mode (search open with a non-empty term)\n  const isActiveSearch = useMemo(\n    () => isSearchOpen && searchTerm.trim().length > 0,\n    [isSearchOpen, searchTerm],\n  );\n\n  // Debounced search execution\n  const executeSearch = useCallback(\n    async (query: string) => {\n      if (!query.trim()) {\n        setSearchResults([]);\n        setSearching(false);\n        return;\n      }\n      setSearching(true);\n      try {\n        const results = await workspaceApi.searchDailyMemoryFiles(query.trim());\n        setSearchResults(results);\n      } catch (err) {\n        console.error(\"Failed to search daily memory files:\", err);\n        toast.error(t(\"workspace.dailyMemory.searchFailed\"));\n      } finally {\n        setSearching(false);\n      }\n    },\n    [t],\n  );\n\n  // Handle search input change with debounce\n  const handleSearchChange = useCallback(\n    (value: string) => {\n      setSearchTerm(value);\n      if (debounceTimerRef.current) {\n        clearTimeout(debounceTimerRef.current);\n      }\n      debounceTimerRef.current = setTimeout(() => {\n        void executeSearch(value);\n      }, 300);\n    },\n    [executeSearch],\n  );\n\n  // Open search bar\n  const openSearch = useCallback(() => {\n    setIsSearchOpen(true);\n    // Focus input on next frame\n    requestAnimationFrame(() => {\n      searchInputRef.current?.focus();\n    });\n  }, []);\n\n  // Close search bar and clear state\n  const closeSearch = useCallback(() => {\n    setIsSearchOpen(false);\n    setSearchTerm(\"\");\n    setSearchResults([]);\n    setSearching(false);\n    if (debounceTimerRef.current) {\n      clearTimeout(debounceTimerRef.current);\n    }\n  }, []);\n\n  // Keyboard shortcut: Cmd/Ctrl+F to open search, Escape to close\n  useEffect(() => {\n    if (!isOpen || editingFile) return;\n\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if ((e.metaKey || e.ctrlKey) && e.key === \"f\") {\n        e.preventDefault();\n        if (!isSearchOpen) {\n          openSearch();\n        } else {\n          searchInputRef.current?.focus();\n        }\n      }\n      if (e.key === \"Escape\" && isSearchOpen) {\n        e.preventDefault();\n        closeSearch();\n      }\n    };\n\n    window.addEventListener(\"keydown\", handleKeyDown);\n    return () => window.removeEventListener(\"keydown\", handleKeyDown);\n  }, [isOpen, editingFile, isSearchOpen, openSearch, closeSearch]);\n\n  // Clean up debounce timer on unmount\n  useEffect(() => {\n    return () => {\n      if (debounceTimerRef.current) {\n        clearTimeout(debounceTimerRef.current);\n      }\n    };\n  }, []);\n\n  // Load file list\n  const loadFiles = useCallback(async () => {\n    setLoadingList(true);\n    try {\n      const list = await workspaceApi.listDailyMemoryFiles();\n      setFiles(list);\n    } catch (err) {\n      console.error(\"Failed to load daily memory files:\", err);\n      toast.error(t(\"workspace.dailyMemory.loadFailed\"));\n    } finally {\n      setLoadingList(false);\n    }\n  }, [t]);\n\n  useEffect(() => {\n    if (isOpen) {\n      void loadFiles();\n    }\n  }, [isOpen, loadFiles]);\n\n  // Open file for editing\n  const openFile = useCallback(\n    async (filename: string) => {\n      setLoadingContent(true);\n      setEditingFile(filename);\n      try {\n        const data = await workspaceApi.readDailyMemoryFile(filename);\n        setContent(data ?? \"\");\n      } catch (err) {\n        console.error(\"Failed to read daily memory file:\", err);\n        toast.error(t(\"workspace.dailyMemory.loadFailed\"));\n        setEditingFile(null);\n      } finally {\n        setLoadingContent(false);\n      }\n    },\n    [t],\n  );\n\n  // Create today's note (deferred — file is only persisted on save)\n  const handleCreateToday = useCallback(async () => {\n    const filename = getTodayFilename();\n    // Check if already exists in the list\n    const existing = files.find((f) => f.filename === filename);\n    if (existing) {\n      // Just open it\n      await openFile(filename);\n      return;\n    }\n    // Open editor with empty content — no file created until user saves\n    setEditingFile(filename);\n    setContent(\"\");\n  }, [files, openFile]);\n\n  // Save current file\n  const handleSave = useCallback(async () => {\n    if (!editingFile) return;\n    setSaving(true);\n    try {\n      await workspaceApi.writeDailyMemoryFile(editingFile, content);\n      toast.success(t(\"workspace.saveSuccess\"));\n    } catch (err) {\n      console.error(\"Failed to save daily memory file:\", err);\n      toast.error(t(\"workspace.saveFailed\"));\n    } finally {\n      setSaving(false);\n    }\n  }, [editingFile, content, t]);\n\n  // Delete file\n  const handleDelete = useCallback(async () => {\n    if (!deletingFile) return;\n    try {\n      await workspaceApi.deleteDailyMemoryFile(deletingFile);\n      toast.success(t(\"workspace.dailyMemory.deleteSuccess\"));\n      setDeletingFile(null);\n      // If we were editing this file, go back to list\n      if (editingFile === deletingFile) {\n        setEditingFile(null);\n      }\n      await loadFiles();\n      // Re-trigger search if active\n      if (isSearchOpen && searchTerm.trim()) {\n        void executeSearch(searchTerm);\n      }\n    } catch (err) {\n      console.error(\"Failed to delete daily memory file:\", err);\n      toast.error(t(\"workspace.dailyMemory.deleteFailed\"));\n      setDeletingFile(null);\n    }\n  }, [\n    deletingFile,\n    editingFile,\n    loadFiles,\n    t,\n    isSearchOpen,\n    searchTerm,\n    executeSearch,\n  ]);\n\n  // Back from edit mode to list mode — preserve search state\n  const handleBackToList = useCallback(() => {\n    setEditingFile(null);\n    setContent(\"\");\n    void loadFiles();\n    // Re-trigger search if active (file content may have changed)\n    if (isSearchOpen && searchTerm.trim()) {\n      void executeSearch(searchTerm);\n    }\n  }, [loadFiles, isSearchOpen, searchTerm, executeSearch]);\n\n  // Close panel entirely — clear search state\n  const handleClose = useCallback(() => {\n    setEditingFile(null);\n    setContent(\"\");\n    setIsSearchOpen(false);\n    setSearchTerm(\"\");\n    setSearchResults([]);\n    setSearching(false);\n    onClose();\n  }, [onClose]);\n\n  // --- Edit mode ---\n  if (editingFile) {\n    return (\n      <>\n        <FullScreenPanel\n          isOpen={isOpen}\n          title={t(\"workspace.editing\", { filename: editingFile })}\n          onClose={handleBackToList}\n          footer={\n            <Button onClick={handleSave} disabled={saving || loadingContent}>\n              {saving ? t(\"common.saving\") : t(\"common.save\")}\n            </Button>\n          }\n        >\n          {loadingContent ? (\n            <div className=\"flex items-center justify-center h-64 text-muted-foreground\">\n              {t(\"prompts.loading\")}\n            </div>\n          ) : (\n            <MarkdownEditor\n              value={content}\n              onChange={setContent}\n              darkMode={isDarkMode}\n              placeholder={`# ${editingFile}\\n\\n...`}\n              minHeight=\"calc(100vh - 240px)\"\n            />\n          )}\n        </FullScreenPanel>\n\n        <ConfirmDialog\n          isOpen={!!deletingFile}\n          title={t(\"workspace.dailyMemory.confirmDeleteTitle\")}\n          message={t(\"workspace.dailyMemory.confirmDeleteMessage\", {\n            date: deletingFile?.replace(\".md\", \"\") ?? \"\",\n          })}\n          onConfirm={handleDelete}\n          onCancel={() => setDeletingFile(null)}\n        />\n      </>\n    );\n  }\n\n  // --- List mode ---\n  return (\n    <>\n      <FullScreenPanel\n        isOpen={isOpen}\n        title={t(\"workspace.dailyMemory.title\")}\n        onClose={handleClose}\n      >\n        <div className=\"space-y-4\">\n          {/* Header with path, search, and create button */}\n          <div className=\"flex items-center justify-between gap-2\">\n            <p\n              className=\"text-sm text-muted-foreground shrink-0 cursor-pointer hover:text-foreground transition-colors inline-flex items-center gap-1\"\n              onClick={() => workspaceApi.openDirectory(\"memory\")}\n              title={t(\"workspace.openDirectory\")}\n            >\n              ~/.openclaw/workspace/memory/\n              <FolderOpen className=\"w-3.5 h-3.5\" />\n            </p>\n            <div className=\"flex items-center gap-1.5\">\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                className=\"h-8 w-8\"\n                onClick={isSearchOpen ? closeSearch : openSearch}\n                title={t(\"workspace.dailyMemory.searchScopeHint\")}\n              >\n                <Search className=\"w-4 h-4\" />\n              </Button>\n              <Button\n                variant=\"outline\"\n                size=\"sm\"\n                onClick={handleCreateToday}\n                className=\"gap-1.5\"\n              >\n                <Plus className=\"w-3.5 h-3.5\" />\n                {t(\"workspace.dailyMemory.createToday\")}\n              </Button>\n            </div>\n          </div>\n\n          {/* Search bar */}\n          <AnimatePresence>\n            {isSearchOpen && (\n              <motion.div\n                initial={{ height: 0, opacity: 0 }}\n                animate={{ height: \"auto\", opacity: 1 }}\n                exit={{ height: 0, opacity: 0 }}\n                transition={{ duration: 0.15 }}\n                className=\"overflow-hidden\"\n              >\n                <div className=\"flex items-center gap-2\">\n                  <div className=\"relative flex-1\">\n                    <Search className=\"absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none\" />\n                    <Input\n                      ref={searchInputRef}\n                      value={searchTerm}\n                      onChange={(e) => handleSearchChange(e.target.value)}\n                      placeholder={t(\"workspace.dailyMemory.searchPlaceholder\")}\n                      className=\"pl-8 pr-8 h-8 text-sm\"\n                    />\n                    {searchTerm && (\n                      <button\n                        onClick={() => handleSearchChange(\"\")}\n                        className=\"absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors\"\n                      >\n                        <X className=\"w-3.5 h-3.5\" />\n                      </button>\n                    )}\n                  </div>\n                  <Button\n                    variant=\"ghost\"\n                    size=\"sm\"\n                    onClick={closeSearch}\n                    className=\"text-xs text-muted-foreground h-8 px-2 shrink-0\"\n                  >\n                    {t(\"workspace.dailyMemory.searchCloseHint\")}\n                  </Button>\n                </div>\n              </motion.div>\n            )}\n          </AnimatePresence>\n\n          {/* Content: search results or normal file list */}\n          {isActiveSearch ? (\n            // --- Search results ---\n            searching ? (\n              <div className=\"flex items-center justify-center h-48 text-muted-foreground\">\n                {t(\"workspace.dailyMemory.searching\")}\n              </div>\n            ) : searchResults.length === 0 ? (\n              <div className=\"flex flex-col items-center justify-center h-48 text-muted-foreground gap-3 border-2 border-dashed border-border rounded-xl\">\n                <Search className=\"w-10 h-10 opacity-40\" />\n                <p className=\"text-sm\">\n                  {t(\"workspace.dailyMemory.noSearchResults\")}\n                </p>\n              </div>\n            ) : (\n              <div className=\"space-y-2\">\n                {searchResults.map((result) => (\n                  <button\n                    key={result.filename}\n                    onClick={() => openFile(result.filename)}\n                    className=\"w-full flex items-start gap-3 p-4 rounded-xl border border-border bg-card hover:bg-accent/50 transition-colors text-left group\"\n                  >\n                    <div className=\"mt-0.5 text-muted-foreground group-hover:text-foreground transition-colors\">\n                      <Calendar className=\"w-4 h-4\" />\n                    </div>\n                    <div className=\"flex-1 min-w-0\">\n                      <div className=\"flex items-center gap-2\">\n                        <span className=\"font-medium text-sm text-foreground\">\n                          {result.date}\n                        </span>\n                        <span className=\"text-xs text-muted-foreground\">\n                          {formatFileSize(result.sizeBytes)}\n                        </span>\n                        {result.matchCount > 0 && (\n                          <span className=\"text-[10px] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary font-medium\">\n                            {t(\"workspace.dailyMemory.matchCount\", {\n                              count: result.matchCount,\n                            })}\n                          </span>\n                        )}\n                      </div>\n                      {result.snippet && (\n                        <p className=\"text-xs text-muted-foreground mt-1 line-clamp-2 whitespace-pre-line\">\n                          {result.snippet}\n                        </p>\n                      )}\n                    </div>\n                    <div\n                      className=\"opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0\"\n                      onClick={(e) => {\n                        e.stopPropagation();\n                        setDeletingFile(result.filename);\n                      }}\n                    >\n                      <Trash2 className=\"w-4 h-4 text-muted-foreground hover:text-destructive transition-colors\" />\n                    </div>\n                  </button>\n                ))}\n              </div>\n            )\n          ) : // --- Normal file list ---\n          loadingList ? (\n            <div className=\"flex items-center justify-center h-48 text-muted-foreground\">\n              {t(\"prompts.loading\")}\n            </div>\n          ) : files.length === 0 ? (\n            <div className=\"flex flex-col items-center justify-center h-48 text-muted-foreground gap-3\">\n              <Calendar className=\"w-10 h-10 opacity-40\" />\n              <p className=\"text-sm\">{t(\"workspace.dailyMemory.empty\")}</p>\n            </div>\n          ) : (\n            <div className=\"space-y-2\">\n              {files.map((file) => (\n                <button\n                  key={file.filename}\n                  onClick={() => openFile(file.filename)}\n                  className=\"w-full flex items-start gap-3 p-4 rounded-xl border border-border bg-card hover:bg-accent/50 transition-colors text-left group\"\n                >\n                  <div className=\"mt-0.5 text-muted-foreground group-hover:text-foreground transition-colors\">\n                    <Calendar className=\"w-4 h-4\" />\n                  </div>\n                  <div className=\"flex-1 min-w-0\">\n                    <div className=\"flex items-center gap-2\">\n                      <span className=\"font-medium text-sm text-foreground\">\n                        {file.date}\n                      </span>\n                      <span className=\"text-xs text-muted-foreground\">\n                        {formatFileSize(file.sizeBytes)}\n                      </span>\n                    </div>\n                    {file.preview && (\n                      <p className=\"text-xs text-muted-foreground mt-1 line-clamp-2\">\n                        {file.preview}\n                      </p>\n                    )}\n                  </div>\n                  <div\n                    className=\"opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0\"\n                    onClick={(e) => {\n                      e.stopPropagation();\n                      setDeletingFile(file.filename);\n                    }}\n                  >\n                    <Trash2 className=\"w-4 h-4 text-muted-foreground hover:text-destructive transition-colors\" />\n                  </div>\n                </button>\n              ))}\n            </div>\n          )}\n        </div>\n      </FullScreenPanel>\n\n      <ConfirmDialog\n        isOpen={!!deletingFile}\n        title={t(\"workspace.dailyMemory.confirmDeleteTitle\")}\n        message={t(\"workspace.dailyMemory.confirmDeleteMessage\", {\n          date: deletingFile?.replace(\".md\", \"\") ?? \"\",\n        })}\n        onConfirm={handleDelete}\n        onCancel={() => setDeletingFile(null)}\n      />\n    </>\n  );\n};\n\nexport default DailyMemoryPanel;\n"
  },
  {
    "path": "src/components/workspace/WorkspaceFileEditor.tsx",
    "content": "import React, { useState, useEffect, useCallback } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { toast } from \"sonner\";\nimport { Button } from \"@/components/ui/button\";\nimport MarkdownEditor from \"@/components/MarkdownEditor\";\nimport { FullScreenPanel } from \"@/components/common/FullScreenPanel\";\nimport { workspaceApi } from \"@/lib/api/workspace\";\n\ninterface WorkspaceFileEditorProps {\n  filename: string;\n  isOpen: boolean;\n  onClose: () => void;\n}\n\nconst WorkspaceFileEditor: React.FC<WorkspaceFileEditorProps> = ({\n  filename,\n  isOpen,\n  onClose,\n}) => {\n  const { t } = useTranslation();\n  const [content, setContent] = useState(\"\");\n  const [loading, setLoading] = useState(true);\n  const [saving, setSaving] = useState(false);\n  const [isDarkMode, setIsDarkMode] = useState(false);\n\n  useEffect(() => {\n    setIsDarkMode(document.documentElement.classList.contains(\"dark\"));\n    const observer = new MutationObserver(() => {\n      setIsDarkMode(document.documentElement.classList.contains(\"dark\"));\n    });\n    observer.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n    return () => observer.disconnect();\n  }, []);\n\n  useEffect(() => {\n    if (!isOpen || !filename) return;\n\n    setLoading(true);\n    workspaceApi\n      .readFile(filename)\n      .then((data) => {\n        setContent(data ?? \"\");\n      })\n      .catch((err) => {\n        console.error(\"Failed to read workspace file:\", err);\n        toast.error(t(\"workspace.loadFailed\"));\n      })\n      .finally(() => setLoading(false));\n  }, [isOpen, filename, t]);\n\n  const handleSave = useCallback(async () => {\n    setSaving(true);\n    try {\n      await workspaceApi.writeFile(filename, content);\n      toast.success(t(\"workspace.saveSuccess\"));\n    } catch (err) {\n      console.error(\"Failed to save workspace file:\", err);\n      toast.error(t(\"workspace.saveFailed\"));\n    } finally {\n      setSaving(false);\n    }\n  }, [filename, content, t]);\n\n  return (\n    <FullScreenPanel\n      isOpen={isOpen}\n      title={t(\"workspace.editing\", { filename })}\n      onClose={onClose}\n      footer={\n        <Button onClick={handleSave} disabled={saving || loading}>\n          {saving ? t(\"common.saving\") : t(\"common.save\")}\n        </Button>\n      }\n    >\n      {loading ? (\n        <div className=\"flex items-center justify-center h-64 text-muted-foreground\">\n          {t(\"prompts.loading\")}\n        </div>\n      ) : (\n        <MarkdownEditor\n          value={content}\n          onChange={setContent}\n          darkMode={isDarkMode}\n          placeholder={`# ${filename}\\n\\n...`}\n          minHeight=\"calc(100vh - 240px)\"\n        />\n      )}\n    </FullScreenPanel>\n  );\n};\n\nexport default WorkspaceFileEditor;\n"
  },
  {
    "path": "src/components/workspace/WorkspaceFilesPanel.tsx",
    "content": "import React, { useState, useEffect } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport {\n  FileCode,\n  Heart,\n  User,\n  IdCard,\n  Wrench,\n  Brain,\n  Activity,\n  Rocket,\n  Power,\n  CheckCircle2,\n  Circle,\n  Calendar,\n  ChevronRight,\n  FolderOpen,\n} from \"lucide-react\";\nimport type { LucideIcon } from \"lucide-react\";\nimport { workspaceApi } from \"@/lib/api/workspace\";\nimport WorkspaceFileEditor from \"./WorkspaceFileEditor\";\nimport DailyMemoryPanel from \"./DailyMemoryPanel\";\n\ninterface WorkspaceFile {\n  filename: string;\n  icon: LucideIcon;\n  descKey: string;\n}\n\nconst WORKSPACE_FILES: WorkspaceFile[] = [\n  { filename: \"AGENTS.md\", icon: FileCode, descKey: \"workspace.files.agents\" },\n  { filename: \"SOUL.md\", icon: Heart, descKey: \"workspace.files.soul\" },\n  { filename: \"USER.md\", icon: User, descKey: \"workspace.files.user\" },\n  {\n    filename: \"IDENTITY.md\",\n    icon: IdCard,\n    descKey: \"workspace.files.identity\",\n  },\n  { filename: \"TOOLS.md\", icon: Wrench, descKey: \"workspace.files.tools\" },\n  { filename: \"MEMORY.md\", icon: Brain, descKey: \"workspace.files.memory\" },\n  {\n    filename: \"HEARTBEAT.md\",\n    icon: Activity,\n    descKey: \"workspace.files.heartbeat\",\n  },\n  {\n    filename: \"BOOTSTRAP.md\",\n    icon: Rocket,\n    descKey: \"workspace.files.bootstrap\",\n  },\n  { filename: \"BOOT.md\", icon: Power, descKey: \"workspace.files.boot\" },\n];\n\nconst WorkspaceFilesPanel: React.FC = () => {\n  const { t } = useTranslation();\n  const [editingFile, setEditingFile] = useState<string | null>(null);\n  const [fileExists, setFileExists] = useState<Record<string, boolean>>({});\n  const [showDailyMemory, setShowDailyMemory] = useState(false);\n\n  const checkFileExistence = async () => {\n    const results: Record<string, boolean> = {};\n    await Promise.all(\n      WORKSPACE_FILES.map(async (f) => {\n        try {\n          const content = await workspaceApi.readFile(f.filename);\n          results[f.filename] = content !== null;\n        } catch {\n          results[f.filename] = false;\n        }\n      }),\n    );\n    setFileExists(results);\n  };\n\n  useEffect(() => {\n    void checkFileExistence();\n  }, []);\n\n  const handleEditorClose = () => {\n    setEditingFile(null);\n    // Re-check file existence after closing editor (file may have been created)\n    void checkFileExistence();\n  };\n\n  return (\n    <div className=\"px-6 pt-4 pb-8\">\n      <p\n        className=\"text-sm text-muted-foreground mb-6 cursor-pointer hover:text-foreground transition-colors inline-flex items-center gap-1\"\n        onClick={() => workspaceApi.openDirectory(\"workspace\")}\n        title={t(\"workspace.openDirectory\")}\n      >\n        ~/.openclaw/workspace/\n        <FolderOpen className=\"w-3.5 h-3.5\" />\n      </p>\n\n      <div className=\"grid grid-cols-1 sm:grid-cols-2 gap-3\">\n        {WORKSPACE_FILES.map((file) => {\n          const Icon = file.icon;\n          const exists = fileExists[file.filename];\n\n          return (\n            <button\n              key={file.filename}\n              onClick={() => setEditingFile(file.filename)}\n              className=\"flex items-start gap-3 p-4 rounded-xl border border-border bg-card hover:bg-accent/50 transition-colors text-left group\"\n            >\n              <div className=\"mt-0.5 text-muted-foreground group-hover:text-foreground transition-colors\">\n                <Icon className=\"w-5 h-5\" />\n              </div>\n              <div className=\"flex-1 min-w-0\">\n                <div className=\"flex items-center gap-2\">\n                  <span className=\"font-medium text-sm text-foreground\">\n                    {file.filename}\n                  </span>\n                  {exists ? (\n                    <CheckCircle2 className=\"w-3.5 h-3.5 text-emerald-500 flex-shrink-0\" />\n                  ) : (\n                    <Circle className=\"w-3.5 h-3.5 text-muted-foreground/40 flex-shrink-0\" />\n                  )}\n                </div>\n                <p className=\"text-xs text-muted-foreground mt-0.5\">\n                  {t(file.descKey)}\n                </p>\n              </div>\n            </button>\n          );\n        })}\n\n        {/* Daily Memory — inline with workspace files */}\n        <button\n          onClick={() => setShowDailyMemory(true)}\n          className=\"flex items-start gap-3 p-4 rounded-xl border border-border bg-card hover:bg-accent/50 transition-colors text-left group\"\n        >\n          <div className=\"mt-0.5 text-muted-foreground group-hover:text-foreground transition-colors\">\n            <Calendar className=\"w-5 h-5\" />\n          </div>\n          <div className=\"flex-1 min-w-0\">\n            <span className=\"font-medium text-sm text-foreground\">\n              {t(\"workspace.dailyMemory.cardTitle\")}\n            </span>\n            <p className=\"text-xs text-muted-foreground mt-0.5\">\n              {t(\"workspace.dailyMemory.cardDescription\")}\n            </p>\n          </div>\n          <div className=\"mt-0.5 text-muted-foreground group-hover:text-foreground transition-colors\">\n            <ChevronRight className=\"w-4 h-4\" />\n          </div>\n        </button>\n      </div>\n\n      <WorkspaceFileEditor\n        filename={editingFile ?? \"\"}\n        isOpen={!!editingFile}\n        onClose={handleEditorClose}\n      />\n\n      <DailyMemoryPanel\n        isOpen={showDailyMemory}\n        onClose={() => setShowDailyMemory(false)}\n      />\n    </div>\n  );\n};\n\nexport default WorkspaceFilesPanel;\n"
  },
  {
    "path": "src/config/appConfig.tsx",
    "content": "import React from \"react\";\nimport type { AppId } from \"@/lib/api/types\";\nimport {\n  ClaudeIcon,\n  CodexIcon,\n  GeminiIcon,\n  OpenClawIcon,\n} from \"@/components/BrandIcons\";\nimport { ProviderIcon } from \"@/components/ProviderIcon\";\n\nexport interface AppConfig {\n  label: string;\n  icon: React.ReactNode;\n  activeClass: string;\n  badgeClass: string;\n}\n\nexport const APP_IDS: AppId[] = [\n  \"claude\",\n  \"codex\",\n  \"gemini\",\n  \"opencode\",\n  \"openclaw\",\n];\n\n/** App IDs shown in MCP & Skills panels (excludes OpenClaw) */\nexport const MCP_SKILLS_APP_IDS: AppId[] = [\n  \"claude\",\n  \"codex\",\n  \"gemini\",\n  \"opencode\",\n];\n\nexport const APP_ICON_MAP: Record<AppId, AppConfig> = {\n  claude: {\n    label: \"Claude\",\n    icon: <ClaudeIcon size={14} />,\n    activeClass:\n      \"bg-orange-500/10 ring-1 ring-orange-500/20 hover:bg-orange-500/20 text-orange-600 dark:text-orange-400\",\n    badgeClass:\n      \"bg-orange-500/10 text-orange-700 dark:text-orange-300 hover:bg-orange-500/20 border-0 gap-1.5\",\n  },\n  codex: {\n    label: \"Codex\",\n    icon: <CodexIcon size={14} />,\n    activeClass:\n      \"bg-green-500/10 ring-1 ring-green-500/20 hover:bg-green-500/20 text-green-600 dark:text-green-400\",\n    badgeClass:\n      \"bg-green-500/10 text-green-700 dark:text-green-300 hover:bg-green-500/20 border-0 gap-1.5\",\n  },\n  gemini: {\n    label: \"Gemini\",\n    icon: <GeminiIcon size={14} />,\n    activeClass:\n      \"bg-blue-500/10 ring-1 ring-blue-500/20 hover:bg-blue-500/20 text-blue-600 dark:text-blue-400\",\n    badgeClass:\n      \"bg-blue-500/10 text-blue-700 dark:text-blue-300 hover:bg-blue-500/20 border-0 gap-1.5\",\n  },\n  opencode: {\n    label: \"OpenCode\",\n    icon: (\n      <ProviderIcon\n        icon=\"opencode\"\n        name=\"OpenCode\"\n        size={14}\n        showFallback={false}\n      />\n    ),\n    activeClass:\n      \"bg-indigo-500/10 ring-1 ring-indigo-500/20 hover:bg-indigo-500/20 text-indigo-600 dark:text-indigo-400\",\n    badgeClass:\n      \"bg-indigo-500/10 text-indigo-700 dark:text-indigo-300 hover:bg-indigo-500/20 border-0 gap-1.5\",\n  },\n  openclaw: {\n    label: \"OpenClaw\",\n    icon: <OpenClawIcon size={14} />,\n    activeClass:\n      \"bg-rose-500/10 ring-1 ring-rose-500/20 hover:bg-rose-500/20 text-rose-600 dark:text-rose-400\",\n    badgeClass:\n      \"bg-rose-500/10 text-rose-700 dark:text-rose-300 hover:bg-rose-500/20 border-0 gap-1.5\",\n  },\n};\n"
  },
  {
    "path": "src/config/claudeProviderPresets.ts",
    "content": "/**\n * 预设供应商配置模板\n */\nimport { ProviderCategory } from \"../types\";\n\nexport interface TemplateValueConfig {\n  label: string;\n  placeholder: string;\n  defaultValue?: string;\n  editorValue: string;\n}\n\n/**\n * 预设供应商的视觉主题配置\n */\nexport interface PresetTheme {\n  /** 图标类型：'claude' | 'codex' | 'gemini' | 'generic' */\n  icon?: \"claude\" | \"codex\" | \"gemini\" | \"generic\";\n  /** 背景色（选中状态），支持 Tailwind 类名或 hex 颜色 */\n  backgroundColor?: string;\n  /** 文字色（选中状态），支持 Tailwind 类名或 hex 颜色 */\n  textColor?: string;\n}\n\nexport interface ProviderPreset {\n  name: string;\n  nameKey?: string; // i18n key for localized display name\n  websiteUrl: string;\n  // 新增：第三方/聚合等可单独配置获取 API Key 的链接\n  apiKeyUrl?: string;\n  settingsConfig: object;\n  isOfficial?: boolean; // 标识是否为官方预设\n  isPartner?: boolean; // 标识是否为商业合作伙伴\n  partnerPromotionKey?: string; // 合作伙伴促销信息的 i18n key\n  category?: ProviderCategory; // 新增：分类\n  // 新增：指定该预设所使用的 API Key 字段名（默认 ANTHROPIC_AUTH_TOKEN）\n  apiKeyField?: \"ANTHROPIC_AUTH_TOKEN\" | \"ANTHROPIC_API_KEY\";\n  // 新增：模板变量定义，用于动态替换配置中的值\n  templateValues?: Record<string, TemplateValueConfig>; // editorValue 存储编辑器中的实时输入值\n  // 新增：请求地址候选列表（用于地址管理/测速）\n  endpointCandidates?: string[];\n  // 新增：视觉主题配置\n  theme?: PresetTheme;\n  // 图标配置\n  icon?: string; // 图标名称\n  iconColor?: string; // 图标颜色\n\n  // Claude API 格式（仅 Claude 供应商使用）\n  // - \"anthropic\" (默认): Anthropic Messages API 格式，直接透传\n  // - \"openai_chat\": OpenAI Chat Completions 格式，需要格式转换\n  // - \"openai_responses\": OpenAI Responses API 格式，需要格式转换\n  apiFormat?: \"anthropic\" | \"openai_chat\" | \"openai_responses\";\n\n  // 供应商类型标识（用于特殊供应商检测）\n  // - \"github_copilot\": GitHub Copilot 供应商（需要 OAuth 认证）\n  providerType?: \"github_copilot\";\n\n  // 是否需要 OAuth 认证（而非 API Key）\n  requiresOAuth?: boolean;\n}\n\nexport const providerPresets: ProviderPreset[] = [\n  {\n    name: \"Claude Official\",\n    websiteUrl: \"https://www.anthropic.com/claude-code\",\n    settingsConfig: {\n      env: {},\n    },\n    isOfficial: true, // 明确标识为官方预设\n    category: \"official\",\n    theme: {\n      icon: \"claude\",\n      backgroundColor: \"#D97757\",\n      textColor: \"#FFFFFF\",\n    },\n    icon: \"anthropic\",\n    iconColor: \"#D4915D\",\n  },\n  {\n    name: \"DeepSeek\",\n    websiteUrl: \"https://platform.deepseek.com\",\n    settingsConfig: {\n      env: {\n        ANTHROPIC_BASE_URL: \"https://api.deepseek.com/anthropic\",\n        ANTHROPIC_AUTH_TOKEN: \"\",\n        ANTHROPIC_MODEL: \"DeepSeek-V3.2\",\n        ANTHROPIC_DEFAULT_HAIKU_MODEL: \"DeepSeek-V3.2\",\n        ANTHROPIC_DEFAULT_SONNET_MODEL: \"DeepSeek-V3.2\",\n        ANTHROPIC_DEFAULT_OPUS_MODEL: \"DeepSeek-V3.2\",\n      },\n    },\n    category: \"cn_official\",\n    icon: \"deepseek\",\n    iconColor: \"#1E88E5\",\n  },\n  {\n    name: \"Zhipu GLM\",\n    websiteUrl: \"https://open.bigmodel.cn\",\n    apiKeyUrl: \"https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII\",\n    settingsConfig: {\n      env: {\n        ANTHROPIC_BASE_URL: \"https://open.bigmodel.cn/api/anthropic\",\n        ANTHROPIC_AUTH_TOKEN: \"\",\n        ANTHROPIC_MODEL: \"glm-5\",\n        ANTHROPIC_DEFAULT_HAIKU_MODEL: \"glm-5\",\n        ANTHROPIC_DEFAULT_SONNET_MODEL: \"glm-5\",\n        ANTHROPIC_DEFAULT_OPUS_MODEL: \"glm-5\",\n      },\n    },\n    category: \"cn_official\",\n    icon: \"zhipu\",\n    iconColor: \"#0F62FE\",\n  },\n  {\n    name: \"Zhipu GLM en\",\n    websiteUrl: \"https://z.ai\",\n    apiKeyUrl: \"https://z.ai/subscribe?ic=8JVLJQFSKB\",\n    settingsConfig: {\n      env: {\n        ANTHROPIC_BASE_URL: \"https://api.z.ai/api/anthropic\",\n        ANTHROPIC_AUTH_TOKEN: \"\",\n        ANTHROPIC_MODEL: \"glm-5\",\n        ANTHROPIC_DEFAULT_HAIKU_MODEL: \"glm-5\",\n        ANTHROPIC_DEFAULT_SONNET_MODEL: \"glm-5\",\n        ANTHROPIC_DEFAULT_OPUS_MODEL: \"glm-5\",\n      },\n    },\n    category: \"cn_official\",\n    icon: \"zhipu\",\n    iconColor: \"#0F62FE\",\n  },\n  {\n    name: \"Bailian\",\n    websiteUrl: \"https://bailian.console.aliyun.com\",\n    settingsConfig: {\n      env: {\n        ANTHROPIC_BASE_URL: \"https://dashscope.aliyuncs.com/apps/anthropic\",\n        ANTHROPIC_AUTH_TOKEN: \"\",\n      },\n    },\n    category: \"cn_official\",\n    icon: \"bailian\",\n    iconColor: \"#624AFF\",\n  },\n  {\n    name: \"Bailian For Coding\",\n    websiteUrl: \"https://bailian.console.aliyun.com\",\n    settingsConfig: {\n      env: {\n        ANTHROPIC_BASE_URL:\n          \"https://coding.dashscope.aliyuncs.com/apps/anthropic\",\n        ANTHROPIC_AUTH_TOKEN: \"\",\n      },\n    },\n    category: \"cn_official\",\n    icon: \"bailian\",\n    iconColor: \"#624AFF\",\n  },\n  {\n    name: \"Kimi\",\n    websiteUrl: \"https://platform.moonshot.cn/console\",\n    settingsConfig: {\n      env: {\n        ANTHROPIC_BASE_URL: \"https://api.moonshot.cn/anthropic\",\n        ANTHROPIC_AUTH_TOKEN: \"\",\n        ANTHROPIC_MODEL: \"kimi-k2.5\",\n        ANTHROPIC_DEFAULT_HAIKU_MODEL: \"kimi-k2.5\",\n        ANTHROPIC_DEFAULT_SONNET_MODEL: \"kimi-k2.5\",\n        ANTHROPIC_DEFAULT_OPUS_MODEL: \"kimi-k2.5\",\n      },\n    },\n    category: \"cn_official\",\n    icon: \"kimi\",\n    iconColor: \"#6366F1\",\n  },\n  {\n    name: \"Kimi For Coding\",\n    websiteUrl: \"https://www.kimi.com/coding/docs/\",\n    settingsConfig: {\n      env: {\n        ANTHROPIC_BASE_URL: \"https://api.kimi.com/coding/\",\n        ANTHROPIC_AUTH_TOKEN: \"\",\n      },\n    },\n    category: \"cn_official\",\n    icon: \"kimi\",\n    iconColor: \"#6366F1\",\n  },\n  {\n    name: \"StepFun\",\n    websiteUrl: \"https://platform.stepfun.ai\",\n    apiKeyUrl: \"https://platform.stepfun.ai/interface-key\",\n    settingsConfig: {\n      env: {\n        ANTHROPIC_BASE_URL: \"https://api.stepfun.ai/v1\",\n        ANTHROPIC_AUTH_TOKEN: \"\",\n        ANTHROPIC_MODEL: \"step-3.5-flash\",\n        ANTHROPIC_DEFAULT_HAIKU_MODEL: \"step-3.5-flash\",\n        ANTHROPIC_DEFAULT_SONNET_MODEL: \"step-3.5-flash\",\n        ANTHROPIC_DEFAULT_OPUS_MODEL: \"step-3.5-flash\",\n      },\n    },\n    category: \"cn_official\",\n    endpointCandidates: [\"https://api.stepfun.ai/v1\"],\n    apiFormat: \"openai_chat\",\n    icon: \"stepfun\",\n    iconColor: \"#005AFF\",\n  },\n  {\n    name: \"ModelScope\",\n    websiteUrl: \"https://modelscope.cn\",\n    settingsConfig: {\n      env: {\n        ANTHROPIC_BASE_URL: \"https://api-inference.modelscope.cn\",\n        ANTHROPIC_AUTH_TOKEN: \"\",\n        ANTHROPIC_MODEL: \"ZhipuAI/GLM-5\",\n        ANTHROPIC_DEFAULT_HAIKU_MODEL: \"ZhipuAI/GLM-5\",\n        ANTHROPIC_DEFAULT_SONNET_MODEL: \"ZhipuAI/GLM-5\",\n        ANTHROPIC_DEFAULT_OPUS_MODEL: \"ZhipuAI/GLM-5\",\n      },\n    },\n    category: \"aggregator\",\n    icon: \"modelscope\",\n    iconColor: \"#624AFF\",\n  },\n  {\n    name: \"KAT-Coder\",\n    websiteUrl: \"https://console.streamlake.ai\",\n    apiKeyUrl: \"https://console.streamlake.ai/console/api-key\",\n    settingsConfig: {\n      env: {\n        ANTHROPIC_BASE_URL:\n          \"https://vanchin.streamlake.ai/api/gateway/v1/endpoints/${ENDPOINT_ID}/claude-code-proxy\",\n        ANTHROPIC_AUTH_TOKEN: \"\",\n        ANTHROPIC_MODEL: \"KAT-Coder-Pro V1\",\n        ANTHROPIC_DEFAULT_HAIKU_MODEL: \"KAT-Coder-Air V1\",\n        ANTHROPIC_DEFAULT_SONNET_MODEL: \"KAT-Coder-Pro V1\",\n        ANTHROPIC_DEFAULT_OPUS_MODEL: \"KAT-Coder-Pro V1\",\n      },\n    },\n    category: \"cn_official\",\n    templateValues: {\n      ENDPOINT_ID: {\n        label: \"Vanchin Endpoint ID\",\n        placeholder: \"ep-xxx-xxx\",\n        defaultValue: \"\",\n        editorValue: \"\",\n      },\n    },\n    icon: \"catcoder\",\n  },\n  {\n    name: \"Longcat\",\n    websiteUrl: \"https://longcat.chat/platform\",\n    apiKeyUrl: \"https://longcat.chat/platform/api_keys\",\n    settingsConfig: {\n      env: {\n        ANTHROPIC_BASE_URL: \"https://api.longcat.chat/anthropic\",\n        ANTHROPIC_AUTH_TOKEN: \"\",\n        ANTHROPIC_MODEL: \"LongCat-Flash-Chat\",\n        ANTHROPIC_DEFAULT_HAIKU_MODEL: \"LongCat-Flash-Chat\",\n        ANTHROPIC_DEFAULT_SONNET_MODEL: \"LongCat-Flash-Chat\",\n        ANTHROPIC_DEFAULT_OPUS_MODEL: \"LongCat-Flash-Chat\",\n        CLAUDE_CODE_MAX_OUTPUT_TOKENS: \"6000\",\n        CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: 1,\n      },\n    },\n    category: \"cn_official\",\n    icon: \"longcat\",\n    iconColor: \"#29E154\",\n  },\n  {\n    name: \"MiniMax\",\n    websiteUrl: \"https://platform.minimaxi.com\",\n    apiKeyUrl: \"https://platform.minimaxi.com/subscribe/coding-plan\",\n    settingsConfig: {\n      env: {\n        ANTHROPIC_BASE_URL: \"https://api.minimaxi.com/anthropic\",\n        ANTHROPIC_AUTH_TOKEN: \"\",\n        API_TIMEOUT_MS: \"3000000\",\n        CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: 1,\n        ANTHROPIC_MODEL: \"MiniMax-M2.5\",\n        ANTHROPIC_DEFAULT_SONNET_MODEL: \"MiniMax-M2.5\",\n        ANTHROPIC_DEFAULT_OPUS_MODEL: \"MiniMax-M2.5\",\n        ANTHROPIC_DEFAULT_HAIKU_MODEL: \"MiniMax-M2.5\",\n      },\n    },\n    category: \"cn_official\",\n    isPartner: true,\n    partnerPromotionKey: \"minimax_cn\",\n    theme: {\n      backgroundColor: \"#f64551\",\n      textColor: \"#FFFFFF\",\n    },\n    icon: \"minimax\",\n    iconColor: \"#FF6B6B\",\n  },\n  {\n    name: \"MiniMax en\",\n    websiteUrl: \"https://platform.minimax.io\",\n    apiKeyUrl: \"https://platform.minimax.io/subscribe/coding-plan\",\n    settingsConfig: {\n      env: {\n        ANTHROPIC_BASE_URL: \"https://api.minimax.io/anthropic\",\n        ANTHROPIC_AUTH_TOKEN: \"\",\n        API_TIMEOUT_MS: \"3000000\",\n        CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: 1,\n        ANTHROPIC_MODEL: \"MiniMax-M2.5\",\n        ANTHROPIC_DEFAULT_SONNET_MODEL: \"MiniMax-M2.5\",\n        ANTHROPIC_DEFAULT_OPUS_MODEL: \"MiniMax-M2.5\",\n        ANTHROPIC_DEFAULT_HAIKU_MODEL: \"MiniMax-M2.5\",\n      },\n    },\n    category: \"cn_official\",\n    isPartner: true,\n    partnerPromotionKey: \"minimax_en\",\n    theme: {\n      backgroundColor: \"#f64551\",\n      textColor: \"#FFFFFF\",\n    },\n    icon: \"minimax\",\n    iconColor: \"#FF6B6B\",\n  },\n  {\n    name: \"DouBaoSeed\",\n    websiteUrl: \"https://www.volcengine.com/product/doubao\",\n    apiKeyUrl: \"https://www.volcengine.com/product/doubao\",\n    settingsConfig: {\n      env: {\n        ANTHROPIC_BASE_URL: \"https://ark.cn-beijing.volces.com/api/coding\",\n        ANTHROPIC_AUTH_TOKEN: \"\",\n        API_TIMEOUT_MS: \"3000000\",\n        ANTHROPIC_MODEL: \"doubao-seed-2-0-code-preview-latest\",\n        ANTHROPIC_DEFAULT_SONNET_MODEL: \"doubao-seed-2-0-code-preview-latest\",\n        ANTHROPIC_DEFAULT_OPUS_MODEL: \"doubao-seed-2-0-code-preview-latest\",\n        ANTHROPIC_DEFAULT_HAIKU_MODEL: \"doubao-seed-2-0-code-preview-latest\",\n      },\n    },\n    category: \"cn_official\",\n    icon: \"doubao\",\n    iconColor: \"#3370FF\",\n  },\n  {\n    name: \"BaiLing\",\n    websiteUrl: \"https://alipaytbox.yuque.com/sxs0ba/ling/get_started\",\n    settingsConfig: {\n      env: {\n        ANTHROPIC_BASE_URL: \"https://api.tbox.cn/api/anthropic\",\n        ANTHROPIC_AUTH_TOKEN: \"\",\n        ANTHROPIC_MODEL: \"Ling-2.5-1T\",\n        ANTHROPIC_DEFAULT_HAIKU_MODEL: \"Ling-2.5-1T\",\n        ANTHROPIC_DEFAULT_SONNET_MODEL: \"Ling-2.5-1T\",\n        ANTHROPIC_DEFAULT_OPUS_MODEL: \"Ling-2.5-1T\",\n      },\n    },\n    category: \"cn_official\",\n  },\n  {\n    name: \"AiHubMix\",\n    websiteUrl: \"https://aihubmix.com\",\n    apiKeyUrl: \"https://aihubmix.com\",\n    // 说明：该供应商使用 ANTHROPIC_API_KEY（而非 ANTHROPIC_AUTH_TOKEN）\n    apiKeyField: \"ANTHROPIC_API_KEY\",\n    settingsConfig: {\n      env: {\n        ANTHROPIC_BASE_URL: \"https://aihubmix.com\",\n        ANTHROPIC_API_KEY: \"\",\n      },\n    },\n    // 请求地址候选（用于地址管理/测速），用户可自行选择/覆盖\n    endpointCandidates: [\"https://aihubmix.com\", \"https://api.aihubmix.com\"],\n    category: \"aggregator\",\n    icon: \"aihubmix\",\n    iconColor: \"#006FFB\",\n  },\n  {\n    name: \"SiliconFlow\",\n    websiteUrl: \"https://siliconflow.cn\",\n    apiKeyUrl: \"https://cloud.siliconflow.cn/i/drGuwc9k\",\n    settingsConfig: {\n      env: {\n        ANTHROPIC_BASE_URL: \"https://api.siliconflow.cn\",\n        ANTHROPIC_AUTH_TOKEN: \"\",\n        ANTHROPIC_MODEL: \"Pro/MiniMaxAI/MiniMax-M2.5\",\n        ANTHROPIC_DEFAULT_HAIKU_MODEL: \"Pro/MiniMaxAI/MiniMax-M2.5\",\n        ANTHROPIC_DEFAULT_SONNET_MODEL: \"Pro/MiniMaxAI/MiniMax-M2.5\",\n        ANTHROPIC_DEFAULT_OPUS_MODEL: \"Pro/MiniMaxAI/MiniMax-M2.5\",\n      },\n    },\n    category: \"aggregator\",\n    isPartner: true,\n    partnerPromotionKey: \"siliconflow\",\n    icon: \"siliconflow\",\n    iconColor: \"#6E29F6\",\n  },\n  {\n    name: \"SiliconFlow en\",\n    websiteUrl: \"https://siliconflow.com\",\n    apiKeyUrl: \"https://cloud.siliconflow.cn/i/drGuwc9k\",\n    settingsConfig: {\n      env: {\n        ANTHROPIC_BASE_URL: \"https://api.siliconflow.com\",\n        ANTHROPIC_AUTH_TOKEN: \"\",\n        ANTHROPIC_MODEL: \"MiniMaxAI/MiniMax-M2.5\",\n        ANTHROPIC_DEFAULT_HAIKU_MODEL: \"MiniMaxAI/MiniMax-M2.5\",\n        ANTHROPIC_DEFAULT_SONNET_MODEL: \"MiniMaxAI/MiniMax-M2.5\",\n        ANTHROPIC_DEFAULT_OPUS_MODEL: \"MiniMaxAI/MiniMax-M2.5\",\n      },\n    },\n    category: \"aggregator\",\n    isPartner: true,\n    partnerPromotionKey: \"siliconflow\",\n    icon: \"siliconflow\",\n    iconColor: \"#000000\",\n  },\n  {\n    name: \"DMXAPI\",\n    websiteUrl: \"https://www.dmxapi.cn\",\n    apiKeyUrl: \"https://www.dmxapi.cn\",\n    settingsConfig: {\n      env: {\n        ANTHROPIC_BASE_URL: \"https://www.dmxapi.cn\",\n        ANTHROPIC_AUTH_TOKEN: \"\",\n      },\n    },\n    // 请求地址候选（用于地址管理/测速），用户可自行选择/覆盖\n    endpointCandidates: [\"https://www.dmxapi.cn\", \"https://api.dmxapi.cn\"],\n    category: \"aggregator\",\n    isPartner: true, // 合作伙伴\n    partnerPromotionKey: \"dmxapi\", // 促销信息 i18n key\n  },\n  {\n    name: \"PackyCode\",\n    websiteUrl: \"https://www.packyapi.com\",\n    apiKeyUrl: \"https://www.packyapi.com/register?aff=cc-switch\",\n    settingsConfig: {\n      env: {\n        ANTHROPIC_BASE_URL: \"https://www.packyapi.com\",\n        ANTHROPIC_AUTH_TOKEN: \"\",\n      },\n    },\n    // 请求地址候选（用于地址管理/测速）\n    endpointCandidates: [\n      \"https://www.packyapi.com\",\n      \"https://api-slb.packyapi.com\",\n    ],\n    category: \"third_party\",\n    isPartner: true, // 合作伙伴\n    partnerPromotionKey: \"packycode\", // 促销信息 i18n key\n    icon: \"packycode\",\n  },\n  {\n    name: \"Cubence\",\n    websiteUrl: \"https://cubence.com\",\n    apiKeyUrl: \"https://cubence.com/signup?code=CCSWITCH&source=ccs\",\n    settingsConfig: {\n      env: {\n        ANTHROPIC_BASE_URL: \"https://api.cubence.com\",\n        ANTHROPIC_AUTH_TOKEN: \"\",\n      },\n    },\n    endpointCandidates: [\n      \"https://api.cubence.com\",\n      \"https://api-cf.cubence.com\",\n      \"https://api-dmit.cubence.com\",\n      \"https://api-bwg.cubence.com\",\n    ],\n    category: \"third_party\",\n    isPartner: true, // 合作伙伴\n    partnerPromotionKey: \"cubence\", // 促销信息 i18n key\n    icon: \"cubence\",\n    iconColor: \"#000000\",\n  },\n  {\n    name: \"AIGoCode\",\n    websiteUrl: \"https://aigocode.com\",\n    apiKeyUrl: \"https://aigocode.com/invite/CC-SWITCH\",\n    settingsConfig: {\n      env: {\n        ANTHROPIC_BASE_URL: \"https://api.aigocode.com\",\n        ANTHROPIC_AUTH_TOKEN: \"\",\n      },\n    },\n    // 请求地址候选（用于地址管理/测速）\n    endpointCandidates: [\"https://api.aigocode.com\"],\n    category: \"third_party\",\n    isPartner: true, // 合作伙伴\n    partnerPromotionKey: \"aigocode\", // 促销信息 i18n key\n    icon: \"aigocode\",\n    iconColor: \"#5B7FFF\",\n  },\n  {\n    name: \"RightCode\",\n    websiteUrl: \"https://www.right.codes\",\n    apiKeyUrl: \"https://www.right.codes/register?aff=CCSWITCH\",\n    settingsConfig: {\n      env: {\n        ANTHROPIC_BASE_URL: \"https://www.right.codes/claude\",\n        ANTHROPIC_AUTH_TOKEN: \"\",\n      },\n    },\n    category: \"third_party\",\n    isPartner: true,\n    partnerPromotionKey: \"rightcode\",\n    icon: \"rc\",\n    iconColor: \"#E96B2C\",\n  },\n  {\n    name: \"AICodeMirror\",\n    websiteUrl: \"https://www.aicodemirror.com\",\n    apiKeyUrl: \"https://www.aicodemirror.com/register?invitecode=9915W3\",\n    settingsConfig: {\n      env: {\n        ANTHROPIC_BASE_URL: \"https://api.aicodemirror.com/api/claudecode\",\n        ANTHROPIC_AUTH_TOKEN: \"\",\n      },\n    },\n    endpointCandidates: [\n      \"https://api.aicodemirror.com/api/claudecode\",\n      \"https://api.claudecode.net.cn/api/claudecode\",\n    ],\n    category: \"third_party\",\n    isPartner: true, // 合作伙伴\n    partnerPromotionKey: \"aicodemirror\", // 促销信息 i18n key\n    icon: \"aicodemirror\",\n    iconColor: \"#000000\",\n  },\n  {\n    name: \"AICoding\",\n    websiteUrl: \"https://aicoding.sh\",\n    apiKeyUrl: \"https://aicoding.sh/i/CCSWITCH\",\n    settingsConfig: {\n      env: {\n        ANTHROPIC_BASE_URL: \"https://api.aicoding.sh\",\n        ANTHROPIC_AUTH_TOKEN: \"\",\n      },\n    },\n    endpointCandidates: [\"https://api.aicoding.sh\"],\n    category: \"third_party\",\n    isPartner: true, // 合作伙伴\n    partnerPromotionKey: \"aicoding\", // 促销信息 i18n key\n    icon: \"aicoding\",\n    iconColor: \"#000000\",\n  },\n  {\n    name: \"CrazyRouter\",\n    websiteUrl: \"https://www.crazyrouter.com\",\n    apiKeyUrl: \"https://www.crazyrouter.com/register?aff=OZcm&ref=cc-switch\",\n    settingsConfig: {\n      env: {\n        ANTHROPIC_BASE_URL: \"https://crazyrouter.com\",\n        ANTHROPIC_AUTH_TOKEN: \"\",\n      },\n    },\n    endpointCandidates: [\"https://crazyrouter.com\"],\n    category: \"third_party\",\n    isPartner: true, // 合作伙伴\n    partnerPromotionKey: \"crazyrouter\", // 促销信息 i18n key\n    icon: \"crazyrouter\",\n    iconColor: \"#000000\",\n  },\n  {\n    name: \"SSSAiCode\",\n    websiteUrl: \"https://www.sssaicode.com\",\n    apiKeyUrl: \"https://www.sssaicode.com/register?ref=DCP0SM\",\n    settingsConfig: {\n      env: {\n        ANTHROPIC_BASE_URL: \"https://node-hk.sssaicode.com/api\",\n        ANTHROPIC_AUTH_TOKEN: \"\",\n      },\n    },\n    endpointCandidates: [\n      \"https://node-hk.sssaicode.com/api\",\n      \"https://claude2.sssaicode.com/api\",\n      \"https://anti.sssaicode.com/api\",\n    ],\n    category: \"third_party\",\n    isPartner: true, // 合作伙伴\n    partnerPromotionKey: \"sssaicode\", // 促销信息 i18n key\n    icon: \"sssaicode\",\n    iconColor: \"#000000\",\n  },\n  {\n    name: \"Compshare\",\n    nameKey: \"providerForm.presets.ucloud\",\n    websiteUrl: \"https://www.compshare.cn\",\n    apiKeyUrl:\n      \"https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch\",\n    settingsConfig: {\n      env: {\n        ANTHROPIC_BASE_URL: \"https://api.modelverse.cn\",\n        ANTHROPIC_AUTH_TOKEN: \"\",\n      },\n    },\n    endpointCandidates: [\"https://api.modelverse.cn\"],\n    category: \"aggregator\",\n    isPartner: true, // 合作伙伴\n    partnerPromotionKey: \"ucloud\", // 促销信息 i18n key\n    icon: \"ucloud\",\n    iconColor: \"#000000\",\n  },\n  {\n    name: \"Micu\",\n    websiteUrl: \"https://www.openclaudecode.cn\",\n    apiKeyUrl: \"https://www.openclaudecode.cn/register?aff=aOYQ\",\n    settingsConfig: {\n      env: {\n        ANTHROPIC_BASE_URL: \"https://www.openclaudecode.cn\",\n        ANTHROPIC_AUTH_TOKEN: \"\",\n      },\n    },\n    endpointCandidates: [\"https://www.openclaudecode.cn\"],\n    category: \"third_party\",\n    isPartner: true, // 合作伙伴\n    partnerPromotionKey: \"micu\", // 促销信息 i18n key\n    icon: \"micu\",\n    iconColor: \"#000000\",\n  },\n  {\n    name: \"X-Code API\",\n    websiteUrl: \"https://x-code.cc\",\n    apiKeyUrl: \"https://x-code.cc\",\n    settingsConfig: {\n      env: {\n        ANTHROPIC_BASE_URL: \"https://x-code.cc\",\n        ANTHROPIC_AUTH_TOKEN: \"\",\n      },\n    },\n    endpointCandidates: [\"https://x-code.cc\"],\n    category: \"third_party\",\n    isPartner: true, // 合作伙伴\n    partnerPromotionKey: \"x-code\", // 促销信息 i18n key\n    icon: \"x-code\",\n    iconColor: \"#000000\",\n  },\n  {\n    name: \"CTok.ai\",\n    websiteUrl: \"https://ctok.ai\",\n    apiKeyUrl: \"https://ctok.ai\",\n    settingsConfig: {\n      env: {\n        ANTHROPIC_BASE_URL: \"https://api.ctok.ai\",\n        ANTHROPIC_AUTH_TOKEN: \"\",\n      },\n    },\n    category: \"third_party\",\n    isPartner: true, // 合作伙伴\n    partnerPromotionKey: \"ctok\", // 促销信息 i18n key\n    icon: \"ctok\",\n    iconColor: \"#000000\",\n  },\n  {\n    name: \"OpenRouter\",\n    websiteUrl: \"https://openrouter.ai\",\n    apiKeyUrl: \"https://openrouter.ai/keys\",\n    settingsConfig: {\n      env: {\n        ANTHROPIC_BASE_URL: \"https://openrouter.ai/api\",\n        ANTHROPIC_AUTH_TOKEN: \"\",\n        ANTHROPIC_MODEL: \"anthropic/claude-sonnet-4.6\",\n        ANTHROPIC_DEFAULT_HAIKU_MODEL: \"anthropic/claude-haiku-4.5\",\n        ANTHROPIC_DEFAULT_SONNET_MODEL: \"anthropic/claude-sonnet-4.6\",\n        ANTHROPIC_DEFAULT_OPUS_MODEL: \"anthropic/claude-opus-4.6\",\n      },\n    },\n    category: \"aggregator\",\n    icon: \"openrouter\",\n    iconColor: \"#6566F1\",\n  },\n  {\n    name: \"Novita AI\",\n    websiteUrl: \"https://novita.ai\",\n    apiKeyUrl: \"https://novita.ai\",\n    settingsConfig: {\n      env: {\n        ANTHROPIC_BASE_URL: \"https://api.novita.ai/anthropic\",\n        ANTHROPIC_AUTH_TOKEN: \"\",\n        ANTHROPIC_MODEL: \"zai-org/glm-5\",\n        ANTHROPIC_DEFAULT_HAIKU_MODEL: \"zai-org/glm-5\",\n        ANTHROPIC_DEFAULT_SONNET_MODEL: \"zai-org/glm-5\",\n        ANTHROPIC_DEFAULT_OPUS_MODEL: \"zai-org/glm-5\",\n      },\n    },\n    category: \"aggregator\",\n    endpointCandidates: [\"https://api.novita.ai/anthropic\"],\n    icon: \"novita\",\n    iconColor: \"#000000\",\n  },\n  {\n    name: \"GitHub Copilot\",\n    websiteUrl: \"https://github.com/features/copilot\",\n    settingsConfig: {\n      env: {\n        ANTHROPIC_BASE_URL: \"https://api.githubcopilot.com\",\n        ANTHROPIC_MODEL: \"claude-opus-4.6\",\n        ANTHROPIC_DEFAULT_HAIKU_MODEL: \"claude-haiku-4.5\",\n        ANTHROPIC_DEFAULT_SONNET_MODEL: \"claude-sonnet-4.6\",\n        ANTHROPIC_DEFAULT_OPUS_MODEL: \"claude-opus-4.6\",\n      },\n    },\n    category: \"third_party\",\n    apiFormat: \"openai_chat\",\n    providerType: \"github_copilot\",\n    requiresOAuth: true,\n    icon: \"github\",\n    iconColor: \"#000000\",\n  },\n  {\n    name: \"Nvidia\",\n    websiteUrl: \"https://build.nvidia.com\",\n    apiKeyUrl: \"https://build.nvidia.com/settings/api-keys\",\n    settingsConfig: {\n      env: {\n        ANTHROPIC_BASE_URL: \"https://integrate.api.nvidia.com\",\n        ANTHROPIC_AUTH_TOKEN: \"\",\n        ANTHROPIC_MODEL: \"moonshotai/kimi-k2.5\",\n        ANTHROPIC_DEFAULT_HAIKU_MODEL: \"moonshotai/kimi-k2.5\",\n        ANTHROPIC_DEFAULT_SONNET_MODEL: \"moonshotai/kimi-k2.5\",\n        ANTHROPIC_DEFAULT_OPUS_MODEL: \"moonshotai/kimi-k2.5\",\n      },\n    },\n    category: \"aggregator\",\n    apiFormat: \"openai_chat\",\n    icon: \"nvidia\",\n    iconColor: \"#000000\",\n  },\n  {\n    name: \"Xiaomi MiMo\",\n    websiteUrl: \"https://platform.xiaomimimo.com\",\n    apiKeyUrl: \"https://platform.xiaomimimo.com/#/console/api-keys\",\n    settingsConfig: {\n      env: {\n        ANTHROPIC_BASE_URL: \"https://api.xiaomimimo.com/anthropic\",\n        ANTHROPIC_AUTH_TOKEN: \"\",\n        ANTHROPIC_MODEL: \"mimo-v2-flash\",\n        ANTHROPIC_DEFAULT_HAIKU_MODEL: \"mimo-v2-flash\",\n        ANTHROPIC_DEFAULT_SONNET_MODEL: \"mimo-v2-flash\",\n        ANTHROPIC_DEFAULT_OPUS_MODEL: \"mimo-v2-flash\",\n      },\n    },\n    category: \"cn_official\",\n    icon: \"xiaomimimo\",\n    iconColor: \"#000000\",\n  },\n  {\n    name: \"AWS Bedrock (AKSK)\",\n    websiteUrl: \"https://aws.amazon.com/bedrock/\",\n    settingsConfig: {\n      env: {\n        ANTHROPIC_BASE_URL:\n          \"https://bedrock-runtime.${AWS_REGION}.amazonaws.com\",\n        AWS_ACCESS_KEY_ID: \"${AWS_ACCESS_KEY_ID}\",\n        AWS_SECRET_ACCESS_KEY: \"${AWS_SECRET_ACCESS_KEY}\",\n        AWS_REGION: \"${AWS_REGION}\",\n        ANTHROPIC_MODEL: \"global.anthropic.claude-opus-4-6-v1\",\n        ANTHROPIC_DEFAULT_HAIKU_MODEL:\n          \"global.anthropic.claude-haiku-4-5-20251001-v1:0\",\n        ANTHROPIC_DEFAULT_SONNET_MODEL: \"global.anthropic.claude-sonnet-4-6\",\n        ANTHROPIC_DEFAULT_OPUS_MODEL: \"global.anthropic.claude-opus-4-6-v1\",\n        CLAUDE_CODE_USE_BEDROCK: \"1\",\n      },\n    },\n    category: \"cloud_provider\",\n    templateValues: {\n      AWS_REGION: {\n        label: \"AWS Region\",\n        placeholder: \"us-west-2\",\n        editorValue: \"us-west-2\",\n      },\n      AWS_ACCESS_KEY_ID: {\n        label: \"Access Key ID\",\n        placeholder: \"AKIA...\",\n        editorValue: \"\",\n      },\n      AWS_SECRET_ACCESS_KEY: {\n        label: \"Secret Access Key\",\n        placeholder: \"your-secret-key\",\n        editorValue: \"\",\n      },\n    },\n    icon: \"aws\",\n    iconColor: \"#FF9900\",\n  },\n  {\n    name: \"AWS Bedrock (API Key)\",\n    websiteUrl: \"https://aws.amazon.com/bedrock/\",\n    settingsConfig: {\n      apiKey: \"\",\n      env: {\n        ANTHROPIC_BASE_URL:\n          \"https://bedrock-runtime.${AWS_REGION}.amazonaws.com\",\n        AWS_REGION: \"${AWS_REGION}\",\n        ANTHROPIC_MODEL: \"global.anthropic.claude-opus-4-6-v1\",\n        ANTHROPIC_DEFAULT_HAIKU_MODEL:\n          \"global.anthropic.claude-haiku-4-5-20251001-v1:0\",\n        ANTHROPIC_DEFAULT_SONNET_MODEL: \"global.anthropic.claude-sonnet-4-6\",\n        ANTHROPIC_DEFAULT_OPUS_MODEL: \"global.anthropic.claude-opus-4-6-v1\",\n        CLAUDE_CODE_USE_BEDROCK: \"1\",\n      },\n    },\n    category: \"cloud_provider\",\n    templateValues: {\n      AWS_REGION: {\n        label: \"AWS Region\",\n        placeholder: \"us-west-2\",\n        editorValue: \"us-west-2\",\n      },\n    },\n    icon: \"aws\",\n    iconColor: \"#FF9900\",\n  },\n];\n"
  },
  {
    "path": "src/config/codexProviderPresets.ts",
    "content": "/**\n * Codex 预设供应商配置模板\n */\nimport { ProviderCategory } from \"../types\";\nimport type { PresetTheme } from \"./claudeProviderPresets\";\n\nexport interface CodexProviderPreset {\n  name: string;\n  nameKey?: string; // i18n key for localized display name\n  websiteUrl: string;\n  // 第三方供应商可提供单独的获取 API Key 链接\n  apiKeyUrl?: string;\n  auth: Record<string, any>; // 将写入 ~/.codex/auth.json\n  config: string; // 将写入 ~/.codex/config.toml（TOML 字符串）\n  isOfficial?: boolean; // 标识是否为官方预设\n  isPartner?: boolean; // 标识是否为商业合作伙伴\n  partnerPromotionKey?: string; // 合作伙伴促销信息的 i18n key\n  category?: ProviderCategory; // 新增：分类\n  isCustomTemplate?: boolean; // 标识是否为自定义模板\n  // 新增：请求地址候选列表（用于地址管理/测速）\n  endpointCandidates?: string[];\n  // 新增：视觉主题配置\n  theme?: PresetTheme;\n  // 图标配置\n  icon?: string; // 图标名称\n  iconColor?: string; // 图标颜色\n}\n\n/**\n * 生成第三方供应商的 auth.json\n */\nexport function generateThirdPartyAuth(apiKey: string): Record<string, any> {\n  return {\n    OPENAI_API_KEY: apiKey || \"\",\n  };\n}\n\n/**\n * 生成第三方供应商的 config.toml\n */\nexport function generateThirdPartyConfig(\n  providerName: string,\n  baseUrl: string,\n  modelName = \"gpt-5.4\",\n): string {\n  // 清理供应商名称，确保符合TOML键名规范\n  const cleanProviderName =\n    providerName\n      .toLowerCase()\n      .replace(/[^a-z0-9_]/g, \"_\")\n      .replace(/^_+|_+$/g, \"\") || \"custom\";\n\n  return `model_provider = \"${cleanProviderName}\"\nmodel = \"${modelName}\"\nmodel_reasoning_effort = \"high\"\ndisable_response_storage = true\n\n[model_providers.${cleanProviderName}]\nname = \"${cleanProviderName}\"\nbase_url = \"${baseUrl}\"\nwire_api = \"responses\"\nrequires_openai_auth = true`;\n}\n\nexport const codexProviderPresets: CodexProviderPreset[] = [\n  {\n    name: \"OpenAI Official\",\n    websiteUrl: \"https://chatgpt.com/codex\",\n    isOfficial: true,\n    category: \"official\",\n    auth: {},\n    config: ``,\n    theme: {\n      icon: \"codex\",\n      backgroundColor: \"#1F2937\", // gray-800\n      textColor: \"#FFFFFF\",\n    },\n    icon: \"openai\",\n    iconColor: \"#00A67E\",\n  },\n  {\n    name: \"Azure OpenAI\",\n    websiteUrl:\n      \"https://learn.microsoft.com/en-us/azure/ai-foundry/openai/how-to/codex\",\n    category: \"third_party\",\n    isOfficial: true,\n    auth: generateThirdPartyAuth(\"\"),\n    config: `model_provider = \"azure\"\nmodel = \"gpt-5.4\"\nmodel_reasoning_effort = \"high\"\ndisable_response_storage = true\n\n[model_providers.azure]\nname = \"Azure OpenAI\"\nbase_url = \"https://YOUR_RESOURCE_NAME.openai.azure.com/openai\"\nenv_key = \"OPENAI_API_KEY\"\nquery_params = { \"api-version\" = \"2025-04-01-preview\" }\nwire_api = \"responses\"\nrequires_openai_auth = true`,\n    endpointCandidates: [\"https://YOUR_RESOURCE_NAME.openai.azure.com/openai\"],\n    theme: {\n      icon: \"codex\",\n      backgroundColor: \"#0078D4\",\n      textColor: \"#FFFFFF\",\n    },\n    icon: \"azure\",\n    iconColor: \"#0078D4\",\n  },\n  {\n    name: \"AiHubMix\",\n    websiteUrl: \"https://aihubmix.com\",\n    category: \"aggregator\",\n    auth: generateThirdPartyAuth(\"\"),\n    config: generateThirdPartyConfig(\n      \"aihubmix\",\n      \"https://aihubmix.com/v1\",\n      \"gpt-5.4\",\n    ),\n    endpointCandidates: [\n      \"https://aihubmix.com/v1\",\n      \"https://api.aihubmix.com/v1\",\n    ],\n  },\n  {\n    name: \"DMXAPI\",\n    websiteUrl: \"https://www.dmxapi.cn\",\n    category: \"aggregator\",\n    auth: generateThirdPartyAuth(\"\"),\n    config: generateThirdPartyConfig(\n      \"dmxapi\",\n      \"https://www.dmxapi.cn/v1\",\n      \"gpt-5.4\",\n    ),\n    endpointCandidates: [\"https://www.dmxapi.cn/v1\"],\n    isPartner: true, // 合作伙伴\n    partnerPromotionKey: \"dmxapi\", // 促销信息 i18n key\n  },\n  {\n    name: \"PackyCode\",\n    websiteUrl: \"https://www.packyapi.com\",\n    apiKeyUrl: \"https://www.packyapi.com/register?aff=cc-switch\",\n    category: \"third_party\",\n    auth: generateThirdPartyAuth(\"\"),\n    config: generateThirdPartyConfig(\n      \"packycode\",\n      \"https://www.packyapi.com/v1\",\n      \"gpt-5.4\",\n    ),\n    endpointCandidates: [\n      \"https://www.packyapi.com/v1\",\n      \"https://api-slb.packyapi.com/v1\",\n    ],\n    isPartner: true, // 合作伙伴\n    partnerPromotionKey: \"packycode\", // 促销信息 i18n key\n    icon: \"packycode\",\n  },\n  {\n    name: \"Cubence\",\n    websiteUrl: \"https://cubence.com\",\n    apiKeyUrl: \"https://cubence.com/signup?code=CCSWITCH&source=ccs\",\n    auth: generateThirdPartyAuth(\"\"),\n    config: generateThirdPartyConfig(\n      \"cubence\",\n      \"https://api.cubence.com/v1\",\n      \"gpt-5.4\",\n    ),\n    endpointCandidates: [\n      \"https://api.cubence.com/v1\",\n      \"https://api-cf.cubence.com/v1\",\n      \"https://api-dmit.cubence.com/v1\",\n      \"https://api-bwg.cubence.com/v1\",\n    ],\n    category: \"third_party\",\n    isPartner: true, // 合作伙伴\n    partnerPromotionKey: \"cubence\", // 促销信息 i18n key\n    icon: \"cubence\",\n    iconColor: \"#000000\",\n  },\n  {\n    name: \"AIGoCode\",\n    websiteUrl: \"https://aigocode.com\",\n    apiKeyUrl: \"https://aigocode.com/invite/CC-SWITCH\",\n    category: \"third_party\",\n    auth: generateThirdPartyAuth(\"\"),\n    config: generateThirdPartyConfig(\n      \"aigocode\",\n      \"https://api.aigocode.com\",\n      \"gpt-5.4\",\n    ),\n    endpointCandidates: [\"https://api.aigocode.com\"],\n    isPartner: true, // 合作伙伴\n    partnerPromotionKey: \"aigocode\", // 促销信息 i18n key\n    icon: \"aigocode\",\n    iconColor: \"#5B7FFF\",\n  },\n  {\n    name: \"RightCode\",\n    websiteUrl: \"https://www.right.codes\",\n    apiKeyUrl: \"https://www.right.codes/register?aff=CCSWITCH\",\n    auth: generateThirdPartyAuth(\"\"),\n    config: generateThirdPartyConfig(\n      \"rightcode\",\n      \"https://right.codes/codex/v1\",\n      \"gpt-5.4\",\n    ),\n    category: \"third_party\",\n    isPartner: true,\n    partnerPromotionKey: \"rightcode\",\n    icon: \"rc\",\n    iconColor: \"#E96B2C\",\n  },\n  {\n    name: \"AICodeMirror\",\n    websiteUrl: \"https://www.aicodemirror.com\",\n    apiKeyUrl: \"https://www.aicodemirror.com/register?invitecode=9915W3\",\n    auth: generateThirdPartyAuth(\"\"),\n    config: generateThirdPartyConfig(\n      \"aicodemirror\",\n      \"https://api.aicodemirror.com/api/codex/backend-api/codex\",\n      \"gpt-5.4\",\n    ),\n    endpointCandidates: [\n      \"https://api.aicodemirror.com/api/codex/backend-api/codex\",\n      \"https://api.claudecode.net.cn/api/codex/backend-api/codex\",\n    ],\n    isPartner: true,\n    partnerPromotionKey: \"aicodemirror\",\n    icon: \"aicodemirror\",\n    iconColor: \"#000000\",\n  },\n  {\n    name: \"AICoding\",\n    websiteUrl: \"https://aicoding.sh\",\n    apiKeyUrl: \"https://aicoding.sh/i/CCSWITCH\",\n    auth: generateThirdPartyAuth(\"\"),\n    config: generateThirdPartyConfig(\n      \"aicoding\",\n      \"https://api.aicoding.sh\",\n      \"gpt-5.4\",\n    ),\n    endpointCandidates: [\"https://api.aicoding.sh\"],\n    isPartner: true,\n    partnerPromotionKey: \"aicoding\",\n    icon: \"aicoding\",\n    iconColor: \"#000000\",\n  },\n  {\n    name: \"CrazyRouter\",\n    websiteUrl: \"https://www.crazyrouter.com\",\n    apiKeyUrl: \"https://www.crazyrouter.com/register?aff=OZcm&ref=cc-switch\",\n    auth: generateThirdPartyAuth(\"\"),\n    config: generateThirdPartyConfig(\n      \"crazyrouter\",\n      \"https://crazyrouter.com/v1\",\n      \"gpt-5.4\",\n    ),\n    endpointCandidates: [\"https://crazyrouter.com/v1\"],\n    isPartner: true,\n    partnerPromotionKey: \"crazyrouter\",\n    icon: \"crazyrouter\",\n    iconColor: \"#000000\",\n  },\n  {\n    name: \"SSSAiCode\",\n    websiteUrl: \"https://www.sssaicode.com\",\n    apiKeyUrl: \"https://www.sssaicode.com/register?ref=DCP0SM\",\n    auth: generateThirdPartyAuth(\"\"),\n    config: generateThirdPartyConfig(\n      \"sssaicode\",\n      \"https://node-hk.sssaicode.com/api/v1\",\n      \"gpt-5.4\",\n    ),\n    endpointCandidates: [\n      \"https://node-hk.sssaicode.com/api/v1\",\n      \"https://claude2.sssaicode.com/api/v1\",\n      \"https://anti.sssaicode.com/api/v1\",\n    ],\n    category: \"third_party\",\n    isPartner: true, // 合作伙伴\n    partnerPromotionKey: \"sssaicode\", // 促销信息 i18n key\n    icon: \"sssaicode\",\n    iconColor: \"#000000\",\n  },\n  {\n    name: \"Compshare\",\n    nameKey: \"providerForm.presets.ucloud\",\n    websiteUrl: \"https://www.compshare.cn\",\n    apiKeyUrl:\n      \"https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch\",\n    auth: generateThirdPartyAuth(\"\"),\n    config: generateThirdPartyConfig(\n      \"compshare\",\n      \"https://api.modelverse.cn/v1\",\n      \"gpt-5.4\",\n    ),\n    endpointCandidates: [\"https://api.modelverse.cn/v1\"],\n    category: \"aggregator\",\n    isPartner: true, // 合作伙伴\n    partnerPromotionKey: \"ucloud\", // 促销信息 i18n key\n    icon: \"ucloud\",\n    iconColor: \"#000000\",\n  },\n  {\n    name: \"Micu\",\n    websiteUrl: \"https://www.openclaudecode.cn\",\n    apiKeyUrl: \"https://www.openclaudecode.cn/register?aff=aOYQ\",\n    auth: generateThirdPartyAuth(\"\"),\n    config: generateThirdPartyConfig(\n      \"micu\",\n      \"https://www.openclaudecode.cn/v1\",\n      \"gpt-5.4\",\n    ),\n    endpointCandidates: [\"https://www.openclaudecode.cn/v1\"],\n    category: \"third_party\",\n    isPartner: true, // 合作伙伴\n    partnerPromotionKey: \"micu\", // 促销信息 i18n key\n    icon: \"micu\",\n    iconColor: \"#000000\",\n  },\n  {\n    name: \"X-Code API\",\n    websiteUrl: \"https://x-code.cc\",\n    apiKeyUrl: \"https://x-code.cc\",\n    auth: generateThirdPartyAuth(\"\"),\n    config: generateThirdPartyConfig(\n      \"x-code\",\n      \"https://x-code.cc/v1\",\n      \"gpt-5.4\",\n    ),\n    endpointCandidates: [\"https://x-code.cc/v1\"],\n    category: \"third_party\",\n    isPartner: true, // 合作伙伴\n    partnerPromotionKey: \"x-code\", // 促销信息 i18n key\n    icon: \"x-code\",\n    iconColor: \"#000000\",\n  },\n  {\n    name: \"CTok.ai\",\n    websiteUrl: \"https://ctok.ai\",\n    apiKeyUrl: \"https://ctok.ai\",\n    auth: generateThirdPartyAuth(\"\"),\n    config: generateThirdPartyConfig(\n      \"ctok\",\n      \"https://api.ctok.ai/v1\",\n      \"gpt-5.4\",\n    ),\n    endpointCandidates: [\"https://api.ctok.ai/v1\"],\n    category: \"third_party\",\n    isPartner: true, // 合作伙伴\n    partnerPromotionKey: \"ctok\", // 促销信息 i18n key\n    icon: \"ctok\",\n    iconColor: \"#000000\",\n  },\n  {\n    name: \"OpenRouter\",\n    websiteUrl: \"https://openrouter.ai\",\n    apiKeyUrl: \"https://openrouter.ai/keys\",\n    auth: generateThirdPartyAuth(\"\"),\n    config: generateThirdPartyConfig(\n      \"openrouter\",\n      \"https://openrouter.ai/api/v1\",\n      \"gpt-5.4\",\n    ),\n    category: \"aggregator\",\n    icon: \"openrouter\",\n    iconColor: \"#6566F1\",\n  },\n];\n"
  },
  {
    "path": "src/config/codexTemplates.ts",
    "content": "/**\n * Codex 配置模板\n * 用于新建自定义供应商时的默认配置\n */\n\nexport interface CodexTemplate {\n  auth: Record<string, any>;\n  config: string;\n}\n\n/**\n * 获取 Codex 自定义模板\n * @returns Codex 模板配置\n */\nexport function getCodexCustomTemplate(): CodexTemplate {\n  const config = `model_provider = \"custom\"\nmodel = \"gpt-5.4\"\nmodel_reasoning_effort = \"high\"\ndisable_response_storage = true\n\n[model_providers.custom]\nname = \"custom\"\nwire_api = \"responses\"\nrequires_openai_auth = true`;\n\n  return {\n    auth: { OPENAI_API_KEY: \"\" },\n    config,\n  };\n}\n"
  },
  {
    "path": "src/config/constants.ts",
    "content": "// Provider 类型常量\nexport const PROVIDER_TYPES = {\n  GITHUB_COPILOT: \"github_copilot\",\n} as const;\n\n// 用量脚本模板类型常量\nexport const TEMPLATE_TYPES = {\n  CUSTOM: \"custom\",\n  GENERAL: \"general\",\n  NEW_API: \"newapi\",\n  GITHUB_COPILOT: \"github_copilot\",\n} as const;\n\nexport type TemplateType =\n  (typeof TEMPLATE_TYPES)[keyof typeof TEMPLATE_TYPES];\n"
  },
  {
    "path": "src/config/geminiProviderPresets.ts",
    "content": "import type { ProviderCategory } from \"@/types\";\n\n/**\n * Gemini 预设供应商的视觉主题配置\n */\nexport interface GeminiPresetTheme {\n  /** 图标类型：'gemini' | 'generic' */\n  icon?: \"gemini\" | \"generic\";\n  /** 背景色（选中状态），支持 hex 颜色 */\n  backgroundColor?: string;\n  /** 文字色（选中状态），支持 hex 颜色 */\n  textColor?: string;\n}\n\nexport interface GeminiProviderPreset {\n  name: string;\n  nameKey?: string; // i18n key for localized display name\n  websiteUrl: string;\n  apiKeyUrl?: string;\n  settingsConfig: object;\n  baseURL?: string;\n  model?: string;\n  description?: string;\n  category?: ProviderCategory;\n  isPartner?: boolean;\n  partnerPromotionKey?: string;\n  endpointCandidates?: string[];\n  theme?: GeminiPresetTheme;\n  // 图标配置\n  icon?: string; // 图标名称\n  iconColor?: string; // 图标颜色\n}\n\nexport const geminiProviderPresets: GeminiProviderPreset[] = [\n  {\n    name: \"Google Official\",\n    websiteUrl: \"https://ai.google.dev/\",\n    apiKeyUrl: \"https://aistudio.google.com/apikey\",\n    settingsConfig: {\n      env: {},\n    },\n    description: \"Google 官方 Gemini API (OAuth)\",\n    category: \"official\",\n    partnerPromotionKey: \"google-official\",\n    theme: {\n      icon: \"gemini\",\n      backgroundColor: \"#4285F4\",\n      textColor: \"#FFFFFF\",\n    },\n    icon: \"gemini\",\n    iconColor: \"#4285F4\",\n  },\n  {\n    name: \"PackyCode\",\n    websiteUrl: \"https://www.packyapi.com\",\n    apiKeyUrl: \"https://www.packyapi.com/register?aff=cc-switch\",\n    settingsConfig: {\n      env: {\n        GOOGLE_GEMINI_BASE_URL: \"https://www.packyapi.com\",\n        GEMINI_MODEL: \"gemini-3.1-pro\",\n      },\n    },\n    baseURL: \"https://www.packyapi.com\",\n    model: \"gemini-3.1-pro\",\n    description: \"PackyCode\",\n    category: \"third_party\",\n    isPartner: true,\n    partnerPromotionKey: \"packycode\",\n    endpointCandidates: [\n      \"https://api-slb.packyapi.com\",\n      \"https://www.packyapi.com\",\n    ],\n    icon: \"packycode\",\n  },\n  {\n    name: \"Cubence\",\n    websiteUrl: \"https://cubence.com\",\n    apiKeyUrl: \"https://cubence.com/signup?code=CCSWITCH&source=ccs\",\n    settingsConfig: {\n      env: {\n        GOOGLE_GEMINI_BASE_URL: \"https://api.cubence.com\",\n        GEMINI_MODEL: \"gemini-3.1-pro\",\n      },\n    },\n    baseURL: \"https://api.cubence.com\",\n    model: \"gemini-3.1-pro\",\n    description: \"Cubence\",\n    category: \"third_party\",\n    isPartner: true,\n    partnerPromotionKey: \"cubence\",\n    endpointCandidates: [\n      \"https://api.cubence.com/v1\",\n      \"https://api-cf.cubence.com/v1\",\n      \"https://api-dmit.cubence.com/v1\",\n      \"https://api-bwg.cubence.com/v1\",\n    ],\n    icon: \"cubence\",\n    iconColor: \"#000000\",\n  },\n  {\n    name: \"AIGoCode\",\n    websiteUrl: \"https://aigocode.com\",\n    apiKeyUrl: \"https://aigocode.com/invite/CC-SWITCH\",\n    settingsConfig: {\n      env: {\n        GOOGLE_GEMINI_BASE_URL: \"https://api.aigocode.com\",\n        GEMINI_MODEL: \"gemini-3.1-pro\",\n      },\n    },\n    baseURL: \"https://api.aigocode.com\",\n    model: \"gemini-3.1-pro\",\n    description: \"AIGoCode\",\n    category: \"third_party\",\n    isPartner: true,\n    partnerPromotionKey: \"aigocode\",\n    endpointCandidates: [\"https://api.aigocode.com\"],\n    icon: \"aigocode\",\n    iconColor: \"#5B7FFF\",\n  },\n  {\n    name: \"AICodeMirror\",\n    websiteUrl: \"https://www.aicodemirror.com\",\n    apiKeyUrl: \"https://www.aicodemirror.com/register?invitecode=9915W3\",\n    settingsConfig: {\n      env: {\n        GOOGLE_GEMINI_BASE_URL: \"https://api.aicodemirror.com/api/gemini\",\n        GEMINI_MODEL: \"gemini-3.1-pro\",\n      },\n    },\n    baseURL: \"https://api.aicodemirror.com/api/gemini\",\n    model: \"gemini-3.1-pro\",\n    description: \"AICodeMirror\",\n    category: \"third_party\",\n    isPartner: true,\n    partnerPromotionKey: \"aicodemirror\",\n    endpointCandidates: [\n      \"https://api.aicodemirror.com/api/gemini\",\n      \"https://api.claudecode.net.cn/api/gemini\",\n    ],\n    icon: \"aicodemirror\",\n    iconColor: \"#000000\",\n  },\n  {\n    name: \"AICoding\",\n    websiteUrl: \"https://aicoding.sh\",\n    apiKeyUrl: \"https://aicoding.sh/i/CCSWITCH\",\n    settingsConfig: {\n      env: {\n        GOOGLE_GEMINI_BASE_URL: \"https://api.aicoding.sh\",\n        GEMINI_MODEL: \"gemini-3.1-pro\",\n      },\n    },\n    baseURL: \"https://api.aicoding.sh\",\n    model: \"gemini-3.1-pro\",\n    description: \"AICoding\",\n    category: \"third_party\",\n    isPartner: true,\n    partnerPromotionKey: \"aicoding\",\n    endpointCandidates: [\"https://api.aicoding.sh\"],\n    icon: \"aicoding\",\n    iconColor: \"#000000\",\n  },\n  {\n    name: \"CrazyRouter\",\n    websiteUrl: \"https://www.crazyrouter.com\",\n    apiKeyUrl: \"https://www.crazyrouter.com/register?aff=OZcm&ref=cc-switch\",\n    settingsConfig: {\n      env: {\n        GOOGLE_GEMINI_BASE_URL: \"https://crazyrouter.com\",\n        GEMINI_MODEL: \"gemini-3.1-pro\",\n      },\n    },\n    baseURL: \"https://crazyrouter.com\",\n    model: \"gemini-3.1-pro\",\n    description: \"CrazyRouter\",\n    category: \"third_party\",\n    isPartner: true,\n    partnerPromotionKey: \"crazyrouter\",\n    endpointCandidates: [\"https://crazyrouter.com\"],\n    icon: \"crazyrouter\",\n    iconColor: \"#000000\",\n  },\n  {\n    name: \"SSSAiCode\",\n    websiteUrl: \"https://www.sssaicode.com\",\n    apiKeyUrl: \"https://www.sssaicode.com/register?ref=DCP0SM\",\n    settingsConfig: {\n      env: {\n        GOOGLE_GEMINI_BASE_URL: \"https://node-hk.sssaicode.com/api\",\n        GEMINI_MODEL: \"gemini-3.1-pro\",\n      },\n    },\n    baseURL: \"https://node-hk.sssaicode.com/api\",\n    model: \"gemini-3.1-pro\",\n    description: \"SSSAiCode\",\n    category: \"third_party\",\n    isPartner: true,\n    partnerPromotionKey: \"sssaicode\",\n    endpointCandidates: [\n      \"https://node-hk.sssaicode.com/api\",\n      \"https://claude2.sssaicode.com/api\",\n      \"https://anti.sssaicode.com/api\",\n    ],\n    icon: \"sssaicode\",\n    iconColor: \"#000000\",\n  },\n  {\n    name: \"CTok.ai\",\n    websiteUrl: \"https://ctok.ai\",\n    apiKeyUrl: \"https://ctok.ai\",\n    settingsConfig: {\n      env: {\n        GOOGLE_GEMINI_BASE_URL: \"https://api.ctok.ai/v1beta\",\n        GEMINI_MODEL: \"gemini-3.1-pro\",\n      },\n    },\n    baseURL: \"https://api.ctok.ai/v1beta\",\n    model: \"gemini-3.1-pro\",\n    description: \"CTok\",\n    category: \"third_party\",\n    isPartner: true,\n    partnerPromotionKey: \"ctok\",\n    endpointCandidates: [\"https://api.ctok.ai/v1beta\"],\n    icon: \"ctok\",\n    iconColor: \"#000000\",\n  },\n  {\n    name: \"OpenRouter\",\n    websiteUrl: \"https://openrouter.ai\",\n    apiKeyUrl: \"https://openrouter.ai/keys\",\n    settingsConfig: {\n      env: {\n        GOOGLE_GEMINI_BASE_URL: \"https://openrouter.ai/api\",\n        GEMINI_MODEL: \"gemini-3.1-pro\",\n      },\n    },\n    baseURL: \"https://openrouter.ai/api\",\n    model: \"gemini-3.1-pro\",\n    description: \"OpenRouter\",\n    category: \"aggregator\",\n    icon: \"openrouter\",\n    iconColor: \"#6566F1\",\n  },\n  {\n    name: \"自定义\",\n    websiteUrl: \"\",\n    settingsConfig: {\n      env: {\n        GOOGLE_GEMINI_BASE_URL: \"\",\n        GEMINI_MODEL: \"gemini-3.1-pro\",\n      },\n    },\n    model: \"gemini-3.1-pro\",\n    description: \"自定义 Gemini API 端点\",\n    category: \"custom\",\n  },\n];\n\nexport function getGeminiPresetByName(\n  name: string,\n): GeminiProviderPreset | undefined {\n  return geminiProviderPresets.find((preset) => preset.name === name);\n}\n\nexport function getGeminiPresetByUrl(\n  url: string,\n): GeminiProviderPreset | undefined {\n  if (!url) return undefined;\n  return geminiProviderPresets.find(\n    (preset) =>\n      preset.baseURL &&\n      url.toLowerCase().includes(preset.baseURL.toLowerCase()),\n  );\n}\n"
  },
  {
    "path": "src/config/iconInference.ts",
    "content": "/**\n * 根据供应商名称智能推断图标配置\n */\n\nconst iconMappings = {\n  // AI 服务商\n  claude: { icon: \"claude\", iconColor: \"#D4915D\" },\n  anthropic: { icon: \"anthropic\", iconColor: \"#D4915D\" },\n  deepseek: { icon: \"deepseek\", iconColor: \"#1E88E5\" },\n  zhipu: { icon: \"zhipu\", iconColor: \"#0F62FE\" },\n  glm: { icon: \"zhipu\", iconColor: \"#0F62FE\" },\n  qwen: { icon: \"qwen\", iconColor: \"#FF6A00\" },\n  bailian: { icon: \"bailian\", iconColor: \"#624AFF\" },\n  alibaba: { icon: \"alibaba\", iconColor: \"#FF6A00\" },\n  aliyun: { icon: \"alibaba\", iconColor: \"#FF6A00\" },\n  kimi: { icon: \"kimi\", iconColor: \"#6366F1\" },\n  moonshot: { icon: \"moonshot\", iconColor: \"#6366F1\" },\n  stepfun: { icon: \"stepfun\", iconColor: \"#005AFF\" },\n  step: { icon: \"stepfun\", iconColor: \"#005AFF\" },\n  baidu: { icon: \"baidu\", iconColor: \"#2932E1\" },\n  tencent: { icon: \"tencent\", iconColor: \"#00A4FF\" },\n  hunyuan: { icon: \"hunyuan\", iconColor: \"#00A4FF\" },\n  minimax: { icon: \"minimax\", iconColor: \"#FF6B6B\" },\n  google: { icon: \"google\", iconColor: \"#4285F4\" },\n  meta: { icon: \"meta\", iconColor: \"#0081FB\" },\n  mistral: { icon: \"mistral\", iconColor: \"#FF7000\" },\n  cohere: { icon: \"cohere\", iconColor: \"#39594D\" },\n  perplexity: { icon: \"perplexity\", iconColor: \"#20808D\" },\n  huggingface: { icon: \"huggingface\", iconColor: \"#FFD21E\" },\n  novita: { icon: \"novita\", iconColor: \"#000000\" },\n\n  // 云平台\n  aws: { icon: \"aws\", iconColor: \"#FF9900\" },\n  azure: { icon: \"azure\", iconColor: \"#0078D4\" },\n  huawei: { icon: \"huawei\", iconColor: \"#FF0000\" },\n  cloudflare: { icon: \"cloudflare\", iconColor: \"#F38020\" },\n};\n\n/**\n * 根据预设名称推断图标\n */\nexport function inferIconForPreset(presetName: string): {\n  icon?: string;\n  iconColor?: string;\n} {\n  const nameLower = presetName.toLowerCase();\n\n  // 精确匹配或模糊匹配\n  for (const [key, config] of Object.entries(iconMappings)) {\n    if (nameLower.includes(key)) {\n      return config;\n    }\n  }\n\n  return {};\n}\n\n/**\n * 批量为预设添加图标配置\n */\nexport function addIconsToPresets<\n  T extends { name: string; icon?: string; iconColor?: string },\n>(presets: T[]): T[] {\n  return presets.map((preset) => {\n    // 如果已经配置了图标，则保留原配置\n    if (preset.icon) {\n      return preset;\n    }\n\n    // 否则根据名称推断\n    const inferred = inferIconForPreset(preset.name);\n    return {\n      ...preset,\n      ...inferred,\n    };\n  });\n}\n"
  },
  {
    "path": "src/config/mcpPresets.ts",
    "content": "import { McpServer, McpServerSpec } from \"../types\";\nimport { isWindows } from \"@/lib/platform\";\n\nexport type McpPreset = Omit<McpServer, \"enabled\" | \"description\">;\n\n// 创建跨平台 npx 命令配置\n// Windows 需要使用 cmd /c wrapper 来执行 npx.cmd\n// Mac/Linux 可以直接执行 npx\nconst createNpxCommand = (\n  packageName: string,\n  extraArgs: string[] = [],\n): { command: string; args: string[] } => {\n  if (isWindows()) {\n    return {\n      command: \"cmd\",\n      args: [\"/c\", \"npx\", ...extraArgs, packageName],\n    };\n  } else {\n    return {\n      command: \"npx\",\n      args: [...extraArgs, packageName],\n    };\n  }\n};\n\n// 预设 MCP（逻辑简化版）：\n// - 仅包含最常用、可快速落地的 stdio 模式示例\n// - 不涉及分类/模板/测速等复杂逻辑，默认以 disabled 形式\"回种\"到 config.json\n// - 用户可在 MCP 面板中一键启用/编辑\n// - description 字段使用国际化 key，在使用时通过 t() 函数获取翻译\nexport const mcpPresets: McpPreset[] = [\n  {\n    id: \"fetch\",\n    name: \"mcp-server-fetch\",\n    tags: [\"stdio\", \"http\", \"web\"],\n    server: {\n      type: \"stdio\",\n      command: \"uvx\",\n      args: [\"mcp-server-fetch\"],\n    } as McpServerSpec,\n    homepage: \"https://github.com/modelcontextprotocol/servers\",\n    docs: \"https://github.com/modelcontextprotocol/servers/tree/main/src/fetch\",\n  },\n  {\n    id: \"time\",\n    name: \"@modelcontextprotocol/server-time\",\n    tags: [\"stdio\", \"time\", \"utility\"],\n    server: {\n      type: \"stdio\",\n      ...createNpxCommand(\"@modelcontextprotocol/server-time\", [\"-y\"]),\n    } as McpServerSpec,\n    homepage: \"https://github.com/modelcontextprotocol/servers\",\n    docs: \"https://github.com/modelcontextprotocol/servers/tree/main/src/time\",\n  },\n  {\n    id: \"memory\",\n    name: \"@modelcontextprotocol/server-memory\",\n    tags: [\"stdio\", \"memory\", \"graph\"],\n    server: {\n      type: \"stdio\",\n      ...createNpxCommand(\"@modelcontextprotocol/server-memory\", [\"-y\"]),\n    } as McpServerSpec,\n    homepage: \"https://github.com/modelcontextprotocol/servers\",\n    docs: \"https://github.com/modelcontextprotocol/servers/tree/main/src/memory\",\n  },\n  {\n    id: \"sequential-thinking\",\n    name: \"@modelcontextprotocol/server-sequential-thinking\",\n    tags: [\"stdio\", \"thinking\", \"reasoning\"],\n    server: {\n      type: \"stdio\",\n      ...createNpxCommand(\"@modelcontextprotocol/server-sequential-thinking\", [\n        \"-y\",\n      ]),\n    } as McpServerSpec,\n    homepage: \"https://github.com/modelcontextprotocol/servers\",\n    docs: \"https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking\",\n  },\n  {\n    id: \"context7\",\n    name: \"@upstash/context7-mcp\",\n    tags: [\"stdio\", \"docs\", \"search\"],\n    server: {\n      type: \"stdio\",\n      ...createNpxCommand(\"@upstash/context7-mcp\", [\"-y\"]),\n    } as McpServerSpec,\n    homepage: \"https://context7.com\",\n    docs: \"https://github.com/upstash/context7/blob/master/README.md\",\n  },\n];\n\n// 获取带国际化描述的预设\nexport const getMcpPresetWithDescription = (\n  preset: McpPreset,\n  t: (key: string) => string,\n): McpServer => {\n  const descriptionKey = `mcp.presets.${preset.id}.description`;\n  return {\n    ...preset,\n    description: t(descriptionKey),\n  } as McpServer;\n};\n\nexport default mcpPresets;\n"
  },
  {
    "path": "src/config/openclawProviderPresets.ts",
    "content": "/**\n * OpenClaw provider presets configuration\n * OpenClaw uses models.providers structure with custom provider configs\n */\nimport type {\n  ProviderCategory,\n  OpenClawProviderConfig,\n  OpenClawDefaultModel,\n} from \"../types\";\nimport type { PresetTheme, TemplateValueConfig } from \"./claudeProviderPresets\";\n\n/** Suggested default model configuration for a preset */\nexport interface OpenClawSuggestedDefaults {\n  /** Default model config to apply (agents.defaults.model) */\n  model?: OpenClawDefaultModel;\n  /** Model catalog entries to add (agents.defaults.models) */\n  modelCatalog?: Record<string, { alias?: string }>;\n}\n\nexport interface OpenClawProviderPreset {\n  name: string;\n  nameKey?: string; // i18n key for localized display name\n  websiteUrl: string;\n  apiKeyUrl?: string;\n  /** OpenClaw settings_config structure */\n  settingsConfig: OpenClawProviderConfig;\n  isOfficial?: boolean;\n  isPartner?: boolean;\n  partnerPromotionKey?: string;\n  category?: ProviderCategory;\n  /** Template variable definitions */\n  templateValues?: Record<string, TemplateValueConfig>;\n  /** Visual theme config */\n  theme?: PresetTheme;\n  /** Icon name */\n  icon?: string;\n  /** Icon color */\n  iconColor?: string;\n  /** Mark as custom template (for UI distinction) */\n  isCustomTemplate?: boolean;\n  /** Suggested default model configuration */\n  suggestedDefaults?: OpenClawSuggestedDefaults;\n}\n\n/**\n * OpenClaw API protocol options\n * @see https://github.com/openclaw/openclaw/blob/main/docs/gateway/configuration.md\n */\nexport const openclawApiProtocols = [\n  { value: \"openai-completions\", label: \"OpenAI Completions\" },\n  { value: \"openai-responses\", label: \"OpenAI Responses\" },\n  { value: \"anthropic-messages\", label: \"Anthropic Messages\" },\n  { value: \"google-generative-ai\", label: \"Google Generative AI\" },\n  { value: \"bedrock-converse-stream\", label: \"AWS Bedrock\" },\n] as const;\n\n/**\n * OpenClaw provider presets list\n */\nexport const openclawProviderPresets: OpenClawProviderPreset[] = [\n  // ========== Chinese Officials ==========\n  {\n    name: \"DeepSeek\",\n    websiteUrl: \"https://platform.deepseek.com\",\n    apiKeyUrl: \"https://platform.deepseek.com/api_keys\",\n    settingsConfig: {\n      baseUrl: \"https://api.deepseek.com/v1\",\n      apiKey: \"\",\n      api: \"openai-completions\",\n      models: [\n        {\n          id: \"deepseek-chat\",\n          name: \"DeepSeek V3.2\",\n          contextWindow: 64000,\n          cost: { input: 0.0005, output: 0.002 },\n        },\n        {\n          id: \"deepseek-reasoner\",\n          name: \"DeepSeek R1\",\n          contextWindow: 64000,\n          cost: { input: 0.0005, output: 0.002 },\n        },\n      ],\n    },\n    category: \"cn_official\",\n    icon: \"deepseek\",\n    iconColor: \"#1E88E5\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"sk-...\",\n        editorValue: \"\",\n      },\n    },\n    suggestedDefaults: {\n      model: {\n        primary: \"deepseek/deepseek-chat\",\n        fallbacks: [\"deepseek/deepseek-reasoner\"],\n      },\n      modelCatalog: {\n        \"deepseek/deepseek-chat\": { alias: \"DeepSeek\" },\n        \"deepseek/deepseek-reasoner\": { alias: \"R1\" },\n      },\n    },\n  },\n  {\n    name: \"Zhipu GLM\",\n    websiteUrl: \"https://open.bigmodel.cn\",\n    apiKeyUrl: \"https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII\",\n    settingsConfig: {\n      baseUrl: \"https://open.bigmodel.cn/api/paas/v4\",\n      apiKey: \"\",\n      api: \"openai-completions\",\n      models: [\n        {\n          id: \"glm-5\",\n          name: \"GLM-5\",\n          contextWindow: 128000,\n          cost: { input: 0.001, output: 0.001 },\n        },\n      ],\n    },\n    category: \"cn_official\",\n    icon: \"zhipu\",\n    iconColor: \"#0F62FE\",\n    templateValues: {\n      baseUrl: {\n        label: \"Base URL\",\n        placeholder: \"https://open.bigmodel.cn/api/paas/v4\",\n        defaultValue: \"https://open.bigmodel.cn/api/paas/v4\",\n        editorValue: \"\",\n      },\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n    suggestedDefaults: {\n      model: { primary: \"zhipu/glm-5\" },\n      modelCatalog: { \"zhipu/glm-5\": { alias: \"GLM\" } },\n    },\n  },\n  {\n    name: \"Zhipu GLM en\",\n    websiteUrl: \"https://z.ai\",\n    apiKeyUrl: \"https://z.ai/subscribe?ic=8JVLJQFSKB\",\n    settingsConfig: {\n      baseUrl: \"https://api.z.ai/v1\",\n      apiKey: \"\",\n      api: \"openai-completions\",\n      models: [\n        {\n          id: \"glm-5\",\n          name: \"GLM-5\",\n          contextWindow: 128000,\n          cost: { input: 0.001, output: 0.001 },\n        },\n      ],\n    },\n    category: \"cn_official\",\n    icon: \"zhipu\",\n    iconColor: \"#0F62FE\",\n    templateValues: {\n      baseUrl: {\n        label: \"Base URL\",\n        placeholder: \"https://api.z.ai/v1\",\n        defaultValue: \"https://api.z.ai/v1\",\n        editorValue: \"\",\n      },\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n    suggestedDefaults: {\n      model: { primary: \"zhipu-en/glm-5\" },\n      modelCatalog: { \"zhipu-en/glm-5\": { alias: \"GLM\" } },\n    },\n  },\n  {\n    name: \"Qwen Coder\",\n    websiteUrl: \"https://bailian.console.aliyun.com\",\n    apiKeyUrl: \"https://bailian.console.aliyun.com/#/api-key\",\n    settingsConfig: {\n      baseUrl: \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n      apiKey: \"\",\n      api: \"openai-completions\",\n      models: [\n        {\n          id: \"qwen3.5-plus\",\n          name: \"Qwen3.5 Plus\",\n          contextWindow: 32000,\n          cost: { input: 0.002, output: 0.006 },\n        },\n      ],\n    },\n    category: \"cn_official\",\n    icon: \"qwen\",\n    iconColor: \"#FF6A00\",\n    templateValues: {\n      baseUrl: {\n        label: \"Base URL\",\n        placeholder: \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n        defaultValue: \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n        editorValue: \"\",\n      },\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"sk-...\",\n        editorValue: \"\",\n      },\n    },\n    suggestedDefaults: {\n      model: { primary: \"qwen/qwen3.5-plus\" },\n      modelCatalog: { \"qwen/qwen3.5-plus\": { alias: \"Qwen\" } },\n    },\n  },\n  {\n    name: \"Kimi k2.5\",\n    websiteUrl: \"https://platform.moonshot.cn/console\",\n    apiKeyUrl: \"https://platform.moonshot.cn/console/api-keys\",\n    settingsConfig: {\n      baseUrl: \"https://api.moonshot.cn/v1\",\n      apiKey: \"\",\n      api: \"openai-completions\",\n      models: [\n        {\n          id: \"kimi-k2.5\",\n          name: \"Kimi K2.5\",\n          contextWindow: 131072,\n          cost: { input: 0.002, output: 0.006 },\n        },\n      ],\n    },\n    category: \"cn_official\",\n    icon: \"kimi\",\n    iconColor: \"#6366F1\",\n    templateValues: {\n      baseUrl: {\n        label: \"Base URL\",\n        placeholder: \"https://api.moonshot.cn/v1\",\n        defaultValue: \"https://api.moonshot.cn/v1\",\n        editorValue: \"\",\n      },\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"sk-...\",\n        editorValue: \"\",\n      },\n    },\n    suggestedDefaults: {\n      model: { primary: \"kimi/kimi-k2.5\" },\n      modelCatalog: { \"kimi/kimi-k2.5\": { alias: \"Kimi\" } },\n    },\n  },\n  {\n    name: \"Kimi For Coding\",\n    websiteUrl: \"https://www.kimi.com/coding/docs/\",\n    apiKeyUrl: \"https://platform.moonshot.cn/console/api-keys\",\n    settingsConfig: {\n      baseUrl: \"https://api.kimi.com/v1\",\n      apiKey: \"\",\n      api: \"openai-completions\",\n      models: [\n        {\n          id: \"kimi-for-coding\",\n          name: \"Kimi For Coding\",\n          contextWindow: 131072,\n          cost: { input: 0.002, output: 0.006 },\n        },\n      ],\n    },\n    category: \"cn_official\",\n    icon: \"kimi\",\n    iconColor: \"#6366F1\",\n    templateValues: {\n      baseUrl: {\n        label: \"Base URL\",\n        placeholder: \"https://api.kimi.com/v1\",\n        defaultValue: \"https://api.kimi.com/v1\",\n        editorValue: \"\",\n      },\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"sk-...\",\n        editorValue: \"\",\n      },\n    },\n    suggestedDefaults: {\n      model: { primary: \"kimi-coding/kimi-for-coding\" },\n      modelCatalog: { \"kimi-coding/kimi-for-coding\": { alias: \"Kimi\" } },\n    },\n  },\n  {\n    name: \"StepFun\",\n    websiteUrl: \"https://platform.stepfun.ai\",\n    apiKeyUrl: \"https://platform.stepfun.ai/interface-key\",\n    settingsConfig: {\n      baseUrl: \"https://api.stepfun.ai/v1\",\n      apiKey: \"\",\n      api: \"openai-completions\",\n      models: [\n        {\n          id: \"step-3.5-flash\",\n          name: \"Step 3.5 Flash\",\n          contextWindow: 262144,\n        },\n      ],\n    },\n    category: \"cn_official\",\n    icon: \"stepfun\",\n    iconColor: \"#005AFF\",\n    templateValues: {\n      baseUrl: {\n        label: \"Base URL\",\n        placeholder: \"https://api.stepfun.ai/v1\",\n        defaultValue: \"https://api.stepfun.ai/v1\",\n        editorValue: \"\",\n      },\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"step-...\",\n        editorValue: \"\",\n      },\n    },\n    suggestedDefaults: {\n      model: { primary: \"stepfun/step-3.5-flash\" },\n      modelCatalog: { \"stepfun/step-3.5-flash\": { alias: \"StepFun\" } },\n    },\n  },\n  {\n    name: \"MiniMax\",\n    websiteUrl: \"https://platform.minimaxi.com\",\n    apiKeyUrl: \"https://platform.minimaxi.com/subscribe/coding-plan\",\n    settingsConfig: {\n      baseUrl: \"https://api.minimaxi.com/v1\",\n      apiKey: \"\",\n      api: \"openai-completions\",\n      models: [\n        {\n          id: \"MiniMax-M2.5\",\n          name: \"MiniMax M2.5\",\n          contextWindow: 200000,\n          cost: { input: 0.001, output: 0.004 },\n        },\n      ],\n    },\n    category: \"cn_official\",\n    isPartner: true,\n    partnerPromotionKey: \"minimax_cn\",\n    theme: {\n      backgroundColor: \"#f64551\",\n      textColor: \"#FFFFFF\",\n    },\n    icon: \"minimax\",\n    iconColor: \"#FF6B6B\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n    suggestedDefaults: {\n      model: { primary: \"minimax/MiniMax-M2.5\" },\n      modelCatalog: { \"minimax/MiniMax-M2.5\": { alias: \"MiniMax\" } },\n    },\n  },\n  {\n    name: \"MiniMax en\",\n    websiteUrl: \"https://platform.minimax.io\",\n    apiKeyUrl: \"https://platform.minimax.io/subscribe/coding-plan\",\n    settingsConfig: {\n      baseUrl: \"https://api.minimax.io/v1\",\n      apiKey: \"\",\n      api: \"openai-completions\",\n      models: [\n        {\n          id: \"MiniMax-M2.5\",\n          name: \"MiniMax M2.5\",\n          contextWindow: 200000,\n          cost: { input: 0.001, output: 0.004 },\n        },\n      ],\n    },\n    category: \"cn_official\",\n    isPartner: true,\n    partnerPromotionKey: \"minimax_en\",\n    theme: {\n      backgroundColor: \"#f64551\",\n      textColor: \"#FFFFFF\",\n    },\n    icon: \"minimax\",\n    iconColor: \"#FF6B6B\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n    suggestedDefaults: {\n      model: { primary: \"minimax-en/MiniMax-M2.5\" },\n      modelCatalog: { \"minimax-en/MiniMax-M2.5\": { alias: \"MiniMax\" } },\n    },\n  },\n  {\n    name: \"KAT-Coder\",\n    websiteUrl: \"https://console.streamlake.ai\",\n    apiKeyUrl: \"https://console.streamlake.ai/console/api-key\",\n    settingsConfig: {\n      baseUrl:\n        \"https://vanchin.streamlake.ai/api/gateway/v1/endpoints/${ENDPOINT_ID}/openai\",\n      apiKey: \"\",\n      api: \"openai-completions\",\n      models: [\n        {\n          id: \"KAT-Coder-Pro\",\n          name: \"KAT-Coder Pro\",\n          contextWindow: 128000,\n          cost: { input: 0.002, output: 0.006 },\n        },\n      ],\n    },\n    category: \"cn_official\",\n    icon: \"catcoder\",\n    templateValues: {\n      baseUrl: {\n        label: \"Base URL\",\n        placeholder:\n          \"https://vanchin.streamlake.ai/api/gateway/v1/endpoints/${ENDPOINT_ID}/openai\",\n        defaultValue:\n          \"https://vanchin.streamlake.ai/api/gateway/v1/endpoints/${ENDPOINT_ID}/openai\",\n        editorValue: \"\",\n      },\n      ENDPOINT_ID: {\n        label: \"Endpoint ID\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n    suggestedDefaults: {\n      model: { primary: \"katcoder/KAT-Coder-Pro\" },\n      modelCatalog: { \"katcoder/KAT-Coder-Pro\": { alias: \"KAT-Coder\" } },\n    },\n  },\n  {\n    name: \"Longcat\",\n    websiteUrl: \"https://longcat.chat/platform\",\n    apiKeyUrl: \"https://longcat.chat/platform/api_keys\",\n    settingsConfig: {\n      baseUrl: \"https://api.longcat.chat/v1\",\n      apiKey: \"\",\n      api: \"openai-completions\",\n      authHeader: true,\n      models: [\n        {\n          id: \"LongCat-Flash-Chat\",\n          name: \"LongCat Flash Chat\",\n          contextWindow: 128000,\n          cost: { input: 0.001, output: 0.004 },\n        },\n      ],\n    },\n    category: \"cn_official\",\n    icon: \"longcat\",\n    iconColor: \"#29E154\",\n    templateValues: {\n      baseUrl: {\n        label: \"Base URL\",\n        placeholder: \"https://api.longcat.chat/v1\",\n        defaultValue: \"https://api.longcat.chat/v1\",\n        editorValue: \"\",\n      },\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n    suggestedDefaults: {\n      model: { primary: \"longcat/LongCat-Flash-Chat\" },\n      modelCatalog: { \"longcat/LongCat-Flash-Chat\": { alias: \"LongCat\" } },\n    },\n  },\n  {\n    name: \"DouBaoSeed\",\n    websiteUrl: \"https://www.volcengine.com/product/doubao\",\n    apiKeyUrl: \"https://www.volcengine.com/product/doubao\",\n    settingsConfig: {\n      baseUrl: \"https://ark.cn-beijing.volces.com/api/v3\",\n      apiKey: \"\",\n      api: \"openai-completions\",\n      models: [\n        {\n          id: \"doubao-seed-2-0-code-preview-latest\",\n          name: \"DouBao Seed Code Preview\",\n          contextWindow: 128000,\n          cost: { input: 0.002, output: 0.006 },\n        },\n      ],\n    },\n    category: \"cn_official\",\n    icon: \"doubao\",\n    iconColor: \"#3370FF\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n    suggestedDefaults: {\n      model: { primary: \"doubaoseed/doubao-seed-2-0-code-preview-latest\" },\n      modelCatalog: {\n        \"doubaoseed/doubao-seed-2-0-code-preview-latest\": { alias: \"DouBao\" },\n      },\n    },\n  },\n  {\n    name: \"BaiLing\",\n    websiteUrl: \"https://alipaytbox.yuque.com/sxs0ba/ling/get_started\",\n    settingsConfig: {\n      baseUrl: \"https://api.tbox.cn/v1\",\n      apiKey: \"\",\n      api: \"openai-completions\",\n      models: [\n        {\n          id: \"Ling-2.5-1T\",\n          name: \"Ling 2.5 1T\",\n          contextWindow: 128000,\n          cost: { input: 0.001, output: 0.004 },\n        },\n      ],\n    },\n    category: \"cn_official\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n    suggestedDefaults: {\n      model: { primary: \"bailing/Ling-2.5-1T\" },\n      modelCatalog: { \"bailing/Ling-2.5-1T\": { alias: \"BaiLing\" } },\n    },\n  },\n  {\n    name: \"Xiaomi MiMo\",\n    websiteUrl: \"https://platform.xiaomimimo.com\",\n    apiKeyUrl: \"https://platform.xiaomimimo.com/#/console/api-keys\",\n    settingsConfig: {\n      baseUrl: \"https://api.xiaomimimo.com/v1\",\n      apiKey: \"\",\n      api: \"openai-completions\",\n      models: [\n        {\n          id: \"mimo-v2-flash\",\n          name: \"MiMo V2 Flash\",\n          contextWindow: 128000,\n          cost: { input: 0.001, output: 0.004 },\n        },\n      ],\n    },\n    category: \"cn_official\",\n    icon: \"xiaomimimo\",\n    iconColor: \"#000000\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n    suggestedDefaults: {\n      model: { primary: \"xiaomimimo/mimo-v2-flash\" },\n      modelCatalog: { \"xiaomimimo/mimo-v2-flash\": { alias: \"MiMo\" } },\n    },\n  },\n\n  // ========== Aggregators ==========\n  {\n    name: \"AiHubMix\",\n    websiteUrl: \"https://aihubmix.com\",\n    apiKeyUrl: \"https://aihubmix.com\",\n    settingsConfig: {\n      baseUrl: \"https://aihubmix.com\",\n      apiKey: \"\",\n      api: \"anthropic-messages\",\n      models: [\n        {\n          id: \"claude-opus-4-6\",\n          name: \"Claude Opus 4.6\",\n          contextWindow: 200000,\n          cost: { input: 5, output: 25 },\n        },\n        {\n          id: \"claude-sonnet-4-6\",\n          name: \"Claude Sonnet 4.6\",\n          contextWindow: 200000,\n          cost: { input: 3, output: 15 },\n        },\n      ],\n    },\n    category: \"aggregator\",\n    icon: \"aihubmix\",\n    iconColor: \"#006FFB\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n    suggestedDefaults: {\n      model: {\n        primary: \"aihubmix/claude-opus-4-6\",\n        fallbacks: [\"aihubmix/claude-sonnet-4-6\"],\n      },\n      modelCatalog: {\n        \"aihubmix/claude-opus-4-6\": { alias: \"Opus\" },\n        \"aihubmix/claude-sonnet-4-6\": { alias: \"Sonnet\" },\n      },\n    },\n  },\n  {\n    name: \"DMXAPI\",\n    websiteUrl: \"https://www.dmxapi.cn\",\n    apiKeyUrl: \"https://www.dmxapi.cn\",\n    settingsConfig: {\n      baseUrl: \"https://www.dmxapi.cn\",\n      apiKey: \"\",\n      api: \"anthropic-messages\",\n      models: [\n        {\n          id: \"claude-opus-4-6\",\n          name: \"Claude Opus 4.6\",\n          contextWindow: 200000,\n          cost: { input: 5, output: 25 },\n        },\n        {\n          id: \"claude-sonnet-4-6\",\n          name: \"Claude Sonnet 4.6\",\n          contextWindow: 200000,\n          cost: { input: 3, output: 15 },\n        },\n      ],\n    },\n    category: \"aggregator\",\n    isPartner: true,\n    partnerPromotionKey: \"dmxapi\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n    suggestedDefaults: {\n      model: {\n        primary: \"dmxapi/claude-opus-4-6\",\n        fallbacks: [\"dmxapi/claude-sonnet-4-6\"],\n      },\n      modelCatalog: {\n        \"dmxapi/claude-opus-4-6\": { alias: \"Opus\" },\n        \"dmxapi/claude-sonnet-4-6\": { alias: \"Sonnet\" },\n      },\n    },\n  },\n  {\n    name: \"OpenRouter\",\n    websiteUrl: \"https://openrouter.ai\",\n    apiKeyUrl: \"https://openrouter.ai/keys\",\n    settingsConfig: {\n      baseUrl: \"https://openrouter.ai/api/v1\",\n      apiKey: \"\",\n      api: \"openai-completions\",\n      models: [\n        {\n          id: \"anthropic/claude-opus-4.6\",\n          name: \"Claude Opus 4.6\",\n          contextWindow: 200000,\n          cost: { input: 5, output: 25 },\n        },\n        {\n          id: \"anthropic/claude-sonnet-4.6\",\n          name: \"Claude Sonnet 4.6\",\n          contextWindow: 200000,\n          cost: { input: 3, output: 15 },\n        },\n      ],\n    },\n    category: \"aggregator\",\n    icon: \"openrouter\",\n    iconColor: \"#6566F1\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"sk-or-...\",\n        editorValue: \"\",\n      },\n    },\n    suggestedDefaults: {\n      model: {\n        primary: \"openrouter/anthropic/claude-opus-4.6\",\n        fallbacks: [\"openrouter/anthropic/claude-sonnet-4.6\"],\n      },\n      modelCatalog: {\n        \"openrouter/anthropic/claude-opus-4.6\": { alias: \"Opus\" },\n        \"openrouter/anthropic/claude-sonnet-4.6\": { alias: \"Sonnet\" },\n      },\n    },\n  },\n  {\n    name: \"ModelScope\",\n    websiteUrl: \"https://modelscope.cn\",\n    apiKeyUrl: \"https://modelscope.cn/my/myaccesstoken\",\n    settingsConfig: {\n      baseUrl: \"https://api-inference.modelscope.cn/v1\",\n      apiKey: \"\",\n      api: \"openai-completions\",\n      models: [\n        {\n          id: \"ZhipuAI/GLM-5\",\n          name: \"GLM-5\",\n          contextWindow: 128000,\n          cost: { input: 0.001, output: 0.001 },\n        },\n      ],\n    },\n    category: \"aggregator\",\n    icon: \"modelscope\",\n    iconColor: \"#624AFF\",\n    templateValues: {\n      baseUrl: {\n        label: \"Base URL\",\n        placeholder: \"https://api-inference.modelscope.cn/v1\",\n        defaultValue: \"https://api-inference.modelscope.cn/v1\",\n        editorValue: \"\",\n      },\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n    suggestedDefaults: {\n      model: { primary: \"modelscope/ZhipuAI/GLM-5\" },\n      modelCatalog: { \"modelscope/ZhipuAI/GLM-5\": { alias: \"GLM\" } },\n    },\n  },\n  {\n    name: \"SiliconFlow\",\n    websiteUrl: \"https://siliconflow.cn\",\n    apiKeyUrl: \"https://cloud.siliconflow.cn/i/drGuwc9k\",\n    settingsConfig: {\n      baseUrl: \"https://api.siliconflow.cn/v1\",\n      apiKey: \"\",\n      api: \"openai-completions\",\n      models: [\n        {\n          id: \"Pro/MiniMaxAI/MiniMax-M2.5\",\n          name: \"MiniMax M2.5\",\n          contextWindow: 200000,\n          cost: { input: 0.001, output: 0.004 },\n        },\n      ],\n    },\n    category: \"aggregator\",\n    isPartner: true,\n    partnerPromotionKey: \"siliconflow\",\n    icon: \"siliconflow\",\n    iconColor: \"#6E29F6\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"sk-...\",\n        editorValue: \"\",\n      },\n    },\n    suggestedDefaults: {\n      model: { primary: \"siliconflow/Pro/MiniMaxAI/MiniMax-M2.5\" },\n      modelCatalog: {\n        \"siliconflow/Pro/MiniMaxAI/MiniMax-M2.5\": { alias: \"MiniMax\" },\n      },\n    },\n  },\n  {\n    name: \"SiliconFlow en\",\n    websiteUrl: \"https://siliconflow.com\",\n    apiKeyUrl: \"https://cloud.siliconflow.cn/i/drGuwc9k\",\n    settingsConfig: {\n      baseUrl: \"https://api.siliconflow.com/v1\",\n      apiKey: \"\",\n      api: \"openai-completions\",\n      models: [\n        {\n          id: \"MiniMaxAI/MiniMax-M2.5\",\n          name: \"MiniMax M2.5\",\n          contextWindow: 200000,\n          cost: { input: 0.001, output: 0.004 },\n        },\n      ],\n    },\n    category: \"aggregator\",\n    isPartner: true,\n    partnerPromotionKey: \"siliconflow\",\n    icon: \"siliconflow\",\n    iconColor: \"#000000\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"sk-...\",\n        editorValue: \"\",\n      },\n    },\n    suggestedDefaults: {\n      model: { primary: \"siliconflow-en/MiniMaxAI/MiniMax-M2.5\" },\n      modelCatalog: {\n        \"siliconflow-en/MiniMaxAI/MiniMax-M2.5\": { alias: \"MiniMax\" },\n      },\n    },\n  },\n  {\n    name: \"Novita AI\",\n    websiteUrl: \"https://novita.ai\",\n    apiKeyUrl: \"https://novita.ai\",\n    settingsConfig: {\n      baseUrl: \"https://api.novita.ai/openai\",\n      apiKey: \"\",\n      api: \"openai-completions\",\n      models: [\n        {\n          id: \"zai-org/glm-5\",\n          name: \"GLM-5\",\n          contextWindow: 202800,\n          cost: { input: 1, output: 3.2, cacheRead: 0.2 },\n        },\n      ],\n    },\n    category: \"aggregator\",\n    icon: \"novita\",\n    iconColor: \"#000000\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"sk-...\",\n        editorValue: \"\",\n      },\n    },\n    suggestedDefaults: {\n      model: { primary: \"novita/zai-org/glm-5\" },\n      modelCatalog: {\n        \"novita/zai-org/glm-5\": { alias: \"GLM-5\" },\n      },\n    },\n  },\n  {\n    name: \"Nvidia\",\n    websiteUrl: \"https://build.nvidia.com\",\n    apiKeyUrl: \"https://build.nvidia.com/settings/api-keys\",\n    settingsConfig: {\n      baseUrl: \"https://integrate.api.nvidia.com/v1\",\n      apiKey: \"\",\n      api: \"openai-completions\",\n      models: [\n        {\n          id: \"moonshotai/kimi-k2.5\",\n          name: \"Kimi K2.5\",\n          contextWindow: 131072,\n          cost: { input: 0.002, output: 0.006 },\n        },\n      ],\n    },\n    category: \"aggregator\",\n    icon: \"nvidia\",\n    iconColor: \"#000000\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"nvapi-...\",\n        editorValue: \"\",\n      },\n    },\n    suggestedDefaults: {\n      model: { primary: \"nvidia/moonshotai/kimi-k2.5\" },\n      modelCatalog: { \"nvidia/moonshotai/kimi-k2.5\": { alias: \"Kimi\" } },\n    },\n  },\n\n  // ========== Third Party Partners ==========\n  {\n    name: \"PackyCode\",\n    websiteUrl: \"https://www.packyapi.com\",\n    apiKeyUrl: \"https://www.packyapi.com/register?aff=cc-switch\",\n    settingsConfig: {\n      baseUrl: \"https://www.packyapi.com\",\n      apiKey: \"\",\n      api: \"anthropic-messages\",\n      models: [\n        {\n          id: \"claude-opus-4-6\",\n          name: \"Claude Opus 4.6\",\n          contextWindow: 200000,\n          cost: { input: 5, output: 25 },\n        },\n        {\n          id: \"claude-sonnet-4-6\",\n          name: \"Claude Sonnet 4.6\",\n          contextWindow: 200000,\n          cost: { input: 3, output: 15 },\n        },\n      ],\n    },\n    category: \"third_party\",\n    isPartner: true,\n    partnerPromotionKey: \"packycode\",\n    icon: \"packycode\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n    suggestedDefaults: {\n      model: {\n        primary: \"packycode/claude-opus-4-6\",\n        fallbacks: [\"packycode/claude-sonnet-4-6\"],\n      },\n      modelCatalog: {\n        \"packycode/claude-opus-4-6\": { alias: \"Opus\" },\n        \"packycode/claude-sonnet-4-6\": { alias: \"Sonnet\" },\n      },\n    },\n  },\n  {\n    name: \"Cubence\",\n    websiteUrl: \"https://cubence.com\",\n    apiKeyUrl: \"https://cubence.com/signup?code=CCSWITCH&source=ccs\",\n    settingsConfig: {\n      baseUrl: \"https://api.cubence.com\",\n      apiKey: \"\",\n      api: \"anthropic-messages\",\n      models: [\n        {\n          id: \"claude-opus-4-6\",\n          name: \"Claude Opus 4.6\",\n          contextWindow: 200000,\n          cost: { input: 5, output: 25 },\n        },\n        {\n          id: \"claude-sonnet-4-6\",\n          name: \"Claude Sonnet 4.6\",\n          contextWindow: 200000,\n          cost: { input: 3, output: 15 },\n        },\n      ],\n    },\n    category: \"third_party\",\n    isPartner: true,\n    partnerPromotionKey: \"cubence\",\n    icon: \"cubence\",\n    iconColor: \"#000000\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n    suggestedDefaults: {\n      model: {\n        primary: \"cubence/claude-opus-4-6\",\n        fallbacks: [\"cubence/claude-sonnet-4-6\"],\n      },\n      modelCatalog: {\n        \"cubence/claude-opus-4-6\": { alias: \"Opus\" },\n        \"cubence/claude-sonnet-4-6\": { alias: \"Sonnet\" },\n      },\n    },\n  },\n  {\n    name: \"AIGoCode\",\n    websiteUrl: \"https://aigocode.com\",\n    apiKeyUrl: \"https://aigocode.com/invite/CC-SWITCH\",\n    settingsConfig: {\n      baseUrl: \"https://api.aigocode.com\",\n      apiKey: \"\",\n      api: \"anthropic-messages\",\n      models: [\n        {\n          id: \"claude-opus-4-6\",\n          name: \"Claude Opus 4.6\",\n          contextWindow: 200000,\n          cost: { input: 5, output: 25 },\n        },\n        {\n          id: \"claude-sonnet-4-6\",\n          name: \"Claude Sonnet 4.6\",\n          contextWindow: 200000,\n          cost: { input: 3, output: 15 },\n        },\n      ],\n    },\n    category: \"third_party\",\n    isPartner: true,\n    partnerPromotionKey: \"aigocode\",\n    icon: \"aigocode\",\n    iconColor: \"#5B7FFF\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n    suggestedDefaults: {\n      model: {\n        primary: \"aigocode/claude-opus-4-6\",\n        fallbacks: [\"aigocode/claude-sonnet-4-6\"],\n      },\n      modelCatalog: {\n        \"aigocode/claude-opus-4-6\": { alias: \"Opus\" },\n        \"aigocode/claude-sonnet-4-6\": { alias: \"Sonnet\" },\n      },\n    },\n  },\n  {\n    name: \"RightCode\",\n    websiteUrl: \"https://www.right.codes\",\n    apiKeyUrl: \"https://www.right.codes/register?aff=CCSWITCH\",\n    settingsConfig: {\n      baseUrl: \"https://www.right.codes/claude\",\n      apiKey: \"\",\n      api: \"anthropic-messages\",\n      models: [\n        {\n          id: \"claude-opus-4-6\",\n          name: \"Claude Opus 4.6\",\n          contextWindow: 200000,\n          cost: { input: 5, output: 25 },\n        },\n        {\n          id: \"claude-sonnet-4-6\",\n          name: \"Claude Sonnet 4.6\",\n          contextWindow: 200000,\n          cost: { input: 3, output: 15 },\n        },\n      ],\n    },\n    category: \"third_party\",\n    isPartner: true,\n    partnerPromotionKey: \"rightcode\",\n    icon: \"rc\",\n    iconColor: \"#E96B2C\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n    suggestedDefaults: {\n      model: {\n        primary: \"rightcode/claude-opus-4-6\",\n        fallbacks: [\"rightcode/claude-sonnet-4-6\"],\n      },\n      modelCatalog: {\n        \"rightcode/claude-opus-4-6\": { alias: \"Opus\" },\n        \"rightcode/claude-sonnet-4-6\": { alias: \"Sonnet\" },\n      },\n    },\n  },\n  {\n    name: \"AICodeMirror\",\n    websiteUrl: \"https://www.aicodemirror.com\",\n    apiKeyUrl: \"https://www.aicodemirror.com/register?invitecode=9915W3\",\n    settingsConfig: {\n      baseUrl: \"https://api.aicodemirror.com/api/claudecode\",\n      apiKey: \"\",\n      api: \"anthropic-messages\",\n      models: [\n        {\n          id: \"claude-opus-4-6\",\n          name: \"Claude Opus 4.6\",\n          contextWindow: 200000,\n          cost: { input: 5, output: 25 },\n        },\n        {\n          id: \"claude-sonnet-4-6\",\n          name: \"Claude Sonnet 4.6\",\n          contextWindow: 200000,\n          cost: { input: 3, output: 15 },\n        },\n      ],\n    },\n    category: \"third_party\",\n    isPartner: true,\n    partnerPromotionKey: \"aicodemirror\",\n    icon: \"aicodemirror\",\n    iconColor: \"#000000\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n    suggestedDefaults: {\n      model: {\n        primary: \"aicodemirror/claude-opus-4-6\",\n        fallbacks: [\"aicodemirror/claude-sonnet-4-6\"],\n      },\n      modelCatalog: {\n        \"aicodemirror/claude-opus-4-6\": { alias: \"Opus\" },\n        \"aicodemirror/claude-sonnet-4-6\": { alias: \"Sonnet\" },\n      },\n    },\n  },\n  {\n    name: \"AICoding\",\n    websiteUrl: \"https://aicoding.sh\",\n    apiKeyUrl: \"https://aicoding.sh/i/CCSWITCH\",\n    settingsConfig: {\n      baseUrl: \"https://api.aicoding.sh\",\n      apiKey: \"\",\n      api: \"anthropic-messages\",\n      models: [\n        {\n          id: \"claude-opus-4-6\",\n          name: \"Claude Opus 4.6\",\n          contextWindow: 200000,\n          cost: { input: 5, output: 25 },\n        },\n        {\n          id: \"claude-sonnet-4-6\",\n          name: \"Claude Sonnet 4.6\",\n          contextWindow: 200000,\n          cost: { input: 3, output: 15 },\n        },\n      ],\n    },\n    category: \"third_party\",\n    isPartner: true,\n    partnerPromotionKey: \"aicoding\",\n    icon: \"aicoding\",\n    iconColor: \"#000000\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n    suggestedDefaults: {\n      model: {\n        primary: \"aicoding/claude-opus-4-6\",\n        fallbacks: [\"aicoding/claude-sonnet-4-6\"],\n      },\n      modelCatalog: {\n        \"aicoding/claude-opus-4-6\": { alias: \"Opus\" },\n        \"aicoding/claude-sonnet-4-6\": { alias: \"Sonnet\" },\n      },\n    },\n  },\n  {\n    name: \"CrazyRouter\",\n    websiteUrl: \"https://www.crazyrouter.com\",\n    apiKeyUrl: \"https://www.crazyrouter.com/register?aff=OZcm&ref=cc-switch\",\n    settingsConfig: {\n      baseUrl: \"https://crazyrouter.com/v1\",\n      apiKey: \"\",\n      api: \"anthropic-messages\",\n      models: [\n        {\n          id: \"claude-opus-4-6\",\n          name: \"Claude Opus 4.6\",\n          contextWindow: 200000,\n          cost: { input: 5, output: 25 },\n        },\n        {\n          id: \"claude-sonnet-4-6\",\n          name: \"Claude Sonnet 4.6\",\n          contextWindow: 200000,\n          cost: { input: 3, output: 15 },\n        },\n      ],\n    },\n    category: \"third_party\",\n    isPartner: true,\n    partnerPromotionKey: \"crazyrouter\",\n    icon: \"crazyrouter\",\n    iconColor: \"#000000\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n    suggestedDefaults: {\n      model: {\n        primary: \"crazyrouter/claude-opus-4-6\",\n        fallbacks: [\"crazyrouter/claude-sonnet-4-6\"],\n      },\n      modelCatalog: {\n        \"crazyrouter/claude-opus-4-6\": { alias: \"Opus\" },\n        \"crazyrouter/claude-sonnet-4-6\": { alias: \"Sonnet\" },\n      },\n    },\n  },\n  {\n    name: \"SSSAiCode\",\n    websiteUrl: \"https://www.sssaicode.com\",\n    apiKeyUrl: \"https://www.sssaicode.com/register?ref=DCP0SM\",\n    settingsConfig: {\n      baseUrl: \"https://node-hk.sssaicode.com/api\",\n      apiKey: \"\",\n      api: \"anthropic-messages\",\n      models: [\n        {\n          id: \"claude-opus-4-6\",\n          name: \"Claude Opus 4.6\",\n          contextWindow: 200000,\n          cost: { input: 5, output: 25 },\n        },\n        {\n          id: \"claude-sonnet-4-6\",\n          name: \"Claude Sonnet 4.6\",\n          contextWindow: 200000,\n          cost: { input: 3, output: 15 },\n        },\n      ],\n    },\n    category: \"third_party\",\n    isPartner: true,\n    partnerPromotionKey: \"sssaicode\",\n    icon: \"sssaicode\",\n    iconColor: \"#000000\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n    suggestedDefaults: {\n      model: {\n        primary: \"sssaicode/claude-opus-4-6\",\n        fallbacks: [\"sssaicode/claude-sonnet-4-6\"],\n      },\n      modelCatalog: {\n        \"sssaicode/claude-opus-4-6\": { alias: \"Opus\" },\n        \"sssaicode/claude-sonnet-4-6\": { alias: \"Sonnet\" },\n      },\n    },\n  },\n  {\n    name: \"Compshare\",\n    nameKey: \"providerForm.presets.ucloud\",\n    websiteUrl: \"https://www.compshare.cn\",\n    apiKeyUrl:\n      \"https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch\",\n    settingsConfig: {\n      baseUrl: \"https://api.modelverse.cn/v1\",\n      apiKey: \"\",\n      api: \"anthropic-messages\",\n      models: [\n        {\n          id: \"claude-opus-4-6\",\n          name: \"Claude Opus 4.6\",\n          contextWindow: 200000,\n          cost: { input: 5, output: 25 },\n        },\n      ],\n    },\n    category: \"aggregator\",\n    isPartner: true, // 合作伙伴\n    partnerPromotionKey: \"ucloud\", // 促销信息 i18n key\n    icon: \"ucloud\",\n    iconColor: \"#000000\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n    suggestedDefaults: {\n      model: {\n        primary: \"compshare/claude-opus-4-6\",\n      },\n      modelCatalog: {\n        \"compshare/claude-opus-4-6\": { alias: \"Opus\" },\n      },\n    },\n  },\n  {\n    name: \"Micu\",\n    websiteUrl: \"https://www.openclaudecode.cn\",\n    apiKeyUrl: \"https://www.openclaudecode.cn/register?aff=aOYQ\",\n    settingsConfig: {\n      baseUrl: \"https://www.openclaudecode.cn\",\n      apiKey: \"\",\n      api: \"anthropic-messages\",\n      models: [\n        {\n          id: \"claude-opus-4-6\",\n          name: \"Claude Opus 4.6\",\n          contextWindow: 200000,\n          cost: { input: 5, output: 25 },\n        },\n      ],\n    },\n    category: \"third_party\",\n    isPartner: true,\n    partnerPromotionKey: \"micu\",\n    icon: \"micu\",\n    iconColor: \"#000000\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n    suggestedDefaults: {\n      model: {\n        primary: \"micu/claude-opus-4-6\",\n      },\n      modelCatalog: {\n        \"micu/claude-opus-4-6\": { alias: \"Opus\" },\n      },\n    },\n  },\n  {\n    name: \"CTok.ai\",\n    websiteUrl: \"https://ctok.ai\",\n    apiKeyUrl: \"https://ctok.ai\",\n    settingsConfig: {\n      baseUrl: \"https://api.ctok.ai\",\n      apiKey: \"\",\n      api: \"anthropic-messages\",\n      models: [\n        {\n          id: \"claude-opus-4-6\",\n          name: \"Claude Opus 4.6\",\n          contextWindow: 200000,\n          cost: { input: 5, output: 25 },\n        },\n      ],\n    },\n    category: \"third_party\",\n    isPartner: true,\n    partnerPromotionKey: \"ctok\",\n    icon: \"ctok\",\n    iconColor: \"#000000\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n    suggestedDefaults: {\n      model: {\n        primary: \"ctok/claude-opus-4-6\",\n      },\n      modelCatalog: {\n        \"ctok/claude-opus-4-6\": { alias: \"Opus\" },\n      },\n    },\n  },\n  // ========== Cloud Providers ==========\n  {\n    name: \"AWS Bedrock\",\n    websiteUrl: \"https://aws.amazon.com/bedrock/\",\n    settingsConfig: {\n      // 请将 us-west-2 替换为你的 AWS Region\n      baseUrl: \"https://bedrock-runtime.us-west-2.amazonaws.com\",\n      apiKey: \"\",\n      api: \"bedrock-converse-stream\",\n      models: [\n        {\n          id: \"anthropic.claude-opus-4-6-20250514-v1:0\",\n          name: \"Claude Opus 4.6\",\n          contextWindow: 200000,\n          cost: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },\n        },\n        {\n          id: \"anthropic.claude-sonnet-4-6\",\n          name: \"Claude Sonnet 4.6\",\n          contextWindow: 200000,\n          cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },\n        },\n        {\n          id: \"anthropic.claude-haiku-4-5-20251022-v1:0\",\n          name: \"Claude Haiku 4.5\",\n          contextWindow: 200000,\n          cost: { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 },\n        },\n      ],\n    },\n    category: \"cloud_provider\",\n    icon: \"aws\",\n    iconColor: \"#FF9900\",\n  },\n\n  // ========== Custom Template ==========\n  {\n    name: \"OpenAI Compatible\",\n    websiteUrl: \"\",\n    settingsConfig: {\n      baseUrl: \"\",\n      apiKey: \"\",\n      api: \"openai-completions\",\n      models: [],\n    },\n    category: \"custom\",\n    isCustomTemplate: true,\n    icon: \"generic\",\n    iconColor: \"#6B7280\",\n    templateValues: {\n      baseUrl: {\n        label: \"Base URL\",\n        placeholder: \"https://api.example.com/v1\",\n        editorValue: \"\",\n      },\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n  },\n];\n"
  },
  {
    "path": "src/config/opencodeProviderPresets.ts",
    "content": "import type { ProviderCategory, OpenCodeProviderConfig } from \"../types\";\nimport type { PresetTheme, TemplateValueConfig } from \"./claudeProviderPresets\";\n\nexport interface OpenCodeProviderPreset {\n  name: string;\n  nameKey?: string; // i18n key for localized display name\n  websiteUrl: string;\n  apiKeyUrl?: string;\n  settingsConfig: OpenCodeProviderConfig;\n  isOfficial?: boolean;\n  isPartner?: boolean;\n  partnerPromotionKey?: string;\n  category?: ProviderCategory;\n  templateValues?: Record<string, TemplateValueConfig>;\n  theme?: PresetTheme;\n  icon?: string;\n  iconColor?: string;\n  isCustomTemplate?: boolean;\n}\n\nexport const opencodeNpmPackages = [\n  { value: \"@ai-sdk/openai\", label: \"OpenAI Responses\" },\n  { value: \"@ai-sdk/openai-compatible\", label: \"OpenAI Compatible\" },\n  { value: \"@ai-sdk/anthropic\", label: \"Anthropic\" },\n  { value: \"@ai-sdk/amazon-bedrock\", label: \"Amazon Bedrock\" },\n  { value: \"@ai-sdk/google\", label: \"Google (Gemini)\" },\n] as const;\n\nexport interface PresetModelVariant {\n  id: string;\n  name?: string;\n  contextLimit?: number;\n  outputLimit?: number;\n  modalities?: { input: string[]; output: string[] };\n  options?: Record<string, unknown>;\n  variants?: Record<string, Record<string, unknown>>;\n}\n\nexport const OPENCODE_PRESET_MODEL_VARIANTS: Record<\n  string,\n  PresetModelVariant[]\n> = {\n  \"@ai-sdk/openai-compatible\": [\n    {\n      id: \"MiniMax-M2.5\",\n      name: \"MiniMax M2.5\",\n      contextLimit: 204800,\n      outputLimit: 131072,\n      modalities: { input: [\"text\"], output: [\"text\"] },\n    },\n    {\n      id: \"glm-5\",\n      name: \"GLM 5\",\n      contextLimit: 204800,\n      outputLimit: 131072,\n      modalities: { input: [\"text\"], output: [\"text\"] },\n    },\n    {\n      id: \"kimi-k2.5\",\n      name: \"Kimi K2.5\",\n      contextLimit: 262144,\n      outputLimit: 262144,\n      modalities: { input: [\"text\", \"image\", \"video\"], output: [\"text\"] },\n    },\n    {\n      id: \"step-3.5-flash\",\n      name: \"Step 3.5 Flash\",\n      contextLimit: 262144,\n    },\n  ],\n  \"@ai-sdk/google\": [\n    {\n      id: \"gemini-2.5-flash-lite\",\n      name: \"Gemini 2.5 Flash Lite\",\n      contextLimit: 1048576,\n      outputLimit: 65536,\n      modalities: {\n        input: [\"text\", \"image\", \"pdf\", \"video\", \"audio\"],\n        output: [\"text\"],\n      },\n      variants: {\n        auto: {\n          thinkingConfig: { includeThoughts: true, thinkingBudget: -1 },\n        },\n        \"no-thinking\": { thinkingConfig: { thinkingBudget: 0 } },\n      },\n    },\n    {\n      id: \"gemini-3-flash-preview\",\n      name: \"Gemini 3 Flash Preview\",\n      contextLimit: 1048576,\n      outputLimit: 65536,\n      modalities: {\n        input: [\"text\", \"image\", \"pdf\", \"video\", \"audio\"],\n        output: [\"text\"],\n      },\n      variants: {\n        minimal: {\n          thinkingConfig: { includeThoughts: true, thinkingLevel: \"minimal\" },\n        },\n        low: {\n          thinkingConfig: { includeThoughts: true, thinkingLevel: \"low\" },\n        },\n        medium: {\n          thinkingConfig: { includeThoughts: true, thinkingLevel: \"medium\" },\n        },\n        high: {\n          thinkingConfig: { includeThoughts: true, thinkingLevel: \"high\" },\n        },\n      },\n    },\n    {\n      id: \"gemini-3-pro-preview\",\n      name: \"Gemini 3 Pro Preview\",\n      contextLimit: 1048576,\n      outputLimit: 65536,\n      modalities: {\n        input: [\"text\", \"image\", \"pdf\", \"video\", \"audio\"],\n        output: [\"text\"],\n      },\n      variants: {\n        low: {\n          thinkingConfig: { includeThoughts: true, thinkingLevel: \"low\" },\n        },\n        high: {\n          thinkingConfig: { includeThoughts: true, thinkingLevel: \"high\" },\n        },\n      },\n    },\n  ],\n  \"@ai-sdk/openai\": [\n    {\n      id: \"gpt-5.4\",\n      name: \"GPT-5.4\",\n      contextLimit: 400000,\n      outputLimit: 128000,\n      modalities: { input: [\"text\", \"image\"], output: [\"text\"] },\n      variants: {\n        low: {\n          reasoningEffort: \"low\",\n          reasoningSummary: \"auto\",\n          textVerbosity: \"medium\",\n        },\n        medium: {\n          reasoningEffort: \"medium\",\n          reasoningSummary: \"auto\",\n          textVerbosity: \"medium\",\n        },\n        high: {\n          reasoningEffort: \"high\",\n          reasoningSummary: \"auto\",\n          textVerbosity: \"medium\",\n        },\n        xhigh: {\n          reasoningEffort: \"xhigh\",\n          reasoningSummary: \"auto\",\n          textVerbosity: \"medium\",\n        },\n      },\n    },\n  ],\n  \"@ai-sdk/amazon-bedrock\": [\n    {\n      id: \"global.anthropic.claude-opus-4-6-v1\",\n      name: \"Claude Opus 4.6\",\n      contextLimit: 1000000,\n      outputLimit: 128000,\n      modalities: { input: [\"text\", \"image\", \"pdf\"], output: [\"text\"] },\n    },\n    {\n      id: \"global.anthropic.claude-sonnet-4-6\",\n      name: \"Claude Sonnet 4.6\",\n      contextLimit: 200000,\n      outputLimit: 64000,\n      modalities: { input: [\"text\", \"image\", \"pdf\"], output: [\"text\"] },\n    },\n    {\n      id: \"global.anthropic.claude-haiku-4-5-20251001-v1:0\",\n      name: \"Claude Haiku 4.5\",\n      contextLimit: 200000,\n      outputLimit: 64000,\n      modalities: { input: [\"text\", \"image\", \"pdf\"], output: [\"text\"] },\n    },\n    {\n      id: \"us.amazon.nova-pro-v1:0\",\n      name: \"Amazon Nova Pro\",\n      contextLimit: 300000,\n      outputLimit: 5000,\n      modalities: { input: [\"text\", \"image\"], output: [\"text\"] },\n    },\n    {\n      id: \"us.meta.llama4-maverick-17b-instruct-v1:0\",\n      name: \"Meta Llama 4 Maverick\",\n      contextLimit: 131072,\n      outputLimit: 131072,\n      modalities: { input: [\"text\"], output: [\"text\"] },\n    },\n    {\n      id: \"us.deepseek.r1-v1:0\",\n      name: \"DeepSeek R1\",\n      contextLimit: 131072,\n      outputLimit: 131072,\n      modalities: { input: [\"text\"], output: [\"text\"] },\n    },\n  ],\n  \"@ai-sdk/anthropic\": [\n    {\n      id: \"claude-sonnet-4-5-20250929\",\n      name: \"Claude Sonnet 4.5\",\n      contextLimit: 200000,\n      outputLimit: 64000,\n      modalities: { input: [\"text\", \"image\", \"pdf\"], output: [\"text\"] },\n      variants: {\n        low: { effort: \"low\" },\n        medium: { effort: \"medium\" },\n        high: { effort: \"high\" },\n      },\n    },\n    {\n      id: \"claude-opus-4-5-20251101\",\n      name: \"Claude Opus 4.5\",\n      contextLimit: 200000,\n      outputLimit: 64000,\n      modalities: { input: [\"text\", \"image\", \"pdf\"], output: [\"text\"] },\n      variants: {\n        low: { thinking: { budgetTokens: 5000, type: \"enabled\" } },\n        medium: { thinking: { budgetTokens: 13000, type: \"enabled\" } },\n        high: { thinking: { budgetTokens: 18000, type: \"enabled\" } },\n      },\n    },\n    {\n      id: \"claude-opus-4-6\",\n      name: \"Claude Opus 4.6\",\n      contextLimit: 1000000,\n      outputLimit: 128000,\n      modalities: { input: [\"text\", \"image\", \"pdf\"], output: [\"text\"] },\n      variants: {\n        low: { effort: \"low\" },\n        medium: { effort: \"medium\" },\n        high: { effort: \"high\" },\n        max: { effort: \"max\" },\n      },\n    },\n    {\n      id: \"claude-haiku-4-5-20251001\",\n      name: \"Claude Haiku 4.5\",\n      contextLimit: 200000,\n      outputLimit: 64000,\n      modalities: { input: [\"text\", \"image\", \"pdf\"], output: [\"text\"] },\n    },\n    {\n      id: \"gemini-claude-opus-4-5-thinking\",\n      name: \"Antigravity - Claude Opus 4.5\",\n      contextLimit: 200000,\n      outputLimit: 64000,\n      modalities: { input: [\"text\", \"image\", \"pdf\"], output: [\"text\"] },\n      variants: {\n        low: { effort: \"low\" },\n        medium: { effort: \"medium\" },\n        high: { effort: \"high\" },\n      },\n    },\n    {\n      id: \"gemini-claude-sonnet-4-5-thinking\",\n      name: \"Antigravity - Claude Sonnet 4.5\",\n      contextLimit: 200000,\n      outputLimit: 64000,\n      modalities: { input: [\"text\", \"image\", \"pdf\"], output: [\"text\"] },\n      variants: {\n        low: { thinking: { budgetTokens: 5000, type: \"enabled\" } },\n        medium: { thinking: { budgetTokens: 13000, type: \"enabled\" } },\n        high: { thinking: { budgetTokens: 18000, type: \"enabled\" } },\n      },\n    },\n  ],\n};\n\n/**\n * Look up preset metadata for a model by npm package and model ID.\n * Returns enrichment fields (options, limit, modalities) that can be\n * merged into a model definition when the user's config doesn't already\n * provide them.\n */\nexport function getPresetModelDefaults(\n  npm: string,\n  modelId: string,\n): PresetModelVariant | undefined {\n  const models = OPENCODE_PRESET_MODEL_VARIANTS[npm];\n  if (!models) return undefined;\n  return models.find((m) => m.id === modelId);\n}\n\nexport const opencodeProviderPresets: OpenCodeProviderPreset[] = [\n  {\n    name: \"DeepSeek\",\n    websiteUrl: \"https://platform.deepseek.com\",\n    apiKeyUrl: \"https://platform.deepseek.com/api_keys\",\n    settingsConfig: {\n      npm: \"@ai-sdk/openai-compatible\",\n      options: {\n        baseURL: \"https://api.deepseek.com/v1\",\n        apiKey: \"\",\n        setCacheKey: true,\n      },\n      models: {\n        \"deepseek-chat\": { name: \"DeepSeek V3.2\" },\n        \"deepseek-reasoner\": { name: \"DeepSeek R1\" },\n      },\n    },\n    category: \"cn_official\",\n    icon: \"deepseek\",\n    iconColor: \"#1E88E5\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"sk-...\",\n        editorValue: \"\",\n      },\n    },\n  },\n  {\n    name: \"Zhipu GLM\",\n    websiteUrl: \"https://open.bigmodel.cn\",\n    apiKeyUrl: \"https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII\",\n    settingsConfig: {\n      npm: \"@ai-sdk/openai-compatible\",\n      name: \"Zhipu GLM\",\n      options: {\n        baseURL: \"https://open.bigmodel.cn/api/paas/v4\",\n        apiKey: \"\",\n        setCacheKey: true,\n      },\n      models: {\n        \"glm-5\": { name: \"GLM-5\" },\n      },\n    },\n    category: \"cn_official\",\n    icon: \"zhipu\",\n    iconColor: \"#0F62FE\",\n    templateValues: {\n      baseURL: {\n        label: \"Base URL\",\n        placeholder: \"https://open.bigmodel.cn/api/paas/v4\",\n        defaultValue: \"https://open.bigmodel.cn/api/paas/v4\",\n        editorValue: \"\",\n      },\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n  },\n  {\n    name: \"Zhipu GLM en\",\n    websiteUrl: \"https://z.ai\",\n    apiKeyUrl: \"https://z.ai/subscribe?ic=8JVLJQFSKB\",\n    settingsConfig: {\n      npm: \"@ai-sdk/openai-compatible\",\n      name: \"Zhipu GLM en\",\n      options: {\n        baseURL: \"https://api.z.ai/v1\",\n        apiKey: \"\",\n        setCacheKey: true,\n      },\n      models: {\n        \"glm-5\": { name: \"GLM-5\" },\n      },\n    },\n    category: \"cn_official\",\n    icon: \"zhipu\",\n    iconColor: \"#0F62FE\",\n    templateValues: {\n      baseURL: {\n        label: \"Base URL\",\n        placeholder: \"https://api.z.ai/v1\",\n        defaultValue: \"https://api.z.ai/v1\",\n        editorValue: \"\",\n      },\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n  },\n  {\n    name: \"Bailian\",\n    websiteUrl: \"https://bailian.console.aliyun.com\",\n    apiKeyUrl: \"https://bailian.console.aliyun.com/#/api-key\",\n    settingsConfig: {\n      npm: \"@ai-sdk/openai-compatible\",\n      name: \"Bailian\",\n      options: {\n        baseURL: \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n        apiKey: \"\",\n        setCacheKey: true,\n      },\n      models: {},\n    },\n    category: \"cn_official\",\n    icon: \"bailian\",\n    iconColor: \"#624AFF\",\n    templateValues: {\n      baseURL: {\n        label: \"Base URL\",\n        placeholder: \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n        defaultValue: \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n        editorValue: \"\",\n      },\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"sk-...\",\n        editorValue: \"\",\n      },\n    },\n  },\n  {\n    name: \"Kimi k2.5\",\n    websiteUrl: \"https://platform.moonshot.cn/console\",\n    apiKeyUrl: \"https://platform.moonshot.cn/console/api-keys\",\n    settingsConfig: {\n      npm: \"@ai-sdk/openai-compatible\",\n      name: \"Kimi k2.5\",\n      options: {\n        baseURL: \"https://api.moonshot.cn/v1\",\n        apiKey: \"\",\n        setCacheKey: true,\n      },\n      models: {\n        \"kimi-k2.5\": { name: \"Kimi K2.5\" },\n      },\n    },\n    category: \"cn_official\",\n    icon: \"kimi\",\n    iconColor: \"#6366F1\",\n    templateValues: {\n      baseURL: {\n        label: \"Base URL\",\n        placeholder: \"https://api.moonshot.cn/v1\",\n        defaultValue: \"https://api.moonshot.cn/v1\",\n        editorValue: \"\",\n      },\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"sk-...\",\n        editorValue: \"\",\n      },\n    },\n  },\n  {\n    name: \"Kimi For Coding\",\n    websiteUrl: \"https://www.kimi.com/coding/docs/\",\n    apiKeyUrl: \"https://platform.moonshot.cn/console/api-keys\",\n    settingsConfig: {\n      npm: \"@ai-sdk/openai-compatible\",\n      name: \"Kimi For Coding\",\n      options: {\n        baseURL: \"https://api.kimi.com/v1\",\n        apiKey: \"\",\n        setCacheKey: true,\n      },\n      models: {\n        \"kimi-for-coding\": { name: \"Kimi For Coding\" },\n      },\n    },\n    category: \"cn_official\",\n    icon: \"kimi\",\n    iconColor: \"#6366F1\",\n    templateValues: {\n      baseURL: {\n        label: \"Base URL\",\n        placeholder: \"https://api.kimi.com/v1\",\n        defaultValue: \"https://api.kimi.com/v1\",\n        editorValue: \"\",\n      },\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"sk-...\",\n        editorValue: \"\",\n      },\n    },\n  },\n  {\n    name: \"StepFun\",\n    websiteUrl: \"https://platform.stepfun.ai\",\n    apiKeyUrl: \"https://platform.stepfun.ai/interface-key\",\n    settingsConfig: {\n      npm: \"@ai-sdk/openai-compatible\",\n      name: \"StepFun\",\n      options: {\n        baseURL: \"https://api.stepfun.ai/v1\",\n        apiKey: \"\",\n        setCacheKey: true,\n      },\n      models: {\n        \"step-3.5-flash\": { name: \"Step 3.5 Flash\" },\n      },\n    },\n    category: \"cn_official\",\n    icon: \"stepfun\",\n    iconColor: \"#005AFF\",\n    templateValues: {\n      baseURL: {\n        label: \"Base URL\",\n        placeholder: \"https://api.stepfun.ai/v1\",\n        defaultValue: \"https://api.stepfun.ai/v1\",\n        editorValue: \"\",\n      },\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"step-...\",\n        editorValue: \"\",\n      },\n    },\n  },\n  {\n    name: \"ModelScope\",\n    websiteUrl: \"https://modelscope.cn\",\n    apiKeyUrl: \"https://modelscope.cn/my/myaccesstoken\",\n    settingsConfig: {\n      npm: \"@ai-sdk/openai-compatible\",\n      name: \"ModelScope\",\n      options: {\n        baseURL: \"https://api-inference.modelscope.cn/v1\",\n        apiKey: \"\",\n        setCacheKey: true,\n      },\n      models: {\n        \"ZhipuAI/GLM-5\": { name: \"GLM-5\" },\n      },\n    },\n    category: \"aggregator\",\n    icon: \"modelscope\",\n    iconColor: \"#624AFF\",\n    templateValues: {\n      baseURL: {\n        label: \"Base URL\",\n        placeholder: \"https://api-inference.modelscope.cn/v1\",\n        defaultValue: \"https://api-inference.modelscope.cn/v1\",\n        editorValue: \"\",\n      },\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n  },\n  {\n    name: \"KAT-Coder\",\n    websiteUrl: \"https://console.streamlake.ai\",\n    apiKeyUrl: \"https://console.streamlake.ai/console/api-key\",\n    settingsConfig: {\n      npm: \"@ai-sdk/openai-compatible\",\n      name: \"KAT-Coder\",\n      options: {\n        baseURL:\n          \"https://vanchin.streamlake.ai/api/gateway/v1/endpoints/${ENDPOINT_ID}/openai\",\n        apiKey: \"\",\n        setCacheKey: true,\n      },\n      models: {\n        \"KAT-Coder-Pro\": { name: \"KAT-Coder Pro\" },\n      },\n    },\n    category: \"cn_official\",\n    templateValues: {\n      baseURL: {\n        label: \"Base URL\",\n        placeholder:\n          \"https://vanchin.streamlake.ai/api/gateway/v1/endpoints/${ENDPOINT_ID}/openai\",\n        defaultValue:\n          \"https://vanchin.streamlake.ai/api/gateway/v1/endpoints/${ENDPOINT_ID}/openai\",\n        editorValue: \"\",\n      },\n      ENDPOINT_ID: {\n        label: \"Vanchin Endpoint ID\",\n        placeholder: \"ep-xxx-xxx\",\n        defaultValue: \"\",\n        editorValue: \"\",\n      },\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n    icon: \"catcoder\",\n  },\n  {\n    name: \"Longcat\",\n    websiteUrl: \"https://longcat.chat/platform\",\n    apiKeyUrl: \"https://longcat.chat/platform/api_keys\",\n    settingsConfig: {\n      npm: \"@ai-sdk/openai-compatible\",\n      name: \"Longcat\",\n      options: {\n        baseURL: \"https://api.longcat.chat/v1\",\n        apiKey: \"\",\n        setCacheKey: true,\n      },\n      models: {\n        \"LongCat-Flash-Chat\": { name: \"LongCat Flash Chat\" },\n      },\n    },\n    category: \"cn_official\",\n    icon: \"longcat\",\n    iconColor: \"#29E154\",\n    templateValues: {\n      baseURL: {\n        label: \"Base URL\",\n        placeholder: \"https://api.longcat.chat/v1\",\n        defaultValue: \"https://api.longcat.chat/v1\",\n        editorValue: \"\",\n      },\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n  },\n  {\n    name: \"MiniMax\",\n    websiteUrl: \"https://platform.minimaxi.com\",\n    apiKeyUrl: \"https://platform.minimaxi.com/subscribe/coding-plan\",\n    settingsConfig: {\n      npm: \"@ai-sdk/openai-compatible\",\n      name: \"MiniMax\",\n      options: {\n        baseURL: \"https://api.minimaxi.com/v1\",\n        apiKey: \"\",\n        setCacheKey: true,\n      },\n      models: {\n        \"MiniMax-M2.5\": { name: \"MiniMax M2.5\" },\n      },\n    },\n    category: \"cn_official\",\n    isPartner: true,\n    partnerPromotionKey: \"minimax_cn\",\n    theme: {\n      backgroundColor: \"#f64551\",\n      textColor: \"#FFFFFF\",\n    },\n    icon: \"minimax\",\n    iconColor: \"#FF6B6B\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n  },\n  {\n    name: \"MiniMax en\",\n    websiteUrl: \"https://platform.minimax.io\",\n    apiKeyUrl: \"https://platform.minimax.io/subscribe/coding-plan\",\n    settingsConfig: {\n      npm: \"@ai-sdk/openai-compatible\",\n      name: \"MiniMax en\",\n      options: {\n        baseURL: \"https://api.minimax.io/v1\",\n        apiKey: \"\",\n        setCacheKey: true,\n      },\n      models: {\n        \"MiniMax-M2.5\": { name: \"MiniMax M2.5\" },\n      },\n    },\n    category: \"cn_official\",\n    isPartner: true,\n    partnerPromotionKey: \"minimax_en\",\n    theme: {\n      backgroundColor: \"#f64551\",\n      textColor: \"#FFFFFF\",\n    },\n    icon: \"minimax\",\n    iconColor: \"#FF6B6B\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n  },\n  {\n    name: \"DouBaoSeed\",\n    websiteUrl: \"https://www.volcengine.com/product/doubao\",\n    apiKeyUrl: \"https://www.volcengine.com/product/doubao\",\n    settingsConfig: {\n      npm: \"@ai-sdk/openai-compatible\",\n      name: \"DouBaoSeed\",\n      options: {\n        baseURL: \"https://ark.cn-beijing.volces.com/api/v3\",\n        apiKey: \"\",\n        setCacheKey: true,\n      },\n      models: {\n        \"doubao-seed-2-0-code-preview-latest\": {\n          name: \"Doubao Seed Code Preview\",\n        },\n      },\n    },\n    category: \"cn_official\",\n    icon: \"doubao\",\n    iconColor: \"#3370FF\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n  },\n  {\n    name: \"BaiLing\",\n    websiteUrl: \"https://alipaytbox.yuque.com/sxs0ba/ling/get_started\",\n    settingsConfig: {\n      npm: \"@ai-sdk/openai-compatible\",\n      name: \"BaiLing\",\n      options: {\n        baseURL: \"https://api.tbox.cn/v1\",\n        apiKey: \"\",\n        setCacheKey: true,\n      },\n      models: {\n        \"Ling-2.5-1T\": { name: \"Ling 2.5-1T\" },\n      },\n    },\n    category: \"cn_official\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n  },\n  {\n    name: \"Xiaomi MiMo\",\n    websiteUrl: \"https://platform.xiaomimimo.com\",\n    apiKeyUrl: \"https://platform.xiaomimimo.com/#/console/api-keys\",\n    settingsConfig: {\n      npm: \"@ai-sdk/openai-compatible\",\n      name: \"Xiaomi MiMo\",\n      options: {\n        baseURL: \"https://api.xiaomimimo.com/v1\",\n        apiKey: \"\",\n        setCacheKey: true,\n      },\n      models: {\n        \"mimo-v2-flash\": { name: \"MiMo V2 Flash\" },\n      },\n    },\n    category: \"cn_official\",\n    icon: \"xiaomimimo\",\n    iconColor: \"#000000\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n  },\n\n  {\n    name: \"AiHubMix\",\n    websiteUrl: \"https://aihubmix.com\",\n    apiKeyUrl: \"https://aihubmix.com\",\n    settingsConfig: {\n      npm: \"@ai-sdk/anthropic\",\n      name: \"AiHubMix\",\n      options: {\n        baseURL: \"https://aihubmix.com/v1\",\n        apiKey: \"\",\n        setCacheKey: true,\n      },\n      models: {\n        \"claude-sonnet-4-6\": { name: \"Claude Sonnet 4.6\" },\n        \"claude-opus-4-6\": { name: \"Claude Opus 4.6\" },\n      },\n    },\n    category: \"aggregator\",\n    icon: \"aihubmix\",\n    iconColor: \"#006FFB\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n  },\n  {\n    name: \"DMXAPI\",\n    websiteUrl: \"https://www.dmxapi.cn\",\n    apiKeyUrl: \"https://www.dmxapi.cn\",\n    settingsConfig: {\n      npm: \"@ai-sdk/anthropic\",\n      name: \"DMXAPI\",\n      options: {\n        baseURL: \"https://www.dmxapi.cn/v1\",\n        apiKey: \"\",\n        setCacheKey: true,\n      },\n      models: {\n        \"claude-sonnet-4-6\": { name: \"Claude Sonnet 4.6\" },\n        \"claude-opus-4-6\": { name: \"Claude Opus 4.6\" },\n      },\n    },\n    category: \"aggregator\",\n    isPartner: true,\n    partnerPromotionKey: \"dmxapi\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n  },\n  {\n    name: \"OpenRouter\",\n    websiteUrl: \"https://openrouter.ai\",\n    apiKeyUrl: \"https://openrouter.ai/keys\",\n    settingsConfig: {\n      npm: \"@ai-sdk/anthropic\",\n      name: \"OpenRouter\",\n      options: {\n        baseURL: \"https://openrouter.ai/api/v1\",\n        apiKey: \"\",\n        setCacheKey: true,\n      },\n      models: {\n        \"anthropic/claude-sonnet-4.6\": { name: \"Claude Sonnet 4.6\" },\n        \"anthropic/claude-opus-4.6\": { name: \"Claude Opus 4.6\" },\n      },\n    },\n    category: \"aggregator\",\n    icon: \"openrouter\",\n    iconColor: \"#6566F1\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"sk-or-...\",\n        editorValue: \"\",\n      },\n    },\n  },\n  {\n    name: \"Novita AI\",\n    websiteUrl: \"https://novita.ai\",\n    apiKeyUrl: \"https://novita.ai\",\n    settingsConfig: {\n      npm: \"@ai-sdk/openai-compatible\",\n      name: \"Novita AI\",\n      options: {\n        baseURL: \"https://api.novita.ai/openai\",\n        apiKey: \"\",\n        setCacheKey: true,\n      },\n      models: {\n        \"zai-org/glm-5\": { name: \"GLM-5\" },\n      },\n    },\n    category: \"aggregator\",\n    icon: \"novita\",\n    iconColor: \"#000000\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n  },\n  {\n    name: \"Nvidia\",\n    websiteUrl: \"https://build.nvidia.com\",\n    apiKeyUrl: \"https://build.nvidia.com/settings/api-keys\",\n    settingsConfig: {\n      npm: \"@ai-sdk/openai-compatible\",\n      name: \"Nvidia\",\n      options: {\n        baseURL: \"https://integrate.api.nvidia.com/v1\",\n        apiKey: \"\",\n        setCacheKey: true,\n      },\n      models: {\n        \"moonshotai/kimi-k2.5\": { name: \"Kimi K2.5\" },\n      },\n    },\n    category: \"aggregator\",\n    icon: \"nvidia\",\n    iconColor: \"#000000\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n  },\n\n  {\n    name: \"PackyCode\",\n    websiteUrl: \"https://www.packyapi.com\",\n    apiKeyUrl: \"https://www.packyapi.com/register?aff=cc-switch\",\n    settingsConfig: {\n      npm: \"@ai-sdk/anthropic\",\n      name: \"PackyCode\",\n      options: {\n        baseURL: \"https://www.packyapi.com/v1\",\n        apiKey: \"\",\n        setCacheKey: true,\n      },\n      models: {\n        \"claude-sonnet-4-6\": { name: \"Claude Sonnet 4.6\" },\n        \"claude-opus-4-6\": { name: \"Claude Opus 4.6\" },\n      },\n    },\n    category: \"third_party\",\n    isPartner: true,\n    partnerPromotionKey: \"packycode\",\n    icon: \"packycode\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n  },\n  {\n    name: \"Cubence\",\n    websiteUrl: \"https://cubence.com\",\n    apiKeyUrl: \"https://cubence.com/signup?code=CCSWITCH&source=ccs\",\n    settingsConfig: {\n      npm: \"@ai-sdk/anthropic\",\n      name: \"Cubence\",\n      options: {\n        baseURL: \"https://api.cubence.com/v1\",\n        apiKey: \"\",\n        setCacheKey: true,\n      },\n      models: {\n        \"claude-sonnet-4-6\": { name: \"Claude Sonnet 4.6\" },\n        \"claude-opus-4-6\": { name: \"Claude Opus 4.6\" },\n      },\n    },\n    category: \"third_party\",\n    isPartner: true,\n    partnerPromotionKey: \"cubence\",\n    icon: \"cubence\",\n    iconColor: \"#000000\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n  },\n  {\n    name: \"AIGoCode\",\n    websiteUrl: \"https://aigocode.com\",\n    apiKeyUrl: \"https://aigocode.com/invite/CC-SWITCH\",\n    settingsConfig: {\n      npm: \"@ai-sdk/anthropic\",\n      name: \"AIGoCode\",\n      options: {\n        baseURL: \"https://api.aigocode.com\",\n        apiKey: \"\",\n        setCacheKey: true,\n      },\n      models: {\n        \"claude-sonnet-4-6\": { name: \"Claude Sonnet 4.6\" },\n        \"claude-opus-4-6\": { name: \"Claude Opus 4.6\" },\n      },\n    },\n    category: \"third_party\",\n    isPartner: true,\n    partnerPromotionKey: \"aigocode\",\n    icon: \"aigocode\",\n    iconColor: \"#5B7FFF\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n  },\n  {\n    name: \"RightCode\",\n    websiteUrl: \"https://www.right.codes\",\n    apiKeyUrl: \"https://www.right.codes/register?aff=CCSWITCH\",\n    settingsConfig: {\n      npm: \"@ai-sdk/openai\",\n      name: \"RightCode\",\n      options: {\n        baseURL: \"https://right.codes/codex/v1\",\n        apiKey: \"\",\n        setCacheKey: true,\n      },\n      models: {\n        \"gpt-5.4\": { name: \"GPT-5.4\" },\n      },\n    },\n    category: \"third_party\",\n    isPartner: true,\n    partnerPromotionKey: \"rightcode\",\n    icon: \"rc\",\n    iconColor: \"#E96B2C\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n  },\n  {\n    name: \"AICodeMirror\",\n    websiteUrl: \"https://www.aicodemirror.com\",\n    apiKeyUrl: \"https://www.aicodemirror.com/register?invitecode=9915W3\",\n    settingsConfig: {\n      npm: \"@ai-sdk/anthropic\",\n      name: \"AICodeMirror\",\n      options: {\n        baseURL: \"https://api.aicodemirror.com/api/claudecode\",\n        apiKey: \"\",\n        setCacheKey: true,\n      },\n      models: {\n        \"claude-sonnet-4.6\": { name: \"Claude Sonnet 4.6\" },\n        \"claude-opus-4.6\": { name: \"Claude Opus 4.6\" },\n      },\n    },\n    category: \"third_party\",\n    isPartner: true,\n    partnerPromotionKey: \"aicodemirror\",\n    icon: \"aicodemirror\",\n    iconColor: \"#000000\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n  },\n  {\n    name: \"AICoding\",\n    websiteUrl: \"https://aicoding.sh\",\n    apiKeyUrl: \"https://aicoding.sh/i/CCSWITCH\",\n    settingsConfig: {\n      npm: \"@ai-sdk/anthropic\",\n      name: \"AICoding\",\n      options: {\n        baseURL: \"https://api.aicoding.sh\",\n        apiKey: \"\",\n        setCacheKey: true,\n      },\n      models: {\n        \"claude-sonnet-4-6\": { name: \"Claude Sonnet 4.6\" },\n        \"claude-opus-4-6\": { name: \"Claude Opus 4.6\" },\n      },\n    },\n    category: \"third_party\",\n    isPartner: true,\n    partnerPromotionKey: \"aicoding\",\n    icon: \"aicoding\",\n    iconColor: \"#000000\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n  },\n  {\n    name: \"CrazyRouter\",\n    websiteUrl: \"https://www.crazyrouter.com\",\n    apiKeyUrl: \"https://www.crazyrouter.com/register?aff=OZcm&ref=cc-switch\",\n    settingsConfig: {\n      npm: \"@ai-sdk/anthropic\",\n      name: \"CrazyRouter\",\n      options: {\n        baseURL: \"https://crazyrouter.com\",\n        apiKey: \"\",\n        setCacheKey: true,\n      },\n      models: {\n        \"claude-sonnet-4-6\": { name: \"Claude Sonnet 4.6\" },\n        \"claude-opus-4-6\": { name: \"Claude Opus 4.6\" },\n      },\n    },\n    category: \"third_party\",\n    isPartner: true,\n    partnerPromotionKey: \"crazyrouter\",\n    icon: \"crazyrouter\",\n    iconColor: \"#000000\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n  },\n  {\n    name: \"SSSAiCode\",\n    websiteUrl: \"https://www.sssaicode.com\",\n    apiKeyUrl: \"https://www.sssaicode.com/register?ref=DCP0SM\",\n    settingsConfig: {\n      npm: \"@ai-sdk/anthropic\",\n      name: \"SSSAiCode\",\n      options: {\n        baseURL: \"https://node-hk.sssaicode.com/api/v1\",\n        apiKey: \"\",\n        setCacheKey: true,\n      },\n      models: {\n        \"claude-sonnet-4-6\": { name: \"Claude Sonnet 4.6\" },\n        \"claude-opus-4-6\": { name: \"Claude Opus 4.6\" },\n      },\n    },\n    category: \"third_party\",\n    isPartner: true,\n    partnerPromotionKey: \"sssaicode\",\n    icon: \"sssaicode\",\n    iconColor: \"#000000\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n  },\n  {\n    name: \"Micu\",\n    websiteUrl: \"https://www.openclaudecode.cn\",\n    apiKeyUrl: \"https://www.openclaudecode.cn/register?aff=aOYQ\",\n    settingsConfig: {\n      npm: \"@ai-sdk/anthropic\",\n      name: \"Micu\",\n      options: {\n        baseURL: \"https://www.openclaudecode.cn/v1\",\n        apiKey: \"\",\n        setCacheKey: true,\n      },\n      models: {\n        \"claude-opus-4-6\": { name: \"Claude Opus 4.6\" },\n        \"claude-sonnet-4-6\": { name: \"Claude Sonnet 4.6\" },\n      },\n    },\n    category: \"third_party\",\n    isPartner: true,\n    partnerPromotionKey: \"micu\",\n    icon: \"micu\",\n    iconColor: \"#000000\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n  },\n  {\n    name: \"X-Code API\",\n    websiteUrl: \"https://x-code.cc\",\n    apiKeyUrl: \"https://x-code.cc\",\n    settingsConfig: {\n      npm: \"@ai-sdk/anthropic\",\n      name: \"X-Code API\",\n      options: {\n        baseURL: \"https://x-code.cc/v1\",\n        apiKey: \"\",\n        setCacheKey: true,\n      },\n      models: {\n        \"claude-opus-4-6\": { name: \"Claude Opus 4.6\" },\n        \"claude-sonnet-4-6\": { name: \"Claude Sonnet 4.6\" },\n      },\n    },\n    category: \"third_party\",\n    isPartner: true,\n    partnerPromotionKey: \"x-code\",\n    icon: \"x-code\",\n    iconColor: \"#000000\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n  },\n  {\n    name: \"CTok.ai\",\n    websiteUrl: \"https://ctok.ai\",\n    apiKeyUrl: \"https://ctok.ai\",\n    settingsConfig: {\n      npm: \"@ai-sdk/anthropic\",\n      name: \"CTok\",\n      options: {\n        baseURL: \"https://api.ctok.ai/v1\",\n        apiKey: \"\",\n        setCacheKey: true,\n      },\n      models: {\n        \"claude-opus-4-6\": { name: \"Claude Opus 4.6\" },\n        \"claude-sonnet-4-6\": { name: \"Claude Sonnet 4.6\" },\n      },\n    },\n    category: \"third_party\",\n    isPartner: true,\n    partnerPromotionKey: \"ctok\",\n    icon: \"ctok\",\n    iconColor: \"#000000\",\n    templateValues: {\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n  },\n  {\n    name: \"AWS Bedrock\",\n    websiteUrl: \"https://aws.amazon.com/bedrock/\",\n    settingsConfig: {\n      npm: \"@ai-sdk/amazon-bedrock\",\n      name: \"AWS Bedrock\",\n      options: {\n        region: \"${region}\",\n        accessKeyId: \"${accessKeyId}\",\n        secretAccessKey: \"${secretAccessKey}\",\n        setCacheKey: true,\n      },\n      models: {\n        \"global.anthropic.claude-opus-4-6-v1\": { name: \"Claude Opus 4.6\" },\n        \"global.anthropic.claude-sonnet-4-6\": {\n          name: \"Claude Sonnet 4.6\",\n        },\n        \"global.anthropic.claude-haiku-4-5-20251001-v1:0\": {\n          name: \"Claude Haiku 4.5\",\n        },\n        \"us.amazon.nova-pro-v1:0\": { name: \"Amazon Nova Pro\" },\n        \"us.meta.llama4-maverick-17b-instruct-v1:0\": {\n          name: \"Meta Llama 4 Maverick\",\n        },\n        \"us.deepseek.r1-v1:0\": { name: \"DeepSeek R1\" },\n      },\n    },\n    category: \"cloud_provider\",\n    icon: \"aws\",\n    iconColor: \"#FF9900\",\n    templateValues: {\n      region: {\n        label: \"AWS Region\",\n        placeholder: \"us-west-2\",\n        defaultValue: \"us-west-2\",\n        editorValue: \"us-west-2\",\n      },\n      accessKeyId: {\n        label: \"Access Key ID\",\n        placeholder: \"AKIA...\",\n        editorValue: \"\",\n      },\n      secretAccessKey: {\n        label: \"Secret Access Key\",\n        placeholder: \"your-secret-key\",\n        editorValue: \"\",\n      },\n    },\n  },\n  {\n    name: \"OpenAI Compatible\",\n    websiteUrl: \"\",\n    settingsConfig: {\n      npm: \"@ai-sdk/openai-compatible\",\n      options: {\n        baseURL: \"\",\n        apiKey: \"\",\n        setCacheKey: true,\n      },\n      models: {},\n    },\n    category: \"custom\",\n    isCustomTemplate: true,\n    icon: \"generic\",\n    iconColor: \"#6B7280\",\n    templateValues: {\n      baseURL: {\n        label: \"Base URL\",\n        placeholder: \"https://api.example.com/v1\",\n        editorValue: \"\",\n      },\n      apiKey: {\n        label: \"API Key\",\n        placeholder: \"\",\n        editorValue: \"\",\n      },\n    },\n  },\n\n  {\n    name: \"Oh My OpenCode\",\n    websiteUrl: \"https://github.com/code-yeongyu/oh-my-opencode\",\n    settingsConfig: {\n      npm: \"\",\n      options: {},\n      models: {},\n    },\n    category: \"omo\" as ProviderCategory,\n    icon: \"opencode\",\n    iconColor: \"#8B5CF6\",\n    isCustomTemplate: true,\n  },\n  {\n    name: \"Oh My OpenCode Slim\",\n    websiteUrl: \"https://github.com/alvinunreal/oh-my-opencode-slim\",\n    settingsConfig: {\n      npm: \"\",\n      options: {},\n      models: {},\n    },\n    category: \"omo-slim\" as ProviderCategory,\n    icon: \"opencode\",\n    iconColor: \"#6366F1\",\n    isCustomTemplate: true,\n  },\n];\n"
  },
  {
    "path": "src/config/universalProviderPresets.ts",
    "content": "/**\n * 统一供应商（Universal Provider）预设配置\n *\n * 统一供应商是跨应用共享的配置，修改后会自动同步到 Claude、Codex、Gemini 三个应用。\n * 适用于 NewAPI 等支持多种协议的 API 网关。\n */\n\nimport type {\n  UniversalProvider,\n  UniversalProviderApps,\n  UniversalProviderModels,\n} from \"@/types\";\n\n/**\n * 统一供应商预设接口\n */\nexport interface UniversalProviderPreset {\n  /** 预设名称 */\n  name: string;\n  /** 供应商类型标识 */\n  providerType: string;\n  /** 默认启用的应用 */\n  defaultApps: UniversalProviderApps;\n  /** 默认模型配置 */\n  defaultModels: UniversalProviderModels;\n  /** 网站链接 */\n  websiteUrl?: string;\n  /** 图标名称 */\n  icon?: string;\n  /** 图标颜色 */\n  iconColor?: string;\n  /** 描述 */\n  description?: string;\n  /** 是否为自定义模板（允许用户完全自定义） */\n  isCustomTemplate?: boolean;\n}\n\n/**\n * NewAPI 默认模型配置\n */\nconst NEWAPI_DEFAULT_MODELS: UniversalProviderModels = {\n  claude: {\n    model: \"claude-sonnet-4-20250514\",\n    haikuModel: \"claude-haiku-4-20250514\",\n    sonnetModel: \"claude-sonnet-4-20250514\",\n    opusModel: \"claude-sonnet-4-20250514\",\n  },\n  codex: {\n    model: \"gpt-5.4\",\n    reasoningEffort: \"high\",\n  },\n  gemini: {\n    model: \"gemini-2.5-pro\",\n  },\n};\n\n/**\n * 统一供应商预设列表\n */\nexport const universalProviderPresets: UniversalProviderPreset[] = [\n  {\n    name: \"NewAPI\",\n    providerType: \"newapi\",\n    defaultApps: {\n      claude: true,\n      codex: true,\n      gemini: true,\n    },\n    defaultModels: NEWAPI_DEFAULT_MODELS,\n    websiteUrl: \"https://www.newapi.pro\",\n    icon: \"newapi\",\n    iconColor: \"#00A67E\",\n    description:\n      \"NewAPI 是一个可自部署的 API 网关，支持 Anthropic、OpenAI、Gemini 等多种协议\",\n  },\n  {\n    name: \"自定义网关\",\n    providerType: \"custom_gateway\",\n    defaultApps: {\n      claude: true,\n      codex: true,\n      gemini: true,\n    },\n    defaultModels: NEWAPI_DEFAULT_MODELS,\n    icon: \"openai\",\n    iconColor: \"#6366F1\",\n    description: \"自定义配置的 API 网关\",\n    isCustomTemplate: true,\n  },\n];\n\n/**\n * 根据预设创建统一供应商\n */\nexport function createUniversalProviderFromPreset(\n  preset: UniversalProviderPreset,\n  id: string,\n  baseUrl: string,\n  apiKey: string,\n  customName?: string,\n): UniversalProvider {\n  return {\n    id,\n    name: customName || preset.name,\n    providerType: preset.providerType,\n    apps: { ...preset.defaultApps },\n    baseUrl,\n    apiKey,\n    models: JSON.parse(JSON.stringify(preset.defaultModels)), // Deep copy\n    websiteUrl: preset.websiteUrl,\n    icon: preset.icon,\n    iconColor: preset.iconColor,\n    createdAt: Date.now(),\n  };\n}\n\n/**\n * 获取预设的显示名称（用于 UI）\n */\nexport function getPresetDisplayName(preset: UniversalProviderPreset): string {\n  return preset.name;\n}\n\n/**\n * 根据类型查找预设\n */\nexport function findPresetByType(\n  providerType: string,\n): UniversalProviderPreset | undefined {\n  return universalProviderPresets.find((p) => p.providerType === providerType);\n}\n"
  },
  {
    "path": "src/contexts/UpdateContext.tsx",
    "content": "import React, {\n  createContext,\n  useContext,\n  useState,\n  useEffect,\n  useCallback,\n  useRef,\n} from \"react\";\nimport type { UpdateInfo, UpdateHandle } from \"../lib/updater\";\nimport { checkForUpdate } from \"../lib/updater\";\n\ninterface UpdateContextValue {\n  // 更新状态\n  hasUpdate: boolean;\n  updateInfo: UpdateInfo | null;\n  updateHandle: UpdateHandle | null;\n  isChecking: boolean;\n  error: string | null;\n\n  // 提示状态\n  isDismissed: boolean;\n  dismissUpdate: () => void;\n\n  // 操作方法\n  checkUpdate: () => Promise<boolean>;\n  resetDismiss: () => void;\n}\n\nconst UpdateContext = createContext<UpdateContextValue | undefined>(undefined);\n\nexport function UpdateProvider({ children }: { children: React.ReactNode }) {\n  const DISMISSED_VERSION_KEY = \"ccswitch:update:dismissedVersion\";\n  const LEGACY_DISMISSED_KEY = \"dismissedUpdateVersion\"; // 兼容旧键\n\n  const [hasUpdate, setHasUpdate] = useState(false);\n  const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);\n  const [updateHandle, setUpdateHandle] = useState<UpdateHandle | null>(null);\n  const [isChecking, setIsChecking] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n  const [isDismissed, setIsDismissed] = useState(false);\n\n  // 从 localStorage 读取已关闭的版本\n  useEffect(() => {\n    const current = updateInfo?.availableVersion;\n    if (!current) return;\n\n    // 读取新键；若不存在，尝试迁移旧键\n    let dismissedVersion = localStorage.getItem(DISMISSED_VERSION_KEY);\n    if (!dismissedVersion) {\n      const legacy = localStorage.getItem(LEGACY_DISMISSED_KEY);\n      if (legacy) {\n        localStorage.setItem(DISMISSED_VERSION_KEY, legacy);\n        localStorage.removeItem(LEGACY_DISMISSED_KEY);\n        dismissedVersion = legacy;\n      }\n    }\n\n    setIsDismissed(dismissedVersion === current);\n  }, [updateInfo?.availableVersion]);\n\n  const isCheckingRef = useRef(false);\n\n  const checkUpdate = useCallback(async () => {\n    if (isCheckingRef.current) return false;\n    isCheckingRef.current = true;\n    setIsChecking(true);\n    setError(null);\n\n    try {\n      const result = await checkForUpdate({ timeout: 30000 });\n\n      if (result.status === \"available\") {\n        setHasUpdate(true);\n        setUpdateInfo(result.info);\n        setUpdateHandle(result.update);\n\n        // 检查是否已经关闭过这个版本的提醒\n        let dismissedVersion = localStorage.getItem(DISMISSED_VERSION_KEY);\n        if (!dismissedVersion) {\n          const legacy = localStorage.getItem(LEGACY_DISMISSED_KEY);\n          if (legacy) {\n            localStorage.setItem(DISMISSED_VERSION_KEY, legacy);\n            localStorage.removeItem(LEGACY_DISMISSED_KEY);\n            dismissedVersion = legacy;\n          }\n        }\n        setIsDismissed(dismissedVersion === result.info.availableVersion);\n        return true; // 有更新\n      } else {\n        setHasUpdate(false);\n        setUpdateInfo(null);\n        setUpdateHandle(null);\n        setIsDismissed(false);\n        return false; // 已是最新\n      }\n    } catch (err) {\n      console.error(\"检查更新失败:\", err);\n      setError(err instanceof Error ? err.message : \"检查更新失败\");\n      setHasUpdate(false);\n      throw err; // 抛出错误让调用方处理\n    } finally {\n      setIsChecking(false);\n      isCheckingRef.current = false;\n    }\n  }, []);\n\n  const dismissUpdate = useCallback(() => {\n    setIsDismissed(true);\n    if (updateInfo?.availableVersion) {\n      localStorage.setItem(DISMISSED_VERSION_KEY, updateInfo.availableVersion);\n      // 清理旧键\n      localStorage.removeItem(LEGACY_DISMISSED_KEY);\n    }\n  }, [updateInfo?.availableVersion]);\n\n  const resetDismiss = useCallback(() => {\n    setIsDismissed(false);\n    localStorage.removeItem(DISMISSED_VERSION_KEY);\n    localStorage.removeItem(LEGACY_DISMISSED_KEY);\n  }, []);\n\n  // 应用启动时自动检查更新\n  useEffect(() => {\n    // 延迟1秒后检查，避免影响启动体验\n    const timer = setTimeout(() => {\n      checkUpdate().catch(console.error);\n    }, 1000);\n\n    return () => clearTimeout(timer);\n  }, [checkUpdate]);\n\n  const value: UpdateContextValue = {\n    hasUpdate,\n    updateInfo,\n    updateHandle,\n    isChecking,\n    error,\n    isDismissed,\n    dismissUpdate,\n    checkUpdate,\n    resetDismiss,\n  };\n\n  return (\n    <UpdateContext.Provider value={value}>{children}</UpdateContext.Provider>\n  );\n}\n\nexport function useUpdate() {\n  const context = useContext(UpdateContext);\n  if (!context) {\n    throw new Error(\"useUpdate must be used within UpdateProvider\");\n  }\n  return context;\n}\n"
  },
  {
    "path": "src/hooks/useAutoCompact.ts",
    "content": "import { useEffect, useRef, useState, type RefObject } from \"react\";\n\n/**\n * Detects whether the container's children overflow the available width\n * and returns a `compact` flag for the AppSwitcher.\n *\n * Uses ResizeObserver on a flex-constrained container. The container\n * must have `flex-1 min-w-0 overflow-hidden` so its width is determined\n * by the parent layout, not its own content — avoiding the oscillation\n * problem when toggling compact mode.\n */\nexport function useAutoCompact(\n  containerRef: RefObject<HTMLDivElement | null>,\n): boolean {\n  const [compact, setCompact] = useState(false);\n  const normalWidthRef = useRef(0);\n  const lockUntilRef = useRef(0);\n\n  useEffect(() => {\n    const el = containerRef.current;\n    if (!el) return;\n\n    const ro = new ResizeObserver(() => {\n      // During expand animation, ignore resize events to prevent flicker\n      if (Date.now() < lockUntilRef.current) return;\n\n      if (!compact) {\n        // Cache the total content width in normal mode\n        normalWidthRef.current = el.scrollWidth;\n        // Overflow detected → switch to compact\n        if (el.scrollWidth > el.clientWidth + 1) {\n          setCompact(true);\n        }\n      } else if (normalWidthRef.current > 0) {\n        // In compact mode: only recover to normal if\n        // available space >= what normal mode needed\n        if (el.clientWidth >= normalWidthRef.current) {\n          // Lock out resize events during the expand animation (200ms + 50ms margin)\n          lockUntilRef.current = Date.now() + 250;\n          setCompact(false);\n        }\n      }\n    });\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [compact]);\n\n  return compact;\n}\n"
  },
  {
    "path": "src/hooks/useBackupManager.ts",
    "content": "import { useQuery, useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { backupsApi } from \"@/lib/api\";\n\nexport function useBackupManager() {\n  const queryClient = useQueryClient();\n\n  const {\n    data: backups = [],\n    isLoading,\n    refetch,\n  } = useQuery({\n    queryKey: [\"db-backups\"],\n    queryFn: () => backupsApi.listDbBackups(),\n  });\n\n  const createMutation = useMutation({\n    mutationFn: () => backupsApi.createDbBackup(),\n    onSuccess: () => refetch(),\n  });\n\n  const restoreMutation = useMutation({\n    mutationFn: (filename: string) => backupsApi.restoreDbBackup(filename),\n    onSuccess: async () => {\n      // Invalidate all queries to refresh data from restored database\n      await queryClient.invalidateQueries();\n      // Refetch backup list\n      await refetch();\n    },\n  });\n\n  const renameMutation = useMutation({\n    mutationFn: ({\n      oldFilename,\n      newName,\n    }: {\n      oldFilename: string;\n      newName: string;\n    }) => backupsApi.renameDbBackup(oldFilename, newName),\n    onSuccess: () => refetch(),\n  });\n\n  const deleteMutation = useMutation({\n    mutationFn: (filename: string) => backupsApi.deleteDbBackup(filename),\n    onSuccess: () => refetch(),\n  });\n\n  return {\n    backups,\n    isLoading,\n    create: createMutation.mutateAsync,\n    isCreating: createMutation.isPending,\n    restore: restoreMutation.mutateAsync,\n    isRestoring: restoreMutation.isPending,\n    rename: renameMutation.mutateAsync,\n    isRenaming: renameMutation.isPending,\n    remove: deleteMutation.mutateAsync,\n    isDeleting: deleteMutation.isPending,\n  };\n}\n"
  },
  {
    "path": "src/hooks/useDirectorySettings.ts",
    "content": "import { useCallback, useEffect, useRef, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { toast } from \"sonner\";\nimport { homeDir, join } from \"@tauri-apps/api/path\";\nimport { settingsApi, type AppId } from \"@/lib/api\";\nimport type { SettingsFormState } from \"./useSettingsForm\";\n\ntype DirectoryKey = \"appConfig\" | \"claude\" | \"codex\" | \"gemini\" | \"opencode\";\n\nexport interface ResolvedDirectories {\n  appConfig: string;\n  claude: string;\n  codex: string;\n  gemini: string;\n  opencode: string;\n}\n\nconst sanitizeDir = (value?: string | null): string | undefined => {\n  if (!value) return undefined;\n  const trimmed = value.trim();\n  return trimmed.length > 0 ? trimmed : undefined;\n};\n\nconst computeDefaultAppConfigDir = async (): Promise<string | undefined> => {\n  try {\n    const home = await homeDir();\n    return await join(home, \".cc-switch\");\n  } catch (error) {\n    console.error(\n      \"[useDirectorySettings] Failed to resolve default app config dir\",\n      error,\n    );\n    return undefined;\n  }\n};\n\nconst computeDefaultConfigDir = async (\n  app: AppId,\n): Promise<string | undefined> => {\n  try {\n    const home = await homeDir();\n    const folder =\n      app === \"claude\"\n        ? \".claude\"\n        : app === \"codex\"\n          ? \".codex\"\n          : app === \"gemini\"\n            ? \".gemini\"\n            : \".config/opencode\";\n    return await join(home, folder);\n  } catch (error) {\n    console.error(\n      \"[useDirectorySettings] Failed to resolve default config dir\",\n      error,\n    );\n    return undefined;\n  }\n};\n\nexport interface UseDirectorySettingsProps {\n  settings: SettingsFormState | null;\n  onUpdateSettings: (updates: Partial<SettingsFormState>) => void;\n}\n\nexport interface UseDirectorySettingsResult {\n  appConfigDir?: string;\n  resolvedDirs: ResolvedDirectories;\n  isLoading: boolean;\n  initialAppConfigDir?: string;\n  updateDirectory: (app: AppId, value?: string) => void;\n  updateAppConfigDir: (value?: string) => void;\n  browseDirectory: (app: AppId) => Promise<void>;\n  browseAppConfigDir: () => Promise<void>;\n  resetDirectory: (app: AppId) => Promise<void>;\n  resetAppConfigDir: () => Promise<void>;\n  resetAllDirectories: (\n    claudeDir?: string,\n    codexDir?: string,\n    geminiDir?: string,\n    opencodeDir?: string,\n  ) => void;\n}\n\n/**\n * useDirectorySettings - 目录管理\n * 负责：\n * - appConfigDir 状态\n * - resolvedDirs 状态\n * - 目录选择（browse）\n * - 目录重置\n * - 默认值计算\n */\nexport function useDirectorySettings({\n  settings,\n  onUpdateSettings,\n}: UseDirectorySettingsProps): UseDirectorySettingsResult {\n  const { t } = useTranslation();\n\n  const [appConfigDir, setAppConfigDir] = useState<string | undefined>(\n    undefined,\n  );\n  const [resolvedDirs, setResolvedDirs] = useState<ResolvedDirectories>({\n    appConfig: \"\",\n    claude: \"\",\n    codex: \"\",\n    gemini: \"\",\n    opencode: \"\",\n  });\n  const [isLoading, setIsLoading] = useState(true);\n\n  const defaultsRef = useRef<ResolvedDirectories>({\n    appConfig: \"\",\n    claude: \"\",\n    codex: \"\",\n    gemini: \"\",\n    opencode: \"\",\n  });\n  const initialAppConfigDirRef = useRef<string | undefined>(undefined);\n\n  // 加载目录信息\n  useEffect(() => {\n    let active = true;\n    setIsLoading(true);\n\n    const load = async () => {\n      try {\n        const [\n          overrideRaw,\n          claudeDir,\n          codexDir,\n          geminiDir,\n          opencodeDir,\n          defaultAppConfig,\n          defaultClaudeDir,\n          defaultCodexDir,\n          defaultGeminiDir,\n          defaultOpencodeDir,\n        ] = await Promise.all([\n          settingsApi.getAppConfigDirOverride(),\n          settingsApi.getConfigDir(\"claude\"),\n          settingsApi.getConfigDir(\"codex\"),\n          settingsApi.getConfigDir(\"gemini\"),\n          settingsApi.getConfigDir(\"opencode\"),\n          computeDefaultAppConfigDir(),\n          computeDefaultConfigDir(\"claude\"),\n          computeDefaultConfigDir(\"codex\"),\n          computeDefaultConfigDir(\"gemini\"),\n          computeDefaultConfigDir(\"opencode\"),\n        ]);\n\n        if (!active) return;\n\n        const normalizedOverride = sanitizeDir(overrideRaw ?? undefined);\n\n        defaultsRef.current = {\n          appConfig: defaultAppConfig ?? \"\",\n          claude: defaultClaudeDir ?? \"\",\n          codex: defaultCodexDir ?? \"\",\n          gemini: defaultGeminiDir ?? \"\",\n          opencode: defaultOpencodeDir ?? \"\",\n        };\n\n        setAppConfigDir(normalizedOverride);\n        initialAppConfigDirRef.current = normalizedOverride;\n\n        setResolvedDirs({\n          appConfig: normalizedOverride ?? defaultsRef.current.appConfig,\n          claude: claudeDir || defaultsRef.current.claude,\n          codex: codexDir || defaultsRef.current.codex,\n          gemini: geminiDir || defaultsRef.current.gemini,\n          opencode: opencodeDir || defaultsRef.current.opencode,\n        });\n      } catch (error) {\n        console.error(\n          \"[useDirectorySettings] Failed to load directory info\",\n          error,\n        );\n      } finally {\n        if (active) {\n          setIsLoading(false);\n        }\n      }\n    };\n\n    void load();\n    return () => {\n      active = false;\n    };\n  }, []);\n\n  const updateDirectoryState = useCallback(\n    (key: DirectoryKey, value?: string) => {\n      const sanitized = sanitizeDir(value);\n      if (key === \"appConfig\") {\n        setAppConfigDir(sanitized);\n      } else {\n        onUpdateSettings(\n          key === \"claude\"\n            ? { claudeConfigDir: sanitized }\n            : key === \"codex\"\n              ? { codexConfigDir: sanitized }\n              : key === \"gemini\"\n                ? { geminiConfigDir: sanitized }\n                : { opencodeConfigDir: sanitized },\n        );\n      }\n\n      setResolvedDirs((prev) => ({\n        ...prev,\n        [key]: sanitized ?? defaultsRef.current[key],\n      }));\n    },\n    [onUpdateSettings],\n  );\n\n  const updateAppConfigDir = useCallback(\n    (value?: string) => {\n      updateDirectoryState(\"appConfig\", value);\n    },\n    [updateDirectoryState],\n  );\n\n  const updateDirectory = useCallback(\n    (app: AppId, value?: string) => {\n      updateDirectoryState(\n        app === \"claude\"\n          ? \"claude\"\n          : app === \"codex\"\n            ? \"codex\"\n            : app === \"gemini\"\n              ? \"gemini\"\n              : \"opencode\",\n        value,\n      );\n    },\n    [updateDirectoryState],\n  );\n\n  const browseDirectory = useCallback(\n    async (app: AppId) => {\n      const key: DirectoryKey =\n        app === \"claude\"\n          ? \"claude\"\n          : app === \"codex\"\n            ? \"codex\"\n            : app === \"gemini\"\n              ? \"gemini\"\n              : \"opencode\";\n      const currentValue =\n        key === \"claude\"\n          ? (settings?.claudeConfigDir ?? resolvedDirs.claude)\n          : key === \"codex\"\n            ? (settings?.codexConfigDir ?? resolvedDirs.codex)\n            : key === \"gemini\"\n              ? (settings?.geminiConfigDir ?? resolvedDirs.gemini)\n              : (settings?.opencodeConfigDir ?? resolvedDirs.opencode);\n\n      try {\n        const picked = await settingsApi.selectConfigDirectory(currentValue);\n        const sanitized = sanitizeDir(picked ?? undefined);\n        if (!sanitized) return;\n        updateDirectoryState(key, sanitized);\n      } catch (error) {\n        console.error(\"[useDirectorySettings] Failed to pick directory\", error);\n        toast.error(\n          t(\"settings.selectFileFailed\", {\n            defaultValue: \"选择目录失败\",\n          }),\n        );\n      }\n    },\n    [settings, resolvedDirs, t, updateDirectoryState],\n  );\n\n  const browseAppConfigDir = useCallback(async () => {\n    const currentValue = appConfigDir ?? resolvedDirs.appConfig;\n    try {\n      const picked = await settingsApi.selectConfigDirectory(currentValue);\n      const sanitized = sanitizeDir(picked ?? undefined);\n      if (!sanitized) return;\n      updateDirectoryState(\"appConfig\", sanitized);\n    } catch (error) {\n      console.error(\n        \"[useDirectorySettings] Failed to pick app config directory\",\n        error,\n      );\n      toast.error(\n        t(\"settings.selectFileFailed\", {\n          defaultValue: \"选择目录失败\",\n        }),\n      );\n    }\n  }, [appConfigDir, resolvedDirs.appConfig, t, updateDirectoryState]);\n\n  const resetDirectory = useCallback(\n    async (app: AppId) => {\n      const key: DirectoryKey =\n        app === \"claude\"\n          ? \"claude\"\n          : app === \"codex\"\n            ? \"codex\"\n            : app === \"gemini\"\n              ? \"gemini\"\n              : \"opencode\";\n      if (!defaultsRef.current[key]) {\n        const fallback = await computeDefaultConfigDir(app);\n        if (fallback) {\n          defaultsRef.current = {\n            ...defaultsRef.current,\n            [key]: fallback,\n          };\n        }\n      }\n      updateDirectoryState(key, undefined);\n    },\n    [updateDirectoryState],\n  );\n\n  const resetAppConfigDir = useCallback(async () => {\n    if (!defaultsRef.current.appConfig) {\n      const fallback = await computeDefaultAppConfigDir();\n      if (fallback) {\n        defaultsRef.current = {\n          ...defaultsRef.current,\n          appConfig: fallback,\n        };\n      }\n    }\n    updateDirectoryState(\"appConfig\", undefined);\n  }, [updateDirectoryState]);\n\n  const resetAllDirectories = useCallback(\n    (\n      claudeDir?: string,\n      codexDir?: string,\n      geminiDir?: string,\n      opencodeDir?: string,\n    ) => {\n      setAppConfigDir(initialAppConfigDirRef.current);\n      setResolvedDirs({\n        appConfig:\n          initialAppConfigDirRef.current ?? defaultsRef.current.appConfig,\n        claude: claudeDir ?? defaultsRef.current.claude,\n        codex: codexDir ?? defaultsRef.current.codex,\n        gemini: geminiDir ?? defaultsRef.current.gemini,\n        opencode: opencodeDir ?? defaultsRef.current.opencode,\n      });\n    },\n    [],\n  );\n\n  return {\n    appConfigDir,\n    resolvedDirs,\n    isLoading,\n    initialAppConfigDir: initialAppConfigDirRef.current,\n    updateDirectory,\n    updateAppConfigDir,\n    browseDirectory,\n    browseAppConfigDir,\n    resetDirectory,\n    resetAppConfigDir,\n    resetAllDirectories,\n  };\n}\n"
  },
  {
    "path": "src/hooks/useDragSort.ts",
    "content": "import { useCallback, useMemo } from \"react\";\nimport {\n  KeyboardSensor,\n  PointerSensor,\n  useSensor,\n  useSensors,\n  type DragEndEvent,\n} from \"@dnd-kit/core\";\nimport { arrayMove, sortableKeyboardCoordinates } from \"@dnd-kit/sortable\";\nimport { useQueryClient } from \"@tanstack/react-query\";\nimport { toast } from \"sonner\";\nimport { useTranslation } from \"react-i18next\";\nimport type { Provider } from \"@/types\";\nimport { providersApi, type AppId } from \"@/lib/api\";\n\nexport function useDragSort(providers: Record<string, Provider>, appId: AppId) {\n  const queryClient = useQueryClient();\n  const { t, i18n } = useTranslation();\n\n  const sortedProviders = useMemo(() => {\n    const locale = i18n.language === \"zh\" ? \"zh-CN\" : \"en-US\";\n    return Object.values(providers).sort((a, b) => {\n      if (a.sortIndex !== undefined && b.sortIndex !== undefined) {\n        return a.sortIndex - b.sortIndex;\n      }\n      if (a.sortIndex !== undefined) return -1;\n      if (b.sortIndex !== undefined) return 1;\n\n      const timeA = a.createdAt ?? 0;\n      const timeB = b.createdAt ?? 0;\n      if (timeA && timeB && timeA !== timeB) {\n        return timeA - timeB;\n      }\n\n      return a.name.localeCompare(b.name, locale);\n    });\n  }, [providers, i18n.language]);\n\n  const sensors = useSensors(\n    useSensor(PointerSensor, {\n      activationConstraint: { distance: 8 },\n    }),\n    useSensor(KeyboardSensor, {\n      coordinateGetter: sortableKeyboardCoordinates,\n    }),\n  );\n\n  const handleDragEnd = useCallback(\n    async (event: DragEndEvent) => {\n      const { active, over } = event;\n      if (!over || active.id === over.id) {\n        return;\n      }\n\n      const oldIndex = sortedProviders.findIndex(\n        (provider) => provider.id === active.id,\n      );\n      const newIndex = sortedProviders.findIndex(\n        (provider) => provider.id === over.id,\n      );\n\n      if (oldIndex === -1 || newIndex === -1) {\n        return;\n      }\n\n      const reordered = arrayMove(sortedProviders, oldIndex, newIndex);\n      const updates = reordered.map((provider, index) => ({\n        id: provider.id,\n        sortIndex: index,\n      }));\n\n      try {\n        await providersApi.updateSortOrder(updates, appId);\n        await queryClient.invalidateQueries({\n          queryKey: [\"providers\", appId],\n        });\n\n        // 刷新故障转移队列（因为队列顺序依赖 sort_index）\n        await queryClient.invalidateQueries({\n          queryKey: [\"failoverQueue\", appId],\n        });\n\n        // 更新托盘菜单以反映新的排序（失败不影响主操作）\n        try {\n          await providersApi.updateTrayMenu();\n        } catch (trayError) {\n          console.error(\"Failed to update tray menu after sort\", trayError);\n          // 托盘菜单更新失败不影响排序成功\n        }\n\n        toast.success(\n          t(\"provider.sortUpdated\", {\n            defaultValue: \"排序已更新\",\n          }),\n          { closeButton: true },\n        );\n      } catch (error) {\n        console.error(\"Failed to update provider sort order\", error);\n        toast.error(\n          t(\"provider.sortUpdateFailed\", {\n            defaultValue: \"排序更新失败\",\n          }),\n        );\n      }\n    },\n    [sortedProviders, appId, queryClient, t],\n  );\n\n  return {\n    sortedProviders,\n    sensors,\n    handleDragEnd,\n  };\n}\n"
  },
  {
    "path": "src/hooks/useGlobalProxy.ts",
    "content": "/**\n * 全局出站代理 React Hooks\n *\n * 提供获取、设置和测试全局代理的 React Query hooks。\n */\n\nimport { useQuery, useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { toast } from \"sonner\";\nimport { useTranslation } from \"react-i18next\";\nimport {\n  getGlobalProxyUrl,\n  setGlobalProxyUrl,\n  testProxyUrl,\n  getUpstreamProxyStatus,\n  scanLocalProxies,\n  type ProxyTestResult,\n  type UpstreamProxyStatus,\n  type DetectedProxy,\n} from \"@/lib/api/globalProxy\";\n\n/**\n * 获取全局代理 URL\n */\nexport function useGlobalProxyUrl() {\n  return useQuery({\n    queryKey: [\"globalProxyUrl\"],\n    queryFn: getGlobalProxyUrl,\n    staleTime: 30 * 1000, // 30秒内不重新获取，避免展开时闪烁\n  });\n}\n\n/**\n * 设置全局代理 URL\n */\nexport function useSetGlobalProxyUrl() {\n  const queryClient = useQueryClient();\n  const { t } = useTranslation();\n\n  return useMutation({\n    mutationFn: setGlobalProxyUrl,\n    onSuccess: () => {\n      toast.success(t(\"settings.globalProxy.saved\"));\n      queryClient.invalidateQueries({ queryKey: [\"globalProxyUrl\"] });\n      queryClient.invalidateQueries({ queryKey: [\"upstreamProxyStatus\"] });\n    },\n    onError: (error: unknown) => {\n      const message =\n        error instanceof Error\n          ? error.message\n          : typeof error === \"string\"\n            ? error\n            : \"Unknown error\";\n      toast.error(t(\"settings.globalProxy.saveFailed\", { error: message }));\n    },\n  });\n}\n\n/**\n * 测试代理连接\n */\nexport function useTestProxy() {\n  const { t } = useTranslation();\n\n  return useMutation({\n    mutationFn: testProxyUrl,\n    onSuccess: (result: ProxyTestResult) => {\n      if (result.success) {\n        toast.success(\n          t(\"settings.globalProxy.testSuccess\", { latency: result.latencyMs }),\n        );\n      } else {\n        toast.error(\n          t(\"settings.globalProxy.testFailed\", { error: result.error }),\n        );\n      }\n    },\n    onError: (error: Error) => {\n      toast.error(error.message);\n    },\n  });\n}\n\n/**\n * 获取当前出站代理状态\n */\nexport function useUpstreamProxyStatus() {\n  return useQuery<UpstreamProxyStatus>({\n    queryKey: [\"upstreamProxyStatus\"],\n    queryFn: getUpstreamProxyStatus,\n  });\n}\n\n/**\n * 扫描本地代理\n */\nexport function useScanProxies() {\n  const { t } = useTranslation();\n\n  return useMutation({\n    mutationFn: scanLocalProxies,\n    onError: (error: Error) => {\n      toast.error(\n        t(\"settings.globalProxy.scanFailed\", { error: error.message }),\n      );\n    },\n  });\n}\n\nexport type { DetectedProxy };\n"
  },
  {
    "path": "src/hooks/useImportExport.ts",
    "content": "import { useCallback, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { toast } from \"sonner\";\nimport { settingsApi } from \"@/lib/api\";\nimport { syncCurrentProvidersLiveSafe } from \"@/utils/postChangeSync\";\n\nexport type ImportStatus =\n  | \"idle\"\n  | \"importing\"\n  | \"success\"\n  | \"partial-success\"\n  | \"error\";\n\nexport interface UseImportExportOptions {\n  onImportSuccess?: () => void | Promise<void>;\n}\n\nexport interface UseImportExportResult {\n  selectedFile: string;\n  status: ImportStatus;\n  errorMessage: string | null;\n  backupId: string | null;\n  isImporting: boolean;\n  selectImportFile: () => Promise<void>;\n  clearSelection: () => void;\n  importConfig: () => Promise<void>;\n  exportConfig: () => Promise<void>;\n  resetStatus: () => void;\n}\n\nexport function useImportExport(\n  options: UseImportExportOptions = {},\n): UseImportExportResult {\n  const { t } = useTranslation();\n  const { onImportSuccess } = options;\n\n  const [selectedFile, setSelectedFile] = useState(\"\");\n  const [status, setStatus] = useState<ImportStatus>(\"idle\");\n  const [errorMessage, setErrorMessage] = useState<string | null>(null);\n  const [backupId, setBackupId] = useState<string | null>(null);\n  const [isImporting, setIsImporting] = useState(false);\n\n  const clearSelection = useCallback(() => {\n    setSelectedFile(\"\");\n    setStatus(\"idle\");\n    setErrorMessage(null);\n    setBackupId(null);\n  }, []);\n\n  const selectImportFile = useCallback(async () => {\n    try {\n      const filePath = await settingsApi.openFileDialog();\n      if (filePath) {\n        setSelectedFile(filePath);\n        setStatus(\"idle\");\n        setErrorMessage(null);\n      }\n    } catch (error) {\n      console.error(\"[useImportExport] Failed to open file dialog\", error);\n      toast.error(\n        t(\"settings.selectFileFailed\", {\n          defaultValue: \"选择文件失败\",\n        }),\n      );\n    }\n  }, [t]);\n\n  const importConfig = useCallback(async () => {\n    if (!selectedFile) {\n      toast.error(\n        t(\"settings.selectFileFailed\", {\n          defaultValue: \"请选择有效的 SQL 备份文件\",\n        }),\n      );\n      return;\n    }\n\n    if (isImporting) return;\n\n    setIsImporting(true);\n    setStatus(\"importing\");\n    setErrorMessage(null);\n\n    try {\n      const result = await settingsApi.importConfigFromFile(selectedFile);\n      if (!result.success) {\n        setStatus(\"error\");\n        const message =\n          result.message ||\n          t(\"settings.configCorrupted\", {\n            defaultValue: \"SQL 文件已损坏或格式不正确\",\n          });\n        setErrorMessage(message);\n        toast.error(message);\n        return;\n      }\n\n      setBackupId(result.backupId ?? null);\n      // 导入成功后立即触发外部刷新（与 live 同步结果解耦）\n      // - 避免 sync 失败时 UI 不刷新\n      // - 避免依赖 setTimeout（组件卸载会取消）\n      void onImportSuccess?.();\n\n      const syncResult = await syncCurrentProvidersLiveSafe();\n      if (syncResult.ok) {\n        setStatus(\"success\");\n        toast.success(\n          t(\"settings.importSuccess\", {\n            defaultValue: \"配置导入成功\",\n          }),\n          { closeButton: true },\n        );\n      } else {\n        console.error(\n          \"[useImportExport] Failed to sync live config\",\n          syncResult.error,\n        );\n        setStatus(\"partial-success\");\n        toast.warning(\n          t(\"settings.importPartialSuccess\", {\n            defaultValue:\n              \"配置已导入，但同步到当前供应商失败。请手动重新选择一次供应商。\",\n          }),\n        );\n      }\n    } catch (error) {\n      console.error(\"[useImportExport] Failed to import config\", error);\n      setStatus(\"error\");\n      const message =\n        error instanceof Error ? error.message : String(error ?? \"\");\n      setErrorMessage(message);\n      toast.error(\n        t(\"settings.importFailedError\", {\n          defaultValue: \"导入配置失败: {{message}}\",\n          message,\n        }),\n      );\n    } finally {\n      setIsImporting(false);\n    }\n  }, [isImporting, onImportSuccess, selectedFile, t]);\n\n  const exportConfig = useCallback(async () => {\n    try {\n      const now = new Date();\n      const stamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, \"0\")}${String(now.getDate()).padStart(2, \"0\")}_${String(now.getHours()).padStart(2, \"0\")}${String(now.getMinutes()).padStart(2, \"0\")}${String(now.getSeconds()).padStart(2, \"0\")}`;\n      const defaultName = `cc-switch-export-${stamp}.sql`;\n      const destination = await settingsApi.saveFileDialog(defaultName);\n      if (!destination) {\n        toast.error(\n          t(\"settings.selectFileFailed\", {\n            defaultValue: \"请选择 SQL 备份保存路径\",\n          }),\n        );\n        return;\n      }\n\n      const result = await settingsApi.exportConfigToFile(destination);\n      if (result.success) {\n        const displayPath = result.filePath ?? destination;\n        toast.success(\n          t(\"settings.configExported\", {\n            defaultValue: \"配置已导出\",\n          }) + `\\n${displayPath}`,\n          { closeButton: true },\n        );\n      } else {\n        toast.error(\n          t(\"settings.exportFailed\", {\n            defaultValue: \"导出配置失败\",\n          }) + (result.message ? `: ${result.message}` : \"\"),\n        );\n      }\n    } catch (error) {\n      console.error(\"[useImportExport] Failed to export config\", error);\n      toast.error(\n        t(\"settings.exportFailedError\", {\n          defaultValue: \"导出配置失败: {{message}}\",\n          message: error instanceof Error ? error.message : String(error ?? \"\"),\n        }),\n      );\n    }\n  }, [t]);\n\n  const resetStatus = useCallback(() => {\n    setStatus(\"idle\");\n    setErrorMessage(null);\n    setBackupId(null);\n  }, []);\n\n  return {\n    selectedFile,\n    status,\n    errorMessage,\n    backupId,\n    isImporting,\n    selectImportFile,\n    clearSelection,\n    importConfig,\n    exportConfig,\n    resetStatus,\n  };\n}\n"
  },
  {
    "path": "src/hooks/useLastValidValue.ts",
    "content": "import { useRef } from \"react\";\n\n/**\n * 保存最后一个非 null/undefined 的值\n * 用于 Dialog 关闭动画期间保持内容显示\n *\n * @param value 当前值\n * @returns 当前值（如果有效）或最后一个有效值\n */\nexport function useLastValidValue<T>(value: T | null | undefined): T | null {\n  const ref = useRef<T | null>(null);\n\n  // 同步更新 ref（在渲染期间，不在 useEffect 中）\n  if (value != null) {\n    ref.current = value;\n  }\n\n  // 返回当前值或最后有效值\n  return value ?? ref.current;\n}\n"
  },
  {
    "path": "src/hooks/useMcp.ts",
    "content": "import { useMutation, useQuery, useQueryClient } from \"@tanstack/react-query\";\nimport { mcpApi } from \"@/lib/api/mcp\";\nimport type { McpServer } from \"@/types\";\nimport type { AppId } from \"@/lib/api/types\";\n\n/**\n * 查询所有 MCP 服务器（统一管理）\n */\nexport function useAllMcpServers() {\n  return useQuery({\n    queryKey: [\"mcp\", \"all\"],\n    queryFn: () => mcpApi.getAllServers(),\n  });\n}\n\n/**\n * 添加或更新 MCP 服务器\n */\nexport function useUpsertMcpServer() {\n  const queryClient = useQueryClient();\n  return useMutation({\n    mutationFn: (server: McpServer) => mcpApi.upsertUnifiedServer(server),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: [\"mcp\", \"all\"] });\n    },\n  });\n}\n\n/**\n * 切换 MCP 服务器在特定应用的启用状态\n */\nexport function useToggleMcpApp() {\n  const queryClient = useQueryClient();\n  return useMutation({\n    mutationFn: ({\n      serverId,\n      app,\n      enabled,\n    }: {\n      serverId: string;\n      app: AppId;\n      enabled: boolean;\n    }) => mcpApi.toggleApp(serverId, app, enabled),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: [\"mcp\", \"all\"] });\n    },\n  });\n}\n\n/**\n * 删除 MCP 服务器\n */\nexport function useDeleteMcpServer() {\n  const queryClient = useQueryClient();\n  return useMutation({\n    mutationFn: (id: string) => mcpApi.deleteUnifiedServer(id),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: [\"mcp\", \"all\"] });\n    },\n  });\n}\n\n/**\n * 从所有应用导入 MCP 服务器\n */\nexport function useImportMcpFromApps() {\n  const queryClient = useQueryClient();\n  return useMutation({\n    mutationFn: () => mcpApi.importFromApps(),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: [\"mcp\", \"all\"] });\n    },\n  });\n}\n"
  },
  {
    "path": "src/hooks/useOpenClaw.ts",
    "content": "import { useMutation, useQuery, useQueryClient } from \"@tanstack/react-query\";\nimport { openclawApi } from \"@/lib/api/openclaw\";\nimport { providersApi } from \"@/lib/api/providers\";\nimport type {\n  OpenClawEnvConfig,\n  OpenClawToolsConfig,\n  OpenClawAgentsDefaults,\n} from \"@/types\";\n\n/**\n * Centralized query keys for all OpenClaw-related queries.\n * Import this from any file that needs to invalidate OpenClaw caches.\n */\nexport const openclawKeys = {\n  all: [\"openclaw\"] as const,\n  liveProviderIds: [\"openclaw\", \"liveProviderIds\"] as const,\n  defaultModel: [\"openclaw\", \"defaultModel\"] as const,\n  env: [\"openclaw\", \"env\"] as const,\n  tools: [\"openclaw\", \"tools\"] as const,\n  agentsDefaults: [\"openclaw\", \"agentsDefaults\"] as const,\n  health: [\"openclaw\", \"health\"] as const,\n};\n\n// ============================================================\n// Query hooks\n// ============================================================\n\n/**\n * Query live provider IDs from openclaw.json config.\n * Used by ProviderList to show \"In Config\" badge.\n */\nexport function useOpenClawLiveProviderIds(enabled: boolean) {\n  return useQuery({\n    queryKey: openclawKeys.liveProviderIds,\n    queryFn: () => providersApi.getOpenClawLiveProviderIds(),\n    enabled,\n  });\n}\n\n/**\n * Query the default model from agents.defaults.model.\n * Used by ProviderList to show which provider is the default.\n */\nexport function useOpenClawDefaultModel(enabled: boolean) {\n  return useQuery({\n    queryKey: openclawKeys.defaultModel,\n    queryFn: () => openclawApi.getDefaultModel(),\n    enabled,\n  });\n}\n\n/**\n * Query env section of openclaw.json.\n */\nexport function useOpenClawEnv() {\n  return useQuery({\n    queryKey: openclawKeys.env,\n    queryFn: () => openclawApi.getEnv(),\n    staleTime: 30_000,\n  });\n}\n\n/**\n * Query tools section of openclaw.json.\n */\nexport function useOpenClawTools() {\n  return useQuery({\n    queryKey: openclawKeys.tools,\n    queryFn: () => openclawApi.getTools(),\n    staleTime: 30_000,\n  });\n}\n\n/**\n * Query agents.defaults section of openclaw.json.\n */\nexport function useOpenClawAgentsDefaults() {\n  return useQuery({\n    queryKey: openclawKeys.agentsDefaults,\n    queryFn: () => openclawApi.getAgentsDefaults(),\n    staleTime: 30_000,\n  });\n}\n\nexport function useOpenClawHealth(enabled: boolean) {\n  return useQuery({\n    queryKey: openclawKeys.health,\n    queryFn: () => openclawApi.scanHealth(),\n    staleTime: 30_000,\n    enabled,\n  });\n}\n\n// ============================================================\n// Mutation hooks\n// ============================================================\n\n/**\n * Save env config. Invalidates env query on success.\n * Toast notifications are handled by the component.\n */\nexport function useSaveOpenClawEnv() {\n  const queryClient = useQueryClient();\n  return useMutation({\n    mutationFn: (env: OpenClawEnvConfig) => openclawApi.setEnv(env),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: openclawKeys.env });\n      queryClient.invalidateQueries({ queryKey: openclawKeys.health });\n    },\n  });\n}\n\n/**\n * Save tools config. Invalidates tools query on success.\n * Toast notifications are handled by the component.\n */\nexport function useSaveOpenClawTools() {\n  const queryClient = useQueryClient();\n  return useMutation({\n    mutationFn: (tools: OpenClawToolsConfig) => openclawApi.setTools(tools),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: openclawKeys.tools });\n      queryClient.invalidateQueries({ queryKey: openclawKeys.health });\n    },\n  });\n}\n\n/**\n * Save agents.defaults config. Invalidates both agentsDefaults and defaultModel\n * queries on success (since changing agents.defaults may affect the default model).\n * Toast notifications are handled by the component.\n */\nexport function useSaveOpenClawAgentsDefaults() {\n  const queryClient = useQueryClient();\n  return useMutation({\n    mutationFn: (defaults: OpenClawAgentsDefaults) =>\n      openclawApi.setAgentsDefaults(defaults),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: openclawKeys.agentsDefaults });\n      queryClient.invalidateQueries({ queryKey: openclawKeys.defaultModel });\n      queryClient.invalidateQueries({ queryKey: openclawKeys.health });\n    },\n  });\n}\n"
  },
  {
    "path": "src/hooks/usePromptActions.ts",
    "content": "import { useState, useCallback } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { toast } from \"sonner\";\nimport { promptsApi, type Prompt, type AppId } from \"@/lib/api\";\n\nexport function usePromptActions(appId: AppId) {\n  const { t } = useTranslation();\n  const [prompts, setPrompts] = useState<Record<string, Prompt>>({});\n  const [loading, setLoading] = useState(false);\n  const [currentFileContent, setCurrentFileContent] = useState<string | null>(\n    null,\n  );\n\n  const reload = useCallback(async () => {\n    setLoading(true);\n    try {\n      const data = await promptsApi.getPrompts(appId);\n      setPrompts(data);\n\n      // 同时加载当前文件内容\n      try {\n        const content = await promptsApi.getCurrentFileContent(appId);\n        setCurrentFileContent(content);\n      } catch (error) {\n        setCurrentFileContent(null);\n      }\n    } catch (error) {\n      toast.error(t(\"prompts.loadFailed\"));\n    } finally {\n      setLoading(false);\n    }\n  }, [appId, t]);\n\n  const savePrompt = useCallback(\n    async (id: string, prompt: Prompt) => {\n      try {\n        await promptsApi.upsertPrompt(appId, id, prompt);\n        await reload();\n        toast.success(t(\"prompts.saveSuccess\"), { closeButton: true });\n      } catch (error) {\n        toast.error(t(\"prompts.saveFailed\"));\n        throw error;\n      }\n    },\n    [appId, reload, t],\n  );\n\n  const deletePrompt = useCallback(\n    async (id: string) => {\n      try {\n        await promptsApi.deletePrompt(appId, id);\n        await reload();\n        toast.success(t(\"prompts.deleteSuccess\"), { closeButton: true });\n      } catch (error) {\n        toast.error(t(\"prompts.deleteFailed\"));\n        throw error;\n      }\n    },\n    [appId, reload, t],\n  );\n\n  const enablePrompt = useCallback(\n    async (id: string) => {\n      try {\n        await promptsApi.enablePrompt(appId, id);\n        await reload();\n        toast.success(t(\"prompts.enableSuccess\"), { closeButton: true });\n      } catch (error) {\n        toast.error(t(\"prompts.enableFailed\"));\n        throw error;\n      }\n    },\n    [appId, reload, t],\n  );\n\n  const toggleEnabled = useCallback(\n    async (id: string, enabled: boolean) => {\n      // Optimistic update\n      const previousPrompts = prompts;\n\n      // 如果要启用当前提示词，先禁用其他所有提示词\n      if (enabled) {\n        const updatedPrompts = Object.keys(prompts).reduce(\n          (acc, key) => {\n            acc[key] = {\n              ...prompts[key],\n              enabled: key === id,\n            };\n            return acc;\n          },\n          {} as Record<string, Prompt>,\n        );\n        setPrompts(updatedPrompts);\n      } else {\n        setPrompts((prev) => ({\n          ...prev,\n          [id]: {\n            ...prev[id],\n            enabled: false,\n          },\n        }));\n      }\n\n      try {\n        if (enabled) {\n          await promptsApi.enablePrompt(appId, id);\n          toast.success(t(\"prompts.enableSuccess\"), { closeButton: true });\n        } else {\n          // 禁用提示词 - 需要后端支持\n          await promptsApi.upsertPrompt(appId, id, {\n            ...prompts[id],\n            enabled: false,\n          });\n          toast.success(t(\"prompts.disableSuccess\"), { closeButton: true });\n        }\n        await reload();\n      } catch (error) {\n        // Rollback on failure\n        setPrompts(previousPrompts);\n        toast.error(\n          enabled ? t(\"prompts.enableFailed\") : t(\"prompts.disableFailed\"),\n        );\n        throw error;\n      }\n    },\n    [appId, prompts, reload, t],\n  );\n\n  const importFromFile = useCallback(async () => {\n    try {\n      const id = await promptsApi.importFromFile(appId);\n      await reload();\n      toast.success(t(\"prompts.importSuccess\"), { closeButton: true });\n      return id;\n    } catch (error) {\n      toast.error(t(\"prompts.importFailed\"));\n      throw error;\n    }\n  }, [appId, reload, t]);\n\n  return {\n    prompts,\n    loading,\n    currentFileContent,\n    reload,\n    savePrompt,\n    deletePrompt,\n    enablePrompt,\n    toggleEnabled,\n    importFromFile,\n  };\n}\n"
  },
  {
    "path": "src/hooks/useProviderActions.ts",
    "content": "import { useCallback } from \"react\";\nimport { useQueryClient } from \"@tanstack/react-query\";\nimport { toast } from \"sonner\";\nimport { useTranslation } from \"react-i18next\";\nimport { providersApi, settingsApi, openclawApi, type AppId } from \"@/lib/api\";\nimport type {\n  Provider,\n  UsageScript,\n  OpenClawProviderConfig,\n  OpenClawDefaultModel,\n} from \"@/types\";\nimport type { OpenClawSuggestedDefaults } from \"@/config/openclawProviderPresets\";\nimport {\n  useAddProviderMutation,\n  useUpdateProviderMutation,\n  useDeleteProviderMutation,\n  useSwitchProviderMutation,\n} from \"@/lib/query\";\nimport { extractErrorMessage } from \"@/utils/errorUtils\";\nimport { openclawKeys } from \"@/hooks/useOpenClaw\";\n\n/**\n * Hook for managing provider actions (add, update, delete, switch)\n * Extracts business logic from App.tsx\n */\nexport function useProviderActions(activeApp: AppId) {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n\n  const addProviderMutation = useAddProviderMutation(activeApp);\n  const updateProviderMutation = useUpdateProviderMutation(activeApp);\n  const deleteProviderMutation = useDeleteProviderMutation(activeApp);\n  const switchProviderMutation = useSwitchProviderMutation(activeApp);\n\n  // Claude 插件同步逻辑\n  const syncClaudePlugin = useCallback(\n    async (provider: Provider) => {\n      if (activeApp !== \"claude\") return;\n\n      try {\n        const settings = await settingsApi.get();\n        if (!settings?.enableClaudePluginIntegration) {\n          return;\n        }\n\n        const isOfficial = provider.category === \"official\";\n        await settingsApi.applyClaudePluginConfig({ official: isOfficial });\n\n        // 静默执行，不显示成功通知\n      } catch (error) {\n        const detail =\n          extractErrorMessage(error) ||\n          t(\"notifications.syncClaudePluginFailed\", {\n            defaultValue: \"同步 Claude 插件失败\",\n          });\n        toast.error(detail, { duration: 4200 });\n      }\n    },\n    [activeApp, t],\n  );\n\n  // 添加供应商\n  const addProvider = useCallback(\n    async (\n      provider: Omit<Provider, \"id\"> & {\n        providerKey?: string;\n        suggestedDefaults?: OpenClawSuggestedDefaults;\n      },\n    ) => {\n      await addProviderMutation.mutateAsync(provider);\n\n      // OpenClaw: register models to allowlist after adding provider\n      if (activeApp === \"openclaw\" && provider.suggestedDefaults) {\n        const { model, modelCatalog } = provider.suggestedDefaults;\n        let modelsRegistered = false;\n\n        try {\n          // 1. Merge model catalog (allowlist)\n          if (modelCatalog && Object.keys(modelCatalog).length > 0) {\n            const existingCatalog = (await openclawApi.getModelCatalog()) || {};\n            const mergedCatalog = { ...existingCatalog, ...modelCatalog };\n            await openclawApi.setModelCatalog(mergedCatalog);\n            await queryClient.invalidateQueries({\n              queryKey: openclawKeys.health,\n            });\n            modelsRegistered = true;\n          }\n\n          // 2. Set default model (only if not already set)\n          if (model) {\n            const existingDefault = await openclawApi.getDefaultModel();\n            if (!existingDefault?.primary) {\n              await openclawApi.setDefaultModel(model);\n              await queryClient.invalidateQueries({\n                queryKey: openclawKeys.health,\n              });\n            }\n          }\n\n          // Show success toast if models were registered\n          if (modelsRegistered) {\n            toast.success(\n              t(\"notifications.openclawModelsRegistered\", {\n                defaultValue: \"模型已注册到 /model 列表\",\n              }),\n              { closeButton: true },\n            );\n          }\n        } catch (error) {\n          // Log warning but don't block main flow - provider config is already saved\n          console.warn(\n            \"[OpenClaw] Failed to register models to allowlist:\",\n            error,\n          );\n        }\n      }\n    },\n    [addProviderMutation, activeApp, queryClient, t],\n  );\n\n  // 更新供应商\n  const updateProvider = useCallback(\n    async (provider: Provider) => {\n      await updateProviderMutation.mutateAsync(provider);\n\n      // 更新托盘菜单（失败不影响主操作）\n      try {\n        await providersApi.updateTrayMenu();\n      } catch (trayError) {\n        console.error(\n          \"Failed to update tray menu after updating provider\",\n          trayError,\n        );\n      }\n    },\n    [updateProviderMutation],\n  );\n\n  // 切换供应商\n  const switchProvider = useCallback(\n    async (provider: Provider) => {\n      try {\n        const result = await switchProviderMutation.mutateAsync(provider.id);\n        await syncClaudePlugin(provider);\n\n        // Show backfill warning if present\n        if (result?.warnings?.length) {\n          toast.warning(\n            t(\"notifications.backfillWarning\", {\n              defaultValue:\n                \"切换成功，但旧供应商配置回填失败，您手动修改的配置可能未保存\",\n            }),\n            { duration: 5000 },\n          );\n        }\n\n        // 根据供应商类型显示不同的成功提示\n        if (\n          activeApp === \"claude\" &&\n          provider.category !== \"official\" &&\n          (provider.meta?.apiFormat === \"openai_chat\" ||\n            provider.meta?.apiFormat === \"openai_responses\")\n        ) {\n          // OpenAI format provider: show proxy hint\n          toast.info(\n            t(\"notifications.openAIFormatHint\", {\n              defaultValue:\n                \"此供应商使用 OpenAI 兼容格式，需要开启代理服务才能正常使用\",\n            }),\n            {\n              duration: 5000,\n              closeButton: true,\n            },\n          );\n        } else {\n          // 普通供应商：显示切换成功\n          // OpenCode/OpenClaw: show \"added to config\" message instead of \"switched\"\n          const isMultiProviderApp =\n            activeApp === \"opencode\" || activeApp === \"openclaw\";\n          const messageKey = isMultiProviderApp\n            ? \"notifications.addToConfigSuccess\"\n            : \"notifications.switchSuccess\";\n          const defaultMessage = isMultiProviderApp\n            ? \"已添加到配置\"\n            : \"切换成功！\";\n\n          toast.success(t(messageKey, { defaultValue: defaultMessage }), {\n            closeButton: true,\n          });\n        }\n      } catch {\n        // 错误提示由 mutation 处理\n      }\n    },\n    [switchProviderMutation, syncClaudePlugin, activeApp, t],\n  );\n\n  // 删除供应商\n  const deleteProvider = useCallback(\n    async (id: string) => {\n      await deleteProviderMutation.mutateAsync(id);\n    },\n    [deleteProviderMutation],\n  );\n\n  // 保存用量脚本\n  const saveUsageScript = useCallback(\n    async (provider: Provider, script: UsageScript) => {\n      try {\n        const updatedProvider: Provider = {\n          ...provider,\n          meta: {\n            ...provider.meta,\n            usage_script: script,\n          },\n        };\n\n        await providersApi.update(updatedProvider, activeApp);\n        await queryClient.invalidateQueries({\n          queryKey: [\"providers\", activeApp],\n        });\n        // 🔧 保存用量脚本后，也应该失效该 provider 的用量查询缓存\n        // 这样主页列表会使用新配置重新查询，而不是使用测试时的缓存\n        await queryClient.invalidateQueries({\n          queryKey: [\"usage\", provider.id, activeApp],\n        });\n        toast.success(\n          t(\"provider.usageSaved\", {\n            defaultValue: \"用量查询配置已保存\",\n          }),\n          { closeButton: true },\n        );\n      } catch (error) {\n        const detail =\n          extractErrorMessage(error) ||\n          t(\"provider.usageSaveFailed\", {\n            defaultValue: \"用量查询配置保存失败\",\n          });\n        toast.error(detail);\n      }\n    },\n    [activeApp, queryClient, t],\n  );\n\n  // Set provider as default model (OpenClaw only)\n  const setAsDefaultModel = useCallback(\n    async (provider: Provider) => {\n      const config = provider.settingsConfig as OpenClawProviderConfig;\n      if (!config.models || config.models.length === 0) {\n        toast.error(\n          t(\"notifications.openclawNoModels\", {\n            defaultValue: \"该供应商没有配置模型\",\n          }),\n        );\n        return;\n      }\n\n      const model: OpenClawDefaultModel = {\n        primary: `${provider.id}/${config.models[0].id}`,\n        fallbacks: config.models.slice(1).map((m) => `${provider.id}/${m.id}`),\n      };\n\n      try {\n        await openclawApi.setDefaultModel(model);\n        await queryClient.invalidateQueries({\n          queryKey: openclawKeys.defaultModel,\n        });\n        await queryClient.invalidateQueries({\n          queryKey: openclawKeys.health,\n        });\n        toast.success(\n          t(\"notifications.openclawDefaultModelSet\", {\n            defaultValue: \"已设为默认模型\",\n          }),\n          { closeButton: true },\n        );\n      } catch (error) {\n        const detail =\n          extractErrorMessage(error) ||\n          t(\"notifications.openclawDefaultModelSetFailed\", {\n            defaultValue: \"设置默认模型失败\",\n          });\n        toast.error(detail);\n      }\n    },\n    [queryClient, t],\n  );\n\n  return {\n    addProvider,\n    updateProvider,\n    switchProvider,\n    deleteProvider,\n    saveUsageScript,\n    setAsDefaultModel,\n    isLoading:\n      addProviderMutation.isPending ||\n      updateProviderMutation.isPending ||\n      deleteProviderMutation.isPending ||\n      switchProviderMutation.isPending,\n  };\n}\n"
  },
  {
    "path": "src/hooks/useProxyConfig.ts",
    "content": "/**\n * 代理配置管理 Hook\n */\n\nimport { useQuery, useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { invoke } from \"@tauri-apps/api/core\";\nimport { toast } from \"sonner\";\nimport { useTranslation } from \"react-i18next\";\nimport type { ProxyConfig } from \"@/types/proxy\";\n\n/**\n * 代理配置管理\n */\nexport function useProxyConfig() {\n  const queryClient = useQueryClient();\n  const { t } = useTranslation();\n\n  // 查询配置\n  const { data: config, isLoading } = useQuery({\n    queryKey: [\"proxyConfig\"],\n    queryFn: () => invoke<ProxyConfig>(\"get_proxy_config\"),\n  });\n\n  // 更新配置\n  const updateMutation = useMutation({\n    mutationFn: (newConfig: ProxyConfig) =>\n      invoke(\"update_proxy_config\", { config: newConfig }),\n    onSuccess: () => {\n      toast.success(t(\"proxy.settings.toast.saved\"), { closeButton: true });\n      queryClient.invalidateQueries({ queryKey: [\"proxyConfig\"] });\n      queryClient.invalidateQueries({ queryKey: [\"proxyStatus\"] });\n    },\n    onError: (error: Error) => {\n      toast.error(\n        t(\"proxy.settings.toast.saveFailed\", {\n          error: error.message,\n        }),\n      );\n    },\n  });\n\n  return {\n    config,\n    isLoading,\n    updateConfig: updateMutation.mutateAsync,\n    isUpdating: updateMutation.isPending,\n  };\n}\n"
  },
  {
    "path": "src/hooks/useProxyStatus.ts",
    "content": "/**\n * 代理服务状态管理 Hook\n */\n\nimport { useQuery, useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { invoke } from \"@tauri-apps/api/core\";\nimport { toast } from \"sonner\";\nimport { useTranslation } from \"react-i18next\";\nimport type {\n  ProxyStatus,\n  ProxyServerInfo,\n  ProxyTakeoverStatus,\n} from \"@/types/proxy\";\nimport { extractErrorMessage } from \"@/utils/errorUtils\";\n\n/**\n * 代理服务状态管理\n */\nexport function useProxyStatus() {\n  const queryClient = useQueryClient();\n  const { t } = useTranslation();\n\n  // 查询状态（自动轮询）\n  const { data: status, isLoading } = useQuery({\n    queryKey: [\"proxyStatus\"],\n    queryFn: () => invoke<ProxyStatus>(\"get_proxy_status\"),\n    // 仅在服务运行时轮询\n    refetchInterval: (query) => (query.state.data?.running ? 2000 : false),\n    // 保持之前的数据，避免闪烁\n    placeholderData: (previousData) => previousData,\n  });\n\n  // 查询各应用接管状态\n  const { data: takeoverStatus } = useQuery({\n    queryKey: [\"proxyTakeoverStatus\"],\n    queryFn: () => invoke<ProxyTakeoverStatus>(\"get_proxy_takeover_status\"),\n    placeholderData: (previousData) => previousData,\n  });\n\n  // 启动服务器（总开关：仅启动服务，不接管）\n  const startProxyServerMutation = useMutation({\n    mutationFn: () => invoke<ProxyServerInfo>(\"start_proxy_server\"),\n    onSuccess: (info) => {\n      toast.success(\n        t(\"proxy.server.started\", {\n          address: info.address,\n          port: info.port,\n          defaultValue: `代理服务已启动 - ${info.address}:${info.port}`,\n        }),\n        { closeButton: true },\n      );\n      queryClient.invalidateQueries({ queryKey: [\"proxyStatus\"] });\n    },\n    onError: (error: Error) => {\n      const detail =\n        extractErrorMessage(error) ||\n        t(\"common.unknown\", { defaultValue: \"未知错误\" });\n      toast.error(\n        t(\"proxy.server.startFailed\", {\n          defaultValue: `启动代理服务失败: ${detail}`,\n        }),\n      );\n    },\n  });\n\n  // 停止服务器（总开关关闭：强制恢复所有已接管的 Live 配置）\n  const stopWithRestoreMutation = useMutation({\n    mutationFn: () => invoke(\"stop_proxy_with_restore\"),\n    onSuccess: () => {\n      toast.success(\n        t(\"proxy.stoppedWithRestore\", {\n          defaultValue: \"代理服务已关闭，已恢复所有接管配置\",\n        }),\n        { closeButton: true },\n      );\n      queryClient.invalidateQueries({ queryKey: [\"proxyStatus\"] });\n      queryClient.invalidateQueries({ queryKey: [\"proxyTakeoverStatus\"] });\n      // 彻底删除所有供应商健康状态缓存（后端已清空数据库记录）\n      queryClient.removeQueries({ queryKey: [\"providerHealth\"] });\n      // 彻底删除所有熔断器统计缓存（代理停止后熔断器状态已重置）\n      queryClient.removeQueries({ queryKey: [\"circuitBreakerStats\"] });\n      // 注意：故障转移队列和开关状态会保留，不需要刷新\n    },\n    onError: (error: Error) => {\n      const detail =\n        extractErrorMessage(error) ||\n        t(\"common.unknown\", { defaultValue: \"未知错误\" });\n      toast.error(\n        t(\"proxy.stopWithRestoreFailed\", {\n          defaultValue: `停止失败: ${detail}`,\n        }),\n      );\n    },\n  });\n\n  // 按应用开启/关闭接管\n  const setTakeoverForAppMutation = useMutation({\n    mutationFn: ({ appType, enabled }: { appType: string; enabled: boolean }) =>\n      invoke(\"set_proxy_takeover_for_app\", { appType, enabled }),\n    onSuccess: (_data, variables) => {\n      const appLabel =\n        variables.appType === \"claude\"\n          ? \"Claude\"\n          : variables.appType === \"codex\"\n            ? \"Codex\"\n            : variables.appType === \"gemini\"\n              ? \"Gemini\"\n              : \"OpenCode\";\n\n      toast.success(\n        variables.enabled\n          ? t(\"proxy.takeover.enabled\", {\n              app: appLabel,\n              defaultValue: `已接管 ${appLabel} 配置（请求将走本地代理）`,\n            })\n          : t(\"proxy.takeover.disabled\", {\n              app: appLabel,\n              defaultValue: `已恢复 ${appLabel} 配置`,\n            }),\n        { closeButton: true },\n      );\n\n      queryClient.invalidateQueries({ queryKey: [\"proxyStatus\"] });\n      queryClient.invalidateQueries({ queryKey: [\"proxyTakeoverStatus\"] });\n    },\n    onError: (error: Error) => {\n      const detail =\n        extractErrorMessage(error) ||\n        t(\"common.unknown\", { defaultValue: \"未知错误\" });\n      toast.error(\n        t(\"proxy.takeover.failed\", {\n          defaultValue: `操作失败: ${detail}`,\n        }),\n      );\n    },\n  });\n\n  // 代理模式切换供应商（热切换）\n  const switchProxyProviderMutation = useMutation({\n    mutationFn: ({\n      appType,\n      providerId,\n    }: {\n      appType: string;\n      providerId: string;\n    }) => invoke(\"switch_proxy_provider\", { appType, providerId }),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: [\"proxyStatus\"] });\n    },\n    onError: (error: Error) => {\n      const detail =\n        extractErrorMessage(error) ||\n        t(\"common.unknown\", { defaultValue: \"未知错误\" });\n      toast.error(\n        t(\"proxy.switchFailed\", {\n          error: detail,\n          defaultValue: `切换失败: ${detail}`,\n        }),\n      );\n    },\n  });\n\n  // 检查是否运行中\n  const checkRunning = async () => {\n    try {\n      return await invoke<boolean>(\"is_proxy_running\");\n    } catch {\n      return false;\n    }\n  };\n\n  // 检查接管状态\n  const checkTakeoverActive = async () => {\n    try {\n      return await invoke<boolean>(\"is_live_takeover_active\");\n    } catch {\n      return false;\n    }\n  };\n\n  return {\n    status,\n    isLoading,\n    isRunning: status?.running || false,\n    takeoverStatus,\n    isTakeoverActive:\n      takeoverStatus?.claude ||\n      takeoverStatus?.codex ||\n      takeoverStatus?.gemini ||\n      false,\n\n    // 启动/停止（总开关）\n    startProxyServer: startProxyServerMutation.mutateAsync,\n    stopWithRestore: stopWithRestoreMutation.mutateAsync,\n\n    // 按应用接管开关\n    setTakeoverForApp: setTakeoverForAppMutation.mutateAsync,\n\n    // 代理模式下切换供应商\n    switchProxyProvider: switchProxyProviderMutation.mutateAsync,\n\n    // 状态检查\n    checkRunning,\n    checkTakeoverActive,\n\n    // 加载状态\n    isStarting: startProxyServerMutation.isPending,\n    isStopping: stopWithRestoreMutation.isPending,\n    isPending:\n      startProxyServerMutation.isPending ||\n      stopWithRestoreMutation.isPending ||\n      setTakeoverForAppMutation.isPending,\n  };\n}\n"
  },
  {
    "path": "src/hooks/useSessionSearch.ts",
    "content": "import { useCallback, useMemo } from \"react\";\nimport FlexSearch from \"flexsearch\";\nimport type { SessionMeta } from \"@/types\";\n\ninterface UseSessionSearchOptions {\n  sessions: SessionMeta[];\n  providerFilter: string;\n}\n\ninterface UseSessionSearchResult {\n  search: (query: string) => SessionMeta[];\n}\n\n/**\n * 使用 FlexSearch 实现会话全文搜索\n * 索引会话元数据（标题、摘要、项目目录等）\n */\nexport function useSessionSearch({\n  sessions,\n  providerFilter,\n}: UseSessionSearchOptions): UseSessionSearchResult {\n  const index = useMemo(() => {\n    // 使用 forward tokenizer 支持中文前缀搜索\n    const nextIndex = new FlexSearch.Index({\n      tokenize: \"forward\",\n      resolution: 9,\n    });\n\n    sessions.forEach((session, idx) => {\n      const metaContent = [\n        session.sessionId,\n        session.title,\n        session.summary,\n        session.projectDir,\n        session.sourcePath,\n      ]\n        .filter(Boolean)\n        .join(\" \");\n\n      nextIndex.add(idx, metaContent);\n    });\n\n    return nextIndex;\n  }, [sessions]);\n\n  // 搜索函数\n  const search = useCallback(\n    (query: string): SessionMeta[] => {\n      const needle = query.trim().toLowerCase();\n\n      // 先按 provider 过滤\n      let filtered = sessions;\n      if (providerFilter !== \"all\") {\n        filtered = sessions.filter((s) => s.providerId === providerFilter);\n      }\n\n      // 如果没有搜索词，返回按时间排序的结果\n      if (!needle) {\n        return [...filtered].sort((a, b) => {\n          const aTs = a.lastActiveAt ?? a.createdAt ?? 0;\n          const bTs = b.lastActiveAt ?? b.createdAt ?? 0;\n          return bTs - aTs;\n        });\n      }\n\n      // 使用 FlexSearch 搜索\n      const results = index.search(needle, { limit: 100 }) as number[];\n\n      // 转换为 session 并过滤\n      const matchedSessions = results\n        .map((idx) => sessions[idx])\n        .filter(\n          (session) =>\n            session &&\n            (providerFilter === \"all\" || session.providerId === providerFilter),\n        );\n\n      // 按时间排序\n      return matchedSessions.sort((a, b) => {\n        const aTs = a.lastActiveAt ?? a.createdAt ?? 0;\n        const bTs = b.lastActiveAt ?? b.createdAt ?? 0;\n        return bTs - aTs;\n      });\n    },\n    [index, providerFilter, sessions],\n  );\n\n  return useMemo(() => ({ search }), [search]);\n}\n"
  },
  {
    "path": "src/hooks/useSettings.ts",
    "content": "import { useCallback, useMemo } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { toast } from \"sonner\";\nimport { providersApi, settingsApi, type AppId } from \"@/lib/api\";\nimport { syncCurrentProvidersLiveSafe } from \"@/utils/postChangeSync\";\nimport { useSettingsQuery, useSaveSettingsMutation } from \"@/lib/query\";\nimport type { Settings } from \"@/types\";\nimport { useSettingsForm, type SettingsFormState } from \"./useSettingsForm\";\nimport {\n  useDirectorySettings,\n  type ResolvedDirectories,\n} from \"./useDirectorySettings\";\nimport { useSettingsMetadata } from \"./useSettingsMetadata\";\n\ntype Language = \"zh\" | \"en\" | \"ja\";\n\ninterface SaveResult {\n  requiresRestart: boolean;\n}\n\nexport interface UseSettingsResult {\n  settings: SettingsFormState | null;\n  isLoading: boolean;\n  isSaving: boolean;\n  isPortable: boolean;\n  appConfigDir?: string;\n  resolvedDirs: ResolvedDirectories;\n  requiresRestart: boolean;\n  updateSettings: (updates: Partial<SettingsFormState>) => void;\n  updateDirectory: (app: AppId, value?: string) => void;\n  updateAppConfigDir: (value?: string) => void;\n  browseDirectory: (app: AppId) => Promise<void>;\n  browseAppConfigDir: () => Promise<void>;\n  resetDirectory: (app: AppId) => Promise<void>;\n  resetAppConfigDir: () => Promise<void>;\n  saveSettings: (\n    overrides?: Partial<SettingsFormState>,\n    options?: { silent?: boolean },\n  ) => Promise<SaveResult | null>;\n  autoSaveSettings: (\n    updates: Partial<SettingsFormState>,\n  ) => Promise<SaveResult | null>;\n  resetSettings: () => void;\n  acknowledgeRestart: () => void;\n}\n\nexport type { SettingsFormState, ResolvedDirectories };\n\nconst sanitizeDir = (value?: string | null): string | undefined => {\n  if (!value) return undefined;\n  const trimmed = value.trim();\n  return trimmed.length > 0 ? trimmed : undefined;\n};\n\n/**\n * useSettings - 组合层\n * 负责：\n * - 组合 useSettingsForm、useDirectorySettings、useSettingsMetadata\n * - 保存设置逻辑\n * - 重置设置逻辑\n */\nexport function useSettings(): UseSettingsResult {\n  const { t } = useTranslation();\n  const { data } = useSettingsQuery();\n  const saveMutation = useSaveSettingsMutation();\n\n  // 1️⃣ 表单状态管理\n  const {\n    settings,\n    isLoading: isFormLoading,\n    initialLanguage,\n    updateSettings,\n    resetSettings: resetForm,\n    syncLanguage,\n  } = useSettingsForm();\n\n  // 2️⃣ 目录管理\n  const {\n    appConfigDir,\n    resolvedDirs,\n    isLoading: isDirectoryLoading,\n    initialAppConfigDir,\n    updateDirectory,\n    updateAppConfigDir,\n    browseDirectory,\n    browseAppConfigDir,\n    resetDirectory,\n    resetAppConfigDir,\n    resetAllDirectories,\n  } = useDirectorySettings({\n    settings,\n    onUpdateSettings: updateSettings,\n  });\n\n  // 3️⃣ 元数据管理\n  const {\n    isPortable,\n    requiresRestart,\n    isLoading: isMetadataLoading,\n    acknowledgeRestart,\n    setRequiresRestart,\n  } = useSettingsMetadata();\n\n  // 重置设置\n  const resetSettings = useCallback(() => {\n    resetForm(data ?? null);\n    syncLanguage(initialLanguage);\n    resetAllDirectories(\n      sanitizeDir(data?.claudeConfigDir),\n      sanitizeDir(data?.codexConfigDir),\n      sanitizeDir(data?.geminiConfigDir),\n      sanitizeDir(data?.opencodeConfigDir),\n    );\n    setRequiresRestart(false);\n  }, [\n    data,\n    initialLanguage,\n    resetForm,\n    syncLanguage,\n    resetAllDirectories,\n    setRequiresRestart,\n  ]);\n\n  // 即时保存设置（用于 General 标签页的实时更新）\n  // 保存基础配置 + 独立的系统 API 调用（开机自启）\n  const autoSaveSettings = useCallback(\n    async (updates: Partial<SettingsFormState>): Promise<SaveResult | null> => {\n      const mergedSettings = settings ? { ...settings, ...updates } : null;\n      if (!mergedSettings) return null;\n\n      try {\n        const sanitizedClaudeDir = sanitizeDir(mergedSettings.claudeConfigDir);\n        const sanitizedCodexDir = sanitizeDir(mergedSettings.codexConfigDir);\n        const sanitizedGeminiDir = sanitizeDir(mergedSettings.geminiConfigDir);\n        const sanitizedOpencodeDir = sanitizeDir(\n          mergedSettings.opencodeConfigDir,\n        );\n        const { webdavSync: _ignoredWebdavSync, ...restSettings } =\n          mergedSettings;\n\n        const payload: Settings = {\n          ...restSettings,\n          claudeConfigDir: sanitizedClaudeDir,\n          codexConfigDir: sanitizedCodexDir,\n          geminiConfigDir: sanitizedGeminiDir,\n          opencodeConfigDir: sanitizedOpencodeDir,\n          language: mergedSettings.language,\n        };\n\n        // 保存到配置文件\n        await saveMutation.mutateAsync(payload);\n\n        // 如果开机自启状态改变，调用系统 API\n        if (\n          payload.launchOnStartup !== undefined &&\n          payload.launchOnStartup !== data?.launchOnStartup\n        ) {\n          try {\n            await settingsApi.setAutoLaunch(payload.launchOnStartup);\n          } catch (error) {\n            console.error(\"Failed to update auto-launch:\", error);\n            toast.error(\n              t(\"settings.autoLaunchFailed\", {\n                defaultValue: \"设置开机自启失败\",\n              }),\n            );\n          }\n        }\n\n        // Claude Code 初次安装确认：开=写入 hasCompletedOnboarding=true；关=删除该字段\n        // 仅在本次更新包含 skipClaudeOnboarding 时触发，避免其它自动保存误触发\n        const nextSkipClaudeOnboarding = updates.skipClaudeOnboarding;\n        if (\n          nextSkipClaudeOnboarding !== undefined &&\n          nextSkipClaudeOnboarding !== (data?.skipClaudeOnboarding ?? false)\n        ) {\n          try {\n            if (nextSkipClaudeOnboarding) {\n              await settingsApi.applyClaudeOnboardingSkip();\n            } else {\n              await settingsApi.clearClaudeOnboardingSkip();\n            }\n          } catch (error) {\n            console.warn(\n              \"[useSettings] Failed to sync Claude onboarding skip\",\n              error,\n            );\n            toast.error(\n              nextSkipClaudeOnboarding\n                ? t(\"notifications.skipClaudeOnboardingFailed\", {\n                    defaultValue: \"跳过 Claude Code 初次安装确认失败\",\n                  })\n                : t(\"notifications.clearClaudeOnboardingSkipFailed\", {\n                    defaultValue: \"恢复 Claude Code 初次安装确认失败\",\n                  }),\n            );\n          }\n        }\n\n        // 持久化语言偏好\n        try {\n          if (typeof window !== \"undefined\" && updates.language) {\n            window.localStorage.setItem(\"language\", updates.language);\n          }\n        } catch (error) {\n          console.warn(\n            \"[useSettings] Failed to persist language preference\",\n            error,\n          );\n        }\n\n        // 更新托盘菜单\n        try {\n          await providersApi.updateTrayMenu();\n        } catch (error) {\n          console.warn(\"[useSettings] Failed to refresh tray menu\", error);\n        }\n\n        return { requiresRestart: false };\n      } catch (error) {\n        console.error(\"[useSettings] Failed to auto-save settings\", error);\n        toast.error(\n          t(\"notifications.settingsSaveFailed\", {\n            defaultValue: \"保存设置失败: {{error}}\",\n            error: (error as Error)?.message ?? String(error),\n          }),\n        );\n        throw error;\n      }\n    },\n    [data, saveMutation, settings, t],\n  );\n\n  // 完整保存设置（用于 Advanced 标签页的手动保存）\n  // 包含所有系统 API 调用和完整的验证流程\n  const saveSettings = useCallback(\n    async (\n      overrides?: Partial<SettingsFormState>,\n      options?: { silent?: boolean },\n    ): Promise<SaveResult | null> => {\n      const mergedSettings = settings ? { ...settings, ...overrides } : null;\n      if (!mergedSettings) return null;\n      try {\n        const sanitizedAppDir = sanitizeDir(appConfigDir);\n        const sanitizedClaudeDir = sanitizeDir(mergedSettings.claudeConfigDir);\n        const sanitizedCodexDir = sanitizeDir(mergedSettings.codexConfigDir);\n        const sanitizedGeminiDir = sanitizeDir(mergedSettings.geminiConfigDir);\n        const sanitizedOpencodeDir = sanitizeDir(\n          mergedSettings.opencodeConfigDir,\n        );\n        const previousAppDir = initialAppConfigDir;\n        const previousClaudeDir = sanitizeDir(data?.claudeConfigDir);\n        const previousCodexDir = sanitizeDir(data?.codexConfigDir);\n        const previousGeminiDir = sanitizeDir(data?.geminiConfigDir);\n        const previousOpencodeDir = sanitizeDir(data?.opencodeConfigDir);\n        const { webdavSync: _ignoredWebdavSync, ...restSettings } =\n          mergedSettings;\n\n        const payload: Settings = {\n          ...restSettings,\n          claudeConfigDir: sanitizedClaudeDir,\n          codexConfigDir: sanitizedCodexDir,\n          geminiConfigDir: sanitizedGeminiDir,\n          opencodeConfigDir: sanitizedOpencodeDir,\n          language: mergedSettings.language,\n        };\n\n        await saveMutation.mutateAsync(payload);\n\n        await settingsApi.setAppConfigDirOverride(sanitizedAppDir ?? null);\n\n        // 只在开机自启状态真正改变时调用系统 API\n        if (\n          payload.launchOnStartup !== undefined &&\n          payload.launchOnStartup !== data?.launchOnStartup\n        ) {\n          try {\n            await settingsApi.setAutoLaunch(payload.launchOnStartup);\n          } catch (error) {\n            console.error(\"Failed to update auto-launch:\", error);\n            toast.error(\n              t(\"settings.autoLaunchFailed\", {\n                defaultValue: \"设置开机自启失败\",\n              }),\n            );\n          }\n        }\n\n        // Claude Code 初次安装确认：开=写入 hasCompletedOnboarding=true；关=删除该字段\n        const prevSkipClaudeOnboarding = data?.skipClaudeOnboarding ?? false;\n        const nextSkipClaudeOnboarding = payload.skipClaudeOnboarding ?? false;\n        if (nextSkipClaudeOnboarding !== prevSkipClaudeOnboarding) {\n          try {\n            if (nextSkipClaudeOnboarding) {\n              await settingsApi.applyClaudeOnboardingSkip();\n            } else {\n              await settingsApi.clearClaudeOnboardingSkip();\n            }\n          } catch (error) {\n            console.warn(\n              \"[useSettings] Failed to sync Claude onboarding skip\",\n              error,\n            );\n            toast.error(\n              nextSkipClaudeOnboarding\n                ? t(\"notifications.skipClaudeOnboardingFailed\", {\n                    defaultValue: \"跳过 Claude Code 初次安装确认失败\",\n                  })\n                : t(\"notifications.clearClaudeOnboardingSkipFailed\", {\n                    defaultValue: \"恢复 Claude Code 初次安装确认失败\",\n                  }),\n            );\n          }\n        }\n\n        // 只在 Claude 插件集成状态真正改变时调用系统 API\n        if (\n          payload.enableClaudePluginIntegration !== undefined &&\n          payload.enableClaudePluginIntegration !==\n            data?.enableClaudePluginIntegration\n        ) {\n          try {\n            if (payload.enableClaudePluginIntegration) {\n              await settingsApi.applyClaudePluginConfig({ official: false });\n            } else {\n              await settingsApi.applyClaudePluginConfig({ official: true });\n            }\n          } catch (error) {\n            console.warn(\n              \"[useSettings] Failed to sync Claude plugin config\",\n              error,\n            );\n            toast.error(\n              t(\"notifications.syncClaudePluginFailed\", {\n                defaultValue: \"同步 Claude 插件失败\",\n              }),\n            );\n          }\n        }\n\n        try {\n          if (typeof window !== \"undefined\") {\n            window.localStorage.setItem(\n              \"language\",\n              payload.language as Language,\n            );\n          }\n        } catch (error) {\n          console.warn(\n            \"[useSettings] Failed to persist language preference\",\n            error,\n          );\n        }\n\n        try {\n          await providersApi.updateTrayMenu();\n        } catch (error) {\n          console.warn(\"[useSettings] Failed to refresh tray menu\", error);\n        }\n\n        // 如果 Claude/Codex/Gemini/OpenCode 的目录覆盖发生变化，则立即将\"当前使用的供应商\"写回对应应用的 live 配置\n        const claudeDirChanged = sanitizedClaudeDir !== previousClaudeDir;\n        const codexDirChanged = sanitizedCodexDir !== previousCodexDir;\n        const geminiDirChanged = sanitizedGeminiDir !== previousGeminiDir;\n        const opencodeDirChanged = sanitizedOpencodeDir !== previousOpencodeDir;\n        if (\n          claudeDirChanged ||\n          codexDirChanged ||\n          geminiDirChanged ||\n          opencodeDirChanged\n        ) {\n          const syncResult = await syncCurrentProvidersLiveSafe();\n          if (!syncResult.ok) {\n            console.warn(\n              \"[useSettings] Failed to sync current providers after directory change\",\n              syncResult.error,\n            );\n          }\n        }\n\n        const appDirChanged = sanitizedAppDir !== (previousAppDir ?? undefined);\n        setRequiresRestart(appDirChanged);\n\n        if (!options?.silent) {\n          toast.success(\n            t(\"notifications.settingsSaved\", {\n              defaultValue: \"设置已保存\",\n            }),\n            { closeButton: true },\n          );\n        }\n\n        return { requiresRestart: appDirChanged };\n      } catch (error) {\n        console.error(\"[useSettings] Failed to save settings\", error);\n        toast.error(\n          t(\"notifications.settingsSaveFailed\", {\n            defaultValue: \"保存设置失败: {{error}}\",\n            error: (error as Error)?.message ?? String(error),\n          }),\n        );\n        throw error;\n      }\n    },\n    [\n      appConfigDir,\n      data,\n      initialAppConfigDir,\n      saveMutation,\n      settings,\n      setRequiresRestart,\n      t,\n    ],\n  );\n\n  const isLoading = useMemo(\n    () => isFormLoading || isDirectoryLoading || isMetadataLoading,\n    [isFormLoading, isDirectoryLoading, isMetadataLoading],\n  );\n\n  return {\n    settings,\n    isLoading,\n    isSaving: saveMutation.isPending,\n    isPortable,\n    appConfigDir,\n    resolvedDirs,\n    requiresRestart,\n    updateSettings,\n    updateDirectory,\n    updateAppConfigDir,\n    browseDirectory,\n    browseAppConfigDir,\n    resetDirectory,\n    resetAppConfigDir,\n    saveSettings,\n    autoSaveSettings,\n    resetSettings,\n    acknowledgeRestart,\n  };\n}\n"
  },
  {
    "path": "src/hooks/useSettingsForm.ts",
    "content": "import { useCallback, useEffect, useRef, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { useSettingsQuery } from \"@/lib/query\";\nimport type { Settings } from \"@/types\";\n\ntype Language = \"zh\" | \"en\" | \"ja\";\n\nexport type SettingsFormState = Omit<Settings, \"language\"> & {\n  language: Language;\n};\n\nconst normalizeLanguage = (lang?: string | null): Language => {\n  if (!lang) return \"zh\";\n  const normalized = lang.toLowerCase();\n  return normalized === \"en\" || normalized === \"ja\" ? normalized : \"zh\";\n};\n\nconst sanitizeDir = (value?: string | null): string | undefined => {\n  if (!value) return undefined;\n  const trimmed = value.trim();\n  return trimmed.length > 0 ? trimmed : undefined;\n};\n\nexport interface UseSettingsFormResult {\n  settings: SettingsFormState | null;\n  isLoading: boolean;\n  initialLanguage: Language;\n  updateSettings: (updates: Partial<SettingsFormState>) => void;\n  resetSettings: (serverData: Settings | null) => void;\n  readPersistedLanguage: () => Language;\n  syncLanguage: (lang: Language) => void;\n}\n\n/**\n * useSettingsForm - 表单状态管理\n * 负责：\n * - 表单数据状态\n * - 表单字段更新\n * - 语言同步\n * - 表单重置\n */\nexport function useSettingsForm(): UseSettingsFormResult {\n  const { i18n } = useTranslation();\n  const { data, isLoading } = useSettingsQuery();\n\n  const [settingsState, setSettingsState] = useState<SettingsFormState | null>(\n    null,\n  );\n\n  const initialLanguageRef = useRef<Language>(\"zh\");\n\n  const readPersistedLanguage = useCallback((): Language => {\n    if (typeof window !== \"undefined\") {\n      const stored = window.localStorage.getItem(\"language\");\n      if (stored === \"en\" || stored === \"zh\" || stored === \"ja\") {\n        return stored as Language;\n      }\n    }\n    return normalizeLanguage(i18n.language);\n  }, [i18n]);\n\n  const syncLanguage = useCallback(\n    (lang: Language) => {\n      const current = normalizeLanguage(i18n.language);\n      if (current !== lang) {\n        void i18n.changeLanguage(lang);\n      }\n    },\n    [i18n],\n  );\n\n  // 初始化设置数据\n  useEffect(() => {\n    if (!data) return;\n\n    const normalizedLanguage = normalizeLanguage(\n      data.language ?? readPersistedLanguage(),\n    );\n\n    const normalized: SettingsFormState = {\n      ...data,\n      showInTray: data.showInTray ?? true,\n      minimizeToTrayOnClose: data.minimizeToTrayOnClose ?? true,\n      enableClaudePluginIntegration:\n        data.enableClaudePluginIntegration ?? false,\n      silentStartup: data.silentStartup ?? false,\n      skipClaudeOnboarding: data.skipClaudeOnboarding ?? false,\n      claudeConfigDir: sanitizeDir(data.claudeConfigDir),\n      codexConfigDir: sanitizeDir(data.codexConfigDir),\n      geminiConfigDir: sanitizeDir(data.geminiConfigDir),\n      opencodeConfigDir: sanitizeDir(data.opencodeConfigDir),\n      language: normalizedLanguage,\n    };\n\n    setSettingsState(normalized);\n    initialLanguageRef.current = normalizedLanguage;\n    syncLanguage(normalizedLanguage);\n  }, [data, readPersistedLanguage, syncLanguage]);\n\n  const updateSettings = useCallback(\n    (updates: Partial<SettingsFormState>) => {\n      setSettingsState((prev) => {\n        const base =\n          prev ??\n          ({\n            showInTray: true,\n            minimizeToTrayOnClose: true,\n            enableClaudePluginIntegration: false,\n            skipClaudeOnboarding: false,\n            language: readPersistedLanguage(),\n          } as SettingsFormState);\n\n        const next: SettingsFormState = {\n          ...base,\n          ...updates,\n        };\n\n        if (updates.language) {\n          const normalized = normalizeLanguage(updates.language);\n          next.language = normalized;\n          syncLanguage(normalized);\n        }\n\n        return next;\n      });\n    },\n    [readPersistedLanguage, syncLanguage],\n  );\n\n  const resetSettings = useCallback(\n    (serverData: Settings | null) => {\n      if (!serverData) return;\n\n      const normalizedLanguage = normalizeLanguage(\n        serverData.language ?? readPersistedLanguage(),\n      );\n\n      const normalized: SettingsFormState = {\n        ...serverData,\n        showInTray: serverData.showInTray ?? true,\n        minimizeToTrayOnClose: serverData.minimizeToTrayOnClose ?? true,\n        enableClaudePluginIntegration:\n          serverData.enableClaudePluginIntegration ?? false,\n        silentStartup: serverData.silentStartup ?? false,\n        skipClaudeOnboarding: serverData.skipClaudeOnboarding ?? false,\n        claudeConfigDir: sanitizeDir(serverData.claudeConfigDir),\n        codexConfigDir: sanitizeDir(serverData.codexConfigDir),\n        geminiConfigDir: sanitizeDir(serverData.geminiConfigDir),\n        opencodeConfigDir: sanitizeDir(serverData.opencodeConfigDir),\n        language: normalizedLanguage,\n      };\n\n      setSettingsState(normalized);\n      syncLanguage(initialLanguageRef.current);\n    },\n    [readPersistedLanguage, syncLanguage],\n  );\n\n  return {\n    settings: settingsState,\n    isLoading,\n    initialLanguage: initialLanguageRef.current,\n    updateSettings,\n    resetSettings,\n    readPersistedLanguage,\n    syncLanguage,\n  };\n}\n"
  },
  {
    "path": "src/hooks/useSettingsMetadata.ts",
    "content": "import { useCallback, useEffect, useState } from \"react\";\nimport { settingsApi } from \"@/lib/api\";\n\nexport interface UseSettingsMetadataResult {\n  isPortable: boolean;\n  requiresRestart: boolean;\n  isLoading: boolean;\n  acknowledgeRestart: () => void;\n  setRequiresRestart: (value: boolean) => void;\n}\n\n/**\n * useSettingsMetadata - 元数据管理\n * 负责：\n * - isPortable（便携模式）\n * - requiresRestart（需要重启标志）\n */\nexport function useSettingsMetadata(): UseSettingsMetadataResult {\n  const [isPortable, setIsPortable] = useState(false);\n  const [requiresRestart, setRequiresRestart] = useState(false);\n  const [isLoading, setIsLoading] = useState(true);\n\n  // 加载元数据\n  useEffect(() => {\n    let active = true;\n    setIsLoading(true);\n\n    const load = async () => {\n      try {\n        const portable = await settingsApi.isPortable();\n\n        if (!active) return;\n\n        setIsPortable(portable);\n      } catch (error) {\n        console.error(\"[useSettingsMetadata] Failed to load metadata\", error);\n      } finally {\n        if (active) {\n          setIsLoading(false);\n        }\n      }\n    };\n\n    void load();\n    return () => {\n      active = false;\n    };\n  }, []);\n\n  const acknowledgeRestart = useCallback(() => {\n    setRequiresRestart(false);\n  }, []);\n\n  return {\n    isPortable,\n    requiresRestart,\n    isLoading,\n    acknowledgeRestart,\n    setRequiresRestart,\n  };\n}\n"
  },
  {
    "path": "src/hooks/useSkills.ts",
    "content": "import { useMutation, useQuery, useQueryClient } from \"@tanstack/react-query\";\nimport {\n  skillsApi,\n  type SkillBackupEntry,\n  type DiscoverableSkill,\n  type ImportSkillSelection,\n  type InstalledSkill,\n} from \"@/lib/api/skills\";\nimport type { AppId } from \"@/lib/api/types\";\n\n/**\n * 查询所有已安装的 Skills\n */\nexport function useInstalledSkills() {\n  return useQuery({\n    queryKey: [\"skills\", \"installed\"],\n    queryFn: () => skillsApi.getInstalled(),\n  });\n}\n\nexport function useSkillBackups() {\n  return useQuery({\n    queryKey: [\"skills\", \"backups\"],\n    queryFn: () => skillsApi.getBackups(),\n    enabled: false,\n  });\n}\n\nexport function useDeleteSkillBackup() {\n  const queryClient = useQueryClient();\n  return useMutation({\n    mutationFn: (backupId: string) => skillsApi.deleteBackup(backupId),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: [\"skills\", \"backups\"] });\n    },\n  });\n}\n\n/**\n * 发现可安装的 Skills（从仓库获取）\n */\nexport function useDiscoverableSkills() {\n  return useQuery({\n    queryKey: [\"skills\", \"discoverable\"],\n    queryFn: () => skillsApi.discoverAvailable(),\n    staleTime: Infinity, // 无限缓存，直到仓库变化时 invalidate\n  });\n}\n\n/**\n * 安装 Skill\n */\nexport function useInstallSkill() {\n  const queryClient = useQueryClient();\n  return useMutation({\n    mutationFn: ({\n      skill,\n      currentApp,\n    }: {\n      skill: DiscoverableSkill;\n      currentApp: AppId;\n    }) => skillsApi.installUnified(skill, currentApp),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: [\"skills\", \"installed\"] });\n      queryClient.invalidateQueries({ queryKey: [\"skills\", \"discoverable\"] });\n    },\n  });\n}\n\n/**\n * 卸载 Skill\n */\nexport function useUninstallSkill() {\n  const queryClient = useQueryClient();\n  return useMutation({\n    mutationFn: (id: string) => skillsApi.uninstallUnified(id),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: [\"skills\", \"installed\"] });\n      queryClient.invalidateQueries({ queryKey: [\"skills\", \"discoverable\"] });\n    },\n  });\n}\n\nexport function useRestoreSkillBackup() {\n  const queryClient = useQueryClient();\n  return useMutation({\n    mutationFn: ({\n      backupId,\n      currentApp,\n    }: {\n      backupId: string;\n      currentApp: AppId;\n    }) => skillsApi.restoreBackup(backupId, currentApp),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: [\"skills\", \"installed\"] });\n      queryClient.invalidateQueries({ queryKey: [\"skills\", \"backups\"] });\n    },\n  });\n}\n\n/**\n * 切换 Skill 在特定应用的启用状态\n */\nexport function useToggleSkillApp() {\n  const queryClient = useQueryClient();\n  return useMutation({\n    mutationFn: ({\n      id,\n      app,\n      enabled,\n    }: {\n      id: string;\n      app: AppId;\n      enabled: boolean;\n    }) => skillsApi.toggleApp(id, app, enabled),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: [\"skills\", \"installed\"] });\n    },\n  });\n}\n\n/**\n * 扫描未管理的 Skills\n */\nexport function useScanUnmanagedSkills() {\n  return useQuery({\n    queryKey: [\"skills\", \"unmanaged\"],\n    queryFn: () => skillsApi.scanUnmanaged(),\n    enabled: false, // 手动触发\n  });\n}\n\n/**\n * 从应用目录导入 Skills\n */\nexport function useImportSkillsFromApps() {\n  const queryClient = useQueryClient();\n  return useMutation({\n    mutationFn: (imports: ImportSkillSelection[]) =>\n      skillsApi.importFromApps(imports),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: [\"skills\", \"installed\"] });\n      queryClient.invalidateQueries({ queryKey: [\"skills\", \"unmanaged\"] });\n    },\n  });\n}\n\n/**\n * 获取仓库列表\n */\nexport function useSkillRepos() {\n  return useQuery({\n    queryKey: [\"skills\", \"repos\"],\n    queryFn: () => skillsApi.getRepos(),\n  });\n}\n\n/**\n * 添加仓库\n */\nexport function useAddSkillRepo() {\n  const queryClient = useQueryClient();\n  return useMutation({\n    mutationFn: skillsApi.addRepo,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: [\"skills\", \"repos\"] });\n      queryClient.invalidateQueries({ queryKey: [\"skills\", \"discoverable\"] });\n    },\n  });\n}\n\n/**\n * 删除仓库\n */\nexport function useRemoveSkillRepo() {\n  const queryClient = useQueryClient();\n  return useMutation({\n    mutationFn: ({ owner, name }: { owner: string; name: string }) =>\n      skillsApi.removeRepo(owner, name),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: [\"skills\", \"repos\"] });\n      queryClient.invalidateQueries({ queryKey: [\"skills\", \"discoverable\"] });\n    },\n  });\n}\n\n/**\n * 从 ZIP 文件安装 Skills\n */\nexport function useInstallSkillsFromZip() {\n  const queryClient = useQueryClient();\n  return useMutation({\n    mutationFn: ({\n      filePath,\n      currentApp,\n    }: {\n      filePath: string;\n      currentApp: AppId;\n    }) => skillsApi.installFromZip(filePath, currentApp),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: [\"skills\", \"installed\"] });\n      queryClient.invalidateQueries({ queryKey: [\"skills\", \"unmanaged\"] });\n    },\n  });\n}\n\n// ========== 辅助类型 ==========\n\nexport type {\n  InstalledSkill,\n  DiscoverableSkill,\n  ImportSkillSelection,\n  SkillBackupEntry,\n  AppId,\n};\n"
  },
  {
    "path": "src/hooks/useStreamCheck.ts",
    "content": "import { useState, useCallback } from \"react\";\nimport { toast } from \"sonner\";\nimport { useTranslation } from \"react-i18next\";\nimport {\n  streamCheckProvider,\n  type StreamCheckResult,\n} from \"@/lib/api/model-test\";\nimport type { AppId } from \"@/lib/api\";\nimport { useResetCircuitBreaker } from \"@/lib/query/failover\";\n\nexport function useStreamCheck(appId: AppId) {\n  const { t } = useTranslation();\n  const [checkingIds, setCheckingIds] = useState<Set<string>>(new Set());\n  const resetCircuitBreaker = useResetCircuitBreaker();\n\n  const checkProvider = useCallback(\n    async (\n      providerId: string,\n      providerName: string,\n    ): Promise<StreamCheckResult | null> => {\n      setCheckingIds((prev) => new Set(prev).add(providerId));\n\n      try {\n        const result = await streamCheckProvider(appId, providerId);\n\n        if (result.status === \"operational\") {\n          toast.success(\n            t(\"streamCheck.operational\", {\n              providerName: providerName,\n              responseTimeMs: result.responseTimeMs,\n              defaultValue: `${providerName} 运行正常 (${result.responseTimeMs}ms)`,\n            }),\n            { closeButton: true },\n          );\n\n          // 测试通过后重置熔断器状态\n          resetCircuitBreaker.mutate({ providerId, appType: appId });\n        } else if (result.status === \"degraded\") {\n          toast.warning(\n            t(\"streamCheck.degraded\", {\n              providerName: providerName,\n              responseTimeMs: result.responseTimeMs,\n              defaultValue: `${providerName} 响应较慢 (${result.responseTimeMs}ms)`,\n            }),\n          );\n\n          // 降级状态也重置熔断器，因为至少能通信\n          resetCircuitBreaker.mutate({ providerId, appType: appId });\n        } else {\n          toast.error(\n            t(\"streamCheck.failed\", {\n              providerName: providerName,\n              message: result.message,\n              defaultValue: `${providerName} 检查失败: ${result.message}`,\n            }),\n          );\n        }\n\n        return result;\n      } catch (e) {\n        toast.error(\n          t(\"streamCheck.error\", {\n            providerName: providerName,\n            error: String(e),\n            defaultValue: `${providerName} 检查出错: ${String(e)}`,\n          }),\n        );\n        return null;\n      } finally {\n        setCheckingIds((prev) => {\n          const next = new Set(prev);\n          next.delete(providerId);\n          return next;\n        });\n      }\n    },\n    [appId, t, resetCircuitBreaker],\n  );\n\n  const isChecking = useCallback(\n    (providerId: string) => checkingIds.has(providerId),\n    [checkingIds],\n  );\n\n  return { checkProvider, isChecking };\n}\n"
  },
  {
    "path": "src/i18n/index.ts",
    "content": "import i18n from \"i18next\";\nimport { initReactI18next } from \"react-i18next\";\n\nimport en from \"./locales/en.json\";\nimport ja from \"./locales/ja.json\";\nimport zh from \"./locales/zh.json\";\n\ntype Language = \"zh\" | \"en\" | \"ja\";\n\nconst DEFAULT_LANGUAGE: Language = \"zh\";\n\nconst getInitialLanguage = (): Language => {\n  if (typeof window !== \"undefined\") {\n    try {\n      const stored = window.localStorage.getItem(\"language\");\n      if (stored === \"zh\" || stored === \"en\" || stored === \"ja\") {\n        return stored;\n      }\n    } catch (error) {\n      console.warn(\"[i18n] Failed to read stored language preference\", error);\n    }\n  }\n\n  const navigatorLang =\n    typeof navigator !== \"undefined\"\n      ? (navigator.language?.toLowerCase() ??\n        navigator.languages?.[0]?.toLowerCase())\n      : undefined;\n\n  if (navigatorLang?.startsWith(\"zh\")) {\n    return \"zh\";\n  }\n\n  if (navigatorLang?.startsWith(\"ja\")) {\n    return \"ja\";\n  }\n\n  if (navigatorLang?.startsWith(\"en\")) {\n    return \"en\";\n  }\n\n  return DEFAULT_LANGUAGE;\n};\n\nconst resources = {\n  en: {\n    translation: en,\n  },\n  ja: {\n    translation: ja,\n  },\n  zh: {\n    translation: zh,\n  },\n};\n\ni18n.use(initReactI18next).init({\n  resources,\n  lng: getInitialLanguage(), // 根据本地存储或系统语言选择默认语言\n  fallbackLng: \"en\", // 如果缺少中文翻译则退回英文\n\n  interpolation: {\n    escapeValue: false, // React 已经默认转义\n  },\n\n  // 开发模式下显示调试信息\n  debug: false,\n});\n\nexport default i18n;\n"
  },
  {
    "path": "src/i18n/locales/en.json",
    "content": "{\n  \"app\": {\n    \"title\": \"CC Switch\",\n    \"description\": \"All-in-One Assistant for Claude Code, Codex & Gemini CLI\"\n  },\n  \"common\": {\n    \"add\": \"Add\",\n    \"edit\": \"Edit\",\n    \"delete\": \"Delete\",\n    \"save\": \"Save\",\n    \"saving\": \"Saving...\",\n    \"cancel\": \"Cancel\",\n    \"confirm\": \"Confirm\",\n    \"close\": \"Close\",\n    \"done\": \"Done\",\n    \"settings\": \"Settings\",\n    \"about\": \"About\",\n    \"version\": \"Version\",\n    \"loading\": \"Loading...\",\n    \"notInstalled\": \"Not installed\",\n    \"success\": \"Success\",\n    \"error\": \"Error\",\n    \"unknown\": \"Unknown\",\n    \"enterValidValue\": \"Please enter a valid value\",\n    \"clear\": \"Clear\",\n    \"toggleTheme\": \"Toggle theme\",\n    \"format\": \"Format\",\n    \"formatSuccess\": \"Formatted successfully\",\n    \"formatError\": \"Format failed: {{error}}\",\n    \"copy\": \"Copy\",\n    \"view\": \"View\",\n    \"back\": \"Back\",\n    \"refresh\": \"Refresh\",\n    \"refreshing\": \"Refreshing...\",\n    \"import\": \"Import\",\n    \"all\": \"All\",\n    \"search\": \"Search\",\n    \"reset\": \"Reset\",\n    \"actions\": \"Actions\",\n    \"deleting\": \"Deleting...\",\n    \"auto\": \"Auto\",\n    \"enabled\": \"Enabled\",\n    \"notSet\": \"Not Set\"\n  },\n  \"apiKeyInput\": {\n    \"placeholder\": \"Enter API Key\",\n    \"show\": \"Show API Key\",\n    \"hide\": \"Hide API Key\"\n  },\n  \"jsonEditor\": {\n    \"mustBeObject\": \"Configuration must be a JSON object, not an array or other type\",\n    \"invalidJson\": \"Invalid JSON format\"\n  },\n  \"claudeConfig\": {\n    \"configLabel\": \"Claude Code settings.json (JSON) *\",\n    \"writeCommonConfig\": \"Write Common Config\",\n    \"editCommonConfig\": \"Edit Common Config\",\n    \"editCommonConfigTitle\": \"Edit Common Config Snippet\",\n    \"commonConfigHint\": \"This snippet will be merged into settings.json when 'Write Common Config' is checked\",\n    \"fullSettingsHint\": \"Full Claude Code settings.json content\",\n    \"extractFromCurrent\": \"Extract from Editor\",\n    \"extractNoCommonConfig\": \"No common config available to extract from editor\",\n    \"extractFailed\": \"Extract failed: {{error}}\",\n    \"saveFailed\": \"Save failed: {{error}}\",\n    \"hideAttribution\": \"Hide AI Attribution\",\n    \"enableTeammates\": \"Teammates Mode\",\n    \"enableToolSearch\": \"Enable Tool Search\",\n    \"effortHigh\": \"High Effort Thinking\"\n  },\n  \"header\": {\n    \"viewOnGithub\": \"View on GitHub\",\n    \"toggleDarkMode\": \"Switch to Dark Mode\",\n    \"toggleLightMode\": \"Switch to Light Mode\",\n    \"addProvider\": \"Add Provider\",\n    \"switchToChinese\": \"Switch to Chinese\",\n    \"switchToEnglish\": \"Switch to English\",\n    \"enterEditMode\": \"Enter Edit Mode\",\n    \"exitEditMode\": \"Exit Edit Mode\"\n  },\n  \"provider\": {\n    \"tabProvider\": \"Provider\",\n    \"tabUniversal\": \"Universal\",\n    \"noProviders\": \"No providers added yet\",\n    \"noProvidersDescription\": \"If you already have a config, click \\\"Import Current Config\\\" — all data will be safely saved in a default provider\",\n    \"noProvidersDescriptionSnippet\": \"Settings other than API key and endpoint (e.g. plugins) will be saved to a common config snippet for sharing across providers\",\n    \"importCurrent\": \"Import Current Config\",\n    \"importCurrentDescription\": \"Import current live config as default provider\",\n    \"currentlyUsing\": \"Currently Using\",\n    \"enable\": \"Enable\",\n    \"inUse\": \"In Use\",\n    \"editProvider\": \"Edit Provider\",\n    \"editProviderHint\": \"Configuration will be applied to the current provider immediately after update.\",\n    \"deleteProvider\": \"Delete Provider\",\n    \"addNewProvider\": \"Add New Provider\",\n    \"addClaudeProvider\": \"Add Claude Code Provider\",\n    \"addCodexProvider\": \"Add Codex Provider\",\n    \"addGeminiProvider\": \"Add Gemini Provider\",\n    \"addOpenCodeProvider\": \"Add OpenCode Provider\",\n    \"addToConfig\": \"Add\",\n    \"removeFromConfig\": \"Remove\",\n    \"setAsDefault\": \"Set Default\",\n    \"isDefault\": \"Current Default\",\n    \"inConfig\": \"Added\",\n    \"addProviderHint\": \"Fill in the information to quickly switch providers in the list.\",\n    \"editClaudeProvider\": \"Edit Claude Code Provider\",\n    \"editCodexProvider\": \"Edit Codex Provider\",\n    \"configError\": \"Configuration Error\",\n    \"notConfigured\": \"Not configured for official website\",\n    \"applyToClaudePlugin\": \"Apply to Claude plugin\",\n    \"removeFromClaudePlugin\": \"Remove from Claude plugin\",\n    \"dragToReorder\": \"Drag to reorder\",\n    \"dragHandle\": \"Drag to reorder\",\n    \"searchPlaceholder\": \"Search name, notes, or URL...\",\n    \"searchAriaLabel\": \"Search providers\",\n    \"searchScopeHint\": \"Matches provider name, notes, and URL.\",\n    \"searchCloseHint\": \"Press Esc to close\",\n    \"searchCloseAriaLabel\": \"Close provider search\",\n    \"noSearchResults\": \"No providers match your search.\",\n    \"duplicate\": \"Duplicate\",\n    \"sortUpdateFailed\": \"Failed to update sort order\",\n    \"configureUsage\": \"Configure usage query\",\n    \"officialPartner\": \"Official Partner\",\n    \"openTerminal\": \"Open Terminal\",\n    \"terminalOpened\": \"Terminal opened\",\n    \"terminalOpenFailed\": \"Failed to open terminal\",\n    \"name\": \"Provider Name\",\n    \"namePlaceholder\": \"e.g., Claude Official\",\n    \"websiteUrl\": \"Website URL\",\n    \"notes\": \"Notes\",\n    \"notesPlaceholder\": \"e.g., Company dedicated account\",\n    \"configJson\": \"Config JSON\",\n    \"writeCommonConfig\": \"Write common config\",\n    \"editCommonConfigButton\": \"Edit common config\",\n    \"configJsonHint\": \"Please fill in complete Claude Code configuration\",\n    \"editCommonConfigTitle\": \"Edit common config snippet\",\n    \"editCommonConfigHint\": \"Common config snippet will be merged into all providers that enable it\",\n    \"addProvider\": \"Add Provider\",\n    \"sortUpdated\": \"Sort order updated\",\n    \"usageSaved\": \"Usage query configuration saved\",\n    \"usageSaveFailed\": \"Failed to save usage query configuration\",\n    \"geminiConfig\": \"Gemini Configuration\",\n    \"geminiConfigHint\": \"Use .env format to configure Gemini\",\n    \"form\": {\n      \"gemini\": {\n        \"model\": \"Model\",\n        \"oauthTitle\": \"OAuth Authentication Mode\",\n        \"oauthHint\": \"Google official uses OAuth personal authentication, no need to fill in API Key. The browser will automatically open for login on first use.\",\n        \"apiKeyPlaceholder\": \"Enter Gemini API Key\"\n      }\n    }\n  },\n  \"notifications\": {\n    \"providerAdded\": \"Provider added\",\n    \"providerSaved\": \"Provider configuration saved\",\n    \"providerDeleted\": \"Provider deleted successfully\",\n    \"switchSuccess\": \"Switch successful!\",\n    \"addToConfigSuccess\": \"Added to config\",\n    \"removeFromConfigSuccess\": \"Removed from config\",\n    \"switchFailedTitle\": \"Switch failed\",\n    \"switchFailed\": \"Switch failed: {{error}}\",\n    \"autoImported\": \"Default provider created from existing configuration\",\n    \"addFailed\": \"Failed to add provider: {{error}}\",\n    \"saveFailed\": \"Save failed: {{error}}\",\n    \"saveFailedGeneric\": \"Save failed, please try again\",\n    \"appliedToClaudePlugin\": \"Applied to Claude plugin\",\n    \"removedFromClaudePlugin\": \"Removed from Claude plugin\",\n    \"syncClaudePluginFailed\": \"Sync Claude plugin failed\",\n    \"skipClaudeOnboardingFailed\": \"Failed to skip Claude Code first-run confirmation\",\n    \"clearClaudeOnboardingSkipFailed\": \"Failed to restore Claude Code first-run confirmation\",\n    \"updateSuccess\": \"Provider updated successfully\",\n    \"updateFailed\": \"Failed to update provider: {{error}}\",\n    \"deleteSuccess\": \"Provider deleted\",\n    \"deleteFailed\": \"Failed to delete provider: {{error}}\",\n    \"settingsSaved\": \"Settings saved\",\n    \"settingsSaveFailed\": \"Failed to save settings: {{error}}\",\n    \"openAIChatFormatHint\": \"This provider uses OpenAI Chat format and requires the proxy service to be enabled\",\n    \"openAIFormatHint\": \"This provider uses OpenAI-compatible format and requires the proxy service to be enabled\",\n    \"openLinkFailed\": \"Failed to open link\",\n    \"openclawModelsRegistered\": \"Models have been registered to /model list\",\n    \"openclawDefaultModelSet\": \"Set as default model\",\n    \"openclawDefaultModelSetFailed\": \"Failed to set default model\",\n    \"openclawNoModels\": \"No models configured\",\n    \"backfillWarning\": \"Switched successfully, but failed to save changes back to the previous provider\"\n  },\n  \"confirm\": {\n    \"deleteProvider\": \"Delete Provider\",\n    \"deleteProviderMessage\": \"Are you sure you want to delete provider \\\"{{name}}\\\"? This action cannot be undone.\",\n    \"removeProvider\": \"Remove Provider\",\n    \"removeProviderMessage\": \"Are you sure you want to remove provider \\\"{{name}}\\\" from the configuration?\\n\\nAfter removal, this provider will no longer be active, but the configuration data will be retained in CC Switch. You can re-add it at any time.\",\n    \"proxy\": {\n      \"title\": \"Enable Local Proxy\",\n      \"message\": \"Local proxy is an advanced feature. Please make sure you understand how it works before enabling.\\n\\nWe recommend consulting the relevant documentation or your provider for proper configuration.\",\n      \"confirm\": \"I understand, enable\"\n    },\n    \"failover\": {\n      \"title\": \"Enable Failover\",\n      \"message\": \"Failover is an advanced feature. Please make sure you understand how it works before enabling.\\n\\nWe recommend configuring provider priorities in the failover queue first.\",\n      \"confirm\": \"I understand, enable\"\n    },\n    \"usage\": {\n      \"title\": \"Configure Usage Query\",\n      \"message\": \"Usage query requires a custom script or API parameters. Please make sure you have obtained the necessary information from your provider.\\n\\nIf unsure how to configure, please consult your provider's documentation first.\",\n      \"confirm\": \"I understand, configure\"\n    },\n    \"streamCheck\": {\n      \"title\": \"Model Health Check\",\n      \"message\": \"Health check tests provider connectivity by sending a direct API request. The following may cause check failures:\\n\\n• Official providers (uses OAuth login, no standalone API Key)\\n• Some relay services (verify requests come from Claude Code CLI)\\n• AWS Bedrock (uses IAM signature authentication)\\n\\nA failed check does not mean the provider is unusable — it only means it cannot be verified via a standalone request. Please refer to actual behavior within the application.\",\n      \"confirm\": \"I understand, proceed\"\n    },\n    \"autoSync\": {\n      \"title\": \"Enable Auto Sync\",\n      \"message\": \"When auto sync is enabled, every database change will be automatically uploaded to the WebDAV server.\\n\\nThis may result in significant network traffic. Please ensure your network and WebDAV service can handle frequent data transfers.\",\n      \"confirm\": \"I understand, enable\"\n    }\n  },\n  \"settings\": {\n    \"title\": \"Settings\",\n    \"general\": \"General\",\n    \"tabGeneral\": \"General\",\n    \"tabAdvanced\": \"Advanced\",\n    \"tabProxy\": \"Proxy\",\n    \"advanced\": {\n      \"configDir\": {\n        \"title\": \"Configuration Directory\",\n        \"description\": \"Manage storage paths for Claude, Codex and Gemini configurations\"\n      },\n      \"proxy\": {\n        \"title\": \"Local Proxy\",\n        \"description\": \"Control proxy service toggle, view status and port info\",\n        \"enableFeature\": \"Show Proxy Toggle on Main Page\",\n        \"enableFeatureDescription\": \"When enabled, the proxy and failover toggles will appear at the top of the main page\",\n        \"enableFailoverToggle\": \"Show Failover Toggle on Main Page\",\n        \"enableFailoverToggleDescription\": \"When enabled, the failover toggle will appear independently at the top of the main page\",\n        \"running\": \"Running\",\n        \"stopped\": \"Stopped\"\n      },\n      \"modelTest\": {\n        \"title\": \"Model Test Config\",\n        \"description\": \"Configure default models and prompts for model testing\"\n      },\n      \"failover\": {\n        \"title\": \"Auto Failover\",\n        \"description\": \"Configure failover queue and circuit breaker strategy\"\n      },\n      \"pricing\": {\n        \"title\": \"Cost Pricing\",\n        \"description\": \"Manage token pricing rules for each model\"\n      },\n      \"globalProxy\": {\n        \"title\": \"Global Outbound Proxy\",\n        \"description\": \"Configure proxy for CC Switch to access external APIs\"\n      },\n      \"data\": {\n        \"title\": \"Data Management\",\n        \"description\": \"Import and export local configuration data\"\n      },\n      \"backup\": {\n        \"title\": \"Backup & Restore\",\n        \"description\": \"Manage automatic backups, view and restore database snapshots\"\n      },\n      \"cloudSync\": {\n        \"title\": \"Cloud Sync\",\n        \"description\": \"Sync data across devices via WebDAV\"\n      },\n      \"rectifier\": {\n        \"title\": \"Rectifier\",\n        \"description\": \"Automatically fix API request compatibility issues\",\n        \"enabled\": \"Enable Rectifier\",\n        \"enabledDescription\": \"Master switch, all rectification features will be disabled when turned off\",\n        \"requestGroup\": \"Request Rectification\",\n        \"responseGroup\": \"Response Rectification\",\n        \"thinkingSignature\": \"Thinking Signature Rectification\",\n        \"thinkingSignatureDescription\": \"When an Anthropic-type provider returns thinking signature incompatibility or illegal request errors, automatically removes incompatible thinking-related blocks and retries once with the same provider\",\n        \"thinkingBudget\": \"Thinking Budget Rectification\",\n        \"thinkingBudgetDescription\": \"When an Anthropic-type provider returns budget_tokens constraint errors (such as at least 1024), automatically normalizes thinking to enabled, sets thinking budget to 32000, and raises max_tokens to 64000 if needed, then retries once\"\n      },\n      \"optimizer\": {\n        \"title\": \"Bedrock Request Optimizer\",\n        \"description\": \"Automatically optimize Thinking and Cache configuration before sending requests (only applies to Bedrock providers)\",\n        \"enabled\": \"Enable Optimizer\",\n        \"thinkingOptimizer\": \"Thinking Optimization\",\n        \"thinkingOptimizerDescription\": \"Automatically enable Adaptive Thinking for Opus/Sonnet, and inject Extended Thinking for legacy models\",\n        \"cacheInjection\": \"Cache Injection\",\n        \"cacheInjectionDescription\": \"Automatically inject cache breakpoints at key positions in requests to reduce duplicate token billing\",\n        \"cacheTtl\": \"Cache TTL\",\n        \"cacheTtl5m\": \"5 minutes\",\n        \"cacheTtl1h\": \"1 hour\"\n      },\n      \"logConfig\": {\n        \"title\": \"Log Management\",\n        \"description\": \"Control log output level\",\n        \"enabled\": \"Enable Logging\",\n        \"enabledDescription\": \"Master switch, all logging will be disabled when turned off\",\n        \"level\": \"Log Level\",\n        \"levelDescription\": \"Set the minimum log level to output\",\n        \"levels\": {\n          \"error\": \"Error\",\n          \"warn\": \"Warning\",\n          \"info\": \"Info\",\n          \"debug\": \"Debug\",\n          \"trace\": \"Trace\"\n        },\n        \"levelHint\": \"Log level descriptions:\",\n        \"levelDesc\": {\n          \"error\": \"Critical errors only\",\n          \"warn\": \"Errors + warnings\",\n          \"info\": \"General operation info (default)\",\n          \"debug\": \"Detailed info including SSE stream and request/response\",\n          \"trace\": \"All logs, most verbose\"\n        }\n      }\n    },\n    \"language\": \"Language\",\n    \"languageHint\": \"Preview interface language immediately after switching, takes effect permanently after saving.\",\n    \"theme\": \"Theme\",\n    \"themeHint\": \"Choose the appearance theme for the app, takes effect immediately.\",\n    \"themeLight\": \"Light\",\n    \"themeDark\": \"Dark\",\n    \"themeSystem\": \"System\",\n    \"importExport\": \"SQL Import/Export\",\n    \"importExportHint\": \"Import or export database SQL backups for migration or restore (import supports only backups exported by CC Switch).\",\n    \"exportConfig\": \"Export SQL Backup\",\n    \"selectConfigFile\": \"Select SQL File\",\n    \"noFileSelected\": \"No configuration file selected.\",\n    \"import\": \"Import\",\n    \"importing\": \"Importing...\",\n    \"importSuccess\": \"Import Successful!\",\n    \"importFailed\": \"Import Failed\",\n    \"syncLiveFailed\": \"Imported, but failed to sync to the current provider. Please reselect the provider manually.\",\n    \"importPartialSuccess\": \"Config imported, but failed to sync to the current provider.\",\n    \"importPartialHint\": \"Please manually reselect the provider to refresh the live configuration.\",\n    \"configExported\": \"Config exported to:\",\n    \"exportFailed\": \"Export failed\",\n    \"selectFileFailed\": \"Please choose a valid SQL backup file\",\n    \"configCorrupted\": \"SQL file may be corrupted or invalid\",\n    \"backupId\": \"Backup ID\",\n    \"backupManager\": {\n      \"title\": \"Database Backups\",\n      \"description\": \"Automatic database snapshots for restoring to a previous state\",\n      \"empty\": \"No backups yet\",\n      \"restore\": \"Restore\",\n      \"restoring\": \"Restoring...\",\n      \"confirmTitle\": \"Confirm Restore\",\n      \"confirmMessage\": \"Restoring this backup will overwrite the current database. A safety backup will be created first.\",\n      \"restoreSuccess\": \"Restore successful! Safety backup created\",\n      \"restoreFailed\": \"Restore failed\",\n      \"safetyBackupId\": \"Safety Backup ID\",\n      \"intervalLabel\": \"Auto-backup Interval\",\n      \"retainLabel\": \"Backup Retention\",\n      \"intervalDisabled\": \"Disabled\",\n      \"intervalHours\": \"{{hours}} hours\",\n      \"intervalDays\": \"{{days}} days\",\n      \"rename\": \"Rename\",\n      \"renameSuccess\": \"Backup renamed\",\n      \"renameFailed\": \"Rename failed\",\n      \"namePlaceholder\": \"Enter new name\",\n      \"createBackup\": \"Backup Now\",\n      \"creating\": \"Backing up...\",\n      \"createSuccess\": \"Backup created successfully\",\n      \"createFailed\": \"Backup failed\",\n      \"delete\": \"Delete\",\n      \"deleting\": \"Deleting...\",\n      \"deleteSuccess\": \"Backup deleted\",\n      \"deleteFailed\": \"Delete failed\",\n      \"deleteConfirmTitle\": \"Confirm Delete\",\n      \"deleteConfirmMessage\": \"This backup will be permanently deleted. This action cannot be undone.\"\n    },\n    \"webdavSync\": {\n      \"title\": \"WebDAV Cloud Sync\",\n      \"description\": \"Sync database and skill configurations across devices via WebDAV.\",\n      \"baseUrl\": \"WebDAV Server URL\",\n      \"baseUrlPlaceholder\": \"https://dav.example.com/dav/\",\n      \"username\": \"WebDAV Account\",\n      \"usernamePlaceholder\": \"Email or username\",\n      \"password\": \"WebDAV Password\",\n      \"passwordPlaceholder\": \"App password\",\n      \"remoteRoot\": \"Remote Root Directory\",\n      \"profile\": \"Sync Profile Name\",\n      \"autoSync\": \"Auto Sync\",\n      \"autoSyncHint\": \"When enabled, each database change triggers an automatic WebDAV upload.\",\n      \"test\": \"Test Connection\",\n      \"testing\": \"Testing...\",\n      \"testSuccess\": \"Connection successful\",\n      \"testFailed\": \"Connection failed: {{error}}\",\n      \"save\": \"Save Config\",\n      \"saving\": \"Saving...\",\n      \"saveFailed\": \"Failed to save config: {{error}}\",\n      \"upload\": \"Upload to Cloud\",\n      \"uploading\": \"Uploading...\",\n      \"uploadSuccess\": \"Uploaded to WebDAV\",\n      \"uploadFailed\": \"Upload failed: {{error}}\",\n      \"autoSyncFailedToast\": \"Auto sync failed: {{error}}\",\n      \"download\": \"Download from Cloud\",\n      \"downloading\": \"Downloading...\",\n      \"downloadSuccess\": \"Downloaded and restored from WebDAV\",\n      \"downloadFailed\": \"Download failed: {{error}}\",\n      \"lastSync\": \"Last sync: {{time}}\",\n      \"autoSyncLastErrorTitle\": \"Last auto sync failed\",\n      \"autoSyncLastErrorHint\": \"Please check network or WebDAV settings. Auto sync will retry on future changes.\",\n      \"missingUrl\": \"Please enter the WebDAV server URL\",\n      \"presets\": {\n        \"label\": \"Provider\",\n        \"jianguoyun\": \"Jianguoyun\",\n        \"jianguoyunHint\": \"Generate an \\\"App Password\\\" in Jianguoyun security settings. Do not use your login password.\",\n        \"nextcloud\": \"Nextcloud\",\n        \"nextcloudHint\": \"Replace your-server with your Nextcloud domain and USERNAME with your username.\",\n        \"synology\": \"Synology NAS\",\n        \"synologyHint\": \"Install and enable the WebDAV Server package in Synology Package Center first.\",\n        \"custom\": \"Custom\"\n      },\n      \"remoteRootDefault\": \"Default: cc-switch-sync\",\n      \"profileDefault\": \"Default: default\",\n      \"saveAndTestSuccess\": \"Config saved, connection OK\",\n      \"saveAndTestFailed\": \"Config saved, but connection test failed: {{error}}\",\n      \"noRemoteData\": \"No sync data found on the remote server\",\n      \"incompatibleVersion\": \"Remote data is incompatible (protocol v{{protocolVersion}}, database {{dbCompatVersion}}). This client supports protocol v2 / db-v6.\",\n      \"unsaved\": \"Unsaved\",\n      \"saved\": \"Saved\",\n      \"unsavedChanges\": \"Please save config first\",\n      \"saveBeforeSync\": \"Save configuration first to enable upload/download.\",\n      \"fetchingRemote\": \"Fetching remote info...\",\n      \"fetchRemoteFailed\": \"Failed to fetch remote info. Please check configuration and network.\",\n      \"confirmDownload\": {\n        \"title\": \"Restore from Cloud\",\n        \"deviceName\": \"Uploaded by\",\n        \"createdAt\": \"Uploaded at\",\n        \"path\": \"Remote path\",\n        \"dbCompat\": \"DB compatibility\",\n        \"artifacts\": \"Contents\",\n        \"legacyNotice\": \"A legacy remote path was detected. After restoring, the next upload will write to the new v2/db-v6 path.\",\n        \"warning\": \"This will overwrite all local data and skill configurations\",\n        \"confirm\": \"Confirm Restore\"\n      },\n      \"confirmUpload\": {\n        \"title\": \"Upload to Cloud\",\n        \"content\": \"The following will be synced to the WebDAV server:\",\n        \"dbItem\": \"Database (all provider configs and data)\",\n        \"skillsItem\": \"Skills (all custom skills)\",\n        \"targetPath\": \"Target path\",\n        \"existingData\": \"Existing cloud data\",\n        \"deviceName\": \"Uploaded by\",\n        \"createdAt\": \"Uploaded at\",\n        \"path\": \"Remote path\",\n        \"dbCompat\": \"DB compatibility\",\n        \"warning\": \"This will overwrite existing sync data on the remote server\",\n        \"legacyNotice\": \"Legacy remote data was detected. This upload will write to the new v2/db-v6 path and will not overwrite the legacy path.\",\n        \"confirm\": \"Confirm Upload\"\n      }\n    },\n    \"autoReload\": \"Data refreshed\",\n    \"languageOptionChinese\": \"中文\",\n    \"languageOptionEnglish\": \"English\",\n    \"languageOptionJapanese\": \"日本語\",\n    \"windowBehavior\": \"Window Behavior\",\n    \"windowBehaviorHint\": \"Configure window minimize and Claude plugin integration policies.\",\n    \"launchOnStartup\": \"Launch on Startup\",\n    \"launchOnStartupDescription\": \"Automatically run CC Switch when system starts\",\n    \"silentStartup\": \"Silent Startup\",\n    \"silentStartupDescription\": \"Start in background mode without showing main window\",\n    \"autoLaunchFailed\": \"Failed to set auto-launch\",\n    \"minimizeToTray\": \"Minimize to tray on close\",\n    \"minimizeToTrayDescription\": \"When checked, clicking the close button will hide to system tray, otherwise the app will exit directly.\",\n    \"enableClaudePluginIntegration\": \"Apply to Claude Code extension\",\n    \"enableClaudePluginIntegrationDescription\": \"When enabled, the VS Code Claude Code extension provider will switch with this app\",\n    \"skipClaudeOnboarding\": \"Skip Claude Code first-run confirmation\",\n    \"skipClaudeOnboardingDescription\": \"When enabled, Claude Code will skip the first-run confirmation\",\n    \"appVisibility\": {\n      \"title\": \"Homepage Display\",\n      \"description\": \"Choose which apps to show on the homepage\",\n      \"claudeDesc\": \"Anthropic Claude Code CLI\",\n      \"codexDesc\": \"OpenAI Codex CLI\",\n      \"geminiDesc\": \"Google Gemini CLI\",\n      \"opencodeDesc\": \"OpenCode CLI\"\n    },\n    \"skillSync\": {\n      \"title\": \"Skill Sync Method\",\n      \"description\": \"Choose how to sync Skills files\",\n      \"symlink\": \"Symlink\",\n      \"copy\": \"Copy Files\",\n      \"symlinkHint\": \"Symlinks save disk space and enable real-time sync. Note: May require admin privileges or Developer Mode on Windows\"\n    },\n    \"terminal\": {\n      \"title\": \"Preferred Terminal\",\n      \"description\": \"Choose which terminal app to use when clicking the terminal button\",\n      \"fallbackHint\": \"If the selected terminal is unavailable, the system default will be used\",\n      \"options\": {\n        \"macos\": {\n          \"terminal\": \"Terminal.app\",\n          \"iterm2\": \"iTerm2\",\n          \"alacritty\": \"Alacritty\",\n          \"kitty\": \"Kitty\",\n          \"ghostty\": \"Ghostty\",\n          \"wezterm\": \"WezTerm\"\n        },\n        \"windows\": {\n          \"cmd\": \"Command Prompt\",\n          \"powershell\": \"PowerShell\",\n          \"wt\": \"Windows Terminal\"\n        },\n        \"linux\": {\n          \"gnomeTerminal\": \"GNOME Terminal\",\n          \"konsole\": \"Konsole\",\n          \"xfce4Terminal\": \"Xfce4 Terminal\",\n          \"alacritty\": \"Alacritty\",\n          \"kitty\": \"Kitty\",\n          \"ghostty\": \"Ghostty\"\n        }\n      }\n    },\n    \"configDirectoryOverride\": \"Configuration Directory Override (Advanced)\",\n    \"configDirectoryDescription\": \"When using Claude Code or Codex in environments like WSL, you can manually specify the configuration directory to the one in WSL to keep provider data consistent with the main environment.\",\n    \"appConfigDir\": \"CC Switch Configuration Directory\",\n    \"appConfigDirDescription\": \"Customize the storage location for CC Switch configuration (point to cloud sync folder to enable config sync)\",\n    \"browsePlaceholderApp\": \"e.g., C:\\\\Users\\\\Administrator\\\\.cc-switch\",\n    \"claudeConfigDir\": \"Claude Code Configuration Directory\",\n    \"claudeConfigDirDescription\": \"Override Claude configuration directory (settings.json) and keep claude.json (MCP) alongside it.\",\n    \"codexConfigDir\": \"Codex Configuration Directory\",\n    \"codexConfigDirDescription\": \"Override Codex configuration directory.\",\n    \"geminiConfigDir\": \"Gemini Configuration Directory\",\n    \"geminiConfigDirDescription\": \"Override Gemini configuration directory (.env).\",\n    \"opencodeConfigDir\": \"OpenCode Configuration Directory\",\n    \"opencodeConfigDirDescription\": \"Override OpenCode configuration directory (opencode.json).\",\n    \"browsePlaceholderClaude\": \"e.g., /home/<your-username>/.claude\",\n    \"browsePlaceholderCodex\": \"e.g., /home/<your-username>/.codex\",\n    \"browsePlaceholderGemini\": \"e.g., /home/<your-username>/.gemini\",\n    \"browsePlaceholderOpencode\": \"e.g., /home/<your-username>/.config/opencode\",\n    \"browseDirectory\": \"Browse Directory\",\n    \"resetDefault\": \"Reset to default directory (takes effect after saving)\",\n    \"checkForUpdates\": \"Check for Updates\",\n    \"updateTo\": \"Update to v{{version}}\",\n    \"updating\": \"Updating...\",\n    \"checking\": \"Checking...\",\n    \"upToDate\": \"Up to Date\",\n    \"aboutHint\": \"View version information and update status.\",\n    \"portableMode\": \"Portable mode: updates require manual download.\",\n    \"updateAvailable\": \"New version available: {{version}}\",\n    \"updateBadge\": \"Update available\",\n    \"updateFailed\": \"Update installation failed, attempted to open download page.\",\n    \"checkUpdateFailed\": \"Failed to check for updates, please try again later.\",\n    \"openReleaseNotesFailed\": \"Failed to open release notes\",\n    \"releaseNotes\": \"Release Notes\",\n    \"viewReleaseNotes\": \"View release notes for this version\",\n    \"viewCurrentReleaseNotes\": \"View current version release notes\",\n    \"oneClickInstall\": \"One-click Install\",\n    \"oneClickInstallHint\": \"Install Claude Code / Codex / Gemini CLI / OpenCode\",\n    \"localEnvCheck\": \"Local environment check\",\n    \"envBadge\": {\n      \"wsl\": \"WSL\",\n      \"windows\": \"Win\",\n      \"macos\": \"macOS\",\n      \"linux\": \"Linux\"\n    },\n    \"wslShell\": \"Shell\",\n    \"wslShellFlag\": \"Flag\",\n    \"installCommandsCopied\": \"Install commands copied\",\n    \"installCommandsCopyFailed\": \"Copy failed, please copy manually.\",\n    \"importFailedError\": \"Import config failed: {{message}}\",\n    \"exportFailedError\": \"Export config failed:\",\n    \"restartRequired\": \"Restart Required\",\n    \"restartRequiredMessage\": \"Modifying the CC Switch configuration directory requires restarting the application to take effect. Restart now?\",\n    \"restartNow\": \"Restart Now\",\n    \"restartLater\": \"Restart Later\",\n    \"restartFailed\": \"Application restart failed, please manually close and reopen.\",\n    \"devModeRestartHint\": \"Dev Mode: Automatic restart not supported, please manually restart the application.\",\n    \"saving\": \"Saving...\",\n    \"globalProxy\": {\n      \"label\": \"Global Proxy\",\n      \"hint\": \"Proxy all requests (API, Skills download, etc.). Leave empty for direct connection.\",\n      \"username\": \"Username (optional)\",\n      \"password\": \"Password (optional)\",\n      \"test\": \"Test Connection\",\n      \"scan\": \"Scan Local Proxies\",\n      \"clear\": \"Clear\",\n      \"scanFailed\": \"Scan failed: {{error}}\",\n      \"saved\": \"Proxy settings saved\",\n      \"saveFailed\": \"Save failed: {{error}}\",\n      \"testSuccess\": \"Connected! Latency {{latency}}ms\",\n      \"testFailed\": \"Connection failed: {{error}}\",\n      \"pricingDefaultsTitle\": \"Pricing Defaults\",\n      \"pricingDefaultsDescription\": \"Set the default multiplier and pricing model source per app.\",\n      \"pricingAppLabel\": \"App\",\n      \"defaultCostMultiplierLabel\": \"Default Multiplier\",\n      \"defaultCostMultiplierHint\": \"Multiplier for cost calculation, decimals supported.\",\n      \"pricingModelSourceLabel\": \"Pricing Model Source\",\n      \"pricingModelSourceRequest\": \"Request model\",\n      \"pricingModelSourceResponse\": \"Response model\",\n      \"pricingSave\": \"Save Pricing Defaults\",\n      \"pricingSaved\": \"Pricing defaults saved\",\n      \"pricingSaveFailed\": \"Failed to save pricing defaults: {{error}}\",\n      \"pricingLoadFailed\": \"Failed to load pricing defaults: {{error}}\",\n      \"defaultCostMultiplierRequired\": \"Default multiplier is required\",\n      \"defaultCostMultiplierInvalid\": \"Invalid multiplier format\"\n    },\n    \"saveFailedGeneric\": \"Save failed, please try again\"\n  },\n  \"apps\": {\n    \"claude\": \"Claude\",\n    \"codex\": \"Codex\",\n    \"gemini\": \"Gemini\",\n    \"opencode\": \"OpenCode\",\n    \"openclaw\": \"OpenClaw\"\n  },\n  \"sessionManager\": {\n    \"title\": \"Session Manager\",\n    \"subtitle\": \"Manage Claude Code, Codex, OpenCode, OpenClaw and Gemini CLI sessions\",\n    \"searchPlaceholder\": \"Search by content, directory, or ID\",\n    \"searchSessions\": \"Search sessions\",\n    \"providerFilterAll\": \"All\",\n    \"sessionList\": \"Sessions\",\n    \"loadingSessions\": \"Loading sessions...\",\n    \"noSessions\": \"No sessions found\",\n    \"selectSession\": \"Select a session to view details\",\n    \"noSummary\": \"No summary available\",\n    \"lastActive\": \"Last active\",\n    \"projectDir\": \"Project directory\",\n    \"sourcePath\": \"Source file\",\n    \"copyResumeCommand\": \"Copy resume command\",\n    \"resumeCommandCopied\": \"Resume command copied\",\n    \"openInTerminal\": \"Resume in terminal\",\n    \"terminalTargetTerminal\": \"Terminal\",\n    \"terminalTargetKitty\": \"kitty\",\n    \"terminalTargetCopy\": \"Copy only\",\n    \"terminalLaunched\": \"Terminal launched\",\n    \"openFailed\": \"Failed to launch terminal\",\n    \"resumeFallbackCopied\": \"Resume command copied for manual use\",\n    \"copyProjectDir\": \"Copy directory\",\n    \"projectDirCopied\": \"Directory copied\",\n    \"copySourcePath\": \"Copy source file\",\n    \"sourcePathCopied\": \"Source file copied\",\n    \"delete\": \"Delete session\",\n    \"deleting\": \"Deleting...\",\n    \"deleteTooltip\": \"Permanently delete this local session record\",\n    \"deleteConfirmTitle\": \"Delete session\",\n    \"deleteConfirmMessage\": \"This will permanently delete the local session \\\"{{title}}\\\"\\nSession ID: {{sessionId}}\\n\\nThis action cannot be undone.\",\n    \"deleteConfirmAction\": \"Delete session\",\n    \"sessionDeleted\": \"Session deleted\",\n    \"deleteFailed\": \"Failed to delete session: {{error}}\",\n    \"loadingMessages\": \"Loading transcript...\",\n    \"emptySession\": \"No messages available\",\n    \"clickToCopyPath\": \"Click to copy path\",\n    \"tocTitle\": \"Contents\",\n    \"justNow\": \"Just now\",\n    \"minutesAgo\": \"{{count}} min ago\",\n    \"hoursAgo\": \"{{count}} hr ago\",\n    \"daysAgo\": \"{{count}} days ago\",\n    \"roleUser\": \"User\",\n    \"roleSystem\": \"System\",\n    \"roleTool\": \"Tool\",\n    \"resume\": \"Resume Session\",\n    \"resumeTooltip\": \"Resume this session in terminal\",\n    \"noResumeCommand\": \"This session cannot be resumed\",\n    \"copyCommand\": \"Copy Command\",\n    \"copyMessage\": \"Copy Message\",\n    \"messageCopied\": \"Message copied\",\n    \"conversationHistory\": \"Conversation History\"\n  },\n  \"console\": {\n    \"providerSwitchReceived\": \"Received provider switch event:\",\n    \"setupListenerFailed\": \"Failed to setup provider switch listener:\",\n    \"updateProviderFailed\": \"Update provider failed:\",\n    \"autoImportFailed\": \"Auto import default configuration failed:\",\n    \"openLinkFailed\": \"Failed to open link:\",\n    \"getVersionFailed\": \"Failed to get version info:\",\n    \"loadSettingsFailed\": \"Failed to load settings:\",\n    \"getConfigPathFailed\": \"Failed to get config path:\",\n    \"getConfigDirFailed\": \"Failed to get config directory:\",\n    \"detectPortableFailed\": \"Failed to detect portable mode:\",\n    \"saveSettingsFailed\": \"Failed to save settings:\",\n    \"updateFailed\": \"Update failed:\",\n    \"checkUpdateFailed\": \"Check for updates failed:\",\n    \"openConfigFolderFailed\": \"Failed to open config folder:\",\n    \"selectConfigDirFailed\": \"Failed to select config directory:\",\n    \"getDefaultConfigDirFailed\": \"Failed to get default config directory:\",\n    \"openReleaseNotesFailed\": \"Failed to open release notes:\"\n  },\n  \"providerForm\": {\n    \"supplierName\": \"Provider Name\",\n    \"supplierNameRequired\": \"Provider Name *\",\n    \"supplierNamePlaceholder\": \"e.g., Anthropic Official\",\n    \"websiteUrl\": \"Website URL\",\n    \"websiteUrlPlaceholder\": \"https://example.com (optional)\",\n    \"apiEndpoint\": \"API Endpoint\",\n    \"apiEndpointPlaceholder\": \"https://your-api-endpoint.com\",\n    \"codexApiEndpointPlaceholder\": \"https://your-api-endpoint.com/v1\",\n    \"manageAndTest\": \"Manage & Test\",\n    \"configContent\": \"Config Content\",\n    \"officialNoApiKey\": \"Official login does not require API Key, save directly\",\n    \"codexOfficialNoApiKey\": \"Official does not require API Key, save directly\",\n    \"codexApiKeyAutoFill\": \"Just fill in here, auth.json below will be auto-filled\",\n    \"apiKeyAutoFill\": \"Just fill in here, config below will be auto-filled\",\n    \"cnOfficialApiKeyHint\": \"💡 Only need to fill in API Key, endpoint is preset\",\n    \"aggregatorApiKeyHint\": \"💡 Only need to fill in API Key, endpoint is preset\",\n    \"thirdPartyApiKeyHint\": \"💡 Only need to fill in API Key, endpoint is preset\",\n    \"customApiKeyHint\": \"💡 Custom configuration requires manually filling all necessary fields\",\n    \"omoHint\": \"💡 OMO config manages Agent model assignments and writes to oh-my-opencode.jsonc\",\n    \"officialHint\": \"💡 Official provider uses browser login, no API Key needed\",\n    \"getApiKey\": \"Get API Key\",\n    \"partnerPromotion\": {\n      \"packycode\": \"PackyCode is an official partner of CC Switch. Register using this link and enter \\\"cc-switch\\\" promo code during recharge to get 10% off\",\n      \"minimax_cn\": \"MiniMax Coding Plan Special Offer, Starter from ¥9.9\",\n      \"minimax_en\": \"MiniMax Coding Plan Black Friday, Starter is now $2/mo (80% OFF!)\",\n      \"dmxapi\": \"Claude Code exclusive model 66% OFF now!\",\n      \"cubence\": \"Cubence is an official partner of CC Switch. Register using this link and enter \\\"CCSWITCH\\\" promo code during recharge to get 10% off every top-up\",\n      \"aigocode\": \"AIGoCode is an official partner of CC Switch. Register using this link and get 10% bonus credit on your first top-up!\",\n      \"rightcode\": \"RightCode is an official partner of CC Switch. Register using this link and get 5% bonus credit on every top-up!\",\n      \"aicodemirror\": \"AICodeMirror is an official partner of CC Switch. Register using this link to get 20% off!\",\n      \"aicoding\": \"AI Coding offers an exclusive discount for CC Switch users — 10% off your first top-up!\",\n      \"crazyrouter\": \"CrazyRouter offers an exclusive bonus for CC Switch users — 30% extra credit on your first top-up!\",\n      \"sssaicode\": \"SSAI Code offers an exclusive bonus for CC Switch users — $10 extra credit on every top-up!\",\n      \"siliconflow\": \"SiliconFlow is an official partner of CC Switch\",\n      \"ucloud\": \"Compshare offers an exclusive bonus for CC Switch users — register via this link to get ¥5 platform trial credit!\",\n      \"micu\": \"Micu is an official partner of CC Switch\",\n      \"x-code\": \"XCodeAPI offers a special bonus for CC Switch users — register via this link and get 10% extra credit on your first order (contact admin to claim)\",\n      \"ctok\": \"Join the CTok community on the official website and subscribe to a plan.\"\n    },\n    \"presets\": {\n      \"ucloud\": \"Compshare\"\n    },\n    \"parameterConfig\": \"Parameter Config - {{name}} *\",\n    \"mainModel\": \"Main Model (optional)\",\n    \"mainModelPlaceholder\": \"e.g., GLM-4.6\",\n    \"fastModel\": \"Fast Model (optional)\",\n    \"fastModelPlaceholder\": \"e.g., GLM-4.5-Air\",\n    \"modelHint\": \"💡 Leave blank to use provider's default model\",\n    \"apiHint\": \"💡 Fill in Claude API compatible service endpoint, avoid trailing slash\",\n    \"apiHintOAI\": \"💡 Fill in OpenAI Chat Completions compatible service endpoint, avoid trailing slash\",\n    \"codexApiHint\": \"💡 Fill in service endpoint compatible with OpenAI Response format\",\n    \"fillSupplierName\": \"Please fill in provider name\",\n    \"fillConfigContent\": \"Please fill in configuration content\",\n    \"fillParameter\": \"Please fill in {{label}}\",\n    \"fillTemplateValue\": \"Please fill in {{label}}\",\n    \"endpointRequired\": \"API endpoint is required for non-official providers\",\n    \"apiKeyRequired\": \"API Key is required for non-official providers\",\n    \"configJsonError\": \"Config JSON format error, please check syntax\",\n    \"authJsonRequired\": \"auth.json must be a JSON object\",\n    \"authJsonError\": \"auth.json format error, please check JSON syntax\",\n    \"fillAuthJson\": \"Please fill in auth.json configuration\",\n    \"fillApiKey\": \"Please fill in OPENAI_API_KEY\",\n    \"visitWebsite\": \"Visit {{url}}\",\n    \"anthropicModel\": \"Main Model\",\n    \"anthropicSmallFastModel\": \"Fast Model\",\n    \"anthropicReasoningModel\": \"Reasoning Model (Thinking)\",\n    \"apiFormat\": \"API Format\",\n    \"apiFormatHint\": \"Select the input format for the provider's API\",\n    \"apiFormatAnthropic\": \"Anthropic Messages (Native)\",\n    \"apiFormatOpenAIChat\": \"OpenAI Chat Completions (Requires proxy)\",\n    \"apiFormatOpenAIResponses\": \"OpenAI Responses API (Requires proxy)\",\n    \"authField\": \"Auth Field\",\n    \"authFieldAuthToken\": \"ANTHROPIC_AUTH_TOKEN (Default)\",\n    \"authFieldApiKey\": \"ANTHROPIC_API_KEY\",\n    \"authFieldHint\": \"Select the authentication env variable name for the config\",\n    \"apiHintResponses\": \"💡 Fill in OpenAI Responses API compatible service endpoint, avoid trailing slash\",\n    \"anthropicDefaultHaikuModel\": \"Default Haiku Model\",\n    \"anthropicDefaultSonnetModel\": \"Default Sonnet Model\",\n    \"anthropicDefaultOpusModel\": \"Default Opus Model\",\n    \"modelPlaceholder\": \"\",\n    \"smallModelPlaceholder\": \"\",\n    \"haikuModelPlaceholder\": \"\",\n    \"modelHelper\": \"Optional: Specify default Claude model to use, leave blank to use system default.\",\n    \"modelMappingLabel\": \"Model Mapping\",\n    \"modelMappingHint\": \"Usually not needed if the provider natively serves Claude models. Only configure when you need to map requests to different model names.\",\n    \"advancedOptionsToggle\": \"Advanced Options\",\n    \"advancedOptionsHint\": \"Includes API format, auth field, and model mapping. Defaults work for most use cases.\",\n    \"categoryOfficial\": \"Official\",\n    \"categoryCnOfficial\": \"Opensource Official\",\n    \"categoryAggregation\": \"Aggregation\",\n    \"categoryThirdParty\": \"Third Party\"\n  },\n  \"copilot\": {\n    \"authSection\": \"GitHub Copilot Authentication\",\n    \"authStatus\": \"Authentication Status\",\n    \"authenticated\": \"Authenticated as {{username}}\",\n    \"notAuthenticated\": \"Not authenticated\",\n    \"loginWithGitHub\": \"Login with GitHub\",\n    \"loginRequired\": \"Please login to GitHub Copilot first\",\n    \"waitingForAuth\": \"Waiting for authorization...\",\n    \"enterCode\": \"Please enter the code in your browser:\",\n    \"logout\": \"Logout\",\n    \"authSuccess\": \"GitHub Copilot authentication successful\",\n    \"authFailed\": \"Authentication failed: {{error}}\",\n    \"authTimeout\": \"Authentication timed out, please try again\",\n    \"tokenExpired\": \"Token expired, please re-authenticate\",\n    \"accountCount\": \"{{count}} account(s)\",\n    \"selectAccount\": \"Select Account\",\n    \"selectAccountPlaceholder\": \"Select a GitHub account\",\n    \"useDefaultAccount\": \"Use default account\",\n    \"loggedInAccounts\": \"Logged in accounts\",\n    \"defaultAccount\": \"Default\",\n    \"selected\": \"Selected\",\n    \"removeAccount\": \"Remove account\",\n    \"setAsDefault\": \"Set as default\",\n    \"addAnotherAccount\": \"Add another account\",\n    \"logoutAll\": \"Logout all accounts\",\n    \"retry\": \"Retry\",\n    \"copyCode\": \"Copy code\",\n    \"migrationFailed\": \"Legacy auth migration failed: {{error}}\",\n    \"loadModelsFailed\": \"Failed to load Copilot models\"\n  },\n  \"endpointTest\": {\n    \"title\": \"API Endpoint Management\",\n    \"endpoints\": \"endpoints\",\n    \"autoSelect\": \"Auto Select\",\n    \"testSpeed\": \"Test\",\n    \"testing\": \"Testing\",\n    \"addEndpointPlaceholder\": \"https://api.example.com\",\n    \"done\": \"Done\",\n    \"noEndpoints\": \"No endpoints\",\n    \"failed\": \"Failed\",\n    \"enterValidUrl\": \"Please enter a valid URL\",\n    \"invalidUrlFormat\": \"Invalid URL format\",\n    \"onlyHttps\": \"Only HTTP/HTTPS supported\",\n    \"urlExists\": \"This URL already exists\",\n    \"saveFailed\": \"Save failed, please try again\",\n    \"loadEndpointsFailed\": \"Failed to load custom endpoints:\",\n    \"addEndpointFailed\": \"Failed to add custom endpoint:\",\n    \"removeEndpointFailed\": \"Failed to remove custom endpoint:\",\n    \"removeFailed\": \"Remove failed: {{error}}\",\n    \"updateLastUsedFailed\": \"Failed to update endpoint last used time\",\n    \"pleaseAddEndpoint\": \"Please add an endpoint first\",\n    \"testUnavailable\": \"Speed test unavailable\",\n    \"noResult\": \"No result returned\",\n    \"testFailed\": \"Speed test failed: {{error}}\",\n    \"empty\": \"No endpoints\"\n  },\n  \"providerAdvanced\": {\n    \"testConfig\": \"Model Test Config\",\n    \"useCustomConfig\": \"Use separate config\",\n    \"testConfigDesc\": \"Configure separate model testing parameters for this provider. Uses global settings when disabled.\",\n    \"testModel\": \"Test Model\",\n    \"testModelPlaceholder\": \"Leave empty to use global config\",\n    \"timeoutSecs\": \"Timeout (seconds)\",\n    \"testPrompt\": \"Test Prompt\",\n    \"degradedThreshold\": \"Degraded Threshold (ms)\",\n    \"maxRetries\": \"Max Retries\",\n    \"proxyConfig\": \"Proxy Config\",\n    \"useCustomProxy\": \"Use separate proxy\",\n    \"proxyConfigDesc\": \"Configure separate network proxy for this provider. Uses system proxy or global settings when disabled.\",\n    \"proxyUsername\": \"Username (optional)\",\n    \"proxyPassword\": \"Password (optional)\",\n    \"pricingConfig\": \"Pricing Config\",\n    \"useCustomPricing\": \"Use separate config\",\n    \"pricingConfigDesc\": \"Configure separate pricing parameters for this provider. Uses global defaults when disabled.\",\n    \"costMultiplier\": \"Cost Multiplier\",\n    \"costMultiplierPlaceholder\": \"Leave empty to use global default (1)\",\n    \"costMultiplierHint\": \"Actual cost = Base cost × Multiplier, supports decimals like 1.5\",\n    \"pricingModelSourceLabel\": \"Pricing Mode\",\n    \"pricingModelSourceInherit\": \"Inherit global default\",\n    \"pricingModelSourceRequest\": \"Request model\",\n    \"pricingModelSourceResponse\": \"Response model\",\n    \"pricingModelSourceHint\": \"Choose whether to match pricing by request model or response model\"\n  },\n  \"codexConfig\": {\n    \"authJson\": \"auth.json (JSON) *\",\n    \"authJsonPlaceholder\": \"{\\n  \\\"OPENAI_API_KEY\\\": \\\"sk-your-api-key-here\\\"\\n}\",\n    \"authJsonHint\": \"Codex auth.json configuration content\",\n    \"configToml\": \"config.toml (TOML)\",\n    \"configTomlHint\": \"Codex config.toml configuration content\",\n    \"writeCommonConfig\": \"Write Common Config\",\n    \"editCommonConfig\": \"Edit Common Config\",\n    \"editCommonConfigTitle\": \"Edit Codex Common Config Snippet\",\n    \"commonConfigHint\": \"This snippet will be appended to the end of config.toml when 'Write Common Config' is checked\",\n    \"apiUrlLabel\": \"API Request URL\",\n    \"extractFromCurrent\": \"Extract from Editor\",\n    \"extractNoCommonConfig\": \"No common config available to extract from editor\",\n    \"extractFailed\": \"Extract failed: {{error}}\",\n    \"saveFailed\": \"Save failed: {{error}}\",\n    \"modelNameHint\": \"Specify the model to use, will be auto-updated in config.toml\",\n    \"modelName\": \"Model Name\",\n    \"modelNamePlaceholder\": \"e.g., gpt-5-codex\",\n    \"contextWindow1M\": \"1M Context Window\",\n    \"autoCompactLimit\": \"Auto Compact Limit\",\n    \"autoCompactLimitHint\": \"Auto-compacts history when context reaches this token limit\"\n  },\n  \"geminiConfig\": {\n    \"envFile\": \"Environment Variables (.env)\",\n    \"envFileHint\": \"Configure Gemini environment variables in .env format\",\n    \"configJson\": \"Configuration File (config.json)\",\n    \"configJsonHint\": \"Configure Gemini extended parameters in JSON format (optional)\",\n    \"writeCommonConfig\": \"Write Common Config\",\n    \"editCommonConfig\": \"Edit Common Config\",\n    \"editCommonConfigTitle\": \"Edit Gemini Common Config Snippet\",\n    \"commonConfigHint\": \"This snippet writes to Gemini .env (GOOGLE_GEMINI_BASE_URL and GEMINI_API_KEY are not allowed)\",\n    \"extractFromCurrent\": \"Extract from Editor\",\n    \"extractNoCommonConfig\": \"No common config available to extract from editor\",\n    \"extractFailed\": \"Extract failed: {{error}}\",\n    \"saveFailed\": \"Save failed: {{error}}\",\n    \"extractedConfigInvalid\": \"Extracted config format is invalid\",\n    \"invalidJsonFormat\": \"Common config snippet format error (must be valid JSON)\",\n    \"commonConfigInvalidKeys\": \"Common config snippet must not include GOOGLE_GEMINI_BASE_URL or GEMINI_API_KEY (found: {{keys}})\",\n    \"commonConfigInvalidValues\": \"Common config snippet values must be strings\",\n    \"noCommonConfigToApply\": \"Common config snippet is empty or has no applicable entries\",\n    \"configMergeFailed\": \"Config merge failed: {{error}}\",\n    \"configReplaceFailed\": \"Config replace failed: {{error}}\"\n  },\n  \"opencode\": {\n    \"npmPackage\": \"API Format\",\n    \"selectPackage\": \"Select API format\",\n    \"npmPackageHint\": \"Select the API format for the AI service\",\n    \"baseUrl\": \"Base URL\",\n    \"baseUrlHint\": \"Custom API endpoint URL\",\n    \"models\": \"Models\",\n    \"modelsHint\": \"Configure available models and their display names\",\n    \"addModel\": \"Add Model\",\n    \"modelId\": \"Model ID\",\n    \"modelName\": \"Display Name\",\n    \"noModels\": \"No models configured\",\n    \"modelsRequired\": \"Please add at least one model\",\n    \"providerKey\": \"Provider Key\",\n    \"providerKeyPlaceholder\": \"my-provider\",\n    \"providerKeyHint\": \"Unique identifier in config file. Cannot be changed after creation. Use lowercase letters, numbers, and hyphens only.\",\n    \"providerKeyRequired\": \"Provider key is required\",\n    \"providerKeyDuplicate\": \"This key is already in use\",\n    \"providerKeyInvalid\": \"Invalid format. Use lowercase letters, numbers, and hyphens only.\",\n    \"extraOptions\": \"Extra Options\",\n    \"extraOptionsHint\": \"Configure extra SDK options like timeout, setCacheKey, etc. Values are auto-parsed to appropriate types (number, boolean, etc.).\",\n    \"addExtraOption\": \"Add\",\n    \"extraOptionKey\": \"Key\",\n    \"extraOptionValue\": \"Value\",\n    \"extraOptionKeyPlaceholder\": \"timeout\",\n    \"extraOptionValuePlaceholder\": \"600000\",\n    \"noExtraOptions\": \"No extra options configured\",\n    \"noModelOptions\": \"Model options, click + to add\",\n    \"modelExtraFields\": \"Model Properties\",\n    \"noModelExtraFields\": \"Model properties (variants, cost, etc.), click + to add\",\n    \"modelExtraFieldKeyPlaceholder\": \"variants\",\n    \"sdkOptions\": \"SDK Options\",\n    \"modelOptionKeyPlaceholder\": \"provider\",\n    \"modelOptionValuePlaceholder\": \"{\\\"order\\\": [\\\"baseten\\\"]}\"\n  },\n  \"providerPreset\": {\n    \"label\": \"Provider Preset\",\n    \"custom\": \"Custom Configuration\",\n    \"other\": \"Other\",\n    \"hint\": \"You can continue to adjust the fields below after selecting a preset.\"\n  },\n  \"usage\": {\n    \"title\": \"Usage Statistics\",\n    \"subtitle\": \"View AI model usage and cost statistics\",\n    \"today\": \"24 Hours\",\n    \"last7days\": \"7 Days\",\n    \"last30days\": \"30 Days\",\n    \"totalRequests\": \"Total Requests\",\n    \"totalCost\": \"Total Cost\",\n    \"cost\": \"Cost\",\n    \"perMillion\": \"(per million)\",\n    \"trends\": \"Usage Trends\",\n    \"rangeToday\": \"Last 24 hours (hourly)\",\n    \"rangeLast7Days\": \"Last 7 days\",\n    \"rangeLast30Days\": \"Last 30 days\",\n    \"totalTokens\": \"Total Tokens\",\n    \"cacheTokens\": \"Cache Tokens\",\n    \"requestLogs\": \"Request Logs\",\n    \"providerStats\": \"Provider Stats\",\n    \"modelStats\": \"Model Stats\",\n    \"time\": \"Time\",\n    \"provider\": \"Provider\",\n    \"billingModel\": \"Billing Model\",\n    \"inputTokens\": \"Input\",\n    \"outputTokens\": \"Output\",\n    \"cacheReadTokens\": \"Cache Hit\",\n    \"cacheCreationTokens\": \"Cache Creation\",\n    \"timingInfo\": \"Duration/TTFT\",\n    \"status\": \"Status\",\n    \"multiplier\": \"Multiplier\",\n    \"requestModel\": \"Request Model\",\n    \"responseModel\": \"Response Model\",\n    \"noData\": \"No data\",\n    \"unknownProvider\": \"Unknown Provider\",\n    \"stream\": \"Stream\",\n    \"nonStream\": \"Non-stream\",\n    \"totalRecords\": \"{{total}} records total\",\n    \"modelPricing\": \"Model Pricing\",\n    \"loadPricingError\": \"Failed to load pricing data\",\n    \"modelPricingDesc\": \"Configure token costs for each model\",\n    \"noPricingData\": \"No pricing data. Click \\\"Add\\\" to add model pricing configuration.\",\n    \"model\": \"Model\",\n    \"displayName\": \"Display Name\",\n    \"inputCost\": \"Input Cost\",\n    \"outputCost\": \"Output Cost\",\n    \"cacheReadCost\": \"Cache Hit\",\n    \"cacheWriteCost\": \"Cache Creation\",\n    \"deleteConfirmTitle\": \"Confirm Delete\",\n    \"deleteConfirmDesc\": \"Are you sure you want to delete this model pricing? This action cannot be undone.\",\n    \"queryFailed\": \"Query failed\",\n    \"refreshUsage\": \"Refresh usage\",\n    \"planUsage\": \"Plan usage\",\n    \"invalid\": \"Expired\",\n    \"total\": \"Total:\",\n    \"used\": \"Used:\",\n    \"remaining\": \"Remaining:\",\n    \"justNow\": \"Just now\",\n    \"minutesAgo\": \"{{count}} min ago\",\n    \"hoursAgo\": \"{{count}} hr ago\",\n    \"daysAgo\": \"{{count}} day ago\",\n    \"multiplePlans\": \"{{count}} plans\",\n    \"expand\": \"Expand\",\n    \"collapse\": \"Collapse\",\n    \"modelIdPlaceholder\": \"e.g., claude-3-5-sonnet-20241022\",\n    \"displayNamePlaceholder\": \"e.g., Claude 3.5 Sonnet\",\n    \"appType\": \"App Type\",\n    \"allApps\": \"All Apps\",\n    \"statusCode\": \"Status Code\",\n    \"searchProviderPlaceholder\": \"Search provider...\",\n    \"searchModelPlaceholder\": \"Search model...\",\n    \"timeRange\": \"Time Range\",\n    \"input\": \"Input\",\n    \"output\": \"Output\",\n    \"cacheWrite\": \"Creation\",\n    \"cacheRead\": \"Hit\",\n    \"baseCost\": \"Base\",\n    \"costMultiplier\": \"Cost Multiplier\",\n    \"withMultiplier\": \"with multiplier\",\n    \"requestDetail\": \"Request Detail\",\n    \"requestNotFound\": \"Request not found\",\n    \"basicInfo\": \"Basic Info\",\n    \"tokenUsage\": \"Token Usage\",\n    \"cacheCreationCost\": \"Cache Creation Cost\",\n    \"costBreakdown\": \"Cost Breakdown\",\n    \"performance\": \"Performance\",\n    \"latency\": \"Latency\",\n    \"errorMessage\": \"Error Message\",\n    \"requests\": \"Requests\",\n    \"tokens\": \"Tokens\",\n    \"avgCost\": \"Average Cost\",\n    \"avgLatency\": \"Average Latency\",\n    \"successRate\": \"Success Rate\",\n    \"requestId\": \"Request ID\",\n    \"never\": \"Never\",\n    \"modelId\": \"Model ID\",\n    \"modelIdRequired\": \"Model ID is required\",\n    \"inputCostPerMillion\": \"Input Cost (per million tokens, USD)\",\n    \"outputCostPerMillion\": \"Output Cost (per million tokens, USD)\",\n    \"invalidPrice\": \"Price must be non-negative\",\n    \"invalidTimeRange\": \"Please select complete start/end time\",\n    \"invalidTimeRangeOrder\": \"Start time cannot be later than end time\",\n    \"timeRangeTooLarge\": \"Time range is too large, please narrow it down\",\n    \"addPricing\": \"Add Pricing\",\n    \"editPricing\": \"Edit Pricing\",\n    \"pricingAdded\": \"Pricing added\",\n    \"pricingUpdated\": \"Pricing updated\",\n    \"cacheReadCostPerMillion\": \"Cache Read Cost (per million tokens, USD)\",\n    \"cacheCreationCostPerMillion\": \"Cache Write Cost (per million tokens, USD)\"\n  },\n  \"usageScript\": {\n    \"title\": \"Configure Usage Query\",\n    \"enableUsageQuery\": \"Enable usage query\",\n    \"presetTemplate\": \"Preset template\",\n    \"requestUrl\": \"Request URL\",\n    \"requestUrlPlaceholder\": \"e.g. https://api.example.com\",\n    \"method\": \"HTTP method\",\n    \"templateCustom\": \"Custom\",\n    \"templateGeneral\": \"General\",\n    \"templateNewAPI\": \"NewAPI\",\n    \"templateCopilot\": \"GitHub Copilot\",\n    \"copilotAutoAuth\": \"Auto OAuth authentication, no manual credentials needed\",\n    \"resetDate\": \"Reset date\",\n    \"premiumRequests\": \"Premium Requests\",\n    \"credentialsConfig\": \"Credentials\",\n    \"credentialsHint\": \"Leave empty to use provider config\",\n    \"optional\": \"optional\",\n    \"apiKeyPlaceholder\": \"Leave empty to use provider's API Key\",\n    \"baseUrlPlaceholder\": \"Leave empty to use provider's base URL\",\n    \"baseUrl\": \"Base URL\",\n    \"accessToken\": \"Access Token\",\n    \"accessTokenPlaceholder\": \"Generate in 'Security Settings'\",\n    \"userId\": \"User ID\",\n    \"userIdPlaceholder\": \"e.g., 114514\",\n    \"defaultPlan\": \"Default Plan\",\n    \"queryFailedMessage\": \"Query failed\",\n    \"queryScript\": \"Query script (JavaScript)\",\n    \"timeoutSeconds\": \"Timeout (seconds)\",\n    \"headers\": \"Headers\",\n    \"body\": \"Body\",\n    \"timeoutHint\": \"Range: 2-30 seconds\",\n    \"timeoutMustBeInteger\": \"Timeout must be an integer, decimal part ignored\",\n    \"timeoutCannotBeNegative\": \"Timeout cannot be negative\",\n    \"autoIntervalMinutes\": \"Auto query interval (minutes, 0 to disable)\",\n    \"autoQueryInterval\": \"Auto Query Interval (minutes)\",\n    \"autoQueryIntervalHint\": \"0 to disable; recommend 5-60 minutes\",\n    \"intervalMustBeInteger\": \"Interval must be an integer, decimal part ignored\",\n    \"intervalCannotBeNegative\": \"Interval cannot be negative\",\n    \"intervalAdjusted\": \"Interval adjusted to {{value}} minutes\",\n    \"scriptHelp\": \"Script writing instructions:\",\n    \"configFormat\": \"Configuration format:\",\n    \"commentOptional\": \"optional\",\n    \"commentResponseIsJson\": \"response is the JSON data returned by the API\",\n    \"extractorFormat\": \"Extractor return format (all fields optional):\",\n    \"tips\": \"💡 Tips:\",\n    \"testing\": \"Testing...\",\n    \"testScript\": \"Test script\",\n    \"format\": \"Format\",\n    \"saveConfig\": \"Save config\",\n    \"scriptEmpty\": \"Script configuration cannot be empty\",\n    \"mustHaveReturn\": \"Script must contain return statement\",\n    \"testSuccess\": \"Test successful!\",\n    \"testFailed\": \"Test failed\",\n    \"formatSuccess\": \"Format successful\",\n    \"formatFailed\": \"Format failed\",\n    \"supportedVariables\": \"Supported Variables\",\n    \"variablesHint\": \"Supported variables: {{apiKey}}, {{baseUrl}} | extractor function receives API response JSON object\",\n    \"scriptConfig\": \"Request configuration\",\n    \"extractorCode\": \"Extractor code\",\n    \"extractorHint\": \"Return object should include remaining quota fields\",\n    \"fieldIsValid\": \"• isValid: Boolean, whether plan is valid\",\n    \"fieldInvalidMessage\": \"• invalidMessage: String, reason for expiration (shown when isValid is false)\",\n    \"fieldRemaining\": \"• remaining: Number, remaining quota\",\n    \"fieldUnit\": \"• unit: String, unit (e.g., \\\"USD\\\")\",\n    \"fieldPlanName\": \"• planName: String, plan name\",\n    \"fieldTotal\": \"• total: Number, total quota\",\n    \"fieldUsed\": \"• used: Number, used quota\",\n    \"fieldExtra\": \"• extra: String, custom display text\",\n    \"tip1\": \"• Variables {{apiKey}} and {{baseUrl}} are automatically replaced\",\n    \"tip2\": \"• Extractor function runs in sandbox environment, supports ES2020+ syntax\",\n    \"tip3\": \"• Entire config must be wrapped in () to form object literal expression\"\n  },\n  \"errors\": {\n    \"usage_query_failed\": \"Usage query failed\",\n    \"configLoadFailedTitle\": \"Configuration Load Failed\",\n    \"configLoadFailedMessage\": \"Unable to read configuration file:\\n{{path}}\\n\\nError details:\\n{{detail}}\\n\\nPlease check if the JSON is valid, or restore from a backup file (e.g., config.json.bak) in the same directory.\\n\\nThe app will exit so you can fix this.\"\n  },\n  \"presetSelector\": {\n    \"title\": \"Select Configuration Type\",\n    \"custom\": \"Custom\",\n    \"customDescription\": \"Manually configure provider, requires complete configuration\",\n    \"officialDescription\": \"Official login, no API Key required\",\n    \"presetDescription\": \"Use preset configuration, only API Key required\"\n  },\n  \"mcp\": {\n    \"title\": \"MCP Management\",\n    \"import\": \"Import\",\n    \"importExisting\": \"Import Existing\",\n    \"addMcp\": \"Add MCP\",\n    \"claudeTitle\": \"Claude Code MCP Management\",\n    \"codexTitle\": \"Codex MCP Management\",\n    \"geminiTitle\": \"Gemini MCP Management\",\n    \"unifiedPanel\": {\n      \"title\": \"MCP Server Management\",\n      \"addServer\": \"Add Server\",\n      \"editServer\": \"Edit Server\",\n      \"deleteServer\": \"Delete Server\",\n      \"deleteConfirm\": \"Are you sure you want to delete server \\\"{{id}}\\\"? This action cannot be undone.\",\n      \"noServers\": \"No servers yet\",\n      \"enabledApps\": \"Enabled Apps\",\n      \"noImportFound\": \"No MCP servers to import found. All servers are already managed by CC Switch.\",\n      \"importSuccess\": \"Successfully imported {{count}} MCP servers\",\n      \"apps\": {\n        \"claude\": \"Claude\",\n        \"codex\": \"Codex\",\n        \"gemini\": \"Gemini\",\n        \"opencode\": \"OpenCode\",\n        \"openclaw\": \"OpenClaw\"\n      }\n    },\n    \"userLevelPath\": \"User-level MCP path\",\n    \"serverList\": \"Servers\",\n    \"loading\": \"Loading...\",\n    \"empty\": \"No MCP servers\",\n    \"emptyDescription\": \"Click the button in the top right to add your first MCP server\",\n    \"add\": \"Add MCP\",\n    \"addServer\": \"Add MCP\",\n    \"editServer\": \"Edit MCP\",\n    \"addClaudeServer\": \"Add Claude Code MCP\",\n    \"editClaudeServer\": \"Edit Claude Code MCP\",\n    \"addCodexServer\": \"Add Codex MCP\",\n    \"editCodexServer\": \"Edit Codex MCP\",\n    \"configPath\": \"Config Path\",\n    \"serverCount\": \"{{count}} MCP server(s) configured\",\n    \"enabledCount\": \"{{count}} enabled\",\n    \"template\": {\n      \"fetch\": \"Quick Template: mcp-fetch\"\n    },\n    \"form\": {\n      \"title\": \"MCP Title (Unique)\",\n      \"titlePlaceholder\": \"my-mcp-server\",\n      \"name\": \"Display Name\",\n      \"namePlaceholder\": \"e.g. @modelcontextprotocol/server-time\",\n      \"enabledApps\": \"Enable to Apps\",\n      \"noAppsWarning\": \"At least one app must be selected\",\n      \"description\": \"Description\",\n      \"descriptionPlaceholder\": \"Optional description\",\n      \"tags\": \"Tags (comma separated)\",\n      \"tagsPlaceholder\": \"stdio, time, utility\",\n      \"homepage\": \"Homepage\",\n      \"homepagePlaceholder\": \"https://example.com\",\n      \"docs\": \"Docs\",\n      \"docsPlaceholder\": \"https://example.com/docs\",\n      \"additionalInfo\": \"Additional Info\",\n      \"jsonConfig\": \"Full JSON Configuration\",\n      \"jsonConfigOrPrefix\": \"Full JSON configuration or use\",\n      \"tomlConfigOrPrefix\": \"Full TOML configuration or use\",\n      \"jsonPlaceholder\": \"{\\n  \\\"type\\\": \\\"stdio\\\",\\n  \\\"command\\\": \\\"uvx\\\",\\n  \\\"args\\\": [\\\"mcp-server-fetch\\\"]\\n}\",\n      \"tomlConfig\": \"Full TOML Configuration\",\n      \"tomlPlaceholder\": \"type = \\\"stdio\\\"\\ncommand = \\\"uvx\\\"\\nargs = [\\\"mcp-server-fetch\\\"]\",\n      \"useWizard\": \"Config Wizard\",\n      \"syncOtherSide\": \"Mirror to {{target}}\",\n      \"syncOtherSideHint\": \"Apply the same settings to {{target}}; existing entries with the same id will be overwritten.\",\n      \"willOverwriteWarning\": \"Will overwrite existing config in {{target}}\"\n    },\n    \"wizard\": {\n      \"title\": \"MCP Configuration Wizard\",\n      \"hint\": \"Quickly configure MCP server and auto-generate JSON configuration\",\n      \"type\": \"Type\",\n      \"typeStdio\": \"stdio\",\n      \"typeHttp\": \"http\",\n      \"typeSse\": \"sse\",\n      \"command\": \"Command\",\n      \"commandPlaceholder\": \"npx or uvx\",\n      \"args\": \"Arguments\",\n      \"argsPlaceholder\": \"arg1\\narg2\",\n      \"env\": \"Environment Variables\",\n      \"envPlaceholder\": \"KEY1=value1\\nKEY2=value2\",\n      \"url\": \"URL\",\n      \"urlPlaceholder\": \"https://api.example.com/mcp\",\n      \"urlRequired\": \"Please enter URL\",\n      \"headers\": \"Headers (optional)\",\n      \"headersPlaceholder\": \"Authorization: Bearer your_token_here\\nContent-Type: application/json\",\n      \"preview\": \"Configuration Preview\",\n      \"apply\": \"Apply Configuration\"\n    },\n    \"id\": \"Identifier (unique)\",\n    \"type\": \"Type\",\n    \"command\": \"Command\",\n    \"validateCommand\": \"Validate Command\",\n    \"args\": \"Args\",\n    \"argsPlaceholder\": \"e.g., mcp-server-fetch --help\",\n    \"env\": \"Environment (one per line, KEY=VALUE)\",\n    \"envPlaceholder\": \"FOO=bar\\nHELLO=world\",\n    \"reset\": \"Reset\",\n    \"msg\": {\n      \"saved\": \"Saved\",\n      \"deleted\": \"Deleted\",\n      \"enabled\": \"Enabled\",\n      \"disabled\": \"Disabled\",\n      \"templateAdded\": \"Template added\"\n    },\n    \"error\": {\n      \"idRequired\": \"Please enter identifier\",\n      \"idExists\": \"Identifier already exists. Please choose another.\",\n      \"jsonInvalid\": \"Invalid JSON format\",\n      \"tomlInvalid\": \"Invalid TOML format\",\n      \"commandRequired\": \"Please enter command\",\n      \"singleServerObjectRequired\": \"Please paste a single MCP server object (do not include top-level mcpServers)\",\n      \"saveFailed\": \"Save failed\",\n      \"deleteFailed\": \"Delete failed\"\n    },\n    \"validation\": {\n      \"ok\": \"Command available\",\n      \"fail\": \"Command not found\"\n    },\n    \"confirm\": {\n      \"deleteTitle\": \"Delete MCP Server\",\n      \"deleteMessage\": \"Are you sure you want to delete MCP server \\\"{{id}}\\\"? This action cannot be undone.\"\n    },\n    \"presets\": {\n      \"title\": \"Select MCP Type\",\n      \"enable\": \"Enable\",\n      \"enabled\": \"Enabled\",\n      \"installed\": \"Installed\",\n      \"docs\": \"Docs\",\n      \"requiresEnv\": \"Requires env\",\n      \"fetch\": {\n        \"name\": \"mcp-server-fetch\",\n        \"description\": \"Universal HTTP request tool, supports GET/POST and other HTTP methods, suitable for quick API requests and web data scraping\"\n      },\n      \"time\": {\n        \"name\": \"@modelcontextprotocol/server-time\",\n        \"description\": \"Time query tool providing current time, timezone conversion, and date calculation features\"\n      },\n      \"memory\": {\n        \"name\": \"@modelcontextprotocol/server-memory\",\n        \"description\": \"Knowledge graph memory system supporting entities, relations, and observations to help AI remember important information from conversations\"\n      },\n      \"sequential-thinking\": {\n        \"name\": \"@modelcontextprotocol/server-sequential-thinking\",\n        \"description\": \"Sequential thinking tool helping AI break down complex problems into multiple steps for deeper thinking\"\n      },\n      \"context7\": {\n        \"name\": \"@upstash/context7-mcp\",\n        \"description\": \"Context7 documentation search tool providing latest library docs and code examples, with higher limits when configured with a key\"\n      }\n    }\n  },\n  \"prompts\": {\n    \"manage\": \"Prompts\",\n    \"title\": \"{{appName}} Prompt Management\",\n    \"claudeTitle\": \"Claude Prompt Management\",\n    \"codexTitle\": \"Codex Prompt Management\",\n    \"add\": \"Add Prompt\",\n    \"edit\": \"Edit Prompt\",\n    \"addTitle\": \"Add {{appName}} Prompt\",\n    \"editTitle\": \"Edit {{appName}} Prompt\",\n    \"import\": \"Import Existing\",\n    \"count\": \"{{count}} prompts\",\n    \"enabled\": \"Enabled\",\n    \"enable\": \"Enable\",\n    \"enabledName\": \"Enabled: {{name}}\",\n    \"noneEnabled\": \"No prompt enabled\",\n    \"currentFile\": \"Current {{filename}} Content\",\n    \"empty\": \"No prompts yet\",\n    \"emptyDescription\": \"Click the button above to add or import prompts\",\n    \"loading\": \"Loading...\",\n    \"name\": \"Name\",\n    \"namePlaceholder\": \"e.g., Default Project Prompt\",\n    \"description\": \"Description\",\n    \"descriptionPlaceholder\": \"Optional description\",\n    \"content\": \"Content\",\n    \"contentPlaceholder\": \"# {{filename}}\\n\\nEnter prompt content here...\",\n    \"loadFailed\": \"Failed to load prompts\",\n    \"saveSuccess\": \"Saved successfully\",\n    \"saveFailed\": \"Failed to save\",\n    \"deleteSuccess\": \"Deleted successfully\",\n    \"deleteFailed\": \"Failed to delete\",\n    \"enableSuccess\": \"Enabled successfully\",\n    \"enableFailed\": \"Failed to enable\",\n    \"disableSuccess\": \"Disabled successfully\",\n    \"disableFailed\": \"Failed to disable\",\n    \"importSuccess\": \"Imported successfully\",\n    \"importFailed\": \"Failed to import\",\n    \"confirm\": {\n      \"deleteTitle\": \"Confirm Delete\",\n      \"deleteMessage\": \"Are you sure you want to delete prompt \\\"{{name}}\\\"?\"\n    }\n  },\n  \"workspace\": {\n    \"title\": \"Workspace Files\",\n    \"manage\": \"Workspace\",\n    \"files\": {\n      \"agents\": \"Agent instructions and rules\",\n      \"soul\": \"Agent personality and communication style\",\n      \"user\": \"User profile and preferences\",\n      \"identity\": \"Agent name and avatar\",\n      \"tools\": \"Local tool documentation\",\n      \"memory\": \"Long-term memory and decisions\",\n      \"heartbeat\": \"Heartbeat run checklist\",\n      \"bootstrap\": \"First-run bootstrap ritual\",\n      \"boot\": \"Gateway reboot checklist\"\n    },\n    \"editing\": \"Edit {{filename}}\",\n    \"saveSuccess\": \"Saved successfully\",\n    \"saveFailed\": \"Failed to save\",\n    \"loadFailed\": \"Failed to load\",\n    \"openDirectory\": \"Open in file manager\",\n    \"dailyMemory\": {\n      \"title\": \"Daily Memory\",\n      \"sectionTitle\": \"Daily Memory\",\n      \"cardTitle\": \"Daily Memory Files\",\n      \"cardDescription\": \"Browse & manage daily memories\",\n      \"createToday\": \"Add Memory\",\n      \"empty\": \"No daily memory files yet\",\n      \"loadFailed\": \"Failed to load daily memory files\",\n      \"createFailed\": \"Failed to create daily memory file\",\n      \"deleteSuccess\": \"Daily memory file deleted\",\n      \"deleteFailed\": \"Failed to delete daily memory file\",\n      \"confirmDeleteTitle\": \"Delete Daily Memory\",\n      \"confirmDeleteMessage\": \"Delete the daily memory for {{date}}? This cannot be undone.\",\n      \"searchPlaceholder\": \"Search full content...\",\n      \"searchScopeHint\": \"Full-text search across all daily memories. ⌘F\",\n      \"searchCloseHint\": \"Esc to close\",\n      \"noSearchResults\": \"No daily memories match your search.\",\n      \"searching\": \"Searching...\",\n      \"searchFailed\": \"Search failed\",\n      \"matchCount\": \"{{count}} match(es)\"\n    }\n  },\n  \"openclaw\": {\n    \"backupCreated\": \"Backup created: {{path}}\",\n    \"providerKey\": \"Provider Key\",\n    \"providerKeyPlaceholder\": \"my-provider\",\n    \"providerKeyHint\": \"Unique identifier in config file. Cannot be changed after creation. Use lowercase letters, numbers, and hyphens only.\",\n    \"providerKeyRequired\": \"Provider key is required\",\n    \"providerKeyDuplicate\": \"This key is already in use\",\n    \"providerKeyInvalid\": \"Invalid format. Use lowercase letters, numbers, and hyphens only.\",\n    \"apiProtocol\": \"API Protocol\",\n    \"selectProtocol\": \"Select API Protocol\",\n    \"apiProtocolHint\": \"Select the protocol type compatible with the provider's API. Most providers use OpenAI Completions format.\",\n    \"baseUrl\": \"API Endpoint\",\n    \"baseUrlHint\": \"The provider's API endpoint address.\",\n    \"models\": \"Models\",\n    \"addModel\": \"Add Model\",\n    \"noModels\": \"No models configured. Click Add Model to configure available models.\",\n    \"modelId\": \"Model ID\",\n    \"modelIdPlaceholder\": \"claude-3-sonnet\",\n    \"modelName\": \"Display Name\",\n    \"modelNamePlaceholder\": \"Claude 3 Sonnet\",\n    \"contextWindow\": \"Context Window\",\n    \"maxTokens\": \"Max Output Tokens\",\n    \"reasoning\": \"Reasoning Mode\",\n    \"reasoningOn\": \"Enabled\",\n    \"reasoningOff\": \"Disabled\",\n    \"inputTypes\": \"Input Types\",\n    \"inputCost\": \"Input Cost ($/M tokens)\",\n    \"outputCost\": \"Output Cost ($/M tokens)\",\n    \"advancedOptions\": \"Advanced Options\",\n    \"cacheReadCost\": \"Cache Read Cost ($/M tokens)\",\n    \"cacheWriteCost\": \"Cache Write Cost ($/M tokens)\",\n    \"cacheCostHint\": \"Cache costs are used to calculate Prompt Caching costs. Leave empty if not using caching.\",\n    \"modelsHint\": \"Configure the models supported by this provider. Model ID is used for API calls, Display Name for the interface.\",\n    \"userAgent\": \"Send User-Agent\",\n    \"userAgentHint\": \"Some providers require a browser User-Agent header to work properly.\",\n    \"env\": {\n      \"title\": \"Environment Variables\",\n      \"description\": \"Manage environment variables in openclaw.json (API keys, custom variables, etc.)\",\n      \"editorHint\": \"Edit the full env section as JSON. Nested objects such as env.vars and env.shellEnv are supported.\",\n      \"objectRequired\": \"OpenClaw env must be a JSON object.\",\n      \"invalidJson\": \"OpenClaw env must be valid JSON.\",\n      \"empty\": \"OpenClaw env cannot be empty. Use {} for an empty object.\",\n      \"keyPlaceholder\": \"Variable name\",\n      \"valuePlaceholder\": \"Value\",\n      \"add\": \"Add Variable\",\n      \"saveSuccess\": \"Environment variables saved\",\n      \"saveFailed\": \"Failed to save environment variables\",\n      \"loadFailed\": \"Failed to load environment variables\",\n      \"duplicateKey\": \"Duplicate variable name detected: {{key}}\"\n    },\n    \"tools\": {\n      \"title\": \"Tool Permissions\",\n      \"description\": \"Manage tool permissions in openclaw.json (allow/deny lists)\",\n      \"profile\": \"Permission Profile\",\n      \"profileMinimal\": \"Minimal\",\n      \"profileCoding\": \"Coding\",\n      \"profileMessaging\": \"Messaging\",\n      \"profileFull\": \"Full\",\n      \"profileUnset\": \"Not set\",\n      \"unsupportedProfileTitle\": \"Unsupported tools profile detected\",\n      \"unsupportedProfileDescription\": \"The current tools.profile value '{{value}}' is not in the supported OpenClaw list. It will be preserved until you choose a new value.\",\n      \"unsupportedProfileLabel\": \"unsupported\",\n      \"allowList\": \"Allow List\",\n      \"denyList\": \"Deny List\",\n      \"patternPlaceholder\": \"Tool name or pattern\",\n      \"addAllow\": \"Add Allow\",\n      \"addDeny\": \"Add Deny\",\n      \"saveSuccess\": \"Tool permissions saved\",\n      \"saveFailed\": \"Failed to save tool permissions\",\n      \"loadFailed\": \"Failed to load tool permissions\"\n    },\n    \"agents\": {\n      \"title\": \"Agents Config\",\n      \"description\": \"Manage agents.defaults in openclaw.json (default model, runtime parameters, etc.)\",\n      \"modelSection\": \"Model Configuration\",\n      \"primaryModel\": \"Default Model\",\n      \"primaryModelHint\": \"Select from models of configured providers\",\n      \"notSet\": \"Not set\",\n      \"fallbackModels\": \"Fallback Models\",\n      \"fallbackModelsHint\": \"When the primary model is unavailable, fallbacks are tried in order\",\n      \"addFallback\": \"Add fallback model\",\n      \"noModels\": \"No configured provider models. Please add an OpenClaw provider first.\",\n      \"notInList\": \"{{value}} (not configured)\",\n      \"runtimeSection\": \"Runtime Parameters\",\n      \"workspace\": \"Workspace Path\",\n      \"timeout\": \"Timeout (seconds)\",\n      \"contextTokens\": \"Context Tokens\",\n      \"maxConcurrent\": \"Max Concurrent\",\n      \"legacyTimeoutTitle\": \"Legacy timeout detected\",\n      \"legacyTimeoutDescription\": \"This config still uses agents.defaults.timeout. Saving here will migrate it to timeoutSeconds.\",\n      \"saveSuccess\": \"Agents config saved\",\n      \"saveFailed\": \"Failed to save agents config\",\n      \"loadFailed\": \"Failed to load agents config\"\n    },\n    \"health\": {\n      \"title\": \"OpenClaw config warnings detected\",\n      \"invalidToolsProfile\": \"tools.profile contains an unsupported value. OpenClaw currently expects minimal, coding, messaging, or full.\",\n      \"legacyTimeout\": \"agents.defaults.timeout is deprecated. Save the Agents panel to migrate it to timeoutSeconds.\",\n      \"stringifiedEnvVars\": \"env.vars should be an object, but the current value looks stringified or malformed.\",\n      \"stringifiedShellEnv\": \"env.shellEnv should be an object, but the current value looks stringified or malformed.\",\n      \"parseFailed\": \"openclaw.json could not be parsed as valid JSON5. Fix the file before editing it here.\"\n    },\n    \"primaryModel\": \"Primary Model\",\n    \"fallbackModel\": \"Fallback Model\"\n  },\n  \"env\": {\n    \"warning\": {\n      \"title\": \"Environment Variable Conflicts Detected\",\n      \"description\": \"Found {{count}} environment variables that may override your configuration\"\n    },\n    \"actions\": {\n      \"expand\": \"View Details\",\n      \"collapse\": \"Collapse\",\n      \"selectAll\": \"Select All\",\n      \"clearSelection\": \"Clear Selection\",\n      \"deleteSelected\": \"Delete Selected ({{count}})\",\n      \"deleting\": \"Deleting...\"\n    },\n    \"field\": {\n      \"value\": \"Value\",\n      \"source\": \"Source\"\n    },\n    \"source\": {\n      \"userRegistry\": \"User Environment Variable (Registry)\",\n      \"systemRegistry\": \"System Environment Variable (Registry)\",\n      \"systemEnv\": \"System Environment Variable\"\n    },\n    \"delete\": {\n      \"success\": \"Environment variables deleted successfully\",\n      \"error\": \"Failed to delete environment variables\"\n    },\n    \"backup\": {\n      \"location\": \"Backup location: {{path}}\"\n    },\n    \"confirm\": {\n      \"title\": \"Confirm Delete Environment Variables\",\n      \"message\": \"Are you sure you want to delete {{count}} environment variable(s)?\",\n      \"backupNotice\": \"A backup will be created automatically before deletion. You can restore it later. Changes take effect after restarting the application or terminal.\",\n      \"confirm\": \"Confirm Delete\"\n    },\n    \"error\": {\n      \"noSelection\": \"Please select environment variables to delete\"\n    }\n  },\n  \"skills\": {\n    \"manage\": \"Skills\",\n    \"title\": \"Skills Management\",\n    \"description\": \"Discover and install skills from popular repositories to extend Claude Code/Codex/Gemini capabilities\",\n    \"refresh\": \"Refresh\",\n    \"refreshing\": \"Refreshing...\",\n    \"repoManager\": \"Repository Management\",\n    \"count\": \"{{count}} skills\",\n    \"empty\": \"No skills available\",\n    \"emptyDescription\": \"Add skill repositories to discover available skills\",\n    \"addRepo\": \"Add Skill Repository\",\n    \"loading\": \"Loading...\",\n    \"installed\": \"Installed\",\n    \"install\": \"Install\",\n    \"installing\": \"Installing...\",\n    \"uninstall\": \"Uninstall\",\n    \"uninstalling\": \"Uninstalling...\",\n    \"view\": \"View\",\n    \"noDescription\": \"No description\",\n    \"loadFailed\": \"Failed to load\",\n    \"installSuccess\": \"Skill {{name}} installed\",\n    \"installFailed\": \"Failed to install\",\n    \"uninstallSuccess\": \"Skill {{name}} uninstalled\",\n    \"uninstallFailed\": \"Failed to uninstall\",\n    \"error\": {\n      \"skillNotFound\": \"Skill not found: {{directory}}\",\n      \"missingRepoInfo\": \"Missing repository info (owner or name)\",\n      \"downloadTimeout\": \"Download repository {{owner}}/{{name}} timeout ({{timeout}}s)\",\n      \"downloadTimeoutHint\": \"Please check network connection or retry later\",\n      \"skillPathNotFound\": \"Skill path '{{path}}' not found in repository {{owner}}/{{name}}\",\n      \"skillDirNotFound\": \"Skill directory not found: {{path}}\",\n      \"directoryConflict\": \"Skill directory '{{directory}}' is already occupied by {{existing_repo}}, cannot install from {{new_repo}}\",\n      \"emptyArchive\": \"Downloaded archive is empty\",\n      \"downloadFailed\": \"Download failed: HTTP {{status}}\",\n      \"allBranchesFailed\": \"All branches failed, tried: {{branches}}\",\n      \"httpError\": \"HTTP error {{status}}\",\n      \"http403\": \"GitHub access restricted, possibly rate limited\",\n      \"http404\": \"Repository or branch not found, please check URL\",\n      \"http429\": \"Too many requests, please wait and retry\",\n      \"parseMetadataFailed\": \"Failed to parse skill metadata\",\n      \"getHomeDirFailed\": \"Unable to get user home directory\",\n      \"noSkillsInZip\": \"No skills found in ZIP file (requires SKILL.md file)\",\n      \"networkError\": \"Network error\",\n      \"fsError\": \"File system error\",\n      \"unknownError\": \"Unknown error\",\n      \"suggestion\": {\n        \"checkNetwork\": \"Please check network connection\",\n        \"checkProxy\": \"Consider configuring HTTP proxy\",\n        \"retryLater\": \"Please retry later\",\n        \"checkRepoUrl\": \"Please check repository URL and branch name\",\n        \"checkDiskSpace\": \"Please check disk space\",\n        \"checkPermission\": \"Please check directory permissions\",\n        \"uninstallFirst\": \"Please uninstall the existing skill with the same name first\",\n        \"checkZipContent\": \"Please verify the ZIP file contains valid skill directories (with SKILL.md files)\"\n      }\n    },\n    \"repo\": {\n      \"title\": \"Manage Skill Repositories\",\n      \"description\": \"Add or remove GitHub skill repository sources\",\n      \"url\": \"Repository URL\",\n      \"urlPlaceholder\": \"owner/name or https://github.com/owner/name\",\n      \"branch\": \"Branch\",\n      \"branchPlaceholder\": \"main\",\n      \"path\": \"Skills Path\",\n      \"pathPlaceholder\": \"skills (optional, leave empty for root)\",\n      \"add\": \"Add Repository\",\n      \"list\": \"Added Repositories\",\n      \"empty\": \"No repositories\",\n      \"invalidUrl\": \"Invalid repository URL format\",\n      \"addSuccess\": \"Repository {{owner}}/{{name}} added, detected {{count}} skills\",\n      \"addFailed\": \"Failed to add\",\n      \"removeSuccess\": \"Repository {{owner}}/{{name}} removed\",\n      \"removeFailed\": \"Failed to remove\",\n      \"skillCount\": \"{{count}} skills detected\"\n    },\n    \"search\": \"Search Skills\",\n    \"searchPlaceholder\": \"Search skill name or repo...\",\n    \"filter\": {\n      \"placeholder\": \"Filter by status\",\n      \"all\": \"All\",\n      \"installed\": \"Installed\",\n      \"uninstalled\": \"Not installed\",\n      \"repo\": \"Filter by repo\",\n      \"allRepos\": \"All repos\"\n    },\n    \"noResults\": \"No matching skills found\",\n    \"noInstalled\": \"No skills installed\",\n    \"noInstalledDescription\": \"Discover and install skills from repositories, or import existing skills\",\n    \"discover\": \"Discover Skills\",\n    \"import\": \"Import Existing\",\n    \"importDescription\": \"Select skills to import into CC Switch unified management\",\n    \"importSuccess\": \"Successfully imported {{count}} skills\",\n    \"importSelected\": \"Import Selected ({{count}})\",\n    \"noUnmanagedFound\": \"No skills to import found. All skills are already managed by CC Switch.\",\n    \"foundIn\": \"Found in\",\n    \"local\": \"Local\",\n    \"uninstallConfirm\": \"Are you sure you want to uninstall \\\"{{name}}\\\"? This will remove the skill from all apps and create a local backup first.\",\n    \"uninstallInMainPanel\": \"Please uninstall skills from the main panel\",\n    \"notFound\": \"Skill not found\",\n    \"backup\": {\n      \"location\": \"Backup location: {{path}}\"\n    },\n    \"restoreFromBackup\": {\n      \"button\": \"Restore Backup\",\n      \"title\": \"Restore From Backup\",\n      \"description\": \"Choose a Skills backup to restore its files locally and add it back to the current list.\",\n      \"empty\": \"No Skills backups available to restore\",\n      \"createdAt\": \"Backed up at\",\n      \"path\": \"Backup path\",\n      \"restore\": \"Restore\",\n      \"restoring\": \"Restoring...\",\n      \"delete\": \"Delete\",\n      \"deleting\": \"Deleting...\",\n      \"deleteSuccess\": \"Deleted backup for {{name}}\",\n      \"deleteFailed\": \"Failed to delete skill backup\",\n      \"deleteConfirmTitle\": \"Delete Backup\",\n      \"deleteConfirmMessage\": \"Are you sure you want to delete the backup for \\\"{{name}}\\\"? This action cannot be undone.\",\n      \"success\": \"Skill {{name}} restored from backup\",\n      \"failed\": \"Failed to restore from backup\"\n    },\n    \"apps\": {\n      \"claude\": \"Claude\",\n      \"codex\": \"Codex\",\n      \"gemini\": \"Gemini\",\n      \"opencode\": \"OpenCode\",\n      \"openclaw\": \"OpenClaw\"\n    },\n    \"installFromZip\": {\n      \"button\": \"Install from ZIP\",\n      \"installing\": \"Installing...\",\n      \"successSingle\": \"Skill {{name}} installed\",\n      \"successMultiple\": \"Successfully installed {{count}} skills\",\n      \"noSkillsFound\": \"No skills found in ZIP file (requires SKILL.md file)\"\n    }\n  },\n  \"deeplink\": {\n    \"confirmImport\": \"Confirm Import Provider\",\n    \"confirmImportDescription\": \"The following configuration will be imported from deep link into CC Switch\",\n    \"importPrompt\": \"Import Prompt\",\n    \"importPromptDescription\": \"Please confirm whether to import this system prompt\",\n    \"importMcp\": \"Import MCP Servers\",\n    \"importMcpDescription\": \"Please confirm whether to import these MCP Servers\",\n    \"importSkill\": \"Add Skill Repository\",\n    \"importSkillDescription\": \"Please confirm whether to add this Skill repository\",\n    \"promptImportSuccess\": \"Prompt imported successfully\",\n    \"promptImportSuccessDescription\": \"Imported prompt: {{name}}\",\n    \"mcpImportSuccess\": \"MCP Servers imported successfully\",\n    \"mcpImportSuccessDescription\": \"Successfully imported {{count}} server(s)\",\n    \"mcpPartialSuccess\": \"Partial import success\",\n    \"mcpPartialSuccessDescription\": \"Success: {{success}}, Failed: {{failed}}\",\n    \"skillImportSuccess\": \"Skill repository added successfully\",\n    \"skillImportSuccessDescription\": \"Added repository: {{repo}}\",\n    \"app\": \"App Type\",\n    \"providerName\": \"Provider Name\",\n    \"homepage\": \"Homepage\",\n    \"endpoint\": \"API Endpoint\",\n    \"apiKey\": \"API Key\",\n    \"icon\": \"Icon\",\n    \"model\": \"Model\",\n    \"haikuModel\": \"Haiku Model\",\n    \"sonnetModel\": \"Sonnet Model\",\n    \"opusModel\": \"Opus Model\",\n    \"multiModel\": \"Multi-Modal Model\",\n    \"notes\": \"Notes\",\n    \"import\": \"Import\",\n    \"importing\": \"Importing...\",\n    \"warning\": \"Please confirm the information above is correct before importing. You can edit or delete it later in the provider list.\",\n    \"parseError\": \"Failed to parse deep link\",\n    \"importSuccess\": \"Import successful\",\n    \"importSuccessDescription\": \"Provider \\\"{{name}}\\\" has been successfully imported\",\n    \"importError\": \"Failed to import\",\n    \"configSource\": \"Config Source\",\n    \"configEmbedded\": \"Embedded Config\",\n    \"configRemote\": \"Remote Config\",\n    \"configDetails\": \"Config Details\",\n    \"configUrl\": \"Config File URL\",\n    \"configMergeError\": \"Failed to merge configuration file\",\n    \"primaryEndpoint\": \"Primary\",\n    \"mcp\": {\n      \"title\": \"Batch Import MCP Servers\",\n      \"targetApps\": \"Target Apps\",\n      \"serverCount\": \"MCP Servers ({{count}})\",\n      \"enabledWarning\": \"After import, configurations will be written to all specified apps immediately\"\n    },\n    \"prompt\": {\n      \"title\": \"Import System Prompt\",\n      \"app\": \"App\",\n      \"name\": \"Name\",\n      \"description\": \"Description\",\n      \"contentPreview\": \"Content Preview\",\n      \"enabledWarning\": \"After import, this prompt will be enabled immediately and other prompts will be disabled\"\n    },\n    \"skill\": {\n      \"title\": \"Add Claude Skill Repository\",\n      \"repo\": \"GitHub Repository\",\n      \"directory\": \"Target Directory\",\n      \"branch\": \"Branch\",\n      \"skillsPath\": \"Skills Path\",\n      \"hint\": \"This will add the Skill repository to the list.\",\n      \"hintDetail\": \"After adding, you can install specific Skills from the Skills management page.\"\n    },\n    \"usageScript\": \"Usage Query\",\n    \"usageScriptEnabled\": \"Enabled\",\n    \"usageScriptDisabled\": \"Disabled\",\n    \"usageApiKey\": \"Usage API Key\",\n    \"usageBaseUrl\": \"Usage Query URL\",\n    \"usageAutoInterval\": \"Auto Query\",\n    \"usageAutoIntervalValue\": \"Every {{minutes}} minutes\"\n  },\n  \"iconPicker\": {\n    \"search\": \"Search Icons\",\n    \"searchPlaceholder\": \"Enter icon name...\",\n    \"noResults\": \"No matching icons found\",\n    \"category\": {\n      \"aiProvider\": \"AI Providers\",\n      \"cloud\": \"Cloud Platforms\",\n      \"tool\": \"Dev Tools\",\n      \"other\": \"Other\"\n    }\n  },\n  \"providerIcon\": {\n    \"label\": \"Icon\",\n    \"colorLabel\": \"Icon Color\",\n    \"selectIcon\": \"Select Icon\",\n    \"preview\": \"Preview\",\n    \"clickToChange\": \"Click to change icon\",\n    \"clickToSelect\": \"Click to select icon\",\n    \"color\": \"Icon Color\"\n  },\n  \"migration\": {\n    \"success\": \"Configuration migrated successfully\",\n    \"skillsSuccess\": \"Automatically imported {{count}} skill(s) into unified management\",\n    \"skillsFailed\": \"Failed to auto import skills\",\n    \"skillsFailedDescription\": \"Open the Skills page and click \\\"Import Existing\\\" to import manually (or restart and try again).\"\n  },\n  \"agents\": {\n    \"title\": \"Agents\"\n  },\n  \"modelTest\": {\n    \"testProvider\": \"Test model\"\n  },\n  \"health\": {\n    \"operational\": \"Operational\",\n    \"degraded\": \"Degraded\",\n    \"failed\": \"Failed\",\n    \"circuitOpen\": \"Circuit Open\",\n    \"consecutiveFailures\": \"{{count}} consecutive failures\"\n  },\n  \"failover\": {\n    \"enabled\": \"{{app}} failover enabled\",\n    \"disabled\": \"{{app}} failover disabled\",\n    \"toggleFailed\": \"Operation failed: {{detail}}\",\n    \"inQueue\": \"In queue\",\n    \"addQueue\": \"Add\",\n    \"priority\": {\n      \"tooltip\": \"Failover priority {{priority}}\"\n    },\n    \"tooltip\": {\n      \"enabled\": \"{{app}} failover enabled\\nRequests follow queue priority (P1→P2→...)\",\n      \"disabled\": \"Enable {{app}} failover\\nSwitches to queue P1 immediately, then falls back on failures\"\n    }\n  },\n  \"proxy\": {\n    \"panel\": {\n      \"serviceAddress\": \"Service Address\",\n      \"addressCopied\": \"Address copied\",\n      \"currentProvider\": \"Current Provider:\",\n      \"waitingFirstRequest\": \"Current Provider: Waiting for first request...\",\n      \"stoppedTitle\": \"Proxy Service Stopped\",\n      \"stoppedDescription\": \"Use the toggle above to start the service\",\n      \"openSettings\": \"Configure Proxy Service\",\n      \"stats\": {\n        \"activeConnections\": \"Active Connections\",\n        \"totalRequests\": \"Total Requests\",\n        \"successRate\": \"Success Rate\",\n        \"uptime\": \"Uptime\"\n      }\n    },\n    \"settings\": {\n      \"title\": \"Proxy Service Settings\",\n      \"description\": \"Configure local proxy server listening address, port and runtime parameters. Changes take effect immediately after saving.\",\n      \"alert\": {\n        \"autoApply\": \"Changes will be automatically synced to the running proxy service without manual restart.\"\n      },\n      \"basic\": {\n        \"title\": \"Basic Settings\",\n        \"description\": \"Configure proxy service listening address and port.\"\n      },\n      \"advanced\": {\n        \"title\": \"Advanced Parameters\",\n        \"description\": \"Control request stability and logging.\"\n      },\n      \"timeout\": {\n        \"title\": \"Timeout Settings\",\n        \"description\": \"Configure timeout for streaming and non-streaming requests.\"\n      },\n      \"fields\": {\n        \"listenAddress\": {\n          \"label\": \"Listen Address\",\n          \"placeholder\": \"127.0.0.1\",\n          \"description\": \"IP address the proxy server listens on (recommended: 127.0.0.1)\"\n        },\n        \"listenPort\": {\n          \"label\": \"Listen Port\",\n          \"placeholder\": \"15721\",\n          \"description\": \"Port number the proxy server listens on (1024 ~ 65535)\"\n        },\n        \"maxRetries\": {\n          \"label\": \"Max Retries\",\n          \"placeholder\": \"3\",\n          \"description\": \"Number of retries on request failure (0 ~ 10)\"\n        },\n        \"requestTimeout\": {\n          \"label\": \"Request Timeout (sec)\",\n          \"placeholder\": \"0 (unlimited) or 300\",\n          \"description\": \"Maximum wait time for a single request (0 = unlimited, or 10 ~ 600 seconds)\"\n        },\n        \"enableLogging\": {\n          \"label\": \"Enable Logging\",\n          \"description\": \"Log all proxy requests for troubleshooting\"\n        },\n        \"streamingFirstByteTimeout\": {\n          \"label\": \"Streaming First Byte Timeout (sec)\",\n          \"description\": \"Maximum time to wait for the first data chunk\"\n        },\n        \"streamingIdleTimeout\": {\n          \"label\": \"Streaming Idle Timeout (sec)\",\n          \"description\": \"Maximum interval between data chunks\"\n        },\n        \"nonStreamingTimeout\": {\n          \"label\": \"Non-Streaming Timeout (sec)\",\n          \"description\": \"Total timeout for non-streaming requests\"\n        }\n      },\n      \"validation\": {\n        \"addressInvalid\": \"Please enter a valid IP address\",\n        \"portMin\": \"Port must be greater than 1024\",\n        \"portMax\": \"Port must be less than 65535\",\n        \"retryMin\": \"Retry count cannot be negative\",\n        \"retryMax\": \"Retry count cannot exceed 10\",\n        \"timeoutNonNegative\": \"Timeout cannot be negative\",\n        \"timeoutMax\": \"Timeout cannot exceed 600 seconds\",\n        \"timeoutRange\": \"Please enter 0 or a value between 10-600\",\n        \"streamingTimeoutMin\": \"Timeout must be at least 5 seconds\",\n        \"streamingTimeoutMax\": \"Timeout cannot exceed 300 seconds\"\n      },\n      \"actions\": {\n        \"save\": \"Save Configuration\"\n      },\n      \"toast\": {\n        \"saved\": \"Proxy configuration saved\",\n        \"saveFailed\": \"Save failed: {{error}}\"\n      },\n      \"invalidPort\": \"Invalid port, please enter a number between 1024-65535\",\n      \"invalidAddress\": \"Invalid address, please enter a valid IP address (e.g. 127.0.0.1) or localhost\",\n      \"configSaved\": \"Proxy configuration saved\",\n      \"configSaveFailed\": \"Failed to save configuration\",\n      \"restartRequired\": \"Restart proxy service for address or port changes to take effect\"\n    },\n    \"switchFailed\": \"Switch failed: {{error}}\",\n    \"takeover\": {\n      \"hint\": \"Select apps to take over — once enabled, requests from that app will be routed through the local proxy\",\n      \"enabled\": \"{{app}} takeover enabled\",\n      \"disabled\": \"{{app}} takeover disabled\",\n      \"failed\": \"Failed to toggle takeover\",\n      \"tooltip\": {\n        \"active\": \"{{appLabel}} is intercepting - {{address}}:{{port}}\\nSwitch provider for hot switching\",\n        \"broken\": \"{{appLabel}} is intercepting, but proxy service is not running\",\n        \"inactive\": \"Intercept {{appLabel}}'s live config to route requests through local proxy\"\n      }\n    },\n    \"failover\": {\n      \"proxyRequired\": \"Proxy service must be started to configure failover\",\n      \"autoSwitch\": \"Auto Failover\",\n      \"autoSwitchDescription\": \"When enabled, switches to queue P1 immediately and automatically tries the next provider in the queue on failures\"\n    },\n    \"failoverQueue\": {\n      \"title\": \"Failover Queue\",\n      \"description\": \"Manage failover order for each app's providers\",\n      \"info\": \"When auto failover is enabled, requests follow the queue priority order (P1 first). On failures, the system will try the next provider in the queue.\",\n      \"selectProvider\": \"Select a provider to add to queue\",\n      \"noAvailableProviders\": \"No providers available to add\",\n      \"empty\": \"Failover queue is empty. Add providers to enable automatic failover.\",\n      \"orderHint\": \"Queue order matches the provider list order on the Home page. Reorder providers on the Home page to change priority.\",\n      \"dragHint\": \"Drag providers to adjust failover order. Lower numbers have higher priority.\",\n      \"toggleEnabled\": \"Enable/Disable\",\n      \"addSuccess\": \"Added to failover queue\",\n      \"addFailed\": \"Failed to add\",\n      \"removeSuccess\": \"Removed from failover queue\",\n      \"removeFailed\": \"Failed to remove\",\n      \"reorderSuccess\": \"Queue order updated\",\n      \"reorderFailed\": \"Failed to update order\",\n      \"toggleFailed\": \"Failed to update status\"\n    },\n    \"autoFailover\": {\n      \"info\": \"When the failover queue has multiple providers, the system will try them in priority order when requests fail. When a provider reaches the consecutive failure threshold, the circuit breaker will open and skip it temporarily.\",\n      \"configSaved\": \"Auto failover config saved\",\n      \"configSaveFailed\": \"Failed to save\",\n      \"validationFailed\": \"The following fields are out of valid range: {{fields}}\",\n      \"retrySettings\": \"Retry & Timeout Settings\",\n      \"failureThreshold\": \"Failure Threshold\",\n      \"failureThresholdHint\": \"Open circuit breaker after this many consecutive failures (recommended: 3-10)\",\n      \"timeout\": \"Recovery Wait Time (seconds)\",\n      \"timeoutHint\": \"Wait this long before trying to recover after circuit opens (recommended: 30-120)\",\n      \"circuitBreakerSettings\": \"Circuit Breaker Settings\",\n      \"successThreshold\": \"Recovery Success Threshold\",\n      \"successThresholdHint\": \"Close circuit breaker after this many successes in half-open state\",\n      \"errorRate\": \"Error Rate Threshold (%)\",\n      \"errorRateHint\": \"Open circuit breaker when error rate exceeds this value\",\n      \"minRequests\": \"Minimum Requests\",\n      \"minRequestsHint\": \"Minimum requests before calculating error rate\",\n      \"explanationTitle\": \"How It Works\",\n      \"failureThresholdLabel\": \"Failure Threshold\",\n      \"failureThresholdExplain\": \"Circuit breaker opens after this many consecutive failures, making the provider temporarily unavailable\",\n      \"timeoutLabel\": \"Recovery Wait Time\",\n      \"timeoutExplain\": \"After circuit opens, wait this long before trying half-open state\",\n      \"successThresholdLabel\": \"Recovery Success Threshold\",\n      \"successThresholdExplain\": \"In half-open state, close circuit breaker after this many successes, making provider available again\",\n      \"errorRateLabel\": \"Error Rate Threshold\",\n      \"errorRateExplain\": \"Open circuit breaker when error rate exceeds this value, even if failure threshold not reached\",\n      \"maxRetries\": \"Max Retries\",\n      \"timeoutSettings\": \"Timeout Settings\",\n      \"streamingFirstByte\": \"Streaming First Byte Timeout\",\n      \"streamingIdle\": \"Streaming Idle Timeout\",\n      \"nonStreaming\": \"Non-Streaming Timeout\",\n      \"maxRetriesHint\": \"Number of retries on request failure (0-10)\",\n      \"streamingFirstByteHint\": \"Max time to wait for first data chunk, range 1-120s, default 60s\",\n      \"streamingIdleHint\": \"Max interval between data chunks, range 60-600s, 0 to disable (prevents mid-stream stalls)\",\n      \"nonStreamingHint\": \"Total timeout for non-streaming requests, range 60-1200s, default 600s (10 min)\"\n    },\n    \"logging\": {\n      \"enabled\": \"Logging enabled\",\n      \"disabled\": \"Logging disabled\",\n      \"failed\": \"Failed to toggle logging\"\n    },\n    \"server\": {\n      \"started\": \"Proxy service started - {{address}}:{{port}}\",\n      \"startFailed\": \"Failed to start proxy service: {{detail}}\"\n    },\n    \"stoppedWithRestore\": \"Proxy service stopped, all takeover configs restored\",\n    \"stopWithRestoreFailed\": \"Stop failed: {{detail}}\"\n  },\n  \"streamCheck\": {\n    \"configSaved\": \"Health check config saved\",\n    \"configSaveFailed\": \"Save failed\",\n    \"testModels\": \"Test Models\",\n    \"claudeModel\": \"Claude Model\",\n    \"codexModel\": \"Codex Model\",\n    \"geminiModel\": \"Gemini Model\",\n    \"checkParams\": \"Check Parameters\",\n    \"timeout\": \"Timeout (seconds)\",\n    \"maxRetries\": \"Max Retries\",\n    \"degradedThreshold\": \"Degraded Threshold (ms)\",\n    \"testPrompt\": \"Test Prompt\",\n    \"operational\": \"{{providerName}} is operational ({{responseTimeMs}}ms)\",\n    \"degraded\": \"{{providerName}} is slow ({{responseTimeMs}}ms)\",\n    \"failed\": \"{{providerName}} check failed: {{message}}\",\n    \"error\": \"{{providerName}} check error: {{error}}\"\n  },\n  \"proxyConfig\": {\n    \"proxyEnabled\": \"Proxy Enabled\",\n    \"appTakeover\": \"Proxy Enabled\",\n    \"perAppConfig\": \"Per-App Config\",\n    \"circuitBreaker\": \"Circuit Breaker\",\n    \"circuitBreakerSettings\": \"Circuit Breaker Settings\",\n    \"failureThreshold\": \"Failure Threshold\",\n    \"successThreshold\": \"Success Threshold\",\n    \"recoveryTimeout\": \"Recovery Timeout\",\n    \"errorRateThreshold\": \"Error Rate Threshold\",\n    \"minRequests\": \"Min Requests\",\n    \"timeoutConfig\": \"Timeout Config\",\n    \"streamingFirstByte\": \"Streaming First Byte Timeout\",\n    \"streamingIdle\": \"Streaming Idle Timeout\",\n    \"nonStreaming\": \"Non-Streaming Timeout\"\n  },\n  \"circuitBreaker\": {\n    \"failureThreshold\": \"Failure Threshold\",\n    \"successThreshold\": \"Success Threshold\",\n    \"timeoutSeconds\": \"Timeout (seconds)\",\n    \"errorRateThreshold\": \"Error Rate Threshold (%)\",\n    \"minRequests\": \"Minimum Requests\",\n    \"validationFailed\": \"The following fields are out of valid range: {{fields}}\",\n    \"configSaved\": \"Circuit breaker configuration saved\",\n    \"saveFailed\": \"Save failed\",\n    \"loading\": \"Loading...\",\n    \"title\": \"Circuit Breaker Configuration\",\n    \"description\": \"Adjust circuit breaker parameters to control fault detection and recovery behavior\",\n    \"failureThresholdHint\": \"How many consecutive failures trigger the circuit breaker\",\n    \"timeoutSecondsHint\": \"How long to wait before attempting recovery (half-open state)\",\n    \"successThresholdHint\": \"How many successes in half-open state to close the circuit breaker\",\n    \"errorRateThresholdHint\": \"Open circuit breaker when error rate exceeds this value\",\n    \"minRequestsHint\": \"Minimum requests before calculating error rate\",\n    \"saveConfig\": \"Save Configuration\",\n    \"instructionsTitle\": \"Configuration Instructions\",\n    \"instructions\": {\n      \"failureThreshold\": \"Circuit breaker opens when consecutive failures reach this count\",\n      \"timeout\": \"After circuit breaker opens, wait this time before attempting half-open\",\n      \"successThreshold\": \"In half-open state, close circuit breaker when successes reach this count\",\n      \"errorRate\": \"Circuit breaker opens when error rate exceeds this value\",\n      \"minRequests\": \"Error rate is only calculated after request count reaches this value\"\n    }\n  },\n  \"universalProvider\": {\n    \"title\": \"Universal Provider\",\n    \"description\": \"Universal providers manage Claude, Codex, and Gemini configurations simultaneously. Changes are automatically synced to all enabled apps.\",\n    \"add\": \"Add Universal Provider\",\n    \"edit\": \"Edit Universal Provider\",\n    \"empty\": \"No universal providers yet\",\n    \"emptyHint\": \"Click the \\\"Add Universal Provider\\\" button below to create one\",\n    \"selectPreset\": \"Select Preset Type\",\n    \"name\": \"Name\",\n    \"namePlaceholder\": \"e.g., My NewAPI\",\n    \"baseUrl\": \"API URL\",\n    \"apiKey\": \"API Key\",\n    \"websiteUrl\": \"Website URL\",\n    \"websiteUrlPlaceholder\": \"https://example.com (optional, displayed in the list)\",\n    \"notes\": \"Notes\",\n    \"notesPlaceholder\": \"Optional: Add notes\",\n    \"enabledApps\": \"Enabled Apps\",\n    \"modelConfig\": \"Model Configuration\",\n    \"model\": \"Model\",\n    \"sync\": \"Sync to Apps\",\n    \"synced\": \"Synced to all apps\",\n    \"syncError\": \"Sync failed\",\n    \"noAppsEnabled\": \"No apps enabled\",\n    \"added\": \"Universal provider added\",\n    \"addedAndSynced\": \"Universal provider added and synced\",\n    \"updated\": \"Universal provider updated\",\n    \"deleted\": \"Universal provider deleted\",\n    \"addSuccess\": \"Universal provider added successfully\",\n    \"addFailed\": \"Failed to add universal provider\",\n    \"hint\": \"Cross-app unified config, auto-sync to Claude/Codex/Gemini\",\n    \"manage\": \"Manage\",\n    \"loadError\": \"Failed to load universal providers\",\n    \"saveError\": \"Failed to save universal provider\",\n    \"deleteError\": \"Failed to delete universal provider\",\n    \"deleteConfirmTitle\": \"Delete Universal Provider\",\n    \"deleteConfirmDescription\": \"Are you sure you want to delete \\\"{{name}}\\\"? This will also delete its generated provider configurations in each app.\",\n    \"syncConfirmTitle\": \"Sync Universal Provider\",\n    \"syncConfirmDescription\": \"Syncing \\\"{{name}}\\\" will overwrite the associated provider configurations in Claude, Codex, and Gemini. Do you want to continue?\",\n    \"syncConfirm\": \"Sync\",\n    \"saveAndSync\": \"Save & Sync\",\n    \"savedAndSynced\": \"Saved and synced to all apps\",\n    \"saveAndSyncError\": \"Failed to save and sync\",\n    \"configJsonPreview\": \"Config JSON Preview\",\n    \"configJsonPreviewHint\": \"The following configurations will be synced to each app (only the displayed fields will be overwritten, other custom settings will be preserved)\"\n  },\n  \"omo\": {\n    \"editProfile\": \"Edit OMO Config\",\n    \"newProfile\": \"New OMO Config\",\n    \"profileName\": \"Name\",\n    \"mainAgents\": \"Main Agents\",\n    \"subAgents\": \"Sub Agents\",\n    \"categories\": \"Categories\",\n    \"customAgents\": \"Custom Agents\",\n    \"noCustomAgents\": \"No custom agents\",\n    \"otherFields\": \"Other Config\",\n    \"globalConfig\": \"OMO Global Config\",\n    \"globalConfigShort\": \"OMO Config\",\n    \"globalConfigSaved\": \"Global config saved\",\n    \"addProfile\": \"Add OMO Provider\",\n    \"disabledItems\": \"Disabled Items\",\n    \"advanced\": \"Advanced Settings\",\n    \"profileCreated\": \"OMO config created\",\n    \"profileUpdated\": \"OMO config updated\",\n    \"invalidJson\": \"Other Fields contains invalid JSON\",\n    \"confirmDelete\": \"Delete Config\",\n    \"confirmDeleteMsg\": \"Delete \\\"{{name}}\\\"?\",\n    \"profileDeleted\": \"Config deleted\",\n    \"imported\": \"Imported as \\\"{{name}}\\\"\",\n    \"import\": \"Import\",\n    \"global\": \"Global\",\n    \"empty\": \"No OMO configs yet. Click + Add or Import from local.\",\n    \"applied\": \"Applied\",\n    \"apply\": \"Apply\",\n    \"enable\": \"Enable\",\n    \"enabled\": \"Enabled\",\n    \"disabled\": \"OMO disabled\",\n    \"disableFailed\": \"Failed to disable OMO: {{error}}\",\n    \"selectPlaceholder\": \"Select...\",\n    \"clear\": \"Clear\",\n    \"clearWrapped\": \"(Clear)\",\n    \"defaultWrapped\": \"(Default)\",\n    \"variantPlaceholder\": \"variant\",\n    \"selectEnabledModel\": \"Select configured model\",\n    \"selectModel\": \"Select configured model\",\n    \"recommendedHint\": \"Recommended: {{model}}\",\n    \"searchModel\": \"Search model...\",\n    \"selectModelFirst\": \"Select model first\",\n    \"noEnabledModels\": \"No configured models\",\n    \"noVariantsForModel\": \"No variants for model\",\n    \"currentValueNotEnabled\": \"{{value}} (current value, not configured)\",\n    \"currentValueUnavailable\": \"{{value}} (current value, unavailable)\",\n    \"advancedLabel\": \"Advanced\",\n    \"advancedJsonInvalid\": \"Advanced JSON is invalid\",\n    \"advancedJsonHint\": \"temperature, top_p, budgetTokens, prompt_append, permission, etc. Leave empty for defaults\",\n    \"noEnabledModelsWarning\": \"No configured models available. Configure OpenCode models first.\",\n    \"modelSourcePartialWarning\": \"Some provider model configs are invalid and were skipped.\",\n    \"modelSourceFallbackWarning\": \"Failed to load live provider state. Falling back to configured providers.\",\n    \"importLocalReplaceSuccess\": \"Imported local file and replaced Agents/Categories/Other Fields\",\n    \"importLocalFailed\": \"Failed to read local file: {{error}}\",\n    \"agentKeyPlaceholder\": \"agent key\",\n    \"categoryKeyPlaceholder\": \"category key\",\n    \"modelNamePlaceholder\": \"model-name\",\n    \"custom\": \"Custom\",\n    \"customCategories\": \"Custom Categories\",\n    \"modelConfiguration\": \"Model Configuration\",\n    \"fillRecommended\": \"Fill Recommended\",\n    \"fillRecommendedSuccess\": \"Filled {{count}} recommended models\",\n    \"fillRecommendedPartial\": \"Filled {{filled}} recommended models, {{unmatched}} unmatched\",\n    \"fillRecommendedAllSet\": \"All slots already have models configured\",\n    \"fillRecommendedNoMatch\": \"Recommended models not found in configured providers\",\n    \"configSummary\": \"{{agents}} agents, {{categories}} categories configured · Click ⚙ for advanced params\",\n    \"enabledModelsCount\": \"{{count}} configured models available\",\n    \"source\": \"from:\",\n    \"otherFieldsJson\": \"Other Fields (JSON)\",\n    \"searchOrType\": \"Search or type custom value...\",\n    \"noMatches\": \"No matches\",\n    \"jsonMustBeObject\": \"{{field}} must be a JSON object\",\n    \"jsonInvalid\": \"{{field}} contains invalid JSON\",\n    \"importGlobalSuccess\": \"Imported global config from local file (unsaved)\",\n    \"importGlobalFailed\": \"Failed to read local file: {{error}}\",\n    \"importLocal\": \"Import Local\",\n    \"saveGlobalConfig\": \"Save Global Config\",\n    \"schemaUrl\": \"$schema\",\n    \"resetDefault\": \"Reset\",\n    \"sisyphusAgentConfig\": \"Sisyphus Agent Config\",\n    \"disabledAgents\": \"Agents\",\n    \"disabledAgentsPlaceholder\": \"Disabled Agents\",\n    \"disabledMcps\": \"MCPs\",\n    \"disabledMcpsPlaceholder\": \"Disabled MCPs\",\n    \"disabledHooks\": \"Hooks\",\n    \"disabledHooksPlaceholder\": \"Disabled Hooks\",\n    \"disabledSkills\": \"Skills\",\n    \"disabledSkillsPlaceholder\": \"Disabled Skills\",\n    \"advancedLsp\": \"LSP Config\",\n    \"advancedExperimental\": \"Experimental Features\",\n    \"advancedBackgroundTask\": \"Background Tasks\",\n    \"advancedBrowserAutomation\": \"Browser Automation\",\n    \"advancedClaudeCode\": \"Claude Code\",\n    \"agentDesc\": {\n      \"sisyphus\": \"Main orchestrator\",\n      \"hephaestus\": \"Autonomous deep worker\",\n      \"prometheus\": \"Strategic planner\",\n      \"atlas\": \"Task manager\",\n      \"oracle\": \"Strategic advisor\",\n      \"librarian\": \"Multi-repo researcher\",\n      \"explore\": \"Fast code search\",\n      \"multimodalLooker\": \"Media analyzer\",\n      \"metis\": \"Pre-plan analysis advisor\",\n      \"momus\": \"Plan reviewer\",\n      \"sisyphusJunior\": \"Delegated task executor\"\n    },\n    \"agentTooltip\": {\n      \"sisyphus\": \"Main orchestrator responsible for task planning, delegation and parallel execution. Uses extended thinking (32k budget) and drives workflows through TODO lists to ensure task completion.\",\n      \"atlas\": \"Main orchestrator (holds TODO list) responsible for task distribution and coordination during execution phase. Delegates to specialized agents rather than completing all work directly.\",\n      \"prometheus\": \"Strategic planner that collects requirements through interview mode and creates detailed work plans. Can only read/write Markdown files in .sisyphus/ directory, never writes code directly.\",\n      \"hephaestus\": \"Autonomous deep worker (\\\"legitimate craftsman\\\") inspired by AmpCode deep mode. Goal-oriented execution that launches 2-5 explore/librarian agents in parallel for research before taking action.\",\n      \"oracle\": \"Architecture decision and debugging advisor. Read-only consulting agent providing excellent logical reasoning and deep analysis. Cannot write files or delegate tasks.\",\n      \"librarian\": \"Multi-repository analysis and documentation retrieval expert. Deeply understands codebases and provides evidence-based answers. Excels at finding official documentation and open-source implementation examples.\",\n      \"explore\": \"Fast codebase exploration and context grep expert. Uses lightweight models for high-speed search. The scout for understanding code structure.\",\n      \"multimodalLooker\": \"Visual content expert that analyzes PDFs, images, charts and other non-text media, extracting information and insights from them.\",\n      \"metis\": \"Plan consultant that performs pre-analysis before planning. Identifies hidden intents, ambiguities and AI failure points to prevent over-engineering.\",\n      \"momus\": \"Plan reviewer that validates plan clarity, verifiability and completeness with high precision. Rejects and requests revisions until plans are perfect.\",\n      \"sisyphusJunior\": \"Category-generated executor, automatically created via category parameters. Focuses on executing assigned tasks and cannot re-delegate, preventing infinite delegation loops.\"\n    },\n    \"categoryDesc\": {\n      \"visualEngineering\": \"Visual/frontend engineering\",\n      \"ultrabrain\": \"Ultra thinking\",\n      \"deep\": \"Deep work\",\n      \"artistry\": \"Creative/artistic\",\n      \"quick\": \"Quick response\",\n      \"unspecifiedLow\": \"General low tier\",\n      \"unspecifiedHigh\": \"General high tier\",\n      \"writing\": \"Writing\"\n    },\n    \"categoryTooltip\": {\n      \"visualEngineering\": \"Frontend and visual engineering category for UI/UX design, styling, animation and interface implementation. Defaults to Gemini 3 Pro model.\",\n      \"ultrabrain\": \"Deep logical reasoning category for complex architectural decisions requiring extensive analysis. Defaults to GPT-5.3 Codex ultra-high reasoning variant.\",\n      \"deep\": \"Deep autonomous problem-solving category with goal-oriented execution. Conducts thorough research before action, suitable for difficult problems requiring deep understanding.\",\n      \"artistry\": \"Highly creative and artistic task category that inspires novel ideas and creative solutions. Defaults to Gemini 3 Pro max variant.\",\n      \"quick\": \"Lightweight task category for single-file modifications, typo fixes, and simple adjustments. Defaults to Claude Haiku 4.5 fast model.\",\n      \"unspecifiedLow\": \"Uncategorized low-effort task category for tasks that don't fit other categories with small workload. Defaults to Claude Sonnet 4.5.\",\n      \"unspecifiedHigh\": \"Uncategorized high-effort task category for tasks that don't fit other categories with large workload. Defaults to Claude Opus 4.6 max variant.\",\n      \"writing\": \"Writing category for documentation, prose and technical writing. Defaults to Gemini 3 Flash fast generation model.\"\n    },\n    \"slimAgentDesc\": {\n      \"orchestrator\": \"Orchestrator\",\n      \"oracle\": \"Oracle\",\n      \"librarian\": \"Librarian\",\n      \"explorer\": \"Explorer\",\n      \"designer\": \"Designer\",\n      \"fixer\": \"Fixer\"\n    },\n    \"slimAgentTooltip\": {\n      \"orchestrator\": \"Writes executable code, orchestrates multi-agent workflow, summons experts\",\n      \"oracle\": \"Root cause analysis, architecture review, debugging guidance (read-only)\",\n      \"librarian\": \"Documentation lookup, GitHub code search (read-only)\",\n      \"explorer\": \"Regex search, AST pattern matching, file discovery (read-only)\",\n      \"designer\": \"Modern responsive design, CSS/Tailwind expertise\",\n      \"fixer\": \"Code implementation, refactoring, testing, verification\"\n    }\n  },\n  \"openclawConfig\": {\n    \"defaultModel\": {\n      \"title\": \"Default Model\",\n      \"description\": \"Configure the default primary model and fallback models for OpenClaw\",\n      \"primary\": \"Primary Model\",\n      \"primaryPlaceholder\": \"e.g., deepseek/deepseek-chat\",\n      \"fallbacks\": \"Fallback Models\",\n      \"fallbacksPlaceholder\": \"e.g., openrouter/anthropic/claude-sonnet-4.5\",\n      \"addFallback\": \"Add Fallback Model\",\n      \"saved\": \"Default model configuration saved\",\n      \"saveFailed\": \"Failed to save default model\"\n    },\n    \"modelCatalog\": {\n      \"title\": \"Model Catalog\",\n      \"description\": \"Configure model aliases and allowlist\",\n      \"modelId\": \"Model ID\",\n      \"modelIdPlaceholder\": \"e.g., deepseek/deepseek-chat\",\n      \"alias\": \"Alias\",\n      \"aliasPlaceholder\": \"e.g., DeepSeek\",\n      \"addEntry\": \"Add Model\",\n      \"removeEntry\": \"Remove\",\n      \"saved\": \"Model catalog saved\",\n      \"saveFailed\": \"Failed to save model catalog\",\n      \"empty\": \"No model catalog entries\",\n      \"emptyHint\": \"Add models to the catalog to configure aliases\"\n    },\n    \"suggestedDefaults\": \"Apply Suggested Defaults\",\n    \"suggestedDefaultsHint\": \"Use this preset's recommended default model configuration\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/ja.json",
    "content": "{\n  \"app\": {\n    \"title\": \"CC Switch\",\n    \"description\": \"Claude Code・Codex・Gemini CLI のためのオールインワンアシスタント\"\n  },\n  \"common\": {\n    \"add\": \"追加\",\n    \"edit\": \"編集\",\n    \"delete\": \"削除\",\n    \"save\": \"保存\",\n    \"saving\": \"保存中...\",\n    \"cancel\": \"キャンセル\",\n    \"confirm\": \"確認\",\n    \"close\": \"閉じる\",\n    \"done\": \"完了\",\n    \"settings\": \"設定\",\n    \"about\": \"バージョン情報\",\n    \"version\": \"バージョン\",\n    \"loading\": \"読み込み中...\",\n    \"notInstalled\": \"未インストール\",\n    \"success\": \"成功\",\n    \"error\": \"エラー\",\n    \"unknown\": \"不明\",\n    \"enterValidValue\": \"有効な値を入力してください\",\n    \"clear\": \"クリア\",\n    \"toggleTheme\": \"テーマを切り替え\",\n    \"format\": \"フォーマット\",\n    \"formatSuccess\": \"整形しました\",\n    \"formatError\": \"整形に失敗しました: {{error}}\",\n    \"copy\": \"コピー\",\n    \"view\": \"表示\",\n    \"back\": \"戻る\",\n    \"refresh\": \"更新\",\n    \"refreshing\": \"更新中...\",\n    \"import\": \"インポート\",\n    \"all\": \"すべて\",\n    \"search\": \"検索\",\n    \"reset\": \"リセット\",\n    \"actions\": \"操作\",\n    \"deleting\": \"削除中...\",\n    \"auto\": \"自動\",\n    \"enabled\": \"有効\",\n    \"notSet\": \"未設定\"\n  },\n  \"apiKeyInput\": {\n    \"placeholder\": \"API Key を入力\",\n    \"show\": \"API Key を表示\",\n    \"hide\": \"API Key を隠す\"\n  },\n  \"jsonEditor\": {\n    \"mustBeObject\": \"設定はオブジェクト形式の JSON で入力してください（配列や他の型は不可）\",\n    \"invalidJson\": \"JSON 形式が正しくありません\"\n  },\n  \"claudeConfig\": {\n    \"configLabel\": \"Claude Code settings.json (JSON) *\",\n    \"writeCommonConfig\": \"共通設定を書き込む\",\n    \"editCommonConfig\": \"共通設定を編集\",\n    \"editCommonConfigTitle\": \"共通設定スニペットを編集\",\n    \"commonConfigHint\": \"「共通設定を書き込む」がオンのとき settings.json にマージされます\",\n    \"fullSettingsHint\": \"Claude Code の settings.json 全文\",\n    \"extractFromCurrent\": \"編集内容から抽出\",\n    \"extractNoCommonConfig\": \"編集内容から抽出できる共通設定がありません\",\n    \"extractFailed\": \"抽出に失敗しました: {{error}}\",\n    \"saveFailed\": \"保存に失敗しました: {{error}}\",\n    \"hideAttribution\": \"AI署名を非表示\",\n    \"enableTeammates\": \"Teammates モード\",\n    \"enableToolSearch\": \"Tool Search を有効化\",\n    \"effortHigh\": \"高強度思考\"\n  },\n  \"header\": {\n    \"viewOnGithub\": \"GitHub で見る\",\n    \"toggleDarkMode\": \"ダークモードに切り替え\",\n    \"toggleLightMode\": \"ライトモードに切り替え\",\n    \"addProvider\": \"プロバイダーを追加\",\n    \"switchToChinese\": \"中国語に切り替え\",\n    \"switchToEnglish\": \"英語に切り替え\",\n    \"enterEditMode\": \"編集モードに入る\",\n    \"exitEditMode\": \"編集モードを終了\"\n  },\n  \"provider\": {\n    \"tabProvider\": \"プロバイダー\",\n    \"tabUniversal\": \"統一プロバイダー\",\n    \"noProviders\": \"まだプロバイダーがありません\",\n    \"noProvidersDescription\": \"既存の設定がある場合は「現在の設定をインポート」をクリックしてください。すべてのデータが default プロバイダーに安全に保存されます\",\n    \"noProvidersDescriptionSnippet\": \"API キーとリクエスト URL 以外のデータ（プラグインなど）は共通設定スニペットに保存され、プロバイダー間で共有できます\",\n    \"importCurrent\": \"現在の設定をインポート\",\n    \"importCurrentDescription\": \"現在使用中の設定をデフォルトプロバイダーとしてインポート\",\n    \"currentlyUsing\": \"現在使用中\",\n    \"enable\": \"有効化\",\n    \"inUse\": \"使用中\",\n    \"editProvider\": \"プロバイダーを編集\",\n    \"editProviderHint\": \"保存すると現在のプロバイダーにすぐ反映されます。\",\n    \"deleteProvider\": \"プロバイダーを削除\",\n    \"addNewProvider\": \"新しいプロバイダーを追加\",\n    \"addClaudeProvider\": \"Claude Code プロバイダーを追加\",\n    \"addCodexProvider\": \"Codex プロバイダーを追加\",\n    \"addGeminiProvider\": \"Gemini プロバイダーを追加\",\n    \"addOpenCodeProvider\": \"OpenCode プロバイダーを追加\",\n    \"addToConfig\": \"追加\",\n    \"removeFromConfig\": \"削除\",\n    \"setAsDefault\": \"デフォルトに設定\",\n    \"isDefault\": \"現在のデフォルト\",\n    \"inConfig\": \"追加済み\",\n    \"addProviderHint\": \"一覧にすばやく切り替えられるよう、ここに情報を入力してください。\",\n    \"editClaudeProvider\": \"Claude Code プロバイダーを編集\",\n    \"editCodexProvider\": \"Codex プロバイダーを編集\",\n    \"configError\": \"設定エラー\",\n    \"notConfigured\": \"公式サイト用に未設定\",\n    \"applyToClaudePlugin\": \"Claude プラグインに適用\",\n    \"removeFromClaudePlugin\": \"Claude プラグインから解除\",\n    \"dragToReorder\": \"ドラッグで並べ替え\",\n    \"dragHandle\": \"ドラッグで並べ替え\",\n    \"searchPlaceholder\": \"名前・メモ・URLで検索...\",\n    \"searchAriaLabel\": \"プロバイダーを検索\",\n    \"searchScopeHint\": \"名前・メモ・URL を対象に検索します。\",\n    \"searchCloseHint\": \"Esc で閉じる\",\n    \"searchCloseAriaLabel\": \"検索を閉じる\",\n    \"noSearchResults\": \"一致するプロバイダーがありません。\",\n    \"duplicate\": \"複製\",\n    \"sortUpdateFailed\": \"並び順の更新に失敗しました\",\n    \"configureUsage\": \"利用状況を設定\",\n    \"officialPartner\": \"公式パートナー\",\n    \"openTerminal\": \"ターミナルを開く\",\n    \"terminalOpened\": \"ターミナルを開きました\",\n    \"terminalOpenFailed\": \"ターミナルを開けませんでした\",\n    \"name\": \"プロバイダー名\",\n    \"namePlaceholder\": \"例: Claude Official\",\n    \"websiteUrl\": \"Web サイト URL\",\n    \"notes\": \"メモ\",\n    \"notesPlaceholder\": \"例: 会社用アカウント\",\n    \"configJson\": \"Config JSON\",\n    \"writeCommonConfig\": \"共通設定を書き込む\",\n    \"editCommonConfigButton\": \"共通設定を編集\",\n    \"configJsonHint\": \"Claude Code の設定をすべて入力してください\",\n    \"editCommonConfigTitle\": \"共通設定スニペットを編集\",\n    \"editCommonConfigHint\": \"共通設定スニペットは、この機能をオンにしたすべてのプロバイダーへマージされます\",\n    \"addProvider\": \"プロバイダーを追加\",\n    \"sortUpdated\": \"並び順を更新しました\",\n    \"usageSaved\": \"利用状況の設定を保存しました\",\n    \"usageSaveFailed\": \"利用状況設定の保存に失敗しました\",\n    \"geminiConfig\": \"Gemini 設定\",\n    \"geminiConfigHint\": \".env 形式で Gemini を設定してください\",\n    \"form\": {\n      \"gemini\": {\n        \"model\": \"モデル\",\n        \"oauthTitle\": \"OAuth 認証モード\",\n        \"oauthHint\": \"Google 公式は OAuth 個人認証を使用するため API Key は不要です。初回利用時にブラウザが開きます。\",\n        \"apiKeyPlaceholder\": \"Gemini API Key を入力\"\n      }\n    }\n  },\n  \"notifications\": {\n    \"providerAdded\": \"プロバイダーを追加しました\",\n    \"providerSaved\": \"プロバイダー設定を保存しました\",\n    \"providerDeleted\": \"プロバイダーを削除しました\",\n    \"switchSuccess\": \"切り替え成功！\",\n    \"addToConfigSuccess\": \"設定に追加しました\",\n    \"removeFromConfigSuccess\": \"設定から削除しました\",\n    \"switchFailedTitle\": \"切り替えに失敗しました\",\n    \"switchFailed\": \"切り替えに失敗しました: {{error}}\",\n    \"autoImported\": \"既存設定からデフォルトプロバイダーを自動作成しました\",\n    \"addFailed\": \"プロバイダーの追加に失敗しました: {{error}}\",\n    \"saveFailed\": \"保存に失敗しました: {{error}}\",\n    \"saveFailedGeneric\": \"保存に失敗しました。もう一度お試しください\",\n    \"appliedToClaudePlugin\": \"Claude プラグインに適用しました\",\n    \"removedFromClaudePlugin\": \"Claude プラグインから削除しました\",\n    \"syncClaudePluginFailed\": \"Claude プラグインとの同期に失敗しました\",\n    \"skipClaudeOnboardingFailed\": \"Claude Code の初回確認スキップに失敗しました\",\n    \"clearClaudeOnboardingSkipFailed\": \"Claude Code の初回確認の復元に失敗しました\",\n    \"updateSuccess\": \"プロバイダーを更新しました\",\n    \"updateFailed\": \"プロバイダーの更新に失敗しました: {{error}}\",\n    \"deleteSuccess\": \"プロバイダーを削除しました\",\n    \"deleteFailed\": \"プロバイダーの削除に失敗しました: {{error}}\",\n    \"settingsSaved\": \"設定を保存しました\",\n    \"settingsSaveFailed\": \"設定の保存に失敗しました: {{error}}\",\n    \"openAIChatFormatHint\": \"このプロバイダーは OpenAI Chat フォーマットを使用しており、プロキシサービスの有効化が必要です\",\n    \"openAIFormatHint\": \"このプロバイダーは OpenAI 互換フォーマットを使用しており、プロキシサービスの有効化が必要です\",\n    \"openLinkFailed\": \"リンクを開けませんでした\",\n    \"openclawModelsRegistered\": \"モデルが /model リストに登録されました\",\n    \"openclawDefaultModelSet\": \"デフォルトモデルに設定しました\",\n    \"openclawDefaultModelSetFailed\": \"デフォルトモデルの設定に失敗しました\",\n    \"openclawNoModels\": \"モデルが設定されていません\",\n    \"backfillWarning\": \"切り替え成功しましたが、前のプロバイダーへの設定保存に失敗しました\"\n  },\n  \"confirm\": {\n    \"deleteProvider\": \"プロバイダーを削除\",\n    \"deleteProviderMessage\": \"プロバイダー「{{name}}」を削除してもよろしいですか？この操作は元に戻せません。\",\n    \"removeProvider\": \"プロバイダーを解除\",\n    \"removeProviderMessage\": \"プロバイダー「{{name}}」を設定から解除してもよろしいですか？\\n\\n解除後、このプロバイダーは無効になりますが、設定データは CC Switch に保持されます。いつでも再追加できます。\",\n    \"proxy\": {\n      \"title\": \"ローカルプロキシの有効化\",\n      \"message\": \"ローカルプロキシは上級機能です。有効にする前に、その仕組みを理解していることをご確認ください。\\n\\n適切な設定方法については、関連ドキュメントまたはプロバイダーにご相談ください。\",\n      \"confirm\": \"理解しました、有効にする\"\n    },\n    \"failover\": {\n      \"title\": \"フェイルオーバーの有効化\",\n      \"message\": \"フェイルオーバーは上級機能です。有効にする前に、その仕組みを理解していることをご確認ください。\\n\\nフェイルオーバーキューでプロバイダーの優先順位を先に設定することをお勧めします。\",\n      \"confirm\": \"理解しました、有効にする\"\n    },\n    \"usage\": {\n      \"title\": \"使用量クエリの設定\",\n      \"message\": \"使用量クエリにはカスタムスクリプトまたは API パラメータが必要です。プロバイダーから必要な情報を取得していることをご確認ください。\\n\\n設定方法が不明な場合は、プロバイダーのドキュメントを先にご確認ください。\",\n      \"confirm\": \"理解しました、設定する\"\n    },\n    \"streamCheck\": {\n      \"title\": \"モデルヘルスチェック\",\n      \"message\": \"ヘルスチェックは API リクエストを直接送信してプロバイダーの接続性をテストします。以下の場合、チェックが失敗する可能性があります：\\n\\n• 公式プロバイダー（OAuth ログイン使用、独立した API キーなし）\\n• 一部の中継サービス（リクエストが Claude Code CLI からのものか検証）\\n• AWS Bedrock（IAM 署名認証を使用）\\n\\nチェック失敗はプロバイダーが使用不能であることを意味しません。独立したリクエストでの検証ができないことを示すだけです。アプリ内の実際の動作を基準にしてください。\",\n      \"confirm\": \"理解しました、続行する\"\n    },\n    \"autoSync\": {\n      \"title\": \"自動同期を有効にする\",\n      \"message\": \"自動同期を有効にすると、データベースの変更ごとに WebDAV サーバーへ自動アップロードされます。\\n\\nネットワークトラフィックが大幅に増加する可能性があります。ネットワーク環境と WebDAV サービスが頻繁なデータ転送に対応できることをご確認ください。\",\n      \"confirm\": \"理解しました、有効にする\"\n    }\n  },\n  \"settings\": {\n    \"title\": \"設定\",\n    \"general\": \"一般\",\n    \"tabGeneral\": \"一般\",\n    \"tabAdvanced\": \"詳細\",\n    \"tabProxy\": \"プロキシ\",\n    \"advanced\": {\n      \"configDir\": {\n        \"title\": \"設定ディレクトリ\",\n        \"description\": \"Claude、Codex、Gemini の設定保存パスを管理\"\n      },\n      \"proxy\": {\n        \"title\": \"ローカルプロキシ\",\n        \"description\": \"プロキシサービスの切り替え、ステータスとポート情報を表示\",\n        \"enableFeature\": \"メインページにプロキシ切り替えを表示\",\n        \"enableFeatureDescription\": \"有効にすると、メインページ上部にプロキシとフェイルオーバーの切り替えが表示されます\",\n        \"enableFailoverToggle\": \"メインページにフェイルオーバー切り替えを表示\",\n        \"enableFailoverToggleDescription\": \"有効にすると、メインページ上部にフェイルオーバー切り替えが独立して表示されます\",\n        \"running\": \"実行中\",\n        \"stopped\": \"停止中\"\n      },\n      \"modelTest\": {\n        \"title\": \"モデルテスト設定\",\n        \"description\": \"モデルテストで使用するデフォルトモデルとプロンプトを設定\"\n      },\n      \"failover\": {\n        \"title\": \"自動フェイルオーバー\",\n        \"description\": \"フェイルオーバーキューとサーキットブレーカー戦略を設定\"\n      },\n      \"pricing\": {\n        \"title\": \"コスト計算\",\n        \"description\": \"各モデルのトークン料金ルールを管理\"\n      },\n      \"globalProxy\": {\n        \"title\": \"グローバル送信プロキシ\",\n        \"description\": \"CC Switch が外部 API にアクセスする際のプロキシを設定\"\n      },\n      \"data\": {\n        \"title\": \"データ管理\",\n        \"description\": \"ローカル設定データのインポートとエクスポート\"\n      },\n      \"backup\": {\n        \"title\": \"バックアップと復元\",\n        \"description\": \"自動バックアップの管理、データベーススナップショットの表示と復元\"\n      },\n      \"cloudSync\": {\n        \"title\": \"クラウド同期\",\n        \"description\": \"WebDAV でデバイス間のデータを同期\"\n      },\n      \"rectifier\": {\n        \"title\": \"整流器\",\n        \"description\": \"API リクエストの互換性問題を自動修正\",\n        \"enabled\": \"整流器を有効化\",\n        \"enabledDescription\": \"マスタースイッチ、オフにするとすべての整流機能が無効になります\",\n        \"requestGroup\": \"リクエスト整流\",\n        \"responseGroup\": \"レスポンス整流\",\n        \"thinkingSignature\": \"Thinking 署名整流\",\n        \"thinkingSignatureDescription\": \"Anthropic タイプのプロバイダーが thinking 署名の非互換性や不正なリクエストエラーを返した場合、互換性のない thinking 関連ブロックを自動削除し、同じプロバイダーで 1 回リトライします\",\n        \"thinkingBudget\": \"Thinking Budget 整流\",\n        \"thinkingBudgetDescription\": \"Anthropic タイプのプロバイダーが budget_tokens 制約エラー（例: 1024 以上）を返した場合、thinking を enabled に正規化し、thinking 予算を 32000 に設定し、必要に応じて max_tokens を 64000 に引き上げて 1 回リトライします\"\n      },\n      \"optimizer\": {\n        \"title\": \"Bedrock リクエストオプティマイザー\",\n        \"description\": \"リクエスト送信前に Thinking と Cache の設定を自動最適化（Bedrock プロバイダーのみ有効）\",\n        \"enabled\": \"オプティマイザーを有効化\",\n        \"thinkingOptimizer\": \"Thinking 最適化\",\n        \"thinkingOptimizerDescription\": \"Opus/Sonnet に Adaptive Thinking を自動的に有効化し、レガシーモデルに Extended Thinking を注入\",\n        \"cacheInjection\": \"Cache 注入\",\n        \"cacheInjectionDescription\": \"リクエストの重要な位置に Cache ブレークポイントを自動注入し、重複トークンの課金を削減\",\n        \"cacheTtl\": \"Cache TTL\",\n        \"cacheTtl5m\": \"5 分\",\n        \"cacheTtl1h\": \"1 時間\"\n      },\n      \"logConfig\": {\n        \"title\": \"ログ管理\",\n        \"description\": \"ログ出力レベルを制御\",\n        \"enabled\": \"ログを有効化\",\n        \"enabledDescription\": \"マスタースイッチ、オフにするとすべてのログが無効になります\",\n        \"level\": \"ログレベル\",\n        \"levelDescription\": \"出力する最小ログレベルを設定\",\n        \"levels\": {\n          \"error\": \"エラー\",\n          \"warn\": \"警告\",\n          \"info\": \"情報\",\n          \"debug\": \"デバッグ\",\n          \"trace\": \"トレース\"\n        },\n        \"levelHint\": \"ログレベルの説明：\",\n        \"levelDesc\": {\n          \"error\": \"重大なエラーのみ\",\n          \"warn\": \"エラー + 警告\",\n          \"info\": \"一般的な操作情報（デフォルト）\",\n          \"debug\": \"SSE ストリームとリクエスト/レスポンスを含む詳細情報\",\n          \"trace\": \"すべてのログ、最も詳細\"\n        }\n      }\n    },\n    \"language\": \"言語\",\n    \"languageHint\": \"切り替えるとすぐにプレビューされ、保存後に永続化されます。\",\n    \"theme\": \"テーマ\",\n    \"themeHint\": \"アプリのテーマを選択します。すぐに反映されます。\",\n    \"themeLight\": \"ライト\",\n    \"themeDark\": \"ダーク\",\n    \"themeSystem\": \"システム\",\n    \"importExport\": \"SQL インポート/エクスポート\",\n    \"importExportHint\": \"移行や復元用にデータベースの SQL バックアップをインポート/エクスポートします（インポートは CC Switch がエクスポートしたバックアップのみ対応）。\",\n    \"exportConfig\": \"SQL バックアップをエクスポート\",\n    \"selectConfigFile\": \"SQL ファイルを選択\",\n    \"noFileSelected\": \"ファイルが選択されていません。\",\n    \"import\": \"インポート\",\n    \"importing\": \"インポート中...\",\n    \"importSuccess\": \"インポート成功！\",\n    \"importFailed\": \"インポート失敗\",\n    \"syncLiveFailed\": \"インポートしましたが、現在のプロバイダーへの同期に失敗しました。手動で再選択してください。\",\n    \"importPartialSuccess\": \"設定はインポートされましたが、現在のプロバイダーへの同期に失敗しました。\",\n    \"importPartialHint\": \"ライブ設定を更新するため、もう一度プロバイダーを選択してください。\",\n    \"configExported\": \"設定をエクスポートしました:\",\n    \"exportFailed\": \"エクスポートに失敗しました\",\n    \"selectFileFailed\": \"有効な SQL バックアップファイルを選択してください\",\n    \"configCorrupted\": \"SQL ファイルが壊れているか形式が無効な可能性があります\",\n    \"backupId\": \"バックアップ ID\",\n    \"backupManager\": {\n      \"title\": \"データベースバックアップ\",\n      \"description\": \"以前の状態に復元するための自動データベーススナップショット\",\n      \"empty\": \"バックアップはまだありません\",\n      \"restore\": \"復元\",\n      \"restoring\": \"復元中...\",\n      \"confirmTitle\": \"バックアップの復元を確認\",\n      \"confirmMessage\": \"このバックアップを復元すると現在のデータベースが上書きされます。安全バックアップが先に作成されます。\",\n      \"restoreSuccess\": \"復元成功！安全バックアップが作成されました\",\n      \"restoreFailed\": \"復元に失敗しました\",\n      \"safetyBackupId\": \"安全バックアップID\",\n      \"intervalLabel\": \"自動バックアップ間隔\",\n      \"retainLabel\": \"バックアップ保持数\",\n      \"intervalDisabled\": \"無効\",\n      \"intervalHours\": \"{{hours}} 時間\",\n      \"intervalDays\": \"{{days}} 日\",\n      \"rename\": \"名前変更\",\n      \"renameSuccess\": \"バックアップの名前を変更しました\",\n      \"renameFailed\": \"名前変更に失敗しました\",\n      \"namePlaceholder\": \"新しい名前を入力\",\n      \"createBackup\": \"今すぐバックアップ\",\n      \"creating\": \"バックアップ中...\",\n      \"createSuccess\": \"バックアップが作成されました\",\n      \"createFailed\": \"バックアップに失敗しました\",\n      \"delete\": \"削除\",\n      \"deleting\": \"削除中...\",\n      \"deleteSuccess\": \"バックアップを削除しました\",\n      \"deleteFailed\": \"削除に失敗しました\",\n      \"deleteConfirmTitle\": \"バックアップの削除を確認\",\n      \"deleteConfirmMessage\": \"このバックアップは完全に削除されます。この操作は取り消せません。\"\n    },\n    \"webdavSync\": {\n      \"title\": \"WebDAV クラウド同期\",\n      \"description\": \"WebDAV を使ってデバイス間でデータベースとスキル設定を同期します。\",\n      \"baseUrl\": \"WebDAV サーバー URL\",\n      \"baseUrlPlaceholder\": \"https://example.com/remote.php/dav/files/user\",\n      \"username\": \"ユーザー名\",\n      \"usernamePlaceholder\": \"メールアドレスまたはユーザー名\",\n      \"password\": \"パスワード\",\n      \"passwordPlaceholder\": \"アプリパスワード\",\n      \"remoteRoot\": \"リモートルートディレクトリ\",\n      \"profile\": \"同期プロファイル名\",\n      \"autoSync\": \"自動同期\",\n      \"autoSyncHint\": \"有効にすると、データベース変更のたびに WebDAV へ自動アップロードします。\",\n      \"test\": \"接続テスト\",\n      \"testing\": \"テスト中...\",\n      \"testSuccess\": \"接続成功\",\n      \"testFailed\": \"接続失敗：{{error}}\",\n      \"save\": \"設定を保存\",\n      \"saving\": \"保存中...\",\n      \"saveFailed\": \"設定の保存に失敗しました：{{error}}\",\n      \"upload\": \"クラウドにアップロード\",\n      \"uploading\": \"アップロード中...\",\n      \"uploadSuccess\": \"WebDAV にアップロードしました\",\n      \"uploadFailed\": \"アップロードに失敗しました：{{error}}\",\n      \"autoSyncFailedToast\": \"自動同期に失敗しました：{{error}}\",\n      \"download\": \"クラウドからダウンロード\",\n      \"downloading\": \"ダウンロード中...\",\n      \"downloadSuccess\": \"WebDAV からダウンロード・復元しました\",\n      \"downloadFailed\": \"ダウンロードに失敗しました：{{error}}\",\n      \"lastSync\": \"前回の同期：{{time}}\",\n      \"autoSyncLastErrorTitle\": \"前回の自動同期に失敗しました\",\n      \"autoSyncLastErrorHint\": \"ネットワークまたは WebDAV 設定を確認してください。次回の変更時に自動再試行されます。\",\n      \"missingUrl\": \"WebDAV サーバー URL を入力してください\",\n      \"presets\": {\n        \"label\": \"サービス\",\n        \"jianguoyun\": \"坚果云\",\n        \"jianguoyunHint\": \"坚果云の「セキュリティ設定」で「サードパーティアプリパスワード」を生成してください。ログインパスワードは使用しないでください。\",\n        \"nextcloud\": \"Nextcloud\",\n        \"nextcloudHint\": \"your-server を Nextcloud サーバーのアドレスに、USERNAME をユーザー名に置き換えてください。\",\n        \"synology\": \"Synology NAS\",\n        \"synologyHint\": \"Synology の「パッケージセンター」で WebDAV Server パッケージをインストール・有効化してください。\",\n        \"custom\": \"カスタム\"\n      },\n      \"remoteRootDefault\": \"デフォルト: cc-switch-sync\",\n      \"profileDefault\": \"デフォルト: default\",\n      \"saveAndTestSuccess\": \"設定を保存しました。接続正常です\",\n      \"saveAndTestFailed\": \"設定を保存しましたが、接続テストに失敗しました：{{error}}\",\n      \"noRemoteData\": \"クラウドに同期データが見つかりません\",\n      \"incompatibleVersion\": \"リモートデータに互換性がありません（プロトコル v{{protocolVersion}}、データベース {{dbCompatVersion}}）。このクライアントは protocol v2 / db-v6 をサポートしています。\",\n      \"unsaved\": \"未保存\",\n      \"saved\": \"保存済み\",\n      \"unsavedChanges\": \"先に設定を保存してください\",\n      \"saveBeforeSync\": \"アップロード/ダウンロードを有効にするには、先に設定を保存してください。\",\n      \"fetchingRemote\": \"リモート情報を取得中...\",\n      \"fetchRemoteFailed\": \"リモート情報の取得に失敗しました。設定とネットワークを確認してください。\",\n      \"confirmDownload\": {\n        \"title\": \"クラウドから復元\",\n        \"deviceName\": \"アップロード元\",\n        \"createdAt\": \"アップロード日時\",\n        \"path\": \"リモートパス\",\n        \"dbCompat\": \"DB 互換レイヤー\",\n        \"artifacts\": \"内容\",\n        \"legacyNotice\": \"旧レイアウトのリモートパスを検出しました。復元後、次回のアップロードは新しい v2/db-v6 パスに書き込まれます。\",\n        \"warning\": \"ローカルのすべてのデータとスキル設定が上書きされます\",\n        \"confirm\": \"復元を実行\"\n      },\n      \"confirmUpload\": {\n        \"title\": \"クラウドにアップロード\",\n        \"content\": \"以下の内容を WebDAV サーバーに同期します：\",\n        \"dbItem\": \"データベース（すべてのプロバイダー設定とデータ）\",\n        \"skillsItem\": \"スキル（すべてのカスタムスキル）\",\n        \"targetPath\": \"保存先パス\",\n        \"existingData\": \"クラウドの既存データ\",\n        \"deviceName\": \"アップロード元\",\n        \"createdAt\": \"アップロード日時\",\n        \"path\": \"リモートパス\",\n        \"dbCompat\": \"DB 互換レイヤー\",\n        \"warning\": \"リモートの既存同期データが上書きされます\",\n        \"legacyNotice\": \"旧レイアウトのリモートデータを検出しました。今回のアップロードは新しい v2/db-v6 パスに書き込み、旧パスは上書きしません。\",\n        \"confirm\": \"アップロードを実行\"\n      }\n    },\n    \"autoReload\": \"データを更新しました\",\n    \"languageOptionChinese\": \"中文\",\n    \"languageOptionEnglish\": \"English\",\n    \"languageOptionJapanese\": \"日本語\",\n    \"windowBehavior\": \"ウィンドウ動作\",\n    \"windowBehaviorHint\": \"最小化動作や Claude プラグイン連携を設定します。\",\n    \"launchOnStartup\": \"起動時に自動実行\",\n    \"launchOnStartupDescription\": \"システム起動時に CC Switch を自動起動します\",\n    \"silentStartup\": \"サイレント起動\",\n    \"silentStartupDescription\": \"起動時にメインウィンドウを表示せず、トレイのみで起動\",\n    \"autoLaunchFailed\": \"自動起動の設定に失敗しました\",\n    \"minimizeToTray\": \"閉じるときトレイへ最小化\",\n    \"minimizeToTrayDescription\": \"チェックすると閉じるボタンでトレイに隠し、オフならアプリを終了します。\",\n    \"enableClaudePluginIntegration\": \"Claude Code 拡張に適用\",\n    \"enableClaudePluginIntegrationDescription\": \"オンにすると VS Code の Claude Code 拡張のプロバイダーも同期します\",\n    \"skipClaudeOnboarding\": \"Claude Code の初回確認をスキップ\",\n    \"skipClaudeOnboardingDescription\": \"オンにすると Claude Code の初回インストール確認をスキップします\",\n    \"appVisibility\": {\n      \"title\": \"ホームページ表示\",\n      \"description\": \"ホームページに表示するアプリを選択\",\n      \"claudeDesc\": \"Anthropic Claude Code CLI\",\n      \"codexDesc\": \"OpenAI Codex CLI\",\n      \"geminiDesc\": \"Google Gemini CLI\",\n      \"opencodeDesc\": \"OpenCode CLI\"\n    },\n    \"skillSync\": {\n      \"title\": \"スキル同期方式\",\n      \"description\": \"スキルファイルの同期方法を選択\",\n      \"symlink\": \"シンボリックリンク\",\n      \"copy\": \"ファイルコピー\",\n      \"symlinkHint\": \"シンボリックリンクはディスク容量を節約し、リアルタイム同期を有効にします。注意：Windowsでは管理者権限または開発者モードが必要な場合があります\"\n    },\n    \"terminal\": {\n      \"title\": \"優先ターミナル\",\n      \"description\": \"ターミナルボタンをクリックした時に使用するターミナルアプリを選択\",\n      \"fallbackHint\": \"選択したターミナルが利用できない場合、システムのデフォルトが使用されます\",\n      \"options\": {\n        \"macos\": {\n          \"terminal\": \"Terminal.app\",\n          \"iterm2\": \"iTerm2\",\n          \"alacritty\": \"Alacritty\",\n          \"kitty\": \"Kitty\",\n          \"ghostty\": \"Ghostty\",\n          \"wezterm\": \"WezTerm\"\n        },\n        \"windows\": {\n          \"cmd\": \"コマンドプロンプト\",\n          \"powershell\": \"PowerShell\",\n          \"wt\": \"Windows Terminal\"\n        },\n        \"linux\": {\n          \"gnomeTerminal\": \"GNOME Terminal\",\n          \"konsole\": \"Konsole\",\n          \"xfce4Terminal\": \"Xfce4 Terminal\",\n          \"alacritty\": \"Alacritty\",\n          \"kitty\": \"Kitty\",\n          \"ghostty\": \"Ghostty\"\n        }\n      }\n    },\n    \"configDirectoryOverride\": \"設定ディレクトリの上書き（詳細）\",\n    \"configDirectoryDescription\": \"WSL などで Claude Code や Codex を使う場合、ここで設定ディレクトリを WSL 側に合わせるとデータを揃えられます。\",\n    \"appConfigDir\": \"CC Switch 設定ディレクトリ\",\n    \"appConfigDirDescription\": \"CC Switch の保存場所をカスタマイズします（クラウド同期フォルダを指定すると設定を同期できます）\",\n    \"browsePlaceholderApp\": \"例: C:\\\\\\\\Users\\\\\\\\Administrator\\\\\\\\.cc-switch\",\n    \"claudeConfigDir\": \"Claude Code 設定ディレクトリ\",\n    \"claudeConfigDirDescription\": \"Claude の設定ディレクトリ（settings.json）を上書きし、claude.json（MCP）も同じ場所に置きます。\",\n    \"codexConfigDir\": \"Codex 設定ディレクトリ\",\n    \"codexConfigDirDescription\": \"Codex の設定ディレクトリを上書きします。\",\n    \"geminiConfigDir\": \"Gemini 設定ディレクトリ\",\n    \"geminiConfigDirDescription\": \"Gemini の設定ディレクトリ（.env）を上書きします。\",\n    \"opencodeConfigDir\": \"OpenCode 設定ディレクトリ\",\n    \"opencodeConfigDirDescription\": \"OpenCode の設定ディレクトリ（opencode.json）を上書きします。\",\n    \"browsePlaceholderClaude\": \"例: /home/<your-username>/.claude\",\n    \"browsePlaceholderCodex\": \"例: /home/<your-username>/.codex\",\n    \"browsePlaceholderGemini\": \"例: /home/<your-username>/.gemini\",\n    \"browsePlaceholderOpencode\": \"例: /home/<your-username>/.config/opencode\",\n    \"browseDirectory\": \"ディレクトリを選択\",\n    \"resetDefault\": \"デフォルトに戻す（保存後に反映）\",\n    \"checkForUpdates\": \"アップデートを確認\",\n    \"updateTo\": \"v{{version}} に更新\",\n    \"updating\": \"更新中...\",\n    \"checking\": \"確認中...\",\n    \"upToDate\": \"最新バージョンです\",\n    \"aboutHint\": \"バージョン情報と更新状況を表示します。\",\n    \"portableMode\": \"ポータブルモード: 更新は手動ダウンロードが必要です。\",\n    \"updateAvailable\": \"新しいバージョンがあります: {{version}}\",\n    \"updateBadge\": \"更新あり\",\n    \"updateFailed\": \"更新のインストールに失敗しました。ダウンロードページを開こうとしました。\",\n    \"checkUpdateFailed\": \"更新の確認に失敗しました。時間をおいて再試行してください。\",\n    \"openReleaseNotesFailed\": \"リリースノートの表示に失敗しました\",\n    \"releaseNotes\": \"リリースノート\",\n    \"viewReleaseNotes\": \"このバージョンのリリースノートを見る\",\n    \"viewCurrentReleaseNotes\": \"現在のバージョンのリリースノートを見る\",\n    \"oneClickInstall\": \"ワンクリックインストール\",\n    \"oneClickInstallHint\": \"Claude Code / Codex / Gemini CLI / OpenCode をインストール\",\n    \"localEnvCheck\": \"ローカル環境チェック\",\n    \"envBadge\": {\n      \"wsl\": \"WSL\",\n      \"windows\": \"Win\",\n      \"macos\": \"macOS\",\n      \"linux\": \"Linux\"\n    },\n    \"wslShell\": \"Shell\",\n    \"wslShellFlag\": \"フラグ\",\n    \"installCommandsCopied\": \"インストールコマンドをコピーしました\",\n    \"installCommandsCopyFailed\": \"コピーに失敗しました。手動でコピーしてください。\",\n    \"importFailedError\": \"設定のインポートに失敗しました: {{message}}\",\n    \"exportFailedError\": \"設定のエクスポートに失敗しました:\",\n    \"restartRequired\": \"再起動が必要です\",\n    \"restartRequiredMessage\": \"CC Switch の設定ディレクトリを変更すると再起動が必要です。今すぐ再起動しますか？\",\n    \"restartNow\": \"今すぐ再起動\",\n    \"restartLater\": \"後で再起動\",\n    \"restartFailed\": \"アプリの再起動に失敗しました。手動で閉じて再度開いてください。\",\n    \"devModeRestartHint\": \"開発モードでは自動再起動をサポートしていません。手動で再起動してください。\",\n    \"saving\": \"保存中...\",\n    \"globalProxy\": {\n      \"label\": \"グローバルプロキシ\",\n      \"hint\": \"すべてのリクエスト（API、Skills ダウンロードなど）をプロキシ経由で送信します。空欄で直接接続。\",\n      \"username\": \"ユーザー名（任意）\",\n      \"password\": \"パスワード（任意）\",\n      \"test\": \"接続テスト\",\n      \"scan\": \"ローカルプロキシをスキャン\",\n      \"clear\": \"クリア\",\n      \"scanFailed\": \"スキャンに失敗しました: {{error}}\",\n      \"saved\": \"プロキシ設定を保存しました\",\n      \"saveFailed\": \"保存に失敗しました: {{error}}\",\n      \"testSuccess\": \"接続成功！遅延 {{latency}}ms\",\n      \"testFailed\": \"接続に失敗しました: {{error}}\",\n      \"pricingDefaultsTitle\": \"課金のデフォルト設定\",\n      \"pricingDefaultsDescription\": \"アプリごとのデフォルト倍率と課金モードを設定します。\",\n      \"pricingAppLabel\": \"アプリ\",\n      \"defaultCostMultiplierLabel\": \"デフォルト倍率\",\n      \"defaultCostMultiplierHint\": \"コスト計算用の倍率（小数対応）。\",\n      \"pricingModelSourceLabel\": \"課金モード\",\n      \"pricingModelSourceRequest\": \"リクエストモデル\",\n      \"pricingModelSourceResponse\": \"レスポンスモデル\",\n      \"pricingSave\": \"課金設定を保存\",\n      \"pricingSaved\": \"課金設定を保存しました\",\n      \"pricingSaveFailed\": \"課金設定の保存に失敗しました: {{error}}\",\n      \"pricingLoadFailed\": \"課金設定の読み込みに失敗しました: {{error}}\",\n      \"defaultCostMultiplierRequired\": \"デフォルト倍率は必須です\",\n      \"defaultCostMultiplierInvalid\": \"デフォルト倍率の形式が正しくありません\"\n    },\n    \"saveFailedGeneric\": \"保存に失敗しました。もう一度お試しください\"\n  },\n  \"apps\": {\n    \"claude\": \"Claude\",\n    \"codex\": \"Codex\",\n    \"gemini\": \"Gemini\",\n    \"opencode\": \"OpenCode\",\n    \"openclaw\": \"OpenClaw\"\n  },\n  \"sessionManager\": {\n    \"title\": \"セッション管理\",\n    \"subtitle\": \"Claude Code / Codex / OpenCode / OpenClaw / Gemini CLI のセッションを管理\",\n    \"searchPlaceholder\": \"内容・ディレクトリ・ID で検索\",\n    \"searchSessions\": \"セッションを検索\",\n    \"providerFilterAll\": \"すべて\",\n    \"sessionList\": \"セッション一覧\",\n    \"loadingSessions\": \"セッションを読み込み中...\",\n    \"noSessions\": \"セッションが見つかりません\",\n    \"selectSession\": \"セッションを選択してください\",\n    \"noSummary\": \"概要なし\",\n    \"lastActive\": \"最終アクティブ\",\n    \"projectDir\": \"プロジェクトディレクトリ\",\n    \"sourcePath\": \"元ファイル\",\n    \"copyResumeCommand\": \"再開コマンドをコピー\",\n    \"resumeCommandCopied\": \"再開コマンドをコピーしました\",\n    \"openInTerminal\": \"ターミナルで再開\",\n    \"terminalTargetTerminal\": \"Terminal\",\n    \"terminalTargetKitty\": \"kitty\",\n    \"terminalTargetCopy\": \"コピーのみ\",\n    \"terminalLaunched\": \"ターミナルを起動しました\",\n    \"openFailed\": \"ターミナルの起動に失敗しました\",\n    \"resumeFallbackCopied\": \"再開コマンドをコピーしました（手動で実行してください）\",\n    \"copyProjectDir\": \"ディレクトリをコピー\",\n    \"projectDirCopied\": \"ディレクトリをコピーしました\",\n    \"copySourcePath\": \"元ファイルをコピー\",\n    \"sourcePathCopied\": \"元ファイルをコピーしました\",\n    \"delete\": \"セッションを削除\",\n    \"deleting\": \"削除中...\",\n    \"deleteTooltip\": \"このローカルセッション記録を完全に削除します\",\n    \"deleteConfirmTitle\": \"セッションを削除\",\n    \"deleteConfirmMessage\": \"ローカルセッション「{{title}}」を完全に削除します\\nSession ID: {{sessionId}}\\n\\nこの操作は元に戻せません。\",\n    \"deleteConfirmAction\": \"セッションを削除\",\n    \"sessionDeleted\": \"セッションを削除しました\",\n    \"deleteFailed\": \"セッションの削除に失敗しました: {{error}}\",\n    \"loadingMessages\": \"内容を読み込み中...\",\n    \"emptySession\": \"表示できる内容がありません\",\n    \"clickToCopyPath\": \"クリックしてパスをコピー\",\n    \"tocTitle\": \"目次\",\n    \"justNow\": \"たった今\",\n    \"minutesAgo\": \"{{count}}分前\",\n    \"hoursAgo\": \"{{count}}時間前\",\n    \"daysAgo\": \"{{count}}日前\",\n    \"roleUser\": \"ユーザー\",\n    \"roleSystem\": \"システム\",\n    \"roleTool\": \"ツール\",\n    \"resume\": \"セッションを再開\",\n    \"resumeTooltip\": \"ターミナルでこのセッションを再開\",\n    \"noResumeCommand\": \"このセッションは再開できません\",\n    \"copyCommand\": \"コマンドをコピー\",\n    \"copyMessage\": \"メッセージをコピー\",\n    \"messageCopied\": \"メッセージがコピーされました\",\n    \"conversationHistory\": \"会話履歴\"\n  },\n  \"console\": {\n    \"providerSwitchReceived\": \"プロバイダー切り替えイベントを受信:\",\n    \"setupListenerFailed\": \"プロバイダー切り替えリスナーの設定に失敗:\",\n    \"updateProviderFailed\": \"プロバイダー更新に失敗:\",\n    \"autoImportFailed\": \"デフォルト設定の自動インポートに失敗:\",\n    \"openLinkFailed\": \"リンクを開けませんでした:\",\n    \"getVersionFailed\": \"バージョン情報の取得に失敗:\",\n    \"loadSettingsFailed\": \"設定の読み込みに失敗:\",\n    \"getConfigPathFailed\": \"設定パスの取得に失敗:\",\n    \"getConfigDirFailed\": \"設定ディレクトリの取得に失敗:\",\n    \"detectPortableFailed\": \"ポータブルモードの検出に失敗:\",\n    \"saveSettingsFailed\": \"設定の保存に失敗:\",\n    \"updateFailed\": \"更新に失敗:\",\n    \"checkUpdateFailed\": \"更新確認に失敗:\",\n    \"openConfigFolderFailed\": \"設定フォルダを開けませんでした:\",\n    \"selectConfigDirFailed\": \"設定ディレクトリの選択に失敗:\",\n    \"getDefaultConfigDirFailed\": \"デフォルト設定ディレクトリの取得に失敗:\",\n    \"openReleaseNotesFailed\": \"リリースノートを開けませんでした:\"\n  },\n  \"providerForm\": {\n    \"supplierName\": \"プロバイダー名\",\n    \"supplierNameRequired\": \"プロバイダー名 *\",\n    \"supplierNamePlaceholder\": \"例: Anthropic Official\",\n    \"websiteUrl\": \"Web サイト URL\",\n    \"websiteUrlPlaceholder\": \"https://example.com（任意）\",\n    \"apiEndpoint\": \"API エンドポイント\",\n    \"apiEndpointPlaceholder\": \"https://your-api-endpoint.com\",\n    \"codexApiEndpointPlaceholder\": \"https://your-api-endpoint.com/v1\",\n    \"manageAndTest\": \"管理・テスト\",\n    \"configContent\": \"設定内容\",\n    \"officialNoApiKey\": \"公式ログインは API Key 不要です。そのまま保存できます\",\n    \"codexOfficialNoApiKey\": \"公式は API Key 不要です。そのまま保存してください\",\n    \"codexApiKeyAutoFill\": \"ここに入力すれば auth.json も自動で埋まります\",\n    \"apiKeyAutoFill\": \"ここに入力すれば下の設定も自動で埋まります\",\n    \"cnOfficialApiKeyHint\": \"💡 API Key のみ入力すれば OK。エンドポイントはプリセット済みです\",\n    \"aggregatorApiKeyHint\": \"💡 API Key のみ入力すれば OK。エンドポイントはプリセット済みです\",\n    \"thirdPartyApiKeyHint\": \"💡 API Key のみ入力すれば OK。エンドポイントはプリセット済みです\",\n    \"customApiKeyHint\": \"💡 カスタム設定では必要な項目をすべて手動で入力してください\",\n    \"omoHint\": \"💡 OMO 設定は Agent のモデル割り当てを管理し、oh-my-opencode.jsonc に書き込みます\",\n    \"officialHint\": \"💡 公式プロバイダーはブラウザログインで、API Key は不要です\",\n    \"getApiKey\": \"API Key を取得\",\n    \"partnerPromotion\": {\n      \"packycode\": \"PackyCode は CC Switch の公式パートナーです。登録後チャージ時に \\\"cc-switch\\\" を入力すると 10% オフ\",\n      \"minimax_cn\": \"MiniMax Coding Plan 特別価格、Starter ¥9.9 から\",\n      \"minimax_en\": \"MiniMax Coding Plan Black Friday、Starter が月額 $2（80% OFF）\",\n      \"dmxapi\": \"Claude Code 専用モデル 66% OFF 実施中！\",\n      \"cubence\": \"Cubence は CC Switch の公式パートナーです。登録後チャージ時に \\\"CCSWITCH\\\" を入力すると、毎回 10% オフ\",\n      \"aigocode\": \"AIGoCode は CC Switch の公式パートナーです。このリンクから登録すると、初回チャージ時に 10% のボーナスクレジットがもらえます！\",\n      \"rightcode\": \"RightCode は CC Switch の公式パートナーです。このリンクから登録すると、毎回のチャージに 5% のボーナスクレジットがもらえます！\",\n      \"aicodemirror\": \"AICodeMirror は CC Switch の公式パートナーです。このリンクから登録すると20%オフ！\",\n      \"aicoding\": \"AI Coding は CC Switch ユーザー向けに特別割引を提供しています。初回チャージが 10% オフ！\",\n      \"crazyrouter\": \"CrazyRouter は CC Switch ユーザー向けに特別ボーナスを提供しています。初回チャージで 30% の追加クレジットがもらえます！\",\n      \"sssaicode\": \"SSAI Code は CC Switch ユーザー向けに特別ボーナスを提供しています。チャージごとに $10 の追加クレジットがもらえます！\",\n      \"siliconflow\": \"SiliconFlow は CC Switch の公式パートナーです\",\n      \"ucloud\": \"Compshare は CC Switch ユーザー向けに特別ボーナスを提供しています。このリンクから登録すると、5元のプラットフォーム体験クレジットがもらえます！\",\n      \"micu\": \"Micu は CC Switch の公式パートナーです\",\n      \"x-code\": \"XCodeAPI は CC Switch ユーザー向けに特別ボーナスを提供しています。このリンクから登録すると、初回注文で 10% の追加クレジットがもらえます（管理者に連絡して受け取り）\",\n      \"ctok\": \"公式サイトで CTok コミュニティに参加し、プランを購読してください。\"\n    },\n    \"presets\": {\n      \"ucloud\": \"Compshare\"\n    },\n    \"parameterConfig\": \"パラメーター設定 - {{name}} *\",\n    \"mainModel\": \"メインモデル（任意）\",\n    \"mainModelPlaceholder\": \"例: GLM-4.6\",\n    \"fastModel\": \"高速モデル（任意）\",\n    \"fastModelPlaceholder\": \"例: GLM-4.5-Air\",\n    \"modelHint\": \"💡 空欄ならプロバイダーのデフォルトモデルを使用します\",\n    \"apiHint\": \"💡 Claude API 互換サービスのエンドポイントを入力してください。末尾にスラッシュを付けないでください\",\n    \"apiHintOAI\": \"💡 OpenAI Chat Completions 互換サービスのエンドポイントを入力してください。末尾にスラッシュを付けないでください\",\n    \"codexApiHint\": \"💡 OpenAI Response 互換のサービスエンドポイントを入力してください\",\n    \"fillSupplierName\": \"プロバイダー名を入力してください\",\n    \"fillConfigContent\": \"設定内容を入力してください\",\n    \"fillParameter\": \"{{label}} を入力してください\",\n    \"fillTemplateValue\": \"{{label}} を入力してください\",\n    \"endpointRequired\": \"公式以外は API エンドポイントが必須です\",\n    \"apiKeyRequired\": \"公式以外は API Key が必須です\",\n    \"configJsonError\": \"Config JSON の形式が正しくありません。構文を確認してください\",\n    \"authJsonRequired\": \"auth.json は JSON オブジェクトで入力してください\",\n    \"authJsonError\": \"auth.json の形式が正しくありません。JSON を確認してください\",\n    \"fillAuthJson\": \"auth.json の設定を入力してください\",\n    \"fillApiKey\": \"OPENAI_API_KEY を入力してください\",\n    \"visitWebsite\": \"{{url}} を開く\",\n    \"anthropicModel\": \"メインモデル\",\n    \"anthropicSmallFastModel\": \"高速モデル\",\n    \"anthropicReasoningModel\": \"推論モデル（Thinking）\",\n    \"apiFormat\": \"API フォーマット\",\n    \"apiFormatHint\": \"プロバイダー API の入力フォーマットを選択\",\n    \"apiFormatAnthropic\": \"Anthropic Messages（ネイティブ）\",\n    \"apiFormatOpenAIChat\": \"OpenAI Chat Completions（プロキシが必要）\",\n    \"apiFormatOpenAIResponses\": \"OpenAI Responses API（プロキシが必要）\",\n    \"authField\": \"認証フィールド\",\n    \"authFieldAuthToken\": \"ANTHROPIC_AUTH_TOKEN（デフォルト）\",\n    \"authFieldApiKey\": \"ANTHROPIC_API_KEY\",\n    \"authFieldHint\": \"設定に書き込む認証環境変数名を選択\",\n    \"apiHintResponses\": \"💡 OpenAI Responses API 互換サービスのエンドポイントを入力してください。末尾にスラッシュを付けないでください\",\n    \"anthropicDefaultHaikuModel\": \"既定 Haiku モデル\",\n    \"anthropicDefaultSonnetModel\": \"既定 Sonnet モデル\",\n    \"anthropicDefaultOpusModel\": \"既定 Opus モデル\",\n    \"modelPlaceholder\": \"\",\n    \"smallModelPlaceholder\": \"\",\n    \"haikuModelPlaceholder\": \"\",\n    \"modelHelper\": \"任意: 既定で使いたい Claude モデルを指定。空欄ならシステム既定を使用します。\",\n    \"modelMappingLabel\": \"モデルマッピング\",\n    \"modelMappingHint\": \"プロバイダーが Claude モデルをネイティブ提供している場合、通常は設定不要です。リクエストを別のモデル名にマッピングする場合のみ設定してください。\",\n    \"advancedOptionsToggle\": \"高級オプション\",\n    \"advancedOptionsHint\": \"API フォーマット、認証フィールド、モデルマッピングの設定を含みます。通常はデフォルトのままで問題ありません。\",\n    \"categoryOfficial\": \"公式\",\n    \"categoryCnOfficial\": \"オープンソース公式\",\n    \"categoryAggregation\": \"アグリゲーター\",\n    \"categoryThirdParty\": \"サードパーティ\"\n  },\n  \"copilot\": {\n    \"authSection\": \"GitHub Copilot 認証\",\n    \"authStatus\": \"認証状態\",\n    \"authenticated\": \"認証済み: {{username}}\",\n    \"notAuthenticated\": \"未認証\",\n    \"loginWithGitHub\": \"GitHub でログイン\",\n    \"loginRequired\": \"先に GitHub Copilot にログインしてください\",\n    \"waitingForAuth\": \"認証を待っています...\",\n    \"enterCode\": \"ブラウザで以下のコードを入力してください：\",\n    \"logout\": \"ログアウト\",\n    \"authSuccess\": \"GitHub Copilot 認証に成功しました\",\n    \"authFailed\": \"認証に失敗しました: {{error}}\",\n    \"authTimeout\": \"認証がタイムアウトしました。もう一度お試しください\",\n    \"tokenExpired\": \"トークンの有効期限が切れました。再認証してください\",\n    \"accountCount\": \"{{count}} アカウント\",\n    \"selectAccount\": \"アカウントを選択\",\n    \"selectAccountPlaceholder\": \"GitHub アカウントを選択\",\n    \"useDefaultAccount\": \"デフォルトアカウントを使用\",\n    \"loggedInAccounts\": \"ログイン済みアカウント\",\n    \"defaultAccount\": \"デフォルト\",\n    \"selected\": \"選択中\",\n    \"removeAccount\": \"アカウントを削除\",\n    \"setAsDefault\": \"デフォルトに設定\",\n    \"addAnotherAccount\": \"別のアカウントを追加\",\n    \"logoutAll\": \"すべてのアカウントをログアウト\",\n    \"retry\": \"再試行\",\n    \"copyCode\": \"コードをコピー\",\n    \"migrationFailed\": \"旧認証データの移行に失敗しました: {{error}}\",\n    \"loadModelsFailed\": \"Copilot モデル一覧の読み込みに失敗しました\"\n  },\n  \"endpointTest\": {\n    \"title\": \"API エンドポイント管理\",\n    \"endpoints\": \"エンドポイント\",\n    \"autoSelect\": \"自動選択\",\n    \"testSpeed\": \"テスト\",\n    \"testing\": \"テスト中\",\n    \"addEndpointPlaceholder\": \"https://api.example.com\",\n    \"done\": \"完了\",\n    \"noEndpoints\": \"エンドポイントがありません\",\n    \"failed\": \"失敗\",\n    \"enterValidUrl\": \"有効な URL を入力してください\",\n    \"invalidUrlFormat\": \"URL 形式が正しくありません\",\n    \"onlyHttps\": \"HTTP/HTTPS のみサポートします\",\n    \"urlExists\": \"この URL はすでに存在します\",\n    \"saveFailed\": \"保存に失敗しました。もう一度お試しください\",\n    \"loadEndpointsFailed\": \"カスタムエンドポイントの読み込みに失敗:\",\n    \"addEndpointFailed\": \"カスタムエンドポイントの追加に失敗:\",\n    \"removeEndpointFailed\": \"カスタムエンドポイントの削除に失敗:\",\n    \"removeFailed\": \"削除に失敗しました: {{error}}\",\n    \"updateLastUsedFailed\": \"エンドポイントの最終使用時間の更新に失敗しました\",\n    \"pleaseAddEndpoint\": \"まずエンドポイントを追加してください\",\n    \"testUnavailable\": \"速度テストを実行できません\",\n    \"noResult\": \"結果がありません\",\n    \"testFailed\": \"速度テストに失敗しました: {{error}}\",\n    \"empty\": \"エンドポイントがありません\"\n  },\n  \"providerAdvanced\": {\n    \"testConfig\": \"モデルテスト設定\",\n    \"useCustomConfig\": \"個別設定を使用\",\n    \"testConfigDesc\": \"このプロバイダーに個別のモデルテストパラメータを設定します。無効の場合はグローバル設定を使用します。\",\n    \"testModel\": \"テストモデル\",\n    \"testModelPlaceholder\": \"空白の場合はグローバル設定を使用\",\n    \"timeoutSecs\": \"タイムアウト（秒）\",\n    \"testPrompt\": \"テストプロンプト\",\n    \"degradedThreshold\": \"低下閾値（ミリ秒）\",\n    \"maxRetries\": \"最大リトライ回数\",\n    \"proxyConfig\": \"プロキシ設定\",\n    \"useCustomProxy\": \"個別プロキシを使用\",\n    \"proxyConfigDesc\": \"このプロバイダーに個別のネットワークプロキシを設定します。無効の場合はシステムプロキシまたはグローバル設定を使用します。\",\n    \"proxyUsername\": \"ユーザー名（任意）\",\n    \"proxyPassword\": \"パスワード（任意）\",\n    \"pricingConfig\": \"課金設定\",\n    \"useCustomPricing\": \"個別設定を使用\",\n    \"pricingConfigDesc\": \"このプロバイダーに個別の課金パラメータを設定します。無効の場合はグローバル設定を使用します。\",\n    \"costMultiplier\": \"コスト倍率\",\n    \"costMultiplierPlaceholder\": \"空白の場合はグローバル設定を使用（1）\",\n    \"costMultiplierHint\": \"実際のコスト = 基本コスト × 倍率、1.5 などの小数をサポート\",\n    \"pricingModelSourceLabel\": \"課金モード\",\n    \"pricingModelSourceInherit\": \"グローバル設定を継承\",\n    \"pricingModelSourceRequest\": \"リクエストモデル\",\n    \"pricingModelSourceResponse\": \"レスポンスモデル\",\n    \"pricingModelSourceHint\": \"リクエストモデルまたはレスポンスモデルで価格を照合するかを選択\"\n  },\n  \"codexConfig\": {\n    \"authJson\": \"auth.json (JSON) *\",\n    \"authJsonPlaceholder\": \"{\\n  \\\"OPENAI_API_KEY\\\": \\\"sk-your-api-key-here\\\"\\n}\",\n    \"authJsonHint\": \"Codex の auth.json 設定内容\",\n    \"configToml\": \"config.toml (TOML)\",\n    \"configTomlHint\": \"Codex の config.toml 設定内容\",\n    \"writeCommonConfig\": \"共通設定を書き込む\",\n    \"editCommonConfig\": \"共通設定を編集\",\n    \"editCommonConfigTitle\": \"Codex 共通設定スニペットを編集\",\n    \"commonConfigHint\": \"「共通設定を書き込む」がオンの場合、config.toml の末尾に追記されます\",\n    \"apiUrlLabel\": \"API リクエスト URL\",\n    \"extractFromCurrent\": \"編集内容から抽出\",\n    \"extractNoCommonConfig\": \"編集内容から抽出できる共通設定がありません\",\n    \"extractFailed\": \"抽出に失敗しました: {{error}}\",\n    \"saveFailed\": \"保存に失敗しました: {{error}}\",\n    \"modelNameHint\": \"使用するモデルを指定します。config.toml に自動更新されます\",\n    \"modelName\": \"モデル名\",\n    \"modelNamePlaceholder\": \"例: gpt-5-codex\",\n    \"contextWindow1M\": \"1M コンテキストウィンドウ\",\n    \"autoCompactLimit\": \"自動圧縮しきい値\",\n    \"autoCompactLimitHint\": \"コンテキストトークン数がこのしきい値に達すると履歴を自動圧縮\"\n  },\n  \"geminiConfig\": {\n    \"envFile\": \"環境変数 (.env)\",\n    \"envFileHint\": \".env 形式で Gemini の環境変数を設定\",\n    \"configJson\": \"設定ファイル (config.json)\",\n    \"configJsonHint\": \"Gemini 拡張パラメーターを JSON 形式で設定（任意）\",\n    \"writeCommonConfig\": \"共通設定を書き込む\",\n    \"editCommonConfig\": \"共通設定を編集\",\n    \"editCommonConfigTitle\": \"Gemini 共通設定スニペットを編集\",\n    \"commonConfigHint\": \"このスニペットは Gemini の .env に書き込みます（GOOGLE_GEMINI_BASE_URL、GEMINI_API_KEY は使用できません）\",\n    \"extractFromCurrent\": \"編集内容から抽出\",\n    \"extractNoCommonConfig\": \"編集内容から抽出できる共通設定がありません\",\n    \"extractFailed\": \"抽出に失敗しました: {{error}}\",\n    \"saveFailed\": \"保存に失敗しました: {{error}}\",\n    \"extractedConfigInvalid\": \"抽出した設定のフォーマットが不正です\",\n    \"invalidJsonFormat\": \"共通設定スニペットの形式が不正です（有効な JSON でなければなりません）\",\n    \"commonConfigInvalidKeys\": \"共通設定スニペットに GOOGLE_GEMINI_BASE_URL または GEMINI_API_KEY を含めることはできません（検出: {{keys}}）\",\n    \"commonConfigInvalidValues\": \"共通設定スニペットの値は文字列である必要があります\",\n    \"noCommonConfigToApply\": \"共通設定スニペットが空、または適用できる項目がありません\",\n    \"configMergeFailed\": \"設定のマージに失敗しました: {{error}}\",\n    \"configReplaceFailed\": \"設定の置換に失敗しました: {{error}}\"\n  },\n  \"opencode\": {\n    \"npmPackage\": \"API フォーマット\",\n    \"selectPackage\": \"API フォーマットを選択\",\n    \"npmPackageHint\": \"AI サービスの API フォーマットを選択\",\n    \"baseUrl\": \"Base URL\",\n    \"baseUrlHint\": \"カスタム API エンドポイント URL\",\n    \"models\": \"モデル設定\",\n    \"modelsHint\": \"利用可能なモデルとその表示名を設定\",\n    \"addModel\": \"モデルを追加\",\n    \"modelId\": \"モデル ID\",\n    \"modelName\": \"表示名\",\n    \"noModels\": \"モデルが設定されていません\",\n    \"modelsRequired\": \"モデルを少なくとも1つ追加してください\",\n    \"providerKey\": \"プロバイダーキー\",\n    \"providerKeyPlaceholder\": \"my-provider\",\n    \"providerKeyHint\": \"設定ファイルの一意の識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用できます。\",\n    \"providerKeyRequired\": \"プロバイダーキーを入力してください\",\n    \"providerKeyDuplicate\": \"このキーは既に使用されています\",\n    \"providerKeyInvalid\": \"無効な形式です。小文字、数字、ハイフンのみ使用できます。\",\n    \"extraOptions\": \"追加オプション\",\n    \"extraOptionsHint\": \"timeout、setCacheKey などの SDK オプションを設定。値は自動的に適切な型（数値、真偽値など）に変換されます。\",\n    \"addExtraOption\": \"追加\",\n    \"extraOptionKey\": \"キー名\",\n    \"extraOptionValue\": \"値\",\n    \"extraOptionKeyPlaceholder\": \"timeout\",\n    \"extraOptionValuePlaceholder\": \"600000\",\n    \"noExtraOptions\": \"追加オプションはありません\",\n    \"noModelOptions\": \"モデルオプション、+ をクリックして追加\",\n    \"modelExtraFields\": \"モデルプロパティ\",\n    \"noModelExtraFields\": \"モデルプロパティ（variants、cost など）、+ をクリックして追加\",\n    \"modelExtraFieldKeyPlaceholder\": \"variants\",\n    \"sdkOptions\": \"SDK オプション\",\n    \"modelOptionKeyPlaceholder\": \"provider\",\n    \"modelOptionValuePlaceholder\": \"{\\\"order\\\": [\\\"baseten\\\"]}\"\n  },\n  \"providerPreset\": {\n    \"label\": \"プロバイダータイプ\",\n    \"custom\": \"カスタム設定\",\n    \"other\": \"その他\",\n    \"hint\": \"プリセットを選んだ後でも、下のフィールドで調整できます。\"\n  },\n  \"usage\": {\n    \"title\": \"利用統計\",\n    \"subtitle\": \"AI モデルの利用状況とコスト統計を表示\",\n    \"today\": \"24時間\",\n    \"last7days\": \"7日間\",\n    \"last30days\": \"30日間\",\n    \"totalRequests\": \"総リクエスト数\",\n    \"totalCost\": \"総コスト\",\n    \"cost\": \"コスト\",\n    \"perMillion\": \"(100万あたり)\",\n    \"trends\": \"利用トレンド\",\n    \"rangeToday\": \"直近24時間 (時間別)\",\n    \"rangeLast7Days\": \"過去7日間\",\n    \"rangeLast30Days\": \"過去30日間\",\n    \"totalTokens\": \"総トークン数\",\n    \"cacheTokens\": \"キャッシュトークン\",\n    \"requestLogs\": \"リクエストログ\",\n    \"providerStats\": \"プロバイダー統計\",\n    \"modelStats\": \"モデル統計\",\n    \"time\": \"時間\",\n    \"provider\": \"プロバイダー\",\n    \"billingModel\": \"課金モデル\",\n    \"inputTokens\": \"入力\",\n    \"outputTokens\": \"出力\",\n    \"cacheReadTokens\": \"キャッシュヒット\",\n    \"cacheCreationTokens\": \"キャッシュ作成\",\n    \"timingInfo\": \"応答時間/TTFT\",\n    \"status\": \"ステータス\",\n    \"multiplier\": \"倍率\",\n    \"requestModel\": \"リクエストモデル\",\n    \"responseModel\": \"レスポンスモデル\",\n    \"noData\": \"データなし\",\n    \"unknownProvider\": \"不明なプロバイダー\",\n    \"stream\": \"ストリーム\",\n    \"nonStream\": \"非ストリーム\",\n    \"totalRecords\": \"全 {{total}} 件\",\n    \"modelPricing\": \"モデル料金\",\n    \"loadPricingError\": \"料金データの読み込みに失敗しました\",\n    \"modelPricingDesc\": \"各モデルのトークンコストを設定\",\n    \"noPricingData\": \"料金データがありません。「追加」をクリックしてモデル料金を設定してください。\",\n    \"model\": \"モデル\",\n    \"displayName\": \"表示名\",\n    \"inputCost\": \"入力コスト\",\n    \"outputCost\": \"出力コスト\",\n    \"cacheReadCost\": \"キャッシュヒット\",\n    \"cacheWriteCost\": \"キャッシュ作成\",\n    \"deleteConfirmTitle\": \"削除の確認\",\n    \"deleteConfirmDesc\": \"このモデル料金を削除しますか？この操作は元に戻せません。\",\n    \"queryFailed\": \"照会に失敗しました\",\n    \"refreshUsage\": \"利用状況を更新\",\n    \"planUsage\": \"プラン利用状況\",\n    \"invalid\": \"期限切れ\",\n    \"total\": \"合計:\",\n    \"used\": \"使用:\",\n    \"remaining\": \"残り:\",\n    \"justNow\": \"たった今\",\n    \"minutesAgo\": \"{{count}} 分前\",\n    \"hoursAgo\": \"{{count}} 時間前\",\n    \"daysAgo\": \"{{count}} 日前\",\n    \"multiplePlans\": \"{{count}} プラン\",\n    \"expand\": \"展開\",\n    \"collapse\": \"折りたたむ\",\n    \"modelIdPlaceholder\": \"例: claude-3-5-sonnet-20241022\",\n    \"displayNamePlaceholder\": \"例: Claude 3.5 Sonnet\",\n    \"appType\": \"アプリ種別\",\n    \"allApps\": \"すべてのアプリ\",\n    \"statusCode\": \"ステータスコード\",\n    \"searchProviderPlaceholder\": \"プロバイダーを検索...\",\n    \"searchModelPlaceholder\": \"モデルを検索...\",\n    \"timeRange\": \"期間\",\n    \"input\": \"Input\",\n    \"output\": \"Output\",\n    \"cacheWrite\": \"作成\",\n    \"cacheRead\": \"ヒット\",\n    \"baseCost\": \"基本\",\n    \"costMultiplier\": \"コスト倍率\",\n    \"withMultiplier\": \"倍率込み\",\n    \"requestDetail\": \"リクエスト詳細\",\n    \"requestNotFound\": \"リクエストが見つかりません\",\n    \"basicInfo\": \"基本情報\",\n    \"tokenUsage\": \"Token 使用量\",\n    \"cacheCreationCost\": \"キャッシュ作成コスト\",\n    \"costBreakdown\": \"コスト明細\",\n    \"performance\": \"パフォーマンス\",\n    \"latency\": \"レイテンシー\",\n    \"errorMessage\": \"エラーメッセージ\",\n    \"requests\": \"リクエスト数\",\n    \"tokens\": \"トークン\",\n    \"avgCost\": \"平均コスト\",\n    \"avgLatency\": \"平均レイテンシ\",\n    \"successRate\": \"成功率\",\n    \"requestId\": \"リクエスト ID\",\n    \"never\": \"なし\",\n    \"modelId\": \"モデル ID\",\n    \"modelIdRequired\": \"モデル ID は必須です\",\n    \"inputCostPerMillion\": \"入力コスト（100万トークンあたり、USD）\",\n    \"outputCostPerMillion\": \"出力コスト（100万トークンあたり、USD）\",\n    \"invalidPrice\": \"価格は負でない数値である必要があります\",\n    \"invalidTimeRange\": \"開始/終了時刻を完全に選択してください\",\n    \"invalidTimeRangeOrder\": \"開始時刻は終了時刻より前である必要があります\",\n    \"timeRangeTooLarge\": \"時間範囲が大きすぎます。範囲を縮小してください\",\n    \"addPricing\": \"価格設定を追加\",\n    \"editPricing\": \"価格設定を編集\",\n    \"pricingAdded\": \"価格設定が追加されました\",\n    \"pricingUpdated\": \"価格設定が更新されました\",\n    \"cacheReadCostPerMillion\": \"キャッシュ読み取りコスト（100万トークンあたり、USD）\",\n    \"cacheCreationCostPerMillion\": \"キャッシュ書き込みコスト（100万トークンあたり、USD）\"\n  },\n  \"usageScript\": {\n    \"title\": \"利用状況を設定\",\n    \"enableUsageQuery\": \"利用状況照会を有効にする\",\n    \"presetTemplate\": \"プリセットテンプレート\",\n    \"requestUrl\": \"リクエスト URL\",\n    \"requestUrlPlaceholder\": \"例: https://api.example.com\",\n    \"method\": \"HTTP メソッド\",\n    \"templateCustom\": \"カスタム\",\n    \"templateGeneral\": \"General\",\n    \"templateNewAPI\": \"NewAPI\",\n    \"templateCopilot\": \"GitHub Copilot\",\n    \"copilotAutoAuth\": \"OAuth 認証を自動使用、手動設定不要\",\n    \"resetDate\": \"リセット日\",\n    \"premiumRequests\": \"Premium リクエスト\",\n    \"credentialsConfig\": \"認証情報\",\n    \"credentialsHint\": \"空欄の場合はプロバイダー設定を使用\",\n    \"optional\": \"オプション\",\n    \"apiKeyPlaceholder\": \"空欄の場合はプロバイダーの API Key を使用\",\n    \"baseUrlPlaceholder\": \"空欄の場合はプロバイダーの Base URL を使用\",\n    \"baseUrl\": \"Base URL\",\n    \"accessToken\": \"Access Token\",\n    \"accessTokenPlaceholder\": \"「Security Settings」で生成\",\n    \"userId\": \"ユーザー ID\",\n    \"userIdPlaceholder\": \"例: 114514\",\n    \"defaultPlan\": \"デフォルトプラン\",\n    \"queryFailedMessage\": \"照会に失敗しました\",\n    \"queryScript\": \"照会スクリプト (JavaScript)\",\n    \"timeoutSeconds\": \"タイムアウト（秒）\",\n    \"headers\": \"ヘッダー\",\n    \"body\": \"ボディ\",\n    \"timeoutHint\": \"範囲: 2〜30 秒\",\n    \"timeoutMustBeInteger\": \"タイムアウトは整数で入力してください（小数は切り捨て）\",\n    \"timeoutCannotBeNegative\": \"タイムアウトは負の値にできません\",\n    \"autoIntervalMinutes\": \"自動照会間隔（分、0 で無効）\",\n    \"autoQueryInterval\": \"自動照会間隔（分）\",\n    \"autoQueryIntervalHint\": \"0 で無効。推奨 5〜60 分\",\n    \"intervalMustBeInteger\": \"間隔は整数で入力してください（小数は切り捨て）\",\n    \"intervalCannotBeNegative\": \"間隔は負の値にできません\",\n    \"intervalAdjusted\": \"間隔を {{value}} 分に調整しました\",\n    \"scriptHelp\": \"スクリプトの書き方:\",\n    \"configFormat\": \"設定の形式:\",\n    \"commentOptional\": \"任意\",\n    \"commentResponseIsJson\": \"response は API から返る JSON データです\",\n    \"extractorFormat\": \"抽出関数の返却形式（すべて任意）:\",\n    \"tips\": \"💡 ヒント:\",\n    \"testing\": \"テスト中...\",\n    \"testScript\": \"スクリプトをテスト\",\n    \"format\": \"整形\",\n    \"saveConfig\": \"設定を保存\",\n    \"scriptEmpty\": \"スクリプト設定は空にできません\",\n    \"mustHaveReturn\": \"スクリプトには return 文が必要です\",\n    \"testSuccess\": \"テスト成功！\",\n    \"testFailed\": \"テストに失敗しました\",\n    \"formatSuccess\": \"整形に成功しました\",\n    \"formatFailed\": \"整形に失敗しました\",\n    \"supportedVariables\": \"使用可能な変数\",\n    \"variablesHint\": \"使用可能な変数: {{apiKey}}, {{baseUrl}} | extractor 関数には API 応答の JSON オブジェクトが渡されます\",\n    \"scriptConfig\": \"リクエスト設定\",\n    \"extractorCode\": \"抽出コード\",\n    \"extractorHint\": \"戻り値のオブジェクトに残り枠の項目を含めてください\",\n    \"fieldIsValid\": \"• isValid: Boolean。プランが有効かどうか\",\n    \"fieldInvalidMessage\": \"• invalidMessage: String。無効時の理由（isValid が false のとき表示）\",\n    \"fieldRemaining\": \"• remaining: Number。残り枠\",\n    \"fieldUnit\": \"• unit: String。単位（例: \\\"USD\\\"）\",\n    \"fieldPlanName\": \"• planName: String。プラン名\",\n    \"fieldTotal\": \"• total: Number。総枠\",\n    \"fieldUsed\": \"• used: Number。使用量\",\n    \"fieldExtra\": \"• extra: String。自由記述の追加テキスト\",\n    \"tip1\": \"• 変数 {{apiKey}} と {{baseUrl}} は自動で置換されます\",\n    \"tip2\": \"• 抽出関数はサンドボックスで実行され、ES2020+ の構文を使えます\",\n    \"tip3\": \"• 全体を () で囲み、オブジェクトリテラル式にしてください\"\n  },\n  \"errors\": {\n    \"usage_query_failed\": \"利用状況の取得に失敗しました\",\n    \"configLoadFailedTitle\": \"設定の読み込みに失敗しました\",\n    \"configLoadFailedMessage\": \"設定ファイルを読み込めません:\\n{{path}}\\n\\nエラー詳細:\\n{{detail}}\\n\\nJSON が正しいか確認するか、同じディレクトリのバックアップファイル（config.json.bak など）から復元してください。\\n\\nアプリを終了して修正してください。\"\n  },\n  \"presetSelector\": {\n    \"title\": \"設定タイプを選択\",\n    \"custom\": \"カスタム\",\n    \"customDescription\": \"手動で設定。完全な構成が必要\",\n    \"officialDescription\": \"公式ログイン。API Key 不要\",\n    \"presetDescription\": \"プリセットを使用。API Key だけ入力すれば OK\"\n  },\n  \"mcp\": {\n    \"title\": \"MCP 管理\",\n    \"import\": \"インポート\",\n    \"importExisting\": \"既存をインポート\",\n    \"addMcp\": \"MCPを追加\",\n    \"claudeTitle\": \"Claude Code MCP 管理\",\n    \"codexTitle\": \"Codex MCP 管理\",\n    \"geminiTitle\": \"Gemini MCP 管理\",\n    \"unifiedPanel\": {\n      \"title\": \"MCP サーバー管理\",\n      \"addServer\": \"サーバーを追加\",\n      \"editServer\": \"サーバーを編集\",\n      \"deleteServer\": \"サーバーを削除\",\n      \"deleteConfirm\": \"サーバー「{{id}}」を削除しますか？この操作は元に戻せません。\",\n      \"noServers\": \"まだサーバーがありません\",\n      \"enabledApps\": \"有効なアプリ\",\n      \"noImportFound\": \"インポートする MCP サーバーが見つかりませんでした。すべてのサーバーは CC Switch で管理されています。\",\n      \"importSuccess\": \"{{count}} 個の MCP サーバーをインポートしました\",\n      \"apps\": {\n        \"claude\": \"Claude\",\n        \"codex\": \"Codex\",\n        \"gemini\": \"Gemini\",\n        \"opencode\": \"OpenCode\",\n        \"openclaw\": \"OpenClaw\"\n      }\n    },\n    \"userLevelPath\": \"ユーザーレベルの MCP パス\",\n    \"serverList\": \"サーバー一覧\",\n    \"loading\": \"読み込み中...\",\n    \"empty\": \"MCP サーバーがありません\",\n    \"emptyDescription\": \"右上のボタンから最初の MCP サーバーを追加してください\",\n    \"add\": \"MCP を追加\",\n    \"addServer\": \"MCP を追加\",\n    \"editServer\": \"MCP を編集\",\n    \"addClaudeServer\": \"Claude Code MCP を追加\",\n    \"editClaudeServer\": \"Claude Code MCP を編集\",\n    \"addCodexServer\": \"Codex MCP を追加\",\n    \"editCodexServer\": \"Codex MCP を編集\",\n    \"configPath\": \"設定パス\",\n    \"serverCount\": \"MCP サーバー: {{count}} 件\",\n    \"enabledCount\": \"{{count}} 件が有効\",\n    \"template\": {\n      \"fetch\": \"クイックテンプレート: mcp-fetch\"\n    },\n    \"form\": {\n      \"title\": \"MCP ID（ユニーク）\",\n      \"titlePlaceholder\": \"my-mcp-server\",\n      \"name\": \"表示名\",\n      \"namePlaceholder\": \"例: @modelcontextprotocol/server-time\",\n      \"enabledApps\": \"適用するアプリ\",\n      \"noAppsWarning\": \"少なくとも 1 つ選択してください\",\n      \"description\": \"説明\",\n      \"descriptionPlaceholder\": \"任意の説明\",\n      \"tags\": \"タグ（カンマ区切り）\",\n      \"tagsPlaceholder\": \"stdio, time, utility\",\n      \"homepage\": \"ホームページ\",\n      \"homepagePlaceholder\": \"https://example.com\",\n      \"docs\": \"ドキュメント\",\n      \"docsPlaceholder\": \"https://example.com/docs\",\n      \"additionalInfo\": \"追加情報\",\n      \"jsonConfig\": \"JSON 全設定\",\n      \"jsonConfigOrPrefix\": \"JSON 全設定、または\",\n      \"tomlConfigOrPrefix\": \"TOML 全設定、または\",\n      \"jsonPlaceholder\": \"{\\n  \\\"type\\\": \\\"stdio\\\",\\n  \\\"command\\\": \\\"uvx\\\",\\n  \\\"args\\\": [\\\"mcp-server-fetch\\\"]\\n}\",\n      \"tomlConfig\": \"TOML 全設定\",\n      \"tomlPlaceholder\": \"type = \\\"stdio\\\"\\ncommand = \\\"uvx\\\"\\nargs = [\\\"mcp-server-fetch\\\"]\",\n      \"useWizard\": \"設定ウィザード\",\n      \"syncOtherSide\": \"{{target}} にも反映\",\n      \"syncOtherSideHint\": \"{{target}} に同じ設定を書き込みます。既存の同一 ID は上書きされます。\",\n      \"willOverwriteWarning\": \"{{target}} の既存設定を上書きします\"\n    },\n    \"wizard\": {\n      \"title\": \"MCP 設定ウィザード\",\n      \"hint\": \"MCP サーバーを素早く設定し JSON を自動生成します\",\n      \"type\": \"タイプ\",\n      \"typeStdio\": \"stdio\",\n      \"typeHttp\": \"http\",\n      \"typeSse\": \"sse\",\n      \"command\": \"コマンド\",\n      \"commandPlaceholder\": \"npx または uvx\",\n      \"args\": \"引数\",\n      \"argsPlaceholder\": \"arg1\\narg2\",\n      \"env\": \"環境変数\",\n      \"envPlaceholder\": \"KEY1=value1\\nKEY2=value2\",\n      \"url\": \"URL\",\n      \"urlPlaceholder\": \"https://api.example.com/mcp\",\n      \"urlRequired\": \"URL を入力してください\",\n      \"headers\": \"ヘッダー（任意）\",\n      \"headersPlaceholder\": \"Authorization: Bearer your_token_here\\nContent-Type: application/json\",\n      \"preview\": \"設定プレビュー\",\n      \"apply\": \"設定を反映\"\n    },\n    \"id\": \"識別子（ユニーク）\",\n    \"type\": \"タイプ\",\n    \"command\": \"コマンド\",\n    \"validateCommand\": \"コマンドを検証\",\n    \"args\": \"引数\",\n    \"argsPlaceholder\": \"例: mcp-server-fetch --help\",\n    \"env\": \"環境変数（1 行に 1 件、KEY=VALUE）\",\n    \"envPlaceholder\": \"FOO=bar\\nHELLO=world\",\n    \"reset\": \"リセット\",\n    \"msg\": {\n      \"saved\": \"保存しました\",\n      \"deleted\": \"削除しました\",\n      \"enabled\": \"有効化しました\",\n      \"disabled\": \"無効化しました\",\n      \"templateAdded\": \"テンプレートを追加しました\"\n    },\n    \"error\": {\n      \"idRequired\": \"識別子を入力してください\",\n      \"idExists\": \"この識別子は既に存在します。別のものを選んでください。\",\n      \"jsonInvalid\": \"JSON 形式が無効です\",\n      \"tomlInvalid\": \"TOML 形式が無効です\",\n      \"commandRequired\": \"コマンドを入力してください\",\n      \"singleServerObjectRequired\": \"単一の MCP サーバーオブジェクトを貼り付けてください（トップレベルの mcpServers は不要）\",\n      \"saveFailed\": \"保存に失敗しました\",\n      \"deleteFailed\": \"削除に失敗しました\"\n    },\n    \"validation\": {\n      \"ok\": \"コマンドが見つかりました\",\n      \"fail\": \"コマンドが見つかりません\"\n    },\n    \"confirm\": {\n      \"deleteTitle\": \"MCP サーバーを削除\",\n      \"deleteMessage\": \"MCP サーバー「{{id}}」を削除してもよろしいですか？この操作は元に戻せません。\"\n    },\n    \"presets\": {\n      \"title\": \"MCP タイプを選択\",\n      \"enable\": \"有効化\",\n      \"enabled\": \"有効\",\n      \"installed\": \"インストール済み\",\n      \"docs\": \"ドキュメント\",\n      \"requiresEnv\": \"環境変数が必要\",\n      \"fetch\": {\n        \"name\": \"mcp-server-fetch\",\n        \"description\": \"汎用 HTTP リクエストツール。GET/POST などに対応し、API テストや Web データ取得に最適です\"\n      },\n      \"time\": {\n        \"name\": \"@modelcontextprotocol/server-time\",\n        \"description\": \"現在時刻、タイムゾーン変換、日付計算を提供する時間クエリツール\"\n      },\n      \"memory\": {\n        \"name\": \"@modelcontextprotocol/server-memory\",\n        \"description\": \"エンティティ・関係・観測を扱うナレッジグラフ型メモリ。会話の重要情報を AI に記憶させます\"\n      },\n      \"sequential-thinking\": {\n        \"name\": \"@modelcontextprotocol/server-sequential-thinking\",\n        \"description\": \"複雑な問題をステップに分解して深く考えるためのシーケンシャル思考ツール\"\n      },\n      \"context7\": {\n        \"name\": \"@upstash/context7-mcp\",\n        \"description\": \"最新のライブラリドキュメントとコード例を提供する Context7 ドキュメント検索ツール。キー設定で上限が拡張されます\"\n      }\n    }\n  },\n  \"prompts\": {\n    \"manage\": \"プロンプト\",\n    \"title\": \"{{appName}} プロンプト管理\",\n    \"claudeTitle\": \"Claude プロンプト管理\",\n    \"codexTitle\": \"Codex プロンプト管理\",\n    \"add\": \"プロンプトを追加\",\n    \"edit\": \"プロンプトを編集\",\n    \"addTitle\": \"{{appName}} プロンプトを追加\",\n    \"editTitle\": \"{{appName}} プロンプトを編集\",\n    \"import\": \"既存をインポート\",\n    \"count\": \"{{count}} 件のプロンプト\",\n    \"enabled\": \"有効\",\n    \"enable\": \"有効化\",\n    \"enabledName\": \"有効: {{name}}\",\n    \"noneEnabled\": \"有効なプロンプトがありません\",\n    \"currentFile\": \"現在の {{filename}} の内容\",\n    \"empty\": \"まだプロンプトがありません\",\n    \"emptyDescription\": \"上のボタンからプロンプトを追加またはインポートしてください\",\n    \"loading\": \"読み込み中...\",\n    \"name\": \"名前\",\n    \"namePlaceholder\": \"例: デフォルトプロジェクトプロンプト\",\n    \"description\": \"説明\",\n    \"descriptionPlaceholder\": \"任意の説明\",\n    \"content\": \"内容\",\n    \"contentPlaceholder\": \"# {{filename}}\\n\\nここにプロンプト内容を入力...\",\n    \"loadFailed\": \"プロンプトの読み込みに失敗しました\",\n    \"saveSuccess\": \"保存しました\",\n    \"saveFailed\": \"保存に失敗しました\",\n    \"deleteSuccess\": \"削除しました\",\n    \"deleteFailed\": \"削除に失敗しました\",\n    \"enableSuccess\": \"有効化しました\",\n    \"enableFailed\": \"有効化に失敗しました\",\n    \"disableSuccess\": \"無効化しました\",\n    \"disableFailed\": \"無効化に失敗しました\",\n    \"importSuccess\": \"インポートしました\",\n    \"importFailed\": \"インポートに失敗しました\",\n    \"confirm\": {\n      \"deleteTitle\": \"削除の確認\",\n      \"deleteMessage\": \"プロンプト「{{name}}」を削除してもよろしいですか？\"\n    }\n  },\n  \"workspace\": {\n    \"title\": \"ワークスペースファイル\",\n    \"manage\": \"ワークスペース\",\n    \"files\": {\n      \"agents\": \"エージェントの操作指示とルール\",\n      \"soul\": \"エージェントの人格とコミュニケーションスタイル\",\n      \"user\": \"ユーザープロファイルと設定\",\n      \"identity\": \"エージェントの名前とアバター\",\n      \"tools\": \"ローカルツールドキュメント\",\n      \"memory\": \"長期記憶と意思決定記録\",\n      \"heartbeat\": \"ハートビート実行チェックリスト\",\n      \"bootstrap\": \"初回実行ブートストラップ\",\n      \"boot\": \"ゲートウェイ再起動チェックリスト\"\n    },\n    \"editing\": \"{{filename}} を編集\",\n    \"saveSuccess\": \"保存しました\",\n    \"saveFailed\": \"保存に失敗しました\",\n    \"loadFailed\": \"読み込みに失敗しました\",\n    \"openDirectory\": \"ファイルマネージャーで開く\",\n    \"dailyMemory\": {\n      \"title\": \"デイリーメモリー\",\n      \"sectionTitle\": \"デイリーメモリー\",\n      \"cardTitle\": \"デイリーメモリーファイル\",\n      \"cardDescription\": \"デイリーメモリーの閲覧・管理\",\n      \"createToday\": \"メモリーを追加\",\n      \"empty\": \"デイリーメモリーファイルはまだありません\",\n      \"loadFailed\": \"デイリーメモリーファイルの読み込みに失敗しました\",\n      \"createFailed\": \"デイリーメモリーファイルの作成に失敗しました\",\n      \"deleteSuccess\": \"デイリーメモリーファイルを削除しました\",\n      \"deleteFailed\": \"デイリーメモリーファイルの削除に失敗しました\",\n      \"confirmDeleteTitle\": \"デイリーメモリーを削除\",\n      \"confirmDeleteMessage\": \"{{date}} のデイリーメモリーを削除しますか？この操作は取り消せません。\",\n      \"searchPlaceholder\": \"全文検索...\",\n      \"searchScopeHint\": \"すべてのデイリーメモリーを全文検索 ⌘F\",\n      \"searchCloseHint\": \"Escで閉じる\",\n      \"noSearchResults\": \"検索に一致するデイリーメモリーはありません。\",\n      \"searching\": \"検索中...\",\n      \"searchFailed\": \"検索に失敗しました\",\n      \"matchCount\": \"{{count}}件一致\"\n    }\n  },\n  \"openclaw\": {\n    \"backupCreated\": \"バックアップを作成しました: {{path}}\",\n    \"providerKey\": \"プロバイダーキー\",\n    \"providerKeyPlaceholder\": \"my-provider\",\n    \"providerKeyHint\": \"設定ファイル内のユニーク識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用可能。\",\n    \"providerKeyRequired\": \"プロバイダーキーを入力してください\",\n    \"providerKeyDuplicate\": \"このキーは既に使用されています\",\n    \"providerKeyInvalid\": \"無効な形式です。小文字、数字、ハイフンのみ使用可能。\",\n    \"apiProtocol\": \"API プロトコル\",\n    \"selectProtocol\": \"API プロトコルを選択\",\n    \"apiProtocolHint\": \"プロバイダーの API と互換性のあるプロトコルタイプを選択してください。ほとんどのプロバイダーは OpenAI Completions 形式を使用します。\",\n    \"baseUrl\": \"API エンドポイント\",\n    \"baseUrlHint\": \"プロバイダーの API エンドポイントアドレス。\",\n    \"models\": \"モデル一覧\",\n    \"addModel\": \"モデルを追加\",\n    \"noModels\": \"モデルが設定されていません。「モデルを追加」をクリックして利用可能なモデルを設定してください。\",\n    \"modelId\": \"モデル ID\",\n    \"modelIdPlaceholder\": \"claude-3-sonnet\",\n    \"modelName\": \"表示名\",\n    \"modelNamePlaceholder\": \"Claude 3 Sonnet\",\n    \"contextWindow\": \"コンテキストウィンドウ\",\n    \"maxTokens\": \"最大出力トークン数\",\n    \"reasoning\": \"推論モード\",\n    \"reasoningOn\": \"有効\",\n    \"reasoningOff\": \"無効\",\n    \"inputTypes\": \"入力タイプ\",\n    \"inputCost\": \"入力コスト ($/M トークン)\",\n    \"outputCost\": \"出力コスト ($/M トークン)\",\n    \"advancedOptions\": \"詳細オプション\",\n    \"cacheReadCost\": \"キャッシュ読取コスト ($/M トークン)\",\n    \"cacheWriteCost\": \"キャッシュ書込コスト ($/M トークン)\",\n    \"cacheCostHint\": \"キャッシュコストは Prompt Caching のコスト計算に使用されます。キャッシュを使用しない場合は空欄のままにしてください。\",\n    \"modelsHint\": \"このプロバイダーがサポートするモデルを設定します。モデル ID は API 呼び出しに、表示名はインターフェースに使用されます。\",\n    \"userAgent\": \"User-Agent を送信\",\n    \"userAgentHint\": \"一部のプロバイダーはブラウザの User-Agent ヘッダーが必要です。\",\n    \"env\": {\n      \"title\": \"環境変数\",\n      \"description\": \"openclaw.json の環境変数設定を管理（APIキー、カスタム変数など）\",\n      \"editorHint\": \"env セクション全体を JSON として編集します。env.vars や env.shellEnv などのネストしたオブジェクトにも対応しています。\",\n      \"objectRequired\": \"OpenClaw の env は JSON オブジェクトである必要があります。\",\n      \"invalidJson\": \"OpenClaw の env は有効な JSON である必要があります。\",\n      \"empty\": \"OpenClaw の env は空にできません。空オブジェクトの場合は {} を使用してください。\",\n      \"keyPlaceholder\": \"変数名\",\n      \"valuePlaceholder\": \"値\",\n      \"add\": \"変数を追加\",\n      \"saveSuccess\": \"環境変数を保存しました\",\n      \"saveFailed\": \"環境変数の保存に失敗しました\",\n      \"loadFailed\": \"環境変数の読み込みに失敗しました\",\n      \"duplicateKey\": \"重複する変数名が検出されました: {{key}}\"\n    },\n    \"tools\": {\n      \"title\": \"ツール権限\",\n      \"description\": \"openclaw.json のツール権限設定を管理（許可/拒否リスト）\",\n      \"profile\": \"権限プロファイル\",\n      \"profileMinimal\": \"最小\",\n      \"profileCoding\": \"コーディング\",\n      \"profileMessaging\": \"メッセージ\",\n      \"profileFull\": \"フルアクセス\",\n      \"profileUnset\": \"未設定\",\n      \"unsupportedProfileTitle\": \"未対応のツールプロファイルを検出しました\",\n      \"unsupportedProfileDescription\": \"現在の tools.profile の値 '{{value}}' は OpenClaw の対応リストにありません。新しい値を選択するまでこの値を保持します。\",\n      \"unsupportedProfileLabel\": \"未対応\",\n      \"allowList\": \"許可リスト\",\n      \"denyList\": \"拒否リスト\",\n      \"patternPlaceholder\": \"ツール名またはパターン\",\n      \"addAllow\": \"許可を追加\",\n      \"addDeny\": \"拒否を追加\",\n      \"saveSuccess\": \"ツール権限を保存しました\",\n      \"saveFailed\": \"ツール権限の保存に失敗しました\",\n      \"loadFailed\": \"ツール権限の読み込みに失敗しました\"\n    },\n    \"agents\": {\n      \"title\": \"Agents 設定\",\n      \"description\": \"openclaw.json の agents.defaults 設定を管理（デフォルトモデル、ランタイムパラメータなど）\",\n      \"modelSection\": \"モデル設定\",\n      \"primaryModel\": \"デフォルトモデル\",\n      \"primaryModelHint\": \"設定済みプロバイダのモデルから選択してください\",\n      \"notSet\": \"未設定\",\n      \"fallbackModels\": \"フォールバックモデル\",\n      \"fallbackModelsHint\": \"プライマリモデルが利用不可の場合、優先度順にフォールバックが試行されます\",\n      \"addFallback\": \"フォールバックモデルを追加\",\n      \"noModels\": \"設定済みのプロバイダモデルがありません。先に OpenClaw プロバイダを追加してください。\",\n      \"notInList\": \"{{value}} (未設定)\",\n      \"runtimeSection\": \"ランタイムパラメータ\",\n      \"workspace\": \"ワークスペースパス\",\n      \"timeout\": \"タイムアウト（秒）\",\n      \"contextTokens\": \"コンテキストトークン数\",\n      \"maxConcurrent\": \"最大同時実行数\",\n      \"legacyTimeoutTitle\": \"旧タイムアウト設定を検出しました\",\n      \"legacyTimeoutDescription\": \"この設定ではまだ agents.defaults.timeout を使用しています。ここで保存すると timeoutSeconds に移行されます。\",\n      \"saveSuccess\": \"Agents 設定を保存しました\",\n      \"saveFailed\": \"Agents 設定の保存に失敗しました\",\n      \"loadFailed\": \"Agents 設定の読み込みに失敗しました\"\n    },\n    \"health\": {\n      \"title\": \"OpenClaw 設定の警告を検出しました\",\n      \"invalidToolsProfile\": \"tools.profile に未対応の値が設定されています。OpenClaw が現在サポートしているのは minimal、coding、messaging、full です。\",\n      \"legacyTimeout\": \"agents.defaults.timeout は非推奨です。Agents パネルを保存すると timeoutSeconds に移行されます。\",\n      \"stringifiedEnvVars\": \"env.vars はオブジェクトである必要がありますが、現在の値は文字列化または破損しているようです。\",\n      \"stringifiedShellEnv\": \"env.shellEnv はオブジェクトである必要がありますが、現在の値は文字列化または破損しているようです。\",\n      \"parseFailed\": \"openclaw.json を有効な JSON5 として解析できませんでした。ここで編集する前にファイルを修正してください。\"\n    },\n    \"primaryModel\": \"プライマリモデル\",\n    \"fallbackModel\": \"フォールバックモデル\"\n  },\n  \"env\": {\n    \"warning\": {\n      \"title\": \"競合する環境変数を検出しました\",\n      \"description\": \"設定を上書きする可能性のある環境変数を {{count}} 件見つけました\"\n    },\n    \"actions\": {\n      \"expand\": \"詳細を表示\",\n      \"collapse\": \"折りたたむ\",\n      \"selectAll\": \"すべて選択\",\n      \"clearSelection\": \"選択を解除\",\n      \"deleteSelected\": \"選択 {{count}} 件を削除\",\n      \"deleting\": \"削除中...\"\n    },\n    \"field\": {\n      \"value\": \"値\",\n      \"source\": \"ソース\"\n    },\n    \"source\": {\n      \"userRegistry\": \"ユーザー環境変数（レジストリ）\",\n      \"systemRegistry\": \"システム環境変数（レジストリ）\",\n      \"systemEnv\": \"システム環境変数\"\n    },\n    \"delete\": {\n      \"success\": \"環境変数を削除しました\",\n      \"error\": \"環境変数の削除に失敗しました\"\n    },\n    \"backup\": {\n      \"location\": \"バックアップ場所: {{path}}\"\n    },\n    \"confirm\": {\n      \"title\": \"環境変数を削除しますか？\",\n      \"message\": \"{{count}} 件の環境変数を削除してもよろしいですか？\",\n      \"backupNotice\": \"削除前に自動バックアップを作成します。後で復元できます。再起動またはターミナル再起動後に反映されます。\",\n      \"confirm\": \"削除を確認\"\n    },\n    \"error\": {\n      \"noSelection\": \"削除する環境変数を選択してください\"\n    }\n  },\n  \"skills\": {\n    \"manage\": \"Skills\",\n    \"title\": \"Skills 管理\",\n    \"description\": \"人気リポジトリからスキルを探してインストールし、Claude Code/Codex/Gemini を拡張\",\n    \"refresh\": \"更新\",\n    \"refreshing\": \"更新中...\",\n    \"repoManager\": \"リポジトリ管理\",\n    \"count\": \"{{count}} 個のスキル\",\n    \"empty\": \"スキルがありません\",\n    \"emptyDescription\": \"スキルリポジトリを追加して探索してください\",\n    \"addRepo\": \"スキルリポジトリを追加\",\n    \"loading\": \"読み込み中...\",\n    \"installed\": \"インストール済み\",\n    \"install\": \"インストール\",\n    \"installing\": \"インストール中...\",\n    \"uninstall\": \"アンインストール\",\n    \"uninstalling\": \"アンインストール中...\",\n    \"view\": \"表示\",\n    \"noDescription\": \"説明なし\",\n    \"loadFailed\": \"読み込みに失敗しました\",\n    \"installSuccess\": \"スキル {{name}} をインストールしました\",\n    \"installFailed\": \"インストールに失敗しました\",\n    \"uninstallSuccess\": \"スキル {{name}} をアンインストールしました\",\n    \"uninstallFailed\": \"アンインストールに失敗しました\",\n    \"error\": {\n      \"skillNotFound\": \"スキルが見つかりません: {{directory}}\",\n      \"missingRepoInfo\": \"リポジトリ情報（owner または name）が不足しています\",\n      \"downloadTimeout\": \"リポジトリ {{owner}}/{{name}} のダウンロードがタイムアウトしました（{{timeout}} 秒）\",\n      \"downloadTimeoutHint\": \"ネットワークを確認するか、時間をおいて再試行してください\",\n      \"skillPathNotFound\": \"リポジトリ {{owner}}/{{name}} にスキルパス '{{path}}' がありません\",\n      \"skillDirNotFound\": \"スキルディレクトリが見つかりません: {{path}}\",\n      \"directoryConflict\": \"スキルディレクトリ '{{directory}}' は既に {{existing_repo}} で使用されています。{{new_repo}} からインストールできません\",\n      \"emptyArchive\": \"ダウンロードしたアーカイブが空です\",\n      \"downloadFailed\": \"ダウンロードに失敗しました: HTTP {{status}}\",\n      \"allBranchesFailed\": \"すべてのブランチで失敗しました。試行: {{branches}}\",\n      \"httpError\": \"HTTP エラー {{status}}\",\n      \"http403\": \"GitHub へのアクセスが制限されています（レート制限の可能性）\",\n      \"http404\": \"リポジトリまたはブランチが見つかりません。URL を確認してください\",\n      \"http429\": \"リクエストが多すぎます。時間をおいて再試行してください\",\n      \"parseMetadataFailed\": \"スキルメタデータの解析に失敗しました\",\n      \"getHomeDirFailed\": \"ユーザーのホームディレクトリを取得できません\",\n      \"noSkillsInZip\": \"ZIP ファイルにスキルが見つかりません（SKILL.md ファイルが必要です）\",\n      \"networkError\": \"ネットワークエラー\",\n      \"fsError\": \"ファイルシステムエラー\",\n      \"unknownError\": \"不明なエラー\",\n      \"suggestion\": {\n        \"checkNetwork\": \"ネットワーク接続を確認してください\",\n        \"checkProxy\": \"HTTP プロキシの設定を検討してください\",\n        \"retryLater\": \"時間をおいて再試行してください\",\n        \"checkRepoUrl\": \"リポジトリ URL とブランチ名を確認してください\",\n        \"checkDiskSpace\": \"ディスク容量を確認してください\",\n        \"checkPermission\": \"ディレクトリの権限を確認してください\",\n        \"uninstallFirst\": \"同名のスキルを先にアンインストールしてください\",\n        \"checkZipContent\": \"ZIP ファイルに有効なスキルディレクトリ（SKILL.md を含む）が含まれていることを確認してください\"\n      }\n    },\n    \"repo\": {\n      \"title\": \"スキルリポジトリを管理\",\n      \"description\": \"GitHub のスキルリポジトリソースを追加または削除します\",\n      \"url\": \"リポジトリ URL\",\n      \"urlPlaceholder\": \"owner/name または https://github.com/owner/name\",\n      \"branch\": \"ブランチ\",\n      \"branchPlaceholder\": \"main\",\n      \"path\": \"スキルパス\",\n      \"pathPlaceholder\": \"skills（任意。空欄はルート）\",\n      \"add\": \"リポジトリを追加\",\n      \"list\": \"追加済みリポジトリ\",\n      \"empty\": \"リポジトリがありません\",\n      \"invalidUrl\": \"リポジトリ URL の形式が無効です\",\n      \"addSuccess\": \"リポジトリ {{owner}}/{{name}} を追加しました。検出スキル: {{count}} 件\",\n      \"addFailed\": \"追加に失敗しました\",\n      \"removeSuccess\": \"リポジトリ {{owner}}/{{name}} を削除しました\",\n      \"removeFailed\": \"削除に失敗しました\",\n      \"skillCount\": \"{{count}} 件のスキルを検出\"\n    },\n    \"search\": \"スキルを検索\",\n    \"searchPlaceholder\": \"スキル名またはリポジトリで検索...\",\n    \"filter\": {\n      \"placeholder\": \"状態で絞り込み\",\n      \"all\": \"すべて\",\n      \"installed\": \"インストール済み\",\n      \"uninstalled\": \"未インストール\",\n      \"repo\": \"リポジトリで絞り込み\",\n      \"allRepos\": \"すべてのリポジトリ\"\n    },\n    \"noResults\": \"一致するスキルが見つかりませんでした\",\n    \"noInstalled\": \"インストールされたスキルがありません\",\n    \"noInstalledDescription\": \"リポジトリからスキルを発見してインストールするか、既存のスキルをインポートしてください\",\n    \"discover\": \"スキルを発見\",\n    \"import\": \"既存をインポート\",\n    \"importDescription\": \"CC Switch 統合管理にインポートするスキルを選択してください\",\n    \"importSuccess\": \"{{count}} 件のスキルをインポートしました\",\n    \"importSelected\": \"選択をインポート ({{count}})\",\n    \"noUnmanagedFound\": \"インポートするスキルが見つかりませんでした。すべてのスキルは CC Switch で管理されています。\",\n    \"foundIn\": \"発見場所\",\n    \"local\": \"ローカル\",\n    \"uninstallConfirm\": \"「{{name}}」をアンインストールしますか？すべてのアプリからこのスキルが削除され、削除前にローカルバックアップが自動作成されます。\",\n    \"uninstallInMainPanel\": \"メインパネルからスキルをアンインストールしてください\",\n    \"notFound\": \"スキルが見つかりません\",\n    \"backup\": {\n      \"location\": \"バックアップ場所: {{path}}\"\n    },\n    \"restoreFromBackup\": {\n      \"button\": \"バックアップから復元\",\n      \"title\": \"バックアップから復元\",\n      \"description\": \"Skills バックアップを選択して、ローカルにファイルを復元し、現在の一覧へ戻します。\",\n      \"empty\": \"復元できる Skills バックアップはありません\",\n      \"createdAt\": \"バックアップ日時\",\n      \"path\": \"バックアップパス\",\n      \"restore\": \"復元\",\n      \"restoring\": \"復元中...\",\n      \"delete\": \"削除\",\n      \"deleting\": \"削除中...\",\n      \"deleteSuccess\": \"スキルバックアップ {{name}} を削除しました\",\n      \"deleteFailed\": \"スキルバックアップの削除に失敗しました\",\n      \"deleteConfirmTitle\": \"バックアップを削除\",\n      \"deleteConfirmMessage\": \"スキルバックアップ「{{name}}」を削除しますか？この操作は元に戻せません。\",\n      \"success\": \"スキル {{name}} をバックアップから復元しました\",\n      \"failed\": \"バックアップからの復元に失敗しました\"\n    },\n    \"apps\": {\n      \"claude\": \"Claude\",\n      \"codex\": \"Codex\",\n      \"gemini\": \"Gemini\",\n      \"opencode\": \"OpenCode\",\n      \"openclaw\": \"OpenClaw\"\n    },\n    \"installFromZip\": {\n      \"button\": \"ZIP からインストール\",\n      \"installing\": \"インストール中...\",\n      \"successSingle\": \"スキル {{name}} をインストールしました\",\n      \"successMultiple\": \"{{count}} 件のスキルをインストールしました\",\n      \"noSkillsFound\": \"ZIP ファイルにスキルが見つかりません（SKILL.md が必要です）\"\n    }\n  },\n  \"deeplink\": {\n    \"confirmImport\": \"プロバイダーのインポートを確認\",\n    \"confirmImportDescription\": \"次の設定をディープリンクから CC Switch へインポートします\",\n    \"importPrompt\": \"プロンプトをインポート\",\n    \"importPromptDescription\": \"このシステムプロンプトをインポートするか確認してください\",\n    \"importMcp\": \"MCP サーバーをインポート\",\n    \"importMcpDescription\": \"これらの MCP サーバーをインポートするか確認してください\",\n    \"importSkill\": \"スキルリポジトリを追加\",\n    \"importSkillDescription\": \"このスキルリポジトリを追加するか確認してください\",\n    \"promptImportSuccess\": \"プロンプトをインポートしました\",\n    \"promptImportSuccessDescription\": \"インポートされたプロンプト: {{name}}\",\n    \"mcpImportSuccess\": \"MCP サーバーをインポートしました\",\n    \"mcpImportSuccessDescription\": \"{{count}} 件のサーバーをインポートしました\",\n    \"mcpPartialSuccess\": \"一部のみインポート成功\",\n    \"mcpPartialSuccessDescription\": \"成功: {{success}}、失敗: {{failed}}\",\n    \"skillImportSuccess\": \"スキルリポジトリを追加しました\",\n    \"skillImportSuccessDescription\": \"追加したリポジトリ: {{repo}}\",\n    \"app\": \"アプリ種別\",\n    \"providerName\": \"プロバイダー名\",\n    \"homepage\": \"ホームページ\",\n    \"endpoint\": \"API エンドポイント\",\n    \"apiKey\": \"API Key\",\n    \"icon\": \"アイコン\",\n    \"model\": \"モデル\",\n    \"haikuModel\": \"Haiku モデル\",\n    \"sonnetModel\": \"Sonnet モデル\",\n    \"opusModel\": \"Opus モデル\",\n    \"multiModel\": \"マルチモーダルモデル\",\n    \"notes\": \"メモ\",\n    \"import\": \"インポート\",\n    \"importing\": \"インポート中...\",\n    \"warning\": \"インポート前に内容を確認してください。後から一覧で編集・削除できます。\",\n    \"parseError\": \"ディープリンクの解析に失敗しました\",\n    \"importSuccess\": \"インポート成功\",\n    \"importSuccessDescription\": \"プロバイダー「{{name}}」をインポートしました\",\n    \"importError\": \"インポートに失敗しました\",\n    \"configSource\": \"設定ソース\",\n    \"configEmbedded\": \"埋め込み設定\",\n    \"configRemote\": \"リモート設定\",\n    \"configDetails\": \"設定の詳細\",\n    \"configUrl\": \"設定ファイル URL\",\n    \"configMergeError\": \"設定ファイルのマージに失敗しました\",\n    \"primaryEndpoint\": \"メイン\",\n    \"mcp\": {\n      \"title\": \"MCP サーバーを一括インポート\",\n      \"targetApps\": \"ターゲットアプリ\",\n      \"serverCount\": \"MCP サーバー（{{count}} 件）\",\n      \"enabledWarning\": \"インポート後、指定したすべてのアプリに即座に書き込まれます\"\n    },\n    \"prompt\": {\n      \"title\": \"システムプロンプトをインポート\",\n      \"app\": \"アプリ\",\n      \"name\": \"名前\",\n      \"description\": \"説明\",\n      \"contentPreview\": \"内容プレビュー\",\n      \"enabledWarning\": \"インポート後すぐにこのプロンプトが有効になり、他は無効になります\"\n    },\n    \"skill\": {\n      \"title\": \"Claude スキルリポジトリを追加\",\n      \"repo\": \"GitHub リポジトリ\",\n      \"directory\": \"対象ディレクトリ\",\n      \"branch\": \"ブランチ\",\n      \"skillsPath\": \"スキルパス\",\n      \"hint\": \"この操作でスキルリポジトリが一覧に追加されます。\",\n      \"hintDetail\": \"追加後、スキル管理ページから個別のスキルをインストールできます。\"\n    },\n    \"usageScript\": \"使用量クエリ\",\n    \"usageScriptEnabled\": \"有効\",\n    \"usageScriptDisabled\": \"無効\",\n    \"usageApiKey\": \"使用量 API キー\",\n    \"usageBaseUrl\": \"使用量クエリ URL\",\n    \"usageAutoInterval\": \"自動クエリ\",\n    \"usageAutoIntervalValue\": \"{{minutes}} 分ごと\"\n  },\n  \"iconPicker\": {\n    \"search\": \"アイコンを検索\",\n    \"searchPlaceholder\": \"アイコン名を入力...\",\n    \"noResults\": \"一致するアイコンが見つかりません\",\n    \"category\": {\n      \"aiProvider\": \"AI プロバイダー\",\n      \"cloud\": \"クラウドプラットフォーム\",\n      \"tool\": \"開発ツール\",\n      \"other\": \"その他\"\n    }\n  },\n  \"providerIcon\": {\n    \"label\": \"アイコン\",\n    \"colorLabel\": \"アイコンカラー\",\n    \"selectIcon\": \"アイコンを選択\",\n    \"preview\": \"プレビュー\",\n    \"clickToChange\": \"クリックでアイコンを変更\",\n    \"clickToSelect\": \"クリックでアイコンを選択\",\n    \"color\": \"アイコンカラー\"\n  },\n  \"migration\": {\n    \"success\": \"設定の移行が完了しました\",\n    \"skillsSuccess\": \"スキルを {{count}} 件、自動的に統合管理へインポートしました\",\n    \"skillsFailed\": \"スキルの自動インポートに失敗しました\",\n    \"skillsFailedDescription\": \"Skills 画面で「既存をインポート」をクリックして手動でインポートしてください（または再起動して再試行）。\"\n  },\n  \"agents\": {\n    \"title\": \"エージェント\"\n  },\n  \"modelTest\": {\n    \"testProvider\": \"モデルテスト\"\n  },\n  \"health\": {\n    \"operational\": \"正常\",\n    \"degraded\": \"低下\",\n    \"failed\": \"失敗\",\n    \"circuitOpen\": \"サーキットオープン\",\n    \"consecutiveFailures\": \"{{count}} 回連続失敗\"\n  },\n  \"failover\": {\n    \"enabled\": \"{{app}} フェイルオーバーが有効になりました\",\n    \"disabled\": \"{{app}} フェイルオーバーが無効になりました\",\n    \"toggleFailed\": \"操作に失敗しました: {{detail}}\",\n    \"inQueue\": \"キュー内\",\n    \"addQueue\": \"追加\",\n    \"priority\": {\n      \"tooltip\": \"フェイルオーバー優先度 {{priority}}\"\n    },\n    \"tooltip\": {\n      \"enabled\": \"{{app}} フェイルオーバーが有効\\nキューの優先度（P1→P2→...）で使用します\",\n      \"disabled\": \"{{app}} フェイルオーバーを有効にする\\nキューの P1 に即時切替し、失敗時は次を順に試行します\"\n    }\n  },\n  \"proxy\": {\n    \"panel\": {\n      \"serviceAddress\": \"サービスアドレス\",\n      \"addressCopied\": \"アドレスをコピーしました\",\n      \"currentProvider\": \"現在のプロバイダー:\",\n      \"waitingFirstRequest\": \"現在のプロバイダー: 最初のリクエスト待ち...\",\n      \"stoppedTitle\": \"プロキシサービス停止中\",\n      \"stoppedDescription\": \"上のトグルでサービスを開始できます\",\n      \"openSettings\": \"プロキシサービスを設定\",\n      \"stats\": {\n        \"activeConnections\": \"アクティブ接続\",\n        \"totalRequests\": \"総リクエスト数\",\n        \"successRate\": \"成功率\",\n        \"uptime\": \"稼働時間\"\n      }\n    },\n    \"settings\": {\n      \"title\": \"プロキシサービス設定\",\n      \"description\": \"ローカルプロキシサーバーのリッスンアドレス、ポート、実行パラメータを設定します。保存後すぐに反映されます。\",\n      \"alert\": {\n        \"autoApply\": \"変更は実行中のプロキシサービスに自動的に同期され、手動での再起動は不要です。\"\n      },\n      \"basic\": {\n        \"title\": \"基本設定\",\n        \"description\": \"プロキシサービスのリッスンアドレスとポートを設定します。\"\n      },\n      \"advanced\": {\n        \"title\": \"詳細パラメータ\",\n        \"description\": \"リクエストの安定性とログ記録を制御します。\"\n      },\n      \"timeout\": {\n        \"title\": \"タイムアウト設定\",\n        \"description\": \"ストリーミングと非ストリーミングリクエストのタイムアウトを設定します。\"\n      },\n      \"fields\": {\n        \"listenAddress\": {\n          \"label\": \"リッスンアドレス\",\n          \"placeholder\": \"127.0.0.1\",\n          \"description\": \"プロキシサーバーがリッスンするIPアドレス（推奨: 127.0.0.1）\"\n        },\n        \"listenPort\": {\n          \"label\": \"リッスンポート\",\n          \"placeholder\": \"15721\",\n          \"description\": \"プロキシサーバーがリッスンするポート番号（1024 ~ 65535）\"\n        },\n        \"maxRetries\": {\n          \"label\": \"最大リトライ回数\",\n          \"placeholder\": \"3\",\n          \"description\": \"リクエスト失敗時のリトライ回数（0 ~ 10）\"\n        },\n        \"requestTimeout\": {\n          \"label\": \"リクエストタイムアウト（秒）\",\n          \"placeholder\": \"0（無制限）または 300\",\n          \"description\": \"単一リクエストの最大待機時間（0 = 無制限、または 10 ~ 600 秒）\"\n        },\n        \"enableLogging\": {\n          \"label\": \"ログ記録を有効化\",\n          \"description\": \"トラブルシューティングのためにすべてのプロキシリクエストを記録\"\n        },\n        \"streamingFirstByteTimeout\": {\n          \"label\": \"ストリーミング初回バイトタイムアウト（秒）\",\n          \"description\": \"最初のデータチャンクを待つ最大時間\"\n        },\n        \"streamingIdleTimeout\": {\n          \"label\": \"ストリーミングアイドルタイムアウト（秒）\",\n          \"description\": \"データチャンク間の最大間隔\"\n        },\n        \"nonStreamingTimeout\": {\n          \"label\": \"非ストリーミングタイムアウト（秒）\",\n          \"description\": \"非ストリーミングリクエストの総タイムアウト\"\n        }\n      },\n      \"validation\": {\n        \"addressInvalid\": \"有効なIPアドレスを入力してください\",\n        \"portMin\": \"ポートは1024より大きい必要があります\",\n        \"portMax\": \"ポートは65535より小さい必要があります\",\n        \"retryMin\": \"リトライ回数は負の値にできません\",\n        \"retryMax\": \"リトライ回数は10を超えることはできません\",\n        \"timeoutNonNegative\": \"タイムアウトは負の値にできません\",\n        \"timeoutMax\": \"タイムアウトは600秒を超えることはできません\",\n        \"timeoutRange\": \"0または10-600の間の値を入力してください\",\n        \"streamingTimeoutMin\": \"タイムアウトは少なくとも5秒必要です\",\n        \"streamingTimeoutMax\": \"タイムアウトは300秒を超えることはできません\"\n      },\n      \"actions\": {\n        \"save\": \"設定を保存\"\n      },\n      \"toast\": {\n        \"saved\": \"プロキシ設定を保存しました\",\n        \"saveFailed\": \"保存に失敗しました: {{error}}\"\n      },\n      \"invalidPort\": \"無効なポートです。1024〜65535 の数値を入力してください\",\n      \"invalidAddress\": \"無効なアドレスです。有効な IP アドレス（例: 127.0.0.1）または localhost を入力してください\",\n      \"configSaved\": \"プロキシ設定を保存しました\",\n      \"configSaveFailed\": \"設定の保存に失敗しました\",\n      \"restartRequired\": \"アドレスまたはポートの変更を反映するにはプロキシサービスの再起動が必要です\"\n    },\n    \"switchFailed\": \"切り替えに失敗しました: {{error}}\",\n    \"takeover\": {\n      \"hint\": \"テイクオーバーするアプリを選択します。有効にすると、そのアプリのリクエストはローカルプロキシ経由で転送されます\",\n      \"enabled\": \"{{app}} テイクオーバー有効\",\n      \"disabled\": \"{{app}} テイクオーバー無効\",\n      \"failed\": \"テイクオーバーの切り替えに失敗しました\",\n      \"tooltip\": {\n        \"active\": \"{{appLabel}} がインターセプト中 - {{address}}:{{port}}\\nホットスイッチングのためプロバイダを切り替え\",\n        \"broken\": \"{{appLabel}} がインターセプト中ですが、プロキシサービスが実行されていません\",\n        \"inactive\": \"{{appLabel}} のライブ設定をインターセプトしてリクエストをローカルプロキシ経由でルーティング\"\n      }\n    },\n    \"failover\": {\n      \"proxyRequired\": \"フェイルオーバーを設定するには、プロキシサービスを先に起動する必要があります\",\n      \"autoSwitch\": \"自動フェイルオーバー\",\n      \"autoSwitchDescription\": \"有効にするとキューの P1 に即時切り替え、リクエスト失敗時はキュー内の次のプロバイダーを自動で試行します\"\n    },\n    \"failoverQueue\": {\n      \"title\": \"フェイルオーバーキュー\",\n      \"description\": \"各アプリのプロバイダーのフェイルオーバー順序を管理します\",\n      \"info\": \"自動フェイルオーバーを有効にすると、キューの優先度順（P1 優先）でプロバイダーを使用します。失敗時はキュー内の次のプロバイダーを順に試行します。\",\n      \"selectProvider\": \"キューに追加するプロバイダーを選択\",\n      \"noAvailableProviders\": \"追加できるプロバイダーがありません\",\n      \"empty\": \"フェイルオーバーキューが空です。自動フェイルオーバーを有効にするにはプロバイダーを追加してください。\",\n      \"orderHint\": \"キューの順序はホームのプロバイダー一覧の順序と一致します。ホームでドラッグして順序を変更できます。\",\n      \"dragHint\": \"ドラッグでフェイルオーバー順序を調整します。番号が小さいほど優先度が高くなります。\",\n      \"toggleEnabled\": \"有効/無効\",\n      \"addSuccess\": \"フェイルオーバーキューに追加しました\",\n      \"addFailed\": \"追加に失敗しました\",\n      \"removeSuccess\": \"フェイルオーバーキューから削除しました\",\n      \"removeFailed\": \"削除に失敗しました\",\n      \"reorderSuccess\": \"キュー順序を更新しました\",\n      \"reorderFailed\": \"順序の更新に失敗しました\",\n      \"toggleFailed\": \"状態の更新に失敗しました\"\n    },\n    \"autoFailover\": {\n      \"info\": \"フェイルオーバーキューに複数のプロバイダーが設定されている場合、リクエストが失敗すると優先度順に試行します。プロバイダーが連続失敗のしきい値に達すると、サーキットブレーカーが開き、一時的にスキップされます。\",\n      \"configSaved\": \"自動フェイルオーバー設定を保存しました\",\n      \"configSaveFailed\": \"保存に失敗しました\",\n      \"validationFailed\": \"以下のフィールドが有効範囲外です: {{fields}}\",\n      \"retrySettings\": \"リトライとタイムアウト設定\",\n      \"failureThreshold\": \"失敗しきい値\",\n      \"failureThresholdHint\": \"この回数連続で失敗するとサーキットブレーカーが開きます（推奨: 3-10）\",\n      \"timeout\": \"回復待ち時間（秒）\",\n      \"timeoutHint\": \"サーキットが開いた後、回復を試みるまでの待ち時間（推奨: 30-120）\",\n      \"circuitBreakerSettings\": \"サーキットブレーカー設定\",\n      \"successThreshold\": \"回復成功しきい値\",\n      \"successThresholdHint\": \"半開状態でこの回数成功するとサーキットブレーカーが閉じます\",\n      \"errorRate\": \"エラー率しきい値 (%)\",\n      \"errorRateHint\": \"この値を超えるとサーキットブレーカーが開きます\",\n      \"minRequests\": \"最小リクエスト数\",\n      \"minRequestsHint\": \"エラー率を計算する前の最小リクエスト数\",\n      \"explanationTitle\": \"仕組み\",\n      \"failureThresholdLabel\": \"失敗しきい値\",\n      \"failureThresholdExplain\": \"この回数連続で失敗すると、サーキットブレーカーが開き、プロバイダーは一時的に利用不可になります\",\n      \"timeoutLabel\": \"回復待ち時間\",\n      \"timeoutExplain\": \"サーキットが開いた後、半開状態を試みるまでの待ち時間\",\n      \"successThresholdLabel\": \"回復成功しきい値\",\n      \"successThresholdExplain\": \"半開状態でこの回数成功するとサーキットブレーカーが閉じ、プロバイダーが再び利用可能になります\",\n      \"errorRateLabel\": \"エラー率しきい値\",\n      \"errorRateExplain\": \"失敗しきい値に達していなくても、エラー率がこの値を超えるとサーキットブレーカーが開きます\",\n      \"maxRetries\": \"最大リトライ回数\",\n      \"timeoutSettings\": \"タイムアウト設定\",\n      \"streamingFirstByte\": \"ストリーミング最初のバイトタイムアウト\",\n      \"streamingIdle\": \"ストリーミングアイドルタイムアウト\",\n      \"nonStreaming\": \"非ストリーミングタイムアウト\",\n      \"maxRetriesHint\": \"リクエスト失敗時のリトライ回数（0-10）\",\n      \"streamingFirstByteHint\": \"最初のデータチャンクを待つ最大時間、範囲 1-120 秒、デフォルト 60 秒\",\n      \"streamingIdleHint\": \"データチャンク間の最大間隔、範囲 60-600 秒、0 で無効化（途中停止を防止）\",\n      \"nonStreamingHint\": \"非ストリーミングリクエストの合計タイムアウト、範囲 60-1200 秒、デフォルト 600 秒（10 分）\"\n    },\n    \"logging\": {\n      \"enabled\": \"ログ記録が有効になりました\",\n      \"disabled\": \"ログ記録が無効になりました\",\n      \"failed\": \"ログ状態の切り替えに失敗しました\"\n    },\n    \"server\": {\n      \"started\": \"プロキシサービスが開始されました - {{address}}:{{port}}\",\n      \"startFailed\": \"プロキシサービスの開始に失敗しました: {{detail}}\"\n    },\n    \"stoppedWithRestore\": \"プロキシサービスが停止し、すべてのテイクオーバー設定が復元されました\",\n    \"stopWithRestoreFailed\": \"停止に失敗しました: {{detail}}\"\n  },\n  \"streamCheck\": {\n    \"configSaved\": \"ヘルスチェック設定を保存しました\",\n    \"configSaveFailed\": \"保存に失敗しました\",\n    \"testModels\": \"テストモデル\",\n    \"claudeModel\": \"Claude モデル\",\n    \"codexModel\": \"Codex モデル\",\n    \"geminiModel\": \"Gemini モデル\",\n    \"checkParams\": \"チェックパラメーター\",\n    \"timeout\": \"タイムアウト（秒）\",\n    \"maxRetries\": \"最大リトライ回数\",\n    \"degradedThreshold\": \"劣化しきい値（ミリ秒）\",\n    \"testPrompt\": \"テストプロンプト\",\n    \"operational\": \"{{providerName}} は正常に動作しています ({{responseTimeMs}}ms)\",\n    \"degraded\": \"{{providerName}} の応答が遅いです ({{responseTimeMs}}ms)\",\n    \"failed\": \"{{providerName}} のチェックに失敗しました: {{message}}\",\n    \"error\": \"{{providerName}} のチェックでエラーが発生しました: {{error}}\"\n  },\n  \"proxyConfig\": {\n    \"proxyEnabled\": \"プロキシ有効\",\n    \"appTakeover\": \"プロキシ有効\",\n    \"perAppConfig\": \"アプリ別設定\",\n    \"circuitBreaker\": \"サーキットブレーカー\",\n    \"circuitBreakerSettings\": \"サーキットブレーカー設定\",\n    \"failureThreshold\": \"失敗閾値\",\n    \"successThreshold\": \"回復閾値\",\n    \"recoveryTimeout\": \"回復待機時間\",\n    \"errorRateThreshold\": \"エラー率閾値\",\n    \"minRequests\": \"最小リクエスト数\",\n    \"timeoutConfig\": \"タイムアウト設定\",\n    \"streamingFirstByte\": \"ストリーミング初回バイトタイムアウト\",\n    \"streamingIdle\": \"ストリーミングアイドルタイムアウト\",\n    \"nonStreaming\": \"非ストリーミングタイムアウト\"\n  },\n  \"circuitBreaker\": {\n    \"failureThreshold\": \"失敗閾値\",\n    \"successThreshold\": \"成功閾値\",\n    \"timeoutSeconds\": \"タイムアウト（秒）\",\n    \"errorRateThreshold\": \"エラー率閾値 (%)\",\n    \"minRequests\": \"最小リクエスト数\",\n    \"validationFailed\": \"以下のフィールドが有効範囲外です: {{fields}}\",\n    \"configSaved\": \"サーキットブレーカー設定が保存されました\",\n    \"saveFailed\": \"保存に失敗しました\",\n    \"loading\": \"読み込み中...\",\n    \"title\": \"サーキットブレーカー設定\",\n    \"description\": \"サーキットブレーカーパラメータを調整して、障害検出と復旧動作を制御します\",\n    \"failureThresholdHint\": \"連続失敗後にサーキットブレーカーを開く回数\",\n    \"timeoutSecondsHint\": \"サーキットブレーカーを開いた後、復旧を試みるまでの時間（半開状態）\",\n    \"successThresholdHint\": \"半開状態で成功してサーキットブレーカーを閉じる回数\",\n    \"errorRateThresholdHint\": \"エラー率がこの値を超えるとサーキットブレーカーを開く\",\n    \"minRequestsHint\": \"エラー率を計算する前の最小リクエスト数\",\n    \"saveConfig\": \"設定を保存\",\n    \"instructionsTitle\": \"設定説明\",\n    \"instructions\": {\n      \"failureThreshold\": \"連続失敗がこの回数に達するとサーキットブレーカーが開く\",\n      \"timeout\": \"サーキットブレーカーを開いた後、この時間待機してから半開を試みる\",\n      \"successThreshold\": \"半開状態で成功がこの回数に達するとサーキットブレーカーを閉じる\",\n      \"errorRate\": \"エラー率がこの値を超えるとサーキットブレーカーが開く\",\n      \"minRequests\": \"リクエスト数がこの値に達した後にのみエラー率が計算される\"\n    }\n  },\n  \"universalProvider\": {\n    \"title\": \"統合プロバイダー\",\n    \"description\": \"統合プロバイダーは Claude、Codex、Gemini の設定を同時に管理します。変更は有効なすべてのアプリに自動的に同期されます。\",\n    \"add\": \"統合プロバイダーを追加\",\n    \"edit\": \"統合プロバイダーを編集\",\n    \"empty\": \"統合プロバイダーがありません\",\n    \"emptyHint\": \"下の「統合プロバイダーを追加」ボタンをクリックして作成してください\",\n    \"selectPreset\": \"プリセットタイプを選択\",\n    \"name\": \"名前\",\n    \"namePlaceholder\": \"例：私の NewAPI\",\n    \"baseUrl\": \"API URL\",\n    \"apiKey\": \"API キー\",\n    \"websiteUrl\": \"ウェブサイト URL\",\n    \"websiteUrlPlaceholder\": \"https://example.com（オプション、リストに表示されます）\",\n    \"notes\": \"メモ\",\n    \"notesPlaceholder\": \"オプション：メモを追加\",\n    \"enabledApps\": \"有効なアプリ\",\n    \"modelConfig\": \"モデル設定\",\n    \"model\": \"モデル\",\n    \"sync\": \"アプリに同期\",\n    \"synced\": \"すべてのアプリに同期しました\",\n    \"syncError\": \"同期に失敗しました\",\n    \"noAppsEnabled\": \"有効なアプリがありません\",\n    \"added\": \"統合プロバイダーを追加しました\",\n    \"addedAndSynced\": \"統合プロバイダーを追加して同期しました\",\n    \"updated\": \"統合プロバイダーを更新しました\",\n    \"deleted\": \"統合プロバイダーを削除しました\",\n    \"addSuccess\": \"統合プロバイダーを追加しました\",\n    \"addFailed\": \"統合プロバイダーの追加に失敗しました\",\n    \"hint\": \"クロスアプリ統合設定。Claude/Codex/Gemini に自動同期します\",\n    \"manage\": \"管理\",\n    \"loadError\": \"統合プロバイダーの読み込みに失敗しました\",\n    \"saveError\": \"統合プロバイダーの保存に失敗しました\",\n    \"deleteError\": \"統合プロバイダーの削除に失敗しました\",\n    \"deleteConfirmTitle\": \"統合プロバイダーを削除\",\n    \"deleteConfirmDescription\": \"「{{name}}」を削除してもよろしいですか？各アプリで生成されたプロバイダー設定も削除されます。\",\n    \"syncConfirmTitle\": \"統合プロバイダーを同期\",\n    \"syncConfirmDescription\": \"「{{name}}」を同期すると、Claude、Codex、Gemini の関連プロバイダー設定が上書きされます。続行しますか？\",\n    \"syncConfirm\": \"同期\",\n    \"saveAndSync\": \"保存して同期\",\n    \"savedAndSynced\": \"すべてのアプリに保存・同期されました\",\n    \"saveAndSyncError\": \"保存と同期に失敗しました\",\n    \"configJsonPreview\": \"設定 JSON プレビュー\",\n    \"configJsonPreviewHint\": \"以下は各アプリに同期される設定内容です（表示されているフィールドのみ上書きされ、他のカスタム設定は保持されます）\"\n  },\n  \"omo\": {\n    \"editProfile\": \"OMO 設定を編集\",\n    \"newProfile\": \"新規 OMO 設定\",\n    \"profileName\": \"名前\",\n    \"mainAgents\": \"メインエージェント\",\n    \"subAgents\": \"サブエージェント\",\n    \"categories\": \"カテゴリ\",\n    \"customAgents\": \"カスタムエージェント\",\n    \"noCustomAgents\": \"カスタムエージェントなし\",\n    \"otherFields\": \"その他の設定\",\n    \"globalConfig\": \"OMO グローバル設定\",\n    \"globalConfigShort\": \"OMO 設定\",\n    \"globalConfigSaved\": \"グローバル設定を保存しました\",\n    \"addProfile\": \"OMO プロバイダーを追加\",\n    \"disabledItems\": \"無効項目設定\",\n    \"advanced\": \"詳細設定\",\n    \"profileCreated\": \"OMO 設定を作成しました\",\n    \"profileUpdated\": \"OMO 設定を更新しました\",\n    \"invalidJson\": \"その他のフィールドに無効なJSONが含まれています\",\n    \"confirmDelete\": \"設定を削除\",\n    \"confirmDeleteMsg\": \"「{{name}}」を削除しますか？\",\n    \"profileDeleted\": \"設定を削除しました\",\n    \"imported\": \"「{{name}}」としてインポートしました\",\n    \"import\": \"インポート\",\n    \"global\": \"グローバル\",\n    \"empty\": \"OMO 設定がありません。+ 追加またはローカルからインポートしてください。\",\n    \"applied\": \"適用済み\",\n    \"apply\": \"適用\",\n    \"enable\": \"有効化\",\n    \"enabled\": \"有効中\",\n    \"disabled\": \"OMO を無効化しました\",\n    \"disableFailed\": \"OMO の無効化に失敗しました: {{error}}\",\n    \"selectPlaceholder\": \"選択してください...\",\n    \"clear\": \"クリア\",\n    \"clearWrapped\": \"(クリア)\",\n    \"defaultWrapped\": \"(デフォルト)\",\n    \"variantPlaceholder\": \"variant\",\n    \"selectEnabledModel\": \"設定済みモデルを選択\",\n    \"selectModel\": \"設定済みモデルを選択\",\n    \"recommendedHint\": \"推奨: {{model}}\",\n    \"searchModel\": \"モデルを検索...\",\n    \"selectModelFirst\": \"先にモデルを選択\",\n    \"noEnabledModels\": \"設定済みモデルがありません\",\n    \"noVariantsForModel\": \"このモデルには思考レベルがありません\",\n    \"currentValueNotEnabled\": \"{{value}} (現在値・未設定)\",\n    \"currentValueUnavailable\": \"{{value}} (現在値・利用不可)\",\n    \"advancedLabel\": \"詳細\",\n    \"advancedJsonInvalid\": \"詳細 JSON が不正です\",\n    \"advancedJsonHint\": \"temperature, top_p, budgetTokens, prompt_append, permission など。空欄でデフォルトを使用します\",\n    \"noEnabledModelsWarning\": \"利用可能な設定済みモデルがありません。先に OpenCode モデルを設定してください。\",\n    \"modelSourcePartialWarning\": \"一部プロバイダーのモデル設定が不正なため、候補から除外しました。\",\n    \"modelSourceFallbackWarning\": \"live プロバイダー状態の取得に失敗したため、設定済みプロバイダーへフォールバックしました。\",\n    \"importLocalReplaceSuccess\": \"ローカルファイルから読み込み、Agents/Categories/Other Fields を置き換えました\",\n    \"importLocalFailed\": \"ローカルファイルの読み込みに失敗しました: {{error}}\",\n    \"agentKeyPlaceholder\": \"agent キー\",\n    \"categoryKeyPlaceholder\": \"カテゴリキー\",\n    \"modelNamePlaceholder\": \"model-name\",\n    \"custom\": \"カスタム\",\n    \"customCategories\": \"カスタムカテゴリ\",\n    \"modelConfiguration\": \"モデル設定\",\n    \"fillRecommended\": \"推奨を入力\",\n    \"fillRecommendedSuccess\": \"推奨モデル {{count}} 件を設定しました\",\n    \"fillRecommendedPartial\": \"推奨モデル {{filled}} 件を設定、{{unmatched}} 件は未一致\",\n    \"fillRecommendedAllSet\": \"すべてのスロットにモデルが設定済みです\",\n    \"fillRecommendedNoMatch\": \"推奨モデルが設定済みプロバイダーに見つかりませんでした\",\n    \"configSummary\": \"{{agents}} 個の Agent、{{categories}} 個の Category を設定済み · ⚙ で詳細を展開\",\n    \"enabledModelsCount\": \"設定済みモデル {{count}} 件\",\n    \"source\": \"出典:\",\n    \"otherFieldsJson\": \"その他のフィールド (JSON)\",\n    \"searchOrType\": \"検索またはカスタム値を入力...\",\n    \"noMatches\": \"一致する項目がありません\",\n    \"jsonMustBeObject\": \"{{field}} は JSON オブジェクトである必要があります\",\n    \"jsonInvalid\": \"{{field}} に無効な JSON が含まれています\",\n    \"importGlobalSuccess\": \"ローカルファイルからグローバル設定を読み込みました（未保存）\",\n    \"importGlobalFailed\": \"ローカルファイルの読み込みに失敗しました: {{error}}\",\n    \"importLocal\": \"ローカルからインポート\",\n    \"saveGlobalConfig\": \"グローバル設定を保存\",\n    \"schemaUrl\": \"$schema\",\n    \"resetDefault\": \"デフォルトに戻す\",\n    \"sisyphusAgentConfig\": \"Sisyphus Agent 設定\",\n    \"disabledAgents\": \"Agents\",\n    \"disabledAgentsPlaceholder\": \"無効化する Agents\",\n    \"disabledMcps\": \"MCPs\",\n    \"disabledMcpsPlaceholder\": \"無効化する MCPs\",\n    \"disabledHooks\": \"Hooks\",\n    \"disabledHooksPlaceholder\": \"無効化する Hooks\",\n    \"disabledSkills\": \"Skills\",\n    \"disabledSkillsPlaceholder\": \"無効化する Skills\",\n    \"advancedLsp\": \"LSP 設定\",\n    \"advancedExperimental\": \"実験的機能\",\n    \"advancedBackgroundTask\": \"バックグラウンドタスク\",\n    \"advancedBrowserAutomation\": \"ブラウザ自動化\",\n    \"advancedClaudeCode\": \"Claude Code\",\n    \"agentDesc\": {\n      \"sisyphus\": \"メインオーケストレーター\",\n      \"hephaestus\": \"自律型ディープワーカー\",\n      \"prometheus\": \"戦略プランナー\",\n      \"atlas\": \"タスクマネージャー\",\n      \"oracle\": \"戦略アドバイザー\",\n      \"librarian\": \"マルチリポジトリ研究員\",\n      \"explore\": \"高速コード検索\",\n      \"multimodalLooker\": \"メディアアナライザー\",\n      \"metis\": \"計画前分析アドバイザー\",\n      \"momus\": \"プランレビュアー\",\n      \"sisyphusJunior\": \"委任タスクエグゼキューター\"\n    },\n    \"agentTooltip\": {\n      \"sisyphus\": \"メインオーケストレーター。タスクの計画、委任、並列実行を担当。拡張思考（32k バジェット）を使用し、TODO リストでワークフローを駆動してタスク完了を確保します。\",\n      \"atlas\": \"メインオーケストレーター（TODO リスト保持）。実行フェーズでのタスク配分と調整を担当。すべての作業を直接行うのではなく、専門エージェントに委任します。\",\n      \"prometheus\": \"戦略プランナー。インタビューモードで要件を収集し、詳細な作業計画を策定。.sisyphus/ ディレクトリ内の Markdown ファイルの読み書きのみ可能で、直接コードを書くことはありません。\",\n      \"hephaestus\": \"自律型ディープワーカー（「正当な職人」）。AmpCode ディープモードに着想を得た目標指向の実行を行い、行動前に 2-5 個の探索/ライブラリアンエージェントを並列起動して調査します。\",\n      \"oracle\": \"アーキテクチャ決定とデバッグのアドバイザー。読み取り専用のコンサルティングエージェントで、優れた論理的推論と深い分析を提供。ファイルの書き込みやタスクの委任はできません。\",\n      \"librarian\": \"マルチリポジトリ分析とドキュメント検索の専門家。コードベースを深く理解し、エビデンスに基づく回答を提供。公式ドキュメントやオープンソース実装例の検索に長けています。\",\n      \"explore\": \"高速コードベース探索とコンテキスト grep の専門家。軽量モデルを使用した高速検索で、コード構造を理解するための先鋒です。\",\n      \"multimodalLooker\": \"ビジュアルコンテンツの専門家。PDF、画像、グラフなどの非テキストメディアを分析し、情報とインサイトを抽出します。\",\n      \"metis\": \"プランコンサルタント。計画前に事前分析を行い、隠れた意図、曖昧な点、AI の失敗ポイントを特定して、過剰エンジニアリングを防止します。\",\n      \"momus\": \"プランレビュアー。計画の明確さ、検証可能性、完全性を高精度で検証。計画が完璧になるまで却下と修正を要求します。\",\n      \"sisyphusJunior\": \"カテゴリ生成のエグゼキューター。category パラメータにより自動生成され、割り当てられたタスクの実行に専念し、再委任はできません。無限委任ループを防止します。\"\n    },\n    \"categoryDesc\": {\n      \"visualEngineering\": \"ビジュアル/フロントエンド工学\",\n      \"ultrabrain\": \"超深度思考\",\n      \"deep\": \"ディープワーク\",\n      \"artistry\": \"クリエイティブ/芸術\",\n      \"quick\": \"クイックレスポンス\",\n      \"unspecifiedLow\": \"汎用ロースペック\",\n      \"unspecifiedHigh\": \"汎用ハイスペック\",\n      \"writing\": \"ライティング\"\n    },\n    \"categoryTooltip\": {\n      \"visualEngineering\": \"フロントエンドとビジュアルエンジニアリングカテゴリ。UI/UX デザイン、スタイリング、アニメーション、インターフェース実装に特化。デフォルトで Gemini 3 Pro モデルを使用。\",\n      \"ultrabrain\": \"ディープロジック推論カテゴリ。広範な分析が必要な複雑なアーキテクチャ決定に使用。デフォルトで GPT-5.3 Codex の超高推論バリアントを使用。\",\n      \"deep\": \"ディープ自律問題解決カテゴリ。目標指向の実行で、行動前に徹底的な調査を実施。深い理解が必要な難問に適しています。\",\n      \"artistry\": \"高度にクリエイティブで芸術的なタスクカテゴリ。斬新なアイデアとクリエイティブなソリューションを促進。デフォルトで Gemini 3 Pro の max バリアントを使用。\",\n      \"quick\": \"軽量タスクカテゴリ。単一ファイルの修正、タイポ修正、簡単な調整などの些細な作業に使用。デフォルトで Claude Haiku 4.5 高速モデルを使用。\",\n      \"unspecifiedLow\": \"未分類の低作業量タスクカテゴリ。他のカテゴリに該当せず作業量が小さいタスクに適用。デフォルトで Claude Sonnet 4.5 を使用。\",\n      \"unspecifiedHigh\": \"未分類の高作業量タスクカテゴリ。他のカテゴリに該当せず作業量が大きいタスクに適用。デフォルトで Claude Opus 4.6 の max バリアントを使用。\",\n      \"writing\": \"ライティングカテゴリ。ドキュメント、散文、技術文書に特化。デフォルトで Gemini 3 Flash 高速生成モデルを使用。\"\n    },\n    \"slimAgentDesc\": {\n      \"orchestrator\": \"オーケストレーター\",\n      \"oracle\": \"オラクル\",\n      \"librarian\": \"ライブラリアン\",\n      \"explorer\": \"エクスプローラー\",\n      \"designer\": \"デザイナー\",\n      \"fixer\": \"フィクサー\"\n    },\n    \"slimAgentTooltip\": {\n      \"orchestrator\": \"実行コードの作成、マルチエージェントワークフローの調整、エキスパートの召喚\",\n      \"oracle\": \"根本原因分析、アーキテクチャレビュー、デバッグガイダンス（読み取り専用）\",\n      \"librarian\": \"ドキュメント検索、GitHubコード検索（読み取り専用）\",\n      \"explorer\": \"正規表現検索、ASTパターンマッチング、ファイル検出（読み取り専用）\",\n      \"designer\": \"モダンなレスポンシブデザイン、CSS/Tailwindの専門知識\",\n      \"fixer\": \"コード実装、リファクタリング、テスト、検証\"\n    }\n  },\n  \"openclawConfig\": {\n    \"defaultModel\": {\n      \"title\": \"デフォルトモデル\",\n      \"description\": \"OpenClaw のデフォルトのプライマリモデルとフォールバックモデルを設定します\",\n      \"primary\": \"プライマリモデル\",\n      \"primaryPlaceholder\": \"例: deepseek/deepseek-chat\",\n      \"fallbacks\": \"フォールバックモデル\",\n      \"fallbacksPlaceholder\": \"例: openrouter/anthropic/claude-sonnet-4.5\",\n      \"addFallback\": \"フォールバックモデルを追加\",\n      \"saved\": \"デフォルトモデル設定を保存しました\",\n      \"saveFailed\": \"デフォルトモデルの保存に失敗しました\"\n    },\n    \"modelCatalog\": {\n      \"title\": \"モデルカタログ\",\n      \"description\": \"モデルのエイリアスと許可リストを設定します\",\n      \"modelId\": \"モデル ID\",\n      \"modelIdPlaceholder\": \"例: deepseek/deepseek-chat\",\n      \"alias\": \"エイリアス\",\n      \"aliasPlaceholder\": \"例: DeepSeek\",\n      \"addEntry\": \"モデルを追加\",\n      \"removeEntry\": \"削除\",\n      \"saved\": \"モデルカタログを保存しました\",\n      \"saveFailed\": \"モデルカタログの保存に失敗しました\",\n      \"empty\": \"モデルカタログエントリがありません\",\n      \"emptyHint\": \"エイリアスを設定するにはカタログにモデルを追加してください\"\n    },\n    \"suggestedDefaults\": \"推奨デフォルト設定を適用\",\n    \"suggestedDefaultsHint\": \"このプリセットの推奨デフォルトモデル設定を使用します\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/zh.json",
    "content": "{\n  \"app\": {\n    \"title\": \"CC Switch\",\n    \"description\": \"Claude Code / Codex / Gemini CLI 全方位辅助工具\"\n  },\n  \"common\": {\n    \"add\": \"添加\",\n    \"edit\": \"编辑\",\n    \"delete\": \"删除\",\n    \"save\": \"保存\",\n    \"saving\": \"保存中...\",\n    \"cancel\": \"取消\",\n    \"confirm\": \"确定\",\n    \"close\": \"关闭\",\n    \"done\": \"完成\",\n    \"settings\": \"设置\",\n    \"about\": \"关于\",\n    \"version\": \"版本\",\n    \"loading\": \"加载中...\",\n    \"notInstalled\": \"未安装\",\n    \"success\": \"成功\",\n    \"error\": \"错误\",\n    \"unknown\": \"未知\",\n    \"enterValidValue\": \"请输入有效的内容\",\n    \"clear\": \"清除\",\n    \"toggleTheme\": \"切换主题\",\n    \"format\": \"格式化\",\n    \"formatSuccess\": \"格式化成功\",\n    \"formatError\": \"格式化失败：{{error}}\",\n    \"copy\": \"复制\",\n    \"view\": \"查看\",\n    \"back\": \"返回\",\n    \"refresh\": \"刷新\",\n    \"refreshing\": \"刷新中...\",\n    \"import\": \"导入\",\n    \"all\": \"全部\",\n    \"search\": \"查询\",\n    \"reset\": \"重置\",\n    \"actions\": \"操作\",\n    \"deleting\": \"删除中...\",\n    \"auto\": \"自动\",\n    \"enabled\": \"已开启\",\n    \"notSet\": \"未设置\"\n  },\n  \"apiKeyInput\": {\n    \"placeholder\": \"请输入API Key\",\n    \"show\": \"显示API Key\",\n    \"hide\": \"隐藏API Key\"\n  },\n  \"jsonEditor\": {\n    \"mustBeObject\": \"配置必须是JSON对象，不能是数组或其他类型\",\n    \"invalidJson\": \"JSON格式错误\"\n  },\n  \"claudeConfig\": {\n    \"configLabel\": \"Claude Code 配置 (JSON) *\",\n    \"writeCommonConfig\": \"写入通用配置\",\n    \"editCommonConfig\": \"编辑通用配置\",\n    \"editCommonConfigTitle\": \"编辑通用配置片段\",\n    \"commonConfigHint\": \"该片段会在勾选\\\"写入通用配置\\\"时合并到 settings.json 中\",\n    \"fullSettingsHint\": \"完整的 Claude Code settings.json 配置内容\",\n    \"extractFromCurrent\": \"从编辑内容提取\",\n    \"extractNoCommonConfig\": \"当前编辑内容没有可提取的通用配置\",\n    \"extractFailed\": \"提取失败: {{error}}\",\n    \"saveFailed\": \"保存失败: {{error}}\",\n    \"hideAttribution\": \"隐藏 AI 署名\",\n    \"enableTeammates\": \"Teammates 模式\",\n    \"enableToolSearch\": \"启用 Tool Search\",\n    \"effortHigh\": \"高强度思考\"\n  },\n  \"header\": {\n    \"viewOnGithub\": \"在 GitHub 上查看\",\n    \"toggleDarkMode\": \"切换到暗色模式\",\n    \"toggleLightMode\": \"切换到亮色模式\",\n    \"addProvider\": \"添加供应商\",\n    \"switchToChinese\": \"切换到中文\",\n    \"switchToEnglish\": \"切换到英文\",\n    \"enterEditMode\": \"进入编辑模式\",\n    \"exitEditMode\": \"退出编辑模式\"\n  },\n  \"provider\": {\n    \"tabProvider\": \"供应商\",\n    \"tabUniversal\": \"统一供应商\",\n    \"noProviders\": \"还没有添加任何供应商\",\n    \"noProvidersDescription\": \"如果你已有配置，请点击\\\"导入当前配置\\\"，所有数据将安全保存在 default 供应商中\",\n    \"noProvidersDescriptionSnippet\": \"除 Key 和请求地址外的数据（如插件配置）会被保存到通用配置片段，用于在不同供应商之间共享\",\n    \"importCurrent\": \"导入当前配置\",\n    \"importCurrentDescription\": \"将当前正在使用的配置导入为默认供应商\",\n    \"currentlyUsing\": \"当前使用\",\n    \"enable\": \"启用\",\n    \"inUse\": \"使用中\",\n    \"editProvider\": \"编辑供应商\",\n    \"editProviderHint\": \"更新配置后将立即应用到当前供应商。\",\n    \"deleteProvider\": \"删除供应商\",\n    \"addNewProvider\": \"添加新供应商\",\n    \"addClaudeProvider\": \"添加 Claude Code 供应商\",\n    \"addCodexProvider\": \"添加 Codex 供应商\",\n    \"addGeminiProvider\": \"添加 Gemini 供应商\",\n    \"addOpenCodeProvider\": \"添加 OpenCode 供应商\",\n    \"addToConfig\": \"添加\",\n    \"removeFromConfig\": \"移除\",\n    \"setAsDefault\": \"设为默认\",\n    \"isDefault\": \"当前默认\",\n    \"inConfig\": \"已添加\",\n    \"addProviderHint\": \"填写信息后即可在列表中快速切换供应商。\",\n    \"editClaudeProvider\": \"编辑 Claude Code 供应商\",\n    \"editCodexProvider\": \"编辑 Codex 供应商\",\n    \"configError\": \"配置错误\",\n    \"notConfigured\": \"未配置官网地址\",\n    \"applyToClaudePlugin\": \"应用到 Claude 插件\",\n    \"removeFromClaudePlugin\": \"从 Claude 插件移除\",\n    \"dragToReorder\": \"拖拽以重新排序\",\n    \"dragHandle\": \"拖拽排序\",\n    \"searchPlaceholder\": \"按名称/备注/网址搜索供应商...\",\n    \"searchAriaLabel\": \"搜索供应商\",\n    \"searchScopeHint\": \"根据名称、备注和官网链接匹配结果。\",\n    \"searchCloseHint\": \"按 Esc 关闭\",\n    \"searchCloseAriaLabel\": \"关闭供应商搜索\",\n    \"noSearchResults\": \"没有符合搜索条件的供应商。\",\n    \"duplicate\": \"复制\",\n    \"sortUpdateFailed\": \"排序更新失败\",\n    \"configureUsage\": \"配置用量查询\",\n    \"officialPartner\": \"官方合作伙伴\",\n    \"openTerminal\": \"打开终端\",\n    \"terminalOpened\": \"终端已打开\",\n    \"terminalOpenFailed\": \"打开终端失败\",\n    \"name\": \"供应商名称\",\n    \"namePlaceholder\": \"例如：Claude 官方\",\n    \"websiteUrl\": \"官网链接\",\n    \"notes\": \"备注\",\n    \"notesPlaceholder\": \"例如：公司专用账号\",\n    \"configJson\": \"配置 JSON\",\n    \"writeCommonConfig\": \"写入通用配置\",\n    \"editCommonConfigButton\": \"编辑通用配置\",\n    \"configJsonHint\": \"请填写完整的 Claude Code 配置\",\n    \"editCommonConfigTitle\": \"编辑通用配置片段\",\n    \"editCommonConfigHint\": \"通用配置片段将合并到所有启用它的供应商配置中\",\n    \"addProvider\": \"添加供应商\",\n    \"sortUpdated\": \"排序已更新\",\n    \"usageSaved\": \"用量查询配置已保存\",\n    \"usageSaveFailed\": \"用量查询配置保存失败\",\n    \"geminiConfig\": \"Gemini 配置\",\n    \"geminiConfigHint\": \"使用 .env 格式配置 Gemini\",\n    \"form\": {\n      \"gemini\": {\n        \"model\": \"模型\",\n        \"oauthTitle\": \"OAuth 认证模式\",\n        \"oauthHint\": \"Google 官方使用 OAuth 个人认证，无需填写 API Key。首次使用时会自动打开浏览器进行登录。\",\n        \"apiKeyPlaceholder\": \"请输入 Gemini API Key\"\n      }\n    }\n  },\n  \"notifications\": {\n    \"providerAdded\": \"供应商已添加\",\n    \"providerSaved\": \"供应商配置已保存\",\n    \"providerDeleted\": \"供应商删除成功\",\n    \"switchSuccess\": \"切换成功！\",\n    \"addToConfigSuccess\": \"已添加到配置\",\n    \"removeFromConfigSuccess\": \"已从配置移除\",\n    \"switchFailedTitle\": \"切换失败\",\n    \"switchFailed\": \"切换失败：{{error}}\",\n    \"autoImported\": \"已从现有配置创建默认供应商\",\n    \"addFailed\": \"添加供应商失败：{{error}}\",\n    \"saveFailed\": \"保存失败：{{error}}\",\n    \"saveFailedGeneric\": \"保存失败，请重试\",\n    \"appliedToClaudePlugin\": \"已应用到 Claude 插件\",\n    \"removedFromClaudePlugin\": \"已从 Claude 插件移除\",\n    \"syncClaudePluginFailed\": \"同步 Claude 插件失败\",\n    \"skipClaudeOnboardingFailed\": \"跳过 Claude Code 初次安装确认失败\",\n    \"clearClaudeOnboardingSkipFailed\": \"恢复 Claude Code 初次安装确认失败\",\n    \"updateSuccess\": \"供应商更新成功\",\n    \"updateFailed\": \"更新供应商失败：{{error}}\",\n    \"deleteSuccess\": \"供应商已删除\",\n    \"deleteFailed\": \"删除供应商失败：{{error}}\",\n    \"settingsSaved\": \"设置已保存\",\n    \"settingsSaveFailed\": \"保存设置失败：{{error}}\",\n    \"openAIChatFormatHint\": \"此供应商使用 OpenAI Chat 格式，需要开启代理服务才能正常使用\",\n    \"openAIFormatHint\": \"此供应商使用 OpenAI 兼容格式，需要开启代理服务才能正常使用\",\n    \"openLinkFailed\": \"链接打开失败\",\n    \"openclawModelsRegistered\": \"模型已注册到 /model 列表\",\n    \"openclawDefaultModelSet\": \"已设为默认模型\",\n    \"openclawDefaultModelSetFailed\": \"设置默认模型失败\",\n    \"openclawNoModels\": \"该供应商没有配置模型\",\n    \"backfillWarning\": \"切换成功，但旧供应商配置回填失败，您手动修改的配置可能未保存\"\n  },\n  \"confirm\": {\n    \"deleteProvider\": \"删除供应商\",\n    \"deleteProviderMessage\": \"确定要删除供应商 \\\"{{name}}\\\" 吗？此操作无法撤销。\",\n    \"removeProvider\": \"移除供应商\",\n    \"removeProviderMessage\": \"确定要从配置中移除供应商 \\\"{{name}}\\\" 吗？\\n\\n移除后该供应商将不再生效，但配置数据会保留在 CC Switch 中，您可以随时重新添加。\",\n    \"proxy\": {\n      \"title\": \"启用本地代理服务\",\n      \"message\": \"本地代理是一项高级功能，启用前请确保您已了解其工作原理。\\n\\n建议先查阅相关文档或咨询您的供应商，以获取正确的配置方式。\",\n      \"confirm\": \"我已了解，继续启用\"\n    },\n    \"failover\": {\n      \"title\": \"启用故障转移功能\",\n      \"message\": \"故障转移是一项高级功能，启用前请确保您已了解其工作原理。\\n\\n建议先在故障转移队列中配置好供应商优先级。\",\n      \"confirm\": \"我已了解，继续启用\"\n    },\n    \"usage\": {\n      \"title\": \"配置用量查询\",\n      \"message\": \"用量查询需要配置专用的查询脚本或 API 参数，请确保您已从供应商处获取相关信息。\\n\\n如不确定如何配置，请先查阅供应商文档。\",\n      \"confirm\": \"我已了解，继续配置\"\n    },\n    \"streamCheck\": {\n      \"title\": \"模型健康检测\",\n      \"message\": \"健康检测通过直接发送 API 请求来测试供应商连通性，以下情况可能导致检测失败：\\n\\n• 官方供应商（使用 OAuth 登录，无独立 API Key）\\n• 部分中转服务（会校验请求是否来自 Claude Code CLI）\\n• AWS Bedrock（使用 IAM 签名认证）\\n\\n检测失败不代表供应商不可用，仅表示无法通过独立请求验证。请以应用内的实际情况为准。\",\n      \"confirm\": \"我已了解，继续检测\"\n    },\n    \"autoSync\": {\n      \"title\": \"开启自动同步\",\n      \"message\": \"开启自动同步后，每次数据库变更都会自动上传到 WebDAV 服务器。\\n\\n这可能会产生较高的网络流量消耗，请确保您的网络环境和 WebDAV 服务支持频繁的数据传输。\",\n      \"confirm\": \"我已了解，继续开启\"\n    }\n  },\n  \"settings\": {\n    \"title\": \"设置\",\n    \"general\": \"通用\",\n    \"tabGeneral\": \"通用\",\n    \"tabAdvanced\": \"高级\",\n    \"tabProxy\": \"代理\",\n    \"advanced\": {\n      \"configDir\": {\n        \"title\": \"配置文件目录\",\n        \"description\": \"管理 Claude、Codex 和 Gemini 的配置存储路径\"\n      },\n      \"proxy\": {\n        \"title\": \"本地代理\",\n        \"description\": \"控制代理服务开关、查看状态与端口信息\",\n        \"enableFeature\": \"在主页面显示本地代理开关\",\n        \"enableFeatureDescription\": \"开启后，主页面顶部将显示代理和故障转移开关\",\n        \"enableFailoverToggle\": \"在主页面显示故障转移开关\",\n        \"enableFailoverToggleDescription\": \"开启后，主页面顶部将独立显示故障转移开关\",\n        \"running\": \"运行中\",\n        \"stopped\": \"已停止\"\n      },\n      \"modelTest\": {\n        \"title\": \"模型测试配置\",\n        \"description\": \"配置模型测试使用的默认模型和提示词\"\n      },\n      \"failover\": {\n        \"title\": \"自动故障转移\",\n        \"description\": \"配置故障转移队列和熔断策略\"\n      },\n      \"pricing\": {\n        \"title\": \"成本定价\",\n        \"description\": \"管理各模型 Token 计费规则\"\n      },\n      \"globalProxy\": {\n        \"title\": \"全局出站代理\",\n        \"description\": \"配置 CC Switch 访问外部 API 时使用的代理\"\n      },\n      \"data\": {\n        \"title\": \"数据管理\",\n        \"description\": \"导入和导出本地配置数据\"\n      },\n      \"backup\": {\n        \"title\": \"备份与恢复\",\n        \"description\": \"管理自动备份，查看和恢复数据库快照\"\n      },\n      \"cloudSync\": {\n        \"title\": \"云同步\",\n        \"description\": \"通过 WebDAV 在多设备间同步数据\"\n      },\n      \"rectifier\": {\n        \"title\": \"整流器\",\n        \"description\": \"自动修复 API 请求中的兼容性问题\",\n        \"enabled\": \"启用整流器\",\n        \"enabledDescription\": \"总开关，关闭后所有整流功能将被禁用\",\n        \"requestGroup\": \"请求整流\",\n        \"responseGroup\": \"响应整流\",\n        \"thinkingSignature\": \"Thinking 签名整流\",\n        \"thinkingSignatureDescription\": \"当 Anthropic 类型供应商返回 thinking 签名不兼容或非法请求等错误时，自动移除不兼容的 thinking 相关块并对同一供应商重试一次\",\n        \"thinkingBudget\": \"Thinking Budget 整流\",\n        \"thinkingBudgetDescription\": \"当 Anthropic 类型供应商返回 budget_tokens 约束错误（如至少 1024）时，自动将 thinking 规范为 enabled 并将 budget 设为 32000，同时在需要时将 max_tokens 设为 64000，然后重试一次\"\n      },\n      \"optimizer\": {\n        \"title\": \"Bedrock 请求优化器\",\n        \"description\": \"在请求发送前自动优化 Thinking 和 Cache 配置（仅 Bedrock 供应商生效）\",\n        \"enabled\": \"启用优化器\",\n        \"thinkingOptimizer\": \"Thinking 优化\",\n        \"thinkingOptimizerDescription\": \"自动为 Opus/Sonnet 启用 Adaptive Thinking，为旧模型注入 Extended Thinking\",\n        \"cacheInjection\": \"Cache 注入\",\n        \"cacheInjectionDescription\": \"自动在请求关键位置注入 Cache 断点，减少重复 token 计费\",\n        \"cacheTtl\": \"Cache TTL\",\n        \"cacheTtl5m\": \"5 分钟\",\n        \"cacheTtl1h\": \"1 小时\"\n      },\n      \"logConfig\": {\n        \"title\": \"日志管理\",\n        \"description\": \"控制日志输出级别\",\n        \"enabled\": \"启用日志\",\n        \"enabledDescription\": \"总开关，关闭后所有日志将被禁用\",\n        \"level\": \"日志级别\",\n        \"levelDescription\": \"设置输出的最低日志级别\",\n        \"levels\": {\n          \"error\": \"错误\",\n          \"warn\": \"警告\",\n          \"info\": \"信息\",\n          \"debug\": \"调试\",\n          \"trace\": \"跟踪\"\n        },\n        \"levelHint\": \"日志级别说明：\",\n        \"levelDesc\": {\n          \"error\": \"仅严重错误\",\n          \"warn\": \"错误 + 警告信息\",\n          \"info\": \"一般操作信息（默认）\",\n          \"debug\": \"详细信息，包含 SSE 流和请求/响应详情\",\n          \"trace\": \"全部日志，最详细\"\n        }\n      }\n    },\n    \"language\": \"界面语言\",\n    \"languageHint\": \"切换后立即预览界面语言，保存后永久生效。\",\n    \"theme\": \"外观主题\",\n    \"themeHint\": \"选择应用的外观主题，立即生效。\",\n    \"themeLight\": \"浅色\",\n    \"themeDark\": \"深色\",\n    \"themeSystem\": \"跟随系统\",\n    \"importExport\": \"SQL 导入导出\",\n    \"importExportHint\": \"导入/导出数据库 SQL 备份（仅支持导入由 CC Switch 导出的备份），便于备份或迁移。\",\n    \"exportConfig\": \"导出 SQL 备份\",\n    \"selectConfigFile\": \"选择 SQL 文件\",\n    \"noFileSelected\": \"尚未选择配置文件。\",\n    \"import\": \"导入\",\n    \"importing\": \"导入中...\",\n    \"importSuccess\": \"导入成功！\",\n    \"importFailed\": \"导入失败\",\n    \"syncLiveFailed\": \"已导入，但同步到当前供应商失败，请手动重新选择一次供应商。\",\n    \"importPartialSuccess\": \"配置已导入，但同步到当前供应商失败。\",\n    \"importPartialHint\": \"请手动重新选择一次供应商以刷新对应配置。\",\n    \"configExported\": \"配置已导出到：\",\n    \"exportFailed\": \"导出失败\",\n    \"selectFileFailed\": \"请选择有效的 SQL 备份文件\",\n    \"configCorrupted\": \"SQL 文件可能已损坏或格式不正确\",\n    \"backupId\": \"备份ID\",\n    \"backupManager\": {\n      \"title\": \"数据库备份\",\n      \"description\": \"自动备份的数据库快照，可用于恢复到之前的状态\",\n      \"empty\": \"暂无备份\",\n      \"restore\": \"恢复\",\n      \"restoring\": \"恢复中...\",\n      \"confirmTitle\": \"确认恢复备份\",\n      \"confirmMessage\": \"恢复到此备份将覆盖当前数据库。恢复前会自动创建安全备份。\",\n      \"restoreSuccess\": \"恢复成功！安全备份已创建\",\n      \"restoreFailed\": \"恢复失败\",\n      \"safetyBackupId\": \"安全备份ID\",\n      \"intervalLabel\": \"自动备份间隔\",\n      \"retainLabel\": \"备份保留数量\",\n      \"intervalDisabled\": \"禁用\",\n      \"intervalHours\": \"{{hours}} 小时\",\n      \"intervalDays\": \"{{days}} 天\",\n      \"rename\": \"重命名\",\n      \"renameSuccess\": \"备份重命名成功\",\n      \"renameFailed\": \"重命名失败\",\n      \"namePlaceholder\": \"输入新名称\",\n      \"createBackup\": \"立即备份\",\n      \"creating\": \"备份中...\",\n      \"createSuccess\": \"备份创建成功\",\n      \"createFailed\": \"备份创建失败\",\n      \"delete\": \"删除\",\n      \"deleting\": \"删除中...\",\n      \"deleteSuccess\": \"备份已删除\",\n      \"deleteFailed\": \"删除失败\",\n      \"deleteConfirmTitle\": \"确认删除备份\",\n      \"deleteConfirmMessage\": \"此备份将被永久删除，此操作无法撤消。\"\n    },\n    \"webdavSync\": {\n      \"title\": \"WebDAV 云同步\",\n      \"description\": \"通过 WebDAV 在多设备间同步数据库和技能配置。\",\n      \"baseUrl\": \"WebDAV 服务器地址\",\n      \"baseUrlPlaceholder\": \"https://dav.jianguoyun.com/dav/\",\n      \"username\": \"WebDAV 账户\",\n      \"usernamePlaceholder\": \"邮箱账号\",\n      \"password\": \"WebDAV 密码\",\n      \"passwordPlaceholder\": \"应用密码（坚果云请使用「第三方应用密码」）\",\n      \"remoteRoot\": \"远程根目录\",\n      \"profile\": \"同步配置名\",\n      \"autoSync\": \"自动同步\",\n      \"autoSyncHint\": \"开启后每次数据库变更都会自动上传到 WebDAV。\",\n      \"test\": \"测试连接\",\n      \"testing\": \"测试中...\",\n      \"testSuccess\": \"连接成功\",\n      \"testFailed\": \"连接失败：{{error}}\",\n      \"save\": \"保存配置\",\n      \"saving\": \"保存中...\",\n      \"saveFailed\": \"保存配置失败：{{error}}\",\n      \"upload\": \"上传到云端\",\n      \"uploading\": \"上传中...\",\n      \"uploadSuccess\": \"已上传到 WebDAV\",\n      \"uploadFailed\": \"上传失败：{{error}}\",\n      \"autoSyncFailedToast\": \"自动同步失败：{{error}}\",\n      \"download\": \"从云端下载\",\n      \"downloading\": \"下载中...\",\n      \"downloadSuccess\": \"已从 WebDAV 下载并恢复\",\n      \"downloadFailed\": \"下载失败：{{error}}\",\n      \"lastSync\": \"上次同步：{{time}}\",\n      \"autoSyncLastErrorTitle\": \"上次自动同步失败\",\n      \"autoSyncLastErrorHint\": \"请检查网络或 WebDAV 配置，系统会在后续变更时继续自动重试。\",\n      \"missingUrl\": \"请填写 WebDAV 服务器地址\",\n      \"presets\": {\n        \"label\": \"服务商\",\n        \"jianguoyun\": \"坚果云\",\n        \"jianguoyunHint\": \"请在坚果云「安全选项」中生成「第三方应用密码」，不要使用登录密码。\",\n        \"nextcloud\": \"Nextcloud\",\n        \"nextcloudHint\": \"请将 your-server 替换为你的 Nextcloud 服务器地址，USERNAME 替换为你的用户名。\",\n        \"synology\": \"群晖 NAS\",\n        \"synologyHint\": \"请先在群晖「套件中心」安装并启用 WebDAV Server 套件。\",\n        \"custom\": \"自定义\"\n      },\n      \"remoteRootDefault\": \"默认: cc-switch-sync\",\n      \"profileDefault\": \"默认: default\",\n      \"saveAndTestSuccess\": \"配置已保存，连接正常\",\n      \"saveAndTestFailed\": \"配置已保存，但连接测试失败：{{error}}\",\n      \"noRemoteData\": \"云端没有找到同步数据\",\n      \"incompatibleVersion\": \"远端数据版本不兼容（协议 v{{protocolVersion}}，数据库 {{dbCompatVersion}}），当前支持协议 v2 / db-v6\",\n      \"unsaved\": \"未保存\",\n      \"saved\": \"已保存\",\n      \"unsavedChanges\": \"请先保存配置\",\n      \"saveBeforeSync\": \"请先保存配置，再使用上传/下载。\",\n      \"fetchingRemote\": \"获取远端信息...\",\n      \"fetchRemoteFailed\": \"获取远端信息失败，请检查配置和网络后重试。\",\n      \"confirmDownload\": {\n        \"title\": \"从云端恢复\",\n        \"deviceName\": \"上传设备\",\n        \"createdAt\": \"上传时间\",\n        \"path\": \"远端路径\",\n        \"dbCompat\": \"数据库兼容层\",\n        \"artifacts\": \"包含内容\",\n        \"legacyNotice\": \"检测到旧版云端路径。恢复完成后，下次上传将写入新路径 v2/db-v6。\",\n        \"warning\": \"恢复将覆盖本地所有数据和技能配置\",\n        \"confirm\": \"确认恢复\"\n      },\n      \"confirmUpload\": {\n        \"title\": \"上传到云端\",\n        \"content\": \"将同步以下内容到 WebDAV 服务器：\",\n        \"dbItem\": \"数据库（所有 Provider 配置和数据）\",\n        \"skillsItem\": \"技能包（所有自定义技能）\",\n        \"targetPath\": \"目标路径\",\n        \"existingData\": \"云端已有数据\",\n        \"deviceName\": \"上传设备\",\n        \"createdAt\": \"上传时间\",\n        \"path\": \"远端路径\",\n        \"dbCompat\": \"数据库兼容层\",\n        \"warning\": \"将覆盖云端已有的同步数据\",\n        \"legacyNotice\": \"检测到旧版云端路径数据。本次上传将写入新路径 v2/db-v6，不会覆盖旧路径。\",\n        \"confirm\": \"确认上传\"\n      }\n    },\n    \"autoReload\": \"数据已刷新\",\n    \"languageOptionChinese\": \"中文\",\n    \"languageOptionEnglish\": \"English\",\n    \"languageOptionJapanese\": \"日本語\",\n    \"windowBehavior\": \"窗口行为\",\n    \"windowBehaviorHint\": \"配置窗口最小化与 Claude 插件联动策略。\",\n    \"launchOnStartup\": \"开机自启\",\n    \"launchOnStartupDescription\": \"随系统启动自动运行 CC Switch\",\n    \"silentStartup\": \"静默启动\",\n    \"silentStartupDescription\": \"程序启动时不显示主窗口，仅在系统托盘运行\",\n    \"autoLaunchFailed\": \"设置开机自启失败\",\n    \"minimizeToTray\": \"关闭时最小化到托盘\",\n    \"minimizeToTrayDescription\": \"勾选后点击关闭按钮会隐藏到系统托盘，取消则直接退出应用。\",\n    \"enableClaudePluginIntegration\": \"应用到 Claude Code 插件\",\n    \"enableClaudePluginIntegrationDescription\": \"开启后 Vscode Claude Code 插件的供应商将随本软件切换\",\n    \"skipClaudeOnboarding\": \"跳过 Claude Code 初次安装确认\",\n    \"skipClaudeOnboardingDescription\": \"开启后跳过 Claude Code 初次安装确认\",\n    \"appVisibility\": {\n      \"title\": \"主页面显示\",\n      \"description\": \"选择在主页面显示的应用\",\n      \"claudeDesc\": \"Anthropic Claude Code CLI\",\n      \"codexDesc\": \"OpenAI Codex CLI\",\n      \"geminiDesc\": \"Google Gemini CLI\",\n      \"opencodeDesc\": \"OpenCode CLI\"\n    },\n    \"skillSync\": {\n      \"title\": \"Skill 同步方式\",\n      \"description\": \"选择 Skills 的文件同步策略\",\n      \"symlink\": \"软连接\",\n      \"copy\": \"文件复制\",\n      \"symlinkHint\": \"软连接节省磁盘空间并支持实时同步。注意：Windows 可能需要管理员权限或开启开发者模式\"\n    },\n    \"terminal\": {\n      \"title\": \"首选终端\",\n      \"description\": \"选择点击终端按钮时使用的终端应用\",\n      \"fallbackHint\": \"如果选择的终端不可用，将自动使用系统默认终端\",\n      \"options\": {\n        \"macos\": {\n          \"terminal\": \"Terminal.app\",\n          \"iterm2\": \"iTerm2\",\n          \"alacritty\": \"Alacritty\",\n          \"kitty\": \"Kitty\",\n          \"ghostty\": \"Ghostty\",\n          \"wezterm\": \"WezTerm\"\n        },\n        \"windows\": {\n          \"cmd\": \"命令提示符\",\n          \"powershell\": \"PowerShell\",\n          \"wt\": \"Windows Terminal\"\n        },\n        \"linux\": {\n          \"gnomeTerminal\": \"GNOME Terminal\",\n          \"konsole\": \"Konsole\",\n          \"xfce4Terminal\": \"Xfce4 Terminal\",\n          \"alacritty\": \"Alacritty\",\n          \"kitty\": \"Kitty\",\n          \"ghostty\": \"Ghostty\"\n        }\n      }\n    },\n    \"configDirectoryOverride\": \"配置目录覆盖（高级）\",\n    \"configDirectoryDescription\": \"在 WSL 等环境使用 Claude Code 或 Codex 的时候，可手动指定为 WSL 里的配置目录，供应商数据与主环境保持一致。\",\n    \"appConfigDir\": \"CC Switch 配置目录\",\n    \"appConfigDirDescription\": \"自定义 CC Switch 的配置存储位置（指定到云同步文件夹即可云同步配置）\",\n    \"browsePlaceholderApp\": \"例如：C:\\\\Users\\\\Administrator\\\\.cc-switch\",\n    \"claudeConfigDir\": \"Claude Code 配置目录\",\n    \"claudeConfigDirDescription\": \"覆盖 Claude 配置目录 (settings.json)，同时会在同级存放 Claude MCP 的 claude.json。\",\n    \"codexConfigDir\": \"Codex 配置目录\",\n    \"codexConfigDirDescription\": \"覆盖 Codex 配置目录。\",\n    \"geminiConfigDir\": \"Gemini 配置目录\",\n    \"geminiConfigDirDescription\": \"覆盖 Gemini 配置目录 (.env)。\",\n    \"opencodeConfigDir\": \"OpenCode 配置目录\",\n    \"opencodeConfigDirDescription\": \"覆盖 OpenCode 配置目录 (opencode.json)。\",\n    \"browsePlaceholderClaude\": \"例如：/home/<你的用户名>/.claude\",\n    \"browsePlaceholderCodex\": \"例如：/home/<你的用户名>/.codex\",\n    \"browsePlaceholderGemini\": \"例如：/home/<你的用户名>/.gemini\",\n    \"browsePlaceholderOpencode\": \"例如：/home/<你的用户名>/.config/opencode\",\n    \"browseDirectory\": \"浏览目录\",\n    \"resetDefault\": \"恢复默认目录（需保存后生效）\",\n    \"checkForUpdates\": \"检查更新\",\n    \"updateTo\": \"更新到 v{{version}}\",\n    \"updating\": \"更新中...\",\n    \"checking\": \"检查中...\",\n    \"upToDate\": \"已是最新\",\n    \"aboutHint\": \"查看版本信息与更新状态。\",\n    \"portableMode\": \"当前为便携版，更新需手动下载。\",\n    \"updateAvailable\": \"检测到新版本：{{version}}\",\n    \"updateBadge\": \"有更新可用\",\n    \"updateFailed\": \"更新安装失败，已尝试打开下载页面。\",\n    \"checkUpdateFailed\": \"检查更新失败，请稍后重试。\",\n    \"openReleaseNotesFailed\": \"打开更新日志失败\",\n    \"releaseNotes\": \"更新日志\",\n    \"viewReleaseNotes\": \"查看该版本更新日志\",\n    \"viewCurrentReleaseNotes\": \"查看当前版本更新日志\",\n    \"oneClickInstall\": \"一键安装\",\n    \"oneClickInstallHint\": \"安装 Claude Code / Codex / Gemini CLI / OpenCode\",\n    \"localEnvCheck\": \"本地环境检查\",\n    \"envBadge\": {\n      \"wsl\": \"WSL\",\n      \"windows\": \"Win\",\n      \"macos\": \"macOS\",\n      \"linux\": \"Linux\"\n    },\n    \"wslShell\": \"Shell\",\n    \"wslShellFlag\": \"标志\",\n    \"installCommandsCopied\": \"安装命令已复制\",\n    \"installCommandsCopyFailed\": \"复制失败，请手动复制。\",\n    \"importFailedError\": \"导入配置失败：{{message}}\",\n    \"exportFailedError\": \"导出配置失败:\",\n    \"restartRequired\": \"需要重启应用\",\n    \"restartRequiredMessage\": \"修改 CC Switch 配置目录后需要重启应用才能生效，是否立即重启？\",\n    \"restartNow\": \"立即重启\",\n    \"restartLater\": \"稍后重启\",\n    \"restartFailed\": \"应用重启失败，请手动关闭后重新打开。\",\n    \"devModeRestartHint\": \"开发模式下不支持自动重启，请手动重新启动应用。\",\n    \"saving\": \"正在保存...\",\n    \"globalProxy\": {\n      \"label\": \"全局代理\",\n      \"hint\": \"代理所有请求（API、Skills 下载等）。留空表示直连。\",\n      \"username\": \"用户名（可选）\",\n      \"password\": \"密码（可选）\",\n      \"test\": \"测试连接\",\n      \"scan\": \"扫描本地代理\",\n      \"clear\": \"清除\",\n      \"scanFailed\": \"扫描失败：{{error}}\",\n      \"saved\": \"代理设置已保存\",\n      \"saveFailed\": \"保存失败：{{error}}\",\n      \"testSuccess\": \"连接成功！延迟 {{latency}}ms\",\n      \"testFailed\": \"连接失败：{{error}}\",\n      \"pricingDefaultsTitle\": \"计费默认配置\",\n      \"pricingDefaultsDescription\": \"设置各应用的默认倍率与计费模式来源。\",\n      \"pricingAppLabel\": \"应用\",\n      \"defaultCostMultiplierLabel\": \"默认倍率\",\n      \"defaultCostMultiplierHint\": \"用于成本计算的倍率，支持小数。\",\n      \"pricingModelSourceLabel\": \"计费模式\",\n      \"pricingModelSourceRequest\": \"请求模型\",\n      \"pricingModelSourceResponse\": \"返回模型\",\n      \"pricingSave\": \"保存计费配置\",\n      \"pricingSaved\": \"计费配置已保存\",\n      \"pricingSaveFailed\": \"保存计费配置失败：{{error}}\",\n      \"pricingLoadFailed\": \"加载计费配置失败：{{error}}\",\n      \"defaultCostMultiplierRequired\": \"默认倍率不能为空\",\n      \"defaultCostMultiplierInvalid\": \"默认倍率格式不正确\"\n    },\n    \"saveFailedGeneric\": \"保存失败，请重试\"\n  },\n  \"apps\": {\n    \"claude\": \"Claude\",\n    \"codex\": \"Codex\",\n    \"gemini\": \"Gemini\",\n    \"opencode\": \"OpenCode\",\n    \"openclaw\": \"OpenClaw\"\n  },\n  \"sessionManager\": {\n    \"title\": \"会话管理\",\n    \"subtitle\": \"管理 Claude Code、Codex、OpenCode、OpenClaw 与 Gemini CLI 会话记录\",\n    \"searchPlaceholder\": \"搜索会话内容、目录或 ID\",\n    \"searchSessions\": \"搜索会话\",\n    \"providerFilterAll\": \"全部\",\n    \"sessionList\": \"会话列表\",\n    \"loadingSessions\": \"加载会话中...\",\n    \"noSessions\": \"未发现会话\",\n    \"selectSession\": \"请选择会话查看详情\",\n    \"noSummary\": \"暂无摘要\",\n    \"lastActive\": \"最近活跃\",\n    \"projectDir\": \"项目目录\",\n    \"sourcePath\": \"原始文件\",\n    \"copyResumeCommand\": \"复制恢复命令\",\n    \"resumeCommandCopied\": \"恢复命令已复制\",\n    \"openInTerminal\": \"在终端恢复\",\n    \"terminalTargetTerminal\": \"Terminal\",\n    \"terminalTargetKitty\": \"kitty\",\n    \"terminalTargetCopy\": \"仅复制\",\n    \"terminalLaunched\": \"终端已启动\",\n    \"openFailed\": \"终端启动失败\",\n    \"resumeFallbackCopied\": \"已复制恢复命令，可手动粘贴到终端\",\n    \"copyProjectDir\": \"复制目录\",\n    \"projectDirCopied\": \"目录已复制\",\n    \"copySourcePath\": \"复制原始文件\",\n    \"sourcePathCopied\": \"原始文件已复制\",\n    \"delete\": \"删除会话\",\n    \"deleting\": \"删除中...\",\n    \"deleteTooltip\": \"永久删除此本地会话记录\",\n    \"deleteConfirmTitle\": \"删除会话\",\n    \"deleteConfirmMessage\": \"将永久删除本地会话“{{title}}”\\nSession ID: {{sessionId}}\\n\\n此操作不可恢复。\",\n    \"deleteConfirmAction\": \"删除会话\",\n    \"sessionDeleted\": \"会话已删除\",\n    \"deleteFailed\": \"删除会话失败: {{error}}\",\n    \"loadingMessages\": \"加载会话内容中...\",\n    \"emptySession\": \"该会话暂无可展示内容\",\n    \"clickToCopyPath\": \"点击复制路径\",\n    \"tocTitle\": \"对话目录\",\n    \"justNow\": \"刚刚\",\n    \"minutesAgo\": \"{{count}} 分钟前\",\n    \"hoursAgo\": \"{{count}} 小时前\",\n    \"daysAgo\": \"{{count}} 天前\",\n    \"roleUser\": \"用户\",\n    \"roleSystem\": \"系统\",\n    \"roleTool\": \"工具\",\n    \"resume\": \"恢复会话\",\n    \"resumeTooltip\": \"在终端中恢复此会话\",\n    \"noResumeCommand\": \"此会话无法恢复\",\n    \"copyCommand\": \"复制命令\",\n    \"copyMessage\": \"复制消息\",\n    \"messageCopied\": \"已复制消息内容\",\n    \"conversationHistory\": \"对话记录\"\n  },\n  \"console\": {\n    \"providerSwitchReceived\": \"收到供应商切换事件:\",\n    \"setupListenerFailed\": \"设置供应商切换监听器失败:\",\n    \"updateProviderFailed\": \"更新供应商失败:\",\n    \"autoImportFailed\": \"自动导入默认配置失败:\",\n    \"openLinkFailed\": \"打开链接失败:\",\n    \"getVersionFailed\": \"获取版本信息失败:\",\n    \"loadSettingsFailed\": \"加载设置失败:\",\n    \"getConfigPathFailed\": \"获取配置路径失败:\",\n    \"getConfigDirFailed\": \"获取配置目录失败:\",\n    \"detectPortableFailed\": \"检测便携模式失败:\",\n    \"saveSettingsFailed\": \"保存设置失败:\",\n    \"updateFailed\": \"更新失败:\",\n    \"checkUpdateFailed\": \"检查更新失败:\",\n    \"openConfigFolderFailed\": \"打开配置文件夹失败:\",\n    \"selectConfigDirFailed\": \"选择配置目录失败:\",\n    \"getDefaultConfigDirFailed\": \"获取默认配置目录失败:\",\n    \"openReleaseNotesFailed\": \"打开更新日志失败:\"\n  },\n  \"providerForm\": {\n    \"supplierName\": \"供应商名称\",\n    \"supplierNameRequired\": \"供应商名称 *\",\n    \"supplierNamePlaceholder\": \"例如：Anthropic 官方\",\n    \"websiteUrl\": \"官网地址\",\n    \"websiteUrlPlaceholder\": \"https://example.com（可选）\",\n    \"apiEndpoint\": \"请求地址\",\n    \"apiEndpointPlaceholder\": \"https://your-api-endpoint.com\",\n    \"codexApiEndpointPlaceholder\": \"https://your-api-endpoint.com/v1\",\n    \"manageAndTest\": \"管理与测速\",\n    \"configContent\": \"配置内容\",\n    \"officialNoApiKey\": \"官方登录无需填写 API Key，直接保存即可\",\n    \"codexOfficialNoApiKey\": \"官方无需填写 API Key，直接保存即可\",\n    \"codexApiKeyAutoFill\": \"只需要填这里，下方 auth.json 会自动填充\",\n    \"apiKeyAutoFill\": \"只需要填这里，下方配置会自动填充\",\n    \"cnOfficialApiKeyHint\": \"💡 只需填写 API Key，请求地址已预设\",\n    \"aggregatorApiKeyHint\": \"💡 只需填写 API Key，请求地址已预设\",\n    \"thirdPartyApiKeyHint\": \"💡 只需填写 API Key，请求地址已预设\",\n    \"customApiKeyHint\": \"💡 自定义配置需手动填写所有必要字段\",\n    \"omoHint\": \"💡 OMO 配置管理 Agent 模型分配，写入 oh-my-opencode.jsonc\",\n    \"officialHint\": \"💡 官方供应商使用浏览器登录，无需配置 API Key\",\n    \"getApiKey\": \"获取 API Key\",\n    \"partnerPromotion\": {\n      \"packycode\": \"PackyCode 是 CC Switch 的官方合作伙伴，使用此链接注册并在充值时填写 \\\"cc-switch\\\" 优惠码，可以享受9折优惠\",\n      \"minimax_cn\": \"MiniMax Coding Plan 特惠，Starter 套餐 9.9 元起\",\n      \"minimax_en\": \"MiniMax Coding Plan 黑五特惠，Starter 套餐现仅 $2/月（2折优惠！）\",\n      \"dmxapi\": \"Claude Code 专属模型 3.4 折优惠进行中！\",\n      \"cubence\": \"Cubence 是 CC Switch 的官方合作伙伴，使用此链接注册并在充值时填写 \\\"CCSWITCH\\\" 优惠码，每次充值均可享受9折优惠\",\n      \"aigocode\": \"AIGoCode 是 CC Switch 的官方合作伙伴，使用此链接注册首次充值时可以获得10%额度奖励！\",\n      \"rightcode\": \"RightCode 是 CC Switch 的官方合作伙伴，使用此链接注册每次充值均可赠送5%额外额度！\",\n      \"aicodemirror\": \"AICodeMirror 是 CC Switch 的官方合作伙伴，使用此链接注册可享受8折优惠！\",\n      \"aicoding\": \"AI Coding 为 CC Switch 的用户提供了特殊优惠，首次充值可以享受 9 折优惠！\",\n      \"crazyrouter\": \"CrazyRouter 为 CC Switch 的用户提供了特殊优惠，首次充值赠予 30% 额外额度！\",\n      \"sssaicode\": \"SSAI Code 为 CC Switch 的用户提供了特殊优惠，每次充值赠予额外 $10 额度！\",\n      \"siliconflow\": \"硅基流动是 CC Switch 的官方合作伙伴\",\n      \"ucloud\": \"优云智算为CC Switch 的用户提供了特殊优惠，通过此链接注册，可以获得五元平台体验金！\",\n      \"micu\": \"Micu 是 CC Switch 的官方合作伙伴\",\n      \"x-code\": \"XCodeAPI 为CC Switch 的用户提供特别福利，使用此链接注册后首单加赠10%的额度(联系站长领取)\",\n      \"ctok\": \"官网加入CTok社群，订阅套餐。\"\n    },\n    \"presets\": {\n      \"ucloud\": \"优云智算\"\n    },\n    \"parameterConfig\": \"参数配置 - {{name}} *\",\n    \"mainModel\": \"主模型 (可选)\",\n    \"mainModelPlaceholder\": \"例如: GLM-4.6\",\n    \"fastModel\": \"快速模型 (可选)\",\n    \"fastModelPlaceholder\": \"例如: GLM-4.5-Air\",\n    \"modelHint\": \"💡 留空将使用供应商的默认模型\",\n    \"apiHint\": \"💡 填写兼容 Claude API 的服务端点地址，不要以斜杠结尾\",\n    \"apiHintOAI\": \"💡 填写兼容 OpenAI Chat Completions 的服务端点地址，不要以斜杠结尾\",\n    \"codexApiHint\": \"💡 填写兼容 OpenAI Response 格式的服务端点地址\",\n    \"fillSupplierName\": \"请填写供应商名称\",\n    \"fillConfigContent\": \"请填写配置内容\",\n    \"fillParameter\": \"请填写 {{label}}\",\n    \"fillTemplateValue\": \"请填写 {{label}}\",\n    \"endpointRequired\": \"非官方供应商请填写 API 端点\",\n    \"apiKeyRequired\": \"非官方供应商请填写 API Key\",\n    \"configJsonError\": \"配置JSON格式错误，请检查语法\",\n    \"authJsonRequired\": \"auth.json 必须是 JSON 对象\",\n    \"authJsonError\": \"auth.json 格式错误，请检查JSON语法\",\n    \"fillAuthJson\": \"请填写 auth.json 配置\",\n    \"fillApiKey\": \"请填写 OPENAI_API_KEY\",\n    \"visitWebsite\": \"访问 {{url}}\",\n    \"anthropicModel\": \"主模型\",\n    \"anthropicSmallFastModel\": \"快速模型\",\n    \"anthropicReasoningModel\": \"推理模型 (Thinking)\",\n    \"apiFormat\": \"API 格式\",\n    \"apiFormatHint\": \"选择供应商 API 的输入格式\",\n    \"apiFormatAnthropic\": \"Anthropic Messages (原生)\",\n    \"apiFormatOpenAIChat\": \"OpenAI Chat Completions (需开启代理)\",\n    \"apiFormatOpenAIResponses\": \"OpenAI Responses API (需开启代理)\",\n    \"authField\": \"认证字段\",\n    \"authFieldAuthToken\": \"ANTHROPIC_AUTH_TOKEN（默认）\",\n    \"authFieldApiKey\": \"ANTHROPIC_API_KEY\",\n    \"authFieldHint\": \"选择写入配置的认证环境变量名\",\n    \"apiHintResponses\": \"💡 填写兼容 OpenAI Responses API 的服务端点地址，不要以斜杠结尾\",\n    \"anthropicDefaultHaikuModel\": \"Haiku 默认模型\",\n    \"anthropicDefaultSonnetModel\": \"Sonnet 默认模型\",\n    \"anthropicDefaultOpusModel\": \"Opus 默认模型\",\n    \"modelPlaceholder\": \"\",\n    \"smallModelPlaceholder\": \"\",\n    \"haikuModelPlaceholder\": \"\",\n    \"modelHelper\": \"可选：指定默认使用的 Claude 模型，留空则使用系统默认。\",\n    \"modelMappingLabel\": \"模型映射\",\n    \"modelMappingHint\": \"如果供应商原生提供 Claude 系列模型，通常无需配置。仅在需要将请求映射到不同模型名称时填写。\",\n    \"advancedOptionsToggle\": \"高级选项\",\n    \"advancedOptionsHint\": \"包含 API 格式、认证字段、模型映射等配置。大多数场景下保持默认即可。\",\n    \"categoryOfficial\": \"官方\",\n    \"categoryCnOfficial\": \"开源官方\",\n    \"categoryAggregation\": \"聚合服务\",\n    \"categoryThirdParty\": \"第三方\"\n  },\n  \"copilot\": {\n    \"authSection\": \"GitHub Copilot 认证\",\n    \"authStatus\": \"认证状态\",\n    \"authenticated\": \"已认证: {{username}}\",\n    \"notAuthenticated\": \"未认证\",\n    \"loginWithGitHub\": \"使用 GitHub 登录\",\n    \"loginRequired\": \"请先登录 GitHub Copilot\",\n    \"waitingForAuth\": \"等待授权中...\",\n    \"enterCode\": \"请在浏览器中输入验证码：\",\n    \"logout\": \"注销\",\n    \"authSuccess\": \"GitHub Copilot 认证成功\",\n    \"authFailed\": \"认证失败: {{error}}\",\n    \"authTimeout\": \"认证超时，请重试\",\n    \"tokenExpired\": \"令牌已过期，请重新认证\",\n    \"accountCount\": \"{{count}} 个账号\",\n    \"selectAccount\": \"选择账号\",\n    \"selectAccountPlaceholder\": \"选择一个 GitHub 账号\",\n    \"useDefaultAccount\": \"使用默认账号\",\n    \"loggedInAccounts\": \"已登录账号\",\n    \"defaultAccount\": \"默认\",\n    \"selected\": \"已选中\",\n    \"removeAccount\": \"移除账号\",\n    \"setAsDefault\": \"设为默认\",\n    \"addAnotherAccount\": \"添加其他账号\",\n    \"logoutAll\": \"注销所有账号\",\n    \"retry\": \"重试\",\n    \"copyCode\": \"复制代码\",\n    \"migrationFailed\": \"旧认证数据迁移失败：{{error}}\",\n    \"loadModelsFailed\": \"加载 Copilot 模型列表失败\"\n  },\n  \"endpointTest\": {\n    \"title\": \"请求地址管理\",\n    \"endpoints\": \"个端点\",\n    \"autoSelect\": \"自动选择\",\n    \"testSpeed\": \"测速\",\n    \"testing\": \"测速中\",\n    \"addEndpointPlaceholder\": \"https://api.example.com\",\n    \"done\": \"完成\",\n    \"noEndpoints\": \"暂无端点\",\n    \"failed\": \"失败\",\n    \"enterValidUrl\": \"请输入有效的 URL\",\n    \"invalidUrlFormat\": \"URL 格式不正确\",\n    \"onlyHttps\": \"仅支持 HTTP/HTTPS\",\n    \"urlExists\": \"该地址已存在\",\n    \"saveFailed\": \"保存失败，请重试\",\n    \"loadEndpointsFailed\": \"加载自定义端点失败:\",\n    \"addEndpointFailed\": \"添加自定义端点失败:\",\n    \"removeEndpointFailed\": \"删除自定义端点失败:\",\n    \"removeFailed\": \"删除失败: {{error}}\",\n    \"updateLastUsedFailed\": \"更新端点使用时间失败\",\n    \"pleaseAddEndpoint\": \"请先添加端点\",\n    \"testUnavailable\": \"测速功能不可用\",\n    \"noResult\": \"未返回结果\",\n    \"testFailed\": \"测速失败: {{error}}\",\n    \"empty\": \"暂无端点\"\n  },\n  \"providerAdvanced\": {\n    \"testConfig\": \"模型测试配置\",\n    \"useCustomConfig\": \"使用单独配置\",\n    \"testConfigDesc\": \"为此供应商配置单独的模型测试参数，不启用时使用全局配置。\",\n    \"testModel\": \"测试模型\",\n    \"testModelPlaceholder\": \"留空使用全局配置\",\n    \"timeoutSecs\": \"超时时间（秒）\",\n    \"testPrompt\": \"测试提示词\",\n    \"degradedThreshold\": \"降级阈值（毫秒）\",\n    \"maxRetries\": \"最大重试次数\",\n    \"proxyConfig\": \"代理配置\",\n    \"useCustomProxy\": \"使用单独代理\",\n    \"proxyConfigDesc\": \"为此供应商配置单独的网络代理，不启用时使用系统代理或全局设置。\",\n    \"proxyUsername\": \"用户名（可选）\",\n    \"proxyPassword\": \"密码（可选）\",\n    \"pricingConfig\": \"计费配置\",\n    \"useCustomPricing\": \"使用单独配置\",\n    \"pricingConfigDesc\": \"为此供应商配置单独的计费参数，不启用时使用全局默认配置。\",\n    \"costMultiplier\": \"成本倍率\",\n    \"costMultiplierPlaceholder\": \"留空使用全局默认（1）\",\n    \"costMultiplierHint\": \"实际成本 = 基础成本 × 倍率，支持小数如 1.5\",\n    \"pricingModelSourceLabel\": \"计费模式\",\n    \"pricingModelSourceInherit\": \"继承全局默认\",\n    \"pricingModelSourceRequest\": \"请求模型\",\n    \"pricingModelSourceResponse\": \"返回模型\",\n    \"pricingModelSourceHint\": \"选择按请求模型还是返回模型进行定价匹配\"\n  },\n  \"codexConfig\": {\n    \"authJson\": \"auth.json (JSON) *\",\n    \"authJsonPlaceholder\": \"{\\n  \\\"OPENAI_API_KEY\\\": \\\"sk-your-api-key-here\\\"\\n}\",\n    \"authJsonHint\": \"Codex auth.json 配置内容\",\n    \"configToml\": \"config.toml (TOML)\",\n    \"configTomlHint\": \"Codex config.toml 配置内容\",\n    \"writeCommonConfig\": \"写入通用配置\",\n    \"editCommonConfig\": \"编辑通用配置\",\n    \"editCommonConfigTitle\": \"编辑 Codex 通用配置片段\",\n    \"commonConfigHint\": \"该片段会在勾选'写入通用配置'时追加到 config.toml 末尾\",\n    \"apiUrlLabel\": \"API 请求地址\",\n    \"extractFromCurrent\": \"从编辑内容提取\",\n    \"extractNoCommonConfig\": \"当前编辑内容没有可提取的通用配置\",\n    \"extractFailed\": \"提取失败: {{error}}\",\n    \"saveFailed\": \"保存失败: {{error}}\",\n    \"modelNameHint\": \"指定使用的模型，将自动更新到 config.toml 中\",\n    \"modelName\": \"模型名称\",\n    \"modelNamePlaceholder\": \"例如: gpt-5-codex\",\n    \"contextWindow1M\": \"1M 上下文窗口\",\n    \"autoCompactLimit\": \"压缩阈值\",\n    \"autoCompactLimitHint\": \"上下文 token 数达到此阈值时自动压缩历史\"\n  },\n  \"geminiConfig\": {\n    \"envFile\": \"环境变量 (.env)\",\n    \"envFileHint\": \"使用 .env 格式配置 Gemini 环境变量\",\n    \"configJson\": \"配置文件 (config.json)\",\n    \"configJsonHint\": \"使用 JSON 格式配置 Gemini 扩展参数（可选）\",\n    \"writeCommonConfig\": \"写入通用配置\",\n    \"editCommonConfig\": \"编辑通用配置\",\n    \"editCommonConfigTitle\": \"编辑 Gemini 通用配置片段\",\n    \"commonConfigHint\": \"该片段会写入 Gemini 的 .env（不允许包含 GOOGLE_GEMINI_BASE_URL、GEMINI_API_KEY）\",\n    \"extractFromCurrent\": \"从编辑内容提取\",\n    \"extractNoCommonConfig\": \"当前编辑内容没有可提取的通用配置\",\n    \"extractFailed\": \"提取失败: {{error}}\",\n    \"saveFailed\": \"保存失败: {{error}}\",\n    \"extractedConfigInvalid\": \"提取的配置格式错误\",\n    \"invalidJsonFormat\": \"通用配置片段格式错误（必须是有效的 JSON）\",\n    \"commonConfigInvalidKeys\": \"通用配置片段不能包含 GOOGLE_GEMINI_BASE_URL 或 GEMINI_API_KEY（发现：{{keys}}）\",\n    \"commonConfigInvalidValues\": \"通用配置片段的值必须是字符串\",\n    \"noCommonConfigToApply\": \"通用配置片段为空或没有可写入的内容\",\n    \"configMergeFailed\": \"配置合并失败: {{error}}\",\n    \"configReplaceFailed\": \"配置替换失败: {{error}}\"\n  },\n  \"opencode\": {\n    \"npmPackage\": \"接口格式\",\n    \"selectPackage\": \"选择接口格式\",\n    \"npmPackageHint\": \"选择 AI 服务的 API 接口格式\",\n    \"baseUrl\": \"Base URL\",\n    \"baseUrlHint\": \"自定义 API 端点地址\",\n    \"models\": \"模型配置\",\n    \"modelsHint\": \"配置可用的模型及其显示名称\",\n    \"addModel\": \"添加模型\",\n    \"modelId\": \"模型 ID\",\n    \"modelName\": \"显示名称\",\n    \"noModels\": \"暂无模型配置\",\n    \"modelsRequired\": \"请至少添加一个模型配置\",\n    \"providerKey\": \"供应商标识\",\n    \"providerKeyPlaceholder\": \"my-provider\",\n    \"providerKeyHint\": \"配置文件中的唯一标识符，创建后无法修改，只能使用小写字母、数字和连字符\",\n    \"providerKeyRequired\": \"请填写供应商标识\",\n    \"providerKeyDuplicate\": \"此标识已被使用，请更换\",\n    \"providerKeyInvalid\": \"标识格式无效，只能使用小写字母、数字和连字符\",\n    \"extraOptions\": \"额外选项\",\n    \"extraOptionsHint\": \"配置额外的 SDK 选项，如 timeout、setCacheKey 等。值会自动解析类型（数字、布尔值等）。\",\n    \"addExtraOption\": \"添加\",\n    \"extraOptionKey\": \"键名\",\n    \"extraOptionValue\": \"值\",\n    \"extraOptionKeyPlaceholder\": \"timeout\",\n    \"extraOptionValuePlaceholder\": \"600000\",\n    \"noExtraOptions\": \"暂无额外选项\",\n    \"noModelOptions\": \"模型选项，点击 + 添加\",\n    \"modelExtraFields\": \"模型属性\",\n    \"noModelExtraFields\": \"模型属性 (variants, cost 等)，点击 + 添加\",\n    \"modelExtraFieldKeyPlaceholder\": \"variants\",\n    \"sdkOptions\": \"SDK 选项\",\n    \"modelOptionKeyPlaceholder\": \"provider\",\n    \"modelOptionValuePlaceholder\": \"{\\\"order\\\": [\\\"baseten\\\"]}\"\n  },\n  \"providerPreset\": {\n    \"label\": \"预设供应商\",\n    \"custom\": \"自定义配置\",\n    \"other\": \"其他\",\n    \"hint\": \"选择预设后可继续调整下方字段。\"\n  },\n  \"usage\": {\n    \"title\": \"使用统计\",\n    \"subtitle\": \"查看 AI 模型的使用情况和成本统计\",\n    \"today\": \"24小时\",\n    \"last7days\": \"7天\",\n    \"last30days\": \"30天\",\n    \"totalRequests\": \"总请求数\",\n    \"totalCost\": \"总成本\",\n    \"cost\": \"成本\",\n    \"perMillion\": \"(每百万)\",\n    \"trends\": \"使用趋势\",\n    \"rangeToday\": \"过去 24 小时 (按小时)\",\n    \"rangeLast7Days\": \"过去 7 天\",\n    \"rangeLast30Days\": \"过去 30 天\",\n    \"totalTokens\": \"总 Token 数\",\n    \"cacheTokens\": \"缓存 Token\",\n    \"requestLogs\": \"请求日志\",\n    \"providerStats\": \"Provider 统计\",\n    \"modelStats\": \"模型统计\",\n    \"time\": \"时间\",\n    \"provider\": \"供应商\",\n    \"billingModel\": \"计费模型\",\n    \"inputTokens\": \"输入\",\n    \"outputTokens\": \"输出\",\n    \"cacheReadTokens\": \"缓存命中\",\n    \"cacheCreationTokens\": \"缓存创建\",\n    \"timingInfo\": \"用时/首字\",\n    \"status\": \"状态\",\n    \"multiplier\": \"倍率\",\n    \"requestModel\": \"请求模型\",\n    \"responseModel\": \"返回模型\",\n    \"noData\": \"暂无数据\",\n    \"unknownProvider\": \"未知供应商\",\n    \"stream\": \"流\",\n    \"nonStream\": \"非流\",\n    \"totalRecords\": \"共 {{total}} 条记录\",\n    \"modelPricing\": \"模型定价\",\n    \"loadPricingError\": \"加载定价数据失败\",\n    \"modelPricingDesc\": \"配置各模型的 Token 成本\",\n    \"noPricingData\": \"暂无定价数据。点击\\\"新增\\\"添加模型定价配置。\",\n    \"model\": \"模型\",\n    \"displayName\": \"显示名称\",\n    \"inputCost\": \"输入成本\",\n    \"outputCost\": \"输出成本\",\n    \"cacheReadCost\": \"缓存命中\",\n    \"cacheWriteCost\": \"缓存创建\",\n    \"deleteConfirmTitle\": \"确认删除\",\n    \"deleteConfirmDesc\": \"确定要删除此模型定价配置吗？此操作无法撤销。\",\n    \"queryFailed\": \"查询失败\",\n    \"refreshUsage\": \"刷新用量\",\n    \"planUsage\": \"套餐用量\",\n    \"invalid\": \"已失效\",\n    \"total\": \"总：\",\n    \"used\": \"已使用：\",\n    \"remaining\": \"剩余：\",\n    \"justNow\": \"刚刚\",\n    \"minutesAgo\": \"{{count}} 分钟前\",\n    \"hoursAgo\": \"{{count}} 小时前\",\n    \"daysAgo\": \"{{count}} 天前\",\n    \"multiplePlans\": \"{{count}} 个套餐\",\n    \"expand\": \"展开\",\n    \"collapse\": \"收起\",\n    \"modelIdPlaceholder\": \"例如: claude-3-5-sonnet-20241022\",\n    \"displayNamePlaceholder\": \"例如: Claude 3.5 Sonnet\",\n    \"appType\": \"应用类型\",\n    \"allApps\": \"全部应用\",\n    \"statusCode\": \"状态码\",\n    \"searchProviderPlaceholder\": \"搜索供应商...\",\n    \"searchModelPlaceholder\": \"搜索模型...\",\n    \"timeRange\": \"时间范围\",\n    \"input\": \"Input\",\n    \"output\": \"Output\",\n    \"cacheWrite\": \"创建\",\n    \"cacheRead\": \"命中\",\n    \"baseCost\": \"基础\",\n    \"costMultiplier\": \"成本倍率\",\n    \"withMultiplier\": \"含倍率\",\n    \"requestDetail\": \"请求详情\",\n    \"requestNotFound\": \"请求未找到\",\n    \"basicInfo\": \"基本信息\",\n    \"tokenUsage\": \"Token 使用量\",\n    \"cacheCreationCost\": \"缓存写入成本\",\n    \"costBreakdown\": \"成本明细\",\n    \"performance\": \"性能信息\",\n    \"latency\": \"延迟\",\n    \"errorMessage\": \"错误信息\",\n    \"requests\": \"请求数\",\n    \"tokens\": \"Tokens\",\n    \"avgCost\": \"平均成本\",\n    \"avgLatency\": \"平均延迟\",\n    \"successRate\": \"成功率\",\n    \"requestId\": \"请求 ID\",\n    \"never\": \"从不\",\n    \"modelId\": \"模型 ID\",\n    \"modelIdRequired\": \"模型 ID 不能为空\",\n    \"inputCostPerMillion\": \"输入成本 (每百万 tokens, USD)\",\n    \"outputCostPerMillion\": \"输出成本 (每百万 tokens, USD)\",\n    \"invalidPrice\": \"价格必须为非负数\",\n    \"invalidTimeRange\": \"请选择完整的开始/结束时间\",\n    \"invalidTimeRangeOrder\": \"开始时间不能晚于结束时间\",\n    \"timeRangeTooLarge\": \"时间范围过大，请缩小范围\",\n    \"addPricing\": \"新增定价\",\n    \"editPricing\": \"编辑定价\",\n    \"pricingAdded\": \"定价已添加\",\n    \"pricingUpdated\": \"定价已更新\",\n    \"cacheReadCostPerMillion\": \"缓存读取成本 (每百万 tokens, USD)\",\n    \"cacheCreationCostPerMillion\": \"缓存写入成本 (每百万 tokens, USD)\"\n  },\n  \"usageScript\": {\n    \"title\": \"配置用量查询\",\n    \"enableUsageQuery\": \"启用用量查询\",\n    \"presetTemplate\": \"预设模板\",\n    \"requestUrl\": \"请求地址\",\n    \"requestUrlPlaceholder\": \"例如：https://api.example.com\",\n    \"method\": \"HTTP 方法\",\n    \"templateCustom\": \"自定义\",\n    \"templateGeneral\": \"通用模板\",\n    \"templateNewAPI\": \"NewAPI\",\n    \"templateCopilot\": \"GitHub Copilot\",\n    \"copilotAutoAuth\": \"自动使用 OAuth 认证，无需手动配置凭证\",\n    \"resetDate\": \"重置日期\",\n    \"premiumRequests\": \"Premium 请求\",\n    \"credentialsConfig\": \"凭证配置\",\n    \"credentialsHint\": \"留空则自动使用供应商配置\",\n    \"optional\": \"可选\",\n    \"apiKeyPlaceholder\": \"留空则使用供应商的 API Key\",\n    \"baseUrlPlaceholder\": \"留空则使用供应商的请求地址\",\n    \"baseUrl\": \"请求地址\",\n    \"accessToken\": \"访问令牌（在个人安全设置里获取）\",\n    \"accessTokenPlaceholder\": \"在'安全设置'里生成\",\n    \"userId\": \"用户 ID\",\n    \"userIdPlaceholder\": \"例如：114514\",\n    \"defaultPlan\": \"默认套餐\",\n    \"queryFailedMessage\": \"查询失败\",\n    \"queryScript\": \"查询脚本（JavaScript）\",\n    \"timeoutSeconds\": \"超时时间（秒）\",\n    \"headers\": \"请求头\",\n    \"body\": \"请求 Body\",\n    \"timeoutHint\": \"范围: 2-30 秒\",\n    \"timeoutMustBeInteger\": \"超时时间必须为整数，小数部分已忽略\",\n    \"timeoutCannotBeNegative\": \"超时时间不能为负数\",\n    \"autoIntervalMinutes\": \"自动查询间隔（分钟，0 表示不自动查询）\",\n    \"autoQueryInterval\": \"自动查询间隔（分钟）\",\n    \"autoQueryIntervalHint\": \"0 表示不自动查询，建议 5-60 分钟\",\n    \"intervalMustBeInteger\": \"自动查询间隔必须为整数，小数部分已忽略\",\n    \"intervalCannotBeNegative\": \"自动查询间隔不能为负数\",\n    \"intervalAdjusted\": \"自动查询间隔已调整为 {{value}} 分钟\",\n    \"scriptHelp\": \"脚本编写说明：\",\n    \"configFormat\": \"配置格式：\",\n    \"commentOptional\": \"可选\",\n    \"commentResponseIsJson\": \"response 是 API 返回的 JSON 数据\",\n    \"extractorFormat\": \"extractor 返回格式（所有字段均为可选）：\",\n    \"tips\": \"💡 提示：\",\n    \"testing\": \"测试中...\",\n    \"testScript\": \"测试脚本\",\n    \"format\": \"格式化\",\n    \"saveConfig\": \"保存配置\",\n    \"scriptEmpty\": \"脚本配置不能为空\",\n    \"mustHaveReturn\": \"脚本必须包含 return 语句\",\n    \"testSuccess\": \"测试成功！\",\n    \"testFailed\": \"测试失败\",\n    \"formatSuccess\": \"格式化成功\",\n    \"formatFailed\": \"格式化失败\",\n    \"supportedVariables\": \"支持的变量\",\n    \"variablesHint\": \"支持变量: {{apiKey}}, {{baseUrl}} | extractor 函数接收 API 响应的 JSON 对象\",\n    \"scriptConfig\": \"请求配置\",\n    \"extractorCode\": \"提取器代码\",\n    \"extractorHint\": \"返回对象需包含剩余额度等字段\",\n    \"fieldIsValid\": \"• isValid: 布尔值，套餐是否有效\",\n    \"fieldInvalidMessage\": \"• invalidMessage: 字符串，失效原因说明（当 isValid 为 false 时显示）\",\n    \"fieldRemaining\": \"• remaining: 数字，剩余额度\",\n    \"fieldUnit\": \"• unit: 字符串，单位（如 \\\"USD\\\"）\",\n    \"fieldPlanName\": \"• planName: 字符串，套餐名称\",\n    \"fieldTotal\": \"• total: 数字，总额度\",\n    \"fieldUsed\": \"• used: 数字，已用额度\",\n    \"fieldExtra\": \"• extra: 字符串，扩展字段，可自由补充需要展示的文本\",\n    \"tip1\": \"• 变量 {{apiKey}} 和 {{baseUrl}} 会自动替换\",\n    \"tip2\": \"• extractor 函数在沙箱环境中执行，支持 ES2020+ 语法\",\n    \"tip3\": \"• 整个配置必须用 () 包裹，形成对象字面量表达式\"\n  },\n  \"errors\": {\n    \"usage_query_failed\": \"用量查询失败\",\n    \"configLoadFailedTitle\": \"配置加载失败\",\n    \"configLoadFailedMessage\": \"无法读取配置文件：\\n{{path}}\\n\\n错误详情：\\n{{detail}}\\n\\n请手动检查 JSON 是否有效，或从同目录的备份文件（如 config.json.bak）恢复。\\n\\n应用将退出以便您进行修复。\"\n  },\n  \"presetSelector\": {\n    \"title\": \"选择配置类型\",\n    \"custom\": \"自定义\",\n    \"customDescription\": \"手动配置供应商，需要填写完整的配置信息\",\n    \"officialDescription\": \"官方登录，不需要填写 API Key\",\n    \"presetDescription\": \"使用预设配置，只需填写 API Key\"\n  },\n  \"mcp\": {\n    \"title\": \"MCP 管理\",\n    \"import\": \"导入\",\n    \"importExisting\": \"导入已有\",\n    \"addMcp\": \"添加MCP\",\n    \"claudeTitle\": \"Claude Code MCP 管理\",\n    \"codexTitle\": \"Codex MCP 管理\",\n    \"geminiTitle\": \"Gemini MCP 管理\",\n    \"unifiedPanel\": {\n      \"title\": \"MCP 服务器管理\",\n      \"addServer\": \"添加服务器\",\n      \"editServer\": \"编辑服务器\",\n      \"deleteServer\": \"删除服务器\",\n      \"deleteConfirm\": \"确定要删除服务器 \\\"{{id}}\\\" 吗？此操作无法撤销。\",\n      \"noServers\": \"暂无服务器\",\n      \"enabledApps\": \"启用的应用\",\n      \"noImportFound\": \"未发现需要导入的 MCP 服务器。所有服务器已在 CC Switch 统一管理中。\",\n      \"importSuccess\": \"成功导入 {{count}} 个 MCP 服务器\",\n      \"apps\": {\n        \"claude\": \"Claude\",\n        \"codex\": \"Codex\",\n        \"gemini\": \"Gemini\",\n        \"opencode\": \"OpenCode\",\n        \"openclaw\": \"OpenClaw\"\n      }\n    },\n    \"userLevelPath\": \"用户级 MCP 配置路径\",\n    \"serverList\": \"服务器列表\",\n    \"loading\": \"加载中...\",\n    \"empty\": \"暂无 MCP 服务器\",\n    \"emptyDescription\": \"点击右上角按钮添加第一个 MCP 服务器\",\n    \"add\": \"添加 MCP\",\n    \"addServer\": \"新增 MCP\",\n    \"editServer\": \"编辑 MCP\",\n    \"addClaudeServer\": \"新增 Claude Code MCP\",\n    \"editClaudeServer\": \"编辑 Claude Code MCP\",\n    \"addCodexServer\": \"新增 Codex MCP\",\n    \"editCodexServer\": \"编辑 Codex MCP\",\n    \"configPath\": \"配置路径\",\n    \"serverCount\": \"已配置 {{count}} 个 MCP 服务器\",\n    \"enabledCount\": \"已启用 {{count}} 个\",\n    \"template\": {\n      \"fetch\": \"快速模板：mcp-fetch\"\n    },\n    \"form\": {\n      \"title\": \"MCP 标题（唯一）\",\n      \"titlePlaceholder\": \"my-mcp-server\",\n      \"name\": \"显示名称\",\n      \"namePlaceholder\": \"例如 @modelcontextprotocol/server-time\",\n      \"enabledApps\": \"启用到应用\",\n      \"noAppsWarning\": \"至少选择一个应用\",\n      \"description\": \"描述\",\n      \"descriptionPlaceholder\": \"可选的描述信息\",\n      \"tags\": \"标签（逗号分隔）\",\n      \"tagsPlaceholder\": \"stdio, time, utility\",\n      \"homepage\": \"主页链接\",\n      \"homepagePlaceholder\": \"https://example.com\",\n      \"docs\": \"文档链接\",\n      \"docsPlaceholder\": \"https://example.com/docs\",\n      \"additionalInfo\": \"附加信息\",\n      \"jsonConfig\": \"完整的 JSON 配置\",\n      \"jsonConfigOrPrefix\": \"完整的 JSON 配置或者使用\",\n      \"tomlConfigOrPrefix\": \"完整的 TOML 配置或者使用\",\n      \"jsonPlaceholder\": \"{\\n  \\\"type\\\": \\\"stdio\\\",\\n  \\\"command\\\": \\\"uvx\\\",\\n  \\\"args\\\": [\\\"mcp-server-fetch\\\"]\\n}\",\n      \"tomlConfig\": \"完整的 TOML 配置\",\n      \"tomlPlaceholder\": \"type = \\\"stdio\\\"\\ncommand = \\\"uvx\\\"\\nargs = [\\\"mcp-server-fetch\\\"]\",\n      \"useWizard\": \"配置向导\",\n      \"syncOtherSide\": \"同步到 {{target}}\",\n      \"syncOtherSideHint\": \"勾选后会把当前配置同时写入 {{target}}，若存在同名配置将被覆盖\",\n      \"willOverwriteWarning\": \"将覆盖 {{target}} 中的同名配置\"\n    },\n    \"wizard\": {\n      \"title\": \"MCP 配置向导\",\n      \"hint\": \"快速配置 MCP 服务器，自动生成 JSON 配置\",\n      \"type\": \"类型\",\n      \"typeStdio\": \"stdio\",\n      \"typeHttp\": \"http\",\n      \"typeSse\": \"sse\",\n      \"command\": \"命令\",\n      \"commandPlaceholder\": \"npx 或 uvx\",\n      \"args\": \"参数\",\n      \"argsPlaceholder\": \"arg1\\narg2\",\n      \"env\": \"环境变量\",\n      \"envPlaceholder\": \"KEY1=value1\\nKEY2=value2\",\n      \"url\": \"URL\",\n      \"urlPlaceholder\": \"https://api.example.com/mcp\",\n      \"urlRequired\": \"请输入 URL\",\n      \"headers\": \"请求头（可选）\",\n      \"headersPlaceholder\": \"Authorization: Bearer your_token_here\\nContent-Type: application/json\",\n      \"preview\": \"配置预览\",\n      \"apply\": \"应用配置\"\n    },\n    \"id\": \"标识 (唯一)\",\n    \"type\": \"类型\",\n    \"command\": \"命令\",\n    \"validateCommand\": \"校验命令\",\n    \"args\": \"参数\",\n    \"argsPlaceholder\": \"例如：mcp-server-fetch --help\",\n    \"env\": \"环境变量 (一行一个，KEY=VALUE)\",\n    \"envPlaceholder\": \"FOO=bar\\nHELLO=world\",\n    \"reset\": \"重置\",\n    \"msg\": {\n      \"saved\": \"已保存\",\n      \"deleted\": \"已删除\",\n      \"enabled\": \"已启用\",\n      \"disabled\": \"已禁用\",\n      \"templateAdded\": \"已添加模板\"\n    },\n    \"error\": {\n      \"idRequired\": \"请填写标识\",\n      \"idExists\": \"该标识已存在，请更换\",\n      \"jsonInvalid\": \"JSON 格式错误，请检查\",\n      \"tomlInvalid\": \"TOML 格式错误，请检查\",\n      \"commandRequired\": \"请填写命令\",\n      \"singleServerObjectRequired\": \"此处只需单个服务器对象，请不要粘贴包含 mcpServers 的整份配置\",\n      \"saveFailed\": \"保存失败\",\n      \"deleteFailed\": \"删除失败\"\n    },\n    \"validation\": {\n      \"ok\": \"命令可用\",\n      \"fail\": \"命令不可用\"\n    },\n    \"confirm\": {\n      \"deleteTitle\": \"删除 MCP 服务器\",\n      \"deleteMessage\": \"确定要删除 MCP 服务器 \\\"{{id}}\\\" 吗？此操作无法撤销。\"\n    },\n    \"presets\": {\n      \"title\": \"选择 MCP 类型\",\n      \"enable\": \"启用\",\n      \"enabled\": \"已启用\",\n      \"installed\": \"已安装\",\n      \"docs\": \"文档\",\n      \"requiresEnv\": \"需要环境变量\",\n      \"fetch\": {\n        \"name\": \"mcp-server-fetch\",\n        \"description\": \"通用 HTTP 请求工具，支持 GET/POST 等 HTTP 方法，适合快速请求接口/抓取网页数据\"\n      },\n      \"time\": {\n        \"name\": \"@modelcontextprotocol/server-time\",\n        \"description\": \"时间查询工具，提供当前时间、时区转换、日期计算等功能\"\n      },\n      \"memory\": {\n        \"name\": \"@modelcontextprotocol/server-memory\",\n        \"description\": \"知识图谱记忆系统，支持存储实体、关系和观察，让 AI 记住对话中的重要信息\"\n      },\n      \"sequential-thinking\": {\n        \"name\": \"@modelcontextprotocol/server-sequential-thinking\",\n        \"description\": \"顺序思考工具，帮助 AI 将复杂问题分解为多个步骤，逐步深入思考\"\n      },\n      \"context7\": {\n        \"name\": \"@upstash/context7-mcp\",\n        \"description\": \"Context7 文档搜索工具，提供最新的库文档和代码示例，配置 key 会有更高限额\"\n      }\n    }\n  },\n  \"prompts\": {\n    \"manage\": \"提示词\",\n    \"title\": \"{{appName}} 提示词管理\",\n    \"claudeTitle\": \"Claude 提示词管理\",\n    \"codexTitle\": \"Codex 提示词管理\",\n    \"add\": \"添加提示词\",\n    \"edit\": \"编辑提示词\",\n    \"addTitle\": \"添加 {{appName}} 提示词\",\n    \"editTitle\": \"编辑 {{appName}} 提示词\",\n    \"import\": \"导入现有\",\n    \"count\": \"共 {{count}} 个提示词\",\n    \"enabled\": \"已启用\",\n    \"enable\": \"启用\",\n    \"enabledName\": \"已启用: {{name}}\",\n    \"noneEnabled\": \"未启用任何提示词\",\n    \"currentFile\": \"当前 {{filename}} 内容\",\n    \"empty\": \"暂无提示词\",\n    \"emptyDescription\": \"点击右上角按钮添加或导入提示词\",\n    \"loading\": \"加载中...\",\n    \"name\": \"名称\",\n    \"namePlaceholder\": \"例如：项目默认提示词\",\n    \"description\": \"描述\",\n    \"descriptionPlaceholder\": \"可选的描述信息\",\n    \"content\": \"内容\",\n    \"contentPlaceholder\": \"# {{filename}}\\n\\n在此输入提示词内容...\",\n    \"loadFailed\": \"加载提示词失败\",\n    \"saveSuccess\": \"保存成功\",\n    \"saveFailed\": \"保存失败\",\n    \"deleteSuccess\": \"删除成功\",\n    \"deleteFailed\": \"删除失败\",\n    \"enableSuccess\": \"启用成功\",\n    \"enableFailed\": \"启用失败\",\n    \"disableSuccess\": \"禁用成功\",\n    \"disableFailed\": \"禁用失败\",\n    \"importSuccess\": \"导入成功\",\n    \"importFailed\": \"导入失败\",\n    \"confirm\": {\n      \"deleteTitle\": \"确认删除\",\n      \"deleteMessage\": \"确定要删除提示词 \\\"{{name}}\\\" 吗？\"\n    }\n  },\n  \"workspace\": {\n    \"title\": \"Workspace 文件管理\",\n    \"manage\": \"Workspace\",\n    \"files\": {\n      \"agents\": \"Agent 操作指令和规则\",\n      \"soul\": \"Agent 人格和沟通风格\",\n      \"user\": \"用户档案和偏好\",\n      \"identity\": \"Agent 名称和头像\",\n      \"tools\": \"本地工具文档\",\n      \"memory\": \"长期记忆和决策记录\",\n      \"heartbeat\": \"心跳运行清单\",\n      \"bootstrap\": \"首次运行仪式\",\n      \"boot\": \"网关重启清单\"\n    },\n    \"editing\": \"编辑 {{filename}}\",\n    \"saveSuccess\": \"保存成功\",\n    \"saveFailed\": \"保存失败\",\n    \"loadFailed\": \"读取失败\",\n    \"openDirectory\": \"在文件管理器中打开\",\n    \"dailyMemory\": {\n      \"title\": \"每日记忆\",\n      \"sectionTitle\": \"每日记忆\",\n      \"cardTitle\": \"每日记忆文件\",\n      \"cardDescription\": \"浏览管理每日记忆\",\n      \"createToday\": \"添加记忆\",\n      \"empty\": \"暂无每日记忆文件\",\n      \"loadFailed\": \"加载每日记忆文件失败\",\n      \"createFailed\": \"创建每日记忆文件失败\",\n      \"deleteSuccess\": \"每日记忆文件已删除\",\n      \"deleteFailed\": \"删除每日记忆文件失败\",\n      \"confirmDeleteTitle\": \"删除每日记忆\",\n      \"confirmDeleteMessage\": \"确定删除 {{date}} 的每日记忆吗？此操作不可撤销。\",\n      \"searchPlaceholder\": \"搜索全文内容...\",\n      \"searchScopeHint\": \"全文搜索所有每日记忆 ⌘F\",\n      \"searchCloseHint\": \"Esc 关闭\",\n      \"noSearchResults\": \"没有找到匹配的每日记忆。\",\n      \"searching\": \"搜索中...\",\n      \"searchFailed\": \"搜索失败\",\n      \"matchCount\": \"{{count}} 处匹配\"\n    }\n  },\n  \"openclaw\": {\n    \"backupCreated\": \"已创建备份：{{path}}\",\n    \"providerKey\": \"供应商标识\",\n    \"providerKeyPlaceholder\": \"my-provider\",\n    \"providerKeyHint\": \"配置文件中的唯一标识符，创建后无法修改，只能使用小写字母、数字和连字符\",\n    \"providerKeyRequired\": \"请填写供应商标识\",\n    \"providerKeyDuplicate\": \"此标识已被使用，请更换\",\n    \"providerKeyInvalid\": \"标识格式无效，只能使用小写字母、数字和连字符\",\n    \"apiProtocol\": \"API 协议\",\n    \"selectProtocol\": \"选择 API 协议\",\n    \"apiProtocolHint\": \"选择与供应商 API 兼容的协议类型。大多数供应商使用 OpenAI Completions 格式。\",\n    \"baseUrl\": \"API 端点\",\n    \"baseUrlHint\": \"供应商的 API 端点地址。\",\n    \"models\": \"模型列表\",\n    \"addModel\": \"添加模型\",\n    \"noModels\": \"暂无模型配置。点击添加模型来配置可用模型。\",\n    \"modelId\": \"模型 ID\",\n    \"modelIdPlaceholder\": \"claude-3-sonnet\",\n    \"modelName\": \"显示名称\",\n    \"modelNamePlaceholder\": \"Claude 3 Sonnet\",\n    \"contextWindow\": \"上下文窗口\",\n    \"maxTokens\": \"最大输出 Tokens\",\n    \"reasoning\": \"推理模式\",\n    \"reasoningOn\": \"启用\",\n    \"reasoningOff\": \"关闭\",\n    \"inputTypes\": \"输入类型\",\n    \"inputCost\": \"输入价格 ($/M tokens)\",\n    \"outputCost\": \"输出价格 ($/M tokens)\",\n    \"advancedOptions\": \"高级选项\",\n    \"cacheReadCost\": \"缓存读取价格 ($/M tokens)\",\n    \"cacheWriteCost\": \"缓存写入价格 ($/M tokens)\",\n    \"cacheCostHint\": \"缓存价格用于计算 Prompt Caching 的成本。如不使用缓存可留空。\",\n    \"modelsHint\": \"配置该供应商支持的模型。模型 ID 用于 API 调用，显示名称用于界面展示。\",\n    \"userAgent\": \"发送 User-Agent\",\n    \"userAgentHint\": \"部分供应商需要浏览器 User-Agent 才能正常访问。\",\n    \"env\": {\n      \"title\": \"环境变量\",\n      \"description\": \"管理 openclaw.json 中的环境变量配置（API Key、自定义变量等）\",\n      \"editorHint\": \"以 JSON 形式编辑整个 env 节点。支持 env.vars、env.shellEnv 等嵌套对象。\",\n      \"objectRequired\": \"OpenClaw 的 env 必须是 JSON 对象。\",\n      \"invalidJson\": \"OpenClaw 的 env 必须是合法 JSON。\",\n      \"empty\": \"OpenClaw 的 env 不能为空。空对象请使用 {}。\",\n      \"keyPlaceholder\": \"变量名\",\n      \"valuePlaceholder\": \"值\",\n      \"add\": \"添加变量\",\n      \"saveSuccess\": \"环境变量已保存\",\n      \"saveFailed\": \"保存环境变量失败\",\n      \"loadFailed\": \"读取环境变量失败\",\n      \"duplicateKey\": \"检测到重复的变量名: {{key}}\"\n    },\n    \"tools\": {\n      \"title\": \"工具权限\",\n      \"description\": \"管理 openclaw.json 中的工具权限配置（允许/拒绝列表）\",\n      \"profile\": \"权限模式\",\n      \"profileMinimal\": \"最小权限\",\n      \"profileCoding\": \"编码\",\n      \"profileMessaging\": \"对话\",\n      \"profileFull\": \"完全访问\",\n      \"profileUnset\": \"未设置\",\n      \"unsupportedProfileTitle\": \"检测到不受支持的工具配置\",\n      \"unsupportedProfileDescription\": \"当前 tools.profile 的值“{{value}}”不在 OpenClaw 支持列表内。在你手动选择新值之前，它会被保留。\",\n      \"unsupportedProfileLabel\": \"不受支持\",\n      \"allowList\": \"允许列表\",\n      \"denyList\": \"拒绝列表\",\n      \"patternPlaceholder\": \"工具名称或模式\",\n      \"addAllow\": \"添加允许\",\n      \"addDeny\": \"添加拒绝\",\n      \"saveSuccess\": \"工具权限已保存\",\n      \"saveFailed\": \"保存工具权限失败\",\n      \"loadFailed\": \"读取工具权限失败\"\n    },\n    \"agents\": {\n      \"title\": \"Agents 配置\",\n      \"description\": \"管理 openclaw.json 中的 agents.defaults 配置（默认模型、运行参数等）\",\n      \"modelSection\": \"模型配置\",\n      \"primaryModel\": \"默认模型\",\n      \"primaryModelHint\": \"从已配置供应商的模型中选择默认模型\",\n      \"notSet\": \"未设置\",\n      \"fallbackModels\": \"回退模型\",\n      \"fallbackModelsHint\": \"当主模型不可用时，按优先级依次尝试以下回退模型\",\n      \"addFallback\": \"添加回退模型\",\n      \"noModels\": \"暂无已配置的供应商模型。请先添加 OpenClaw 供应商。\",\n      \"notInList\": \"{{value}} (供应商未配置)\",\n      \"runtimeSection\": \"运行参数\",\n      \"workspace\": \"工作区路径\",\n      \"timeout\": \"超时时间（秒）\",\n      \"contextTokens\": \"上下文 Token 数\",\n      \"maxConcurrent\": \"最大并发数\",\n      \"legacyTimeoutTitle\": \"检测到旧版超时字段\",\n      \"legacyTimeoutDescription\": \"当前配置仍在使用 agents.defaults.timeout。保存本页面时会迁移为 timeoutSeconds。\",\n      \"saveSuccess\": \"Agents 配置已保存\",\n      \"saveFailed\": \"保存 Agents 配置失败\",\n      \"loadFailed\": \"读取 Agents 配置失败\"\n    },\n    \"health\": {\n      \"title\": \"检测到 OpenClaw 配置警告\",\n      \"invalidToolsProfile\": \"tools.profile 使用了不受支持的值。OpenClaw 当前只支持 minimal、coding、messaging、full。\",\n      \"legacyTimeout\": \"agents.defaults.timeout 已废弃。打开并保存 Agents 面板即可迁移到 timeoutSeconds。\",\n      \"stringifiedEnvVars\": \"env.vars 应为对象，但当前值看起来像被字符串化或已损坏。\",\n      \"stringifiedShellEnv\": \"env.shellEnv 应为对象，但当前值看起来像被字符串化或已损坏。\",\n      \"parseFailed\": \"openclaw.json 不是合法 JSON5。请先修复文件，再通过这里编辑。\"\n    },\n    \"primaryModel\": \"默认模型\",\n    \"fallbackModel\": \"回退模型\"\n  },\n  \"env\": {\n    \"warning\": {\n      \"title\": \"检测到系统环境变量冲突\",\n      \"description\": \"发现 {{count}} 个环境变量可能会覆盖您的配置\"\n    },\n    \"actions\": {\n      \"expand\": \"查看详情\",\n      \"collapse\": \"收起\",\n      \"selectAll\": \"全选\",\n      \"clearSelection\": \"取消选择\",\n      \"deleteSelected\": \"删除选中 ({{count}})\",\n      \"deleting\": \"删除中...\"\n    },\n    \"field\": {\n      \"value\": \"值\",\n      \"source\": \"来源\"\n    },\n    \"source\": {\n      \"userRegistry\": \"用户环境变量 (注册表)\",\n      \"systemRegistry\": \"系统环境变量 (注册表)\",\n      \"systemEnv\": \"系统环境变量\"\n    },\n    \"delete\": {\n      \"success\": \"环境变量已成功删除\",\n      \"error\": \"删除环境变量失败\"\n    },\n    \"backup\": {\n      \"location\": \"备份位置: {{path}}\"\n    },\n    \"confirm\": {\n      \"title\": \"确认删除环境变量\",\n      \"message\": \"确定要删除 {{count}} 个环境变量吗？\",\n      \"backupNotice\": \"删除前将自动备份,您可以稍后恢复。删除后需要重启应用或终端才能生效。\",\n      \"confirm\": \"确认删除\"\n    },\n    \"error\": {\n      \"noSelection\": \"请选择要删除的环境变量\"\n    }\n  },\n  \"skills\": {\n    \"manage\": \"Skills\",\n    \"title\": \"Skills 管理\",\n    \"description\": \"从流行的仓库发现并安装技能，扩展 Claude Code/Codex/Gemini 的能力\",\n    \"refresh\": \"刷新\",\n    \"refreshing\": \"刷新中...\",\n    \"repoManager\": \"仓库管理\",\n    \"count\": \"共 {{count}} 个技能\",\n    \"empty\": \"暂无可用技能\",\n    \"emptyDescription\": \"添加技能仓库以发现可用的技能\",\n    \"addRepo\": \"添加技能仓库\",\n    \"loading\": \"加载中...\",\n    \"installed\": \"已安装\",\n    \"install\": \"安装\",\n    \"installing\": \"安装中...\",\n    \"uninstall\": \"卸载\",\n    \"uninstalling\": \"卸载中...\",\n    \"view\": \"查看\",\n    \"noDescription\": \"暂无描述\",\n    \"loadFailed\": \"加载失败\",\n    \"installSuccess\": \"技能 {{name}} 已安装\",\n    \"installFailed\": \"安装失败\",\n    \"uninstallSuccess\": \"技能 {{name}} 已卸载\",\n    \"uninstallFailed\": \"卸载失败\",\n    \"error\": {\n      \"skillNotFound\": \"技能不存在：{{directory}}\",\n      \"missingRepoInfo\": \"缺少仓库信息（owner 或 name）\",\n      \"downloadTimeout\": \"下载仓库 {{owner}}/{{name}} 超时（{{timeout}}秒）\",\n      \"downloadTimeoutHint\": \"请检查网络连接或稍后重试\",\n      \"skillPathNotFound\": \"仓库 {{owner}}/{{name}} 中未找到技能路径 '{{path}}'\",\n      \"skillDirNotFound\": \"技能目录不存在：{{path}}\",\n      \"directoryConflict\": \"技能目录 '{{directory}}' 已被 {{existing_repo}} 占用，无法从 {{new_repo}} 安装\",\n      \"emptyArchive\": \"下载的压缩包为空\",\n      \"downloadFailed\": \"下载失败：HTTP {{status}}\",\n      \"allBranchesFailed\": \"所有分支下载失败，尝试了：{{branches}}\",\n      \"httpError\": \"HTTP 错误 {{status}}\",\n      \"http403\": \"GitHub 访问受限，可能是请求频率过高\",\n      \"http404\": \"仓库或分支不存在，请检查地址\",\n      \"http429\": \"请求过于频繁，请等待后重试\",\n      \"parseMetadataFailed\": \"解析技能元数据失败\",\n      \"getHomeDirFailed\": \"无法获取用户主目录\",\n      \"noSkillsInZip\": \"ZIP 文件中未找到技能（需包含 SKILL.md 文件）\",\n      \"networkError\": \"网络错误\",\n      \"fsError\": \"文件系统错误\",\n      \"unknownError\": \"未知错误\",\n      \"suggestion\": {\n        \"checkNetwork\": \"请检查网络连接\",\n        \"checkProxy\": \"建议配置 HTTP 代理\",\n        \"retryLater\": \"请稍后重试\",\n        \"checkRepoUrl\": \"请检查仓库地址和分支名称\",\n        \"checkDiskSpace\": \"请检查磁盘空间\",\n        \"checkPermission\": \"请检查目录权限\",\n        \"uninstallFirst\": \"请先卸载已安装的同名技能\",\n        \"checkZipContent\": \"请确认 ZIP 文件包含有效的技能目录（含 SKILL.md 文件）\"\n      }\n    },\n    \"repo\": {\n      \"title\": \"管理技能仓库\",\n      \"description\": \"添加或删除 GitHub 技能仓库源\",\n      \"url\": \"仓库 URL\",\n      \"urlPlaceholder\": \"owner/name 或 https://github.com/owner/name\",\n      \"branch\": \"分支\",\n      \"branchPlaceholder\": \"main\",\n      \"path\": \"技能路径\",\n      \"pathPlaceholder\": \"skills (可选，留空扫描根目录)\",\n      \"add\": \"添加仓库\",\n      \"list\": \"已添加的仓库\",\n      \"empty\": \"暂无仓库\",\n      \"invalidUrl\": \"无效的仓库 URL 格式\",\n      \"addSuccess\": \"仓库 {{owner}}/{{name}} 已添加，识别到 {{count}} 个技能\",\n      \"addFailed\": \"添加失败\",\n      \"removeSuccess\": \"仓库 {{owner}}/{{name}} 已删除\",\n      \"removeFailed\": \"删除失败\",\n      \"skillCount\": \"识别到 {{count}} 个技能\"\n    },\n    \"search\": \"搜索技能\",\n    \"searchPlaceholder\": \"搜索技能名称或仓库名称...\",\n    \"filter\": {\n      \"placeholder\": \"状态筛选\",\n      \"all\": \"全部\",\n      \"installed\": \"已安装\",\n      \"uninstalled\": \"未安装\",\n      \"repo\": \"仓库筛选\",\n      \"allRepos\": \"全部仓库\"\n    },\n    \"noResults\": \"未找到匹配的技能\",\n    \"noInstalled\": \"暂无已安装的技能\",\n    \"noInstalledDescription\": \"从仓库发现并安装技能，或导入已有的技能\",\n    \"discover\": \"发现技能\",\n    \"import\": \"导入已有\",\n    \"importDescription\": \"选择要导入到 CC Switch 统一管理的技能\",\n    \"importSuccess\": \"成功导入 {{count}} 个技能\",\n    \"importSelected\": \"导入已选 ({{count}})\",\n    \"noUnmanagedFound\": \"未发现需要导入的技能。所有技能已在 CC Switch 统一管理中。\",\n    \"foundIn\": \"发现于\",\n    \"local\": \"本地\",\n    \"uninstallConfirm\": \"确定要卸载技能 \\\"{{name}}\\\" 吗？这将从所有应用中移除该技能，并在删除前自动创建本地备份。\",\n    \"uninstallInMainPanel\": \"请在主面板中卸载技能\",\n    \"notFound\": \"未找到技能\",\n    \"backup\": {\n      \"location\": \"备份位置: {{path}}\"\n    },\n    \"restoreFromBackup\": {\n      \"button\": \"从备份中恢复\",\n      \"title\": \"从备份中恢复\",\n      \"description\": \"选择一个 Skills 备份，将文件恢复到本地并重新加入当前列表。\",\n      \"empty\": \"暂无可恢复的 Skills 备份\",\n      \"createdAt\": \"备份时间\",\n      \"path\": \"备份路径\",\n      \"restore\": \"恢复\",\n      \"restoring\": \"恢复中...\",\n      \"delete\": \"删除\",\n      \"deleting\": \"删除中...\",\n      \"deleteSuccess\": \"技能备份 {{name}} 已删除\",\n      \"deleteFailed\": \"删除技能备份失败\",\n      \"deleteConfirmTitle\": \"确认删除备份\",\n      \"deleteConfirmMessage\": \"确定要删除技能备份 \\\"{{name}}\\\" 吗？此操作无法撤销。\",\n      \"success\": \"技能 {{name}} 已从备份恢复\",\n      \"failed\": \"从备份恢复失败\"\n    },\n    \"apps\": {\n      \"claude\": \"Claude\",\n      \"codex\": \"Codex\",\n      \"gemini\": \"Gemini\",\n      \"opencode\": \"OpenCode\",\n      \"openclaw\": \"OpenClaw\"\n    },\n    \"installFromZip\": {\n      \"button\": \"从 ZIP 安装\",\n      \"installing\": \"安装中...\",\n      \"successSingle\": \"技能 {{name}} 已安装\",\n      \"successMultiple\": \"成功安装 {{count}} 个技能\",\n      \"noSkillsFound\": \"ZIP 文件中未找到技能（需包含 SKILL.md 文件）\"\n    }\n  },\n  \"deeplink\": {\n    \"confirmImport\": \"确认导入供应商配置\",\n    \"confirmImportDescription\": \"以下配置将导入到 CC Switch\",\n    \"importPrompt\": \"导入提示词\",\n    \"importPromptDescription\": \"请确认是否导入此系统提示词\",\n    \"importMcp\": \"导入 MCP Servers\",\n    \"importMcpDescription\": \"请确认是否导入这些 MCP Servers\",\n    \"importSkill\": \"添加 Skill 仓库\",\n    \"importSkillDescription\": \"请确认是否添加此 Skill 仓库\",\n    \"promptImportSuccess\": \"提示词导入成功\",\n    \"promptImportSuccessDescription\": \"已导入提示词: {{name}}\",\n    \"mcpImportSuccess\": \"MCP Servers 导入成功\",\n    \"mcpImportSuccessDescription\": \"成功导入 {{count}} 个服务器\",\n    \"mcpPartialSuccess\": \"部分导入成功\",\n    \"mcpPartialSuccessDescription\": \"成功: {{success}}, 失败: {{failed}}\",\n    \"skillImportSuccess\": \"Skill 仓库添加成功\",\n    \"skillImportSuccessDescription\": \"已添加仓库: {{repo}}\",\n    \"app\": \"应用类型\",\n    \"providerName\": \"供应商名称\",\n    \"homepage\": \"官网地址\",\n    \"endpoint\": \"API 端点\",\n    \"apiKey\": \"API 密钥\",\n    \"icon\": \"图标\",\n    \"model\": \"模型\",\n    \"haikuModel\": \"Haiku 模型\",\n    \"sonnetModel\": \"Sonnet 模型\",\n    \"opusModel\": \"Opus 模型\",\n    \"multiModel\": \"多模态模型\",\n    \"notes\": \"备注\",\n    \"import\": \"导入\",\n    \"importing\": \"导入中...\",\n    \"warning\": \"请确认以上信息准确无误后再导入。导入后可在供应商列表中编辑或删除。\",\n    \"parseError\": \"深链接解析失败\",\n    \"importSuccess\": \"导入成功\",\n    \"importSuccessDescription\": \"供应商 \\\"{{name}}\\\" 已成功导入\",\n    \"importError\": \"导入失败\",\n    \"configSource\": \"配置来源\",\n    \"configEmbedded\": \"内嵌配置\",\n    \"configRemote\": \"远程配置\",\n    \"configDetails\": \"配置详情\",\n    \"configUrl\": \"配置文件 URL\",\n    \"configMergeError\": \"合并配置文件失败\",\n    \"primaryEndpoint\": \"主\",\n    \"mcp\": {\n      \"title\": \"批量导入 MCP Servers\",\n      \"targetApps\": \"目标应用\",\n      \"serverCount\": \"MCP Servers ({{count}} 个)\",\n      \"enabledWarning\": \"导入后将立即写入所有指定应用的配置文件\"\n    },\n    \"prompt\": {\n      \"title\": \"导入系统提示词\",\n      \"app\": \"应用\",\n      \"name\": \"名称\",\n      \"description\": \"描述\",\n      \"contentPreview\": \"内容预览\",\n      \"enabledWarning\": \"导入后将立即启用此提示词，其他提示词将被禁用\"\n    },\n    \"skill\": {\n      \"title\": \"添加 Claude Skill 仓库\",\n      \"repo\": \"GitHub 仓库\",\n      \"directory\": \"目标目录\",\n      \"branch\": \"分支\",\n      \"skillsPath\": \"Skills 路径\",\n      \"hint\": \"此操作将添加 Skill 仓库到列表。\",\n      \"hintDetail\": \"添加后，您可以在 Skills 管理界面中选择安装具体的 Skill。\"\n    },\n    \"usageScript\": \"用量查询\",\n    \"usageScriptEnabled\": \"已启用\",\n    \"usageScriptDisabled\": \"未启用\",\n    \"usageApiKey\": \"用量 API Key\",\n    \"usageBaseUrl\": \"用量查询地址\",\n    \"usageAutoInterval\": \"自动查询\",\n    \"usageAutoIntervalValue\": \"每 {{minutes}} 分钟\"\n  },\n  \"iconPicker\": {\n    \"search\": \"搜索图标\",\n    \"searchPlaceholder\": \"输入图标名称...\",\n    \"noResults\": \"未找到匹配的图标\",\n    \"category\": {\n      \"aiProvider\": \"AI 服务商\",\n      \"cloud\": \"云平台\",\n      \"tool\": \"开发工具\",\n      \"other\": \"其他\"\n    }\n  },\n  \"providerIcon\": {\n    \"label\": \"图标\",\n    \"colorLabel\": \"图标颜色\",\n    \"selectIcon\": \"选择图标\",\n    \"preview\": \"预览\",\n    \"clickToChange\": \"点击更换图标\",\n    \"clickToSelect\": \"点击选择图标\",\n    \"color\": \"图标颜色\"\n  },\n  \"migration\": {\n    \"success\": \"配置迁移成功\",\n    \"skillsSuccess\": \"已自动导入 {{count}} 个技能到统一管理\",\n    \"skillsFailed\": \"自动导入技能失败\",\n    \"skillsFailedDescription\": \"请打开 Skills 页面点击“导入已有”手动导入（或重启后再试）。\"\n  },\n  \"agents\": {\n    \"title\": \"智能体\"\n  },\n  \"modelTest\": {\n    \"testProvider\": \"测试模型\"\n  },\n  \"health\": {\n    \"operational\": \"正常\",\n    \"degraded\": \"降级\",\n    \"failed\": \"失败\",\n    \"circuitOpen\": \"熔断\",\n    \"consecutiveFailures\": \"连续失败 {{count}} 次\"\n  },\n  \"failover\": {\n    \"enabled\": \"{{app}} 故障转移已启用\",\n    \"disabled\": \"{{app}} 故障转移已关闭\",\n    \"toggleFailed\": \"操作失败: {{detail}}\",\n    \"inQueue\": \"已加入\",\n    \"addQueue\": \"加入\",\n    \"priority\": {\n      \"tooltip\": \"故障转移优先级 {{priority}}\"\n    },\n    \"tooltip\": {\n      \"enabled\": \"{{app}} 故障转移已启用\\n按队列优先级（P1→P2→...）选择供应商\",\n      \"disabled\": \"启用 {{app}} 故障转移\\n将立即切换到队列 P1，并在失败时自动切换到下一个\"\n    }\n  },\n  \"proxy\": {\n    \"panel\": {\n      \"serviceAddress\": \"服务地址\",\n      \"addressCopied\": \"地址已复制\",\n      \"currentProvider\": \"当前 Provider：\",\n      \"waitingFirstRequest\": \"当前 Provider：等待首次请求…\",\n      \"stoppedTitle\": \"代理服务已停止\",\n      \"stoppedDescription\": \"使用上方开关即可启动服务\",\n      \"openSettings\": \"配置代理服务\",\n      \"stats\": {\n        \"activeConnections\": \"活跃连接\",\n        \"totalRequests\": \"总请求数\",\n        \"successRate\": \"成功率\",\n        \"uptime\": \"运行时间\"\n      }\n    },\n    \"settings\": {\n      \"title\": \"代理服务设置\",\n      \"description\": \"配置本地代理服务器的监听地址、端口和运行参数，保存后立即生效。\",\n      \"alert\": {\n        \"autoApply\": \"保存后将自动同步到正在运行的代理服务，无需手动重启。\"\n      },\n      \"basic\": {\n        \"title\": \"基础设置\",\n        \"description\": \"配置代理服务监听的地址与端口。\"\n      },\n      \"advanced\": {\n        \"title\": \"高级参数\",\n        \"description\": \"控制请求的稳定性和日志记录。\"\n      },\n      \"timeout\": {\n        \"title\": \"超时设置\",\n        \"description\": \"配置流式和非流式请求的超时时间。\"\n      },\n      \"fields\": {\n        \"listenAddress\": {\n          \"label\": \"监听地址\",\n          \"placeholder\": \"127.0.0.1\",\n          \"description\": \"代理服务器监听的 IP 地址（推荐 127.0.0.1）\"\n        },\n        \"listenPort\": {\n          \"label\": \"监听端口\",\n          \"placeholder\": \"15721\",\n          \"description\": \"代理服务器监听的端口号（1024 ~ 65535）\"\n        },\n        \"maxRetries\": {\n          \"label\": \"最大重试次数\",\n          \"placeholder\": \"3\",\n          \"description\": \"请求失败时的重试次数（0 ~ 10）\"\n        },\n        \"requestTimeout\": {\n          \"label\": \"请求超时（秒）\",\n          \"placeholder\": \"0（不限）或 300\",\n          \"description\": \"单个请求的最大等待时间（0 表示不限制，或设置 10 ~ 600 秒）\"\n        },\n        \"enableLogging\": {\n          \"label\": \"启用日志记录\",\n          \"description\": \"记录所有代理请求，便于排查问题\"\n        },\n        \"streamingFirstByteTimeout\": {\n          \"label\": \"流式首字超时（秒）\",\n          \"description\": \"等待首个数据块的最大时间\"\n        },\n        \"streamingIdleTimeout\": {\n          \"label\": \"流式静默超时（秒）\",\n          \"description\": \"数据块之间的最大间隔\"\n        },\n        \"nonStreamingTimeout\": {\n          \"label\": \"非流式超时（秒）\",\n          \"description\": \"非流式请求的总超时时间\"\n        }\n      },\n      \"validation\": {\n        \"addressInvalid\": \"请输入有效的IP地址\",\n        \"portMin\": \"端口必须大于1024\",\n        \"portMax\": \"端口必须小于65535\",\n        \"retryMin\": \"重试次数不能为负\",\n        \"retryMax\": \"重试次数不能超过10\",\n        \"timeoutNonNegative\": \"超时时间不能为负数\",\n        \"timeoutMax\": \"超时时间最多600秒\",\n        \"timeoutRange\": \"请输入 0 或 10-600 之间的数值\",\n        \"streamingTimeoutMin\": \"超时时间至少5秒\",\n        \"streamingTimeoutMax\": \"超时时间最多300秒\"\n      },\n      \"actions\": {\n        \"save\": \"保存配置\"\n      },\n      \"toast\": {\n        \"saved\": \"代理配置已保存\",\n        \"saveFailed\": \"保存失败: {{error}}\"\n      },\n      \"invalidPort\": \"端口无效，请输入 1024-65535 之间的数字\",\n      \"invalidAddress\": \"地址无效，请输入有效的 IP 地址（如 127.0.0.1）或 localhost\",\n      \"configSaved\": \"代理配置已保存\",\n      \"configSaveFailed\": \"保存配置失败\",\n      \"restartRequired\": \"修改地址或端口后需要重启代理服务才能生效\"\n    },\n    \"switchFailed\": \"切换失败: {{error}}\",\n    \"takeover\": {\n      \"hint\": \"选择要接管的应用，启用后该应用的请求将通过本地代理转发\",\n      \"enabled\": \"{{app}} 接管已启用\",\n      \"disabled\": \"{{app}} 接管已关闭\",\n      \"failed\": \"切换接管状态失败\",\n      \"tooltip\": {\n        \"active\": \"{{appLabel}} 已接管 - {{address}}:{{port}}\\n切换该应用供应商为热切换\",\n        \"broken\": \"{{appLabel}} 已接管，但代理服务未运行\",\n        \"inactive\": \"接管 {{appLabel}} 的 Live 配置，让该应用请求走本地代理\"\n      }\n    },\n    \"failover\": {\n      \"proxyRequired\": \"需要先启动代理服务才能配置故障转移\",\n      \"autoSwitch\": \"自动故障转移\",\n      \"autoSwitchDescription\": \"开启后将立即切换到队列 P1，并在请求失败时自动切换到队列中的下一个供应商\"\n    },\n    \"failoverQueue\": {\n      \"title\": \"故障转移队列\",\n      \"description\": \"管理各应用的供应商故障转移顺序\",\n      \"info\": \"启用自动故障转移后，将按队列优先级选择供应商（P1 优先）。当请求失败时，系统会按队列顺序依次尝试下一个供应商。\",\n      \"selectProvider\": \"选择供应商添加到队列\",\n      \"noAvailableProviders\": \"没有可添加的供应商\",\n      \"empty\": \"故障转移队列为空。添加供应商以启用自动故障转移。\",\n      \"orderHint\": \"队列顺序与首页供应商列表顺序一致，可在首页拖拽调整顺序。\",\n      \"dragHint\": \"拖拽供应商可调整故障转移顺序，序号越小优先级越高。\",\n      \"toggleEnabled\": \"启用/禁用\",\n      \"addSuccess\": \"已添加到故障转移队列\",\n      \"addFailed\": \"添加失败\",\n      \"removeSuccess\": \"已从故障转移队列移除\",\n      \"removeFailed\": \"移除失败\",\n      \"reorderSuccess\": \"队列顺序已更新\",\n      \"reorderFailed\": \"更新顺序失败\",\n      \"toggleFailed\": \"状态更新失败\"\n    },\n    \"autoFailover\": {\n      \"info\": \"当故障转移队列中配置了多个供应商时，系统会在请求失败时按优先级顺序依次尝试。当某个供应商连续失败达到阈值时，熔断器会打开并在一段时间内跳过该供应商。\",\n      \"configSaved\": \"自动故障转移配置已保存\",\n      \"configSaveFailed\": \"保存失败\",\n      \"validationFailed\": \"以下字段超出有效范围: {{fields}}\",\n      \"retrySettings\": \"重试与超时设置\",\n      \"failureThreshold\": \"失败阈值\",\n      \"failureThresholdHint\": \"连续失败多少次后打开熔断器（建议: 3-10）\",\n      \"timeout\": \"恢复等待时间（秒）\",\n      \"timeoutHint\": \"熔断器打开后，等待多久后尝试恢复（建议: 30-120）\",\n      \"circuitBreakerSettings\": \"熔断器设置\",\n      \"successThreshold\": \"恢复成功阈值\",\n      \"successThresholdHint\": \"半开状态下成功多少次后关闭熔断器\",\n      \"errorRate\": \"错误率阈值 (%)\",\n      \"errorRateHint\": \"错误率超过此值时打开熔断器\",\n      \"minRequests\": \"最小请求数\",\n      \"minRequestsHint\": \"计算错误率前的最小请求数\",\n      \"explanationTitle\": \"工作原理\",\n      \"failureThresholdLabel\": \"失败阈值\",\n      \"failureThresholdExplain\": \"连续失败达到此次数时，熔断器打开，该供应商暂时不可用\",\n      \"timeoutLabel\": \"恢复等待时间\",\n      \"timeoutExplain\": \"熔断器打开后，等待此时间后尝试半开状态\",\n      \"successThresholdLabel\": \"恢复成功阈值\",\n      \"successThresholdExplain\": \"半开状态下，成功达到此次数时关闭熔断器，供应商恢复可用\",\n      \"errorRateLabel\": \"错误率阈值\",\n      \"errorRateExplain\": \"错误率超过此值时，即使未达到失败阈值也会打开熔断器\",\n      \"maxRetries\": \"最大重试次数\",\n      \"timeoutSettings\": \"超时配置\",\n      \"streamingFirstByte\": \"流式首字节超时\",\n      \"streamingIdle\": \"流式静默超时\",\n      \"nonStreaming\": \"非流式超时\",\n      \"maxRetriesHint\": \"请求失败时的重试次数（0-10）\",\n      \"streamingFirstByteHint\": \"等待首个数据块的最大时间，范围 1-120 秒，默认 60 秒\",\n      \"streamingIdleHint\": \"数据块之间的最大间隔，范围 60-600 秒，填 0 禁用（防止中途卡住）\",\n      \"nonStreamingHint\": \"非流式请求的总超时时间，范围 60-1200 秒，默认 600 秒（10 分钟）\"\n    },\n    \"logging\": {\n      \"enabled\": \"日志记录已启用\",\n      \"disabled\": \"日志记录已关闭\",\n      \"failed\": \"切换日志状态失败\"\n    },\n    \"server\": {\n      \"started\": \"代理服务已启动 - {{address}}:{{port}}\",\n      \"startFailed\": \"启动代理服务失败: {{detail}}\"\n    },\n    \"stoppedWithRestore\": \"代理服务已关闭，已恢复所有接管配置\",\n    \"stopWithRestoreFailed\": \"停止失败: {{detail}}\"\n  },\n  \"streamCheck\": {\n    \"configSaved\": \"健康检查配置已保存\",\n    \"configSaveFailed\": \"保存失败\",\n    \"testModels\": \"测试模型\",\n    \"claudeModel\": \"Claude 模型\",\n    \"codexModel\": \"Codex 模型\",\n    \"geminiModel\": \"Gemini 模型\",\n    \"checkParams\": \"检查参数\",\n    \"timeout\": \"超时时间（秒）\",\n    \"maxRetries\": \"最大重试次数\",\n    \"degradedThreshold\": \"降级阈值（毫秒）\",\n    \"testPrompt\": \"检查提示词\",\n    \"operational\": \"{{providerName}} 运行正常 ({{responseTimeMs}}ms)\",\n    \"degraded\": \"{{providerName}} 响应较慢 ({{responseTimeMs}}ms)\",\n    \"failed\": \"{{providerName}} 检查失败: {{message}}\",\n    \"error\": \"{{providerName}} 检查出错: {{error}}\"\n  },\n  \"proxyConfig\": {\n    \"proxyEnabled\": \"代理总开关\",\n    \"appTakeover\": \"代理启用\",\n    \"perAppConfig\": \"应用配置\",\n    \"circuitBreaker\": \"熔断器配置\",\n    \"circuitBreakerSettings\": \"熔断器设置\",\n    \"failureThreshold\": \"失败阈值\",\n    \"successThreshold\": \"恢复阈值\",\n    \"recoveryTimeout\": \"恢复等待时间\",\n    \"errorRateThreshold\": \"错误率阈值\",\n    \"minRequests\": \"最小请求数\",\n    \"timeoutConfig\": \"超时配置\",\n    \"streamingFirstByte\": \"流式首字节超时\",\n    \"streamingIdle\": \"流式静默超时\",\n    \"nonStreaming\": \"非流式超时\"\n  },\n  \"circuitBreaker\": {\n    \"failureThreshold\": \"失败阈值\",\n    \"successThreshold\": \"成功阈值\",\n    \"timeoutSeconds\": \"超时时间（秒）\",\n    \"errorRateThreshold\": \"错误率阈值 (%)\",\n    \"minRequests\": \"最小请求数\",\n    \"validationFailed\": \"以下字段超出有效范围: {{fields}}\",\n    \"configSaved\": \"熔断器配置已保存\",\n    \"saveFailed\": \"保存失败\",\n    \"loading\": \"加载中...\",\n    \"title\": \"熔断器配置\",\n    \"description\": \"调整熔断器参数以控制故障检测和恢复行为\",\n    \"failureThresholdHint\": \"连续失败多少次后打开熔断器\",\n    \"timeoutSecondsHint\": \"熔断器打开后多久尝试恢复（半开状态）\",\n    \"successThresholdHint\": \"半开状态下成功多少次后关闭熔断器\",\n    \"errorRateThresholdHint\": \"错误率超过此值时打开熔断器\",\n    \"minRequestsHint\": \"计算错误率前的最小请求数\",\n    \"saveConfig\": \"保存配置\",\n    \"instructionsTitle\": \"配置说明\",\n    \"instructions\": {\n      \"failureThreshold\": \"连续失败达到此次数时，熔断器打开\",\n      \"timeout\": \"熔断器打开后，等待此时间后尝试半开\",\n      \"successThreshold\": \"半开状态下，成功达到此次数时关闭熔断器\",\n      \"errorRate\": \"错误率超过此值时，熔断器打开\",\n      \"minRequests\": \"只有请求数达到此值后才计算错误率\"\n    }\n  },\n  \"universalProvider\": {\n    \"title\": \"统一供应商\",\n    \"description\": \"统一供应商可以同时管理 Claude、Codex 和 Gemini 的配置。修改后会自动同步到所有启用的应用。\",\n    \"add\": \"添加统一供应商\",\n    \"edit\": \"编辑统一供应商\",\n    \"empty\": \"还没有统一供应商\",\n    \"emptyHint\": \"点击下方「添加统一供应商」按钮创建一个\",\n    \"selectPreset\": \"选择预设类型\",\n    \"name\": \"名称\",\n    \"namePlaceholder\": \"例如：我的 NewAPI\",\n    \"baseUrl\": \"API 地址\",\n    \"apiKey\": \"API Key\",\n    \"websiteUrl\": \"官网地址\",\n    \"websiteUrlPlaceholder\": \"https://example.com（可选，用于在列表中显示）\",\n    \"notes\": \"备注\",\n    \"notesPlaceholder\": \"可选：添加备注信息\",\n    \"enabledApps\": \"启用的应用\",\n    \"modelConfig\": \"模型配置\",\n    \"model\": \"模型\",\n    \"sync\": \"同步到应用\",\n    \"synced\": \"已同步到所有应用\",\n    \"syncError\": \"同步失败\",\n    \"noAppsEnabled\": \"未启用任何应用\",\n    \"added\": \"统一供应商已添加\",\n    \"addedAndSynced\": \"统一供应商已添加并同步\",\n    \"updated\": \"统一供应商已更新\",\n    \"deleted\": \"统一供应商已删除\",\n    \"addSuccess\": \"统一供应商添加成功\",\n    \"addFailed\": \"统一供应商添加失败\",\n    \"hint\": \"跨应用统一配置，自动同步到 Claude/Codex/Gemini\",\n    \"manage\": \"管理\",\n    \"loadError\": \"加载统一供应商失败\",\n    \"saveError\": \"保存统一供应商失败\",\n    \"deleteError\": \"删除统一供应商失败\",\n    \"deleteConfirmTitle\": \"删除统一供应商\",\n    \"deleteConfirmDescription\": \"确定要删除 \\\"{{name}}\\\" 吗？这将同时删除它在各应用中生成的供应商配置。\",\n    \"syncConfirmTitle\": \"同步统一供应商\",\n    \"syncConfirmDescription\": \"同步 \\\"{{name}}\\\" 将会覆盖 Claude、Codex 和 Gemini 中关联的供应商配置。确定要继续吗？\",\n    \"syncConfirm\": \"同步\",\n    \"saveAndSync\": \"保存并同步\",\n    \"savedAndSynced\": \"已保存并同步到所有应用\",\n    \"saveAndSyncError\": \"保存并同步失败\",\n    \"configJsonPreview\": \"配置 JSON 预览\",\n    \"configJsonPreviewHint\": \"以下是将要同步到各应用的配置内容（仅覆盖显示的字段，保留其他自定义配置）\"\n  },\n  \"omo\": {\n    \"editProfile\": \"编辑 OMO 配置\",\n    \"newProfile\": \"新建 OMO 配置\",\n    \"profileName\": \"名称\",\n    \"mainAgents\": \"主 Agent\",\n    \"subAgents\": \"子 Agent\",\n    \"categories\": \"分类\",\n    \"customAgents\": \"自定义 Agent\",\n    \"noCustomAgents\": \"暂无自定义 Agent\",\n    \"otherFields\": \"其他配置\",\n    \"globalConfig\": \"OMO 全局配置\",\n    \"globalConfigShort\": \"OMO 配置\",\n    \"globalConfigSaved\": \"全局配置已保存\",\n    \"addProfile\": \"添加 OMO 配置\",\n    \"disabledItems\": \"禁用项设置\",\n    \"advanced\": \"高级设置\",\n    \"profileCreated\": \"OMO 配置已创建\",\n    \"profileUpdated\": \"OMO 配置已更新\",\n    \"invalidJson\": \"其他字段包含无效 JSON\",\n    \"confirmDelete\": \"删除配置\",\n    \"confirmDeleteMsg\": \"确定删除 \\\"{{name}}\\\" 吗？\",\n    \"profileDeleted\": \"配置已删除\",\n    \"imported\": \"已导入为 \\\"{{name}}\\\"\",\n    \"import\": \"导入\",\n    \"global\": \"全局\",\n    \"empty\": \"暂无配置。点击 + 添加或从本地导入。\",\n    \"applied\": \"已应用\",\n    \"apply\": \"应用\",\n    \"enable\": \"启用\",\n    \"enabled\": \"启用中\",\n    \"disabled\": \"OMO 已停用\",\n    \"disableFailed\": \"停用 OMO 失败: {{error}}\",\n    \"selectPlaceholder\": \"请选择...\",\n    \"clear\": \"清空\",\n    \"clearWrapped\": \"（清空）\",\n    \"defaultWrapped\": \"（默认）\",\n    \"variantPlaceholder\": \"思考等级\",\n    \"selectEnabledModel\": \"选择已配置模型\",\n    \"selectModel\": \"选择已配置模型\",\n    \"recommendedHint\": \"推荐: {{model}}\",\n    \"searchModel\": \"搜索模型...\",\n    \"selectModelFirst\": \"先选择模型\",\n    \"noEnabledModels\": \"暂无已配置模型\",\n    \"noVariantsForModel\": \"该模型无思考等级\",\n    \"currentValueNotEnabled\": \"{{value}}（当前值，未配置）\",\n    \"currentValueUnavailable\": \"{{value}}（当前值，不可用）\",\n    \"advancedLabel\": \"高级参数\",\n    \"advancedJsonInvalid\": \"高级参数 JSON 无效\",\n    \"advancedJsonHint\": \"temperature, top_p, budgetTokens, prompt_append, permission 等，留空使用默认值\",\n    \"noEnabledModelsWarning\": \"当前没有可用的已配置模型，请先配置 OpenCode 模型\",\n    \"modelSourcePartialWarning\": \"部分供应商模型配置无效，已自动跳过。\",\n    \"modelSourceFallbackWarning\": \"读取 live 供应商状态失败，已回退到已配置供应商列表。\",\n    \"importLocalReplaceSuccess\": \"已从本地文件导入并覆盖 Agent/Category/Other Fields\",\n    \"importLocalFailed\": \"读取本地文件失败: {{error}}\",\n    \"agentKeyPlaceholder\": \"agent 键名\",\n    \"categoryKeyPlaceholder\": \"分类键名\",\n    \"modelNamePlaceholder\": \"模型名\",\n    \"custom\": \"自定义\",\n    \"customCategories\": \"自定义分类\",\n    \"modelConfiguration\": \"模型配置\",\n    \"fillRecommended\": \"填充推荐\",\n    \"fillRecommendedSuccess\": \"已填充 {{count}} 个推荐模型\",\n    \"fillRecommendedPartial\": \"已填充 {{filled}} 个推荐模型，{{unmatched}} 个未匹配\",\n    \"fillRecommendedAllSet\": \"所有槽位已有模型配置\",\n    \"fillRecommendedNoMatch\": \"推荐模型在已配置的供应商中未找到匹配\",\n    \"configSummary\": \"已配置 {{agents}} 个 Agent，{{categories}} 个 Category · 点击 ⚙ 展开高级参数\",\n    \"enabledModelsCount\": \"可选已配置模型 {{count}} 个\",\n    \"source\": \"来源:\",\n    \"otherFieldsJson\": \"其他字段 (JSON)\",\n    \"searchOrType\": \"搜索或输入自定义值...\",\n    \"noMatches\": \"无匹配项\",\n    \"jsonMustBeObject\": \"{{field}} 必须是 JSON 对象\",\n    \"jsonInvalid\": \"{{field}} 包含无效 JSON\",\n    \"importGlobalSuccess\": \"已从本地文件导入全局配置（未保存）\",\n    \"importGlobalFailed\": \"读取本地文件失败: {{error}}\",\n    \"importLocal\": \"从本地导入\",\n    \"saveGlobalConfig\": \"保存全局配置\",\n    \"schemaUrl\": \"$schema\",\n    \"resetDefault\": \"重置默认\",\n    \"sisyphusAgentConfig\": \"Sisyphus Agent 设置\",\n    \"disabledAgents\": \"Agents\",\n    \"disabledAgentsPlaceholder\": \"禁用的 Agents\",\n    \"disabledMcps\": \"MCPs\",\n    \"disabledMcpsPlaceholder\": \"禁用的 MCPs\",\n    \"disabledHooks\": \"Hooks\",\n    \"disabledHooksPlaceholder\": \"禁用的 Hooks\",\n    \"disabledSkills\": \"Skills\",\n    \"disabledSkillsPlaceholder\": \"禁用的 Skills\",\n    \"advancedLsp\": \"LSP 配置\",\n    \"advancedExperimental\": \"实验性功能\",\n    \"advancedBackgroundTask\": \"后台任务\",\n    \"advancedBrowserAutomation\": \"浏览器自动化\",\n    \"advancedClaudeCode\": \"Claude Code\",\n    \"agentDesc\": {\n      \"sisyphus\": \"主编排者\",\n      \"hephaestus\": \"自主深度工作者\",\n      \"prometheus\": \"战略规划者\",\n      \"atlas\": \"任务管理者\",\n      \"oracle\": \"战略顾问\",\n      \"librarian\": \"多仓库研究员\",\n      \"explore\": \"快速代码搜索\",\n      \"multimodalLooker\": \"媒体分析器\",\n      \"metis\": \"规划前分析顾问\",\n      \"momus\": \"计划审查者\",\n      \"sisyphusJunior\": \"委托任务执行器\"\n    },\n    \"agentTooltip\": {\n      \"sisyphus\": \"主编排器，负责任务规划、委派与并行执行，使用扩展思考（32k 预算），通过 TODO 驱动工作流确保任务完成。\",\n      \"atlas\": \"主编排器（持有 TODO 列表），负责执行阶段的任务分发与协调，不直接完成所有工作而是委派给专业代理。\",\n      \"prometheus\": \"战略规划师，通过访谈模式收集需求并制定详细工作计划，仅能在 .sisyphus/ 目录读写 Markdown 文件，从不直接写代码。\",\n      \"hephaestus\": \"自主深度工作者（「合法工匠」），受 AmpCode 深度模式启发，目标导向执行，行动前会并行启动 2-5 个探索/图书管理员代理进行研究。\",\n      \"oracle\": \"架构决策与调试顾问，只读咨询代理，提供出色的逻辑推理和深度分析，不能写文件或委派任务。\",\n      \"librarian\": \"多仓库分析与文档检索专家，深度理解代码库并提供基于证据的答案，擅长查找官方文档和开源实现示例。\",\n      \"explore\": \"快速代码库探索与上下文 grep 专家，使用轻量级模型进行高速搜索，是理解代码结构的先锋。\",\n      \"multimodalLooker\": \"视觉内容专家，分析 PDF、图片、图表等非文本媒体，提取其中的信息与洞察。\",\n      \"metis\": \"计划顾问，在规划前进行预分析，识别隐藏意图、模糊点和 AI 失败点，防止过度工程化。\",\n      \"momus\": \"计划评审员，高精度验证计划的清晰度、可验证性和完整性，拒绝并要求修订直到计划完美。\",\n      \"sisyphusJunior\": \"类别生成的执行器，由 category 参数自动生成，专注于执行分配的任务且不能再委派，防止无限委派循环。\"\n    },\n    \"categoryDesc\": {\n      \"visualEngineering\": \"视觉/前端工程\",\n      \"ultrabrain\": \"超级思考\",\n      \"deep\": \"深度工作\",\n      \"artistry\": \"创意/文艺\",\n      \"quick\": \"快速响应\",\n      \"unspecifiedLow\": \"通用低配\",\n      \"unspecifiedHigh\": \"通用高配\",\n      \"writing\": \"写作\"\n    },\n    \"categoryTooltip\": {\n      \"visualEngineering\": \"前端与视觉工程类别，专注于 UI/UX 设计、样式、动画和界面实现，默认使用 Gemini 3 Pro 模型。\",\n      \"ultrabrain\": \"深度逻辑推理类别，用于需要广泛分析的复杂架构决策，默认使用 GPT-5.3 Codex 的超高推理变体。\",\n      \"deep\": \"深度自主问题解决类别，目标导向执行，行动前进行彻底研究，适用于需要深度理解的棘手问题。\",\n      \"artistry\": \"高度创意与艺术性任务类别，激发新颖想法和创造性解决方案，默认使用 Gemini 3 Pro 的最大变体。\",\n      \"quick\": \"轻量任务类别，用于单文件修改、错别字修复、简单调整等琐碎工作，默认使用 Claude Haiku 4.5 快速模型。\",\n      \"unspecifiedLow\": \"未归类低工作量任务类别，适用于不适合其他类别且工作量较小的任务，默认使用 Claude Sonnet 4.5。\",\n      \"unspecifiedHigh\": \"未归类高工作量任务类别，适用于不适合其他类别且工作量较大的任务，默认使用 Claude Opus 4.6 的最大变体。\",\n      \"writing\": \"写作类别，专注于文档、散文和技术写作，默认使用 Gemini 3 Flash 快速生成模型。\"\n    },\n    \"slimAgentDesc\": {\n      \"orchestrator\": \"编排者\",\n      \"oracle\": \"神谕者\",\n      \"librarian\": \"图书管理员\",\n      \"explorer\": \"探索者\",\n      \"designer\": \"设计师\",\n      \"fixer\": \"修复者\"\n    },\n    \"slimAgentTooltip\": {\n      \"orchestrator\": \"编写执行代码，编排多代理工作流，召唤专家\",\n      \"oracle\": \"根本原因分析、架构审查、调试指导（只读）\",\n      \"librarian\": \"文档查询、GitHub 代码搜索（只读）\",\n      \"explorer\": \"正则搜索、AST 模式匹配、文件发现（只读）\",\n      \"designer\": \"现代响应式设计、CSS/Tailwind 精通\",\n      \"fixer\": \"代码实现、重构、测试、验证\"\n    }\n  },\n  \"openclawConfig\": {\n    \"defaultModel\": {\n      \"title\": \"默认模型\",\n      \"description\": \"配置 OpenClaw 的默认主模型和回退模型\",\n      \"primary\": \"主模型\",\n      \"primaryPlaceholder\": \"例如: deepseek/deepseek-chat\",\n      \"fallbacks\": \"回退模型\",\n      \"fallbacksPlaceholder\": \"例如: openrouter/anthropic/claude-sonnet-4.5\",\n      \"addFallback\": \"添加回退模型\",\n      \"saved\": \"默认模型配置已保存\",\n      \"saveFailed\": \"保存默认模型失败\"\n    },\n    \"modelCatalog\": {\n      \"title\": \"模型目录\",\n      \"description\": \"配置可用模型的别名和允许列表\",\n      \"modelId\": \"模型 ID\",\n      \"modelIdPlaceholder\": \"例如: deepseek/deepseek-chat\",\n      \"alias\": \"别名\",\n      \"aliasPlaceholder\": \"例如: DeepSeek\",\n      \"addEntry\": \"添加模型\",\n      \"removeEntry\": \"移除\",\n      \"saved\": \"模型目录已保存\",\n      \"saveFailed\": \"保存模型目录失败\",\n      \"empty\": \"暂无模型目录配置\",\n      \"emptyHint\": \"添加模型到目录以配置别名\"\n    },\n    \"suggestedDefaults\": \"应用建议默认配置\",\n    \"suggestedDefaultsHint\": \"使用此预设推荐的默认模型配置\"\n  }\n}\n"
  },
  {
    "path": "src/icons/extracted/index.ts",
    "content": "// Auto-generated icon index\n// Do not edit manually\n\nexport const icons: Record<string, string> = {\n  aicodemirror: `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 1017.97 1056.47\"><title>AICodeMirror</title><path fill=\"#E4906E\" fill-rule=\"nonzero\" d=\"M944.92 1014.53c-17.29,-9.23 -33.98,-19.28 -50.08,-30.16 -5.39,-3.65 -14.99,-11.7 -28.81,-24.16 -6.06,-5.47 -14.07,-13.51 -24.03,-24.13 -15.15,-16.17 -29.61,-29.9 -41.69,-40.4 -3.98,-3.46 -14.2,-11.02 -30.68,-22.69 -6.24,-4.42 -12.88,-12.15 -18.63,-19.09 -23.98,-29.04 -49.53,-58.44 -76.66,-88.19 -11.93,-13.1 -25.64,-26.11 -35.61,-36.5 -28.72,-29.92 -51.92,-51.96 -78.23,-79.66 -11.24,-11.83 -20.52,-21.3 -27.85,-28.41 -0.56,-0.53 -1.3,-0.84 -2.08,-0.84 -0.51,0 -1.02,0.14 -1.47,0.39 -9.76,5.54 -17.53,14.33 -24.91,23.31 -10.71,13.01 -21.86,26.65 -33.44,40.91 -4.37,5.38 -7.9,9.46 -10.56,12.24 -5.43,5.67 -9.83,10.88 -15.08,15.39 -9.57,8.23 -19.57,16.01 -29.98,23.31 -14.73,10.32 -29.07,20.29 -43.04,29.9 -5.22,3.61 -13.68,9.81 -20.14,15.41 -25.71,22.32 -53.59,46.12 -83.65,71.41 -9.46,7.95 -19.65,16.88 -34.02,29.22 -25.66,22.03 -52.94,40.06 -81.67,55.65 -7.71,4.19 -14.15,8.2 -19.32,12.01 -19.3,14.23 -33.84,25.65 -43.62,34.28 -25.99,22.91 -43.04,37.82 -51.16,44.73 -9.19,7.8 -18.6,17.05 -28.42,25.54 -2.71,2.35 -5.7,3.03 -8.96,2.01 -0.78,-0.24 -1.25,-1.02 -1.1,-1.82 0.36,-2 1.19,-4.1 2.47,-6.32 6.86,-11.81 14.46,-23.09 19.95,-36.03 3.48,-8.23 7.87,-16.52 13.18,-24.89 2.03,-3.19 4.73,-8.77 8.11,-16.74 2.98,-7.02 7.34,-15.05 13.07,-24.12 5.79,-9.14 16,-23.36 30.63,-42.67 7.66,-10.11 19.49,-23.6 35.49,-40.47 4.9,-5.16 12.21,-11.87 21.92,-20.14 12.12,-10.31 23.53,-21.19 34.23,-32.65 11.73,-12.54 16.99,-22.33 27.39,-40.8 2.37,-4.19 6.49,-9.43 12.37,-15.71 8.27,-8.82 17,-17.23 26.21,-25.23 30.11,-26.18 55.17,-47.43 75.17,-63.76 8.66,-7.08 26.42,-21.39 39.65,-30.77 17.11,-12.13 28.62,-20.44 34.53,-24.91 4.5,-3.4 8.93,-6.6 13.3,-10.56 26.03,-23.54 51.66,-45.71 77.28,-70.7 0.42,-0.41 0.51,-1.06 0.21,-1.58 -6.8,-11.78 -12.84,-21.8 -18.11,-30.06 -10.22,-15.99 -22.07,-29.65 -35.57,-40.99 -7.56,-6.36 -18.41,-13.85 -28.65,-20.43 -15.08,-9.66 -30.62,-21.97 -46.63,-36.9 -35.08,-32.73 -67.65,-71.22 -85.32,-115.42 -5.53,-13.85 -10.8,-29.31 -15.8,-46.37 -5.89,-20.13 -12.37,-35.63 -23.22,-51.27 -8.93,-12.9 -15.77,-21.94 -19.58,-35.93 -1.27,-4.67 -2.93,-12.75 -4.99,-24.23 -2.07,-11.54 -6.54,-22.62 -13.41,-33.25 -7.54,-11.68 -13.66,-21.04 -18.33,-28.08 -3.68,-5.53 -7.02,-12.39 -9.63,-18.53 -3.9,-9.18 -8.14,-15.7 -13.6,-23.37 -3.94,-5.53 -5.07,-12.75 0,-18.32 4.14,-4.57 17.49,-3.02 21.56,-1.13 3.86,1.81 8.1,5.13 12.71,9.94 16.16,16.88 26.41,27.77 30.74,32.66 4.69,5.31 11.21,13.79 16.69,19.94 20.19,22.63 36.17,39.74 47.36,59.71 10.46,18.66 16.41,30.42 29.84,44.67 9.32,9.92 17.94,19.33 25.85,28.23 9.01,10.15 19.25,22.95 30.72,38.39 7.54,10.17 13.89,20.11 19.05,29.84 6.39,12.05 10.8,30.19 15.13,41.41 4.88,12.67 12.52,23.25 22.92,31.75 0.58,0.47 6.79,5.44 18.62,14.89 13.54,10.82 23.74,23.47 30.61,37.96 4.55,9.58 7.82,16.16 9.8,19.74 6.62,11.85 14.64,22.05 24.07,30.59 8.99,8.14 17.47,13.2 31.06,22.64 4.28,2.96 6.68,5.98 10.65,2.54 7.08,-6.11 13.73,-10.71 17.96,-14.53 6.12,-5.54 11.71,-11.84 16.79,-18.92 3.5,-4.88 8.77,-10.16 15.19,-14.42 22.77,-15.02 38.17,-31.11 63.32,-55.15 22.13,-21.17 46.22,-47.56 69.25,-66.8 32.17,-26.89 54.99,-45.9 68.46,-57.01 17.15,-14.15 35.82,-30.97 56.02,-50.46 16.06,-15.5 29.25,-27.72 39.57,-36.66 9.78,-8.47 17.55,-14.8 23.31,-18.98 8.52,-6.2 18.55,-10.61 30.56,-15.03 12.34,-4.55 23.44,-11.06 35.67,-17.61 9.07,-4.85 19.76,-9.89 30.05,-8.84 0.5,0.06 0.97,0.32 1.3,0.72 0.85,1.07 1.01,2.48 0.48,4.23 -2.1,6.99 -5.15,13.55 -9.66,20.87 -6.42,10.42 -11.51,19.46 -15.29,27.11 -6.09,12.35 -9.66,19.49 -10.69,21.43 -7.78,14.65 -17.56,27.97 -29.34,39.97 -4.8,4.89 -12.93,12.92 -24.37,24.07 -14.23,13.87 -25.02,30.77 -37.12,50.78 -15.21,25.13 -29.56,48.47 -43.06,70.01 -5.21,8.29 -13.68,15.13 -21.58,21.47 -25.71,20.7 -46.75,41.48 -70.98,64.67 -1.97,1.88 -4.98,4.47 -9.03,7.76 -22.62,18.36 -45.88,35.93 -69.78,52.74 -5.96,4.2 -13.77,11.24 -23.42,21.11 -17.12,17.5 -25.93,26.56 -26.42,27.19 -1.22,1.54 -1.08,3.09 0.41,4.67 14.11,14.81 34.25,37.65 60.42,68.51 11.89,14.01 24.87,27.08 36.03,39.46 8.75,9.7 16.81,22.11 31.82,42.59 2.69,3.68 13.53,16.07 32.5,37.17 5.17,5.76 11.64,14.47 19.4,26.12 16.37,24.6 36.28,56.2 59.73,94.79 4.2,6.92 7.74,12.33 10.62,16.21 5.41,7.29 10.37,13.74 14.92,20.97 6.26,9.94 11.3,19.92 15.11,29.92 4.29,11.27 7.73,19.49 10.32,24.69 7.21,14.5 14.81,28.41 22.8,41.73 3.44,5.75 6.78,13.03 6.11,20.05 -0.07,0.76 -0.71,1.34 -1.48,1.34 -0.25,0 -0.49,-0.06 -0.71,-0.18l0 0.01z\"/></svg>`,\n  aicoding: `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 470 470\"><title>AICoding</title><path fill=\"#a78bfa\" d=\"M 33 73 L 137 13 L 263 83 L 159 143 Z\"/><path fill=\"#a78bfa\" opacity=\"0.92\" d=\"M 33 73 L 33 213 L 159 283 L 159 143 Z\"/><path fill=\"#fff\" d=\"M 207 247 L 311 187 L 431 257 L 327 317 Z\"/><path fill=\"#a78bfa\" opacity=\"0.92\" d=\"M 207 247 L 207 387 L 327 457 L 327 317 Z\"/><path fill=\"#fdba74\" d=\"M 327 317 L 431 257 L 431 397 L 327 457 Z\"/><path fill=\"none\" stroke=\"#2b1b4b\" stroke-width=\"22\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M 33 73 L 137 13 L 263 83 L 159 143 L 33 73\"/><path fill=\"none\" stroke=\"#2b1b4b\" stroke-width=\"22\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M 33 73 L 33 213 L 159 283 L 159 143\"/><path fill=\"none\" stroke=\"#2b1b4b\" stroke-width=\"22\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M 159 143 L 263 83 L 263 223\"/><path fill=\"none\" stroke=\"#2b1b4b\" stroke-width=\"22\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M 207 247 L 311 187 L 431 257 L 327 317 L 207 247\"/><path fill=\"none\" stroke=\"#2b1b4b\" stroke-width=\"22\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M 207 247 L 207 387 L 327 457 L 327 317\"/><path fill=\"none\" stroke=\"#2b1b4b\" stroke-width=\"22\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M 327 317 L 431 257 L 431 397 L 327 457\"/><path fill=\"none\" stroke=\"#2b1b4b\" stroke-width=\"22\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M 245 163 L 365 163\"/><path fill=\"none\" stroke=\"#2b1b4b\" stroke-width=\"22\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M 175 197 L 335 117\"/><path fill=\"none\" stroke=\"#2b1b4b\" stroke-width=\"22\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M 365 163 L 365 241\"/></svg>`,\n  aigocode: `<svg height=\"1em\" width=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 648 564\" xmlns=\"http://www.w3.org/2000/svg\"><title>AiGoCode</title><g transform=\"translate(0,564) scale(0.1,-0.1)\"><path fill=\"#7C6AEF\" d=\"M5392 5379 c-26 -10 -36 -28 -56 -108 -24 -94 -47 -140 -101 -199 -56 -62 -117 -96 -219 -121 -101 -25 -116 -35 -116 -75 0 -45 21 -60 123 -89 190 -53 271 -147 336 -384 13 -46 38 -61 85 -49 25 6 36 28 56 111 7 28 23 72 36 99 13 28 21 54 18 58 -3 5 -2 7 3 6 4 -2 29 18 55 44 41 41 145 110 173 114 56 8 126 27 131 36 4 6 7 30 8 54 1 49 -5 54 -104 74 -164 33 -280 148 -320 320 -23 101 -52 129 -108 109z\"/><path fill=\"#5B7FFF\" d=\"M1770 4814 c-138 -25 -301 -93 -425 -177 -80 -55 -227 -197 -280 -272 -98 -138 -161 -279 -201 -447 -16 -66 -18 -164 -21 -1098 -4 -1130 -4 -1144 57 -1331 80 -242 222 -434 444 -598 l56 -42 0 -337 c0 -309 2 -340 19 -379 31 -66 87 -93 158 -74 22 6 202 117 404 249 200 131 398 259 439 285 144 89 48 81 1095 88 912 5 932 6 1012 27 243 64 441 178 599 344 166 176 274 408 304 648 13 110 13 2016 0 2120 -25 190 -83 347 -183 498 -160 240 -384 400 -677 484 l-75 22 -1325 2 c-1086 2 -1339 0 -1400 -12z m1068 -1455 l-3 -751 -71 -18 c-71 -18 -154 -55 -205 -91 -14 -10 -29 -16 -33 -12 -3 3 -6 91 -6 195 l0 188 -306 0 -305 0 -21 -47 c-11 -27 -33 -75 -48 -108 -16 -33 -44 -96 -62 -140 l-34 -80 -172 -3 c-101 -1 -172 1 -172 7 0 5 14 40 31 78 31 68 101 227 254 578 335 769 358 814 434 874 94 74 122 79 449 80 l272 1 -2 -751z m794 717 c41 -13 103 -42 139 -62 67 -40 202 -168 232 -221 l17 -31 -77 -65 c-43 -35 -101 -80 -129 -101 l-52 -36 -39 57 c-79 116 -204 177 -313 154 -66 -14 -105 -42 -130 -91 -19 -37 -20 -60 -20 -400 0 -339 1 -363 20 -399 26 -51 61 -78 128 -97 143 -42 327 52 366 186 l13 45 -163 3 -163 2 -3 157 c-2 111 0 157 9 160 6 2 263 3 570 1 487 -3 562 -5 593 -19 98 -45 125 -159 58 -244 -39 -50 -66 -55 -322 -55 l-236 0 -6 -27 c-18 -83 -29 -115 -62 -183 -69 -143 -230 -269 -402 -316 -81 -22 -271 -25 -350 -5 -173 43 -289 145 -341 298 -19 57 -20 81 -17 534 l3 474 33 67 c55 112 168 199 307 235 79 20 246 10 337 -21z m828 -1515 c67 -71 67 -73 -62 -396 -45 -110 -102 -254 -128 -320 -254 -639 -272 -681 -298 -702 -74 -62 -192 -15 -192 77 0 12 39 116 86 233 48 117 97 239 110 272 119 311 336 833 354 852 36 37 85 32 130 -16z m-1574 -205 c16 -13 19 -29 22 -128 l3 -113 -195 -155 c-108 -85 -196 -158 -196 -161 0 -4 17 -19 38 -35 128 -96 327 -272 339 -301 16 -39 17 -143 2 -177 -15 -33 -34 -39 -67 -22 -48 26 -566 446 -584 474 -23 35 -23 89 1 128 16 27 150 143 376 326 39 31 79 65 90 75 44 41 128 103 139 103 7 0 21 -6 32 -14z m713 -25 c52 -37 53 -33 -92 -551 -36 -129 -80 -289 -98 -355 -39 -143 -62 -175 -129 -175 -47 0 -92 20 -114 52 -24 34 -19 90 18 217 19 64 78 271 131 461 53 190 98 353 101 364 6 16 14 18 79 14 54 -4 81 -11 104 -27z m1116 -351 c55 -45 117 -98 138 -120 61 -64 48 -115 -50 -192 -32 -25 -118 -94 -191 -152 -136 -108 -163 -120 -217 -100 -26 10 -47 62 -39 97 10 46 22 62 94 119 36 29 93 77 128 106 l62 55 -65 59 c-71 65 -77 84 -49 141 24 47 44 67 68 67 12 0 66 -36 121 -80z\"/><path fill=\"#5B7FFF\" d=\"M2328 3755 c-37 -20 -54 -53 -153 -280 -48 -110 -96 -219 -106 -242 l-18 -43 234 0 235 0 0 290 0 290 -82 0 c-53 -1 -93 -6 -110 -15z\"/></g></svg>`,\n  alibaba: `<svg height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>Alibaba</title><path d=\"M24 14.014c-2.8 1.512-5.62 2.896-8.759 3.524-.7.139-1.476.139-2.187.043-.678-.085-1.017-.682-.776-1.31.23-.585.536-1.181.93-1.671.852-1.065 1.814-2.034 2.678-3.088a15.75 15.75 0 001.422-2.054c.306-.511.164-1.129-.372-1.384-.897-.437-1.859-.745-2.81-1.075-.11-.043-.274.074-.492.149.273.244.47.425.743.67-2.821.48-5.49 1.16-8.08 2.098-.012.053-.033.095-.023.117.383.585.208 1.032-.35 1.394a2.365 2.365 0 00-.568.522c1.706.5 3.226.213 4.68-.735-.087-.127-.175-.244-.262-.372.546.096.874.394.918.862.011.107-.054.213-.087.32-.077-.086-.175-.17-.24-.267-.045-.064-.056-.138-.088-.245-1.728 1.15-3.587 1.438-5.632.842 0 .404-.022.745.011 1.075.022.287-.098.415-.36.564-.591.362-1.204.735-1.696 1.214-.59.585-.371 1.299.427 1.597.907.34 1.859.35 2.81.234 1.126-.139 2.23-.32 3.456-.49-1.433.67-2.844 1.14-4.33 1.33-1.04.14-2.078.214-3.106-.084-1.476-.415-2.133-1.501-1.75-2.96.361-1.363 1.236-2.449 2.176-3.45 3.139-3.332 7.108-5.024 11.7-5.365 1.072-.074 2.155.064 3.16.511 1.411.639 2.002 1.99 1.313 3.354-.448.905-1.072 1.735-1.695 2.555-.612.809-1.301 1.554-1.946 2.331-.186.234-.361.48-.503.745-.274.5-.088.83.492.778 1.213-.118 2.45-.213 3.62-.511 1.716-.437 3.389-1.054 5.084-1.597.175-.043.339-.107.492-.17z\" fill=\"#FF6003\" fill-rule=\"evenodd\"></path></svg>`,\n  anthropic: `<svg fill=\"currentColor\" fill-rule=\"evenodd\" height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>Anthropic</title><path d=\"M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z\"></path></svg>`,\n  aws: `<svg fill=\"currentColor\" fill-rule=\"evenodd\" height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>AWS</title><path d=\"M6.763 11.212c0 .296.032.535.088.71.064.176.144.368.256.576.04.063.056.127.056.183 0 .08-.048.16-.152.24l-.503.335a.383.383 0 01-.208.072c-.08 0-.16-.04-.239-.112a2.47 2.47 0 01-.287-.375 6.18 6.18 0 01-.248-.471c-.622.734-1.405 1.101-2.347 1.101-.67 0-1.205-.191-1.596-.574-.39-.384-.59-.894-.59-1.533 0-.678.24-1.23.726-1.644.487-.415 1.133-.623 1.955-.623.272 0 .551.024.846.064.296.04.6.104.918.176v-.583c0-.607-.127-1.03-.375-1.277-.255-.248-.686-.367-1.3-.367-.28 0-.568.031-.863.103-.295.072-.583.16-.862.272a2.4 2.4 0 01-.28.104.488.488 0 01-.127.023c-.112 0-.168-.08-.168-.247v-.391c0-.128.016-.224.056-.28a.597.597 0 01.224-.167 4.577 4.577 0 011.005-.36 4.84 4.84 0 011.246-.151c.95 0 1.644.216 2.091.647.44.43.662 1.085.662 1.963v2.586h.016zm-3.24 1.214c.263 0 .534-.048.822-.144a1.78 1.78 0 00.758-.51 1.27 1.27 0 00.272-.512c.047-.191.08-.423.08-.694v-.335a6.66 6.66 0 00-.735-.136 6.02 6.02 0 00-.75-.048c-.535 0-.926.104-1.19.32-.263.215-.39.518-.39.917 0 .375.095.655.295.846.191.2.47.296.838.296zm6.41.862c-.144 0-.24-.024-.304-.08-.064-.048-.12-.16-.168-.311L7.586 6.726a1.398 1.398 0 01-.072-.32c0-.128.064-.2.191-.2h.783c.151 0 .255.025.31.08.065.048.113.16.16.312l1.342 5.284 1.245-5.284c.04-.16.088-.264.151-.312a.549.549 0 01.32-.08h.638c.152 0 .256.025.32.08.063.048.12.16.151.312l1.261 5.348 1.381-5.348c.048-.16.104-.264.16-.312a.52.52 0 01.311-.08h.743c.127 0 .2.065.2.2 0 .04-.009.08-.017.128a1.137 1.137 0 01-.056.2l-1.923 6.17c-.048.16-.104.263-.168.311a.51.51 0 01-.303.08h-.687c-.15 0-.255-.024-.32-.08-.063-.056-.119-.16-.15-.32L12.32 7.747l-1.23 5.14c-.04.16-.087.264-.15.32-.065.056-.177.08-.32.08l-.686.001zm10.256.215c-.415 0-.83-.048-1.229-.143-.399-.096-.71-.2-.918-.32-.128-.071-.215-.151-.247-.223a.563.563 0 01-.048-.224v-.407c0-.167.064-.247.183-.247.048 0 .096.008.144.024.048.016.12.048.2.08.271.12.566.215.878.279.32.064.63.096.95.096.502 0 .894-.088 1.165-.264a.86.86 0 00.415-.758.777.777 0 00-.215-.559c-.144-.151-.416-.287-.807-.415l-1.157-.36c-.583-.183-1.014-.454-1.277-.813a1.902 1.902 0 01-.4-1.158c0-.335.073-.63.216-.886.144-.255.335-.479.575-.654.24-.184.51-.32.83-.415.32-.096.655-.136 1.006-.136.175 0 .36.008.535.032.183.024.35.056.518.088.16.04.312.08.455.127.144.048.256.096.336.144a.69.69 0 01.24.2.43.43 0 01.071.263v.375c0 .168-.064.256-.184.256a.83.83 0 01-.303-.096 3.652 3.652 0 00-1.532-.311c-.455 0-.815.071-1.062.223-.248.152-.375.383-.375.71 0 .224.08.416.24.567.16.152.454.304.877.44l1.134.358c.574.184.99.44 1.237.767.247.327.367.702.367 1.117 0 .343-.072.655-.207.926a2.157 2.157 0 01-.583.703c-.248.2-.543.343-.886.447-.36.111-.734.167-1.142.167z\"></path><path d=\"M.378 15.475c3.384 1.963 7.56 3.153 11.877 3.153 2.914 0 6.114-.607 9.06-1.852.44-.2.814.287.383.607-2.626 1.94-6.442 2.969-9.722 2.969-4.598 0-8.74-1.7-11.87-4.526-.247-.223-.024-.527.272-.351zm23.531-.2c.287.36-.08 2.826-1.485 4.007-.215.184-.423.088-.327-.151l.175-.439c.343-.88.802-2.198.52-2.555-.336-.43-2.22-.207-3.074-.103-.255.032-.295-.192-.063-.36 1.5-1.053 3.967-.75 4.254-.399z\" fill=\"#F90\"></path></svg>`,\n  azure: `<svg height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>Azure</title><path d=\"M7.242 1.613A1.11 1.11 0 018.295.857h6.977L8.03 22.316a1.11 1.11 0 01-1.052.755h-5.43a1.11 1.11 0 01-1.053-1.466L7.242 1.613z\" fill=\"url(#lobe-icons-azure-fill-0)\"></path><path d=\"M18.397 15.296H7.4a.51.51 0 00-.347.882l7.066 6.595c.206.192.477.298.758.298h6.226l-2.706-7.775z\" fill=\"#0078D4\"></path><path d=\"M15.272.857H7.497L0 23.071h7.775l1.596-4.73 5.068 4.73h6.665l-2.707-7.775h-7.998L15.272.857z\" fill=\"url(#lobe-icons-azure-fill-1)\"></path><path d=\"M17.193 1.613a1.11 1.11 0 00-1.052-.756h-7.81.035c.477 0 .9.304 1.052.756l6.748 19.992a1.11 1.11 0 01-1.052 1.466h-.12 7.895a1.11 1.11 0 001.052-1.466L17.193 1.613z\" fill=\"url(#lobe-icons-azure-fill-2)\"></path><defs><linearGradient gradientUnits=\"userSpaceOnUse\" id=\"lobe-icons-azure-fill-0\" x1=\"8.247\" x2=\"1.002\" y1=\"1.626\" y2=\"23.03\"><stop stop-color=\"#114A8B\"></stop><stop offset=\"1\" stop-color=\"#0669BC\"></stop></linearGradient><linearGradient gradientUnits=\"userSpaceOnUse\" id=\"lobe-icons-azure-fill-1\" x1=\"14.042\" x2=\"12.324\" y1=\"15.302\" y2=\"15.888\"><stop stop-opacity=\".3\"></stop><stop offset=\".071\" stop-opacity=\".2\"></stop><stop offset=\".321\" stop-opacity=\".1\"></stop><stop offset=\".623\" stop-opacity=\".05\"></stop><stop offset=\"1\" stop-opacity=\"0\"></stop></linearGradient><linearGradient gradientUnits=\"userSpaceOnUse\" id=\"lobe-icons-azure-fill-2\" x1=\"12.841\" x2=\"20.793\" y1=\"1.626\" y2=\"22.814\"><stop stop-color=\"#3CCBF4\"></stop><stop offset=\"1\" stop-color=\"#2892DF\"></stop></linearGradient></defs></svg>`,\n  baidu: `<svg height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>Baidu</title><path d=\"M8.859 11.735c1.017-1.71 4.059-3.083 6.202.286 1.579 2.284 4.284 4.397 4.284 4.397s2.027 1.601.73 4.684c-1.24 2.956-5.64 1.607-6.005 1.49l-.024-.009s-1.746-.568-3.776-.112c-2.026.458-3.773.286-3.773.286l-.045-.001c-.328-.01-2.38-.187-3.001-2.968-.675-3.028 2.365-4.687 2.592-4.968.226-.288 1.802-1.37 2.816-3.085zm.986 1.738v2.032h-1.64s-1.64.138-2.213 2.014c-.2 1.252.177 1.99.242 2.148.067.157.596 1.073 1.927 1.342h3.078v-7.514l-1.394-.022zm3.588 2.191l-1.44.024v3.956s.064.985 1.44 1.344h3.541v-5.3h-1.528v3.979h-1.46s-.466-.068-.553-.447v-3.556zM9.82 16.715v3.06H8.58s-.863-.045-1.126-1.049c-.136-.445.02-.959.088-1.16.063-.203.353-.671.951-.85H9.82zm9.525-9.036c2.086 0 2.646 2.06 2.646 2.742 0 .688.284 3.597-2.309 3.655-2.595.057-2.704-1.77-2.704-3.08 0-1.374.277-3.317 2.367-3.317zM4.24 6.08c1.523-.135 2.645 1.55 2.762 2.513.07.625.393 3.486-1.975 4-2.364.515-3.244-2.249-2.984-3.544 0 0 .28-2.797 2.197-2.969zm8.847-1.483c.14-1.31 1.69-3.316 2.931-3.028 1.236.285 2.367 1.944 2.137 3.37-.224 1.428-1.345 3.313-3.095 3.082-1.748-.226-2.143-1.823-1.973-3.424zM9.425 1c1.307 0 2.364 1.519 2.364 3.398 0 1.879-1.057 3.4-2.364 3.4s-2.367-1.521-2.367-3.4C7.058 2.518 8.118 1 9.425 1z\" fill=\"#2932E1\" fill-rule=\"nonzero\"></path></svg>`,\n  bytedance: `<svg height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>ByteDance</title><path d=\"M14.944 18.587l-1.704-.445V10.01l1.824-.462c1-.254 1.84-.461 1.88-.453.032 0 .056 2.235.056 4.972v4.973l-.176-.008c-.104 0-.952-.207-1.88-.446z\" fill=\"#00C8D2\" fill-rule=\"nonzero\"></path><path d=\"M7 16.542c0-2.736.024-4.98.064-4.98.032-.008.872.2 1.88.454l1.816.461-.016 4.05-.024 4.049-1.632.422c-.896.23-1.736.445-1.856.469L7 21.523v-4.98z\" fill=\"#3C8CFF\" fill-rule=\"nonzero\"></path><path d=\"M19.24 12.477c0-9.03.008-9.515.144-9.475.072.024.784.207 1.576.406.792.207 1.576.405 1.744.445l.296.08-.016 8.56-.024 8.568-1.624.414c-.888.23-1.728.437-1.856.47l-.24.055v-9.523z\" fill=\"#78E6DC\" fill-rule=\"nonzero\"></path><path d=\"M1 12.509c0-4.678.024-8.505.064-8.505.032 0 .872.207 1.872.454l1.824.461v7.582c0 4.16-.016 7.574-.032 7.574-.024 0-.872.215-1.88.47L1 21.013v-8.505z\" fill=\"#325AB4\"></path></svg>`,\n  chatglm: `<svg height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>ChatGLM</title><defs><linearGradient id=\"lobe-icons-chat-glm-fill\" x1=\"-18.756%\" x2=\"70.894%\" y1=\"49.371%\" y2=\"90.944%\"><stop offset=\"0%\" stop-color=\"#504AF4\"></stop><stop offset=\"100%\" stop-color=\"#3485FF\"></stop></linearGradient></defs><path d=\"M9.917 2c4.906 0 10.178 3.947 8.93 10.58-.014.07-.037.14-.057.21l-.003-.277c-.083-3-1.534-8.934-8.87-8.934-3.393 0-8.137 3.054-7.93 8.158-.04 4.778 3.555 8.4 7.95 8.332l.073-.001c1.2-.033 2.763-.429 3.1-1.657.063-.031.26.534.268.598.048.256.112.369.192.34.981-.348 2.286-1.222 1.952-2.38-.176-.61-1.775-.147-1.921-.347.418-.979 2.234-.926 3.153-.716.443.102.657.38 1.012.442.29.052.981-.2.96.242-1.5 3.042-4.893 5.41-8.808 5.41C3.654 22 0 16.574 0 11.737 0 5.947 4.959 2 9.917 2zM9.9 5.3c.484 0 1.125.225 1.38.585 3.669.145 4.313 2.686 4.694 5.444.255 1.838.315 2.3.182 1.387l.083.59c.068.448.554.737.982.516.144-.075.254-.231.328-.47a.2.2 0 01.258-.13l.625.22a.2.2 0 01.124.238 2.172 2.172 0 01-.51.92c-.878.917-2.757.664-3.08-.62-.14-.554-.055-.626-.345-1.242-.292-.621-1.238-.709-1.69-.295-.345.315-.407.805-.406 1.282L12.6 15.9a.9.9 0 01-.9.9h-1.4a.9.9 0 01-.9-.9v-.65a1.15 1.15 0 10-2.3 0v.65a.9.9 0 01-.9.9H4.8a.9.9 0 01-.9-.9l.035-3.239c.012-1.884.356-3.658 2.47-4.134.2-.045.252.13.29.342.025.154.043.252.053.294.701 3.058 1.75 4.299 3.144 3.722l.66-.331.254-.13c.158-.082.25-.131.276-.15.012-.01-.165-.206-.407-.464l-1.012-1.067a8.925 8.925 0 01-.199-.216c-.047-.034-.116.068-.208.306-.074.157-.251.252-.272.326-.013.058.108.298.362.72.164.288.22.508-.31.343-1.04-.8-1.518-2.273-1.684-3.725-.004-.035-.162-1.913-.162-1.913a1.2 1.2 0 011.113-1.281L9.9 5.3zm12.994 8.68c.037.697-.403.704-1.213.591l-1.783-.276c-.265-.053-.385-.099-.313-.147.47-.315 3.268-.93 3.31-.168zm-.915-.083l-.926.042c-.85.077-1.452.24.338.336l.103.003c.815.012 1.264-.359.485-.381zm1.667-3.601h.01c.79.398.067 1.03-.65 1.393-.14.07-.491.176-1.052.315-.241.04-.457.092-.333.16l.01.005c1.952.958-3.123 1.534-2.495 1.285l.38-.148c.68-.266 1.614-.682 1.666-1.337.038-.48 1.253-.442 1.493-.968.048-.106 0-.236-.144-.389-.05-.047-.094-.094-.107-.148-.073-.305.7-.431 1.222-.168zm-2.568-.474c-.135 1.198-2.479 4.192-1.949 2.863l.017-.042c.298-.717.376-2.221 1.337-3.221.25-.26.636.035.595.4zm-7.976-.253c.02-.694 1.002-.968 1.346-.347.01-1.274-1.941-.768-1.346.347z\" fill=\"url(#lobe-icons-chat-glm-fill)\" fill-rule=\"evenodd\"></path></svg>`,\n  claude: `<svg height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>Claude</title><path d=\"M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z\" fill=\"#D97757\" fill-rule=\"nonzero\"></path></svg>`,\n  cloudflare: `<svg height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>Cloudflare</title><path d=\"M16.493 17.4c.135-.52.08-.983-.161-1.338-.215-.328-.592-.519-1.05-.519l-8.663-.109a.148.148 0 01-.135-.082c-.027-.054-.027-.109-.027-.163.027-.082.108-.164.189-.164l8.744-.11c1.05-.054 2.153-.9 2.556-1.937l.511-1.31c.027-.055.027-.11.027-.164C17.92 8.91 15.66 7 12.942 7c-2.503 0-4.628 1.638-5.381 3.903a2.432 2.432 0 00-1.803-.491c-1.21.109-2.153 1.092-2.287 2.32-.027.328 0 .628.054.9C1.56 13.688 0 15.326 0 17.319c0 .19.027.355.027.545 0 .082.08.137.161.137h15.983c.08 0 .188-.055.215-.164l.107-.437\" fill=\"#F38020\"></path><path d=\"M19.238 11.75h-.242c-.054 0-.108.054-.135.109l-.35 1.2c-.134.52-.08.983.162 1.338.215.328.592.518 1.05.518l1.855.11c.054 0 .108.027.135.082.027.054.027.109.027.163-.027.082-.108.164-.188.164l-1.91.11c-1.05.054-2.153.9-2.557 1.937l-.134.355c-.027.055.026.137.107.137h6.592c.081 0 .162-.055.162-.137.107-.41.188-.846.188-1.31-.027-2.62-2.153-4.777-4.762-4.777\" fill=\"#FCAD32\"></path></svg>`,\n  cohere: `<svg height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>Cohere</title><path clip-rule=\"evenodd\" d=\"M8.128 14.099c.592 0 1.77-.033 3.398-.703 1.897-.781 5.672-2.2 8.395-3.656 1.905-1.018 2.74-2.366 2.74-4.18A4.56 4.56 0 0018.1 1H7.549A6.55 6.55 0 001 7.55c0 3.617 2.745 6.549 7.128 6.549z\" fill=\"#39594D\" fill-rule=\"evenodd\"></path><path clip-rule=\"evenodd\" d=\"M9.912 18.61a4.387 4.387 0 012.705-4.052l3.323-1.38c3.361-1.394 7.06 1.076 7.06 4.715a5.104 5.104 0 01-5.105 5.104l-3.597-.001a4.386 4.386 0 01-4.386-4.387z\" fill=\"#D18EE2\" fill-rule=\"evenodd\"></path><path d=\"M4.776 14.962A3.775 3.775 0 001 18.738v.489a3.776 3.776 0 007.551 0v-.49a3.775 3.775 0 00-3.775-3.775z\" fill=\"#FF7759\"></path></svg>`,\n  copilot: `<svg height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>Copilot</title><path d=\"M17.533 1.829A2.528 2.528 0 0015.11 0h-.737a2.531 2.531 0 00-2.484 2.087l-1.263 6.937.314-1.08a2.528 2.528 0 012.424-1.833h4.284l1.797.706 1.731-.706h-.505a2.528 2.528 0 01-2.423-1.829l-.715-2.453z\" fill=\"url(#lobe-icons-copilot-fill-0)\" transform=\"translate(0 1)\"></path><path d=\"M6.726 20.16A2.528 2.528 0 009.152 22h1.566c1.37 0 2.49-1.1 2.525-2.48l.17-6.69-.357 1.228a2.528 2.528 0 01-2.423 1.83h-4.32l-1.54-.842-1.667.843h.497c1.124 0 2.113.75 2.426 1.84l.697 2.432z\" fill=\"url(#lobe-icons-copilot-fill-1)\" transform=\"translate(0 1)\"></path><path d=\"M15 0H6.252c-2.5 0-4 3.331-5 6.662-1.184 3.947-2.734 9.225 1.75 9.225H6.78c1.13 0 2.12-.753 2.43-1.847.657-2.317 1.809-6.359 2.713-9.436.46-1.563.842-2.906 1.43-3.742A1.97 1.97 0 0115 0\" fill=\"url(#lobe-icons-copilot-fill-2)\" transform=\"translate(0 1)\"></path><path d=\"M15 0H6.252c-2.5 0-4 3.331-5 6.662-1.184 3.947-2.734 9.225 1.75 9.225H6.78c1.13 0 2.12-.753 2.43-1.847.657-2.317 1.809-6.359 2.713-9.436.46-1.563.842-2.906 1.43-3.742A1.97 1.97 0 0115 0\" fill=\"url(#lobe-icons-copilot-fill-3)\" transform=\"translate(0 1)\"></path><path d=\"M9 22h8.749c2.5 0 4-3.332 5-6.663 1.184-3.948 2.734-9.227-1.75-9.227H17.22c-1.129 0-2.12.754-2.43 1.848a1149.2 1149.2 0 01-2.713 9.437c-.46 1.564-.842 2.907-1.43 3.743A1.97 1.97 0 019 22\" fill=\"url(#lobe-icons-copilot-fill-4)\" transform=\"translate(0 1)\"></path><path d=\"M9 22h8.749c2.5 0 4-3.332 5-6.663 1.184-3.948 2.734-9.227-1.75-9.227H17.22c-1.129 0-2.12.754-2.43 1.848a1149.2 1149.2 0 01-2.713 9.437c-.46 1.564-.842 2.907-1.43 3.743A1.97 1.97 0 019 22\" fill=\"url(#lobe-icons-copilot-fill-5)\" transform=\"translate(0 1)\"></path><defs><radialGradient cx=\"85.44%\" cy=\"100.653%\" fx=\"85.44%\" fy=\"100.653%\" gradientTransform=\"scale(-.8553 -1) rotate(50.927 2.041 -1.946)\" id=\"lobe-icons-copilot-fill-0\" r=\"105.116%\"><stop offset=\"9.6%\" stop-color=\"#00AEFF\"></stop><stop offset=\"77.3%\" stop-color=\"#2253CE\"></stop><stop offset=\"100%\" stop-color=\"#0736C4\"></stop></radialGradient><radialGradient cx=\"18.143%\" cy=\"32.928%\" fx=\"18.143%\" fy=\"32.928%\" gradientTransform=\"scale(.8897 1) rotate(52.069 .193 .352)\" id=\"lobe-icons-copilot-fill-1\" r=\"95.612%\"><stop offset=\"0%\" stop-color=\"#FFB657\"></stop><stop offset=\"63.4%\" stop-color=\"#FF5F3D\"></stop><stop offset=\"92.3%\" stop-color=\"#C02B3C\"></stop></radialGradient><radialGradient cx=\"82.987%\" cy=\"-9.792%\" fx=\"82.987%\" fy=\"-9.792%\" gradientTransform=\"scale(-1 -.9441) rotate(-70.872 .142 1.17)\" id=\"lobe-icons-copilot-fill-4\" r=\"140.622%\"><stop offset=\"6.6%\" stop-color=\"#8C48FF\"></stop><stop offset=\"50%\" stop-color=\"#F2598A\"></stop><stop offset=\"89.6%\" stop-color=\"#FFB152\"></stop></radialGradient><linearGradient id=\"lobe-icons-copilot-fill-2\" x1=\"39.465%\" x2=\"46.884%\" y1=\"12.117%\" y2=\"103.774%\"><stop offset=\"15.6%\" stop-color=\"#0D91E1\"></stop><stop offset=\"48.7%\" stop-color=\"#52B471\"></stop><stop offset=\"65.2%\" stop-color=\"#98BD42\"></stop><stop offset=\"93.7%\" stop-color=\"#FFC800\"></stop></linearGradient><linearGradient id=\"lobe-icons-copilot-fill-3\" x1=\"45.949%\" x2=\"50%\" y1=\"0%\" y2=\"100%\"><stop offset=\"0%\" stop-color=\"#3DCBFF\"></stop><stop offset=\"24.7%\" stop-color=\"#0588F7\" stop-opacity=\"0\"></stop></linearGradient><linearGradient id=\"lobe-icons-copilot-fill-5\" x1=\"83.507%\" x2=\"83.453%\" y1=\"-6.106%\" y2=\"21.131%\"><stop offset=\"5.8%\" stop-color=\"#F8ADFA\"></stop><stop offset=\"70.8%\" stop-color=\"#A86EDD\" stop-opacity=\"0\"></stop></linearGradient></defs></svg>`,\n  ctok: `<svg xmlns=\"http://www.w3.org/2000/svg\" height=\"1em\" width=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 200 200\"><title>CTok</title><rect x=\"0\" y=\"0\" width=\"200\" height=\"200\" rx=\"40\" ry=\"40\" fill=\"#3B82F6\"/><circle cx=\"100\" cy=\"100\" r=\"45\" fill=\"white\"/><circle cx=\"100\" cy=\"100\" r=\"25\" fill=\"#3B82F6\"/><circle cx=\"50\" cy=\"50\" r=\"6\" fill=\"white\" opacity=\"0.6\"/><circle cx=\"165\" cy=\"70\" r=\"5\" fill=\"white\" opacity=\"0.5\"/><circle cx=\"170\" cy=\"140\" r=\"7\" fill=\"white\" opacity=\"0.4\"/><defs><clipPath id=\"ctok-rc\"><rect x=\"0\" y=\"0\" width=\"200\" height=\"200\" rx=\"40\" ry=\"40\"/></clipPath></defs><path d=\"M 200 150 Q 175 175 150 200 L 200 200 Z\" fill=\"#2563EB\" opacity=\"0.6\" clip-path=\"url(#ctok-rc)\"/></svg>`,\n  crazyrouter: `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 563 648\"><title>CrazyRouter</title><path fill=\"currentColor\" d=\"M 167.5 190 L 276.5 190 L 277 191.5 L 253 234 L 247.5 235 L 246.5 234 L 235.5 234 L 230.5 235 L 229.5 234 L 217.5 234 L 215.5 234 L 191.5 234 L 189.5 235 L 186.5 234 L 164.5 235 L 150.5 239 Q 132.7 246.7 121 260.5 Q 108.2 275.2 102 296.5 L 99 312.5 L 99 335.5 L 102 351.5 L 110 371.5 Q 118.1 386.4 130.5 397 Q 143.5 409 164.5 413 L 173.5 413 L 174.5 414 L 258.5 414 L 314.5 318 L 433.5 318 Q 449.3 314.8 457 303.5 L 462 294.5 L 465 283.5 L 465 269.5 L 460 253.5 L 449.5 241 L 440.5 236 L 430.5 234 L 332.5 234 L 258.5 361 L 212.5 361 L 212 359.5 L 310.5 190 L 438.5 190 L 448.5 192 L 461.5 197 Q 476 205 486 217.5 L 496 233.5 L 502 250.5 L 504 260.5 L 504 268.5 L 505 269.5 L 505 283.5 L 504 284.5 L 503 297.5 L 499 311.5 Q 490.8 331.8 475.5 345 L 462.5 354 L 446 360.5 L 502 452.5 L 504 458 L 456.5 458 L 454 455.5 L 416 389.5 L 398.5 362 L 336.5 362 L 335 363.5 L 285 452.5 L 280.5 458 L 167.5 458 L 166.5 457 L 153.5 456 Q 112.4 445.6 90 416.5 Q 72.7 396.3 64 367.5 L 59 343.5 L 58 314.5 L 59 313.5 L 60 297.5 L 64 280.5 L 73 257.5 Q 85.3 233.8 104.5 217 Q 116.8 206.3 132.5 199 L 149.5 193 L 166.5 191 L 167.5 190 Z\"/></svg>`,\n  cubence: `<svg height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 179 203\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>Cubence</title><rect width=\"100\" height=\"100\" rx=\"13\" transform=\"matrix(0.866025 -0.5 0 1 92 103)\" fill=\"#4B5563\"></rect><rect width=\"100\" height=\"100\" rx=\"13\" transform=\"matrix(0.866025 0.5 -0.866025 0.5 88.6025 -3)\" fill=\"#1F2937\"></rect><rect width=\"100\" height=\"100\" rx=\"13\" transform=\"matrix(0.866025 0.5 0 1 0 53)\" fill=\"#111827\"></rect><rect width=\"72.7816\" height=\"72.7816\" rx=\"13\" transform=\"matrix(0.866025 0.5 0 1 11 73)\" fill=\"#374151\"></rect><rect width=\"28.1436\" height=\"28.1436\" rx=\"3\" transform=\"matrix(0.866025 0.5 0 1 11 86)\" fill=\"#E5E7EB\" fill-opacity=\"0.9\"></rect><rect width=\"28.1436\" height=\"28.1436\" rx=\"3\" transform=\"matrix(0.866025 0.5 0 1 50 107)\" fill=\"#E5E7EB\" fill-opacity=\"0.9\"></rect><rect width=\"13.8564\" height=\"13.8564\" rx=\"3\" transform=\"matrix(0.866025 0.5 0 1 43 148)\" fill=\"#E5E7EB\" fill-opacity=\"0.9\"></rect></svg>`,\n  deepseek: `<svg height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>DeepSeek</title><path d=\"M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z\" fill=\"#4D6BFE\"></path></svg>`,\n  doubao: `<svg height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>Doubao</title><path d=\"M5.31 15.756c.172-3.75 1.883-5.999 2.549-6.739-3.26 2.058-5.425 5.658-6.358 8.308v1.12C1.501 21.513 4.226 24 7.59 24a6.59 6.59 0 002.2-.375c.353-.12.7-.248 1.039-.378.913-.899 1.65-1.91 2.243-2.992-4.877 2.431-7.974.072-7.763-4.5l.002.001z\" fill=\"#1E37FC\"></path><path d=\"M22.57 10.283c-1.212-.901-4.109-2.404-7.397-2.8.295 3.792.093 8.766-2.1 12.773a12.782 12.782 0 01-2.244 2.992c3.764-1.448 6.746-3.457 8.596-5.219 2.82-2.683 3.353-5.178 3.361-6.66a2.737 2.737 0 00-.216-1.084v-.002z\" fill=\"#37E1BE\"></path><path d=\"M14.303 1.867C12.955.7 11.248 0 9.39 0 7.532 0 5.883.677 4.545 1.807 2.791 3.29 1.627 5.557 1.5 8.125v9.201c.932-2.65 3.097-6.25 6.357-8.307.5-.318 1.025-.595 1.569-.829 1.883-.801 3.878-.932 5.746-.706-.222-2.83-.718-5.002-.87-5.617h.001z\" fill=\"#A569FF\"></path><path d=\"M17.305 4.961a199.47 199.47 0 01-1.08-1.094c-.202-.213-.398-.419-.586-.622l-1.333-1.378c.151.615.648 2.786.869 5.617 3.288.395 6.185 1.898 7.396 2.8-1.306-1.275-3.475-3.487-5.266-5.323z\" fill=\"#1E37FC\"></path></svg>`,\n  gemini: `<svg height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>Gemini</title><path d=\"M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z\" fill=\"#3186FF\"></path><path d=\"M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z\" fill=\"url(#lobe-icons-gemini-fill-0)\"></path><path d=\"M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z\" fill=\"url(#lobe-icons-gemini-fill-1)\"></path><path d=\"M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z\" fill=\"url(#lobe-icons-gemini-fill-2)\"></path><defs><linearGradient gradientUnits=\"userSpaceOnUse\" id=\"lobe-icons-gemini-fill-0\" x1=\"7\" x2=\"11\" y1=\"15.5\" y2=\"12\"><stop stop-color=\"#08B962\"></stop><stop offset=\"1\" stop-color=\"#08B962\" stop-opacity=\"0\"></stop></linearGradient><linearGradient gradientUnits=\"userSpaceOnUse\" id=\"lobe-icons-gemini-fill-1\" x1=\"8\" x2=\"11.5\" y1=\"5.5\" y2=\"11\"><stop stop-color=\"#F94543\"></stop><stop offset=\"1\" stop-color=\"#F94543\" stop-opacity=\"0\"></stop></linearGradient><linearGradient gradientUnits=\"userSpaceOnUse\" id=\"lobe-icons-gemini-fill-2\" x1=\"3.5\" x2=\"17.5\" y1=\"13.5\" y2=\"12\"><stop stop-color=\"#FABC12\"></stop><stop offset=\".46\" stop-color=\"#FABC12\" stop-opacity=\"0\"></stop></linearGradient></defs></svg>`,\n  gemma: `<svg height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>Gemma</title><defs><linearGradient id=\"lobe-icons-gemma-fill\" x1=\"24.419%\" x2=\"75.194%\" y1=\"75.581%\" y2=\"25.194%\"><stop offset=\"0%\" stop-color=\"#446EFF\"></stop><stop offset=\"36.661%\" stop-color=\"#2E96FF\"></stop><stop offset=\"83.221%\" stop-color=\"#B1C5FF\"></stop></linearGradient></defs><path d=\"M12.34 5.953a8.233 8.233 0 01-.247-1.125V3.72a8.25 8.25 0 015.562 2.232H12.34zm-.69 0c.113-.373.199-.755.257-1.145V3.72a8.25 8.25 0 00-5.562 2.232h5.304zm-5.433.187h5.373a7.98 7.98 0 01-.267.696 8.41 8.41 0 01-1.76 2.65L6.216 6.14zm-.264-.187H2.977v.187h2.915a8.436 8.436 0 00-2.357 5.767H0v.186h3.535a8.436 8.436 0 002.357 5.767H2.977v.186h2.976v2.977h.187v-2.915a8.436 8.436 0 005.767 2.357V24h.186v-3.535a8.436 8.436 0 005.767-2.357v2.915h.186v-2.977h2.977v-.186h-2.915a8.436 8.436 0 002.357-5.767H24v-.186h-3.535a8.436 8.436 0 00-2.357-5.767h2.915v-.187h-2.977V2.977h-.186v2.915a8.436 8.436 0 00-5.767-2.357V0h-.186v3.535A8.436 8.436 0 006.14 5.892V2.977h-.187v2.976zm6.14 14.326a8.25 8.25 0 005.562-2.233H12.34c-.108.367-.19.743-.247 1.126v1.107zm-.186-1.087a8.015 8.015 0 00-.258-1.146H6.345a8.25 8.25 0 005.562 2.233v-1.087zm-8.186-7.285h1.107a8.23 8.23 0 001.125-.247V6.345a8.25 8.25 0 00-2.232 5.562zm1.087.186H3.72a8.25 8.25 0 002.232 5.562v-5.304a8.012 8.012 0 00-1.145-.258zm15.47-.186a8.25 8.25 0 00-2.232-5.562v5.315c.367.108.743.19 1.126.247h1.107zm-1.086.186c-.39.058-.772.144-1.146.258v5.304a8.25 8.25 0 002.233-5.562h-1.087zm-1.332 5.69V12.41a7.97 7.97 0 00-.696.267 8.409 8.409 0 00-2.65 1.76l3.346 3.346zm0-6.18v-5.45l-.012-.013h-5.451c.076.235.162.468.26.696a8.698 8.698 0 001.819 2.688 8.698 8.698 0 002.688 1.82c.228.097.46.183.696.259zM6.14 17.848V12.41c.235.078.468.167.696.267a8.403 8.403 0 012.688 1.799 8.404 8.404 0 011.799 2.688c.1.228.19.46.267.696H6.152l-.012-.012zm0-6.245V6.326l3.29 3.29a8.716 8.716 0 01-2.594 1.728 8.14 8.14 0 01-.696.259zm6.257 6.257h5.277l-3.29-3.29a8.716 8.716 0 00-1.728 2.594 8.135 8.135 0 00-.259.696zm-2.347-7.81a9.435 9.435 0 01-2.88 1.96 9.14 9.14 0 012.88 1.94 9.14 9.14 0 011.94 2.88 9.435 9.435 0 011.96-2.88 9.14 9.14 0 012.88-1.94 9.435 9.435 0 01-2.88-1.96 9.434 9.434 0 01-1.96-2.88 9.14 9.14 0 01-1.94 2.88z\" fill=\"url(#lobe-icons-gemma-fill)\" fill-rule=\"evenodd\"></path></svg>`,\n  github: `<svg fill=\"currentColor\" fill-rule=\"evenodd\" height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>Github</title><path d=\"M12 0c6.63 0 12 5.276 12 11.79-.001 5.067-3.29 9.567-8.175 11.187-.6.118-.825-.25-.825-.56 0-.398.015-1.665.015-3.242 0-1.105-.375-1.813-.81-2.181 2.67-.295 5.475-1.297 5.475-5.822 0-1.297-.465-2.344-1.23-3.169.12-.295.54-1.503-.12-3.125 0 0-1.005-.324-3.3 1.209a11.32 11.32 0 00-3-.398c-1.02 0-2.04.133-3 .398-2.295-1.518-3.3-1.209-3.3-1.209-.66 1.622-.24 2.83-.12 3.125-.765.825-1.23 1.887-1.23 3.169 0 4.51 2.79 5.527 5.46 5.822-.345.294-.66.81-.765 1.577-.69.31-2.415.81-3.495-.973-.225-.354-.9-1.223-1.845-1.209-1.005.015-.405.56.015.781.51.28 1.095 1.327 1.23 1.666.24.663 1.02 1.93 4.035 1.385 0 .988.015 1.916.015 2.196 0 .31-.225.664-.825.56C3.303 21.374-.003 16.867 0 11.791 0 5.276 5.37 0 12 0z\"></path></svg>`,\n  githubcopilot: `<svg fill=\"currentColor\" fill-rule=\"evenodd\" height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>GithubCopilot</title><path d=\"M19.245 5.364c1.322 1.36 1.877 3.216 2.11 5.817.622 0 1.2.135 1.592.654l.73.964c.21.278.323.61.323.955v2.62c0 .339-.173.669-.453.868C20.239 19.602 16.157 21.5 12 21.5c-4.6 0-9.205-2.583-11.547-4.258-.28-.2-.452-.53-.453-.868v-2.62c0-.345.113-.679.321-.956l.73-.963c.392-.517.974-.654 1.593-.654l.029-.297c.25-2.446.81-4.213 2.082-5.52 2.461-2.54 5.71-2.851 7.146-2.864h.198c1.436.013 4.685.323 7.146 2.864zm-7.244 4.328c-.284 0-.613.016-.962.05-.123.447-.305.85-.57 1.108-1.05 1.023-2.316 1.18-2.994 1.18-.638 0-1.306-.13-1.851-.464-.516.165-1.012.403-1.044.996a65.882 65.882 0 00-.063 2.884l-.002.48c-.002.563-.005 1.126-.013 1.69.002.326.204.63.51.765 2.482 1.102 4.83 1.657 6.99 1.657 2.156 0 4.504-.555 6.985-1.657a.854.854 0 00.51-.766c.03-1.682.006-3.372-.076-5.053-.031-.596-.528-.83-1.046-.996-.546.333-1.212.464-1.85.464-.677 0-1.942-.157-2.993-1.18-.266-.258-.447-.661-.57-1.108-.32-.032-.64-.049-.96-.05zm-2.525 4.013c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zm5 0c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zM7.635 5.087c-1.05.102-1.935.438-2.385.906-.975 1.037-.765 3.668-.21 4.224.405.394 1.17.657 1.995.657h.09c.649-.013 1.785-.176 2.73-1.11.435-.41.705-1.433.675-2.47-.03-.834-.27-1.52-.63-1.813-.39-.336-1.275-.482-2.265-.394zm6.465.394c-.36.292-.6.98-.63 1.813-.03 1.037.24 2.06.675 2.47.968.957 2.136 1.104 2.776 1.11h.044c.825 0 1.59-.263 1.995-.657.555-.556.765-3.187-.21-4.224-.45-.468-1.335-.804-2.385-.906-.99-.088-1.875.058-2.265.394zM12 7.615c-.24 0-.525.015-.84.044.03.16.045.336.06.526l-.001.159a2.94 2.94 0 01-.014.25c.225-.022.425-.027.612-.028h.366c.187 0 .387.006.612.028-.015-.146-.015-.277-.015-.409.015-.19.03-.365.06-.526a9.29 9.29 0 00-.84-.044z\"></path></svg>`,\n  google: `<svg height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>Google</title><path d=\"M23 12.245c0-.905-.075-1.565-.236-2.25h-10.54v4.083h6.186c-.124 1.014-.797 2.542-2.294 3.569l-.021.136 3.332 2.53.23.022C21.779 18.417 23 15.593 23 12.245z\" fill=\"#4285F4\"></path><path d=\"M12.225 23c3.03 0 5.574-.978 7.433-2.665l-3.542-2.688c-.948.648-2.22 1.1-3.891 1.1a6.745 6.745 0 01-6.386-4.572l-.132.011-3.465 2.628-.045.124C4.043 20.531 7.835 23 12.225 23z\" fill=\"#34A853\"></path><path d=\"M5.84 14.175A6.65 6.65 0 015.463 12c0-.758.138-1.491.361-2.175l-.006-.147-3.508-2.67-.115.054A10.831 10.831 0 001 12c0 1.772.436 3.447 1.197 4.938l3.642-2.763z\" fill=\"#FBBC05\"></path><path d=\"M12.225 5.253c2.108 0 3.529.892 4.34 1.638l3.167-3.031C17.787 2.088 15.255 1 12.225 1 7.834 1 4.043 3.469 2.197 7.062l3.63 2.763a6.77 6.77 0 016.398-4.572z\" fill=\"#EB4335\"></path></svg>`,\n  googlecloud: `<svg height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>GoogleCloud</title><path d=\"M15.961 7.327l2.086-2.086.14-.879C14.384.905 8.34 1.297 4.913 5.18A9.643 9.643 0 002.88 8.991l.747-.105 4.172-.688.322-.33c1.856-2.038 4.994-2.312 7.137-.578l.703.037z\" fill=\"#EA4335\"></path><path d=\"M21.02 8.93a9.399 9.399 0 00-2.834-4.568L15.258 7.29a5.204 5.204 0 011.91 4.129v.52a2.606 2.606 0 012.607 2.605c0 1.44-1.167 2.577-2.606 2.577h-5.22l-.512.556v3.126l.513.49h5.219c3.743.03 6.802-2.952 6.83-6.695a6.778 6.778 0 00-2.98-5.668z\" fill=\"#4285F4\"></path><path d=\"M6.738 21.293h5.212v-4.172H6.738c-.371 0-.731-.08-1.069-.234l-.74.227-2.1 2.086-.183.71a6.763 6.763 0 004.092 1.383z\" fill=\"#34A853\"></path><path d=\"M6.738 7.759A6.778 6.778 0 002.646 19.91l3.023-3.023a2.606 2.606 0 113.448-3.448l3.023-3.023a6.771 6.771 0 00-5.402-2.657z\" fill=\"#FBBC05\"></path></svg>`,\n  grok: `<svg fill=\"currentColor\" fill-rule=\"evenodd\" height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>Grok</title><path d=\"M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815\"></path></svg>`,\n  huawei: `<svg height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>Huawei</title><path d=\"M10.341 17.042s.062-.061 0-.061C7.516 10.902 3.646 6.22 3.646 6.22S1.557 8.168 1.68 10.174c.061 1.52 1.228 2.37 1.228 2.37 1.843 1.763 6.266 4.012 7.31 4.499h.123zm-.737 1.52c0-.061-.123-.061-.123-.061l-7.371.243c.798 1.398 2.15 2.492 3.563 2.188.983-.243 3.194-1.763 3.87-2.25.123-.12.061-.12.061-.12zm.123-.67c.062-.06 0-.12 0-.12C6.471 15.581.206 12.3.206 12.3c-.553 1.763.184 3.161.184 3.161.798 1.702 2.334 2.189 2.334 2.189.676.303 1.413.303 1.413.303h5.529c.061 0 .061-.06.061-.06zm.492-14.831c-.308 0-1.168.243-1.168.243-1.965.486-2.395 2.249-2.395 2.249-.369 1.094 0 2.31 0 2.31.675 2.857 3.87 7.598 4.545 8.57l.062.062c.061 0 .061-.061.061-.061C12.43 5.796 10.22 3.06 10.22 3.06zm2.457 13.373c.061 0 .123-.061.123-.061.737-1.033 3.87-5.714 4.545-8.57 0 0 .369-1.399 0-2.31 0 0-.491-1.764-2.457-2.25 0 0-.553-.121-1.167-.243 0 0-2.211 2.796-1.106 13.312 0 .122.062.122.062.122zm1.72 2.067s-.062 0-.062.06v.122c.738.486 2.826 2.006 3.87 2.249 0 0 1.905.669 3.563-2.188l-7.371-.243zm9.398-6.261s-6.265 3.343-9.521 5.531c0 0-.062.06-.062.122 0 0 0 .06.062.06h5.651s.553 0 1.29-.303c0 0 1.536-.487 2.396-2.25 0-.06.737-1.458.184-3.16zM13.66 17.042s.061.06.122 0c1.045-.547 5.468-2.736 7.31-4.499 0 0 1.168-.911 1.23-2.37.122-2.067-1.967-3.951-1.967-3.951s-3.87 4.559-6.695 10.698c0 0-.062.06 0 .122z\" fill=\"#C7000B\"></path></svg>`,\n  huggingface: `<svg height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>HuggingFace</title><path d=\"M2.25 11.535c0-3.407 1.847-6.554 4.844-8.258a9.822 9.822 0 019.687 0c2.997 1.704 4.844 4.851 4.844 8.258 0 5.266-4.337 9.535-9.687 9.535S2.25 16.8 2.25 11.535z\" fill=\"#FF9D0B\"></path><path d=\"M11.938 20.086c4.797 0 8.687-3.829 8.687-8.551 0-4.722-3.89-8.55-8.687-8.55-4.798 0-8.688 3.828-8.688 8.55 0 4.722 3.89 8.55 8.688 8.55z\" fill=\"#FFD21E\"></path><path d=\"M11.875 15.113c2.457 0 3.25-2.156 3.25-3.263 0-.576-.393-.394-1.023-.089-.582.283-1.365.675-2.224.675-1.798 0-3.25-1.693-3.25-.586 0 1.107.79 3.263 3.25 3.263h-.003z\" fill=\"#FF323D\"></path><path d=\"M14.76 9.21c.32.108.445.753.767.585.447-.233.707-.708.659-1.204a1.235 1.235 0 00-.879-1.059 1.262 1.262 0 00-1.33.394c-.322.384-.377.92-.14 1.36.153.283.638-.177.925-.079l-.002.003zm-5.887 0c-.32.108-.448.753-.768.585a1.226 1.226 0 01-.658-1.204c.048-.495.395-.913.878-1.059a1.262 1.262 0 011.33.394c.322.384.377.92.14 1.36-.152.283-.64-.177-.925-.079l.003.003zm1.12 5.34a2.166 2.166 0 011.325-1.106c.07-.02.144.06.219.171l.192.306c.069.1.139.175.209.175.074 0 .15-.074.223-.172l.205-.302c.08-.11.157-.188.234-.165.537.168.986.536 1.25 1.026.932-.724 1.275-1.905 1.275-2.633 0-.508-.306-.426-.81-.19l-.616.296c-.52.24-1.148.48-1.824.48-.676 0-1.302-.24-1.823-.48l-.589-.283c-.52-.248-.838-.342-.838.177 0 .703.32 1.831 1.187 2.56l.18.14z\" fill=\"#3A3B45\"></path><path d=\"M17.812 10.366a.806.806 0 00.813-.8c0-.441-.364-.8-.813-.8a.806.806 0 00-.812.8c0 .442.364.8.812.8zm-11.624 0a.806.806 0 00.812-.8c0-.441-.364-.8-.812-.8a.806.806 0 00-.813.8c0 .442.364.8.813.8zM4.515 13.073c-.405 0-.765.162-1.017.46a1.455 1.455 0 00-.333.925 1.801 1.801 0 00-.485-.074c-.387 0-.737.146-.985.409a1.41 1.41 0 00-.2 1.722 1.302 1.302 0 00-.447.694c-.06.222-.12.69.2 1.166a1.267 1.267 0 00-.093 1.236c.238.533.81.958 1.89 1.405l.24.096c.768.3 1.473.492 1.478.494.89.243 1.808.375 2.732.394 1.465 0 2.513-.443 3.115-1.314.93-1.342.842-2.575-.274-3.763l-.151-.154c-.692-.684-1.155-1.69-1.25-1.912-.195-.655-.71-1.383-1.562-1.383-.46.007-.889.233-1.15.605-.25-.31-.495-.553-.715-.694a1.87 1.87 0 00-.993-.312zm14.97 0c.405 0 .767.162 1.017.46.216.262.333.588.333.925.158-.047.322-.071.487-.074.388 0 .738.146.985.409a1.41 1.41 0 01.2 1.722c.22.178.377.422.445.694.06.222.12.69-.2 1.166.244.37.279.836.093 1.236-.238.533-.81.958-1.889 1.405l-.239.096c-.77.3-1.475.492-1.48.494-.89.243-1.808.375-2.732.394-1.465 0-2.513-.443-3.115-1.314-.93-1.342-.842-2.575.274-3.763l.151-.154c.695-.684 1.157-1.69 1.252-1.912.195-.655.708-1.383 1.56-1.383.46.007.889.233 1.15.605.25-.31.495-.553.718-.694.244-.162.523-.265.814-.3l.176-.012z\" fill=\"#FF9D0B\"></path><path d=\"M9.785 20.132c.688-.994.638-1.74-.305-2.667-.945-.928-1.495-2.288-1.495-2.288s-.205-.788-.672-.714c-.468.074-.81 1.25.17 1.971.977.721-.195 1.21-.573.534-.375-.677-1.405-2.416-1.94-2.751-.532-.332-.907-.148-.782.541.125.687 2.357 2.35 2.14 2.707-.218.362-.983-.42-.983-.42S2.953 14.9 2.43 15.46c-.52.558.398 1.026 1.7 1.803 1.308.778 1.41.985 1.225 1.28-.187.295-3.07-2.1-3.34-1.083-.27 1.011 2.943 1.304 2.745 2.006-.2.7-2.265-1.324-2.685-.537-.425.79 2.913 1.718 2.94 1.725 1.075.276 3.813.859 4.77-.522zm4.432 0c-.687-.994-.64-1.74.305-2.667.943-.928 1.493-2.288 1.493-2.288s.205-.788.675-.714c.465.074.807 1.25-.17 1.971-.98.721.195 1.21.57.534.377-.677 1.407-2.416 1.94-2.751.532-.332.91-.148.782.541-.125.687-2.355 2.35-2.137 2.707.215.362.98-.42.98-.42S21.05 14.9 21.57 15.46c.52.558-.395 1.026-1.7 1.803-1.308.778-1.408.985-1.225 1.28.187.295 3.07-2.1 3.34-1.083.27 1.011-2.94 1.304-2.743 2.006.2.7 2.263-1.324 2.685-.537.423.79-2.912 1.718-2.94 1.725-1.077.276-3.815.859-4.77-.522z\" fill=\"#FFD21E\"></path></svg>`,\n  hunyuan: `<svg height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>Hunyuan</title><circle cx=\"12\" cy=\"12\" fill=\"#0055E9\" r=\"12\"></circle><path d=\"M12 0c.518 0 1.028.033 1.528.096A6.188 6.188 0 0112.12 12.28l-.12.001c-2.99 0-5.242 2.179-5.554 5.11-.223 2.086.353 4.412 2.242 6.146C3.672 22.1 0 17.479 0 12 0 5.373 5.373 0 12 0z\" fill=\"#A8DFF5\"></path><path d=\"M5.286 5a2.438 2.438 0 01.682 3.38c-3.962 5.966-3.215 10.743 2.648 15.136C3.636 22.056 0 17.452 0 12c0-1.787.39-3.482 1.09-5.006.253-.435.525-.872.817-1.311A2.438 2.438 0 015.286 5z\" fill=\"#0055E9\"></path><path d=\"M12.98.04c.272.021.543.053.81.093.583.106 1.117.254 1.538.44 6.638 2.927 8.07 10.052 1.748 15.642a4.125 4.125 0 01-5.822-.358c-1.51-1.706-1.3-4.184.357-5.822.858-.848 3.108-1.223 4.045-2.441 1.257-1.634 2.122-6.009-2.523-7.506L12.98.039z\" fill=\"#00BCFF\"></path><path d=\"M13.528.096A6.187 6.187 0 0112 12.281a5.75 5.75 0 00-1.71.255c.147-.905.595-1.784 1.321-2.501.858-.848 3.108-1.223 4.045-2.441 1.27-1.651 2.14-6.104-2.676-7.554.184.014.367.033.548.056z\" fill=\"#ECECEE\"></path></svg>`,\n  kimi: `<svg fill=\"currentColor\" fill-rule=\"evenodd\" height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>Kimi</title><path d=\"M19.738 5.776c.163-.209.306-.4.457-.585.07-.087.064-.153-.004-.244-.655-.861-.717-1.817-.34-2.787.283-.73.909-1.072 1.674-1.145.477-.045.945.004 1.379.236.57.305.902.77 1.01 1.412.086.512.07 1.012-.075 1.508-.257.878-.888 1.333-1.753 1.448-.718.096-1.446.108-2.17.157-.056.004-.113 0-.178 0z\" fill=\"#027AFF\"></path><path d=\"M17.962 1.844h-4.326l-3.425 7.81H5.369V1.878H1.5V22h3.87v-8.477h6.824a3.025 3.025 0 002.743-1.75V22h3.87v-8.477a3.87 3.87 0 00-3.588-3.86v-.01h-2.125a3.94 3.94 0 002.323-2.12l2.545-5.689z\"></path></svg>`,\n  meta: `<svg height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>Meta</title><path d=\"M6.897 4h-.024l-.031 2.615h.022c1.715 0 3.046 1.357 5.94 6.246l.175.297.012.02 1.62-2.438-.012-.019a48.763 48.763 0 00-1.098-1.716 28.01 28.01 0 00-1.175-1.629C10.413 4.932 8.812 4 6.896 4z\" fill=\"url(#lobe-icons-meta-fill-0)\"></path><path d=\"M6.873 4C4.95 4.01 3.247 5.258 2.02 7.17a4.352 4.352 0 00-.01.017l2.254 1.231.011-.017c.718-1.083 1.61-1.774 2.568-1.785h.021L6.896 4h-.023z\" fill=\"url(#lobe-icons-meta-fill-1)\"></path><path d=\"M2.019 7.17l-.011.017C1.2 8.447.598 9.995.274 11.664l-.005.022 2.534.6.004-.022c.27-1.467.786-2.828 1.456-3.845l.011-.017L2.02 7.17z\" fill=\"url(#lobe-icons-meta-fill-2)\"></path><path d=\"M2.807 12.264l-2.533-.6-.005.022c-.177.918-.267 1.851-.269 2.786v.023l2.598.233v-.023a12.591 12.591 0 01.21-2.44z\" fill=\"url(#lobe-icons-meta-fill-3)\"></path><path d=\"M2.677 15.537a5.462 5.462 0 01-.079-.813v-.022L0 14.468v.024a8.89 8.89 0 00.146 1.652l2.535-.585a4.106 4.106 0 01-.004-.022z\" fill=\"url(#lobe-icons-meta-fill-4)\"></path><path d=\"M3.27 16.89c-.284-.31-.484-.756-.589-1.328l-.004-.021-2.535.585.004.021c.192 1.01.568 1.85 1.106 2.487l.014.017 2.018-1.745a2.106 2.106 0 01-.015-.016z\" fill=\"url(#lobe-icons-meta-fill-5)\"></path><path d=\"M10.78 9.654c-1.528 2.35-2.454 3.825-2.454 3.825-2.035 3.2-2.739 3.917-3.871 3.917a1.545 1.545 0 01-1.186-.508l-2.017 1.744.014.017C2.01 19.518 3.058 20 4.356 20c1.963 0 3.374-.928 5.884-5.33l1.766-3.13a41.283 41.283 0 00-1.227-1.886z\" fill=\"#0082FB\"></path><path d=\"M13.502 5.946l-.016.016c-.4.43-.786.908-1.16 1.416.378.483.768 1.024 1.175 1.63.48-.743.928-1.345 1.367-1.807l.016-.016-1.382-1.24z\" fill=\"url(#lobe-icons-meta-fill-6)\"></path><path d=\"M20.918 5.713C19.853 4.633 18.583 4 17.225 4c-1.432 0-2.637.787-3.723 1.944l-.016.016 1.382 1.24.016-.017c.715-.747 1.408-1.12 2.176-1.12.826 0 1.6.39 2.27 1.075l.015.016 1.589-1.425-.016-.016z\" fill=\"#0082FB\"></path><path d=\"M23.998 14.125c-.06-3.467-1.27-6.566-3.064-8.396l-.016-.016-1.588 1.424.015.016c1.35 1.392 2.277 3.98 2.361 6.971v.023h2.292v-.022z\" fill=\"url(#lobe-icons-meta-fill-7)\"></path><path d=\"M23.998 14.15v-.023h-2.292v.022c.004.14.006.282.006.424 0 .815-.121 1.474-.368 1.95l-.011.022 1.708 1.782.013-.02c.62-.96.946-2.293.946-3.91 0-.083 0-.165-.002-.247z\" fill=\"url(#lobe-icons-meta-fill-8)\"></path><path d=\"M21.344 16.52l-.011.02c-.214.402-.519.67-.917.787l.778 2.462a3.493 3.493 0 00.438-.182 3.558 3.558 0 001.366-1.218l.044-.065.012-.02-1.71-1.784z\" fill=\"url(#lobe-icons-meta-fill-9)\"></path><path d=\"M19.92 17.393c-.262 0-.492-.039-.718-.14l-.798 2.522c.449.153.927.222 1.46.222.492 0 .943-.073 1.352-.215l-.78-2.462c-.167.05-.341.075-.517.073z\" fill=\"url(#lobe-icons-meta-fill-10)\"></path><path d=\"M18.323 16.534l-.014-.017-1.836 1.914.016.017c.637.682 1.246 1.105 1.937 1.337l.797-2.52c-.291-.125-.573-.353-.9-.731z\" fill=\"url(#lobe-icons-meta-fill-11)\"></path><path d=\"M18.309 16.515c-.55-.642-1.232-1.712-2.303-3.44l-1.396-2.336-.011-.02-1.62 2.438.012.02.989 1.668c.959 1.61 1.74 2.774 2.493 3.585l.016.016 1.834-1.914a2.353 2.353 0 01-.014-.017z\" fill=\"url(#lobe-icons-meta-fill-12)\"></path><defs><linearGradient id=\"lobe-icons-meta-fill-0\" x1=\"75.897%\" x2=\"26.312%\" y1=\"89.199%\" y2=\"12.194%\"><stop offset=\".06%\" stop-color=\"#0867DF\"></stop><stop offset=\"45.39%\" stop-color=\"#0668E1\"></stop><stop offset=\"85.91%\" stop-color=\"#0064E0\"></stop></linearGradient><linearGradient id=\"lobe-icons-meta-fill-1\" x1=\"21.67%\" x2=\"97.068%\" y1=\"75.874%\" y2=\"23.985%\"><stop offset=\"13.23%\" stop-color=\"#0064DF\"></stop><stop offset=\"99.88%\" stop-color=\"#0064E0\"></stop></linearGradient><linearGradient id=\"lobe-icons-meta-fill-2\" x1=\"38.263%\" x2=\"60.895%\" y1=\"89.127%\" y2=\"16.131%\"><stop offset=\"1.47%\" stop-color=\"#0072EC\"></stop><stop offset=\"68.81%\" stop-color=\"#0064DF\"></stop></linearGradient><linearGradient id=\"lobe-icons-meta-fill-3\" x1=\"47.032%\" x2=\"52.15%\" y1=\"90.19%\" y2=\"15.745%\"><stop offset=\"7.31%\" stop-color=\"#007CF6\"></stop><stop offset=\"99.43%\" stop-color=\"#0072EC\"></stop></linearGradient><linearGradient id=\"lobe-icons-meta-fill-4\" x1=\"52.155%\" x2=\"47.591%\" y1=\"58.301%\" y2=\"37.004%\"><stop offset=\"7.31%\" stop-color=\"#007FF9\"></stop><stop offset=\"100%\" stop-color=\"#007CF6\"></stop></linearGradient><linearGradient id=\"lobe-icons-meta-fill-5\" x1=\"37.689%\" x2=\"61.961%\" y1=\"12.502%\" y2=\"63.624%\"><stop offset=\"7.31%\" stop-color=\"#007FF9\"></stop><stop offset=\"100%\" stop-color=\"#0082FB\"></stop></linearGradient><linearGradient id=\"lobe-icons-meta-fill-6\" x1=\"34.808%\" x2=\"62.313%\" y1=\"68.859%\" y2=\"23.174%\"><stop offset=\"27.99%\" stop-color=\"#007FF8\"></stop><stop offset=\"91.41%\" stop-color=\"#0082FB\"></stop></linearGradient><linearGradient id=\"lobe-icons-meta-fill-7\" x1=\"43.762%\" x2=\"57.602%\" y1=\"6.235%\" y2=\"98.514%\"><stop offset=\"0%\" stop-color=\"#0082FB\"></stop><stop offset=\"99.95%\" stop-color=\"#0081FA\"></stop></linearGradient><linearGradient id=\"lobe-icons-meta-fill-8\" x1=\"60.055%\" x2=\"39.88%\" y1=\"4.661%\" y2=\"69.077%\"><stop offset=\"6.19%\" stop-color=\"#0081FA\"></stop><stop offset=\"100%\" stop-color=\"#0080F9\"></stop></linearGradient><linearGradient id=\"lobe-icons-meta-fill-9\" x1=\"30.282%\" x2=\"61.081%\" y1=\"59.32%\" y2=\"33.244%\"><stop offset=\"0%\" stop-color=\"#027AF3\"></stop><stop offset=\"100%\" stop-color=\"#0080F9\"></stop></linearGradient><linearGradient id=\"lobe-icons-meta-fill-10\" x1=\"20.433%\" x2=\"82.112%\" y1=\"50.001%\" y2=\"50.001%\"><stop offset=\"0%\" stop-color=\"#0377EF\"></stop><stop offset=\"99.94%\" stop-color=\"#0279F1\"></stop></linearGradient><linearGradient id=\"lobe-icons-meta-fill-11\" x1=\"40.303%\" x2=\"72.394%\" y1=\"35.298%\" y2=\"57.811%\"><stop offset=\".19%\" stop-color=\"#0471E9\"></stop><stop offset=\"100%\" stop-color=\"#0377EF\"></stop></linearGradient><linearGradient id=\"lobe-icons-meta-fill-12\" x1=\"32.254%\" x2=\"68.003%\" y1=\"19.719%\" y2=\"84.908%\"><stop offset=\"27.65%\" stop-color=\"#0867DF\"></stop><stop offset=\"100%\" stop-color=\"#0471E9\"></stop></linearGradient></defs></svg>`,\n  midjourney: `<svg fill=\"currentColor\" fill-rule=\"evenodd\" height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>Midjourney</title><path d=\"M22.369 17.676c-1.387 1.259-3.17 2.378-5.332 3.417.044.03.086.057.13.083l.018.01.019.012c.216.123.42.184.641.184.222 0 .426-.061.642-.184l.018-.011.019-.011c.14-.084.266-.178.492-.366l.178-.148c.279-.232.426-.342.625-.456.304-.174.612-.266.949-.266.337 0 .645.092.949.266l.023.014c.188.109.334.219.602.442l.178.148c.221.184.346.278.483.36l.028.017.018.01c.21.12.407.181.62.185h.022a.31.31 0 110 .618c-.337 0-.645-.092-.95-.266a3.137 3.137 0 01-.09-.054l-.022-.014-.022-.013-.02-.014a5.356 5.356 0 01-.49-.377l-.159-.132a3.836 3.836 0 00-.483-.36l-.027-.017-.019-.01a1.256 1.256 0 00-.641-.185c-.222 0-.426.061-.641.184l-.02.011-.018.011c-.14.084-.266.178-.492.366l-.158.132a5.125 5.125 0 01-.51.39l-.022.014-.022.014-.09.054a1.868 1.868 0 01-.95.266c-.337 0-.644-.092-.949-.266a3.137 3.137 0 01-.09-.054l-.022-.014-.022-.013-.026-.017a4.881 4.881 0 01-.425-.325.308.308 0 01-.12-.1l-.098-.081a3.836 3.836 0 00-.483-.36l-.027-.017-.019-.01a1.256 1.256 0 00-.641-.185c-.222 0-.426.061-.642.184l-.018.011-.019.011c-.14.084-.266.178-.492.366l-.158.132a5.125 5.125 0 01-.51.39l-.023.014-.022.014-.09.054A1.868 1.868 0 0112 22c-.337 0-.645-.092-.949-.266a3.137 3.137 0 01-.09-.054l-.022-.014-.022-.013-.021-.014a5.356 5.356 0 01-.49-.377l-.158-.132a3.836 3.836 0 00-.483-.36l-.028-.017-.018-.01a1.256 1.256 0 00-.642-.185c-.221 0-.425.061-.641.184l-.019.011-.018.011c-.141.084-.266.178-.492.366l-.158.132a5.125 5.125 0 01-.511.39l-.022.014-.022.014-.09.054a1.868 1.868 0 01-.986.264c-.746-.09-1.319-.38-1.89-.866l-.035-.03c-.047-.041-.118-.106-.192-.174l-.196-.181-.107-.1-.011-.01a1.531 1.531 0 00-.336-.253.313.313 0 00-.095-.03h-.005c-.119.022-.238.059-.361.11a.308.308 0 01-.077.061l-.008.005a.309.309 0 01-.126.034 5.66 5.66 0 00-.774.518l-.416.324-.055.043a6.542 6.542 0 01-.324.236c-.305.207-.552.315-.8.315a.31.31 0 01-.01-.618h.01c.09 0 .235-.062.438-.198l.04-.027c.077-.054.163-.117.27-.199l.385-.301.06-.047c.268-.206.506-.373.73-.505l-.633-1.21a.309.309 0 01.254-.451l20.287-1.305a.309.309 0 01.228.537zm-1.118.14L2.369 19.03l.423.809c.128-.045.256-.078.388-.1a.31.31 0 01.052-.005c.132 0 .26.032.386.093.153.073.294.179.483.35l.016.015.092.086.144.134.097.089c.065.06.125.114.16.144.485.418.948.658 1.554.736h.011a1.25 1.25 0 00.6-.172l.021-.011.019-.011.018-.011c.141-.084.266-.178.492-.366l.178-.148c.279-.232.426-.342.625-.456.305-.174.612-.266.95-.266.336 0 .644.092.948.266l.023.014c.188.109.335.219.603.442l.177.148c.222.184.346.278.484.36l.027.017.019.01c.215.124.42.185.641.185.222 0 .426-.061.641-.184l.019-.011.018-.011c.141-.084.267-.178.493-.366l.177-.148c.28-.232.427-.342.626-.456.304-.174.612-.266.949-.266.337 0 .644.092.949.266l.025.015c.187.109.334.22.603.443 1.867-.878 3.448-1.811 4.73-2.832l.02-.016zM3.653 2.026C6.073 3.06 8.69 4.941 10.8 7.258c2.46 2.7 4.109 5.828 4.637 9.149a.31.31 0 01-.421.335c-2.348-.945-4.54-1.258-6.59-1.02-1.739.2-3.337.792-4.816 1.703-.294.182-.62-.182-.405-.454 1.856-2.355 2.581-4.99 2.343-7.794-.195-2.292-1.031-4.61-2.284-6.709a.31.31 0 01.388-.442zM10.04 4.45c1.778.543 3.892 2.102 5.782 4.243 1.984 2.248 3.552 4.934 4.347 7.582a.31.31 0 01-.401.38l-.022-.01-.386-.154a10.594 10.594 0 00-.291-.112l-.016-.006c-.68-.247-1.199-.291-1.944-.101a.31.31 0 01-.375-.218C15.378 11.123 13.073 7.276 9.775 5c-.291-.201-.072-.653.266-.55zM4.273 2.996l.008.015c1.028 1.94 1.708 4.031 1.885 6.113.213 2.513-.31 4.906-1.673 7.092l-.02.031.003-.001c1.198-.581 2.47-.969 3.825-1.132l.055-.006c1.981-.23 4.083.029 6.309.837l.066.025-.007-.039c-.593-2.95-2.108-5.737-4.31-8.179l-.07-.078c-1.785-1.96-3.944-3.6-6.014-4.65l-.057-.028zm7.92 3.238l.048.048c2.237 2.295 3.885 5.431 4.974 9.191l.038.132.022-.004c.71-.133 1.284-.063 1.963.18l.027.01.066.024.046.018-.025-.073c-.811-2.307-2.208-4.62-3.936-6.594l-.058-.065c-1.02-1.155-2.103-2.132-3.15-2.856l-.015-.011z\"></path></svg>`,\n  minimax: `<svg height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>Minimax</title><defs><linearGradient id=\"lobe-icons-minimax-fill\" x1=\"0%\" x2=\"100.182%\" y1=\"50.057%\" y2=\"50.057%\"><stop offset=\"0%\" stop-color=\"#E2167E\"></stop><stop offset=\"100%\" stop-color=\"#FE603C\"></stop></linearGradient></defs><path d=\"M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z\" fill=\"url(#lobe-icons-minimax-fill)\" fill-rule=\"nonzero\"></path></svg>`,\n  mistral: `<svg height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>Mistral</title><path d=\"M3.428 3.4h3.429v3.428H3.428V3.4zm13.714 0h3.43v3.428h-3.43V3.4z\" fill=\"gold\"></path><path d=\"M3.428 6.828h6.857v3.429H3.429V6.828zm10.286 0h6.857v3.429h-6.857V6.828z\" fill=\"#FFAF00\"></path><path d=\"M3.428 10.258h17.144v3.428H3.428v-3.428z\" fill=\"#FF8205\"></path><path d=\"M3.428 13.686h3.429v3.428H3.428v-3.428zm6.858 0h3.429v3.428h-3.429v-3.428zm6.856 0h3.43v3.428h-3.43v-3.428z\" fill=\"#FA500F\"></path><path d=\"M0 17.114h10.286v3.429H0v-3.429zm13.714 0H24v3.429H13.714v-3.429z\" fill=\"#E10500\"></path></svg>`,\n  newapi: `<svg fill-rule=\"evenodd\" height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>NewAPI</title><path d=\"M23.078 16.34c-.506 1.323-1.198 2.519-2.117 3.562-2.378 2.696-5.374 4.057-8.971 4.098a.037.037 0 01-.024-.01.041.041 0 01-.013-.023.041.041 0 01.003-.025.037.037 0 01.019-.019c1.886-.779 3.454-1.973 4.625-3.639a10.148 10.148 0 001.626-3.677c.217-.98.33-1.955.282-2.942-.048-1.018-.152-1.601-.484-2.565-.386-1.12-.915-2.16-1.627-3.089-.883-1.154-1.876-1.87-2.9-2.779-.995-.88-2.19-2.623-1.059-3.754.384-.384.997-.59 1.838-.621 2.478-.09 5.011 1.636 6.597 3.453.75.86 1.38 1.798 1.865 2.837.486 1.041.814 2.122.978 3.246.133.915.117 1.441.092 2.365a10.82 10.82 0 01-.73 3.582z\" fill=\"url(#lobe-icons-new-api-fill-0)\"></path><path d=\"M11.86.01a.041.041 0 01.009.049.038.038 0 01-.018.018C9.964.856 8.396 2.05 7.225 3.716a10.148 10.148 0 00-1.626 3.678c-.217.979-.33 1.955-.283 2.941.049 1.018.154 1.601.486 2.565.385 1.12.914 2.16 1.626 3.088.883 1.154 1.876 1.872 2.9 2.78.995.88 2.19 2.622 1.059 3.753-.385.385-.997.591-1.838.622-2.478.089-5.011-1.636-6.597-3.454-.75-.86-1.38-1.797-1.865-2.837a11.591 11.591 0 01-.978-3.246c-.133-.914-.117-1.44-.091-2.364.034-1.225.284-2.416.73-3.582.504-1.323 1.197-2.52 2.116-3.562C5.241 1.402 8.238.04 11.835 0c.009 0 .018.004.024.01z\" fill=\"url(#lobe-icons-new-api-fill-1)\"></path><path d=\"M8.721 11.903l2.455-.708.72-2.48a.066.066 0 01.127.002l.58 2.26c.776.437 1.65.755 2.622.956a.05.05 0 01.028.075.05.05 0 01-.024.019l-2.382.709a.163.163 0 00-.109.108l-.72 2.444a.034.034 0 01-.031.027.034.034 0 01-.034-.024l-.713-2.395a.183.183 0 00-.128-.128l-2.39-.705a.084.084 0 01-.044-.13.084.084 0 01.043-.03z\" fill=\"url(#lobe-icons-new-api-fill-2)\"></path><defs><linearGradient gradientUnits=\"userSpaceOnUse\" id=\"lobe-icons-new-api-fill-0\" x1=\"17.889\" x2=\"17.889\" y1=\".854\" y2=\"24\"><stop stop-color=\"#F85EAD\"></stop><stop offset=\"1\" stop-color=\"#FD75FD\"></stop></linearGradient><linearGradient gradientUnits=\"userSpaceOnUse\" id=\"lobe-icons-new-api-fill-1\" x1=\"5.936\" x2=\"5.936\" y1=\"0\" y2=\"23.146\"><stop offset=\".332\" stop-color=\"#11F5EF\"></stop><stop offset=\"1\" stop-color=\"#C738FB\"></stop></linearGradient><linearGradient gradientUnits=\"userSpaceOnUse\" id=\"lobe-icons-new-api-fill-2\" x1=\"11.961\" x2=\"11.961\" y1=\"8.666\" y2=\"15.315\"><stop offset=\".332\" stop-color=\"#11F5EF\"></stop><stop offset=\"1\" stop-color=\"#C738FB\"></stop></linearGradient></defs></svg>`,\n  notion: `<svg fill=\"currentColor\" fill-rule=\"evenodd\" height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>Notion</title><path clip-rule=\"evenodd\" d=\"M15.257.055l-13.31.98C.874 1.128.5 1.83.5 2.667v14.559c0 .654.233 1.213.794 1.96l3.129 4.06c.513.653.98.794 1.962.745l15.457-.932c1.307-.093 1.681-.7 1.681-1.727V4.954c0-.53-.21-.684-.829-1.135l-.106-.078L18.34.755c-1.027-.746-1.45-.84-3.083-.7zm-8.521 4.63c-1.263.086-1.549.105-2.266-.477L2.647 2.76c-.186-.187-.092-.42.375-.466l12.796-.933c1.074-.094 1.634.28 2.054.606l2.195 1.587c.093.047.326.326.047.326l-13.216.794-.162.01zM5.263 21.193V7.287c0-.606.187-.886.748-.933l15.176-.886c.515-.047.748.28.748.886v13.81c0 .609-.093 1.122-.934 1.168l-14.523.84c-.842.047-1.215-.232-1.215-.98zm14.338-13.16c.093.422 0 .842-.422.89l-.699.139v10.264c-.608.327-1.168.513-1.635.513-.747 0-.934-.232-1.495-.932l-4.576-7.185v6.952l1.448.327s0 .84-1.169.84l-3.221.186c-.094-.187 0-.654.327-.747l.84-.232V9.853L7.832 9.76c-.093-.42.14-1.026.794-1.073l3.456-.232 4.763 7.279v-6.44l-1.214-.14c-.094-.513.28-.887.747-.933l3.223-.187z\"></path></svg>`,\n  ollama: `<svg fill=\"currentColor\" fill-rule=\"evenodd\" height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>Ollama</title><path d=\"M7.905 1.09c.216.085.411.225.588.41.295.306.544.744.734 1.263.191.522.315 1.1.362 1.68a5.054 5.054 0 012.049-.636l.051-.004c.87-.07 1.73.087 2.48.474.101.053.2.11.297.17.05-.569.172-1.134.36-1.644.19-.52.439-.957.733-1.264a1.67 1.67 0 01.589-.41c.257-.1.53-.118.796-.042.401.114.745.368 1.016.737.248.337.434.769.561 1.287.23.934.27 2.163.115 3.645l.053.04.026.019c.757.576 1.284 1.397 1.563 2.35.435 1.487.216 3.155-.534 4.088l-.018.021.002.003c.417.762.67 1.567.724 2.4l.002.03c.064 1.065-.2 2.137-.814 3.19l-.007.01.01.024c.472 1.157.62 2.322.438 3.486l-.006.039a.651.651 0 01-.747.536.648.648 0 01-.54-.742c.167-1.033.01-2.069-.48-3.123a.643.643 0 01.04-.617l.004-.006c.604-.924.854-1.83.8-2.72-.046-.779-.325-1.544-.8-2.273a.644.644 0 01.18-.886l.009-.006c.243-.159.467-.565.58-1.12a4.229 4.229 0 00-.095-1.974c-.205-.7-.58-1.284-1.105-1.683-.595-.454-1.383-.673-2.38-.61a.653.653 0 01-.632-.371c-.314-.665-.772-1.141-1.343-1.436a3.288 3.288 0 00-1.772-.332c-1.245.099-2.343.801-2.67 1.686a.652.652 0 01-.61.425c-1.067.002-1.893.252-2.497.703-.522.39-.878.935-1.066 1.588a4.07 4.07 0 00-.068 1.886c.112.558.331 1.02.582 1.269l.008.007c.212.207.257.53.109.785-.36.622-.629 1.549-.673 2.44-.05 1.018.186 1.902.719 2.536l.016.019a.643.643 0 01.095.69c-.576 1.236-.753 2.252-.562 3.052a.652.652 0 01-1.269.298c-.243-1.018-.078-2.184.473-3.498l.014-.035-.008-.012a4.339 4.339 0 01-.598-1.309l-.005-.019a5.764 5.764 0 01-.177-1.785c.044-.91.278-1.842.622-2.59l.012-.026-.002-.002c-.293-.418-.51-.953-.63-1.545l-.005-.024a5.352 5.352 0 01.093-2.49c.262-.915.777-1.701 1.536-2.269.06-.045.123-.09.186-.132-.159-1.493-.119-2.73.112-3.67.127-.518.314-.95.562-1.287.27-.368.614-.622 1.015-.737.266-.076.54-.059.797.042zm4.116 9.09c.936 0 1.8.313 2.446.855.63.527 1.005 1.235 1.005 1.94 0 .888-.406 1.58-1.133 2.022-.62.375-1.451.557-2.403.557-1.009 0-1.871-.259-2.493-.734-.617-.47-.963-1.13-.963-1.845 0-.707.398-1.417 1.056-1.946.668-.537 1.55-.849 2.485-.849zm0 .896a3.07 3.07 0 00-1.916.65c-.461.37-.722.835-.722 1.25 0 .428.21.829.61 1.134.455.347 1.124.548 1.943.548.799 0 1.473-.147 1.932-.426.463-.28.7-.686.7-1.257 0-.423-.246-.89-.683-1.256-.484-.405-1.14-.643-1.864-.643zm.662 1.21l.004.004c.12.151.095.37-.056.49l-.292.23v.446a.375.375 0 01-.376.373.375.375 0 01-.376-.373v-.46l-.271-.218a.347.347 0 01-.052-.49.353.353 0 01.494-.051l.215.172.22-.174a.353.353 0 01.49.051zm-5.04-1.919c.478 0 .867.39.867.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zm8.706 0c.48 0 .868.39.868.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zM7.44 2.3l-.003.002a.659.659 0 00-.285.238l-.005.006c-.138.189-.258.467-.348.832-.17.692-.216 1.631-.124 2.782.43-.128.899-.208 1.404-.237l.01-.001.019-.034c.046-.082.095-.161.148-.239.123-.771.022-1.692-.253-2.444-.134-.364-.297-.65-.453-.813a.628.628 0 00-.107-.09L7.44 2.3zm9.174.04l-.002.001a.628.628 0 00-.107.09c-.156.163-.32.45-.453.814-.29.794-.387 1.776-.23 2.572l.058.097.008.014h.03a5.184 5.184 0 011.466.212c.086-1.124.038-2.043-.128-2.722-.09-.365-.21-.643-.349-.832l-.004-.006a.659.659 0 00-.285-.239h-.004z\"></path></svg>`,\n  openai: `<svg fill=\"currentColor\" fill-rule=\"evenodd\" height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>OpenAI</title><path d=\"M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z\"></path></svg>`,\n  openclaw: `<svg height=\"1em\" width=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 120 120\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><title>OpenClaw</title><defs><linearGradient id=\"oc-g\" x1=\"0%\" y1=\"0%\" x2=\"100%\" y2=\"100%\"><stop offset=\"0%\" stop-color=\"#ff4d4d\"/><stop offset=\"100%\" stop-color=\"#991b1b\"/></linearGradient></defs><path d=\"M60 10 C30 10 15 35 15 55 C15 75 30 95 45 100 L45 110 L55 110 L55 100 C55 100 60 102 65 100 L65 110 L75 110 L75 100 C90 95 105 75 105 55 C105 35 90 10 60 10Z\" fill=\"url(#oc-g)\"/><path d=\"M20 45 C5 40 0 50 5 60 C10 70 20 65 25 55 C28 48 25 45 20 45Z\" fill=\"url(#oc-g)\"/><path d=\"M100 45 C115 40 120 50 115 60 C110 70 100 65 95 55 C92 48 95 45 100 45Z\" fill=\"url(#oc-g)\"/><path d=\"M45 15 Q35 5 30 8\" stroke=\"#ff4d4d\" stroke-width=\"3\" stroke-linecap=\"round\"/><path d=\"M75 15 Q85 5 90 8\" stroke=\"#ff4d4d\" stroke-width=\"3\" stroke-linecap=\"round\"/><circle cx=\"45\" cy=\"35\" r=\"6\" fill=\"#050810\"/><circle cx=\"75\" cy=\"35\" r=\"6\" fill=\"#050810\"/><circle cx=\"46\" cy=\"34\" r=\"2.5\" fill=\"#00e5cc\"/><circle cx=\"76\" cy=\"34\" r=\"2.5\" fill=\"#00e5cc\"/></svg>`,\n  packycode: `<svg height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 145.55 113.29\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>PackyCode</title><path fill=\"currentColor\" stroke=\"currentColor\" stroke-miterlimit=\"10\" d=\"M144.68,38.49l-.06-.23c-.88-3.28-2.5-5.94-4.58-8.06.14,5.65-2.96,11.02-6.22,16.66l-.39.68c-2.26,3.91-4.66,7.94-6.57,11.1l-.86,1.38c-3.36,5.44-6.27,10.14-12.18,14.18-8.34,5.87-18.2,5.81-26.92,5.76-2.81-.02-5.48-.03-7.95.14l-.35.02c-3.22.06-5.96,1.57-8.17,4.49l-.14.18c-.86,1.06-1.7,2.26-2.58,3.54-3.43,4.92-7.69,11.04-16.17,12.42-4.37.86-9.98.84-14.94.83h-1.95c-.52,0-1.06,0-1.61,0-6.95,0-16.08-.89-21.94-6.55-.15-.15-.3-.3-.44-.45.5,4.73,2.33,8.64,5.44,11.65,5.86,5.66,14.98,6.55,21.94,6.55.55,0,1.09,0,1.61-.01h1.93c4.96.02,10.58.04,14.96-.83,8.48-1.37,12.74-7.5,16.17-12.42.88-1.27,1.72-2.48,2.58-3.54l.14-.18c2.21-2.92,4.95-4.43,8.17-4.49l.35-.02c2.47-.17,5.13-.16,7.95-.14,8.72.05,18.58.11,26.91-5.76,5.92-4.03,8.82-8.73,12.19-14.17l.86-1.39c1.91-3.15,4.3-7.18,6.57-11.09l.39-.69c3.81-6.6,7.41-12.84,5.86-19.57ZM120.9,23.02c-.28,0-.56,0-.83,0-9.68-.19-24.03-.09-35.57-.01l-2.04.02c-.93.01-1.82.02-2.67.03-8.36.08-14.4.13-23.33,3.82l-.27.12c-10.76,4.68-16.91,12.16-22.83,21.95-5.53,8.76-12.62,20.57-16.32,26.79-.41.69-.77,1.3-1.09,1.84l-.49.84c-1.49,2.53-3.23,5.49-4.21,8.95,3.98,1.9,8.52,2.65,12.71,2.91.27-.77.64-1.56,1.07-2.38.48-.94,1.05-1.9,1.63-2.89l.49-.84c.96-1.62,2.38-3.99,4.05-6.78,3.82-6.36,8.98-14.88,13.19-21.55l.07-.11c5.02-8.31,9.2-13.45,16.91-16.81l.11-.05c6.57-2.7,10.54-2.74,18.43-2.81.87-.01,1.78-.02,2.69-.03l1.99-.02c9.17-.06,20.13-.14,29.01-.07,2.26.01,4.39.04,6.32.08h.12s.11,0,.11,0c1.25-.01,2.91.11,4.61.46,1.03.22,2.08.52,3.05.93.21-.35.41-.71.62-1.06l.39-.68c1.92-3.33,3.79-6.57,4.97-9.82-4.08-1.92-8.7-2.76-12.89-2.82Z\"/><path fill=\"currentColor\" stroke=\"currentColor\" stroke-miterlimit=\"10\" d=\"M115.07,11.81c-9.7-.19-24.07-.09-35.62-.01h-1.99c-.93.03-1.82.04-2.67.04-8.36.08-14.4.14-23.33,3.83l-.27.12c-10.76,4.67-16.91,12.16-22.83,21.95-6.13,9.71-14.19,23.21-17.41,28.63l-.5.84c-2.08,3.55-4.68,7.97-4.94,13.39v.2s0,.21,0,.21c.01.81.06,1.61.15,2.38.14.15.29.3.44.45,1.53,1.47,3.28,2.63,5.15,3.52,3.98,1.9,8.52,2.65,12.71,2.91,1.41.09,2.78.12,4.08.12.55,0,1.09-.01,1.61-.01h1.95c4.95.01,10.57.03,14.94-.83,8.48-1.38,12.74-7.5,16.17-12.42.88-1.28,1.72-2.48,2.58-3.54l.14-.18c2.21-2.92,4.95-4.44,8.17-4.49l.35-.02c2.47-.17,5.14-.16,7.95-.14,8.71.05,18.58.1,26.92-5.76,5.91-4.04,8.82-8.74,12.18-14.18l.86-1.39c1.74-2.86,3.87-6.45,5.95-10.03.21-.35.41-.71.62-1.06l.39-.68c1.92-3.33,3.79-6.57,4.97-9.82.82-2.26,1.31-4.53,1.25-6.84-5.18-5.29-13.22-7.25-19.97-7.19ZM122.56,40.37l-.39.67c-2.21,3.81-4.55,7.77-6.39,10.79l-.84,1.36c-3.01,4.87-4.83,7.81-8.48,10.29l-.1.07c-4.94,3.49-11.95,3.45-19.38,3.41-2.88-.02-5.87-.04-8.79.16-7.1.19-13.51,3.58-18.07,9.57-1.13,1.4-2.12,2.83-3.08,4.21-2.92,4.18-4.71,6.57-7.64,7.02l-.31.06c-3.09.63-8.28.61-12.45.6h-2.16c-3.85.07-7.01-.16-9.45-.69-2.24-.48-3.87-1.22-4.89-2.2-.66-.64-1.54-1.81-1.63-4.65.11-1.35.71-2.82,1.52-4.35.48-.94,1.05-1.9,1.63-2.89l.49-.84c3.17-5.33,11.19-18.75,17.24-28.33l.07-.11c5.02-8.32,9.2-13.45,16.92-16.81l.1-.05c6.57-2.7,10.54-2.74,18.43-2.82.87,0,1.78,0,2.69-.03h1.94c11.52-.09,25.86-.19,35.38,0h.23c1.25-.01,2.92.11,4.61.46,3.15.66,6.4,2.12,7.27,5.02.2,1.2-.89,3.62-2.27,6.18-.7,1.31-1.48,2.65-2.2,3.9ZM120.07,23.01c-9.68-.19-24.03-.09-35.57-.01l-2.04.02c-.93.01-1.82.02-2.67.03-8.36.08-14.4.13-23.33,3.82l-.27.12c-10.76,4.68-16.91,12.16-22.83,21.95-5.53,8.76-12.62,20.57-16.32,26.79-.78-.35-1.41-.77-1.9-1.24-.66-.64-1.54-1.81-1.63-4.65.18-2.18,1.62-4.64,3.15-7.25l.49-.83c3.17-5.32,11.18-18.73,17.24-28.33l.07-.12c5.02-8.31,9.2-13.45,16.92-16.81l.1-.04c6.57-2.7,10.54-2.74,18.43-2.82.87-.01,1.78-.01,2.69-.03h1.99c11.5-.09,25.82-.19,35.33,0h.23c3.57-.04,10.55,1.02,11.88,5.48.14.84-.35,2.27-1.13,3.93-.28,0-.56,0-.83,0Z\"/><path fill=\"currentColor\" stroke=\"currentColor\" stroke-miterlimit=\"10\" d=\"M134.68,16.09l-.06-.23c-3.07-11.45-15.08-15.33-24.55-15.25-9.68-.19-24.03-.09-35.57-.01h-2.04c-.93.03-1.82.04-2.67.04-8.36.08-14.4.14-23.33,3.83l-.27.12c-10.76,4.67-16.91,12.16-22.83,21.95-6.14,9.73-14.2,23.22-17.41,28.62l-.49.85c-2.09,3.55-4.69,7.96-4.95,13.39v.2s0,.21,0,.21c.09,5.57,1.82,10.13,5.15,13.58.14.15.29.3.44.45,1.53,1.47,3.28,2.63,5.15,3.52,3.98,1.9,8.52,2.65,12.71,2.91,1.41.09,2.78.12,4.08.12.55,0,1.09-.01,1.61-.01h1.95c4.95.01,10.57.03,14.94-.83,8.48-1.38,12.74-7.5,16.17-12.42.88-1.28,1.72-2.48,2.58-3.54l.14-.18c2.21-2.92,4.95-4.44,8.17-4.49l.35-.02c2.47-.17,5.14-.16,7.95-.14,8.71.05,18.58.1,26.92-5.76,5.91-4.04,8.82-8.74,12.18-14.18l.86-1.39c1.74-2.86,3.87-6.45,5.95-10.03.21-.35.41-.71.62-1.06l.39-.68c1.92-3.33,3.79-6.57,4.97-9.82.82-2.26,1.31-4.53,1.25-6.84-.02-.96-.14-1.93-.36-2.91ZM119.76,25.27c-.7,1.31-1.48,2.65-2.2,3.9l-.39.67c-1.19,2.04-2.41,4.13-3.57,6.09-1.01,1.7-1.97,3.31-2.82,4.7l-.84,1.36c-3.01,4.87-4.83,7.81-8.48,10.29l-.1.07c-4.94,3.49-11.96,3.45-19.38,3.41-2.89-.02-5.87-.04-8.79.16-7.1.18-13.51,3.58-18.07,9.57-1.13,1.4-2.12,2.83-3.08,4.21-2.92,4.18-4.71,6.57-7.64,7.02l-.31.06c-3.09.63-8.27.61-12.45.6h-2.16c-3.85.07-7.01-.16-9.45-.69-1.16-.25-2.16-.57-2.99-.96-.78-.35-1.41-.77-1.9-1.24-.66-.64-1.54-1.81-1.63-4.65.18-2.18,1.62-4.64,3.15-7.25l.49-.83c3.17-5.32,11.18-18.73,17.24-28.33l.07-.12c5.02-8.31,9.2-13.45,16.92-16.81l.1-.04c6.57-2.7,10.54-2.74,18.43-2.82.87-.01,1.78-.01,2.69-.03h1.99c11.5-.09,25.82-.19,35.33,0h.23c3.57-.04,10.55,1.02,11.88,5.48.14.84-.35,2.27-1.13,3.93-.34.72-.73,1.48-1.14,2.25Z\"/></svg>`,\n  palm: `<svg height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>PaLM</title><path d=\"M12 22.926c.928 0 1.679-.752 1.679-1.68V6.696h-3.358v14.552c0 .927.751 1.679 1.679 1.679z\" fill=\"#F9AB00\"></path><path d=\"M18.69 12.005A5.819 5.819 0 0012 10.904l7.188 7.188c.296.296.807.179.933-.22a5.815 5.815 0 00-1.431-5.867z\" fill=\"#5BB974\"></path><path d=\"M5.31 12.005A5.819 5.819 0 0112 10.904l-7.188 7.188a.562.562 0 01-.933-.22 5.815 5.815 0 011.431-5.867z\" fill=\"#129EAF\"></path><path d=\"M18.157 6.426c-2.86 0-5.288 1.875-6.157 4.478h11.367a.629.629 0 00.565-.908c-1.08-2.12-3.26-3.57-5.775-3.57z\" fill=\"#AF5CF7\"></path><path d=\"M13.188 3.384c-2.023 2.024-2.414 5.064-1.188 7.52l8.038-8.039a.629.629 0 00-.242-1.042c-2.264-.735-4.83-.217-6.608 1.561z\" fill=\"#FF8BCB\"></path><path d=\"M10.812 3.384c2.023 2.024 2.414 5.064 1.188 7.52L3.962 2.865a.629.629 0 01.242-1.042c2.264-.735 4.83-.217 6.608 1.561z\" fill=\"#FA7B17\"></path><path d=\"M5.843 6.426c2.86 0 5.288 1.875 6.157 4.478H.633a.629.629 0 01-.565-.908c1.08-2.12 3.26-3.57 5.775-3.57z\" fill=\"#4285F4\"></path></svg>`,\n  perplexity: `<svg height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>Perplexity</title><path d=\"M19.785 0v7.272H22.5V17.62h-2.935V24l-7.037-6.194v6.145h-1.091v-6.152L4.392 24v-6.465H1.5V7.188h2.884V0l7.053 6.494V.19h1.09v6.49L19.786 0zm-7.257 9.044v7.319l5.946 5.234V14.44l-5.946-5.397zm-1.099-.08l-5.946 5.398v7.235l5.946-5.234V8.965zm8.136 7.58h1.844V8.349H13.46l6.105 5.54v2.655zm-8.982-8.28H2.59v8.195h1.8v-2.576l6.192-5.62zM5.475 2.476v4.71h5.115l-5.115-4.71zm13.219 0l-5.115 4.71h5.115v-4.71z\" fill=\"#22B8CD\" fill-rule=\"nonzero\"></path></svg>`,\n  qwen: `<svg height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>Qwen</title><path d=\"M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z\" fill=\"url(#lobe-icons-qwen-fill)\" fill-rule=\"nonzero\"></path><defs><linearGradient id=\"lobe-icons-qwen-fill\" x1=\"0%\" x2=\"100%\" y1=\"0%\" y2=\"0%\"><stop offset=\"0%\" stop-color=\"#6336E7\" stop-opacity=\".84\"></stop><stop offset=\"100%\" stop-color=\"#6F69F7\" stop-opacity=\".84\"></stop></linearGradient></defs></svg>`,\n  stability: `<svg height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>Stability</title><path d=\"M7.223 21c4.252 0 7.018-2.22 7.018-5.56 0-2.59-1.682-4.236-4.69-4.918l-1.93-.571c-1.694-.375-2.683-.825-2.45-1.975.194-.957.773-1.497 2.122-1.497 4.285 0 5.873 1.497 5.873 1.497v-3.6S11.62 3 7.293 3C3.213 3 1 5.07 1 8.273c0 2.59 1.534 4.097 4.645 4.812l.334.083c.473.144 1.112.335 1.916.572 1.59.375 1.999.773 1.999 1.966 0 1.09-1.15 1.71-2.67 1.71C2.841 17.416 1 15.231 1 15.231v3.989S2.152 21 7.223 21z\" fill=\"url(#lobe-icons-stability-fill)\"></path><path d=\"M20.374 20.73c1.505 0 2.626-1.073 2.626-2.526 0-1.484-1.089-2.526-2.626-2.526-1.505 0-2.594 1.042-2.594 2.526 0 1.484 1.089 2.526 2.594 2.526z\" fill=\"#E80000\"></path><defs><linearGradient id=\"lobe-icons-stability-fill\" x1=\"50%\" x2=\"50%\" y1=\"0%\" y2=\"100%\"><stop offset=\"0%\" stop-color=\"#9D39FF\"></stop><stop offset=\"100%\" stop-color=\"#A380FF\"></stop></linearGradient></defs></svg>`,\n  tencent: `<svg height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>Tencent</title><path d=\"M9.976 1L24 9.8l-10.587.015L10.723 23H5.489L8.18 9.8H3.244L1 5.4h8.077L9.976 1z\" fill=\"#0052D9\" fill-rule=\"evenodd\"></path></svg>`,\n  vercel: `<svg fill=\"currentColor\" fill-rule=\"evenodd\" height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>Vercel</title><path d=\"M12 0l12 20.785H0L12 0z\"></path></svg>`,\n  wenxin: `<svg height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>Wenxin</title><path d=\"M11.32 1.176a1.4 1.4 0 011.36 0l8.64 4.843c.421.234.68.67.68 1.141v9.68c0 .472-.259.908-.68 1.143l-8.64 4.84a1.4 1.4 0 01-1.36 0l-8.64-4.84A1.31 1.31 0 012 16.84V7.159c0-.471.259-.907.68-1.142l8.64-4.84zm7.42 13.839V8.227L12.002 12 12 19.551l6.059-3.394a1.31 1.31 0 00.68-1.142zM12.68 4.833a1.393 1.393 0 00-1.36 0L5.944 7.846c-.421.235-.68.67-.68 1.142v6.027c0 .47.259.905.68 1.142l2.795 1.566V11.09a1.546 1.546 0 00.221.79 1.527 1.527 0 01-.216-.834l.004-.094.02-.15.018-.084.017-.062.039-.117.062-.142.035-.065.081-.13.094-.122.084-.091.08-.075.125-.1.071-.048.134-.076 5.87-3.29-2.796-1.566z\" fill=\"url(#lobe-icons-wenxin-fill)\"></path><path d=\"M12 11.088c0-.875-.73-1.584-1.631-1.584a1.66 1.66 0 00-.855.237c-.027.016-.055.033-.08.05a2.361 2.361 0 00-.123.093c-.022.02-.045.038-.066.059l-.048.045-.063.067c-.014.016-.028.031-.04.048a2.303 2.303 0 00-.094.125l-.042.069a1.7 1.7 0 00-.07.13l-.036.081a.764.764 0 00-.022.06c-.01.03-.02.058-.028.087l-.017.062a.883.883 0 00-.03.16c-.002.025-.007.05-.008.074a1.527 1.527 0 00.213.929c.302.508.85.792 1.414.792.277 0 .558-.068.814-.212l.815-.457v-.914L12 11.088z\" fill=\"#012F8D\"></path><defs><linearGradient id=\"lobe-icons-wenxin-fill\" x1=\"9.155%\" x2=\"90.531%\" y1=\"75.177%\" y2=\"25.028%\"><stop offset=\"0%\" stop-color=\"#0A51C3\"></stop><stop offset=\"100%\" stop-color=\"#23A4FB\"></stop></linearGradient></defs></svg>`,\n  xai: `<svg fill=\"currentColor\" fill-rule=\"evenodd\" height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>Grok</title><path d=\"M6.469 8.776L16.512 23h-4.464L2.005 8.776H6.47zm-.004 7.9l2.233 3.164L6.467 23H2l4.465-6.324zM22 2.582V23h-3.659V7.764L22 2.582zM22 1l-9.952 14.095-2.233-3.163L17.533 1H22z\"></path></svg>`,\n  xiaomimimo: `<svg fill=\"currentColor\" height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 152 132\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>Logo</title><g transform=\"translate(10 58)\"><path d=\"M64.9008 0.00138769C64.6875 -0.00400695 64.4753 0.0339904 64.2771 0.113075C64.0789 0.192159 63.8988 0.310682 63.7478 0.461454C63.5968 0.612226 63.478 0.792105 63.3985 0.990178C63.3191 1.18825 63.2808 1.40039 63.2858 1.61373C63.2858 2.04553 63.4574 2.45964 63.7627 2.76497C64.068 3.0703 64.4821 3.24183 64.9139 3.24183C65.3457 3.24183 65.7599 3.0703 66.0652 2.76497C66.3705 2.45964 66.542 2.04553 66.542 1.61373C66.5473 1.39811 66.5082 1.18371 66.4271 0.983812C66.3461 0.783917 66.2249 0.602783 66.0711 0.451629C65.9172 0.300476 65.734 0.182523 65.5327 0.105076C65.3314 0.027629 65.1163 -0.00766247 64.9008 0.00138769Z\"></path><path d=\"M66.1689 4.55469H63.6296V15.7255H66.1689V4.55469Z\"></path><path d=\"M38.8643 4.06641C35.3586 4.06641 33.0872 6.76065 33.0872 10.0326C33.0872 13.3045 35.3717 16.0014 38.8643 16.0014C42.3568 16.0014 44.6414 13.3072 44.6414 10.0326C44.6414 6.75802 42.3673 4.06641 38.8643 4.06641ZM38.8643 13.6932C37.0261 13.6932 35.5844 12.3408 35.5844 10.0326C35.5844 7.72437 37.0261 6.37463 38.8643 6.37463C40.7025 6.37463 42.1415 7.727 42.1415 10.0326C42.1415 12.3382 40.7025 13.6932 38.8643 13.6932Z\"></path><path d=\"M17.7909 0.000890693C17.5765 -0.00632181 17.3629 0.0303305 17.1631 0.108601C16.9634 0.186872 16.7817 0.305111 16.6293 0.456073C16.4768 0.607034 16.3568 0.787536 16.2766 0.986515C16.1964 1.18549 16.1577 1.39876 16.1628 1.61323C16.1628 2.04503 16.3343 2.45914 16.6397 2.76447C16.945 3.0698 17.3591 3.24133 17.7909 3.24133C18.2227 3.24133 18.6368 3.0698 18.9421 2.76447C19.2475 2.45914 19.419 2.04503 19.419 1.61323C19.4241 1.39876 19.3854 1.18549 19.3052 0.986515C19.225 0.787536 19.105 0.607034 18.9525 0.456073C18.8001 0.305111 18.6184 0.186872 18.4187 0.108601C18.2189 0.0303305 18.0053 -0.00632181 17.7909 0.000890693Z\"></path><path d=\"M11.7564 15.7252L0.359741 1.61328H3.51615L15.1124 15.7252H11.7564Z\"></path><path d=\"M3.35861 15.7252L14.7527 1.61328H11.5963L0 15.7252H3.35861Z\"></path><path d=\"M19.0618 4.55469H16.5225V15.7255H19.0618V4.55469Z\"></path><path d=\"M26.5905 3.78906C24.5055 3.78906 23.0454 4.31426 21.7009 5.36464L22.6988 7.20282C23.6328 6.41651 24.8055 5.96948 26.0259 5.93448C27.7354 5.93448 28.7543 6.82993 28.7543 8.38975H26.8347C23.0139 8.38975 20.9184 10.2699 21.1442 12.8539C21.4068 15.921 24.6158 16.005 24.9808 16.005C26.5563 16.005 28.0321 15.4799 28.7543 14.5214V15.7188H31.2936V8.37662C31.2936 7.12404 30.7001 3.78906 26.5905 3.78906ZM28.7543 11.1418C28.7231 11.8658 28.4128 12.5496 27.8885 13.05C27.3642 13.5503 26.6666 13.8282 25.9419 13.8255C24.7392 13.8255 23.9304 13.2504 23.8411 12.3366C23.7518 11.4227 24.5921 10.2831 27.5174 10.2831H28.7569L28.7543 11.1418Z\"></path><path d=\"M57.7238 4.11087C57.0499 4.09158 56.3837 4.25893 55.7989 4.59444C55.2141 4.92994 54.7334 5.42054 54.4099 6.01207C53.7954 4.64394 52.7923 4.11087 51.6868 4.11087C51.1688 4.13165 50.6626 4.2713 50.2072 4.51901C49.7519 4.76672 49.3596 5.11585 49.0608 5.5394V4.55203H46.5215V15.7255H49.0608V9.11859C49.0608 8.25464 49.0608 6.40071 50.8044 6.40071C52.5481 6.40071 52.5481 8.25465 52.5481 8.72732V15.7255H55.0139V8.72732C55.0139 8.25465 55.0139 6.40071 56.7575 6.40071C58.5011 6.40071 58.5038 8.25464 58.5038 9.11859V15.7255H61.0404V8.4017C61.0536 5.3976 59.6093 4.11087 57.7238 4.11087Z\"></path><path d=\"M73.7449 15.9987C73.4083 15.998 73.0857 15.8638 72.8479 15.6256C72.6101 15.3873 72.4766 15.0644 72.4766 14.7278V7.165C72.4914 6.83757 72.632 6.52848 72.869 6.30203C73.1059 6.07559 73.4211 5.94922 73.7488 5.94922C74.0766 5.94922 74.3918 6.07559 74.6287 6.30203C74.8657 6.52848 75.0062 6.83757 75.0211 7.165V14.7278C75.0208 14.895 74.9875 15.0606 74.9232 15.215C74.8588 15.3693 74.7647 15.5095 74.6462 15.6276C74.5277 15.7456 74.3871 15.8391 74.2325 15.9028C74.0778 15.9665 73.9122 15.9991 73.7449 15.9987Z\"></path><path d=\"M87.3607 15.9993C87.0234 15.9993 86.6998 15.8655 86.4611 15.6272C86.2223 15.389 86.0878 15.0657 86.0871 14.7284V4.67881L81.4654 9.45281C81.2304 9.69657 80.9081 9.83697 80.5695 9.84312C80.231 9.84928 79.9038 9.72069 79.6601 9.48563C79.4163 9.25058 79.2759 8.92832 79.2697 8.58976C79.2667 8.42211 79.2967 8.25551 79.358 8.09947C79.4194 7.94342 79.5108 7.80098 79.6272 7.68028L86.4469 0.661082C86.6229 0.478921 86.8494 0.35354 87.0973 0.301033C87.3451 0.248526 87.603 0.271291 87.8378 0.366403C88.0727 0.461516 88.2737 0.624637 88.4151 0.834827C88.5566 1.04502 88.632 1.29268 88.6317 1.54603V14.7284C88.6317 15.0655 88.4978 15.3887 88.2594 15.6271C88.021 15.8654 87.6978 15.9993 87.3607 15.9993Z\"></path><path d=\"M80.5514 9.82621C80.3824 9.82749 80.2149 9.79518 80.0584 9.73117C79.902 9.66716 79.7599 9.57272 79.6402 9.45332L72.8337 2.4315C72.599 2.18948 72.4701 1.86414 72.4752 1.52705C72.4804 1.18996 72.6193 0.868726 72.8613 0.634023C73.1033 0.399319 73.4287 0.270369 73.7658 0.27554C74.1028 0.280711 74.4241 0.419579 74.6588 0.661595L81.4653 7.66767C81.6389 7.84735 81.7558 8.07413 81.8015 8.31977C81.8472 8.56541 81.8196 8.81906 81.7222 9.04914C81.6248 9.27922 81.4618 9.47557 81.2537 9.61374C81.0455 9.75191 80.8013 9.8258 80.5514 9.82621Z\"></path><path d=\"M98.3029 15.9992C97.9659 15.9992 97.6426 15.8653 97.4042 15.627C97.1659 15.3886 97.032 15.0654 97.032 14.7283V7.1655C97.032 6.82842 97.1659 6.50514 97.4042 6.26679C97.6426 6.02844 97.9659 5.89453 98.3029 5.89453C98.64 5.89453 98.9633 6.02844 99.2017 6.26679C99.44 6.50514 99.5739 6.82842 99.5739 7.1655V14.7283C99.5739 15.0654 99.44 15.3886 99.2017 15.627C98.9633 15.8653 98.64 15.9992 98.3029 15.9992Z\"></path><path d=\"M111.916 16.0004C111.579 16.0004 111.256 15.8664 111.017 15.6281C110.779 15.3897 110.645 15.0665 110.645 14.7294V4.67982L106.023 9.45382C105.788 9.69584 105.467 9.83457 105.129 9.83949C104.792 9.84442 104.466 9.71513 104.224 9.48008C103.982 9.24503 103.844 8.92346 103.839 8.58613C103.834 8.24879 103.963 7.92331 104.198 7.6813L111.005 0.662093C111.181 0.483575 111.407 0.361405 111.653 0.311007C111.899 0.260609 112.155 0.284243 112.387 0.378925C112.62 0.473608 112.819 0.635093 112.96 0.842994C113.101 1.05089 113.177 1.29589 113.179 1.54704V14.7294C113.178 15.0649 113.045 15.3866 112.809 15.6246C112.572 15.8625 112.251 15.9976 111.916 16.0004Z\"></path><path d=\"M105.109 9.82618C104.94 9.82746 104.773 9.79516 104.616 9.73115C104.46 9.66713 104.318 9.57269 104.198 9.45329L97.3917 2.43147C97.2687 2.31326 97.1708 2.1715 97.1037 2.01463C97.0367 1.85777 97.0019 1.68901 97.0015 1.51842C97.001 1.34783 97.0349 1.1789 97.1012 1.02169C97.1674 0.864476 97.2646 0.722207 97.387 0.603357C97.5093 0.484507 97.6544 0.391509 97.8135 0.329905C97.9725 0.268302 98.1424 0.239353 98.3129 0.244785C98.4834 0.250217 98.6511 0.289918 98.8059 0.361522C98.9607 0.433126 99.0996 0.535168 99.2141 0.661566L106.023 7.66764C106.197 7.84732 106.314 8.0741 106.359 8.31974C106.405 8.56538 106.378 8.81903 106.28 9.04911C106.183 9.27919 106.02 9.47554 105.812 9.61371C105.603 9.75189 105.359 9.82577 105.109 9.82618Z\"></path><path d=\"M92.8305 15.9997C92.4935 15.9997 92.1702 15.8658 91.9318 15.6274C91.6935 15.3891 91.5596 15.0658 91.5596 14.7287V1.54636C91.5596 1.20928 91.6935 0.886001 91.9318 0.647648C92.1702 0.409296 92.4935 0.275391 92.8305 0.275391C93.1676 0.275391 93.4909 0.409296 93.7292 0.647648C93.9676 0.886001 94.1015 1.20928 94.1015 1.54636V14.7287C94.1015 14.8956 94.0686 15.0609 94.0048 15.2151C93.9409 15.3693 93.8473 15.5094 93.7292 15.6274C93.6112 15.7454 93.4711 15.839 93.3169 15.9029C93.1627 15.9668 92.9974 15.9997 92.8305 15.9997Z\"></path><path d=\"M123.42 15.9814C122.03 15.9865 120.663 15.6287 119.454 14.9433C118.244 14.2579 117.235 13.2687 116.525 12.0735C115.815 10.8783 115.43 9.5185 115.407 8.12863C115.384 6.73875 115.724 5.36692 116.393 4.1488C116.561 3.86356 116.833 3.65487 117.152 3.56707C117.471 3.47927 117.811 3.51928 118.101 3.67859C118.391 3.8379 118.608 4.10397 118.704 4.42026C118.801 4.73656 118.771 5.07816 118.62 5.3725C118.053 6.40664 117.836 7.59693 118.003 8.76468C118.169 9.93243 118.71 11.0147 119.544 11.8489C120.378 12.6831 121.46 13.2244 122.628 13.3914C123.795 13.5584 124.986 13.3421 126.02 12.7751C126.315 12.6125 126.663 12.5738 126.987 12.6676C127.311 12.7614 127.584 12.98 127.747 13.2753C127.909 13.5706 127.948 13.9184 127.854 14.2422C127.76 14.566 127.542 14.8393 127.246 15.0019C126.074 15.6455 124.758 15.9824 123.42 15.9814Z\"></path><path d=\"M129.287 12.5052C129.071 12.5044 128.86 12.4484 128.672 12.3424C128.378 12.1795 128.159 11.9066 128.066 11.5833C127.972 11.26 128.009 10.9126 128.171 10.6171C128.738 9.58297 128.955 8.39268 128.788 7.22493C128.622 6.05718 128.081 4.97496 127.247 4.14073C126.413 3.30649 125.331 2.76525 124.163 2.59825C122.996 2.43125 121.805 2.64749 120.771 3.21452C120.624 3.30059 120.462 3.3564 120.293 3.37863C120.125 3.40087 119.954 3.38908 119.79 3.34397C119.626 3.29886 119.473 3.22134 119.339 3.116C119.206 3.01066 119.095 2.87964 119.013 2.73069C118.931 2.58174 118.88 2.41788 118.863 2.24882C118.845 2.07975 118.862 1.90891 118.912 1.7464C118.962 1.58389 119.044 1.43301 119.153 1.3027C119.262 1.17238 119.396 1.06527 119.547 0.987704C121.064 0.155491 122.809 -0.162266 124.522 0.0821474C126.234 0.326561 127.822 1.11995 129.045 2.34319C130.268 3.56642 131.061 5.15347 131.306 6.86604C131.55 8.5786 131.232 10.3242 130.4 11.8408C130.291 12.0411 130.13 12.2084 129.934 12.3253C129.739 12.4422 129.515 12.5043 129.287 12.5052Z\"></path></g></svg>`,\n  yi: `<svg fill=\"currentColor\" fill-rule=\"evenodd\" height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>Yi</title><path d=\"M18.62 13.927c.611 0 1.107.505 1.107 1.128v5.817c0 .623-.496 1.128-1.108 1.128a1.118 1.118 0 01-1.108-1.128v-5.817c0-.623.496-1.128 1.108-1.128zM16.59 3.052a1.094 1.094 0 011.562-.129c.466.404.522 1.116.126 1.59l-5.938 7.111v9.147c0 .624-.496 1.129-1.108 1.129a1.118 1.118 0 01-1.108-1.129v-9.477l.003-.088.01-.087c.015-.232.102-.462.261-.654l6.192-7.413zM2.906 2.256a1.094 1.094 0 011.559.157l4.387 5.45a1.142 1.142 0 01-.155 1.587 1.094 1.094 0 01-1.559-.157l-4.387-5.45a1.144 1.144 0 01.06-1.498l.095-.09z\"></path><ellipse cx=\"20.146\" cy=\"10.692\" fill=\"#00FF25\" rx=\"1.354\" ry=\"1.379\"></ellipse></svg>`,\n  zeroone: `<svg fill=\"currentColor\" fill-rule=\"evenodd\" height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>01.AI</title><path d=\"M5.246 12c0 .837-.086 1.554-.257 2.151-.172.598-.45 1.055-.837 1.373-.386.317-.898.476-1.534.476-.901 0-1.563-.353-1.985-1.059C.211 14.235 0 13.255 0 12c0-.837.086-1.554.257-2.151.172-.598.45-1.055.832-1.373C1.472 8.16 1.981 8 2.618 8c.894 0 1.555.351 1.985 1.053.429.702.643 1.685.643 2.947zm-3.883 0c0 .956.09 1.668.273 2.134.183.467.51.7.982.7.465 0 .792-.23.981-.694.19-.463.285-1.176.285-2.14 0-.956-.095-1.668-.285-2.134-.19-.467-.516-.7-.981-.7-.472 0-.8.233-.982.7-.182.466-.273 1.178-.273 2.134zm8.52 3.771H8.517l.011-6.295-1.823.324V8.571l2.04-.457h1.136v7.657zm2.497-1.6h.543c.3 0 .543.256.543.572v.571a.558.558 0 01-.543.572h-.543a.558.558 0 01-.543-.572v-.571c0-.316.243-.572.543-.572zm10.317-6.057H24v7.772h-1.303V8.114zm-3.692 0l2.606 7.772h-1.303l-.69-2.058h-3.073l-.69 2.058h-1.303l2.606-7.772h1.847zm.191 4.457l-1.115-3.323-1.114 3.323h2.23z\"></path></svg>`,\n  zhipu: `<svg height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>Zhipu</title><path d=\"M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z\" fill=\"#3859FF\" fill-rule=\"nonzero\"></path></svg>`,\n  openrouter: `<svg fill=\"currentColor\" fill-rule=\"evenodd\" height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>OpenRouter</title><path d=\"M16.804 1.957l7.22 4.105v.087L16.73 10.21l.017-2.117-.821-.03c-1.059-.028-1.611.002-2.268.11-1.064.175-2.038.577-3.147 1.352L8.345 11.03c-.284.195-.495.336-.68.455l-.515.322-.397.234.385.23.53.338c.476.314 1.17.796 2.701 1.866 1.11.775 2.083 1.177 3.147 1.352l.3.045c.694.091 1.375.094 2.825.033l.022-2.159 7.22 4.105v.087L16.589 22l.014-1.862-.635.022c-1.386.042-2.137.002-3.138-.162-1.694-.28-3.26-.926-4.881-2.059l-2.158-1.5a21.997 21.997 0 00-.755-.498l-.467-.28a55.927 55.927 0 00-.76-.43C2.908 14.73.563 14.116 0 14.116V9.888l.14.004c.564-.007 2.91-.622 3.809-1.124l1.016-.58.438-.274c.428-.28 1.072-.726 2.686-1.853 1.621-1.133 3.186-1.78 4.881-2.059 1.152-.19 1.974-.213 3.814-.138l.02-1.907z\"></path></svg>`,\n  rc: `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 128 128\"><title>RightCode</title><path fill=\"#EA6C2C\" fill-rule=\"evenodd\" d=\"M 67.00 124.35 L 65.00 124.65 L 63.00 124.31 L 60.87 122.00 L 60.51 120.00 L 60.62 119.00 L 61.63 117.00 L 63.64 115.00 L 63.65 23.00 L 63.40 22.00 L 60.71 20.00 L 59.73 18.00 L 59.66 16.00 L 61.18 13.00 L 63.00 11.70 L 65.00 11.36 L 67.00 11.68 L 69.00 13.07 L 70.24 15.00 L 70.43 17.00 L 69.35 20.00 L 66.75 22.00 L 66.53 23.00 L 66.56 115.00 L 69.38 118.00 L 69.69 120.00 L 69.38 122.00 L 67.00 124.35 Z M 65.38 19.00 L 66.99 18.00 L 67.30 16.00 L 66.00 14.66 L 64.00 14.69 L 62.79 16.00 L 62.66 17.00 L 64.00 18.73 L 65.38 19.00 Z M 72.00 53.03 L 71.70 52.00 L 71.56 35.00 L 70.00 33.71 L 68.79 32.00 L 68.52 30.00 L 69.00 28.15 L 70.00 26.71 L 72.00 25.59 L 74.00 25.45 L 75.00 25.70 L 76.75 27.00 L 77.50 28.00 L 78.15 30.00 L 77.18 33.00 L 74.81 35.00 L 74.60 50.00 L 72.00 53.03 Z M 74.12 32.00 L 75.11 31.00 L 75.31 30.00 L 74.77 29.00 L 74.00 28.40 L 72.00 28.65 L 71.35 30.00 L 71.45 31.00 L 73.00 32.26 L 74.12 32.00 Z M 58.00 52.97 L 55.68 50.00 L 55.65 40.00 L 52.89 37.00 L 52.36 35.00 L 52.62 33.00 L 54.00 31.03 L 57.00 29.77 L 60.00 30.84 L 61.03 32.00 L 61.64 34.00 L 61.21 37.00 L 58.49 40.00 L 58.47 52.00 L 58.00 52.97 Z M 58.43 36.00 L 59.06 35.00 L 58.91 34.00 L 58.00 32.96 L 57.00 32.66 L 56.00 33.10 L 55.29 34.00 L 55.19 35.00 L 55.64 36.00 L 57.00 36.59 L 58.43 36.00 Z M 57.00 89.45 L 46.00 89.35 L 37.24 77.00 L 36.00 75.99 L 35.00 75.93 L 28.00 75.93 L 27.12 77.00 L 27.14 88.00 L 26.80 89.00 L 26.00 89.36 L 18.00 89.36 L 16.89 89.00 L 16.66 48.00 L 17.00 46.90 L 44.00 46.73 L 48.00 47.67 L 50.00 48.57 L 53.23 51.00 L 55.44 54.00 L 56.34 56.00 L 57.07 59.00 L 56.95 64.00 L 56.15 67.00 L 54.34 70.00 L 52.00 72.26 L 48.83 74.00 L 48.06 75.00 L 57.06 88.00 L 57.53 89.00 L 57.00 89.45 Z M 110.00 89.13 L 109.00 89.65 L 90.00 89.61 L 85.00 89.18 L 80.00 87.40 L 77.00 85.39 L 74.64 83.00 L 72.64 80.00 L 70.76 75.00 L 70.20 70.00 L 70.36 65.00 L 70.81 62.00 L 72.61 57.00 L 74.49 54.00 L 77.36 51.00 L 81.00 48.62 L 84.00 47.54 L 88.00 46.77 L 109.00 46.64 L 109.79 47.00 L 110.13 48.00 L 110.00 55.15 L 109.00 55.68 L 91.00 55.74 L 87.00 56.77 L 84.11 59.00 L 82.59 61.00 L 81.36 64.00 L 80.64 69.00 L 81.62 74.00 L 82.58 76.00 L 85.00 78.66 L 88.00 80.22 L 92.00 80.64 L 109.00 80.64 L 109.80 81.00 L 110.13 82.00 L 110.00 89.13 Z M 43.29 67.00 L 44.89 66.00 L 46.00 64.70 L 46.86 62.00 L 46.50 59.00 L 45.00 56.77 L 43.00 55.56 L 40.00 55.18 L 28.00 55.17 L 27.12 56.00 L 27.16 67.00 L 28.00 67.65 L 40.00 67.63 L 43.29 67.00 Z M 74.00 105.20 L 72.00 105.09 L 70.13 104.00 L 68.71 102.00 L 68.54 101.00 L 68.69 99.00 L 70.00 97.06 L 71.42 96.00 L 71.71 95.00 L 71.70 86.00 L 72.00 84.36 L 74.56 87.00 L 74.64 95.00 L 75.00 95.97 L 77.39 98.00 L 78.12 100.00 L 77.32 103.00 L 76.00 104.37 L 74.00 105.20 Z M 59.00 111.05 L 57.00 111.40 L 55.00 111.01 L 52.89 109.00 L 52.36 107.00 L 53.00 104.45 L 55.51 102.00 L 55.65 93.00 L 55.88 92.00 L 57.00 91.64 L 58.28 92.00 L 58.49 93.00 L 58.63 102.00 L 61.00 104.14 L 61.65 106.00 L 61.26 109.00 L 59.00 111.05 Z M 74.39 102.00 L 75.23 101.00 L 75.00 99.65 L 74.00 98.65 L 73.00 98.54 L 72.06 99.00 L 71.39 100.00 L 71.39 101.00 L 72.00 101.96 L 73.00 102.38 L 74.39 102.00 Z M 58.51 108.00 L 59.09 107.00 L 58.83 106.00 L 58.00 105.18 L 57.00 104.85 L 55.33 106.00 L 55.18 107.00 L 56.00 108.39 L 57.00 108.65 L 58.51 108.00 Z M 65.09 122.00 L 66.75 121.00 L 67.14 120.00 L 66.00 118.36 L 65.00 118.16 L 63.54 119.00 L 63.28 120.00 L 63.47 121.00 L 65.09 122.00 Z\"/></svg>`,\n  longcat: `<svg fill=\"currentColor\" height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>LongCat</title><path clip-rule=\"evenodd\" d=\"M.507 19.883a.507.507 0 01-.489-.642L4.29 3.745a1.013 1.013 0 011.533-.578l5.622 3.687a1.013 1.013 0 001.11 0L18.2 3.165a1.013 1.013 0 011.532.58l4.25 15.497a.506.506 0 01-.49.64H18.07a6.297 6.297 0 001.53-4.115v-.177a6.09 6.09 0 00-1.513-4.017l-.697-3.495a.438.438 0 00-.694-.266L14.07 9.781a.748.748 0 01-.654.121 5.156 5.156 0 00-2.833 0 .746.746 0 01-.653-.121L7.302 7.81a.435.435 0 00-.688.269l-.675 3.652a5.36 5.36 0 00-1.539 3.76v.333c0 1.474.527 2.9 1.488 4.02l.032.038H.507z\" fill=\"#29E154\" fill-rule=\"evenodd\"></path><path d=\"M9.213 16.843h1.52v-3.546h-1.29l-.23 3.546zm5.573 0h-1.52v-3.546h1.29l.23 3.546z\"></path></svg>`,\n  modelscope: `<svg height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>ModelScope</title><path d=\"M0 7.967h2.667v2.667H0zM8 10.633h2.667V13.3H8z\" fill=\"#36CED0\"></path><path d=\"M0 10.633h2.667V13.3H0zM2.667 13.3h2.666v2.667H8v2.666H2.667V13.3zM2.667 5.3H8v2.667H5.333v2.666H2.667V5.3zM10.667 13.3h2.667v2.667h-2.667z\" fill=\"#624AFF\"></path><path d=\"M24 7.967h-2.667v2.667H24zM16 10.633h-2.667V13.3H16z\" fill=\"#36CED0\"></path><path d=\"M24 10.633h-2.667V13.3H24zM21.333 13.3h-2.666v2.667H16v2.666h5.333V13.3zM21.333 5.3H16v2.667h2.667v2.666h2.666V5.3z\" fill=\"#624AFF\"></path></svg>`,\n  aihubmix: `<svg height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>AiHubMix</title><path d=\"M12 24c6.627 0 12-5.373 12-12S18.627 0 12 0 0 5.373 0 12s5.373 12 12 12z\" fill=\"#006FFB\"></path><path clip-rule=\"evenodd\" d=\"M11.24 8.393c.095-.644.302-1.47.624-2.48L12 5.496l.136.417c.322 1.01.53 1.836.624 2.48.071.472.071 1.072 0 1.8-.072.731-.072 1.336 0 1.814.106.7.426 1.281.96 1.744a2.795 2.795 0 001.89.708 2.78 2.78 0 002.034-.84c.56-.559.842-1.234.848-2.024.003-.7.075-1.472.216-2.316.069-.422.14-.775.21-1.06l.095-.384.168.356a7.862 7.862 0 01.76 3.244v.16a7.84 7.84 0 01-.624 3.089 7.952 7.952 0 01-4.228 4.228 7.841 7.841 0 01-3.089.623 7.84 7.84 0 01-3.089-.623 7.952 7.952 0 01-4.228-4.228 7.84 7.84 0 01-.623-3.09v-.159a7.862 7.862 0 01.759-3.244l.169-.356.093.385c.072.284.143.637.211 1.059.141.844.213 1.616.216 2.316.006.79.29 1.465.848 2.024.563.56 1.241.84 2.035.84.715 0 1.345-.236 1.889-.708a2.79 2.79 0 00.96-1.744c.073-.478.073-1.083 0-1.814-.071-.728-.071-1.328 0-1.8zm.76 9.694c1.097 0 2.125-.26 3.085-.778a6.379 6.379 0 001.77-1.399c.063-.07-.01-.178-.101-.153-.37.1-.75.15-1.144.15a4.236 4.236 0 01-2.18-.59 4.253 4.253 0 01-1.35-1.233.099.099 0 00-.16 0 4.253 4.253 0 01-1.35 1.232 4.236 4.236 0 01-2.18.591c-.393 0-.774-.05-1.143-.15-.091-.025-.165.083-.102.153a6.38 6.38 0 001.77 1.399c.96.518 1.988.778 3.085.778z\" fill=\"#fff\" fill-rule=\"evenodd\"></path></svg>`,\n  opencode: `<svg height=\"1em\" width=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 240 300\" xmlns=\"http://www.w3.org/2000/svg\"><title>OpenCode</title><g clip-path=\"url(#clip0_1401_86274)\"><mask id=\"mask0_1401_86274\" style=\"mask-type:luminance\" maskUnits=\"userSpaceOnUse\" x=\"0\" y=\"0\" width=\"240\" height=\"300\"><path d=\"M240 0H0V300H240V0Z\" fill=\"white\"/></mask><g mask=\"url(#mask0_1401_86274)\"><path d=\"M180 240H60V120H180V240Z\" fill=\"#CFCECD\"/><path d=\"M180 60H60V240H180V60ZM240 300H0V0H240V300Z\" fill=\"#211E1E\"/></g></g><defs><clipPath id=\"clip0_1401_86274\"><rect width=\"240\" height=\"300\" fill=\"white\"/></clipPath></defs></svg>`,\n  siliconflow: `<svg height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>SiliconCloud</title><path clip-rule=\"evenodd\" d=\"M22.956 6.521H12.522c-.577 0-1.044.468-1.044 1.044v3.13c0 .577-.466 1.044-1.043 1.044H1.044c-.577 0-1.044.467-1.044 1.044v4.174C0 17.533.467 18 1.044 18h10.434c.577 0 1.044-.467 1.044-1.043v-3.13c0-.578.466-1.044 1.043-1.044h9.391c.577 0 1.044-.467 1.044-1.044V7.565c0-.576-.467-1.044-1.044-1.044z\" fill=\"#6E29F6\" fill-rule=\"evenodd\"></path></svg>`,\n  \"x-code\": `<svg xmlns=\"http://www.w3.org/2000/svg\" height=\"1em\" width=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 32 32\"><title>X-Code</title><path d=\"M0 0h32v32H0Z\" fill=\"#E9F5FC\"/><path d=\"M16.5 7.625c.77-.008 1.55-.015 2.34-.023 2.64.487 3.55 1.285 5.16 3.398v1.32h-8v-1c-.99.495-.99.495-2 .995-.47 2.356-.47 2.356-.63 5.063l-.22 2.785c-.05.71-.1 1.42-.15 2.152h2l.99-3h8c-1.88 3.875-1.88 3.875-3 5-1.96.203-1.96.203-4.38.25-.79.026-1.58.052-2.4.078-2.22-.328-2.22-.328-3.98-1.531-1.63-2.37-1.63-3.94-1.61-6.797.008-.866.015-1.733.024-2.625.38-2.375.38-2.375 1.62-4.172 2.22-1.53 3.58-1.612 5.94-1.586Z\" fill=\"#243FC5\"/><path d=\"M17 1c1.44-.081 2.87-.14 4.31-.188 1.2-.052 1.2-.052 2.43-.105C26 1 26 1 27.79 2.387 29 4 29 4 29 6h-8V4c-1.32.33-2.64.66-4 1V1Z\" fill=\"#219DF4\"/><path d=\"M14 1c-1.96 2.29-3.92 4.58-7 8-.37 2.328-.7 4.662-1 7H2C1.1 8.539 1.1 8.539 3.13 5.066 6.49 1.414 8.9.272 14 1Z\" fill=\"#1448D6\"/><path d=\"M27 20h4c.47 2.647.4 4.37-1.05 6.668-2.18 2.478-3.82 4.133-7.18 4.71H17v-4l3.31-.688C24.57 24.797 24.91 23.778 27 20Z\" fill=\"#0E49D9\"/><path d=\"M2 19c4.53 3.678 8.39 7.41 12 12H8.01c-.42-.447-.84-.895-1.28-1.355C2.46 25.016 2.46 25.016 1 19Z\" fill=\"#0B89F0\"/><path d=\"M11 9v5l-2-1c-.36-2.313-.36-2.313-.31-5 .004-.887.008-1.773.01-2.688C9 3 9 3 11 1v8Z\" fill=\"#1939C2\"/><path d=\"M2 7h4v9H2V7Z\" fill=\"#059CF3\"/><path d=\"M17 27h6l2 4h-8v-4Z\" fill=\"#0773EE\"/><path d=\"M17 1h8l-2 4h-6V1Z\" fill=\"#0CB7F7\"/><path d=\"M16 19h8l-2 4h-7l1-4Z\" fill=\"#2F4AC8\"/><path d=\"M7 4l3 1-4 5-4-1 5-5Z\" fill=\"#053FD7\"/></svg>`,\n  micu: `<svg xmlns=\"http://www.w3.org/2000/svg\" height=\"1em\" width=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 241.39 240.6\"><title>Micu</title><defs><style>.mc-1{fill:#068cde}.mc-2{fill:#fff}.mc-3{fill:#02a6ff}</style></defs><g><path class=\"mc-1\" d=\"M226.14,157.63c-3.62,0-7.24,0-10.95,0v-24.96c5.2,0,10.17,0,15.13,0,5.55-.01,8.52-4.01,10.18-8.16,1.34-3.37,1.36-7.51-1.34-11.1-3.16-4.2-7.23-5.72-12.25-5.63-3.87.07-7.74.01-11.66.01v-24.79c6.56-.43,12.93.45,19.3-1.13.4-.42.85-1.05,1.44-1.49,4.61-3.47,6.48-9.22,4.67-14.51-1.63-4.76-6.67-8.03-12.22-7.99-4.37.03-8.74,0-13.42,0,0-5.79.1-11.16-.04-16.54-.08-3.23-1.09-6.23-3.22-8.79-7.6-9.17-17.84-6.02-28.13-6.29-.39-.23-.63-1.14-.45-2.35.73-4.72.37-9.44-.24-14.13-.51-3.95-5.79-9.24-9.1-9.56-10.42-1-15.88,5.21-15.83,14.89.02,3.54,0,7.08,0,10.92h-24.44c-.76-1.03-.45-2.13-.42-3.19.15-5.2.71-10.35-1.11-15.5-2.15-6.08-11.68-9.27-17.05-5.79-5.27,3.41-7.22,7.95-6.99,13.99.14,3.56.53,7.22-.44,10.6h-23.98c-.22-.38-.37-.51-.37-.66-.05-4.56,0-9.12-.14-13.68-.15-5.18-4.72-10.76-9.31-11.57-8.81-1.55-15.64,4.23-15.64,13.24,0,4.11,0,8.21,0,12.61-4.92,0-9.32.08-13.72-.02-10.41-.22-18.81,7.79-17.84,18.57.39,4.32.06,8.7.06,13.34-5.17,0-9.9-.05-14.63.01-5.36.07-11.08,5.57-11.83,10.1-1.39,8.44,6.23,15.25,14.55,14.78,3.86-.22,7.74-.04,11.75-.04v24.92c-4.45,0-8.67-.07-12.89.02-3.2.07-6.5.62-8.75,2.93-3.6,3.69-6.1,8.11-4.12,13.5,2.13,5.8,6.42,8.43,12.98,8.43,4.21,0,8.42,0,12.83,0v24.95c-3.48,0-6.79-.12-10.08.03-3.74.18-7.43.36-10.78,2.69-4.11,2.87-6.48,8.21-5.32,12.83,1.1,4.36,7.17,9.83,11.59,9.41,4.82-.46,9.72-.1,14.69-.1,0,5.18.51,9.88-.1,14.44-1.36,10,8.64,18.06,17.22,17.5,4.62-.3,9.28-.05,14.15-.05,1.26,9.11-3.28,19.95,9.5,25.9,10.27,1.43,15.74-3.33,15.75-15.14,0-3.45,0-6.9,0-10.55h24.94c0,3.39,0,6.6,0,9.8,0,3.81.26,7.41,2.73,10.76,3.09,4.2,8.64,6.68,14,4.87,3.62-1.22,8.31-5.43,8.3-10.7,0-4.85,0-9.7,0-14.7h24.92c0,3.98-.14,7.7.03,11.41.22,4.83,1.4,9.35,5.68,12.32,5.16,3.59,12.81,2.96,17.28-2.41,3.93-7.43,2.05-14.57,2.39-21.58,5.71,0,11.1.03,16.49-.02,1.98-.02,4.05-.06,5.8-1.09,6.78-3.99,9.8-9.94,9.36-17.81-.23-4.18-.04-8.39-.04-12.56,8.59-1.68,18.23,2.96,24.73-6.3.08-.31.31-1.1.5-1.9.97-4.19,1.82-8.06-1.59-12.01-3.49-4.05-7.66-5.05-12.52-5.04ZM168.3,160.54c0,3.41-1.48,4.58-4.69,4.49-4.41-.12-8.82-.09-13.23-.05-3.15.03-4.43-1.39-4.41-4.56.06-16.85.02-33.69-.03-50.54,0-.99.44-2.13-.73-3.15-4.49,13.97-8.94,27.8-13.44,41.79h-21.8c-4.39-13.78-8.8-27.62-13.55-42.54-.15,1.94-.29,2.89-.29,3.84-.01,16.68-.08,33.36.04,50.04.03,3.7-1.18,5.38-5.05,5.16-5.51-.31-11.08.38-16.22-.44-1.24-1.59-1.21-2.95-1.21-4.26.07-27.3.18-54.6.22-81.9,0-2.16.89-3.46,2.97-3.48,9.9-.06,19.8,0,29.7.05.31,0,.63.19,1.39.44,4.22,14.29,8.51,28.79,12.8,43.3.29.05.58.1.87.15,4.53-14.59,9.07-29.17,13.66-43.95,10.35,0,20.48-.03,30.62.03,1.76.01,2.38,1.37,2.42,2.92.08,2.57.1,5.14.1,7.71-.06,24.98-.16,49.95-.15,74.93Z\"/><rect class=\"mc-3\" x=\"48.86\" y=\"48.46\" width=\"143.67\" height=\"143.67\" rx=\"10.57\" ry=\"10.57\"/><path class=\"mc-2\" d=\"M165.55,75.28c-10.14-.06-20.27-.03-30.62-.03-4.59,14.78-9.12,29.36-13.66,43.95-.29-.05-.58-.1-.87-.15-4.29-14.51-8.58-29.01-12.8-43.3-.77-.25-1.08-.44-1.39-.44-9.9-.04-19.8-.1-29.7-.05-2.08.01-2.96,1.32-2.97,3.48-.04,27.3-.15,54.6-.22,81.9,0,1.31-.03,2.67,1.21,4.26,5.13.82,10.7.13,16.22.44,3.87.22,5.07-1.46,5.05-5.16-.12-16.68-.06-33.36-.04-50.04,0-.95.14-1.9.29-3.84,4.75,14.91,9.16,28.76,13.55,42.54h21.8c4.5-13.99,8.94-27.82,13.44-41.79,1.18,1.01.73,2.16.73,3.15.04,16.85.09,33.69.03,50.54-.01,3.17,1.26,4.59,4.41,4.56,4.41-.04,8.82-.07,13.23.05,3.21.09,4.69-1.08,4.69-4.49-.01-24.98.09-49.95.15-74.93,0-2.57-.02-5.14-.1-7.71-.05-1.55-.67-2.91-2.42-2.92Z\"/></g></svg>`,\n  ucloud: `<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" height=\"1em\" width=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 172.11 172.11\"><title>UCloud</title><defs><style>.cls-1{fill:url(#uc-g56)}.cls-2{fill:url(#uc-g37)}.cls-3{fill:url(#uc-g37-2)}.cls-4{fill:url(#uc-g37-3)}.cls-5{fill:url(#uc-g37-4)}.cls-6{fill:url(#uc-g37-5)}.cls-7{fill:url(#uc-g37-6)}.cls-8{fill:url(#uc-g37-7)}.cls-9{fill:#fff}.cls-10{fill:url(#uc-g38)}</style><linearGradient id=\"uc-g56\" x1=\"86.06\" y1=\"-6.73\" x2=\"86.06\" y2=\"185.53\" gradientUnits=\"userSpaceOnUse\"><stop offset=\"0\" stop-color=\"#32303a\"/><stop offset=\".36\" stop-color=\"#34323d\"/><stop offset=\".62\" stop-color=\"#3a3946\"/><stop offset=\".85\" stop-color=\"#444556\"/><stop offset=\"1\" stop-color=\"#4e5065\"/></linearGradient><linearGradient id=\"uc-g37\" x1=\"143.96\" y1=\"73.06\" x2=\"71.52\" y2=\"34.1\" gradientUnits=\"userSpaceOnUse\"><stop offset=\"0\" stop-color=\"#4043ff\"/><stop offset=\"1\" stop-color=\"#f0f5fa\"/></linearGradient><linearGradient id=\"uc-g37-2\" x1=\"104.88\" y1=\"118.84\" x2=\"71.68\" y2=\"66.44\" xlink:href=\"#uc-g37\"/><linearGradient id=\"uc-g37-3\" x1=\"95.68\" y1=\"72.87\" x2=\"33.68\" y2=\"76.43\" xlink:href=\"#uc-g37\"/><linearGradient id=\"uc-g37-4\" x1=\"70\" y1=\"130.18\" x2=\"46.68\" y2=\"73.38\" xlink:href=\"#uc-g37\"/><linearGradient id=\"uc-g37-5\" x1=\"107.49\" y1=\"106.07\" x2=\"147.27\" y2=\"159.57\" xlink:href=\"#uc-g37\"/><linearGradient id=\"uc-g37-6\" x1=\"106.69\" y1=\"50.6\" x2=\"142.65\" y2=\"131.51\" xlink:href=\"#uc-g37\"/><linearGradient id=\"uc-g37-7\" x1=\"111.35\" y1=\"152.42\" x2=\"82.55\" y2=\"89.87\" xlink:href=\"#uc-g37\"/><linearGradient id=\"uc-g38\" x1=\"64.73\" y1=\"89.4\" x2=\"83.39\" y2=\"89.4\" gradientUnits=\"userSpaceOnUse\"><stop offset=\"0\" stop-color=\"#5b5dfe\"/><stop offset=\".18\" stop-color=\"#5b5dfe\" stop-opacity=\".99\"/><stop offset=\".31\" stop-color=\"#5b5dfe\" stop-opacity=\".95\"/><stop offset=\".43\" stop-color=\"#5b5cfe\" stop-opacity=\".89\"/><stop offset=\".54\" stop-color=\"#5b5cfe\" stop-opacity=\".8\"/><stop offset=\".64\" stop-color=\"#5b5bfe\" stop-opacity=\".68\"/><stop offset=\".74\" stop-color=\"#5b5afe\" stop-opacity=\".54\"/><stop offset=\".83\" stop-color=\"#5a59ff\" stop-opacity=\".38\"/><stop offset=\".92\" stop-color=\"#5a58ff\" stop-opacity=\".19\"/><stop offset=\"1\" stop-color=\"#5a57ff\" stop-opacity=\"0\"/></linearGradient></defs><rect class=\"cls-1\" width=\"172.11\" height=\"172.11\" rx=\"46.24\"/><polygon class=\"cls-2\" points=\"124.1 51.65 104.14 63.16 104.08 63.12 84.2 51.65 104.08 40.17 104.14 40.14 124.1 51.65\"/><path class=\"cls-3\" d=\"M111.61,90.51l-.1-.06-2.92-1.69a8.9,8.9,0,0,1-4.46-7.69l0-17.95L84.2,51.65l-.12.07V69.59a8.91,8.91,0,0,1-4.45,7.71L64.26,86.17v23l12.4-7.15,3.09-1.78a8.92,8.92,0,0,1,8.91,0l15.42,8.91.06,0,19.93-11.49h0Z\"/><path class=\"cls-4\" d=\"M84.08,66v3.55a8.91,8.91,0,0,1-4.45,7.71L64.26,86.17,44.32,74.68v0L64.26,63.17l12.41,7.15A4.94,4.94,0,0,0,84.08,66Z\"/><polygon class=\"cls-5\" points=\"64.26 86.17 64.26 109.2 44.32 97.7 44.32 74.68 64.26 86.17\"/><polygon class=\"cls-6\" points=\"124.1 97.7 124.1 120.72 124.08 120.72 104.14 132.23 104.08 132.21 104.08 109.25 104.14 109.2 124.08 97.7 124.1 97.7\"/><path class=\"cls-7\" d=\"M124.1,51.65v23h0l-12.48,7.21c-3.28,1.89-3,6.87-3,6.87a8.9,8.9,0,0,1-4.46-7.69l0-17.89.06,0Z\"/><path class=\"cls-8\" d=\"M104.08,109.18v23L84.2,120.72l-.12-.07V106.33a4.94,4.94,0,0,0-7.41-4.28l3-1.72a8.89,8.89,0,0,1,8.87,0Z\"/><path class=\"cls-9\" d=\"M85.28,81.09V91.24a2.56,2.56,0,0,0,3.85,2.22l8.81-5.09a2.56,2.56,0,0,0,0-4.44l-8.82-5.06A2.56,2.56,0,0,0,85.28,81.09Z\"/><path class=\"cls-10\" d=\"M84.08,69.59a8.91,8.91,0,0,1-4.45,7.71L64.26,86.17v23l12.4-7.15,3.09-1.78a8.82,8.82,0,0,1,4.33-1.19Z\"/></svg>`,\n  sssaicode: `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 512 512\"><title>SSAI Code</title><defs><linearGradient id=\"ssc-gradLeft\" x1=\"0%\" y1=\"0%\" x2=\"100%\" y2=\"100%\"><stop offset=\"0%\" stop-color=\"#0ff5ce\" /><stop offset=\"100%\" stop-color=\"#147a8a\" /></linearGradient><linearGradient id=\"ssc-gradRight\" x1=\"100%\" y1=\"0%\" x2=\"0%\" y2=\"100%\"><stop offset=\"0%\" stop-color=\"#d0e4f5\" /><stop offset=\"100%\" stop-color=\"#6a9ec4\" /></linearGradient><linearGradient id=\"ssc-gradTop\" x1=\"50%\" y1=\"0%\" x2=\"50%\" y2=\"100%\"><stop offset=\"0%\" stop-color=\"#a0d8e8\" /><stop offset=\"100%\" stop-color=\"#4aafbf\" /></linearGradient><linearGradient id=\"ssc-gradText\" x1=\"0%\" y1=\"0%\" x2=\"100%\" y2=\"0%\"><stop offset=\"0%\" stop-color=\"#0ff5ce\" /><stop offset=\"35%\" stop-color=\"#4abfcf\" /><stop offset=\"65%\" stop-color=\"#7badd4\" /><stop offset=\"100%\" stop-color=\"#c0daf0\" /></linearGradient><linearGradient id=\"ssc-gradS\" x1=\"0%\" y1=\"0%\" x2=\"0%\" y2=\"100%\"><stop offset=\"0%\" stop-color=\"#f0f8ff\" /><stop offset=\"100%\" stop-color=\"#6cbfcf\" /></linearGradient><filter id=\"ssc-glow\" x=\"-30%\" y=\"-30%\" width=\"160%\" height=\"160%\"><feGaussianBlur stdDeviation=\"4\" result=\"blur\" /><feMerge><feMergeNode in=\"blur\" /><feMergeNode in=\"SourceGraphic\" /></feMerge></filter><pattern id=\"ssc-binL\" x=\"0\" y=\"0\" width=\"55\" height=\"16\" patternUnits=\"userSpaceOnUse\" patternTransform=\"rotate(-3)\"><text x=\"0\" y=\"11\" font-family=\"monospace\" font-size=\"8\" fill=\"rgba(0,255,210,0.25)\">1001 1101</text></pattern><pattern id=\"ssc-binR\" x=\"0\" y=\"0\" width=\"55\" height=\"16\" patternUnits=\"userSpaceOnUse\" patternTransform=\"rotate(3)\"><text x=\"0\" y=\"11\" font-family=\"monospace\" font-size=\"8\" fill=\"rgba(180,210,240,0.25)\">0110 1011</text></pattern><pattern id=\"ssc-binT\" x=\"0\" y=\"0\" width=\"50\" height=\"16\" patternUnits=\"userSpaceOnUse\"><text x=\"2\" y=\"11\" font-family=\"monospace\" font-size=\"8\" fill=\"rgba(120,200,220,0.2)\">10 110</text></pattern></defs><rect width=\"512\" height=\"512\" rx=\"72\" fill=\"#08080e\" /><polygon points=\"90,350 250,350 170,228\" fill=\"url(#ssc-gradLeft)\" opacity=\"0.8\" /><polygon points=\"90,350 250,350 170,228\" fill=\"url(#ssc-binL)\" /><polygon points=\"262,350 422,350 342,228\" fill=\"url(#ssc-gradRight)\" opacity=\"0.8\" /><polygon points=\"262,350 422,350 342,228\" fill=\"url(#ssc-binR)\" /><polygon points=\"176,290 336,290 256,168\" fill=\"none\" stroke=\"url(#ssc-gradTop)\" stroke-width=\"2.5\" opacity=\"0.85\" /><polygon points=\"192,280 320,280 256,184\" fill=\"none\" stroke=\"url(#ssc-gradTop)\" stroke-width=\"0.8\" opacity=\"0.35\" /><text x=\"256\" y=\"316\" text-anchor=\"middle\" font-family=\"Georgia, 'Times New Roman', serif\" font-size=\"120\" font-weight=\"bold\" fill=\"url(#ssc-gradS)\" filter=\"url(#ssc-glow)\">S</text><text x=\"256\" y=\"425\" text-anchor=\"middle\" font-family=\"'Helvetica Neue', 'Segoe UI', Arial, sans-serif\" font-size=\"40\" font-weight=\"300\" letter-spacing=\"5\" fill=\"url(#ssc-gradText)\">SSSAiCode</text></svg>`,\n  stepfun: `<svg width=\"1em\" height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><title>StepFun</title><g clip-path=\"url(#clip0_10683_3111)\"><path d=\"M23.2964 14.7395H17.4448V23.2981H12.9565V13.2307H23.2964V14.7395ZM20.355 8.24341H10.4683V20.1165H0.63916V15.76H5.94385L5.94678 3.90942H20.355V8.24341ZM4.02002 12.5881H2.48779V2.51685H4.02002V12.5881ZM22.4272 1.60962H23.3394V2.51587H22.4272V4.32544H21.519V2.51587H19.6997V1.60962H21.519V0.702393H22.4272V1.60962Z\" fill=\"#005AFF\"/></g><defs><clipPath id=\"clip0_10683_3111\"><rect width=\"24\" height=\"24\" fill=\"white\"/></clipPath></defs></svg>`,\n  catcoder: `<svg fill=\"currentColor\" fill-rule=\"evenodd\" height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>KwaiKAT</title><path d=\"M20.42 19.311h3.418V1l-6.781 4.177-6.778-4.111.026 7.868h3.418l-.026-2.222 3.42 2.035 3.303-2.035v12.6z\"></path><path d=\"M3.064 10.734c2.784-2.07 6.942-2.394 9.941.907l.01.01.01.013 9.16 12.24h-3.84l-7.69-10.217c-1.63-1.737-3.891-1.689-5.515-.638-1.624 1.05-2.563 3.073-1.548 5.28 1.494 3.246 6.152 3.275 7.725.108l.032-.064 2.02 2.629c-2.98 3.968-9.329 3.926-12.165-.552-2.395-3.78-.926-7.645 1.86-9.716z\"></path></svg>`,\n  mcp: `<svg fill=\"currentColor\" fill-rule=\"evenodd\" height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>ModelContextProtocol</title><path d=\"M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z\"></path><path d=\"M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z\"></path></svg>`,\n  novita: `<svg width=\"1em\" height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 40 40\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><title>Novita</title><g clip-path=\"url(#clip0_3135_1230)\"><path d=\"M15.5564 8.26172V16.5239L2.1875 29.8928H15.5564V21.6302L23.8194 29.8928H37.1875L15.5564 8.26172Z\" fill=\"#000000\"/></g><defs><clipPath id=\"clip0_3135_1230\"><rect width=\"35\" height=\"21.6311\" fill=\"white\" transform=\"translate(2.1875 8.26172)\"/></clipPath></defs></svg>`,\n  nvidia: `<svg height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>Nvidia</title><path d=\"M10.212 8.976V7.62c.127-.01.256-.017.388-.021 3.596-.117 5.957 3.184 5.957 3.184s-2.548 3.647-5.282 3.647a3.227 3.227 0 01-1.063-.175v-4.109c1.4.174 1.681.812 2.523 2.258l1.873-1.627a4.905 4.905 0 00-3.67-1.846 6.594 6.594 0 00-.729.044m0-4.476v2.025c.13-.01.259-.019.388-.024 5.002-.174 8.261 4.226 8.261 4.226s-3.743 4.69-7.643 4.69c-.338 0-.675-.031-1.007-.092v1.25c.278.038.558.057.838.057 3.629 0 6.253-1.91 8.794-4.169.421.347 2.146 1.193 2.501 1.564-2.416 2.083-8.048 3.763-11.24 3.763-.308 0-.603-.02-.894-.048V19.5H24v-15H10.21zm0 9.756v1.068c-3.356-.616-4.287-4.21-4.287-4.21a7.173 7.173 0 014.287-2.138v1.172h-.005a3.182 3.182 0 00-2.502 1.178s.615 2.276 2.507 2.931m-5.961-3.3c1.436-1.935 3.604-3.148 5.961-3.336V6.523C5.81 6.887 2 10.723 2 10.723s2.158 6.427 8.21 7.015v-1.166C5.77 16 4.25 10.958 4.25 10.958h-.002z\" fill=\"#74B71B\" fill-rule=\"nonzero\"></path></svg>`,\n  bailian: `<svg fill=\"currentColor\" fill-rule=\"evenodd\" height=\"1em\" style=\"flex:none;line-height:1\" viewBox=\"0 0 24 24\" width=\"1em\" xmlns=\"http://www.w3.org/2000/svg\"><title>BaiLian</title><path d=\"M6.336 8.919v6.162l5.335-3.083L6.337 8.92z\" fill-opacity=\".4\"></path><path d=\"M21.394 5.288s-.006-.006-.01-.006L17.01 2.754 6.336 8.92l5.335 3.082 9.701-5.6.016-.01a.635.635 0 00.006-1.1v-.003z\" fill-opacity=\".8\"></path><path d=\"M21.71 12.465a.62.62 0 00-.316.085s-.006 0-.009.003l-4.375 2.528 5.05 2.915h.006a2.06 2.06 0 00.28-1.04v-3.855a.637.637 0 00-.636-.636z\"></path><path d=\"M22.06 17.996l-5.05-2.915L6.34 21.242l4.27 2.465s.016.006.022.012a2.102 2.102 0 002.093 0c.006-.003.016-.006.022-.012l8.538-4.93c.003 0 .006-.003.01-.006.321-.183.589-.45.775-.772h-.006l-.004-.003z\" fill-opacity=\".8\"></path><path d=\"M11.672 11.998l-5.336 3.083-1.444.832-3.605 2.083H1.28c.173.303.416.555.709.738l.078.044.016.01.02.012 4.232 2.442 10.671-6.161-5.335-3.082z\"></path><path d=\"M12.74.29c-.1-.06-.208-.107-.315-.148-.02-.006-.038-.016-.057-.022a2.121 2.121 0 00-.7-.12c-.233 0-.457.038-.668.11l-.031.01a2.196 2.196 0 00-.372.17L2.068 5.222s-.003 0-.006.003c-.324.183-.592.451-.781.773h.006l5.049 2.918L17.01 2.758 12.74.29z\" fill-opacity=\".6\"></path><path d=\"M1.287 6.001H1.28A2.06 2.06 0 001 7.041v9.915c0 .378.1.735.28 1.043h.007l5.049-2.918V8.919l-5.05-2.918z\" fill-opacity=\".3\"></path></svg>`,\n};\n\nexport const iconList = Object.keys(icons);\n\nexport function getIcon(name: string): string {\n  return icons[name.toLowerCase()] || \"\";\n}\n\nexport function hasIcon(name: string): boolean {\n  return name.toLowerCase() in icons;\n}\n\nexport { getIconMetadata } from \"./metadata\";\n"
  },
  {
    "path": "src/icons/extracted/metadata.ts",
    "content": "// Icon metadata for search and categorization\nimport { IconMetadata } from \"@/types/icon\";\n\nexport const iconMetadata: Record<string, IconMetadata> = {\n  aigocode: {\n    name: \"aigocode\",\n    displayName: \"AIGoCode\",\n    category: \"ai-provider\",\n    keywords: [\"aigocode\", \"aigo\", \"code\", \"third-party\"],\n    defaultColor: \"#5B7FFF\",\n  },\n  alibaba: {\n    name: \"alibaba\",\n    displayName: \"Alibaba\",\n    category: \"ai-provider\",\n    keywords: [\"qwen\", \"tongyi\"],\n    defaultColor: \"#FF6A00\",\n  },\n  anthropic: {\n    name: \"anthropic\",\n    displayName: \"Anthropic\",\n    category: \"ai-provider\",\n    keywords: [\"claude\"],\n    defaultColor: \"#D4915D\",\n  },\n  aws: {\n    name: \"aws\",\n    displayName: \"AWS\",\n    category: \"cloud\",\n    keywords: [\"amazon\", \"cloud\"],\n    defaultColor: \"#FF9900\",\n  },\n  azure: {\n    name: \"azure\",\n    displayName: \"Azure\",\n    category: \"cloud\",\n    keywords: [\"microsoft\", \"cloud\"],\n    defaultColor: \"#0078D4\",\n  },\n  baidu: {\n    name: \"baidu\",\n    displayName: \"Baidu\",\n    category: \"ai-provider\",\n    keywords: [\"ernie\", \"wenxin\"],\n    defaultColor: \"#2932E1\",\n  },\n  bailian: {\n    name: \"bailian\",\n    displayName: \"Bailian\",\n    category: \"ai-provider\",\n    keywords: [\"bailian\", \"dashscope\", \"aliyun\", \"alibaba\"],\n    defaultColor: \"#624AFF\",\n  },\n  bytedance: {\n    name: \"bytedance\",\n    displayName: \"bytedance\",\n    category: \"other\",\n    keywords: [],\n    defaultColor: \"currentColor\",\n  },\n  chatglm: {\n    name: \"chatglm\",\n    displayName: \"chatglm\",\n    category: \"other\",\n    keywords: [],\n    defaultColor: \"currentColor\",\n  },\n  claude: {\n    name: \"claude\",\n    displayName: \"Claude\",\n    category: \"ai-provider\",\n    keywords: [\"anthropic\"],\n    defaultColor: \"#D4915D\",\n  },\n  cloudflare: {\n    name: \"cloudflare\",\n    displayName: \"Cloudflare\",\n    category: \"cloud\",\n    keywords: [\"cloudflare\", \"cdn\"],\n    defaultColor: \"#F38020\",\n  },\n  cohere: {\n    name: \"cohere\",\n    displayName: \"Cohere\",\n    category: \"ai-provider\",\n    keywords: [\"cohere\"],\n    defaultColor: \"#39594D\",\n  },\n  copilot: {\n    name: \"copilot\",\n    displayName: \"copilot\",\n    category: \"other\",\n    keywords: [],\n    defaultColor: \"currentColor\",\n  },\n  ctok: {\n    name: \"ctok\",\n    displayName: \"CTok\",\n    category: \"ai-provider\",\n    keywords: [\"ctok\", \"ai\", \"programming\"],\n    defaultColor: \"#3B82F6\",\n  },\n  cubence: {\n    name: \"cubence\",\n    displayName: \"Cubence\",\n    category: \"ai-provider\",\n    keywords: [\"cubence\", \"api\", \"relay\"],\n    defaultColor: \"#4B5563\",\n  },\n  deepseek: {\n    name: \"deepseek\",\n    displayName: \"DeepSeek\",\n    category: \"ai-provider\",\n    keywords: [\"deep\", \"seek\"],\n    defaultColor: \"#1E88E5\",\n  },\n  doubao: {\n    name: \"doubao\",\n    displayName: \"doubao\",\n    category: \"other\",\n    keywords: [],\n    defaultColor: \"currentColor\",\n  },\n  gemini: {\n    name: \"gemini\",\n    displayName: \"Gemini\",\n    category: \"ai-provider\",\n    keywords: [\"google\"],\n    defaultColor: \"#4285F4\",\n  },\n  gemma: {\n    name: \"gemma\",\n    displayName: \"gemma\",\n    category: \"other\",\n    keywords: [],\n    defaultColor: \"currentColor\",\n  },\n  github: {\n    name: \"github\",\n    displayName: \"GitHub\",\n    category: \"tool\",\n    keywords: [\"git\", \"version control\"],\n    defaultColor: \"#181717\",\n  },\n  githubcopilot: {\n    name: \"githubcopilot\",\n    displayName: \"githubcopilot\",\n    category: \"other\",\n    keywords: [],\n    defaultColor: \"currentColor\",\n  },\n  google: {\n    name: \"google\",\n    displayName: \"Google\",\n    category: \"ai-provider\",\n    keywords: [\"gemini\", \"bard\"],\n    defaultColor: \"#4285F4\",\n  },\n  googlecloud: {\n    name: \"googlecloud\",\n    displayName: \"googlecloud\",\n    category: \"other\",\n    keywords: [],\n    defaultColor: \"currentColor\",\n  },\n  grok: {\n    name: \"grok\",\n    displayName: \"grok\",\n    category: \"other\",\n    keywords: [],\n    defaultColor: \"currentColor\",\n  },\n  huawei: {\n    name: \"huawei\",\n    displayName: \"Huawei\",\n    category: \"cloud\",\n    keywords: [\"huawei\", \"cloud\"],\n    defaultColor: \"#FF0000\",\n  },\n  huggingface: {\n    name: \"huggingface\",\n    displayName: \"Hugging Face\",\n    category: \"ai-provider\",\n    keywords: [\"huggingface\", \"hf\"],\n    defaultColor: \"#FFD21E\",\n  },\n  hunyuan: {\n    name: \"hunyuan\",\n    displayName: \"hunyuan\",\n    category: \"other\",\n    keywords: [],\n    defaultColor: \"currentColor\",\n  },\n  kimi: {\n    name: \"kimi\",\n    displayName: \"Kimi\",\n    category: \"ai-provider\",\n    keywords: [\"moonshot\"],\n    defaultColor: \"#6366F1\",\n  },\n  meta: {\n    name: \"meta\",\n    displayName: \"Meta\",\n    category: \"ai-provider\",\n    keywords: [\"facebook\", \"llama\"],\n    defaultColor: \"#0081FB\",\n  },\n  midjourney: {\n    name: \"midjourney\",\n    displayName: \"midjourney\",\n    category: \"other\",\n    keywords: [],\n    defaultColor: \"currentColor\",\n  },\n  minimax: {\n    name: \"minimax\",\n    displayName: \"MiniMax\",\n    category: \"ai-provider\",\n    keywords: [\"minimax\"],\n    defaultColor: \"#FF6B6B\",\n  },\n  mistral: {\n    name: \"mistral\",\n    displayName: \"Mistral\",\n    category: \"ai-provider\",\n    keywords: [\"mistral\"],\n    defaultColor: \"#FF7000\",\n  },\n  newapi: {\n    name: \"newapi\",\n    displayName: \"newapi\",\n    category: \"other\",\n    keywords: [],\n    defaultColor: \"currentColor\",\n  },\n  notion: {\n    name: \"notion\",\n    displayName: \"notion\",\n    category: \"other\",\n    keywords: [],\n    defaultColor: \"currentColor\",\n  },\n  ollama: {\n    name: \"ollama\",\n    displayName: \"ollama\",\n    category: \"other\",\n    keywords: [],\n    defaultColor: \"currentColor\",\n  },\n  openai: {\n    name: \"openai\",\n    displayName: \"OpenAI\",\n    category: \"ai-provider\",\n    keywords: [\"gpt\", \"chatgpt\"],\n    defaultColor: \"currentColor\",\n  },\n  openclaw: {\n    name: \"openclaw\",\n    displayName: \"OpenClaw\",\n    category: \"ai-provider\",\n    keywords: [\"openclaw\", \"lobster\", \"claw\"],\n    defaultColor: \"#ff4f40\",\n  },\n  packycode: {\n    name: \"packycode\",\n    displayName: \"PackyCode\",\n    category: \"ai-provider\",\n    keywords: [\"packycode\", \"packy\", \"packyapi\"],\n    defaultColor: \"currentColor\",\n  },\n  palm: {\n    name: \"palm\",\n    displayName: \"palm\",\n    category: \"other\",\n    keywords: [],\n    defaultColor: \"currentColor\",\n  },\n  perplexity: {\n    name: \"perplexity\",\n    displayName: \"Perplexity\",\n    category: \"ai-provider\",\n    keywords: [\"perplexity\"],\n    defaultColor: \"#20808D\",\n  },\n  qwen: {\n    name: \"qwen\",\n    displayName: \"qwen\",\n    category: \"other\",\n    keywords: [],\n    defaultColor: \"currentColor\",\n  },\n  stability: {\n    name: \"stability\",\n    displayName: \"stability\",\n    category: \"other\",\n    keywords: [],\n    defaultColor: \"currentColor\",\n  },\n  tencent: {\n    name: \"tencent\",\n    displayName: \"Tencent\",\n    category: \"ai-provider\",\n    keywords: [\"hunyuan\"],\n    defaultColor: \"#00A4FF\",\n  },\n  vercel: {\n    name: \"vercel\",\n    displayName: \"vercel\",\n    category: \"other\",\n    keywords: [],\n    defaultColor: \"currentColor\",\n  },\n  wenxin: {\n    name: \"wenxin\",\n    displayName: \"wenxin\",\n    category: \"other\",\n    keywords: [],\n    defaultColor: \"currentColor\",\n  },\n  xai: {\n    name: \"xai\",\n    displayName: \"xai\",\n    category: \"other\",\n    keywords: [],\n    defaultColor: \"currentColor\",\n  },\n  yi: {\n    name: \"yi\",\n    displayName: \"yi\",\n    category: \"other\",\n    keywords: [],\n    defaultColor: \"currentColor\",\n  },\n  zeroone: {\n    name: \"zeroone\",\n    displayName: \"zeroone\",\n    category: \"other\",\n    keywords: [],\n    defaultColor: \"currentColor\",\n  },\n  zhipu: {\n    name: \"zhipu\",\n    displayName: \"Zhipu AI\",\n    category: \"ai-provider\",\n    keywords: [\"chatglm\", \"glm\"],\n    defaultColor: \"#0F62FE\",\n  },\n  openrouter: {\n    name: \"openrouter\",\n    displayName: \"OpenRouter\",\n    category: \"ai-provider\",\n    keywords: [\"openrouter\", \"router\", \"aggregator\"],\n    defaultColor: \"#6566F1\",\n  },\n  longcat: {\n    name: \"longcat\",\n    displayName: \"LongCat\",\n    category: \"ai-provider\",\n    keywords: [\"longcat\", \"long\", \"cat\"],\n    defaultColor: \"#29E154\",\n  },\n  modelscope: {\n    name: \"modelscope\",\n    displayName: \"ModelScope\",\n    category: \"ai-provider\",\n    keywords: [\"modelscope\", \"alibaba\", \"scope\"],\n    defaultColor: \"#624AFF\",\n  },\n  aihubmix: {\n    name: \"aihubmix\",\n    displayName: \"AiHubMix\",\n    category: \"ai-provider\",\n    keywords: [\"aihubmix\", \"hub\", \"mix\", \"aggregator\"],\n    defaultColor: \"#006FFB\",\n  },\n  xiaomimimo: {\n    name: \"xiaomimimo\",\n    displayName: \"Xiaomi MiMo\",\n    category: \"ai-provider\",\n    keywords: [\"xiaomimimo\", \"xiaomi\", \"mimo\"],\n    defaultColor: \"#000000\",\n  },\n  novita: {\n    name: \"novita\",\n    displayName: \"Novita AI\",\n    category: \"ai-provider\",\n    keywords: [\"novita\", \"novita ai\"],\n    defaultColor: \"#000000\",\n  },\n  nvidia: {\n    name: \"nvidia\",\n    displayName: \"NVIDIA\",\n    category: \"ai-provider\",\n    keywords: [\"nvidia\", \"nim\", \"gpu\"],\n    defaultColor: \"#74B71B\",\n  },\n  stepfun: {\n    name: \"stepfun\",\n    displayName: \"StepFun\",\n    category: \"ai-provider\",\n    keywords: [\"stepfun\", \"step\", \"jieyue\", \"阶跃星辰\"],\n    defaultColor: \"#005AFF\",\n  },\n};\n\nexport function getIconMetadata(name: string): IconMetadata | undefined {\n  return iconMetadata[name.toLowerCase()];\n}\n\nexport function searchIcons(query: string): string[] {\n  const lowerQuery = query.toLowerCase();\n  return Object.values(iconMetadata)\n    .filter(\n      (meta) =>\n        meta.name.includes(lowerQuery) ||\n        meta.displayName.toLowerCase().includes(lowerQuery) ||\n        meta.keywords.some((k) => k.includes(lowerQuery)),\n    )\n    .map((meta) => meta.name);\n}\n"
  },
  {
    "path": "src/index.css",
    "content": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n@layer base {\n  :root {\n    --background: 0 0% 100%;\n    --foreground: 240 10% 3.9%;\n    --card: 0 0% 100%;\n    --card-foreground: 240 10% 3.9%;\n    --popover: 0 0% 100%;\n    --popover-foreground: 240 10% 3.9%;\n\n    --primary: 210 100% 56%;\n    --primary-foreground: 0 0% 100%;\n\n    --secondary: 240 4.8% 95.9%;\n    --secondary-foreground: 240 5.9% 10%;\n\n    --muted: 240 4.8% 95.9%;\n    --muted-foreground: 240 3.8% 46.1%;\n    --accent: 240 4.8% 95.9%;\n    --accent-foreground: 240 5.9% 10%;\n\n    --destructive: 0 84.2% 60.2%;\n    --destructive-foreground: 0 0% 98%;\n\n    --border: 240 5.9% 90%;\n    --input: 240 5.9% 90%;\n    --ring: 210 100% 56%;\n\n    --radius: 0.5rem;\n  }\n\n  .dark {\n    --background: 240 5% 12%;\n    --foreground: 0 0% 98%;\n    --card: 240 5% 16%;\n    --card-foreground: 0 0% 98%;\n    --popover: 240 5% 16%;\n    --popover-foreground: 0 0% 98%;\n\n    --primary: 210 100% 54%;\n    --primary-foreground: 0 0% 100%;\n\n    --secondary: 240 5% 18%;\n    --secondary-foreground: 0 0% 98%;\n\n    --muted: 240 5% 18%;\n    --muted-foreground: 240 5% 64.9%;\n    --accent: 240 5% 18%;\n    --accent-foreground: 0 0% 98%;\n\n    --destructive: 0 62.8% 30.6%;\n    --destructive-foreground: 0 0% 98%;\n\n    --border: 240 5% 24%;\n    --input: 240 5% 24%;\n    --ring: 210 100% 54%;\n  }\n}\n\n.glass {\n  background: rgba(255, 255, 255, 0.7);\n  backdrop-filter: blur(10px);\n  -webkit-backdrop-filter: blur(10px);\n  border: 1px solid rgba(0, 0, 0, 0.1);\n}\n\n.dark .glass {\n  background: rgba(255, 255, 255, 0.05);\n  border: 1px solid rgba(255, 255, 255, 0.1);\n}\n\n.glass-card {\n  background: rgba(255, 255, 255, 0.8);\n  backdrop-filter: blur(20px);\n  -webkit-backdrop-filter: blur(20px);\n  border: 1px solid rgba(0, 0, 0, 0.1);\n}\n\n.dark .glass-card {\n  background: linear-gradient(\n    145deg,\n    rgba(255, 255, 255, 0.05) 0%,\n    rgba(255, 255, 255, 0.01) 100%\n  );\n  border: 1px solid rgba(255, 255, 255, 0.05);\n  box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);\n}\n\n.glass-card-active {\n  background: rgba(59, 130, 246, 0.08);\n  border: 1px solid rgba(59, 130, 246, 0.4);\n}\n\n.dark .glass-card-active {\n  background: rgba(59, 130, 246, 0.12);\n  border: 1px solid rgba(59, 130, 246, 0.3);\n}\n\n.glass-header {\n  background: hsl(var(--background));\n  backdrop-filter: none;\n  -webkit-backdrop-filter: none;\n  border: none;\n  border-top: 2px solid hsl(var(--border));\n}\n\n.dark .glass-header {\n  background: hsl(var(--background));\n  border: none;\n  border-top: 2px solid hsl(var(--border));\n}\n\n[data-tauri-drag-region] {\n  -webkit-app-region: drag;\n}\n\n[data-tauri-no-drag],\n[data-tauri-drag-region] .no-drag {\n  -webkit-app-region: no-drag;\n}\n\n* {\n  box-sizing: border-box;\n  scrollbar-width: none;\n  -ms-overflow-style: none;\n}\n\nhtml {\n  @apply font-sans antialiased;\n  line-height: 1.5;\n  color-scheme: light;\n  overscroll-behavior: none;\n}\n\nbody {\n  @apply m-0 p-0 text-sm;\n  background-color: hsl(var(--background));\n  color: hsl(var(--foreground));\n}\n\nhtml.dark {\n  color-scheme: dark;\n}\n\n::-webkit-scrollbar {\n  display: none;\n}\n\n*:focus-visible {\n  @apply outline-2 outline-blue-500 outline-offset-2;\n}\n\n@layer utilities {\n  .scroll-overlay {\n    scrollbar-gutter: stable both-edges;\n    padding-right: 0.5rem;\n    margin-right: -0.5rem;\n    overflow-x: hidden;\n  }\n\n  .border-default {\n    border-width: 1px;\n    border-color: hsl(var(--border));\n  }\n\n  .border-active {\n    border-width: 2px;\n  }\n\n  .border-border-default {\n    border-color: hsl(var(--border));\n  }\n\n  .border-border-active {\n    border-color: hsl(var(--primary));\n  }\n\n  .border-border-hover {\n    border-color: hsl(var(--primary) / 0.4);\n  }\n\n  .border-border-dragging {\n    border-color: hsl(var(--primary) / 0.6);\n  }\n}\n\ninput[type=\"password\"]::-ms-reveal,\ninput[type=\"password\"]::-ms-clear {\n  display: none;\n}\n\n::view-transition-old(root),\n::view-transition-new(root) {\n  animation: none;\n  mix-blend-mode: normal;\n}\n\n::view-transition-old(root) {\n  z-index: 1;\n}\n\n::view-transition-new(root) {\n  z-index: 9999;\n}\n\n@keyframes theme-circle-expand {\n  from {\n    clip-path: circle(\n      0% at var(--theme-transition-x, 50%) var(--theme-transition-y, 50%)\n    );\n  }\n\n  to {\n    clip-path: circle(\n      150% at var(--theme-transition-x, 50%) var(--theme-transition-y, 50%)\n    );\n  }\n}\n\n::view-transition-new(root) {\n  animation: theme-circle-expand 0.4s ease-out;\n}\n\n@media (prefers-reduced-motion: reduce) {\n  ::view-transition-new(root) {\n    animation: none;\n  }\n}\n"
  },
  {
    "path": "src/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>Claude Code 供应商切换器</title>\n  </head>\n  <body>\n    <div id=\"root\"></div>\n    <script type=\"module\" src=\"./main.tsx\"></script>\n  </body>\n</html>"
  },
  {
    "path": "src/lib/api/auth.ts",
    "content": "import { invoke } from \"@tauri-apps/api/core\";\n\nexport type ManagedAuthProvider = \"github_copilot\";\n\nexport interface ManagedAuthAccount {\n  id: string;\n  provider: ManagedAuthProvider;\n  login: string;\n  avatar_url: string | null;\n  authenticated_at: number;\n  is_default: boolean;\n}\n\nexport interface ManagedAuthStatus {\n  provider: ManagedAuthProvider;\n  authenticated: boolean;\n  default_account_id: string | null;\n  migration_error?: string | null;\n  accounts: ManagedAuthAccount[];\n}\n\nexport interface ManagedAuthDeviceCodeResponse {\n  provider: ManagedAuthProvider;\n  device_code: string;\n  user_code: string;\n  verification_uri: string;\n  expires_in: number;\n  interval: number;\n}\n\nexport async function authStartLogin(\n  authProvider: ManagedAuthProvider,\n): Promise<ManagedAuthDeviceCodeResponse> {\n  return invoke<ManagedAuthDeviceCodeResponse>(\"auth_start_login\", {\n    authProvider,\n  });\n}\n\nexport async function authPollForAccount(\n  authProvider: ManagedAuthProvider,\n  deviceCode: string,\n): Promise<ManagedAuthAccount | null> {\n  return invoke<ManagedAuthAccount | null>(\"auth_poll_for_account\", {\n    authProvider,\n    deviceCode,\n  });\n}\n\nexport async function authListAccounts(\n  authProvider: ManagedAuthProvider,\n): Promise<ManagedAuthAccount[]> {\n  return invoke<ManagedAuthAccount[]>(\"auth_list_accounts\", {\n    authProvider,\n  });\n}\n\nexport async function authGetStatus(\n  authProvider: ManagedAuthProvider,\n): Promise<ManagedAuthStatus> {\n  return invoke<ManagedAuthStatus>(\"auth_get_status\", {\n    authProvider,\n  });\n}\n\nexport async function authRemoveAccount(\n  authProvider: ManagedAuthProvider,\n  accountId: string,\n): Promise<void> {\n  return invoke(\"auth_remove_account\", {\n    authProvider,\n    accountId,\n  });\n}\n\nexport async function authSetDefaultAccount(\n  authProvider: ManagedAuthProvider,\n  accountId: string,\n): Promise<void> {\n  return invoke(\"auth_set_default_account\", {\n    authProvider,\n    accountId,\n  });\n}\n\nexport async function authLogout(\n  authProvider: ManagedAuthProvider,\n): Promise<void> {\n  return invoke(\"auth_logout\", {\n    authProvider,\n  });\n}\n\nexport const authApi = {\n  authStartLogin,\n  authPollForAccount,\n  authListAccounts,\n  authGetStatus,\n  authRemoveAccount,\n  authSetDefaultAccount,\n  authLogout,\n};\n"
  },
  {
    "path": "src/lib/api/config.ts",
    "content": "// 配置相关 API\nimport { invoke } from \"@tauri-apps/api/core\";\n\nexport type AppType = \"claude\" | \"codex\" | \"gemini\" | \"omo\" | \"omo_slim\";\n\n/**\n * 获取 Claude 通用配置片段（已废弃，使用 getCommonConfigSnippet）\n * @returns 通用配置片段（JSON 字符串），如果不存在则返回 null\n * @deprecated 使用 getCommonConfigSnippet('claude') 替代\n */\nexport async function getClaudeCommonConfigSnippet(): Promise<string | null> {\n  return invoke<string | null>(\"get_claude_common_config_snippet\");\n}\n\n/**\n * 设置 Claude 通用配置片段（已废弃，使用 setCommonConfigSnippet）\n * @param snippet - 通用配置片段（JSON 字符串）\n * @throws 如果 JSON 格式无效\n * @deprecated 使用 setCommonConfigSnippet('claude', snippet) 替代\n */\nexport async function setClaudeCommonConfigSnippet(\n  snippet: string,\n): Promise<void> {\n  return invoke(\"set_claude_common_config_snippet\", { snippet });\n}\n\n/**\n * 获取通用配置片段（统一接口）\n * @param appType - 应用类型（claude/codex/gemini）\n * @returns 通用配置片段（原始字符串），如果不存在则返回 null\n */\nexport async function getCommonConfigSnippet(\n  appType: AppType,\n): Promise<string | null> {\n  return invoke<string | null>(\"get_common_config_snippet\", { appType });\n}\n\n/**\n * 设置通用配置片段（统一接口）\n * @param appType - 应用类型（claude/codex/gemini）\n * @param snippet - 通用配置片段（原始字符串）\n * @throws 如果格式无效（Claude/Gemini 验证 JSON，Codex 暂不验证）\n */\nexport async function setCommonConfigSnippet(\n  appType: AppType,\n  snippet: string,\n): Promise<void> {\n  return invoke(\"set_common_config_snippet\", { appType, snippet });\n}\n\n/**\n * 提取通用配置片段\n *\n * 默认读取当前激活供应商的配置；若传入 `options.settingsConfig`，则从编辑器当前内容提取。\n * 会自动排除差异化字段（API Key、模型配置、端点等），返回可复用的通用配置片段。\n *\n * @param appType - 应用类型（claude/codex/gemini）\n * @param options - 可选：提取来源\n * @returns 提取的通用配置片段（JSON/TOML 字符串）\n */\nexport type ExtractCommonConfigSnippetOptions = {\n  settingsConfig?: string;\n};\n\nexport async function extractCommonConfigSnippet(\n  appType: Exclude<AppType, \"omo\">,\n  options?: ExtractCommonConfigSnippetOptions,\n): Promise<string> {\n  const args: Record<string, unknown> = { appType };\n  const settingsConfig = options?.settingsConfig;\n\n  if (typeof settingsConfig === \"string\" && settingsConfig.trim()) {\n    args.settingsConfig = settingsConfig;\n  }\n\n  return invoke<string>(\"extract_common_config_snippet\", args);\n}\n"
  },
  {
    "path": "src/lib/api/copilot.ts",
    "content": "/**\n * GitHub Copilot OAuth API\n *\n * 提供 GitHub Copilot OAuth 设备码流程相关的 API 函数。\n * 支持多账号管理。\n */\n\nimport { invoke } from \"@tauri-apps/api/core\";\n\n/**\n * GitHub 设备码响应\n */\nexport interface CopilotDeviceCodeResponse {\n  device_code: string;\n  user_code: string;\n  verification_uri: string;\n  expires_in: number;\n  interval: number;\n}\n\n/**\n * GitHub 账号信息（公开信息）\n */\nexport interface GitHubAccount {\n  /** GitHub 用户 ID（唯一标识） */\n  id: string;\n  /** GitHub 用户名 */\n  login: string;\n  /** 头像 URL */\n  avatar_url: string | null;\n  /** 认证时间戳（Unix 秒） */\n  authenticated_at: number;\n}\n\n/**\n * Copilot 认证状态（多账号版本）\n */\nexport interface CopilotAuthStatus {\n  /** 是否已认证（有任意账号）- 向后兼容 */\n  authenticated: boolean;\n  /** 默认账号 ID */\n  default_account_id: string | null;\n  /** 旧认证数据迁移失败时的状态消息 */\n  migration_error?: string | null;\n  /** 第一个账号的用户名 - 向后兼容 */\n  username: string | null;\n  /** Copilot Token 过期时间 - 向后兼容 */\n  expires_at: number | null;\n  /** 所有已认证账号列表 */\n  accounts: GitHubAccount[];\n}\n\n/**\n * 启动 GitHub OAuth 设备码流程\n *\n * @returns 设备码响应，包含用户码和验证 URL\n */\nexport async function copilotStartDeviceFlow(): Promise<CopilotDeviceCodeResponse> {\n  return invoke<CopilotDeviceCodeResponse>(\"copilot_start_device_flow\");\n}\n\n/**\n * 轮询 OAuth Token\n *\n * 使用设备码轮询 GitHub，等待用户完成授权。\n *\n * @param deviceCode - 设备码\n * @returns true 表示认证成功，false 表示仍在等待用户授权\n */\nexport async function copilotPollForAuth(deviceCode: string): Promise<boolean> {\n  return invoke<boolean>(\"copilot_poll_for_auth\", {\n    deviceCode,\n  });\n}\n\n/**\n * 获取 Copilot 认证状态\n *\n * @returns 认证状态，包含是否已认证、用户名和过期时间\n */\nexport async function copilotGetAuthStatus(): Promise<CopilotAuthStatus> {\n  return invoke<CopilotAuthStatus>(\"copilot_get_auth_status\");\n}\n\n/**\n * 注销 Copilot 认证\n */\nexport async function copilotLogout(): Promise<void> {\n  return invoke(\"copilot_logout\");\n}\n\n/**\n * 检查是否已认证\n *\n * @returns true 表示已认证\n */\nexport async function copilotIsAuthenticated(): Promise<boolean> {\n  return invoke<boolean>(\"copilot_is_authenticated\");\n}\n\n/**\n * Copilot 可用模型\n */\nexport interface CopilotModel {\n  id: string;\n  name: string;\n  vendor: string;\n  model_picker_enabled: boolean;\n}\n\n/**\n * 获取有效的 Copilot Token\n *\n * 内部使用，用于代理请求。\n *\n * @returns Copilot Token\n */\nexport async function copilotGetToken(): Promise<string> {\n  return invoke<string>(\"copilot_get_token\");\n}\n\n/**\n * 获取 Copilot 可用模型列表\n *\n * @returns 可用模型列表\n */\nexport async function copilotGetModels(): Promise<CopilotModel[]> {\n  return invoke<CopilotModel[]>(\"copilot_get_models\");\n}\n\n/**\n * 配额详情\n */\nexport interface QuotaDetail {\n  entitlement: number;\n  remaining: number;\n  percent_remaining: number;\n  unlimited: boolean;\n}\n\n/**\n * 配额快照\n */\nexport interface QuotaSnapshots {\n  chat: QuotaDetail;\n  completions: QuotaDetail;\n  premium_interactions: QuotaDetail;\n}\n\n/**\n * Copilot 使用量响应\n */\nexport interface CopilotUsageResponse {\n  copilot_plan: string;\n  quota_reset_date: string;\n  quota_snapshots: QuotaSnapshots;\n}\n\n/**\n * 获取 Copilot 使用量信息\n *\n * @returns 使用量信息，包含计划类型、重置日期和配额快照\n */\nexport async function copilotGetUsage(): Promise<CopilotUsageResponse> {\n  return invoke<CopilotUsageResponse>(\"copilot_get_usage\");\n}\n\n// ==================== 多账号管理 API ====================\n\n/**\n * 列出所有已认证的 GitHub 账号\n *\n * @returns 账号列表\n */\nexport async function copilotListAccounts(): Promise<GitHubAccount[]> {\n  return invoke<GitHubAccount[]>(\"copilot_list_accounts\");\n}\n\n/**\n * 轮询 OAuth Token（多账号版本）\n *\n * 使用设备码轮询 GitHub，等待用户完成授权。\n * 授权成功后返回新添加的账号信息。\n *\n * @param deviceCode - 设备码\n * @returns 新添加的账号信息，如果仍在等待则返回 null\n */\nexport async function copilotPollForAccount(\n  deviceCode: string,\n): Promise<GitHubAccount | null> {\n  return invoke<GitHubAccount | null>(\"copilot_poll_for_account\", {\n    deviceCode,\n  });\n}\n\n/**\n * 移除指定的 GitHub 账号\n *\n * @param accountId - GitHub 用户 ID\n */\nexport async function copilotRemoveAccount(accountId: string): Promise<void> {\n  return invoke(\"copilot_remove_account\", { accountId });\n}\n\n/**\n * 设置默认 GitHub 账号\n *\n * @param accountId - GitHub 用户 ID\n */\nexport async function copilotSetDefaultAccount(\n  accountId: string,\n): Promise<void> {\n  return invoke(\"copilot_set_default_account\", { accountId });\n}\n\n/**\n * 获取指定账号的有效 Copilot Token\n *\n * 内部使用，用于代理请求。\n *\n * @param accountId - GitHub 用户 ID\n * @returns Copilot Token\n */\nexport async function copilotGetTokenForAccount(\n  accountId: string,\n): Promise<string> {\n  return invoke<string>(\"copilot_get_token_for_account\", { accountId });\n}\n\n/**\n * 获取指定账号的 Copilot 可用模型列表\n *\n * @param accountId - GitHub 用户 ID\n * @returns 可用模型列表\n */\nexport async function copilotGetModelsForAccount(\n  accountId: string,\n): Promise<CopilotModel[]> {\n  return invoke<CopilotModel[]>(\"copilot_get_models_for_account\", {\n    accountId,\n  });\n}\n\n/**\n * 获取指定账号的 Copilot 使用量信息\n *\n * @param accountId - GitHub 用户 ID\n * @returns 使用量信息\n */\nexport async function copilotGetUsageForAccount(\n  accountId: string,\n): Promise<CopilotUsageResponse> {\n  return invoke<CopilotUsageResponse>(\"copilot_get_usage_for_account\", {\n    accountId,\n  });\n}\n"
  },
  {
    "path": "src/lib/api/deeplink.ts",
    "content": "import { invoke } from \"@tauri-apps/api/core\";\n\nexport type ResourceType = \"provider\" | \"prompt\" | \"mcp\" | \"skill\";\n\nexport interface DeepLinkImportRequest {\n  version: string;\n  resource: ResourceType;\n\n  // Common fields\n  app?: \"claude\" | \"codex\" | \"gemini\";\n  name?: string;\n  enabled?: boolean;\n\n  // Provider fields\n  homepage?: string;\n  endpoint?: string;\n  apiKey?: string;\n  icon?: string;\n  model?: string;\n  notes?: string;\n  haikuModel?: string;\n  sonnetModel?: string;\n  opusModel?: string;\n\n  // Prompt fields\n  content?: string;\n  description?: string;\n\n  // MCP fields\n  apps?: string; // \"claude,codex,gemini\"\n\n  // Skill fields\n  repo?: string;\n  directory?: string;\n  branch?: string;\n\n  // Config file fields\n  config?: string;\n  configFormat?: string;\n  configUrl?: string;\n\n  // Usage script fields (v3.9+)\n  usageEnabled?: boolean;\n  usageScript?: string;\n  usageApiKey?: string;\n  usageBaseUrl?: string;\n  usageAccessToken?: string;\n  usageUserId?: string;\n  usageAutoInterval?: number;\n}\n\nexport interface McpImportResult {\n  importedCount: number;\n  importedIds: string[];\n  failed: Array<{\n    id: string;\n    error: string;\n  }>;\n}\n\nexport type ImportResult =\n  | { type: \"provider\"; id: string }\n  | { type: \"prompt\"; id: string }\n  | {\n      type: \"mcp\";\n      importedCount: number;\n      importedIds: string[];\n      failed: Array<{ id: string; error: string }>;\n    }\n  | { type: \"skill\"; key: string };\n\nexport const deeplinkApi = {\n  /**\n   * Parse a deep link URL\n   * @param url The ccswitch:// URL to parse\n   * @returns Parsed deep link request\n   */\n  parseDeeplink: async (url: string): Promise<DeepLinkImportRequest> => {\n    return invoke(\"parse_deeplink\", { url });\n  },\n\n  /**\n   * Merge configuration from Base64/URL into a deep link request\n   * This is used to show the complete configuration in the confirmation dialog\n   * @param request The deep link import request\n   * @returns Merged deep link request with config fields populated\n   */\n  mergeDeeplinkConfig: async (\n    request: DeepLinkImportRequest,\n  ): Promise<DeepLinkImportRequest> => {\n    return invoke(\"merge_deeplink_config\", { request });\n  },\n\n  /**\n   * Import a resource from a deep link request (unified handler)\n   * @param request The deep link import request\n   * @returns Import result based on resource type\n   */\n  importFromDeeplink: async (\n    request: DeepLinkImportRequest,\n  ): Promise<ImportResult> => {\n    return invoke(\"import_from_deeplink_unified\", { request });\n  },\n};\n"
  },
  {
    "path": "src/lib/api/env.ts",
    "content": "import { invoke } from \"@tauri-apps/api/core\";\nimport type { EnvConflict, BackupInfo } from \"@/types/env\";\n\n/**\n * 环境变量管理 API\n */\n\n/**\n * 检查指定应用的环境变量冲突\n * @param appType 应用类型 (\"claude\" | \"codex\" | \"gemini\")\n * @returns 环境变量冲突列表\n */\nexport async function checkEnvConflicts(\n  appType: string,\n): Promise<EnvConflict[]> {\n  return invoke<EnvConflict[]>(\"check_env_conflicts\", { app: appType });\n}\n\n/**\n * 删除指定的环境变量 (会自动备份)\n * @param conflicts 要删除的环境变量冲突列表\n * @returns 备份信息\n */\nexport async function deleteEnvVars(\n  conflicts: EnvConflict[],\n): Promise<BackupInfo> {\n  return invoke<BackupInfo>(\"delete_env_vars\", { conflicts });\n}\n\n/**\n * 从备份文件恢复环境变量\n * @param backupPath 备份文件路径\n */\nexport async function restoreEnvBackup(backupPath: string): Promise<void> {\n  return invoke<void>(\"restore_env_backup\", { backupPath });\n}\n\n/**\n * 检查所有应用的环境变量冲突\n * @returns 按应用类型分组的环境变量冲突\n */\nexport async function checkAllEnvConflicts(): Promise<\n  Record<string, EnvConflict[]>\n> {\n  const apps = [\"claude\", \"codex\", \"gemini\"];\n  const results: Record<string, EnvConflict[]> = {};\n\n  await Promise.all(\n    apps.map(async (app) => {\n      try {\n        results[app] = await checkEnvConflicts(app);\n      } catch (error) {\n        console.error(`检查 ${app} 环境变量失败:`, error);\n        results[app] = [];\n      }\n    }),\n  );\n\n  return results;\n}\n"
  },
  {
    "path": "src/lib/api/failover.ts",
    "content": "import { invoke } from \"@tauri-apps/api/core\";\nimport type {\n  ProviderHealth,\n  CircuitBreakerConfig,\n  CircuitBreakerStats,\n  FailoverQueueItem,\n} from \"@/types/proxy\";\n\nexport interface Provider {\n  id: string;\n  name: string;\n  settingsConfig: unknown;\n  websiteUrl?: string;\n  category?: string;\n  createdAt?: number;\n  sortIndex?: number;\n  notes?: string;\n  meta?: unknown;\n  icon?: string;\n  iconColor?: string;\n}\n\nexport const failoverApi = {\n  // ========== 熔断器 API ==========\n\n  // 获取供应商健康状态\n  async getProviderHealth(\n    providerId: string,\n    appType: string,\n  ): Promise<ProviderHealth> {\n    return invoke(\"get_provider_health\", { providerId, appType });\n  },\n\n  // 重置熔断器\n  async resetCircuitBreaker(\n    providerId: string,\n    appType: string,\n  ): Promise<void> {\n    return invoke(\"reset_circuit_breaker\", { providerId, appType });\n  },\n\n  // 获取熔断器配置\n  async getCircuitBreakerConfig(): Promise<CircuitBreakerConfig> {\n    return invoke(\"get_circuit_breaker_config\");\n  },\n\n  // 更新熔断器配置\n  async updateCircuitBreakerConfig(\n    config: CircuitBreakerConfig,\n  ): Promise<void> {\n    return invoke(\"update_circuit_breaker_config\", { config });\n  },\n\n  // 获取熔断器统计信息\n  async getCircuitBreakerStats(\n    providerId: string,\n    appType: string,\n  ): Promise<CircuitBreakerStats | null> {\n    return invoke(\"get_circuit_breaker_stats\", { providerId, appType });\n  },\n\n  // ========== 故障转移队列 API（新） ==========\n\n  // 获取故障转移队列\n  async getFailoverQueue(appType: string): Promise<FailoverQueueItem[]> {\n    return invoke(\"get_failover_queue\", { appType });\n  },\n\n  // 获取可添加到队列的供应商（不在队列中的）\n  async getAvailableProvidersForFailover(appType: string): Promise<Provider[]> {\n    return invoke(\"get_available_providers_for_failover\", { appType });\n  },\n\n  // 添加供应商到故障转移队列\n  async addToFailoverQueue(appType: string, providerId: string): Promise<void> {\n    return invoke(\"add_to_failover_queue\", { appType, providerId });\n  },\n\n  // 从故障转移队列移除供应商\n  async removeFromFailoverQueue(\n    appType: string,\n    providerId: string,\n  ): Promise<void> {\n    return invoke(\"remove_from_failover_queue\", { appType, providerId });\n  },\n\n  // 获取指定应用的自动故障转移开关状态\n  async getAutoFailoverEnabled(appType: string): Promise<boolean> {\n    return invoke(\"get_auto_failover_enabled\", { appType });\n  },\n\n  // 设置指定应用的自动故障转移开关状态\n  async setAutoFailoverEnabled(\n    appType: string,\n    enabled: boolean,\n  ): Promise<void> {\n    return invoke(\"set_auto_failover_enabled\", { appType, enabled });\n  },\n};\n"
  },
  {
    "path": "src/lib/api/globalProxy.ts",
    "content": "/**\n * 全局出站代理 API\n *\n * 提供获取、设置和测试全局代理的功能。\n */\n\nimport { invoke } from \"@tauri-apps/api/core\";\n\n/**\n * 代理测试结果\n */\nexport interface ProxyTestResult {\n  success: boolean;\n  latencyMs: number;\n  error: string | null;\n}\n\n/**\n * 出站代理状态\n */\nexport interface UpstreamProxyStatus {\n  enabled: boolean;\n  proxyUrl: string | null;\n}\n\n/**\n * 检测到的代理\n */\nexport interface DetectedProxy {\n  url: string;\n  proxyType: string;\n  port: number;\n}\n\n/**\n * 获取全局代理 URL\n *\n * @returns 代理 URL，null 表示未配置（直连）\n */\nexport async function getGlobalProxyUrl(): Promise<string | null> {\n  return invoke<string | null>(\"get_global_proxy_url\");\n}\n\n/**\n * 设置全局代理 URL\n *\n * @param url - 代理 URL（如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080）\n *              空字符串表示清除代理（直连）\n */\nexport async function setGlobalProxyUrl(url: string): Promise<void> {\n  try {\n    return await invoke(\"set_global_proxy_url\", { url });\n  } catch (error) {\n    // Tauri invoke 错误可能是字符串\n    throw new Error(typeof error === \"string\" ? error : String(error));\n  }\n}\n\n/**\n * 测试代理连接\n *\n * @param url - 要测试的代理 URL\n * @returns 测试结果，包含是否成功、延迟和错误信息\n */\nexport async function testProxyUrl(url: string): Promise<ProxyTestResult> {\n  return invoke<ProxyTestResult>(\"test_proxy_url\", { url });\n}\n\n/**\n * 获取当前出站代理状态\n *\n * @returns 代理状态，包含是否启用和代理 URL\n */\nexport async function getUpstreamProxyStatus(): Promise<UpstreamProxyStatus> {\n  return invoke<UpstreamProxyStatus>(\"get_upstream_proxy_status\");\n}\n\n/**\n * 扫描本地代理\n *\n * @returns 检测到的代理列表\n */\nexport async function scanLocalProxies(): Promise<DetectedProxy[]> {\n  return invoke<DetectedProxy[]>(\"scan_local_proxies\");\n}\n"
  },
  {
    "path": "src/lib/api/index.ts",
    "content": "export type { AppId } from \"./types\";\nexport { providersApi, universalProvidersApi } from \"./providers\";\nexport { settingsApi } from \"./settings\";\nexport { backupsApi } from \"./settings\";\nexport { mcpApi } from \"./mcp\";\nexport { promptsApi } from \"./prompts\";\nexport { skillsApi } from \"./skills\";\nexport { usageApi } from \"./usage\";\nexport { vscodeApi } from \"./vscode\";\nexport { proxyApi } from \"./proxy\";\nexport { openclawApi } from \"./openclaw\";\nexport { sessionsApi } from \"./sessions\";\nexport { workspaceApi } from \"./workspace\";\nexport * as configApi from \"./config\";\nexport * as authApi from \"./auth\";\nexport * as copilotApi from \"./copilot\";\nexport type { ProviderSwitchEvent } from \"./providers\";\nexport type { Prompt } from \"./prompts\";\nexport type {\n  CopilotDeviceCodeResponse,\n  CopilotAuthStatus,\n  GitHubAccount,\n} from \"./copilot\";\nexport type {\n  ManagedAuthProvider,\n  ManagedAuthAccount,\n  ManagedAuthStatus,\n  ManagedAuthDeviceCodeResponse,\n} from \"./auth\";\n"
  },
  {
    "path": "src/lib/api/mcp.ts",
    "content": "import { invoke } from \"@tauri-apps/api/core\";\nimport type {\n  McpConfigResponse,\n  McpServer,\n  McpServerSpec,\n  McpServersMap,\n  McpStatus,\n} from \"@/types\";\nimport type { AppId } from \"./types\";\n\nexport const mcpApi = {\n  async getStatus(): Promise<McpStatus> {\n    return await invoke(\"get_claude_mcp_status\");\n  },\n\n  async readConfig(): Promise<string | null> {\n    return await invoke(\"read_claude_mcp_config\");\n  },\n\n  async upsertServer(\n    id: string,\n    spec: McpServerSpec | Record<string, any>,\n  ): Promise<boolean> {\n    return await invoke(\"upsert_claude_mcp_server\", { id, spec });\n  },\n\n  async deleteServer(id: string): Promise<boolean> {\n    return await invoke(\"delete_claude_mcp_server\", { id });\n  },\n\n  async validateCommand(cmd: string): Promise<boolean> {\n    return await invoke(\"validate_mcp_command\", { cmd });\n  },\n\n  /**\n   * @deprecated 使用 getAllServers() 代替（v3.7.0+）\n   */\n  async getConfig(app: AppId = \"claude\"): Promise<McpConfigResponse> {\n    return await invoke(\"get_mcp_config\", { app });\n  },\n\n  /**\n   * @deprecated 使用 upsertUnifiedServer() 代替（v3.7.0+）\n   */\n  async upsertServerInConfig(\n    app: AppId,\n    id: string,\n    spec: McpServer,\n    options?: { syncOtherSide?: boolean },\n  ): Promise<boolean> {\n    const payload = {\n      app,\n      id,\n      spec,\n      ...(options?.syncOtherSide !== undefined\n        ? { syncOtherSide: options.syncOtherSide }\n        : {}),\n    };\n    return await invoke(\"upsert_mcp_server_in_config\", payload);\n  },\n\n  /**\n   * @deprecated 使用 deleteUnifiedServer() 代替（v3.7.0+）\n   */\n  async deleteServerInConfig(\n    app: AppId,\n    id: string,\n    options?: { syncOtherSide?: boolean },\n  ): Promise<boolean> {\n    const payload = {\n      app,\n      id,\n      ...(options?.syncOtherSide !== undefined\n        ? { syncOtherSide: options.syncOtherSide }\n        : {}),\n    };\n    return await invoke(\"delete_mcp_server_in_config\", payload);\n  },\n\n  /**\n   * @deprecated 使用 toggleApp() 代替（v3.7.0+）\n   */\n  async setEnabled(app: AppId, id: string, enabled: boolean): Promise<boolean> {\n    return await invoke(\"set_mcp_enabled\", { app, id, enabled });\n  },\n\n  // ========================================================================\n  // v3.7.0 新增：统一 MCP 管理 API\n  // ========================================================================\n\n  /**\n   * 获取所有 MCP 服务器（统一结构）\n   */\n  async getAllServers(): Promise<McpServersMap> {\n    return await invoke(\"get_mcp_servers\");\n  },\n\n  /**\n   * 添加或更新 MCP 服务器（统一结构）\n   */\n  async upsertUnifiedServer(server: McpServer): Promise<void> {\n    return await invoke(\"upsert_mcp_server\", { server });\n  },\n\n  /**\n   * 删除 MCP 服务器\n   */\n  async deleteUnifiedServer(id: string): Promise<boolean> {\n    return await invoke(\"delete_mcp_server\", { id });\n  },\n\n  /**\n   * 切换 MCP 服务器在指定应用的启用状态\n   */\n  async toggleApp(\n    serverId: string,\n    app: AppId,\n    enabled: boolean,\n  ): Promise<void> {\n    return await invoke(\"toggle_mcp_app\", { serverId, app, enabled });\n  },\n\n  /**\n   * 从所有应用导入 MCP 服务器\n   */\n  async importFromApps(): Promise<number> {\n    return await invoke(\"import_mcp_from_apps\");\n  },\n};\n"
  },
  {
    "path": "src/lib/api/model-test.ts",
    "content": "import { invoke } from \"@tauri-apps/api/core\";\nimport type { AppId } from \"./types\";\n\n// ===== 流式健康检查类型 =====\n\nexport type HealthStatus = \"operational\" | \"degraded\" | \"failed\";\n\nexport interface StreamCheckConfig {\n  timeoutSecs: number;\n  maxRetries: number;\n  degradedThresholdMs: number;\n  claudeModel: string;\n  codexModel: string;\n  geminiModel: string;\n  testPrompt: string;\n}\n\nexport interface StreamCheckResult {\n  status: HealthStatus;\n  success: boolean;\n  message: string;\n  responseTimeMs?: number;\n  httpStatus?: number;\n  modelUsed: string;\n  testedAt: number;\n  retryCount: number;\n}\n\n// ===== 流式健康检查 API =====\n\n/**\n * 流式健康检查（单个供应商）\n */\nexport async function streamCheckProvider(\n  appType: AppId,\n  providerId: string,\n): Promise<StreamCheckResult> {\n  return invoke(\"stream_check_provider\", { appType, providerId });\n}\n\n/**\n * 批量流式健康检查\n */\nexport async function streamCheckAllProviders(\n  appType: AppId,\n  proxyTargetsOnly: boolean = false,\n): Promise<Array<[string, StreamCheckResult]>> {\n  return invoke(\"stream_check_all_providers\", { appType, proxyTargetsOnly });\n}\n\n/**\n * 获取流式检查配置\n */\nexport async function getStreamCheckConfig(): Promise<StreamCheckConfig> {\n  return invoke(\"get_stream_check_config\");\n}\n\n/**\n * 保存流式检查配置\n */\nexport async function saveStreamCheckConfig(\n  config: StreamCheckConfig,\n): Promise<void> {\n  return invoke(\"save_stream_check_config\", { config });\n}\n"
  },
  {
    "path": "src/lib/api/omo.ts",
    "content": "import { invoke } from \"@tauri-apps/api/core\";\nimport type { OmoLocalFileData } from \"@/types/omo\";\n\nexport const omoApi = {\n  readLocalFile: (): Promise<OmoLocalFileData> => invoke(\"read_omo_local_file\"),\n  getCurrentOmoProviderId: (): Promise<string> =>\n    invoke(\"get_current_omo_provider_id\"),\n  disableCurrentOmo: (): Promise<void> => invoke(\"disable_current_omo\"),\n};\n\nexport const omoSlimApi = {\n  readLocalFile: (): Promise<OmoLocalFileData> =>\n    invoke(\"read_omo_slim_local_file\"),\n  getCurrentProviderId: (): Promise<string> =>\n    invoke(\"get_current_omo_slim_provider_id\"),\n  disableCurrent: (): Promise<void> => invoke(\"disable_current_omo_slim\"),\n};\n"
  },
  {
    "path": "src/lib/api/openclaw.ts",
    "content": "import { invoke } from \"@tauri-apps/api/core\";\nimport type {\n  OpenClawDefaultModel,\n  OpenClawModelCatalogEntry,\n  OpenClawAgentsDefaults,\n  OpenClawEnvConfig,\n  OpenClawToolsConfig,\n  OpenClawHealthWarning,\n  OpenClawWriteOutcome,\n} from \"@/types\";\n\n/**\n * OpenClaw configuration API\n *\n * Manages ~/.openclaw/openclaw.json sections:\n * - agents.defaults (model, catalog)\n * - env (environment variables)\n * - tools (permissions)\n */\nexport const openclawApi = {\n  // ============================================================\n  // Agents Configuration\n  // ============================================================\n\n  /**\n   * Get default model configuration (agents.defaults.model)\n   */\n  async getDefaultModel(): Promise<OpenClawDefaultModel | null> {\n    return await invoke(\"get_openclaw_default_model\");\n  },\n\n  /**\n   * Set default model configuration (agents.defaults.model)\n   */\n  async setDefaultModel(\n    model: OpenClawDefaultModel,\n  ): Promise<OpenClawWriteOutcome> {\n    return await invoke(\"set_openclaw_default_model\", { model });\n  },\n\n  /**\n   * Get model catalog/allowlist (agents.defaults.models)\n   */\n  async getModelCatalog(): Promise<Record<\n    string,\n    OpenClawModelCatalogEntry\n  > | null> {\n    return await invoke(\"get_openclaw_model_catalog\");\n  },\n\n  /**\n   * Set model catalog/allowlist (agents.defaults.models)\n   */\n  async setModelCatalog(\n    catalog: Record<string, OpenClawModelCatalogEntry>,\n  ): Promise<OpenClawWriteOutcome> {\n    return await invoke(\"set_openclaw_model_catalog\", { catalog });\n  },\n\n  /**\n   * Get full agents.defaults config (all fields)\n   */\n  async getAgentsDefaults(): Promise<OpenClawAgentsDefaults | null> {\n    return await invoke(\"get_openclaw_agents_defaults\");\n  },\n\n  /**\n   * Set full agents.defaults config (all fields)\n   */\n  async setAgentsDefaults(\n    defaults: OpenClawAgentsDefaults,\n  ): Promise<OpenClawWriteOutcome> {\n    return await invoke(\"set_openclaw_agents_defaults\", { defaults });\n  },\n\n  // ============================================================\n  // Env Configuration\n  // ============================================================\n\n  /**\n   * Get env config (env section of openclaw.json)\n   */\n  async getEnv(): Promise<OpenClawEnvConfig> {\n    return await invoke(\"get_openclaw_env\");\n  },\n\n  /**\n   * Set env config (env section of openclaw.json)\n   */\n  async setEnv(env: OpenClawEnvConfig): Promise<OpenClawWriteOutcome> {\n    return await invoke(\"set_openclaw_env\", { env });\n  },\n\n  // ============================================================\n  // Tools Configuration\n  // ============================================================\n\n  /**\n   * Get tools config (tools section of openclaw.json)\n   */\n  async getTools(): Promise<OpenClawToolsConfig> {\n    return await invoke(\"get_openclaw_tools\");\n  },\n\n  /**\n   * Set tools config (tools section of openclaw.json)\n   */\n  async setTools(tools: OpenClawToolsConfig): Promise<OpenClawWriteOutcome> {\n    return await invoke(\"set_openclaw_tools\", { tools });\n  },\n\n  async scanHealth(): Promise<OpenClawHealthWarning[]> {\n    return await invoke(\"scan_openclaw_config_health\");\n  },\n\n  async getLiveProvider(\n    providerId: string,\n  ): Promise<Record<string, unknown> | null> {\n    return await invoke(\"get_openclaw_live_provider\", { providerId });\n  },\n};\n"
  },
  {
    "path": "src/lib/api/prompts.ts",
    "content": "import { invoke } from \"@tauri-apps/api/core\";\nimport type { AppId } from \"./types\";\n\nexport interface Prompt {\n  id: string;\n  name: string;\n  content: string;\n  description?: string;\n  enabled: boolean;\n  createdAt?: number;\n  updatedAt?: number;\n}\n\nexport const promptsApi = {\n  async getPrompts(app: AppId): Promise<Record<string, Prompt>> {\n    return await invoke(\"get_prompts\", { app });\n  },\n\n  async upsertPrompt(app: AppId, id: string, prompt: Prompt): Promise<void> {\n    return await invoke(\"upsert_prompt\", { app, id, prompt });\n  },\n\n  async deletePrompt(app: AppId, id: string): Promise<void> {\n    return await invoke(\"delete_prompt\", { app, id });\n  },\n\n  async enablePrompt(app: AppId, id: string): Promise<void> {\n    return await invoke(\"enable_prompt\", { app, id });\n  },\n\n  async importFromFile(app: AppId): Promise<string> {\n    return await invoke(\"import_prompt_from_file\", { app });\n  },\n\n  async getCurrentFileContent(app: AppId): Promise<string | null> {\n    return await invoke(\"get_current_prompt_file_content\", { app });\n  },\n};\n"
  },
  {
    "path": "src/lib/api/providers.ts",
    "content": "import { invoke } from \"@tauri-apps/api/core\";\nimport { listen, type UnlistenFn } from \"@tauri-apps/api/event\";\nimport type {\n  Provider,\n  UniversalProvider,\n  UniversalProvidersMap,\n} from \"@/types\";\nimport type { AppId } from \"./types\";\n\nexport interface ProviderSortUpdate {\n  id: string;\n  sortIndex: number;\n}\n\nexport interface ProviderSwitchEvent {\n  appType: AppId;\n  providerId: string;\n}\n\nexport interface SwitchResult {\n  warnings: string[];\n}\n\nexport const providersApi = {\n  async getAll(appId: AppId): Promise<Record<string, Provider>> {\n    return await invoke(\"get_providers\", { app: appId });\n  },\n\n  async getCurrent(appId: AppId): Promise<string> {\n    return await invoke(\"get_current_provider\", { app: appId });\n  },\n\n  async add(provider: Provider, appId: AppId): Promise<boolean> {\n    return await invoke(\"add_provider\", { provider, app: appId });\n  },\n\n  async update(provider: Provider, appId: AppId): Promise<boolean> {\n    return await invoke(\"update_provider\", { provider, app: appId });\n  },\n\n  async delete(id: string, appId: AppId): Promise<boolean> {\n    return await invoke(\"delete_provider\", { id, app: appId });\n  },\n\n  /**\n   * Remove provider from live config only (for additive mode apps like OpenCode)\n   * Does NOT delete from database - provider remains in the list\n   */\n  async removeFromLiveConfig(id: string, appId: AppId): Promise<boolean> {\n    return await invoke(\"remove_provider_from_live_config\", { id, app: appId });\n  },\n\n  async switch(id: string, appId: AppId): Promise<SwitchResult> {\n    return await invoke(\"switch_provider\", { id, app: appId });\n  },\n\n  async importDefault(appId: AppId): Promise<boolean> {\n    return await invoke(\"import_default_config\", { app: appId });\n  },\n\n  async updateTrayMenu(): Promise<boolean> {\n    return await invoke(\"update_tray_menu\");\n  },\n\n  async updateSortOrder(\n    updates: ProviderSortUpdate[],\n    appId: AppId,\n  ): Promise<boolean> {\n    return await invoke(\"update_providers_sort_order\", { updates, app: appId });\n  },\n\n  async onSwitched(\n    handler: (event: ProviderSwitchEvent) => void,\n  ): Promise<UnlistenFn> {\n    return await listen(\"provider-switched\", (event) => {\n      const payload = event.payload as ProviderSwitchEvent;\n      handler(payload);\n    });\n  },\n\n  /**\n   * 打开指定提供商的终端\n   * 任何提供商都可以打开终端，不受是否为当前激活提供商的限制\n   * 终端会使用该提供商特定的 API 配置，不影响全局设置\n   */\n  async openTerminal(providerId: string, appId: AppId): Promise<boolean> {\n    return await invoke(\"open_provider_terminal\", { providerId, app: appId });\n  },\n\n  /**\n   * 从 OpenCode live 配置导入供应商到数据库\n   * OpenCode 特有功能：由于累加模式，用户可能已在 opencode.json 中配置供应商\n   */\n  async importOpenCodeFromLive(): Promise<number> {\n    return await invoke(\"import_opencode_providers_from_live\");\n  },\n\n  /**\n   * 获取 OpenCode live 配置中的供应商 ID 列表\n   * 用于前端判断供应商是否已添加到 opencode.json\n   */\n  async getOpenCodeLiveProviderIds(): Promise<string[]> {\n    return await invoke(\"get_opencode_live_provider_ids\");\n  },\n\n  /**\n   * 获取 OpenClaw live 配置中的供应商 ID 列表\n   * 用于前端判断供应商是否已添加到 openclaw.json\n   */\n  async getOpenClawLiveProviderIds(): Promise<string[]> {\n    return await invoke(\"get_openclaw_live_provider_ids\");\n  },\n\n  /**\n   * 从 OpenClaw live 配置导入供应商到数据库\n   * OpenClaw 特有功能：由于累加模式，用户可能已在 openclaw.json 中配置供应商\n   */\n  async importOpenClawFromLive(): Promise<number> {\n    return await invoke(\"import_openclaw_providers_from_live\");\n  },\n};\n\n// ============================================================================\n// 统一供应商（Universal Provider）API\n// ============================================================================\n\nexport const universalProvidersApi = {\n  /**\n   * 获取所有统一供应商\n   */\n  async getAll(): Promise<UniversalProvidersMap> {\n    return await invoke(\"get_universal_providers\");\n  },\n\n  /**\n   * 获取单个统一供应商\n   */\n  async get(id: string): Promise<UniversalProvider | null> {\n    return await invoke(\"get_universal_provider\", { id });\n  },\n\n  /**\n   * 添加或更新统一供应商\n   */\n  async upsert(provider: UniversalProvider): Promise<boolean> {\n    return await invoke(\"upsert_universal_provider\", { provider });\n  },\n\n  /**\n   * 删除统一供应商\n   */\n  async delete(id: string): Promise<boolean> {\n    return await invoke(\"delete_universal_provider\", { id });\n  },\n\n  /**\n   * 手动同步统一供应商到各应用\n   */\n  async sync(id: string): Promise<boolean> {\n    return await invoke(\"sync_universal_provider\", { id });\n  },\n};\n"
  },
  {
    "path": "src/lib/api/proxy.ts",
    "content": "import { invoke } from \"@tauri-apps/api/core\";\nimport type {\n  ProxyConfig,\n  ProxyStatus,\n  ProxyServerInfo,\n  ProxyTakeoverStatus,\n  GlobalProxyConfig,\n  AppProxyConfig,\n} from \"@/types/proxy\";\n\nexport const proxyApi = {\n  // ========== 代理服务器控制 API ==========\n\n  // 启动代理服务器\n  async startProxyServer(): Promise<ProxyServerInfo> {\n    return invoke(\"start_proxy_server\");\n  },\n\n  // 停止代理服务器并恢复配置\n  async stopProxyWithRestore(): Promise<void> {\n    return invoke(\"stop_proxy_with_restore\");\n  },\n\n  // 获取代理服务器状态\n  async getProxyStatus(): Promise<ProxyStatus> {\n    return invoke(\"get_proxy_status\");\n  },\n\n  // 检查代理服务器是否正在运行\n  async isProxyRunning(): Promise<boolean> {\n    return invoke(\"is_proxy_running\");\n  },\n\n  // 检查是否处于接管模式\n  async isLiveTakeoverActive(): Promise<boolean> {\n    return invoke(\"is_live_takeover_active\");\n  },\n\n  // 代理模式下切换供应商\n  async switchProxyProvider(\n    appType: string,\n    providerId: string,\n  ): Promise<void> {\n    return invoke(\"switch_proxy_provider\", { appType, providerId });\n  },\n\n  // ========== 接管状态 API ==========\n\n  // 获取各应用接管状态\n  async getProxyTakeoverStatus(): Promise<ProxyTakeoverStatus> {\n    return invoke(\"get_proxy_takeover_status\");\n  },\n\n  // 为指定应用开启/关闭接管\n  async setProxyTakeoverForApp(\n    appType: string,\n    enabled: boolean,\n  ): Promise<void> {\n    return invoke(\"set_proxy_takeover_for_app\", { appType, enabled });\n  },\n\n  // ========== Legacy 代理配置 API (兼容) ==========\n\n  // 获取代理配置（旧版 v2 兼容接口）\n  async getProxyConfig(): Promise<ProxyConfig> {\n    return invoke(\"get_proxy_config\");\n  },\n\n  // 更新代理配置（旧版 v2 兼容接口）\n  async updateProxyConfig(config: ProxyConfig): Promise<void> {\n    return invoke(\"update_proxy_config\", { config });\n  },\n\n  // ========== v3+ 全局/应用级配置 API ==========\n\n  // 获取全局代理配置\n  async getGlobalProxyConfig(): Promise<GlobalProxyConfig> {\n    return invoke(\"get_global_proxy_config\");\n  },\n\n  // 更新全局代理配置\n  async updateGlobalProxyConfig(config: GlobalProxyConfig): Promise<void> {\n    return invoke(\"update_global_proxy_config\", { config });\n  },\n\n  // 获取指定应用的代理配置\n  async getProxyConfigForApp(appType: string): Promise<AppProxyConfig> {\n    return invoke(\"get_proxy_config_for_app\", { appType });\n  },\n\n  // 更新指定应用的代理配置\n  async updateProxyConfigForApp(config: AppProxyConfig): Promise<void> {\n    return invoke(\"update_proxy_config_for_app\", { config });\n  },\n\n  // ========== 计费默认配置 API ==========\n\n  // 获取默认成本倍率\n  async getDefaultCostMultiplier(appType: string): Promise<string> {\n    return invoke(\"get_default_cost_multiplier\", { appType });\n  },\n\n  // 设置默认成本倍率\n  async setDefaultCostMultiplier(\n    appType: string,\n    value: string,\n  ): Promise<void> {\n    return invoke(\"set_default_cost_multiplier\", { appType, value });\n  },\n\n  // 获取计费模式来源\n  async getPricingModelSource(appType: string): Promise<string> {\n    return invoke(\"get_pricing_model_source\", { appType });\n  },\n\n  // 设置计费模式来源\n  async setPricingModelSource(appType: string, value: string): Promise<void> {\n    return invoke(\"set_pricing_model_source\", { appType, value });\n  },\n};\n"
  },
  {
    "path": "src/lib/api/sessions.ts",
    "content": "import { invoke } from \"@tauri-apps/api/core\";\nimport type { SessionMessage, SessionMeta } from \"@/types\";\n\nexport interface DeleteSessionOptions {\n  providerId: string;\n  sessionId: string;\n  sourcePath: string;\n}\n\nexport const sessionsApi = {\n  async list(): Promise<SessionMeta[]> {\n    return await invoke(\"list_sessions\");\n  },\n\n  async getMessages(\n    providerId: string,\n    sourcePath: string,\n  ): Promise<SessionMessage[]> {\n    return await invoke(\"get_session_messages\", { providerId, sourcePath });\n  },\n\n  async delete(options: DeleteSessionOptions): Promise<boolean> {\n    const { providerId, sessionId, sourcePath } = options;\n    return await invoke(\"delete_session\", {\n      providerId,\n      sessionId,\n      sourcePath,\n    });\n  },\n\n  async launchTerminal(options: {\n    command: string;\n    cwd?: string | null;\n    customConfig?: string | null;\n  }): Promise<boolean> {\n    const { command, cwd, customConfig } = options;\n    return await invoke(\"launch_session_terminal\", {\n      command,\n      cwd,\n      customConfig,\n    });\n  },\n};\n"
  },
  {
    "path": "src/lib/api/settings.ts",
    "content": "import { invoke } from \"@tauri-apps/api/core\";\nimport type { Settings, WebDavSyncSettings, RemoteSnapshotInfo } from \"@/types\";\nimport type { AppId } from \"./types\";\n\nexport interface ConfigTransferResult {\n  success: boolean;\n  message: string;\n  filePath?: string;\n  backupId?: string;\n}\n\nexport interface WebDavTestResult {\n  success: boolean;\n  message?: string;\n}\n\nexport interface WebDavSyncResult {\n  status: string;\n}\n\nexport const settingsApi = {\n  async get(): Promise<Settings> {\n    return await invoke(\"get_settings\");\n  },\n\n  async save(settings: Settings): Promise<boolean> {\n    return await invoke(\"save_settings\", { settings });\n  },\n\n  async restart(): Promise<boolean> {\n    return await invoke(\"restart_app\");\n  },\n\n  async checkUpdates(): Promise<void> {\n    await invoke(\"check_for_updates\");\n  },\n\n  async isPortable(): Promise<boolean> {\n    return await invoke(\"is_portable_mode\");\n  },\n\n  async getConfigDir(appId: AppId): Promise<string> {\n    return await invoke(\"get_config_dir\", { app: appId });\n  },\n\n  async openConfigFolder(appId: AppId): Promise<void> {\n    await invoke(\"open_config_folder\", { app: appId });\n  },\n\n  async selectConfigDirectory(defaultPath?: string): Promise<string | null> {\n    return await invoke(\"pick_directory\", { defaultPath });\n  },\n\n  async getClaudeCodeConfigPath(): Promise<string> {\n    return await invoke(\"get_claude_code_config_path\");\n  },\n\n  async getAppConfigPath(): Promise<string> {\n    return await invoke(\"get_app_config_path\");\n  },\n\n  async openAppConfigFolder(): Promise<void> {\n    await invoke(\"open_app_config_folder\");\n  },\n\n  async getAppConfigDirOverride(): Promise<string | null> {\n    return await invoke(\"get_app_config_dir_override\");\n  },\n\n  async setAppConfigDirOverride(path: string | null): Promise<boolean> {\n    return await invoke(\"set_app_config_dir_override\", { path });\n  },\n\n  async applyClaudePluginConfig(options: {\n    official: boolean;\n  }): Promise<boolean> {\n    const { official } = options;\n    return await invoke(\"apply_claude_plugin_config\", { official });\n  },\n\n  async applyClaudeOnboardingSkip(): Promise<boolean> {\n    return await invoke(\"apply_claude_onboarding_skip\");\n  },\n\n  async clearClaudeOnboardingSkip(): Promise<boolean> {\n    return await invoke(\"clear_claude_onboarding_skip\");\n  },\n\n  async saveFileDialog(defaultName: string): Promise<string | null> {\n    return await invoke(\"save_file_dialog\", { defaultName });\n  },\n\n  async openFileDialog(): Promise<string | null> {\n    return await invoke(\"open_file_dialog\");\n  },\n\n  async exportConfigToFile(filePath: string): Promise<ConfigTransferResult> {\n    return await invoke(\"export_config_to_file\", { filePath });\n  },\n\n  async importConfigFromFile(filePath: string): Promise<ConfigTransferResult> {\n    return await invoke(\"import_config_from_file\", { filePath });\n  },\n\n  // ─── WebDAV sync ──────────────────────────────────────────\n\n  async webdavTestConnection(\n    settings: WebDavSyncSettings,\n    preserveEmptyPassword = true,\n  ): Promise<WebDavTestResult> {\n    return await invoke(\"webdav_test_connection\", {\n      settings,\n      preserveEmptyPassword,\n    });\n  },\n\n  async webdavSyncUpload(): Promise<WebDavSyncResult> {\n    return await invoke(\"webdav_sync_upload\");\n  },\n\n  async webdavSyncDownload(): Promise<WebDavSyncResult> {\n    return await invoke(\"webdav_sync_download\");\n  },\n\n  async webdavSyncSaveSettings(\n    settings: WebDavSyncSettings,\n    passwordTouched = false,\n  ): Promise<{ success: boolean }> {\n    return await invoke(\"webdav_sync_save_settings\", {\n      settings,\n      passwordTouched,\n    });\n  },\n\n  async webdavSyncFetchRemoteInfo(): Promise<\n    RemoteSnapshotInfo | { empty: true }\n  > {\n    return await invoke(\"webdav_sync_fetch_remote_info\");\n  },\n\n  async syncCurrentProvidersLive(): Promise<void> {\n    const result = (await invoke(\"sync_current_providers_live\")) as {\n      success?: boolean;\n      message?: string;\n    };\n    if (!result?.success) {\n      throw new Error(result?.message || \"Sync current providers failed\");\n    }\n  },\n\n  async openExternal(url: string): Promise<void> {\n    try {\n      const u = new URL(url);\n      const scheme = u.protocol.replace(\":\", \"\").toLowerCase();\n      if (scheme !== \"http\" && scheme !== \"https\") {\n        throw new Error(\"Unsupported URL scheme\");\n      }\n    } catch {\n      throw new Error(\"Invalid URL\");\n    }\n    await invoke(\"open_external\", { url });\n  },\n\n  async setAutoLaunch(enabled: boolean): Promise<boolean> {\n    return await invoke(\"set_auto_launch\", { enabled });\n  },\n\n  async getAutoLaunchStatus(): Promise<boolean> {\n    return await invoke(\"get_auto_launch_status\");\n  },\n\n  async getToolVersions(\n    tools?: string[],\n    wslShellByTool?: Record<\n      string,\n      { wslShell?: string | null; wslShellFlag?: string | null }\n    >,\n  ): Promise<\n    Array<{\n      name: string;\n      version: string | null;\n      latest_version: string | null;\n      error: string | null;\n      env_type: \"windows\" | \"wsl\" | \"macos\" | \"linux\" | \"unknown\";\n      wsl_distro: string | null;\n    }>\n  > {\n    return await invoke(\"get_tool_versions\", { tools, wslShellByTool });\n  },\n\n  async getRectifierConfig(): Promise<RectifierConfig> {\n    return await invoke(\"get_rectifier_config\");\n  },\n\n  async setRectifierConfig(config: RectifierConfig): Promise<boolean> {\n    return await invoke(\"set_rectifier_config\", { config });\n  },\n\n  async getOptimizerConfig(): Promise<OptimizerConfig> {\n    return await invoke(\"get_optimizer_config\");\n  },\n\n  async setOptimizerConfig(config: OptimizerConfig): Promise<boolean> {\n    return await invoke(\"set_optimizer_config\", { config });\n  },\n\n  async getLogConfig(): Promise<LogConfig> {\n    return await invoke(\"get_log_config\");\n  },\n\n  async setLogConfig(config: LogConfig): Promise<boolean> {\n    return await invoke(\"set_log_config\", { config });\n  },\n};\n\nexport interface RectifierConfig {\n  enabled: boolean;\n  requestThinkingSignature: boolean;\n  requestThinkingBudget: boolean;\n}\n\nexport interface OptimizerConfig {\n  enabled: boolean;\n  thinkingOptimizer: boolean;\n  cacheInjection: boolean;\n  cacheTtl: string;\n}\n\nexport interface LogConfig {\n  enabled: boolean;\n  level: \"error\" | \"warn\" | \"info\" | \"debug\" | \"trace\";\n}\n\nexport interface BackupEntry {\n  filename: string;\n  sizeBytes: number;\n  createdAt: string;\n}\n\nexport const backupsApi = {\n  async createDbBackup(): Promise<string> {\n    return await invoke(\"create_db_backup\");\n  },\n\n  async listDbBackups(): Promise<BackupEntry[]> {\n    return await invoke(\"list_db_backups\");\n  },\n\n  async restoreDbBackup(filename: string): Promise<string> {\n    return await invoke(\"restore_db_backup\", { filename });\n  },\n\n  async renameDbBackup(oldFilename: string, newName: string): Promise<string> {\n    return await invoke(\"rename_db_backup\", { oldFilename, newName });\n  },\n\n  async deleteDbBackup(filename: string): Promise<void> {\n    await invoke(\"delete_db_backup\", { filename });\n  },\n};\n"
  },
  {
    "path": "src/lib/api/skills.ts",
    "content": "import { invoke } from \"@tauri-apps/api/core\";\n\nimport type { AppId } from \"@/lib/api/types\";\n\nexport type AppType = \"claude\" | \"codex\" | \"gemini\" | \"opencode\" | \"openclaw\";\n\n/** Skill 应用启用状态 */\nexport interface SkillApps {\n  claude: boolean;\n  codex: boolean;\n  gemini: boolean;\n  opencode: boolean;\n  openclaw: boolean;\n}\n\n/** 已安装的 Skill（v3.10.0+ 统一结构） */\nexport interface InstalledSkill {\n  id: string;\n  name: string;\n  description?: string;\n  directory: string;\n  repoOwner?: string;\n  repoName?: string;\n  repoBranch?: string;\n  readmeUrl?: string;\n  apps: SkillApps;\n  installedAt: number;\n}\n\nexport interface SkillUninstallResult {\n  backupPath?: string;\n}\n\nexport interface SkillBackupEntry {\n  backupId: string;\n  backupPath: string;\n  createdAt: number;\n  skill: InstalledSkill;\n}\n\n/** 可发现的 Skill（来自仓库） */\nexport interface DiscoverableSkill {\n  key: string;\n  name: string;\n  description: string;\n  directory: string;\n  readmeUrl?: string;\n  repoOwner: string;\n  repoName: string;\n  repoBranch: string;\n}\n\n/** 未管理的 Skill（用于导入） */\nexport interface UnmanagedSkill {\n  directory: string;\n  name: string;\n  description?: string;\n  foundIn: string[];\n  path: string;\n}\n\n/** 导入已有 Skill 时提交的应用启用状态 */\nexport interface ImportSkillSelection {\n  directory: string;\n  apps: SkillApps;\n}\n\n/** 技能对象（兼容旧 API） */\nexport interface Skill {\n  key: string;\n  name: string;\n  description: string;\n  directory: string;\n  readmeUrl?: string;\n  installed: boolean;\n  repoOwner?: string;\n  repoName?: string;\n  repoBranch?: string;\n}\n\n/** 仓库配置 */\nexport interface SkillRepo {\n  owner: string;\n  name: string;\n  branch: string;\n  enabled: boolean;\n}\n\n// ========== API ==========\n\nexport const skillsApi = {\n  // ========== 统一管理 API (v3.10.0+) ==========\n\n  /** 获取所有已安装的 Skills */\n  async getInstalled(): Promise<InstalledSkill[]> {\n    return await invoke(\"get_installed_skills\");\n  },\n\n  /** 获取可恢复的 Skill 备份列表 */\n  async getBackups(): Promise<SkillBackupEntry[]> {\n    return await invoke(\"get_skill_backups\");\n  },\n\n  /** 删除 Skill 备份 */\n  async deleteBackup(backupId: string): Promise<boolean> {\n    return await invoke(\"delete_skill_backup\", { backupId });\n  },\n\n  /** 安装 Skill（统一安装） */\n  async installUnified(\n    skill: DiscoverableSkill,\n    currentApp: AppId,\n  ): Promise<InstalledSkill> {\n    return await invoke(\"install_skill_unified\", { skill, currentApp });\n  },\n\n  /** 卸载 Skill（统一卸载） */\n  async uninstallUnified(id: string): Promise<SkillUninstallResult> {\n    return await invoke(\"uninstall_skill_unified\", { id });\n  },\n\n  /** 从备份恢复 Skill */\n  async restoreBackup(\n    backupId: string,\n    currentApp: AppId,\n  ): Promise<InstalledSkill> {\n    return await invoke(\"restore_skill_backup\", { backupId, currentApp });\n  },\n\n  /** 切换 Skill 的应用启用状态 */\n  async toggleApp(id: string, app: AppId, enabled: boolean): Promise<boolean> {\n    return await invoke(\"toggle_skill_app\", { id, app, enabled });\n  },\n\n  /** 扫描未管理的 Skills */\n  async scanUnmanaged(): Promise<UnmanagedSkill[]> {\n    return await invoke(\"scan_unmanaged_skills\");\n  },\n\n  /** 从应用目录导入 Skills */\n  async importFromApps(\n    imports: ImportSkillSelection[],\n  ): Promise<InstalledSkill[]> {\n    return await invoke(\"import_skills_from_apps\", { imports });\n  },\n\n  /** 发现可安装的 Skills（从仓库获取） */\n  async discoverAvailable(): Promise<DiscoverableSkill[]> {\n    return await invoke(\"discover_available_skills\");\n  },\n\n  // ========== 兼容旧 API ==========\n\n  /** 获取技能列表（兼容旧 API） */\n  async getAll(app: AppId = \"claude\"): Promise<Skill[]> {\n    if (app === \"claude\") {\n      return await invoke(\"get_skills\");\n    }\n    return await invoke(\"get_skills_for_app\", { app });\n  },\n\n  /** 安装技能（兼容旧 API） */\n  async install(directory: string, app: AppId = \"claude\"): Promise<boolean> {\n    if (app === \"claude\") {\n      return await invoke(\"install_skill\", { directory });\n    }\n    return await invoke(\"install_skill_for_app\", { app, directory });\n  },\n\n  /** 卸载技能（兼容旧 API） */\n  async uninstall(\n    directory: string,\n    app: AppId = \"claude\",\n  ): Promise<SkillUninstallResult> {\n    if (app === \"claude\") {\n      return await invoke(\"uninstall_skill\", { directory });\n    }\n    return await invoke(\"uninstall_skill_for_app\", { app, directory });\n  },\n\n  // ========== 仓库管理 ==========\n\n  /** 获取仓库列表 */\n  async getRepos(): Promise<SkillRepo[]> {\n    return await invoke(\"get_skill_repos\");\n  },\n\n  /** 添加仓库 */\n  async addRepo(repo: SkillRepo): Promise<boolean> {\n    return await invoke(\"add_skill_repo\", { repo });\n  },\n\n  /** 删除仓库 */\n  async removeRepo(owner: string, name: string): Promise<boolean> {\n    return await invoke(\"remove_skill_repo\", { owner, name });\n  },\n\n  // ========== ZIP 安装 ==========\n\n  /** 打开 ZIP 文件选择对话框 */\n  async openZipFileDialog(): Promise<string | null> {\n    return await invoke(\"open_zip_file_dialog\");\n  },\n\n  /** 从 ZIP 文件安装 Skills */\n  async installFromZip(\n    filePath: string,\n    currentApp: AppId,\n  ): Promise<InstalledSkill[]> {\n    return await invoke(\"install_skills_from_zip\", { filePath, currentApp });\n  },\n};\n"
  },
  {
    "path": "src/lib/api/types.ts",
    "content": "// 前端统一使用 AppId 作为应用标识（与后端命令参数 `app` 一致）\nexport type AppId = \"claude\" | \"codex\" | \"gemini\" | \"opencode\" | \"openclaw\";\n"
  },
  {
    "path": "src/lib/api/usage.ts",
    "content": "import { invoke } from \"@tauri-apps/api/core\";\nimport type {\n  UsageSummary,\n  DailyStats,\n  ProviderStats,\n  ModelStats,\n  RequestLog,\n  LogFilters,\n  ModelPricing,\n  ProviderLimitStatus,\n  PaginatedLogs,\n} from \"@/types/usage\";\nimport type { UsageResult } from \"@/types\";\nimport type { AppId } from \"./types\";\nimport type { TemplateType } from \"@/config/constants\";\n\nexport const usageApi = {\n  // Provider usage script methods\n  query: async (providerId: string, appId: AppId): Promise<UsageResult> => {\n    return invoke(\"queryProviderUsage\", { providerId, app: appId });\n  },\n\n  testScript: async (\n    providerId: string,\n    appId: AppId,\n    scriptCode: string,\n    timeout?: number,\n    apiKey?: string,\n    baseUrl?: string,\n    accessToken?: string,\n    userId?: string,\n    templateType?: TemplateType,\n  ): Promise<UsageResult> => {\n    return invoke(\"testUsageScript\", {\n      providerId,\n      app: appId,\n      scriptCode,\n      timeout,\n      apiKey,\n      baseUrl,\n      accessToken,\n      userId,\n      templateType,\n    });\n  },\n\n  // Proxy usage statistics methods\n  getUsageSummary: async (\n    startDate?: number,\n    endDate?: number,\n  ): Promise<UsageSummary> => {\n    return invoke(\"get_usage_summary\", { startDate, endDate });\n  },\n\n  getUsageTrends: async (\n    startDate?: number,\n    endDate?: number,\n  ): Promise<DailyStats[]> => {\n    return invoke(\"get_usage_trends\", { startDate, endDate });\n  },\n\n  getProviderStats: async (): Promise<ProviderStats[]> => {\n    return invoke(\"get_provider_stats\");\n  },\n\n  getModelStats: async (): Promise<ModelStats[]> => {\n    return invoke(\"get_model_stats\");\n  },\n\n  getRequestLogs: async (\n    filters: LogFilters,\n    page: number = 0,\n    pageSize: number = 20,\n  ): Promise<PaginatedLogs> => {\n    return invoke(\"get_request_logs\", {\n      filters,\n      page,\n      pageSize,\n    });\n  },\n\n  getRequestDetail: async (requestId: string): Promise<RequestLog | null> => {\n    return invoke(\"get_request_detail\", { requestId });\n  },\n\n  getModelPricing: async (): Promise<ModelPricing[]> => {\n    return invoke(\"get_model_pricing\");\n  },\n\n  updateModelPricing: async (\n    modelId: string,\n    displayName: string,\n    inputCost: string,\n    outputCost: string,\n    cacheReadCost: string,\n    cacheCreationCost: string,\n  ): Promise<void> => {\n    return invoke(\"update_model_pricing\", {\n      modelId,\n      displayName,\n      inputCost,\n      outputCost,\n      cacheReadCost,\n      cacheCreationCost,\n    });\n  },\n\n  deleteModelPricing: async (modelId: string): Promise<void> => {\n    return invoke(\"delete_model_pricing\", { modelId });\n  },\n\n  checkProviderLimits: async (\n    providerId: string,\n    appType: string,\n  ): Promise<ProviderLimitStatus> => {\n    return invoke(\"check_provider_limits\", { providerId, appType });\n  },\n};\n"
  },
  {
    "path": "src/lib/api/vscode.ts",
    "content": "import { invoke } from \"@tauri-apps/api/core\";\nimport type { CustomEndpoint } from \"@/types\";\nimport type { AppId } from \"./types\";\n\nexport interface EndpointLatencyResult {\n  url: string;\n  latency: number | null;\n  status?: number;\n  error?: string;\n}\n\nexport const vscodeApi = {\n  async getLiveProviderSettings(appId: AppId) {\n    return await invoke(\"read_live_provider_settings\", { app: appId });\n  },\n\n  async testApiEndpoints(\n    urls: string[],\n    options?: { timeoutSecs?: number },\n  ): Promise<EndpointLatencyResult[]> {\n    return await invoke(\"test_api_endpoints\", {\n      urls,\n      timeoutSecs: options?.timeoutSecs,\n    });\n  },\n\n  async getCustomEndpoints(\n    appId: AppId,\n    providerId: string,\n  ): Promise<CustomEndpoint[]> {\n    return await invoke(\"get_custom_endpoints\", {\n      app: appId,\n      providerId: providerId,\n    });\n  },\n\n  async addCustomEndpoint(\n    appId: AppId,\n    providerId: string,\n    url: string,\n  ): Promise<void> {\n    await invoke(\"add_custom_endpoint\", {\n      app: appId,\n      providerId: providerId,\n      url,\n    });\n  },\n\n  async removeCustomEndpoint(\n    appId: AppId,\n    providerId: string,\n    url: string,\n  ): Promise<void> {\n    await invoke(\"remove_custom_endpoint\", {\n      app: appId,\n      providerId: providerId,\n      url,\n    });\n  },\n\n  async updateEndpointLastUsed(\n    appId: AppId,\n    providerId: string,\n    url: string,\n  ): Promise<void> {\n    await invoke(\"update_endpoint_last_used\", {\n      app: appId,\n      providerId: providerId,\n      url,\n    });\n  },\n\n  async exportConfigToFile(filePath: string) {\n    return await invoke(\"export_config_to_file\", {\n      filePath,\n    });\n  },\n\n  async importConfigFromFile(filePath: string) {\n    return await invoke(\"import_config_from_file\", {\n      filePath,\n    });\n  },\n\n  async saveFileDialog(defaultName: string): Promise<string | null> {\n    return await invoke(\"save_file_dialog\", {\n      defaultName,\n    });\n  },\n\n  async openFileDialog(): Promise<string | null> {\n    return await invoke(\"open_file_dialog\");\n  },\n};\n"
  },
  {
    "path": "src/lib/api/workspace.ts",
    "content": "import { invoke } from \"@tauri-apps/api/core\";\n\nexport interface DailyMemoryFileInfo {\n  filename: string;\n  date: string;\n  sizeBytes: number;\n  modifiedAt: number;\n  preview: string;\n}\n\nexport interface DailyMemorySearchResult {\n  filename: string;\n  date: string;\n  sizeBytes: number;\n  modifiedAt: number;\n  snippet: string;\n  matchCount: number;\n}\n\nexport const workspaceApi = {\n  async readFile(filename: string): Promise<string | null> {\n    return invoke<string | null>(\"read_workspace_file\", { filename });\n  },\n\n  async writeFile(filename: string, content: string): Promise<void> {\n    return invoke<void>(\"write_workspace_file\", { filename, content });\n  },\n\n  async listDailyMemoryFiles(): Promise<DailyMemoryFileInfo[]> {\n    return invoke<DailyMemoryFileInfo[]>(\"list_daily_memory_files\");\n  },\n\n  async readDailyMemoryFile(filename: string): Promise<string | null> {\n    return invoke<string | null>(\"read_daily_memory_file\", { filename });\n  },\n\n  async writeDailyMemoryFile(filename: string, content: string): Promise<void> {\n    return invoke<void>(\"write_daily_memory_file\", { filename, content });\n  },\n\n  async deleteDailyMemoryFile(filename: string): Promise<void> {\n    return invoke<void>(\"delete_daily_memory_file\", { filename });\n  },\n\n  async searchDailyMemoryFiles(\n    query: string,\n  ): Promise<DailyMemorySearchResult[]> {\n    return invoke<DailyMemorySearchResult[]>(\"search_daily_memory_files\", {\n      query,\n    });\n  },\n\n  async openDirectory(subdir: \"workspace\" | \"memory\"): Promise<void> {\n    await invoke(\"open_workspace_directory\", { subdir });\n  },\n};\n"
  },
  {
    "path": "src/lib/authBinding.ts",
    "content": "import type { ProviderMeta } from \"@/types\";\n\nexport function resolveManagedAccountId(\n  meta: ProviderMeta | undefined,\n  authProvider: string,\n): string | null {\n  const binding = meta?.authBinding;\n\n  if (\n    binding?.source === \"managed_account\" &&\n    binding.authProvider === authProvider\n  ) {\n    return binding.accountId ?? null;\n  }\n\n  if (authProvider === \"github_copilot\") {\n    return meta?.githubAccountId ?? null;\n  }\n\n  return null;\n}\n"
  },
  {
    "path": "src/lib/errors/skillErrorParser.ts",
    "content": "import { TFunction } from \"i18next\";\n\n/**\n * 结构化错误对象\n */\nexport interface SkillError {\n  code: string;\n  context: Record<string, string>;\n  suggestion?: string;\n}\n\n/**\n * 尝试解析后端返回的错误字符串\n * 如果是 JSON 格式，返回结构化错误；否则返回 null\n */\nexport function parseSkillError(errorString: string): SkillError | null {\n  try {\n    const parsed = JSON.parse(errorString);\n    if (parsed.code && parsed.context) {\n      return parsed as SkillError;\n    }\n  } catch {\n    // 不是 JSON 格式，返回 null\n  }\n  return null;\n}\n\n/**\n * 将错误码映射到 i18n key\n */\nfunction getErrorI18nKey(code: string): string {\n  const mapping: Record<string, string> = {\n    SKILL_NOT_FOUND: \"skills.error.skillNotFound\",\n    MISSING_REPO_INFO: \"skills.error.missingRepoInfo\",\n    DOWNLOAD_TIMEOUT: \"skills.error.downloadTimeout\",\n    DOWNLOAD_FAILED: \"skills.error.downloadFailed\",\n    SKILL_DIR_NOT_FOUND: \"skills.error.skillDirNotFound\",\n    SKILL_DIRECTORY_CONFLICT: \"skills.error.directoryConflict\",\n    EMPTY_ARCHIVE: \"skills.error.emptyArchive\",\n    GET_HOME_DIR_FAILED: \"skills.error.getHomeDirFailed\",\n    NO_SKILLS_IN_ZIP: \"skills.error.noSkillsInZip\",\n  };\n\n  return mapping[code] || \"skills.error.unknownError\";\n}\n\n/**\n * 将建议码映射到 i18n key\n */\nfunction getSuggestionI18nKey(suggestion: string): string {\n  const mapping: Record<string, string> = {\n    checkNetwork: \"skills.error.suggestion.checkNetwork\",\n    checkProxy: \"skills.error.suggestion.checkProxy\",\n    retryLater: \"skills.error.suggestion.retryLater\",\n    checkRepoUrl: \"skills.error.suggestion.checkRepoUrl\",\n    checkPermission: \"skills.error.suggestion.checkPermission\",\n    uninstallFirst: \"skills.error.suggestion.uninstallFirst\",\n    checkZipContent: \"skills.error.suggestion.checkZipContent\",\n    http403: \"skills.error.http403\",\n    http404: \"skills.error.http404\",\n    http429: \"skills.error.http429\",\n  };\n\n  return mapping[suggestion] || suggestion;\n}\n\n/**\n * 格式化技能错误为用户友好的消息\n * @param errorString 后端返回的错误字符串\n * @param t i18next 翻译函数\n * @param defaultTitle 默认标题的 i18n key（如 \"skills.installFailed\"）\n * @returns 包含标题和描述的对象\n */\nexport function formatSkillError(\n  errorString: string,\n  t: TFunction,\n  defaultTitle: string = \"skills.installFailed\",\n): { title: string; description: string } {\n  const parsedError = parseSkillError(errorString);\n\n  if (!parsedError) {\n    // 如果不是结构化错误，返回原始错误字符串\n    return {\n      title: t(defaultTitle),\n      description: errorString || t(\"common.error\"),\n    };\n  }\n\n  const { code, context, suggestion } = parsedError;\n\n  // 获取错误消息的 i18n key\n  const errorKey = getErrorI18nKey(code);\n\n  // 构建描述（错误消息 + 建议）\n  let description = t(errorKey, context);\n\n  // 如果有建议，追加到描述中\n  if (suggestion) {\n    const suggestionKey = getSuggestionI18nKey(suggestion);\n    const suggestionText = t(suggestionKey);\n    description += `\\n\\n${suggestionText}`;\n  }\n\n  return {\n    title: t(defaultTitle),\n    description,\n  };\n}\n"
  },
  {
    "path": "src/lib/platform.ts",
    "content": "// 轻量平台检测，避免在 SSR 或无 navigator 的环境报错\nexport const isMac = (): boolean => {\n  try {\n    const ua = navigator.userAgent || \"\";\n    const plat = (navigator.platform || \"\").toLowerCase();\n    return /mac/i.test(ua) || plat.includes(\"mac\");\n  } catch {\n    return false;\n  }\n};\n\nexport const isWindows = (): boolean => {\n  try {\n    const ua = navigator.userAgent || \"\";\n    return /windows|win32|win64/i.test(ua);\n  } catch {\n    return false;\n  }\n};\n\nexport const isLinux = (): boolean => {\n  try {\n    const ua = navigator.userAgent || \"\";\n    // WebKitGTK/Chromium 在 Linux/Wayland/X11 下 UA 通常包含 Linux 或 X11\n    return (\n      /linux|x11/i.test(ua) && !/android/i.test(ua) && !isMac() && !isWindows()\n    );\n  } catch {\n    return false;\n  }\n};\n"
  },
  {
    "path": "src/lib/query/failover.ts",
    "content": "import { useQuery, useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { failoverApi } from \"@/lib/api/failover\";\nimport { toast } from \"sonner\";\nimport { useTranslation } from \"react-i18next\";\nimport { extractErrorMessage } from \"@/utils/errorUtils\";\n\n// ========== 熔断器 Hooks ==========\n\n/**\n * 获取供应商健康状态\n */\nexport function useProviderHealth(providerId: string, appType: string) {\n  return useQuery({\n    queryKey: [\"providerHealth\", providerId, appType],\n    queryFn: () => failoverApi.getProviderHealth(providerId, appType),\n    enabled: !!providerId && !!appType,\n    refetchInterval: 5000, // 每 5 秒刷新一次\n    retry: false,\n  });\n}\n\n/**\n * 重置熔断器\n *\n * 重置后后端会检查是否应该切回优先级更高的供应商，\n * 因此需要同时刷新供应商列表和代理状态。\n */\nexport function useResetCircuitBreaker() {\n  const queryClient = useQueryClient();\n\n  return useMutation({\n    mutationFn: ({\n      providerId,\n      appType,\n    }: {\n      providerId: string;\n      appType: string;\n    }) => failoverApi.resetCircuitBreaker(providerId, appType),\n    onSuccess: (_, variables) => {\n      // 刷新健康状态\n      queryClient.invalidateQueries({\n        queryKey: [\"providerHealth\", variables.providerId, variables.appType],\n      });\n      // 刷新供应商列表（因为可能发生了自动恢复切换）\n      queryClient.invalidateQueries({\n        queryKey: [\"providers\", variables.appType],\n      });\n      // 刷新代理状态（更新 active_targets）\n      queryClient.invalidateQueries({\n        queryKey: [\"proxyStatus\"],\n      });\n    },\n  });\n}\n\n/**\n * 获取熔断器配置\n */\nexport function useCircuitBreakerConfig() {\n  return useQuery({\n    queryKey: [\"circuitBreakerConfig\"],\n    queryFn: () => failoverApi.getCircuitBreakerConfig(),\n  });\n}\n\n/**\n * 更新熔断器配置\n */\nexport function useUpdateCircuitBreakerConfig() {\n  const queryClient = useQueryClient();\n\n  return useMutation({\n    mutationFn: failoverApi.updateCircuitBreakerConfig,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: [\"circuitBreakerConfig\"] });\n    },\n  });\n}\n\n/**\n * 获取熔断器统计信息\n */\nexport function useCircuitBreakerStats(providerId: string, appType: string) {\n  return useQuery({\n    queryKey: [\"circuitBreakerStats\", providerId, appType],\n    queryFn: () => failoverApi.getCircuitBreakerStats(providerId, appType),\n    enabled: !!providerId && !!appType,\n    refetchInterval: 5000, // 每 5 秒刷新一次\n  });\n}\n\n// ========== 故障转移队列 Hooks（新） ==========\n\n/**\n * 获取故障转移队列\n */\nexport function useFailoverQueue(appType: string) {\n  return useQuery({\n    queryKey: [\"failoverQueue\", appType],\n    queryFn: () => failoverApi.getFailoverQueue(appType),\n    enabled: !!appType,\n  });\n}\n\n/**\n * 获取可添加到队列的供应商\n */\nexport function useAvailableProvidersForFailover(appType: string) {\n  return useQuery({\n    queryKey: [\"availableProvidersForFailover\", appType],\n    queryFn: () => failoverApi.getAvailableProvidersForFailover(appType),\n    enabled: !!appType,\n  });\n}\n\n/**\n * 添加供应商到故障转移队列\n */\nexport function useAddToFailoverQueue() {\n  const queryClient = useQueryClient();\n\n  return useMutation({\n    mutationFn: ({\n      appType,\n      providerId,\n    }: {\n      appType: string;\n      providerId: string;\n    }) => failoverApi.addToFailoverQueue(appType, providerId),\n    onSuccess: (_, variables) => {\n      queryClient.invalidateQueries({\n        queryKey: [\"failoverQueue\", variables.appType],\n      });\n      queryClient.invalidateQueries({\n        queryKey: [\"availableProvidersForFailover\", variables.appType],\n      });\n      queryClient.invalidateQueries({\n        queryKey: [\"providers\", variables.appType],\n      });\n    },\n  });\n}\n\n/**\n * 从故障转移队列移除供应商\n */\nexport function useRemoveFromFailoverQueue() {\n  const queryClient = useQueryClient();\n\n  return useMutation({\n    mutationFn: ({\n      appType,\n      providerId,\n    }: {\n      appType: string;\n      providerId: string;\n    }) => failoverApi.removeFromFailoverQueue(appType, providerId),\n    onSuccess: (_, variables) => {\n      queryClient.invalidateQueries({\n        queryKey: [\"failoverQueue\", variables.appType],\n      });\n      queryClient.invalidateQueries({\n        queryKey: [\"availableProvidersForFailover\", variables.appType],\n      });\n      queryClient.invalidateQueries({\n        queryKey: [\"providers\", variables.appType],\n      });\n      // 清除该供应商的健康状态缓存（退出队列后不再需要健康监控）\n      queryClient.invalidateQueries({\n        queryKey: [\"providerHealth\", variables.providerId, variables.appType],\n      });\n      // 清除该供应商的熔断器统计缓存\n      queryClient.invalidateQueries({\n        queryKey: [\n          \"circuitBreakerStats\",\n          variables.providerId,\n          variables.appType,\n        ],\n      });\n    },\n  });\n}\n\n// ========== 自动故障转移开关 Hooks ==========\n\n/**\n * 获取指定应用的自动故障转移开关状态\n */\nexport function useAutoFailoverEnabled(appType: string) {\n  return useQuery({\n    queryKey: [\"autoFailoverEnabled\", appType],\n    queryFn: () => failoverApi.getAutoFailoverEnabled(appType),\n    // 默认值为 false（与后端保持一致）\n    placeholderData: false,\n  });\n}\n\n/**\n * 设置指定应用的自动故障转移开关状态\n */\nexport function useSetAutoFailoverEnabled() {\n  const queryClient = useQueryClient();\n  const { t } = useTranslation();\n\n  return useMutation({\n    mutationFn: ({ appType, enabled }: { appType: string; enabled: boolean }) =>\n      failoverApi.setAutoFailoverEnabled(appType, enabled),\n\n    // 乐观更新\n    onMutate: async ({ appType, enabled }) => {\n      await queryClient.cancelQueries({\n        queryKey: [\"autoFailoverEnabled\", appType],\n      });\n      const previousValue = queryClient.getQueryData<boolean>([\n        \"autoFailoverEnabled\",\n        appType,\n      ]);\n\n      queryClient.setQueryData([\"autoFailoverEnabled\", appType], enabled);\n\n      return { previousValue, appType };\n    },\n\n    onSuccess: (_data, variables) => {\n      const appLabel =\n        variables.appType === \"claude\"\n          ? \"Claude\"\n          : variables.appType === \"codex\"\n            ? \"Codex\"\n            : \"Gemini\";\n\n      toast.success(\n        variables.enabled\n          ? t(\"failover.enabled\", {\n              app: appLabel,\n              defaultValue: `${appLabel} 故障转移已启用`,\n            })\n          : t(\"failover.disabled\", {\n              app: appLabel,\n              defaultValue: `${appLabel} 故障转移已关闭`,\n            }),\n        { closeButton: true },\n      );\n    },\n\n    // 错误时回滚\n    onError: (error: Error, _variables, context) => {\n      if (context?.previousValue !== undefined) {\n        queryClient.setQueryData(\n          [\"autoFailoverEnabled\", context.appType],\n          context.previousValue,\n        );\n      }\n\n      const detail =\n        extractErrorMessage(error) ||\n        t(\"common.unknown\", { defaultValue: \"未知错误\" });\n      toast.error(\n        t(\"failover.toggleFailed\", {\n          detail,\n          defaultValue: `操作失败: ${detail}`,\n        }),\n      );\n    },\n\n    // 无论成功失败，都重新获取\n    onSettled: (_, __, variables) => {\n      queryClient.invalidateQueries({\n        queryKey: [\"autoFailoverEnabled\", variables.appType],\n      });\n      // 启用/关闭故障转移可能触发：\n      // - 立即切到队列 P1（当前供应商变化）\n      // - 队列为空时自动把当前供应商加入队列（队列内容变化）\n      queryClient.invalidateQueries({\n        queryKey: [\"failoverQueue\", variables.appType],\n      });\n      queryClient.invalidateQueries({\n        queryKey: [\"availableProvidersForFailover\", variables.appType],\n      });\n      queryClient.invalidateQueries({\n        queryKey: [\"providers\", variables.appType],\n      });\n      queryClient.invalidateQueries({\n        queryKey: [\"proxyStatus\"],\n      });\n    },\n  });\n}\n"
  },
  {
    "path": "src/lib/query/index.ts",
    "content": "export * from \"./queryClient\";\nexport * from \"./queries\";\nexport * from \"./mutations\";\nexport * from \"./proxy\";\n"
  },
  {
    "path": "src/lib/query/mutations.ts",
    "content": "import { useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { useTranslation } from \"react-i18next\";\nimport { toast } from \"sonner\";\nimport { providersApi, sessionsApi, settingsApi, type AppId } from \"@/lib/api\";\nimport type { DeleteSessionOptions } from \"@/lib/api/sessions\";\nimport type { SwitchResult } from \"@/lib/api/providers\";\nimport type { Provider, SessionMeta, Settings } from \"@/types\";\nimport { extractErrorMessage } from \"@/utils/errorUtils\";\nimport { generateUUID } from \"@/utils/uuid\";\nimport { openclawKeys } from \"@/hooks/useOpenClaw\";\n\nexport const useAddProviderMutation = (appId: AppId) => {\n  const queryClient = useQueryClient();\n  const { t } = useTranslation();\n\n  return useMutation({\n    mutationFn: async (\n      providerInput: Omit<Provider, \"id\"> & { providerKey?: string },\n    ) => {\n      let id: string;\n\n      if (appId === \"opencode\" || appId === \"openclaw\") {\n        if (\n          providerInput.category === \"omo\" ||\n          providerInput.category === \"omo-slim\"\n        ) {\n          const prefix = providerInput.category === \"omo\" ? \"omo\" : \"omo-slim\";\n          id = `${prefix}-${generateUUID()}`;\n        } else {\n          if (!providerInput.providerKey) {\n            throw new Error(`Provider key is required for ${appId}`);\n          }\n          id = providerInput.providerKey;\n        }\n      } else {\n        id = generateUUID();\n      }\n\n      const { providerKey: _providerKey, ...rest } = providerInput;\n\n      const newProvider: Provider = {\n        ...rest,\n        id,\n        createdAt: Date.now(),\n      };\n      delete (newProvider as any).providerKey;\n\n      await providersApi.add(newProvider, appId);\n      return newProvider;\n    },\n    onSuccess: async () => {\n      await queryClient.invalidateQueries({ queryKey: [\"providers\", appId] });\n\n      if (appId === \"opencode\") {\n        await queryClient.invalidateQueries({\n          queryKey: [\"omo\", \"current-provider-id\"],\n        });\n        await queryClient.invalidateQueries({\n          queryKey: [\"omo\", \"provider-count\"],\n        });\n        await queryClient.invalidateQueries({\n          queryKey: [\"omo-slim\", \"current-provider-id\"],\n        });\n        await queryClient.invalidateQueries({\n          queryKey: [\"omo-slim\", \"provider-count\"],\n        });\n      }\n\n      if (appId === \"openclaw\") {\n        await queryClient.invalidateQueries({\n          queryKey: openclawKeys.health,\n        });\n      }\n\n      try {\n        await providersApi.updateTrayMenu();\n      } catch (trayError) {\n        console.error(\n          \"Failed to update tray menu after adding provider\",\n          trayError,\n        );\n      }\n\n      toast.success(\n        t(\"notifications.providerAdded\", {\n          defaultValue: \"供应商已添加\",\n        }),\n        {\n          closeButton: true,\n        },\n      );\n    },\n    onError: (error: Error) => {\n      const detail = extractErrorMessage(error) || t(\"common.unknown\");\n      toast.error(\n        t(\"notifications.addFailed\", {\n          defaultValue: \"添加供应商失败: {{error}}\",\n          error: detail,\n        }),\n      );\n    },\n  });\n};\n\nexport const useUpdateProviderMutation = (appId: AppId) => {\n  const queryClient = useQueryClient();\n  const { t } = useTranslation();\n\n  return useMutation({\n    mutationFn: async (provider: Provider) => {\n      await providersApi.update(provider, appId);\n      return provider;\n    },\n    onSuccess: async () => {\n      await queryClient.invalidateQueries({ queryKey: [\"providers\", appId] });\n      if (appId === \"openclaw\") {\n        await queryClient.invalidateQueries({\n          queryKey: openclawKeys.health,\n        });\n      }\n      toast.success(\n        t(\"notifications.updateSuccess\", {\n          defaultValue: \"供应商更新成功\",\n        }),\n        {\n          closeButton: true,\n        },\n      );\n    },\n    onError: (error: Error) => {\n      const detail = extractErrorMessage(error) || t(\"common.unknown\");\n      toast.error(\n        t(\"notifications.updateFailed\", {\n          defaultValue: \"更新供应商失败: {{error}}\",\n          error: detail,\n        }),\n      );\n    },\n  });\n};\n\nexport const useDeleteProviderMutation = (appId: AppId) => {\n  const queryClient = useQueryClient();\n  const { t } = useTranslation();\n\n  return useMutation({\n    mutationFn: async (providerId: string) => {\n      await providersApi.delete(providerId, appId);\n    },\n    onSuccess: async () => {\n      await queryClient.invalidateQueries({ queryKey: [\"providers\", appId] });\n\n      if (appId === \"opencode\") {\n        await queryClient.invalidateQueries({\n          queryKey: [\"omo\", \"current-provider-id\"],\n        });\n        await queryClient.invalidateQueries({\n          queryKey: [\"omo\", \"provider-count\"],\n        });\n        await queryClient.invalidateQueries({\n          queryKey: [\"omo-slim\", \"current-provider-id\"],\n        });\n        await queryClient.invalidateQueries({\n          queryKey: [\"omo-slim\", \"provider-count\"],\n        });\n      }\n\n      if (appId === \"openclaw\") {\n        await queryClient.invalidateQueries({\n          queryKey: openclawKeys.health,\n        });\n      }\n\n      try {\n        await providersApi.updateTrayMenu();\n      } catch (trayError) {\n        console.error(\n          \"Failed to update tray menu after deleting provider\",\n          trayError,\n        );\n      }\n\n      toast.success(\n        t(\"notifications.deleteSuccess\", {\n          defaultValue: \"供应商已删除\",\n        }),\n        {\n          closeButton: true,\n        },\n      );\n    },\n    onError: (error: Error) => {\n      const detail = extractErrorMessage(error) || t(\"common.unknown\");\n      toast.error(\n        t(\"notifications.deleteFailed\", {\n          defaultValue: \"删除供应商失败: {{error}}\",\n          error: detail,\n        }),\n      );\n    },\n  });\n};\n\nexport const useSwitchProviderMutation = (appId: AppId) => {\n  const queryClient = useQueryClient();\n  const { t } = useTranslation();\n\n  return useMutation({\n    mutationFn: async (providerId: string): Promise<SwitchResult> => {\n      return await providersApi.switch(providerId, appId);\n    },\n    onSuccess: async () => {\n      await queryClient.invalidateQueries({ queryKey: [\"providers\", appId] });\n\n      // OpenCode/OpenClaw: also invalidate live provider IDs cache to update button state\n      if (appId === \"opencode\") {\n        await queryClient.invalidateQueries({\n          queryKey: [\"opencodeLiveProviderIds\"],\n        });\n        await queryClient.invalidateQueries({\n          queryKey: [\"omo\", \"current-provider-id\"],\n        });\n        await queryClient.invalidateQueries({\n          queryKey: [\"omo-slim\", \"current-provider-id\"],\n        });\n      }\n      if (appId === \"openclaw\") {\n        await queryClient.invalidateQueries({\n          queryKey: openclawKeys.liveProviderIds,\n        });\n        await queryClient.invalidateQueries({\n          queryKey: openclawKeys.defaultModel,\n        });\n        await queryClient.invalidateQueries({\n          queryKey: openclawKeys.health,\n        });\n      }\n\n      try {\n        await providersApi.updateTrayMenu();\n      } catch (trayError) {\n        console.error(\n          \"Failed to update tray menu after switching provider\",\n          trayError,\n        );\n      }\n    },\n    onError: (error: Error) => {\n      const detail = extractErrorMessage(error) || t(\"common.unknown\");\n\n      toast.error(\n        t(\"notifications.switchFailedTitle\", { defaultValue: \"切换失败\" }),\n        {\n          description: t(\"notifications.switchFailed\", {\n            defaultValue: \"切换失败：{{error}}\",\n            error: detail,\n          }),\n          duration: 6000,\n          action: {\n            label: t(\"common.copy\", { defaultValue: \"复制\" }),\n            onClick: () => {\n              navigator.clipboard?.writeText(detail).catch(() => undefined);\n            },\n          },\n        },\n      );\n    },\n  });\n};\n\nexport const useDeleteSessionMutation = () => {\n  const queryClient = useQueryClient();\n  const { t } = useTranslation();\n\n  return useMutation({\n    mutationFn: async (input: DeleteSessionOptions) => {\n      await sessionsApi.delete(input);\n      return input;\n    },\n    onSuccess: async (input) => {\n      queryClient.setQueryData<SessionMeta[]>([\"sessions\"], (current) =>\n        (current ?? []).filter(\n          (session) =>\n            !(\n              session.providerId === input.providerId &&\n              session.sessionId === input.sessionId &&\n              session.sourcePath === input.sourcePath\n            ),\n        ),\n      );\n      queryClient.removeQueries({\n        queryKey: [\"sessionMessages\", input.providerId, input.sourcePath],\n      });\n\n      await queryClient.invalidateQueries({ queryKey: [\"sessions\"] });\n\n      toast.success(\n        t(\"sessionManager.sessionDeleted\", {\n          defaultValue: \"会话已删除\",\n        }),\n      );\n    },\n    onError: (error: Error) => {\n      const detail = extractErrorMessage(error) || t(\"common.unknown\");\n      toast.error(\n        t(\"sessionManager.deleteFailed\", {\n          defaultValue: \"删除会话失败: {{error}}\",\n          error: detail,\n        }),\n      );\n    },\n  });\n};\n\nexport const useSaveSettingsMutation = () => {\n  const queryClient = useQueryClient();\n\n  return useMutation({\n    mutationFn: async (settings: Settings) => {\n      await settingsApi.save(settings);\n    },\n    onSuccess: async () => {\n      await queryClient.invalidateQueries({ queryKey: [\"settings\"] });\n    },\n  });\n};\n"
  },
  {
    "path": "src/lib/query/omo.ts",
    "content": "import { useQuery, useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { omoApi, omoSlimApi } from \"@/lib/api/omo\";\n\n// ── Factory ────────────────────────────────────────────────────\n\nfunction createOmoQueryKeys(prefix: string) {\n  return {\n    all: [prefix] as const,\n    currentProviderId: () => [prefix, \"current-provider-id\"] as const,\n  };\n}\n\nfunction createOmoQueryHooks(\n  variant: \"omo\" | \"omo-slim\",\n  api: typeof omoApi | typeof omoSlimApi,\n) {\n  const keys = createOmoQueryKeys(variant);\n\n  function invalidateAll(queryClient: ReturnType<typeof useQueryClient>) {\n    queryClient.invalidateQueries({ queryKey: [\"providers\"] });\n    queryClient.invalidateQueries({ queryKey: keys.currentProviderId() });\n  }\n\n  function useCurrentProviderId(enabled = true) {\n    return useQuery({\n      queryKey: keys.currentProviderId(),\n      queryFn:\n        \"getCurrentOmoProviderId\" in api\n          ? (api as typeof omoApi).getCurrentOmoProviderId\n          : (api as typeof omoSlimApi).getCurrentProviderId,\n      enabled,\n    });\n  }\n\n  function useReadLocalFile() {\n    return useMutation({\n      mutationFn: () => api.readLocalFile(),\n    });\n  }\n\n  function useDisableCurrent() {\n    const queryClient = useQueryClient();\n    return useMutation({\n      mutationFn:\n        \"disableCurrentOmo\" in api\n          ? (api as typeof omoApi).disableCurrentOmo\n          : (api as typeof omoSlimApi).disableCurrent,\n      onSuccess: () => invalidateAll(queryClient),\n    });\n  }\n\n  return {\n    keys,\n    useCurrentProviderId,\n    useReadLocalFile,\n    useDisableCurrent,\n  };\n}\n\n// ── Instances ──────────────────────────────────────────────────\n\nconst omoHooks = createOmoQueryHooks(\"omo\", omoApi);\nconst omoSlimHooks = createOmoQueryHooks(\"omo-slim\", omoSlimApi);\n\n// ── Backward-compatible exports ────────────────────────────────\n\nexport const omoKeys = omoHooks.keys;\nexport const omoSlimKeys = omoSlimHooks.keys;\n\nexport const useCurrentOmoProviderId = omoHooks.useCurrentProviderId;\nexport const useReadOmoLocalFile = omoHooks.useReadLocalFile;\nexport const useDisableCurrentOmo = omoHooks.useDisableCurrent;\n\nexport const useCurrentOmoSlimProviderId = omoSlimHooks.useCurrentProviderId;\nexport const useReadOmoSlimLocalFile = omoSlimHooks.useReadLocalFile;\nexport const useDisableCurrentOmoSlim = omoSlimHooks.useDisableCurrent;\n"
  },
  {
    "path": "src/lib/query/proxy.ts",
    "content": "import { useQuery, useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { proxyApi } from \"@/lib/api/proxy\";\nimport { toast } from \"sonner\";\nimport { useTranslation } from \"react-i18next\";\nimport type { GlobalProxyConfig, AppProxyConfig } from \"@/types/proxy\";\n\n// ========== 代理服务器状态 Hooks ==========\n\n/**\n * 获取代理服务器状态\n */\nexport function useProxyStatus() {\n  return useQuery({\n    queryKey: [\"proxyStatus\"],\n    queryFn: () => proxyApi.getProxyStatus(),\n    refetchInterval: 5000, // 每 5 秒刷新一次\n  });\n}\n\n/**\n * 检查代理服务器是否运行\n */\nexport function useIsProxyRunning() {\n  return useQuery({\n    queryKey: [\"proxyRunning\"],\n    queryFn: () => proxyApi.isProxyRunning(),\n    refetchInterval: 2000,\n  });\n}\n\n/**\n * 检查是否处于接管模式\n */\nexport function useIsLiveTakeoverActive() {\n  return useQuery({\n    queryKey: [\"liveTakeoverActive\"],\n    queryFn: () => proxyApi.isLiveTakeoverActive(),\n    refetchInterval: 2000,\n  });\n}\n\n/**\n * 获取各应用接管状态\n */\nexport function useProxyTakeoverStatus() {\n  return useQuery({\n    queryKey: [\"proxyTakeoverStatus\"],\n    queryFn: () => proxyApi.getProxyTakeoverStatus(),\n    refetchInterval: 2000,\n  });\n}\n\n// ========== 代理服务器控制 Hooks ==========\n\n/**\n * 启动代理服务器\n */\nexport function useStartProxyServer() {\n  const queryClient = useQueryClient();\n\n  return useMutation({\n    mutationFn: () => proxyApi.startProxyServer(),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: [\"proxyStatus\"] });\n      queryClient.invalidateQueries({ queryKey: [\"proxyRunning\"] });\n      queryClient.invalidateQueries({ queryKey: [\"liveTakeoverActive\"] });\n      queryClient.invalidateQueries({ queryKey: [\"proxyTakeoverStatus\"] });\n    },\n  });\n}\n\n/**\n * 停止代理服务器\n */\nexport function useStopProxyServer() {\n  const queryClient = useQueryClient();\n\n  return useMutation({\n    mutationFn: () => proxyApi.stopProxyWithRestore(),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: [\"proxyStatus\"] });\n      queryClient.invalidateQueries({ queryKey: [\"proxyRunning\"] });\n      queryClient.invalidateQueries({ queryKey: [\"liveTakeoverActive\"] });\n      queryClient.invalidateQueries({ queryKey: [\"proxyTakeoverStatus\"] });\n    },\n  });\n}\n\n/**\n * 设置应用接管状态\n */\nexport function useSetProxyTakeoverForApp() {\n  const queryClient = useQueryClient();\n\n  return useMutation({\n    mutationFn: ({ appType, enabled }: { appType: string; enabled: boolean }) =>\n      proxyApi.setProxyTakeoverForApp(appType, enabled),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: [\"proxyTakeoverStatus\"] });\n      queryClient.invalidateQueries({ queryKey: [\"liveTakeoverActive\"] });\n    },\n  });\n}\n\n/**\n * 代理模式下切换供应商\n */\nexport function useSwitchProxyProvider() {\n  const queryClient = useQueryClient();\n  const { t } = useTranslation();\n\n  return useMutation({\n    mutationFn: ({\n      appType,\n      providerId,\n    }: {\n      appType: string;\n      providerId: string;\n    }) => proxyApi.switchProxyProvider(appType, providerId),\n    onSuccess: (_, variables) => {\n      queryClient.invalidateQueries({ queryKey: [\"proxyStatus\"] });\n      queryClient.invalidateQueries({\n        queryKey: [\"providers\", variables.appType],\n      });\n    },\n    onError: (error: Error) => {\n      toast.error(t(\"proxy.switchFailed\", { error: error.message }));\n    },\n  });\n}\n\n// ========== Legacy 代理配置 Hooks (兼容) ==========\n\n/**\n * 获取代理配置（旧版）\n */\nexport function useProxyConfig() {\n  const queryClient = useQueryClient();\n  const { t } = useTranslation();\n\n  const { data: config, isLoading } = useQuery({\n    queryKey: [\"proxyConfig\"],\n    queryFn: () => proxyApi.getProxyConfig(),\n  });\n\n  const updateMutation = useMutation({\n    mutationFn: proxyApi.updateProxyConfig,\n    onSuccess: () => {\n      toast.success(t(\"proxy.settings.toast.saved\"), { closeButton: true });\n      queryClient.invalidateQueries({ queryKey: [\"proxyConfig\"] });\n      queryClient.invalidateQueries({ queryKey: [\"proxyStatus\"] });\n    },\n    onError: (error: Error) => {\n      toast.error(\n        t(\"proxy.settings.toast.saveFailed\", { error: error.message }),\n      );\n    },\n  });\n\n  return {\n    config,\n    isLoading,\n    updateConfig: updateMutation.mutateAsync,\n    isUpdating: updateMutation.isPending,\n  };\n}\n\n// ========== v3+ 全局/应用级配置 Hooks ==========\n\n/**\n * 获取全局代理配置\n */\nexport function useGlobalProxyConfig() {\n  return useQuery({\n    queryKey: [\"globalProxyConfig\"],\n    queryFn: () => proxyApi.getGlobalProxyConfig(),\n  });\n}\n\n/**\n * 更新全局代理配置\n */\nexport function useUpdateGlobalProxyConfig() {\n  const queryClient = useQueryClient();\n  const { t } = useTranslation();\n\n  return useMutation({\n    mutationFn: (config: GlobalProxyConfig) =>\n      proxyApi.updateGlobalProxyConfig(config),\n    onSuccess: () => {\n      toast.success(t(\"proxy.settings.toast.saved\"), { closeButton: true });\n      queryClient.invalidateQueries({ queryKey: [\"globalProxyConfig\"] });\n      queryClient.invalidateQueries({ queryKey: [\"proxyConfig\"] });\n      queryClient.invalidateQueries({ queryKey: [\"proxyStatus\"] });\n    },\n    onError: (error: Error) => {\n      toast.error(\n        t(\"proxy.settings.toast.saveFailed\", { error: error.message }),\n      );\n    },\n  });\n}\n\n/**\n * 获取指定应用的代理配置\n */\nexport function useAppProxyConfig(appType: string) {\n  return useQuery({\n    queryKey: [\"appProxyConfig\", appType],\n    queryFn: () => proxyApi.getProxyConfigForApp(appType),\n    enabled: !!appType,\n  });\n}\n\n/**\n * 更新指定应用的代理配置\n */\nexport function useUpdateAppProxyConfig() {\n  const queryClient = useQueryClient();\n  const { t } = useTranslation();\n\n  return useMutation({\n    mutationFn: (config: AppProxyConfig) =>\n      proxyApi.updateProxyConfigForApp(config),\n    onSuccess: (_, variables) => {\n      toast.success(t(\"proxy.settings.toast.saved\"), { closeButton: true });\n      queryClient.invalidateQueries({\n        queryKey: [\"appProxyConfig\", variables.appType],\n      });\n      queryClient.invalidateQueries({ queryKey: [\"proxyConfig\"] });\n      queryClient.invalidateQueries({ queryKey: [\"circuitBreakerConfig\"] });\n    },\n    onError: (error: Error) => {\n      toast.error(\n        t(\"proxy.settings.toast.saveFailed\", { error: error.message }),\n      );\n    },\n  });\n}\n"
  },
  {
    "path": "src/lib/query/queries.ts",
    "content": "import {\n  useQuery,\n  type UseQueryResult,\n  keepPreviousData,\n} from \"@tanstack/react-query\";\nimport {\n  providersApi,\n  settingsApi,\n  usageApi,\n  sessionsApi,\n  type AppId,\n} from \"@/lib/api\";\nimport type {\n  Provider,\n  Settings,\n  UsageResult,\n  SessionMeta,\n  SessionMessage,\n} from \"@/types\";\n\nconst sortProviders = (\n  providers: Record<string, Provider>,\n): Record<string, Provider> => {\n  const sortedEntries = Object.values(providers)\n    .sort((a, b) => {\n      const indexA = a.sortIndex ?? Number.MAX_SAFE_INTEGER;\n      const indexB = b.sortIndex ?? Number.MAX_SAFE_INTEGER;\n      if (indexA !== indexB) {\n        return indexA - indexB;\n      }\n\n      const timeA = a.createdAt ?? 0;\n      const timeB = b.createdAt ?? 0;\n      if (timeA === timeB) {\n        return a.name.localeCompare(b.name, \"zh-CN\");\n      }\n      return timeA - timeB;\n    })\n    .map((provider) => [provider.id, provider] as const);\n\n  return Object.fromEntries(sortedEntries);\n};\n\nexport interface ProvidersQueryData {\n  providers: Record<string, Provider>;\n  currentProviderId: string;\n}\n\nexport interface UseProvidersQueryOptions {\n  isProxyRunning?: boolean; // 代理服务是否运行中\n}\n\nexport const useProvidersQuery = (\n  appId: AppId,\n  options?: UseProvidersQueryOptions,\n): UseQueryResult<ProvidersQueryData> => {\n  const { isProxyRunning = false } = options || {};\n\n  return useQuery({\n    queryKey: [\"providers\", appId],\n    placeholderData: keepPreviousData,\n    // 当代理服务运行时，每 10 秒刷新一次供应商列表\n    // 这样可以自动反映后端熔断器自动禁用代理目标的变更\n    refetchInterval: isProxyRunning ? 10000 : false,\n    queryFn: async () => {\n      let providers: Record<string, Provider> = {};\n      let currentProviderId = \"\";\n\n      try {\n        providers = await providersApi.getAll(appId);\n      } catch (error) {\n        console.error(\"获取供应商列表失败:\", error);\n      }\n\n      try {\n        currentProviderId = await providersApi.getCurrent(appId);\n      } catch (error) {\n        console.error(\"获取当前供应商失败:\", error);\n      }\n\n      return {\n        providers: sortProviders(providers),\n        currentProviderId,\n      };\n    },\n  });\n};\n\nexport const useSettingsQuery = (): UseQueryResult<Settings> => {\n  return useQuery({\n    queryKey: [\"settings\"],\n    queryFn: async () => settingsApi.get(),\n  });\n};\n\nexport interface UseUsageQueryOptions {\n  enabled?: boolean;\n  autoQueryInterval?: number; // 自动查询间隔（分钟），0 表示禁用\n}\n\nexport const useUsageQuery = (\n  providerId: string,\n  appId: AppId,\n  options?: UseUsageQueryOptions,\n) => {\n  const { enabled = true, autoQueryInterval = 0 } = options || {};\n\n  // 计算 staleTime：如果有自动刷新间隔，使用该间隔；否则默认 5 分钟\n  // 这样可以避免切换 app 页面时重复触发查询\n  const staleTime =\n    autoQueryInterval > 0\n      ? autoQueryInterval * 60 * 1000 // 与刷新间隔保持一致\n      : 5 * 60 * 1000; // 默认 5 分钟\n\n  const query = useQuery<UsageResult>({\n    queryKey: [\"usage\", providerId, appId],\n    queryFn: async () => usageApi.query(providerId, appId),\n    enabled: enabled && !!providerId,\n    refetchInterval:\n      autoQueryInterval > 0\n        ? Math.max(autoQueryInterval, 1) * 60 * 1000 // 最小1分钟\n        : false,\n    refetchIntervalInBackground: true, // 后台也继续定时查询\n    refetchOnWindowFocus: false,\n    retry: false,\n    staleTime, // 使用动态计算的缓存时间\n    gcTime: 10 * 60 * 1000, // 缓存保留 10 分钟（组件卸载后）\n  });\n\n  return {\n    ...query,\n    lastQueriedAt: query.dataUpdatedAt || null,\n  };\n};\n\nexport const useSessionsQuery = () => {\n  return useQuery<SessionMeta[]>({\n    queryKey: [\"sessions\"],\n    queryFn: async () => sessionsApi.list(),\n    staleTime: 30 * 1000,\n  });\n};\n\nexport const useSessionMessagesQuery = (\n  providerId?: string,\n  sourcePath?: string,\n) => {\n  return useQuery<SessionMessage[]>({\n    queryKey: [\"sessionMessages\", providerId, sourcePath],\n    queryFn: async () => sessionsApi.getMessages(providerId!, sourcePath!),\n    enabled: Boolean(providerId && sourcePath),\n    staleTime: 30 * 1000,\n  });\n};\n"
  },
  {
    "path": "src/lib/query/queryClient.ts",
    "content": "import { QueryClient } from \"@tanstack/react-query\";\n\nexport const queryClient = new QueryClient({\n  defaultOptions: {\n    queries: {\n      retry: 1,\n      refetchOnWindowFocus: true,\n      staleTime: 0,\n    },\n    mutations: {\n      retry: false,\n    },\n  },\n});\n"
  },
  {
    "path": "src/lib/query/usage.ts",
    "content": "import { useQuery, useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { usageApi } from \"@/lib/api/usage\";\nimport type { LogFilters } from \"@/types/usage\";\n\nconst DEFAULT_REFETCH_INTERVAL_MS = 30000;\n\ntype UsageQueryOptions = {\n  refetchInterval?: number | false;\n  refetchIntervalInBackground?: boolean;\n};\n\ntype RequestLogsTimeMode = \"rolling\" | \"fixed\";\n\ntype RequestLogsQueryArgs = {\n  filters: LogFilters;\n  timeMode: RequestLogsTimeMode;\n  page?: number;\n  pageSize?: number;\n  rollingWindowSeconds?: number;\n  options?: UsageQueryOptions;\n};\n\ntype RequestLogsKey = {\n  timeMode: RequestLogsTimeMode;\n  rollingWindowSeconds?: number;\n  appType?: string;\n  providerName?: string;\n  model?: string;\n  statusCode?: number;\n  startDate?: number;\n  endDate?: number;\n};\n\n// Query keys\nexport const usageKeys = {\n  all: [\"usage\"] as const,\n  summary: (days: number) => [...usageKeys.all, \"summary\", days] as const,\n  trends: (days: number) => [...usageKeys.all, \"trends\", days] as const,\n  providerStats: () => [...usageKeys.all, \"provider-stats\"] as const,\n  modelStats: () => [...usageKeys.all, \"model-stats\"] as const,\n  logs: (key: RequestLogsKey, page: number, pageSize: number) =>\n    [\n      ...usageKeys.all,\n      \"logs\",\n      key.timeMode,\n      key.rollingWindowSeconds ?? 0,\n      key.appType ?? \"\",\n      key.providerName ?? \"\",\n      key.model ?? \"\",\n      key.statusCode ?? -1,\n      key.startDate ?? 0,\n      key.endDate ?? 0,\n      page,\n      pageSize,\n    ] as const,\n  detail: (requestId: string) =>\n    [...usageKeys.all, \"detail\", requestId] as const,\n  pricing: () => [...usageKeys.all, \"pricing\"] as const,\n  limits: (providerId: string, appType: string) =>\n    [...usageKeys.all, \"limits\", providerId, appType] as const,\n};\n\nconst getWindow = (days: number) => {\n  const endDate = Math.floor(Date.now() / 1000);\n  const startDate = endDate - days * 24 * 60 * 60;\n  return { startDate, endDate };\n};\n\n// Hooks\nexport function useUsageSummary(days: number, options?: UsageQueryOptions) {\n  return useQuery({\n    queryKey: usageKeys.summary(days),\n    queryFn: () => {\n      const { startDate, endDate } = getWindow(days);\n      return usageApi.getUsageSummary(startDate, endDate);\n    },\n    refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新\n    refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false, // 后台不刷新\n  });\n}\n\nexport function useUsageTrends(days: number, options?: UsageQueryOptions) {\n  return useQuery({\n    queryKey: usageKeys.trends(days),\n    queryFn: () => {\n      const { startDate, endDate } = getWindow(days);\n      return usageApi.getUsageTrends(startDate, endDate);\n    },\n    refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新\n    refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,\n  });\n}\n\nexport function useProviderStats(options?: UsageQueryOptions) {\n  return useQuery({\n    queryKey: usageKeys.providerStats(),\n    queryFn: usageApi.getProviderStats,\n    refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新\n    refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,\n  });\n}\n\nexport function useModelStats(options?: UsageQueryOptions) {\n  return useQuery({\n    queryKey: usageKeys.modelStats(),\n    queryFn: usageApi.getModelStats,\n    refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新\n    refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,\n  });\n}\n\nconst getRollingRange = (windowSeconds: number) => {\n  const endDate = Math.floor(Date.now() / 1000);\n  const startDate = endDate - windowSeconds;\n  return { startDate, endDate };\n};\n\nexport function useRequestLogs({\n  filters,\n  timeMode,\n  page = 0,\n  pageSize = 20,\n  rollingWindowSeconds = 24 * 60 * 60,\n  options,\n}: RequestLogsQueryArgs) {\n  const key: RequestLogsKey = {\n    timeMode,\n    rollingWindowSeconds:\n      timeMode === \"rolling\" ? rollingWindowSeconds : undefined,\n    appType: filters.appType,\n    providerName: filters.providerName,\n    model: filters.model,\n    statusCode: filters.statusCode,\n    startDate: timeMode === \"fixed\" ? filters.startDate : undefined,\n    endDate: timeMode === \"fixed\" ? filters.endDate : undefined,\n  };\n\n  return useQuery({\n    queryKey: usageKeys.logs(key, page, pageSize),\n    queryFn: () => {\n      const effectiveFilters =\n        timeMode === \"rolling\"\n          ? { ...filters, ...getRollingRange(rollingWindowSeconds) }\n          : filters;\n      return usageApi.getRequestLogs(effectiveFilters, page, pageSize);\n    },\n    refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新\n    refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,\n  });\n}\n\nexport function useRequestDetail(requestId: string) {\n  return useQuery({\n    queryKey: usageKeys.detail(requestId),\n    queryFn: () => usageApi.getRequestDetail(requestId),\n    enabled: !!requestId,\n  });\n}\n\nexport function useModelPricing() {\n  return useQuery({\n    queryKey: usageKeys.pricing(),\n    queryFn: usageApi.getModelPricing,\n  });\n}\n\nexport function useProviderLimits(providerId: string, appType: string) {\n  return useQuery({\n    queryKey: usageKeys.limits(providerId, appType),\n    queryFn: () => usageApi.checkProviderLimits(providerId, appType),\n    enabled: !!providerId && !!appType,\n  });\n}\n\nexport function useUpdateModelPricing() {\n  const queryClient = useQueryClient();\n\n  return useMutation({\n    mutationFn: (params: {\n      modelId: string;\n      displayName: string;\n      inputCost: string;\n      outputCost: string;\n      cacheReadCost: string;\n      cacheCreationCost: string;\n    }) =>\n      usageApi.updateModelPricing(\n        params.modelId,\n        params.displayName,\n        params.inputCost,\n        params.outputCost,\n        params.cacheReadCost,\n        params.cacheCreationCost,\n      ),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: usageKeys.pricing() });\n    },\n  });\n}\n\nexport function useDeleteModelPricing() {\n  const queryClient = useQueryClient();\n\n  return useMutation({\n    mutationFn: (modelId: string) => usageApi.deleteModelPricing(modelId),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: usageKeys.pricing() });\n    },\n  });\n}\n"
  },
  {
    "path": "src/lib/schemas/common.ts",
    "content": "import { z } from \"zod\";\nimport { validateToml, tomlToMcpServer } from \"@/utils/tomlUtils\";\n\n/**\n * 解析 JSON 语法错误，返回更友好的位置信息。\n */\nfunction parseJsonError(error: unknown): string {\n  if (!(error instanceof SyntaxError)) {\n    return \"JSON 格式错误\";\n  }\n\n  const message = error.message || \"JSON 解析失败\";\n\n  // Chrome/V8: \"Unexpected token ... in JSON at position 123\"\n  const positionMatch = message.match(/at position (\\d+)/i);\n  if (positionMatch) {\n    const position = parseInt(positionMatch[1], 10);\n    return `JSON 格式错误（位置：${position}）`;\n  }\n\n  // Firefox: \"JSON.parse: unexpected character at line 1 column 23\"\n  const lineColumnMatch = message.match(/line (\\d+) column (\\d+)/i);\n  if (lineColumnMatch) {\n    const line = lineColumnMatch[1];\n    const column = lineColumnMatch[2];\n    return `JSON 格式错误：第 ${line} 行，第 ${column} 列`;\n  }\n\n  return `JSON 格式错误：${message}`;\n}\n\n/**\n * 通用的 JSON 配置文本校验：\n * - 非空\n * - 可解析且为对象（非数组）\n */\nexport const jsonConfigSchema = z\n  .string()\n  .min(1, \"配置不能为空\")\n  .superRefine((value, ctx) => {\n    try {\n      const obj = JSON.parse(value);\n      if (!obj || typeof obj !== \"object\" || Array.isArray(obj)) {\n        ctx.addIssue({\n          code: z.ZodIssueCode.custom,\n          message: \"需为单个对象配置\",\n        });\n      }\n    } catch (e) {\n      ctx.addIssue({\n        code: z.ZodIssueCode.custom,\n        message: parseJsonError(e),\n      });\n    }\n  });\n\n/**\n * 通用的 TOML 配置文本校验：\n * - 允许为空（由上层业务决定是否必填）\n * - 语法与结构有效\n * - 针对 stdio/http/sse 的必填字段（command/url）进行提示\n */\nexport const tomlConfigSchema = z.string().superRefine((value, ctx) => {\n  const err = validateToml(value);\n  if (err) {\n    ctx.addIssue({\n      code: z.ZodIssueCode.custom,\n      message: `TOML 无效：${err}`,\n    });\n    return;\n  }\n\n  if (!value.trim()) return;\n\n  try {\n    const server = tomlToMcpServer(value);\n    if (server.type === \"stdio\" && !server.command?.trim()) {\n      ctx.addIssue({\n        code: z.ZodIssueCode.custom,\n        message: \"stdio 类型需填写 command\",\n      });\n    }\n    if (\n      (server.type === \"http\" || server.type === \"sse\") &&\n      !server.url?.trim()\n    ) {\n      ctx.addIssue({\n        code: z.ZodIssueCode.custom,\n        message: `${server.type} 类型需填写 url`,\n      });\n    }\n  } catch (e: any) {\n    ctx.addIssue({\n      code: z.ZodIssueCode.custom,\n      message: e?.message || \"TOML 解析失败\",\n    });\n  }\n});\n"
  },
  {
    "path": "src/lib/schemas/mcp.ts",
    "content": "import { z } from \"zod\";\n\nconst mcpServerSpecSchema = z\n  .object({\n    type: z.enum([\"stdio\", \"http\", \"sse\"]).optional(),\n    command: z.string().trim().optional(),\n    args: z.array(z.string()).optional(),\n    env: z.record(z.string(), z.string()).optional(),\n    cwd: z.string().optional(),\n    url: z.string().trim().url(\"请输入有效的 URL\").optional(),\n    headers: z.record(z.string(), z.string()).optional(),\n  })\n  .superRefine((server, ctx) => {\n    const type = server.type ?? \"stdio\";\n    if (type === \"stdio\" && !server.command?.trim()) {\n      ctx.addIssue({\n        code: z.ZodIssueCode.custom,\n        message: \"stdio 类型需填写 command\",\n        path: [\"command\"],\n      });\n    }\n    if ((type === \"http\" || type === \"sse\") && !server.url?.trim()) {\n      ctx.addIssue({\n        code: z.ZodIssueCode.custom,\n        message: `${type} 类型需填写 url`,\n        path: [\"url\"],\n      });\n    }\n  });\n\nexport const mcpServerSchema = z.object({\n  id: z.string().min(1, \"请输入服务器 ID\"),\n  name: z.string().optional(),\n  description: z.string().optional(),\n  tags: z.array(z.string()).optional(),\n  homepage: z.string().url().optional(),\n  docs: z.string().url().optional(),\n  enabled: z.boolean().optional(),\n  server: mcpServerSpecSchema,\n});\n\nexport type McpServerFormData = z.infer<typeof mcpServerSchema>;\n"
  },
  {
    "path": "src/lib/schemas/provider.ts",
    "content": "import { z } from \"zod\";\n\n/**\n * 解析 JSON 语法错误，提取位置信息\n */\nfunction parseJsonError(error: unknown): string {\n  if (!(error instanceof SyntaxError)) {\n    return \"配置 JSON 格式错误\";\n  }\n\n  const message = error.message;\n\n  // 提取位置信息：Chrome/V8: \"Unexpected token ... in JSON at position 123\"\n  const positionMatch = message.match(/at position (\\d+)/i);\n  if (positionMatch) {\n    const position = parseInt(positionMatch[1], 10);\n    return `JSON 格式错误：${message.split(\" in JSON\")[0]}（位置：${position}）`;\n  }\n\n  // Firefox: \"JSON.parse: unexpected character at line 1 column 23\"\n  const lineColumnMatch = message.match(/line (\\d+) column (\\d+)/i);\n  if (lineColumnMatch) {\n    const line = lineColumnMatch[1];\n    const column = lineColumnMatch[2];\n    return `JSON 格式错误：第 ${line} 行，第 ${column} 列`;\n  }\n\n  // 通用情况：提取关键错误信息\n  const cleanMessage = message\n    .replace(/^JSON\\.parse:\\s*/i, \"\")\n    .replace(/^Unexpected\\s+/i, \"意外的 \")\n    .replace(/token/gi, \"符号\")\n    .replace(/Expected/gi, \"预期\");\n\n  return `JSON 格式错误：${cleanMessage}`;\n}\n\nexport const providerSchema = z.object({\n  name: z.string(), // 必填校验移至 handleSubmit 中用 toast 提示\n  websiteUrl: z.string().url(\"请输入有效的网址\").optional().or(z.literal(\"\")),\n  notes: z.string().optional(),\n  settingsConfig: z\n    .string()\n    .min(1, \"请填写配置内容\")\n    .superRefine((value, ctx) => {\n      try {\n        JSON.parse(value);\n      } catch (error) {\n        ctx.addIssue({\n          code: z.ZodIssueCode.custom,\n          message: parseJsonError(error),\n        });\n      }\n    }),\n  // 图标配置\n  icon: z.string().optional(),\n  iconColor: z.string().optional(),\n});\n\nexport type ProviderFormData = z.infer<typeof providerSchema>;\n"
  },
  {
    "path": "src/lib/schemas/settings.ts",
    "content": "import { z } from \"zod\";\n\nconst directorySchema = z\n  .string()\n  .trim()\n  .min(1, \"路径不能为空\")\n  .optional()\n  .or(z.literal(\"\"));\n\nexport const settingsSchema = z.object({\n  // 设备级 UI 设置\n  showInTray: z.boolean(),\n  minimizeToTrayOnClose: z.boolean(),\n  enableClaudePluginIntegration: z.boolean().optional(),\n  skipClaudeOnboarding: z.boolean().optional(),\n  launchOnStartup: z.boolean().optional(),\n  enableLocalProxy: z.boolean().optional(),\n  language: z.enum([\"en\", \"zh\", \"ja\"]).optional(),\n\n  // 设备级目录覆盖\n  claudeConfigDir: directorySchema.nullable().optional(),\n  codexConfigDir: directorySchema.nullable().optional(),\n  geminiConfigDir: directorySchema.nullable().optional(),\n\n  // 当前供应商 ID（设备级）\n  currentProviderClaude: z.string().optional(),\n  currentProviderCodex: z.string().optional(),\n  currentProviderGemini: z.string().optional(),\n\n  // Skill 同步设置\n  skillSyncMethod: z.enum([\"auto\", \"symlink\", \"copy\"]).optional(),\n\n  // WebDAV v2 同步设置（通过专用命令保存，schema 仅用于读取）\n  webdavSync: z\n    .object({\n      enabled: z.boolean().optional(),\n      autoSync: z.boolean().optional(),\n      baseUrl: z.string().trim().optional().or(z.literal(\"\")),\n      username: z.string().trim().optional().or(z.literal(\"\")),\n      password: z.string().optional(),\n      remoteRoot: z.string().trim().optional().or(z.literal(\"\")),\n      profile: z.string().trim().optional().or(z.literal(\"\")),\n      status: z\n        .object({\n          lastSyncAt: z.number().nullable().optional(),\n          lastError: z.string().nullable().optional(),\n          lastErrorSource: z.string().nullable().optional(),\n          lastRemoteEtag: z.string().nullable().optional(),\n          lastLocalManifestHash: z.string().nullable().optional(),\n          lastRemoteManifestHash: z.string().nullable().optional(),\n        })\n        .optional(),\n    })\n    .optional(),\n});\n\nexport type SettingsFormData = z.infer<typeof settingsSchema>;\n"
  },
  {
    "path": "src/lib/updater.ts",
    "content": "import { getVersion } from \"@tauri-apps/api/app\";\n\n// 可选导入：在未注册插件或非 Tauri 环境下，调用时会抛错，外层需做兜底\n// 我们按需加载并在运行时捕获错误，避免构建期类型问题\n// eslint-disable-next-line @typescript-eslint/consistent-type-imports\nimport type { Update } from \"@tauri-apps/plugin-updater\";\n\nexport type UpdateChannel = \"stable\" | \"beta\";\n\nexport type UpdaterPhase =\n  | \"idle\"\n  | \"checking\"\n  | \"available\"\n  | \"downloading\"\n  | \"installing\"\n  | \"restarting\"\n  | \"upToDate\"\n  | \"error\";\n\nexport interface UpdateInfo {\n  currentVersion: string;\n  availableVersion: string;\n  notes?: string;\n  pubDate?: string;\n}\n\nexport interface UpdateProgressEvent {\n  event: \"Started\" | \"Progress\" | \"Finished\";\n  total?: number;\n  downloaded?: number;\n}\n\nexport interface UpdateHandle {\n  version: string;\n  notes?: string;\n  date?: string;\n  downloadAndInstall: (\n    onProgress?: (e: UpdateProgressEvent) => void,\n  ) => Promise<void>;\n  download?: () => Promise<void>;\n  install?: () => Promise<void>;\n}\n\nexport interface CheckOptions {\n  timeout?: number;\n  channel?: UpdateChannel;\n}\n\nfunction mapUpdateHandle(raw: Update): UpdateHandle {\n  return {\n    version: (raw as any).version ?? \"\",\n    notes: (raw as any).notes,\n    date: (raw as any).date,\n    async downloadAndInstall(onProgress?: (e: UpdateProgressEvent) => void) {\n      await (raw as any).downloadAndInstall((evt: any) => {\n        if (!onProgress) return;\n        const mapped: UpdateProgressEvent = {\n          event: evt?.event,\n        };\n        if (evt?.event === \"Started\") {\n          mapped.total = evt?.data?.contentLength ?? 0;\n          mapped.downloaded = 0;\n        } else if (evt?.event === \"Progress\") {\n          mapped.downloaded = evt?.data?.chunkLength ?? 0; // 累积由调用方完成\n        }\n        onProgress(mapped);\n      });\n    },\n    // 透传可选 API（若插件版本支持）\n    download: (raw as any).download\n      ? async () => {\n          // eslint-disable-next-line @typescript-eslint/no-unsafe-call\n          await (raw as any).download();\n        }\n      : undefined,\n    install: (raw as any).install\n      ? async () => {\n          // eslint-disable-next-line @typescript-eslint/no-unsafe-call\n          await (raw as any).install();\n        }\n      : undefined,\n  };\n}\n\nexport async function getCurrentVersion(): Promise<string> {\n  try {\n    return await getVersion();\n  } catch {\n    return \"\";\n  }\n}\n\nexport async function checkForUpdate(\n  opts: CheckOptions = {},\n): Promise<\n  | { status: \"up-to-date\" }\n  | { status: \"available\"; info: UpdateInfo; update: UpdateHandle }\n> {\n  // 动态引入，避免在未安装插件时导致打包期问题\n  const { check } = await import(\"@tauri-apps/plugin-updater\");\n\n  const currentVersion = await getCurrentVersion();\n  const update = await check({ timeout: opts.timeout ?? 30000 } as any);\n\n  if (!update) {\n    return { status: \"up-to-date\" };\n  }\n\n  const mapped = mapUpdateHandle(update);\n  const info: UpdateInfo = {\n    currentVersion,\n    availableVersion: mapped.version,\n    notes: mapped.notes,\n    pubDate: mapped.date,\n  };\n\n  return { status: \"available\", info, update: mapped };\n}\n\nexport async function relaunchApp(): Promise<void> {\n  const { relaunch } = await import(\"@tauri-apps/plugin-process\");\n  await relaunch();\n}\n\n// 旧的聚合更新流程已由调用方直接使用 updateHandle 取代\n// 如需单函数封装，可在需要时基于 checkForUpdate + updateHandle 复合调用\n"
  },
  {
    "path": "src/lib/utils/base64.ts",
    "content": "/**\n * Decode Base64 encoded UTF-8 string\n *\n * This function handles various Base64 edge cases that can occur when\n * Base64 strings are passed through URLs:\n * - Spaces (URL parsing may convert '+' to space)\n * - Missing padding ('=' characters)\n * - Different Base64 variants\n *\n * @param str - Base64 encoded string\n * @returns Decoded UTF-8 string\n */\nexport function decodeBase64Utf8(str: string): string {\n  try {\n    // Clean up the input: replace spaces with + (URL parsing may convert + to space)\n    let cleaned = str.trim().replace(/ /g, \"+\");\n\n    // Try to decode with standard Base64 first\n    try {\n      const binString = atob(cleaned);\n      const bytes = Uint8Array.from(binString, (m) => m.codePointAt(0)!);\n      return new TextDecoder(\"utf-8\", { fatal: false }).decode(bytes);\n    } catch (e1) {\n      // If standard fails, try adding padding\n      const remainder = cleaned.length % 4;\n      if (remainder !== 0) {\n        cleaned += \"=\".repeat(4 - remainder);\n      }\n      const binString = atob(cleaned);\n      const bytes = Uint8Array.from(binString, (m) => m.codePointAt(0)!);\n      return new TextDecoder(\"utf-8\", { fatal: false }).decode(bytes);\n    }\n  } catch (e) {\n    console.error(\"Base64 decode error:\", e, \"Input:\", str);\n    // Last resort fallback using deprecated but sometimes working method\n    try {\n      return decodeURIComponent(escape(atob(str.replace(/ /g, \"+\"))));\n    } catch {\n      // If all else fails, return original string\n      return str;\n    }\n  }\n}\n"
  },
  {
    "path": "src/lib/utils.ts",
    "content": "import { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"
  },
  {
    "path": "src/main.tsx",
    "content": "import React from \"react\";\nimport ReactDOM from \"react-dom/client\";\nimport App from \"./App\";\nimport { UpdateProvider } from \"./contexts/UpdateContext\";\nimport \"./index.css\";\n// 导入国际化配置\nimport i18n from \"./i18n\";\nimport { QueryClientProvider } from \"@tanstack/react-query\";\nimport { ThemeProvider } from \"@/components/theme-provider\";\nimport { queryClient } from \"@/lib/query\";\nimport { Toaster } from \"@/components/ui/sonner\";\nimport { listen } from \"@tauri-apps/api/event\";\nimport { invoke } from \"@tauri-apps/api/core\";\nimport { message } from \"@tauri-apps/plugin-dialog\";\nimport { exit } from \"@tauri-apps/plugin-process\";\n\n// 根据平台添加 body class，便于平台特定样式\ntry {\n  const ua = navigator.userAgent || \"\";\n  const plat = (navigator.platform || \"\").toLowerCase();\n  const isMac = /mac/i.test(ua) || plat.includes(\"mac\");\n  if (isMac) {\n    document.body.classList.add(\"is-mac\");\n  }\n} catch {\n  // 忽略平台检测失败\n}\n\n// 配置加载错误payload类型\ninterface ConfigLoadErrorPayload {\n  path?: string;\n  error?: string;\n}\n\n/**\n * 处理配置加载失败：显示错误消息并强制退出应用\n * 不给用户\"取消\"选项，因为配置损坏时应用无法正常运行\n */\nasync function handleConfigLoadError(\n  payload: ConfigLoadErrorPayload | null,\n): Promise<void> {\n  const path = payload?.path ?? \"~/.cc-switch/config.json\";\n  const detail = payload?.error ?? \"Unknown error\";\n\n  await message(\n    i18n.t(\"errors.configLoadFailedMessage\", {\n      path,\n      detail,\n      defaultValue:\n        \"无法读取配置文件：\\n{{path}}\\n\\n错误详情：\\n{{detail}}\\n\\n请手动检查 JSON 是否有效，或从同目录的备份文件（如 config.json.bak）恢复。\\n\\n应用将退出以便您进行修复。\",\n    }),\n    {\n      title: i18n.t(\"errors.configLoadFailedTitle\", {\n        defaultValue: \"配置加载失败\",\n      }),\n      kind: \"error\",\n    },\n  );\n\n  await exit(1);\n}\n\n// 监听后端的配置加载错误事件：仅提醒用户并强制退出，不修改任何配置文件\ntry {\n  void listen(\"configLoadError\", async (evt) => {\n    await handleConfigLoadError(evt.payload as ConfigLoadErrorPayload | null);\n  });\n} catch (e) {\n  // 忽略事件订阅异常（例如在非 Tauri 环境下）\n  console.error(\"订阅 configLoadError 事件失败\", e);\n}\n\nasync function bootstrap() {\n  // 启动早期主动查询后端初始化错误，避免事件竞态\n  try {\n    const initError = (await invoke(\n      \"get_init_error\",\n    )) as ConfigLoadErrorPayload | null;\n    if (initError && (initError.path || initError.error)) {\n      await handleConfigLoadError(initError);\n      // 注意：不会执行到这里，因为 exit(1) 会终止进程\n      return;\n    }\n  } catch (e) {\n    // 忽略拉取错误，继续渲染\n    console.error(\"拉取初始化错误失败\", e);\n  }\n\n  ReactDOM.createRoot(document.getElementById(\"root\")!).render(\n    <React.StrictMode>\n      <QueryClientProvider client={queryClient}>\n        <ThemeProvider defaultTheme=\"system\" storageKey=\"cc-switch-theme\">\n          <UpdateProvider>\n            <App />\n            <Toaster />\n          </UpdateProvider>\n        </ThemeProvider>\n      </QueryClientProvider>\n    </React.StrictMode>,\n  );\n}\n\nvoid bootstrap();\n"
  },
  {
    "path": "src/types/env.ts",
    "content": "/**\n * 环境变量冲突检测相关类型定义\n */\n\n/**\n * 环境变量冲突信息\n */\nexport interface EnvConflict {\n  /** 环境变量名称 */\n  varName: string;\n  /** 环境变量的值 */\n  varValue: string;\n  /** 来源类型: \"system\" 表示系统环境变量, \"file\" 表示配置文件 */\n  sourceType: \"system\" | \"file\";\n  /** 来源路径 (注册表路径或文件路径:行号) */\n  sourcePath: string;\n}\n\n/**\n * 备份信息\n */\nexport interface BackupInfo {\n  /** 备份文件路径 */\n  backupPath: string;\n  /** 备份时间戳 */\n  timestamp: string;\n  /** 被备份的环境变量冲突列表 */\n  conflicts: EnvConflict[];\n}\n"
  },
  {
    "path": "src/types/icon.ts",
    "content": "export interface IconMetadata {\n  name: string; // 图标名称（小写，如 \"openai\"）\n  displayName: string; // 显示名称（如 \"OpenAI\"）\n  category: string; // 分类（如 \"ai-provider\", \"cloud\", \"tool\"）\n  keywords: string[]; // 搜索关键词\n  defaultColor?: string; // 默认颜色\n}\n\nexport interface IconPreset {\n  [key: string]: IconMetadata;\n}\n"
  },
  {
    "path": "src/types/omo.ts",
    "content": "export interface OmoLocalFileData {\n  agents?: Record<string, Record<string, unknown>>;\n  categories?: Record<string, Record<string, unknown>>;\n  otherFields?: Record<string, unknown>;\n  filePath: string;\n  lastModified?: string;\n}\n\nexport interface OmoAgentDef {\n  key: string;\n  display: string;\n  descKey: string;\n  tooltipKey: string;\n  recommended?: string;\n  group: \"main\" | \"sub\";\n}\n\nexport interface OmoCategoryDef {\n  key: string;\n  display: string;\n  descKey: string;\n  tooltipKey: string;\n  recommended?: string;\n}\n\nexport const OMO_BUILTIN_AGENTS: OmoAgentDef[] = [\n  {\n    key: \"sisyphus\",\n    display: \"Sisyphus\",\n    descKey: \"omo.agentDesc.sisyphus\",\n    tooltipKey: \"omo.agentTooltip.sisyphus\",\n    recommended: \"claude-opus-4-6\",\n    group: \"main\",\n  },\n  {\n    key: \"hephaestus\",\n    display: \"Hephaestus\",\n    descKey: \"omo.agentDesc.hephaestus\",\n    tooltipKey: \"omo.agentTooltip.hephaestus\",\n    recommended: \"gpt-5.4\",\n    group: \"main\",\n  },\n  {\n    key: \"prometheus\",\n    display: \"Prometheus\",\n    descKey: \"omo.agentDesc.prometheus\",\n    tooltipKey: \"omo.agentTooltip.prometheus\",\n    recommended: \"claude-opus-4-6\",\n    group: \"main\",\n  },\n  {\n    key: \"atlas\",\n    display: \"Atlas\",\n    descKey: \"omo.agentDesc.atlas\",\n    tooltipKey: \"omo.agentTooltip.atlas\",\n    recommended: \"kimi-k2.5\",\n    group: \"main\",\n  },\n  {\n    key: \"oracle\",\n    display: \"Oracle\",\n    descKey: \"omo.agentDesc.oracle\",\n    tooltipKey: \"omo.agentTooltip.oracle\",\n    recommended: \"gpt-5.4\",\n    group: \"sub\",\n  },\n  {\n    key: \"librarian\",\n    display: \"Librarian\",\n    descKey: \"omo.agentDesc.librarian\",\n    tooltipKey: \"omo.agentTooltip.librarian\",\n    recommended: \"gemini-3-flash\",\n    group: \"sub\",\n  },\n  {\n    key: \"explore\",\n    display: \"Explore\",\n    descKey: \"omo.agentDesc.explore\",\n    tooltipKey: \"omo.agentTooltip.explore\",\n    recommended: \"grok-code-fast-1\",\n    group: \"sub\",\n  },\n  {\n    key: \"multimodal-looker\",\n    display: \"Multimodal-Looker\",\n    descKey: \"omo.agentDesc.multimodalLooker\",\n    tooltipKey: \"omo.agentTooltip.multimodalLooker\",\n    recommended: \"kimi-k2.5\",\n    group: \"sub\",\n  },\n  {\n    key: \"metis\",\n    display: \"Metis\",\n    descKey: \"omo.agentDesc.metis\",\n    tooltipKey: \"omo.agentTooltip.metis\",\n    recommended: \"claude-opus-4-6\",\n    group: \"sub\",\n  },\n  {\n    key: \"momus\",\n    display: \"Momus\",\n    descKey: \"omo.agentDesc.momus\",\n    tooltipKey: \"omo.agentTooltip.momus\",\n    recommended: \"gpt-5.4\",\n    group: \"sub\",\n  },\n  {\n    key: \"sisyphus-junior\",\n    display: \"Sisyphus-Junior\",\n    descKey: \"omo.agentDesc.sisyphusJunior\",\n    tooltipKey: \"omo.agentTooltip.sisyphusJunior\",\n    group: \"sub\",\n  },\n];\n\nexport const OMO_BUILTIN_CATEGORIES: OmoCategoryDef[] = [\n  {\n    key: \"visual-engineering\",\n    display: \"Visual Engineering\",\n    descKey: \"omo.categoryDesc.visualEngineering\",\n    tooltipKey: \"omo.categoryTooltip.visualEngineering\",\n    recommended: \"gemini-3-pro\",\n  },\n  {\n    key: \"ultrabrain\",\n    display: \"Ultrabrain\",\n    descKey: \"omo.categoryDesc.ultrabrain\",\n    tooltipKey: \"omo.categoryTooltip.ultrabrain\",\n    recommended: \"gpt-5.4\",\n  },\n  {\n    key: \"deep\",\n    display: \"Deep\",\n    descKey: \"omo.categoryDesc.deep\",\n    tooltipKey: \"omo.categoryTooltip.deep\",\n    recommended: \"gpt-5.4\",\n  },\n  {\n    key: \"artistry\",\n    display: \"Artistry\",\n    descKey: \"omo.categoryDesc.artistry\",\n    tooltipKey: \"omo.categoryTooltip.artistry\",\n    recommended: \"gemini-3-pro\",\n  },\n  {\n    key: \"quick\",\n    display: \"Quick\",\n    descKey: \"omo.categoryDesc.quick\",\n    tooltipKey: \"omo.categoryTooltip.quick\",\n    recommended: \"claude-haiku-4-5\",\n  },\n  {\n    key: \"unspecified-low\",\n    display: \"Unspecified Low\",\n    descKey: \"omo.categoryDesc.unspecifiedLow\",\n    tooltipKey: \"omo.categoryTooltip.unspecifiedLow\",\n    recommended: \"claude-sonnet-4-6\",\n  },\n  {\n    key: \"unspecified-high\",\n    display: \"Unspecified High\",\n    descKey: \"omo.categoryDesc.unspecifiedHigh\",\n    tooltipKey: \"omo.categoryTooltip.unspecifiedHigh\",\n    recommended: \"claude-opus-4-6\",\n  },\n  {\n    key: \"writing\",\n    display: \"Writing\",\n    descKey: \"omo.categoryDesc.writing\",\n    tooltipKey: \"omo.categoryTooltip.writing\",\n    recommended: \"gemini-3-flash\",\n  },\n];\n\nexport const OMO_DISABLEABLE_AGENTS = [\n  { value: \"Prometheus (Planner)\", label: \"Prometheus (Planner)\" },\n  { value: \"Atlas\", label: \"Atlas\" },\n  { value: \"oracle\", label: \"Oracle\" },\n  { value: \"librarian\", label: \"Librarian\" },\n  { value: \"explore\", label: \"Explore\" },\n  { value: \"multimodal-looker\", label: \"Multimodal Looker\" },\n  { value: \"frontend-ui-ux-engineer\", label: \"Frontend UI/UX Engineer\" },\n  { value: \"document-writer\", label: \"Document Writer\" },\n  { value: \"Sisyphus-Junior\", label: \"Sisyphus-Junior\" },\n  { value: \"Metis (Plan Consultant)\", label: \"Metis (Plan Consultant)\" },\n  { value: \"Momus (Plan Reviewer)\", label: \"Momus (Plan Reviewer)\" },\n  { value: \"OpenCode-Builder\", label: \"OpenCode-Builder\" },\n] as const;\n\nexport const OMO_DISABLEABLE_MCPS = [\n  { value: \"context7\", label: \"context7\" },\n  { value: \"grep_app\", label: \"grep_app\" },\n  { value: \"websearch\", label: \"websearch\" },\n] as const;\n\nexport const OMO_DISABLEABLE_HOOKS = [\n  { value: \"todo-continuation-enforcer\", label: \"todo-continuation-enforcer\" },\n  { value: \"context-window-monitor\", label: \"context-window-monitor\" },\n  { value: \"session-recovery\", label: \"session-recovery\" },\n  { value: \"session-notification\", label: \"session-notification\" },\n  { value: \"comment-checker\", label: \"comment-checker\" },\n  { value: \"grep-output-truncator\", label: \"grep-output-truncator\" },\n  { value: \"tool-output-truncator\", label: \"tool-output-truncator\" },\n  {\n    value: \"directory-agents-injector\",\n    label: \"directory-agents-injector\",\n  },\n  {\n    value: \"directory-readme-injector\",\n    label: \"directory-readme-injector\",\n  },\n  {\n    value: \"empty-task-response-detector\",\n    label: \"empty-task-response-detector\",\n  },\n  { value: \"think-mode\", label: \"think-mode\" },\n  {\n    value: \"anthropic-context-window-limit-recovery\",\n    label: \"anthropic-context-window-limit-recovery\",\n  },\n  { value: \"rules-injector\", label: \"rules-injector\" },\n  { value: \"background-notification\", label: \"background-notification\" },\n  { value: \"auto-update-checker\", label: \"auto-update-checker\" },\n  { value: \"startup-toast\", label: \"startup-toast\" },\n  { value: \"keyword-detector\", label: \"keyword-detector\" },\n  { value: \"agent-usage-reminder\", label: \"agent-usage-reminder\" },\n  { value: \"non-interactive-env\", label: \"non-interactive-env\" },\n  { value: \"interactive-bash-session\", label: \"interactive-bash-session\" },\n  {\n    value: \"compaction-context-injector\",\n    label: \"compaction-context-injector\",\n  },\n  {\n    value: \"thinking-block-validator\",\n    label: \"thinking-block-validator\",\n  },\n  { value: \"claude-code-hooks\", label: \"claude-code-hooks\" },\n  { value: \"ralph-loop\", label: \"ralph-loop\" },\n  { value: \"preemptive-compaction\", label: \"preemptive-compaction\" },\n] as const;\n\nexport const OMO_DISABLEABLE_SKILLS = [\n  { value: \"playwright\", label: \"playwright\" },\n  { value: \"agent-browser\", label: \"agent-browser\" },\n  { value: \"git-master\", label: \"git-master\" },\n] as const;\n\nexport const OMO_DEFAULT_SCHEMA_URL =\n  \"https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json\";\n\nexport const OMO_SISYPHUS_AGENT_PLACEHOLDER = `{\n  \"disabled\": false,\n  \"default_builder_enabled\": false,\n  \"planner_enabled\": true,\n  \"replace_plan\": true\n}`;\n\nexport const OMO_LSP_PLACEHOLDER = `{\n  \"typescript-language-server\": {\n    \"command\": [\"typescript-language-server\", \"--stdio\"],\n    \"extensions\": [\".ts\", \".tsx\"],\n    \"priority\": 10\n  },\n  \"pylsp\": {\n    \"disabled\": true\n  }\n}`;\n\nexport const OMO_EXPERIMENTAL_PLACEHOLDER = `{\n  \"truncate_all_tool_outputs\": true,\n  \"aggressive_truncation\": true,\n  \"auto_resume\": true\n}`;\n\nexport const OMO_BACKGROUND_TASK_PLACEHOLDER = `{\n  \"defaultConcurrency\": 5,\n  \"providerConcurrency\": {\n    \"anthropic\": 3,\n    \"openai\": 5,\n    \"google\": 10\n  },\n  \"modelConcurrency\": {\n    \"anthropic/claude-opus-4-6\": 2,\n    \"google/gemini-3-flash\": 10\n  }\n}`;\n\nexport const OMO_BROWSER_AUTOMATION_PLACEHOLDER = `{\n  \"provider\": \"playwright\"\n}`;\n\nexport const OMO_CLAUDE_CODE_PLACEHOLDER = `{\n  \"mcp\": true,\n  \"commands\": true,\n  \"skills\": true,\n  \"agents\": true,\n  \"hooks\": true,\n  \"plugins\": true\n}`;\n\nexport function parseOmoOtherFieldsObject(\n  raw: string,\n): Record<string, unknown> | undefined {\n  if (!raw.trim()) return undefined;\n  const parsed: unknown = JSON.parse(raw);\n  if (typeof parsed !== \"object\" || parsed === null || Array.isArray(parsed)) {\n    return undefined;\n  }\n  return parsed as Record<string, unknown>;\n}\n\n// ============================================================================\n// OMO Slim (oh-my-opencode-slim) definitions\n// ============================================================================\n\nexport const OMO_SLIM_BUILTIN_AGENTS: OmoAgentDef[] = [\n  {\n    key: \"orchestrator\",\n    display: \"Orchestrator\",\n    descKey: \"omo.slimAgentDesc.orchestrator\",\n    tooltipKey: \"omo.slimAgentTooltip.orchestrator\",\n    recommended: \"claude-opus-4-6\",\n    group: \"main\",\n  },\n  {\n    key: \"oracle\",\n    display: \"Oracle\",\n    descKey: \"omo.slimAgentDesc.oracle\",\n    tooltipKey: \"omo.slimAgentTooltip.oracle\",\n    recommended: \"gpt-5.4\",\n    group: \"sub\",\n  },\n  {\n    key: \"librarian\",\n    display: \"Librarian\",\n    descKey: \"omo.slimAgentDesc.librarian\",\n    tooltipKey: \"omo.slimAgentTooltip.librarian\",\n    recommended: \"gemini-3-flash\",\n    group: \"sub\",\n  },\n  {\n    key: \"explorer\",\n    display: \"Explorer\",\n    descKey: \"omo.slimAgentDesc.explorer\",\n    tooltipKey: \"omo.slimAgentTooltip.explorer\",\n    recommended: \"grok-code-fast-1\",\n    group: \"sub\",\n  },\n  {\n    key: \"designer\",\n    display: \"Designer\",\n    descKey: \"omo.slimAgentDesc.designer\",\n    tooltipKey: \"omo.slimAgentTooltip.designer\",\n    recommended: \"gemini-3-pro\",\n    group: \"sub\",\n  },\n  {\n    key: \"fixer\",\n    display: \"Fixer\",\n    descKey: \"omo.slimAgentDesc.fixer\",\n    tooltipKey: \"omo.slimAgentTooltip.fixer\",\n    recommended: \"gpt-5.4\",\n    group: \"sub\",\n  },\n];\n\nexport const OMO_SLIM_DISABLEABLE_AGENTS = [\n  { value: \"orchestrator\", label: \"Orchestrator\" },\n  { value: \"oracle\", label: \"Oracle\" },\n  { value: \"librarian\", label: \"Librarian\" },\n  { value: \"explorer\", label: \"Explorer\" },\n  { value: \"designer\", label: \"Designer\" },\n  { value: \"fixer\", label: \"Fixer\" },\n] as const;\n\nexport const OMO_SLIM_DISABLEABLE_MCPS = [\n  { value: \"context7\", label: \"context7\" },\n  { value: \"grep_app\", label: \"grep_app\" },\n  { value: \"websearch\", label: \"websearch\" },\n] as const;\n\nexport const OMO_SLIM_DISABLEABLE_HOOKS = [\n  { value: \"auto-update-checker\", label: \"auto-update-checker\" },\n  { value: \"phase-reminder\", label: \"phase-reminder\" },\n  { value: \"post-read-nudge\", label: \"post-read-nudge\" },\n] as const;\n\nexport const OMO_SLIM_DEFAULT_SCHEMA_URL =\n  \"https://raw.githubusercontent.com/alvinunreal/oh-my-opencode-slim/master/assets/oh-my-opencode-slim.schema.json\";\n\nexport function buildOmoProfilePreview(\n  agents: Record<string, Record<string, unknown>>,\n  categories: Record<string, Record<string, unknown>> | undefined,\n  otherFieldsStr: string,\n  options?: { slim?: boolean },\n): Record<string, unknown> {\n  const result: Record<string, unknown> = {};\n  const isSlim = options?.slim ?? false;\n\n  if (Object.keys(agents).length > 0) result[\"agents\"] = agents;\n  if (!isSlim && categories && Object.keys(categories).length > 0)\n    result[\"categories\"] = categories;\n\n  try {\n    const other = parseOmoOtherFieldsObject(otherFieldsStr);\n    if (other) {\n      for (const [k, v] of Object.entries(other)) {\n        result[k] = v;\n      }\n    }\n  } catch {}\n\n  return result;\n}\n\n/** @deprecated Use buildOmoProfilePreview with options.slim=true */\nexport function buildOmoSlimProfilePreview(\n  agents: Record<string, Record<string, unknown>>,\n  otherFieldsStr: string,\n): Record<string, unknown> {\n  return buildOmoProfilePreview(agents, undefined, otherFieldsStr, {\n    slim: true,\n  });\n}\n"
  },
  {
    "path": "src/types/proxy.ts",
    "content": "export interface ProxyConfig {\n  listen_address: string;\n  listen_port: number;\n  max_retries: number;\n  request_timeout: number;\n  enable_logging: boolean;\n  live_takeover_active?: boolean;\n  // 超时配置\n  streaming_first_byte_timeout: number;\n  streaming_idle_timeout: number;\n  non_streaming_timeout: number;\n}\n\nexport interface ProxyStatus {\n  running: boolean;\n  address: string;\n  port: number;\n  active_connections: number;\n  total_requests: number;\n  success_requests: number;\n  failed_requests: number;\n  success_rate: number;\n  uptime_seconds: number;\n  current_provider: string | null;\n  current_provider_id: string | null;\n  last_request_at: string | null;\n  last_error: string | null;\n  failover_count: number;\n  active_targets?: ActiveTarget[];\n}\n\nexport interface ActiveTarget {\n  app_type: string;\n  provider_name: string;\n  provider_id: string;\n}\n\nexport interface ProxyServerInfo {\n  address: string;\n  port: number;\n  started_at: string;\n}\n\nexport interface ProxyTakeoverStatus {\n  claude: boolean;\n  codex: boolean;\n  gemini: boolean;\n  opencode: boolean;\n  openclaw: boolean;\n}\n\nexport interface ProviderHealth {\n  provider_id: string;\n  app_type: string;\n  is_healthy: boolean;\n  consecutive_failures: number;\n  last_success_at: string | null;\n  last_failure_at: string | null;\n  last_error: string | null;\n  updated_at: string;\n}\n\n// 熔断器相关类型\nexport interface CircuitBreakerConfig {\n  failureThreshold: number;\n  successThreshold: number;\n  timeoutSeconds: number;\n  errorRateThreshold: number;\n  minRequests: number;\n}\n\nexport type CircuitState = \"closed\" | \"open\" | \"half_open\";\n\nexport interface CircuitBreakerStats {\n  state: CircuitState;\n  consecutiveFailures: number;\n  consecutiveSuccesses: number;\n  totalRequests: number;\n  failedRequests: number;\n}\n\n// 供应商健康状态枚举\nexport enum ProviderHealthStatus {\n  Healthy = \"healthy\",\n  Degraded = \"degraded\",\n  Failed = \"failed\",\n  Unknown = \"unknown\",\n}\n\n// 扩展 ProviderHealth 以包含前端计算的状态\nexport interface ProviderHealthWithStatus extends ProviderHealth {\n  status: ProviderHealthStatus;\n  circuitState?: CircuitState;\n}\n\nexport interface ProxyUsageRecord {\n  provider_id: string;\n  app_type: string;\n  endpoint: string;\n  request_tokens: number | null;\n  response_tokens: number | null;\n  status_code: number;\n  latency_ms: number;\n  error: string | null;\n  timestamp: string;\n}\n\n// 故障转移队列条目\nexport interface FailoverQueueItem {\n  providerId: string;\n  providerName: string;\n  sortIndex?: number;\n}\n\n// 全局代理配置（统一字段，三行镜像）\nexport interface GlobalProxyConfig {\n  proxyEnabled: boolean;\n  listenAddress: string;\n  listenPort: number;\n  enableLogging: boolean;\n}\n\n// 应用级代理配置（每个 app 独立）\nexport interface AppProxyConfig {\n  appType: string;\n  enabled: boolean;\n  autoFailoverEnabled: boolean;\n  maxRetries: number;\n  streamingFirstByteTimeout: number;\n  streamingIdleTimeout: number;\n  nonStreamingTimeout: number;\n  circuitFailureThreshold: number;\n  circuitSuccessThreshold: number;\n  circuitTimeoutSeconds: number;\n  circuitErrorRateThreshold: number;\n  circuitMinRequests: number;\n}\n"
  },
  {
    "path": "src/types/usage.ts",
    "content": "// 使用统计相关类型定义\n\nexport interface TokenUsage {\n  inputTokens: number;\n  outputTokens: number;\n  cacheReadTokens: number;\n  cacheCreationTokens: number;\n}\n\nexport interface RequestLog {\n  requestId: string;\n  providerId: string;\n  providerName?: string;\n  appType: string;\n  model: string;\n  requestModel?: string;\n  costMultiplier: string;\n  inputTokens: number;\n  outputTokens: number;\n  cacheReadTokens: number;\n  cacheCreationTokens: number;\n  inputCostUsd: string;\n  outputCostUsd: string;\n  cacheReadCostUsd: string;\n  cacheCreationCostUsd: string;\n  totalCostUsd: string;\n  isStreaming: boolean;\n  latencyMs: number;\n  firstTokenMs?: number;\n  durationMs?: number;\n  statusCode: number;\n  errorMessage?: string;\n  createdAt: number;\n}\n\nexport interface PaginatedLogs {\n  data: RequestLog[];\n  total: number;\n  page: number;\n  pageSize: number;\n}\n\nexport interface ModelPricing {\n  modelId: string;\n  displayName: string;\n  inputCostPerMillion: string;\n  outputCostPerMillion: string;\n  cacheReadCostPerMillion: string;\n  cacheCreationCostPerMillion: string;\n}\n\nexport interface UsageSummary {\n  totalRequests: number;\n  totalCost: string;\n  totalInputTokens: number;\n  totalOutputTokens: number;\n  totalCacheCreationTokens: number;\n  totalCacheReadTokens: number;\n  successRate: number;\n}\n\nexport interface DailyStats {\n  date: string;\n  requestCount: number;\n  totalCost: string;\n  totalTokens: number;\n  totalInputTokens: number;\n  totalOutputTokens: number;\n  totalCacheCreationTokens: number;\n  totalCacheReadTokens: number;\n}\n\nexport interface ProviderStats {\n  providerId: string;\n  providerName: string;\n  requestCount: number;\n  totalTokens: number;\n  totalCost: string;\n  successRate: number;\n  avgLatencyMs: number;\n}\n\nexport interface ModelStats {\n  model: string;\n  requestCount: number;\n  totalTokens: number;\n  totalCost: string;\n  avgCostPerRequest: string;\n}\n\nexport interface LogFilters {\n  appType?: string;\n  providerName?: string;\n  model?: string;\n  statusCode?: number;\n  startDate?: number;\n  endDate?: number;\n}\n\nexport interface ProviderLimitStatus {\n  providerId: string;\n  dailyUsage: string;\n  dailyLimit?: string;\n  dailyExceeded: boolean;\n  monthlyUsage: string;\n  monthlyLimit?: string;\n  monthlyExceeded: boolean;\n}\n\nexport type TimeRange = \"1d\" | \"7d\" | \"30d\";\n\nexport interface StatsFilters {\n  timeRange: TimeRange;\n  providerId?: string;\n  appType?: string;\n}\n"
  },
  {
    "path": "src/types.ts",
    "content": "export type ProviderCategory =\n  | \"official\" // 官方\n  | \"cn_official\" // 开源官方（原\"国产官方\"）\n  | \"cloud_provider\" // 云服务商（AWS Bedrock 等）\n  | \"aggregator\" // 聚合网站\n  | \"third_party\" // 第三方供应商\n  | \"custom\" // 自定义\n  | \"omo\" // Oh My OpenCode\n  | \"omo-slim\"; // Oh My OpenCode Slim\n\nexport interface Provider {\n  id: string;\n  name: string;\n  settingsConfig: Record<string, any>; // 应用配置对象：Claude 为 settings.json；Codex 为 { auth, config }\n  websiteUrl?: string;\n  // 新增：供应商分类（用于差异化提示/能力开关）\n  category?: ProviderCategory;\n  createdAt?: number; // 添加时间戳（毫秒）\n  sortIndex?: number; // 排序索引（用于自定义拖拽排序）\n  // 备注信息\n  notes?: string;\n  // 新增：是否为商业合作伙伴\n  isPartner?: boolean;\n  // 可选：供应商元数据（仅存于 ~/.cc-switch/config.json，不写入 live 配置）\n  meta?: ProviderMeta;\n  // 图标配置\n  icon?: string; // 图标名称（如 \"openai\", \"anthropic\"）\n  iconColor?: string; // 图标颜色（Hex 格式，如 \"#00A67E\"）\n  // 是否加入故障转移队列\n  inFailoverQueue?: boolean;\n}\n\nexport interface AppConfig {\n  providers: Record<string, Provider>;\n  current: string;\n}\n\n// 自定义端点配置\nexport interface CustomEndpoint {\n  url: string;\n  addedAt: number;\n  lastUsed?: number;\n}\n\n// 端点候选项（用于端点测速弹窗）\nexport interface EndpointCandidate {\n  id?: string;\n  url: string;\n  isCustom?: boolean;\n}\n\nimport type { TemplateType } from \"./config/constants\";\n\n// 用量查询脚本配置\nexport interface UsageScript {\n  enabled: boolean; // 是否启用用量查询\n  language: \"javascript\"; // 脚本语言\n  code: string; // 脚本代码（JSON 格式配置）\n  timeout?: number; // 超时时间（秒，默认 10）\n  templateType?: TemplateType; // 模板类型（用于后端判断验证规则）\n  apiKey?: string; // 用量查询专用的 API Key（通用模板使用）\n  baseUrl?: string; // 用量查询专用的 Base URL（通用和 NewAPI 模板使用）\n  accessToken?: string; // 访问令牌（NewAPI 模板使用）\n  userId?: string; // 用户ID（NewAPI 模板使用）\n  autoQueryInterval?: number; // 自动查询间隔（单位：分钟，0 表示禁用）\n  autoIntervalMinutes?: number; // 自动查询间隔（分钟）- 别名字段\n  request?: {\n    // 请求配置\n    url?: string; // 请求 URL\n    method?: string; // HTTP 方法\n    headers?: Record<string, string>; // 请求头\n    body?: any; // 请求体\n  };\n}\n\n// 单个套餐用量数据\nexport interface UsageData {\n  planName?: string; // 套餐名称（可选）\n  extra?: string; // 扩展字段，可自由补充需要展示的文本（可选）\n  isValid?: boolean; // 套餐是否有效（可选）\n  invalidMessage?: string; // 失效原因说明（可选，当 isValid 为 false 时显示）\n  total?: number; // 总额度（可选）\n  used?: number; // 已用额度（可选）\n  remaining?: number; // 剩余额度（可选）\n  unit?: string; // 单位（可选）\n}\n\n// 用量查询结果（支持多套餐）\nexport interface UsageResult {\n  success: boolean;\n  data?: UsageData[]; // 改为数组，支持返回多个套餐\n  error?: string;\n}\n\n// 供应商单独的模型测试配置\nexport interface ProviderTestConfig {\n  // 是否启用单独配置（false 时使用全局配置）\n  enabled: boolean;\n  // 测试用的模型名称（覆盖全局配置）\n  testModel?: string;\n  // 超时时间（秒）\n  timeoutSecs?: number;\n  // 测试提示词\n  testPrompt?: string;\n  // 降级阈值（毫秒）\n  degradedThresholdMs?: number;\n  // 最大重试次数\n  maxRetries?: number;\n}\n\n// 供应商单独的代理配置\nexport interface ProviderProxyConfig {\n  // 是否启用单独配置（false 时使用全局/系统代理）\n  enabled: boolean;\n  // 代理类型：http, https, socks5\n  proxyType?: \"http\" | \"https\" | \"socks5\";\n  // 代理主机\n  proxyHost?: string;\n  // 代理端口\n  proxyPort?: number;\n  // 代理用户名（可选）\n  proxyUsername?: string;\n  // 代理密码（可选）\n  proxyPassword?: string;\n}\n\nexport type AuthBindingSource = \"provider_config\" | \"managed_account\";\n\nexport interface AuthBinding {\n  source: AuthBindingSource;\n  authProvider?: string;\n  accountId?: string;\n}\n\n// 供应商元数据（字段名与后端一致，保持 snake_case）\nexport interface ProviderMeta {\n  // 自定义端点：以 URL 为键，值为端点信息\n  custom_endpoints?: Record<string, CustomEndpoint>;\n  // 是否在切换/同步到 live 时应用通用配置片段\n  commonConfigEnabled?: boolean;\n  // 用量查询脚本配置\n  usage_script?: UsageScript;\n  // 请求地址管理：测速后自动选择最佳端点\n  endpointAutoSelect?: boolean;\n  // 是否为官方合作伙伴\n  isPartner?: boolean;\n  // 合作伙伴促销 key（用于后端识别 PackyCode 等）\n  partnerPromotionKey?: string;\n  // 供应商单独的模型测试配置\n  testConfig?: ProviderTestConfig;\n  // 供应商单独的代理配置\n  proxyConfig?: ProviderProxyConfig;\n  // 供应商成本倍率\n  costMultiplier?: string;\n  // 供应商计费模式来源\n  pricingModelSource?: string;\n  // Claude API 格式（仅 Claude 供应商使用）\n  // - \"anthropic\": 原生 Anthropic Messages API 格式，直接透传\n  // - \"openai_chat\": OpenAI Chat Completions 格式，需要格式转换\n  // - \"openai_responses\": OpenAI Responses API 格式，需要格式转换\n  apiFormat?: \"anthropic\" | \"openai_chat\" | \"openai_responses\";\n  // 通用认证绑定\n  authBinding?: AuthBinding;\n  // Claude 认证字段名\n  apiKeyField?: ClaudeApiKeyField;\n  // Prompt cache key for OpenAI-compatible endpoints (improves cache hit rate)\n  promptCacheKey?: string;\n  // 供应商类型（用于识别 Copilot 等特殊供应商）\n  providerType?: string;\n  // GitHub Copilot 关联账号 ID（旧字段，保留兼容读取）\n  githubAccountId?: string;\n}\n\n// Skill 同步方式\nexport type SkillSyncMethod = \"auto\" | \"symlink\" | \"copy\";\n\n// Claude API 格式类型\n// - \"anthropic\": 原生 Anthropic Messages API 格式，直接透传\n// - \"openai_chat\": OpenAI Chat Completions 格式，需要格式转换\n// - \"openai_responses\": OpenAI Responses API 格式，需要格式转换\nexport type ClaudeApiFormat = \"anthropic\" | \"openai_chat\" | \"openai_responses\";\n\n// Claude 认证字段类型\nexport type ClaudeApiKeyField = \"ANTHROPIC_AUTH_TOKEN\" | \"ANTHROPIC_API_KEY\";\n\n// 主页面显示的应用配置\nexport interface VisibleApps {\n  claude: boolean;\n  codex: boolean;\n  gemini: boolean;\n  opencode: boolean;\n  openclaw: boolean;\n}\n\n// WebDAV 同步状态\nexport interface WebDavSyncStatus {\n  lastSyncAt?: number | null;\n  lastError?: string | null;\n  lastErrorSource?: string | null;\n  lastRemoteEtag?: string | null;\n  lastLocalManifestHash?: string | null;\n  lastRemoteManifestHash?: string | null;\n}\n\n// WebDAV 同步配置\nexport interface WebDavSyncSettings {\n  enabled?: boolean;\n  autoSync?: boolean;\n  baseUrl?: string;\n  username?: string;\n  password?: string;\n  remoteRoot?: string;\n  profile?: string;\n  status?: WebDavSyncStatus;\n}\n\nexport type RemoteSnapshotLayout = \"current\" | \"legacy\";\n\n// 远端快照信息（下载前预览）\nexport interface RemoteSnapshotInfo {\n  deviceName: string;\n  createdAt: string;\n  snapshotId: string;\n  version: number;\n  protocolVersion: number;\n  dbCompatVersion?: number | null;\n  compatible: boolean;\n  artifacts: string[];\n  layout: RemoteSnapshotLayout;\n  remotePath: string;\n}\n\n// 应用设置类型（用于设置对话框与 Tauri API）\n// 存储在本地 ~/.cc-switch/settings.json，不随数据库同步\nexport interface Settings {\n  // ===== 设备级 UI 设置 =====\n  // 是否在系统托盘（macOS 菜单栏）显示图标\n  showInTray: boolean;\n  // 点击关闭按钮时是否最小化到托盘而不是关闭应用\n  minimizeToTrayOnClose: boolean;\n  // 启用 Claude 插件联动（写入 ~/.claude/config.json 的 primaryApiKey）\n  enableClaudePluginIntegration?: boolean;\n  // 跳过 Claude Code 初次安装确认（写入 ~/.claude.json 的 hasCompletedOnboarding）\n  skipClaudeOnboarding?: boolean;\n  // 是否开机自启\n  launchOnStartup?: boolean;\n  // 静默启动（程序启动时不显示主窗口）\n  silentStartup?: boolean;\n  // 是否启用主页面本地代理功能（默认关闭）\n  enableLocalProxy?: boolean;\n  // User has confirmed the local proxy first-run notice\n  proxyConfirmed?: boolean;\n  // User has confirmed the usage query first-run notice\n  usageConfirmed?: boolean;\n  // User has confirmed the stream check first-run notice\n  streamCheckConfirmed?: boolean;\n  // Whether to show the failover toggle independently on the main page\n  enableFailoverToggle?: boolean;\n  // User has confirmed the failover toggle first-run notice\n  failoverConfirmed?: boolean;\n  // User has confirmed the auto-sync traffic warning\n  autoSyncConfirmed?: boolean;\n  // 首选语言（可选，默认中文）\n  language?: \"en\" | \"zh\" | \"ja\";\n\n  // 主页面显示的应用（默认全部显示）\n  visibleApps?: VisibleApps;\n\n  // ===== 设备级目录覆盖 =====\n  // 覆盖 Claude Code 配置目录（可选）\n  claudeConfigDir?: string;\n  // 覆盖 Codex 配置目录（可选）\n  codexConfigDir?: string;\n  // 覆盖 Gemini 配置目录（可选）\n  geminiConfigDir?: string;\n  // 覆盖 OpenCode 配置目录（可选）\n  opencodeConfigDir?: string;\n  // 覆盖 OpenClaw 配置目录（可选）\n  openclawConfigDir?: string;\n\n  // ===== 当前供应商 ID（设备级）=====\n  // 当前 Claude 供应商 ID（优先于数据库 is_current）\n  currentProviderClaude?: string;\n  // 当前 Codex 供应商 ID（优先于数据库 is_current）\n  currentProviderCodex?: string;\n  // 当前 Gemini 供应商 ID（优先于数据库 is_current）\n  currentProviderGemini?: string;\n\n  // ===== Skill 同步设置 =====\n  // Skill 同步方式：auto（默认，优先 symlink）、symlink、copy\n  skillSyncMethod?: SkillSyncMethod;\n\n  // ===== WebDAV v2 同步设置 =====\n  webdavSync?: WebDavSyncSettings;\n\n  // ===== 备份策略设置 =====\n  // Auto-backup interval in hours (0=disabled, default 24)\n  backupIntervalHours?: number;\n  // Maximum backup files to retain (default 10)\n  backupRetainCount?: number;\n\n  // ===== 终端设置 =====\n  // 首选终端应用（可选，默认使用系统默认终端）\n  // macOS: \"terminal\" | \"iterm2\" | \"warp\" | \"alacritty\" | \"kitty\" | \"ghostty\"\n  // Windows: \"cmd\" | \"powershell\" | \"wt\"\n  // Linux: \"gnome-terminal\" | \"konsole\" | \"xfce4-terminal\" | \"alacritty\" | \"kitty\" | \"ghostty\"\n  preferredTerminal?: string;\n}\n\nexport interface SessionMeta {\n  providerId: string;\n  sessionId: string;\n  title?: string;\n  summary?: string;\n  projectDir?: string | null;\n  createdAt?: number;\n  lastActiveAt?: number;\n  sourcePath?: string;\n  resumeCommand?: string;\n}\n\nexport interface SessionMessage {\n  role: string;\n  content: string;\n  ts?: number;\n}\n\n// MCP 服务器连接参数（宽松：允许扩展字段）\nexport interface McpServerSpec {\n  // 可选：社区常见 .mcp.json 中 stdio 配置可不写 type\n  type?: \"stdio\" | \"http\" | \"sse\";\n  // stdio 字段\n  command?: string;\n  args?: string[];\n  env?: Record<string, string>;\n  cwd?: string;\n  // http 和 sse 字段\n  url?: string;\n  headers?: Record<string, string>;\n  // 通用字段\n  [key: string]: any;\n}\n\n// v3.7.0: MCP 服务器应用启用状态\nexport interface McpApps {\n  claude: boolean;\n  codex: boolean;\n  gemini: boolean;\n  opencode: boolean;\n  openclaw: boolean;\n}\n\n// MCP 服务器条目（v3.7.0 统一结构）\nexport interface McpServer {\n  id: string;\n  name: string;\n  server: McpServerSpec;\n  apps: McpApps; // v3.7.0: 标记应用到哪些客户端\n  description?: string;\n  tags?: string[];\n  homepage?: string;\n  docs?: string;\n  // 兼容旧字段（v3.6.x 及以前）\n  enabled?: boolean; // 已废弃，v3.7.0 使用 apps 字段\n  source?: string;\n  [key: string]: any;\n}\n\n// MCP 服务器映射（id -> McpServer）\nexport type McpServersMap = Record<string, McpServer>;\n\n// MCP 配置状态\nexport interface McpStatus {\n  userConfigPath: string;\n  userConfigExists: boolean;\n  serverCount: number;\n}\n\n// 新：来自 config.json 的 MCP 列表响应\nexport interface McpConfigResponse {\n  configPath: string;\n  servers: Record<string, McpServer>;\n}\n\n// ============================================================================\n// 统一供应商（Universal Provider）- 跨应用共享配置\n// ============================================================================\n\n// 统一供应商的应用启用状态\nexport interface UniversalProviderApps {\n  claude: boolean;\n  codex: boolean;\n  gemini: boolean;\n}\n\n// Claude 模型配置\nexport interface ClaudeModelConfig {\n  model?: string;\n  haikuModel?: string;\n  sonnetModel?: string;\n  opusModel?: string;\n}\n\n// Codex 模型配置\nexport interface CodexModelConfig {\n  model?: string;\n  reasoningEffort?: string;\n}\n\n// Gemini 模型配置\nexport interface GeminiModelConfig {\n  model?: string;\n}\n\n// 各应用的模型配置\nexport interface UniversalProviderModels {\n  claude?: ClaudeModelConfig;\n  codex?: CodexModelConfig;\n  gemini?: GeminiModelConfig;\n}\n\n// 统一供应商（跨应用共享配置）\nexport interface UniversalProvider {\n  id: string;\n  name: string;\n  providerType: string; // \"newapi\" | \"custom\" 等\n  apps: UniversalProviderApps;\n  baseUrl: string;\n  apiKey: string;\n  models: UniversalProviderModels;\n  websiteUrl?: string;\n  notes?: string;\n  icon?: string;\n  iconColor?: string;\n  meta?: ProviderMeta;\n  createdAt?: number;\n  sortIndex?: number;\n}\n\n// 统一供应商映射（id -> UniversalProvider）\nexport type UniversalProvidersMap = Record<string, UniversalProvider>;\n\n// ============================================================================\n// OpenCode 专属配置（v3.9.2+）\n// ============================================================================\n\n// OpenCode 模型配置\nexport interface OpenCodeModel {\n  name: string;\n  limit?: {\n    context?: number;\n    output?: number;\n  };\n  options?: Record<string, unknown>; // 模型级别额外选项（provider 路由等）\n  // 支持任意额外字段（cost、modalities、thinking、variants 等）\n  [key: string]: unknown;\n}\n\n// OpenCode 供应商选项\nexport interface OpenCodeProviderOptions {\n  baseURL?: string;\n  apiKey?: string;\n  headers?: Record<string, string>;\n  // 支持额外选项（timeout, setCacheKey 等）\n  [key: string]: unknown;\n}\n\n// OpenCode 供应商配置（settings_config 结构）\nexport interface OpenCodeProviderConfig {\n  npm: string; // AI SDK 包名，如 \"@ai-sdk/openai-compatible\"\n  name?: string; // 供应商显示名称\n  options: OpenCodeProviderOptions;\n  models: Record<string, OpenCodeModel>;\n}\n\n// OpenCode MCP 服务器配置（与统一格式不同）\nexport interface OpenCodeMcpServerSpec {\n  type: \"local\" | \"remote\";\n  // local 类型字段\n  command?: string[]; // 与统一格式不同：命令和参数合并为数组\n  environment?: Record<string, string>; // 与统一格式不同：使用 environment 而非 env\n  // remote 类型字段\n  url?: string;\n  headers?: Record<string, string>;\n  // 通用字段\n  enabled?: boolean;\n}\n\n// ============================================================================\n// OpenClaw 专属配置（v3.11.0+）\n// ============================================================================\n\n// OpenClaw 模型配置\nexport interface OpenClawModel {\n  id: string;\n  name: string;\n  alias?: string;\n  reasoning?: boolean; // 是否支持推理模式（如 o1、DeepSeek R1）\n  input?: string[]; // 支持的输入类型（如 [\"text\"]、[\"text\", \"image\"]）\n  cost?: {\n    input: number;\n    output: number;\n    cacheRead?: number; // 缓存读取价格\n    cacheWrite?: number; // 缓存写入价格\n  };\n  contextWindow?: number;\n  maxTokens?: number; // 最大输出 token 数\n}\n\n// OpenClaw 默认模型配置（agents.defaults.model）\nexport interface OpenClawDefaultModel {\n  primary: string;\n  fallbacks?: string[];\n}\n\n// OpenClaw 模型目录条目（agents.defaults.models 中的值）\nexport interface OpenClawModelCatalogEntry {\n  alias?: string;\n}\n\nexport interface OpenClawHealthWarning {\n  code: string;\n  message: string;\n  path?: string;\n}\n\nexport interface OpenClawWriteOutcome {\n  backupPath?: string;\n  warnings: OpenClawHealthWarning[];\n}\n\nexport type OpenClawToolsProfile = \"minimal\" | \"coding\" | \"messaging\" | \"full\";\n\n// OpenClaw 供应商配置（settings_config 结构）\n// 对应 OpenClaw 的 models.providers.<provider-id> 配置\nexport interface OpenClawProviderConfig {\n  baseUrl?: string; // API 端点\n  apiKey?: string; // API 密钥\n  api?: string; // API 协议类型（如 \"openai-completions\"、\"anthropic\"）\n  models?: OpenClawModel[]; // 可用模型列表\n  headers?: Record<string, string>; // 自定义请求头（如 User-Agent）\n  authHeader?: boolean; // 供应商自定义认证开关（如 Longcat）\n}\n\n// OpenClaw agents.defaults 完整配置\nexport interface OpenClawAgentsDefaults {\n  model?: OpenClawDefaultModel;\n  models?: Record<string, OpenClawModelCatalogEntry>;\n  timeoutSeconds?: number;\n  timeout?: number;\n  [key: string]: unknown; // preserve unknown fields\n}\n\n// OpenClaw env 配置（openclaw.json 的 env 节点）\nexport interface OpenClawEnvConfig {\n  [key: string]: unknown;\n}\n\n// OpenClaw tools 配置（openclaw.json 的 tools 节点）\nexport interface OpenClawToolsConfig {\n  profile?: OpenClawToolsProfile | string;\n  allow?: string[];\n  deny?: string[];\n  [key: string]: unknown; // preserve unknown fields\n}\n"
  },
  {
    "path": "src/utils/domUtils.ts",
    "content": "export function isTextEditableTarget(target: EventTarget | null): boolean {\n  if (!(target instanceof HTMLElement)) return false;\n\n  const tagName = target.tagName;\n  return (\n    tagName === \"INPUT\" ||\n    tagName === \"TEXTAREA\" ||\n    tagName === \"SELECT\" ||\n    target.isContentEditable\n  );\n}\n"
  },
  {
    "path": "src/utils/errorUtils.ts",
    "content": "/**\n * 从各种错误对象中提取错误信息\n * @param error 错误对象\n * @returns 提取的错误信息字符串\n */\nexport const extractErrorMessage = (error: unknown): string => {\n  if (!error) return \"\";\n  if (typeof error === \"string\") {\n    return error;\n  }\n  if (error instanceof Error && error.message.trim()) {\n    return error.message;\n  }\n\n  if (typeof error === \"object\") {\n    const errObject = error as Record<string, unknown>;\n\n    const candidate = errObject.message ?? errObject.error ?? errObject.detail;\n    if (typeof candidate === \"string\" && candidate.trim()) {\n      return candidate;\n    }\n\n    const payload = errObject.payload;\n    if (typeof payload === \"string\" && payload.trim()) {\n      return payload;\n    }\n    if (payload && typeof payload === \"object\") {\n      const payloadObj = payload as Record<string, unknown>;\n      const payloadCandidate =\n        payloadObj.message ?? payloadObj.error ?? payloadObj.detail;\n      if (typeof payloadCandidate === \"string\" && payloadCandidate.trim()) {\n        return payloadCandidate;\n      }\n    }\n  }\n\n  return \"\";\n};\n\n/**\n * 将已知的 MCP 相关后端错误（通常为中文硬编码）映射为 i18n 文案\n * 采用包含式匹配，尽量稳健地覆盖不同上下文的相似消息。\n * 若无法识别，返回空字符串以便调用方回退到原始 detail 或默认 i18n。\n */\nexport const translateMcpBackendError = (\n  message: string,\n  t: (key: string, opts?: any) => string,\n): string => {\n  if (!message) return \"\";\n  const msg = String(message).trim();\n\n  // 基础字段与结构校验相关\n  if (msg.includes(\"MCP 服务器 ID 不能为空\")) {\n    return t(\"mcp.error.idRequired\");\n  }\n  if (\n    msg.includes(\"MCP 服务器定义必须为 JSON 对象\") ||\n    msg.includes(\"MCP 服务器条目必须为 JSON 对象\") ||\n    msg.includes(\"MCP 服务器条目缺少 server 字段\") ||\n    msg.includes(\"MCP 服务器 server 字段必须为 JSON 对象\") ||\n    msg.includes(\"MCP 服务器连接定义必须为 JSON 对象\") ||\n    msg.includes(\"MCP 服务器 '\" /* 不是对象 */) ||\n    msg.includes(\"不是对象\") ||\n    msg.includes(\"服务器配置必须是对象\") ||\n    msg.includes(\"MCP 服务器 name 必须为字符串\") ||\n    msg.includes(\"MCP 服务器 description 必须为字符串\") ||\n    msg.includes(\"MCP 服务器 homepage 必须为字符串\") ||\n    msg.includes(\"MCP 服务器 docs 必须为字符串\") ||\n    msg.includes(\"MCP 服务器 tags 必须为字符串数组\") ||\n    msg.includes(\"MCP 服务器 enabled 必须为布尔值\")\n  ) {\n    return t(\"mcp.error.jsonInvalid\");\n  }\n  if (msg.includes(\"MCP 服务器 type 必须是\")) {\n    return t(\"mcp.error.jsonInvalid\");\n  }\n\n  // 必填字段\n  if (\n    msg.includes(\"stdio 类型的 MCP 服务器缺少 command 字段\") ||\n    msg.includes(\"必须包含 command 字段\")\n  ) {\n    return t(\"mcp.error.commandRequired\");\n  }\n  if (\n    msg.includes(\"http 类型的 MCP 服务器缺少 url 字段\") ||\n    msg.includes(\"sse 类型的 MCP 服务器缺少 url 字段\") ||\n    msg.includes(\"必须包含 url 字段\") ||\n    msg === \"URL 不能为空\"\n  ) {\n    return t(\"mcp.wizard.urlRequired\");\n  }\n\n  // 文件解析/序列化\n  if (\n    msg.includes(\"解析 ~/.claude.json 失败\") ||\n    msg.includes(\"解析 config.toml 失败\") ||\n    msg.includes(\"无法识别的 TOML 格式\") ||\n    msg.includes(\"TOML 内容不能为空\")\n  ) {\n    return t(\"mcp.error.tomlInvalid\");\n  }\n  if (msg.includes(\"序列化 config.toml 失败\")) {\n    return t(\"mcp.error.tomlInvalid\");\n  }\n\n  return \"\";\n};\n"
  },
  {
    "path": "src/utils/formatters.ts",
    "content": "/**\n * 格式化 JSON 字符串\n * @param value - 原始 JSON 字符串\n * @returns 格式化后的 JSON 字符串（2 空格缩进）\n * @throws 如果 JSON 格式无效\n */\nexport function formatJSON(value: string): string {\n  const trimmed = value.trim();\n  if (!trimmed) {\n    return \"\";\n  }\n  const parsed = JSON.parse(trimmed);\n  return JSON.stringify(parsed, null, 2);\n}\n\n/**\n * 智能解析 MCP JSON 配置\n * 支持两种格式：\n * 1. 纯配置对象：{ \"command\": \"npx\", \"args\": [...], ... }\n * 2. 带键名包装：  \"server-name\": { \"command\": \"npx\", ... }  或  { \"server-name\": {...} }\n *\n * @param jsonText - JSON 字符串\n * @returns { id?: string, config: object, formattedConfig: string }\n * @throws 如果 JSON 格式无效\n */\nexport function parseSmartMcpJson(jsonText: string): {\n  id?: string;\n  config: any;\n  formattedConfig: string;\n} {\n  let trimmed = jsonText.trim();\n  if (!trimmed) {\n    return { config: {}, formattedConfig: \"\" };\n  }\n\n  // 如果是键值对片段（\"key\": {...}），包装成完整对象\n  if (trimmed.startsWith('\"') && !trimmed.startsWith(\"{\")) {\n    trimmed = `{${trimmed}}`;\n  }\n\n  const parsed = JSON.parse(trimmed);\n\n  // 如果是单键对象且值是对象，提取键名和配置\n  const keys = Object.keys(parsed);\n  if (\n    keys.length === 1 &&\n    parsed[keys[0]] &&\n    typeof parsed[keys[0]] === \"object\" &&\n    !Array.isArray(parsed[keys[0]])\n  ) {\n    const id = keys[0];\n    const config = parsed[id];\n    return {\n      id,\n      config,\n      formattedConfig: JSON.stringify(config, null, 2),\n    };\n  }\n\n  // 否则直接使用\n  return {\n    config: parsed,\n    formattedConfig: JSON.stringify(parsed, null, 2),\n  };\n}\n\n/**\n * TOML 格式化功能已禁用\n *\n * 原因：smol-toml 的 parse/stringify 会丢失所有注释和原有排版。\n * 由于 TOML 常用于配置文件，注释是重要的文档说明，丢失注释会造成严重的用户体验问题。\n *\n * 未来可选方案：\n * - 使用 @ltd/j-toml（支持注释保留，但需额外依赖和复杂的 API）\n * - 实现仅格式化缩进/空白的轻量级方案\n * - 使用 toml-eslint-parser + 自定义生成器\n *\n * 暂时建议：依赖现有的 TOML 语法校验（useCodexTomlValidation），不提供格式化功能。\n */\n"
  },
  {
    "path": "src/utils/postChangeSync.ts",
    "content": "import { settingsApi } from \"@/lib/api\";\n\n/**\n * 统一的“后置同步”工具：将当前使用的供应商写回对应应用的 live 配置。\n * 不抛出异常，由调用方根据返回值决定提示策略。\n */\nexport async function syncCurrentProvidersLiveSafe(): Promise<{\n  ok: boolean;\n  error?: Error;\n}> {\n  try {\n    await settingsApi.syncCurrentProvidersLive();\n    return { ok: true };\n  } catch (err) {\n    const error = err instanceof Error ? err : new Error(String(err ?? \"\"));\n    return { ok: false, error };\n  }\n}\n"
  },
  {
    "path": "src/utils/providerConfigUtils.ts",
    "content": "// 供应商配置处理工具函数\n\nimport type { TemplateValueConfig } from \"../config/claudeProviderPresets\";\nimport { normalizeTomlText } from \"@/utils/textNormalization\";\nimport { parse as parseToml, stringify as stringifyToml } from \"smol-toml\";\n\nconst isPlainObject = (value: unknown): value is Record<string, any> => {\n  return Object.prototype.toString.call(value) === \"[object Object]\";\n};\n\nconst deepMerge = (\n  target: Record<string, any>,\n  source: Record<string, any>,\n): Record<string, any> => {\n  Object.entries(source).forEach(([key, value]) => {\n    if (isPlainObject(value)) {\n      if (!isPlainObject(target[key])) {\n        target[key] = {};\n      }\n      deepMerge(target[key], value);\n    } else {\n      // 直接覆盖非对象字段（数组/基础类型）\n      target[key] = value;\n    }\n  });\n  return target;\n};\n\nconst deepRemove = (\n  target: Record<string, any>,\n  source: Record<string, any>,\n) => {\n  Object.entries(source).forEach(([key, value]) => {\n    if (!(key in target)) return;\n\n    if (isPlainObject(value) && isPlainObject(target[key])) {\n      // 只移除完全匹配的嵌套属性\n      deepRemove(target[key], value);\n      if (Object.keys(target[key]).length === 0) {\n        delete target[key];\n      }\n    } else if (isSubset(target[key], value)) {\n      // 只有当值完全匹配时才删除\n      delete target[key];\n    }\n  });\n};\n\nconst isSubset = (target: any, source: any): boolean => {\n  if (isPlainObject(source)) {\n    if (!isPlainObject(target)) return false;\n    return Object.entries(source).every(([key, value]) =>\n      isSubset(target[key], value),\n    );\n  }\n\n  if (Array.isArray(source)) {\n    if (!Array.isArray(target) || target.length !== source.length) return false;\n    return source.every((item, index) => isSubset(target[index], item));\n  }\n\n  return target === source;\n};\n\n// 深拷贝函数\nconst deepClone = <T>(obj: T): T => {\n  if (obj === null || typeof obj !== \"object\") return obj;\n  if (obj instanceof Date) return new Date(obj.getTime()) as T;\n  if (obj instanceof Array) return obj.map((item) => deepClone(item)) as T;\n  if (obj instanceof Object) {\n    const clonedObj = {} as T;\n    for (const key in obj) {\n      if (obj.hasOwnProperty(key)) {\n        clonedObj[key] = deepClone(obj[key]);\n      }\n    }\n    return clonedObj;\n  }\n  return obj;\n};\n\nexport interface UpdateCommonConfigResult {\n  updatedConfig: string;\n  error?: string;\n}\n\n// 验证JSON配置格式\nexport const validateJsonConfig = (\n  value: string,\n  fieldName: string = \"配置\",\n): string => {\n  if (!value.trim()) {\n    return \"\";\n  }\n  try {\n    const parsed = JSON.parse(value);\n    if (!parsed || typeof parsed !== \"object\" || Array.isArray(parsed)) {\n      return `${fieldName}必须是 JSON 对象`;\n    }\n    return \"\";\n  } catch {\n    return `${fieldName}JSON格式错误，请检查语法`;\n  }\n};\n\n// 将通用配置片段写入/移除 settingsConfig\nexport const updateCommonConfigSnippet = (\n  jsonString: string,\n  snippetString: string,\n  enabled: boolean,\n): UpdateCommonConfigResult => {\n  let config: Record<string, any>;\n  try {\n    config = jsonString ? JSON.parse(jsonString) : {};\n  } catch (err) {\n    return {\n      updatedConfig: jsonString,\n      error: \"配置 JSON 解析失败，无法写入通用配置\",\n    };\n  }\n\n  if (!snippetString.trim()) {\n    return {\n      updatedConfig: JSON.stringify(config, null, 2),\n    };\n  }\n\n  // 使用统一的验证函数\n  const snippetError = validateJsonConfig(snippetString, \"通用配置片段\");\n  if (snippetError) {\n    return {\n      updatedConfig: JSON.stringify(config, null, 2),\n      error: snippetError,\n    };\n  }\n\n  const snippet = JSON.parse(snippetString) as Record<string, any>;\n\n  if (enabled) {\n    const merged = deepMerge(deepClone(config), snippet);\n    return {\n      updatedConfig: JSON.stringify(merged, null, 2),\n    };\n  }\n\n  const cloned = deepClone(config);\n  deepRemove(cloned, snippet);\n  return {\n    updatedConfig: JSON.stringify(cloned, null, 2),\n  };\n};\n\n// 检查当前配置是否已包含通用配置片段\nexport const hasCommonConfigSnippet = (\n  jsonString: string,\n  snippetString: string,\n): boolean => {\n  try {\n    if (!snippetString.trim()) return false;\n    const config = jsonString ? JSON.parse(jsonString) : {};\n    const snippet = JSON.parse(snippetString);\n    if (!isPlainObject(snippet)) return false;\n    return isSubset(config, snippet);\n  } catch (err) {\n    return false;\n  }\n};\n\n// 读取配置中的 API Key（支持 Claude, Codex, Gemini）\nexport const getApiKeyFromConfig = (\n  jsonString: string,\n  appType?: string,\n): string => {\n  try {\n    const config = JSON.parse(jsonString);\n\n    // 优先检查顶层 apiKey 字段（用于 Bedrock API Key 等预设）\n    if (\n      typeof config?.apiKey === \"string\" &&\n      config.apiKey &&\n      !config.apiKey.includes(\"${\")\n    ) {\n      return config.apiKey;\n    }\n\n    const env = config?.env;\n\n    if (!env) return \"\";\n\n    // Gemini API Key\n    if (appType === \"gemini\") {\n      const geminiKey = env.GEMINI_API_KEY;\n      return typeof geminiKey === \"string\" ? geminiKey : \"\";\n    }\n\n    // Codex API Key\n    if (appType === \"codex\") {\n      const codexKey = env.CODEX_API_KEY;\n      return typeof codexKey === \"string\" ? codexKey : \"\";\n    }\n\n    // Claude API Key (优先 ANTHROPIC_AUTH_TOKEN，其次 ANTHROPIC_API_KEY)\n    const token = env.ANTHROPIC_AUTH_TOKEN;\n    const apiKey = env.ANTHROPIC_API_KEY;\n    const value =\n      typeof token === \"string\"\n        ? token\n        : typeof apiKey === \"string\"\n          ? apiKey\n          : \"\";\n    return value;\n  } catch (err) {\n    return \"\";\n  }\n};\n\n// 模板变量替换\nexport const applyTemplateValues = (\n  config: any,\n  templateValues: Record<string, TemplateValueConfig> | undefined,\n): any => {\n  const resolvedValues = Object.fromEntries(\n    Object.entries(templateValues ?? {}).map(([key, value]) => {\n      const resolvedValue =\n        value.editorValue !== undefined\n          ? value.editorValue\n          : (value.defaultValue ?? \"\");\n      return [key, resolvedValue];\n    }),\n  );\n\n  const replaceInString = (str: string): string => {\n    return Object.entries(resolvedValues).reduce((acc, [key, value]) => {\n      const placeholder = `\\${${key}}`;\n      if (!acc.includes(placeholder)) {\n        return acc;\n      }\n      return acc.split(placeholder).join(value ?? \"\");\n    }, str);\n  };\n\n  const traverse = (obj: any): any => {\n    if (typeof obj === \"string\") {\n      return replaceInString(obj);\n    }\n    if (Array.isArray(obj)) {\n      return obj.map(traverse);\n    }\n    if (obj && typeof obj === \"object\") {\n      const result: any = {};\n      for (const [key, value] of Object.entries(obj)) {\n        result[key] = traverse(value);\n      }\n      return result;\n    }\n    return obj;\n  };\n\n  return traverse(config);\n};\n\n// 判断配置中是否存在 API Key 字段\nexport const hasApiKeyField = (\n  jsonString: string,\n  appType?: string,\n): boolean => {\n  try {\n    const config = JSON.parse(jsonString);\n\n    // 检查顶层 apiKey 字段（用于 Bedrock API Key 等预设）\n    if (Object.prototype.hasOwnProperty.call(config, \"apiKey\")) {\n      return true;\n    }\n\n    const env = config?.env ?? {};\n\n    if (appType === \"gemini\") {\n      return Object.prototype.hasOwnProperty.call(env, \"GEMINI_API_KEY\");\n    }\n\n    if (appType === \"codex\") {\n      return Object.prototype.hasOwnProperty.call(env, \"CODEX_API_KEY\");\n    }\n\n    return (\n      Object.prototype.hasOwnProperty.call(env, \"ANTHROPIC_AUTH_TOKEN\") ||\n      Object.prototype.hasOwnProperty.call(env, \"ANTHROPIC_API_KEY\")\n    );\n  } catch (err) {\n    return false;\n  }\n};\n\n// 写入/更新配置中的 API Key，默认不新增缺失字段\nexport const setApiKeyInConfig = (\n  jsonString: string,\n  apiKey: string,\n  options: {\n    createIfMissing?: boolean;\n    appType?: string;\n    apiKeyField?: string;\n  } = {},\n): string => {\n  const { createIfMissing = false, appType, apiKeyField } = options;\n  try {\n    const config = JSON.parse(jsonString);\n\n    // 优先检查顶层 apiKey 字段（用于 Bedrock API Key 等预设）\n    if (Object.prototype.hasOwnProperty.call(config, \"apiKey\")) {\n      config.apiKey = apiKey;\n      return JSON.stringify(config, null, 2);\n    }\n\n    if (!config.env) {\n      if (!createIfMissing) return jsonString;\n      config.env = {};\n    }\n    const env = config.env as Record<string, any>;\n\n    // Gemini API Key\n    if (appType === \"gemini\") {\n      if (\"GEMINI_API_KEY\" in env) {\n        env.GEMINI_API_KEY = apiKey;\n      } else if (createIfMissing) {\n        env.GEMINI_API_KEY = apiKey;\n      } else {\n        return jsonString;\n      }\n      return JSON.stringify(config, null, 2);\n    }\n\n    // Codex API Key\n    if (appType === \"codex\") {\n      if (\"CODEX_API_KEY\" in env) {\n        env.CODEX_API_KEY = apiKey;\n      } else if (createIfMissing) {\n        env.CODEX_API_KEY = apiKey;\n      } else {\n        return jsonString;\n      }\n      return JSON.stringify(config, null, 2);\n    }\n\n    // Claude API Key (优先写入已存在的字段；若两者均不存在且允许创建，则使用 apiKeyField 或默认 AUTH_TOKEN 字段)\n    if (\"ANTHROPIC_AUTH_TOKEN\" in env) {\n      env.ANTHROPIC_AUTH_TOKEN = apiKey;\n    } else if (\"ANTHROPIC_API_KEY\" in env) {\n      env.ANTHROPIC_API_KEY = apiKey;\n    } else if (createIfMissing) {\n      env[apiKeyField ?? \"ANTHROPIC_AUTH_TOKEN\"] = apiKey;\n    } else {\n      return jsonString;\n    }\n    return JSON.stringify(config, null, 2);\n  } catch (err) {\n    return jsonString;\n  }\n};\n\n// ========== TOML Config Utilities ==========\n\nexport interface UpdateTomlCommonConfigResult {\n  updatedConfig: string;\n  error?: string;\n}\n\n// Write/remove common config snippet to/from TOML config (structural merge)\nexport const updateTomlCommonConfigSnippet = (\n  tomlString: string,\n  snippetString: string,\n  enabled: boolean,\n): UpdateTomlCommonConfigResult => {\n  if (!snippetString.trim()) {\n    return { updatedConfig: tomlString };\n  }\n\n  try {\n    const config = parseToml(normalizeTomlText(tomlString || \"\"));\n    const snippet = parseToml(normalizeTomlText(snippetString));\n\n    if (enabled) {\n      const merged = deepMerge(\n        deepClone(config) as Record<string, any>,\n        deepClone(snippet) as Record<string, any>,\n      );\n      return { updatedConfig: stringifyToml(merged) };\n    } else {\n      const result = deepClone(config) as Record<string, any>;\n      deepRemove(result, snippet as Record<string, any>);\n      return { updatedConfig: stringifyToml(result) };\n    }\n  } catch (e) {\n    return { updatedConfig: tomlString, error: String(e) };\n  }\n};\n\n// Check if TOML config already contains the common config snippet (structural subset check)\nexport const hasTomlCommonConfigSnippet = (\n  tomlString: string,\n  snippetString: string,\n): boolean => {\n  if (!snippetString.trim()) return false;\n\n  try {\n    const config = parseToml(normalizeTomlText(tomlString || \"\"));\n    const snippet = parseToml(normalizeTomlText(snippetString));\n    return isSubset(config, snippet);\n  } catch {\n    // Fallback to text-based matching if TOML parsing fails\n    const norm = (s: string) => s.replace(/\\s+/g, \" \").trim();\n    return norm(tomlString).includes(norm(snippetString));\n  }\n};\n\n// ========== Codex base_url utils ==========\n\nconst TOML_SECTION_HEADER_PATTERN = /^\\s*\\[([^\\]\\r\\n]+)\\]\\s*$/;\nconst TOML_BASE_URL_PATTERN =\n  /^\\s*base_url\\s*=\\s*([\"'])([^\"'\\r\\n]+)\\1\\s*(?:#.*)?$/;\nconst TOML_MODEL_PATTERN = /^\\s*model\\s*=\\s*([\"'])([^\"'\\r\\n]+)\\1\\s*(?:#.*)?$/;\nconst TOML_MODEL_PROVIDER_LINE_PATTERN =\n  /^\\s*model_provider\\s*=\\s*([\"'])([^\"'\\r\\n]+)\\1\\s*(?:#.*)?$/;\nconst TOML_MODEL_PROVIDER_PATTERN =\n  /^\\s*model_provider\\s*=\\s*([\"'])([^\"'\\r\\n]+)\\1\\s*(?:#.*)?$/m;\n\ninterface TomlSectionRange {\n  bodyEndIndex: number;\n  bodyStartIndex: number;\n}\n\ninterface TomlAssignmentMatch {\n  index: number;\n  sectionName?: string;\n  value: string;\n}\n\nconst finalizeTomlText = (lines: string[]): string =>\n  lines\n    .join(\"\\n\")\n    .replace(/\\n{3,}/g, \"\\n\\n\")\n    .replace(/^\\n+/, \"\");\n\nconst getTomlSectionRange = (\n  lines: string[],\n  sectionName: string,\n): TomlSectionRange | undefined => {\n  let headerLineIndex = -1;\n\n  for (let index = 0; index < lines.length; index += 1) {\n    const match = lines[index].match(TOML_SECTION_HEADER_PATTERN);\n    if (!match) {\n      continue;\n    }\n\n    if (headerLineIndex === -1) {\n      if (match[1] === sectionName) {\n        headerLineIndex = index;\n      }\n      continue;\n    }\n\n    return {\n      bodyStartIndex: headerLineIndex + 1,\n      bodyEndIndex: index,\n    };\n  }\n\n  if (headerLineIndex === -1) {\n    return undefined;\n  }\n\n  return {\n    bodyStartIndex: headerLineIndex + 1,\n    bodyEndIndex: lines.length,\n  };\n};\n\nconst getTopLevelEndIndex = (lines: string[]): number => {\n  const firstSectionIndex = lines.findIndex((line) =>\n    TOML_SECTION_HEADER_PATTERN.test(line),\n  );\n  return firstSectionIndex === -1 ? lines.length : firstSectionIndex;\n};\n\nconst getTomlSectionInsertIndex = (\n  lines: string[],\n  sectionRange: TomlSectionRange,\n): number => {\n  let insertIndex = sectionRange.bodyEndIndex;\n  while (\n    insertIndex > sectionRange.bodyStartIndex &&\n    lines[insertIndex - 1].trim() === \"\"\n  ) {\n    insertIndex -= 1;\n  }\n  return insertIndex;\n};\n\nconst getCodexModelProviderName = (configText: string): string | undefined => {\n  const match = configText.match(TOML_MODEL_PROVIDER_PATTERN);\n  const providerName = match?.[2]?.trim();\n  return providerName || undefined;\n};\n\nconst getCodexProviderSectionName = (\n  configText: string,\n): string | undefined => {\n  const providerName = getCodexModelProviderName(configText);\n  return providerName ? `model_providers.${providerName}` : undefined;\n};\n\nconst findTomlAssignmentInRange = (\n  lines: string[],\n  pattern: RegExp,\n  startIndex: number,\n  endIndex: number,\n  sectionName?: string,\n): TomlAssignmentMatch | undefined => {\n  for (let index = startIndex; index < endIndex; index += 1) {\n    const match = lines[index].match(pattern);\n    if (match?.[2]) {\n      return {\n        index,\n        sectionName,\n        value: match[2],\n      };\n    }\n  }\n\n  return undefined;\n};\n\nconst findTomlAssignments = (\n  lines: string[],\n  pattern: RegExp,\n): TomlAssignmentMatch[] => {\n  const assignments: TomlAssignmentMatch[] = [];\n  let currentSectionName: string | undefined;\n\n  lines.forEach((line, index) => {\n    const sectionMatch = line.match(TOML_SECTION_HEADER_PATTERN);\n    if (sectionMatch) {\n      currentSectionName = sectionMatch[1];\n      return;\n    }\n\n    const match = line.match(pattern);\n    if (!match?.[2]) {\n      return;\n    }\n\n    assignments.push({\n      index,\n      sectionName: currentSectionName,\n      value: match[2],\n    });\n  });\n\n  return assignments;\n};\n\nconst isMcpServerSection = (sectionName?: string): boolean =>\n  sectionName === \"mcp_servers\" ||\n  sectionName?.startsWith(\"mcp_servers.\") === true;\n\nconst isOtherProviderSection = (\n  sectionName: string | undefined,\n  targetSectionName: string | undefined,\n): boolean =>\n  Boolean(\n    sectionName &&\n      sectionName !== targetSectionName &&\n      (sectionName === \"model_providers\" ||\n        sectionName.startsWith(\"model_providers.\")),\n  );\n\nconst getRecoverableBaseUrlAssignments = (\n  assignments: TomlAssignmentMatch[],\n  targetSectionName: string | undefined,\n): TomlAssignmentMatch[] =>\n  assignments.filter(\n    ({ sectionName }) =>\n      sectionName !== targetSectionName &&\n      !isMcpServerSection(sectionName) &&\n      !isOtherProviderSection(sectionName, targetSectionName),\n  );\n\nconst getTopLevelModelProviderLineIndex = (lines: string[]): number => {\n  const topLevelEndIndex = getTopLevelEndIndex(lines);\n\n  for (let index = 0; index < topLevelEndIndex; index += 1) {\n    if (TOML_MODEL_PROVIDER_LINE_PATTERN.test(lines[index])) {\n      return index;\n    }\n  }\n\n  return -1;\n};\n\n// 从 Codex 的 TOML 配置文本中提取 base_url（支持单/双引号）\nexport const extractCodexBaseUrl = (\n  configText: string | undefined | null,\n): string | undefined => {\n  try {\n    const raw = typeof configText === \"string\" ? configText : \"\";\n    const text = normalizeTomlText(raw);\n    if (!text) return undefined;\n\n    const lines = text.split(\"\\n\");\n    const targetSectionName = getCodexProviderSectionName(text);\n\n    if (targetSectionName) {\n      const sectionRange = getTomlSectionRange(lines, targetSectionName);\n      if (sectionRange) {\n        const match = findTomlAssignmentInRange(\n          lines,\n          TOML_BASE_URL_PATTERN,\n          sectionRange.bodyStartIndex,\n          sectionRange.bodyEndIndex,\n          targetSectionName,\n        );\n        if (match?.value) {\n          return match.value;\n        }\n      }\n    }\n\n    const topLevelMatch = findTomlAssignmentInRange(\n      lines,\n      TOML_BASE_URL_PATTERN,\n      0,\n      getTopLevelEndIndex(lines),\n    );\n    if (topLevelMatch?.value) {\n      return topLevelMatch.value;\n    }\n\n    const fallbackAssignments = getRecoverableBaseUrlAssignments(\n      findTomlAssignments(lines, TOML_BASE_URL_PATTERN),\n      targetSectionName,\n    );\n    return fallbackAssignments.length === 1\n      ? fallbackAssignments[0].value\n      : undefined;\n  } catch {\n    return undefined;\n  }\n};\n\n// 从 Provider 对象中提取 Codex base_url（当 settingsConfig.config 为 TOML 字符串时）\nexport const getCodexBaseUrl = (\n  provider: { settingsConfig?: Record<string, any> } | undefined | null,\n): string | undefined => {\n  try {\n    const text =\n      typeof provider?.settingsConfig?.config === \"string\"\n        ? (provider as any).settingsConfig.config\n        : \"\";\n    return extractCodexBaseUrl(text);\n  } catch {\n    return undefined;\n  }\n};\n\n// 在 Codex 的 TOML 配置文本中写入或更新 base_url 字段\nexport const setCodexBaseUrl = (\n  configText: string,\n  baseUrl: string,\n): string => {\n  const trimmed = baseUrl.trim();\n  const normalizedText = normalizeTomlText(configText);\n  const lines = normalizedText ? normalizedText.split(\"\\n\") : [];\n  const targetSectionName = getCodexProviderSectionName(normalizedText);\n  const allAssignments = findTomlAssignments(lines, TOML_BASE_URL_PATTERN);\n  const recoverableAssignments = getRecoverableBaseUrlAssignments(\n    allAssignments,\n    targetSectionName,\n  );\n\n  if (!trimmed) {\n    if (!normalizedText) return normalizedText;\n\n    if (targetSectionName) {\n      const sectionRange = getTomlSectionRange(lines, targetSectionName);\n      const targetMatch = sectionRange\n        ? findTomlAssignmentInRange(\n            lines,\n            TOML_BASE_URL_PATTERN,\n            sectionRange.bodyStartIndex,\n            sectionRange.bodyEndIndex,\n            targetSectionName,\n          )\n        : undefined;\n\n      if (targetMatch) {\n        lines.splice(targetMatch.index, 1);\n        return finalizeTomlText(lines);\n      }\n    }\n\n    if (recoverableAssignments.length === 1) {\n      lines.splice(recoverableAssignments[0].index, 1);\n      return finalizeTomlText(lines);\n    }\n\n    return finalizeTomlText(lines);\n  }\n\n  const normalizedUrl = trimmed.replace(/\\s+/g, \"\");\n  const replacementLine = `base_url = \"${normalizedUrl}\"`;\n\n  if (targetSectionName) {\n    let targetSectionRange = getTomlSectionRange(lines, targetSectionName);\n    const targetMatch = targetSectionRange\n      ? findTomlAssignmentInRange(\n          lines,\n          TOML_BASE_URL_PATTERN,\n          targetSectionRange.bodyStartIndex,\n          targetSectionRange.bodyEndIndex,\n          targetSectionName,\n        )\n      : undefined;\n\n    if (targetMatch) {\n      lines[targetMatch.index] = replacementLine;\n      return finalizeTomlText(lines);\n    }\n\n    if (recoverableAssignments.length === 1) {\n      lines.splice(recoverableAssignments[0].index, 1);\n      targetSectionRange = getTomlSectionRange(lines, targetSectionName);\n    }\n\n    if (targetSectionRange) {\n      const insertIndex = getTomlSectionInsertIndex(lines, targetSectionRange);\n      lines.splice(insertIndex, 0, replacementLine);\n      return finalizeTomlText(lines);\n    }\n\n    if (lines.length > 0 && lines[lines.length - 1].trim() !== \"\") {\n      lines.push(\"\");\n    }\n    lines.push(`[${targetSectionName}]`, replacementLine);\n    return finalizeTomlText(lines);\n  }\n\n  const topLevelEndIndex = getTopLevelEndIndex(lines);\n  const topLevelMatch = findTomlAssignmentInRange(\n    lines,\n    TOML_BASE_URL_PATTERN,\n    0,\n    topLevelEndIndex,\n  );\n  if (topLevelMatch) {\n    lines[topLevelMatch.index] = replacementLine;\n    return finalizeTomlText(lines);\n  }\n\n  const modelProviderIndex = getTopLevelModelProviderLineIndex(lines);\n  if (modelProviderIndex !== -1) {\n    lines.splice(modelProviderIndex + 1, 0, replacementLine);\n    return finalizeTomlText(lines);\n  }\n\n  if (lines.length === 0) {\n    return `${replacementLine}\\n`;\n  }\n\n  const insertIndex = topLevelEndIndex;\n  lines.splice(insertIndex, 0, replacementLine);\n  return finalizeTomlText(lines);\n};\n\n// ========== Codex model name utils ==========\n\n// 从 Codex 的 TOML 配置文本中提取 model 字段（支持单/双引号）\nexport const extractCodexModelName = (\n  configText: string | undefined | null,\n): string | undefined => {\n  try {\n    const raw = typeof configText === \"string\" ? configText : \"\";\n    const text = normalizeTomlText(raw);\n    if (!text) return undefined;\n    const lines = text.split(\"\\n\");\n    const topLevelMatch = findTomlAssignmentInRange(\n      lines,\n      TOML_MODEL_PATTERN,\n      0,\n      getTopLevelEndIndex(lines),\n    );\n    return topLevelMatch?.value;\n  } catch {\n    return undefined;\n  }\n};\n\n// 在 Codex 的 TOML 配置文本中写入或更新 model 字段\nexport const setCodexModelName = (\n  configText: string,\n  modelName: string,\n): string => {\n  const trimmed = modelName.trim();\n  const normalizedText = normalizeTomlText(configText);\n  const lines = normalizedText ? normalizedText.split(\"\\n\") : [];\n  const topLevelEndIndex = getTopLevelEndIndex(lines);\n  const topLevelMatch = findTomlAssignmentInRange(\n    lines,\n    TOML_MODEL_PATTERN,\n    0,\n    topLevelEndIndex,\n  );\n\n  if (!trimmed) {\n    if (!normalizedText) return normalizedText;\n    if (topLevelMatch) {\n      lines.splice(topLevelMatch.index, 1);\n    }\n    return finalizeTomlText(lines);\n  }\n\n  const replacementLine = `model = \"${trimmed}\"`;\n  if (topLevelMatch) {\n    lines[topLevelMatch.index] = replacementLine;\n    return finalizeTomlText(lines);\n  }\n\n  const modelProviderIndex = getTopLevelModelProviderLineIndex(lines);\n  if (modelProviderIndex !== -1) {\n    lines.splice(modelProviderIndex + 1, 0, replacementLine);\n    return finalizeTomlText(lines);\n  }\n\n  if (lines.length === 0) {\n    return `${replacementLine}\\n`;\n  }\n\n  lines.splice(topLevelEndIndex, 0, replacementLine);\n  return finalizeTomlText(lines);\n};\n\n// ========== Codex top-level integer field utils ==========\n\nconst tomlTopLevelIntPattern = (field: string) =>\n  new RegExp(`^\\\\s*${field}\\\\s*=\\\\s*(\\\\d+)\\\\s*(?:#.*)?$`);\n\nconst findTopLevelIntMatch = (\n  lines: string[],\n  fieldName: string,\n  topLevelEndIndex: number,\n): { index: number; value: number } | undefined => {\n  const pattern = tomlTopLevelIntPattern(fieldName);\n  for (let i = 0; i < topLevelEndIndex; i += 1) {\n    const m = lines[i].match(pattern);\n    if (m) {\n      return { index: i, value: Number(m[1]) };\n    }\n  }\n  return undefined;\n};\n\n// 从 Codex TOML 配置中提取顶级整数字段\nexport const extractCodexTopLevelInt = (\n  configText: string | undefined | null,\n  fieldName: string,\n): number | undefined => {\n  try {\n    const raw = typeof configText === \"string\" ? configText : \"\";\n    const text = normalizeTomlText(raw);\n    if (!text) return undefined;\n    const lines = text.split(\"\\n\");\n    return findTopLevelIntMatch(lines, fieldName, getTopLevelEndIndex(lines))\n      ?.value;\n  } catch {\n    return undefined;\n  }\n};\n\n// 在 Codex TOML 配置中设置或更新顶级整数字段\nexport const setCodexTopLevelInt = (\n  configText: string,\n  fieldName: string,\n  value: number,\n): string => {\n  const normalizedText = normalizeTomlText(configText);\n  const lines = normalizedText ? normalizedText.split(\"\\n\") : [];\n  const topLevelEndIndex = getTopLevelEndIndex(lines);\n  const existing = findTopLevelIntMatch(lines, fieldName, topLevelEndIndex);\n  const replacementLine = `${fieldName} = ${value}`;\n\n  if (existing) {\n    lines[existing.index] = replacementLine;\n    return finalizeTomlText(lines);\n  }\n\n  // 插入位置：顶级区域末尾（section header 之前）\n  if (lines.length === 0) {\n    return `${replacementLine}\\n`;\n  }\n\n  lines.splice(topLevelEndIndex, 0, replacementLine);\n  return finalizeTomlText(lines);\n};\n\n// 从 Codex TOML 配置中移除顶级字段行\nexport const removeCodexTopLevelField = (\n  configText: string,\n  fieldName: string,\n): string => {\n  const normalizedText = normalizeTomlText(configText);\n  if (!normalizedText) return normalizedText;\n  const lines = normalizedText.split(\"\\n\");\n  const topLevelEndIndex = getTopLevelEndIndex(lines);\n  const existing = findTopLevelIntMatch(lines, fieldName, topLevelEndIndex);\n  if (existing) {\n    lines.splice(existing.index, 1);\n  }\n  return finalizeTomlText(lines);\n};\n"
  },
  {
    "path": "src/utils/providerMetaUtils.ts",
    "content": "import type { CustomEndpoint, ProviderMeta } from \"@/types\";\n\n/**\n * 合并供应商元数据中的自定义端点。\n * - 当 customEndpoints 为空对象时，明确删除自定义端点但保留其它元数据。\n * - 当 customEndpoints 为 null/undefined 时，不修改端点（保留原有端点）。\n * - 当 customEndpoints 存在时，覆盖原有自定义端点。\n * - 若结果为空对象且非明确清空场景则返回 undefined，避免写入空 meta。\n */\nexport function mergeProviderMeta(\n  initialMeta: ProviderMeta | undefined,\n  customEndpoints: Record<string, CustomEndpoint> | null | undefined,\n): ProviderMeta | undefined {\n  const hasCustomEndpoints =\n    !!customEndpoints && Object.keys(customEndpoints).length > 0;\n\n  // 明确清空：传入空对象（非 null/undefined）表示用户想要删除所有端点\n  const isExplicitClear =\n    customEndpoints !== null &&\n    customEndpoints !== undefined &&\n    Object.keys(customEndpoints).length === 0;\n\n  if (hasCustomEndpoints) {\n    return {\n      ...(initialMeta ? { ...initialMeta } : {}),\n      custom_endpoints: customEndpoints!,\n    };\n  }\n\n  // 明确清空端点\n  if (isExplicitClear) {\n    if (!initialMeta) {\n      // 新供应商且用户没有添加端点（理论上不会到这里）\n      return undefined;\n    }\n\n    if (\"custom_endpoints\" in initialMeta) {\n      const { custom_endpoints, ...rest } = initialMeta;\n      // 保留其他字段（如 usage_script）\n      // 即使 rest 为空，也要返回空对象（让后端知道要清空 meta）\n      return Object.keys(rest).length > 0 ? rest : {};\n    }\n\n    // initialMeta 中本来就没有 custom_endpoints\n    return { ...initialMeta };\n  }\n\n  // null/undefined：用户没有修改端点，保持不变\n  if (!initialMeta) {\n    return undefined;\n  }\n\n  if (\"custom_endpoints\" in initialMeta) {\n    const { custom_endpoints, ...rest } = initialMeta;\n    return Object.keys(rest).length > 0 ? rest : undefined;\n  }\n\n  return { ...initialMeta };\n}\n"
  },
  {
    "path": "src/utils/textNormalization.ts",
    "content": "/**\n * 将常见的中文/全角/弯引号统一为 ASCII 引号，以避免 TOML 解析失败。\n * - 双引号：” “ „ ‟ ＂ → \"\n * - 单引号：’ ‘ ＇ → '\n * 保守起见，不替换书名号/角引号（《》、「」等），避免误伤内容语义。\n */\nexport const normalizeQuotes = (text: string): string => {\n  if (!text) return text;\n  return (\n    text\n      // 双引号族 → \"\n      .replace(/[“”„‟＂]/g, '\"')\n      // 单引号族 → '\n      .replace(/[‘’＇]/g, \"'\")\n  );\n};\n\n/**\n * 专用于 TOML 文本的归一化；目前等同于 normalizeQuotes，后续可扩展（如空白、行尾等）。\n */\nexport const normalizeTomlText = (text: string): string =>\n  normalizeQuotes(text);\n"
  },
  {
    "path": "src/utils/tomlUtils.ts",
    "content": "import { parse as parseToml, stringify as stringifyToml } from \"smol-toml\";\nimport { normalizeTomlText } from \"@/utils/textNormalization\";\nimport { McpServerSpec } from \"../types\";\n\n/**\n * 验证 TOML 格式并转换为 JSON 对象\n * @param text TOML 文本\n * @returns 错误信息（空字符串表示成功）\n */\nexport const validateToml = (text: string): string => {\n  if (!text.trim()) return \"\";\n  try {\n    const normalized = normalizeTomlText(text);\n    const parsed = parseToml(normalized);\n    if (!parsed || typeof parsed !== \"object\" || Array.isArray(parsed)) {\n      return \"mustBeObject\";\n    }\n    return \"\";\n  } catch (e: any) {\n    // 返回底层错误信息，由上层进行 i18n 包装\n    return e?.message || \"parseError\";\n  }\n};\n\n/**\n * 将 McpServerSpec 对象转换为 TOML 字符串\n * 使用 @iarna/toml 的 stringify，自动处理转义与嵌套表\n * 保留所有字段（包括扩展字段如 timeout_ms）\n */\nexport const mcpServerToToml = (server: McpServerSpec): string => {\n  // 先复制所有字段（保留扩展字段）\n  const obj: any = { ...server };\n\n  // 去除未定义字段，确保输出更干净\n  for (const k of Object.keys(obj)) {\n    if (obj[k] === undefined) delete obj[k];\n  }\n\n  // stringify 默认会带换行，做一次 trim 以适配文本框展示\n  return stringifyToml(obj).trim();\n};\n\n/**\n * 将 TOML 文本转换为 McpServerSpec 对象（单个服务器配置）\n * 支持两种格式：\n * 1. 直接的服务器配置（type, command, args 等）\n * 2. [mcp_servers.<id>] 格式（推荐，取第一个服务器）\n * 3. [mcp.servers.<id>] 错误格式（容错解析，同样取第一个服务器）\n * @param tomlText TOML 文本\n * @returns McpServer 对象\n * @throws 解析或转换失败时抛出错误\n */\nexport const tomlToMcpServer = (tomlText: string): McpServerSpec => {\n  if (!tomlText.trim()) {\n    throw new Error(\"TOML 内容不能为空\");\n  }\n\n  const parsed = parseToml(normalizeTomlText(tomlText));\n\n  // 情况 1: 直接是服务器配置（包含 type/command/url 等字段）\n  if (\n    parsed.type ||\n    parsed.command ||\n    parsed.url ||\n    parsed.args ||\n    parsed.env\n  ) {\n    return normalizeServerConfig(parsed);\n  }\n\n  // 情况 2: [mcp_servers.<id>] 格式（推荐）\n  if (parsed.mcp_servers && typeof parsed.mcp_servers === \"object\") {\n    const serverIds = Object.keys(parsed.mcp_servers);\n    if (serverIds.length > 0) {\n      const firstServer = (parsed.mcp_servers as any)[serverIds[0]];\n      return normalizeServerConfig(firstServer);\n    }\n  }\n\n  // 情况 3: [mcp.servers.<id>] 错误格式（容错解析）\n  if (parsed.mcp && typeof parsed.mcp === \"object\") {\n    const mcpObj = parsed.mcp as any;\n    if (mcpObj.servers && typeof mcpObj.servers === \"object\") {\n      const serverIds = Object.keys(mcpObj.servers);\n      if (serverIds.length > 0) {\n        const firstServer = mcpObj.servers[serverIds[0]];\n        return normalizeServerConfig(firstServer);\n      }\n    }\n  }\n\n  throw new Error(\n    \"无法识别的 TOML 格式。请提供单个 MCP 服务器配置，或使用 [mcp_servers.<id>] 格式\",\n  );\n};\n\n/**\n * 规范化服务器配置对象为 McpServer 格式\n * 保留所有字段（包括扩展字段如 timeout_ms）\n */\nfunction normalizeServerConfig(config: any): McpServerSpec {\n  if (!config || typeof config !== \"object\") {\n    throw new Error(\"服务器配置必须是对象\");\n  }\n\n  const type = (config.type as string) || \"stdio\";\n\n  // 已知字段列表（用于后续排除）\n  const knownFields = new Set<string>();\n\n  if (type === \"stdio\") {\n    if (!config.command || typeof config.command !== \"string\") {\n      throw new Error(\"stdio 类型的 MCP 服务器必须包含 command 字段\");\n    }\n\n    const server: McpServerSpec = {\n      type: \"stdio\",\n      command: config.command,\n    };\n    knownFields.add(\"type\");\n    knownFields.add(\"command\");\n\n    // 可选字段\n    if (config.args && Array.isArray(config.args)) {\n      server.args = config.args.map((arg: any) => String(arg));\n      knownFields.add(\"args\");\n    }\n    if (config.env && typeof config.env === \"object\") {\n      const env: Record<string, string> = {};\n      for (const [k, v] of Object.entries(config.env)) {\n        env[k] = String(v);\n      }\n      server.env = env;\n      knownFields.add(\"env\");\n    }\n    if (config.cwd && typeof config.cwd === \"string\") {\n      server.cwd = config.cwd;\n      knownFields.add(\"cwd\");\n    }\n\n    // 保留所有未知字段（如 timeout_ms 等扩展字段）\n    for (const key of Object.keys(config)) {\n      if (!knownFields.has(key)) {\n        server[key] = config[key];\n      }\n    }\n\n    return server;\n  } else if (type === \"http\" || type === \"sse\") {\n    if (!config.url || typeof config.url !== \"string\") {\n      throw new Error(`${type} 类型的 MCP 服务器必须包含 url 字段`);\n    }\n\n    const server: McpServerSpec = {\n      type: type as \"http\" | \"sse\",\n      url: config.url,\n    };\n    knownFields.add(\"type\");\n    knownFields.add(\"url\");\n\n    // 可选字段\n    if (config.headers && typeof config.headers === \"object\") {\n      const headers: Record<string, string> = {};\n      for (const [k, v] of Object.entries(config.headers)) {\n        headers[k] = String(v);\n      }\n      server.headers = headers;\n      knownFields.add(\"headers\");\n    }\n\n    // 保留所有未知字段\n    for (const key of Object.keys(config)) {\n      if (!knownFields.has(key)) {\n        server[key] = config[key];\n      }\n    }\n\n    return server;\n  } else {\n    throw new Error(`不支持的 MCP 服务器类型: ${type}`);\n  }\n}\n\n/**\n * 尝试从 TOML 中提取合理的服务器 ID/标题\n * @param tomlText TOML 文本\n * @returns 建议的 ID，失败返回空字符串\n */\nexport const extractIdFromToml = (tomlText: string): string => {\n  try {\n    const parsed = parseToml(normalizeTomlText(tomlText));\n\n    // 尝试从 [mcp_servers.<id>] 或 [mcp.servers.<id>] 中提取 ID\n    if (parsed.mcp_servers && typeof parsed.mcp_servers === \"object\") {\n      const serverIds = Object.keys(parsed.mcp_servers);\n      if (serverIds.length > 0) {\n        return serverIds[0];\n      }\n    }\n\n    if (parsed.mcp && typeof parsed.mcp === \"object\") {\n      const mcpObj = parsed.mcp as any;\n      if (mcpObj.servers && typeof mcpObj.servers === \"object\") {\n        const serverIds = Object.keys(mcpObj.servers);\n        if (serverIds.length > 0) {\n          return serverIds[0];\n        }\n      }\n    }\n\n    // 尝试从 command 中推断\n    if (parsed.command && typeof parsed.command === \"string\") {\n      const cmd = parsed.command.split(/[\\\\/]/).pop() || \"\";\n      return cmd.replace(/\\.(exe|bat|sh|js|py)$/i, \"\");\n    }\n  } catch {\n    // 解析失败，返回空\n  }\n\n  return \"\";\n};\n"
  },
  {
    "path": "src/utils/uuid.ts",
    "content": "/**\n * 生成 UUID v4\n *\n * 优先使用 crypto.randomUUID()，不可用时使用 crypto.getRandomValues() 实现\n *\n * 兼容性：\n * - crypto.randomUUID(): Chrome 92+, Safari 15.4+, Firefox 95+\n * - crypto.getRandomValues(): Chrome 11+, Safari 5+, Firefox 21+\n */\nexport function generateUUID(): string {\n  const cryptoApi = globalThis.crypto;\n\n  // 优先使用原生 API\n  if (typeof cryptoApi?.randomUUID === \"function\") {\n    return cryptoApi.randomUUID();\n  }\n\n  // Fallback: 使用 crypto.getRandomValues 实现 UUID v4\n  if (!cryptoApi?.getRandomValues) {\n    throw new Error(\n      \"crypto API not available - please update your operating system\",\n    );\n  }\n\n  const bytes = new Uint8Array(16);\n  cryptoApi.getRandomValues(bytes);\n\n  // 设置版本 (4) 和变体 (RFC 4122)\n  bytes[6] = (bytes[6] & 0x0f) | 0x40;\n  bytes[8] = (bytes[8] & 0x3f) | 0x80;\n\n  const hex = Array.from(bytes)\n    .map((b) => b.toString(16).padStart(2, \"0\"))\n    .join(\"\");\n\n  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;\n}\n"
  },
  {
    "path": "src/vite-env.d.ts",
    "content": "/// <reference types=\"vite/client\" />\n\nexport {};\n"
  },
  {
    "path": "src-tauri/.gitignore",
    "content": "# Generated by Cargo\n# will have compiled files and executables\n/target/\n/gen/schemas\n"
  },
  {
    "path": "src-tauri/Cargo.toml",
    "content": "[package]\nname = \"cc-switch\"\nversion = \"3.12.3\"\ndescription = \"All-in-One Assistant for Claude Code, Codex & Gemini CLI\"\nauthors = [\"Jason Young\"]\nlicense = \"MIT\"\nrepository = \"https://github.com/farion1231/cc-switch\"\nedition = \"2021\"\nrust-version = \"1.85.0\"\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n\n[lib]\nname = \"cc_switch_lib\"\ncrate-type = [\"staticlib\", \"cdylib\", \"rlib\"]\ndoctest = false\n\n[features]\ndefault = []\ntest-hooks = []\n\n[build-dependencies]\ntauri-build = { version = \"2.4.0\", features = [] }\n\n[dependencies]\nserde_json = \"1.0\"\nserde = { version = \"1.0\", features = [\"derive\"] }\nlog = \"0.4\"\nchrono = { version = \"0.4\", features = [\"serde\"] }\ntauri = { version = \"2.8.2\", features = [\"tray-icon\", \"protocol-asset\", \"image-png\"] }\ntauri-plugin-log = \"2\"\ntauri-plugin-opener = \"2\"\ntauri-plugin-process = \"2\"\ntauri-plugin-updater = \"2\"\ntauri-plugin-dialog = \"2\"\ntauri-plugin-store = \"2\"\ntauri-plugin-deep-link = \"2\"\ndirs = \"5.0\"\ntoml = \"0.8\"\ntoml_edit = \"0.22\"\nreqwest = { version = \"0.12\", features = [\"rustls-tls\", \"json\", \"stream\", \"socks\", \"gzip\"] }\ntokio = { version = \"1\", features = [\"macros\", \"rt-multi-thread\", \"time\", \"sync\"] }\nfutures = \"0.3\"\nasync-stream = \"0.3\"\nbytes = \"1.5\"\naxum = \"0.7\"\ntower = \"0.4\"\ntower-http = { version = \"0.5\", features = [\"cors\"] }\nhyper = { version = \"1.0\", features = [\"full\"] }\nregex = \"1.10\"\nrquickjs = { version = \"0.8\", features = [\"array-buffer\", \"classes\"] }\nthiserror = \"2.0\"\nanyhow = \"1.0\"\nzip = \"2.2\"\nserde_yaml = \"0.9\"\ntempfile = \"3\"\nurl = \"2.5\"\nauto-launch = \"0.5\"\nonce_cell = \"1.21.3\"\nbase64 = \"0.22\"\nrusqlite = { version = \"0.31\", features = [\"bundled\", \"backup\", \"hooks\"] }\nindexmap = { version = \"2\", features = [\"serde\"] }\nrust_decimal = \"1.33\"\nuuid = { version = \"1.11\", features = [\"v4\"] }\nsha2 = \"0.10\"\njson5 = \"0.4\"\njson-five = \"0.3.1\"\n\n[target.'cfg(any(target_os = \"macos\", target_os = \"windows\", target_os = \"linux\"))'.dependencies]\ntauri-plugin-single-instance = \"2\"\n\n[target.'cfg(target_os = \"linux\")'.dependencies]\nwebkit2gtk = { version = \"2.0.1\", features = [\"v2_16\"] }\n\n[target.'cfg(target_os = \"windows\")'.dependencies]\nwinreg = \"0.52\"\n\n[target.'cfg(target_os = \"macos\")'.dependencies]\nobjc2 = \"0.5\"\nobjc2-app-kit = { version = \"0.2\", features = [\"NSColor\"] }\n\n# Optimize release binary size to help reduce AppImage footprint\n[profile.release]\ncodegen-units = 1\nlto = \"thin\"\nopt-level = \"s\"\n# 使用 unwind 以便 panic hook 能捕获 backtrace（abort 会直接终止无法捕获）\npanic = \"unwind\"\nstrip = \"symbols\"\n\n[dev-dependencies]\nserial_test = \"3\"\ntempfile = \"3\"\n"
  },
  {
    "path": "src-tauri/Info.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n  <!-- 注册 ccswitch:// 自定义 URL 协议，用于深链接导入 -->\n  <key>CFBundleURLTypes</key>\n  <array>\n    <dict>\n      <key>CFBundleURLName</key>\n      <string>CC Switch Deep Link</string>\n      <key>CFBundleURLSchemes</key>\n      <array>\n        <string>ccswitch</string>\n      </array>\n    </dict>\n  </array>\n</dict>\n</plist>\n\n"
  },
  {
    "path": "src-tauri/build.rs",
    "content": "fn main() {\n    tauri_build::build();\n\n    // Windows: Embed Common Controls v6 manifest for test binaries\n    //\n    // When running `cargo test`, the generated test executables don't include\n    // the standard Tauri application manifest. Without Common Controls v6,\n    // `tauri::test` calls fail with STATUS_ENTRYPOINT_NOT_FOUND.\n    //\n    // This workaround:\n    // 1. Embeds the manifest into test binaries via /MANIFEST:EMBED\n    // 2. Uses /MANIFEST:NO for the main binary to avoid duplicate resources\n    //    (Tauri already handles manifest embedding for the app binary)\n    #[cfg(target_os = \"windows\")]\n    {\n        let manifest_path = std::path::PathBuf::from(\n            std::env::var(\"CARGO_MANIFEST_DIR\").expect(\"missing CARGO_MANIFEST_DIR\"),\n        )\n        .join(\"common-controls.manifest\");\n        let manifest_arg = format!(\"/MANIFESTINPUT:{}\", manifest_path.display());\n\n        println!(\"cargo:rustc-link-arg=/MANIFEST:EMBED\");\n        println!(\"cargo:rustc-link-arg={}\", manifest_arg);\n        // Avoid duplicate manifest resources in binary builds.\n        println!(\"cargo:rustc-link-arg-bins=/MANIFEST:NO\");\n        println!(\"cargo:rerun-if-changed={}\", manifest_path.display());\n    }\n}\n"
  },
  {
    "path": "src-tauri/capabilities/default.json",
    "content": "{\n  \"$schema\": \"../gen/schemas/desktop-schema.json\",\n  \"identifier\": \"default\",\n  \"description\": \"enables the default permissions\",\n  \"windows\": [\n    \"main\"\n  ],\n  \"permissions\": [\n    \"core:default\",\n    \"opener:default\",\n    \"updater:default\",\n    \"core:window:allow-set-skip-taskbar\",\n    \"core:window:allow-start-dragging\",\n    \"process:allow-restart\",\n    \"dialog:default\"\n  ]\n}"
  },
  {
    "path": "src-tauri/common-controls.manifest",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<assembly xmlns=\"urn:schemas-microsoft-com:asm.v1\" manifestVersion=\"1.0\">\n  <dependency>\n    <dependentAssembly>\n      <assemblyIdentity type=\"win32\"\n        name=\"Microsoft.Windows.Common-Controls\"\n        version=\"6.0.0.0\"\n        processorArchitecture=\"*\"\n        publicKeyToken=\"6595b64144ccf1df\"\n        language=\"*\"/>\n    </dependentAssembly>\n  </dependency>\n</assembly>\n"
  },
  {
    "path": "src-tauri/src/app_config.rs",
    "content": "use serde::{Deserialize, Serialize};\nuse std::collections::HashMap;\nuse std::str::FromStr;\n\nuse crate::services::skill::SkillStore;\n\n/// MCP 服务器应用状态（标记应用到哪些客户端）\n#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]\npub struct McpApps {\n    #[serde(default)]\n    pub claude: bool,\n    #[serde(default)]\n    pub codex: bool,\n    #[serde(default)]\n    pub gemini: bool,\n    #[serde(default)]\n    pub opencode: bool,\n}\n\nimpl McpApps {\n    /// 检查指定应用是否启用\n    pub fn is_enabled_for(&self, app: &AppType) -> bool {\n        match app {\n            AppType::Claude => self.claude,\n            AppType::Codex => self.codex,\n            AppType::Gemini => self.gemini,\n            AppType::OpenCode => self.opencode,\n            AppType::OpenClaw => false, // OpenClaw doesn't support MCP\n        }\n    }\n\n    /// 设置指定应用的启用状态\n    pub fn set_enabled_for(&mut self, app: &AppType, enabled: bool) {\n        match app {\n            AppType::Claude => self.claude = enabled,\n            AppType::Codex => self.codex = enabled,\n            AppType::Gemini => self.gemini = enabled,\n            AppType::OpenCode => self.opencode = enabled,\n            AppType::OpenClaw => {} // OpenClaw doesn't support MCP, ignore\n        }\n    }\n\n    /// 获取所有启用的应用列表\n    pub fn enabled_apps(&self) -> Vec<AppType> {\n        let mut apps = Vec::new();\n        if self.claude {\n            apps.push(AppType::Claude);\n        }\n        if self.codex {\n            apps.push(AppType::Codex);\n        }\n        if self.gemini {\n            apps.push(AppType::Gemini);\n        }\n        if self.opencode {\n            apps.push(AppType::OpenCode);\n        }\n        apps\n    }\n\n    /// 检查是否所有应用都未启用\n    pub fn is_empty(&self) -> bool {\n        !self.claude && !self.codex && !self.gemini && !self.opencode\n    }\n}\n\n/// Skill 应用启用状态（标记 Skill 应用到哪些客户端）\n#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]\npub struct SkillApps {\n    #[serde(default)]\n    pub claude: bool,\n    #[serde(default)]\n    pub codex: bool,\n    #[serde(default)]\n    pub gemini: bool,\n    #[serde(default)]\n    pub opencode: bool,\n}\n\nimpl SkillApps {\n    /// 检查指定应用是否启用\n    pub fn is_enabled_for(&self, app: &AppType) -> bool {\n        match app {\n            AppType::Claude => self.claude,\n            AppType::Codex => self.codex,\n            AppType::Gemini => self.gemini,\n            AppType::OpenCode => self.opencode,\n            AppType::OpenClaw => false, // OpenClaw doesn't support Skills\n        }\n    }\n\n    /// 设置指定应用的启用状态\n    pub fn set_enabled_for(&mut self, app: &AppType, enabled: bool) {\n        match app {\n            AppType::Claude => self.claude = enabled,\n            AppType::Codex => self.codex = enabled,\n            AppType::Gemini => self.gemini = enabled,\n            AppType::OpenCode => self.opencode = enabled,\n            AppType::OpenClaw => {} // OpenClaw doesn't support Skills, ignore\n        }\n    }\n\n    /// 获取所有启用的应用列表\n    pub fn enabled_apps(&self) -> Vec<AppType> {\n        let mut apps = Vec::new();\n        if self.claude {\n            apps.push(AppType::Claude);\n        }\n        if self.codex {\n            apps.push(AppType::Codex);\n        }\n        if self.gemini {\n            apps.push(AppType::Gemini);\n        }\n        if self.opencode {\n            apps.push(AppType::OpenCode);\n        }\n        apps\n    }\n\n    /// 检查是否所有应用都未启用\n    pub fn is_empty(&self) -> bool {\n        !self.claude && !self.codex && !self.gemini && !self.opencode\n    }\n\n    /// 仅启用指定应用（其他应用设为禁用）\n    pub fn only(app: &AppType) -> Self {\n        let mut apps = Self::default();\n        apps.set_enabled_for(app, true);\n        apps\n    }\n\n    /// 从来源标签列表构建启用状态\n    ///\n    /// 标签与 AppType::as_str() 一致时启用对应应用，\n    /// 其他标签（如 \"agents\", \"cc-switch\"）忽略。\n    pub fn from_labels(labels: &[String]) -> Self {\n        let mut apps = Self::default();\n        for label in labels {\n            if let Ok(app) = label.parse::<AppType>() {\n                apps.set_enabled_for(&app, true);\n            }\n        }\n        apps\n    }\n}\n\n/// 已安装的 Skill（v3.10.0+ 统一结构）\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct InstalledSkill {\n    /// 唯一标识符（格式：\"owner/repo:directory\" 或 \"local:directory\"）\n    pub id: String,\n    /// 显示名称\n    pub name: String,\n    /// 描述\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub description: Option<String>,\n    /// 安装目录名（在 SSOT 目录中的子目录名）\n    pub directory: String,\n    /// 仓库所有者（GitHub 用户/组织）\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub repo_owner: Option<String>,\n    /// 仓库名称\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub repo_name: Option<String>,\n    /// 仓库分支\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub repo_branch: Option<String>,\n    /// README URL\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub readme_url: Option<String>,\n    /// 应用启用状态\n    pub apps: SkillApps,\n    /// 安装时间（Unix 时间戳）\n    pub installed_at: i64,\n}\n\n/// 未管理的 Skill（在应用目录中发现但未被 CC Switch 管理）\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct UnmanagedSkill {\n    /// 目录名\n    pub directory: String,\n    /// 显示名称（从 SKILL.md 解析）\n    pub name: String,\n    /// 描述\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub description: Option<String>,\n    /// 在哪些应用目录中发现（如 [\"claude\", \"codex\"]）\n    pub found_in: Vec<String>,\n    /// 发现路径（首个匹配的完整路径）\n    pub path: String,\n}\n\n/// MCP 服务器定义（v3.7.0 统一结构）\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct McpServer {\n    pub id: String,\n    pub name: String,\n    pub server: serde_json::Value,\n    pub apps: McpApps,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub description: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub homepage: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub docs: Option<String>,\n    #[serde(default, skip_serializing_if = \"Vec::is_empty\")]\n    pub tags: Vec<String>,\n}\n\n/// MCP 配置：单客户端维度（v3.6.x 及以前，保留用于向后兼容）\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct McpConfig {\n    /// 以 id 为键的服务器定义（宽松 JSON 对象，包含 enabled/source 等 UI 辅助字段）\n    #[serde(default)]\n    pub servers: HashMap<String, serde_json::Value>,\n}\n\nimpl McpConfig {\n    /// 检查配置是否为空\n    pub fn is_empty(&self) -> bool {\n        self.servers.is_empty()\n    }\n}\n\n/// MCP 根配置（v3.7.0 新旧结构并存）\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct McpRoot {\n    /// 统一的 MCP 服务器存储（v3.7.0+）\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub servers: Option<HashMap<String, McpServer>>,\n\n    /// 旧的分应用存储（v3.6.x 及以前，保留用于迁移）\n    #[serde(default, skip_serializing_if = \"McpConfig::is_empty\")]\n    pub claude: McpConfig,\n    #[serde(default, skip_serializing_if = \"McpConfig::is_empty\")]\n    pub codex: McpConfig,\n    #[serde(default, skip_serializing_if = \"McpConfig::is_empty\")]\n    pub gemini: McpConfig,\n    /// OpenCode MCP 配置（v4.0.0+，实际使用 opencode.json）\n    #[serde(default, skip_serializing_if = \"McpConfig::is_empty\")]\n    pub opencode: McpConfig,\n    /// OpenClaw MCP 配置（v4.1.0+，实际使用 openclaw.json）\n    #[serde(default, skip_serializing_if = \"McpConfig::is_empty\")]\n    pub openclaw: McpConfig,\n}\n\nimpl Default for McpRoot {\n    fn default() -> Self {\n        Self {\n            // v3.7.0+ 默认使用新的统一结构（空 HashMap）\n            servers: Some(HashMap::new()),\n            // 旧结构保持空，仅用于反序列化旧配置时的迁移\n            claude: McpConfig::default(),\n            codex: McpConfig::default(),\n            gemini: McpConfig::default(),\n            opencode: McpConfig::default(),\n            openclaw: McpConfig::default(),\n        }\n    }\n}\n\n/// Prompt 配置：单客户端维度\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct PromptConfig {\n    #[serde(default)]\n    pub prompts: HashMap<String, crate::prompt::Prompt>,\n}\n\n/// Prompt 根：按客户端分开维护\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct PromptRoot {\n    #[serde(default)]\n    pub claude: PromptConfig,\n    #[serde(default)]\n    pub codex: PromptConfig,\n    #[serde(default)]\n    pub gemini: PromptConfig,\n    #[serde(default)]\n    pub opencode: PromptConfig,\n    #[serde(default)]\n    pub openclaw: PromptConfig,\n}\n\nuse crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};\nuse crate::error::AppError;\nuse crate::prompt_files::prompt_file_path;\nuse crate::provider::ProviderManager;\n\n/// 应用类型\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n#[serde(rename_all = \"lowercase\")]\npub enum AppType {\n    Claude,\n    Codex,\n    Gemini,\n    OpenCode,\n    OpenClaw,\n}\n\nimpl AppType {\n    pub fn as_str(&self) -> &str {\n        match self {\n            AppType::Claude => \"claude\",\n            AppType::Codex => \"codex\",\n            AppType::Gemini => \"gemini\",\n            AppType::OpenCode => \"opencode\",\n            AppType::OpenClaw => \"openclaw\",\n        }\n    }\n\n    /// Check if this app uses additive mode\n    ///\n    /// - Switch mode (false): Only the current provider is written to live config (Claude, Codex, Gemini)\n    /// - Additive mode (true): All providers are written to live config (OpenCode, OpenClaw)\n    pub fn is_additive_mode(&self) -> bool {\n        matches!(self, AppType::OpenCode | AppType::OpenClaw)\n    }\n\n    /// Return an iterator over all app types\n    pub fn all() -> impl Iterator<Item = AppType> {\n        [\n            AppType::Claude,\n            AppType::Codex,\n            AppType::Gemini,\n            AppType::OpenCode,\n            AppType::OpenClaw,\n        ]\n        .into_iter()\n    }\n}\n\nimpl FromStr for AppType {\n    type Err = AppError;\n\n    fn from_str(s: &str) -> Result<Self, Self::Err> {\n        let normalized = s.trim().to_lowercase();\n        match normalized.as_str() {\n            \"claude\" => Ok(AppType::Claude),\n            \"codex\" => Ok(AppType::Codex),\n            \"gemini\" => Ok(AppType::Gemini),\n            \"opencode\" => Ok(AppType::OpenCode),\n            \"openclaw\" => Ok(AppType::OpenClaw),\n            other => Err(AppError::localized(\n                \"unsupported_app\",\n                format!(\"不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode, openclaw。\"),\n                format!(\"Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode, openclaw.\"),\n            )),\n        }\n    }\n}\n\n/// 通用配置片段（按应用分治）\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct CommonConfigSnippets {\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub claude: Option<String>,\n\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub codex: Option<String>,\n\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub gemini: Option<String>,\n\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub opencode: Option<String>,\n\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub openclaw: Option<String>,\n}\n\nimpl CommonConfigSnippets {\n    /// 获取指定应用的通用配置片段\n    pub fn get(&self, app: &AppType) -> Option<&String> {\n        match app {\n            AppType::Claude => self.claude.as_ref(),\n            AppType::Codex => self.codex.as_ref(),\n            AppType::Gemini => self.gemini.as_ref(),\n            AppType::OpenCode => self.opencode.as_ref(),\n            AppType::OpenClaw => self.openclaw.as_ref(),\n        }\n    }\n\n    /// 设置指定应用的通用配置片段\n    pub fn set(&mut self, app: &AppType, snippet: Option<String>) {\n        match app {\n            AppType::Claude => self.claude = snippet,\n            AppType::Codex => self.codex = snippet,\n            AppType::Gemini => self.gemini = snippet,\n            AppType::OpenCode => self.opencode = snippet,\n            AppType::OpenClaw => self.openclaw = snippet,\n        }\n    }\n}\n\n/// 多应用配置结构（向后兼容）\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct MultiAppConfig {\n    #[serde(default = \"default_version\")]\n    pub version: u32,\n    /// 应用管理器（claude/codex）\n    #[serde(flatten)]\n    pub apps: HashMap<String, ProviderManager>,\n    /// MCP 配置（按客户端分治）\n    #[serde(default)]\n    pub mcp: McpRoot,\n    /// Prompt 配置（按客户端分治）\n    #[serde(default)]\n    pub prompts: PromptRoot,\n    /// Claude Skills 配置\n    #[serde(default)]\n    pub skills: SkillStore,\n    /// 通用配置片段（按应用分治）\n    #[serde(default)]\n    pub common_config_snippets: CommonConfigSnippets,\n    /// Claude 通用配置片段（旧字段，用于向后兼容迁移）\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub claude_common_config_snippet: Option<String>,\n}\n\nfn default_version() -> u32 {\n    2\n}\n\nimpl Default for MultiAppConfig {\n    fn default() -> Self {\n        let mut apps = HashMap::new();\n        apps.insert(\"claude\".to_string(), ProviderManager::default());\n        apps.insert(\"codex\".to_string(), ProviderManager::default());\n        apps.insert(\"gemini\".to_string(), ProviderManager::default());\n        apps.insert(\"opencode\".to_string(), ProviderManager::default());\n        apps.insert(\"openclaw\".to_string(), ProviderManager::default());\n\n        Self {\n            version: 2,\n            apps,\n            mcp: McpRoot::default(),\n            prompts: PromptRoot::default(),\n            skills: SkillStore::default(),\n            common_config_snippets: CommonConfigSnippets::default(),\n            claude_common_config_snippet: None,\n        }\n    }\n}\n\nimpl MultiAppConfig {\n    /// 从文件加载配置（仅支持 v2 结构）\n    pub fn load() -> Result<Self, AppError> {\n        let config_path = get_app_config_path();\n\n        if !config_path.exists() {\n            log::info!(\"配置文件不存在，创建新的多应用配置并自动导入提示词\");\n            // 使用新的方法，支持自动导入提示词\n            let config = Self::default_with_auto_import()?;\n            // 立即保存到磁盘\n            config.save()?;\n            return Ok(config);\n        }\n\n        // 尝试读取文件\n        let content =\n            std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;\n\n        // 先解析为 Value，以便严格判定是否为 v1 结构；\n        // 满足：顶层同时包含 providers(object) + current(string)，且不包含 version/apps/mcp 关键键，即视为 v1\n        let value: serde_json::Value =\n            serde_json::from_str(&content).map_err(|e| AppError::json(&config_path, e))?;\n        let is_v1 = value.as_object().is_some_and(|map| {\n            let has_providers = map.get(\"providers\").map(|v| v.is_object()).unwrap_or(false);\n            let has_current = map.get(\"current\").map(|v| v.is_string()).unwrap_or(false);\n            // v1 的充分必要条件：有 providers 和 current，且 apps 不存在（version/mcp 可能存在但不作为 v2 判据）\n            let has_apps = map.contains_key(\"apps\");\n            has_providers && has_current && !has_apps\n        });\n        if is_v1 {\n            return Err(AppError::localized(\n                \"config.unsupported_v1\",\n                \"检测到旧版 v1 配置格式。当前版本已不再支持运行时自动迁移。\\n\\n解决方案：\\n1. 安装 v3.2.x 版本进行一次性自动迁移\\n2. 或手动编辑 ~/.cc-switch/config.json，将顶层结构调整为：\\n   {\\\"version\\\": 2, \\\"claude\\\": {...}, \\\"codex\\\": {...}, \\\"mcp\\\": {...}}\\n\\n\",\n                \"Detected legacy v1 config. Runtime auto-migration is no longer supported.\\n\\nSolutions:\\n1. Install v3.2.x for one-time auto-migration\\n2. Or manually edit ~/.cc-switch/config.json to adjust the top-level structure:\\n   {\\\"version\\\": 2, \\\"claude\\\": {...}, \\\"codex\\\": {...}, \\\"mcp\\\": {...}}\\n\\n\",\n            ));\n        }\n\n        let has_skills_in_config = value\n            .as_object()\n            .is_some_and(|map| map.contains_key(\"skills\"));\n\n        // 解析 v2 结构\n        let mut config: Self =\n            serde_json::from_value(value).map_err(|e| AppError::json(&config_path, e))?;\n        let mut updated = false;\n\n        if !has_skills_in_config {\n            let skills_path = get_app_config_dir().join(\"skills.json\");\n            if skills_path.exists() {\n                match std::fs::read_to_string(&skills_path) {\n                    Ok(content) => match serde_json::from_str::<SkillStore>(&content) {\n                        Ok(store) => {\n                            config.skills = store;\n                            updated = true;\n                            log::info!(\"已从旧版 skills.json 导入 Claude Skills 配置\");\n                        }\n                        Err(e) => {\n                            log::warn!(\"解析旧版 skills.json 失败: {e}\");\n                        }\n                    },\n                    Err(e) => {\n                        log::warn!(\"读取旧版 skills.json 失败: {e}\");\n                    }\n                }\n            }\n        }\n\n        // 确保 gemini 应用存在（兼容旧配置文件）\n        if !config.apps.contains_key(\"gemini\") {\n            config\n                .apps\n                .insert(\"gemini\".to_string(), ProviderManager::default());\n            updated = true;\n        }\n\n        // 执行 MCP 迁移（v3.6.x → v3.7.0）\n        let migrated = config.migrate_mcp_to_unified()?;\n        if migrated {\n            log::info!(\"MCP 配置已迁移到 v3.7.0 统一结构，保存配置...\");\n            updated = true;\n        }\n\n        // 对于已经存在的配置文件，如果此前版本还没有 Prompt 功能，\n        // 且 prompts 仍然是空的，则尝试自动导入现有提示词文件。\n        let imported_prompts = config.maybe_auto_import_prompts_for_existing_config()?;\n        if imported_prompts {\n            updated = true;\n        }\n\n        // 迁移通用配置片段：claude_common_config_snippet → common_config_snippets.claude\n        if let Some(old_claude_snippet) = config.claude_common_config_snippet.take() {\n            log::info!(\n                \"迁移通用配置：claude_common_config_snippet → common_config_snippets.claude\"\n            );\n            config.common_config_snippets.claude = Some(old_claude_snippet);\n            updated = true;\n        }\n\n        if updated {\n            log::info!(\"配置结构已更新（包括 MCP 迁移或 Prompt 自动导入），保存配置...\");\n            config.save()?;\n        }\n\n        Ok(config)\n    }\n\n    /// 保存配置到文件\n    pub fn save(&self) -> Result<(), AppError> {\n        let config_path = get_app_config_path();\n        // 先备份旧版（若存在）到 ~/.cc-switch/config.json.bak，再写入新内容\n        if config_path.exists() {\n            let backup_path = get_app_config_dir().join(\"config.json.bak\");\n            if let Err(e) = copy_file(&config_path, &backup_path) {\n                log::warn!(\"备份 config.json 到 .bak 失败: {e}\");\n            }\n        }\n\n        write_json_file(&config_path, self)?;\n        Ok(())\n    }\n\n    /// 获取指定应用的管理器\n    pub fn get_manager(&self, app: &AppType) -> Option<&ProviderManager> {\n        self.apps.get(app.as_str())\n    }\n\n    /// 获取指定应用的管理器（可变引用）\n    pub fn get_manager_mut(&mut self, app: &AppType) -> Option<&mut ProviderManager> {\n        self.apps.get_mut(app.as_str())\n    }\n\n    /// 确保应用存在\n    pub fn ensure_app(&mut self, app: &AppType) {\n        if !self.apps.contains_key(app.as_str()) {\n            self.apps\n                .insert(app.as_str().to_string(), ProviderManager::default());\n        }\n    }\n\n    /// 获取指定客户端的 MCP 配置（不可变引用）\n    pub fn mcp_for(&self, app: &AppType) -> &McpConfig {\n        match app {\n            AppType::Claude => &self.mcp.claude,\n            AppType::Codex => &self.mcp.codex,\n            AppType::Gemini => &self.mcp.gemini,\n            AppType::OpenCode => &self.mcp.opencode,\n            AppType::OpenClaw => &self.mcp.openclaw,\n        }\n    }\n\n    /// 获取指定客户端的 MCP 配置（可变引用）\n    pub fn mcp_for_mut(&mut self, app: &AppType) -> &mut McpConfig {\n        match app {\n            AppType::Claude => &mut self.mcp.claude,\n            AppType::Codex => &mut self.mcp.codex,\n            AppType::Gemini => &mut self.mcp.gemini,\n            AppType::OpenCode => &mut self.mcp.opencode,\n            AppType::OpenClaw => &mut self.mcp.openclaw,\n        }\n    }\n\n    /// 创建默认配置并自动导入已存在的提示词文件\n    fn default_with_auto_import() -> Result<Self, AppError> {\n        log::info!(\"首次启动，创建默认配置并检测提示词文件\");\n\n        let mut config = Self::default();\n\n        // 为每个应用尝试自动导入提示词\n        Self::auto_import_prompt_if_exists(&mut config, AppType::Claude)?;\n        Self::auto_import_prompt_if_exists(&mut config, AppType::Codex)?;\n        Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?;\n        Self::auto_import_prompt_if_exists(&mut config, AppType::OpenCode)?;\n        Self::auto_import_prompt_if_exists(&mut config, AppType::OpenClaw)?;\n\n        Ok(config)\n    }\n\n    /// 已存在配置文件时的 Prompt 自动导入逻辑\n    ///\n    /// 适用于「老版本已经生成过 config.json，但当时还没有 Prompt 功能」的升级场景。\n    /// 判定规则：\n    /// - 仅当所有应用的 prompts 都为空时才尝试导入（避免打扰已经在使用 Prompt 功能的用户）\n    /// - 每个应用最多导入一次，对应各自的提示词文件（如 CLAUDE.md/AGENTS.md/GEMINI.md）\n    ///\n    /// 返回值：\n    /// - Ok(true)  表示至少有一个应用成功导入了提示词\n    /// - Ok(false) 表示无需导入或未导入任何内容\n    fn maybe_auto_import_prompts_for_existing_config(&mut self) -> Result<bool, AppError> {\n        // 如果任一应用已经有提示词配置，说明用户已经在使用 Prompt 功能，避免再次自动导入\n        if !self.prompts.claude.prompts.is_empty()\n            || !self.prompts.codex.prompts.is_empty()\n            || !self.prompts.gemini.prompts.is_empty()\n            || !self.prompts.opencode.prompts.is_empty()\n            || !self.prompts.openclaw.prompts.is_empty()\n        {\n            return Ok(false);\n        }\n\n        log::info!(\"检测到已存在配置文件且 Prompt 列表为空，将尝试从现有提示词文件自动导入\");\n\n        let mut imported = false;\n        for app in [\n            AppType::Claude,\n            AppType::Codex,\n            AppType::Gemini,\n            AppType::OpenCode,\n            AppType::OpenClaw,\n        ] {\n            // 复用已有的单应用导入逻辑\n            if Self::auto_import_prompt_if_exists(self, app)? {\n                imported = true;\n            }\n        }\n\n        Ok(imported)\n    }\n\n    /// 检查并自动导入单个应用的提示词文件\n    ///\n    /// 返回值：\n    /// - Ok(true)  表示成功导入了非空文件\n    /// - Ok(false) 表示未导入（文件不存在、内容为空或读取失败）\n    fn auto_import_prompt_if_exists(config: &mut Self, app: AppType) -> Result<bool, AppError> {\n        let file_path = prompt_file_path(&app)?;\n\n        // 检查文件是否存在\n        if !file_path.exists() {\n            log::debug!(\"提示词文件不存在，跳过自动导入: {file_path:?}\");\n            return Ok(false);\n        }\n\n        // 读取文件内容\n        let content = match std::fs::read_to_string(&file_path) {\n            Ok(c) => c,\n            Err(e) => {\n                log::warn!(\"读取提示词文件失败: {file_path:?}, 错误: {e}\");\n                return Ok(false); // 失败时不中断，继续处理其他应用\n            }\n        };\n\n        // 检查内容是否为空\n        if content.trim().is_empty() {\n            log::debug!(\"提示词文件内容为空，跳过导入: {file_path:?}\");\n            return Ok(false);\n        }\n\n        log::info!(\"发现提示词文件，自动导入: {file_path:?}\");\n\n        // 创建提示词对象\n        let timestamp = std::time::SystemTime::now()\n            .duration_since(std::time::UNIX_EPOCH)\n            .map(|d| d.as_secs() as i64)\n            .unwrap_or_else(|_| {\n                log::warn!(\"Failed to get system time, using 0 as timestamp\");\n                0\n            });\n\n        let id = format!(\"auto-imported-{timestamp}\");\n        let prompt = crate::prompt::Prompt {\n            id: id.clone(),\n            name: format!(\n                \"Auto-imported Prompt {}\",\n                chrono::Local::now().format(\"%Y-%m-%d %H:%M\")\n            ),\n            content,\n            description: Some(\"Automatically imported on first launch\".to_string()),\n            enabled: true, // 自动启用\n            created_at: Some(timestamp),\n            updated_at: Some(timestamp),\n        };\n\n        // 插入到对应的应用配置中\n        let prompts = match app {\n            AppType::Claude => &mut config.prompts.claude.prompts,\n            AppType::Codex => &mut config.prompts.codex.prompts,\n            AppType::Gemini => &mut config.prompts.gemini.prompts,\n            AppType::OpenCode => &mut config.prompts.opencode.prompts,\n            AppType::OpenClaw => &mut config.prompts.openclaw.prompts,\n        };\n\n        prompts.insert(id, prompt);\n\n        log::info!(\"自动导入完成: {}\", app.as_str());\n        Ok(true)\n    }\n\n    /// 将 v3.6.x 的分应用 MCP 结构迁移到 v3.7.0 的统一结构\n    ///\n    /// 迁移策略：\n    /// 1. 检查是否已经迁移（mcp.servers 是否存在）\n    /// 2. 收集所有应用的 MCP，按 ID 去重合并\n    /// 3. 生成统一的 McpServer 结构，标记应用到哪些客户端\n    /// 4. 清空旧的分应用配置\n    pub fn migrate_mcp_to_unified(&mut self) -> Result<bool, AppError> {\n        // 检查是否已经是新结构\n        if self.mcp.servers.is_some() {\n            log::debug!(\"MCP 配置已是统一结构，跳过迁移\");\n            return Ok(false);\n        }\n\n        log::info!(\"检测到旧版 MCP 配置格式，开始迁移到 v3.7.0 统一结构...\");\n\n        let mut unified_servers: HashMap<String, McpServer> = HashMap::new();\n        let mut conflicts = Vec::new();\n\n        // 收集所有应用的 MCP\n        for app in [\n            AppType::Claude,\n            AppType::Codex,\n            AppType::Gemini,\n            AppType::OpenCode,\n        ] {\n            let old_servers = match app {\n                AppType::Claude => &self.mcp.claude.servers,\n                AppType::Codex => &self.mcp.codex.servers,\n                AppType::Gemini => &self.mcp.gemini.servers,\n                AppType::OpenCode => &self.mcp.opencode.servers,\n                AppType::OpenClaw => continue, // OpenClaw MCP is still in development, skip\n            };\n\n            for (id, entry) in old_servers {\n                let enabled = entry\n                    .get(\"enabled\")\n                    .and_then(|v| v.as_bool())\n                    .unwrap_or(true);\n\n                if let Some(existing) = unified_servers.get_mut(id) {\n                    // 该 ID 已存在，合并 apps 字段\n                    existing.apps.set_enabled_for(&app, enabled);\n\n                    // 检测配置冲突（同 ID 但配置不同）\n                    if existing.server != *entry.get(\"server\").unwrap_or(&serde_json::json!({})) {\n                        conflicts.push(format!(\n                            \"MCP '{id}' 在 {} 和之前的应用中配置不同，将使用首次遇到的配置\",\n                            app.as_str()\n                        ));\n                    }\n                } else {\n                    // 首次遇到该 MCP，创建新条目\n                    let name = entry\n                        .get(\"name\")\n                        .and_then(|v| v.as_str())\n                        .unwrap_or(id)\n                        .to_string();\n\n                    let server = entry\n                        .get(\"server\")\n                        .cloned()\n                        .unwrap_or(serde_json::json!({}));\n\n                    let description = entry\n                        .get(\"description\")\n                        .and_then(|v| v.as_str())\n                        .map(|s| s.to_string());\n\n                    let homepage = entry\n                        .get(\"homepage\")\n                        .and_then(|v| v.as_str())\n                        .map(|s| s.to_string());\n\n                    let docs = entry\n                        .get(\"docs\")\n                        .and_then(|v| v.as_str())\n                        .map(|s| s.to_string());\n\n                    let tags = entry\n                        .get(\"tags\")\n                        .and_then(|v| v.as_array())\n                        .map(|arr| {\n                            arr.iter()\n                                .filter_map(|v| v.as_str().map(|s| s.to_string()))\n                                .collect()\n                        })\n                        .unwrap_or_default();\n\n                    let mut apps = McpApps::default();\n                    apps.set_enabled_for(&app, enabled);\n\n                    unified_servers.insert(\n                        id.clone(),\n                        McpServer {\n                            id: id.clone(),\n                            name,\n                            server,\n                            apps,\n                            description,\n                            homepage,\n                            docs,\n                            tags,\n                        },\n                    );\n                }\n            }\n        }\n\n        // 记录冲突警告\n        if !conflicts.is_empty() {\n            log::warn!(\"MCP 迁移过程中检测到配置冲突：\");\n            for conflict in &conflicts {\n                log::warn!(\"  - {conflict}\");\n            }\n        }\n\n        log::info!(\n            \"MCP 迁移完成，共迁移 {} 个服务器{}\",\n            unified_servers.len(),\n            if !conflicts.is_empty() {\n                format!(\"（存在 {} 个冲突）\", conflicts.len())\n            } else {\n                String::new()\n            }\n        );\n\n        // 替换为新结构\n        self.mcp.servers = Some(unified_servers);\n\n        // 清空旧的分应用配置\n        self.mcp.claude = McpConfig::default();\n        self.mcp.codex = McpConfig::default();\n        self.mcp.gemini = McpConfig::default();\n\n        Ok(true)\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use serial_test::serial;\n    use std::env;\n    use std::fs;\n    use tempfile::TempDir;\n\n    struct TempHome {\n        #[allow(dead_code)] // 字段通过 Drop trait 管理临时目录生命周期\n        dir: TempDir,\n        original_home: Option<String>,\n        original_userprofile: Option<String>,\n    }\n\n    impl TempHome {\n        fn new() -> Self {\n            let dir = TempDir::new().expect(\"failed to create temp home\");\n            let original_home = env::var(\"HOME\").ok();\n            let original_userprofile = env::var(\"USERPROFILE\").ok();\n\n            env::set_var(\"HOME\", dir.path());\n            env::set_var(\"USERPROFILE\", dir.path());\n\n            Self {\n                dir,\n                original_home,\n                original_userprofile,\n            }\n        }\n    }\n\n    impl Drop for TempHome {\n        fn drop(&mut self) {\n            match &self.original_home {\n                Some(value) => env::set_var(\"HOME\", value),\n                None => env::remove_var(\"HOME\"),\n            }\n\n            match &self.original_userprofile {\n                Some(value) => env::set_var(\"USERPROFILE\", value),\n                None => env::remove_var(\"USERPROFILE\"),\n            }\n        }\n    }\n\n    fn write_prompt_file(app: AppType, content: &str) {\n        let path = crate::prompt_files::prompt_file_path(&app).expect(\"prompt path\");\n        if let Some(parent) = path.parent() {\n            fs::create_dir_all(parent).expect(\"create parent dir\");\n        }\n        fs::write(path, content).expect(\"write prompt\");\n    }\n\n    #[test]\n    #[serial]\n    fn auto_imports_existing_prompt_when_config_missing() {\n        let _home = TempHome::new();\n        write_prompt_file(AppType::Claude, \"# hello\");\n\n        let config = MultiAppConfig::load().expect(\"load config\");\n\n        assert_eq!(config.prompts.claude.prompts.len(), 1);\n        let prompt = config\n            .prompts\n            .claude\n            .prompts\n            .values()\n            .next()\n            .expect(\"prompt exists\");\n        assert!(prompt.enabled);\n        assert_eq!(prompt.content, \"# hello\");\n\n        let config_path = crate::config::get_app_config_path();\n        assert!(\n            config_path.exists(),\n            \"auto import should persist config to disk\"\n        );\n    }\n\n    #[test]\n    #[serial]\n    fn skips_empty_prompt_files_during_import() {\n        let _home = TempHome::new();\n        write_prompt_file(AppType::Claude, \"   \\n  \");\n\n        let config = MultiAppConfig::load().expect(\"load config\");\n        assert!(\n            config.prompts.claude.prompts.is_empty(),\n            \"empty files must be ignored\"\n        );\n    }\n\n    #[test]\n    #[serial]\n    fn auto_import_happens_only_once() {\n        let _home = TempHome::new();\n        write_prompt_file(AppType::Claude, \"first version\");\n\n        let first = MultiAppConfig::load().expect(\"load config\");\n        assert_eq!(first.prompts.claude.prompts.len(), 1);\n        let claude_prompt = first\n            .prompts\n            .claude\n            .prompts\n            .values()\n            .next()\n            .expect(\"prompt exists\")\n            .content\n            .clone();\n        assert_eq!(claude_prompt, \"first version\");\n\n        // 覆盖文件内容，但保留 config.json\n        write_prompt_file(AppType::Claude, \"second version\");\n        let second = MultiAppConfig::load().expect(\"load config again\");\n\n        assert_eq!(second.prompts.claude.prompts.len(), 1);\n        let prompt = second\n            .prompts\n            .claude\n            .prompts\n            .values()\n            .next()\n            .expect(\"prompt exists\");\n        assert_eq!(\n            prompt.content, \"first version\",\n            \"should not re-import when config already exists\"\n        );\n    }\n\n    #[test]\n    #[serial]\n    fn auto_imports_gemini_prompt_on_first_launch() {\n        let _home = TempHome::new();\n        write_prompt_file(AppType::Gemini, \"# Gemini Prompt\\n\\nTest content\");\n\n        let config = MultiAppConfig::load().expect(\"load config\");\n\n        assert_eq!(config.prompts.gemini.prompts.len(), 1);\n        let prompt = config\n            .prompts\n            .gemini\n            .prompts\n            .values()\n            .next()\n            .expect(\"gemini prompt exists\");\n        assert!(prompt.enabled, \"gemini prompt should be enabled\");\n        assert_eq!(prompt.content, \"# Gemini Prompt\\n\\nTest content\");\n        assert_eq!(\n            prompt.description,\n            Some(\"Automatically imported on first launch\".to_string())\n        );\n    }\n\n    #[test]\n    #[serial]\n    fn auto_imports_all_three_apps_prompts() {\n        let _home = TempHome::new();\n        write_prompt_file(AppType::Claude, \"# Claude prompt\");\n        write_prompt_file(AppType::Codex, \"# Codex prompt\");\n        write_prompt_file(AppType::Gemini, \"# Gemini prompt\");\n\n        let config = MultiAppConfig::load().expect(\"load config\");\n\n        // 验证所有三个应用的提示词都被导入\n        assert_eq!(config.prompts.claude.prompts.len(), 1);\n        assert_eq!(config.prompts.codex.prompts.len(), 1);\n        assert_eq!(config.prompts.gemini.prompts.len(), 1);\n\n        // 验证所有提示词都被启用\n        assert!(\n            config\n                .prompts\n                .claude\n                .prompts\n                .values()\n                .next()\n                .unwrap()\n                .enabled\n        );\n        assert!(\n            config\n                .prompts\n                .codex\n                .prompts\n                .values()\n                .next()\n                .unwrap()\n                .enabled\n        );\n        assert!(\n            config\n                .prompts\n                .gemini\n                .prompts\n                .values()\n                .next()\n                .unwrap()\n                .enabled\n        );\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/app_store.rs",
    "content": "use serde_json::Value;\nuse std::path::PathBuf;\nuse std::sync::{OnceLock, RwLock};\nuse tauri_plugin_store::StoreExt;\n\nuse crate::error::AppError;\n\n/// Store 中的键名\nconst STORE_KEY_APP_CONFIG_DIR: &str = \"app_config_dir_override\";\n\n/// 缓存当前的 app_config_dir 覆盖路径，避免存储 AppHandle\nstatic APP_CONFIG_DIR_OVERRIDE: OnceLock<RwLock<Option<PathBuf>>> = OnceLock::new();\n\nfn override_cache() -> &'static RwLock<Option<PathBuf>> {\n    APP_CONFIG_DIR_OVERRIDE.get_or_init(|| RwLock::new(None))\n}\n\nfn update_cached_override(value: Option<PathBuf>) {\n    if let Ok(mut guard) = override_cache().write() {\n        *guard = value;\n    }\n}\n\n/// 获取缓存中的 app_config_dir 覆盖路径\npub fn get_app_config_dir_override() -> Option<PathBuf> {\n    override_cache().read().ok()?.clone()\n}\n\nfn read_override_from_store(app: &tauri::AppHandle) -> Option<PathBuf> {\n    let store = match app.store_builder(\"app_paths.json\").build() {\n        Ok(store) => store,\n        Err(e) => {\n            log::warn!(\"无法创建 Store: {e}\");\n            return None;\n        }\n    };\n\n    match store.get(STORE_KEY_APP_CONFIG_DIR) {\n        Some(Value::String(path_str)) => {\n            let path_str = path_str.trim();\n            if path_str.is_empty() {\n                return None;\n            }\n\n            let path = resolve_path(path_str);\n\n            if !path.exists() {\n                log::warn!(\n                    \"Store 中配置的 app_config_dir 不存在: {path:?}\\n\\\n                     将使用默认路径。\"\n                );\n                return None;\n            }\n\n            log::info!(\"使用 Store 中的 app_config_dir: {path:?}\");\n            Some(path)\n        }\n        Some(_) => {\n            log::warn!(\"Store 中的 {STORE_KEY_APP_CONFIG_DIR} 类型不正确，应为字符串\");\n            None\n        }\n        None => None,\n    }\n}\n\n/// 从 Store 刷新 app_config_dir 覆盖值并更新缓存\npub fn refresh_app_config_dir_override(app: &tauri::AppHandle) -> Option<PathBuf> {\n    let value = read_override_from_store(app);\n    update_cached_override(value.clone());\n    value\n}\n\n/// 写入 app_config_dir 到 Tauri Store\npub fn set_app_config_dir_to_store(\n    app: &tauri::AppHandle,\n    path: Option<&str>,\n) -> Result<(), AppError> {\n    let store = app\n        .store_builder(\"app_paths.json\")\n        .build()\n        .map_err(|e| AppError::Message(format!(\"创建 Store 失败: {e}\")))?;\n\n    match path {\n        Some(p) => {\n            let trimmed = p.trim();\n            if !trimmed.is_empty() {\n                store.set(STORE_KEY_APP_CONFIG_DIR, Value::String(trimmed.to_string()));\n                log::info!(\"已将 app_config_dir 写入 Store: {trimmed}\");\n            } else {\n                store.delete(STORE_KEY_APP_CONFIG_DIR);\n                log::info!(\"已从 Store 中删除 app_config_dir 配置\");\n            }\n        }\n        None => {\n            store.delete(STORE_KEY_APP_CONFIG_DIR);\n            log::info!(\"已从 Store 中删除 app_config_dir 配置\");\n        }\n    }\n\n    store\n        .save()\n        .map_err(|e| AppError::Message(format!(\"保存 Store 失败: {e}\")))?;\n\n    refresh_app_config_dir_override(app);\n    Ok(())\n}\n\n/// 解析路径，支持 ~ 开头的相对路径\nfn resolve_path(raw: &str) -> PathBuf {\n    if raw == \"~\" {\n        if let Some(home) = dirs::home_dir() {\n            return home;\n        }\n    } else if let Some(stripped) = raw.strip_prefix(\"~/\") {\n        if let Some(home) = dirs::home_dir() {\n            return home.join(stripped);\n        }\n    } else if let Some(stripped) = raw.strip_prefix(\"~\\\\\") {\n        if let Some(home) = dirs::home_dir() {\n            return home.join(stripped);\n        }\n    }\n\n    PathBuf::from(raw)\n}\n\n/// 从旧的 settings.json 迁移 app_config_dir 到 Store\npub fn migrate_app_config_dir_from_settings(app: &tauri::AppHandle) -> Result<(), AppError> {\n    // app_config_dir 已从 settings.json 移除，此函数保留但不再执行迁移\n    // 如果用户在旧版本设置过 app_config_dir，需要在 Store 中手动配置\n    log::info!(\"app_config_dir 迁移功能已移除，请在设置中重新配置\");\n\n    let _ = refresh_app_config_dir_override(app);\n    Ok(())\n}\n"
  },
  {
    "path": "src-tauri/src/auto_launch.rs",
    "content": "use crate::error::AppError;\nuse auto_launch::{AutoLaunch, AutoLaunchBuilder};\n\n/// 获取 macOS 上的 .app bundle 路径\n/// 将 `/path/to/CC Switch.app/Contents/MacOS/CC Switch` 转换为 `/path/to/CC Switch.app`\n#[cfg(target_os = \"macos\")]\nfn get_macos_app_bundle_path(exe_path: &std::path::Path) -> Option<std::path::PathBuf> {\n    let path_str = exe_path.to_string_lossy();\n    // 查找 .app/Contents/MacOS/ 模式\n    if let Some(app_pos) = path_str.find(\".app/Contents/MacOS/\") {\n        let app_bundle_end = app_pos + 4; // \".app\" 的结束位置\n        Some(std::path::PathBuf::from(&path_str[..app_bundle_end]))\n    } else {\n        None\n    }\n}\n\n/// 初始化 AutoLaunch 实例\nfn get_auto_launch() -> Result<AutoLaunch, AppError> {\n    let app_name = \"CC Switch\";\n    let exe_path =\n        std::env::current_exe().map_err(|e| AppError::Message(format!(\"无法获取应用路径: {e}\")))?;\n\n    // macOS 需要使用 .app bundle 路径，否则 AppleScript login item 会打开终端\n    #[cfg(target_os = \"macos\")]\n    let app_path = get_macos_app_bundle_path(&exe_path).unwrap_or(exe_path);\n\n    #[cfg(not(target_os = \"macos\"))]\n    let app_path = exe_path;\n\n    // 使用 AutoLaunchBuilder 消除平台差异\n    // macOS: 使用 AppleScript 方式（默认），需要 .app bundle 路径\n    // Windows/Linux: 使用注册表/XDG autostart\n    let auto_launch = AutoLaunchBuilder::new()\n        .set_app_name(app_name)\n        .set_app_path(&app_path.to_string_lossy())\n        .build()\n        .map_err(|e| AppError::Message(format!(\"创建 AutoLaunch 失败: {e}\")))?;\n\n    Ok(auto_launch)\n}\n\n/// 启用开机自启\npub fn enable_auto_launch() -> Result<(), AppError> {\n    let auto_launch = get_auto_launch()?;\n    auto_launch\n        .enable()\n        .map_err(|e| AppError::Message(format!(\"启用开机自启失败: {e}\")))?;\n    log::info!(\"已启用开机自启\");\n    Ok(())\n}\n\n/// 禁用开机自启\npub fn disable_auto_launch() -> Result<(), AppError> {\n    let auto_launch = get_auto_launch()?;\n    auto_launch\n        .disable()\n        .map_err(|e| AppError::Message(format!(\"禁用开机自启失败: {e}\")))?;\n    log::info!(\"已禁用开机自启\");\n    Ok(())\n}\n\n/// 检查是否已启用开机自启\npub fn is_auto_launch_enabled() -> Result<bool, AppError> {\n    let auto_launch = get_auto_launch()?;\n    auto_launch\n        .is_enabled()\n        .map_err(|e| AppError::Message(format!(\"检查开机自启状态失败: {e}\")))\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[cfg(target_os = \"macos\")]\n    #[test]\n    fn test_get_macos_app_bundle_path_valid() {\n        let exe_path = std::path::Path::new(\"/Applications/CC Switch.app/Contents/MacOS/CC Switch\");\n        let result = get_macos_app_bundle_path(exe_path);\n        assert_eq!(\n            result,\n            Some(std::path::PathBuf::from(\"/Applications/CC Switch.app\"))\n        );\n    }\n\n    #[cfg(target_os = \"macos\")]\n    #[test]\n    fn test_get_macos_app_bundle_path_with_spaces() {\n        let exe_path =\n            std::path::Path::new(\"/Users/test/My Apps/CC Switch.app/Contents/MacOS/CC Switch\");\n        let result = get_macos_app_bundle_path(exe_path);\n        assert_eq!(\n            result,\n            Some(std::path::PathBuf::from(\n                \"/Users/test/My Apps/CC Switch.app\"\n            ))\n        );\n    }\n\n    #[cfg(target_os = \"macos\")]\n    #[test]\n    fn test_get_macos_app_bundle_path_not_in_bundle() {\n        let exe_path = std::path::Path::new(\"/usr/local/bin/cc-switch\");\n        let result = get_macos_app_bundle_path(exe_path);\n        assert_eq!(result, None);\n    }\n\n    #[cfg(target_os = \"macos\")]\n    #[test]\n    fn test_get_macos_app_bundle_path_dev_build() {\n        // 开发环境下的路径通常不在 .app bundle 内\n        let exe_path = std::path::Path::new(\"/Users/dev/project/target/debug/cc-switch\");\n        let result = get_macos_app_bundle_path(exe_path);\n        assert_eq!(result, None);\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/claude_mcp.rs",
    "content": "use serde::{Deserialize, Serialize};\nuse serde_json::{Map, Value};\nuse std::env;\nuse std::fs;\nuse std::path::{Path, PathBuf};\n\nuse crate::config::{atomic_write, get_claude_mcp_path, get_default_claude_mcp_path};\nuse crate::error::AppError;\n\n/// 需要在 Windows 上用 cmd /c 包装的命令\n/// 这些命令在 Windows 上实际是 .cmd 批处理文件，需要通过 cmd /c 来执行\n#[cfg(windows)]\nconst WINDOWS_WRAP_COMMANDS: &[&str] = &[\"npx\", \"npm\", \"yarn\", \"pnpm\", \"node\", \"bun\", \"deno\"];\n\n/// Windows 平台：将 `npx args...` 转换为 `cmd /c npx args...`\n/// 解决 Claude Code /doctor 报告的 \"Windows requires 'cmd /c' wrapper to execute npx\" 警告\n#[cfg(windows)]\nfn wrap_command_for_windows(obj: &mut Map<String, Value>) {\n    // 只处理 stdio 类型（默认或显式）\n    let server_type = obj.get(\"type\").and_then(|v| v.as_str()).unwrap_or(\"stdio\");\n    if server_type != \"stdio\" {\n        return;\n    }\n\n    let Some(cmd) = obj.get(\"command\").and_then(|v| v.as_str()) else {\n        return;\n    };\n\n    // 已经是 cmd 的不重复包装\n    if cmd.eq_ignore_ascii_case(\"cmd\") || cmd.eq_ignore_ascii_case(\"cmd.exe\") {\n        return;\n    }\n\n    // 提取命令名（去掉 .cmd 后缀和路径）\n    let cmd_name = Path::new(cmd)\n        .file_stem()\n        .and_then(|s| s.to_str())\n        .unwrap_or(cmd);\n\n    let needs_wrap = WINDOWS_WRAP_COMMANDS\n        .iter()\n        .any(|&c| cmd_name.eq_ignore_ascii_case(c));\n\n    if !needs_wrap {\n        return;\n    }\n\n    // 构建新的 args: [\"/c\", \"原命令\", ...原args]\n    let original_args = obj\n        .get(\"args\")\n        .and_then(|v| v.as_array())\n        .cloned()\n        .unwrap_or_default();\n\n    let mut new_args = vec![Value::String(\"/c\".into()), Value::String(cmd.into())];\n    new_args.extend(original_args);\n\n    obj.insert(\"command\".into(), Value::String(\"cmd\".into()));\n    obj.insert(\"args\".into(), Value::Array(new_args));\n}\n\n/// 非 Windows 平台无需处理\n#[cfg(not(windows))]\nfn wrap_command_for_windows(_obj: &mut Map<String, Value>) {\n    // 非 Windows 平台不做任何处理\n}\n\n/// 检测路径是否为 WSL 网络路径（如 \\\\wsl$\\Ubuntu\\... 或 \\\\wsl.localhost\\Ubuntu\\...）\n/// WSL 环境运行的是 Linux，不需要 cmd /c 包装\n/// 注意：仅检测直接 UNC 路径，映射磁盘符（如 Z: -> \\\\wsl$\\...）无法检测\n#[cfg(windows)]\nfn is_wsl_path(path: &Path) -> bool {\n    use std::path::{Component, Prefix};\n    if let Some(Component::Prefix(prefix)) = path.components().next() {\n        match prefix.kind() {\n            Prefix::UNC(server, _) | Prefix::VerbatimUNC(server, _) => {\n                let s = server.to_string_lossy();\n                s.eq_ignore_ascii_case(\"wsl$\") || s.eq_ignore_ascii_case(\"wsl.localhost\")\n            }\n            _ => false,\n        }\n    } else {\n        false\n    }\n}\n\n#[cfg(not(windows))]\nfn is_wsl_path(_path: &Path) -> bool {\n    false\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct McpStatus {\n    pub user_config_path: String,\n    pub user_config_exists: bool,\n    pub server_count: usize,\n}\n\nfn user_config_path() -> PathBuf {\n    ensure_mcp_override_migrated();\n    get_claude_mcp_path()\n}\n\nfn ensure_mcp_override_migrated() {\n    if crate::settings::get_claude_override_dir().is_none() {\n        return;\n    }\n\n    let new_path = get_claude_mcp_path();\n    if new_path.exists() {\n        return;\n    }\n\n    let legacy_path = get_default_claude_mcp_path();\n    if !legacy_path.exists() {\n        return;\n    }\n\n    if let Some(parent) = new_path.parent() {\n        if let Err(err) = fs::create_dir_all(parent) {\n            log::warn!(\"创建 MCP 目录失败: {err}\");\n            return;\n        }\n    }\n\n    match fs::copy(&legacy_path, &new_path) {\n        Ok(_) => {\n            log::info!(\n                \"已根据覆盖目录复制 MCP 配置: {} -> {}\",\n                legacy_path.display(),\n                new_path.display()\n            );\n        }\n        Err(err) => {\n            log::warn!(\n                \"复制 MCP 配置失败: {} -> {}: {}\",\n                legacy_path.display(),\n                new_path.display(),\n                err\n            );\n        }\n    }\n}\n\nfn read_json_value(path: &Path) -> Result<Value, AppError> {\n    if !path.exists() {\n        return Ok(serde_json::json!({}));\n    }\n    let content = fs::read_to_string(path).map_err(|e| AppError::io(path, e))?;\n    let value: Value = serde_json::from_str(&content).map_err(|e| AppError::json(path, e))?;\n    Ok(value)\n}\n\nfn write_json_value(path: &Path, value: &Value) -> Result<(), AppError> {\n    if let Some(parent) = path.parent() {\n        fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;\n    }\n    let json =\n        serde_json::to_string_pretty(value).map_err(|e| AppError::JsonSerialize { source: e })?;\n    atomic_write(path, json.as_bytes())\n}\n\npub fn get_mcp_status() -> Result<McpStatus, AppError> {\n    let path = user_config_path();\n    let (exists, count) = if path.exists() {\n        let v = read_json_value(&path)?;\n        let servers = v.get(\"mcpServers\").and_then(|x| x.as_object());\n        (true, servers.map(|m| m.len()).unwrap_or(0))\n    } else {\n        (false, 0)\n    };\n\n    Ok(McpStatus {\n        user_config_path: path.to_string_lossy().to_string(),\n        user_config_exists: exists,\n        server_count: count,\n    })\n}\n\npub fn read_mcp_json() -> Result<Option<String>, AppError> {\n    let path = user_config_path();\n    if !path.exists() {\n        return Ok(None);\n    }\n    let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;\n    Ok(Some(content))\n}\n\n/// 在 ~/.claude.json 根对象写入 hasCompletedOnboarding=true（用于跳过 Claude Code 初次安装确认）\n/// 仅增量写入该字段，其他字段保持不变\npub fn set_has_completed_onboarding() -> Result<bool, AppError> {\n    let path = user_config_path();\n    let mut root = if path.exists() {\n        read_json_value(&path)?\n    } else {\n        serde_json::json!({})\n    };\n\n    let obj = root\n        .as_object_mut()\n        .ok_or_else(|| AppError::Config(\"~/.claude.json 根必须是对象\".into()))?;\n\n    let already = obj\n        .get(\"hasCompletedOnboarding\")\n        .and_then(|v| v.as_bool())\n        .unwrap_or(false);\n    if already {\n        return Ok(false);\n    }\n\n    obj.insert(\"hasCompletedOnboarding\".into(), Value::Bool(true));\n    write_json_value(&path, &root)?;\n    Ok(true)\n}\n\n/// 删除 ~/.claude.json 根对象的 hasCompletedOnboarding 字段（恢复 Claude Code 初次安装确认）\n/// 仅增量删除该字段，其他字段保持不变\npub fn clear_has_completed_onboarding() -> Result<bool, AppError> {\n    let path = user_config_path();\n    if !path.exists() {\n        return Ok(false);\n    }\n\n    let mut root = read_json_value(&path)?;\n    let obj = root\n        .as_object_mut()\n        .ok_or_else(|| AppError::Config(\"~/.claude.json 根必须是对象\".into()))?;\n\n    let existed = obj.remove(\"hasCompletedOnboarding\").is_some();\n    if !existed {\n        return Ok(false);\n    }\n\n    write_json_value(&path, &root)?;\n    Ok(true)\n}\n\npub fn upsert_mcp_server(id: &str, spec: Value) -> Result<bool, AppError> {\n    if id.trim().is_empty() {\n        return Err(AppError::InvalidInput(\"MCP 服务器 ID 不能为空\".into()));\n    }\n    // 基础字段校验（尽量宽松）\n    if !spec.is_object() {\n        return Err(AppError::McpValidation(\n            \"MCP 服务器定义必须为 JSON 对象\".into(),\n        ));\n    }\n    let t_opt = spec.get(\"type\").and_then(|x| x.as_str());\n    let is_stdio = t_opt.map(|t| t == \"stdio\").unwrap_or(true); // 兼容缺省（按 stdio 处理）\n    let is_http = t_opt.map(|t| t == \"http\").unwrap_or(false);\n    let is_sse = t_opt.map(|t| t == \"sse\").unwrap_or(false);\n    if !(is_stdio || is_http || is_sse) {\n        return Err(AppError::McpValidation(\n            \"MCP 服务器 type 必须是 'stdio'、'http' 或 'sse'（或省略表示 stdio）\".into(),\n        ));\n    }\n\n    // stdio 类型必须有 command\n    if is_stdio {\n        let cmd = spec.get(\"command\").and_then(|x| x.as_str()).unwrap_or(\"\");\n        if cmd.is_empty() {\n            return Err(AppError::McpValidation(\n                \"stdio 类型的 MCP 服务器缺少 command 字段\".into(),\n            ));\n        }\n    }\n\n    // http/sse 类型必须有 url\n    if is_http || is_sse {\n        let url = spec.get(\"url\").and_then(|x| x.as_str()).unwrap_or(\"\");\n        if url.is_empty() {\n            return Err(AppError::McpValidation(if is_http {\n                \"http 类型的 MCP 服务器缺少 url 字段\".into()\n            } else {\n                \"sse 类型的 MCP 服务器缺少 url 字段\".into()\n            }));\n        }\n    }\n\n    let path = user_config_path();\n    let mut root = if path.exists() {\n        read_json_value(&path)?\n    } else {\n        serde_json::json!({})\n    };\n\n    // 确保 mcpServers 对象存在\n    {\n        let obj = root\n            .as_object_mut()\n            .ok_or_else(|| AppError::Config(\"mcp.json 根必须是对象\".into()))?;\n        if !obj.contains_key(\"mcpServers\") {\n            obj.insert(\"mcpServers\".into(), serde_json::json!({}));\n        }\n    }\n\n    let before = root.clone();\n    if let Some(servers) = root.get_mut(\"mcpServers\").and_then(|v| v.as_object_mut()) {\n        servers.insert(id.to_string(), spec);\n    }\n\n    if before == root && path.exists() {\n        return Ok(false);\n    }\n\n    write_json_value(&path, &root)?;\n    Ok(true)\n}\n\npub fn delete_mcp_server(id: &str) -> Result<bool, AppError> {\n    if id.trim().is_empty() {\n        return Err(AppError::InvalidInput(\"MCP 服务器 ID 不能为空\".into()));\n    }\n    let path = user_config_path();\n    if !path.exists() {\n        return Ok(false);\n    }\n    let mut root = read_json_value(&path)?;\n    let Some(servers) = root.get_mut(\"mcpServers\").and_then(|v| v.as_object_mut()) else {\n        return Ok(false);\n    };\n    let existed = servers.remove(id).is_some();\n    if !existed {\n        return Ok(false);\n    }\n    write_json_value(&path, &root)?;\n    Ok(true)\n}\n\npub fn validate_command_in_path(cmd: &str) -> Result<bool, AppError> {\n    if cmd.trim().is_empty() {\n        return Ok(false);\n    }\n    // 如果包含路径分隔符，直接判断是否存在可执行文件\n    if cmd.contains('/') || cmd.contains('\\\\') {\n        return Ok(Path::new(cmd).exists());\n    }\n\n    let path_var = env::var_os(\"PATH\").unwrap_or_default();\n    let paths = env::split_paths(&path_var);\n\n    #[cfg(windows)]\n    let exts: Vec<String> = env::var(\"PATHEXT\")\n        .unwrap_or(\".COM;.EXE;.BAT;.CMD\".into())\n        .split(';')\n        .map(|s| s.trim().to_uppercase())\n        .collect();\n\n    for p in paths {\n        let candidate = p.join(cmd);\n        if candidate.is_file() {\n            return Ok(true);\n        }\n        #[cfg(windows)]\n        {\n            for ext in &exts {\n                let cand = p.join(format!(\"{}{}\", cmd, ext));\n                if cand.is_file() {\n                    return Ok(true);\n                }\n            }\n        }\n    }\n    Ok(false)\n}\n\n/// 读取 ~/.claude.json 中的 mcpServers 映射\npub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>, AppError> {\n    let path = user_config_path();\n    if !path.exists() {\n        return Ok(std::collections::HashMap::new());\n    }\n\n    let root = read_json_value(&path)?;\n    let servers = root\n        .get(\"mcpServers\")\n        .and_then(|v| v.as_object())\n        .map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect())\n        .unwrap_or_default();\n\n    Ok(servers)\n}\n\n/// 将给定的启用 MCP 服务器映射写入到用户级 ~/.claude.json 的 mcpServers 字段\n/// 仅覆盖 mcpServers，其他字段保持不变\npub fn set_mcp_servers_map(\n    servers: &std::collections::HashMap<String, Value>,\n) -> Result<(), AppError> {\n    let path = user_config_path();\n    let mut root = if path.exists() {\n        read_json_value(&path)?\n    } else {\n        serde_json::json!({})\n    };\n\n    // 构建 mcpServers 对象：移除 UI 辅助字段（enabled/source），仅保留实际 MCP 规范\n    // 检测目标路径是否为 WSL，若是则跳过 cmd /c 包装\n    let is_wsl_target = is_wsl_path(&path);\n    if is_wsl_target {\n        log::info!(\"检测到 WSL 路径，跳过 cmd /c 包装: {}\", path.display());\n    }\n    let mut out: Map<String, Value> = Map::new();\n    for (id, spec) in servers.iter() {\n        let mut obj = if let Some(map) = spec.as_object() {\n            map.clone()\n        } else {\n            return Err(AppError::McpValidation(format!(\n                \"MCP 服务器 '{id}' 不是对象\"\n            )));\n        };\n\n        if let Some(server_val) = obj.remove(\"server\") {\n            let server_obj = server_val.as_object().cloned().ok_or_else(|| {\n                AppError::McpValidation(format!(\"MCP 服务器 '{id}' server 字段不是对象\"))\n            })?;\n            obj = server_obj;\n        }\n\n        obj.remove(\"enabled\");\n        obj.remove(\"source\");\n        obj.remove(\"id\");\n        obj.remove(\"name\");\n        obj.remove(\"description\");\n        obj.remove(\"tags\");\n        obj.remove(\"homepage\");\n        obj.remove(\"docs\");\n\n        // Windows 平台自动包装 npx/npm 等命令为 cmd /c 格式（WSL 路径除外）\n        if !is_wsl_target {\n            wrap_command_for_windows(&mut obj);\n        }\n\n        out.insert(id.clone(), Value::Object(obj));\n    }\n\n    {\n        let obj = root\n            .as_object_mut()\n            .ok_or_else(|| AppError::Config(\"~/.claude.json 根必须是对象\".into()))?;\n        obj.insert(\"mcpServers\".into(), Value::Object(out));\n    }\n\n    write_json_value(&path, &root)?;\n    Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use serde_json::json;\n\n    /// 测试 Windows 命令包装功能\n    /// 由于使用条件编译，在非 Windows 平台上测试的是空函数\n    #[test]\n    fn test_wrap_command_for_windows_npx() {\n        let mut obj = json!({\"command\": \"npx\", \"args\": [\"-y\", \"@upstash/context7-mcp\"]})\n            .as_object()\n            .unwrap()\n            .clone();\n        wrap_command_for_windows(&mut obj);\n\n        #[cfg(windows)]\n        {\n            assert_eq!(obj[\"command\"], \"cmd\");\n            assert_eq!(\n                obj[\"args\"],\n                json!([\"/c\", \"npx\", \"-y\", \"@upstash/context7-mcp\"])\n            );\n        }\n\n        #[cfg(not(windows))]\n        {\n            // 非 Windows 平台不做任何处理\n            assert_eq!(obj[\"command\"], \"npx\");\n        }\n    }\n\n    #[test]\n    fn test_wrap_command_for_windows_npm() {\n        let mut obj = json!({\"command\": \"npm\", \"args\": [\"run\", \"start\"]})\n            .as_object()\n            .unwrap()\n            .clone();\n        wrap_command_for_windows(&mut obj);\n\n        #[cfg(windows)]\n        {\n            assert_eq!(obj[\"command\"], \"cmd\");\n            assert_eq!(obj[\"args\"], json!([\"/c\", \"npm\", \"run\", \"start\"]));\n        }\n    }\n\n    #[test]\n    fn test_wrap_command_for_windows_already_cmd() {\n        // 已经是 cmd 的不应该重复包装\n        let mut obj = json!({\"command\": \"cmd\", \"args\": [\"/c\", \"npx\", \"-y\", \"foo\"]})\n            .as_object()\n            .unwrap()\n            .clone();\n        wrap_command_for_windows(&mut obj);\n\n        assert_eq!(obj[\"command\"], \"cmd\");\n        // args 应该保持不变，不会变成 [\"/c\", \"cmd\", \"/c\", \"npx\", ...]\n        assert_eq!(obj[\"args\"], json!([\"/c\", \"npx\", \"-y\", \"foo\"]));\n    }\n\n    #[test]\n    fn test_wrap_command_for_windows_http_type_skipped() {\n        // http 类型不应该被处理\n        let mut obj = json!({\"type\": \"http\", \"url\": \"https://example.com/mcp\"})\n            .as_object()\n            .unwrap()\n            .clone();\n        wrap_command_for_windows(&mut obj);\n\n        assert!(!obj.contains_key(\"command\"));\n        assert_eq!(obj[\"url\"], \"https://example.com/mcp\");\n    }\n\n    #[test]\n    fn test_wrap_command_for_windows_other_command_skipped() {\n        // 非目标命令（如 python）不应该被包装\n        let mut obj = json!({\"command\": \"python\", \"args\": [\"server.py\"]})\n            .as_object()\n            .unwrap()\n            .clone();\n        wrap_command_for_windows(&mut obj);\n\n        // python 不在 WINDOWS_WRAP_COMMANDS 列表中，不应该被包装\n        assert_eq!(obj[\"command\"], \"python\");\n        assert_eq!(obj[\"args\"], json!([\"server.py\"]));\n    }\n\n    #[test]\n    fn test_wrap_command_for_windows_no_args() {\n        // 没有 args 的情况\n        let mut obj = json!({\"command\": \"npx\"}).as_object().unwrap().clone();\n        wrap_command_for_windows(&mut obj);\n\n        #[cfg(windows)]\n        {\n            assert_eq!(obj[\"command\"], \"cmd\");\n            assert_eq!(obj[\"args\"], json!([\"/c\", \"npx\"]));\n        }\n    }\n\n    #[test]\n    fn test_wrap_command_for_windows_with_cmd_suffix() {\n        // 处理 npx.cmd 格式\n        let mut obj = json!({\"command\": \"npx.cmd\", \"args\": [\"-y\", \"foo\"]})\n            .as_object()\n            .unwrap()\n            .clone();\n        wrap_command_for_windows(&mut obj);\n\n        #[cfg(windows)]\n        {\n            assert_eq!(obj[\"command\"], \"cmd\");\n            assert_eq!(obj[\"args\"], json!([\"/c\", \"npx.cmd\", \"-y\", \"foo\"]));\n        }\n    }\n\n    #[test]\n    fn test_wrap_command_for_windows_case_insensitive() {\n        // 大小写不敏感\n        let mut obj = json!({\"command\": \"NPX\", \"args\": [\"-y\", \"foo\"]})\n            .as_object()\n            .unwrap()\n            .clone();\n        wrap_command_for_windows(&mut obj);\n\n        #[cfg(windows)]\n        {\n            assert_eq!(obj[\"command\"], \"cmd\");\n            assert_eq!(obj[\"args\"], json!([\"/c\", \"NPX\", \"-y\", \"foo\"]));\n        }\n    }\n\n    /// 测试 WSL 路径检测功能\n    #[test]\n    fn test_is_wsl_path_wsl_dollar() {\n        // wsl$ 格式 - 各种发行版\n        #[cfg(windows)]\n        {\n            assert!(is_wsl_path(Path::new(r\"\\\\wsl$\\Ubuntu\\home\\user\\.claude\")));\n            assert!(is_wsl_path(Path::new(r\"\\\\wsl$\\Debian\\home\\user\\.claude\")));\n            assert!(is_wsl_path(Path::new(\n                r\"\\\\wsl$\\openSUSE-Leap-15.2\\home\\user\"\n            )));\n            assert!(is_wsl_path(Path::new(r\"\\\\wsl$\\kali-linux\\home\\user\")));\n            assert!(is_wsl_path(Path::new(r\"\\\\wsl$\\Arch\\home\\user\")));\n            assert!(is_wsl_path(Path::new(r\"\\\\wsl$\\Alpine\\home\\user\")));\n            assert!(is_wsl_path(Path::new(r\"\\\\wsl$\\Fedora\\home\\user\")));\n        }\n\n        #[cfg(not(windows))]\n        {\n            // 非 Windows 平台始终返回 false\n            assert!(!is_wsl_path(Path::new(r\"\\\\wsl$\\Ubuntu\\home\\user\\.claude\")));\n        }\n    }\n\n    #[test]\n    fn test_is_wsl_path_wsl_localhost() {\n        // wsl.localhost 格式\n        #[cfg(windows)]\n        {\n            assert!(is_wsl_path(Path::new(\n                r\"\\\\wsl.localhost\\Ubuntu\\home\\user\\.claude\"\n            )));\n            assert!(is_wsl_path(Path::new(r\"\\\\wsl.localhost\\Debian\\home\\user\")));\n            assert!(is_wsl_path(Path::new(\n                r\"\\\\wsl.localhost\\openSUSE-Leap-15.2\\home\\user\"\n            )));\n        }\n    }\n\n    #[test]\n    fn test_is_wsl_path_case_insensitive() {\n        // 大小写不敏感\n        #[cfg(windows)]\n        {\n            assert!(is_wsl_path(Path::new(r\"\\\\WSL$\\Ubuntu\\home\\user\")));\n            assert!(is_wsl_path(Path::new(r\"\\\\Wsl$\\Ubuntu\\home\\user\")));\n            assert!(is_wsl_path(Path::new(r\"\\\\WSL.LOCALHOST\\Ubuntu\\home\\user\")));\n            assert!(is_wsl_path(Path::new(r\"\\\\Wsl.Localhost\\Ubuntu\\home\\user\")));\n        }\n    }\n\n    #[test]\n    fn test_is_wsl_path_non_wsl() {\n        // 非 WSL 路径\n        assert!(!is_wsl_path(Path::new(r\"C:\\Users\\user\\.claude\")));\n        assert!(!is_wsl_path(Path::new(r\"D:\\Workspace\\project\")));\n        #[cfg(windows)]\n        {\n            assert!(!is_wsl_path(Path::new(r\"\\\\server\\share\\path\")));\n            assert!(!is_wsl_path(Path::new(r\"\\\\localhost\\c$\\Users\")));\n            assert!(!is_wsl_path(Path::new(r\"\\\\192.168.1.1\\share\")));\n        }\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/claude_plugin.rs",
    "content": "use std::fs;\nuse std::path::PathBuf;\n\nuse crate::error::AppError;\n\nconst CLAUDE_DIR: &str = \".claude\";\nconst CLAUDE_CONFIG_FILE: &str = \"config.json\";\n\nfn claude_dir() -> Result<PathBuf, AppError> {\n    // 优先使用设置中的覆盖目录\n    if let Some(dir) = crate::settings::get_claude_override_dir() {\n        return Ok(dir);\n    }\n    let home = dirs::home_dir().ok_or_else(|| AppError::Config(\"无法获取用户主目录\".into()))?;\n    Ok(home.join(CLAUDE_DIR))\n}\n\npub fn claude_config_path() -> Result<PathBuf, AppError> {\n    Ok(claude_dir()?.join(CLAUDE_CONFIG_FILE))\n}\n\npub fn ensure_claude_dir_exists() -> Result<PathBuf, AppError> {\n    let dir = claude_dir()?;\n    if !dir.exists() {\n        fs::create_dir_all(&dir).map_err(|e| AppError::io(&dir, e))?;\n    }\n    Ok(dir)\n}\n\npub fn read_claude_config() -> Result<Option<String>, AppError> {\n    let path = claude_config_path()?;\n    if path.exists() {\n        let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;\n        Ok(Some(content))\n    } else {\n        Ok(None)\n    }\n}\n\nfn is_managed_config(content: &str) -> bool {\n    match serde_json::from_str::<serde_json::Value>(content) {\n        Ok(value) => value\n            .get(\"primaryApiKey\")\n            .and_then(|v| v.as_str())\n            .map(|val| val == \"any\")\n            .unwrap_or(false),\n        Err(_) => false,\n    }\n}\n\npub fn write_claude_config() -> Result<bool, AppError> {\n    // 增量写入：仅设置 primaryApiKey = \"any\"，保留其它字段\n    let path = claude_config_path()?;\n    ensure_claude_dir_exists()?;\n\n    // 尝试读取并解析为对象\n    let mut obj = match read_claude_config()? {\n        Some(existing) => match serde_json::from_str::<serde_json::Value>(&existing) {\n            Ok(serde_json::Value::Object(map)) => serde_json::Value::Object(map),\n            _ => serde_json::json!({}),\n        },\n        None => serde_json::json!({}),\n    };\n\n    let mut changed = false;\n    if let Some(map) = obj.as_object_mut() {\n        let cur = map\n            .get(\"primaryApiKey\")\n            .and_then(|v| v.as_str())\n            .unwrap_or(\"\");\n        if cur != \"any\" {\n            map.insert(\n                \"primaryApiKey\".to_string(),\n                serde_json::Value::String(\"any\".to_string()),\n            );\n            changed = true;\n        }\n    }\n\n    if changed || !path.exists() {\n        let serialized = serde_json::to_string_pretty(&obj)\n            .map_err(|e| AppError::JsonSerialize { source: e })?;\n        fs::write(&path, format!(\"{serialized}\\n\")).map_err(|e| AppError::io(&path, e))?;\n        Ok(true)\n    } else {\n        Ok(false)\n    }\n}\n\npub fn clear_claude_config() -> Result<bool, AppError> {\n    let path = claude_config_path()?;\n    if !path.exists() {\n        return Ok(false);\n    }\n\n    let content = match read_claude_config()? {\n        Some(content) => content,\n        None => return Ok(false),\n    };\n\n    let mut value = match serde_json::from_str::<serde_json::Value>(&content) {\n        Ok(value) => value,\n        Err(_) => return Ok(false),\n    };\n\n    let obj = match value.as_object_mut() {\n        Some(obj) => obj,\n        None => return Ok(false),\n    };\n\n    if obj.remove(\"primaryApiKey\").is_none() {\n        return Ok(false);\n    }\n\n    let serialized =\n        serde_json::to_string_pretty(&value).map_err(|e| AppError::JsonSerialize { source: e })?;\n    fs::write(&path, format!(\"{serialized}\\n\")).map_err(|e| AppError::io(&path, e))?;\n    Ok(true)\n}\n\npub fn claude_config_status() -> Result<(bool, PathBuf), AppError> {\n    let path = claude_config_path()?;\n    Ok((path.exists(), path))\n}\n\npub fn is_claude_config_applied() -> Result<bool, AppError> {\n    match read_claude_config()? {\n        Some(content) => Ok(is_managed_config(&content)),\n        None => Ok(false),\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/codex_config.rs",
    "content": "// unused imports removed\nuse std::path::PathBuf;\n\nuse crate::config::{\n    atomic_write, delete_file, get_home_dir, sanitize_provider_name, write_json_file,\n    write_text_file,\n};\nuse crate::error::AppError;\nuse serde_json::Value;\nuse std::fs;\nuse std::path::Path;\nuse toml_edit::DocumentMut;\n\n/// 获取 Codex 配置目录路径\npub fn get_codex_config_dir() -> PathBuf {\n    if let Some(custom) = crate::settings::get_codex_override_dir() {\n        return custom;\n    }\n\n    get_home_dir().join(\".codex\")\n}\n\n/// 获取 Codex auth.json 路径\npub fn get_codex_auth_path() -> PathBuf {\n    get_codex_config_dir().join(\"auth.json\")\n}\n\n/// 获取 Codex config.toml 路径\npub fn get_codex_config_path() -> PathBuf {\n    get_codex_config_dir().join(\"config.toml\")\n}\n\n/// 获取 Codex 供应商配置文件路径\n#[allow(dead_code)]\npub fn get_codex_provider_paths(\n    provider_id: &str,\n    provider_name: Option<&str>,\n) -> (PathBuf, PathBuf) {\n    let base_name = provider_name\n        .map(sanitize_provider_name)\n        .unwrap_or_else(|| sanitize_provider_name(provider_id));\n\n    let auth_path = get_codex_config_dir().join(format!(\"auth-{base_name}.json\"));\n    let config_path = get_codex_config_dir().join(format!(\"config-{base_name}.toml\"));\n\n    (auth_path, config_path)\n}\n\n/// 删除 Codex 供应商配置文件\n#[allow(dead_code)]\npub fn delete_codex_provider_config(\n    provider_id: &str,\n    provider_name: &str,\n) -> Result<(), AppError> {\n    let (auth_path, config_path) = get_codex_provider_paths(provider_id, Some(provider_name));\n\n    delete_file(&auth_path).ok();\n    delete_file(&config_path).ok();\n\n    Ok(())\n}\n\n/// 原子写 Codex 的 `auth.json` 与 `config.toml`，在第二步失败时回滚第一步\npub fn write_codex_live_atomic(\n    auth: &Value,\n    config_text_opt: Option<&str>,\n) -> Result<(), AppError> {\n    let auth_path = get_codex_auth_path();\n    let config_path = get_codex_config_path();\n\n    if let Some(parent) = auth_path.parent() {\n        std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;\n    }\n\n    // 读取旧内容用于回滚\n    let old_auth = if auth_path.exists() {\n        Some(fs::read(&auth_path).map_err(|e| AppError::io(&auth_path, e))?)\n    } else {\n        None\n    };\n    let _old_config = if config_path.exists() {\n        Some(fs::read(&config_path).map_err(|e| AppError::io(&config_path, e))?)\n    } else {\n        None\n    };\n\n    // 准备写入内容\n    let cfg_text = match config_text_opt {\n        Some(s) => s.to_string(),\n        None => String::new(),\n    };\n    if !cfg_text.trim().is_empty() {\n        toml::from_str::<toml::Table>(&cfg_text).map_err(|e| AppError::toml(&config_path, e))?;\n    }\n\n    // 第一步：写 auth.json\n    write_json_file(&auth_path, auth)?;\n\n    // 第二步：写 config.toml（失败则回滚 auth.json）\n    if let Err(e) = write_text_file(&config_path, &cfg_text) {\n        // 回滚 auth.json\n        if let Some(bytes) = old_auth {\n            let _ = atomic_write(&auth_path, &bytes);\n        } else {\n            let _ = delete_file(&auth_path);\n        }\n        return Err(e);\n    }\n\n    Ok(())\n}\n\n/// 读取 `~/.codex/config.toml`，若不存在返回空字符串\npub fn read_codex_config_text() -> Result<String, AppError> {\n    let path = get_codex_config_path();\n    if path.exists() {\n        std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))\n    } else {\n        Ok(String::new())\n    }\n}\n\n/// 对非空的 TOML 文本进行语法校验\npub fn validate_config_toml(text: &str) -> Result<(), AppError> {\n    if text.trim().is_empty() {\n        return Ok(());\n    }\n    toml::from_str::<toml::Table>(text)\n        .map(|_| ())\n        .map_err(|e| AppError::toml(Path::new(\"config.toml\"), e))\n}\n\n/// 读取并校验 `~/.codex/config.toml`，返回文本（可能为空）\npub fn read_and_validate_codex_config_text() -> Result<String, AppError> {\n    let s = read_codex_config_text()?;\n    validate_config_toml(&s)?;\n    Ok(s)\n}\n\n/// Update a field in Codex config.toml using toml_edit (syntax-preserving).\n///\n/// Supported fields:\n/// - `\"base_url\"`: writes to `[model_providers.<current>].base_url` if `model_provider` exists,\n///    otherwise falls back to top-level `base_url`.\n/// - `\"model\"`: writes to top-level `model` field.\n///\n/// Empty value removes the field.\npub fn update_codex_toml_field(toml_str: &str, field: &str, value: &str) -> Result<String, String> {\n    let mut doc = toml_str\n        .parse::<DocumentMut>()\n        .map_err(|e| format!(\"TOML parse error: {e}\"))?;\n\n    let trimmed = value.trim();\n\n    match field {\n        \"base_url\" => {\n            let model_provider = doc\n                .get(\"model_provider\")\n                .and_then(|item| item.as_str())\n                .map(str::to_string);\n\n            if let Some(provider_key) = model_provider {\n                // Ensure [model_providers] table exists\n                if doc.get(\"model_providers\").is_none() {\n                    doc[\"model_providers\"] = toml_edit::table();\n                }\n\n                if let Some(model_providers) = doc[\"model_providers\"].as_table_mut() {\n                    // Ensure [model_providers.<provider_key>] table exists\n                    if !model_providers.contains_key(&provider_key) {\n                        model_providers[&provider_key] = toml_edit::table();\n                    }\n\n                    if let Some(provider_table) = model_providers[&provider_key].as_table_mut() {\n                        if trimmed.is_empty() {\n                            provider_table.remove(\"base_url\");\n                        } else {\n                            provider_table[\"base_url\"] = toml_edit::value(trimmed);\n                        }\n                        return Ok(doc.to_string());\n                    }\n                }\n            }\n\n            // Fallback: no model_provider or structure mismatch → top-level base_url\n            if trimmed.is_empty() {\n                doc.as_table_mut().remove(\"base_url\");\n            } else {\n                doc[\"base_url\"] = toml_edit::value(trimmed);\n            }\n        }\n        \"model\" => {\n            if trimmed.is_empty() {\n                doc.as_table_mut().remove(\"model\");\n            } else {\n                doc[\"model\"] = toml_edit::value(trimmed);\n            }\n        }\n        _ => return Err(format!(\"unsupported field: {field}\")),\n    }\n\n    Ok(doc.to_string())\n}\n\n/// Remove `base_url` from the active model_provider section only if it matches `predicate`.\n/// Also removes top-level `base_url` if it matches.\n/// Used by proxy cleanup to strip local proxy URLs without touching user-configured URLs.\npub fn remove_codex_toml_base_url_if(toml_str: &str, predicate: impl Fn(&str) -> bool) -> String {\n    let mut doc = match toml_str.parse::<DocumentMut>() {\n        Ok(doc) => doc,\n        Err(_) => return toml_str.to_string(),\n    };\n\n    let model_provider = doc\n        .get(\"model_provider\")\n        .and_then(|item| item.as_str())\n        .map(str::to_string);\n\n    if let Some(provider_key) = model_provider {\n        if let Some(model_providers) = doc\n            .get_mut(\"model_providers\")\n            .and_then(|v| v.as_table_mut())\n        {\n            if let Some(provider_table) = model_providers\n                .get_mut(provider_key.as_str())\n                .and_then(|v| v.as_table_mut())\n            {\n                let should_remove = provider_table\n                    .get(\"base_url\")\n                    .and_then(|item| item.as_str())\n                    .map(&predicate)\n                    .unwrap_or(false);\n                if should_remove {\n                    provider_table.remove(\"base_url\");\n                }\n            }\n        }\n    }\n\n    // Fallback: also clean up top-level base_url if it matches\n    let should_remove_root = doc\n        .get(\"base_url\")\n        .and_then(|item| item.as_str())\n        .map(&predicate)\n        .unwrap_or(false);\n    if should_remove_root {\n        doc.as_table_mut().remove(\"base_url\");\n    }\n\n    doc.to_string()\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn base_url_writes_into_correct_model_provider_section() {\n        let input = r#\"model_provider = \"any\"\nmodel = \"gpt-5.1-codex\"\n\n[model_providers.any]\nname = \"any\"\nwire_api = \"responses\"\n\"#;\n\n        let result = update_codex_toml_field(input, \"base_url\", \"https://example.com/v1\").unwrap();\n        let parsed: toml::Value = toml::from_str(&result).unwrap();\n\n        let base_url = parsed\n            .get(\"model_providers\")\n            .and_then(|v| v.get(\"any\"))\n            .and_then(|v| v.get(\"base_url\"))\n            .and_then(|v| v.as_str())\n            .expect(\"base_url should be in model_providers.any\");\n        assert_eq!(base_url, \"https://example.com/v1\");\n\n        // Should NOT have top-level base_url\n        assert!(parsed.get(\"base_url\").is_none());\n\n        // wire_api preserved\n        let wire_api = parsed\n            .get(\"model_providers\")\n            .and_then(|v| v.get(\"any\"))\n            .and_then(|v| v.get(\"wire_api\"))\n            .and_then(|v| v.as_str());\n        assert_eq!(wire_api, Some(\"responses\"));\n    }\n\n    #[test]\n    fn base_url_creates_section_when_missing() {\n        let input = r#\"model_provider = \"custom\"\nmodel = \"gpt-4\"\n\"#;\n\n        let result = update_codex_toml_field(input, \"base_url\", \"https://custom.api/v1\").unwrap();\n        let parsed: toml::Value = toml::from_str(&result).unwrap();\n\n        let base_url = parsed\n            .get(\"model_providers\")\n            .and_then(|v| v.get(\"custom\"))\n            .and_then(|v| v.get(\"base_url\"))\n            .and_then(|v| v.as_str())\n            .expect(\"should create section and set base_url\");\n        assert_eq!(base_url, \"https://custom.api/v1\");\n    }\n\n    #[test]\n    fn base_url_falls_back_to_top_level_without_model_provider() {\n        let input = r#\"model = \"gpt-4\"\n\"#;\n\n        let result = update_codex_toml_field(input, \"base_url\", \"https://fallback.api/v1\").unwrap();\n        let parsed: toml::Value = toml::from_str(&result).unwrap();\n\n        let base_url = parsed\n            .get(\"base_url\")\n            .and_then(|v| v.as_str())\n            .expect(\"should set top-level base_url\");\n        assert_eq!(base_url, \"https://fallback.api/v1\");\n    }\n\n    #[test]\n    fn clearing_base_url_removes_only_from_correct_section() {\n        let input = r#\"model_provider = \"any\"\n\n[model_providers.any]\nname = \"any\"\nbase_url = \"https://old.api/v1\"\nwire_api = \"responses\"\n\n[mcp_servers.context7]\ncommand = \"npx\"\n\"#;\n\n        let result = update_codex_toml_field(input, \"base_url\", \"\").unwrap();\n        let parsed: toml::Value = toml::from_str(&result).unwrap();\n\n        // base_url removed from model_providers.any\n        let any_section = parsed\n            .get(\"model_providers\")\n            .and_then(|v| v.get(\"any\"))\n            .expect(\"model_providers.any should exist\");\n        assert!(any_section.get(\"base_url\").is_none());\n\n        // wire_api preserved\n        assert_eq!(\n            any_section.get(\"wire_api\").and_then(|v| v.as_str()),\n            Some(\"responses\")\n        );\n\n        // mcp_servers untouched\n        assert!(parsed.get(\"mcp_servers\").is_some());\n    }\n\n    #[test]\n    fn model_field_operates_on_top_level() {\n        let input = r#\"model_provider = \"any\"\nmodel = \"gpt-4\"\n\n[model_providers.any]\nname = \"any\"\n\"#;\n\n        let result = update_codex_toml_field(input, \"model\", \"gpt-5\").unwrap();\n        let parsed: toml::Value = toml::from_str(&result).unwrap();\n        assert_eq!(parsed.get(\"model\").and_then(|v| v.as_str()), Some(\"gpt-5\"));\n\n        // Clear model\n        let result2 = update_codex_toml_field(&result, \"model\", \"\").unwrap();\n        let parsed2: toml::Value = toml::from_str(&result2).unwrap();\n        assert!(parsed2.get(\"model\").is_none());\n    }\n\n    #[test]\n    fn preserves_comments_and_whitespace() {\n        let input = r#\"# My Codex config\nmodel_provider = \"any\"\nmodel = \"gpt-4\"\n\n# Provider section\n[model_providers.any]\nname = \"any\"\nbase_url = \"https://old.api/v1\"\n\"#;\n\n        let result = update_codex_toml_field(input, \"base_url\", \"https://new.api/v1\").unwrap();\n\n        // Comments should be preserved\n        assert!(result.contains(\"# My Codex config\"));\n        assert!(result.contains(\"# Provider section\"));\n    }\n\n    #[test]\n    fn does_not_misplace_when_profiles_section_follows() {\n        let input = r#\"model_provider = \"any\"\n\n[model_providers.any]\nname = \"any\"\nbase_url = \"https://old.api/v1\"\n\n[profiles.default]\nmodel = \"gpt-4\"\n\"#;\n\n        let result = update_codex_toml_field(input, \"base_url\", \"https://new.api/v1\").unwrap();\n        let parsed: toml::Value = toml::from_str(&result).unwrap();\n\n        // base_url in correct section\n        let base_url = parsed\n            .get(\"model_providers\")\n            .and_then(|v| v.get(\"any\"))\n            .and_then(|v| v.get(\"base_url\"))\n            .and_then(|v| v.as_str());\n        assert_eq!(base_url, Some(\"https://new.api/v1\"));\n\n        // profiles section untouched\n        let profile_model = parsed\n            .get(\"profiles\")\n            .and_then(|v| v.get(\"default\"))\n            .and_then(|v| v.get(\"model\"))\n            .and_then(|v| v.as_str());\n        assert_eq!(profile_model, Some(\"gpt-4\"));\n    }\n\n    #[test]\n    fn remove_base_url_if_predicate() {\n        let input = r#\"model_provider = \"any\"\n\n[model_providers.any]\nname = \"any\"\nbase_url = \"http://127.0.0.1:5000/v1\"\nwire_api = \"responses\"\n\"#;\n\n        let result =\n            remove_codex_toml_base_url_if(input, |url| url.starts_with(\"http://127.0.0.1\"));\n        let parsed: toml::Value = toml::from_str(&result).unwrap();\n\n        let any_section = parsed\n            .get(\"model_providers\")\n            .and_then(|v| v.get(\"any\"))\n            .unwrap();\n        assert!(any_section.get(\"base_url\").is_none());\n        assert_eq!(\n            any_section.get(\"wire_api\").and_then(|v| v.as_str()),\n            Some(\"responses\")\n        );\n    }\n\n    #[test]\n    fn remove_base_url_if_keeps_non_matching() {\n        let input = r#\"model_provider = \"any\"\n\n[model_providers.any]\nbase_url = \"https://production.api/v1\"\n\"#;\n\n        let result =\n            remove_codex_toml_base_url_if(input, |url| url.starts_with(\"http://127.0.0.1\"));\n        let parsed: toml::Value = toml::from_str(&result).unwrap();\n\n        let base_url = parsed\n            .get(\"model_providers\")\n            .and_then(|v| v.get(\"any\"))\n            .and_then(|v| v.get(\"base_url\"))\n            .and_then(|v| v.as_str());\n        assert_eq!(base_url, Some(\"https://production.api/v1\"));\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/commands/auth.rs",
    "content": "use tauri::State;\n\nuse crate::commands::copilot::CopilotAuthState;\nuse crate::proxy::providers::copilot_auth::{GitHubAccount, GitHubDeviceCodeResponse};\n\nconst AUTH_PROVIDER_GITHUB_COPILOT: &str = \"github_copilot\";\n\n#[derive(Debug, Clone, serde::Serialize)]\npub struct ManagedAuthAccount {\n    pub id: String,\n    pub provider: String,\n    pub login: String,\n    pub avatar_url: Option<String>,\n    pub authenticated_at: i64,\n    pub is_default: bool,\n}\n\n#[derive(Debug, Clone, serde::Serialize)]\npub struct ManagedAuthStatus {\n    pub provider: String,\n    pub authenticated: bool,\n    pub default_account_id: Option<String>,\n    pub migration_error: Option<String>,\n    pub accounts: Vec<ManagedAuthAccount>,\n}\n\n#[derive(Debug, Clone, serde::Serialize)]\npub struct ManagedAuthDeviceCodeResponse {\n    pub provider: String,\n    pub device_code: String,\n    pub user_code: String,\n    pub verification_uri: String,\n    pub expires_in: u64,\n    pub interval: u64,\n}\n\nfn ensure_auth_provider(auth_provider: &str) -> Result<&str, String> {\n    match auth_provider {\n        AUTH_PROVIDER_GITHUB_COPILOT => Ok(AUTH_PROVIDER_GITHUB_COPILOT),\n        _ => Err(format!(\"Unsupported auth provider: {auth_provider}\")),\n    }\n}\n\nfn map_account(\n    provider: &str,\n    account: GitHubAccount,\n    default_account_id: Option<&str>,\n) -> ManagedAuthAccount {\n    ManagedAuthAccount {\n        is_default: default_account_id == Some(account.id.as_str()),\n        id: account.id,\n        provider: provider.to_string(),\n        login: account.login,\n        avatar_url: account.avatar_url,\n        authenticated_at: account.authenticated_at,\n    }\n}\n\nfn map_device_code_response(\n    provider: &str,\n    response: GitHubDeviceCodeResponse,\n) -> ManagedAuthDeviceCodeResponse {\n    ManagedAuthDeviceCodeResponse {\n        provider: provider.to_string(),\n        device_code: response.device_code,\n        user_code: response.user_code,\n        verification_uri: response.verification_uri,\n        expires_in: response.expires_in,\n        interval: response.interval,\n    }\n}\n\n#[tauri::command(rename_all = \"camelCase\")]\npub async fn auth_start_login(\n    auth_provider: String,\n    state: State<'_, CopilotAuthState>,\n) -> Result<ManagedAuthDeviceCodeResponse, String> {\n    let auth_provider = ensure_auth_provider(&auth_provider)?;\n    let auth_manager = state.0.read().await;\n    let response = auth_manager\n        .start_device_flow()\n        .await\n        .map_err(|e| e.to_string())?;\n    Ok(map_device_code_response(auth_provider, response))\n}\n\n#[tauri::command(rename_all = \"camelCase\")]\npub async fn auth_poll_for_account(\n    auth_provider: String,\n    device_code: String,\n    state: State<'_, CopilotAuthState>,\n) -> Result<Option<ManagedAuthAccount>, String> {\n    let auth_provider = ensure_auth_provider(&auth_provider)?;\n    let auth_manager = state.0.write().await;\n    match auth_manager.poll_for_token(&device_code).await {\n        Ok(account) => {\n            let default_account_id = auth_manager.get_status().await.default_account_id;\n            Ok(account\n                .map(|account| map_account(auth_provider, account, default_account_id.as_deref())))\n        }\n        Err(crate::proxy::providers::copilot_auth::CopilotAuthError::AuthorizationPending) => {\n            Ok(None)\n        }\n        Err(e) => Err(e.to_string()),\n    }\n}\n\n#[tauri::command(rename_all = \"camelCase\")]\npub async fn auth_list_accounts(\n    auth_provider: String,\n    state: State<'_, CopilotAuthState>,\n) -> Result<Vec<ManagedAuthAccount>, String> {\n    let auth_provider = ensure_auth_provider(&auth_provider)?;\n    let auth_manager = state.0.read().await;\n    let status = auth_manager.get_status().await;\n    let default_account_id = status.default_account_id.clone();\n    Ok(status\n        .accounts\n        .into_iter()\n        .map(|account| map_account(auth_provider, account, default_account_id.as_deref()))\n        .collect())\n}\n\n#[tauri::command(rename_all = \"camelCase\")]\npub async fn auth_get_status(\n    auth_provider: String,\n    state: State<'_, CopilotAuthState>,\n) -> Result<ManagedAuthStatus, String> {\n    let auth_provider = ensure_auth_provider(&auth_provider)?;\n    let auth_manager = state.0.read().await;\n    let status = auth_manager.get_status().await;\n    let default_account_id = status.default_account_id.clone();\n    Ok(ManagedAuthStatus {\n        provider: auth_provider.to_string(),\n        authenticated: status.authenticated,\n        default_account_id: default_account_id.clone(),\n        migration_error: status.migration_error,\n        accounts: status\n            .accounts\n            .into_iter()\n            .map(|account| map_account(auth_provider, account, default_account_id.as_deref()))\n            .collect(),\n    })\n}\n\n#[tauri::command(rename_all = \"camelCase\")]\npub async fn auth_remove_account(\n    auth_provider: String,\n    account_id: String,\n    state: State<'_, CopilotAuthState>,\n) -> Result<(), String> {\n    ensure_auth_provider(&auth_provider)?;\n    let auth_manager = state.0.write().await;\n    auth_manager\n        .remove_account(&account_id)\n        .await\n        .map_err(|e| e.to_string())\n}\n\n#[tauri::command(rename_all = \"camelCase\")]\npub async fn auth_set_default_account(\n    auth_provider: String,\n    account_id: String,\n    state: State<'_, CopilotAuthState>,\n) -> Result<(), String> {\n    ensure_auth_provider(&auth_provider)?;\n    let auth_manager = state.0.write().await;\n    auth_manager\n        .set_default_account(&account_id)\n        .await\n        .map_err(|e| e.to_string())\n}\n\n#[tauri::command(rename_all = \"camelCase\")]\npub async fn auth_logout(\n    auth_provider: String,\n    state: State<'_, CopilotAuthState>,\n) -> Result<(), String> {\n    ensure_auth_provider(&auth_provider)?;\n    let auth_manager = state.0.write().await;\n    auth_manager.clear_auth().await.map_err(|e| e.to_string())\n}\n"
  },
  {
    "path": "src-tauri/src/commands/config.rs",
    "content": "#![allow(non_snake_case)]\n\nuse tauri::AppHandle;\nuse tauri_plugin_dialog::DialogExt;\nuse tauri_plugin_opener::OpenerExt;\n\nuse crate::app_config::AppType;\nuse crate::codex_config;\nuse crate::config::{self, get_claude_settings_path, ConfigStatus};\nuse crate::settings;\n\n#[tauri::command]\npub async fn get_claude_config_status() -> Result<ConfigStatus, String> {\n    Ok(config::get_claude_config_status())\n}\n\nuse std::str::FromStr;\n\nfn invalid_json_format_error(error: serde_json::Error) -> String {\n    let lang = settings::get_settings()\n        .language\n        .unwrap_or_else(|| \"zh\".to_string());\n\n    match lang.as_str() {\n        \"en\" => format!(\"Invalid JSON format: {error}\"),\n        \"ja\" => format!(\"JSON形式が無効です: {error}\"),\n        _ => format!(\"无效的 JSON 格式: {error}\"),\n    }\n}\n\nfn invalid_toml_format_error(error: toml_edit::TomlError) -> String {\n    let lang = settings::get_settings()\n        .language\n        .unwrap_or_else(|| \"zh\".to_string());\n\n    match lang.as_str() {\n        \"en\" => format!(\"Invalid TOML format: {error}\"),\n        \"ja\" => format!(\"TOML形式が無効です: {error}\"),\n        _ => format!(\"无效的 TOML 格式: {error}\"),\n    }\n}\n\nfn validate_common_config_snippet(app_type: &str, snippet: &str) -> Result<(), String> {\n    if snippet.trim().is_empty() {\n        return Ok(());\n    }\n\n    match app_type {\n        \"claude\" | \"gemini\" | \"omo\" | \"omo-slim\" => {\n            serde_json::from_str::<serde_json::Value>(snippet)\n                .map_err(invalid_json_format_error)?;\n        }\n        \"codex\" => {\n            snippet\n                .parse::<toml_edit::DocumentMut>()\n                .map_err(invalid_toml_format_error)?;\n        }\n        _ => {}\n    }\n\n    Ok(())\n}\n\n#[tauri::command]\npub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {\n    match AppType::from_str(&app).map_err(|e| e.to_string())? {\n        AppType::Claude => Ok(config::get_claude_config_status()),\n        AppType::Codex => {\n            let auth_path = codex_config::get_codex_auth_path();\n            let exists = auth_path.exists();\n            let path = codex_config::get_codex_config_dir()\n                .to_string_lossy()\n                .to_string();\n\n            Ok(ConfigStatus { exists, path })\n        }\n        AppType::Gemini => {\n            let env_path = crate::gemini_config::get_gemini_env_path();\n            let exists = env_path.exists();\n            let path = crate::gemini_config::get_gemini_dir()\n                .to_string_lossy()\n                .to_string();\n\n            Ok(ConfigStatus { exists, path })\n        }\n        AppType::OpenCode => {\n            let config_path = crate::opencode_config::get_opencode_config_path();\n            let exists = config_path.exists();\n            let path = crate::opencode_config::get_opencode_dir()\n                .to_string_lossy()\n                .to_string();\n\n            Ok(ConfigStatus { exists, path })\n        }\n        AppType::OpenClaw => {\n            let config_path = crate::openclaw_config::get_openclaw_config_path();\n            let exists = config_path.exists();\n            let path = crate::openclaw_config::get_openclaw_dir()\n                .to_string_lossy()\n                .to_string();\n\n            Ok(ConfigStatus { exists, path })\n        }\n    }\n}\n\n#[tauri::command]\npub async fn get_claude_code_config_path() -> Result<String, String> {\n    Ok(get_claude_settings_path().to_string_lossy().to_string())\n}\n\n#[tauri::command]\npub async fn get_config_dir(app: String) -> Result<String, String> {\n    let dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {\n        AppType::Claude => config::get_claude_config_dir(),\n        AppType::Codex => codex_config::get_codex_config_dir(),\n        AppType::Gemini => crate::gemini_config::get_gemini_dir(),\n        AppType::OpenCode => crate::opencode_config::get_opencode_dir(),\n        AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir(),\n    };\n\n    Ok(dir.to_string_lossy().to_string())\n}\n\n#[tauri::command]\npub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool, String> {\n    let config_dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {\n        AppType::Claude => config::get_claude_config_dir(),\n        AppType::Codex => codex_config::get_codex_config_dir(),\n        AppType::Gemini => crate::gemini_config::get_gemini_dir(),\n        AppType::OpenCode => crate::opencode_config::get_opencode_dir(),\n        AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir(),\n    };\n\n    if !config_dir.exists() {\n        std::fs::create_dir_all(&config_dir).map_err(|e| format!(\"创建目录失败: {e}\"))?;\n    }\n\n    handle\n        .opener()\n        .open_path(config_dir.to_string_lossy().to_string(), None::<String>)\n        .map_err(|e| format!(\"打开文件夹失败: {e}\"))?;\n\n    Ok(true)\n}\n\n#[tauri::command]\npub async fn pick_directory(\n    app: AppHandle,\n    #[allow(non_snake_case)] defaultPath: Option<String>,\n) -> Result<Option<String>, String> {\n    let initial = defaultPath\n        .map(|p| p.trim().to_string())\n        .filter(|p| !p.is_empty());\n\n    let result = tauri::async_runtime::spawn_blocking(move || {\n        let mut builder = app.dialog().file();\n        if let Some(path) = initial {\n            builder = builder.set_directory(path);\n        }\n        builder.blocking_pick_folder()\n    })\n    .await\n    .map_err(|e| format!(\"弹出目录选择器失败: {e}\"))?;\n\n    match result {\n        Some(file_path) => {\n            let resolved = file_path\n                .simplified()\n                .into_path()\n                .map_err(|e| format!(\"解析选择的目录失败: {e}\"))?;\n            Ok(Some(resolved.to_string_lossy().to_string()))\n        }\n        None => Ok(None),\n    }\n}\n\n#[tauri::command]\npub async fn get_app_config_path() -> Result<String, String> {\n    let config_path = config::get_app_config_path();\n    Ok(config_path.to_string_lossy().to_string())\n}\n\n#[tauri::command]\npub async fn open_app_config_folder(handle: AppHandle) -> Result<bool, String> {\n    let config_dir = config::get_app_config_dir();\n\n    if !config_dir.exists() {\n        std::fs::create_dir_all(&config_dir).map_err(|e| format!(\"创建目录失败: {e}\"))?;\n    }\n\n    handle\n        .opener()\n        .open_path(config_dir.to_string_lossy().to_string(), None::<String>)\n        .map_err(|e| format!(\"打开文件夹失败: {e}\"))?;\n\n    Ok(true)\n}\n\n#[tauri::command]\npub async fn get_claude_common_config_snippet(\n    state: tauri::State<'_, crate::store::AppState>,\n) -> Result<Option<String>, String> {\n    state\n        .db\n        .get_config_snippet(\"claude\")\n        .map_err(|e| e.to_string())\n}\n\n#[tauri::command]\npub async fn set_claude_common_config_snippet(\n    snippet: String,\n    state: tauri::State<'_, crate::store::AppState>,\n) -> Result<(), String> {\n    let is_cleared = snippet.trim().is_empty();\n\n    if !snippet.trim().is_empty() {\n        serde_json::from_str::<serde_json::Value>(&snippet).map_err(invalid_json_format_error)?;\n    }\n\n    let value = if is_cleared { None } else { Some(snippet) };\n\n    state\n        .db\n        .set_config_snippet(\"claude\", value)\n        .map_err(|e| e.to_string())?;\n    state\n        .db\n        .set_config_snippet_cleared(\"claude\", is_cleared)\n        .map_err(|e| e.to_string())?;\n    Ok(())\n}\n\n#[tauri::command]\npub async fn get_common_config_snippet(\n    app_type: String,\n    state: tauri::State<'_, crate::store::AppState>,\n) -> Result<Option<String>, String> {\n    state\n        .db\n        .get_config_snippet(&app_type)\n        .map_err(|e| e.to_string())\n}\n\n#[tauri::command]\npub async fn set_common_config_snippet(\n    app_type: String,\n    snippet: String,\n    state: tauri::State<'_, crate::store::AppState>,\n) -> Result<(), String> {\n    let is_cleared = snippet.trim().is_empty();\n    let old_snippet = state\n        .db\n        .get_config_snippet(&app_type)\n        .map_err(|e| e.to_string())?;\n\n    validate_common_config_snippet(&app_type, &snippet)?;\n\n    let value = if is_cleared { None } else { Some(snippet) };\n\n    if matches!(app_type.as_str(), \"claude\" | \"codex\" | \"gemini\") {\n        if let Some(legacy_snippet) = old_snippet\n            .as_deref()\n            .filter(|value| !value.trim().is_empty())\n        {\n            let app = AppType::from_str(&app_type).map_err(|e| e.to_string())?;\n            crate::services::provider::ProviderService::migrate_legacy_common_config_usage(\n                state.inner(),\n                app,\n                legacy_snippet,\n            )\n            .map_err(|e| e.to_string())?;\n        }\n    }\n\n    state\n        .db\n        .set_config_snippet(&app_type, value)\n        .map_err(|e| e.to_string())?;\n    state\n        .db\n        .set_config_snippet_cleared(&app_type, is_cleared)\n        .map_err(|e| e.to_string())?;\n\n    if matches!(app_type.as_str(), \"claude\" | \"codex\" | \"gemini\") {\n        let app = AppType::from_str(&app_type).map_err(|e| e.to_string())?;\n        crate::services::provider::ProviderService::sync_current_provider_for_app(\n            state.inner(),\n            app,\n        )\n        .map_err(|e| e.to_string())?;\n    }\n\n    if app_type == \"omo\"\n        && state\n            .db\n            .get_current_omo_provider(\"opencode\", \"omo\")\n            .map_err(|e| e.to_string())?\n            .is_some()\n    {\n        crate::services::OmoService::write_config_to_file(\n            state.inner(),\n            &crate::services::omo::STANDARD,\n        )\n        .map_err(|e| e.to_string())?;\n    }\n    if app_type == \"omo-slim\"\n        && state\n            .db\n            .get_current_omo_provider(\"opencode\", \"omo-slim\")\n            .map_err(|e| e.to_string())?\n            .is_some()\n    {\n        crate::services::OmoService::write_config_to_file(\n            state.inner(),\n            &crate::services::omo::SLIM,\n        )\n        .map_err(|e| e.to_string())?;\n    }\n    Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n    use super::validate_common_config_snippet;\n\n    #[test]\n    fn validate_common_config_snippet_accepts_comment_only_codex_snippet() {\n        validate_common_config_snippet(\"codex\", \"# comment only\\n\")\n            .expect(\"comment-only codex snippet should be valid\");\n    }\n\n    #[test]\n    fn validate_common_config_snippet_rejects_invalid_codex_snippet() {\n        let err = validate_common_config_snippet(\"codex\", \"[broken\")\n            .expect_err(\"invalid codex snippet should be rejected\");\n        assert!(\n            err.contains(\"TOML\") || err.contains(\"toml\") || err.contains(\"格式\"),\n            \"expected TOML validation error, got {err}\"\n        );\n    }\n}\n\n#[tauri::command]\npub async fn extract_common_config_snippet(\n    appType: String,\n    settingsConfig: Option<String>,\n    state: tauri::State<'_, crate::store::AppState>,\n) -> Result<String, String> {\n    let app = AppType::from_str(&appType).map_err(|e| e.to_string())?;\n\n    if let Some(settings_config) = settingsConfig.filter(|s| !s.trim().is_empty()) {\n        let settings: serde_json::Value =\n            serde_json::from_str(&settings_config).map_err(invalid_json_format_error)?;\n\n        return crate::services::provider::ProviderService::extract_common_config_snippet_from_settings(\n            app,\n            &settings,\n        )\n        .map_err(|e| e.to_string());\n    }\n\n    crate::services::provider::ProviderService::extract_common_config_snippet(&state, app)\n        .map_err(|e| e.to_string())\n}\n"
  },
  {
    "path": "src-tauri/src/commands/copilot.rs",
    "content": "//! GitHub Copilot Tauri Commands\n//!\n//! 提供 Copilot OAuth 认证相关的 Tauri 命令，支持多账号管理。\n\nuse crate::proxy::providers::copilot_auth::{\n    CopilotAuthManager, CopilotAuthStatus, CopilotModel, CopilotUsageResponse, GitHubAccount,\n    GitHubDeviceCodeResponse,\n};\nuse std::sync::Arc;\nuse tauri::State;\nuse tokio::sync::RwLock;\n\n/// Copilot 认证状态\npub struct CopilotAuthState(pub Arc<RwLock<CopilotAuthManager>>);\n\n// ==================== 设备码流程 ====================\n\n/// 启动设备码流程\n///\n/// 返回设备码和用户码，用于 OAuth 认证\n#[tauri::command]\npub async fn copilot_start_device_flow(\n    state: State<'_, CopilotAuthState>,\n) -> Result<GitHubDeviceCodeResponse, String> {\n    let auth_manager = state.0.read().await;\n    auth_manager\n        .start_device_flow()\n        .await\n        .map_err(|e| e.to_string())\n}\n\n/// 轮询 OAuth Token（向后兼容）\n///\n/// 使用设备码轮询 GitHub，等待用户完成授权\n/// 返回 true 表示授权成功，false 表示等待中\n#[tauri::command(rename_all = \"camelCase\")]\npub async fn copilot_poll_for_auth(\n    device_code: String,\n    state: State<'_, CopilotAuthState>,\n) -> Result<bool, String> {\n    let auth_manager = state.0.write().await;\n    match auth_manager.poll_for_token(&device_code).await {\n        Ok(Some(_account)) => {\n            log::info!(\"[CopilotAuth] 用户已授权\");\n            Ok(true)\n        }\n        Ok(None) => Ok(false),\n        Err(crate::proxy::providers::copilot_auth::CopilotAuthError::AuthorizationPending) => {\n            Ok(false)\n        }\n        Err(e) => {\n            log::error!(\"[CopilotAuth] 轮询失败: {}\", e);\n            Err(e.to_string())\n        }\n    }\n}\n\n/// 轮询 OAuth Token（多账号版本）\n///\n/// 返回新添加的账号信息，如果授权成功\n#[tauri::command(rename_all = \"camelCase\")]\npub async fn copilot_poll_for_account(\n    device_code: String,\n    state: State<'_, CopilotAuthState>,\n) -> Result<Option<GitHubAccount>, String> {\n    let auth_manager = state.0.write().await;\n    match auth_manager.poll_for_token(&device_code).await {\n        Ok(account) => Ok(account),\n        Err(crate::proxy::providers::copilot_auth::CopilotAuthError::AuthorizationPending) => {\n            Ok(None)\n        }\n        Err(e) => {\n            log::error!(\"[CopilotAuth] 轮询失败: {}\", e);\n            Err(e.to_string())\n        }\n    }\n}\n\n// ==================== 多账号管理 ====================\n\n/// 列出所有已认证的账号\n#[tauri::command]\npub async fn copilot_list_accounts(\n    state: State<'_, CopilotAuthState>,\n) -> Result<Vec<GitHubAccount>, String> {\n    let auth_manager = state.0.read().await;\n    Ok(auth_manager.list_accounts().await)\n}\n\n/// 移除指定账号\n#[tauri::command(rename_all = \"camelCase\")]\npub async fn copilot_remove_account(\n    account_id: String,\n    state: State<'_, CopilotAuthState>,\n) -> Result<(), String> {\n    let auth_manager = state.0.write().await;\n    auth_manager\n        .remove_account(&account_id)\n        .await\n        .map_err(|e| e.to_string())\n}\n\n/// 设置默认账号\n#[tauri::command(rename_all = \"camelCase\")]\npub async fn copilot_set_default_account(\n    account_id: String,\n    state: State<'_, CopilotAuthState>,\n) -> Result<(), String> {\n    let auth_manager = state.0.write().await;\n    auth_manager\n        .set_default_account(&account_id)\n        .await\n        .map_err(|e| e.to_string())\n}\n\n// ==================== 状态查询 ====================\n\n/// 获取认证状态（包含所有账号）\n#[tauri::command]\npub async fn copilot_get_auth_status(\n    state: State<'_, CopilotAuthState>,\n) -> Result<CopilotAuthStatus, String> {\n    let auth_manager = state.0.read().await;\n    Ok(auth_manager.get_status().await)\n}\n\n/// 检查是否已认证（有任意账号）\n#[tauri::command]\npub async fn copilot_is_authenticated(state: State<'_, CopilotAuthState>) -> Result<bool, String> {\n    let auth_manager = state.0.read().await;\n    Ok(auth_manager.is_authenticated().await)\n}\n\n/// 注销所有 Copilot 认证\n#[tauri::command]\npub async fn copilot_logout(state: State<'_, CopilotAuthState>) -> Result<(), String> {\n    let auth_manager = state.0.write().await;\n    auth_manager.clear_auth().await.map_err(|e| e.to_string())\n}\n\n// ==================== Token 获取 ====================\n\n/// 获取有效的 Copilot Token（向后兼容：使用第一个账号）\n///\n/// 内部使用，用于代理请求\n#[tauri::command]\npub async fn copilot_get_token(state: State<'_, CopilotAuthState>) -> Result<String, String> {\n    let auth_manager = state.0.read().await;\n    auth_manager\n        .get_valid_token()\n        .await\n        .map_err(|e| e.to_string())\n}\n\n/// 获取指定账号的有效 Copilot Token\n#[tauri::command(rename_all = \"camelCase\")]\npub async fn copilot_get_token_for_account(\n    account_id: String,\n    state: State<'_, CopilotAuthState>,\n) -> Result<String, String> {\n    let auth_manager = state.0.read().await;\n    auth_manager\n        .get_valid_token_for_account(&account_id)\n        .await\n        .map_err(|e| e.to_string())\n}\n\n// ==================== 模型和使用量 ====================\n\n/// 获取 Copilot 可用模型列表（向后兼容：使用第一个账号）\n#[tauri::command]\npub async fn copilot_get_models(\n    state: State<'_, CopilotAuthState>,\n) -> Result<Vec<CopilotModel>, String> {\n    let auth_manager = state.0.read().await;\n    auth_manager.fetch_models().await.map_err(|e| e.to_string())\n}\n\n/// 获取指定账号的 Copilot 可用模型列表\n#[tauri::command(rename_all = \"camelCase\")]\npub async fn copilot_get_models_for_account(\n    account_id: String,\n    state: State<'_, CopilotAuthState>,\n) -> Result<Vec<CopilotModel>, String> {\n    let auth_manager = state.0.read().await;\n    auth_manager\n        .fetch_models_for_account(&account_id)\n        .await\n        .map_err(|e| e.to_string())\n}\n\n/// 获取 Copilot 使用量信息（向后兼容：使用第一个账号）\n#[tauri::command]\npub async fn copilot_get_usage(\n    state: State<'_, CopilotAuthState>,\n) -> Result<CopilotUsageResponse, String> {\n    let auth_manager = state.0.read().await;\n    auth_manager.fetch_usage().await.map_err(|e| e.to_string())\n}\n\n/// 获取指定账号的 Copilot 使用量信息\n#[tauri::command(rename_all = \"camelCase\")]\npub async fn copilot_get_usage_for_account(\n    account_id: String,\n    state: State<'_, CopilotAuthState>,\n) -> Result<CopilotUsageResponse, String> {\n    let auth_manager = state.0.read().await;\n    auth_manager\n        .fetch_usage_for_account(&account_id)\n        .await\n        .map_err(|e| e.to_string())\n}\n"
  },
  {
    "path": "src-tauri/src/commands/deeplink.rs",
    "content": "use crate::deeplink::{\n    import_mcp_from_deeplink, import_prompt_from_deeplink, import_provider_from_deeplink,\n    import_skill_from_deeplink, parse_deeplink_url, DeepLinkImportRequest,\n};\nuse crate::store::AppState;\nuse tauri::State;\n\n/// Parse a deep link URL and return the parsed request for frontend confirmation\n#[tauri::command]\npub fn parse_deeplink(url: String) -> Result<DeepLinkImportRequest, String> {\n    log::info!(\"Parsing deep link URL: {url}\");\n    parse_deeplink_url(&url).map_err(|e| e.to_string())\n}\n\n/// Merge configuration from Base64/URL into a deep link request\n/// This is used by the frontend to show the complete configuration in the confirmation dialog\n#[tauri::command]\npub fn merge_deeplink_config(\n    request: DeepLinkImportRequest,\n) -> Result<DeepLinkImportRequest, String> {\n    log::info!(\"Merging config for deep link request: {:?}\", request.name);\n    crate::deeplink::parse_and_merge_config(&request).map_err(|e| e.to_string())\n}\n\n/// Import a provider from a deep link request (legacy, kept for compatibility)\n#[tauri::command]\npub fn import_from_deeplink(\n    state: State<AppState>,\n    request: DeepLinkImportRequest,\n) -> Result<String, String> {\n    log::info!(\n        \"Importing provider from deep link: {:?} for app {:?}\",\n        request.name,\n        request.app\n    );\n\n    let provider_id = import_provider_from_deeplink(&state, request).map_err(|e| e.to_string())?;\n\n    log::info!(\"Successfully imported provider with ID: {provider_id}\");\n\n    Ok(provider_id)\n}\n\n/// Import resource from a deep link request (unified handler)\n#[tauri::command]\npub async fn import_from_deeplink_unified(\n    state: State<'_, AppState>,\n    request: DeepLinkImportRequest,\n) -> Result<serde_json::Value, String> {\n    log::info!(\"Importing {} resource from deep link\", request.resource);\n\n    match request.resource.as_str() {\n        \"provider\" => {\n            let provider_id =\n                import_provider_from_deeplink(&state, request).map_err(|e| e.to_string())?;\n            Ok(serde_json::json!({\n                \"type\": \"provider\",\n                \"id\": provider_id\n            }))\n        }\n        \"prompt\" => {\n            let prompt_id =\n                import_prompt_from_deeplink(&state, request).map_err(|e| e.to_string())?;\n            Ok(serde_json::json!({\n                \"type\": \"prompt\",\n                \"id\": prompt_id\n            }))\n        }\n        \"mcp\" => {\n            let result = import_mcp_from_deeplink(&state, request).map_err(|e| e.to_string())?;\n            // Add type field to the result\n            Ok(serde_json::json!({\n                \"type\": \"mcp\",\n                \"importedCount\": result.imported_count,\n                \"importedIds\": result.imported_ids,\n                \"failed\": result.failed\n            }))\n        }\n        \"skill\" => {\n            let skill_key =\n                import_skill_from_deeplink(&state, request).map_err(|e| e.to_string())?;\n            Ok(serde_json::json!({\n                \"type\": \"skill\",\n                \"key\": skill_key\n            }))\n        }\n        _ => Err(format!(\"Unsupported resource type: {}\", request.resource)),\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/commands/env.rs",
    "content": "use crate::services::env_checker::{check_env_conflicts as check_conflicts, EnvConflict};\nuse crate::services::env_manager::{\n    delete_env_vars as delete_vars, restore_from_backup, BackupInfo,\n};\n\n/// Check environment variable conflicts for a specific app\n#[tauri::command]\npub fn check_env_conflicts(app: String) -> Result<Vec<EnvConflict>, String> {\n    check_conflicts(&app)\n}\n\n/// Delete environment variables with backup\n#[tauri::command]\npub fn delete_env_vars(conflicts: Vec<EnvConflict>) -> Result<BackupInfo, String> {\n    delete_vars(conflicts)\n}\n\n/// Restore environment variables from backup file\n#[tauri::command]\npub fn restore_env_backup(backup_path: String) -> Result<(), String> {\n    restore_from_backup(backup_path)\n}\n"
  },
  {
    "path": "src-tauri/src/commands/failover.rs",
    "content": "//! 故障转移队列命令\n//!\n//! 管理代理模式下的故障转移队列（基于 providers 表的 in_failover_queue 字段）\n\nuse crate::database::FailoverQueueItem;\nuse crate::provider::Provider;\nuse crate::store::AppState;\nuse std::str::FromStr;\nuse tauri::Emitter;\n\n/// 获取故障转移队列\n#[tauri::command]\npub async fn get_failover_queue(\n    state: tauri::State<'_, AppState>,\n    app_type: String,\n) -> Result<Vec<FailoverQueueItem>, String> {\n    state\n        .db\n        .get_failover_queue(&app_type)\n        .map_err(|e| e.to_string())\n}\n\n/// 获取可添加到故障转移队列的供应商（不在队列中的）\n#[tauri::command]\npub async fn get_available_providers_for_failover(\n    state: tauri::State<'_, AppState>,\n    app_type: String,\n) -> Result<Vec<Provider>, String> {\n    state\n        .db\n        .get_available_providers_for_failover(&app_type)\n        .map_err(|e| e.to_string())\n}\n\n/// 添加供应商到故障转移队列\n#[tauri::command]\npub async fn add_to_failover_queue(\n    state: tauri::State<'_, AppState>,\n    app_type: String,\n    provider_id: String,\n) -> Result<(), String> {\n    state\n        .db\n        .add_to_failover_queue(&app_type, &provider_id)\n        .map_err(|e| e.to_string())\n}\n\n/// 从故障转移队列移除供应商\n#[tauri::command]\npub async fn remove_from_failover_queue(\n    state: tauri::State<'_, AppState>,\n    app_type: String,\n    provider_id: String,\n) -> Result<(), String> {\n    state\n        .db\n        .remove_from_failover_queue(&app_type, &provider_id)\n        .map_err(|e| e.to_string())\n}\n\n/// 获取指定应用的自动故障转移开关状态（从 proxy_config 表读取）\n#[tauri::command]\npub async fn get_auto_failover_enabled(\n    state: tauri::State<'_, AppState>,\n    app_type: String,\n) -> Result<bool, String> {\n    state\n        .db\n        .get_proxy_config_for_app(&app_type)\n        .await\n        .map(|config| config.auto_failover_enabled)\n        .map_err(|e| e.to_string())\n}\n\n/// 设置指定应用的自动故障转移开关状态（写入 proxy_config 表）\n///\n/// 注意：关闭故障转移时不会清除队列，队列内容会保留供下次开启时使用\n#[tauri::command]\npub async fn set_auto_failover_enabled(\n    app: tauri::AppHandle,\n    state: tauri::State<'_, AppState>,\n    app_type: String,\n    enabled: bool,\n) -> Result<(), String> {\n    log::info!(\n        \"[Failover] Setting auto_failover_enabled: app_type='{app_type}', enabled={enabled}\"\n    );\n\n    // 强一致语义：开启故障转移后立即切到队列 P1（并确保队列非空）\n    //\n    // 说明：\n    // - 仅在 enabled=true 时执行“切到 P1”\n    // - 若队列为空，则尝试把“当前供应商”自动加入队列作为 P1，避免用户在 UI 上陷入死锁（无法先加队列再开启）\n    let p1_provider_id = if enabled {\n        let mut queue = state\n            .db\n            .get_failover_queue(&app_type)\n            .map_err(|e| e.to_string())?;\n\n        if queue.is_empty() {\n            let app_enum = crate::app_config::AppType::from_str(&app_type)\n                .map_err(|_| format!(\"无效的应用类型: {app_type}\"))?;\n\n            let current_id = crate::settings::get_effective_current_provider(&state.db, &app_enum)\n                .map_err(|e| e.to_string())?;\n\n            let Some(current_id) = current_id else {\n                return Err(\"故障转移队列为空，且未设置当前供应商，无法开启故障转移\".to_string());\n            };\n\n            state\n                .db\n                .add_to_failover_queue(&app_type, &current_id)\n                .map_err(|e| e.to_string())?;\n\n            queue = state\n                .db\n                .get_failover_queue(&app_type)\n                .map_err(|e| e.to_string())?;\n        }\n\n        queue\n            .first()\n            .map(|item| item.provider_id.clone())\n            .ok_or_else(|| \"故障转移队列为空，无法开启故障转移\".to_string())?\n    } else {\n        String::new()\n    };\n\n    // 读取当前配置\n    let mut config = state\n        .db\n        .get_proxy_config_for_app(&app_type)\n        .await\n        .map_err(|e| e.to_string())?;\n\n    // 更新 auto_failover_enabled 字段\n    config.auto_failover_enabled = enabled;\n\n    // 写回数据库\n    state\n        .db\n        .update_proxy_config_for_app(config)\n        .await\n        .map_err(|e| e.to_string())?;\n\n    // 开启后立即切到 P1：更新 is_current + 本地 settings + Live 备份（接管模式下）\n    if enabled {\n        state\n            .proxy_service\n            .switch_proxy_target(&app_type, &p1_provider_id)\n            .await?;\n\n        // 发射 provider-switched 事件（让前端刷新当前供应商）\n        let event_data = serde_json::json!({\n            \"appType\": app_type,\n            \"providerId\": p1_provider_id,\n            \"source\": \"failoverEnabled\"\n        });\n        let _ = app.emit(\"provider-switched\", event_data);\n    }\n\n    // 刷新托盘菜单，确保状态同步\n    if let Ok(new_menu) = crate::tray::create_tray_menu(&app, &state) {\n        if let Some(tray) = app.tray_by_id(\"main\") {\n            let _ = tray.set_menu(Some(new_menu));\n        }\n    }\n\n    Ok(())\n}\n"
  },
  {
    "path": "src-tauri/src/commands/global_proxy.rs",
    "content": "//! 全局出站代理相关命令\n//!\n//! 提供获取、设置和测试全局代理的 Tauri 命令。\n\nuse crate::proxy::http_client;\nuse crate::store::AppState;\nuse serde::Serialize;\nuse std::net::{Ipv4Addr, SocketAddrV4, TcpStream};\nuse std::time::{Duration, Instant};\n\n/// 获取全局代理 URL\n///\n/// 返回当前配置的代理 URL，null 表示直连。\n#[tauri::command]\npub fn get_global_proxy_url(state: tauri::State<'_, AppState>) -> Result<Option<String>, String> {\n    let result = state.db.get_global_proxy_url().map_err(|e| e.to_string())?;\n    log::debug!(\n        \"[GlobalProxy] [GP-010] Read from database: {}\",\n        result\n            .as_ref()\n            .map(|u| http_client::mask_url(u))\n            .unwrap_or_else(|| \"None\".to_string())\n    );\n    Ok(result)\n}\n\n/// 设置全局代理 URL\n///\n/// - 传入非空字符串：启用代理\n/// - 传入空字符串：清除代理（直连）\n///\n/// 执行顺序：先验证 → 写 DB → 再应用\n/// 这样确保 DB 写失败时不会出现运行态与持久化不一致的问题\n#[tauri::command]\npub fn set_global_proxy_url(state: tauri::State<'_, AppState>, url: String) -> Result<(), String> {\n    // 调试：显示接收到的 URL 信息（不包含敏感内容）\n    let has_auth = url.contains('@') && (url.starts_with(\"http://\") || url.starts_with(\"socks\"));\n    log::debug!(\n        \"[GlobalProxy] [GP-011] Received URL: length={}, has_auth={}\",\n        url.len(),\n        has_auth\n    );\n\n    let url_opt = if url.trim().is_empty() {\n        None\n    } else {\n        Some(url.as_str())\n    };\n\n    // 1. 先验证代理配置是否有效（不应用）\n    http_client::validate_proxy(url_opt)?;\n\n    // 2. 验证成功后保存到数据库\n    state\n        .db\n        .set_global_proxy_url(url_opt)\n        .map_err(|e| e.to_string())?;\n\n    // 3. DB 写入成功后再应用到运行态\n    http_client::apply_proxy(url_opt)?;\n\n    log::info!(\n        \"[GlobalProxy] [GP-009] Configuration updated: {}\",\n        url_opt\n            .map(http_client::mask_url)\n            .unwrap_or_else(|| \"direct connection\".to_string())\n    );\n\n    Ok(())\n}\n\n/// 代理测试结果\n#[derive(Debug, Clone, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct ProxyTestResult {\n    /// 是否连接成功\n    pub success: bool,\n    /// 延迟（毫秒）\n    pub latency_ms: u64,\n    /// 错误信息\n    pub error: Option<String>,\n}\n\n/// 测试代理连接\n///\n/// 通过指定的代理 URL 发送测试请求，返回连接结果和延迟。\n/// 使用多个测试目标，任一成功即认为代理可用。\n#[tauri::command]\npub async fn test_proxy_url(url: String) -> Result<ProxyTestResult, String> {\n    if url.trim().is_empty() {\n        return Err(\"Proxy URL is empty\".to_string());\n    }\n\n    let start = Instant::now();\n\n    // 构建带代理的临时客户端\n    let proxy = reqwest::Proxy::all(&url).map_err(|e| format!(\"Invalid proxy URL: {e}\"))?;\n\n    let client = reqwest::Client::builder()\n        .proxy(proxy)\n        .timeout(std::time::Duration::from_secs(10))\n        .connect_timeout(std::time::Duration::from_secs(10))\n        .build()\n        .map_err(|e| format!(\"Failed to build client: {e}\"))?;\n\n    // 使用多个测试目标，提高兼容性\n    // 优先使用 httpbin（专门用于 HTTP 测试），回退到其他公共端点\n    let test_urls = [\n        \"https://httpbin.org/get\",\n        \"https://www.google.com\",\n        \"https://api.anthropic.com\",\n    ];\n\n    let mut last_error = None;\n\n    for test_url in test_urls {\n        match client.head(test_url).send().await {\n            Ok(resp) => {\n                let latency = start.elapsed().as_millis() as u64;\n                log::debug!(\n                    \"[GlobalProxy] Test successful: {} -> {} via {} ({}ms)\",\n                    http_client::mask_url(&url),\n                    test_url,\n                    resp.status(),\n                    latency\n                );\n                return Ok(ProxyTestResult {\n                    success: true,\n                    latency_ms: latency,\n                    error: None,\n                });\n            }\n            Err(e) => {\n                log::debug!(\"[GlobalProxy] Test to {test_url} failed: {e}\");\n                last_error = Some(e);\n            }\n        }\n    }\n\n    // 所有测试目标都失败\n    let latency = start.elapsed().as_millis() as u64;\n    let error_msg = last_error\n        .map(|e| e.to_string())\n        .unwrap_or_else(|| \"All test targets failed\".to_string());\n\n    log::debug!(\n        \"[GlobalProxy] Test failed: {} -> {} ({}ms)\",\n        http_client::mask_url(&url),\n        error_msg,\n        latency\n    );\n\n    Ok(ProxyTestResult {\n        success: false,\n        latency_ms: latency,\n        error: Some(error_msg),\n    })\n}\n\n/// 获取当前出站代理状态\n///\n/// 返回当前是否启用了出站代理以及代理 URL。\n#[tauri::command]\npub fn get_upstream_proxy_status() -> UpstreamProxyStatus {\n    let url = http_client::get_current_proxy_url();\n    UpstreamProxyStatus {\n        enabled: url.is_some(),\n        proxy_url: url,\n    }\n}\n\n/// 出站代理状态信息\n#[derive(Debug, Clone, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct UpstreamProxyStatus {\n    /// 是否启用代理\n    pub enabled: bool,\n    /// 代理 URL\n    pub proxy_url: Option<String>,\n}\n\n/// 检测到的代理信息\n#[derive(Debug, Clone, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct DetectedProxy {\n    /// 代理 URL\n    pub url: String,\n    /// 代理类型 (http/socks5)\n    pub proxy_type: String,\n    /// 端口\n    pub port: u16,\n}\n\n/// 常见代理端口配置\n/// 格式：(端口, 主要类型, 是否同时支持 http 和 socks5)\n/// 对于 mixed 端口，会同时返回两种协议供用户选择\nconst PROXY_PORTS: &[(u16, &str, bool)] = &[\n    (7890, \"http\", true),     // Clash (mixed mode)\n    (7891, \"socks5\", false),  // Clash SOCKS only\n    (1080, \"socks5\", false),  // 通用 SOCKS5\n    (8080, \"http\", false),    // 通用 HTTP\n    (8888, \"http\", false),    // Charles/Fiddler\n    (3128, \"http\", false),    // Squid\n    (10808, \"socks5\", false), // V2Ray SOCKS\n    (10809, \"http\", false),   // V2Ray HTTP\n];\n\n/// 扫描本地代理\n///\n/// 检测常见端口是否有代理服务在运行。\n/// 使用异步任务避免阻塞 UI 线程。\n#[tauri::command]\npub async fn scan_local_proxies() -> Vec<DetectedProxy> {\n    // 使用 spawn_blocking 避免阻塞主线程\n    tokio::task::spawn_blocking(|| {\n        let mut found = Vec::new();\n\n        for &(port, primary_type, is_mixed) in PROXY_PORTS {\n            let addr = SocketAddrV4::new(Ipv4Addr::LOCALHOST, port);\n            if TcpStream::connect_timeout(&addr.into(), Duration::from_millis(100)).is_ok() {\n                // 添加主要类型\n                found.push(DetectedProxy {\n                    url: format!(\"{primary_type}://127.0.0.1:{port}\"),\n                    proxy_type: primary_type.to_string(),\n                    port,\n                });\n                // 对于 mixed 端口，同时添加另一种协议\n                if is_mixed {\n                    let alt_type = if primary_type == \"http\" {\n                        \"socks5\"\n                    } else {\n                        \"http\"\n                    };\n                    found.push(DetectedProxy {\n                        url: format!(\"{alt_type}://127.0.0.1:{port}\"),\n                        proxy_type: alt_type.to_string(),\n                        port,\n                    });\n                }\n            }\n        }\n\n        found\n    })\n    .await\n    .unwrap_or_default()\n}\n"
  },
  {
    "path": "src-tauri/src/commands/import_export.rs",
    "content": "#![allow(non_snake_case)]\n\nuse serde_json::{json, Value};\nuse std::path::PathBuf;\nuse tauri::State;\nuse tauri_plugin_dialog::DialogExt;\n\nuse crate::commands::sync_support::{\n    post_sync_warning_from_result, run_post_import_sync, success_payload_with_warning,\n};\nuse crate::database::backup::BackupEntry;\nuse crate::database::Database;\nuse crate::error::AppError;\nuse crate::services::provider::ProviderService;\nuse crate::store::AppState;\n\n// ─── File import/export ──────────────────────────────────────\n\n/// 导出数据库为 SQL 备份\n#[tauri::command]\npub async fn export_config_to_file(\n    #[allow(non_snake_case)] filePath: String,\n    state: State<'_, AppState>,\n) -> Result<Value, String> {\n    let db = state.db.clone();\n    tauri::async_runtime::spawn_blocking(move || {\n        let target_path = PathBuf::from(&filePath);\n        db.export_sql(&target_path)?;\n        Ok::<_, AppError>(json!({\n            \"success\": true,\n            \"message\": \"SQL exported successfully\",\n            \"filePath\": filePath\n        }))\n    })\n    .await\n    .map_err(|e| format!(\"导出配置失败: {e}\"))?\n    .map_err(|e: AppError| e.to_string())\n}\n\n/// 从 SQL 备份导入数据库\n#[tauri::command]\npub async fn import_config_from_file(\n    #[allow(non_snake_case)] filePath: String,\n    state: State<'_, AppState>,\n) -> Result<Value, String> {\n    let db = state.db.clone();\n    let db_for_sync = db.clone();\n    tauri::async_runtime::spawn_blocking(move || {\n        let path_buf = PathBuf::from(&filePath);\n        let backup_id = db.import_sql(&path_buf)?;\n        let warning = post_sync_warning_from_result(Ok(run_post_import_sync(db_for_sync)));\n        if let Some(msg) = warning.as_ref() {\n            log::warn!(\"[Import] post-import sync warning: {msg}\");\n        }\n        Ok::<_, AppError>(success_payload_with_warning(backup_id, warning))\n    })\n    .await\n    .map_err(|e| format!(\"导入配置失败: {e}\"))?\n    .map_err(|e: AppError| e.to_string())\n}\n\n#[tauri::command]\npub async fn sync_current_providers_live(state: State<'_, AppState>) -> Result<Value, String> {\n    let db = state.db.clone();\n    tauri::async_runtime::spawn_blocking(move || {\n        let app_state = AppState::new(db);\n        ProviderService::sync_current_to_live(&app_state)?;\n        Ok::<_, AppError>(json!({\n            \"success\": true,\n            \"message\": \"Live configuration synchronized\"\n        }))\n    })\n    .await\n    .map_err(|e| format!(\"同步当前供应商失败: {e}\"))?\n    .map_err(|e: AppError| e.to_string())\n}\n\n// ─── File dialogs ────────────────────────────────────────────\n\n/// 保存文件对话框\n#[tauri::command]\npub async fn save_file_dialog<R: tauri::Runtime>(\n    app: tauri::AppHandle<R>,\n    #[allow(non_snake_case)] defaultName: String,\n) -> Result<Option<String>, String> {\n    let dialog = app.dialog();\n    let result = dialog\n        .file()\n        .add_filter(\"SQL\", &[\"sql\"])\n        .set_file_name(&defaultName)\n        .blocking_save_file();\n\n    Ok(result.map(|p| p.to_string()))\n}\n\n/// 打开文件对话框\n#[tauri::command]\npub async fn open_file_dialog<R: tauri::Runtime>(\n    app: tauri::AppHandle<R>,\n) -> Result<Option<String>, String> {\n    let dialog = app.dialog();\n    let result = dialog\n        .file()\n        .add_filter(\"SQL\", &[\"sql\"])\n        .blocking_pick_file();\n\n    Ok(result.map(|p| p.to_string()))\n}\n\n/// 打开 ZIP 文件选择对话框\n#[tauri::command]\npub async fn open_zip_file_dialog<R: tauri::Runtime>(\n    app: tauri::AppHandle<R>,\n) -> Result<Option<String>, String> {\n    let dialog = app.dialog();\n    let result = dialog\n        .file()\n        .add_filter(\"ZIP / Skill\", &[\"zip\", \"skill\"])\n        .blocking_pick_file();\n\n    Ok(result.map(|p| p.to_string()))\n}\n\n// ─── Database backup management ─────────────────────────────\n\n/// Manually create a database backup\n#[tauri::command]\npub async fn create_db_backup(state: State<'_, AppState>) -> Result<String, String> {\n    let db = state.db.clone();\n    tauri::async_runtime::spawn_blocking(move || match db.backup_database_file()? {\n        Some(path) => Ok(path\n            .file_name()\n            .map(|f| f.to_string_lossy().into_owned())\n            .unwrap_or_default()),\n        None => Err(AppError::Config(\n            \"Database file not found, backup skipped\".to_string(),\n        )),\n    })\n    .await\n    .map_err(|e| format!(\"Backup failed: {e}\"))?\n    .map_err(|e: AppError| e.to_string())\n}\n\n/// List all database backup files\n#[tauri::command]\npub fn list_db_backups() -> Result<Vec<BackupEntry>, String> {\n    Database::list_backups().map_err(|e| e.to_string())\n}\n\n/// Restore database from a backup file\n#[tauri::command]\npub async fn restore_db_backup(\n    state: State<'_, AppState>,\n    filename: String,\n) -> Result<String, String> {\n    let db = state.db.clone();\n    tauri::async_runtime::spawn_blocking(move || db.restore_from_backup(&filename))\n        .await\n        .map_err(|e| format!(\"Restore failed: {e}\"))?\n        .map_err(|e: AppError| e.to_string())\n}\n\n/// Rename a database backup file\n#[tauri::command]\npub fn rename_db_backup(\n    #[allow(non_snake_case)] oldFilename: String,\n    #[allow(non_snake_case)] newName: String,\n) -> Result<String, String> {\n    Database::rename_backup(&oldFilename, &newName).map_err(|e| e.to_string())\n}\n\n/// Delete a database backup file\n#[tauri::command]\npub fn delete_db_backup(filename: String) -> Result<(), String> {\n    Database::delete_backup(&filename).map_err(|e| e.to_string())\n}\n"
  },
  {
    "path": "src-tauri/src/commands/mcp.rs",
    "content": "#![allow(non_snake_case)]\n\nuse indexmap::IndexMap;\nuse std::collections::HashMap;\n\nuse serde::Serialize;\nuse tauri::State;\n\nuse crate::app_config::AppType;\nuse crate::claude_mcp;\nuse crate::services::McpService;\nuse crate::store::AppState;\n\n/// 获取 Claude MCP 状态\n#[tauri::command]\npub async fn get_claude_mcp_status() -> Result<claude_mcp::McpStatus, String> {\n    claude_mcp::get_mcp_status().map_err(|e| e.to_string())\n}\n\n/// 读取 mcp.json 文本内容\n#[tauri::command]\npub async fn read_claude_mcp_config() -> Result<Option<String>, String> {\n    claude_mcp::read_mcp_json().map_err(|e| e.to_string())\n}\n\n/// 新增或更新一个 MCP 服务器条目\n#[tauri::command]\npub async fn upsert_claude_mcp_server(id: String, spec: serde_json::Value) -> Result<bool, String> {\n    claude_mcp::upsert_mcp_server(&id, spec).map_err(|e| e.to_string())\n}\n\n/// 删除一个 MCP 服务器条目\n#[tauri::command]\npub async fn delete_claude_mcp_server(id: String) -> Result<bool, String> {\n    claude_mcp::delete_mcp_server(&id).map_err(|e| e.to_string())\n}\n\n/// 校验命令是否在 PATH 中可用（不执行）\n#[tauri::command]\npub async fn validate_mcp_command(cmd: String) -> Result<bool, String> {\n    claude_mcp::validate_command_in_path(&cmd).map_err(|e| e.to_string())\n}\n\n#[derive(Serialize)]\npub struct McpConfigResponse {\n    pub config_path: String,\n    pub servers: HashMap<String, serde_json::Value>,\n}\n\n/// 获取 MCP 配置（来自 ~/.cc-switch/config.json）\nuse std::str::FromStr;\n\n#[tauri::command]\n#[allow(deprecated)] // 兼容层命令，内部调用已废弃的 Service 方法\npub async fn get_mcp_config(\n    state: State<'_, AppState>,\n    app: String,\n) -> Result<McpConfigResponse, String> {\n    let config_path = crate::config::get_app_config_path()\n        .to_string_lossy()\n        .to_string();\n    let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?;\n    let servers = McpService::get_servers(&state, app_ty).map_err(|e| e.to_string())?;\n    Ok(McpConfigResponse {\n        config_path,\n        servers,\n    })\n}\n\n/// 在 config.json 中新增或更新一个 MCP 服务器定义\n/// [已废弃] 该命令仍然使用旧的分应用API，会转换为统一结构\n#[tauri::command]\npub async fn upsert_mcp_server_in_config(\n    state: State<'_, AppState>,\n    app: String,\n    id: String,\n    spec: serde_json::Value,\n    sync_other_side: Option<bool>,\n) -> Result<bool, String> {\n    use crate::app_config::McpServer;\n\n    let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?;\n\n    // 读取现有的服务器（如果存在）\n    let existing_server = {\n        let servers = state.db.get_all_mcp_servers().map_err(|e| e.to_string())?;\n        servers.get(&id).cloned()\n    };\n\n    // 构建新的统一服务器结构\n    let mut new_server = if let Some(mut existing) = existing_server {\n        // 更新现有服务器\n        existing.server = spec.clone();\n        existing.apps.set_enabled_for(&app_ty, true);\n        existing\n    } else {\n        // 创建新服务器\n        let mut apps = crate::app_config::McpApps::default();\n        apps.set_enabled_for(&app_ty, true);\n\n        // 尝试从 spec 中提取 name，否则使用 id\n        let name = spec\n            .get(\"name\")\n            .and_then(|v| v.as_str())\n            .unwrap_or(&id)\n            .to_string();\n\n        McpServer {\n            id: id.clone(),\n            name,\n            server: spec,\n            apps,\n            description: None,\n            homepage: None,\n            docs: None,\n            tags: Vec::new(),\n        }\n    };\n\n    // 如果 sync_other_side 为 true，也启用其他应用\n    if sync_other_side.unwrap_or(false) {\n        new_server.apps.claude = true;\n        new_server.apps.codex = true;\n        new_server.apps.gemini = true;\n        new_server.apps.opencode = true;\n    }\n\n    McpService::upsert_server(&state, new_server)\n        .map(|_| true)\n        .map_err(|e| e.to_string())\n}\n\n/// 在 config.json 中删除一个 MCP 服务器定义\n#[tauri::command]\npub async fn delete_mcp_server_in_config(\n    state: State<'_, AppState>,\n    _app: String, // 参数保留用于向后兼容，但在统一结构中不再需要\n    id: String,\n) -> Result<bool, String> {\n    McpService::delete_server(&state, &id).map_err(|e| e.to_string())\n}\n\n/// 设置启用状态并同步到客户端配置\n#[tauri::command]\n#[allow(deprecated)] // 兼容层命令，内部调用已废弃的 Service 方法\npub async fn set_mcp_enabled(\n    state: State<'_, AppState>,\n    app: String,\n    id: String,\n    enabled: bool,\n) -> Result<bool, String> {\n    let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?;\n    McpService::set_enabled(&state, app_ty, &id, enabled).map_err(|e| e.to_string())\n}\n\n// ============================================================================\n// v3.7.0 新增：统一 MCP 管理命令\n// ============================================================================\n\nuse crate::app_config::McpServer;\n\n/// 获取所有 MCP 服务器（统一结构）\n#[tauri::command]\npub async fn get_mcp_servers(\n    state: State<'_, AppState>,\n) -> Result<IndexMap<String, McpServer>, String> {\n    McpService::get_all_servers(&state).map_err(|e| e.to_string())\n}\n\n/// 添加或更新 MCP 服务器\n#[tauri::command]\npub async fn upsert_mcp_server(\n    state: State<'_, AppState>,\n    server: McpServer,\n) -> Result<(), String> {\n    McpService::upsert_server(&state, server).map_err(|e| e.to_string())\n}\n\n/// 删除 MCP 服务器\n#[tauri::command]\npub async fn delete_mcp_server(state: State<'_, AppState>, id: String) -> Result<bool, String> {\n    McpService::delete_server(&state, &id).map_err(|e| e.to_string())\n}\n\n/// 切换 MCP 服务器在指定应用的启用状态\n#[tauri::command]\npub async fn toggle_mcp_app(\n    state: State<'_, AppState>,\n    server_id: String,\n    app: String,\n    enabled: bool,\n) -> Result<(), String> {\n    let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?;\n    McpService::toggle_app(&state, &server_id, app_ty, enabled).map_err(|e| e.to_string())\n}\n\n/// 从所有应用导入 MCP 服务器（复用已有的导入逻辑）\n#[tauri::command]\npub async fn import_mcp_from_apps(state: State<'_, AppState>) -> Result<usize, String> {\n    let mut total = 0;\n    total += McpService::import_from_claude(&state).unwrap_or(0);\n    total += McpService::import_from_codex(&state).unwrap_or(0);\n    total += McpService::import_from_gemini(&state).unwrap_or(0);\n    total += McpService::import_from_opencode(&state).unwrap_or(0);\n    Ok(total)\n}\n"
  },
  {
    "path": "src-tauri/src/commands/misc.rs",
    "content": "#![allow(non_snake_case)]\n\nuse crate::app_config::AppType;\nuse crate::init_status::{InitErrorPayload, SkillsMigrationPayload};\nuse crate::services::ProviderService;\nuse once_cell::sync::Lazy;\nuse regex::Regex;\nuse std::collections::HashMap;\nuse std::path::Path;\nuse std::str::FromStr;\nuse tauri::AppHandle;\nuse tauri::State;\nuse tauri_plugin_opener::OpenerExt;\n\n#[cfg(target_os = \"windows\")]\nuse std::os::windows::process::CommandExt;\n\n#[cfg(target_os = \"windows\")]\nconst CREATE_NO_WINDOW: u32 = 0x08000000;\n\n/// 打开外部链接\n#[tauri::command]\npub async fn open_external(app: AppHandle, url: String) -> Result<bool, String> {\n    let url = if url.starts_with(\"http://\") || url.starts_with(\"https://\") {\n        url\n    } else {\n        format!(\"https://{url}\")\n    };\n\n    app.opener()\n        .open_url(&url, None::<String>)\n        .map_err(|e| format!(\"打开链接失败: {e}\"))?;\n\n    Ok(true)\n}\n\n/// 检查更新\n#[tauri::command]\npub async fn check_for_updates(handle: AppHandle) -> Result<bool, String> {\n    handle\n        .opener()\n        .open_url(\n            \"https://github.com/farion1231/cc-switch/releases/latest\",\n            None::<String>,\n        )\n        .map_err(|e| format!(\"打开更新页面失败: {e}\"))?;\n\n    Ok(true)\n}\n\n/// 判断是否为便携版（绿色版）运行\n#[tauri::command]\npub async fn is_portable_mode() -> Result<bool, String> {\n    let exe_path = std::env::current_exe().map_err(|e| format!(\"获取可执行路径失败: {e}\"))?;\n    if let Some(dir) = exe_path.parent() {\n        Ok(dir.join(\"portable.ini\").is_file())\n    } else {\n        Ok(false)\n    }\n}\n\n/// 获取应用启动阶段的初始化错误（若有）。\n/// 用于前端在早期主动拉取，避免事件订阅竞态导致的提示缺失。\n#[tauri::command]\npub async fn get_init_error() -> Result<Option<InitErrorPayload>, String> {\n    Ok(crate::init_status::get_init_error())\n}\n\n/// 获取 JSON→SQLite 迁移结果（若有）。\n/// 只返回一次 true，之后返回 false，用于前端显示一次性 Toast 通知。\n#[tauri::command]\npub async fn get_migration_result() -> Result<bool, String> {\n    Ok(crate::init_status::take_migration_success())\n}\n\n/// 获取 Skills 自动导入（SSOT）迁移结果（若有）。\n/// 只返回一次 Some({count})，之后返回 None，用于前端显示一次性 Toast 通知。\n#[tauri::command]\npub async fn get_skills_migration_result() -> Result<Option<SkillsMigrationPayload>, String> {\n    Ok(crate::init_status::take_skills_migration_result())\n}\n\n#[derive(serde::Serialize)]\npub struct ToolVersion {\n    name: String,\n    version: Option<String>,\n    latest_version: Option<String>, // 新增字段：最新版本\n    error: Option<String>,\n    /// 工具运行环境: \"windows\", \"wsl\", \"macos\", \"linux\", \"unknown\"\n    env_type: String,\n    /// 当 env_type 为 \"wsl\" 时，返回该工具绑定的 WSL distro（用于按 distro 探测 shells）\n    wsl_distro: Option<String>,\n}\n\nconst VALID_TOOLS: [&str; 4] = [\"claude\", \"codex\", \"gemini\", \"opencode\"];\n\n#[derive(Debug, Clone, serde::Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct WslShellPreferenceInput {\n    #[serde(default)]\n    pub wsl_shell: Option<String>,\n    #[serde(default)]\n    pub wsl_shell_flag: Option<String>,\n}\n\n// Keep platform-specific env detection in one place to avoid repeating cfg blocks.\n#[cfg(target_os = \"windows\")]\nfn tool_env_type_and_wsl_distro(tool: &str) -> (String, Option<String>) {\n    if let Some(distro) = wsl_distro_for_tool(tool) {\n        (\"wsl\".to_string(), Some(distro))\n    } else {\n        (\"windows\".to_string(), None)\n    }\n}\n\n#[cfg(target_os = \"macos\")]\nfn tool_env_type_and_wsl_distro(_tool: &str) -> (String, Option<String>) {\n    (\"macos\".to_string(), None)\n}\n\n#[cfg(target_os = \"linux\")]\nfn tool_env_type_and_wsl_distro(_tool: &str) -> (String, Option<String>) {\n    (\"linux\".to_string(), None)\n}\n\n#[cfg(not(any(target_os = \"windows\", target_os = \"macos\", target_os = \"linux\")))]\nfn tool_env_type_and_wsl_distro(_tool: &str) -> (String, Option<String>) {\n    (\"unknown\".to_string(), None)\n}\n\n#[tauri::command]\npub async fn get_tool_versions(\n    tools: Option<Vec<String>>,\n    wsl_shell_by_tool: Option<HashMap<String, WslShellPreferenceInput>>,\n) -> Result<Vec<ToolVersion>, String> {\n    // Windows: completely disable tool version detection to prevent\n    // accidentally launching apps (e.g. Claude Code) via protocol handlers.\n    #[cfg(target_os = \"windows\")]\n    {\n        let _ = (tools, wsl_shell_by_tool);\n        return Ok(Vec::new());\n    }\n\n    #[cfg(not(target_os = \"windows\"))]\n    {\n        let requested: Vec<&str> = if let Some(tools) = tools.as_ref() {\n            let set: std::collections::HashSet<&str> = tools.iter().map(|s| s.as_str()).collect();\n            VALID_TOOLS\n                .iter()\n                .copied()\n                .filter(|t| set.contains(t))\n                .collect()\n        } else {\n            VALID_TOOLS.to_vec()\n        };\n        let mut results = Vec::new();\n\n        for tool in requested {\n            let pref = wsl_shell_by_tool.as_ref().and_then(|m| m.get(tool));\n            let tool_wsl_shell = pref.and_then(|p| p.wsl_shell.as_deref());\n            let tool_wsl_shell_flag = pref.and_then(|p| p.wsl_shell_flag.as_deref());\n\n            results.push(\n                get_single_tool_version_impl(tool, tool_wsl_shell, tool_wsl_shell_flag).await,\n            );\n        }\n\n        Ok(results)\n    }\n}\n\n/// 获取单个工具的版本信息（内部实现）\nasync fn get_single_tool_version_impl(\n    tool: &str,\n    wsl_shell: Option<&str>,\n    wsl_shell_flag: Option<&str>,\n) -> ToolVersion {\n    debug_assert!(\n        VALID_TOOLS.contains(&tool),\n        \"unexpected tool name in get_single_tool_version_impl: {tool}\"\n    );\n\n    // 判断该工具的运行环境 & WSL distro（如有）\n    let (env_type, wsl_distro) = tool_env_type_and_wsl_distro(tool);\n\n    // 使用全局 HTTP 客户端（已包含代理配置）\n    let client = crate::proxy::http_client::get();\n\n    // 1. 获取本地版本\n    let (local_version, local_error) = if let Some(distro) = wsl_distro.as_deref() {\n        try_get_version_wsl(tool, distro, wsl_shell, wsl_shell_flag)\n    } else {\n        let direct_result = try_get_version(tool);\n        if direct_result.0.is_some() {\n            direct_result\n        } else {\n            scan_cli_version(tool)\n        }\n    };\n\n    // 2. 获取远程最新版本\n    let latest_version = match tool {\n        \"claude\" => fetch_npm_latest_version(&client, \"@anthropic-ai/claude-code\").await,\n        \"codex\" => fetch_npm_latest_version(&client, \"@openai/codex\").await,\n        \"gemini\" => fetch_npm_latest_version(&client, \"@google/gemini-cli\").await,\n        \"opencode\" => fetch_github_latest_version(&client, \"anomalyco/opencode\").await,\n        _ => None,\n    };\n\n    ToolVersion {\n        name: tool.to_string(),\n        version: local_version,\n        latest_version,\n        error: local_error,\n        env_type,\n        wsl_distro,\n    }\n}\n\n/// Helper function to fetch latest version from npm registry\nasync fn fetch_npm_latest_version(client: &reqwest::Client, package: &str) -> Option<String> {\n    let url = format!(\"https://registry.npmjs.org/{package}\");\n    match client.get(&url).send().await {\n        Ok(resp) => {\n            if let Ok(json) = resp.json::<serde_json::Value>().await {\n                json.get(\"dist-tags\")\n                    .and_then(|tags| tags.get(\"latest\"))\n                    .and_then(|v| v.as_str())\n                    .map(|s| s.to_string())\n            } else {\n                None\n            }\n        }\n        Err(_) => None,\n    }\n}\n\n/// Helper function to fetch latest version from GitHub releases\nasync fn fetch_github_latest_version(client: &reqwest::Client, repo: &str) -> Option<String> {\n    let url = format!(\"https://api.github.com/repos/{repo}/releases/latest\");\n    match client\n        .get(&url)\n        .header(\"User-Agent\", \"cc-switch\")\n        .header(\"Accept\", \"application/vnd.github+json\")\n        .send()\n        .await\n    {\n        Ok(resp) => {\n            if let Ok(json) = resp.json::<serde_json::Value>().await {\n                json.get(\"tag_name\")\n                    .and_then(|v| v.as_str())\n                    .map(|s| s.strip_prefix('v').unwrap_or(s).to_string())\n            } else {\n                None\n            }\n        }\n        Err(_) => None,\n    }\n}\n\n/// 预编译的版本号正则表达式\nstatic VERSION_RE: Lazy<Regex> =\n    Lazy::new(|| Regex::new(r\"\\d+\\.\\d+\\.\\d+(-[\\w.]+)?\").expect(\"Invalid version regex\"));\n\n/// 从版本输出中提取纯版本号\nfn extract_version(raw: &str) -> String {\n    VERSION_RE\n        .find(raw)\n        .map(|m| m.as_str().to_string())\n        .unwrap_or_else(|| raw.to_string())\n}\n\n/// 尝试直接执行命令获取版本\nfn try_get_version(tool: &str) -> (Option<String>, Option<String>) {\n    use std::process::Command;\n\n    #[cfg(target_os = \"windows\")]\n    let output = {\n        Command::new(\"cmd\")\n            .args([\"/C\", &format!(\"{tool} --version\")])\n            .creation_flags(CREATE_NO_WINDOW)\n            .output()\n    };\n\n    #[cfg(not(target_os = \"windows\"))]\n    let output = {\n        Command::new(\"sh\")\n            .arg(\"-c\")\n            .arg(format!(\"{tool} --version\"))\n            .output()\n    };\n\n    match output {\n        Ok(out) => {\n            let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();\n            let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();\n            if out.status.success() {\n                let raw = if stdout.is_empty() { &stderr } else { &stdout };\n                if raw.is_empty() {\n                    (None, Some(\"not installed or not executable\".to_string()))\n                } else {\n                    (Some(extract_version(raw)), None)\n                }\n            } else {\n                let err = if stderr.is_empty() { stdout } else { stderr };\n                (\n                    None,\n                    Some(if err.is_empty() {\n                        \"not installed or not executable\".to_string()\n                    } else {\n                        err\n                    }),\n                )\n            }\n        }\n        Err(e) => (None, Some(e.to_string())),\n    }\n}\n\n/// 校验 WSL 发行版名称是否合法\n/// WSL 发行版名称只允许字母、数字、连字符和下划线\n#[cfg(target_os = \"windows\")]\nfn is_valid_wsl_distro_name(name: &str) -> bool {\n    !name.is_empty()\n        && name.len() <= 64\n        && name\n            .chars()\n            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')\n}\n\n/// Validate that the given shell name is one of the allowed shells.\n#[cfg(target_os = \"windows\")]\nfn is_valid_shell(shell: &str) -> bool {\n    matches!(\n        shell.rsplit('/').next().unwrap_or(shell),\n        \"sh\" | \"bash\" | \"zsh\" | \"fish\" | \"dash\"\n    )\n}\n\n/// Validate that the given shell flag is one of the allowed flags.\n#[cfg(target_os = \"windows\")]\nfn is_valid_shell_flag(flag: &str) -> bool {\n    matches!(flag, \"-c\" | \"-lc\" | \"-lic\")\n}\n\n/// Return the default invocation flag for the given shell.\n#[cfg(target_os = \"windows\")]\nfn default_flag_for_shell(shell: &str) -> &'static str {\n    match shell.rsplit('/').next().unwrap_or(shell) {\n        \"dash\" | \"sh\" => \"-c\",\n        \"fish\" => \"-lc\",\n        _ => \"-lic\",\n    }\n}\n\n#[cfg(target_os = \"windows\")]\nfn try_get_version_wsl(\n    tool: &str,\n    distro: &str,\n    force_shell: Option<&str>,\n    force_shell_flag: Option<&str>,\n) -> (Option<String>, Option<String>) {\n    use std::process::Command;\n\n    // 防御性断言：tool 只能是预定义的值\n    debug_assert!(\n        [\"claude\", \"codex\", \"gemini\", \"opencode\"].contains(&tool),\n        \"unexpected tool name: {tool}\"\n    );\n\n    // 校验 distro 名称，防止命令注入\n    if !is_valid_wsl_distro_name(distro) {\n        return (None, Some(format!(\"[WSL:{distro}] invalid distro name\")));\n    }\n\n    // 构建 Shell 脚本检测逻辑\n    let (shell, flag, cmd) = if let Some(shell) = force_shell {\n        // Defensive validation: never allow an arbitrary executable name here.\n        if !is_valid_shell(shell) {\n            return (None, Some(format!(\"[WSL:{distro}] invalid shell: {shell}\")));\n        }\n        let shell = shell.rsplit('/').next().unwrap_or(shell);\n        let flag = if let Some(flag) = force_shell_flag {\n            if !is_valid_shell_flag(flag) {\n                return (\n                    None,\n                    Some(format!(\"[WSL:{distro}] invalid shell flag: {flag}\")),\n                );\n            }\n            flag\n        } else {\n            default_flag_for_shell(shell)\n        };\n\n        (shell.to_string(), flag, format!(\"{tool} --version\"))\n    } else {\n        let cmd = if let Some(flag) = force_shell_flag {\n            if !is_valid_shell_flag(flag) {\n                return (\n                    None,\n                    Some(format!(\"[WSL:{distro}] invalid shell flag: {flag}\")),\n                );\n            }\n            format!(\"\\\"${{SHELL:-sh}}\\\" {flag} '{tool} --version'\")\n        } else {\n            // 兜底：自动尝试 -lic, -lc, -c\n            format!(\n                \"\\\"${{SHELL:-sh}}\\\" -lic '{tool} --version' 2>/dev/null || \\\"${{SHELL:-sh}}\\\" -lc '{tool} --version' 2>/dev/null || \\\"${{SHELL:-sh}}\\\" -c '{tool} --version'\"\n            )\n        };\n\n        (\"sh\".to_string(), \"-c\", cmd)\n    };\n\n    let output = Command::new(\"wsl.exe\")\n        .args([\"-d\", distro, \"--\", &shell, flag, &cmd])\n        .creation_flags(CREATE_NO_WINDOW)\n        .output();\n\n    match output {\n        Ok(out) => {\n            let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();\n            let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();\n            if out.status.success() {\n                let raw = if stdout.is_empty() { &stderr } else { &stdout };\n                if raw.is_empty() {\n                    (\n                        None,\n                        Some(format!(\"[WSL:{distro}] not installed or not executable\")),\n                    )\n                } else {\n                    (Some(extract_version(raw)), None)\n                }\n            } else {\n                let err = if stderr.is_empty() { stdout } else { stderr };\n                (\n                    None,\n                    Some(format!(\n                        \"[WSL:{distro}] {}\",\n                        if err.is_empty() {\n                            \"not installed or not executable\".to_string()\n                        } else {\n                            err\n                        }\n                    )),\n                )\n            }\n        }\n        Err(e) => (None, Some(format!(\"[WSL:{distro}] exec failed: {e}\"))),\n    }\n}\n\n/// 非 Windows 平台的 WSL 版本检测存根\n/// 注意：此函数实际上不会被调用，因为 `wsl_distro_from_path` 在非 Windows 平台总是返回 None。\n/// 保留此函数是为了保持 API 一致性，防止未来重构时遗漏。\n#[cfg(not(target_os = \"windows\"))]\nfn try_get_version_wsl(\n    _tool: &str,\n    _distro: &str,\n    _force_shell: Option<&str>,\n    _force_shell_flag: Option<&str>,\n) -> (Option<String>, Option<String>) {\n    (\n        None,\n        Some(\"WSL check not supported on this platform\".to_string()),\n    )\n}\n\nfn push_unique_path(paths: &mut Vec<std::path::PathBuf>, path: std::path::PathBuf) {\n    if path.as_os_str().is_empty() {\n        return;\n    }\n\n    if !paths.iter().any(|existing| existing == &path) {\n        paths.push(path);\n    }\n}\n\nfn push_env_single_dir(paths: &mut Vec<std::path::PathBuf>, value: Option<std::ffi::OsString>) {\n    if let Some(raw) = value {\n        push_unique_path(paths, std::path::PathBuf::from(raw));\n    }\n}\n\nfn extend_from_path_list(\n    paths: &mut Vec<std::path::PathBuf>,\n    value: Option<std::ffi::OsString>,\n    suffix: Option<&str>,\n) {\n    if let Some(raw) = value {\n        for p in std::env::split_paths(&raw) {\n            let dir = match suffix {\n                Some(s) => p.join(s),\n                None => p,\n            };\n            push_unique_path(paths, dir);\n        }\n    }\n}\n\n/// OpenCode install.sh 路径优先级（见 https://github.com/anomalyco/opencode README）:\n///   $OPENCODE_INSTALL_DIR > $XDG_BIN_DIR > $HOME/bin > $HOME/.opencode/bin\n/// 额外扫描 Go 安装路径（~/go/bin、$GOPATH/*/bin）。\nfn opencode_extra_search_paths(\n    home: &Path,\n    opencode_install_dir: Option<std::ffi::OsString>,\n    xdg_bin_dir: Option<std::ffi::OsString>,\n    gopath: Option<std::ffi::OsString>,\n) -> Vec<std::path::PathBuf> {\n    let mut paths = Vec::new();\n\n    push_env_single_dir(&mut paths, opencode_install_dir);\n    push_env_single_dir(&mut paths, xdg_bin_dir);\n\n    if !home.as_os_str().is_empty() {\n        push_unique_path(&mut paths, home.join(\"bin\"));\n        push_unique_path(&mut paths, home.join(\".opencode\").join(\"bin\"));\n        push_unique_path(&mut paths, home.join(\"go\").join(\"bin\"));\n    }\n\n    extend_from_path_list(&mut paths, gopath, Some(\"bin\"));\n\n    paths\n}\n\nfn tool_executable_candidates(tool: &str, dir: &Path) -> Vec<std::path::PathBuf> {\n    #[cfg(target_os = \"windows\")]\n    {\n        vec![\n            dir.join(format!(\"{tool}.cmd\")),\n            dir.join(format!(\"{tool}.exe\")),\n            dir.join(tool),\n        ]\n    }\n\n    #[cfg(not(target_os = \"windows\"))]\n    {\n        vec![dir.join(tool)]\n    }\n}\n\n/// 扫描常见路径查找 CLI\nfn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {\n    use std::process::Command;\n\n    let home = dirs::home_dir().unwrap_or_default();\n\n    // 常见的安装路径（原生安装优先）\n    let mut search_paths: Vec<std::path::PathBuf> = Vec::new();\n    if !home.as_os_str().is_empty() {\n        push_unique_path(&mut search_paths, home.join(\".local/bin\"));\n        push_unique_path(&mut search_paths, home.join(\".npm-global/bin\"));\n        push_unique_path(&mut search_paths, home.join(\"n/bin\"));\n        push_unique_path(&mut search_paths, home.join(\".volta/bin\"));\n    }\n\n    #[cfg(target_os = \"macos\")]\n    {\n        push_unique_path(\n            &mut search_paths,\n            std::path::PathBuf::from(\"/opt/homebrew/bin\"),\n        );\n        push_unique_path(\n            &mut search_paths,\n            std::path::PathBuf::from(\"/usr/local/bin\"),\n        );\n    }\n\n    #[cfg(target_os = \"linux\")]\n    {\n        push_unique_path(\n            &mut search_paths,\n            std::path::PathBuf::from(\"/usr/local/bin\"),\n        );\n        push_unique_path(&mut search_paths, std::path::PathBuf::from(\"/usr/bin\"));\n    }\n\n    #[cfg(target_os = \"windows\")]\n    {\n        if let Some(appdata) = dirs::data_dir() {\n            push_unique_path(&mut search_paths, appdata.join(\"npm\"));\n        }\n        push_unique_path(\n            &mut search_paths,\n            std::path::PathBuf::from(\"C:\\\\Program Files\\\\nodejs\"),\n        );\n    }\n\n    let fnm_base = home.join(\".local/state/fnm_multishells\");\n    if fnm_base.exists() {\n        if let Ok(entries) = std::fs::read_dir(&fnm_base) {\n            for entry in entries.flatten() {\n                let bin_path = entry.path().join(\"bin\");\n                if bin_path.exists() {\n                    push_unique_path(&mut search_paths, bin_path);\n                }\n            }\n        }\n    }\n\n    let nvm_base = home.join(\".nvm/versions/node\");\n    if nvm_base.exists() {\n        if let Ok(entries) = std::fs::read_dir(&nvm_base) {\n            for entry in entries.flatten() {\n                let bin_path = entry.path().join(\"bin\");\n                if bin_path.exists() {\n                    push_unique_path(&mut search_paths, bin_path);\n                }\n            }\n        }\n    }\n\n    if tool == \"opencode\" {\n        let extra_paths = opencode_extra_search_paths(\n            &home,\n            std::env::var_os(\"OPENCODE_INSTALL_DIR\"),\n            std::env::var_os(\"XDG_BIN_DIR\"),\n            std::env::var_os(\"GOPATH\"),\n        );\n\n        for path in extra_paths {\n            push_unique_path(&mut search_paths, path);\n        }\n    }\n\n    let current_path = std::env::var(\"PATH\").unwrap_or_default();\n\n    for path in &search_paths {\n        #[cfg(target_os = \"windows\")]\n        let new_path = format!(\"{};{}\", path.display(), current_path);\n\n        #[cfg(not(target_os = \"windows\"))]\n        let new_path = format!(\"{}:{}\", path.display(), current_path);\n\n        for tool_path in tool_executable_candidates(tool, path) {\n            if !tool_path.exists() {\n                continue;\n            }\n\n            #[cfg(target_os = \"windows\")]\n            let output = {\n                Command::new(\"cmd\")\n                    .args([\"/C\", &format!(\"\\\"{}\\\" --version\", tool_path.display())])\n                    .env(\"PATH\", &new_path)\n                    .creation_flags(CREATE_NO_WINDOW)\n                    .output()\n            };\n\n            #[cfg(not(target_os = \"windows\"))]\n            let output = {\n                Command::new(&tool_path)\n                    .arg(\"--version\")\n                    .env(\"PATH\", &new_path)\n                    .output()\n            };\n\n            if let Ok(out) = output {\n                let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();\n                let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();\n                if out.status.success() {\n                    let raw = if stdout.is_empty() { &stderr } else { &stdout };\n                    if !raw.is_empty() {\n                        return (Some(extract_version(raw)), None);\n                    }\n                }\n            }\n        }\n    }\n\n    (None, Some(\"not installed or not executable\".to_string()))\n}\n\n#[cfg(target_os = \"windows\")]\nfn wsl_distro_for_tool(tool: &str) -> Option<String> {\n    let override_dir = match tool {\n        \"claude\" => crate::settings::get_claude_override_dir(),\n        \"codex\" => crate::settings::get_codex_override_dir(),\n        \"gemini\" => crate::settings::get_gemini_override_dir(),\n        \"opencode\" => crate::settings::get_opencode_override_dir(),\n        _ => None,\n    }?;\n\n    wsl_distro_from_path(&override_dir)\n}\n\n/// 从 UNC 路径中提取 WSL 发行版名称\n/// 支持 `\\\\wsl$\\Ubuntu\\...` 和 `\\\\wsl.localhost\\Ubuntu\\...` 两种格式\n#[cfg(target_os = \"windows\")]\nfn wsl_distro_from_path(path: &Path) -> Option<String> {\n    use std::path::{Component, Prefix};\n    let Some(Component::Prefix(prefix)) = path.components().next() else {\n        return None;\n    };\n    match prefix.kind() {\n        Prefix::UNC(server, share) | Prefix::VerbatimUNC(server, share) => {\n            let server_name = server.to_string_lossy();\n            if server_name.eq_ignore_ascii_case(\"wsl$\")\n                || server_name.eq_ignore_ascii_case(\"wsl.localhost\")\n            {\n                let distro = share.to_string_lossy().to_string();\n                if !distro.is_empty() {\n                    return Some(distro);\n                }\n            }\n            None\n        }\n        _ => None,\n    }\n}\n\n/// 打开指定提供商的终端\n///\n/// 根据提供商配置的环境变量启动一个带有该提供商特定设置的终端\n/// 无需检查是否为当前激活的提供商，任何提供商都可以打开终端\n#[allow(non_snake_case)]\n#[tauri::command]\npub async fn open_provider_terminal(\n    state: State<'_, crate::store::AppState>,\n    app: String,\n    #[allow(non_snake_case)] providerId: String,\n) -> Result<bool, String> {\n    let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;\n\n    // 获取提供商配置\n    let providers = ProviderService::list(state.inner(), app_type.clone())\n        .map_err(|e| format!(\"获取提供商列表失败: {e}\"))?;\n\n    let provider = providers\n        .get(&providerId)\n        .ok_or_else(|| format!(\"提供商 {providerId} 不存在\"))?;\n\n    // 从提供商配置中提取环境变量\n    let config = &provider.settings_config;\n    let env_vars = extract_env_vars_from_config(config, &app_type);\n\n    // 根据平台启动终端，传入提供商ID用于生成唯一的配置文件名\n    launch_terminal_with_env(env_vars, &providerId).map_err(|e| format!(\"启动终端失败: {e}\"))?;\n\n    Ok(true)\n}\n\n/// 从提供商配置中提取环境变量\nfn extract_env_vars_from_config(\n    config: &serde_json::Value,\n    app_type: &AppType,\n) -> Vec<(String, String)> {\n    let mut env_vars = Vec::new();\n\n    let Some(obj) = config.as_object() else {\n        return env_vars;\n    };\n\n    // 处理 env 字段（Claude/Gemini 通用）\n    if let Some(env) = obj.get(\"env\").and_then(|v| v.as_object()) {\n        for (key, value) in env {\n            if let Some(str_val) = value.as_str() {\n                env_vars.push((key.clone(), str_val.to_string()));\n            }\n        }\n\n        // 处理 base_url: 根据应用类型添加对应的环境变量\n        let base_url_key = match app_type {\n            AppType::Claude => Some(\"ANTHROPIC_BASE_URL\"),\n            AppType::Gemini => Some(\"GOOGLE_GEMINI_BASE_URL\"),\n            _ => None,\n        };\n\n        if let Some(key) = base_url_key {\n            if let Some(url_str) = env.get(key).and_then(|v| v.as_str()) {\n                env_vars.push((key.to_string(), url_str.to_string()));\n            }\n        }\n    }\n\n    // Codex 使用 auth 字段转换为 OPENAI_API_KEY\n    if *app_type == AppType::Codex {\n        if let Some(auth) = obj.get(\"auth\").and_then(|v| v.as_str()) {\n            env_vars.push((\"OPENAI_API_KEY\".to_string(), auth.to_string()));\n        }\n    }\n\n    // Gemini 使用 api_key 字段转换为 GEMINI_API_KEY\n    if *app_type == AppType::Gemini {\n        if let Some(api_key) = obj.get(\"api_key\").and_then(|v| v.as_str()) {\n            env_vars.push((\"GEMINI_API_KEY\".to_string(), api_key.to_string()));\n        }\n    }\n\n    env_vars\n}\n\n/// 创建临时配置文件并启动 claude 终端\n/// 使用 --settings 参数传入提供商特定的 API 配置\nfn launch_terminal_with_env(\n    env_vars: Vec<(String, String)>,\n    provider_id: &str,\n) -> Result<(), String> {\n    let temp_dir = std::env::temp_dir();\n    let config_file = temp_dir.join(format!(\n        \"claude_{}_{}.json\",\n        provider_id,\n        std::process::id()\n    ));\n\n    // 创建并写入配置文件\n    write_claude_config(&config_file, &env_vars)?;\n\n    #[cfg(target_os = \"macos\")]\n    {\n        launch_macos_terminal(&config_file)?;\n        Ok(())\n    }\n\n    #[cfg(target_os = \"linux\")]\n    {\n        launch_linux_terminal(&config_file)?;\n        Ok(())\n    }\n\n    #[cfg(target_os = \"windows\")]\n    {\n        launch_windows_terminal(&temp_dir, &config_file)?;\n        return Ok(());\n    }\n\n    #[cfg(not(any(target_os = \"macos\", target_os = \"linux\", target_os = \"windows\")))]\n    Err(\"不支持的操作系统\".to_string())\n}\n\n/// 写入 claude 配置文件\nfn write_claude_config(\n    config_file: &std::path::Path,\n    env_vars: &[(String, String)],\n) -> Result<(), String> {\n    let mut config_obj = serde_json::Map::new();\n    let mut env_obj = serde_json::Map::new();\n\n    for (key, value) in env_vars {\n        env_obj.insert(key.clone(), serde_json::Value::String(value.clone()));\n    }\n\n    config_obj.insert(\"env\".to_string(), serde_json::Value::Object(env_obj));\n\n    let config_json =\n        serde_json::to_string_pretty(&config_obj).map_err(|e| format!(\"序列化配置失败: {e}\"))?;\n\n    std::fs::write(config_file, config_json).map_err(|e| format!(\"写入配置文件失败: {e}\"))\n}\n\n/// macOS: 根据用户首选终端启动\n#[cfg(target_os = \"macos\")]\nfn launch_macos_terminal(config_file: &std::path::Path) -> Result<(), String> {\n    use std::os::unix::fs::PermissionsExt;\n\n    let preferred = crate::settings::get_preferred_terminal();\n    let terminal = preferred.as_deref().unwrap_or(\"terminal\");\n\n    let temp_dir = std::env::temp_dir();\n    let script_file = temp_dir.join(format!(\"cc_switch_launcher_{}.sh\", std::process::id()));\n    let config_path = config_file.to_string_lossy();\n\n    // Write the shell script to a temp file\n    let script_content = format!(\n        r#\"#!/bin/bash\ntrap 'rm -f \"{config_path}\" \"{script_file}\"' EXIT\necho \"Using provider-specific claude config:\"\necho \"{config_path}\"\nclaude --settings \"{config_path}\"\nexec bash --norc --noprofile\n\"#,\n        config_path = config_path,\n        script_file = script_file.display()\n    );\n\n    std::fs::write(&script_file, &script_content).map_err(|e| format!(\"写入启动脚本失败: {e}\"))?;\n\n    // Make script executable\n    std::fs::set_permissions(&script_file, std::fs::Permissions::from_mode(0o755))\n        .map_err(|e| format!(\"设置脚本权限失败: {e}\"))?;\n\n    // Try the preferred terminal first, fall back to Terminal.app if it fails\n    // Note: Kitty doesn't need the -e flag, others do\n    let result = match terminal {\n        \"iterm2\" => launch_macos_iterm2(&script_file),\n        \"alacritty\" => launch_macos_open_app(\"Alacritty\", &script_file, true),\n        \"kitty\" => launch_macos_open_app(\"kitty\", &script_file, false),\n        \"ghostty\" => launch_macos_open_app(\"Ghostty\", &script_file, true),\n        \"wezterm\" => launch_macos_open_app(\"WezTerm\", &script_file, true),\n        _ => launch_macos_terminal_app(&script_file), // \"terminal\" or default\n    };\n\n    // If preferred terminal fails and it's not the default, try Terminal.app as fallback\n    if result.is_err() && terminal != \"terminal\" {\n        log::warn!(\n            \"首选终端 {} 启动失败，回退到 Terminal.app: {:?}\",\n            terminal,\n            result.as_ref().err()\n        );\n        return launch_macos_terminal_app(&script_file);\n    }\n\n    result\n}\n\n/// macOS: Terminal.app\n#[cfg(target_os = \"macos\")]\nfn launch_macos_terminal_app(script_file: &std::path::Path) -> Result<(), String> {\n    use std::process::Command;\n\n    let applescript = format!(\n        r#\"tell application \"Terminal\"\n    activate\n    do script \"bash '{}'\"\nend tell\"#,\n        script_file.display()\n    );\n\n    let output = Command::new(\"osascript\")\n        .arg(\"-e\")\n        .arg(&applescript)\n        .output()\n        .map_err(|e| format!(\"执行 osascript 失败: {e}\"))?;\n\n    if !output.status.success() {\n        let stderr = String::from_utf8_lossy(&output.stderr);\n        return Err(format!(\n            \"Terminal.app 执行失败 (exit code: {:?}): {}\",\n            output.status.code(),\n            stderr\n        ));\n    }\n\n    Ok(())\n}\n\n/// macOS: iTerm2\n#[cfg(target_os = \"macos\")]\nfn launch_macos_iterm2(script_file: &std::path::Path) -> Result<(), String> {\n    use std::process::Command;\n\n    let applescript = format!(\n        r#\"tell application \"iTerm\"\n    activate\n    tell current window\n        create tab with default profile\n        tell current session\n            write text \"bash '{}'\"\n        end tell\n    end tell\nend tell\"#,\n        script_file.display()\n    );\n\n    let output = Command::new(\"osascript\")\n        .arg(\"-e\")\n        .arg(&applescript)\n        .output()\n        .map_err(|e| format!(\"执行 osascript 失败: {e}\"))?;\n\n    if !output.status.success() {\n        let stderr = String::from_utf8_lossy(&output.stderr);\n        return Err(format!(\n            \"iTerm2 执行失败 (exit code: {:?}): {}\",\n            output.status.code(),\n            stderr\n        ));\n    }\n\n    Ok(())\n}\n\n/// macOS: 使用 open -a 启动支持 --args 参数的终端（Alacritty/Kitty/Ghostty）\n#[cfg(target_os = \"macos\")]\nfn launch_macos_open_app(\n    app_name: &str,\n    script_file: &std::path::Path,\n    use_e_flag: bool,\n) -> Result<(), String> {\n    use std::process::Command;\n\n    let mut cmd = Command::new(\"open\");\n    cmd.arg(\"-a\").arg(app_name).arg(\"--args\");\n\n    if use_e_flag {\n        cmd.arg(\"-e\");\n    }\n    cmd.arg(\"bash\").arg(script_file);\n\n    let output = cmd\n        .output()\n        .map_err(|e| format!(\"启动 {app_name} 失败: {e}\"))?;\n\n    if !output.status.success() {\n        let stderr = String::from_utf8_lossy(&output.stderr);\n        return Err(format!(\n            \"{} 启动失败 (exit code: {:?}): {}\",\n            app_name,\n            output.status.code(),\n            stderr\n        ));\n    }\n\n    Ok(())\n}\n\n/// Linux: 根据用户首选终端启动\n#[cfg(target_os = \"linux\")]\nfn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> {\n    use std::os::unix::fs::PermissionsExt;\n    use std::process::Command;\n\n    let preferred = crate::settings::get_preferred_terminal();\n\n    // Default terminal list with their arguments\n    let default_terminals = [\n        (\"gnome-terminal\", vec![\"--\"]),\n        (\"konsole\", vec![\"-e\"]),\n        (\"xfce4-terminal\", vec![\"-e\"]),\n        (\"mate-terminal\", vec![\"--\"]),\n        (\"lxterminal\", vec![\"-e\"]),\n        (\"alacritty\", vec![\"-e\"]),\n        (\"kitty\", vec![\"-e\"]),\n        (\"ghostty\", vec![\"-e\"]),\n    ];\n\n    // Create temp script file\n    let temp_dir = std::env::temp_dir();\n    let script_file = temp_dir.join(format!(\"cc_switch_launcher_{}.sh\", std::process::id()));\n    let config_path = config_file.to_string_lossy();\n\n    let script_content = format!(\n        r#\"#!/bin/bash\ntrap 'rm -f \"{config_path}\" \"{script_file}\"' EXIT\necho \"Using provider-specific claude config:\"\necho \"{config_path}\"\nclaude --settings \"{config_path}\"\nexec bash --norc --noprofile\n\"#,\n        config_path = config_path,\n        script_file = script_file.display()\n    );\n\n    std::fs::write(&script_file, &script_content).map_err(|e| format!(\"写入启动脚本失败: {e}\"))?;\n\n    std::fs::set_permissions(&script_file, std::fs::Permissions::from_mode(0o755))\n        .map_err(|e| format!(\"设置脚本权限失败: {e}\"))?;\n\n    // Build terminal list: preferred terminal first (if specified), then defaults\n    let terminals_to_try: Vec<(&str, Vec<&str>)> = if let Some(ref pref) = preferred {\n        // Find the preferred terminal's args from default list\n        let pref_args = default_terminals\n            .iter()\n            .find(|(name, _)| *name == pref.as_str())\n            .map(|(_, args)| args.iter().map(|s| *s).collect::<Vec<&str>>())\n            .unwrap_or_else(|| vec![\"-e\"]); // Default args for unknown terminals\n\n        let mut list = vec![(pref.as_str(), pref_args)];\n        // Add remaining terminals as fallbacks\n        for (name, args) in &default_terminals {\n            if *name != pref.as_str() {\n                list.push((*name, args.iter().map(|s| *s).collect()));\n            }\n        }\n        list\n    } else {\n        default_terminals\n            .iter()\n            .map(|(name, args)| (*name, args.iter().map(|s| *s).collect()))\n            .collect()\n    };\n\n    let mut last_error = String::from(\"未找到可用的终端\");\n\n    for (terminal, args) in terminals_to_try {\n        // Check if terminal exists in common paths\n        let terminal_exists = std::path::Path::new(&format!(\"/usr/bin/{}\", terminal)).exists()\n            || std::path::Path::new(&format!(\"/bin/{}\", terminal)).exists()\n            || std::path::Path::new(&format!(\"/usr/local/bin/{}\", terminal)).exists()\n            || which_command(terminal);\n\n        if terminal_exists {\n            let result = Command::new(terminal)\n                .args(&args)\n                .arg(\"bash\")\n                .arg(script_file.to_string_lossy().as_ref())\n                .spawn();\n\n            match result {\n                Ok(_) => return Ok(()),\n                Err(e) => {\n                    last_error = format!(\"执行 {} 失败: {}\", terminal, e);\n                }\n            }\n        }\n    }\n\n    // Clean up on failure\n    let _ = std::fs::remove_file(&script_file);\n    let _ = std::fs::remove_file(config_file);\n    Err(last_error)\n}\n\n/// Check if a command exists using `which`\n#[cfg(target_os = \"linux\")]\nfn which_command(cmd: &str) -> bool {\n    use std::process::Command;\n    Command::new(\"which\")\n        .arg(cmd)\n        .output()\n        .map(|o| o.status.success())\n        .unwrap_or(false)\n}\n\n/// Windows: 根据用户首选终端启动\n#[cfg(target_os = \"windows\")]\nfn launch_windows_terminal(\n    temp_dir: &std::path::Path,\n    config_file: &std::path::Path,\n) -> Result<(), String> {\n    let preferred = crate::settings::get_preferred_terminal();\n    let terminal = preferred.as_deref().unwrap_or(\"cmd\");\n\n    let bat_file = temp_dir.join(format!(\"cc_switch_claude_{}.bat\", std::process::id()));\n    let config_path_for_batch = config_file.to_string_lossy().replace('&', \"^&\");\n\n    let content = format!(\n        \"@echo off\necho Using provider-specific claude config:\necho {}\nclaude --settings \\\"{}\\\"\ndel \\\"{}\\\" >nul 2>&1\ndel \\\"%~f0\\\" >nul 2>&1\n\",\n        config_path_for_batch, config_path_for_batch, config_path_for_batch\n    );\n\n    std::fs::write(&bat_file, &content).map_err(|e| format!(\"写入批处理文件失败: {e}\"))?;\n\n    let bat_path = bat_file.to_string_lossy();\n    let ps_cmd = format!(\"& '{}'\", bat_path);\n\n    // Try the preferred terminal first\n    let result = match terminal {\n        \"powershell\" => run_windows_start_command(\n            &[\"powershell\", \"-NoExit\", \"-Command\", &ps_cmd],\n            \"PowerShell\",\n        ),\n        \"wt\" => run_windows_start_command(&[\"wt\", \"cmd\", \"/K\", &bat_path], \"Windows Terminal\"),\n        _ => run_windows_start_command(&[\"cmd\", \"/K\", &bat_path], \"cmd\"), // \"cmd\" or default\n    };\n\n    // If preferred terminal fails and it's not the default, try cmd as fallback\n    if result.is_err() && terminal != \"cmd\" {\n        log::warn!(\n            \"首选终端 {} 启动失败，回退到 cmd: {:?}\",\n            terminal,\n            result.as_ref().err()\n        );\n        return run_windows_start_command(&[\"cmd\", \"/K\", &bat_path], \"cmd\");\n    }\n\n    result\n}\n\n/// Windows: Run a start command with common error handling\n#[cfg(target_os = \"windows\")]\nfn run_windows_start_command(args: &[&str], terminal_name: &str) -> Result<(), String> {\n    use std::process::Command;\n\n    let mut full_args = vec![\"/C\", \"start\"];\n    full_args.extend(args);\n\n    let output = Command::new(\"cmd\")\n        .args(&full_args)\n        .creation_flags(CREATE_NO_WINDOW)\n        .output()\n        .map_err(|e| format!(\"启动 {} 失败: {e}\", terminal_name))?;\n\n    if !output.status.success() {\n        let stderr = String::from_utf8_lossy(&output.stderr);\n        return Err(format!(\n            \"{} 启动失败 (exit code: {:?}): {}\",\n            terminal_name,\n            output.status.code(),\n            stderr\n        ));\n    }\n\n    Ok(())\n}\n\n/// 设置窗口主题（Windows/macOS 标题栏颜色）\n/// theme: \"dark\" | \"light\" | \"system\"\n#[tauri::command]\npub async fn set_window_theme(window: tauri::Window, theme: String) -> Result<(), String> {\n    use tauri::Theme;\n\n    let tauri_theme = match theme.as_str() {\n        \"dark\" => Some(Theme::Dark),\n        \"light\" => Some(Theme::Light),\n        _ => None, // system default\n    };\n\n    window.set_theme(tauri_theme).map_err(|e| e.to_string())\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use std::path::PathBuf;\n\n    #[test]\n    fn test_extract_version() {\n        assert_eq!(extract_version(\"claude 1.0.20\"), \"1.0.20\");\n        assert_eq!(extract_version(\"v2.3.4-beta.1\"), \"2.3.4-beta.1\");\n        assert_eq!(extract_version(\"no version here\"), \"no version here\");\n    }\n\n    #[cfg(target_os = \"windows\")]\n    mod wsl_helpers {\n        use super::super::*;\n\n        #[test]\n        fn test_is_valid_shell() {\n            assert!(is_valid_shell(\"bash\"));\n            assert!(is_valid_shell(\"zsh\"));\n            assert!(is_valid_shell(\"sh\"));\n            assert!(is_valid_shell(\"fish\"));\n            assert!(is_valid_shell(\"dash\"));\n            assert!(is_valid_shell(\"/usr/bin/bash\"));\n            assert!(is_valid_shell(\"/bin/zsh\"));\n            assert!(!is_valid_shell(\"powershell\"));\n            assert!(!is_valid_shell(\"cmd\"));\n            assert!(!is_valid_shell(\"\"));\n        }\n\n        #[test]\n        fn test_is_valid_shell_flag() {\n            assert!(is_valid_shell_flag(\"-c\"));\n            assert!(is_valid_shell_flag(\"-lc\"));\n            assert!(is_valid_shell_flag(\"-lic\"));\n            assert!(!is_valid_shell_flag(\"-x\"));\n            assert!(!is_valid_shell_flag(\"\"));\n            assert!(!is_valid_shell_flag(\"--login\"));\n        }\n\n        #[test]\n        fn test_default_flag_for_shell() {\n            assert_eq!(default_flag_for_shell(\"sh\"), \"-c\");\n            assert_eq!(default_flag_for_shell(\"dash\"), \"-c\");\n            assert_eq!(default_flag_for_shell(\"/bin/dash\"), \"-c\");\n            assert_eq!(default_flag_for_shell(\"fish\"), \"-lc\");\n            assert_eq!(default_flag_for_shell(\"bash\"), \"-lic\");\n            assert_eq!(default_flag_for_shell(\"zsh\"), \"-lic\");\n            assert_eq!(default_flag_for_shell(\"/usr/bin/zsh\"), \"-lic\");\n        }\n\n        #[test]\n        fn test_is_valid_wsl_distro_name() {\n            assert!(is_valid_wsl_distro_name(\"Ubuntu\"));\n            assert!(is_valid_wsl_distro_name(\"Ubuntu-22.04\"));\n            assert!(is_valid_wsl_distro_name(\"my_distro\"));\n            assert!(!is_valid_wsl_distro_name(\"\"));\n            assert!(!is_valid_wsl_distro_name(\"distro with spaces\"));\n            assert!(!is_valid_wsl_distro_name(&\"a\".repeat(65)));\n        }\n    }\n\n    #[test]\n    fn opencode_extra_search_paths_includes_install_and_fallback_dirs() {\n        let home = PathBuf::from(\"/home/tester\");\n        let install_dir = Some(std::ffi::OsString::from(\"/custom/opencode/bin\"));\n        let xdg_bin_dir = Some(std::ffi::OsString::from(\"/xdg/bin\"));\n        let gopath =\n            std::env::join_paths([PathBuf::from(\"/go/path1\"), PathBuf::from(\"/go/path2\")]).ok();\n\n        let paths = opencode_extra_search_paths(&home, install_dir, xdg_bin_dir, gopath);\n\n        assert_eq!(paths[0], PathBuf::from(\"/custom/opencode/bin\"));\n        assert_eq!(paths[1], PathBuf::from(\"/xdg/bin\"));\n        assert!(paths.contains(&PathBuf::from(\"/home/tester/bin\")));\n        assert!(paths.contains(&PathBuf::from(\"/home/tester/.opencode/bin\")));\n        assert!(paths.contains(&PathBuf::from(\"/home/tester/go/bin\")));\n        assert!(paths.contains(&PathBuf::from(\"/go/path1/bin\")));\n        assert!(paths.contains(&PathBuf::from(\"/go/path2/bin\")));\n    }\n\n    #[test]\n    fn opencode_extra_search_paths_deduplicates_repeated_entries() {\n        let home = PathBuf::from(\"/home/tester\");\n        let same_dir = Some(std::ffi::OsString::from(\"/same/path\"));\n\n        let paths = opencode_extra_search_paths(&home, same_dir.clone(), same_dir.clone(), None);\n\n        let count = paths\n            .iter()\n            .filter(|path| **path == PathBuf::from(\"/same/path\"))\n            .count();\n        assert_eq!(count, 1);\n    }\n\n    #[cfg(not(target_os = \"windows\"))]\n    #[test]\n    fn tool_executable_candidates_non_windows_uses_plain_binary_name() {\n        let dir = PathBuf::from(\"/usr/local/bin\");\n        let candidates = tool_executable_candidates(\"opencode\", &dir);\n\n        assert_eq!(candidates, vec![PathBuf::from(\"/usr/local/bin/opencode\")]);\n    }\n\n    #[cfg(target_os = \"windows\")]\n    #[test]\n    fn tool_executable_candidates_windows_includes_cmd_exe_and_plain_name() {\n        let dir = PathBuf::from(\"C:\\\\tools\");\n        let candidates = tool_executable_candidates(\"opencode\", &dir);\n\n        assert_eq!(\n            candidates,\n            vec![\n                PathBuf::from(\"C:\\\\tools\\\\opencode.cmd\"),\n                PathBuf::from(\"C:\\\\tools\\\\opencode.exe\"),\n                PathBuf::from(\"C:\\\\tools\\\\opencode\"),\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/commands/mod.rs",
    "content": "#![allow(non_snake_case)]\n\nmod auth;\nmod config;\nmod copilot;\nmod deeplink;\nmod env;\nmod failover;\nmod global_proxy;\nmod import_export;\nmod mcp;\nmod misc;\nmod omo;\nmod openclaw;\nmod plugin;\nmod prompt;\nmod provider;\nmod proxy;\nmod session_manager;\nmod settings;\npub mod skill;\nmod stream_check;\nmod sync_support;\n\nmod usage;\nmod webdav_sync;\nmod workspace;\n\npub use auth::*;\npub use config::*;\npub use copilot::*;\npub use deeplink::*;\npub use env::*;\npub use failover::*;\npub use global_proxy::*;\npub use import_export::*;\npub use mcp::*;\npub use misc::*;\npub use omo::*;\npub use openclaw::*;\npub use plugin::*;\npub use prompt::*;\npub use provider::*;\npub use proxy::*;\npub use session_manager::*;\npub use settings::*;\npub use skill::*;\npub use stream_check::*;\n\npub use usage::*;\npub use webdav_sync::*;\npub use workspace::*;\n"
  },
  {
    "path": "src-tauri/src/commands/omo.rs",
    "content": "use tauri::State;\n\nuse crate::services::omo::{OmoLocalFileData, SLIM, STANDARD};\nuse crate::services::OmoService;\nuse crate::store::AppState;\n\n#[tauri::command]\npub async fn read_omo_local_file() -> Result<OmoLocalFileData, String> {\n    OmoService::read_local_file(&STANDARD).map_err(|e| e.to_string())\n}\n\n#[tauri::command]\npub async fn get_current_omo_provider_id(state: State<'_, AppState>) -> Result<String, String> {\n    let provider = state\n        .db\n        .get_current_omo_provider(\"opencode\", \"omo\")\n        .map_err(|e| e.to_string())?;\n    Ok(provider.map(|p| p.id).unwrap_or_default())\n}\n\n#[tauri::command]\npub async fn disable_current_omo(state: State<'_, AppState>) -> Result<(), String> {\n    let providers = state\n        .db\n        .get_all_providers(\"opencode\")\n        .map_err(|e| e.to_string())?;\n    for (id, p) in &providers {\n        if p.category.as_deref() == Some(\"omo\") {\n            state\n                .db\n                .clear_omo_provider_current(\"opencode\", id, \"omo\")\n                .map_err(|e| e.to_string())?;\n        }\n    }\n    OmoService::delete_config_file(&STANDARD).map_err(|e| e.to_string())?;\n    Ok(())\n}\n\n// ── OMO Slim commands ───────────────────────────────────────\n\n#[tauri::command]\npub async fn read_omo_slim_local_file() -> Result<OmoLocalFileData, String> {\n    OmoService::read_local_file(&SLIM).map_err(|e| e.to_string())\n}\n\n#[tauri::command]\npub async fn get_current_omo_slim_provider_id(\n    state: State<'_, AppState>,\n) -> Result<String, String> {\n    let provider = state\n        .db\n        .get_current_omo_provider(\"opencode\", \"omo-slim\")\n        .map_err(|e| e.to_string())?;\n    Ok(provider.map(|p| p.id).unwrap_or_default())\n}\n\n#[tauri::command]\npub async fn disable_current_omo_slim(state: State<'_, AppState>) -> Result<(), String> {\n    let providers = state\n        .db\n        .get_all_providers(\"opencode\")\n        .map_err(|e| e.to_string())?;\n    for (id, p) in &providers {\n        if p.category.as_deref() == Some(\"omo-slim\") {\n            state\n                .db\n                .clear_omo_provider_current(\"opencode\", id, \"omo-slim\")\n                .map_err(|e| e.to_string())?;\n        }\n    }\n    OmoService::delete_config_file(&SLIM).map_err(|e| e.to_string())?;\n    Ok(())\n}\n"
  },
  {
    "path": "src-tauri/src/commands/openclaw.rs",
    "content": "use std::collections::HashMap;\nuse tauri::State;\n\nuse crate::openclaw_config;\nuse crate::store::AppState;\n\n// ============================================================================\n// OpenClaw Provider Commands (migrated from provider.rs)\n// ============================================================================\n\n/// Import providers from OpenClaw live config to database.\n///\n/// OpenClaw uses additive mode — users may already have providers\n/// configured in openclaw.json.\n#[tauri::command]\npub fn import_openclaw_providers_from_live(state: State<'_, AppState>) -> Result<usize, String> {\n    crate::services::provider::import_openclaw_providers_from_live(state.inner())\n        .map_err(|e| e.to_string())\n}\n\n/// Get provider IDs in the OpenClaw live config.\n#[tauri::command]\npub fn get_openclaw_live_provider_ids() -> Result<Vec<String>, String> {\n    openclaw_config::get_providers()\n        .map(|providers| providers.keys().cloned().collect())\n        .map_err(|e| e.to_string())\n}\n\n/// Get a single OpenClaw provider fragment from live config.\n#[tauri::command]\npub fn get_openclaw_live_provider(\n    #[allow(non_snake_case)] providerId: String,\n) -> Result<Option<serde_json::Value>, String> {\n    openclaw_config::get_provider(&providerId).map_err(|e| e.to_string())\n}\n\n/// Scan openclaw.json for known configuration hazards.\n#[tauri::command]\npub fn scan_openclaw_config_health() -> Result<Vec<openclaw_config::OpenClawHealthWarning>, String>\n{\n    openclaw_config::scan_openclaw_config_health().map_err(|e| e.to_string())\n}\n\n// ============================================================================\n// Agents Configuration Commands\n// ============================================================================\n\n/// Get OpenClaw default model config (agents.defaults.model)\n#[tauri::command]\npub fn get_openclaw_default_model() -> Result<Option<openclaw_config::OpenClawDefaultModel>, String>\n{\n    openclaw_config::get_default_model().map_err(|e| e.to_string())\n}\n\n/// Set OpenClaw default model config (agents.defaults.model)\n#[tauri::command]\npub fn set_openclaw_default_model(\n    model: openclaw_config::OpenClawDefaultModel,\n) -> Result<openclaw_config::OpenClawWriteOutcome, String> {\n    openclaw_config::set_default_model(&model).map_err(|e| e.to_string())\n}\n\n/// Get OpenClaw model catalog/allowlist (agents.defaults.models)\n#[tauri::command]\npub fn get_openclaw_model_catalog(\n) -> Result<Option<HashMap<String, openclaw_config::OpenClawModelCatalogEntry>>, String> {\n    openclaw_config::get_model_catalog().map_err(|e| e.to_string())\n}\n\n/// Set OpenClaw model catalog/allowlist (agents.defaults.models)\n#[tauri::command]\npub fn set_openclaw_model_catalog(\n    catalog: HashMap<String, openclaw_config::OpenClawModelCatalogEntry>,\n) -> Result<openclaw_config::OpenClawWriteOutcome, String> {\n    openclaw_config::set_model_catalog(&catalog).map_err(|e| e.to_string())\n}\n\n/// Get full agents.defaults config (all fields)\n#[tauri::command]\npub fn get_openclaw_agents_defaults(\n) -> Result<Option<openclaw_config::OpenClawAgentsDefaults>, String> {\n    openclaw_config::get_agents_defaults().map_err(|e| e.to_string())\n}\n\n/// Set full agents.defaults config (all fields)\n#[tauri::command]\npub fn set_openclaw_agents_defaults(\n    defaults: openclaw_config::OpenClawAgentsDefaults,\n) -> Result<openclaw_config::OpenClawWriteOutcome, String> {\n    openclaw_config::set_agents_defaults(&defaults).map_err(|e| e.to_string())\n}\n\n// ============================================================================\n// Env Configuration Commands\n// ============================================================================\n\n/// Get OpenClaw env config (env section of openclaw.json)\n#[tauri::command]\npub fn get_openclaw_env() -> Result<openclaw_config::OpenClawEnvConfig, String> {\n    openclaw_config::get_env_config().map_err(|e| e.to_string())\n}\n\n/// Set OpenClaw env config (env section of openclaw.json)\n#[tauri::command]\npub fn set_openclaw_env(\n    env: openclaw_config::OpenClawEnvConfig,\n) -> Result<openclaw_config::OpenClawWriteOutcome, String> {\n    openclaw_config::set_env_config(&env).map_err(|e| e.to_string())\n}\n\n// ============================================================================\n// Tools Configuration Commands\n// ============================================================================\n\n/// Get OpenClaw tools config (tools section of openclaw.json)\n#[tauri::command]\npub fn get_openclaw_tools() -> Result<openclaw_config::OpenClawToolsConfig, String> {\n    openclaw_config::get_tools_config().map_err(|e| e.to_string())\n}\n\n/// Set OpenClaw tools config (tools section of openclaw.json)\n#[tauri::command]\npub fn set_openclaw_tools(\n    tools: openclaw_config::OpenClawToolsConfig,\n) -> Result<openclaw_config::OpenClawWriteOutcome, String> {\n    openclaw_config::set_tools_config(&tools).map_err(|e| e.to_string())\n}\n"
  },
  {
    "path": "src-tauri/src/commands/plugin.rs",
    "content": "#![allow(non_snake_case)]\n\nuse crate::config::ConfigStatus;\n\n/// Claude 插件：获取 ~/.claude/config.json 状态\n#[tauri::command]\npub async fn get_claude_plugin_status() -> Result<ConfigStatus, String> {\n    crate::claude_plugin::claude_config_status()\n        .map(|(exists, path)| ConfigStatus {\n            exists,\n            path: path.to_string_lossy().to_string(),\n        })\n        .map_err(|e| e.to_string())\n}\n\n/// Claude 插件：读取配置内容（若不存在返回 Ok(None)）\n#[tauri::command]\npub async fn read_claude_plugin_config() -> Result<Option<String>, String> {\n    crate::claude_plugin::read_claude_config().map_err(|e| e.to_string())\n}\n\n/// Claude 插件：写入/清除固定配置\n#[tauri::command]\npub async fn apply_claude_plugin_config(official: bool) -> Result<bool, String> {\n    if official {\n        crate::claude_plugin::clear_claude_config().map_err(|e| e.to_string())\n    } else {\n        crate::claude_plugin::write_claude_config().map_err(|e| e.to_string())\n    }\n}\n\n/// Claude 插件：检测是否已写入目标配置\n#[tauri::command]\npub async fn is_claude_plugin_applied() -> Result<bool, String> {\n    crate::claude_plugin::is_claude_config_applied().map_err(|e| e.to_string())\n}\n\n/// Claude Code：跳过初次安装确认（写入 ~/.claude.json 的 hasCompletedOnboarding=true）\n#[tauri::command]\npub async fn apply_claude_onboarding_skip() -> Result<bool, String> {\n    crate::claude_mcp::set_has_completed_onboarding().map_err(|e| e.to_string())\n}\n\n/// Claude Code：恢复初次安装确认（删除 ~/.claude.json 的 hasCompletedOnboarding 字段）\n#[tauri::command]\npub async fn clear_claude_onboarding_skip() -> Result<bool, String> {\n    crate::claude_mcp::clear_has_completed_onboarding().map_err(|e| e.to_string())\n}\n"
  },
  {
    "path": "src-tauri/src/commands/prompt.rs",
    "content": "use indexmap::IndexMap;\nuse std::str::FromStr;\n\nuse tauri::State;\n\nuse crate::app_config::AppType;\nuse crate::prompt::Prompt;\nuse crate::services::PromptService;\nuse crate::store::AppState;\n\n#[tauri::command]\npub async fn get_prompts(\n    app: String,\n    state: State<'_, AppState>,\n) -> Result<IndexMap<String, Prompt>, String> {\n    let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;\n    PromptService::get_prompts(&state, app_type).map_err(|e| e.to_string())\n}\n\n#[tauri::command]\npub async fn upsert_prompt(\n    app: String,\n    id: String,\n    prompt: Prompt,\n    state: State<'_, AppState>,\n) -> Result<(), String> {\n    let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;\n    PromptService::upsert_prompt(&state, app_type, &id, prompt).map_err(|e| e.to_string())\n}\n\n#[tauri::command]\npub async fn delete_prompt(\n    app: String,\n    id: String,\n    state: State<'_, AppState>,\n) -> Result<(), String> {\n    let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;\n    PromptService::delete_prompt(&state, app_type, &id).map_err(|e| e.to_string())\n}\n\n#[tauri::command]\npub async fn enable_prompt(\n    app: String,\n    id: String,\n    state: State<'_, AppState>,\n) -> Result<(), String> {\n    let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;\n    PromptService::enable_prompt(&state, app_type, &id).map_err(|e| e.to_string())\n}\n\n#[tauri::command]\npub async fn import_prompt_from_file(\n    app: String,\n    state: State<'_, AppState>,\n) -> Result<String, String> {\n    let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;\n    PromptService::import_from_file(&state, app_type).map_err(|e| e.to_string())\n}\n\n#[tauri::command]\npub async fn get_current_prompt_file_content(app: String) -> Result<Option<String>, String> {\n    let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;\n    PromptService::get_current_file_content(app_type).map_err(|e| e.to_string())\n}\n"
  },
  {
    "path": "src-tauri/src/commands/provider.rs",
    "content": "use indexmap::IndexMap;\nuse tauri::State;\n\nuse crate::app_config::AppType;\nuse crate::commands::copilot::CopilotAuthState;\nuse crate::error::AppError;\nuse crate::provider::Provider;\nuse crate::services::{\n    EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService, SwitchResult,\n};\nuse crate::store::AppState;\nuse std::str::FromStr;\n\n// 常量定义\nconst TEMPLATE_TYPE_GITHUB_COPILOT: &str = \"github_copilot\";\nconst COPILOT_UNIT_PREMIUM: &str = \"requests\";\n\n/// 获取所有供应商\n#[tauri::command]\npub fn get_providers(\n    state: State<'_, AppState>,\n    app: String,\n) -> Result<IndexMap<String, Provider>, String> {\n    let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;\n    ProviderService::list(state.inner(), app_type).map_err(|e| e.to_string())\n}\n\n#[tauri::command]\npub fn get_current_provider(state: State<'_, AppState>, app: String) -> Result<String, String> {\n    let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;\n    ProviderService::current(state.inner(), app_type).map_err(|e| e.to_string())\n}\n\n#[tauri::command]\npub fn add_provider(\n    state: State<'_, AppState>,\n    app: String,\n    provider: Provider,\n) -> Result<bool, String> {\n    let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;\n    ProviderService::add(state.inner(), app_type, provider).map_err(|e| e.to_string())\n}\n\n#[tauri::command]\npub fn update_provider(\n    state: State<'_, AppState>,\n    app: String,\n    provider: Provider,\n) -> Result<bool, String> {\n    let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;\n    ProviderService::update(state.inner(), app_type, provider).map_err(|e| e.to_string())\n}\n\n#[tauri::command]\npub fn delete_provider(\n    state: State<'_, AppState>,\n    app: String,\n    id: String,\n) -> Result<bool, String> {\n    let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;\n    ProviderService::delete(state.inner(), app_type, &id)\n        .map(|_| true)\n        .map_err(|e| e.to_string())\n}\n\n#[tauri::command]\npub fn remove_provider_from_live_config(\n    state: tauri::State<'_, AppState>,\n    app: String,\n    id: String,\n) -> Result<bool, String> {\n    let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;\n    ProviderService::remove_from_live_config(state.inner(), app_type, &id)\n        .map(|_| true)\n        .map_err(|e| e.to_string())\n}\n\nfn switch_provider_internal(\n    state: &AppState,\n    app_type: AppType,\n    id: &str,\n) -> Result<SwitchResult, AppError> {\n    ProviderService::switch(state, app_type, id)\n}\n\n#[cfg_attr(not(feature = \"test-hooks\"), doc(hidden))]\npub fn switch_provider_test_hook(\n    state: &AppState,\n    app_type: AppType,\n    id: &str,\n) -> Result<SwitchResult, AppError> {\n    switch_provider_internal(state, app_type, id)\n}\n\n#[tauri::command]\npub fn switch_provider(\n    state: State<'_, AppState>,\n    app: String,\n    id: String,\n) -> Result<SwitchResult, String> {\n    let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;\n    switch_provider_internal(&state, app_type, &id).map_err(|e| e.to_string())\n}\n\nfn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<bool, AppError> {\n    let imported = ProviderService::import_default_config(state, app_type.clone())?;\n\n    if imported {\n        // Extract common config snippet (mirrors old startup logic in lib.rs)\n        if state\n            .db\n            .should_auto_extract_config_snippet(app_type.as_str())?\n        {\n            match ProviderService::extract_common_config_snippet(state, app_type.clone()) {\n                Ok(snippet) if !snippet.is_empty() && snippet != \"{}\" => {\n                    let _ = state\n                        .db\n                        .set_config_snippet(app_type.as_str(), Some(snippet));\n                    let _ = state\n                        .db\n                        .set_config_snippet_cleared(app_type.as_str(), false);\n                }\n                _ => {}\n            }\n        }\n\n        ProviderService::migrate_legacy_common_config_usage_if_needed(state, app_type.clone())?;\n    }\n\n    Ok(imported)\n}\n\n#[cfg_attr(not(feature = \"test-hooks\"), doc(hidden))]\npub fn import_default_config_test_hook(\n    state: &AppState,\n    app_type: AppType,\n) -> Result<bool, AppError> {\n    import_default_config_internal(state, app_type)\n}\n\n#[tauri::command]\npub fn import_default_config(state: State<'_, AppState>, app: String) -> Result<bool, String> {\n    let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;\n    import_default_config_internal(&state, app_type).map_err(Into::into)\n}\n\n#[allow(non_snake_case)]\n#[tauri::command]\npub async fn queryProviderUsage(\n    state: State<'_, AppState>,\n    copilot_state: State<'_, CopilotAuthState>,\n    #[allow(non_snake_case)] providerId: String, // 使用 camelCase 匹配前端\n    app: String,\n) -> Result<crate::provider::UsageResult, String> {\n    let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;\n\n    // 检查是否为 GitHub Copilot 模板类型，并解析绑定账号\n    let (is_copilot_template, copilot_account_id) = {\n        let providers = state\n            .db\n            .get_all_providers(app_type.as_str())\n            .map_err(|e| format!(\"Failed to get providers: {}\", e))?;\n\n        let provider = providers.get(&providerId);\n        let is_copilot = provider\n            .and_then(|p| p.meta.as_ref())\n            .and_then(|m| m.usage_script.as_ref())\n            .and_then(|s| s.template_type.as_ref())\n            .map(|t| t == TEMPLATE_TYPE_GITHUB_COPILOT)\n            .unwrap_or(false);\n        let account_id = provider\n            .and_then(|p| p.meta.as_ref())\n            .and_then(|m| m.managed_account_id_for(TEMPLATE_TYPE_GITHUB_COPILOT));\n\n        (is_copilot, account_id)\n    };\n\n    if is_copilot_template {\n        // 使用 Copilot 专用 API\n        let auth_manager = copilot_state.0.read().await;\n        let usage = match copilot_account_id.as_deref() {\n            Some(account_id) => auth_manager\n                .fetch_usage_for_account(account_id)\n                .await\n                .map_err(|e| format!(\"Failed to fetch Copilot usage: {}\", e))?,\n            None => auth_manager\n                .fetch_usage()\n                .await\n                .map_err(|e| format!(\"Failed to fetch Copilot usage: {}\", e))?,\n        };\n        let premium = &usage.quota_snapshots.premium_interactions;\n        let used = premium.entitlement - premium.remaining;\n\n        return Ok(crate::provider::UsageResult {\n            success: true,\n            data: Some(vec![crate::provider::UsageData {\n                plan_name: Some(usage.copilot_plan),\n                remaining: Some(premium.remaining as f64),\n                total: Some(premium.entitlement as f64),\n                used: Some(used as f64),\n                unit: Some(COPILOT_UNIT_PREMIUM.to_string()),\n                is_valid: Some(true),\n                invalid_message: None,\n                extra: Some(format!(\"Reset: {}\", usage.quota_reset_date)),\n            }]),\n            error: None,\n        });\n    }\n\n    ProviderService::query_usage(state.inner(), app_type, &providerId)\n        .await\n        .map_err(|e| e.to_string())\n}\n\n#[allow(non_snake_case)]\n#[allow(clippy::too_many_arguments)]\n#[tauri::command]\npub async fn testUsageScript(\n    state: State<'_, AppState>,\n    #[allow(non_snake_case)] providerId: String,\n    app: String,\n    #[allow(non_snake_case)] scriptCode: String,\n    timeout: Option<u64>,\n    #[allow(non_snake_case)] apiKey: Option<String>,\n    #[allow(non_snake_case)] baseUrl: Option<String>,\n    #[allow(non_snake_case)] accessToken: Option<String>,\n    #[allow(non_snake_case)] userId: Option<String>,\n    #[allow(non_snake_case)] templateType: Option<String>,\n) -> Result<crate::provider::UsageResult, String> {\n    let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;\n    ProviderService::test_usage_script(\n        state.inner(),\n        app_type,\n        &providerId,\n        &scriptCode,\n        timeout.unwrap_or(10),\n        apiKey.as_deref(),\n        baseUrl.as_deref(),\n        accessToken.as_deref(),\n        userId.as_deref(),\n        templateType.as_deref(),\n    )\n    .await\n    .map_err(|e| e.to_string())\n}\n\n#[tauri::command]\npub fn read_live_provider_settings(app: String) -> Result<serde_json::Value, String> {\n    let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;\n    ProviderService::read_live_settings(app_type).map_err(|e| e.to_string())\n}\n\n#[tauri::command]\npub async fn test_api_endpoints(\n    urls: Vec<String>,\n    #[allow(non_snake_case)] timeoutSecs: Option<u64>,\n) -> Result<Vec<EndpointLatency>, String> {\n    SpeedtestService::test_endpoints(urls, timeoutSecs)\n        .await\n        .map_err(|e| e.to_string())\n}\n\n#[tauri::command]\npub fn get_custom_endpoints(\n    state: State<'_, AppState>,\n    app: String,\n    #[allow(non_snake_case)] providerId: String,\n) -> Result<Vec<crate::settings::CustomEndpoint>, String> {\n    let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;\n    ProviderService::get_custom_endpoints(state.inner(), app_type, &providerId)\n        .map_err(|e| e.to_string())\n}\n\n#[tauri::command]\npub fn add_custom_endpoint(\n    state: State<'_, AppState>,\n    app: String,\n    #[allow(non_snake_case)] providerId: String,\n    url: String,\n) -> Result<(), String> {\n    let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;\n    ProviderService::add_custom_endpoint(state.inner(), app_type, &providerId, url)\n        .map_err(|e| e.to_string())\n}\n\n#[tauri::command]\npub fn remove_custom_endpoint(\n    state: State<'_, AppState>,\n    app: String,\n    #[allow(non_snake_case)] providerId: String,\n    url: String,\n) -> Result<(), String> {\n    let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;\n    ProviderService::remove_custom_endpoint(state.inner(), app_type, &providerId, url)\n        .map_err(|e| e.to_string())\n}\n\n#[tauri::command]\npub fn update_endpoint_last_used(\n    state: State<'_, AppState>,\n    app: String,\n    #[allow(non_snake_case)] providerId: String,\n    url: String,\n) -> Result<(), String> {\n    let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;\n    ProviderService::update_endpoint_last_used(state.inner(), app_type, &providerId, url)\n        .map_err(|e| e.to_string())\n}\n\n#[tauri::command]\npub fn update_providers_sort_order(\n    state: State<'_, AppState>,\n    app: String,\n    updates: Vec<ProviderSortUpdate>,\n) -> Result<bool, String> {\n    let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;\n    ProviderService::update_sort_order(state.inner(), app_type, updates).map_err(|e| e.to_string())\n}\n\nuse crate::provider::UniversalProvider;\nuse std::collections::HashMap;\nuse tauri::{AppHandle, Emitter};\n\n#[derive(Clone, serde::Serialize)]\npub struct UniversalProviderSyncedEvent {\n    pub action: String,\n    pub id: String,\n}\n\nfn emit_universal_provider_synced(app: &AppHandle, action: &str, id: &str) {\n    let _ = app.emit(\n        \"universal-provider-synced\",\n        UniversalProviderSyncedEvent {\n            action: action.to_string(),\n            id: id.to_string(),\n        },\n    );\n}\n\n#[tauri::command]\npub fn get_universal_providers(\n    state: State<'_, AppState>,\n) -> Result<HashMap<String, UniversalProvider>, String> {\n    ProviderService::list_universal(state.inner()).map_err(|e| e.to_string())\n}\n\n#[tauri::command]\npub fn get_universal_provider(\n    state: State<'_, AppState>,\n    id: String,\n) -> Result<Option<UniversalProvider>, String> {\n    ProviderService::get_universal(state.inner(), &id).map_err(|e| e.to_string())\n}\n\n#[tauri::command]\npub fn upsert_universal_provider(\n    app: AppHandle,\n    state: State<'_, AppState>,\n    provider: UniversalProvider,\n) -> Result<bool, String> {\n    let id = provider.id.clone();\n    let result =\n        ProviderService::upsert_universal(state.inner(), provider).map_err(|e| e.to_string())?;\n\n    emit_universal_provider_synced(&app, \"upsert\", &id);\n\n    Ok(result)\n}\n\n#[tauri::command]\npub fn delete_universal_provider(\n    app: AppHandle,\n    state: State<'_, AppState>,\n    id: String,\n) -> Result<bool, String> {\n    let result =\n        ProviderService::delete_universal(state.inner(), &id).map_err(|e| e.to_string())?;\n\n    emit_universal_provider_synced(&app, \"delete\", &id);\n\n    Ok(result)\n}\n\n#[tauri::command]\npub fn sync_universal_provider(\n    app: AppHandle,\n    state: State<'_, AppState>,\n    id: String,\n) -> Result<bool, String> {\n    let result =\n        ProviderService::sync_universal_to_apps(state.inner(), &id).map_err(|e| e.to_string())?;\n\n    emit_universal_provider_synced(&app, \"sync\", &id);\n\n    Ok(result)\n}\n\n#[tauri::command]\npub fn import_opencode_providers_from_live(state: State<'_, AppState>) -> Result<usize, String> {\n    crate::services::provider::import_opencode_providers_from_live(state.inner())\n        .map_err(|e| e.to_string())\n}\n\n#[tauri::command]\npub fn get_opencode_live_provider_ids() -> Result<Vec<String>, String> {\n    crate::opencode_config::get_providers()\n        .map(|providers| providers.keys().cloned().collect())\n        .map_err(|e| e.to_string())\n}\n\n// ============================================================================\n// OpenClaw 专属命令 → 已迁移至 commands/openclaw.rs\n// ============================================================================\n"
  },
  {
    "path": "src-tauri/src/commands/proxy.rs",
    "content": "//! 代理服务相关的 Tauri 命令\n//!\n//! 提供前端调用的 API 接口\n\nuse crate::error::AppError;\nuse crate::proxy::types::*;\nuse crate::proxy::{CircuitBreakerConfig, CircuitBreakerStats};\nuse crate::store::AppState;\n\n/// 启动代理服务器（仅启动服务，不接管 Live 配置）\n#[tauri::command]\npub async fn start_proxy_server(\n    state: tauri::State<'_, AppState>,\n) -> Result<ProxyServerInfo, String> {\n    state.proxy_service.start().await\n}\n\n/// 停止代理服务器（恢复 Live 配置）\n#[tauri::command]\npub async fn stop_proxy_with_restore(state: tauri::State<'_, AppState>) -> Result<(), String> {\n    state.proxy_service.stop_with_restore().await\n}\n\n/// 获取各应用接管状态\n#[tauri::command]\npub async fn get_proxy_takeover_status(\n    state: tauri::State<'_, AppState>,\n) -> Result<ProxyTakeoverStatus, String> {\n    state.proxy_service.get_takeover_status().await\n}\n\n/// 为指定应用开启/关闭接管\n#[tauri::command]\npub async fn set_proxy_takeover_for_app(\n    state: tauri::State<'_, AppState>,\n    app_type: String,\n    enabled: bool,\n) -> Result<(), String> {\n    state\n        .proxy_service\n        .set_takeover_for_app(&app_type, enabled)\n        .await\n}\n\n/// 获取代理服务器状态\n#[tauri::command]\npub async fn get_proxy_status(state: tauri::State<'_, AppState>) -> Result<ProxyStatus, String> {\n    state.proxy_service.get_status().await\n}\n\n/// 获取代理配置\n#[tauri::command]\npub async fn get_proxy_config(state: tauri::State<'_, AppState>) -> Result<ProxyConfig, String> {\n    state.proxy_service.get_config().await\n}\n\n/// 更新代理配置\n#[tauri::command]\npub async fn update_proxy_config(\n    state: tauri::State<'_, AppState>,\n    config: ProxyConfig,\n) -> Result<(), String> {\n    state.proxy_service.update_config(&config).await\n}\n\n// ==================== Global & Per-App Config ====================\n\n/// 获取全局代理配置\n///\n/// 返回统一的全局配置字段（代理开关、监听地址、端口、日志开关）\n#[tauri::command]\npub async fn get_global_proxy_config(\n    state: tauri::State<'_, AppState>,\n) -> Result<GlobalProxyConfig, String> {\n    let db = &state.db;\n    db.get_global_proxy_config()\n        .await\n        .map_err(|e| e.to_string())\n}\n\n/// 更新全局代理配置\n///\n/// 更新统一的全局配置字段，会同时更新三行（claude/codex/gemini）\n#[tauri::command]\npub async fn update_global_proxy_config(\n    state: tauri::State<'_, AppState>,\n    config: GlobalProxyConfig,\n) -> Result<(), String> {\n    let db = &state.db;\n    db.update_global_proxy_config(config)\n        .await\n        .map_err(|e| e.to_string())\n}\n\n/// 获取指定应用的代理配置\n///\n/// 返回应用级配置（enabled、auto_failover、超时、熔断器等）\n#[tauri::command]\npub async fn get_proxy_config_for_app(\n    state: tauri::State<'_, AppState>,\n    app_type: String,\n) -> Result<AppProxyConfig, String> {\n    let db = &state.db;\n    db.get_proxy_config_for_app(&app_type)\n        .await\n        .map_err(|e| e.to_string())\n}\n\n/// 更新指定应用的代理配置\n///\n/// 更新应用级配置（enabled、auto_failover、超时、熔断器等）\n#[tauri::command]\npub async fn update_proxy_config_for_app(\n    state: tauri::State<'_, AppState>,\n    config: AppProxyConfig,\n) -> Result<(), String> {\n    let db = &state.db;\n    db.update_proxy_config_for_app(config)\n        .await\n        .map_err(|e| e.to_string())\n}\n\nasync fn get_default_cost_multiplier_internal(\n    state: &AppState,\n    app_type: &str,\n) -> Result<String, AppError> {\n    let db = &state.db;\n    db.get_default_cost_multiplier(app_type).await\n}\n\n#[cfg_attr(not(feature = \"test-hooks\"), doc(hidden))]\npub async fn get_default_cost_multiplier_test_hook(\n    state: &AppState,\n    app_type: &str,\n) -> Result<String, AppError> {\n    get_default_cost_multiplier_internal(state, app_type).await\n}\n\n/// 获取默认成本倍率\n#[tauri::command]\npub async fn get_default_cost_multiplier(\n    state: tauri::State<'_, AppState>,\n    app_type: String,\n) -> Result<String, String> {\n    get_default_cost_multiplier_internal(&state, &app_type)\n        .await\n        .map_err(|e| e.to_string())\n}\n\nasync fn set_default_cost_multiplier_internal(\n    state: &AppState,\n    app_type: &str,\n    value: &str,\n) -> Result<(), AppError> {\n    let db = &state.db;\n    db.set_default_cost_multiplier(app_type, value).await\n}\n\n#[cfg_attr(not(feature = \"test-hooks\"), doc(hidden))]\npub async fn set_default_cost_multiplier_test_hook(\n    state: &AppState,\n    app_type: &str,\n    value: &str,\n) -> Result<(), AppError> {\n    set_default_cost_multiplier_internal(state, app_type, value).await\n}\n\n/// 设置默认成本倍率\n#[tauri::command]\npub async fn set_default_cost_multiplier(\n    state: tauri::State<'_, AppState>,\n    app_type: String,\n    value: String,\n) -> Result<(), String> {\n    set_default_cost_multiplier_internal(&state, &app_type, &value)\n        .await\n        .map_err(|e| e.to_string())\n}\n\nasync fn get_pricing_model_source_internal(\n    state: &AppState,\n    app_type: &str,\n) -> Result<String, AppError> {\n    let db = &state.db;\n    db.get_pricing_model_source(app_type).await\n}\n\n#[cfg_attr(not(feature = \"test-hooks\"), doc(hidden))]\npub async fn get_pricing_model_source_test_hook(\n    state: &AppState,\n    app_type: &str,\n) -> Result<String, AppError> {\n    get_pricing_model_source_internal(state, app_type).await\n}\n\n/// 获取计费模式来源\n#[tauri::command]\npub async fn get_pricing_model_source(\n    state: tauri::State<'_, AppState>,\n    app_type: String,\n) -> Result<String, String> {\n    get_pricing_model_source_internal(&state, &app_type)\n        .await\n        .map_err(|e| e.to_string())\n}\n\nasync fn set_pricing_model_source_internal(\n    state: &AppState,\n    app_type: &str,\n    value: &str,\n) -> Result<(), AppError> {\n    let db = &state.db;\n    db.set_pricing_model_source(app_type, value).await\n}\n\n#[cfg_attr(not(feature = \"test-hooks\"), doc(hidden))]\npub async fn set_pricing_model_source_test_hook(\n    state: &AppState,\n    app_type: &str,\n    value: &str,\n) -> Result<(), AppError> {\n    set_pricing_model_source_internal(state, app_type, value).await\n}\n\n/// 设置计费模式来源\n#[tauri::command]\npub async fn set_pricing_model_source(\n    state: tauri::State<'_, AppState>,\n    app_type: String,\n    value: String,\n) -> Result<(), String> {\n    set_pricing_model_source_internal(&state, &app_type, &value)\n        .await\n        .map_err(|e| e.to_string())\n}\n\n/// 检查代理服务器是否正在运行\n#[tauri::command]\npub async fn is_proxy_running(state: tauri::State<'_, AppState>) -> Result<bool, String> {\n    Ok(state.proxy_service.is_running().await)\n}\n\n/// 检查是否处于 Live 接管模式\n#[tauri::command]\npub async fn is_live_takeover_active(state: tauri::State<'_, AppState>) -> Result<bool, String> {\n    state.proxy_service.is_takeover_active().await\n}\n\n/// 代理模式下切换供应商（热切换）\n#[tauri::command]\npub async fn switch_proxy_provider(\n    state: tauri::State<'_, AppState>,\n    app_type: String,\n    provider_id: String,\n) -> Result<(), String> {\n    state\n        .proxy_service\n        .switch_proxy_target(&app_type, &provider_id)\n        .await\n}\n\n// ==================== 故障转移相关命令 ====================\n\n/// 获取供应商健康状态\n#[tauri::command]\npub async fn get_provider_health(\n    state: tauri::State<'_, AppState>,\n    provider_id: String,\n    app_type: String,\n) -> Result<ProviderHealth, String> {\n    let db = &state.db;\n    db.get_provider_health(&provider_id, &app_type)\n        .await\n        .map_err(|e| e.to_string())\n}\n\n/// 重置熔断器\n///\n/// 重置后会检查是否应该切回队列中优先级更高的供应商：\n/// 1. 检查自动故障转移是否开启\n/// 2. 如果恢复的供应商在队列中优先级更高（queue_order 更小），则自动切换\n#[tauri::command]\npub async fn reset_circuit_breaker(\n    app_handle: tauri::AppHandle,\n    state: tauri::State<'_, AppState>,\n    provider_id: String,\n    app_type: String,\n) -> Result<(), String> {\n    // 1. 重置数据库健康状态\n    let db = &state.db;\n    db.update_provider_health(&provider_id, &app_type, true, None)\n        .await\n        .map_err(|e| e.to_string())?;\n\n    // 2. 如果代理正在运行，重置内存中的熔断器状态\n    state\n        .proxy_service\n        .reset_provider_circuit_breaker(&provider_id, &app_type)\n        .await?;\n\n    // 3. 检查是否应该切回优先级更高的供应商（从 proxy_config 表读取）\n    // 只有当该应用已被代理接管（enabled=true）且开启了自动故障转移时才执行\n    let (app_enabled, auto_failover_enabled) = match db.get_proxy_config_for_app(&app_type).await {\n        Ok(config) => (config.enabled, config.auto_failover_enabled),\n        Err(e) => {\n            log::error!(\"[{app_type}] Failed to read proxy_config: {e}, defaulting to disabled\");\n            (false, false)\n        }\n    };\n\n    if app_enabled && auto_failover_enabled && state.proxy_service.is_running().await {\n        // 获取当前供应商 ID\n        let current_id = db\n            .get_current_provider(&app_type)\n            .map_err(|e| e.to_string())?;\n\n        if let Some(current_id) = current_id {\n            // 获取故障转移队列\n            let queue = db\n                .get_failover_queue(&app_type)\n                .map_err(|e| e.to_string())?;\n\n            // 找到恢复的供应商和当前供应商在队列中的位置（使用 sort_index）\n            let restored_order = queue\n                .iter()\n                .find(|item| item.provider_id == provider_id)\n                .and_then(|item| item.sort_index);\n\n            let current_order = queue\n                .iter()\n                .find(|item| item.provider_id == current_id)\n                .and_then(|item| item.sort_index);\n\n            // 如果恢复的供应商优先级更高（sort_index 更小），则切换\n            if let (Some(restored), Some(current)) = (restored_order, current_order) {\n                if restored < current {\n                    log::info!(\n                        \"[Recovery] 供应商 {provider_id} 已恢复且优先级更高 (P{restored} vs P{current})，自动切换\"\n                    );\n\n                    // 获取供应商名称用于日志和事件\n                    let provider_name = db\n                        .get_all_providers(&app_type)\n                        .ok()\n                        .and_then(|providers| providers.get(&provider_id).map(|p| p.name.clone()))\n                        .unwrap_or_else(|| provider_id.clone());\n\n                    // 创建故障转移切换管理器并执行切换\n                    let switch_manager =\n                        crate::proxy::failover_switch::FailoverSwitchManager::new(db.clone());\n                    if let Err(e) = switch_manager\n                        .try_switch(Some(&app_handle), &app_type, &provider_id, &provider_name)\n                        .await\n                    {\n                        log::error!(\"[Recovery] 自动切换失败: {e}\");\n                    }\n                }\n            }\n        }\n    }\n\n    Ok(())\n}\n\n/// 获取熔断器配置\n#[tauri::command]\npub async fn get_circuit_breaker_config(\n    state: tauri::State<'_, AppState>,\n) -> Result<CircuitBreakerConfig, String> {\n    let db = &state.db;\n    db.get_circuit_breaker_config()\n        .await\n        .map_err(|e| e.to_string())\n}\n\n/// 更新熔断器配置\n#[tauri::command]\npub async fn update_circuit_breaker_config(\n    state: tauri::State<'_, AppState>,\n    config: CircuitBreakerConfig,\n) -> Result<(), String> {\n    let db = &state.db;\n\n    // 1. 更新数据库配置\n    db.update_circuit_breaker_config(&config)\n        .await\n        .map_err(|e| e.to_string())?;\n\n    // 2. 如果代理正在运行，热更新内存中的熔断器配置\n    state\n        .proxy_service\n        .update_circuit_breaker_configs(config)\n        .await?;\n\n    Ok(())\n}\n\n/// 获取熔断器统计信息（仅当代理服务器运行时）\n#[tauri::command]\npub async fn get_circuit_breaker_stats(\n    state: tauri::State<'_, AppState>,\n    provider_id: String,\n    app_type: String,\n) -> Result<Option<CircuitBreakerStats>, String> {\n    // 这个功能需要访问运行中的代理服务器的内存状态\n    // 目前先返回 None，后续可以通过 ProxyService 暴露接口来实现\n    let _ = (state, provider_id, app_type);\n    Ok(None)\n}\n"
  },
  {
    "path": "src-tauri/src/commands/session_manager.rs",
    "content": "#![allow(non_snake_case)]\n\nuse crate::session_manager;\n\n#[tauri::command]\npub async fn list_sessions() -> Result<Vec<session_manager::SessionMeta>, String> {\n    let sessions = tauri::async_runtime::spawn_blocking(session_manager::scan_sessions)\n        .await\n        .map_err(|e| format!(\"Failed to scan sessions: {e}\"))?;\n    Ok(sessions)\n}\n\n#[tauri::command]\npub async fn get_session_messages(\n    providerId: String,\n    sourcePath: String,\n) -> Result<Vec<session_manager::SessionMessage>, String> {\n    let provider_id = providerId.clone();\n    let source_path = sourcePath.clone();\n    tauri::async_runtime::spawn_blocking(move || {\n        session_manager::load_messages(&provider_id, &source_path)\n    })\n    .await\n    .map_err(|e| format!(\"Failed to load session messages: {e}\"))?\n}\n\n#[tauri::command]\npub async fn launch_session_terminal(\n    command: String,\n    cwd: Option<String>,\n    custom_config: Option<String>,\n) -> Result<bool, String> {\n    let command = command.clone();\n    let cwd = cwd.clone();\n    let custom_config = custom_config.clone();\n\n    // Read preferred terminal from global settings\n    let preferred = crate::settings::get_preferred_terminal();\n    // Map global setting terminal names to session terminal names\n    // Global uses \"iterm2\", session terminal uses \"iterm\"\n    let target = match preferred.as_deref() {\n        Some(\"iterm2\") => \"iterm\".to_string(),\n        Some(t) => t.to_string(),\n        None => \"terminal\".to_string(), // Default to Terminal.app on macOS\n    };\n\n    tauri::async_runtime::spawn_blocking(move || {\n        session_manager::terminal::launch_terminal(\n            &target,\n            &command,\n            cwd.as_deref(),\n            custom_config.as_deref(),\n        )\n    })\n    .await\n    .map_err(|e| format!(\"Failed to launch terminal: {e}\"))??;\n\n    Ok(true)\n}\n\n#[tauri::command]\npub async fn delete_session(\n    providerId: String,\n    sessionId: String,\n    sourcePath: String,\n) -> Result<bool, String> {\n    let provider_id = providerId.clone();\n    let session_id = sessionId.clone();\n    let source_path = sourcePath.clone();\n\n    tauri::async_runtime::spawn_blocking(move || {\n        session_manager::delete_session(&provider_id, &session_id, &source_path)\n    })\n    .await\n    .map_err(|e| format!(\"Failed to delete session: {e}\"))?\n}\n"
  },
  {
    "path": "src-tauri/src/commands/settings.rs",
    "content": "#![allow(non_snake_case)]\n\nuse tauri::AppHandle;\n\nfn merge_settings_for_save(\n    mut incoming: crate::settings::AppSettings,\n    existing: &crate::settings::AppSettings,\n) -> crate::settings::AppSettings {\n    if incoming.webdav_sync.is_none() {\n        incoming.webdav_sync = existing.webdav_sync.clone();\n    }\n    incoming\n}\n\n/// 获取设置\n#[tauri::command]\npub async fn get_settings() -> Result<crate::settings::AppSettings, String> {\n    Ok(crate::settings::get_settings_for_frontend())\n}\n\n/// 保存设置\n#[tauri::command]\npub async fn save_settings(settings: crate::settings::AppSettings) -> Result<bool, String> {\n    let existing = crate::settings::get_settings();\n    let merged = merge_settings_for_save(settings, &existing);\n    crate::settings::update_settings(merged).map_err(|e| e.to_string())?;\n    Ok(true)\n}\n\n/// 重启应用程序（当 app_config_dir 变更后使用）\n#[tauri::command]\npub async fn restart_app(app: AppHandle) -> Result<bool, String> {\n    // 在后台延迟重启，让函数有时间返回响应\n    tauri::async_runtime::spawn(async move {\n        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;\n        app.restart();\n    });\n    Ok(true)\n}\n\n/// 获取 app_config_dir 覆盖配置 (从 Store)\n#[tauri::command]\npub async fn get_app_config_dir_override(app: AppHandle) -> Result<Option<String>, String> {\n    Ok(crate::app_store::refresh_app_config_dir_override(&app)\n        .map(|p| p.to_string_lossy().to_string()))\n}\n\n/// 设置 app_config_dir 覆盖配置 (到 Store)\n#[tauri::command]\npub async fn set_app_config_dir_override(\n    app: AppHandle,\n    path: Option<String>,\n) -> Result<bool, String> {\n    crate::app_store::set_app_config_dir_to_store(&app, path.as_deref())?;\n    Ok(true)\n}\n\n/// 设置开机自启\n#[tauri::command]\npub async fn set_auto_launch(enabled: bool) -> Result<bool, String> {\n    if enabled {\n        crate::auto_launch::enable_auto_launch().map_err(|e| format!(\"启用开机自启失败: {e}\"))?;\n    } else {\n        crate::auto_launch::disable_auto_launch().map_err(|e| format!(\"禁用开机自启失败: {e}\"))?;\n    }\n    Ok(true)\n}\n\n#[cfg(test)]\nmod tests {\n    use super::merge_settings_for_save;\n    use crate::settings::{AppSettings, WebDavSyncSettings};\n\n    #[test]\n    fn save_settings_should_preserve_existing_webdav_when_payload_omits_it() {\n        let mut existing = AppSettings::default();\n        existing.webdav_sync = Some(WebDavSyncSettings {\n            base_url: \"https://dav.example.com\".to_string(),\n            username: \"alice\".to_string(),\n            password: \"secret\".to_string(),\n            ..WebDavSyncSettings::default()\n        });\n\n        let incoming = AppSettings::default();\n        let merged = merge_settings_for_save(incoming, &existing);\n\n        assert!(merged.webdav_sync.is_some());\n        assert_eq!(\n            merged.webdav_sync.as_ref().map(|v| v.base_url.as_str()),\n            Some(\"https://dav.example.com\")\n        );\n    }\n\n    #[test]\n    fn save_settings_should_keep_incoming_webdav_when_present() {\n        let mut existing = AppSettings::default();\n        existing.webdav_sync = Some(WebDavSyncSettings {\n            base_url: \"https://dav.old.example.com\".to_string(),\n            username: \"old\".to_string(),\n            password: \"old-pass\".to_string(),\n            ..WebDavSyncSettings::default()\n        });\n\n        let mut incoming = AppSettings::default();\n        incoming.webdav_sync = Some(WebDavSyncSettings {\n            base_url: \"https://dav.new.example.com\".to_string(),\n            username: \"new\".to_string(),\n            password: \"new-pass\".to_string(),\n            ..WebDavSyncSettings::default()\n        });\n\n        let merged = merge_settings_for_save(incoming, &existing);\n\n        assert_eq!(\n            merged.webdav_sync.as_ref().map(|v| v.base_url.as_str()),\n            Some(\"https://dav.new.example.com\")\n        );\n    }\n}\n\n/// 获取开机自启状态\n#[tauri::command]\npub async fn get_auto_launch_status() -> Result<bool, String> {\n    crate::auto_launch::is_auto_launch_enabled().map_err(|e| format!(\"获取开机自启状态失败: {e}\"))\n}\n\n/// 获取整流器配置\n#[tauri::command]\npub async fn get_rectifier_config(\n    state: tauri::State<'_, crate::AppState>,\n) -> Result<crate::proxy::types::RectifierConfig, String> {\n    state.db.get_rectifier_config().map_err(|e| e.to_string())\n}\n\n/// 设置整流器配置\n#[tauri::command]\npub async fn set_rectifier_config(\n    state: tauri::State<'_, crate::AppState>,\n    config: crate::proxy::types::RectifierConfig,\n) -> Result<bool, String> {\n    state\n        .db\n        .set_rectifier_config(&config)\n        .map_err(|e| e.to_string())?;\n    Ok(true)\n}\n\n/// 获取优化器配置\n#[tauri::command]\npub async fn get_optimizer_config(\n    state: tauri::State<'_, crate::AppState>,\n) -> Result<crate::proxy::types::OptimizerConfig, String> {\n    state.db.get_optimizer_config().map_err(|e| e.to_string())\n}\n\n/// 设置优化器配置\n#[tauri::command]\npub async fn set_optimizer_config(\n    state: tauri::State<'_, crate::AppState>,\n    config: crate::proxy::types::OptimizerConfig,\n) -> Result<bool, String> {\n    // Validate cache_ttl: only allow known values\n    match config.cache_ttl.as_str() {\n        \"5m\" | \"1h\" => {}\n        other => {\n            return Err(format!(\n                \"Invalid cache_ttl value: '{other}'. Allowed values: '5m', '1h'\"\n            ))\n        }\n    }\n    state\n        .db\n        .set_optimizer_config(&config)\n        .map_err(|e| e.to_string())?;\n    Ok(true)\n}\n\n/// 获取日志配置\n#[tauri::command]\npub async fn get_log_config(\n    state: tauri::State<'_, crate::AppState>,\n) -> Result<crate::proxy::types::LogConfig, String> {\n    state.db.get_log_config().map_err(|e| e.to_string())\n}\n\n/// 设置日志配置\n#[tauri::command]\npub async fn set_log_config(\n    state: tauri::State<'_, crate::AppState>,\n    config: crate::proxy::types::LogConfig,\n) -> Result<bool, String> {\n    state\n        .db\n        .set_log_config(&config)\n        .map_err(|e| e.to_string())?;\n    log::set_max_level(config.to_level_filter());\n    log::info!(\n        \"日志配置已更新: enabled={}, level={}\",\n        config.enabled,\n        config.level\n    );\n    Ok(true)\n}\n"
  },
  {
    "path": "src-tauri/src/commands/skill.rs",
    "content": "//! Skills 命令层\n//!\n//! v3.10.0+ 统一管理架构：\n//! - 支持三应用开关（Claude/Codex/Gemini）\n//! - SSOT 存储在 ~/.cc-switch/skills/\n\nuse crate::app_config::{AppType, InstalledSkill, UnmanagedSkill};\nuse crate::error::format_skill_error;\nuse crate::services::skill::{\n    DiscoverableSkill, ImportSkillSelection, Skill, SkillBackupEntry, SkillRepo, SkillService,\n    SkillUninstallResult,\n};\nuse crate::store::AppState;\nuse std::sync::Arc;\nuse tauri::State;\n\n/// SkillService 状态包装\npub struct SkillServiceState(pub Arc<SkillService>);\n\n/// 解析 app 参数为 AppType\nfn parse_app_type(app: &str) -> Result<AppType, String> {\n    match app.to_lowercase().as_str() {\n        \"claude\" => Ok(AppType::Claude),\n        \"codex\" => Ok(AppType::Codex),\n        \"gemini\" => Ok(AppType::Gemini),\n        \"opencode\" => Ok(AppType::OpenCode),\n        _ => Err(format!(\"不支持的 app 类型: {app}\")),\n    }\n}\n\n// ========== 统一管理命令 ==========\n\n/// 获取所有已安装的 Skills\n#[tauri::command]\npub fn get_installed_skills(app_state: State<'_, AppState>) -> Result<Vec<InstalledSkill>, String> {\n    SkillService::get_all_installed(&app_state.db).map_err(|e| e.to_string())\n}\n\n#[tauri::command]\npub fn get_skill_backups() -> Result<Vec<SkillBackupEntry>, String> {\n    SkillService::list_backups().map_err(|e| e.to_string())\n}\n\n#[tauri::command]\npub fn delete_skill_backup(backup_id: String) -> Result<bool, String> {\n    SkillService::delete_backup(&backup_id).map_err(|e| e.to_string())?;\n    Ok(true)\n}\n\n/// 安装 Skill（新版统一安装）\n///\n/// 参数：\n/// - skill: 从发现列表获取的技能信息\n/// - current_app: 当前选中的应用，安装后默认启用该应用\n#[tauri::command]\npub async fn install_skill_unified(\n    skill: DiscoverableSkill,\n    current_app: String,\n    service: State<'_, SkillServiceState>,\n    app_state: State<'_, AppState>,\n) -> Result<InstalledSkill, String> {\n    let app_type = parse_app_type(&current_app)?;\n\n    service\n        .0\n        .install(&app_state.db, &skill, &app_type)\n        .await\n        .map_err(|e| e.to_string())\n}\n\n/// 卸载 Skill（新版统一卸载）\n#[tauri::command]\npub fn uninstall_skill_unified(\n    id: String,\n    app_state: State<'_, AppState>,\n) -> Result<SkillUninstallResult, String> {\n    SkillService::uninstall(&app_state.db, &id).map_err(|e| e.to_string())\n}\n\n#[tauri::command]\npub fn restore_skill_backup(\n    backup_id: String,\n    current_app: String,\n    app_state: State<'_, AppState>,\n) -> Result<InstalledSkill, String> {\n    let app_type = parse_app_type(&current_app)?;\n    SkillService::restore_from_backup(&app_state.db, &backup_id, &app_type)\n        .map_err(|e| e.to_string())\n}\n\n/// 切换 Skill 的应用启用状态\n#[tauri::command]\npub fn toggle_skill_app(\n    id: String,\n    app: String,\n    enabled: bool,\n    app_state: State<'_, AppState>,\n) -> Result<bool, String> {\n    let app_type = parse_app_type(&app)?;\n    SkillService::toggle_app(&app_state.db, &id, &app_type, enabled).map_err(|e| e.to_string())?;\n    Ok(true)\n}\n\n/// 扫描未管理的 Skills\n#[tauri::command]\npub fn scan_unmanaged_skills(\n    app_state: State<'_, AppState>,\n) -> Result<Vec<UnmanagedSkill>, String> {\n    SkillService::scan_unmanaged(&app_state.db).map_err(|e| e.to_string())\n}\n\n/// 从应用目录导入 Skills\n#[tauri::command]\npub fn import_skills_from_apps(\n    imports: Vec<ImportSkillSelection>,\n    app_state: State<'_, AppState>,\n) -> Result<Vec<InstalledSkill>, String> {\n    SkillService::import_from_apps(&app_state.db, imports).map_err(|e| e.to_string())\n}\n\n// ========== 发现功能命令 ==========\n\n/// 发现可安装的 Skills（从仓库获取）\n#[tauri::command]\npub async fn discover_available_skills(\n    service: State<'_, SkillServiceState>,\n    app_state: State<'_, AppState>,\n) -> Result<Vec<DiscoverableSkill>, String> {\n    let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;\n    service\n        .0\n        .discover_available(repos)\n        .await\n        .map_err(|e| e.to_string())\n}\n\n// ========== 兼容旧 API 的命令 ==========\n\n/// 获取技能列表（兼容旧 API）\n#[tauri::command]\npub async fn get_skills(\n    service: State<'_, SkillServiceState>,\n    app_state: State<'_, AppState>,\n) -> Result<Vec<Skill>, String> {\n    let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;\n    service\n        .0\n        .list_skills(repos, &app_state.db)\n        .await\n        .map_err(|e| e.to_string())\n}\n\n/// 获取指定应用的技能列表（兼容旧 API）\n#[tauri::command]\npub async fn get_skills_for_app(\n    app: String,\n    service: State<'_, SkillServiceState>,\n    app_state: State<'_, AppState>,\n) -> Result<Vec<Skill>, String> {\n    // 新版本不再区分应用，统一返回所有技能\n    let _ = parse_app_type(&app)?; // 验证 app 参数有效\n    get_skills(service, app_state).await\n}\n\n/// 安装技能（兼容旧 API）\n#[tauri::command]\npub async fn install_skill(\n    directory: String,\n    service: State<'_, SkillServiceState>,\n    app_state: State<'_, AppState>,\n) -> Result<bool, String> {\n    install_skill_for_app(\"claude\".to_string(), directory, service, app_state).await\n}\n\n/// 安装指定应用的技能（兼容旧 API）\n#[tauri::command]\npub async fn install_skill_for_app(\n    app: String,\n    directory: String,\n    service: State<'_, SkillServiceState>,\n    app_state: State<'_, AppState>,\n) -> Result<bool, String> {\n    let app_type = parse_app_type(&app)?;\n\n    // 先获取技能信息\n    let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;\n    let skills = service\n        .0\n        .discover_available(repos)\n        .await\n        .map_err(|e| e.to_string())?;\n\n    let skill = skills\n        .into_iter()\n        .find(|s| {\n            let install_name = std::path::Path::new(&s.directory)\n                .file_name()\n                .map(|n| n.to_string_lossy().to_string())\n                .unwrap_or_else(|| s.directory.clone());\n            install_name.eq_ignore_ascii_case(&directory)\n                || s.directory.eq_ignore_ascii_case(&directory)\n        })\n        .ok_or_else(|| {\n            format_skill_error(\n                \"SKILL_NOT_FOUND\",\n                &[(\"directory\", &directory)],\n                Some(\"checkRepoUrl\"),\n            )\n        })?;\n\n    service\n        .0\n        .install(&app_state.db, &skill, &app_type)\n        .await\n        .map_err(|e| e.to_string())?;\n\n    Ok(true)\n}\n\n/// 卸载技能（兼容旧 API）\n#[tauri::command]\npub fn uninstall_skill(\n    directory: String,\n    app_state: State<'_, AppState>,\n) -> Result<SkillUninstallResult, String> {\n    uninstall_skill_for_app(\"claude\".to_string(), directory, app_state)\n}\n\n/// 卸载指定应用的技能（兼容旧 API）\n#[tauri::command]\npub fn uninstall_skill_for_app(\n    app: String,\n    directory: String,\n    app_state: State<'_, AppState>,\n) -> Result<SkillUninstallResult, String> {\n    let _ = parse_app_type(&app)?; // 验证参数\n\n    // 通过 directory 找到对应的 skill id\n    let skills = SkillService::get_all_installed(&app_state.db).map_err(|e| e.to_string())?;\n\n    let skill = skills\n        .into_iter()\n        .find(|s| s.directory.eq_ignore_ascii_case(&directory))\n        .ok_or_else(|| format!(\"未找到已安装的 Skill: {directory}\"))?;\n\n    SkillService::uninstall(&app_state.db, &skill.id).map_err(|e| e.to_string())\n}\n\n// ========== 仓库管理命令 ==========\n\n/// 获取技能仓库列表\n#[tauri::command]\npub fn get_skill_repos(app_state: State<'_, AppState>) -> Result<Vec<SkillRepo>, String> {\n    app_state.db.get_skill_repos().map_err(|e| e.to_string())\n}\n\n/// 添加技能仓库\n#[tauri::command]\npub fn add_skill_repo(repo: SkillRepo, app_state: State<'_, AppState>) -> Result<bool, String> {\n    app_state\n        .db\n        .save_skill_repo(&repo)\n        .map_err(|e| e.to_string())?;\n    Ok(true)\n}\n\n/// 删除技能仓库\n#[tauri::command]\npub fn remove_skill_repo(\n    owner: String,\n    name: String,\n    app_state: State<'_, AppState>,\n) -> Result<bool, String> {\n    app_state\n        .db\n        .delete_skill_repo(&owner, &name)\n        .map_err(|e| e.to_string())?;\n    Ok(true)\n}\n\n/// 从 ZIP 文件安装 Skills\n#[tauri::command]\npub fn install_skills_from_zip(\n    file_path: String,\n    current_app: String,\n    app_state: State<'_, AppState>,\n) -> Result<Vec<InstalledSkill>, String> {\n    let app_type = parse_app_type(&current_app)?;\n    let path = std::path::Path::new(&file_path);\n\n    SkillService::install_from_zip(&app_state.db, path, &app_type).map_err(|e| e.to_string())\n}\n"
  },
  {
    "path": "src-tauri/src/commands/stream_check.rs",
    "content": "//! 流式健康检查命令\n\nuse crate::app_config::AppType;\nuse crate::commands::copilot::CopilotAuthState;\nuse crate::error::AppError;\nuse crate::services::stream_check::{\n    HealthStatus, StreamCheckConfig, StreamCheckResult, StreamCheckService,\n};\nuse crate::store::AppState;\nuse std::collections::HashSet;\nuse tauri::State;\n\n/// 流式健康检查（单个供应商）\n#[tauri::command]\npub async fn stream_check_provider(\n    state: State<'_, AppState>,\n    copilot_state: State<'_, CopilotAuthState>,\n    app_type: AppType,\n    provider_id: String,\n) -> Result<StreamCheckResult, AppError> {\n    let config = state.db.get_stream_check_config()?;\n\n    let providers = state.db.get_all_providers(app_type.as_str())?;\n    let provider = providers\n        .get(&provider_id)\n        .ok_or_else(|| AppError::Message(format!(\"供应商 {provider_id} 不存在\")))?;\n\n    let auth_override = resolve_copilot_auth_override(provider, &copilot_state).await?;\n    let result =\n        StreamCheckService::check_with_retry(&app_type, provider, &config, auth_override).await?;\n\n    // 记录日志\n    let _ =\n        state\n            .db\n            .save_stream_check_log(&provider_id, &provider.name, app_type.as_str(), &result);\n\n    Ok(result)\n}\n\n/// 批量流式健康检查\n#[tauri::command]\npub async fn stream_check_all_providers(\n    state: State<'_, AppState>,\n    copilot_state: State<'_, CopilotAuthState>,\n    app_type: AppType,\n    proxy_targets_only: bool,\n) -> Result<Vec<(String, StreamCheckResult)>, AppError> {\n    let config = state.db.get_stream_check_config()?;\n    let providers = state.db.get_all_providers(app_type.as_str())?;\n\n    let mut results = Vec::new();\n    let allowed_ids: Option<HashSet<String>> = if proxy_targets_only {\n        let mut ids = HashSet::new();\n        if let Ok(Some(current_id)) = state.db.get_current_provider(app_type.as_str()) {\n            ids.insert(current_id);\n        }\n        if let Ok(queue) = state.db.get_failover_queue(app_type.as_str()) {\n            for item in queue {\n                ids.insert(item.provider_id);\n            }\n        }\n        Some(ids)\n    } else {\n        None\n    };\n\n    for (id, provider) in providers {\n        if let Some(ids) = &allowed_ids {\n            if !ids.contains(&id) {\n                continue;\n            }\n        }\n\n        let auth_override = resolve_copilot_auth_override(&provider, &copilot_state).await?;\n        let result =\n            StreamCheckService::check_with_retry(&app_type, &provider, &config, auth_override)\n                .await\n                .unwrap_or_else(|e| StreamCheckResult {\n                    status: HealthStatus::Failed,\n                    success: false,\n                    message: e.to_string(),\n                    response_time_ms: None,\n                    http_status: None,\n                    model_used: String::new(),\n                    tested_at: chrono::Utc::now().timestamp(),\n                    retry_count: 0,\n                });\n\n        let _ = state\n            .db\n            .save_stream_check_log(&id, &provider.name, app_type.as_str(), &result);\n\n        results.push((id, result));\n    }\n\n    Ok(results)\n}\n\n/// 获取流式检查配置\n#[tauri::command]\npub fn get_stream_check_config(state: State<'_, AppState>) -> Result<StreamCheckConfig, AppError> {\n    state.db.get_stream_check_config()\n}\n\n/// 保存流式检查配置\n#[tauri::command]\npub fn save_stream_check_config(\n    state: State<'_, AppState>,\n    config: StreamCheckConfig,\n) -> Result<(), AppError> {\n    state.db.save_stream_check_config(&config)\n}\n\nasync fn resolve_copilot_auth_override(\n    provider: &crate::provider::Provider,\n    copilot_state: &State<'_, CopilotAuthState>,\n) -> Result<Option<crate::proxy::providers::AuthInfo>, AppError> {\n    let is_copilot = provider\n        .meta\n        .as_ref()\n        .and_then(|meta| meta.provider_type.as_deref())\n        == Some(\"github_copilot\")\n        || provider\n            .settings_config\n            .pointer(\"/env/ANTHROPIC_BASE_URL\")\n            .and_then(|value| value.as_str())\n            .map(|url| url.contains(\"githubcopilot.com\"))\n            .unwrap_or(false);\n\n    if !is_copilot {\n        return Ok(None);\n    }\n\n    let auth_manager = copilot_state.0.read().await;\n    let account_id = provider\n        .meta\n        .as_ref()\n        .and_then(|meta| meta.github_account_id.clone());\n\n    let token = match account_id.as_deref() {\n        Some(id) => auth_manager\n            .get_valid_token_for_account(id)\n            .await\n            .map_err(|e| AppError::Message(format!(\"GitHub Copilot 认证失败: {e}\")))?,\n        None => auth_manager\n            .get_valid_token()\n            .await\n            .map_err(|e| AppError::Message(format!(\"GitHub Copilot 认证失败: {e}\")))?,\n    };\n\n    Ok(Some(crate::proxy::providers::AuthInfo::new(\n        token,\n        crate::proxy::providers::AuthStrategy::GitHubCopilot,\n    )))\n}\n"
  },
  {
    "path": "src-tauri/src/commands/sync_support.rs",
    "content": "use serde_json::{json, Value};\nuse std::sync::Arc;\n\nuse crate::database::Database;\nuse crate::error::AppError;\nuse crate::services::provider::ProviderService;\nuse crate::settings;\nuse crate::store::AppState;\n\npub(crate) fn run_post_import_sync(db: Arc<Database>) -> Result<(), AppError> {\n    let app_state = AppState::new(db);\n    ProviderService::sync_current_to_live(&app_state)?;\n    settings::reload_settings()?;\n    Ok(())\n}\n\nfn post_sync_warning<E: std::fmt::Display>(err: E) -> String {\n    AppError::localized(\n        \"sync.post_operation_sync_failed\",\n        format!(\"后置同步状态失败: {err}\"),\n        format!(\"Post-operation synchronization failed: {err}\"),\n    )\n    .to_string()\n}\n\npub(crate) fn post_sync_warning_from_result(\n    result: Result<Result<(), AppError>, String>,\n) -> Option<String> {\n    match result {\n        Ok(Ok(())) => None,\n        Ok(Err(err)) => Some(post_sync_warning(err)),\n        Err(err) => Some(post_sync_warning(err)),\n    }\n}\n\npub(crate) fn attach_warning(mut value: Value, warning: Option<String>) -> Value {\n    if let Some(message) = warning {\n        if let Some(obj) = value.as_object_mut() {\n            obj.insert(\"warning\".to_string(), Value::String(message));\n        }\n    }\n    value\n}\n\npub(crate) fn success_payload_with_warning(backup_id: String, warning: Option<String>) -> Value {\n    attach_warning(\n        json!({\n            \"success\": true,\n            \"message\": \"SQL imported successfully\",\n            \"backupId\": backup_id\n        }),\n        warning,\n    )\n}\n\n#[cfg(test)]\nmod tests {\n    use super::{attach_warning, post_sync_warning_from_result};\n    use serde_json::json;\n\n    #[test]\n    fn post_sync_warning_from_result_returns_none_on_success() {\n        let warning = post_sync_warning_from_result(Ok(Ok(())));\n        assert!(warning.is_none());\n    }\n\n    #[test]\n    fn post_sync_warning_from_result_returns_some_on_sync_error() {\n        let warning =\n            post_sync_warning_from_result(Ok(Err(crate::error::AppError::Config(\"boom\".into()))));\n        assert!(warning.is_some());\n    }\n\n    #[tokio::test]\n    async fn post_sync_warning_from_result_returns_some_on_join_error() {\n        let handle = tokio::spawn(async move {\n            panic!(\"forced join error\");\n        });\n        let join_err = handle.await.expect_err(\"task should panic\");\n        let warning = post_sync_warning_from_result(Err(join_err.to_string()));\n        assert!(warning.is_some());\n    }\n\n    #[test]\n    fn attach_warning_adds_warning_without_dropping_existing_fields() {\n        let payload = json!({ \"status\": \"downloaded\" });\n        let updated = attach_warning(payload, Some(\"post sync warning\".to_string()));\n        assert_eq!(\n            updated.get(\"status\").and_then(|v| v.as_str()),\n            Some(\"downloaded\")\n        );\n        assert_eq!(\n            updated.get(\"warning\").and_then(|v| v.as_str()),\n            Some(\"post sync warning\")\n        );\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/commands/usage.rs",
    "content": "//! 使用统计相关命令\n\nuse crate::error::AppError;\nuse crate::services::usage_stats::*;\nuse crate::store::AppState;\nuse tauri::State;\n\n/// 获取使用量汇总\n#[tauri::command]\npub fn get_usage_summary(\n    state: State<'_, AppState>,\n    start_date: Option<i64>,\n    end_date: Option<i64>,\n) -> Result<UsageSummary, AppError> {\n    state.db.get_usage_summary(start_date, end_date)\n}\n\n/// 获取每日趋势\n#[tauri::command]\npub fn get_usage_trends(\n    state: State<'_, AppState>,\n    start_date: Option<i64>,\n    end_date: Option<i64>,\n) -> Result<Vec<DailyStats>, AppError> {\n    state.db.get_daily_trends(start_date, end_date)\n}\n\n/// 获取 Provider 统计\n#[tauri::command]\npub fn get_provider_stats(state: State<'_, AppState>) -> Result<Vec<ProviderStats>, AppError> {\n    state.db.get_provider_stats()\n}\n\n/// 获取模型统计\n#[tauri::command]\npub fn get_model_stats(state: State<'_, AppState>) -> Result<Vec<ModelStats>, AppError> {\n    state.db.get_model_stats()\n}\n\n/// 获取请求日志列表\n#[tauri::command]\npub fn get_request_logs(\n    state: State<'_, AppState>,\n    filters: LogFilters,\n    page: u32,\n    page_size: u32,\n) -> Result<PaginatedLogs, AppError> {\n    state.db.get_request_logs(&filters, page, page_size)\n}\n\n/// 获取单个请求详情\n#[tauri::command]\npub fn get_request_detail(\n    state: State<'_, AppState>,\n    request_id: String,\n) -> Result<Option<RequestLogDetail>, AppError> {\n    state.db.get_request_detail(&request_id)\n}\n\n/// 获取模型定价列表\n#[tauri::command]\npub fn get_model_pricing(state: State<'_, AppState>) -> Result<Vec<ModelPricingInfo>, AppError> {\n    log::info!(\"获取模型定价列表\");\n    state.db.ensure_model_pricing_seeded()?;\n\n    let db = state.db.clone();\n    let conn = crate::database::lock_conn!(db.conn);\n\n    // 检查表是否存在\n    let table_exists: bool = conn\n        .query_row(\n            \"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='model_pricing'\",\n            [],\n            |row| row.get::<_, i64>(0).map(|count| count > 0),\n        )\n        .unwrap_or(false);\n\n    if !table_exists {\n        log::error!(\"model_pricing 表不存在,可能需要重启应用以触发数据库迁移\");\n        return Ok(Vec::new());\n    }\n\n    let mut stmt = conn.prepare(\n        \"SELECT model_id, display_name, input_cost_per_million, output_cost_per_million,\n                cache_read_cost_per_million, cache_creation_cost_per_million\n         FROM model_pricing\n         ORDER BY display_name\",\n    )?;\n\n    let rows = stmt.query_map([], |row| {\n        Ok(ModelPricingInfo {\n            model_id: row.get(0)?,\n            display_name: row.get(1)?,\n            input_cost_per_million: row.get(2)?,\n            output_cost_per_million: row.get(3)?,\n            cache_read_cost_per_million: row.get(4)?,\n            cache_creation_cost_per_million: row.get(5)?,\n        })\n    })?;\n\n    let mut pricing = Vec::new();\n    for row in rows {\n        pricing.push(row?);\n    }\n\n    log::info!(\"成功获取 {} 条模型定价数据\", pricing.len());\n    Ok(pricing)\n}\n\n/// 更新模型定价\n#[tauri::command]\npub fn update_model_pricing(\n    state: State<'_, AppState>,\n    model_id: String,\n    display_name: String,\n    input_cost: String,\n    output_cost: String,\n    cache_read_cost: String,\n    cache_creation_cost: String,\n) -> Result<(), AppError> {\n    let db = state.db.clone();\n    let conn = crate::database::lock_conn!(db.conn);\n\n    conn.execute(\n        \"INSERT OR REPLACE INTO model_pricing (\n            model_id, display_name, input_cost_per_million, output_cost_per_million,\n            cache_read_cost_per_million, cache_creation_cost_per_million\n        ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)\",\n        rusqlite::params![\n            model_id,\n            display_name,\n            input_cost,\n            output_cost,\n            cache_read_cost,\n            cache_creation_cost\n        ],\n    )\n    .map_err(|e| AppError::Database(format!(\"更新模型定价失败: {e}\")))?;\n\n    Ok(())\n}\n\n/// 检查 Provider 使用限额\n#[tauri::command]\npub fn check_provider_limits(\n    state: State<'_, AppState>,\n    provider_id: String,\n    app_type: String,\n) -> Result<crate::services::usage_stats::ProviderLimitStatus, AppError> {\n    state.db.check_provider_limits(&provider_id, &app_type)\n}\n\n/// 删除模型定价\n#[tauri::command]\npub fn delete_model_pricing(state: State<'_, AppState>, model_id: String) -> Result<(), AppError> {\n    let db = state.db.clone();\n    let conn = crate::database::lock_conn!(db.conn);\n\n    conn.execute(\n        \"DELETE FROM model_pricing WHERE model_id = ?1\",\n        rusqlite::params![model_id],\n    )\n    .map_err(|e| AppError::Database(format!(\"删除模型定价失败: {e}\")))?;\n\n    log::info!(\"已删除模型定价: {model_id}\");\n    Ok(())\n}\n\n/// 模型定价信息\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct ModelPricingInfo {\n    pub model_id: String,\n    pub display_name: String,\n    pub input_cost_per_million: String,\n    pub output_cost_per_million: String,\n    pub cache_read_cost_per_million: String,\n    pub cache_creation_cost_per_million: String,\n}\n"
  },
  {
    "path": "src-tauri/src/commands/webdav_sync.rs",
    "content": "#![allow(non_snake_case)]\n\nuse serde_json::{json, Value};\nuse tauri::State;\n\nuse crate::commands::sync_support::{\n    attach_warning, post_sync_warning_from_result, run_post_import_sync,\n};\nuse crate::error::AppError;\nuse crate::services::webdav_sync as webdav_sync_service;\nuse crate::settings::{self, WebDavSyncSettings};\nuse crate::store::AppState;\n\nfn persist_sync_error(settings: &mut WebDavSyncSettings, error: &AppError, source: &str) {\n    settings.status.last_error = Some(error.to_string());\n    settings.status.last_error_source = Some(source.to_string());\n    let _ = settings::update_webdav_sync_status(settings.status.clone());\n}\n\nfn webdav_not_configured_error() -> String {\n    AppError::localized(\n        \"webdav.sync.not_configured\",\n        \"未配置 WebDAV 同步\",\n        \"WebDAV sync is not configured.\",\n    )\n    .to_string()\n}\n\nfn webdav_sync_disabled_error() -> String {\n    AppError::localized(\n        \"webdav.sync.disabled\",\n        \"WebDAV 同步未启用\",\n        \"WebDAV sync is disabled.\",\n    )\n    .to_string()\n}\n\nfn require_enabled_webdav_settings() -> Result<WebDavSyncSettings, String> {\n    let settings = settings::get_webdav_sync_settings().ok_or_else(webdav_not_configured_error)?;\n    if !settings.enabled {\n        return Err(webdav_sync_disabled_error());\n    }\n    Ok(settings)\n}\n\nfn resolve_password_for_request(\n    mut incoming: WebDavSyncSettings,\n    existing: Option<WebDavSyncSettings>,\n    preserve_empty_password: bool,\n) -> WebDavSyncSettings {\n    if let Some(existing_settings) = existing {\n        if preserve_empty_password && incoming.password.is_empty() {\n            incoming.password = existing_settings.password;\n        }\n    }\n    incoming\n}\n\n#[cfg(test)]\nfn webdav_sync_mutex() -> &'static tokio::sync::Mutex<()> {\n    webdav_sync_service::sync_mutex()\n}\n\nasync fn run_with_webdav_lock<T, Fut>(operation: Fut) -> Result<T, AppError>\nwhere\n    Fut: std::future::Future<Output = Result<T, AppError>>,\n{\n    webdav_sync_service::run_with_sync_lock(operation).await\n}\n\nfn map_sync_result<T, F>(result: Result<T, AppError>, on_error: F) -> Result<T, String>\nwhere\n    F: FnOnce(&AppError),\n{\n    match result {\n        Ok(value) => Ok(value),\n        Err(err) => {\n            on_error(&err);\n            Err(err.to_string())\n        }\n    }\n}\n\n#[tauri::command]\npub async fn webdav_test_connection(\n    settings: WebDavSyncSettings,\n    #[allow(non_snake_case)] preserveEmptyPassword: Option<bool>,\n) -> Result<Value, String> {\n    let preserve_empty = preserveEmptyPassword.unwrap_or(true);\n    let resolved = resolve_password_for_request(\n        settings,\n        settings::get_webdav_sync_settings(),\n        preserve_empty,\n    );\n    webdav_sync_service::check_connection(&resolved)\n        .await\n        .map_err(|e| e.to_string())?;\n    Ok(json!({\n        \"success\": true,\n        \"message\": \"WebDAV connection ok\"\n    }))\n}\n\n#[tauri::command]\npub async fn webdav_sync_upload(state: State<'_, AppState>) -> Result<Value, String> {\n    let db = state.db.clone();\n    let mut settings = require_enabled_webdav_settings()?;\n\n    let result = run_with_webdav_lock(webdav_sync_service::upload(&db, &mut settings)).await;\n    map_sync_result(result, |error| {\n        persist_sync_error(&mut settings, error, \"manual\")\n    })\n}\n\n#[tauri::command]\npub async fn webdav_sync_download(state: State<'_, AppState>) -> Result<Value, String> {\n    let db = state.db.clone();\n    let db_for_sync = db.clone();\n    let mut settings = require_enabled_webdav_settings()?;\n    let _auto_sync_suppression = crate::services::webdav_auto_sync::AutoSyncSuppressionGuard::new();\n\n    let sync_result = run_with_webdav_lock(webdav_sync_service::download(&db, &mut settings)).await;\n    let mut result = map_sync_result(sync_result, |error| {\n        persist_sync_error(&mut settings, error, \"manual\")\n    })?;\n\n    // Post-download sync is best-effort: snapshot restore has already succeeded.\n    let warning = post_sync_warning_from_result(\n        tauri::async_runtime::spawn_blocking(move || run_post_import_sync(db_for_sync))\n            .await\n            .map_err(|e| e.to_string()),\n    );\n    if let Some(msg) = warning.as_ref() {\n        log::warn!(\"[WebDAV] post-download sync warning: {msg}\");\n    }\n    result = attach_warning(result, warning);\n\n    Ok(result)\n}\n\n#[tauri::command]\npub async fn webdav_sync_save_settings(\n    settings: WebDavSyncSettings,\n    #[allow(non_snake_case)] passwordTouched: Option<bool>,\n) -> Result<Value, String> {\n    let password_touched = passwordTouched.unwrap_or(false);\n    let existing = settings::get_webdav_sync_settings();\n    let mut sync_settings =\n        resolve_password_for_request(settings, existing.clone(), !password_touched);\n\n    // Preserve server-owned fields that the frontend does not manage\n    if let Some(existing_settings) = existing {\n        sync_settings.status = existing_settings.status;\n    }\n\n    sync_settings.normalize();\n    sync_settings.validate().map_err(|e| e.to_string())?;\n    settings::set_webdav_sync_settings(Some(sync_settings)).map_err(|e| e.to_string())?;\n    Ok(json!({ \"success\": true }))\n}\n\n#[tauri::command]\npub async fn webdav_sync_fetch_remote_info() -> Result<Value, String> {\n    let settings = require_enabled_webdav_settings()?;\n    let info = webdav_sync_service::fetch_remote_info(&settings)\n        .await\n        .map_err(|e| e.to_string())?;\n    Ok(info.unwrap_or(json!({ \"empty\": true })))\n}\n\n#[cfg(test)]\nmod tests {\n    use super::{\n        map_sync_result, persist_sync_error, require_enabled_webdav_settings,\n        resolve_password_for_request, run_with_webdav_lock, webdav_sync_mutex,\n    };\n    use crate::error::AppError;\n    use crate::settings::{AppSettings, WebDavSyncSettings};\n    use serial_test::serial;\n    use std::sync::atomic::{AtomicBool, Ordering};\n    use std::sync::Arc;\n    use std::time::Duration;\n\n    #[tokio::test]\n    async fn webdav_sync_mutex_is_singleton() {\n        let a = webdav_sync_mutex() as *const _;\n        let b = webdav_sync_mutex() as *const _;\n        assert_eq!(a, b);\n    }\n\n    #[tokio::test]\n    #[serial]\n    async fn webdav_sync_mutex_serializes_concurrent_access() {\n        let guard = webdav_sync_mutex().lock().await;\n        let acquired = Arc::new(AtomicBool::new(false));\n        let acquired_bg = Arc::clone(&acquired);\n\n        let waiter = tokio::spawn(async move {\n            let _inner_guard = webdav_sync_mutex().lock().await;\n            acquired_bg.store(true, Ordering::SeqCst);\n        });\n\n        tokio::time::sleep(Duration::from_millis(40)).await;\n        assert!(!acquired.load(Ordering::SeqCst));\n\n        drop(guard);\n        tokio::time::timeout(Duration::from_secs(1), waiter)\n            .await\n            .expect(\"background task should complete after lock release\")\n            .expect(\"background task should not panic\");\n\n        assert!(acquired.load(Ordering::SeqCst));\n    }\n\n    #[tokio::test]\n    #[serial]\n    async fn map_sync_result_runs_error_handler_after_lock_release() {\n        let result = run_with_webdav_lock(async {\n            Err::<(), AppError>(AppError::Config(\"boom\".to_string()))\n        })\n        .await;\n\n        let mut lock_released = false;\n        let mapped = map_sync_result(result, |_| {\n            lock_released = webdav_sync_mutex().try_lock().is_ok();\n        });\n\n        assert!(mapped.is_err());\n        assert!(lock_released);\n    }\n\n    #[test]\n    fn resolve_password_for_request_preserves_existing_when_requested() {\n        let incoming = WebDavSyncSettings {\n            base_url: \"https://dav.example.com\".to_string(),\n            username: \"alice\".to_string(),\n            password: String::new(),\n            ..WebDavSyncSettings::default()\n        };\n        let existing = Some(WebDavSyncSettings {\n            password: \"secret\".to_string(),\n            ..WebDavSyncSettings::default()\n        });\n        let resolved = resolve_password_for_request(incoming, existing, true);\n        assert_eq!(resolved.password, \"secret\");\n    }\n\n    #[test]\n    fn resolve_password_for_request_allows_explicit_empty_password() {\n        let incoming = WebDavSyncSettings {\n            base_url: \"https://dav.example.com\".to_string(),\n            username: \"alice\".to_string(),\n            password: String::new(),\n            ..WebDavSyncSettings::default()\n        };\n        let existing = Some(WebDavSyncSettings {\n            password: \"secret\".to_string(),\n            ..WebDavSyncSettings::default()\n        });\n        let resolved = resolve_password_for_request(incoming, existing, false);\n        assert!(resolved.password.is_empty());\n    }\n\n    #[test]\n    #[serial]\n    fn persist_sync_error_updates_status_without_overwriting_credentials() {\n        let test_home = std::env::temp_dir().join(\"cc-switch-sync-error-status-test\");\n        let _ = std::fs::remove_dir_all(&test_home);\n        std::fs::create_dir_all(&test_home).expect(\"create test home\");\n        std::env::set_var(\"CC_SWITCH_TEST_HOME\", &test_home);\n\n        crate::settings::update_settings(AppSettings::default()).expect(\"reset settings\");\n        let mut current = WebDavSyncSettings {\n            enabled: true,\n            base_url: \"https://dav.example.com/dav/\".to_string(),\n            username: \"alice\".to_string(),\n            password: \"secret\".to_string(),\n            remote_root: \"cc-switch-sync\".to_string(),\n            profile: \"default\".to_string(),\n            ..WebDavSyncSettings::default()\n        };\n        crate::settings::set_webdav_sync_settings(Some(current.clone()))\n            .expect(\"seed webdav settings\");\n\n        persist_sync_error(\n            &mut current,\n            &crate::error::AppError::Config(\"boom\".to_string()),\n            \"manual\",\n        );\n\n        let after = crate::settings::get_webdav_sync_settings().expect(\"read webdav settings\");\n        assert_eq!(after.base_url, \"https://dav.example.com/dav/\");\n        assert_eq!(after.username, \"alice\");\n        assert_eq!(after.password, \"secret\");\n        assert_eq!(after.remote_root, \"cc-switch-sync\");\n        assert_eq!(after.profile, \"default\");\n        assert!(\n            after\n                .status\n                .last_error\n                .as_deref()\n                .unwrap_or_default()\n                .contains(\"boom\"),\n            \"status error should be updated\"\n        );\n        assert_eq!(after.status.last_error_source.as_deref(), Some(\"manual\"));\n    }\n\n    #[test]\n    #[serial]\n    fn require_enabled_webdav_settings_rejects_disabled_config() {\n        let test_home = std::env::temp_dir().join(\"cc-switch-sync-enabled-disabled-test\");\n        let _ = std::fs::remove_dir_all(&test_home);\n        std::fs::create_dir_all(&test_home).expect(\"create test home\");\n        std::env::set_var(\"CC_SWITCH_TEST_HOME\", &test_home);\n\n        crate::settings::update_settings(AppSettings::default()).expect(\"reset settings\");\n        crate::settings::set_webdav_sync_settings(Some(WebDavSyncSettings {\n            enabled: false,\n            base_url: \"https://dav.example.com/dav/\".to_string(),\n            username: \"alice\".to_string(),\n            password: \"secret\".to_string(),\n            ..WebDavSyncSettings::default()\n        }))\n        .expect(\"seed disabled webdav settings\");\n\n        let err = require_enabled_webdav_settings().expect_err(\"disabled settings should fail\");\n        assert!(\n            err.contains(\"disabled\") || err.contains(\"未启用\"),\n            \"unexpected error: {err}\"\n        );\n    }\n\n    #[test]\n    #[serial]\n    fn require_enabled_webdav_settings_returns_settings_when_enabled() {\n        let test_home = std::env::temp_dir().join(\"cc-switch-sync-enabled-ok-test\");\n        let _ = std::fs::remove_dir_all(&test_home);\n        std::fs::create_dir_all(&test_home).expect(\"create test home\");\n        std::env::set_var(\"CC_SWITCH_TEST_HOME\", &test_home);\n\n        crate::settings::update_settings(AppSettings::default()).expect(\"reset settings\");\n        crate::settings::set_webdav_sync_settings(Some(WebDavSyncSettings {\n            enabled: true,\n            base_url: \"https://dav.example.com/dav/\".to_string(),\n            username: \"alice\".to_string(),\n            password: \"secret\".to_string(),\n            ..WebDavSyncSettings::default()\n        }))\n        .expect(\"seed enabled webdav settings\");\n\n        let settings =\n            require_enabled_webdav_settings().expect(\"enabled settings should be accepted\");\n        assert!(settings.enabled);\n        assert_eq!(settings.base_url, \"https://dav.example.com/dav/\");\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/commands/workspace.rs",
    "content": "use regex::Regex;\nuse std::sync::LazyLock;\nuse tauri::AppHandle;\nuse tauri_plugin_opener::OpenerExt;\n\nuse crate::config::write_text_file;\nuse crate::openclaw_config::get_openclaw_dir;\n\n/// Allowed workspace filenames (whitelist for security)\nconst ALLOWED_FILES: &[&str] = &[\n    \"AGENTS.md\",\n    \"SOUL.md\",\n    \"USER.md\",\n    \"IDENTITY.md\",\n    \"TOOLS.md\",\n    \"MEMORY.md\",\n    \"HEARTBEAT.md\",\n    \"BOOTSTRAP.md\",\n    \"BOOT.md\",\n];\n\nfn validate_filename(filename: &str) -> Result<(), String> {\n    if !ALLOWED_FILES.contains(&filename) {\n        return Err(format!(\n            \"Invalid workspace filename: {filename}. Allowed: {}\",\n            ALLOWED_FILES.join(\", \")\n        ));\n    }\n    Ok(())\n}\n\n// --- Daily memory files (memory/YYYY-MM-DD.md) ---\n\nstatic DAILY_MEMORY_RE: LazyLock<Regex> =\n    LazyLock::new(|| Regex::new(r\"^\\d{4}-\\d{2}-\\d{2}\\.md$\").unwrap());\n\nfn validate_daily_memory_filename(filename: &str) -> Result<(), String> {\n    if !DAILY_MEMORY_RE.is_match(filename) {\n        return Err(format!(\n            \"Invalid daily memory filename: {filename}. Expected: YYYY-MM-DD.md\"\n        ));\n    }\n    Ok(())\n}\n\n#[derive(serde::Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct DailyMemoryFileInfo {\n    pub filename: String,\n    pub date: String,\n    pub size_bytes: u64,\n    pub modified_at: u64,\n    pub preview: String,\n}\n\n// --- Daily memory commands ---\n\n/// List all daily memory files under `workspace/memory/`.\n#[tauri::command]\npub async fn list_daily_memory_files() -> Result<Vec<DailyMemoryFileInfo>, String> {\n    let memory_dir = get_openclaw_dir().join(\"workspace\").join(\"memory\");\n\n    if !memory_dir.exists() {\n        return Ok(Vec::new());\n    }\n\n    let mut files: Vec<DailyMemoryFileInfo> = Vec::new();\n\n    let entries = std::fs::read_dir(&memory_dir)\n        .map_err(|e| format!(\"Failed to read memory directory: {e}\"))?;\n\n    for entry in entries.flatten() {\n        let name = entry.file_name().to_string_lossy().to_string();\n        if !name.ends_with(\".md\") {\n            continue;\n        }\n\n        let meta = match entry.metadata() {\n            Ok(m) => m,\n            Err(_) => continue,\n        };\n        if !meta.is_file() {\n            continue;\n        }\n\n        let date = name.trim_end_matches(\".md\").to_string();\n\n        let size_bytes = meta.len();\n        let modified_at = meta\n            .modified()\n            .ok()\n            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())\n            .map(|d| d.as_secs())\n            .unwrap_or(0);\n\n        let preview = std::fs::read_to_string(entry.path())\n            .unwrap_or_default()\n            .chars()\n            .take(200)\n            .collect::<String>();\n\n        files.push(DailyMemoryFileInfo {\n            filename: name,\n            date,\n            size_bytes,\n            modified_at,\n            preview,\n        });\n    }\n\n    // Sort by filename descending (newest date first, YYYY-MM-DD.md)\n    files.sort_by(|a, b| b.filename.cmp(&a.filename));\n\n    Ok(files)\n}\n\n/// Read a daily memory file.\n#[tauri::command]\npub async fn read_daily_memory_file(filename: String) -> Result<Option<String>, String> {\n    validate_daily_memory_filename(&filename)?;\n\n    let path = get_openclaw_dir()\n        .join(\"workspace\")\n        .join(\"memory\")\n        .join(&filename);\n\n    if !path.exists() {\n        return Ok(None);\n    }\n\n    std::fs::read_to_string(&path)\n        .map(Some)\n        .map_err(|e| format!(\"Failed to read daily memory file {filename}: {e}\"))\n}\n\n/// Write a daily memory file (atomic write).\n#[tauri::command]\npub async fn write_daily_memory_file(filename: String, content: String) -> Result<(), String> {\n    validate_daily_memory_filename(&filename)?;\n\n    let memory_dir = get_openclaw_dir().join(\"workspace\").join(\"memory\");\n\n    std::fs::create_dir_all(&memory_dir)\n        .map_err(|e| format!(\"Failed to create memory directory: {e}\"))?;\n\n    let path = memory_dir.join(&filename);\n\n    write_text_file(&path, &content)\n        .map_err(|e| format!(\"Failed to write daily memory file {filename}: {e}\"))\n}\n\n/// Find the largest index `<= i` that is a valid UTF-8 char boundary.\n/// Equivalent to the unstable `str::floor_char_boundary` (stabilized in 1.91).\nfn floor_char_boundary(s: &str, mut i: usize) -> usize {\n    if i >= s.len() {\n        return s.len();\n    }\n    while !s.is_char_boundary(i) {\n        i -= 1;\n    }\n    i\n}\n\n/// Find the smallest index `>= i` that is a valid UTF-8 char boundary.\n/// Equivalent to the unstable `str::ceil_char_boundary` (stabilized in 1.91).\nfn ceil_char_boundary(s: &str, mut i: usize) -> usize {\n    if i >= s.len() {\n        return s.len();\n    }\n    while !s.is_char_boundary(i) {\n        i += 1;\n    }\n    i\n}\n\n/// Search result for daily memory full-text search.\n#[derive(serde::Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct DailyMemorySearchResult {\n    pub filename: String,\n    pub date: String,\n    pub size_bytes: u64,\n    pub modified_at: u64,\n    pub snippet: String,\n    pub match_count: usize,\n}\n\n/// Full-text search across all daily memory files.\n///\n/// Performs case-insensitive search on both the date field and file content.\n/// Returns results sorted by filename descending (newest first), each with a\n/// snippet showing ~120 characters of context around the first match.\n#[tauri::command]\npub async fn search_daily_memory_files(\n    query: String,\n) -> Result<Vec<DailyMemorySearchResult>, String> {\n    let memory_dir = get_openclaw_dir().join(\"workspace\").join(\"memory\");\n\n    if !memory_dir.exists() || query.is_empty() {\n        return Ok(Vec::new());\n    }\n\n    let query_lower = query.to_lowercase();\n    let mut results: Vec<DailyMemorySearchResult> = Vec::new();\n\n    let entries = std::fs::read_dir(&memory_dir)\n        .map_err(|e| format!(\"Failed to read memory directory: {e}\"))?;\n\n    for entry in entries.flatten() {\n        let name = entry.file_name().to_string_lossy().to_string();\n        if !name.ends_with(\".md\") {\n            continue;\n        }\n        let meta = match entry.metadata() {\n            Ok(m) if m.is_file() => m,\n            _ => continue,\n        };\n\n        let date = name.trim_end_matches(\".md\").to_string();\n        let content = std::fs::read_to_string(entry.path()).unwrap_or_default();\n        let content_lower = content.to_lowercase();\n\n        // Count matches in content\n        let content_matches: Vec<usize> = content_lower\n            .match_indices(&query_lower)\n            .map(|(i, _)| i)\n            .collect();\n\n        // Also check date field\n        let date_matches = date.to_lowercase().contains(&query_lower);\n\n        if content_matches.is_empty() && !date_matches {\n            continue;\n        }\n\n        // Build snippet around first content match (~120 chars of context)\n        let snippet = if let Some(&first_pos) = content_matches.first() {\n            let start = if first_pos > 50 {\n                floor_char_boundary(&content, first_pos - 50)\n            } else {\n                0\n            };\n            let end = ceil_char_boundary(&content, (first_pos + 70).min(content.len()));\n            let mut s = String::new();\n            if start > 0 {\n                s.push_str(\"...\");\n            }\n            s.push_str(&content[start..end]);\n            if end < content.len() {\n                s.push_str(\"...\");\n            }\n            s\n        } else {\n            // Date-only match — use beginning of file as preview\n            let end = ceil_char_boundary(&content, 120.min(content.len()));\n            let mut s = content[..end].to_string();\n            if end < content.len() {\n                s.push_str(\"...\");\n            }\n            s\n        };\n\n        let size_bytes = meta.len();\n        let modified_at = meta\n            .modified()\n            .ok()\n            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())\n            .map(|d| d.as_secs())\n            .unwrap_or(0);\n\n        results.push(DailyMemorySearchResult {\n            filename: name,\n            date,\n            size_bytes,\n            modified_at,\n            snippet,\n            match_count: content_matches.len(),\n        });\n    }\n\n    // Sort by filename descending (newest date first)\n    results.sort_by(|a, b| b.filename.cmp(&a.filename));\n\n    Ok(results)\n}\n\n/// Delete a daily memory file (idempotent).\n#[tauri::command]\npub async fn delete_daily_memory_file(filename: String) -> Result<(), String> {\n    validate_daily_memory_filename(&filename)?;\n\n    let path = get_openclaw_dir()\n        .join(\"workspace\")\n        .join(\"memory\")\n        .join(&filename);\n\n    if path.exists() {\n        std::fs::remove_file(&path)\n            .map_err(|e| format!(\"Failed to delete daily memory file {filename}: {e}\"))?;\n    }\n\n    Ok(())\n}\n\n// --- Workspace file commands ---\n\n/// Read an OpenClaw workspace file content.\n/// Returns None if the file does not exist.\n#[tauri::command]\npub async fn read_workspace_file(filename: String) -> Result<Option<String>, String> {\n    validate_filename(&filename)?;\n\n    let path = get_openclaw_dir().join(\"workspace\").join(&filename);\n\n    if !path.exists() {\n        return Ok(None);\n    }\n\n    std::fs::read_to_string(&path)\n        .map(Some)\n        .map_err(|e| format!(\"Failed to read workspace file {filename}: {e}\"))\n}\n\n/// Write content to an OpenClaw workspace file (atomic write).\n/// Creates the workspace directory if it does not exist.\n#[tauri::command]\npub async fn write_workspace_file(filename: String, content: String) -> Result<(), String> {\n    validate_filename(&filename)?;\n\n    let workspace_dir = get_openclaw_dir().join(\"workspace\");\n\n    // Ensure workspace directory exists\n    std::fs::create_dir_all(&workspace_dir)\n        .map_err(|e| format!(\"Failed to create workspace directory: {e}\"))?;\n\n    let path = workspace_dir.join(&filename);\n\n    write_text_file(&path, &content)\n        .map_err(|e| format!(\"Failed to write workspace file {filename}: {e}\"))\n}\n\n/// Open the workspace or memory directory in the system file manager.\n/// `subdir`: \"workspace\" opens `~/.openclaw/workspace/`,\n///           \"memory\" opens `~/.openclaw/workspace/memory/`.\n#[tauri::command]\npub async fn open_workspace_directory(handle: AppHandle, subdir: String) -> Result<bool, String> {\n    let dir = match subdir.as_str() {\n        \"memory\" => get_openclaw_dir().join(\"workspace\").join(\"memory\"),\n        _ => get_openclaw_dir().join(\"workspace\"),\n    };\n\n    if !dir.exists() {\n        std::fs::create_dir_all(&dir).map_err(|e| format!(\"Failed to create directory: {e}\"))?;\n    }\n\n    handle\n        .opener()\n        .open_path(dir.to_string_lossy().to_string(), None::<String>)\n        .map_err(|e| format!(\"Failed to open directory: {e}\"))?;\n\n    Ok(true)\n}\n"
  },
  {
    "path": "src-tauri/src/config.rs",
    "content": "use serde::{Deserialize, Serialize};\nuse std::fs;\nuse std::io::Write;\nuse std::path::{Path, PathBuf};\n\nuse crate::error::AppError;\n\n/// 获取用户主目录，带回退和日志\n///\n/// ## Windows 注意事项\n///\n/// - `dirs::home_dir()` 在 Windows 上使用 `SHGetKnownFolderPath(FOLDERID_Profile)`，\n///   返回的是真实用户目录（类似 `C:\\\\Users\\\\Alice`），与 v3.10.2 行为一致。\n/// - 不要直接使用 `HOME` 环境变量：它可能由 Git/Cygwin/MSYS 等第三方工具注入，\n///   且不一定等于用户目录，可能导致 `.cc-switch/cc-switch.db` 路径变化，从而“看起来像数据丢失”。\n///\n/// ## 测试隔离\n///\n/// 为了让 Windows CI/本地测试能稳定隔离真实用户数据，可通过 `CC_SWITCH_TEST_HOME`\n/// 显式覆盖 home dir（仅用于测试/调试场景）。\npub fn get_home_dir() -> PathBuf {\n    if let Ok(home) = std::env::var(\"CC_SWITCH_TEST_HOME\") {\n        let trimmed = home.trim();\n        if !trimmed.is_empty() {\n            return PathBuf::from(trimmed);\n        }\n    }\n\n    dirs::home_dir().unwrap_or_else(|| {\n        log::warn!(\"无法获取用户主目录，回退到当前目录\");\n        PathBuf::from(\".\")\n    })\n}\n\n/// 获取 Claude Code 配置目录路径\npub fn get_claude_config_dir() -> PathBuf {\n    if let Some(custom) = crate::settings::get_claude_override_dir() {\n        return custom;\n    }\n\n    get_home_dir().join(\".claude\")\n}\n\n/// 默认 Claude MCP 配置文件路径 (~/.claude.json)\npub fn get_default_claude_mcp_path() -> PathBuf {\n    get_home_dir().join(\".claude.json\")\n}\n\nfn derive_mcp_path_from_override(dir: &Path) -> Option<PathBuf> {\n    let file_name = dir\n        .file_name()\n        .map(|name| name.to_string_lossy().to_string())?\n        .trim()\n        .to_string();\n    if file_name.is_empty() {\n        return None;\n    }\n    let parent = dir.parent().unwrap_or_else(|| Path::new(\"\"));\n    Some(parent.join(format!(\"{file_name}.json\")))\n}\n\n/// 获取 Claude MCP 配置文件路径，若设置了目录覆盖则与覆盖目录同级\npub fn get_claude_mcp_path() -> PathBuf {\n    if let Some(custom_dir) = crate::settings::get_claude_override_dir() {\n        if let Some(path) = derive_mcp_path_from_override(&custom_dir) {\n            return path;\n        }\n    }\n    get_default_claude_mcp_path()\n}\n\n/// 获取 Claude Code 主配置文件路径\npub fn get_claude_settings_path() -> PathBuf {\n    let dir = get_claude_config_dir();\n    let settings = dir.join(\"settings.json\");\n    if settings.exists() {\n        return settings;\n    }\n    // 兼容旧版命名：若存在旧文件则继续使用\n    let legacy = dir.join(\"claude.json\");\n    if legacy.exists() {\n        return legacy;\n    }\n    // 默认新建：回落到标准文件名 settings.json（不再生成 claude.json）\n    settings\n}\n\n/// 获取应用配置目录路径 (~/.cc-switch)\npub fn get_app_config_dir() -> PathBuf {\n    if let Some(custom) = crate::app_store::get_app_config_dir_override() {\n        return custom;\n    }\n\n    let default_dir = get_home_dir().join(\".cc-switch\");\n\n    // 兼容 v3.10.3：当用户环境存在 `HOME` 且与真实用户目录不同，\n    // v3.10.3 可能在 `HOME/.cc-switch/` 下创建/使用了数据库。\n    // 这里仅在“默认位置没有数据库”时回退到旧位置，避免再次出现“供应商消失”问题，\n    // 同时也避免新安装因为 `HOME` 被设置而写入非预期路径。\n    #[cfg(windows)]\n    {\n        let default_db = default_dir.join(\"cc-switch.db\");\n        if !default_db.exists() {\n            if let Ok(home_env) = std::env::var(\"HOME\") {\n                let trimmed = home_env.trim();\n                if !trimmed.is_empty() {\n                    let legacy_dir = PathBuf::from(trimmed).join(\".cc-switch\");\n                    if legacy_dir.join(\"cc-switch.db\").exists() {\n                        log::info!(\n                            \"Detected v3.10.3 legacy database at {}, using it instead of {}\",\n                            legacy_dir.display(),\n                            default_dir.display()\n                        );\n                        return legacy_dir;\n                    }\n                }\n            }\n        }\n    }\n\n    default_dir\n}\n\n/// 获取应用配置文件路径\npub fn get_app_config_path() -> PathBuf {\n    get_app_config_dir().join(\"config.json\")\n}\n\n/// 清理供应商名称，确保文件名安全\n#[allow(dead_code)]\npub fn sanitize_provider_name(name: &str) -> String {\n    name.chars()\n        .map(|c| match c {\n            '<' | '>' | ':' | '\"' | '/' | '\\\\' | '|' | '?' | '*' => '-',\n            _ => c,\n        })\n        .collect::<String>()\n        .to_lowercase()\n}\n\n/// 获取供应商配置文件路径\n#[allow(dead_code)]\npub fn get_provider_config_path(provider_id: &str, provider_name: Option<&str>) -> PathBuf {\n    let base_name = provider_name\n        .map(sanitize_provider_name)\n        .unwrap_or_else(|| sanitize_provider_name(provider_id));\n\n    get_claude_config_dir().join(format!(\"settings-{base_name}.json\"))\n}\n\n/// 读取 JSON 配置文件\npub fn read_json_file<T: for<'a> Deserialize<'a>>(path: &Path) -> Result<T, AppError> {\n    if !path.exists() {\n        return Err(AppError::Config(format!(\"文件不存在: {}\", path.display())));\n    }\n\n    let content = fs::read_to_string(path).map_err(|e| AppError::io(path, e))?;\n\n    serde_json::from_str(&content).map_err(|e| AppError::json(path, e))\n}\n\n/// 写入 JSON 配置文件\npub fn write_json_file<T: Serialize>(path: &Path, data: &T) -> Result<(), AppError> {\n    // 确保目录存在\n    if let Some(parent) = path.parent() {\n        fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;\n    }\n\n    let json =\n        serde_json::to_string_pretty(data).map_err(|e| AppError::JsonSerialize { source: e })?;\n\n    atomic_write(path, json.as_bytes())\n}\n\n/// 原子写入文本文件（用于 TOML/纯文本）\npub fn write_text_file(path: &Path, data: &str) -> Result<(), AppError> {\n    if let Some(parent) = path.parent() {\n        fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;\n    }\n    atomic_write(path, data.as_bytes())\n}\n\n/// 原子写入：写入临时文件后 rename 替换，避免半写状态\npub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> {\n    if let Some(parent) = path.parent() {\n        fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;\n    }\n\n    let parent = path\n        .parent()\n        .ok_or_else(|| AppError::Config(\"无效的路径\".to_string()))?;\n    let mut tmp = parent.to_path_buf();\n    let file_name = path\n        .file_name()\n        .ok_or_else(|| AppError::Config(\"无效的文件名\".to_string()))?\n        .to_string_lossy()\n        .to_string();\n    let ts = std::time::SystemTime::now()\n        .duration_since(std::time::UNIX_EPOCH)\n        .unwrap_or_default()\n        .as_nanos();\n    tmp.push(format!(\"{file_name}.tmp.{ts}\"));\n\n    {\n        let mut f = fs::File::create(&tmp).map_err(|e| AppError::io(&tmp, e))?;\n        f.write_all(data).map_err(|e| AppError::io(&tmp, e))?;\n        f.flush().map_err(|e| AppError::io(&tmp, e))?;\n    }\n\n    #[cfg(unix)]\n    {\n        use std::os::unix::fs::PermissionsExt;\n        if let Ok(meta) = fs::metadata(path) {\n            let perm = meta.permissions().mode();\n            let _ = fs::set_permissions(&tmp, fs::Permissions::from_mode(perm));\n        }\n    }\n\n    #[cfg(windows)]\n    {\n        // Windows 上 rename 目标存在会失败，先移除再重命名（尽量接近原子性）\n        if path.exists() {\n            let _ = fs::remove_file(path);\n        }\n        fs::rename(&tmp, path).map_err(|e| AppError::IoContext {\n            context: format!(\"原子替换失败: {} -> {}\", tmp.display(), path.display()),\n            source: e,\n        })?;\n    }\n\n    #[cfg(not(windows))]\n    {\n        fs::rename(&tmp, path).map_err(|e| AppError::IoContext {\n            context: format!(\"原子替换失败: {} -> {}\", tmp.display(), path.display()),\n            source: e,\n        })?;\n    }\n    Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn derive_mcp_path_from_override_preserves_folder_name() {\n        let override_dir = PathBuf::from(\"/tmp/profile/.claude\");\n        let derived = derive_mcp_path_from_override(&override_dir)\n            .expect(\"should derive path for nested dir\");\n        assert_eq!(derived, PathBuf::from(\"/tmp/profile/.claude.json\"));\n    }\n\n    #[test]\n    fn derive_mcp_path_from_override_handles_non_hidden_folder() {\n        let override_dir = PathBuf::from(\"/data/claude-config\");\n        let derived = derive_mcp_path_from_override(&override_dir)\n            .expect(\"should derive path for standard dir\");\n        assert_eq!(derived, PathBuf::from(\"/data/claude-config.json\"));\n    }\n\n    #[test]\n    fn derive_mcp_path_from_override_supports_relative_rootless_dir() {\n        let override_dir = PathBuf::from(\"claude\");\n        let derived = derive_mcp_path_from_override(&override_dir)\n            .expect(\"should derive path for single segment\");\n        assert_eq!(derived, PathBuf::from(\"claude.json\"));\n    }\n\n    #[test]\n    fn derive_mcp_path_from_root_like_dir_returns_none() {\n        let override_dir = PathBuf::from(\"/\");\n        assert!(derive_mcp_path_from_override(&override_dir).is_none());\n    }\n}\n\n/// 复制文件\npub fn copy_file(from: &Path, to: &Path) -> Result<(), AppError> {\n    fs::copy(from, to).map_err(|e| AppError::IoContext {\n        context: format!(\"复制文件失败 ({} -> {})\", from.display(), to.display()),\n        source: e,\n    })?;\n    Ok(())\n}\n\n/// 删除文件\npub fn delete_file(path: &Path) -> Result<(), AppError> {\n    if path.exists() {\n        fs::remove_file(path).map_err(|e| AppError::io(path, e))?;\n    }\n    Ok(())\n}\n\n/// 检查 Claude Code 配置状态\n#[derive(Serialize, Deserialize)]\npub struct ConfigStatus {\n    pub exists: bool,\n    pub path: String,\n}\n\n/// 获取 Claude Code 配置状态\npub fn get_claude_config_status() -> ConfigStatus {\n    let path = get_claude_settings_path();\n    ConfigStatus {\n        exists: path.exists(),\n        path: path.to_string_lossy().to_string(),\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/database/backup.rs",
    "content": "//! 数据库备份和恢复\n//!\n//! 提供 SQL 导出/导入和二进制快照备份功能。\n\nuse super::{lock_conn, Database};\nuse crate::config::get_app_config_dir;\nuse crate::error::AppError;\nuse chrono::{Local, Utc};\nuse rusqlite::backup::Backup;\nuse rusqlite::types::ValueRef;\nuse rusqlite::Connection;\nuse std::fs;\nuse std::path::{Path, PathBuf};\nuse tempfile::NamedTempFile;\n\nconst CC_SWITCH_SQL_EXPORT_HEADER: &str = \"-- CC Switch SQLite 导出\";\n\n/// Tables whose data rows are skipped when exporting for WebDAV sync.\nconst SYNC_SKIP_TABLES: &[&str] = &[\n    \"proxy_request_logs\",\n    \"stream_check_logs\",\n    \"provider_health\",\n    \"proxy_live_backup\",\n    \"usage_daily_rollups\",\n];\n\n/// Tables whose local data is preserved (restored from local snapshot) during WebDAV import.\n/// Excludes ephemeral tables like provider_health that can safely rebuild at runtime.\nconst SYNC_PRESERVE_TABLES: &[&str] = &[\n    \"proxy_request_logs\",\n    \"stream_check_logs\",\n    \"proxy_live_backup\",\n    \"usage_daily_rollups\",\n];\n\n/// A database backup entry for the UI\n#[derive(Debug, serde::Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct BackupEntry {\n    pub filename: String,\n    pub size_bytes: u64,\n    pub created_at: String, // ISO 8601\n}\n\nimpl Database {\n    /// 导出为 SQLite 兼容的 SQL 文本（内存字符串，完整导出）\n    pub fn export_sql_string(&self) -> Result<String, AppError> {\n        let snapshot = self.snapshot_to_memory()?;\n        Self::dump_sql(&snapshot, &[])\n    }\n\n    /// Export SQL for sync (WebDAV), skipping local-only tables' data\n    pub fn export_sql_string_for_sync(&self) -> Result<String, AppError> {\n        let snapshot = self.snapshot_to_memory()?;\n        Self::dump_sql(&snapshot, SYNC_SKIP_TABLES)\n    }\n\n    /// 导出为 SQLite 兼容的 SQL 文本\n    pub fn export_sql(&self, target_path: &Path) -> Result<(), AppError> {\n        let dump = self.export_sql_string()?;\n\n        if let Some(parent) = target_path.parent() {\n            fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;\n        }\n\n        crate::config::atomic_write(target_path, dump.as_bytes())\n    }\n\n    /// 从 SQL 文件导入，返回生成的备份 ID（若无备份则为空字符串）\n    pub fn import_sql(&self, source_path: &Path) -> Result<String, AppError> {\n        if !source_path.exists() {\n            return Err(AppError::InvalidInput(format!(\n                \"SQL 文件不存在: {}\",\n                source_path.display()\n            )));\n        }\n\n        let sql_raw = fs::read_to_string(source_path).map_err(|e| AppError::io(source_path, e))?;\n        let sql_content = sql_raw.trim_start_matches('\\u{feff}');\n        self.import_sql_string(sql_content)\n    }\n\n    /// 从 SQL 字符串导入，返回生成的备份 ID（若无备份则为空字符串）\n    pub fn import_sql_string(&self, sql_raw: &str) -> Result<String, AppError> {\n        self.import_sql_string_inner(sql_raw, &[])\n    }\n\n    /// Import SQL generated for sync, then restore local-only tables from the\n    /// current device snapshot before replacing the main database.\n    pub(crate) fn import_sql_string_for_sync(&self, sql_raw: &str) -> Result<String, AppError> {\n        self.import_sql_string_inner(sql_raw, SYNC_PRESERVE_TABLES)\n    }\n\n    fn import_sql_string_inner(\n        &self,\n        sql_raw: &str,\n        preserve_tables: &[&str],\n    ) -> Result<String, AppError> {\n        let sql_content = sql_raw.trim_start_matches('\\u{feff}');\n        Self::validate_cc_switch_sql_export(sql_content)?;\n\n        // 导入前备份现有数据库\n        let backup_path = self.backup_database_file()?;\n\n        let local_snapshot = if preserve_tables.is_empty() {\n            None\n        } else {\n            Some(self.snapshot_to_memory()?)\n        };\n\n        // 在临时数据库执行导入，确保失败不会污染主库\n        let temp_file = NamedTempFile::new().map_err(|e| AppError::IoContext {\n            context: \"创建临时数据库文件失败\".to_string(),\n            source: e,\n        })?;\n        let temp_path = temp_file.path().to_path_buf();\n        let temp_conn =\n            Connection::open(&temp_path).map_err(|e| AppError::Database(e.to_string()))?;\n\n        temp_conn\n            .execute_batch(sql_content)\n            .map_err(|e| AppError::Database(format!(\"执行 SQL 导入失败: {e}\")))?;\n\n        // 补齐缺失表/索引并进行基础校验\n        Self::create_tables_on_conn(&temp_conn)?;\n        Self::apply_schema_migrations_on_conn(&temp_conn)?;\n        Self::validate_basic_state(&temp_conn)?;\n        if let Some(local_snapshot) = local_snapshot.as_ref() {\n            Self::restore_tables(local_snapshot, &temp_conn, preserve_tables)?;\n        }\n\n        // 使用 Backup 将临时库原子写回主库\n        {\n            let mut main_conn = lock_conn!(self.conn);\n            let backup = Backup::new(&temp_conn, &mut main_conn)\n                .map_err(|e| AppError::Database(e.to_string()))?;\n            backup\n                .step(-1)\n                .map_err(|e| AppError::Database(e.to_string()))?;\n        }\n\n        let backup_id = backup_path\n            .and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string()))\n            .unwrap_or_default();\n\n        Ok(backup_id)\n    }\n\n    /// 创建内存快照以避免长时间持有数据库锁\n    pub(crate) fn snapshot_to_memory(&self) -> Result<Connection, AppError> {\n        let conn = lock_conn!(self.conn);\n        let mut snapshot =\n            Connection::open_in_memory().map_err(|e| AppError::Database(e.to_string()))?;\n\n        {\n            let backup =\n                Backup::new(&conn, &mut snapshot).map_err(|e| AppError::Database(e.to_string()))?;\n            backup\n                .step(-1)\n                .map_err(|e| AppError::Database(e.to_string()))?;\n        }\n\n        Ok(snapshot)\n    }\n\n    fn validate_cc_switch_sql_export(sql: &str) -> Result<(), AppError> {\n        let trimmed = sql.trim_start();\n        if trimmed.starts_with(CC_SWITCH_SQL_EXPORT_HEADER) {\n            return Ok(());\n        }\n\n        Err(AppError::localized(\n            \"backup.sql.invalid_format\",\n            \"仅支持导入由 CC Switch 导出的 SQL 备份文件。\",\n            \"Only SQL backups exported by CC Switch are supported.\",\n        ))\n    }\n\n    fn restore_tables(\n        source_conn: &Connection,\n        target_conn: &Connection,\n        tables: &[&str],\n    ) -> Result<(), AppError> {\n        for table in tables {\n            if !Self::table_exists(source_conn, table)? || !Self::table_exists(target_conn, table)?\n            {\n                continue;\n            }\n\n            let columns = Self::get_table_columns(source_conn, table)?;\n            if columns.is_empty() {\n                continue;\n            }\n\n            target_conn\n                .execute(&format!(\"DELETE FROM \\\"{table}\\\"\"), [])\n                .map_err(|e| AppError::Database(format!(\"清空表 {table} 失败: {e}\")))?;\n\n            let placeholders = (1..=columns.len())\n                .map(|idx| format!(\"?{idx}\"))\n                .collect::<Vec<_>>()\n                .join(\", \");\n            let cols = columns\n                .iter()\n                .map(|column| format!(\"\\\"{column}\\\"\"))\n                .collect::<Vec<_>>()\n                .join(\", \");\n            let insert_sql = format!(\"INSERT INTO \\\"{table}\\\" ({cols}) VALUES ({placeholders})\");\n\n            let mut stmt = source_conn\n                .prepare(&format!(\"SELECT * FROM \\\"{table}\\\"\"))\n                .map_err(|e| AppError::Database(format!(\"读取表 {table} 失败: {e}\")))?;\n            let mut rows = stmt\n                .query([])\n                .map_err(|e| AppError::Database(format!(\"查询表 {table} 数据失败: {e}\")))?;\n\n            while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {\n                let mut values = Vec::with_capacity(columns.len());\n                for idx in 0..columns.len() {\n                    values.push(\n                        row.get::<_, rusqlite::types::Value>(idx)\n                            .map_err(|e| AppError::Database(e.to_string()))?,\n                    );\n                }\n\n                target_conn\n                    .execute(&insert_sql, rusqlite::params_from_iter(values.iter()))\n                    .map_err(|e| AppError::Database(format!(\"恢复表 {table} 数据失败: {e}\")))?;\n            }\n        }\n\n        Ok(())\n    }\n\n    /// Periodic backup: create a new backup if the latest one is older than the configured interval\n    pub(crate) fn periodic_backup_if_needed(&self) -> Result<(), AppError> {\n        let interval_hours = crate::settings::effective_backup_interval_hours();\n        if interval_hours > 0 {\n            let backup_dir = get_app_config_dir().join(\"backups\");\n            if !backup_dir.exists() {\n                self.backup_database_file()?;\n            } else {\n                let latest = fs::read_dir(&backup_dir).ok().and_then(|entries| {\n                    entries\n                        .filter_map(|e| e.ok())\n                        .filter(|e| e.path().extension().map(|ext| ext == \"db\").unwrap_or(false))\n                        .filter_map(|e| e.metadata().ok().and_then(|m| m.modified().ok()))\n                        .max()\n                });\n\n                let interval_secs = u64::from(interval_hours) * 3600;\n                let needs_backup = match latest {\n                    None => true,\n                    Some(last_modified) => {\n                        last_modified.elapsed().unwrap_or_default()\n                            > std::time::Duration::from_secs(interval_secs)\n                    }\n                };\n\n                if needs_backup {\n                    log::info!(\n                        \"Periodic backup: latest backup is older than {interval_hours} hours, creating new backup\"\n                    );\n                    self.backup_database_file()?;\n                }\n            }\n        }\n\n        // Periodic maintenance is always enabled, regardless of auto-backup settings.\n        let mut reclaimed_rows = 0u64;\n        match self.cleanup_old_stream_check_logs(7) {\n            Ok(deleted) => {\n                reclaimed_rows += deleted;\n            }\n            Err(e) => {\n                log::warn!(\"Periodic stream_check_logs cleanup failed: {e}\");\n            }\n        }\n        match self.rollup_and_prune(30) {\n            Ok(deleted) => {\n                reclaimed_rows += deleted;\n            }\n            Err(e) => {\n                log::warn!(\"Periodic rollup_and_prune failed: {e}\");\n            }\n        }\n        if reclaimed_rows > 0 {\n            let conn = lock_conn!(self.conn);\n            if let Err(e) = conn.execute_batch(\"PRAGMA incremental_vacuum;\") {\n                log::warn!(\"Periodic incremental vacuum failed: {e}\");\n            }\n        }\n\n        Ok(())\n    }\n\n    /// 生成一致性快照备份，返回备份文件路径（不存在主库时返回 None）\n    pub(crate) fn backup_database_file(&self) -> Result<Option<PathBuf>, AppError> {\n        let db_path = get_app_config_dir().join(\"cc-switch.db\");\n        if !db_path.exists() {\n            return Ok(None);\n        }\n\n        let backup_dir = db_path\n            .parent()\n            .ok_or_else(|| AppError::Config(\"无效的数据库路径\".to_string()))?\n            .join(\"backups\");\n\n        fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?;\n\n        let base_id = format!(\"db_backup_{}\", Local::now().format(\"%Y%m%d_%H%M%S\"));\n        let mut backup_id = base_id.clone();\n        let mut backup_path = backup_dir.join(format!(\"{backup_id}.db\"));\n        let mut counter = 1;\n        while backup_path.exists() {\n            backup_id = format!(\"{base_id}_{counter}\");\n            backup_path = backup_dir.join(format!(\"{backup_id}.db\"));\n            counter += 1;\n        }\n\n        {\n            let conn = lock_conn!(self.conn);\n            let mut dest_conn =\n                Connection::open(&backup_path).map_err(|e| AppError::Database(e.to_string()))?;\n            let backup = Backup::new(&conn, &mut dest_conn)\n                .map_err(|e| AppError::Database(e.to_string()))?;\n            backup\n                .step(-1)\n                .map_err(|e| AppError::Database(e.to_string()))?;\n        }\n\n        Self::cleanup_db_backups(&backup_dir)?;\n        Ok(Some(backup_path))\n    }\n\n    /// 清理旧的数据库备份，保留最新的 N 个\n    fn cleanup_db_backups(dir: &Path) -> Result<(), AppError> {\n        let retain = crate::settings::effective_backup_retain_count();\n        let entries = match fs::read_dir(dir) {\n            Ok(iter) => iter\n                .filter_map(|entry| entry.ok())\n                .filter(|entry| {\n                    entry\n                        .path()\n                        .extension()\n                        .map(|ext| ext == \"db\")\n                        .unwrap_or(false)\n                })\n                .collect::<Vec<_>>(),\n            Err(_) => return Ok(()),\n        };\n\n        if entries.len() <= retain {\n            return Ok(());\n        }\n\n        let remove_count = entries.len().saturating_sub(retain);\n        let mut sorted = entries;\n        sorted.sort_by_key(|entry| entry.metadata().and_then(|m| m.modified()).ok());\n\n        for entry in sorted.into_iter().take(remove_count) {\n            if let Err(err) = fs::remove_file(entry.path()) {\n                log::warn!(\"删除旧数据库备份失败 {}: {}\", entry.path().display(), err);\n            }\n        }\n        Ok(())\n    }\n\n    /// 基础状态校验\n    fn validate_basic_state(conn: &Connection) -> Result<(), AppError> {\n        let provider_count: i64 = conn\n            .query_row(\"SELECT COUNT(*) FROM providers\", [], |row| row.get(0))\n            .map_err(|e| AppError::Database(e.to_string()))?;\n        let mcp_count: i64 = conn\n            .query_row(\"SELECT COUNT(*) FROM mcp_servers\", [], |row| row.get(0))\n            .map_err(|e| AppError::Database(e.to_string()))?;\n\n        if provider_count == 0 && mcp_count == 0 {\n            return Err(AppError::Config(\n                \"导入的 SQL 未包含有效的供应商或 MCP 数据\".to_string(),\n            ));\n        }\n        Ok(())\n    }\n\n    /// 导出数据库为 SQL 文本\n    fn dump_sql(conn: &Connection, skip_tables: &[&str]) -> Result<String, AppError> {\n        let mut output = String::new();\n        let timestamp = Utc::now().format(\"%Y-%m-%d %H:%M:%S\").to_string();\n        let user_version: i64 = conn\n            .query_row(\"PRAGMA user_version;\", [], |row| row.get(0))\n            .unwrap_or(0);\n\n        output.push_str(&format!(\n            \"-- CC Switch SQLite 导出\\n-- 生成时间: {timestamp}\\n-- user_version: {user_version}\\n\"\n        ));\n        output.push_str(\"PRAGMA foreign_keys=OFF;\\n\");\n        output.push_str(&format!(\"PRAGMA user_version={user_version};\\n\"));\n        output.push_str(\"BEGIN TRANSACTION;\\n\");\n\n        // 导出 schema\n        let mut stmt = conn\n            .prepare(\n                \"SELECT type, name, tbl_name, sql\n                 FROM sqlite_master\n                 WHERE sql NOT NULL AND type IN ('table','index','trigger','view')\n                 ORDER BY type='table' DESC, name\",\n            )\n            .map_err(|e| AppError::Database(e.to_string()))?;\n\n        let mut tables = Vec::new();\n        let mut rows = stmt\n            .query([])\n            .map_err(|e| AppError::Database(e.to_string()))?;\n        while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {\n            let obj_type: String = row.get(0).map_err(|e| AppError::Database(e.to_string()))?;\n            let name: String = row.get(1).map_err(|e| AppError::Database(e.to_string()))?;\n            let sql: String = row.get(3).map_err(|e| AppError::Database(e.to_string()))?;\n\n            // 跳过 SQLite 内部对象（如 sqlite_sequence）\n            if name.starts_with(\"sqlite_\") {\n                continue;\n            }\n\n            output.push_str(&sql);\n            output.push_str(\";\\n\");\n\n            if obj_type == \"table\" && !name.starts_with(\"sqlite_\") {\n                tables.push(name);\n            }\n        }\n\n        // 导出数据\n        for table in tables {\n            if skip_tables.iter().any(|t| *t == table) {\n                continue;\n            }\n            let columns = Self::get_table_columns(conn, &table)?;\n            if columns.is_empty() {\n                continue;\n            }\n\n            let mut stmt = conn\n                .prepare(&format!(\"SELECT * FROM \\\"{table}\\\"\"))\n                .map_err(|e| AppError::Database(e.to_string()))?;\n            let mut rows = stmt\n                .query([])\n                .map_err(|e| AppError::Database(e.to_string()))?;\n\n            while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {\n                let mut values = Vec::with_capacity(columns.len());\n                for idx in 0..columns.len() {\n                    let value = row\n                        .get_ref(idx)\n                        .map_err(|e| AppError::Database(e.to_string()))?;\n                    values.push(Self::format_sql_value(value)?);\n                }\n\n                let cols = columns\n                    .iter()\n                    .map(|c| format!(\"\\\"{c}\\\"\"))\n                    .collect::<Vec<_>>()\n                    .join(\", \");\n                output.push_str(&format!(\n                    \"INSERT INTO \\\"{table}\\\" ({cols}) VALUES ({});\\n\",\n                    values.join(\", \")\n                ));\n            }\n        }\n\n        output.push_str(\"COMMIT;\\nPRAGMA foreign_keys=ON;\\n\");\n        Ok(output)\n    }\n\n    /// 获取表的列名列表\n    fn get_table_columns(conn: &Connection, table: &str) -> Result<Vec<String>, AppError> {\n        let mut stmt = conn\n            .prepare(&format!(\"PRAGMA table_info(\\\"{table}\\\")\"))\n            .map_err(|e| AppError::Database(e.to_string()))?;\n        let iter = stmt\n            .query_map([], |row| row.get::<_, String>(1))\n            .map_err(|e| AppError::Database(e.to_string()))?;\n\n        let mut columns = Vec::new();\n        for col in iter {\n            columns.push(col.map_err(|e| AppError::Database(e.to_string()))?);\n        }\n        Ok(columns)\n    }\n\n    /// 格式化 SQL 值\n    fn format_sql_value(value: ValueRef<'_>) -> Result<String, AppError> {\n        match value {\n            ValueRef::Null => Ok(\"NULL\".to_string()),\n            ValueRef::Integer(i) => Ok(i.to_string()),\n            ValueRef::Real(f) => Ok(f.to_string()),\n            ValueRef::Text(t) => {\n                let text = std::str::from_utf8(t)\n                    .map_err(|e| AppError::Database(format!(\"文本字段不是有效的 UTF-8: {e}\")))?;\n                let escaped = text.replace('\\'', \"''\");\n                Ok(format!(\"'{escaped}'\"))\n            }\n            ValueRef::Blob(bytes) => {\n                let mut s = String::from(\"X'\");\n                for b in bytes {\n                    use std::fmt::Write;\n                    let _ = write!(&mut s, \"{b:02X}\");\n                }\n                s.push('\\'');\n                Ok(s)\n            }\n        }\n    }\n\n    /// List all database backup files, sorted by creation time (newest first)\n    pub fn list_backups() -> Result<Vec<BackupEntry>, AppError> {\n        let backup_dir = get_app_config_dir().join(\"backups\");\n        if !backup_dir.exists() {\n            return Ok(vec![]);\n        }\n\n        let mut entries: Vec<BackupEntry> = fs::read_dir(&backup_dir)\n            .map_err(|e| AppError::io(&backup_dir, e))?\n            .filter_map(|e| e.ok())\n            .filter(|e| e.path().extension().map(|ext| ext == \"db\").unwrap_or(false))\n            .filter_map(|e| {\n                let metadata = e.metadata().ok()?;\n                let filename = e.file_name().to_string_lossy().to_string();\n                let size_bytes = metadata.len();\n                let created_at = metadata\n                    .modified()\n                    .ok()\n                    .map(|t| {\n                        let dt: chrono::DateTime<Utc> = t.into();\n                        dt.to_rfc3339()\n                    })\n                    .unwrap_or_default();\n                Some(BackupEntry {\n                    filename,\n                    size_bytes,\n                    created_at,\n                })\n            })\n            .collect();\n\n        // Sort by created_at descending (newest first)\n        entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));\n        Ok(entries)\n    }\n\n    /// Restore database from a backup file. Returns the safety backup ID.\n    pub fn restore_from_backup(&self, filename: &str) -> Result<String, AppError> {\n        // Security: validate filename to prevent path traversal\n        if filename.contains(\"..\")\n            || filename.contains('/')\n            || filename.contains('\\\\')\n            || !filename.ends_with(\".db\")\n        {\n            return Err(AppError::InvalidInput(\n                \"Invalid backup filename\".to_string(),\n            ));\n        }\n\n        let backup_dir = get_app_config_dir().join(\"backups\");\n        let backup_path = backup_dir.join(filename);\n\n        if !backup_path.exists() {\n            return Err(AppError::InvalidInput(format!(\n                \"Backup file not found: {filename}\"\n            )));\n        }\n\n        // Step 1: Create safety backup of current database\n        let safety_backup = self.backup_database_file()?;\n        let safety_id = safety_backup\n            .and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string()))\n            .unwrap_or_default();\n\n        // Step 2: Open the backup file and restore it to the main database\n        let source_conn =\n            Connection::open(&backup_path).map_err(|e| AppError::Database(e.to_string()))?;\n\n        {\n            let mut main_conn = lock_conn!(self.conn);\n            let backup = Backup::new(&source_conn, &mut main_conn)\n                .map_err(|e| AppError::Database(e.to_string()))?;\n            backup\n                .step(-1)\n                .map_err(|e| AppError::Database(e.to_string()))?;\n        }\n\n        // Step 3: Run schema migrations (backup may be from an older version)\n        self.create_tables()?;\n        self.apply_schema_migrations()?;\n        self.ensure_model_pricing_seeded()?;\n\n        log::info!(\"Database restored from backup: {filename}, safety backup: {safety_id}\");\n        Ok(safety_id)\n    }\n\n    /// Rename a backup file. Returns the new filename.\n    pub fn rename_backup(old_filename: &str, new_name: &str) -> Result<String, AppError> {\n        // Validate old filename (path traversal + .db suffix)\n        if old_filename.contains(\"..\")\n            || old_filename.contains('/')\n            || old_filename.contains('\\\\')\n            || !old_filename.ends_with(\".db\")\n        {\n            return Err(AppError::InvalidInput(\n                \"Invalid backup filename\".to_string(),\n            ));\n        }\n\n        // Clean new name\n        let trimmed = new_name.trim();\n        if trimmed.is_empty() {\n            return Err(AppError::InvalidInput(\n                \"New name cannot be empty\".to_string(),\n            ));\n        }\n\n        // Length limit (without .db suffix)\n        let name_part = trimmed.strip_suffix(\".db\").unwrap_or(trimmed);\n        if name_part.len() > 100 {\n            return Err(AppError::InvalidInput(\n                \"Name too long (max 100 characters)\".to_string(),\n            ));\n        }\n\n        // Prevent path traversal in new name\n        if name_part.contains(\"..\")\n            || name_part.contains('/')\n            || name_part.contains('\\\\')\n            || name_part.contains('\\0')\n        {\n            return Err(AppError::InvalidInput(\n                \"Invalid characters in new name\".to_string(),\n            ));\n        }\n\n        let new_filename = format!(\"{name_part}.db\");\n\n        let backup_dir = get_app_config_dir().join(\"backups\");\n        let old_path = backup_dir.join(old_filename);\n        let new_path = backup_dir.join(&new_filename);\n\n        if !old_path.exists() {\n            return Err(AppError::InvalidInput(format!(\n                \"Backup file not found: {old_filename}\"\n            )));\n        }\n\n        if new_path.exists() {\n            return Err(AppError::InvalidInput(format!(\n                \"A backup named '{new_filename}' already exists\"\n            )));\n        }\n\n        fs::rename(&old_path, &new_path).map_err(|e| AppError::io(&old_path, e))?;\n        log::info!(\"Renamed backup: {old_filename} -> {new_filename}\");\n        Ok(new_filename)\n    }\n\n    /// Delete a backup file permanently.\n    pub fn delete_backup(filename: &str) -> Result<(), AppError> {\n        // Validate filename (path traversal + .db suffix)\n        if filename.contains(\"..\")\n            || filename.contains('/')\n            || filename.contains('\\\\')\n            || !filename.ends_with(\".db\")\n        {\n            return Err(AppError::InvalidInput(\n                \"Invalid backup filename\".to_string(),\n            ));\n        }\n\n        let backup_path = get_app_config_dir().join(\"backups\").join(filename);\n        if !backup_path.exists() {\n            return Err(AppError::InvalidInput(format!(\n                \"Backup file not found: {filename}\"\n            )));\n        }\n\n        fs::remove_file(&backup_path).map_err(|e| AppError::io(&backup_path, e))?;\n        log::info!(\"Deleted backup: {filename}\");\n        Ok(())\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::Database;\n    use crate::error::AppError;\n    use crate::settings::{update_settings, AppSettings};\n    use serial_test::serial;\n\n    #[test]\n    fn sync_import_preserves_local_only_tables() -> Result<(), AppError> {\n        let remote_db = Database::memory()?;\n        {\n            let conn = crate::database::lock_conn!(remote_db.conn);\n            conn.execute(\n                \"INSERT INTO providers (id, app_type, name, settings_config, meta)\n                 VALUES ('remote-provider', 'claude', 'Remote Provider', '{}', '{}')\",\n                [],\n            )?;\n        }\n        let remote_sql = remote_db.export_sql_string_for_sync()?;\n\n        let local_db = Database::memory()?;\n        {\n            let conn = crate::database::lock_conn!(local_db.conn);\n            conn.execute(\n                \"INSERT INTO providers (id, app_type, name, settings_config, meta)\n                 VALUES ('local-provider', 'claude', 'Local Provider', '{}', '{}')\",\n                [],\n            )?;\n            conn.execute(\n                \"INSERT INTO proxy_request_logs (\n                    request_id, provider_id, app_type, model,\n                    input_tokens, output_tokens, total_cost_usd,\n                    latency_ms, status_code, created_at\n                ) VALUES ('req-1', 'local-provider', 'claude', 'claude-3', 100, 50, '0.01', 120, 200, 1000)\",\n                [],\n            )?;\n            conn.execute(\n                \"INSERT INTO usage_daily_rollups (\n                    date, app_type, provider_id, model, request_count, success_count,\n                    input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,\n                    total_cost_usd, avg_latency_ms\n                ) VALUES ('2026-03-01', 'claude', 'local-provider', 'claude-3', 7, 7, 700, 350, 0, 0, '0.07', 120)\",\n                [],\n            )?;\n            conn.execute(\n                \"INSERT INTO stream_check_logs (\n                    provider_id, provider_name, app_type, status, success, message,\n                    response_time_ms, http_status, model_used, retry_count, tested_at\n                ) VALUES ('local-provider', 'Local Provider', 'claude', 'operational', 1, 'ok', 42, 200, 'claude-3', 0, 1000)\",\n                [],\n            )?;\n        }\n\n        local_db.import_sql_string_for_sync(&remote_sql)?;\n\n        let remote_provider_exists: i64 = {\n            let conn = crate::database::lock_conn!(local_db.conn);\n            conn.query_row(\n                \"SELECT COUNT(*) FROM providers WHERE id = 'remote-provider' AND app_type = 'claude'\",\n                [],\n                |row| row.get(0),\n            )?\n        };\n        assert_eq!(\n            remote_provider_exists, 1,\n            \"remote config should be imported\"\n        );\n\n        let (request_logs, rollups, stream_logs): (i64, i64, i64) = {\n            let conn = crate::database::lock_conn!(local_db.conn);\n            let request_logs =\n                conn.query_row(\"SELECT COUNT(*) FROM proxy_request_logs\", [], |row| {\n                    row.get(0)\n                })?;\n            let rollups =\n                conn.query_row(\"SELECT COUNT(*) FROM usage_daily_rollups\", [], |row| {\n                    row.get(0)\n                })?;\n            let stream_logs =\n                conn.query_row(\"SELECT COUNT(*) FROM stream_check_logs\", [], |row| {\n                    row.get(0)\n                })?;\n            (request_logs, rollups, stream_logs)\n        };\n        assert_eq!(request_logs, 1, \"local request logs should be preserved\");\n        assert_eq!(rollups, 1, \"local rollups should be preserved\");\n        assert_eq!(\n            stream_logs, 1,\n            \"local stream check logs should be preserved\"\n        );\n\n        Ok(())\n    }\n\n    #[test]\n    #[serial]\n    fn periodic_maintenance_runs_even_when_auto_backup_disabled() -> Result<(), AppError> {\n        let old_test_home = std::env::var_os(\"CC_SWITCH_TEST_HOME\");\n        let test_home =\n            std::env::temp_dir().join(\"cc-switch-periodic-maintenance-backup-disabled-test\");\n        let _ = std::fs::remove_dir_all(&test_home);\n        std::fs::create_dir_all(&test_home).expect(\"create test home\");\n        std::env::set_var(\"CC_SWITCH_TEST_HOME\", &test_home);\n\n        let mut settings = AppSettings::default();\n        settings.backup_interval_hours = Some(0);\n        update_settings(settings).expect(\"disable auto backup\");\n\n        let db = Database::memory()?;\n        let now = chrono::Utc::now().timestamp();\n        let old_ts = now - 40 * 86400;\n        let old_stream_ts = now - 8 * 86400;\n\n        {\n            let conn = crate::database::lock_conn!(db.conn);\n            conn.execute(\n                \"INSERT INTO proxy_request_logs (\n                    request_id, provider_id, app_type, model,\n                    input_tokens, output_tokens, total_cost_usd,\n                    latency_ms, status_code, created_at\n                ) VALUES ('old-req', 'p1', 'claude', 'claude-3', 100, 50, '0.01', 100, 200, ?1)\",\n                [old_ts],\n            )?;\n            conn.execute(\n                \"INSERT INTO stream_check_logs (\n                    provider_id, provider_name, app_type, status, success, message,\n                    response_time_ms, http_status, model_used, retry_count, tested_at\n                ) VALUES ('p1', 'Provider 1', 'claude', 'operational', 1, 'ok', 42, 200, 'claude-3', 0, ?1)\",\n                [old_stream_ts],\n            )?;\n        }\n\n        db.periodic_backup_if_needed()?;\n\n        let (remaining_request_logs, stream_logs, rollups): (i64, i64, i64) = {\n            let conn = crate::database::lock_conn!(db.conn);\n            let remaining_request_logs =\n                conn.query_row(\"SELECT COUNT(*) FROM proxy_request_logs\", [], |row| {\n                    row.get(0)\n                })?;\n            let stream_logs =\n                conn.query_row(\"SELECT COUNT(*) FROM stream_check_logs\", [], |row| {\n                    row.get(0)\n                })?;\n            let rollups =\n                conn.query_row(\"SELECT COUNT(*) FROM usage_daily_rollups\", [], |row| {\n                    row.get(0)\n                })?;\n            (remaining_request_logs, stream_logs, rollups)\n        };\n\n        assert_eq!(\n            remaining_request_logs, 0,\n            \"old request logs should still be pruned when auto backup is disabled\"\n        );\n        assert_eq!(\n            stream_logs, 0,\n            \"old stream check logs should still be pruned when auto backup is disabled\"\n        );\n        assert_eq!(rollups, 1, \"old request logs should be rolled up\");\n\n        match old_test_home {\n            Some(value) => std::env::set_var(\"CC_SWITCH_TEST_HOME\", value),\n            None => std::env::remove_var(\"CC_SWITCH_TEST_HOME\"),\n        }\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/database/dao/failover.rs",
    "content": "//! 故障转移队列 DAO\n//!\n//! 管理代理模式下的故障转移队列（基于 providers 表的 in_failover_queue 字段）\n\nuse crate::database::{lock_conn, Database};\nuse crate::error::AppError;\nuse crate::provider::Provider;\nuse serde::{Deserialize, Serialize};\n\n/// 故障转移队列条目（简化版，用于前端展示）\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct FailoverQueueItem {\n    pub provider_id: String,\n    pub provider_name: String,\n    pub sort_index: Option<usize>,\n}\n\nimpl Database {\n    /// 获取故障转移队列（按 sort_index 排序）\n    pub fn get_failover_queue(&self, app_type: &str) -> Result<Vec<FailoverQueueItem>, AppError> {\n        let conn = lock_conn!(self.conn);\n\n        let mut stmt = conn\n            .prepare(\n                \"SELECT id, name, sort_index\n                 FROM providers\n                 WHERE app_type = ?1 AND in_failover_queue = 1\n                 ORDER BY COALESCE(sort_index, 999999), id ASC\",\n            )\n            .map_err(|e| AppError::Database(e.to_string()))?;\n\n        let items = stmt\n            .query_map([app_type], |row| {\n                Ok(FailoverQueueItem {\n                    provider_id: row.get(0)?,\n                    provider_name: row.get(1)?,\n                    sort_index: row.get(2)?,\n                })\n            })\n            .map_err(|e| AppError::Database(e.to_string()))?\n            .collect::<Result<Vec<_>, _>>()\n            .map_err(|e| AppError::Database(e.to_string()))?;\n\n        Ok(items)\n    }\n\n    /// 获取故障转移队列中的供应商（完整 Provider 信息，按顺序）\n    pub fn get_failover_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> {\n        let all_providers = self.get_all_providers(app_type)?;\n\n        let result: Vec<Provider> = all_providers\n            .into_values()\n            .filter(|p| p.in_failover_queue)\n            .collect();\n\n        Ok(result)\n    }\n\n    /// 添加供应商到故障转移队列\n    pub fn add_to_failover_queue(&self, app_type: &str, provider_id: &str) -> Result<(), AppError> {\n        let conn = lock_conn!(self.conn);\n\n        conn.execute(\n            \"UPDATE providers SET in_failover_queue = 1 WHERE id = ?1 AND app_type = ?2\",\n            rusqlite::params![provider_id, app_type],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n\n        Ok(())\n    }\n\n    /// 从故障转移队列中移除供应商\n    pub fn remove_from_failover_queue(\n        &self,\n        app_type: &str,\n        provider_id: &str,\n    ) -> Result<(), AppError> {\n        let conn = lock_conn!(self.conn);\n\n        // 1. 从队列中移除\n        conn.execute(\n            \"UPDATE providers SET in_failover_queue = 0 WHERE id = ?1 AND app_type = ?2\",\n            rusqlite::params![provider_id, app_type],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n\n        // 2. 清除该供应商的健康状态（退出队列后不再需要健康监控）\n        conn.execute(\n            \"DELETE FROM provider_health WHERE provider_id = ?1 AND app_type = ?2\",\n            rusqlite::params![provider_id, app_type],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n\n        log::info!(\"已从故障转移队列移除供应商 {provider_id} ({app_type}), 并清除其健康状态\");\n\n        Ok(())\n    }\n\n    /// 清空故障转移队列\n    pub fn clear_failover_queue(&self, app_type: &str) -> Result<(), AppError> {\n        let conn = lock_conn!(self.conn);\n\n        conn.execute(\n            \"UPDATE providers SET in_failover_queue = 0 WHERE app_type = ?1\",\n            [app_type],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n\n        Ok(())\n    }\n\n    /// 检查供应商是否在故障转移队列中\n    pub fn is_in_failover_queue(\n        &self,\n        app_type: &str,\n        provider_id: &str,\n    ) -> Result<bool, AppError> {\n        let conn = lock_conn!(self.conn);\n\n        let in_queue: bool = conn\n            .query_row(\n                \"SELECT in_failover_queue FROM providers WHERE id = ?1 AND app_type = ?2\",\n                rusqlite::params![provider_id, app_type],\n                |row| row.get(0),\n            )\n            .unwrap_or(false);\n\n        Ok(in_queue)\n    }\n\n    /// 获取可添加到故障转移队列的供应商（不在队列中的）\n    pub fn get_available_providers_for_failover(\n        &self,\n        app_type: &str,\n    ) -> Result<Vec<Provider>, AppError> {\n        let all_providers = self.get_all_providers(app_type)?;\n\n        let available: Vec<Provider> = all_providers\n            .into_values()\n            .filter(|p| !p.in_failover_queue)\n            .collect();\n\n        Ok(available)\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/database/dao/mcp.rs",
    "content": "//! MCP 服务器数据访问对象\n//!\n//! 提供 MCP 服务器的 CRUD 操作。\n\nuse crate::app_config::{McpApps, McpServer};\nuse crate::database::{lock_conn, Database};\nuse crate::error::AppError;\nuse indexmap::IndexMap;\nuse rusqlite::params;\n\nimpl Database {\n    /// 获取所有 MCP 服务器\n    pub fn get_all_mcp_servers(&self) -> Result<IndexMap<String, McpServer>, AppError> {\n        let conn = lock_conn!(self.conn);\n        let mut stmt = conn.prepare(\n            \"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode\n             FROM mcp_servers\n             ORDER BY name ASC, id ASC\"\n        ).map_err(|e| AppError::Database(e.to_string()))?;\n\n        let server_iter = stmt\n            .query_map([], |row| {\n                let id: String = row.get(0)?;\n                let name: String = row.get(1)?;\n                let server_config_str: String = row.get(2)?;\n                let description: Option<String> = row.get(3)?;\n                let homepage: Option<String> = row.get(4)?;\n                let docs: Option<String> = row.get(5)?;\n                let tags_str: String = row.get(6)?;\n                let enabled_claude: bool = row.get(7)?;\n                let enabled_codex: bool = row.get(8)?;\n                let enabled_gemini: bool = row.get(9)?;\n                let enabled_opencode: bool = row.get(10)?;\n\n                let server = serde_json::from_str(&server_config_str).unwrap_or_default();\n                let tags = serde_json::from_str(&tags_str).unwrap_or_default();\n\n                Ok((\n                    id.clone(),\n                    McpServer {\n                        id,\n                        name,\n                        server,\n                        apps: McpApps {\n                            claude: enabled_claude,\n                            codex: enabled_codex,\n                            gemini: enabled_gemini,\n                            opencode: enabled_opencode,\n                        },\n                        description,\n                        homepage,\n                        docs,\n                        tags,\n                    },\n                ))\n            })\n            .map_err(|e| AppError::Database(e.to_string()))?;\n\n        let mut servers = IndexMap::new();\n        for server_res in server_iter {\n            let (id, server) = server_res.map_err(|e| AppError::Database(e.to_string()))?;\n            servers.insert(id, server);\n        }\n        Ok(servers)\n    }\n\n    /// 保存 MCP 服务器\n    pub fn save_mcp_server(&self, server: &McpServer) -> Result<(), AppError> {\n        let conn = lock_conn!(self.conn);\n        conn.execute(\n            \"INSERT OR REPLACE INTO mcp_servers (\n                id, name, server_config, description, homepage, docs, tags,\n                enabled_claude, enabled_codex, enabled_gemini, enabled_opencode\n            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)\",\n            params![\n                server.id,\n                server.name,\n                serde_json::to_string(&server.server).map_err(|e| AppError::Database(format!(\n                    \"Failed to serialize server config: {e}\"\n                )))?,\n                server.description,\n                server.homepage,\n                server.docs,\n                serde_json::to_string(&server.tags)\n                    .map_err(|e| AppError::Database(format!(\"Failed to serialize tags: {e}\")))?,\n                server.apps.claude,\n                server.apps.codex,\n                server.apps.gemini,\n                server.apps.opencode,\n            ],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n        Ok(())\n    }\n\n    /// 删除 MCP 服务器\n    pub fn delete_mcp_server(&self, id: &str) -> Result<(), AppError> {\n        let conn = lock_conn!(self.conn);\n        conn.execute(\"DELETE FROM mcp_servers WHERE id = ?1\", params![id])\n            .map_err(|e| AppError::Database(e.to_string()))?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/database/dao/mod.rs",
    "content": "//! Data Access Object layer\n//!\n//! Database access operations for each domain\n\npub mod failover;\npub mod mcp;\npub mod prompts;\npub mod providers;\npub mod proxy;\npub mod settings;\npub mod skills;\npub mod stream_check;\npub mod universal_providers;\npub mod usage_rollup;\n\n// 所有 DAO 方法都通过 Database impl 提供，无需单独导出\n// 导出 FailoverQueueItem 供外部使用\npub use failover::FailoverQueueItem;\n"
  },
  {
    "path": "src-tauri/src/database/dao/prompts.rs",
    "content": "//! 提示词数据访问对象\n//!\n//! 提供提示词（Prompt）的 CRUD 操作。\n\nuse crate::database::{lock_conn, Database};\nuse crate::error::AppError;\nuse crate::prompt::Prompt;\nuse indexmap::IndexMap;\nuse rusqlite::params;\n\nimpl Database {\n    /// 获取指定应用类型的所有提示词\n    pub fn get_prompts(&self, app_type: &str) -> Result<IndexMap<String, Prompt>, AppError> {\n        let conn = lock_conn!(self.conn);\n        let mut stmt = conn\n            .prepare(\n                \"SELECT id, name, content, description, enabled, created_at, updated_at\n             FROM prompts WHERE app_type = ?1\n             ORDER BY created_at ASC, id ASC\",\n            )\n            .map_err(|e| AppError::Database(e.to_string()))?;\n\n        let prompt_iter = stmt\n            .query_map(params![app_type], |row| {\n                let id: String = row.get(0)?;\n                let name: String = row.get(1)?;\n                let content: String = row.get(2)?;\n                let description: Option<String> = row.get(3)?;\n                let enabled: bool = row.get(4)?;\n                let created_at: Option<i64> = row.get(5)?;\n                let updated_at: Option<i64> = row.get(6)?;\n\n                Ok((\n                    id.clone(),\n                    Prompt {\n                        id,\n                        name,\n                        content,\n                        description,\n                        enabled,\n                        created_at,\n                        updated_at,\n                    },\n                ))\n            })\n            .map_err(|e| AppError::Database(e.to_string()))?;\n\n        let mut prompts = IndexMap::new();\n        for prompt_res in prompt_iter {\n            let (id, prompt) = prompt_res.map_err(|e| AppError::Database(e.to_string()))?;\n            prompts.insert(id, prompt);\n        }\n        Ok(prompts)\n    }\n\n    /// 保存提示词\n    pub fn save_prompt(&self, app_type: &str, prompt: &Prompt) -> Result<(), AppError> {\n        let conn = lock_conn!(self.conn);\n        conn.execute(\n            \"INSERT OR REPLACE INTO prompts (\n                id, app_type, name, content, description, enabled, created_at, updated_at\n            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)\",\n            params![\n                prompt.id,\n                app_type,\n                prompt.name,\n                prompt.content,\n                prompt.description,\n                prompt.enabled,\n                prompt.created_at,\n                prompt.updated_at,\n            ],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n        Ok(())\n    }\n\n    /// 删除提示词\n    pub fn delete_prompt(&self, app_type: &str, id: &str) -> Result<(), AppError> {\n        let conn = lock_conn!(self.conn);\n        conn.execute(\n            \"DELETE FROM prompts WHERE id = ?1 AND app_type = ?2\",\n            params![id, app_type],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/database/dao/providers.rs",
    "content": "use crate::database::{lock_conn, Database};\nuse crate::error::AppError;\nuse crate::provider::{Provider, ProviderMeta};\nuse indexmap::IndexMap;\nuse rusqlite::params;\nuse std::collections::HashMap;\n\ntype OmoProviderRow = (\n    String,\n    String,\n    String,\n    Option<String>,\n    Option<i64>,\n    Option<usize>,\n    Option<String>,\n    String,\n);\n\nimpl Database {\n    pub fn get_all_providers(\n        &self,\n        app_type: &str,\n    ) -> Result<IndexMap<String, Provider>, AppError> {\n        let conn = lock_conn!(self.conn);\n        let mut stmt = conn.prepare(\n            \"SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, in_failover_queue\n             FROM providers WHERE app_type = ?1\n             ORDER BY COALESCE(sort_index, 999999), created_at ASC, id ASC\"\n        ).map_err(|e| AppError::Database(e.to_string()))?;\n\n        let provider_iter = stmt\n            .query_map(params![app_type], |row| {\n                let id: String = row.get(0)?;\n                let name: String = row.get(1)?;\n                let settings_config_str: String = row.get(2)?;\n                let website_url: Option<String> = row.get(3)?;\n                let category: Option<String> = row.get(4)?;\n                let created_at: Option<i64> = row.get(5)?;\n                let sort_index: Option<usize> = row.get(6)?;\n                let notes: Option<String> = row.get(7)?;\n                let icon: Option<String> = row.get(8)?;\n                let icon_color: Option<String> = row.get(9)?;\n                let meta_str: String = row.get(10)?;\n                let in_failover_queue: bool = row.get(11)?;\n\n                let settings_config =\n                    serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null);\n                let meta: ProviderMeta = serde_json::from_str(&meta_str).unwrap_or_default();\n\n                Ok((\n                    id,\n                    Provider {\n                        id: \"\".to_string(), // Placeholder, set below\n                        name,\n                        settings_config,\n                        website_url,\n                        category,\n                        created_at,\n                        sort_index,\n                        notes,\n                        meta: Some(meta),\n                        icon,\n                        icon_color,\n                        in_failover_queue,\n                    },\n                ))\n            })\n            .map_err(|e| AppError::Database(e.to_string()))?;\n\n        let mut providers = IndexMap::new();\n        for provider_res in provider_iter {\n            let (id, mut provider) = provider_res.map_err(|e| AppError::Database(e.to_string()))?;\n            provider.id = id.clone();\n\n            let mut stmt_endpoints = conn.prepare(\n                \"SELECT url, added_at FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2 ORDER BY added_at ASC, url ASC\"\n            ).map_err(|e| AppError::Database(e.to_string()))?;\n\n            let endpoints_iter = stmt_endpoints\n                .query_map(params![id, app_type], |row| {\n                    let url: String = row.get(0)?;\n                    let added_at: Option<i64> = row.get(1)?;\n                    Ok((\n                        url,\n                        crate::settings::CustomEndpoint {\n                            url: \"\".to_string(),\n                            added_at: added_at.unwrap_or(0),\n                            last_used: None,\n                        },\n                    ))\n                })\n                .map_err(|e| AppError::Database(e.to_string()))?;\n\n            let mut custom_endpoints = HashMap::new();\n            for ep_res in endpoints_iter {\n                let (url, mut ep) = ep_res.map_err(|e| AppError::Database(e.to_string()))?;\n                ep.url = url.clone();\n                custom_endpoints.insert(url, ep);\n            }\n\n            if let Some(meta) = &mut provider.meta {\n                meta.custom_endpoints = custom_endpoints;\n            }\n\n            providers.insert(id, provider);\n        }\n\n        Ok(providers)\n    }\n\n    pub fn get_current_provider(&self, app_type: &str) -> Result<Option<String>, AppError> {\n        let conn = lock_conn!(self.conn);\n        let mut stmt = conn\n            .prepare(\"SELECT id FROM providers WHERE app_type = ?1 AND is_current = 1 LIMIT 1\")\n            .map_err(|e| AppError::Database(e.to_string()))?;\n\n        let mut rows = stmt\n            .query(params![app_type])\n            .map_err(|e| AppError::Database(e.to_string()))?;\n\n        if let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {\n            Ok(Some(\n                row.get(0).map_err(|e| AppError::Database(e.to_string()))?,\n            ))\n        } else {\n            Ok(None)\n        }\n    }\n\n    pub fn get_provider_by_id(\n        &self,\n        id: &str,\n        app_type: &str,\n    ) -> Result<Option<Provider>, AppError> {\n        let conn = lock_conn!(self.conn);\n        let result = conn.query_row(\n            \"SELECT name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, in_failover_queue\n             FROM providers WHERE id = ?1 AND app_type = ?2\",\n            params![id, app_type],\n            |row| {\n                let name: String = row.get(0)?;\n                let settings_config_str: String = row.get(1)?;\n                let website_url: Option<String> = row.get(2)?;\n                let category: Option<String> = row.get(3)?;\n                let created_at: Option<i64> = row.get(4)?;\n                let sort_index: Option<usize> = row.get(5)?;\n                let notes: Option<String> = row.get(6)?;\n                let icon: Option<String> = row.get(7)?;\n                let icon_color: Option<String> = row.get(8)?;\n                let meta_str: String = row.get(9)?;\n                let in_failover_queue: bool = row.get(10)?;\n\n                let settings_config = serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null);\n                let meta: ProviderMeta = serde_json::from_str(&meta_str).unwrap_or_default();\n\n                Ok(Provider {\n                    id: id.to_string(),\n                    name,\n                    settings_config,\n                    website_url,\n                    category,\n                    created_at,\n                    sort_index,\n                    notes,\n                    meta: Some(meta),\n                    icon,\n                    icon_color,\n                    in_failover_queue,\n                })\n            },\n        );\n\n        match result {\n            Ok(provider) => Ok(Some(provider)),\n            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),\n            Err(e) => Err(AppError::Database(e.to_string())),\n        }\n    }\n\n    pub fn save_provider(&self, app_type: &str, provider: &Provider) -> Result<(), AppError> {\n        let mut conn = lock_conn!(self.conn);\n        let tx = conn\n            .transaction()\n            .map_err(|e| AppError::Database(e.to_string()))?;\n\n        let mut meta_clone = provider.meta.clone().unwrap_or_default();\n        let endpoints = std::mem::take(&mut meta_clone.custom_endpoints);\n\n        let existing: Option<(bool, bool)> = tx\n            .query_row(\n                \"SELECT is_current, in_failover_queue FROM providers WHERE id = ?1 AND app_type = ?2\",\n                params![provider.id, app_type],\n                |row| Ok((row.get(0)?, row.get(1)?)),\n            )\n            .ok();\n\n        let is_update = existing.is_some();\n        let (is_current, in_failover_queue) =\n            existing.unwrap_or((false, provider.in_failover_queue));\n\n        if is_update {\n            tx.execute(\n                \"UPDATE providers SET\n                    name = ?1,\n                    settings_config = ?2,\n                    website_url = ?3,\n                    category = ?4,\n                    created_at = ?5,\n                    sort_index = ?6,\n                    notes = ?7,\n                    icon = ?8,\n                    icon_color = ?9,\n                    meta = ?10,\n                    is_current = ?11,\n                    in_failover_queue = ?12\n                WHERE id = ?13 AND app_type = ?14\",\n                params![\n                    provider.name,\n                    serde_json::to_string(&provider.settings_config).map_err(|e| {\n                        AppError::Database(format!(\"Failed to serialize settings_config: {e}\"))\n                    })?,\n                    provider.website_url,\n                    provider.category,\n                    provider.created_at,\n                    provider.sort_index,\n                    provider.notes,\n                    provider.icon,\n                    provider.icon_color,\n                    serde_json::to_string(&meta_clone).map_err(|e| AppError::Database(format!(\n                        \"Failed to serialize meta: {e}\"\n                    )))?,\n                    is_current,\n                    in_failover_queue,\n                    provider.id,\n                    app_type,\n                ],\n            )\n            .map_err(|e| AppError::Database(e.to_string()))?;\n        } else {\n            tx.execute(\n                \"INSERT INTO providers (\n                    id, app_type, name, settings_config, website_url, category,\n                    created_at, sort_index, notes, icon, icon_color, meta, is_current, in_failover_queue\n                ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)\",\n                params![\n                    provider.id,\n                    app_type,\n                    provider.name,\n                    serde_json::to_string(&provider.settings_config)\n                        .map_err(|e| AppError::Database(format!(\"Failed to serialize settings_config: {e}\")))?,\n                    provider.website_url,\n                    provider.category,\n                    provider.created_at,\n                    provider.sort_index,\n                    provider.notes,\n                    provider.icon,\n                    provider.icon_color,\n                    serde_json::to_string(&meta_clone)\n                        .map_err(|e| AppError::Database(format!(\"Failed to serialize meta: {e}\")))?,\n                    is_current,\n                    in_failover_queue,\n                ],\n            )\n            .map_err(|e| AppError::Database(e.to_string()))?;\n\n            for (url, endpoint) in endpoints {\n                tx.execute(\n                    \"INSERT INTO provider_endpoints (provider_id, app_type, url, added_at)\n                     VALUES (?1, ?2, ?3, ?4)\",\n                    params![provider.id, app_type, url, endpoint.added_at],\n                )\n                .map_err(|e| AppError::Database(e.to_string()))?;\n            }\n        }\n\n        tx.commit().map_err(|e| AppError::Database(e.to_string()))?;\n        Ok(())\n    }\n\n    pub fn delete_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> {\n        let conn = lock_conn!(self.conn);\n        conn.execute(\n            \"DELETE FROM providers WHERE id = ?1 AND app_type = ?2\",\n            params![id, app_type],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n        Ok(())\n    }\n\n    pub fn set_current_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> {\n        let mut conn = lock_conn!(self.conn);\n        let tx = conn\n            .transaction()\n            .map_err(|e| AppError::Database(e.to_string()))?;\n\n        tx.execute(\n            \"UPDATE providers SET is_current = 0 WHERE app_type = ?1\",\n            params![app_type],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n\n        tx.execute(\n            \"UPDATE providers SET is_current = 1 WHERE id = ?1 AND app_type = ?2\",\n            params![id, app_type],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n\n        tx.commit().map_err(|e| AppError::Database(e.to_string()))?;\n        Ok(())\n    }\n\n    pub fn update_provider_settings_config(\n        &self,\n        app_type: &str,\n        provider_id: &str,\n        settings_config: &serde_json::Value,\n    ) -> Result<(), AppError> {\n        let conn = lock_conn!(self.conn);\n        conn.execute(\n            \"UPDATE providers SET settings_config = ?1 WHERE id = ?2 AND app_type = ?3\",\n            params![\n                serde_json::to_string(settings_config).map_err(|e| AppError::Database(format!(\n                    \"Failed to serialize settings_config: {e}\"\n                )))?,\n                provider_id,\n                app_type\n            ],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n        Ok(())\n    }\n\n    pub fn add_custom_endpoint(\n        &self,\n        app_type: &str,\n        provider_id: &str,\n        url: &str,\n    ) -> Result<(), AppError> {\n        let conn = lock_conn!(self.conn);\n        let added_at = chrono::Utc::now().timestamp_millis();\n        conn.execute(\n            \"INSERT INTO provider_endpoints (provider_id, app_type, url, added_at) VALUES (?1, ?2, ?3, ?4)\",\n            params![provider_id, app_type, url, added_at],\n        ).map_err(|e| AppError::Database(e.to_string()))?;\n        Ok(())\n    }\n\n    pub fn remove_custom_endpoint(\n        &self,\n        app_type: &str,\n        provider_id: &str,\n        url: &str,\n    ) -> Result<(), AppError> {\n        let conn = lock_conn!(self.conn);\n        conn.execute(\n            \"DELETE FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2 AND url = ?3\",\n            params![provider_id, app_type, url],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n        Ok(())\n    }\n\n    pub fn set_omo_provider_current(\n        &self,\n        app_type: &str,\n        provider_id: &str,\n        category: &str,\n    ) -> Result<(), AppError> {\n        let mut conn = lock_conn!(self.conn);\n        let tx = conn\n            .transaction()\n            .map_err(|e| AppError::Database(e.to_string()))?;\n        tx.execute(\n            \"UPDATE providers SET is_current = 0 WHERE app_type = ?1 AND category = ?2\",\n            params![app_type, category],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n        // OMO ↔ OMO Slim mutually exclusive: deactivate the opposite category\n        let opposite = match category {\n            \"omo\" => Some(\"omo-slim\"),\n            \"omo-slim\" => Some(\"omo\"),\n            _ => None,\n        };\n        if let Some(opp) = opposite {\n            tx.execute(\n                \"UPDATE providers SET is_current = 0 WHERE app_type = ?1 AND category = ?2\",\n                params![app_type, opp],\n            )\n            .map_err(|e| AppError::Database(e.to_string()))?;\n        }\n        let updated = tx\n            .execute(\n                \"UPDATE providers SET is_current = 1 WHERE id = ?1 AND app_type = ?2 AND category = ?3\",\n                params![provider_id, app_type, category],\n            )\n            .map_err(|e| AppError::Database(e.to_string()))?;\n        if updated != 1 {\n            return Err(AppError::Database(format!(\n                \"Failed to set {category} provider current: provider '{provider_id}' not found in app '{app_type}'\"\n            )));\n        }\n        tx.commit().map_err(|e| AppError::Database(e.to_string()))?;\n        Ok(())\n    }\n\n    pub fn is_omo_provider_current(\n        &self,\n        app_type: &str,\n        provider_id: &str,\n        category: &str,\n    ) -> Result<bool, AppError> {\n        let conn = lock_conn!(self.conn);\n        match conn.query_row(\n            \"SELECT is_current FROM providers\n             WHERE id = ?1 AND app_type = ?2 AND category = ?3\",\n            params![provider_id, app_type, category],\n            |row| row.get(0),\n        ) {\n            Ok(is_current) => Ok(is_current),\n            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),\n            Err(e) => Err(AppError::Database(e.to_string())),\n        }\n    }\n\n    pub fn clear_omo_provider_current(\n        &self,\n        app_type: &str,\n        provider_id: &str,\n        category: &str,\n    ) -> Result<(), AppError> {\n        let conn = lock_conn!(self.conn);\n        conn.execute(\n            \"UPDATE providers SET is_current = 0\n             WHERE id = ?1 AND app_type = ?2 AND category = ?3\",\n            params![provider_id, app_type, category],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n        Ok(())\n    }\n\n    pub fn get_current_omo_provider(\n        &self,\n        app_type: &str,\n        category: &str,\n    ) -> Result<Option<Provider>, AppError> {\n        let conn = lock_conn!(self.conn);\n        let row_data: Result<OmoProviderRow, rusqlite::Error> = conn.query_row(\n            \"SELECT id, name, settings_config, category, created_at, sort_index, notes, meta\n             FROM providers\n             WHERE app_type = ?1 AND category = ?2 AND is_current = 1\n             LIMIT 1\",\n            params![app_type, category],\n            |row| {\n                Ok((\n                    row.get(0)?,\n                    row.get(1)?,\n                    row.get(2)?,\n                    row.get(3)?,\n                    row.get(4)?,\n                    row.get(5)?,\n                    row.get(6)?,\n                    row.get(7)?,\n                ))\n            },\n        );\n\n        let (id, name, settings_config_str, _row_category, created_at, sort_index, notes, meta_str) =\n            match row_data {\n                Ok(v) => v,\n                Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),\n                Err(e) => return Err(AppError::Database(e.to_string())),\n            };\n\n        let settings_config = serde_json::from_str(&settings_config_str).map_err(|e| {\n            AppError::Database(format!(\n                \"Failed to parse {category} provider settings_config (provider_id={id}): {e}\"\n            ))\n        })?;\n        let meta: crate::provider::ProviderMeta = if meta_str.trim().is_empty() {\n            crate::provider::ProviderMeta::default()\n        } else {\n            serde_json::from_str(&meta_str).map_err(|e| {\n                AppError::Database(format!(\n                    \"Failed to parse {category} provider meta (provider_id={id}): {e}\"\n                ))\n            })?\n        };\n\n        Ok(Some(Provider {\n            id,\n            name,\n            settings_config,\n            website_url: None,\n            category: Some(category.to_string()),\n            created_at,\n            sort_index,\n            notes,\n            meta: Some(meta),\n            icon: None,\n            icon_color: None,\n            in_failover_queue: false,\n        }))\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/database/dao/proxy.rs",
    "content": "//! 代理功能数据访问层\n//!\n//! 处理代理配置、Provider健康状态和使用统计的数据库操作\n\nuse crate::error::AppError;\nuse crate::proxy::types::*;\nuse rust_decimal::Decimal;\n\nuse super::super::{lock_conn, Database};\n\nimpl Database {\n    // ==================== Global Proxy Config ====================\n\n    /// 获取全局代理配置（统一字段）\n    ///\n    /// 从 claude 行读取（三行镜像一致）\n    pub async fn get_global_proxy_config(&self) -> Result<GlobalProxyConfig, AppError> {\n        // 使用 block 限制 conn 的作用域，避免跨 await 持有锁\n        let result = {\n            let conn = lock_conn!(self.conn);\n            conn.query_row(\n                \"SELECT proxy_enabled, listen_address, listen_port, enable_logging\n                 FROM proxy_config WHERE app_type = 'claude'\",\n                [],\n                |row| {\n                    Ok(GlobalProxyConfig {\n                        proxy_enabled: row.get::<_, i32>(0)? != 0,\n                        listen_address: row.get(1)?,\n                        listen_port: row.get::<_, i32>(2)? as u16,\n                        enable_logging: row.get::<_, i32>(3)? != 0,\n                    })\n                },\n            )\n        };\n        // conn 已在 block 结束时释放\n\n        match result {\n            Ok(config) => Ok(config),\n            Err(rusqlite::Error::QueryReturnedNoRows) => {\n                // 如果不存在，创建默认配置\n                self.init_proxy_config_rows().await?;\n                Ok(GlobalProxyConfig {\n                    proxy_enabled: false,\n                    listen_address: \"127.0.0.1\".to_string(),\n                    listen_port: 15721,\n                    enable_logging: true,\n                })\n            }\n            Err(e) => Err(AppError::Database(e.to_string())),\n        }\n    }\n\n    /// 更新全局代理配置（镜像写三行）\n    pub async fn update_global_proxy_config(\n        &self,\n        config: GlobalProxyConfig,\n    ) -> Result<(), AppError> {\n        let conn = lock_conn!(self.conn);\n\n        conn.execute(\n            \"UPDATE proxy_config SET\n                proxy_enabled = ?1,\n                listen_address = ?2,\n                listen_port = ?3,\n                enable_logging = ?4,\n                updated_at = datetime('now')\",\n            rusqlite::params![\n                if config.proxy_enabled { 1 } else { 0 },\n                config.listen_address,\n                config.listen_port as i32,\n                if config.enable_logging { 1 } else { 0 },\n            ],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n\n        Ok(())\n    }\n\n    /// 获取默认成本倍率\n    pub async fn get_default_cost_multiplier(&self, app_type: &str) -> Result<String, AppError> {\n        let result = {\n            let conn = lock_conn!(self.conn);\n            conn.query_row(\n                \"SELECT default_cost_multiplier FROM proxy_config WHERE app_type = ?1\",\n                [app_type],\n                |row| row.get(0),\n            )\n        };\n\n        match result {\n            Ok(value) => Ok(value),\n            Err(rusqlite::Error::QueryReturnedNoRows) => {\n                self.init_proxy_config_rows().await?;\n                Ok(\"1\".to_string())\n            }\n            Err(e) => Err(AppError::Database(e.to_string())),\n        }\n    }\n\n    /// 设置默认成本倍率\n    pub async fn set_default_cost_multiplier(\n        &self,\n        app_type: &str,\n        value: &str,\n    ) -> Result<(), AppError> {\n        let trimmed = value.trim();\n        if trimmed.is_empty() {\n            return Err(AppError::localized(\n                \"error.multiplierEmpty\",\n                \"倍率不能为空\",\n                \"Multiplier cannot be empty\",\n            ));\n        }\n        trimmed.parse::<Decimal>().map_err(|e| {\n            AppError::localized(\n                \"error.invalidMultiplier\",\n                format!(\"无效倍率: {value} - {e}\"),\n                format!(\"Invalid multiplier: {value} - {e}\"),\n            )\n        })?;\n\n        // 确保行存在\n        self.ensure_proxy_config_row_exists(app_type)?;\n\n        let conn = lock_conn!(self.conn);\n        conn.execute(\n            \"UPDATE proxy_config SET\n                default_cost_multiplier = ?2,\n                updated_at = datetime('now')\n             WHERE app_type = ?1\",\n            rusqlite::params![app_type, trimmed],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n\n        Ok(())\n    }\n\n    /// 获取计费模式来源\n    pub async fn get_pricing_model_source(&self, app_type: &str) -> Result<String, AppError> {\n        let result = {\n            let conn = lock_conn!(self.conn);\n            conn.query_row(\n                \"SELECT pricing_model_source FROM proxy_config WHERE app_type = ?1\",\n                [app_type],\n                |row| row.get(0),\n            )\n        };\n\n        match result {\n            Ok(value) => Ok(value),\n            Err(rusqlite::Error::QueryReturnedNoRows) => {\n                self.init_proxy_config_rows().await?;\n                Ok(\"response\".to_string())\n            }\n            Err(e) => Err(AppError::Database(e.to_string())),\n        }\n    }\n\n    /// 设置计费模式来源\n    pub async fn set_pricing_model_source(\n        &self,\n        app_type: &str,\n        value: &str,\n    ) -> Result<(), AppError> {\n        let trimmed = value.trim();\n        if !matches!(trimmed, \"response\" | \"request\") {\n            return Err(AppError::localized(\n                \"error.invalidPricingMode\",\n                format!(\"无效计费模式: {value}\"),\n                format!(\"Invalid pricing mode: {value}\"),\n            ));\n        }\n\n        // 确保行存在\n        self.ensure_proxy_config_row_exists(app_type)?;\n\n        let conn = lock_conn!(self.conn);\n        conn.execute(\n            \"UPDATE proxy_config SET\n                pricing_model_source = ?2,\n                updated_at = datetime('now')\n             WHERE app_type = ?1\",\n            rusqlite::params![app_type, trimmed],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n\n        Ok(())\n    }\n\n    /// 获取应用级代理配置\n    pub async fn get_proxy_config_for_app(\n        &self,\n        app_type: &str,\n    ) -> Result<AppProxyConfig, AppError> {\n        // 使用 block 限制 conn 的作用域，避免跨 await 持有锁\n        let app_type_owned = app_type.to_string();\n        let result = {\n            let conn = lock_conn!(self.conn);\n            conn.query_row(\n                \"SELECT app_type, enabled, auto_failover_enabled,\n                        max_retries, streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,\n                        circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,\n                        circuit_error_rate_threshold, circuit_min_requests\n                 FROM proxy_config WHERE app_type = ?1\",\n                [app_type],\n                |row| {\n                    Ok(AppProxyConfig {\n                        app_type: row.get(0)?,\n                        enabled: row.get::<_, i32>(1)? != 0,\n                        auto_failover_enabled: row.get::<_, i32>(2)? != 0,\n                        max_retries: row.get::<_, i32>(3)? as u32,\n                        streaming_first_byte_timeout: row.get::<_, i32>(4)? as u32,\n                        streaming_idle_timeout: row.get::<_, i32>(5)? as u32,\n                        non_streaming_timeout: row.get::<_, i32>(6)? as u32,\n                        circuit_failure_threshold: row.get::<_, i32>(7)? as u32,\n                        circuit_success_threshold: row.get::<_, i32>(8)? as u32,\n                        circuit_timeout_seconds: row.get::<_, i32>(9)? as u32,\n                        circuit_error_rate_threshold: row.get(10)?,\n                        circuit_min_requests: row.get::<_, i32>(11)? as u32,\n                    })\n                },\n            )\n        };\n        // conn 已在 block 结束时释放\n\n        match result {\n            Ok(config) => Ok(config),\n            Err(rusqlite::Error::QueryReturnedNoRows) => {\n                // 如果不存在，创建默认配置\n                self.init_proxy_config_rows().await?;\n                Ok(AppProxyConfig {\n                    app_type: app_type_owned,\n                    enabled: false,\n                    auto_failover_enabled: false,\n                    max_retries: 3,\n                    streaming_first_byte_timeout: 60,\n                    streaming_idle_timeout: 120,\n                    non_streaming_timeout: 600,\n                    circuit_failure_threshold: 4,\n                    circuit_success_threshold: 2,\n                    circuit_timeout_seconds: 60,\n                    circuit_error_rate_threshold: 0.6,\n                    circuit_min_requests: 10,\n                })\n            }\n            Err(e) => Err(AppError::Database(e.to_string())),\n        }\n    }\n\n    /// 更新应用级代理配置\n    pub async fn update_proxy_config_for_app(\n        &self,\n        config: AppProxyConfig,\n    ) -> Result<(), AppError> {\n        let conn = lock_conn!(self.conn);\n\n        conn.execute(\n            \"UPDATE proxy_config SET\n                enabled = ?2,\n                auto_failover_enabled = ?3,\n                max_retries = ?4,\n                streaming_first_byte_timeout = ?5,\n                streaming_idle_timeout = ?6,\n                non_streaming_timeout = ?7,\n                circuit_failure_threshold = ?8,\n                circuit_success_threshold = ?9,\n                circuit_timeout_seconds = ?10,\n                circuit_error_rate_threshold = ?11,\n                circuit_min_requests = ?12,\n                updated_at = datetime('now')\n             WHERE app_type = ?1\",\n            rusqlite::params![\n                config.app_type,\n                if config.enabled { 1 } else { 0 },\n                if config.auto_failover_enabled { 1 } else { 0 },\n                config.max_retries as i32,\n                config.streaming_first_byte_timeout as i32,\n                config.streaming_idle_timeout as i32,\n                config.non_streaming_timeout as i32,\n                config.circuit_failure_threshold as i32,\n                config.circuit_success_threshold as i32,\n                config.circuit_timeout_seconds as i32,\n                config.circuit_error_rate_threshold,\n                config.circuit_min_requests as i32,\n            ],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n\n        Ok(())\n    }\n\n    /// 确保指定 app_type 的 proxy_config 行存在（同步版本，用于 set_* 函数）\n    ///\n    /// 使用与 schema.rs seed 相同的 per-app 默认值\n    fn ensure_proxy_config_row_exists(&self, app_type: &str) -> Result<(), AppError> {\n        let conn = self\n            .conn\n            .lock()\n            .map_err(|e| AppError::Lock(e.to_string()))?;\n\n        // 根据 app_type 使用不同的默认值（与 schema.rs seed 保持一致）\n        let (retries, fb_timeout, idle_timeout, cb_fail, cb_succ, cb_timeout, cb_rate, cb_min) =\n            match app_type {\n                \"claude\" => (6, 90, 180, 8, 3, 90, 0.7, 15),\n                \"codex\" => (3, 60, 120, 4, 2, 60, 0.6, 10),\n                \"gemini\" => (5, 60, 120, 4, 2, 60, 0.6, 10),\n                _ => (3, 60, 120, 4, 2, 60, 0.6, 10), // 默认值\n            };\n\n        conn.execute(\n            \"INSERT OR IGNORE INTO proxy_config (\n                app_type, max_retries,\n                streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,\n                circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,\n                circuit_error_rate_threshold, circuit_min_requests\n            ) VALUES (?1, ?2, ?3, ?4, 600, ?5, ?6, ?7, ?8, ?9)\",\n            rusqlite::params![\n                app_type,\n                retries,\n                fb_timeout,\n                idle_timeout,\n                cb_fail,\n                cb_succ,\n                cb_timeout,\n                cb_rate,\n                cb_min\n            ],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n\n        Ok(())\n    }\n\n    /// 初始化 proxy_config 表的三行数据\n    ///\n    /// 使用与 schema.rs seed 相同的 per-app 默认值\n    async fn init_proxy_config_rows(&self) -> Result<(), AppError> {\n        let conn = lock_conn!(self.conn);\n\n        // 使用与 schema.rs seed 相同的 per-app 默认值\n        // claude: 更激进的重试和超时配置\n        conn.execute(\n            \"INSERT OR IGNORE INTO proxy_config (\n                app_type, max_retries,\n                streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,\n                circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,\n                circuit_error_rate_threshold, circuit_min_requests\n            ) VALUES ('claude', 6, 90, 180, 600, 8, 3, 90, 0.7, 15)\",\n            [],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n\n        // codex: 默认配置\n        conn.execute(\n            \"INSERT OR IGNORE INTO proxy_config (\n                app_type, max_retries,\n                streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,\n                circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,\n                circuit_error_rate_threshold, circuit_min_requests\n            ) VALUES ('codex', 3, 60, 120, 600, 4, 2, 60, 0.6, 10)\",\n            [],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n\n        // gemini: 稍高的重试次数\n        conn.execute(\n            \"INSERT OR IGNORE INTO proxy_config (\n                app_type, max_retries,\n                streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,\n                circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,\n                circuit_error_rate_threshold, circuit_min_requests\n            ) VALUES ('gemini', 5, 60, 120, 600, 4, 2, 60, 0.6, 10)\",\n            [],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n\n        Ok(())\n    }\n\n    // ==================== Legacy Proxy Config (兼容旧代码) ====================\n\n    /// 获取代理配置（兼容旧接口，返回 claude 行的配置）\n    pub async fn get_proxy_config(&self) -> Result<ProxyConfig, AppError> {\n        // 使用 block 限制 conn 的作用域，避免跨 await 持有锁\n        let result = {\n            let conn = lock_conn!(self.conn);\n            conn.query_row(\n                \"SELECT listen_address, listen_port, max_retries,\n                        enable_logging,\n                        streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout\n                 FROM proxy_config WHERE app_type = 'claude'\",\n                [],\n                |row| {\n                    Ok(ProxyConfig {\n                        listen_address: row.get(0)?,\n                        listen_port: row.get::<_, i32>(1)? as u16,\n                        max_retries: row.get::<_, i32>(2)? as u8,\n                        request_timeout: 600, // 废弃字段，返回默认值\n                        enable_logging: row.get::<_, i32>(3)? != 0,\n                        live_takeover_active: false, // 废弃字段\n                        streaming_first_byte_timeout: row.get::<_, i32>(4).unwrap_or(60) as u64,\n                        streaming_idle_timeout: row.get::<_, i32>(5).unwrap_or(120) as u64,\n                        non_streaming_timeout: row.get::<_, i32>(6).unwrap_or(600) as u64,\n                    })\n                },\n            )\n        };\n        // conn 已在 block 结束时释放\n\n        match result {\n            Ok(config) => Ok(config),\n            Err(rusqlite::Error::QueryReturnedNoRows) => {\n                // 如果不存在，初始化默认配置\n                self.init_proxy_config_rows().await?;\n                Ok(ProxyConfig::default())\n            }\n            Err(e) => Err(AppError::Database(e.to_string())),\n        }\n    }\n\n    /// 更新代理配置（兼容旧接口，更新所有三行的公共字段）\n    pub async fn update_proxy_config(&self, config: ProxyConfig) -> Result<(), AppError> {\n        let conn = lock_conn!(self.conn);\n\n        // 更新所有三行的公共字段\n        conn.execute(\n            \"UPDATE proxy_config SET\n                listen_address = ?1,\n                listen_port = ?2,\n                max_retries = ?3,\n                enable_logging = ?4,\n                streaming_first_byte_timeout = ?5,\n                streaming_idle_timeout = ?6,\n                non_streaming_timeout = ?7,\n                updated_at = datetime('now')\",\n            rusqlite::params![\n                config.listen_address,\n                config.listen_port as i32,\n                config.max_retries as i32,\n                if config.enable_logging { 1 } else { 0 },\n                config.streaming_first_byte_timeout as i32,\n                config.streaming_idle_timeout as i32,\n                config.non_streaming_timeout as i32,\n            ],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n\n        Ok(())\n    }\n\n    /// 设置 Live 接管状态（兼容旧版本，更新 enabled 字段）\n    pub async fn set_live_takeover_active(&self, _active: bool) -> Result<(), AppError> {\n        // 不再使用此字段，由 enabled 字段替代\n        // 保留空实现以兼容旧代码\n        Ok(())\n    }\n\n    /// 检查是否处于 Live 接管模式\n    ///\n    /// 检查是否有任一 app 的 enabled = true\n    pub async fn is_live_takeover_active(&self) -> Result<bool, AppError> {\n        let conn = lock_conn!(self.conn);\n        let count: i64 = conn\n            .query_row(\n                \"SELECT COUNT(*) FROM proxy_config WHERE enabled = 1\",\n                [],\n                |row| row.get(0),\n            )\n            .map_err(|e| AppError::Database(e.to_string()))?;\n        Ok(count > 0)\n    }\n\n    // ==================== Provider Health ====================\n\n    /// 获取Provider健康状态\n    pub async fn get_provider_health(\n        &self,\n        provider_id: &str,\n        app_type: &str,\n    ) -> Result<ProviderHealth, AppError> {\n        let result = {\n            let conn = lock_conn!(self.conn);\n\n            conn.query_row(\n                \"SELECT provider_id, app_type, is_healthy, consecutive_failures,\n                        last_success_at, last_failure_at, last_error, updated_at\n                 FROM provider_health\n                 WHERE provider_id = ?1 AND app_type = ?2\",\n                rusqlite::params![provider_id, app_type],\n                |row| {\n                    Ok(ProviderHealth {\n                        provider_id: row.get(0)?,\n                        app_type: row.get(1)?,\n                        is_healthy: row.get::<_, i64>(2)? != 0,\n                        consecutive_failures: row.get::<_, i64>(3)? as u32,\n                        last_success_at: row.get(4)?,\n                        last_failure_at: row.get(5)?,\n                        last_error: row.get(6)?,\n                        updated_at: row.get(7)?,\n                    })\n                },\n            )\n        };\n\n        match result {\n            Ok(health) => Ok(health),\n            // 缺少记录时视为健康（关闭后清空状态，再次打开时默认正常）\n            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(ProviderHealth {\n                provider_id: provider_id.to_string(),\n                app_type: app_type.to_string(),\n                is_healthy: true,\n                consecutive_failures: 0,\n                last_success_at: None,\n                last_failure_at: None,\n                last_error: None,\n                updated_at: chrono::Utc::now().to_rfc3339(),\n            }),\n            Err(e) => Err(AppError::Database(e.to_string())),\n        }\n    }\n\n    /// 更新Provider健康状态\n    ///\n    /// 使用默认阈值（5）判断是否健康，建议使用 `update_provider_health_with_threshold` 传入配置的阈值\n    pub async fn update_provider_health(\n        &self,\n        provider_id: &str,\n        app_type: &str,\n        success: bool,\n        error_msg: Option<String>,\n    ) -> Result<(), AppError> {\n        // 默认阈值与 CircuitBreakerConfig::default() 保持一致\n        self.update_provider_health_with_threshold(provider_id, app_type, success, error_msg, 5)\n            .await\n    }\n\n    /// 更新Provider健康状态（带阈值参数）\n    ///\n    /// # Arguments\n    /// * `failure_threshold` - 连续失败多少次后标记为不健康\n    pub async fn update_provider_health_with_threshold(\n        &self,\n        provider_id: &str,\n        app_type: &str,\n        success: bool,\n        error_msg: Option<String>,\n        failure_threshold: u32,\n    ) -> Result<(), AppError> {\n        let conn = lock_conn!(self.conn);\n\n        let now = chrono::Utc::now().to_rfc3339();\n\n        // 先查询当前状态\n        let current = conn.query_row(\n            \"SELECT consecutive_failures FROM provider_health\n             WHERE provider_id = ?1 AND app_type = ?2\",\n            rusqlite::params![provider_id, app_type],\n            |row| Ok(row.get::<_, i64>(0)? as u32),\n        );\n\n        let (is_healthy, consecutive_failures) = if success {\n            // 成功：重置失败计数\n            (1, 0)\n        } else {\n            // 失败：增加失败计数\n            let failures = current.unwrap_or(0) + 1;\n            // 使用传入的阈值而非硬编码\n            let healthy = if failures >= failure_threshold { 0 } else { 1 };\n            (healthy, failures)\n        };\n\n        let (last_success_at, last_failure_at) = if success {\n            (Some(now.clone()), None)\n        } else {\n            (None, Some(now.clone()))\n        };\n\n        // UPSERT\n        conn.execute(\n            \"INSERT OR REPLACE INTO provider_health\n             (provider_id, app_type, is_healthy, consecutive_failures,\n              last_success_at, last_failure_at, last_error, updated_at)\n             VALUES (?1, ?2, ?3, ?4,\n                     COALESCE(?5, (SELECT last_success_at FROM provider_health\n                                   WHERE provider_id = ?1 AND app_type = ?2)),\n                     COALESCE(?6, (SELECT last_failure_at FROM provider_health\n                                   WHERE provider_id = ?1 AND app_type = ?2)),\n                     ?7, ?8)\",\n            rusqlite::params![\n                provider_id,\n                app_type,\n                is_healthy,\n                consecutive_failures as i64,\n                last_success_at,\n                last_failure_at,\n                error_msg,\n                &now,\n            ],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n\n        Ok(())\n    }\n\n    /// 重置Provider健康状态\n    pub async fn reset_provider_health(\n        &self,\n        provider_id: &str,\n        app_type: &str,\n    ) -> Result<(), AppError> {\n        let conn = lock_conn!(self.conn);\n\n        conn.execute(\n            \"DELETE FROM provider_health WHERE provider_id = ?1 AND app_type = ?2\",\n            rusqlite::params![provider_id, app_type],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n\n        log::debug!(\"Reset health status for provider {provider_id} (app: {app_type})\");\n\n        Ok(())\n    }\n\n    /// 清空指定应用的健康状态（关闭单个代理时使用）\n    pub async fn clear_provider_health_for_app(&self, app_type: &str) -> Result<(), AppError> {\n        let conn = lock_conn!(self.conn);\n\n        conn.execute(\n            \"DELETE FROM provider_health WHERE app_type = ?1\",\n            [app_type],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n\n        log::debug!(\"Cleared provider health records for app {app_type}\");\n        Ok(())\n    }\n\n    /// 清空所有Provider健康状态（代理停止时调用）\n    pub async fn clear_all_provider_health(&self) -> Result<(), AppError> {\n        let conn = lock_conn!(self.conn);\n\n        conn.execute(\"DELETE FROM provider_health\", [])\n            .map_err(|e| AppError::Database(e.to_string()))?;\n\n        log::debug!(\"Cleared all provider health records\");\n        Ok(())\n    }\n\n    // ==================== Circuit Breaker Config (Legacy Compatibility) ====================\n\n    /// 获取熔断器配置（兼容旧接口，从 claude 行读取）\n    ///\n    /// 熔断器配置已合并到 proxy_config 表，每 app 独立\n    /// 此方法保留用于兼容旧代码，建议使用 get_proxy_config_for_app\n    pub async fn get_circuit_breaker_config(\n        &self,\n    ) -> Result<crate::proxy::circuit_breaker::CircuitBreakerConfig, AppError> {\n        // 使用 block 限制 conn 的作用域，避免跨 await 持有锁\n        let result = {\n            let conn = lock_conn!(self.conn);\n            conn.query_row(\n                \"SELECT circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,\n                        circuit_error_rate_threshold, circuit_min_requests\n                 FROM proxy_config WHERE app_type = 'claude'\",\n                [],\n                |row| {\n                    Ok(crate::proxy::circuit_breaker::CircuitBreakerConfig {\n                        failure_threshold: row.get::<_, i32>(0)? as u32,\n                        success_threshold: row.get::<_, i32>(1)? as u32,\n                        timeout_seconds: row.get::<_, i64>(2)? as u64,\n                        error_rate_threshold: row.get(3)?,\n                        min_requests: row.get::<_, i32>(4)? as u32,\n                    })\n                },\n            )\n        };\n        // conn 已在 block 结束时释放\n\n        match result {\n            Ok(config) => Ok(config),\n            Err(rusqlite::Error::QueryReturnedNoRows) => {\n                // 如果不存在，初始化默认配置\n                self.init_proxy_config_rows().await?;\n                Ok(crate::proxy::circuit_breaker::CircuitBreakerConfig::default())\n            }\n            Err(e) => Err(AppError::Database(e.to_string())),\n        }\n    }\n\n    /// 更新熔断器配置（兼容旧接口，更新所有三行）\n    ///\n    /// 熔断器配置已合并到 proxy_config 表\n    /// 此方法保留用于兼容旧代码，建议使用 update_proxy_config_for_app\n    pub async fn update_circuit_breaker_config(\n        &self,\n        config: &crate::proxy::circuit_breaker::CircuitBreakerConfig,\n    ) -> Result<(), AppError> {\n        let conn = lock_conn!(self.conn);\n\n        // 更新所有三行的熔断器配置\n        conn.execute(\n            \"UPDATE proxy_config SET\n                circuit_failure_threshold = ?1,\n                circuit_success_threshold = ?2,\n                circuit_timeout_seconds = ?3,\n                circuit_error_rate_threshold = ?4,\n                circuit_min_requests = ?5,\n                updated_at = datetime('now')\",\n            rusqlite::params![\n                config.failure_threshold as i32,\n                config.success_threshold as i32,\n                config.timeout_seconds as i64,\n                config.error_rate_threshold,\n                config.min_requests as i32,\n            ],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n\n        Ok(())\n    }\n\n    // ==================== Live Backup ====================\n\n    /// 保存 Live 配置备份\n    pub async fn save_live_backup(\n        &self,\n        app_type: &str,\n        config_json: &str,\n    ) -> Result<(), AppError> {\n        let conn = lock_conn!(self.conn);\n        let now = chrono::Utc::now().to_rfc3339();\n\n        conn.execute(\n            \"INSERT OR REPLACE INTO proxy_live_backup (app_type, original_config, backed_up_at)\n             VALUES (?1, ?2, ?3)\",\n            rusqlite::params![app_type, config_json, now],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n\n        log::info!(\"已备份 {app_type} Live 配置\");\n        Ok(())\n    }\n\n    /// 检查是否存在任意 Live 配置备份\n    pub async fn has_any_live_backup(&self) -> Result<bool, AppError> {\n        let conn = lock_conn!(self.conn);\n        let count: i64 = conn\n            .query_row(\"SELECT COUNT(*) FROM proxy_live_backup\", [], |row| {\n                row.get(0)\n            })\n            .map_err(|e| AppError::Database(e.to_string()))?;\n        Ok(count > 0)\n    }\n\n    /// 获取 Live 配置备份\n    pub async fn get_live_backup(&self, app_type: &str) -> Result<Option<LiveBackup>, AppError> {\n        let conn = lock_conn!(self.conn);\n\n        let result = conn.query_row(\n            \"SELECT app_type, original_config, backed_up_at FROM proxy_live_backup WHERE app_type = ?1\",\n            rusqlite::params![app_type],\n            |row| {\n                Ok(LiveBackup {\n                    app_type: row.get(0)?,\n                    original_config: row.get(1)?,\n                    backed_up_at: row.get(2)?,\n                })\n            },\n        );\n\n        match result {\n            Ok(backup) => Ok(Some(backup)),\n            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),\n            Err(e) => Err(AppError::Database(e.to_string())),\n        }\n    }\n\n    /// 删除 Live 配置备份\n    pub async fn delete_live_backup(&self, app_type: &str) -> Result<(), AppError> {\n        let conn = lock_conn!(self.conn);\n\n        conn.execute(\n            \"DELETE FROM proxy_live_backup WHERE app_type = ?1\",\n            rusqlite::params![app_type],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n\n        log::info!(\"已删除 {app_type} Live 配置备份\");\n        Ok(())\n    }\n\n    /// 删除所有 Live 配置备份\n    pub async fn delete_all_live_backups(&self) -> Result<(), AppError> {\n        let conn = lock_conn!(self.conn);\n\n        conn.execute(\"DELETE FROM proxy_live_backup\", [])\n            .map_err(|e| AppError::Database(e.to_string()))?;\n\n        log::info!(\"已删除所有 Live 配置备份\");\n        Ok(())\n    }\n\n    // ==================== Sync Methods for Tray Menu ====================\n\n    /// 同步获取应用的 proxy 启用状态和自动故障转移状态\n    ///\n    /// 用于托盘菜单构建等同步场景\n    /// 返回 (enabled, auto_failover_enabled)\n    pub fn get_proxy_flags_sync(&self, app_type: &str) -> (bool, bool) {\n        let conn = match self.conn.lock() {\n            Ok(c) => c,\n            Err(_) => return (false, false),\n        };\n\n        conn.query_row(\n            \"SELECT enabled, auto_failover_enabled FROM proxy_config WHERE app_type = ?1\",\n            [app_type],\n            |row| Ok((row.get::<_, i32>(0)? != 0, row.get::<_, i32>(1)? != 0)),\n        )\n        .unwrap_or((false, false))\n    }\n\n    /// 同步设置应用的 proxy 启用状态和自动故障转移状态\n    ///\n    /// 用于托盘菜单点击等同步场景\n    pub fn set_proxy_flags_sync(\n        &self,\n        app_type: &str,\n        enabled: bool,\n        auto_failover_enabled: bool,\n    ) -> Result<(), AppError> {\n        let conn = self\n            .conn\n            .lock()\n            .map_err(|e| AppError::Database(format!(\"Mutex lock failed: {e}\")))?;\n\n        conn.execute(\n            \"UPDATE proxy_config SET enabled = ?2, auto_failover_enabled = ?3, updated_at = datetime('now') WHERE app_type = ?1\",\n            rusqlite::params![\n                app_type,\n                if enabled { 1 } else { 0 },\n                if auto_failover_enabled { 1 } else { 0 },\n            ],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n\n        Ok(())\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use crate::database::Database;\n    use crate::error::AppError;\n\n    #[tokio::test]\n    async fn test_default_cost_multiplier_round_trip() -> Result<(), AppError> {\n        let db = Database::memory()?;\n\n        let default = db.get_default_cost_multiplier(\"claude\").await?;\n        assert_eq!(default, \"1\");\n\n        db.set_default_cost_multiplier(\"claude\", \"1.5\").await?;\n        let updated = db.get_default_cost_multiplier(\"claude\").await?;\n        assert_eq!(updated, \"1.5\");\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_default_cost_multiplier_validation() -> Result<(), AppError> {\n        let db = Database::memory()?;\n\n        let err = db\n            .set_default_cost_multiplier(\"claude\", \"not-a-number\")\n            .await\n            .unwrap_err();\n        // AppError::localized returns AppError::Localized variant\n        assert!(matches!(\n            err,\n            AppError::Localized {\n                key: \"error.invalidMultiplier\",\n                ..\n            }\n        ));\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_pricing_model_source_round_trip_and_validation() -> Result<(), AppError> {\n        let db = Database::memory()?;\n\n        let default = db.get_pricing_model_source(\"claude\").await?;\n        assert_eq!(default, \"response\");\n\n        db.set_pricing_model_source(\"claude\", \"request\").await?;\n        let updated = db.get_pricing_model_source(\"claude\").await?;\n        assert_eq!(updated, \"request\");\n\n        let err = db\n            .set_pricing_model_source(\"claude\", \"invalid\")\n            .await\n            .unwrap_err();\n        // AppError::localized returns AppError::Localized variant\n        assert!(matches!(\n            err,\n            AppError::Localized {\n                key: \"error.invalidPricingMode\",\n                ..\n            }\n        ));\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/database/dao/settings.rs",
    "content": "//! 通用设置数据访问对象\n//!\n//! 提供键值对形式的通用设置存储。\n\nuse crate::database::{lock_conn, Database};\nuse crate::error::AppError;\nuse rusqlite::params;\n\nimpl Database {\n    const LEGACY_COMMON_CONFIG_MIGRATED_KEY: &'static str = \"common_config_legacy_migrated_v1\";\n\n    fn config_snippet_cleared_key(app_type: &str) -> String {\n        format!(\"common_config_{app_type}_cleared\")\n    }\n\n    /// 获取设置值\n    pub fn get_setting(&self, key: &str) -> Result<Option<String>, AppError> {\n        let conn = lock_conn!(self.conn);\n        let mut stmt = conn\n            .prepare(\"SELECT value FROM settings WHERE key = ?1\")\n            .map_err(|e| AppError::Database(e.to_string()))?;\n\n        let mut rows = stmt\n            .query(params![key])\n            .map_err(|e| AppError::Database(e.to_string()))?;\n\n        if let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {\n            Ok(Some(\n                row.get(0).map_err(|e| AppError::Database(e.to_string()))?,\n            ))\n        } else {\n            Ok(None)\n        }\n    }\n\n    /// 设置值\n    pub fn set_setting(&self, key: &str, value: &str) -> Result<(), AppError> {\n        let conn = lock_conn!(self.conn);\n        conn.execute(\n            \"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)\",\n            params![key, value],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n        Ok(())\n    }\n\n    // --- 通用配置片段 (Common Config Snippet) ---\n\n    /// 获取通用配置片段\n    pub fn get_config_snippet(&self, app_type: &str) -> Result<Option<String>, AppError> {\n        self.get_setting(&format!(\"common_config_{app_type}\"))\n    }\n\n    /// 检查通用配置片段是否被用户显式清空\n    pub fn is_config_snippet_cleared(&self, app_type: &str) -> Result<bool, AppError> {\n        Ok(self\n            .get_setting(&Self::config_snippet_cleared_key(app_type))?\n            .as_deref()\n            == Some(\"true\"))\n    }\n\n    /// 设置通用配置片段是否被显式清空\n    pub fn set_config_snippet_cleared(\n        &self,\n        app_type: &str,\n        cleared: bool,\n    ) -> Result<(), AppError> {\n        let key = Self::config_snippet_cleared_key(app_type);\n        if cleared {\n            self.set_setting(&key, \"true\")\n        } else {\n            let conn = lock_conn!(self.conn);\n            conn.execute(\"DELETE FROM settings WHERE key = ?1\", params![key])\n                .map_err(|e| AppError::Database(e.to_string()))?;\n            Ok(())\n        }\n    }\n\n    /// 当前是否允许从 live 配置自动抽取通用配置片段\n    pub fn should_auto_extract_config_snippet(&self, app_type: &str) -> Result<bool, AppError> {\n        Ok(self.get_config_snippet(app_type)?.is_none()\n            && !self.is_config_snippet_cleared(app_type)?)\n    }\n\n    /// 检查历史通用配置迁移是否已经执行过\n    pub fn is_legacy_common_config_migrated(&self) -> Result<bool, AppError> {\n        Ok(self\n            .get_setting(Self::LEGACY_COMMON_CONFIG_MIGRATED_KEY)?\n            .as_deref()\n            == Some(\"true\"))\n    }\n\n    /// 标记历史通用配置迁移已经执行完成\n    pub fn set_legacy_common_config_migrated(&self, migrated: bool) -> Result<(), AppError> {\n        if migrated {\n            self.set_setting(Self::LEGACY_COMMON_CONFIG_MIGRATED_KEY, \"true\")\n        } else {\n            let conn = lock_conn!(self.conn);\n            conn.execute(\n                \"DELETE FROM settings WHERE key = ?1\",\n                params![Self::LEGACY_COMMON_CONFIG_MIGRATED_KEY],\n            )\n            .map_err(|e| AppError::Database(e.to_string()))?;\n            Ok(())\n        }\n    }\n\n    /// 设置通用配置片段\n    pub fn set_config_snippet(\n        &self,\n        app_type: &str,\n        snippet: Option<String>,\n    ) -> Result<(), AppError> {\n        let key = format!(\"common_config_{app_type}\");\n        if let Some(value) = snippet {\n            self.set_setting(&key, &value)\n        } else {\n            // 如果为 None 则删除\n            let conn = lock_conn!(self.conn);\n            conn.execute(\"DELETE FROM settings WHERE key = ?1\", params![key])\n                .map_err(|e| AppError::Database(e.to_string()))?;\n            Ok(())\n        }\n    }\n\n    // --- 全局出站代理 ---\n\n    /// 全局代理 URL 的存储键名\n    const GLOBAL_PROXY_URL_KEY: &'static str = \"global_proxy_url\";\n\n    /// 获取全局出站代理 URL\n    ///\n    /// 返回 None 表示未配置或已清除代理（直连）\n    /// 返回 Some(url) 表示已配置代理\n    pub fn get_global_proxy_url(&self) -> Result<Option<String>, AppError> {\n        self.get_setting(Self::GLOBAL_PROXY_URL_KEY)\n    }\n\n    /// 设置全局出站代理 URL\n    ///\n    /// - 传入非空字符串：启用代理\n    /// - 传入空字符串或 None：清除代理设置（直连）\n    pub fn set_global_proxy_url(&self, url: Option<&str>) -> Result<(), AppError> {\n        match url {\n            Some(u) if !u.trim().is_empty() => {\n                self.set_setting(Self::GLOBAL_PROXY_URL_KEY, u.trim())\n            }\n            _ => {\n                // 清除代理设置\n                let conn = lock_conn!(self.conn);\n                conn.execute(\n                    \"DELETE FROM settings WHERE key = ?1\",\n                    params![Self::GLOBAL_PROXY_URL_KEY],\n                )\n                .map_err(|e| AppError::Database(e.to_string()))?;\n                Ok(())\n            }\n        }\n    }\n\n    // --- 代理接管状态管理（已废弃，使用 proxy_config.enabled 替代）---\n\n    /// 获取指定应用的代理接管状态\n    ///\n    /// **已废弃**: 请使用 `proxy_config.enabled` 字段替代\n    /// 此方法仅用于数据库迁移时读取旧数据\n    #[deprecated(since = \"3.9.0\", note = \"使用 get_proxy_config_for_app().enabled 替代\")]\n    pub fn get_proxy_takeover_enabled(&self, app_type: &str) -> Result<bool, AppError> {\n        let key = format!(\"proxy_takeover_{app_type}\");\n        match self.get_setting(&key)? {\n            Some(value) => Ok(value == \"true\"),\n            None => Ok(false),\n        }\n    }\n\n    /// 设置指定应用的代理接管状态\n    ///\n    /// **已废弃**: 请使用 `proxy_config.enabled` 字段替代\n    #[deprecated(\n        since = \"3.9.0\",\n        note = \"使用 update_proxy_config_for_app() 修改 enabled 字段\"\n    )]\n    pub fn set_proxy_takeover_enabled(\n        &self,\n        app_type: &str,\n        enabled: bool,\n    ) -> Result<(), AppError> {\n        let key = format!(\"proxy_takeover_{app_type}\");\n        let value = if enabled { \"true\" } else { \"false\" };\n        self.set_setting(&key, value)\n    }\n\n    /// 检查是否有任一应用开启了代理接管\n    ///\n    /// **已废弃**: 请使用 `is_live_takeover_active()` 替代\n    #[deprecated(since = \"3.9.0\", note = \"使用 is_live_takeover_active() 替代\")]\n    pub fn has_any_proxy_takeover(&self) -> Result<bool, AppError> {\n        let conn = lock_conn!(self.conn);\n        let count: i64 = conn\n            .query_row(\n                \"SELECT COUNT(*) FROM settings WHERE key LIKE 'proxy_takeover_%' AND value = 'true'\",\n                [],\n                |row| row.get(0),\n            )\n            .map_err(|e| AppError::Database(e.to_string()))?;\n        Ok(count > 0)\n    }\n\n    /// 清除所有代理接管状态（将所有 proxy_takeover_* 设置为 false）\n    ///\n    /// **已废弃**: settings 表不再用于存储代理状态\n    #[deprecated(\n        since = \"3.9.0\",\n        note = \"使用 update_proxy_config_for_app() 清除各应用的 enabled 字段\"\n    )]\n    pub fn clear_all_proxy_takeover(&self) -> Result<(), AppError> {\n        let conn = lock_conn!(self.conn);\n        conn.execute(\n            \"UPDATE settings SET value = 'false' WHERE key LIKE 'proxy_takeover_%'\",\n            [],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n        log::info!(\"已清除所有代理接管状态\");\n        Ok(())\n    }\n\n    // --- 整流器配置 ---\n\n    /// 获取整流器配置\n    ///\n    /// 返回整流器配置，如果不存在则返回默认值（全部开启）\n    pub fn get_rectifier_config(&self) -> Result<crate::proxy::types::RectifierConfig, AppError> {\n        match self.get_setting(\"rectifier_config\")? {\n            Some(json) => serde_json::from_str(&json)\n                .map_err(|e| AppError::Database(format!(\"解析整流器配置失败: {e}\"))),\n            None => Ok(crate::proxy::types::RectifierConfig::default()),\n        }\n    }\n\n    /// 更新整流器配置\n    pub fn set_rectifier_config(\n        &self,\n        config: &crate::proxy::types::RectifierConfig,\n    ) -> Result<(), AppError> {\n        let json = serde_json::to_string(config)\n            .map_err(|e| AppError::Database(format!(\"序列化整流器配置失败: {e}\")))?;\n        self.set_setting(\"rectifier_config\", &json)\n    }\n\n    // --- 优化器配置 ---\n\n    /// 获取优化器配置\n    ///\n    /// 返回优化器配置，如果不存在则返回默认值（默认关闭）\n    pub fn get_optimizer_config(&self) -> Result<crate::proxy::types::OptimizerConfig, AppError> {\n        match self.get_setting(\"optimizer_config\")? {\n            Some(json) => serde_json::from_str(&json)\n                .map_err(|e| AppError::Database(format!(\"解析优化器配置失败: {e}\"))),\n            None => Ok(crate::proxy::types::OptimizerConfig::default()),\n        }\n    }\n\n    /// 更新优化器配置\n    pub fn set_optimizer_config(\n        &self,\n        config: &crate::proxy::types::OptimizerConfig,\n    ) -> Result<(), AppError> {\n        let json = serde_json::to_string(config)\n            .map_err(|e| AppError::Database(format!(\"序列化优化器配置失败: {e}\")))?;\n        self.set_setting(\"optimizer_config\", &json)\n    }\n\n    // --- 日志配置 ---\n\n    /// 获取日志配置\n    pub fn get_log_config(&self) -> Result<crate::proxy::types::LogConfig, AppError> {\n        match self.get_setting(\"log_config\")? {\n            Some(json) => serde_json::from_str(&json)\n                .map_err(|e| AppError::Database(format!(\"解析日志配置失败: {e}\"))),\n            None => Ok(crate::proxy::types::LogConfig::default()),\n        }\n    }\n\n    /// 更新日志配置\n    pub fn set_log_config(&self, config: &crate::proxy::types::LogConfig) -> Result<(), AppError> {\n        let json = serde_json::to_string(config)\n            .map_err(|e| AppError::Database(format!(\"序列化日志配置失败: {e}\")))?;\n        self.set_setting(\"log_config\", &json)\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/database/dao/skills.rs",
    "content": "//! Skills 数据访问对象\n//!\n//! 提供 Skills 和 Skill Repos 的 CRUD 操作。\n//!\n//! v3.10.0+ 统一管理架构：\n//! - Skills 使用统一的 id 主键，支持四应用启用标志\n//! - 实际文件存储在 ~/.cc-switch/skills/，同步到各应用目录\n\nuse crate::app_config::{InstalledSkill, SkillApps};\nuse crate::database::{lock_conn, Database};\nuse crate::error::AppError;\nuse crate::services::skill::SkillRepo;\nuse indexmap::IndexMap;\nuse rusqlite::params;\n\nimpl Database {\n    // ========== InstalledSkill CRUD ==========\n\n    /// 获取所有已安装的 Skills\n    pub fn get_all_installed_skills(&self) -> Result<IndexMap<String, InstalledSkill>, AppError> {\n        let conn = lock_conn!(self.conn);\n        let mut stmt = conn\n            .prepare(\n                \"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,\n                        readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at\n                 FROM skills ORDER BY name ASC\",\n            )\n            .map_err(|e| AppError::Database(e.to_string()))?;\n\n        let skill_iter = stmt\n            .query_map([], |row| {\n                Ok(InstalledSkill {\n                    id: row.get(0)?,\n                    name: row.get(1)?,\n                    description: row.get(2)?,\n                    directory: row.get(3)?,\n                    repo_owner: row.get(4)?,\n                    repo_name: row.get(5)?,\n                    repo_branch: row.get(6)?,\n                    readme_url: row.get(7)?,\n                    apps: SkillApps {\n                        claude: row.get(8)?,\n                        codex: row.get(9)?,\n                        gemini: row.get(10)?,\n                        opencode: row.get(11)?,\n                    },\n                    installed_at: row.get(12)?,\n                })\n            })\n            .map_err(|e| AppError::Database(e.to_string()))?;\n\n        let mut skills = IndexMap::new();\n        for skill_res in skill_iter {\n            let skill = skill_res.map_err(|e| AppError::Database(e.to_string()))?;\n            skills.insert(skill.id.clone(), skill);\n        }\n        Ok(skills)\n    }\n\n    /// 获取单个已安装的 Skill\n    pub fn get_installed_skill(&self, id: &str) -> Result<Option<InstalledSkill>, AppError> {\n        let conn = lock_conn!(self.conn);\n        let mut stmt = conn\n            .prepare(\n                \"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,\n                        readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at\n                 FROM skills WHERE id = ?1\",\n            )\n            .map_err(|e| AppError::Database(e.to_string()))?;\n\n        let result = stmt.query_row([id], |row| {\n            Ok(InstalledSkill {\n                id: row.get(0)?,\n                name: row.get(1)?,\n                description: row.get(2)?,\n                directory: row.get(3)?,\n                repo_owner: row.get(4)?,\n                repo_name: row.get(5)?,\n                repo_branch: row.get(6)?,\n                readme_url: row.get(7)?,\n                apps: SkillApps {\n                    claude: row.get(8)?,\n                    codex: row.get(9)?,\n                    gemini: row.get(10)?,\n                    opencode: row.get(11)?,\n                },\n                installed_at: row.get(12)?,\n            })\n        });\n\n        match result {\n            Ok(skill) => Ok(Some(skill)),\n            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),\n            Err(e) => Err(AppError::Database(e.to_string())),\n        }\n    }\n\n    /// 保存 Skill（添加或更新）\n    pub fn save_skill(&self, skill: &InstalledSkill) -> Result<(), AppError> {\n        let conn = lock_conn!(self.conn);\n        conn.execute(\n            \"INSERT OR REPLACE INTO skills\n             (id, name, description, directory, repo_owner, repo_name, repo_branch,\n              readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at)\n             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)\",\n            params![\n                skill.id,\n                skill.name,\n                skill.description,\n                skill.directory,\n                skill.repo_owner,\n                skill.repo_name,\n                skill.repo_branch,\n                skill.readme_url,\n                skill.apps.claude,\n                skill.apps.codex,\n                skill.apps.gemini,\n                skill.apps.opencode,\n                skill.installed_at,\n            ],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n        Ok(())\n    }\n\n    /// 删除 Skill\n    pub fn delete_skill(&self, id: &str) -> Result<bool, AppError> {\n        let conn = lock_conn!(self.conn);\n        let affected = conn\n            .execute(\"DELETE FROM skills WHERE id = ?1\", params![id])\n            .map_err(|e| AppError::Database(e.to_string()))?;\n        Ok(affected > 0)\n    }\n\n    /// 清空所有 Skills（用于迁移）\n    pub fn clear_skills(&self) -> Result<(), AppError> {\n        let conn = lock_conn!(self.conn);\n        conn.execute(\"DELETE FROM skills\", [])\n            .map_err(|e| AppError::Database(e.to_string()))?;\n        Ok(())\n    }\n\n    /// 更新 Skill 的应用启用状态\n    pub fn update_skill_apps(&self, id: &str, apps: &SkillApps) -> Result<bool, AppError> {\n        let conn = lock_conn!(self.conn);\n        let affected = conn\n            .execute(\n                \"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_opencode = ?4 WHERE id = ?5\",\n                params![apps.claude, apps.codex, apps.gemini, apps.opencode, id],\n            )\n            .map_err(|e| AppError::Database(e.to_string()))?;\n        Ok(affected > 0)\n    }\n\n    // ========== SkillRepo CRUD（保持原有） ==========\n\n    /// 获取所有 Skill 仓库\n    pub fn get_skill_repos(&self) -> Result<Vec<SkillRepo>, AppError> {\n        let conn = lock_conn!(self.conn);\n        let mut stmt = conn\n            .prepare(\n                \"SELECT owner, name, branch, enabled FROM skill_repos ORDER BY owner ASC, name ASC\",\n            )\n            .map_err(|e| AppError::Database(e.to_string()))?;\n\n        let repo_iter = stmt\n            .query_map([], |row| {\n                Ok(SkillRepo {\n                    owner: row.get(0)?,\n                    name: row.get(1)?,\n                    branch: row.get(2)?,\n                    enabled: row.get(3)?,\n                })\n            })\n            .map_err(|e| AppError::Database(e.to_string()))?;\n\n        let mut repos = Vec::new();\n        for repo_res in repo_iter {\n            repos.push(repo_res.map_err(|e| AppError::Database(e.to_string()))?);\n        }\n        Ok(repos)\n    }\n\n    /// 保存 Skill 仓库\n    pub fn save_skill_repo(&self, repo: &SkillRepo) -> Result<(), AppError> {\n        let conn = lock_conn!(self.conn);\n        conn.execute(\n            \"INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled) VALUES (?1, ?2, ?3, ?4)\",\n            params![repo.owner, repo.name, repo.branch, repo.enabled],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n        Ok(())\n    }\n\n    /// 删除 Skill 仓库\n    pub fn delete_skill_repo(&self, owner: &str, name: &str) -> Result<(), AppError> {\n        let conn = lock_conn!(self.conn);\n        conn.execute(\n            \"DELETE FROM skill_repos WHERE owner = ?1 AND name = ?2\",\n            params![owner, name],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n        Ok(())\n    }\n\n    /// 初始化默认的 Skill 仓库（启动时调用，补充缺失的默认仓库）\n    pub fn init_default_skill_repos(&self) -> Result<usize, AppError> {\n        // 获取已有仓库列表\n        let existing = self.get_skill_repos()?;\n        let existing_keys: std::collections::HashSet<(String, String)> = existing\n            .iter()\n            .map(|r| (r.owner.clone(), r.name.clone()))\n            .collect();\n\n        // 获取默认仓库列表\n        let default_store = crate::services::skill::SkillStore::default();\n        let mut count = 0;\n\n        // 仅插入缺失的默认仓库\n        for repo in &default_store.repos {\n            let key = (repo.owner.clone(), repo.name.clone());\n            if !existing_keys.contains(&key) {\n                self.save_skill_repo(repo)?;\n                count += 1;\n                log::info!(\"补充默认 Skill 仓库: {}/{}\", repo.owner, repo.name);\n            }\n        }\n\n        if count > 0 {\n            log::info!(\"补充默认 Skill 仓库完成，新增 {count} 个\");\n        }\n        Ok(count)\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/database/dao/stream_check.rs",
    "content": "//! 流式健康检查日志 DAO\n\nuse crate::database::{lock_conn, Database};\nuse crate::error::AppError;\nuse crate::services::stream_check::{StreamCheckConfig, StreamCheckResult};\n\nimpl Database {\n    /// 保存流式检查日志\n    pub fn save_stream_check_log(\n        &self,\n        provider_id: &str,\n        provider_name: &str,\n        app_type: &str,\n        result: &StreamCheckResult,\n    ) -> Result<i64, AppError> {\n        let conn = lock_conn!(self.conn);\n\n        conn.execute(\n            \"INSERT INTO stream_check_logs \n             (provider_id, provider_name, app_type, status, success, message, \n              response_time_ms, http_status, model_used, retry_count, tested_at)\n             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)\",\n            rusqlite::params![\n                provider_id,\n                provider_name,\n                app_type,\n                format!(\"{:?}\", result.status).to_lowercase(),\n                result.success,\n                result.message,\n                result.response_time_ms.map(|t| t as i64),\n                result.http_status.map(|s| s as i64),\n                result.model_used,\n                result.retry_count as i64,\n                result.tested_at,\n            ],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n\n        Ok(conn.last_insert_rowid())\n    }\n\n    /// 获取流式检查配置\n    pub fn get_stream_check_config(&self) -> Result<StreamCheckConfig, AppError> {\n        match self.get_setting(\"stream_check_config\")? {\n            Some(json) => serde_json::from_str(&json)\n                .map_err(|e| AppError::Message(format!(\"解析配置失败: {e}\"))),\n            None => Ok(StreamCheckConfig::default()),\n        }\n    }\n\n    /// Delete stream check logs older than `retain_days` days.\n    /// Returns the number of deleted rows.\n    pub fn cleanup_old_stream_check_logs(&self, retain_days: i64) -> Result<u64, AppError> {\n        let cutoff = chrono::Utc::now().timestamp() - retain_days * 86400;\n        let conn = lock_conn!(self.conn);\n        let deleted = conn\n            .execute(\n                \"DELETE FROM stream_check_logs WHERE tested_at < ?1\",\n                [cutoff],\n            )\n            .map_err(|e| AppError::Database(e.to_string()))?;\n        if deleted > 0 {\n            log::info!(\"Cleaned up {deleted} stream_check_logs older than {retain_days} days\");\n        }\n        Ok(deleted as u64)\n    }\n\n    /// 保存流式检查配置\n    pub fn save_stream_check_config(&self, config: &StreamCheckConfig) -> Result<(), AppError> {\n        let json = serde_json::to_string(config)\n            .map_err(|e| AppError::Message(format!(\"序列化配置失败: {e}\")))?;\n        self.set_setting(\"stream_check_config\", &json)\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/database/dao/universal_providers.rs",
    "content": "//! 统一供应商 (Universal Provider) DAO\n//!\n//! 提供统一供应商的 CRUD 操作。\n\nuse crate::database::{lock_conn, to_json_string, Database};\nuse crate::error::AppError;\nuse crate::provider::UniversalProvider;\nuse std::collections::HashMap;\n\n/// 统一供应商的 Settings Key\nconst UNIVERSAL_PROVIDERS_KEY: &str = \"universal_providers\";\n\nimpl Database {\n    /// 获取所有统一供应商\n    pub fn get_all_universal_providers(\n        &self,\n    ) -> Result<HashMap<String, UniversalProvider>, AppError> {\n        let conn = lock_conn!(self.conn);\n\n        let mut stmt = conn\n            .prepare(\"SELECT value FROM settings WHERE key = ?\")\n            .map_err(|e| AppError::Database(e.to_string()))?;\n\n        let result: Option<String> = stmt\n            .query_row([UNIVERSAL_PROVIDERS_KEY], |row| row.get(0))\n            .ok();\n\n        match result {\n            Some(json) => serde_json::from_str(&json)\n                .map_err(|e| AppError::Database(format!(\"解析统一供应商数据失败: {e}\"))),\n            None => Ok(HashMap::new()),\n        }\n    }\n\n    /// 获取单个统一供应商\n    pub fn get_universal_provider(&self, id: &str) -> Result<Option<UniversalProvider>, AppError> {\n        let providers = self.get_all_universal_providers()?;\n        Ok(providers.get(id).cloned())\n    }\n\n    /// 保存统一供应商（添加或更新）\n    pub fn save_universal_provider(&self, provider: &UniversalProvider) -> Result<(), AppError> {\n        let mut providers = self.get_all_universal_providers()?;\n        providers.insert(provider.id.clone(), provider.clone());\n        self.save_all_universal_providers(&providers)\n    }\n\n    /// 删除统一供应商\n    pub fn delete_universal_provider(&self, id: &str) -> Result<bool, AppError> {\n        let mut providers = self.get_all_universal_providers()?;\n        let existed = providers.remove(id).is_some();\n        if existed {\n            self.save_all_universal_providers(&providers)?;\n        }\n        Ok(existed)\n    }\n\n    /// 保存所有统一供应商（内部方法）\n    fn save_all_universal_providers(\n        &self,\n        providers: &HashMap<String, UniversalProvider>,\n    ) -> Result<(), AppError> {\n        let conn = lock_conn!(self.conn);\n        let json = to_json_string(providers)?;\n\n        conn.execute(\n            \"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)\",\n            [UNIVERSAL_PROVIDERS_KEY, &json],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/database/dao/usage_rollup.rs",
    "content": "//! Usage rollup DAO\n//!\n//! Aggregates proxy_request_logs into daily rollups and prunes old detail rows.\n\nuse crate::database::{lock_conn, Database};\nuse crate::error::AppError;\n\nimpl Database {\n    /// Aggregate proxy_request_logs older than `retain_days` into usage_daily_rollups,\n    /// then delete the aggregated detail rows.\n    /// Returns the number of deleted detail rows.\n    pub fn rollup_and_prune(&self, retain_days: i64) -> Result<u64, AppError> {\n        let cutoff = chrono::Utc::now().timestamp() - retain_days * 86400;\n        let conn = lock_conn!(self.conn);\n\n        // Check if there are any rows to process\n        let count: i64 = conn\n            .query_row(\n                \"SELECT COUNT(*) FROM proxy_request_logs WHERE created_at < ?1\",\n                [cutoff],\n                |row| row.get(0),\n            )\n            .map_err(|e| AppError::Database(e.to_string()))?;\n\n        if count == 0 {\n            return Ok(0);\n        }\n\n        // Use a savepoint for atomicity\n        conn.execute(\"SAVEPOINT rollup_prune;\", [])\n            .map_err(|e| AppError::Database(e.to_string()))?;\n\n        let result = Self::do_rollup_and_prune(&conn, cutoff);\n\n        match result {\n            Ok(deleted) => {\n                conn.execute(\"RELEASE rollup_prune;\", [])\n                    .map_err(|e| AppError::Database(e.to_string()))?;\n                if deleted > 0 {\n                    log::info!(\n                        \"Rolled up and pruned {deleted} proxy_request_logs (retain={retain_days}d)\"\n                    );\n                }\n                Ok(deleted)\n            }\n            Err(e) => {\n                conn.execute(\"ROLLBACK TO rollup_prune;\", []).ok();\n                conn.execute(\"RELEASE rollup_prune;\", []).ok();\n                Err(e)\n            }\n        }\n    }\n\n    fn do_rollup_and_prune(conn: &rusqlite::Connection, cutoff: i64) -> Result<u64, AppError> {\n        // Aggregate old logs, merging with any pre-existing rollup rows via LEFT JOIN.\n        conn.execute(\n            \"INSERT OR REPLACE INTO usage_daily_rollups\n                (date, app_type, provider_id, model,\n                 request_count, success_count,\n                 input_tokens, output_tokens,\n                 cache_read_tokens, cache_creation_tokens,\n                 total_cost_usd, avg_latency_ms)\n            SELECT\n                d, a, p, m,\n                COALESCE(old.request_count, 0) + new_req,\n                COALESCE(old.success_count, 0) + new_succ,\n                COALESCE(old.input_tokens, 0) + new_in,\n                COALESCE(old.output_tokens, 0) + new_out,\n                COALESCE(old.cache_read_tokens, 0) + new_cr,\n                COALESCE(old.cache_creation_tokens, 0) + new_cc,\n                CAST(COALESCE(CAST(old.total_cost_usd AS REAL), 0) + new_cost AS TEXT),\n                CASE WHEN COALESCE(old.request_count, 0) + new_req > 0\n                    THEN (COALESCE(old.avg_latency_ms, 0) * COALESCE(old.request_count, 0)\n                          + new_lat * new_req)\n                         / (COALESCE(old.request_count, 0) + new_req)\n                    ELSE 0 END\n            FROM (\n                SELECT\n                    date(created_at, 'unixepoch', 'localtime') as d,\n                    app_type as a, provider_id as p, model as m,\n                    COUNT(*) as new_req,\n                    SUM(CASE WHEN status_code >= 200 AND status_code < 300 THEN 1 ELSE 0 END) as new_succ,\n                    COALESCE(SUM(input_tokens), 0) as new_in,\n                    COALESCE(SUM(output_tokens), 0) as new_out,\n                    COALESCE(SUM(cache_read_tokens), 0) as new_cr,\n                    COALESCE(SUM(cache_creation_tokens), 0) as new_cc,\n                    COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as new_cost,\n                    COALESCE(AVG(latency_ms), 0) as new_lat\n                FROM proxy_request_logs WHERE created_at < ?1\n                GROUP BY d, a, p, m\n            ) agg\n            LEFT JOIN usage_daily_rollups old\n                ON old.date = agg.d AND old.app_type = agg.a\n                AND old.provider_id = agg.p AND old.model = agg.m\",\n            [cutoff],\n        )\n        .map_err(|e| AppError::Database(format!(\"Rollup aggregation failed: {e}\")))?;\n\n        // Delete the aggregated detail rows\n        let deleted = conn\n            .execute(\n                \"DELETE FROM proxy_request_logs WHERE created_at < ?1\",\n                [cutoff],\n            )\n            .map_err(|e| AppError::Database(format!(\"Pruning old logs failed: {e}\")))?;\n\n        Ok(deleted as u64)\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use crate::database::Database;\n    use crate::error::AppError;\n\n    #[test]\n    fn test_rollup_and_prune() -> Result<(), AppError> {\n        let db = Database::memory()?;\n        let now = chrono::Utc::now().timestamp();\n        let old_ts = now - 40 * 86400; // 40 days ago\n        let recent_ts = now - 5 * 86400; // 5 days ago\n\n        {\n            let conn = crate::database::lock_conn!(db.conn);\n            for i in 0..5 {\n                conn.execute(\n                    \"INSERT INTO proxy_request_logs (\n                        request_id, provider_id, app_type, model,\n                        input_tokens, output_tokens, total_cost_usd,\n                        latency_ms, status_code, created_at\n                    ) VALUES (?1, 'p1', 'claude', 'claude-3', 100, 50, '0.01', 100, 200, ?2)\",\n                    rusqlite::params![format!(\"old-{i}\"), old_ts + i as i64],\n                )?;\n            }\n            for i in 0..3 {\n                conn.execute(\n                    \"INSERT INTO proxy_request_logs (\n                        request_id, provider_id, app_type, model,\n                        input_tokens, output_tokens, total_cost_usd,\n                        latency_ms, status_code, created_at\n                    ) VALUES (?1, 'p1', 'claude', 'claude-3', 200, 100, '0.02', 150, 200, ?2)\",\n                    rusqlite::params![format!(\"recent-{i}\"), recent_ts + i as i64],\n                )?;\n            }\n        }\n\n        let deleted = db.rollup_and_prune(30)?;\n        assert_eq!(deleted, 5);\n\n        // Verify rollup data\n        let conn = crate::database::lock_conn!(db.conn);\n        let count: i64 = conn.query_row(\n            \"SELECT request_count FROM usage_daily_rollups WHERE app_type = 'claude'\",\n            [],\n            |row| row.get(0),\n        )?;\n        assert_eq!(count, 5);\n\n        // Verify recent logs untouched\n        let remaining: i64 =\n            conn.query_row(\"SELECT COUNT(*) FROM proxy_request_logs\", [], |row| {\n                row.get(0)\n            })?;\n        assert_eq!(remaining, 3);\n        Ok(())\n    }\n\n    #[test]\n    fn test_rollup_noop_when_no_old_data() -> Result<(), AppError> {\n        let db = Database::memory()?;\n        assert_eq!(db.rollup_and_prune(30)?, 0);\n        Ok(())\n    }\n\n    #[test]\n    fn test_rollup_merges_with_existing() -> Result<(), AppError> {\n        let db = Database::memory()?;\n        let now = chrono::Utc::now().timestamp();\n        let old_ts = now - 40 * 86400;\n\n        {\n            let conn = crate::database::lock_conn!(db.conn);\n            let date_str = chrono::DateTime::from_timestamp(old_ts, 0)\n                .unwrap()\n                .format(\"%Y-%m-%d\")\n                .to_string();\n            conn.execute(\n                \"INSERT INTO usage_daily_rollups\n                    (date, app_type, provider_id, model, request_count, success_count,\n                     input_tokens, output_tokens, total_cost_usd, avg_latency_ms)\n                 VALUES (?1, 'claude', 'p1', 'claude-3', 10, 10, 1000, 500, '0.10', 100)\",\n                [&date_str],\n            )?;\n            for i in 0..3 {\n                conn.execute(\n                    \"INSERT INTO proxy_request_logs (\n                        request_id, provider_id, app_type, model,\n                        input_tokens, output_tokens, total_cost_usd,\n                        latency_ms, status_code, created_at\n                    ) VALUES (?1, 'p1', 'claude', 'claude-3', 100, 50, '0.01', 200, 200, ?2)\",\n                    rusqlite::params![format!(\"merge-{i}\"), old_ts + i as i64],\n                )?;\n            }\n        }\n\n        let deleted = db.rollup_and_prune(30)?;\n        assert_eq!(deleted, 3);\n\n        let conn = crate::database::lock_conn!(db.conn);\n        let (count, input): (i64, i64) = conn.query_row(\n            \"SELECT request_count, input_tokens FROM usage_daily_rollups\n             WHERE app_type = 'claude' AND provider_id = 'p1'\",\n            [],\n            |row| Ok((row.get(0)?, row.get(1)?)),\n        )?;\n        assert_eq!(count, 13, \"10 existing + 3 new\");\n        assert_eq!(input, 1300, \"1000 existing + 300 new\");\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/database/migration.rs",
    "content": "//! JSON → SQLite 数据迁移\n//!\n//! 将旧版 config.json (MultiAppConfig) 数据迁移到 SQLite 数据库。\n\nuse super::{lock_conn, to_json_string, Database};\nuse crate::app_config::MultiAppConfig;\nuse crate::error::AppError;\nuse rusqlite::{params, Connection};\n\nimpl Database {\n    /// 从 MultiAppConfig 迁移数据到数据库\n    pub fn migrate_from_json(&self, config: &MultiAppConfig) -> Result<(), AppError> {\n        let mut conn = lock_conn!(self.conn);\n        let tx = conn\n            .transaction()\n            .map_err(|e| AppError::Database(e.to_string()))?;\n\n        Self::migrate_from_json_tx(&tx, config)?;\n\n        tx.commit()\n            .map_err(|e| AppError::Database(format!(\"Commit migration failed: {e}\")))?;\n        Ok(())\n    }\n\n    /// 运行迁移的 dry-run 模式（在内存数据库中验证，不写入磁盘）\n    ///\n    /// 用于部署前验证迁移逻辑是否正确。\n    pub fn migrate_from_json_dry_run(config: &MultiAppConfig) -> Result<(), AppError> {\n        let mut conn =\n            Connection::open_in_memory().map_err(|e| AppError::Database(e.to_string()))?;\n        Self::create_tables_on_conn(&conn)?;\n        Self::apply_schema_migrations_on_conn(&conn)?;\n\n        let tx = conn\n            .transaction()\n            .map_err(|e| AppError::Database(e.to_string()))?;\n        Self::migrate_from_json_tx(&tx, config)?;\n\n        // 显式 drop transaction 而不提交（内存数据库会被丢弃）\n        drop(tx);\n        Ok(())\n    }\n\n    /// 在事务中执行迁移\n    fn migrate_from_json_tx(\n        tx: &rusqlite::Transaction<'_>,\n        config: &MultiAppConfig,\n    ) -> Result<(), AppError> {\n        // 1. 迁移 Providers\n        Self::migrate_providers(tx, config)?;\n\n        // 2. 迁移 MCP Servers\n        Self::migrate_mcp_servers(tx, config)?;\n\n        // 3. 迁移 Prompts\n        Self::migrate_prompts(tx, config)?;\n\n        // 4. 迁移 Skills\n        Self::migrate_skills(tx, config)?;\n\n        // 5. 迁移 Common Config\n        Self::migrate_common_config(tx, config)?;\n\n        Ok(())\n    }\n\n    /// 迁移供应商数据\n    fn migrate_providers(\n        tx: &rusqlite::Transaction<'_>,\n        config: &MultiAppConfig,\n    ) -> Result<(), AppError> {\n        for (app_key, manager) in &config.apps {\n            let app_type = app_key;\n            let current_id = &manager.current;\n\n            for (id, provider) in &manager.providers {\n                let is_current = if id == current_id { 1 } else { 0 };\n\n                // 处理 meta 和 endpoints\n                let mut meta_clone = provider.meta.clone().unwrap_or_default();\n                let endpoints = std::mem::take(&mut meta_clone.custom_endpoints);\n\n                tx.execute(\n                    \"INSERT OR REPLACE INTO providers (\n                        id, app_type, name, settings_config, website_url, category,\n                        created_at, sort_index, notes, icon, icon_color, meta, is_current\n                    ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)\",\n                    params![\n                        id,\n                        app_type,\n                        provider.name,\n                        to_json_string(&provider.settings_config)?,\n                        provider.website_url,\n                        provider.category,\n                        provider.created_at,\n                        provider.sort_index,\n                        provider.notes,\n                        provider.icon,\n                        provider.icon_color,\n                        to_json_string(&meta_clone)?,\n                        is_current,\n                    ],\n                )\n                .map_err(|e| AppError::Database(format!(\"Migrate provider failed: {e}\")))?;\n\n                // 迁移 Endpoints\n                for (url, endpoint) in endpoints {\n                    tx.execute(\n                        \"INSERT INTO provider_endpoints (provider_id, app_type, url, added_at)\n                         VALUES (?1, ?2, ?3, ?4)\",\n                        params![id, app_type, url, endpoint.added_at],\n                    )\n                    .map_err(|e| AppError::Database(format!(\"Migrate endpoint failed: {e}\")))?;\n                }\n            }\n        }\n        Ok(())\n    }\n\n    /// 迁移 MCP 服务器数据\n    fn migrate_mcp_servers(\n        tx: &rusqlite::Transaction<'_>,\n        config: &MultiAppConfig,\n    ) -> Result<(), AppError> {\n        if let Some(servers) = &config.mcp.servers {\n            for (id, server) in servers {\n                tx.execute(\n                    \"INSERT OR REPLACE INTO mcp_servers (\n                        id, name, server_config, description, homepage, docs, tags,\n                        enabled_claude, enabled_codex, enabled_gemini\n                    ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)\",\n                    params![\n                        id,\n                        server.name,\n                        to_json_string(&server.server)?,\n                        server.description,\n                        server.homepage,\n                        server.docs,\n                        to_json_string(&server.tags)?,\n                        server.apps.claude,\n                        server.apps.codex,\n                        server.apps.gemini,\n                    ],\n                )\n                .map_err(|e| AppError::Database(format!(\"Migrate mcp server failed: {e}\")))?;\n            }\n        }\n        Ok(())\n    }\n\n    /// 迁移提示词数据\n    fn migrate_prompts(\n        tx: &rusqlite::Transaction<'_>,\n        config: &MultiAppConfig,\n    ) -> Result<(), AppError> {\n        let migrate_app_prompts = |prompts_map: &std::collections::HashMap<\n            String,\n            crate::prompt::Prompt,\n        >,\n                                   app_type: &str|\n         -> Result<(), AppError> {\n            for (id, prompt) in prompts_map {\n                tx.execute(\n                        \"INSERT OR REPLACE INTO prompts (\n                            id, app_type, name, content, description, enabled, created_at, updated_at\n                        ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)\",\n                        params![\n                            id,\n                            app_type,\n                            prompt.name,\n                            prompt.content,\n                            prompt.description,\n                            prompt.enabled,\n                            prompt.created_at,\n                            prompt.updated_at,\n                        ],\n                    )\n                    .map_err(|e| AppError::Database(format!(\"Migrate prompt failed: {e}\")))?;\n            }\n            Ok(())\n        };\n\n        migrate_app_prompts(&config.prompts.claude.prompts, \"claude\")?;\n        migrate_app_prompts(&config.prompts.codex.prompts, \"codex\")?;\n        migrate_app_prompts(&config.prompts.gemini.prompts, \"gemini\")?;\n\n        Ok(())\n    }\n\n    /// 迁移 Skills 数据\n    fn migrate_skills(\n        tx: &rusqlite::Transaction<'_>,\n        config: &MultiAppConfig,\n    ) -> Result<(), AppError> {\n        // v3.10.0+：Skills 的 SSOT 已迁移到文件系统（~/.cc-switch/skills/）+ 数据库统一结构。\n        //\n        // 旧版 config.json 里的 `skills.skills` 仅记录“安装状态”，但不包含完整元数据，\n        // 且无法保证 SSOT 目录中一定存在对应的 skill 文件。\n        //\n        // 因此这里不再直接把旧的安装状态写入新 skills 表，避免产生“数据库显示已安装但文件缺失”的不一致。\n        // 迁移后可通过：\n        // - 前端「导入已有」(扫描各应用的 skills 目录并复制到 SSOT)\n        // - 或后续启动时的自动扫描逻辑\n        // 来重建已安装技能记录。\n\n        for repo in &config.skills.repos {\n            tx.execute(\n                \"INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled) VALUES (?1, ?2, ?3, ?4)\",\n                params![repo.owner, repo.name, repo.branch, repo.enabled],\n            ).map_err(|e| AppError::Database(format!(\"Migrate skill repo failed: {e}\")))?;\n        }\n\n        Ok(())\n    }\n\n    /// 迁移通用配置片段\n    fn migrate_common_config(\n        tx: &rusqlite::Transaction<'_>,\n        config: &MultiAppConfig,\n    ) -> Result<(), AppError> {\n        if let Some(snippet) = &config.common_config_snippets.claude {\n            tx.execute(\n                \"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)\",\n                params![\"common_config_claude\", snippet],\n            )\n            .map_err(|e| AppError::Database(format!(\"Migrate settings failed: {e}\")))?;\n        }\n        if let Some(snippet) = &config.common_config_snippets.codex {\n            tx.execute(\n                \"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)\",\n                params![\"common_config_codex\", snippet],\n            )\n            .map_err(|e| AppError::Database(format!(\"Migrate settings failed: {e}\")))?;\n        }\n        if let Some(snippet) = &config.common_config_snippets.gemini {\n            tx.execute(\n                \"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)\",\n                params![\"common_config_gemini\", snippet],\n            )\n            .map_err(|e| AppError::Database(format!(\"Migrate settings failed: {e}\")))?;\n        }\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/database/mod.rs",
    "content": "//! 数据库模块 - SQLite 数据持久化\n//!\n//! 此模块提供应用的核心数据存储功能，包括：\n//! - 供应商配置管理\n//! - MCP 服务器配置\n//! - 提示词管理\n//! - Skills 管理\n//! - 通用设置存储\n//!\n//! ## 架构设计\n//!\n//! ```text\n//! database/\n//! ├── mod.rs        - Database 结构体 + 初始化\n//! ├── schema.rs     - 表结构定义 + Schema 迁移\n//! ├── backup.rs     - SQL 导入导出 + 快照备份\n//! ├── migration.rs  - JSON → SQLite 数据迁移\n//! └── dao/          - 数据访问对象\n//!     ├── providers.rs\n//!     ├── mcp.rs\n//!     ├── prompts.rs\n//!     ├── skills.rs\n//!     └── settings.rs\n//! ```\n\npub(crate) mod backup;\nmod dao;\nmod migration;\nmod schema;\n\n#[cfg(test)]\nmod tests;\n\n// DAO 类型导出供外部使用\npub use dao::FailoverQueueItem;\n\nuse crate::config::get_app_config_dir;\nuse crate::error::AppError;\nuse rusqlite::{hooks::Action, Connection};\nuse serde::Serialize;\nuse std::sync::Mutex;\n\n// DAO 方法通过 impl Database 提供，无需额外导出\n\n/// 当前 Schema 版本号\n/// 每次修改表结构时递增，并在 schema.rs 中添加相应的迁移逻辑\npub(crate) const SCHEMA_VERSION: i32 = 6;\n\n/// 安全地序列化 JSON，避免 unwrap panic\npub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {\n    serde_json::to_string(value)\n        .map_err(|e| AppError::Config(format!(\"JSON serialization failed: {e}\")))\n}\n\n/// 安全地获取 Mutex 锁，避免 unwrap panic\nmacro_rules! lock_conn {\n    ($mutex:expr) => {\n        $mutex\n            .lock()\n            .map_err(|e| AppError::Database(format!(\"Mutex lock failed: {}\", e)))?\n    };\n}\n\n// 导出宏供子模块使用\npub(crate) use lock_conn;\n\n/// 数据库连接封装\n///\n/// 使用 Mutex 包装 Connection 以支持在多线程环境（如 Tauri State）中共享。\n/// rusqlite::Connection 本身不是 Sync 的，因此需要这层包装。\npub struct Database {\n    pub(crate) conn: Mutex<Connection>,\n}\n\nfn register_db_change_hook(conn: &Connection) {\n    conn.update_hook(Some(\n        |action: Action, _database: &str, table: &str, _row_id: i64| match action {\n            Action::SQLITE_INSERT | Action::SQLITE_UPDATE | Action::SQLITE_DELETE => {\n                crate::services::webdav_auto_sync::notify_db_changed(table);\n            }\n            _ => {}\n        },\n    ));\n}\n\nimpl Database {\n    /// 初始化数据库连接并创建表\n    ///\n    /// 数据库文件位于 `~/.cc-switch/cc-switch.db`\n    pub fn init() -> Result<Self, AppError> {\n        let db_path = get_app_config_dir().join(\"cc-switch.db\");\n        let db_exists = db_path.exists();\n\n        // 确保父目录存在\n        if let Some(parent) = db_path.parent() {\n            std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;\n        }\n\n        let conn = Connection::open(&db_path).map_err(|e| AppError::Database(e.to_string()))?;\n\n        // 启用外键约束\n        conn.execute(\"PRAGMA foreign_keys = ON;\", [])\n            .map_err(|e| AppError::Database(e.to_string()))?;\n        if !db_exists {\n            // For a brand-new database, configure incremental auto-vacuum\n            // before creating any tables so no rebuild is needed later.\n            conn.execute(\"PRAGMA auto_vacuum = INCREMENTAL;\", [])\n                .map_err(|e| AppError::Database(e.to_string()))?;\n        }\n        register_db_change_hook(&conn);\n\n        let db = Self {\n            conn: Mutex::new(conn),\n        };\n        db.create_tables()?;\n\n        // Pre-migration backup: only when upgrading from an existing database\n        {\n            let conn = lock_conn!(db.conn);\n            let version = Self::get_user_version(&conn)?;\n            drop(conn);\n            if version > 0 && version < SCHEMA_VERSION {\n                log::info!(\n                    \"Creating pre-migration database backup (v{version} → v{SCHEMA_VERSION})\"\n                );\n                if let Err(e) = db.backup_database_file() {\n                    log::warn!(\"Pre-migration backup failed, continuing migration: {e}\");\n                }\n            }\n        }\n\n        db.apply_schema_migrations()?;\n        if let Err(e) = db.ensure_incremental_auto_vacuum() {\n            log::warn!(\"Failed to ensure incremental auto-vacuum: {e}\");\n        }\n        db.ensure_model_pricing_seeded()?;\n\n        // Startup cleanup: prune old logs and reclaim space\n        if let Err(e) = db.cleanup_old_stream_check_logs(7) {\n            log::warn!(\"Startup stream_check_logs cleanup failed: {e}\");\n        }\n        if let Err(e) = db.rollup_and_prune(30) {\n            log::warn!(\"Startup rollup_and_prune failed: {e}\");\n        }\n        // Reclaim disk space after cleanup\n        {\n            let conn = lock_conn!(db.conn);\n            if let Err(e) = conn.execute_batch(\"PRAGMA incremental_vacuum;\") {\n                log::warn!(\"Startup incremental vacuum failed: {e}\");\n            }\n        }\n\n        Ok(db)\n    }\n\n    /// 创建内存数据库（用于测试）\n    pub fn memory() -> Result<Self, AppError> {\n        let conn = Connection::open_in_memory().map_err(|e| AppError::Database(e.to_string()))?;\n\n        // 启用外键约束\n        conn.execute(\"PRAGMA foreign_keys = ON;\", [])\n            .map_err(|e| AppError::Database(e.to_string()))?;\n        conn.execute(\"PRAGMA auto_vacuum = INCREMENTAL;\", [])\n            .map_err(|e| AppError::Database(e.to_string()))?;\n        register_db_change_hook(&conn);\n\n        let db = Self {\n            conn: Mutex::new(conn),\n        };\n        db.create_tables()?;\n        db.ensure_model_pricing_seeded()?;\n\n        Ok(db)\n    }\n\n    pub(crate) fn get_auto_vacuum_mode(conn: &Connection) -> Result<i32, AppError> {\n        conn.query_row(\"PRAGMA auto_vacuum;\", [], |row| row.get(0))\n            .map_err(|e| AppError::Database(format!(\"读取 auto_vacuum 失败: {e}\")))\n    }\n\n    fn has_user_tables(conn: &Connection) -> Result<bool, AppError> {\n        let count: i64 = conn\n            .query_row(\n                \"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'\",\n                [],\n                |row| row.get(0),\n            )\n            .map_err(|e| AppError::Database(format!(\"读取表数量失败: {e}\")))?;\n        Ok(count > 0)\n    }\n\n    pub(crate) fn ensure_incremental_auto_vacuum_on_conn(\n        conn: &Connection,\n    ) -> Result<bool, AppError> {\n        let mode = Self::get_auto_vacuum_mode(conn)?;\n        if mode == 2 {\n            return Ok(false);\n        }\n\n        let has_tables = Self::has_user_tables(conn)?;\n        conn.execute(\"PRAGMA auto_vacuum = INCREMENTAL;\", [])\n            .map_err(|e| AppError::Database(format!(\"设置 auto_vacuum 失败: {e}\")))?;\n\n        if !has_tables {\n            return Ok(false);\n        }\n\n        conn.execute(\"VACUUM;\", [])\n            .map_err(|e| AppError::Database(format!(\"执行 VACUUM 失败: {e}\")))?;\n        conn.execute(\"PRAGMA foreign_keys = ON;\", [])\n            .map_err(|e| AppError::Database(format!(\"恢复 foreign_keys 失败: {e}\")))?;\n        Ok(true)\n    }\n\n    pub(crate) fn ensure_incremental_auto_vacuum(&self) -> Result<bool, AppError> {\n        let mode = {\n            let conn = lock_conn!(self.conn);\n            Self::get_auto_vacuum_mode(&conn)?\n        };\n        if mode == 2 {\n            return Ok(false);\n        }\n\n        let has_tables = {\n            let conn = lock_conn!(self.conn);\n            Self::has_user_tables(&conn)?\n        };\n        if has_tables {\n            log::info!(\n                \"Detected auto_vacuum={mode}, rebuilding database to enable incremental vacuum\"\n            );\n            self.backup_database_file()?;\n        }\n\n        let rebuilt = {\n            let conn = lock_conn!(self.conn);\n            Self::ensure_incremental_auto_vacuum_on_conn(&conn)?\n        };\n\n        if rebuilt {\n            log::info!(\"Incremental auto-vacuum enabled after database rebuild\");\n        } else {\n            log::info!(\"Incremental auto-vacuum configured for new database\");\n        }\n\n        Ok(rebuilt)\n    }\n\n    /// 检查 MCP 服务器表是否为空\n    pub fn is_mcp_table_empty(&self) -> Result<bool, AppError> {\n        let conn = lock_conn!(self.conn);\n        let count: i64 = conn\n            .query_row(\"SELECT COUNT(*) FROM mcp_servers\", [], |row| row.get(0))\n            .map_err(|e| AppError::Database(e.to_string()))?;\n        Ok(count == 0)\n    }\n\n    /// 检查提示词表是否为空\n    pub fn is_prompts_table_empty(&self) -> Result<bool, AppError> {\n        let conn = lock_conn!(self.conn);\n        let count: i64 = conn\n            .query_row(\"SELECT COUNT(*) FROM prompts\", [], |row| row.get(0))\n            .map_err(|e| AppError::Database(e.to_string()))?;\n        Ok(count == 0)\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/database/schema.rs",
    "content": "//! Schema 定义和迁移\n//!\n//! 负责数据库表结构的创建和版本迁移。\n\nuse super::{lock_conn, Database, SCHEMA_VERSION};\nuse crate::error::AppError;\nuse rusqlite::{params, Connection};\nuse serde::Serialize;\n\n#[derive(Serialize)]\nstruct LegacySkillMigrationRow {\n    directory: String,\n    app_type: String,\n}\n\nimpl Database {\n    /// 创建所有数据库表\n    pub(crate) fn create_tables(&self) -> Result<(), AppError> {\n        let conn = lock_conn!(self.conn);\n        Self::create_tables_on_conn(&conn)\n    }\n\n    /// 在指定连接上创建表（供迁移和测试使用）\n    pub(crate) fn create_tables_on_conn(conn: &Connection) -> Result<(), AppError> {\n        // 1. Providers 表\n        conn.execute(\n            \"CREATE TABLE IF NOT EXISTS providers (\n                id TEXT NOT NULL,\n                app_type TEXT NOT NULL,\n                name TEXT NOT NULL,\n                settings_config TEXT NOT NULL,\n                website_url TEXT,\n                category TEXT,\n                created_at INTEGER,\n                sort_index INTEGER,\n                notes TEXT,\n                icon TEXT,\n                icon_color TEXT,\n                meta TEXT NOT NULL DEFAULT '{}',\n                is_current BOOLEAN NOT NULL DEFAULT 0,\n                in_failover_queue BOOLEAN NOT NULL DEFAULT 0,\n                PRIMARY KEY (id, app_type)\n            )\",\n            [],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n\n        // 2. Provider Endpoints 表\n        conn.execute(\n            \"CREATE TABLE IF NOT EXISTS provider_endpoints (\n                id INTEGER PRIMARY KEY AUTOINCREMENT,\n                provider_id TEXT NOT NULL,\n                app_type TEXT NOT NULL,\n                url TEXT NOT NULL,\n                added_at INTEGER,\n                FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE\n            )\",\n            [],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n\n        // 3. MCP Servers 表\n        conn.execute(\n            \"CREATE TABLE IF NOT EXISTS mcp_servers (\n            id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL,\n            description TEXT, homepage TEXT, docs TEXT, tags TEXT NOT NULL DEFAULT '[]',\n            enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0,\n            enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0\n        )\",\n            [],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n\n        // 4. Prompts 表\n        conn.execute(\"CREATE TABLE IF NOT EXISTS prompts (\n            id TEXT NOT NULL, app_type TEXT NOT NULL, name TEXT NOT NULL, content TEXT NOT NULL,\n            description TEXT, enabled BOOLEAN NOT NULL DEFAULT 1, created_at INTEGER, updated_at INTEGER,\n            PRIMARY KEY (id, app_type)\n        )\", []).map_err(|e| AppError::Database(e.to_string()))?;\n\n        // 5. Skills 表（v3.10.0+ 统一结构）\n        conn.execute(\n            \"CREATE TABLE IF NOT EXISTS skills (\n            id TEXT PRIMARY KEY,\n            name TEXT NOT NULL,\n            description TEXT,\n            directory TEXT NOT NULL,\n            repo_owner TEXT,\n            repo_name TEXT,\n            repo_branch TEXT DEFAULT 'main',\n            readme_url TEXT,\n            enabled_claude BOOLEAN NOT NULL DEFAULT 0,\n            enabled_codex BOOLEAN NOT NULL DEFAULT 0,\n            enabled_gemini BOOLEAN NOT NULL DEFAULT 0,\n            enabled_opencode BOOLEAN NOT NULL DEFAULT 0,\n            installed_at INTEGER NOT NULL DEFAULT 0\n        )\",\n            [],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n\n        // 6. Skill Repos 表\n        conn.execute(\n            \"CREATE TABLE IF NOT EXISTS skill_repos (\n            owner TEXT NOT NULL, name TEXT NOT NULL, branch TEXT NOT NULL DEFAULT 'main',\n            enabled BOOLEAN NOT NULL DEFAULT 1, PRIMARY KEY (owner, name)\n        )\",\n            [],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n\n        // 7. Settings 表\n        conn.execute(\n            \"CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)\",\n            [],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n\n        // 8. Proxy Config 表（三行结构，app_type 主键）\n        conn.execute(\"CREATE TABLE IF NOT EXISTS proxy_config (\n            app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')),\n            proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',\n            listen_port INTEGER NOT NULL DEFAULT 15721, enable_logging INTEGER NOT NULL DEFAULT 1,\n            enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,\n            max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60,\n            streaming_idle_timeout INTEGER NOT NULL DEFAULT 120, non_streaming_timeout INTEGER NOT NULL DEFAULT 600,\n            circuit_failure_threshold INTEGER NOT NULL DEFAULT 4, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,\n            circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.6,\n            circuit_min_requests INTEGER NOT NULL DEFAULT 10,\n            default_cost_multiplier TEXT NOT NULL DEFAULT '1',\n            pricing_model_source TEXT NOT NULL DEFAULT 'response',\n            created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))\n        )\", []).map_err(|e| AppError::Database(e.to_string()))?;\n\n        // 初始化三行数据（每应用不同默认值）\n        //\n        // 兼容旧数据库：\n        // - 老版本 proxy_config 是单例表（没有 app_type 列），此时不能执行三行 seed insert；\n        // - 旧表会在 apply_schema_migrations() 中迁移为三行结构后再插入。\n        if Self::has_column(conn, \"proxy_config\", \"app_type\")? {\n            conn.execute(\n                \"INSERT OR IGNORE INTO proxy_config (app_type, max_retries,\n                streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,\n                circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,\n                circuit_error_rate_threshold, circuit_min_requests)\n                VALUES ('claude', 6, 90, 180, 600, 8, 3, 90, 0.7, 15)\",\n                [],\n            )\n            .map_err(|e| AppError::Database(e.to_string()))?;\n            conn.execute(\n                \"INSERT OR IGNORE INTO proxy_config (app_type, max_retries,\n                streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,\n                circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,\n                circuit_error_rate_threshold, circuit_min_requests)\n                VALUES ('codex', 3, 60, 120, 600, 4, 2, 60, 0.6, 10)\",\n                [],\n            )\n            .map_err(|e| AppError::Database(e.to_string()))?;\n            conn.execute(\n                \"INSERT OR IGNORE INTO proxy_config (app_type, max_retries,\n                streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,\n                circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,\n                circuit_error_rate_threshold, circuit_min_requests)\n                VALUES ('gemini', 5, 60, 120, 600, 4, 2, 60, 0.6, 10)\",\n                [],\n            )\n            .map_err(|e| AppError::Database(e.to_string()))?;\n        }\n\n        // 9. Provider Health 表\n        conn.execute(\"CREATE TABLE IF NOT EXISTS provider_health (\n            provider_id TEXT NOT NULL, app_type TEXT NOT NULL, is_healthy INTEGER NOT NULL DEFAULT 1,\n            consecutive_failures INTEGER NOT NULL DEFAULT 0, last_success_at TEXT, last_failure_at TEXT,\n            last_error TEXT, updated_at TEXT NOT NULL,\n            PRIMARY KEY (provider_id, app_type),\n            FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE\n        )\", []).map_err(|e| AppError::Database(e.to_string()))?;\n\n        // 10. Proxy Request Logs 表\n        conn.execute(\"CREATE TABLE IF NOT EXISTS proxy_request_logs (\n            request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL,\n            request_model TEXT,\n            input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0,\n            cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0,\n            input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0',\n            cache_read_cost_usd TEXT NOT NULL DEFAULT '0', cache_creation_cost_usd TEXT NOT NULL DEFAULT '0',\n            total_cost_usd TEXT NOT NULL DEFAULT '0', latency_ms INTEGER NOT NULL, first_token_ms INTEGER,\n            duration_ms INTEGER, status_code INTEGER NOT NULL, error_message TEXT, session_id TEXT,\n            provider_type TEXT, is_streaming INTEGER NOT NULL DEFAULT 0,\n            cost_multiplier TEXT NOT NULL DEFAULT '1.0', created_at INTEGER NOT NULL\n        )\", []).map_err(|e| AppError::Database(e.to_string()))?;\n\n        conn.execute(\"CREATE INDEX IF NOT EXISTS idx_request_logs_provider ON proxy_request_logs(provider_id, app_type)\", [])\n            .map_err(|e| AppError::Database(e.to_string()))?;\n        conn.execute(\"CREATE INDEX IF NOT EXISTS idx_request_logs_created_at ON proxy_request_logs(created_at)\", [])\n            .map_err(|e| AppError::Database(e.to_string()))?;\n        conn.execute(\n            \"CREATE INDEX IF NOT EXISTS idx_request_logs_model ON proxy_request_logs(model)\",\n            [],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n        conn.execute(\n            \"CREATE INDEX IF NOT EXISTS idx_request_logs_session ON proxy_request_logs(session_id)\",\n            [],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n        conn.execute(\n            \"CREATE INDEX IF NOT EXISTS idx_request_logs_status ON proxy_request_logs(status_code)\",\n            [],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n\n        // 11. Model Pricing 表\n        conn.execute(\n            \"CREATE TABLE IF NOT EXISTS model_pricing (\n            model_id TEXT PRIMARY KEY, display_name TEXT NOT NULL,\n            input_cost_per_million TEXT NOT NULL, output_cost_per_million TEXT NOT NULL,\n            cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',\n            cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'\n        )\",\n            [],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n\n        // 12. Stream Check Logs 表\n        conn.execute(\"CREATE TABLE IF NOT EXISTS stream_check_logs (\n            id INTEGER PRIMARY KEY AUTOINCREMENT, provider_id TEXT NOT NULL, provider_name TEXT NOT NULL,\n            app_type TEXT NOT NULL, status TEXT NOT NULL, success INTEGER NOT NULL, message TEXT NOT NULL,\n            response_time_ms INTEGER, http_status INTEGER, model_used TEXT,\n            retry_count INTEGER DEFAULT 0, tested_at INTEGER NOT NULL\n        )\", []).map_err(|e| AppError::Database(e.to_string()))?;\n\n        conn.execute(\n            \"CREATE INDEX IF NOT EXISTS idx_stream_check_logs_provider\n             ON stream_check_logs(app_type, provider_id, tested_at DESC)\",\n            [],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n\n        // 注意：circuit_breaker_config 已合并到 proxy_config 表中\n\n        // 16. Proxy Live Backup 表 (Live 配置备份)\n        conn.execute(\n            \"CREATE TABLE IF NOT EXISTS proxy_live_backup (\n            app_type TEXT PRIMARY KEY, original_config TEXT NOT NULL, backed_up_at TEXT NOT NULL\n        )\",\n            [],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n\n        // 17. Usage Daily Rollups 表 (日聚合统计)\n        conn.execute(\n            \"CREATE TABLE IF NOT EXISTS usage_daily_rollups (\n                date TEXT NOT NULL,\n                app_type TEXT NOT NULL,\n                provider_id TEXT NOT NULL,\n                model TEXT NOT NULL,\n                request_count INTEGER NOT NULL DEFAULT 0,\n                success_count INTEGER NOT NULL DEFAULT 0,\n                input_tokens INTEGER NOT NULL DEFAULT 0,\n                output_tokens INTEGER NOT NULL DEFAULT 0,\n                cache_read_tokens INTEGER NOT NULL DEFAULT 0,\n                cache_creation_tokens INTEGER NOT NULL DEFAULT 0,\n                total_cost_usd TEXT NOT NULL DEFAULT '0',\n                avg_latency_ms INTEGER NOT NULL DEFAULT 0,\n                PRIMARY KEY (date, app_type, provider_id, model)\n            )\",\n            [],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n\n        // 尝试添加 live_takeover_active 列到 proxy_config 表\n        let _ = conn.execute(\n            \"ALTER TABLE proxy_config ADD COLUMN live_takeover_active INTEGER NOT NULL DEFAULT 0\",\n            [],\n        );\n\n        // 尝试添加基础配置列到 proxy_config 表（兼容 v3.9.0-2 升级）\n        let _ = conn.execute(\n            \"ALTER TABLE proxy_config ADD COLUMN proxy_enabled INTEGER NOT NULL DEFAULT 0\",\n            [],\n        );\n        let _ = conn.execute(\n            \"ALTER TABLE proxy_config ADD COLUMN listen_address TEXT NOT NULL DEFAULT '127.0.0.1'\",\n            [],\n        );\n        let _ = conn.execute(\n            \"ALTER TABLE proxy_config ADD COLUMN listen_port INTEGER NOT NULL DEFAULT 15721\",\n            [],\n        );\n        let _ = conn.execute(\n            \"ALTER TABLE proxy_config ADD COLUMN enable_logging INTEGER NOT NULL DEFAULT 1\",\n            [],\n        );\n\n        // 尝试添加超时配置列到 proxy_config 表\n        let _ = conn.execute(\n            \"ALTER TABLE proxy_config ADD COLUMN streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60\",\n            [],\n        );\n        let _ = conn.execute(\n            \"ALTER TABLE proxy_config ADD COLUMN streaming_idle_timeout INTEGER NOT NULL DEFAULT 120\",\n            [],\n        );\n        let _ = conn.execute(\n            \"ALTER TABLE proxy_config ADD COLUMN non_streaming_timeout INTEGER NOT NULL DEFAULT 600\",\n            [],\n        );\n\n        // 兼容：若旧版 proxy_config 仍为单例结构（无 app_type），则在启动时直接转换为三行结构\n        // 说明：user_version=2 时不会再触发 v1->v2 迁移，但新代码查询依赖 app_type 列。\n        if Self::table_exists(conn, \"proxy_config\")?\n            && !Self::has_column(conn, \"proxy_config\", \"app_type\")?\n        {\n            Self::migrate_proxy_config_to_per_app(conn)?;\n        }\n\n        // 确保 in_failover_queue 列存在（对于已存在的 v2 数据库）\n        Self::add_column_if_missing(\n            conn,\n            \"providers\",\n            \"in_failover_queue\",\n            \"BOOLEAN NOT NULL DEFAULT 0\",\n        )?;\n\n        // 删除旧的 failover_queue 表（如果存在）\n        let _ = conn.execute(\"DROP INDEX IF EXISTS idx_failover_queue_order\", []);\n        let _ = conn.execute(\"DROP TABLE IF EXISTS failover_queue\", []);\n\n        // 为故障转移队列创建索引（基于 providers 表）\n        let _ = conn.execute(\n            \"CREATE INDEX IF NOT EXISTS idx_providers_failover\n             ON providers(app_type, in_failover_queue, sort_index)\",\n            [],\n        );\n\n        Ok(())\n    }\n\n    /// 应用 Schema 迁移\n    pub(crate) fn apply_schema_migrations(&self) -> Result<(), AppError> {\n        let conn = lock_conn!(self.conn);\n        Self::apply_schema_migrations_on_conn(&conn)\n    }\n\n    /// 在指定连接上应用 Schema 迁移\n    pub(crate) fn apply_schema_migrations_on_conn(conn: &Connection) -> Result<(), AppError> {\n        conn.execute(\"SAVEPOINT schema_migration;\", [])\n            .map_err(|e| AppError::Database(format!(\"开启迁移 savepoint 失败: {e}\")))?;\n\n        let mut version = Self::get_user_version(conn)?;\n\n        if version > SCHEMA_VERSION {\n            conn.execute(\"ROLLBACK TO schema_migration;\", []).ok();\n            conn.execute(\"RELEASE schema_migration;\", []).ok();\n            return Err(AppError::Database(format!(\n                \"数据库版本过新（{version}），当前应用仅支持 {SCHEMA_VERSION}，请升级应用后再尝试。\"\n            )));\n        }\n\n        let result = (|| {\n            while version < SCHEMA_VERSION {\n                match version {\n                    0 => {\n                        log::info!(\"检测到 user_version=0，迁移到 1（补齐缺失列并设置版本）\");\n                        Self::migrate_v0_to_v1(conn)?;\n                        Self::set_user_version(conn, 1)?;\n                    }\n                    1 => {\n                        log::info!(\n                            \"迁移数据库从 v1 到 v2（添加使用统计表和完整字段，重构 skills 表）\"\n                        );\n                        Self::migrate_v1_to_v2(conn)?;\n                        Self::set_user_version(conn, 2)?;\n                    }\n                    2 => {\n                        log::info!(\"迁移数据库从 v2 到 v3（Skills 统一管理架构）\");\n                        Self::migrate_v2_to_v3(conn)?;\n                        Self::set_user_version(conn, 3)?;\n                    }\n                    3 => {\n                        log::info!(\"迁移数据库从 v3 到 v4（OpenCode 支持）\");\n                        Self::migrate_v3_to_v4(conn)?;\n                        Self::set_user_version(conn, 4)?;\n                    }\n                    4 => {\n                        log::info!(\"迁移数据库从 v4 到 v5（计费模式支持）\");\n                        Self::migrate_v4_to_v5(conn)?;\n                        Self::set_user_version(conn, 5)?;\n                    }\n                    5 => {\n                        log::info!(\"迁移数据库从 v5 到 v6（使用量聚合表 + Copilot 模板类型统一）\");\n                        Self::migrate_v5_to_v6(conn)?;\n                        Self::set_user_version(conn, 6)?;\n                    }\n                    _ => {\n                        return Err(AppError::Database(format!(\n                            \"未知的数据库版本 {version}，无法迁移到 {SCHEMA_VERSION}\"\n                        )));\n                    }\n                }\n                version = Self::get_user_version(conn)?;\n            }\n            Ok(())\n        })();\n\n        match result {\n            Ok(_) => {\n                conn.execute(\"RELEASE schema_migration;\", [])\n                    .map_err(|e| AppError::Database(format!(\"提交迁移 savepoint 失败: {e}\")))?;\n                Ok(())\n            }\n            Err(e) => {\n                conn.execute(\"ROLLBACK TO schema_migration;\", []).ok();\n                conn.execute(\"RELEASE schema_migration;\", []).ok();\n                Err(e)\n            }\n        }\n    }\n\n    /// v0 -> v1 迁移：补齐所有缺失列\n    fn migrate_v0_to_v1(conn: &Connection) -> Result<(), AppError> {\n        // providers 表\n        Self::add_column_if_missing(conn, \"providers\", \"category\", \"TEXT\")?;\n        Self::add_column_if_missing(conn, \"providers\", \"created_at\", \"INTEGER\")?;\n        Self::add_column_if_missing(conn, \"providers\", \"sort_index\", \"INTEGER\")?;\n        Self::add_column_if_missing(conn, \"providers\", \"notes\", \"TEXT\")?;\n        Self::add_column_if_missing(conn, \"providers\", \"icon\", \"TEXT\")?;\n        Self::add_column_if_missing(conn, \"providers\", \"icon_color\", \"TEXT\")?;\n        Self::add_column_if_missing(conn, \"providers\", \"meta\", \"TEXT NOT NULL DEFAULT '{}'\")?;\n        Self::add_column_if_missing(\n            conn,\n            \"providers\",\n            \"is_current\",\n            \"BOOLEAN NOT NULL DEFAULT 0\",\n        )?;\n\n        // provider_endpoints 表\n        Self::add_column_if_missing(conn, \"provider_endpoints\", \"added_at\", \"INTEGER\")?;\n\n        // mcp_servers 表\n        Self::add_column_if_missing(conn, \"mcp_servers\", \"description\", \"TEXT\")?;\n        Self::add_column_if_missing(conn, \"mcp_servers\", \"homepage\", \"TEXT\")?;\n        Self::add_column_if_missing(conn, \"mcp_servers\", \"docs\", \"TEXT\")?;\n        Self::add_column_if_missing(conn, \"mcp_servers\", \"tags\", \"TEXT NOT NULL DEFAULT '[]'\")?;\n        Self::add_column_if_missing(\n            conn,\n            \"mcp_servers\",\n            \"enabled_codex\",\n            \"BOOLEAN NOT NULL DEFAULT 0\",\n        )?;\n        Self::add_column_if_missing(\n            conn,\n            \"mcp_servers\",\n            \"enabled_gemini\",\n            \"BOOLEAN NOT NULL DEFAULT 0\",\n        )?;\n\n        // prompts 表\n        Self::add_column_if_missing(conn, \"prompts\", \"description\", \"TEXT\")?;\n        Self::add_column_if_missing(conn, \"prompts\", \"enabled\", \"BOOLEAN NOT NULL DEFAULT 1\")?;\n        Self::add_column_if_missing(conn, \"prompts\", \"created_at\", \"INTEGER\")?;\n        Self::add_column_if_missing(conn, \"prompts\", \"updated_at\", \"INTEGER\")?;\n\n        // skills 表\n        Self::add_column_if_missing(conn, \"skills\", \"installed_at\", \"INTEGER NOT NULL DEFAULT 0\")?;\n\n        // skill_repos 表\n        Self::add_column_if_missing(\n            conn,\n            \"skill_repos\",\n            \"branch\",\n            \"TEXT NOT NULL DEFAULT 'main'\",\n        )?;\n        Self::add_column_if_missing(conn, \"skill_repos\", \"enabled\", \"BOOLEAN NOT NULL DEFAULT 1\")?;\n        // 注意: skills_path 字段已被移除，因为现在支持全仓库递归扫描\n\n        Ok(())\n    }\n\n    /// v1 -> v2 迁移：添加使用统计表和完整字段，重构 skills 表\n    fn migrate_v1_to_v2(conn: &Connection) -> Result<(), AppError> {\n        // providers 表字段\n        Self::add_column_if_missing(\n            conn,\n            \"providers\",\n            \"cost_multiplier\",\n            \"TEXT NOT NULL DEFAULT '1.0'\",\n        )?;\n        Self::add_column_if_missing(conn, \"providers\", \"limit_daily_usd\", \"TEXT\")?;\n        Self::add_column_if_missing(conn, \"providers\", \"limit_monthly_usd\", \"TEXT\")?;\n        Self::add_column_if_missing(conn, \"providers\", \"provider_type\", \"TEXT\")?;\n        Self::add_column_if_missing(\n            conn,\n            \"providers\",\n            \"in_failover_queue\",\n            \"BOOLEAN NOT NULL DEFAULT 0\",\n        )?;\n\n        // 添加代理超时配置字段\n        if Self::table_exists(conn, \"proxy_config\")? {\n            // 兼容旧版本缺失的基础字段\n            Self::add_column_if_missing(\n                conn,\n                \"proxy_config\",\n                \"proxy_enabled\",\n                \"INTEGER NOT NULL DEFAULT 0\",\n            )?;\n            Self::add_column_if_missing(\n                conn,\n                \"proxy_config\",\n                \"listen_address\",\n                \"TEXT NOT NULL DEFAULT '127.0.0.1'\",\n            )?;\n            Self::add_column_if_missing(\n                conn,\n                \"proxy_config\",\n                \"listen_port\",\n                \"INTEGER NOT NULL DEFAULT 15721\",\n            )?;\n            Self::add_column_if_missing(\n                conn,\n                \"proxy_config\",\n                \"enable_logging\",\n                \"INTEGER NOT NULL DEFAULT 1\",\n            )?;\n\n            Self::add_column_if_missing(\n                conn,\n                \"proxy_config\",\n                \"streaming_first_byte_timeout\",\n                \"INTEGER NOT NULL DEFAULT 60\",\n            )?;\n            Self::add_column_if_missing(\n                conn,\n                \"proxy_config\",\n                \"streaming_idle_timeout\",\n                \"INTEGER NOT NULL DEFAULT 120\",\n            )?;\n            Self::add_column_if_missing(\n                conn,\n                \"proxy_config\",\n                \"non_streaming_timeout\",\n                \"INTEGER NOT NULL DEFAULT 600\",\n            )?;\n        }\n\n        // 删除旧的 failover_queue 表（如果存在）\n        conn.execute(\"DROP INDEX IF EXISTS idx_failover_queue_order\", [])\n            .map_err(|e| AppError::Database(format!(\"删除 failover_queue 索引失败: {e}\")))?;\n        conn.execute(\"DROP TABLE IF EXISTS failover_queue\", [])\n            .map_err(|e| AppError::Database(format!(\"删除 failover_queue 表失败: {e}\")))?;\n\n        // 创建 failover 索引\n        conn.execute(\n            \"CREATE INDEX IF NOT EXISTS idx_providers_failover\n             ON providers(app_type, in_failover_queue, sort_index)\",\n            [],\n        )\n        .map_err(|e| AppError::Database(format!(\"创建 failover 索引失败: {e}\")))?;\n\n        // proxy_request_logs 表\n        conn.execute(\"CREATE TABLE IF NOT EXISTS proxy_request_logs (\n            request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL,\n            request_model TEXT,\n            input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0,\n            cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0,\n            input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0',\n            cache_read_cost_usd TEXT NOT NULL DEFAULT '0', cache_creation_cost_usd TEXT NOT NULL DEFAULT '0',\n            total_cost_usd TEXT NOT NULL DEFAULT '0', latency_ms INTEGER NOT NULL, first_token_ms INTEGER,\n            duration_ms INTEGER, status_code INTEGER NOT NULL, error_message TEXT, session_id TEXT,\n            provider_type TEXT, is_streaming INTEGER NOT NULL DEFAULT 0,\n            cost_multiplier TEXT NOT NULL DEFAULT '1.0', created_at INTEGER NOT NULL\n        )\", [])?;\n\n        // 为已存在的表添加新字段\n        Self::add_column_if_missing(conn, \"proxy_request_logs\", \"provider_type\", \"TEXT\")?;\n        Self::add_column_if_missing(\n            conn,\n            \"proxy_request_logs\",\n            \"is_streaming\",\n            \"INTEGER NOT NULL DEFAULT 0\",\n        )?;\n        Self::add_column_if_missing(\n            conn,\n            \"proxy_request_logs\",\n            \"cost_multiplier\",\n            \"TEXT NOT NULL DEFAULT '1.0'\",\n        )?;\n        Self::add_column_if_missing(conn, \"proxy_request_logs\", \"first_token_ms\", \"INTEGER\")?;\n        Self::add_column_if_missing(conn, \"proxy_request_logs\", \"duration_ms\", \"INTEGER\")?;\n\n        // model_pricing 表\n        conn.execute(\n            \"CREATE TABLE IF NOT EXISTS model_pricing (\n            model_id TEXT PRIMARY KEY, display_name TEXT NOT NULL,\n            input_cost_per_million TEXT NOT NULL, output_cost_per_million TEXT NOT NULL,\n            cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',\n            cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'\n        )\",\n            [],\n        )?;\n\n        // 清空并重新插入模型定价\n        conn.execute(\"DELETE FROM model_pricing\", [])\n            .map_err(|e| AppError::Database(format!(\"清空模型定价失败: {e}\")))?;\n        Self::seed_model_pricing(conn)?;\n\n        // 重构 skills 表（添加 app_type 字段）\n        Self::migrate_skills_table(conn)?;\n\n        // 重构 proxy_config 为三行结构（每应用独立配置）\n        Self::migrate_proxy_config_to_per_app(conn)?;\n\n        Ok(())\n    }\n\n    /// 将 proxy_config 迁移为三行结构（每应用独立配置）\n    fn migrate_proxy_config_to_per_app(conn: &Connection) -> Result<(), AppError> {\n        // 检查是否已经是新表结构（幂等性）\n        if !Self::table_exists(conn, \"proxy_config\")? {\n            // 表不存在，跳过迁移（新安装）\n            return Ok(());\n        }\n\n        if Self::has_column(conn, \"proxy_config\", \"app_type\")? {\n            // 已经是三行结构，跳过迁移\n            log::info!(\"proxy_config 已经是三行结构，跳过迁移\");\n            return Ok(());\n        }\n\n        // 读取旧配置\n        let old_config = conn\n            .query_row(\n                \"SELECT listen_address, listen_port, max_retries, enable_logging,\n                    streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout\n             FROM proxy_config WHERE id = 1\",\n                [],\n                |row| {\n                    Ok((\n                        row.get::<_, String>(0)?,\n                        row.get::<_, i32>(1)?,\n                        row.get::<_, i32>(2)?,\n                        row.get::<_, i32>(3)?,\n                        row.get::<_, i32>(4).unwrap_or(30),\n                        row.get::<_, i32>(5).unwrap_or(60),\n                        row.get::<_, i32>(6).unwrap_or(300),\n                    ))\n                },\n            )\n            .unwrap_or_else(|_| (\"127.0.0.1\".to_string(), 5000, 3, 1, 30, 60, 300));\n\n        let old_cb = conn.query_row(\n            \"SELECT failure_threshold, success_threshold, timeout_seconds, error_rate_threshold, min_requests\n             FROM circuit_breaker_config WHERE id = 1\", [],\n            |row| Ok((row.get::<_, i32>(0)?, row.get::<_, i32>(1)?, row.get::<_, i64>(2)?,\n                      row.get::<_, f64>(3)?, row.get::<_, i32>(4)?))\n        ).unwrap_or((5, 2, 60, 0.5, 10));\n\n        let get_bool = |key: &str| -> bool {\n            conn.query_row(\"SELECT value FROM settings WHERE key = ?\", [key], |r| {\n                r.get::<_, String>(0)\n            })\n            .map(|v| v == \"true\" || v == \"1\")\n            .unwrap_or(false)\n        };\n\n        let apps = [\n            (\n                \"claude\",\n                get_bool(\"proxy_takeover_claude\"),\n                get_bool(\"auto_failover_enabled_claude\"),\n                6,\n                45,\n                90,\n                8,\n                3,\n                90,\n                0.6,\n                15,\n            ),\n            (\n                \"codex\",\n                get_bool(\"proxy_takeover_codex\"),\n                get_bool(\"auto_failover_enabled_codex\"),\n                3,\n                old_config.4,\n                old_config.5,\n                old_cb.0,\n                old_cb.1,\n                old_cb.2,\n                old_cb.3,\n                old_cb.4,\n            ),\n            (\n                \"gemini\",\n                get_bool(\"proxy_takeover_gemini\"),\n                get_bool(\"auto_failover_enabled_gemini\"),\n                5,\n                old_config.4,\n                old_config.5,\n                old_cb.0,\n                old_cb.1,\n                old_cb.2,\n                old_cb.3,\n                old_cb.4,\n            ),\n        ];\n\n        // 创建新表\n        conn.execute(\"DROP TABLE IF EXISTS proxy_config_new\", [])?;\n        conn.execute(\"CREATE TABLE proxy_config_new (\n            app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')),\n            proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',\n            listen_port INTEGER NOT NULL DEFAULT 15721, enable_logging INTEGER NOT NULL DEFAULT 1,\n            enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,\n            max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60,\n            streaming_idle_timeout INTEGER NOT NULL DEFAULT 120, non_streaming_timeout INTEGER NOT NULL DEFAULT 600,\n            circuit_failure_threshold INTEGER NOT NULL DEFAULT 4, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,\n            circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.6,\n            circuit_min_requests INTEGER NOT NULL DEFAULT 10,\n            default_cost_multiplier TEXT NOT NULL DEFAULT '1',\n            pricing_model_source TEXT NOT NULL DEFAULT 'response',\n            created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))\n        )\", [])?;\n\n        // 插入三行配置\n        for (app, takeover, failover, retries, fb, idle, cb_f, cb_s, cb_t, cb_r, cb_m) in apps {\n            conn.execute(\n                \"INSERT INTO proxy_config_new (app_type, proxy_enabled, listen_address, listen_port, enable_logging,\n                 enabled, auto_failover_enabled, max_retries, streaming_first_byte_timeout, streaming_idle_timeout,\n                 non_streaming_timeout, circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,\n                 circuit_error_rate_threshold, circuit_min_requests)\n                 VALUES (?1, 0, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)\",\n                rusqlite::params![app, old_config.0, old_config.1, old_config.3,\n                    if takeover { 1 } else { 0 }, if failover { 1 } else { 0 },\n                    retries, fb, idle, old_config.6, cb_f, cb_s, cb_t, cb_r, cb_m]\n            ).map_err(|e| AppError::Database(format!(\"插入 {app} 配置失败: {e}\")))?;\n        }\n\n        // 替换表并清理\n        conn.execute(\"DROP TABLE IF EXISTS proxy_config\", [])?;\n        conn.execute(\"ALTER TABLE proxy_config_new RENAME TO proxy_config\", [])?;\n        conn.execute(\"DROP TABLE IF EXISTS circuit_breaker_config\", [])?;\n        conn.execute(\"DELETE FROM settings WHERE key LIKE 'proxy_takeover_%'\", [])?;\n        conn.execute(\n            \"DELETE FROM settings WHERE key LIKE 'auto_failover_enabled_%'\",\n            [],\n        )?;\n\n        log::info!(\"proxy_config 已迁移为三行结构\");\n        Ok(())\n    }\n\n    /// 迁移 skills 表：从单 key 主键改为 (directory, app_type) 复合主键\n    fn migrate_skills_table(conn: &Connection) -> Result<(), AppError> {\n        // v3 结构（统一管理架构）已经是更高版本的 skills 表：\n        // - 主键为 id\n        // - 包含 enabled_claude / enabled_codex / enabled_gemini 等列\n        // 在这种情况下，不应再执行 v1 -> v2 的迁移逻辑，否则会因列不匹配而失败。\n        if Self::has_column(conn, \"skills\", \"enabled_claude\")?\n            || Self::has_column(conn, \"skills\", \"id\")?\n        {\n            log::info!(\"skills 表已经是 v3 结构，跳过 v1 -> v2 迁移\");\n            return Ok(());\n        }\n\n        // 检查是否已经是新表结构\n        if Self::has_column(conn, \"skills\", \"app_type\")? {\n            log::info!(\"skills 表已经包含 app_type 字段，跳过迁移\");\n            return Ok(());\n        }\n\n        log::info!(\"开始迁移 skills 表...\");\n\n        // 1. 重命名旧表\n        conn.execute(\"ALTER TABLE skills RENAME TO skills_old\", [])\n            .map_err(|e| AppError::Database(format!(\"重命名旧 skills 表失败: {e}\")))?;\n\n        // 2. 创建新表\n        conn.execute(\n            \"CREATE TABLE skills (\n                directory TEXT NOT NULL,\n                app_type TEXT NOT NULL,\n                installed BOOLEAN NOT NULL DEFAULT 0,\n                installed_at INTEGER NOT NULL DEFAULT 0,\n                PRIMARY KEY (directory, app_type)\n            )\",\n            [],\n        )\n        .map_err(|e| AppError::Database(format!(\"创建新 skills 表失败: {e}\")))?;\n\n        // 3. 迁移数据：解析 key 格式（如 \"claude:my-skill\" 或 \"codex:foo\"）\n        //    旧数据如果没有前缀，默认为 claude\n        let mut stmt = conn\n            .prepare(\"SELECT key, installed, installed_at FROM skills_old\")\n            .map_err(|e| AppError::Database(format!(\"查询旧 skills 数据失败: {e}\")))?;\n\n        let old_skills: Vec<(String, bool, i64)> = stmt\n            .query_map([], |row| {\n                Ok((\n                    row.get::<_, String>(0)?,\n                    row.get::<_, bool>(1)?,\n                    row.get::<_, i64>(2)?,\n                ))\n            })\n            .map_err(|e| AppError::Database(format!(\"读取旧 skills 数据失败: {e}\")))?\n            .collect::<Result<Vec<_>, _>>()\n            .map_err(|e| AppError::Database(format!(\"解析旧 skills 数据失败: {e}\")))?;\n\n        let count = old_skills.len();\n\n        for (key, installed, installed_at) in old_skills {\n            // 解析 key: \"app:directory\" 或 \"directory\"（默认 claude）\n            let (app_type, directory) = if let Some(idx) = key.find(':') {\n                let (app, dir) = key.split_at(idx);\n                (app.to_string(), dir[1..].to_string()) // 跳过冒号\n            } else {\n                (\"claude\".to_string(), key.clone())\n            };\n\n            conn.execute(\n                \"INSERT INTO skills (directory, app_type, installed, installed_at) VALUES (?1, ?2, ?3, ?4)\",\n                rusqlite::params![directory, app_type, installed, installed_at],\n            )\n            .map_err(|e| {\n                AppError::Database(format!(\"迁移 skill {key} 到新表失败: {e}\"))\n            })?;\n        }\n\n        // 4. 删除旧表\n        conn.execute(\"DROP TABLE skills_old\", [])\n            .map_err(|e| AppError::Database(format!(\"删除旧 skills 表失败: {e}\")))?;\n\n        log::info!(\"skills 表迁移完成，共迁移 {count} 条记录\");\n        Ok(())\n    }\n\n    /// v2 -> v3 迁移：Skills 统一管理架构\n    ///\n    /// 将 skills 表从 (directory, app_type) 复合主键结构迁移到统一的 id 主键结构，\n    /// 支持三应用启用标志（enabled_claude, enabled_codex, enabled_gemini）。\n    ///\n    /// 迁移策略：\n    /// 1. 旧数据库只存储安装记录，真正的 skill 文件在文件系统\n    /// 2. 直接重建新表结构，后续由 SkillService 在首次启动时扫描文件系统重建数据\n    fn migrate_v2_to_v3(conn: &Connection) -> Result<(), AppError> {\n        // 检查是否已经是新结构（通过检查是否有 enabled_claude 列）\n        if Self::has_column(conn, \"skills\", \"enabled_claude\")? {\n            log::info!(\"skills 表已经是 v3 结构，跳过迁移\");\n            return Ok(());\n        }\n\n        log::info!(\"开始迁移 skills 表到 v3 结构（统一管理架构）...\");\n\n        // 1. 备份旧数据（用于日志和后续启动迁移）\n        let old_count: i64 = conn\n            .query_row(\"SELECT COUNT(*) FROM skills\", [], |row| row.get(0))\n            .unwrap_or(0);\n        log::info!(\"旧 skills 表有 {old_count} 条记录\");\n\n        let mut stmt = conn\n            .prepare(\n                \"SELECT directory, app_type FROM skills\n                 WHERE installed = 1\",\n            )\n            .map_err(|e| AppError::Database(format!(\"查询旧 skills 快照失败: {e}\")))?;\n        let snapshot_rows: Vec<LegacySkillMigrationRow> = stmt\n            .query_map([], |row| {\n                Ok(LegacySkillMigrationRow {\n                    directory: row.get(0)?,\n                    app_type: row.get(1)?,\n                })\n            })\n            .map_err(|e| AppError::Database(format!(\"读取旧 skills 快照失败: {e}\")))?\n            .collect::<Result<Vec<_>, _>>()\n            .map_err(|e| AppError::Database(format!(\"解析旧 skills 快照失败: {e}\")))?;\n        let snapshot_json = serde_json::to_string(&snapshot_rows)\n            .map_err(|e| AppError::Database(format!(\"序列化旧 skills 快照失败: {e}\")))?;\n\n        // 标记：需要在启动后从文件系统扫描并重建 Skills 数据\n        // 说明：v3 结构将 Skills 的 SSOT 迁移到 ~/.cc-switch/skills/，\n        // 旧表只存“安装记录”，无法直接无损迁移到新结构，因此改为启动后扫描 app 目录导入。\n        let _ = conn.execute(\n            \"INSERT OR REPLACE INTO settings (key, value) VALUES ('skills_ssot_migration_pending', 'true')\",\n            [],\n        );\n        let _ = conn.execute(\n            \"INSERT OR REPLACE INTO settings (key, value) VALUES ('skills_ssot_migration_snapshot', ?1)\",\n            [snapshot_json],\n        );\n\n        // 2. 删除旧表\n        conn.execute(\"DROP TABLE IF EXISTS skills\", [])\n            .map_err(|e| AppError::Database(format!(\"删除旧 skills 表失败: {e}\")))?;\n\n        // 3. 创建新表\n        conn.execute(\n            \"CREATE TABLE skills (\n                id TEXT PRIMARY KEY,\n                name TEXT NOT NULL,\n                description TEXT,\n                directory TEXT NOT NULL,\n                repo_owner TEXT,\n                repo_name TEXT,\n                repo_branch TEXT DEFAULT 'main',\n                readme_url TEXT,\n                enabled_claude BOOLEAN NOT NULL DEFAULT 0,\n                enabled_codex BOOLEAN NOT NULL DEFAULT 0,\n                enabled_gemini BOOLEAN NOT NULL DEFAULT 0,\n                installed_at INTEGER NOT NULL DEFAULT 0\n            )\",\n            [],\n        )\n        .map_err(|e| AppError::Database(format!(\"创建新 skills 表失败: {e}\")))?;\n\n        log::info!(\n            \"skills 表已迁移到 v3 结构。\\n\\\n             注意：旧的安装记录已清除，首次启动时将自动扫描文件系统重建数据。\"\n        );\n\n        Ok(())\n    }\n\n    /// v3 -> v4 迁移：添加 OpenCode 支持\n    ///\n    /// 为 mcp_servers 和 skills 表添加 enabled_opencode 列。\n    fn migrate_v3_to_v4(conn: &Connection) -> Result<(), AppError> {\n        // 为 mcp_servers 表添加 enabled_opencode 列\n        Self::add_column_if_missing(\n            conn,\n            \"mcp_servers\",\n            \"enabled_opencode\",\n            \"BOOLEAN NOT NULL DEFAULT 0\",\n        )?;\n\n        // 为 skills 表添加 enabled_opencode 列\n        Self::add_column_if_missing(\n            conn,\n            \"skills\",\n            \"enabled_opencode\",\n            \"BOOLEAN NOT NULL DEFAULT 0\",\n        )?;\n\n        log::info!(\"v3 -> v4 迁移完成：已添加 OpenCode 支持\");\n        Ok(())\n    }\n\n    /// v4 -> v5 迁移：新增计费模式配置与请求模型字段\n    fn migrate_v4_to_v5(conn: &Connection) -> Result<(), AppError> {\n        if Self::table_exists(conn, \"proxy_config\")? {\n            Self::add_column_if_missing(\n                conn,\n                \"proxy_config\",\n                \"default_cost_multiplier\",\n                \"TEXT NOT NULL DEFAULT '1'\",\n            )?;\n            Self::add_column_if_missing(\n                conn,\n                \"proxy_config\",\n                \"pricing_model_source\",\n                \"TEXT NOT NULL DEFAULT 'response'\",\n            )?;\n        }\n        if Self::table_exists(conn, \"proxy_request_logs\")? {\n            Self::add_column_if_missing(conn, \"proxy_request_logs\", \"request_model\", \"TEXT\")?;\n        }\n\n        log::info!(\"v4 -> v5 迁移完成：已添加计费模式与请求模型字段\");\n        Ok(())\n    }\n\n    /// v5 -> v6 迁移：添加使用量日聚合表 + 统一 Copilot 模板类型\n    fn migrate_v5_to_v6(conn: &Connection) -> Result<(), AppError> {\n        // 1. 添加使用量日聚合表\n        conn.execute(\n            \"CREATE TABLE IF NOT EXISTS usage_daily_rollups (\n                date TEXT NOT NULL,\n                app_type TEXT NOT NULL,\n                provider_id TEXT NOT NULL,\n                model TEXT NOT NULL,\n                request_count INTEGER NOT NULL DEFAULT 0,\n                success_count INTEGER NOT NULL DEFAULT 0,\n                input_tokens INTEGER NOT NULL DEFAULT 0,\n                output_tokens INTEGER NOT NULL DEFAULT 0,\n                cache_read_tokens INTEGER NOT NULL DEFAULT 0,\n                cache_creation_tokens INTEGER NOT NULL DEFAULT 0,\n                total_cost_usd TEXT NOT NULL DEFAULT '0',\n                avg_latency_ms INTEGER NOT NULL DEFAULT 0,\n                PRIMARY KEY (date, app_type, provider_id, model)\n            )\",\n            [],\n        )\n        .map_err(|e| AppError::Database(format!(\"创建 usage_daily_rollups 表失败: {e}\")))?;\n\n        // 2. 统一 Copilot 模板类型为 github_copilot\n        let mut stmt = conn\n            .prepare(\"SELECT id, app_type, meta FROM providers\")\n            .map_err(|e| AppError::Database(e.to_string()))?;\n\n        let rows = stmt\n            .query_map([], |row| {\n                Ok((\n                    row.get::<_, String>(0)?,\n                    row.get::<_, String>(1)?,\n                    row.get::<_, String>(2)?,\n                ))\n            })\n            .map_err(|e| AppError::Database(e.to_string()))?;\n\n        let mut updates = Vec::new();\n        for row in rows {\n            let (id, app_type, meta_str) = row.map_err(|e| AppError::Database(e.to_string()))?;\n\n            if let Ok(mut meta) = serde_json::from_str::<serde_json::Value>(&meta_str) {\n                let mut updated = false;\n\n                if let Some(usage_script) = meta.get_mut(\"usage_script\") {\n                    if let Some(template_type) = usage_script.get_mut(\"template_type\") {\n                        if template_type == \"copilot\" {\n                            *template_type =\n                                serde_json::Value::String(\"github_copilot\".to_string());\n                            updated = true;\n                        }\n                    }\n                }\n\n                if updated {\n                    let new_meta_str = serde_json::to_string(&meta)\n                        .map_err(|e| AppError::Database(e.to_string()))?;\n                    updates.push((id, app_type, new_meta_str));\n                }\n            }\n        }\n\n        for (id, app_type, new_meta) in updates {\n            conn.execute(\n                \"UPDATE providers SET meta = ?1 WHERE id = ?2 AND app_type = ?3\",\n                params![new_meta, id, app_type],\n            )\n            .map_err(|e| AppError::Database(e.to_string()))?;\n        }\n\n        log::info!(\"v5 -> v6 迁移完成：已添加使用量日聚合表，统一 copilot 模板类型\");\n        Ok(())\n    }\n\n    /// 插入默认模型定价数据\n    /// 格式: (model_id, display_name, input, output, cache_read, cache_creation)\n    /// 注意: model_id 使用短横线格式（如 claude-haiku-4-5），与 API 返回的模型名称标准化后一致\n    fn seed_model_pricing(conn: &Connection) -> Result<(), AppError> {\n        let pricing_data = [\n            // Claude 4.6 系列\n            (\n                \"claude-opus-4-6-20260206\",\n                \"Claude Opus 4.6\",\n                \"5\",\n                \"25\",\n                \"0.50\",\n                \"6.25\",\n            ),\n            // Claude 4.5 系列\n            (\n                \"claude-opus-4-5-20251101\",\n                \"Claude Opus 4.5\",\n                \"5\",\n                \"25\",\n                \"0.50\",\n                \"6.25\",\n            ),\n            (\n                \"claude-sonnet-4-5-20250929\",\n                \"Claude Sonnet 4.5\",\n                \"3\",\n                \"15\",\n                \"0.30\",\n                \"3.75\",\n            ),\n            (\n                \"claude-haiku-4-5-20251001\",\n                \"Claude Haiku 4.5\",\n                \"1\",\n                \"5\",\n                \"0.10\",\n                \"1.25\",\n            ),\n            // Claude 4 系列 (Legacy Models)\n            (\n                \"claude-opus-4-20250514\",\n                \"Claude Opus 4\",\n                \"15\",\n                \"75\",\n                \"1.50\",\n                \"18.75\",\n            ),\n            (\n                \"claude-opus-4-1-20250805\",\n                \"Claude Opus 4.1\",\n                \"15\",\n                \"75\",\n                \"1.50\",\n                \"18.75\",\n            ),\n            (\n                \"claude-sonnet-4-20250514\",\n                \"Claude Sonnet 4\",\n                \"3\",\n                \"15\",\n                \"0.30\",\n                \"3.75\",\n            ),\n            // Claude 3.5 系列\n            (\n                \"claude-3-5-haiku-20241022\",\n                \"Claude 3.5 Haiku\",\n                \"0.80\",\n                \"4\",\n                \"0.08\",\n                \"1\",\n            ),\n            (\n                \"claude-3-5-sonnet-20241022\",\n                \"Claude 3.5 Sonnet\",\n                \"3\",\n                \"15\",\n                \"0.30\",\n                \"3.75\",\n            ),\n            // GPT-5.2 系列\n            (\"gpt-5.2\", \"GPT-5.2\", \"1.75\", \"14\", \"0.175\", \"0\"),\n            (\"gpt-5.2-low\", \"GPT-5.2\", \"1.75\", \"14\", \"0.175\", \"0\"),\n            (\"gpt-5.2-medium\", \"GPT-5.2\", \"1.75\", \"14\", \"0.175\", \"0\"),\n            (\"gpt-5.2-high\", \"GPT-5.2\", \"1.75\", \"14\", \"0.175\", \"0\"),\n            (\"gpt-5.2-xhigh\", \"GPT-5.2\", \"1.75\", \"14\", \"0.175\", \"0\"),\n            (\"gpt-5.2-codex\", \"GPT-5.2 Codex\", \"1.75\", \"14\", \"0.175\", \"0\"),\n            (\n                \"gpt-5.2-codex-low\",\n                \"GPT-5.2 Codex\",\n                \"1.75\",\n                \"14\",\n                \"0.175\",\n                \"0\",\n            ),\n            (\n                \"gpt-5.2-codex-medium\",\n                \"GPT-5.2 Codex\",\n                \"1.75\",\n                \"14\",\n                \"0.175\",\n                \"0\",\n            ),\n            (\n                \"gpt-5.2-codex-high\",\n                \"GPT-5.2 Codex\",\n                \"1.75\",\n                \"14\",\n                \"0.175\",\n                \"0\",\n            ),\n            (\n                \"gpt-5.2-codex-xhigh\",\n                \"GPT-5.2 Codex\",\n                \"1.75\",\n                \"14\",\n                \"0.175\",\n                \"0\",\n            ),\n            // GPT-5.3 Codex 系列\n            (\"gpt-5.3-codex\", \"GPT-5.3 Codex\", \"1.75\", \"14\", \"0.175\", \"0\"),\n            (\n                \"gpt-5.3-codex-low\",\n                \"GPT-5.3 Codex\",\n                \"1.75\",\n                \"14\",\n                \"0.175\",\n                \"0\",\n            ),\n            (\n                \"gpt-5.3-codex-medium\",\n                \"GPT-5.3 Codex\",\n                \"1.75\",\n                \"14\",\n                \"0.175\",\n                \"0\",\n            ),\n            (\n                \"gpt-5.3-codex-high\",\n                \"GPT-5.3 Codex\",\n                \"1.75\",\n                \"14\",\n                \"0.175\",\n                \"0\",\n            ),\n            (\n                \"gpt-5.3-codex-xhigh\",\n                \"GPT-5.3 Codex\",\n                \"1.75\",\n                \"14\",\n                \"0.175\",\n                \"0\",\n            ),\n            // GPT-5.1 系列\n            (\"gpt-5.1\", \"GPT-5.1\", \"1.25\", \"10\", \"0.125\", \"0\"),\n            (\"gpt-5.1-low\", \"GPT-5.1\", \"1.25\", \"10\", \"0.125\", \"0\"),\n            (\"gpt-5.1-medium\", \"GPT-5.1\", \"1.25\", \"10\", \"0.125\", \"0\"),\n            (\"gpt-5.1-high\", \"GPT-5.1\", \"1.25\", \"10\", \"0.125\", \"0\"),\n            (\"gpt-5.1-minimal\", \"GPT-5.1\", \"1.25\", \"10\", \"0.125\", \"0\"),\n            (\"gpt-5.1-codex\", \"GPT-5.1 Codex\", \"1.25\", \"10\", \"0.125\", \"0\"),\n            (\n                \"gpt-5.1-codex-mini\",\n                \"GPT-5.1 Codex\",\n                \"1.25\",\n                \"10\",\n                \"0.125\",\n                \"0\",\n            ),\n            (\n                \"gpt-5.1-codex-max\",\n                \"GPT-5.1 Codex\",\n                \"1.25\",\n                \"10\",\n                \"0.125\",\n                \"0\",\n            ),\n            (\n                \"gpt-5.1-codex-max-high\",\n                \"GPT-5.1 Codex\",\n                \"1.25\",\n                \"10\",\n                \"0.125\",\n                \"0\",\n            ),\n            (\n                \"gpt-5.1-codex-max-xhigh\",\n                \"GPT-5.1 Codex\",\n                \"1.25\",\n                \"10\",\n                \"0.125\",\n                \"0\",\n            ),\n            // GPT-5 系列\n            (\"gpt-5\", \"GPT-5\", \"1.25\", \"10\", \"0.125\", \"0\"),\n            (\"gpt-5-low\", \"GPT-5\", \"1.25\", \"10\", \"0.125\", \"0\"),\n            (\"gpt-5-medium\", \"GPT-5\", \"1.25\", \"10\", \"0.125\", \"0\"),\n            (\"gpt-5-high\", \"GPT-5\", \"1.25\", \"10\", \"0.125\", \"0\"),\n            (\"gpt-5-minimal\", \"GPT-5\", \"1.25\", \"10\", \"0.125\", \"0\"),\n            (\"gpt-5-codex\", \"GPT-5 Codex\", \"1.25\", \"10\", \"0.125\", \"0\"),\n            (\"gpt-5-codex-low\", \"GPT-5 Codex\", \"1.25\", \"10\", \"0.125\", \"0\"),\n            (\n                \"gpt-5-codex-medium\",\n                \"GPT-5 Codex\",\n                \"1.25\",\n                \"10\",\n                \"0.125\",\n                \"0\",\n            ),\n            (\n                \"gpt-5-codex-high\",\n                \"GPT-5 Codex\",\n                \"1.25\",\n                \"10\",\n                \"0.125\",\n                \"0\",\n            ),\n            (\n                \"gpt-5-codex-mini\",\n                \"GPT-5 Codex\",\n                \"1.25\",\n                \"10\",\n                \"0.125\",\n                \"0\",\n            ),\n            (\n                \"gpt-5-codex-mini-medium\",\n                \"GPT-5 Codex\",\n                \"1.25\",\n                \"10\",\n                \"0.125\",\n                \"0\",\n            ),\n            (\n                \"gpt-5-codex-mini-high\",\n                \"GPT-5 Codex\",\n                \"1.25\",\n                \"10\",\n                \"0.125\",\n                \"0\",\n            ),\n            // Gemini 3 系列\n            (\n                \"gemini-3-pro-preview\",\n                \"Gemini 3 Pro Preview\",\n                \"2\",\n                \"12\",\n                \"0.2\",\n                \"0\",\n            ),\n            (\n                \"gemini-3-flash-preview\",\n                \"Gemini 3 Flash Preview\",\n                \"0.5\",\n                \"3\",\n                \"0.05\",\n                \"0\",\n            ),\n            // Gemini 2.5 系列\n            (\n                \"gemini-2.5-pro\",\n                \"Gemini 2.5 Pro\",\n                \"1.25\",\n                \"10\",\n                \"0.125\",\n                \"0\",\n            ),\n            (\n                \"gemini-2.5-flash\",\n                \"Gemini 2.5 Flash\",\n                \"0.3\",\n                \"2.5\",\n                \"0.03\",\n                \"0\",\n            ),\n            // StepFun 系列\n            (\n                \"step-3.5-flash\",\n                \"Step 3.5 Flash\",\n                \"0.10\",\n                \"0.30\",\n                \"0.02\",\n                \"0\",\n            ),\n            // ====== 国产模型 (CNY/1M tokens) ======\n            // Doubao (字节跳动)\n            (\n                \"doubao-seed-code\",\n                \"Doubao Seed Code\",\n                \"1.20\",\n                \"8.00\",\n                \"0.24\",\n                \"0\",\n            ),\n            // DeepSeek 系列\n            (\n                \"deepseek-v3.2\",\n                \"DeepSeek V3.2\",\n                \"2.00\",\n                \"3.00\",\n                \"0.40\",\n                \"0\",\n            ),\n            (\n                \"deepseek-v3.1\",\n                \"DeepSeek V3.1\",\n                \"4.00\",\n                \"12.00\",\n                \"0.80\",\n                \"0\",\n            ),\n            (\"deepseek-v3\", \"DeepSeek V3\", \"2.00\", \"8.00\", \"0.40\", \"0\"),\n            // Kimi (月之暗面)\n            (\n                \"kimi-k2-thinking\",\n                \"Kimi K2 Thinking\",\n                \"4.00\",\n                \"16.00\",\n                \"1.00\",\n                \"0\",\n            ),\n            (\"kimi-k2-0905\", \"Kimi K2\", \"4.00\", \"16.00\", \"1.00\", \"0\"),\n            (\n                \"kimi-k2-turbo\",\n                \"Kimi K2 Turbo\",\n                \"8.00\",\n                \"58.00\",\n                \"1.00\",\n                \"0\",\n            ),\n            // MiniMax 系列\n            (\"minimax-m2.1\", \"MiniMax M2.1\", \"2.10\", \"8.40\", \"0.21\", \"0\"),\n            (\n                \"minimax-m2.1-lightning\",\n                \"MiniMax M2.1 Lightning\",\n                \"2.10\",\n                \"16.80\",\n                \"0.21\",\n                \"0\",\n            ),\n            (\"minimax-m2\", \"MiniMax M2\", \"2.10\", \"8.40\", \"0.21\", \"0\"),\n            // GLM (智谱)\n            (\"glm-4.7\", \"GLM-4.7\", \"2.00\", \"8.00\", \"0.40\", \"0\"),\n            (\"glm-4.6\", \"GLM-4.6\", \"2.00\", \"8.00\", \"0.40\", \"0\"),\n            // Mimo (小米)\n            (\"mimo-v2-flash\", \"Mimo V2 Flash\", \"0\", \"0\", \"0\", \"0\"),\n        ];\n\n        for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data {\n            conn.execute(\n                \"INSERT OR IGNORE INTO model_pricing (\n                    model_id, display_name, input_cost_per_million, output_cost_per_million,\n                    cache_read_cost_per_million, cache_creation_cost_per_million\n                ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)\",\n                rusqlite::params![\n                    model_id,\n                    display_name,\n                    input,\n                    output,\n                    cache_read,\n                    cache_creation\n                ],\n            )\n            .map_err(|e| AppError::Database(format!(\"插入模型定价失败: {e}\")))?;\n        }\n\n        log::info!(\"已插入 {} 条默认模型定价数据\", pricing_data.len());\n        Ok(())\n    }\n\n    /// 确保模型定价表具备默认数据\n    pub fn ensure_model_pricing_seeded(&self) -> Result<(), AppError> {\n        let conn = lock_conn!(self.conn);\n        Self::ensure_model_pricing_seeded_on_conn(&conn)\n    }\n\n    fn ensure_model_pricing_seeded_on_conn(conn: &Connection) -> Result<(), AppError> {\n        // 每次启动都执行 INSERT OR IGNORE，增量追加新模型，已有数据不覆盖\n        Self::seed_model_pricing(conn)\n    }\n\n    // --- 辅助方法 ---\n\n    pub(crate) fn get_user_version(conn: &Connection) -> Result<i32, AppError> {\n        conn.query_row(\"PRAGMA user_version;\", [], |row| row.get(0))\n            .map_err(|e| AppError::Database(format!(\"读取 user_version 失败: {e}\")))\n    }\n\n    pub(crate) fn set_user_version(conn: &Connection, version: i32) -> Result<(), AppError> {\n        if version < 0 {\n            return Err(AppError::Database(\"user_version 不能为负数\".to_string()));\n        }\n        let sql = format!(\"PRAGMA user_version = {version};\");\n        conn.execute(&sql, [])\n            .map_err(|e| AppError::Database(format!(\"写入 user_version 失败: {e}\")))?;\n        Ok(())\n    }\n\n    fn validate_identifier(s: &str, kind: &str) -> Result<(), AppError> {\n        if s.is_empty() {\n            return Err(AppError::Database(format!(\"{kind} 不能为空\")));\n        }\n        if !s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {\n            return Err(AppError::Database(format!(\n                \"非法{kind}: {s}，仅允许字母、数字和下划线\"\n            )));\n        }\n        Ok(())\n    }\n\n    pub(crate) fn table_exists(conn: &Connection, table: &str) -> Result<bool, AppError> {\n        Self::validate_identifier(table, \"表名\")?;\n\n        let mut stmt = conn\n            .prepare(\"SELECT name FROM sqlite_master WHERE type='table'\")\n            .map_err(|e| AppError::Database(format!(\"读取表名失败: {e}\")))?;\n        let mut rows = stmt\n            .query([])\n            .map_err(|e| AppError::Database(format!(\"查询表名失败: {e}\")))?;\n        while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {\n            let name: String = row\n                .get(0)\n                .map_err(|e| AppError::Database(format!(\"解析表名失败: {e}\")))?;\n            if name.eq_ignore_ascii_case(table) {\n                return Ok(true);\n            }\n        }\n        Ok(false)\n    }\n\n    pub(crate) fn has_column(\n        conn: &Connection,\n        table: &str,\n        column: &str,\n    ) -> Result<bool, AppError> {\n        Self::validate_identifier(table, \"表名\")?;\n        Self::validate_identifier(column, \"列名\")?;\n\n        let sql = format!(\"PRAGMA table_info(\\\"{table}\\\");\");\n        let mut stmt = conn\n            .prepare(&sql)\n            .map_err(|e| AppError::Database(format!(\"读取表结构失败: {e}\")))?;\n        let mut rows = stmt\n            .query([])\n            .map_err(|e| AppError::Database(format!(\"查询表结构失败: {e}\")))?;\n        while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {\n            let name: String = row\n                .get(1)\n                .map_err(|e| AppError::Database(format!(\"读取列名失败: {e}\")))?;\n            if name.eq_ignore_ascii_case(column) {\n                return Ok(true);\n            }\n        }\n        Ok(false)\n    }\n\n    fn add_column_if_missing(\n        conn: &Connection,\n        table: &str,\n        column: &str,\n        definition: &str,\n    ) -> Result<bool, AppError> {\n        Self::validate_identifier(table, \"表名\")?;\n        Self::validate_identifier(column, \"列名\")?;\n\n        if !Self::table_exists(conn, table)? {\n            return Err(AppError::Database(format!(\n                \"表 {table} 不存在，无法添加列 {column}\"\n            )));\n        }\n        if Self::has_column(conn, table, column)? {\n            return Ok(false);\n        }\n\n        let sql = format!(\"ALTER TABLE \\\"{table}\\\" ADD COLUMN \\\"{column}\\\" {definition};\");\n        conn.execute(&sql, [])\n            .map_err(|e| AppError::Database(format!(\"为表 {table} 添加列 {column} 失败: {e}\")))?;\n        log::info!(\"已为表 {table} 添加缺失列 {column}\");\n        Ok(true)\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/database/tests.rs",
    "content": "//! 数据库模块测试\n//!\n//! 包含 Schema 迁移和基本功能的测试。\n\nuse super::*;\nuse crate::app_config::MultiAppConfig;\nuse crate::provider::{Provider, ProviderManager};\nuse indexmap::IndexMap;\nuse rusqlite::{params, Connection};\nuse serde_json::json;\nuse std::collections::HashMap;\nuse tempfile::NamedTempFile;\n\nconst LEGACY_SCHEMA_SQL: &str = r#\"\n    CREATE TABLE providers (\n        id TEXT NOT NULL,\n        app_type TEXT NOT NULL,\n        name TEXT NOT NULL,\n        settings_config TEXT NOT NULL,\n        PRIMARY KEY (id, app_type)\n    );\n    CREATE TABLE provider_endpoints (\n        id INTEGER PRIMARY KEY AUTOINCREMENT,\n        provider_id TEXT NOT NULL,\n        app_type TEXT NOT NULL,\n        url TEXT NOT NULL\n    );\n    CREATE TABLE mcp_servers (\n        id TEXT PRIMARY KEY,\n        name TEXT NOT NULL,\n        server_config TEXT NOT NULL\n    );\n    CREATE TABLE prompts (\n        id TEXT NOT NULL,\n        app_type TEXT NOT NULL,\n        name TEXT NOT NULL,\n        content TEXT NOT NULL,\n        PRIMARY KEY (id, app_type)\n    );\n    CREATE TABLE skills (\n        key TEXT PRIMARY KEY,\n        installed BOOLEAN NOT NULL DEFAULT 0\n    );\n    CREATE TABLE skill_repos (\n        owner TEXT NOT NULL,\n        name TEXT NOT NULL,\n        PRIMARY KEY (owner, name)\n    );\n    CREATE TABLE settings (\n        key TEXT PRIMARY KEY,\n        value TEXT\n    );\n\"#;\n\n// v3.8.x（schema v1）的真实表结构快照：用于验证从 v3.8.* 升级到当前版本的迁移链路\n// 参考：tag v3.8.3 的 src-tauri/src/database/schema.rs\nconst V3_8_SCHEMA_V1_SQL: &str = r#\"\n    CREATE TABLE providers (\n        id TEXT NOT NULL,\n        app_type TEXT NOT NULL,\n        name TEXT NOT NULL,\n        settings_config TEXT NOT NULL,\n        website_url TEXT,\n        category TEXT,\n        created_at INTEGER,\n        sort_index INTEGER,\n        notes TEXT,\n        icon TEXT,\n        icon_color TEXT,\n        meta TEXT NOT NULL DEFAULT '{}',\n        is_current BOOLEAN NOT NULL DEFAULT 0,\n        PRIMARY KEY (id, app_type)\n    );\n    CREATE TABLE provider_endpoints (\n        id INTEGER PRIMARY KEY AUTOINCREMENT,\n        provider_id TEXT NOT NULL,\n        app_type TEXT NOT NULL,\n        url TEXT NOT NULL,\n        added_at INTEGER,\n        FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE\n    );\n    CREATE TABLE mcp_servers (\n        id TEXT PRIMARY KEY,\n        name TEXT NOT NULL,\n        server_config TEXT NOT NULL,\n        description TEXT,\n        homepage TEXT,\n        docs TEXT,\n        tags TEXT NOT NULL DEFAULT '[]',\n        enabled_claude BOOLEAN NOT NULL DEFAULT 0,\n        enabled_codex BOOLEAN NOT NULL DEFAULT 0,\n        enabled_gemini BOOLEAN NOT NULL DEFAULT 0\n    );\n    CREATE TABLE prompts (\n        id TEXT NOT NULL,\n        app_type TEXT NOT NULL,\n        name TEXT NOT NULL,\n        content TEXT NOT NULL,\n        description TEXT,\n        enabled BOOLEAN NOT NULL DEFAULT 1,\n        created_at INTEGER,\n        updated_at INTEGER,\n        PRIMARY KEY (id, app_type)\n    );\n    CREATE TABLE skills (\n        key TEXT PRIMARY KEY,\n        installed BOOLEAN NOT NULL DEFAULT 0,\n        installed_at INTEGER NOT NULL DEFAULT 0\n    );\n    CREATE TABLE skill_repos (\n        owner TEXT NOT NULL,\n        name TEXT NOT NULL,\n        branch TEXT NOT NULL DEFAULT 'main',\n        enabled BOOLEAN NOT NULL DEFAULT 1,\n        PRIMARY KEY (owner, name)\n    );\n    CREATE TABLE settings (\n        key TEXT PRIMARY KEY,\n        value TEXT\n    );\n\"#;\n\n#[derive(Debug)]\nstruct ColumnInfo {\n    r#type: String,\n    notnull: i64,\n    default: Option<String>,\n}\n\nfn get_column_info(conn: &Connection, table: &str, column: &str) -> ColumnInfo {\n    let mut stmt = conn\n        .prepare(&format!(\"PRAGMA table_info(\\\"{table}\\\");\"))\n        .expect(\"prepare pragma\");\n    let mut rows = stmt.query([]).expect(\"query pragma\");\n    while let Some(row) = rows.next().expect(\"read row\") {\n        let column_name: String = row.get(1).expect(\"name\");\n        if column_name.eq_ignore_ascii_case(column) {\n            return ColumnInfo {\n                r#type: row.get::<_, String>(2).expect(\"type\"),\n                notnull: row.get::<_, i64>(3).expect(\"notnull\"),\n                default: row.get::<_, Option<String>>(4).ok().flatten(),\n            };\n        }\n    }\n    panic!(\"column {table}.{column} not found\");\n}\n\nfn normalize_default(default: &Option<String>) -> Option<String> {\n    default\n        .as_ref()\n        .map(|s| s.trim_matches('\\'').trim_matches('\"').to_string())\n}\n\n#[test]\nfn schema_migration_sets_user_version_when_missing() {\n    let conn = Connection::open_in_memory().expect(\"open memory db\");\n\n    Database::create_tables_on_conn(&conn).expect(\"create tables\");\n    assert_eq!(\n        Database::get_user_version(&conn).expect(\"read version before\"),\n        0\n    );\n\n    Database::apply_schema_migrations_on_conn(&conn).expect(\"apply migration\");\n\n    assert_eq!(\n        Database::get_user_version(&conn).expect(\"read version after\"),\n        SCHEMA_VERSION\n    );\n}\n\n#[test]\nfn schema_migration_rejects_future_version() {\n    let conn = Connection::open_in_memory().expect(\"open memory db\");\n    Database::create_tables_on_conn(&conn).expect(\"create tables\");\n    Database::set_user_version(&conn, SCHEMA_VERSION + 1).expect(\"set future version\");\n\n    let err =\n        Database::apply_schema_migrations_on_conn(&conn).expect_err(\"should reject higher version\");\n    assert!(\n        err.to_string().contains(\"数据库版本过新\"),\n        \"unexpected error: {err}\"\n    );\n}\n\n#[test]\nfn schema_migration_adds_missing_columns_for_providers() {\n    let conn = Connection::open_in_memory().expect(\"open memory db\");\n\n    // 创建旧版 providers 表，缺少新增列\n    conn.execute_batch(LEGACY_SCHEMA_SQL)\n        .expect(\"seed old schema\");\n\n    Database::apply_schema_migrations_on_conn(&conn).expect(\"apply migrations\");\n\n    // 验证关键新增列已补齐\n    for (table, column) in [\n        (\"providers\", \"meta\"),\n        (\"providers\", \"is_current\"),\n        (\"provider_endpoints\", \"added_at\"),\n        (\"mcp_servers\", \"enabled_gemini\"),\n        (\"prompts\", \"updated_at\"),\n        (\"skills\", \"installed_at\"),\n        (\"skill_repos\", \"enabled\"),\n    ] {\n        assert!(\n            Database::has_column(&conn, table, column).expect(\"check column\"),\n            \"{table}.{column} should exist after migration\"\n        );\n    }\n\n    // 验证 meta 列约束保持一致\n    let meta = get_column_info(&conn, \"providers\", \"meta\");\n    assert_eq!(meta.notnull, 1, \"meta should be NOT NULL\");\n    assert_eq!(\n        normalize_default(&meta.default).as_deref(),\n        Some(\"{}\"),\n        \"meta default should be '{{}}'\"\n    );\n\n    assert_eq!(\n        Database::get_user_version(&conn).expect(\"version after migration\"),\n        SCHEMA_VERSION\n    );\n}\n\n#[test]\nfn schema_migration_aligns_column_defaults_and_types() {\n    let conn = Connection::open_in_memory().expect(\"open memory db\");\n    conn.execute_batch(LEGACY_SCHEMA_SQL)\n        .expect(\"seed old schema\");\n\n    Database::apply_schema_migrations_on_conn(&conn).expect(\"apply migrations\");\n\n    let is_current = get_column_info(&conn, \"providers\", \"is_current\");\n    assert_eq!(is_current.r#type, \"BOOLEAN\");\n    assert_eq!(is_current.notnull, 1);\n    assert_eq!(normalize_default(&is_current.default).as_deref(), Some(\"0\"));\n\n    let tags = get_column_info(&conn, \"mcp_servers\", \"tags\");\n    assert_eq!(tags.r#type, \"TEXT\");\n    assert_eq!(tags.notnull, 1);\n    assert_eq!(normalize_default(&tags.default).as_deref(), Some(\"[]\"));\n\n    let enabled = get_column_info(&conn, \"prompts\", \"enabled\");\n    assert_eq!(enabled.r#type, \"BOOLEAN\");\n    assert_eq!(enabled.notnull, 1);\n    assert_eq!(normalize_default(&enabled.default).as_deref(), Some(\"1\"));\n\n    let installed_at = get_column_info(&conn, \"skills\", \"installed_at\");\n    assert_eq!(installed_at.r#type, \"INTEGER\");\n    assert_eq!(installed_at.notnull, 1);\n    assert_eq!(\n        normalize_default(&installed_at.default).as_deref(),\n        Some(\"0\")\n    );\n\n    let branch = get_column_info(&conn, \"skill_repos\", \"branch\");\n    assert_eq!(branch.r#type, \"TEXT\");\n    assert_eq!(normalize_default(&branch.default).as_deref(), Some(\"main\"));\n\n    let skill_repo_enabled = get_column_info(&conn, \"skill_repos\", \"enabled\");\n    assert_eq!(skill_repo_enabled.r#type, \"BOOLEAN\");\n    assert_eq!(skill_repo_enabled.notnull, 1);\n    assert_eq!(\n        normalize_default(&skill_repo_enabled.default).as_deref(),\n        Some(\"1\")\n    );\n}\n\n#[test]\nfn schema_create_tables_include_pricing_model_columns() {\n    let conn = Connection::open_in_memory().expect(\"open memory db\");\n    Database::create_tables_on_conn(&conn).expect(\"create tables\");\n\n    let multiplier = get_column_info(&conn, \"proxy_config\", \"default_cost_multiplier\");\n    assert_eq!(multiplier.r#type, \"TEXT\");\n    assert_eq!(multiplier.notnull, 1);\n    assert_eq!(normalize_default(&multiplier.default).as_deref(), Some(\"1\"));\n\n    let pricing_source = get_column_info(&conn, \"proxy_config\", \"pricing_model_source\");\n    assert_eq!(pricing_source.r#type, \"TEXT\");\n    assert_eq!(pricing_source.notnull, 1);\n    assert_eq!(\n        normalize_default(&pricing_source.default).as_deref(),\n        Some(\"response\")\n    );\n\n    let request_model = get_column_info(&conn, \"proxy_request_logs\", \"request_model\");\n    assert_eq!(request_model.r#type, \"TEXT\");\n    assert_eq!(request_model.notnull, 0);\n}\n\n#[test]\nfn schema_migration_v4_adds_pricing_model_columns() {\n    let conn = Connection::open_in_memory().expect(\"open memory db\");\n    conn.execute_batch(\n        r#\"\n        CREATE TABLE proxy_config (app_type TEXT PRIMARY KEY);\n        CREATE TABLE proxy_request_logs (request_id TEXT PRIMARY KEY, model TEXT NOT NULL);\n        CREATE TABLE mcp_servers (\n            id TEXT PRIMARY KEY,\n            name TEXT NOT NULL,\n            server_config TEXT NOT NULL,\n            enabled_claude INTEGER NOT NULL DEFAULT 0,\n            enabled_codex INTEGER NOT NULL DEFAULT 0,\n            enabled_gemini INTEGER NOT NULL DEFAULT 0,\n            enabled_opencode INTEGER NOT NULL DEFAULT 0\n        );\n        \"#,\n    )\n    .expect(\"seed v4 schema\");\n\n    Database::set_user_version(&conn, 4).expect(\"set user_version=4\");\n    Database::apply_schema_migrations_on_conn(&conn).expect(\"apply migrations\");\n\n    let multiplier = get_column_info(&conn, \"proxy_config\", \"default_cost_multiplier\");\n    assert_eq!(multiplier.r#type, \"TEXT\");\n    assert_eq!(multiplier.notnull, 1);\n    assert_eq!(normalize_default(&multiplier.default).as_deref(), Some(\"1\"));\n\n    let pricing_source = get_column_info(&conn, \"proxy_config\", \"pricing_model_source\");\n    assert_eq!(pricing_source.r#type, \"TEXT\");\n    assert_eq!(pricing_source.notnull, 1);\n    assert_eq!(\n        normalize_default(&pricing_source.default).as_deref(),\n        Some(\"response\")\n    );\n\n    let request_model = get_column_info(&conn, \"proxy_request_logs\", \"request_model\");\n    assert_eq!(request_model.r#type, \"TEXT\");\n    assert_eq!(request_model.notnull, 0);\n\n    assert_eq!(\n        Database::get_user_version(&conn).expect(\"version after migration\"),\n        SCHEMA_VERSION\n    );\n}\n\n#[test]\nfn schema_create_tables_repairs_legacy_proxy_config_singleton_to_per_app() {\n    let conn = Connection::open_in_memory().expect(\"open memory db\");\n\n    // 模拟测试版 v2：user_version=2，但 proxy_config 仍是单例结构（无 app_type）\n    Database::set_user_version(&conn, 2).expect(\"set user_version\");\n    conn.execute_batch(\n        r#\"\n        CREATE TABLE proxy_config (\n            id INTEGER PRIMARY KEY,\n            enabled INTEGER NOT NULL DEFAULT 0,\n            listen_address TEXT NOT NULL DEFAULT '127.0.0.1',\n            listen_port INTEGER NOT NULL DEFAULT 5000,\n            max_retries INTEGER NOT NULL DEFAULT 3,\n            request_timeout INTEGER NOT NULL DEFAULT 300,\n            enable_logging INTEGER NOT NULL DEFAULT 1,\n            target_app TEXT NOT NULL DEFAULT 'claude',\n            created_at TEXT NOT NULL DEFAULT (datetime('now')),\n            updated_at TEXT NOT NULL DEFAULT (datetime('now'))\n        );\n        INSERT INTO proxy_config (id, enabled) VALUES (1, 1);\n        \"#,\n    )\n    .expect(\"seed legacy proxy_config\");\n\n    Database::create_tables_on_conn(&conn).expect(\"create tables should repair proxy_config\");\n\n    assert!(\n        Database::has_column(&conn, \"proxy_config\", \"app_type\").expect(\"check app_type\"),\n        \"proxy_config should be migrated to per-app structure\"\n    );\n\n    let count: i32 = conn\n        .query_row(\"SELECT COUNT(*) FROM proxy_config\", [], |r| r.get(0))\n        .expect(\"count rows\");\n    assert_eq!(count, 3, \"per-app proxy_config should have 3 rows\");\n\n    // 新结构下应能按 app_type 查询\n    let _: i32 = conn\n        .query_row(\n            \"SELECT COUNT(*) FROM proxy_config WHERE app_type = 'claude'\",\n            [],\n            |r| r.get(0),\n        )\n        .expect(\"query by app_type\");\n}\n\n#[test]\nfn migration_from_v3_8_schema_v1_to_current_schema_v3() {\n    let conn = Connection::open_in_memory().expect(\"open memory db\");\n    conn.execute(\"PRAGMA foreign_keys = ON;\", [])\n        .expect(\"enable foreign keys\");\n\n    // 模拟 v3.8.* 用户的数据库（schema v1）\n    conn.execute_batch(V3_8_SCHEMA_V1_SQL)\n        .expect(\"seed v3.8 schema v1\");\n    Database::set_user_version(&conn, 1).expect(\"set user_version=1\");\n\n    // 插入一条旧版 Provider + Skill（用于验证迁移不会破坏既有数据）\n    conn.execute(\n        \"INSERT INTO providers (\n            id, app_type, name, settings_config, website_url, category,\n            created_at, sort_index, notes, icon, icon_color, meta, is_current\n        ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)\",\n        params![\n            \"p1\",\n            \"claude\",\n            \"Test Provider\",\n            serde_json::to_string(&json!({ \"anthropicApiKey\": \"sk-test\" })).unwrap(),\n            Option::<String>::None,\n            Option::<String>::None,\n            Option::<i64>::None,\n            Option::<usize>::None,\n            Option::<String>::None,\n            Option::<String>::None,\n            Option::<String>::None,\n            \"{}\",\n            1,\n        ],\n    )\n    .expect(\"seed provider\");\n\n    conn.execute(\n        \"INSERT INTO skills (key, installed, installed_at) VALUES (?1, ?2, ?3)\",\n        params![\"claude:demo-skill\", 1, 1700000000i64],\n    )\n    .expect(\"seed legacy skill\");\n\n    // 按应用启动流程：先 create_tables（补齐新增表），再 apply_schema_migrations（按 user_version 迁移）\n    Database::create_tables_on_conn(&conn).expect(\"create tables\");\n    Database::apply_schema_migrations_on_conn(&conn).expect(\"apply migrations\");\n\n    assert_eq!(\n        Database::get_user_version(&conn).expect(\"user_version after migration\"),\n        SCHEMA_VERSION\n    );\n\n    // v1 -> v2：providers 新增字段必须补齐\n    for column in [\n        \"cost_multiplier\",\n        \"limit_daily_usd\",\n        \"limit_monthly_usd\",\n        \"provider_type\",\n        \"in_failover_queue\",\n    ] {\n        assert!(\n            Database::has_column(&conn, \"providers\", column).expect(\"check column\"),\n            \"providers.{column} should exist after migration\"\n        );\n    }\n\n    // 旧 provider 不应丢失，且新增字段应有默认值\n    let provider_count: i64 = conn\n        .query_row(\n            \"SELECT COUNT(*) FROM providers WHERE id = 'p1' AND app_type = 'claude'\",\n            [],\n            |r| r.get(0),\n        )\n        .expect(\"count providers\");\n    assert_eq!(provider_count, 1);\n\n    let cost_multiplier: String = conn\n        .query_row(\n            \"SELECT cost_multiplier FROM providers WHERE id = 'p1' AND app_type = 'claude'\",\n            [],\n            |r| r.get(0),\n        )\n        .expect(\"read cost_multiplier\");\n    assert_eq!(cost_multiplier, \"1.0\");\n\n    // v2 -> v3：skills 表重建为统一结构，并设置 pending 标记（后续由启动时扫描文件系统重建数据）\n    assert!(\n        Database::has_column(&conn, \"skills\", \"enabled_claude\").expect(\"check skills v3 column\"),\n        \"skills table should be migrated to v3 structure\"\n    );\n    let skills_count: i64 = conn\n        .query_row(\"SELECT COUNT(*) FROM skills\", [], |r| r.get(0))\n        .expect(\"count skills\");\n    assert_eq!(skills_count, 0, \"skills table should be rebuilt empty\");\n\n    let pending: Option<String> = conn\n        .query_row(\n            \"SELECT value FROM settings WHERE key = 'skills_ssot_migration_pending'\",\n            [],\n            |r| r.get(0),\n        )\n        .ok();\n    assert!(\n        matches!(pending.as_deref(), Some(\"true\") | Some(\"1\")),\n        \"skills_ssot_migration_pending should be set after v2->v3 migration\"\n    );\n    let snapshot: Option<String> = conn\n        .query_row(\n            \"SELECT value FROM settings WHERE key = 'skills_ssot_migration_snapshot'\",\n            [],\n            |r| r.get(0),\n        )\n        .ok();\n    let snapshot = snapshot.expect(\"skills migration snapshot should be recorded\");\n    let snapshot_rows: serde_json::Value =\n        serde_json::from_str(&snapshot).expect(\"parse skills migration snapshot\");\n    assert!(\n        snapshot_rows\n            .as_array()\n            .is_some_and(|rows| rows.iter().any(|row| {\n                row.get(\"directory\").and_then(|v| v.as_str()) == Some(\"demo-skill\")\n                    && row.get(\"app_type\").and_then(|v| v.as_str()) == Some(\"claude\")\n            })),\n        \"skills migration snapshot should preserve legacy app mapping\"\n    );\n\n    // v3.9+ 新增：proxy_config 三行 seed 必须存在（否则 UI 会查不到默认值）\n    let proxy_rows: i64 = conn\n        .query_row(\"SELECT COUNT(*) FROM proxy_config\", [], |r| r.get(0))\n        .expect(\"count proxy_config rows\");\n    assert_eq!(proxy_rows, 3);\n\n    // model_pricing 应具备默认数据（迁移时会 seed）\n    let pricing_rows: i64 = conn\n        .query_row(\"SELECT COUNT(*) FROM model_pricing\", [], |r| r.get(0))\n        .expect(\"count model_pricing rows\");\n    assert!(pricing_rows > 0, \"model_pricing should be seeded\");\n}\n\n#[test]\nfn schema_dry_run_does_not_write_to_disk() {\n    // Create minimal valid config for migration\n    let mut apps = HashMap::new();\n    apps.insert(\"claude\".to_string(), ProviderManager::default());\n\n    let config = MultiAppConfig {\n        version: 2,\n        apps,\n        mcp: Default::default(),\n        prompts: Default::default(),\n        skills: Default::default(),\n        common_config_snippets: Default::default(),\n        claude_common_config_snippet: None,\n    };\n\n    // Dry-run should succeed without any file I/O errors\n    let result = Database::migrate_from_json_dry_run(&config);\n    assert!(\n        result.is_ok(),\n        \"Dry-run should succeed with valid config: {result:?}\"\n    );\n}\n\n#[test]\nfn dry_run_validates_schema_compatibility() {\n    // Create config with actual provider data\n    let mut providers = IndexMap::new();\n    providers.insert(\n        \"test-provider\".to_string(),\n        Provider {\n            id: \"test-provider\".to_string(),\n            name: \"Test Provider\".to_string(),\n            settings_config: json!({\n                \"anthropicApiKey\": \"sk-test-123\",\n            }),\n            website_url: None,\n            category: None,\n            created_at: Some(1234567890),\n            sort_index: None,\n            notes: None,\n            meta: None,\n            icon: None,\n            icon_color: None,\n            in_failover_queue: false,\n        },\n    );\n\n    let manager = ProviderManager {\n        providers,\n        current: \"test-provider\".to_string(),\n    };\n\n    let mut apps = HashMap::new();\n    apps.insert(\"claude\".to_string(), manager);\n\n    let config = MultiAppConfig {\n        version: 2,\n        apps,\n        mcp: Default::default(),\n        prompts: Default::default(),\n        skills: Default::default(),\n        common_config_snippets: Default::default(),\n        claude_common_config_snippet: None,\n    };\n\n    // Dry-run should validate the full migration path\n    let result = Database::migrate_from_json_dry_run(&config);\n    assert!(\n        result.is_ok(),\n        \"Dry-run should succeed with provider data: {result:?}\"\n    );\n}\n\n#[test]\nfn schema_model_pricing_is_seeded_on_init() {\n    let db = Database::memory().expect(\"create memory db\");\n\n    let conn = db.conn.lock().expect(\"lock conn\");\n\n    let count: i64 = conn\n        .query_row(\"SELECT COUNT(*) FROM model_pricing\", [], |row| row.get(0))\n        .expect(\"count pricing\");\n\n    assert!(\n        count > 0,\n        \"模型定价数据应该在初始化时自动填充，实际数量: {}\",\n        count\n    );\n\n    // 验证包含 Claude 模型\n    let claude_count: i64 = conn\n        .query_row(\n            \"SELECT COUNT(*) FROM model_pricing WHERE model_id LIKE 'claude-%'\",\n            [],\n            |row| row.get(0),\n        )\n        .expect(\"check claude\");\n    assert!(\n        claude_count > 0,\n        \"应该包含 Claude 模型定价，实际数量: {}\",\n        claude_count\n    );\n\n    // 验证包含 GPT 模型\n    let gpt_count: i64 = conn\n        .query_row(\n            \"SELECT COUNT(*) FROM model_pricing WHERE model_id LIKE 'gpt-%'\",\n            [],\n            |row| row.get(0),\n        )\n        .expect(\"check gpt\");\n    assert!(\n        gpt_count > 0,\n        \"应该包含 GPT 模型定价，实际数量: {}\",\n        gpt_count\n    );\n\n    // 验证包含 Gemini 模型\n    let gemini_count: i64 = conn\n        .query_row(\n            \"SELECT COUNT(*) FROM model_pricing WHERE model_id LIKE 'gemini-%'\",\n            [],\n            |row| row.get(0),\n        )\n        .expect(\"check gemini\");\n    assert!(\n        gemini_count > 0,\n        \"应该包含 Gemini 模型定价，实际数量: {}\",\n        gemini_count\n    );\n}\n\n#[test]\nfn ensure_incremental_auto_vacuum_rebuilds_existing_file_db() {\n    let temp = NamedTempFile::new().expect(\"create temp db file\");\n    let path = temp.path().to_path_buf();\n\n    let conn = Connection::open(&path).expect(\"open temp db\");\n    conn.execute(\"PRAGMA auto_vacuum = NONE;\", [])\n        .expect(\"set none auto_vacuum\");\n    Database::create_tables_on_conn(&conn).expect(\"create tables\");\n\n    assert_eq!(\n        Database::get_auto_vacuum_mode(&conn).expect(\"auto_vacuum before rebuild\"),\n        0,\n        \"existing file db should start with NONE auto_vacuum\"\n    );\n\n    let rebuilt =\n        Database::ensure_incremental_auto_vacuum_on_conn(&conn).expect(\"enable incremental mode\");\n    assert!(rebuilt, \"existing db should require rebuild via VACUUM\");\n    drop(conn);\n\n    let reopened = Connection::open(&path).expect(\"reopen temp db\");\n    assert_eq!(\n        Database::get_auto_vacuum_mode(&reopened).expect(\"auto_vacuum after rebuild\"),\n        2,\n        \"file db should persist INCREMENTAL auto_vacuum after VACUUM rebuild\"\n    );\n}\n"
  },
  {
    "path": "src-tauri/src/deeplink/mcp.rs",
    "content": "//! MCP server import from deep link\n//!\n//! Handles batch import of MCP server configurations via ccswitch:// URLs.\n\nuse super::utils::decode_base64_param;\nuse super::DeepLinkImportRequest;\nuse crate::app_config::{McpApps, McpServer};\nuse crate::error::AppError;\nuse crate::services::McpService;\nuse crate::store::AppState;\nuse serde::{Deserialize, Serialize};\nuse serde_json::Value;\n\n/// MCP import result\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct McpImportResult {\n    /// Number of successfully imported MCP servers\n    pub imported_count: usize,\n    /// IDs of successfully imported MCP servers\n    pub imported_ids: Vec<String>,\n    /// Failed imports with error messages\n    pub failed: Vec<McpImportError>,\n}\n\n/// MCP import error\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct McpImportError {\n    /// MCP server ID\n    pub id: String,\n    /// Error message\n    pub error: String,\n}\n\n/// Import MCP servers from deep link request\n///\n/// This function handles batch import of MCP servers from standard MCP JSON format.\n/// If a server already exists, only the apps flags are merged (existing config preserved).\npub fn import_mcp_from_deeplink(\n    state: &AppState,\n    request: DeepLinkImportRequest,\n) -> Result<McpImportResult, AppError> {\n    // Verify this is an MCP request\n    if request.resource != \"mcp\" {\n        return Err(AppError::InvalidInput(format!(\n            \"Expected mcp resource, got '{}'\",\n            request.resource\n        )));\n    }\n\n    // Extract and validate apps parameter\n    let apps_str = request\n        .apps\n        .as_ref()\n        .ok_or_else(|| AppError::InvalidInput(\"Missing 'apps' parameter for MCP\".to_string()))?;\n\n    // Parse apps into McpApps struct\n    let target_apps = parse_mcp_apps(apps_str)?;\n\n    // Extract config\n    let config_b64 = request\n        .config\n        .as_ref()\n        .ok_or_else(|| AppError::InvalidInput(\"Missing 'config' parameter for MCP\".to_string()))?;\n\n    // Decode Base64 config\n    let decoded = decode_base64_param(\"config\", config_b64)?;\n\n    let config_str = String::from_utf8(decoded)\n        .map_err(|e| AppError::InvalidInput(format!(\"Invalid UTF-8 in config: {e}\")))?;\n\n    // Parse JSON\n    let config_json: Value = serde_json::from_str(&config_str)\n        .map_err(|e| AppError::InvalidInput(format!(\"Invalid JSON in MCP config: {e}\")))?;\n\n    // Extract mcpServers object\n    let mcp_servers = config_json\n        .get(\"mcpServers\")\n        .and_then(|v| v.as_object())\n        .ok_or_else(|| {\n            AppError::InvalidInput(\"MCP config must contain 'mcpServers' object\".to_string())\n        })?;\n\n    if mcp_servers.is_empty() {\n        return Err(AppError::InvalidInput(\n            \"No MCP servers found in config\".to_string(),\n        ));\n    }\n\n    // Get existing servers to check for duplicates\n    let existing_servers = state.db.get_all_mcp_servers()?;\n\n    // Import each MCP server\n    let mut imported_ids = Vec::new();\n    let mut failed = Vec::new();\n\n    for (id, server_spec) in mcp_servers.iter() {\n        // Check if server already exists\n        let server = if let Some(existing) = existing_servers.get(id) {\n            // Server exists - merge apps only, keep other fields unchanged\n            log::info!(\"MCP server '{id}' already exists, merging apps only\");\n\n            let mut merged_apps = existing.apps.clone();\n            // Merge new apps into existing apps\n            if target_apps.claude {\n                merged_apps.claude = true;\n            }\n            if target_apps.codex {\n                merged_apps.codex = true;\n            }\n            if target_apps.gemini {\n                merged_apps.gemini = true;\n            }\n\n            McpServer {\n                id: existing.id.clone(),\n                name: existing.name.clone(),\n                server: existing.server.clone(), // Keep existing server config\n                apps: merged_apps,               // Merged apps\n                description: existing.description.clone(),\n                homepage: existing.homepage.clone(),\n                docs: existing.docs.clone(),\n                tags: existing.tags.clone(),\n            }\n        } else {\n            // New server - create with provided config\n            log::info!(\"Creating new MCP server: {id}\");\n            McpServer {\n                id: id.clone(),\n                name: id.clone(),\n                server: server_spec.clone(),\n                apps: target_apps.clone(),\n                description: None,\n                homepage: None,\n                docs: None,\n                tags: vec![\"imported\".to_string()],\n            }\n        };\n\n        match McpService::upsert_server(state, server) {\n            Ok(_) => {\n                imported_ids.push(id.clone());\n                log::info!(\"Successfully imported/updated MCP server: {id}\");\n            }\n            Err(e) => {\n                failed.push(McpImportError {\n                    id: id.clone(),\n                    error: format!(\"{e}\"),\n                });\n                log::warn!(\"Failed to import MCP server '{id}': {e}\");\n            }\n        }\n    }\n\n    Ok(McpImportResult {\n        imported_count: imported_ids.len(),\n        imported_ids,\n        failed,\n    })\n}\n\n/// Parse apps string into McpApps struct\npub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {\n    let mut apps = McpApps {\n        claude: false,\n        codex: false,\n        gemini: false,\n        opencode: false,\n    };\n\n    for app in apps_str.split(',') {\n        match app.trim() {\n            \"claude\" => apps.claude = true,\n            \"codex\" => apps.codex = true,\n            \"gemini\" => apps.gemini = true,\n            \"opencode\" => apps.opencode = true,\n            \"openclaw\" => {\n                // OpenClaw doesn't support MCP, ignore silently\n                log::debug!(\"OpenClaw doesn't support MCP, ignoring in apps parameter\");\n            }\n            other => {\n                return Err(AppError::InvalidInput(format!(\n                    \"Invalid app in 'apps': {other}\"\n                )))\n            }\n        }\n    }\n\n    if apps.is_empty() {\n        return Err(AppError::InvalidInput(\n            \"At least one app must be specified in 'apps'\".to_string(),\n        ));\n    }\n\n    Ok(apps)\n}\n"
  },
  {
    "path": "src-tauri/src/deeplink/mod.rs",
    "content": "//! Deep link import functionality for CC Switch\n//!\n//! This module implements the ccswitch:// protocol for importing configurations\n//! via deep links. Supports importing:\n//! - Provider configurations (Claude/Codex/Gemini)\n//! - MCP server configurations\n//! - Prompts\n//! - Skills\n//!\n\nmod mcp;\nmod parser;\nmod prompt;\nmod provider;\nmod skill;\nmod utils;\n\n#[cfg(test)]\nmod tests;\n\nuse serde::{Deserialize, Serialize};\n\n// Re-export public API\npub use mcp::import_mcp_from_deeplink;\npub use parser::parse_deeplink_url;\npub use prompt::import_prompt_from_deeplink;\npub use provider::{import_provider_from_deeplink, parse_and_merge_config};\npub use skill::import_skill_from_deeplink;\n\n/// Deep link import request model\n///\n/// Represents a parsed ccswitch:// URL ready for processing.\n/// This struct contains all possible fields for all resource types.\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct DeepLinkImportRequest {\n    /// Protocol version (e.g., \"v1\")\n    pub version: String,\n    /// Resource type to import: \"provider\" | \"prompt\" | \"mcp\" | \"skill\"\n    pub resource: String,\n\n    // ============ Common fields ============\n    /// Target application (claude/codex/gemini) - for provider, prompt, skill\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub app: Option<String>,\n    /// Resource name\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub name: Option<String>,\n    /// Whether to enable after import (default: false)\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub enabled: Option<bool>,\n\n    // ============ Provider-specific fields ============\n    /// Provider homepage URL\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub homepage: Option<String>,\n    /// API endpoint/base URL (supports comma-separated multiple URLs)\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub endpoint: Option<String>,\n    /// API key\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub api_key: Option<String>,\n    /// Optional provider icon name (maps to built-in SVG)\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub icon: Option<String>,\n    /// Optional model name\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub model: Option<String>,\n    /// Optional notes/description\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub notes: Option<String>,\n    /// Optional Haiku model (Claude only, v3.7.1+)\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub haiku_model: Option<String>,\n    /// Optional Sonnet model (Claude only, v3.7.1+)\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub sonnet_model: Option<String>,\n    /// Optional Opus model (Claude only, v3.7.1+)\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub opus_model: Option<String>,\n\n    // ============ Prompt-specific fields ============\n    /// Base64 encoded Markdown content\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub content: Option<String>,\n    /// Prompt description\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub description: Option<String>,\n\n    // ============ MCP-specific fields ============\n    /// Target applications for MCP (comma-separated: \"claude,codex,gemini\")\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub apps: Option<String>,\n\n    // ============ Skill-specific fields ============\n    /// GitHub repository (format: \"owner/name\")\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub repo: Option<String>,\n    /// Skill directory name\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub directory: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub branch: Option<String>,\n\n    // ============ Config file fields (v3.8+) ============\n    /// Base64 encoded config content\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub config: Option<String>,\n    /// Config format (json/toml)\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub config_format: Option<String>,\n    /// Remote config URL\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub config_url: Option<String>,\n\n    // ============ Usage script fields (v3.9+) ============\n    /// Whether to enable usage query (default: true if usage_script is provided)\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub usage_enabled: Option<bool>,\n    /// Base64 encoded usage query script code\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub usage_script: Option<String>,\n    /// Usage query API key (if different from provider API key)\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub usage_api_key: Option<String>,\n    /// Usage query base URL (if different from provider endpoint)\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub usage_base_url: Option<String>,\n    /// Usage query access token (for NewAPI template)\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub usage_access_token: Option<String>,\n    /// Usage query user ID (for NewAPI template)\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub usage_user_id: Option<String>,\n    /// Auto query interval in minutes (0 to disable)\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub usage_auto_interval: Option<u64>,\n}\n"
  },
  {
    "path": "src-tauri/src/deeplink/parser.rs",
    "content": "//! Deep link URL parser\n//!\n//! Parses ccswitch:// URLs into DeepLinkImportRequest structures.\n\nuse super::utils::validate_url;\nuse super::DeepLinkImportRequest;\nuse crate::error::AppError;\nuse std::collections::HashMap;\nuse url::Url;\n\n/// Parse a ccswitch:// URL into a DeepLinkImportRequest\n///\n/// Expected format:\n/// ccswitch://v1/import?resource={type}&...\npub fn parse_deeplink_url(url_str: &str) -> Result<DeepLinkImportRequest, AppError> {\n    // Parse URL\n    let url = Url::parse(url_str)\n        .map_err(|e| AppError::InvalidInput(format!(\"Invalid deep link URL: {e}\")))?;\n\n    // Validate scheme\n    let scheme = url.scheme();\n    if scheme != \"ccswitch\" {\n        return Err(AppError::InvalidInput(format!(\n            \"Invalid scheme: expected 'ccswitch', got '{scheme}'\"\n        )));\n    }\n\n    // Extract version from host\n    let version = url\n        .host_str()\n        .ok_or_else(|| AppError::InvalidInput(\"Missing version in URL host\".to_string()))?\n        .to_string();\n\n    // Validate version\n    if version != \"v1\" {\n        return Err(AppError::InvalidInput(format!(\n            \"Unsupported protocol version: {version}\"\n        )));\n    }\n\n    // Extract path (should be \"/import\")\n    let path = url.path();\n    if path != \"/import\" {\n        return Err(AppError::InvalidInput(format!(\n            \"Invalid path: expected '/import', got '{path}'\"\n        )));\n    }\n\n    // Parse query parameters\n    let params: HashMap<String, String> = url.query_pairs().into_owned().collect();\n\n    // Extract and validate resource type\n    let resource = params\n        .get(\"resource\")\n        .ok_or_else(|| AppError::InvalidInput(\"Missing 'resource' parameter\".to_string()))?\n        .clone();\n\n    // Dispatch to appropriate parser based on resource type\n    match resource.as_str() {\n        \"provider\" => parse_provider_deeplink(&params, version, resource),\n        \"prompt\" => parse_prompt_deeplink(&params, version, resource),\n        \"mcp\" => parse_mcp_deeplink(&params, version, resource),\n        \"skill\" => parse_skill_deeplink(&params, version, resource),\n        _ => Err(AppError::InvalidInput(format!(\n            \"Unsupported resource type: {resource}\"\n        ))),\n    }\n}\n\n/// Parse provider deep link parameters\nfn parse_provider_deeplink(\n    params: &HashMap<String, String>,\n    version: String,\n    resource: String,\n) -> Result<DeepLinkImportRequest, AppError> {\n    let app = params\n        .get(\"app\")\n        .ok_or_else(|| AppError::InvalidInput(\"Missing 'app' parameter\".to_string()))?\n        .clone();\n\n    // Validate app type\n    if !matches!(\n        app.as_str(),\n        \"claude\" | \"codex\" | \"gemini\" | \"opencode\" | \"openclaw\"\n    ) {\n        return Err(AppError::InvalidInput(format!(\n            \"Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', or 'openclaw', got '{app}'\"\n        )));\n    }\n\n    let name = params\n        .get(\"name\")\n        .ok_or_else(|| AppError::InvalidInput(\"Missing 'name' parameter\".to_string()))?\n        .clone();\n\n    // Make these optional for config file auto-fill (v3.8+)\n    let homepage = params.get(\"homepage\").cloned();\n    let endpoint = params.get(\"endpoint\").cloned();\n    let api_key = params.get(\"apiKey\").cloned();\n\n    // Validate URLs only if provided\n    if let Some(ref hp) = homepage {\n        if !hp.is_empty() {\n            validate_url(hp, \"homepage\")?;\n        }\n    }\n    // Validate each endpoint (supports comma-separated multiple URLs)\n    if let Some(ref ep) = endpoint {\n        for (i, url) in ep.split(',').enumerate() {\n            let trimmed = url.trim();\n            if !trimmed.is_empty() {\n                validate_url(trimmed, &format!(\"endpoint[{i}]\"))?;\n            }\n        }\n    }\n\n    // Extract optional fields\n    let model = params.get(\"model\").cloned();\n    let notes = params.get(\"notes\").cloned();\n    let haiku_model = params.get(\"haikuModel\").cloned();\n    let sonnet_model = params.get(\"sonnetModel\").cloned();\n    let opus_model = params.get(\"opusModel\").cloned();\n    let icon = params\n        .get(\"icon\")\n        .map(|v| v.trim().to_lowercase())\n        .filter(|v| !v.is_empty());\n    let config = params.get(\"config\").cloned();\n    let config_format = params.get(\"configFormat\").cloned();\n    let config_url = params.get(\"configUrl\").cloned();\n    let enabled = params.get(\"enabled\").and_then(|v| v.parse::<bool>().ok());\n\n    // Extract usage script fields (v3.9+)\n    let usage_enabled = params\n        .get(\"usageEnabled\")\n        .and_then(|v| v.parse::<bool>().ok());\n    let usage_script = params.get(\"usageScript\").cloned();\n    let usage_api_key = params.get(\"usageApiKey\").cloned();\n    let usage_base_url = params.get(\"usageBaseUrl\").cloned();\n    let usage_access_token = params.get(\"usageAccessToken\").cloned();\n    let usage_user_id = params.get(\"usageUserId\").cloned();\n    let usage_auto_interval = params\n        .get(\"usageAutoInterval\")\n        .and_then(|v| v.parse::<u64>().ok());\n\n    Ok(DeepLinkImportRequest {\n        version,\n        resource,\n        app: Some(app),\n        name: Some(name),\n        enabled,\n        homepage,\n        endpoint,\n        api_key,\n        icon,\n        model,\n        notes,\n        haiku_model,\n        sonnet_model,\n        opus_model,\n        content: None,\n        description: None,\n        apps: None,\n        repo: None,\n        directory: None,\n        branch: None,\n        config,\n        config_format,\n        config_url,\n        usage_enabled,\n        usage_script,\n        usage_api_key,\n        usage_base_url,\n        usage_access_token,\n        usage_user_id,\n        usage_auto_interval,\n    })\n}\n\n/// Parse prompt deep link parameters\nfn parse_prompt_deeplink(\n    params: &HashMap<String, String>,\n    version: String,\n    resource: String,\n) -> Result<DeepLinkImportRequest, AppError> {\n    let app = params\n        .get(\"app\")\n        .ok_or_else(|| AppError::InvalidInput(\"Missing 'app' parameter for prompt\".to_string()))?\n        .clone();\n\n    // Validate app type\n    if !matches!(\n        app.as_str(),\n        \"claude\" | \"codex\" | \"gemini\" | \"opencode\" | \"openclaw\"\n    ) {\n        return Err(AppError::InvalidInput(format!(\n            \"Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', or 'openclaw', got '{app}'\"\n        )));\n    }\n\n    let name = params\n        .get(\"name\")\n        .ok_or_else(|| AppError::InvalidInput(\"Missing 'name' parameter for prompt\".to_string()))?\n        .clone();\n\n    let content = params\n        .get(\"content\")\n        .ok_or_else(|| {\n            AppError::InvalidInput(\"Missing 'content' parameter for prompt\".to_string())\n        })?\n        .clone();\n\n    let description = params.get(\"description\").cloned();\n    let enabled = params.get(\"enabled\").and_then(|v| v.parse::<bool>().ok());\n\n    Ok(DeepLinkImportRequest {\n        version,\n        resource,\n        app: Some(app),\n        name: Some(name),\n        enabled,\n        content: Some(content),\n        description,\n        icon: None,\n        homepage: None,\n        endpoint: None,\n        api_key: None,\n        model: None,\n        notes: None,\n        haiku_model: None,\n        sonnet_model: None,\n        opus_model: None,\n        apps: None,\n        repo: None,\n        directory: None,\n        branch: None,\n        config: None,\n        config_format: None,\n        config_url: None,\n        usage_enabled: None,\n        usage_script: None,\n        usage_api_key: None,\n        usage_base_url: None,\n        usage_access_token: None,\n        usage_user_id: None,\n        usage_auto_interval: None,\n    })\n}\n\n/// Parse MCP deep link parameters\nfn parse_mcp_deeplink(\n    params: &HashMap<String, String>,\n    version: String,\n    resource: String,\n) -> Result<DeepLinkImportRequest, AppError> {\n    let apps = params\n        .get(\"apps\")\n        .ok_or_else(|| AppError::InvalidInput(\"Missing 'apps' parameter for MCP\".to_string()))?\n        .clone();\n\n    // Validate apps format\n    for app in apps.split(',') {\n        let trimmed = app.trim();\n        if !matches!(\n            trimmed,\n            \"claude\" | \"codex\" | \"gemini\" | \"opencode\" | \"openclaw\"\n        ) {\n            return Err(AppError::InvalidInput(format!(\n                \"Invalid app in 'apps': must be 'claude', 'codex', 'gemini', 'opencode', or 'openclaw', got '{trimmed}'\"\n            )));\n        }\n    }\n\n    let config = params\n        .get(\"config\")\n        .ok_or_else(|| AppError::InvalidInput(\"Missing 'config' parameter for MCP\".to_string()))?\n        .clone();\n\n    let enabled = params.get(\"enabled\").and_then(|v| v.parse::<bool>().ok());\n\n    Ok(DeepLinkImportRequest {\n        version,\n        resource,\n        apps: Some(apps),\n        enabled,\n        config: Some(config),\n        config_format: Some(\"json\".to_string()), // MCP config is always JSON\n        app: None,\n        name: None,\n        icon: None,\n        homepage: None,\n        endpoint: None,\n        api_key: None,\n        model: None,\n        notes: None,\n        haiku_model: None,\n        sonnet_model: None,\n        opus_model: None,\n        content: None,\n        description: None,\n        repo: None,\n        directory: None,\n        branch: None,\n        config_url: None,\n        usage_enabled: None,\n        usage_script: None,\n        usage_api_key: None,\n        usage_base_url: None,\n        usage_access_token: None,\n        usage_user_id: None,\n        usage_auto_interval: None,\n    })\n}\n\n/// Parse skill deep link parameters\nfn parse_skill_deeplink(\n    params: &HashMap<String, String>,\n    version: String,\n    resource: String,\n) -> Result<DeepLinkImportRequest, AppError> {\n    let repo = params\n        .get(\"repo\")\n        .ok_or_else(|| AppError::InvalidInput(\"Missing 'repo' parameter for skill\".to_string()))?\n        .clone();\n\n    // Validate repo format (should be \"owner/name\")\n    if !repo.contains('/') || repo.split('/').count() != 2 {\n        return Err(AppError::InvalidInput(format!(\n            \"Invalid repo format: expected 'owner/name', got '{repo}'\"\n        )));\n    }\n\n    let directory = params.get(\"directory\").cloned();\n    let branch = params.get(\"branch\").cloned();\n\n    Ok(DeepLinkImportRequest {\n        version,\n        resource,\n        repo: Some(repo),\n        directory,\n        branch,\n        icon: None,\n        app: Some(\"claude\".to_string()), // Skills are Claude-only\n        name: None,\n        enabled: None,\n        homepage: None,\n        endpoint: None,\n        api_key: None,\n        model: None,\n        notes: None,\n        haiku_model: None,\n        sonnet_model: None,\n        opus_model: None,\n        content: None,\n        description: None,\n        apps: None,\n        config: None,\n        config_format: None,\n        config_url: None,\n        usage_enabled: None,\n        usage_script: None,\n        usage_api_key: None,\n        usage_base_url: None,\n        usage_access_token: None,\n        usage_user_id: None,\n        usage_auto_interval: None,\n    })\n}\n"
  },
  {
    "path": "src-tauri/src/deeplink/prompt.rs",
    "content": "//! Prompt import from deep link\n//!\n//! Handles importing prompt configurations via ccswitch:// URLs.\n\nuse super::utils::decode_base64_param;\nuse super::DeepLinkImportRequest;\nuse crate::error::AppError;\nuse crate::prompt::Prompt;\nuse crate::services::PromptService;\nuse crate::store::AppState;\nuse crate::AppType;\nuse std::str::FromStr;\n\n/// Import a prompt from deep link request\npub fn import_prompt_from_deeplink(\n    state: &AppState,\n    request: DeepLinkImportRequest,\n) -> Result<String, AppError> {\n    // Verify this is a prompt request\n    if request.resource != \"prompt\" {\n        return Err(AppError::InvalidInput(format!(\n            \"Expected prompt resource, got '{}'\",\n            request.resource\n        )));\n    }\n\n    // Extract required fields\n    let app_str = request\n        .app\n        .as_ref()\n        .ok_or_else(|| AppError::InvalidInput(\"Missing 'app' field for prompt\".to_string()))?;\n\n    let name = request\n        .name\n        .ok_or_else(|| AppError::InvalidInput(\"Missing 'name' field for prompt\".to_string()))?;\n\n    // Parse app type\n    let app_type = AppType::from_str(app_str)\n        .map_err(|_| AppError::InvalidInput(format!(\"Invalid app type: {app_str}\")))?;\n\n    // Decode content\n    let content_b64 = request\n        .content\n        .as_ref()\n        .ok_or_else(|| AppError::InvalidInput(\"Missing 'content' field for prompt\".to_string()))?;\n\n    let content = decode_base64_param(\"content\", content_b64)?;\n    let content = String::from_utf8(content)\n        .map_err(|e| AppError::InvalidInput(format!(\"Invalid UTF-8 in content: {e}\")))?;\n\n    // Generate ID\n    let timestamp = chrono::Utc::now().timestamp_millis();\n    let sanitized_name = name\n        .chars()\n        .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')\n        .collect::<String>()\n        .to_lowercase();\n    let id = format!(\"{sanitized_name}-{timestamp}\");\n\n    // Check if we should enable this prompt\n    let should_enable = request.enabled.unwrap_or(false);\n\n    // Create Prompt (initially disabled)\n    let prompt = Prompt {\n        id: id.clone(),\n        name: name.clone(),\n        content,\n        description: request.description,\n        enabled: false, // Always start as disabled, will be enabled later if needed\n        created_at: Some(timestamp),\n        updated_at: Some(timestamp),\n    };\n\n    // Save using PromptService\n    PromptService::upsert_prompt(state, app_type.clone(), &id, prompt)?;\n\n    // If enabled flag is set, enable this prompt (which will disable others)\n    if should_enable {\n        PromptService::enable_prompt(state, app_type, &id)?;\n        log::info!(\"Successfully imported and enabled prompt '{name}' for {app_str}\");\n    } else {\n        log::info!(\"Successfully imported prompt '{name}' for {app_str} (disabled)\");\n    }\n\n    Ok(id)\n}\n"
  },
  {
    "path": "src-tauri/src/deeplink/provider.rs",
    "content": "//! Provider import from deep link\n//!\n//! Handles importing provider configurations via ccswitch:// URLs.\n\nuse super::utils::{decode_base64_param, infer_homepage_from_endpoint};\nuse super::DeepLinkImportRequest;\nuse crate::error::AppError;\nuse crate::provider::{Provider, ProviderMeta, UsageScript};\nuse crate::services::ProviderService;\nuse crate::store::AppState;\nuse crate::AppType;\nuse serde_json::json;\nuse std::str::FromStr;\n\n/// Import a provider from a deep link request\n///\n/// This function:\n/// 1. Validates the request\n/// 2. Merges config file if provided (v3.8+)\n/// 3. Converts it to a Provider structure\n/// 4. Delegates to ProviderService for actual import\n/// 5. Optionally sets as current provider if enabled=true\npub fn import_provider_from_deeplink(\n    state: &AppState,\n    request: DeepLinkImportRequest,\n) -> Result<String, AppError> {\n    // Verify this is a provider request\n    if request.resource != \"provider\" {\n        return Err(AppError::InvalidInput(format!(\n            \"Expected provider resource, got '{}'\",\n            request.resource\n        )));\n    }\n\n    // Step 1: Merge config file if provided (v3.8+)\n    let mut merged_request = parse_and_merge_config(&request)?;\n\n    // Extract required fields (now as Option)\n    let app_str = merged_request\n        .app\n        .clone()\n        .ok_or_else(|| AppError::InvalidInput(\"Missing 'app' field for provider\".to_string()))?;\n\n    let api_key = merged_request.api_key.as_ref().ok_or_else(|| {\n        AppError::InvalidInput(\"API key is required (either in URL or config file)\".to_string())\n    })?;\n\n    if api_key.is_empty() {\n        return Err(AppError::InvalidInput(\n            \"API key cannot be empty\".to_string(),\n        ));\n    }\n\n    // Get endpoint: supports comma-separated multiple URLs (first is primary)\n    let endpoint_str = merged_request.endpoint.as_ref().ok_or_else(|| {\n        AppError::InvalidInput(\"Endpoint is required (either in URL or config file)\".to_string())\n    })?;\n\n    // Parse endpoints: split by comma, first is primary\n    let all_endpoints: Vec<String> = endpoint_str\n        .split(',')\n        .map(|e| e.trim().to_string())\n        .filter(|e| !e.is_empty())\n        .collect();\n\n    let primary_endpoint = all_endpoints\n        .first()\n        .ok_or_else(|| AppError::InvalidInput(\"Endpoint cannot be empty\".to_string()))?;\n\n    // Auto-infer homepage from endpoint if not provided\n    if merged_request\n        .homepage\n        .as_ref()\n        .is_none_or(|s| s.is_empty())\n    {\n        merged_request.homepage = infer_homepage_from_endpoint(primary_endpoint);\n    }\n\n    let homepage = merged_request.homepage.as_ref().ok_or_else(|| {\n        AppError::InvalidInput(\"Homepage is required (either in URL or config file)\".to_string())\n    })?;\n\n    if homepage.is_empty() {\n        return Err(AppError::InvalidInput(\n            \"Homepage cannot be empty\".to_string(),\n        ));\n    }\n\n    let name = merged_request\n        .name\n        .clone()\n        .ok_or_else(|| AppError::InvalidInput(\"Missing 'name' field for provider\".to_string()))?;\n\n    // Parse app type\n    let app_type = AppType::from_str(&app_str)\n        .map_err(|_| AppError::InvalidInput(format!(\"Invalid app type: {app_str}\")))?;\n\n    // Build provider configuration based on app type\n    let mut provider = build_provider_from_request(&app_type, &merged_request)?;\n\n    // Generate a unique ID for the provider using timestamp + sanitized name\n    let timestamp = chrono::Utc::now().timestamp_millis();\n    let sanitized_name = name\n        .chars()\n        .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')\n        .collect::<String>()\n        .to_lowercase();\n    provider.id = format!(\"{sanitized_name}-{timestamp}\");\n\n    let provider_id = provider.id.clone();\n\n    // Use ProviderService to add the provider\n    ProviderService::add(state, app_type.clone(), provider)?;\n\n    // Add extra endpoints as custom endpoints (skip first one as it's the primary)\n    for ep in all_endpoints.iter().skip(1) {\n        let normalized = ep.trim().trim_end_matches('/').to_string();\n        if !normalized.is_empty() {\n            if let Err(e) = ProviderService::add_custom_endpoint(\n                state,\n                app_type.clone(),\n                &provider_id,\n                normalized.clone(),\n            ) {\n                log::warn!(\"Failed to add custom endpoint '{normalized}': {e}\");\n            }\n        }\n    }\n\n    // If enabled=true, set as current provider\n    if merged_request.enabled.unwrap_or(false) {\n        ProviderService::switch(state, app_type.clone(), &provider_id)?;\n        log::info!(\"Provider '{provider_id}' set as current for {app_type:?}\");\n    }\n\n    Ok(provider_id)\n}\n\n/// Build a Provider structure from a deep link request\npub(crate) fn build_provider_from_request(\n    app_type: &AppType,\n    request: &DeepLinkImportRequest,\n) -> Result<Provider, AppError> {\n    let settings_config = match app_type {\n        AppType::Claude => build_claude_settings(request),\n        AppType::Codex => build_codex_settings(request),\n        AppType::Gemini => build_gemini_settings(request),\n        AppType::OpenCode => build_opencode_settings(request),\n        AppType::OpenClaw => build_openclaw_settings(request),\n    };\n\n    // Build usage script configuration if provided\n    let meta = build_provider_meta(request)?;\n\n    let provider = Provider {\n        id: String::new(), // Will be generated by caller\n        name: request.name.clone().unwrap_or_default(),\n        settings_config,\n        website_url: request.homepage.clone(),\n        category: None,\n        created_at: None,\n        sort_index: None,\n        notes: request.notes.clone(),\n        meta,\n        icon: request.icon.clone(),\n        icon_color: None,\n        in_failover_queue: false,\n    };\n\n    Ok(provider)\n}\n\n/// Get primary endpoint from request (first one if comma-separated)\nfn get_primary_endpoint(request: &DeepLinkImportRequest) -> String {\n    request\n        .endpoint\n        .as_ref()\n        .and_then(|ep| ep.split(',').next())\n        .map(|s| s.trim().to_string())\n        .unwrap_or_default()\n}\n\n/// Build provider meta with usage script configuration\nfn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<ProviderMeta>, AppError> {\n    // Check if any usage script fields are provided\n    if request.usage_script.is_none()\n        && request.usage_enabled.is_none()\n        && request.usage_api_key.is_none()\n        && request.usage_base_url.is_none()\n        && request.usage_access_token.is_none()\n        && request.usage_user_id.is_none()\n        && request.usage_auto_interval.is_none()\n    {\n        return Ok(None);\n    }\n\n    // Decode usage script code if provided\n    let code = if let Some(script_b64) = &request.usage_script {\n        let decoded = decode_base64_param(\"usage_script\", script_b64)?;\n        String::from_utf8(decoded)\n            .map_err(|e| AppError::InvalidInput(format!(\"Invalid UTF-8 in usage_script: {e}\")))?\n    } else {\n        String::new()\n    };\n\n    // Determine enabled state: explicit param > has code > false\n    let enabled = request.usage_enabled.unwrap_or(!code.is_empty());\n\n    // Build UsageScript - use provider's API key and endpoint as defaults\n    // Note: use primary endpoint only (first one if comma-separated)\n    let usage_script = UsageScript {\n        enabled,\n        language: \"javascript\".to_string(),\n        code,\n        timeout: Some(10),\n        api_key: request\n            .usage_api_key\n            .clone()\n            .or_else(|| request.api_key.clone()),\n        base_url: request.usage_base_url.clone().or_else(|| {\n            let primary = get_primary_endpoint(request);\n            if primary.is_empty() {\n                None\n            } else {\n                Some(primary)\n            }\n        }),\n        access_token: request.usage_access_token.clone(),\n        user_id: request.usage_user_id.clone(),\n        template_type: None, // Deeplink providers don't specify template type (will use backward compatibility logic)\n        auto_query_interval: request.usage_auto_interval,\n    };\n\n    Ok(Some(ProviderMeta {\n        usage_script: Some(usage_script),\n        ..Default::default()\n    }))\n}\n\n/// Build Claude settings configuration\nfn build_claude_settings(request: &DeepLinkImportRequest) -> serde_json::Value {\n    let mut env = serde_json::Map::new();\n    env.insert(\n        \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n        json!(request.api_key.clone().unwrap_or_default()),\n    );\n    env.insert(\n        \"ANTHROPIC_BASE_URL\".to_string(),\n        json!(get_primary_endpoint(request)),\n    );\n\n    // Add default model if provided\n    if let Some(model) = &request.model {\n        env.insert(\"ANTHROPIC_MODEL\".to_string(), json!(model));\n    }\n\n    // Add Claude-specific model fields (v3.7.1+)\n    if let Some(haiku_model) = &request.haiku_model {\n        env.insert(\n            \"ANTHROPIC_DEFAULT_HAIKU_MODEL\".to_string(),\n            json!(haiku_model),\n        );\n    }\n    if let Some(sonnet_model) = &request.sonnet_model {\n        env.insert(\n            \"ANTHROPIC_DEFAULT_SONNET_MODEL\".to_string(),\n            json!(sonnet_model),\n        );\n    }\n    if let Some(opus_model) = &request.opus_model {\n        env.insert(\n            \"ANTHROPIC_DEFAULT_OPUS_MODEL\".to_string(),\n            json!(opus_model),\n        );\n    }\n\n    json!({ \"env\": env })\n}\n\n/// Build Codex settings configuration\nfn build_codex_settings(request: &DeepLinkImportRequest) -> serde_json::Value {\n    // Generate a safe provider name identifier\n    let clean_provider_name = {\n        let raw: String = request\n            .name\n            .clone()\n            .unwrap_or_else(|| \"custom\".to_string())\n            .chars()\n            .filter(|c| !c.is_control())\n            .collect();\n        let lower = raw.to_lowercase();\n        let mut key: String = lower\n            .chars()\n            .map(|c| match c {\n                'a'..='z' | '0'..='9' | '_' => c,\n                _ => '_',\n            })\n            .collect();\n\n        // Remove leading/trailing underscores\n        while key.starts_with('_') {\n            key.remove(0);\n        }\n        while key.ends_with('_') {\n            key.pop();\n        }\n\n        if key.is_empty() {\n            \"custom\".to_string()\n        } else {\n            key\n        }\n    };\n\n    // Model name: use deeplink model or default\n    let model_name = request\n        .model\n        .as_deref()\n        .unwrap_or(\"gpt-5-codex\")\n        .to_string();\n\n    // Endpoint: normalize trailing slashes (use primary endpoint only)\n    let endpoint = get_primary_endpoint(request)\n        .trim()\n        .trim_end_matches('/')\n        .to_string();\n\n    // Build config.toml content\n    let config_toml = format!(\n        r#\"model_provider = \"{clean_provider_name}\"\nmodel = \"{model_name}\"\nmodel_reasoning_effort = \"high\"\ndisable_response_storage = true\n\n[model_providers.{clean_provider_name}]\nname = \"{clean_provider_name}\"\nbase_url = \"{endpoint}\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n    );\n\n    json!({\n        \"auth\": {\n            \"OPENAI_API_KEY\": request.api_key,\n        },\n        \"config\": config_toml\n    })\n}\n\n/// Build Gemini settings configuration\nfn build_gemini_settings(request: &DeepLinkImportRequest) -> serde_json::Value {\n    let mut env = serde_json::Map::new();\n    env.insert(\"GEMINI_API_KEY\".to_string(), json!(request.api_key));\n    env.insert(\n        \"GOOGLE_GEMINI_BASE_URL\".to_string(),\n        json!(get_primary_endpoint(request)),\n    );\n\n    // Add model if provided\n    if let Some(model) = &request.model {\n        env.insert(\"GEMINI_MODEL\".to_string(), json!(model));\n    }\n\n    json!({ \"env\": env })\n}\n\n/// Build OpenCode settings configuration\nfn build_opencode_settings(request: &DeepLinkImportRequest) -> serde_json::Value {\n    let endpoint = get_primary_endpoint(request);\n\n    // Build options object\n    let mut options = serde_json::Map::new();\n    if !endpoint.is_empty() {\n        options.insert(\"baseURL\".to_string(), json!(endpoint));\n    }\n    if let Some(api_key) = &request.api_key {\n        options.insert(\"apiKey\".to_string(), json!(api_key));\n    }\n\n    // Build models object\n    let mut models = serde_json::Map::new();\n    if let Some(model) = &request.model {\n        models.insert(model.clone(), json!({ \"name\": model }));\n    }\n\n    // Default to openai-compatible npm package\n    json!({\n        \"npm\": \"@ai-sdk/openai-compatible\",\n        \"options\": options,\n        \"models\": models\n    })\n}\n\nfn build_openclaw_settings(request: &DeepLinkImportRequest) -> serde_json::Value {\n    let endpoint = get_primary_endpoint(request);\n\n    // Build OpenClaw provider config\n    // Format: { baseUrl, apiKey, api, models }\n    let mut config = serde_json::Map::new();\n\n    if !endpoint.is_empty() {\n        config.insert(\"baseUrl\".to_string(), json!(endpoint));\n    }\n\n    if let Some(api_key) = &request.api_key {\n        config.insert(\"apiKey\".to_string(), json!(api_key));\n    }\n\n    // Default to OpenAI-compatible API\n    config.insert(\"api\".to_string(), json!(\"openai-completions\"));\n\n    // Build models array\n    if let Some(model) = &request.model {\n        config.insert(\n            \"models\".to_string(),\n            json!([{ \"id\": model, \"name\": model }]),\n        );\n    }\n\n    json!(config)\n}\n\n// =============================================================================\n// Config Merge Logic\n// =============================================================================\n\n/// Parse and merge configuration from Base64 encoded config or remote URL\n///\n/// Priority: URL params > inline config > remote config\npub fn parse_and_merge_config(\n    request: &DeepLinkImportRequest,\n) -> Result<DeepLinkImportRequest, AppError> {\n    // If no config provided, return original request\n    if request.config.is_none() && request.config_url.is_none() {\n        return Ok(request.clone());\n    }\n\n    // Step 1: Get config content\n    let config_content = if let Some(config_b64) = &request.config {\n        // Decode Base64 inline config\n        let decoded = decode_base64_param(\"config\", config_b64)?;\n        String::from_utf8(decoded)\n            .map_err(|e| AppError::InvalidInput(format!(\"Invalid UTF-8 in config: {e}\")))?\n    } else if let Some(_config_url) = &request.config_url {\n        // Fetch remote config (TODO: implement remote fetching in next phase)\n        return Err(AppError::InvalidInput(\n            \"Remote config URL is not yet supported. Use inline config instead.\".to_string(),\n        ));\n    } else {\n        return Ok(request.clone());\n    };\n\n    // Step 2: Parse config based on format\n    let format = request.config_format.as_deref().unwrap_or(\"json\");\n    let config_value: serde_json::Value = match format {\n        \"json\" => serde_json::from_str(&config_content)\n            .map_err(|e| AppError::InvalidInput(format!(\"Invalid JSON config: {e}\")))?,\n        \"toml\" => {\n            let toml_value: toml::Value = toml::from_str(&config_content)\n                .map_err(|e| AppError::InvalidInput(format!(\"Invalid TOML config: {e}\")))?;\n            // Convert TOML to JSON for uniform processing\n            serde_json::to_value(toml_value)\n                .map_err(|e| AppError::Message(format!(\"Failed to convert TOML to JSON: {e}\")))?\n        }\n        _ => {\n            return Err(AppError::InvalidInput(format!(\n                \"Unsupported config format: {format}\"\n            )))\n        }\n    };\n\n    // Step 3: Extract values from config based on app type and merge with URL params\n    let mut merged = request.clone();\n\n    // MCP, Skill and other resource types don't need config merging\n    if request.resource != \"provider\" {\n        return Ok(merged);\n    }\n\n    match request.app.as_deref().unwrap_or(\"\") {\n        \"claude\" => merge_claude_config(&mut merged, &config_value)?,\n        \"codex\" => merge_codex_config(&mut merged, &config_value)?,\n        \"gemini\" => merge_gemini_config(&mut merged, &config_value)?,\n        // Additive mode apps use JSON config directly; pass through as-is\n        \"openclaw\" | \"opencode\" => {\n            merge_additive_config(&mut merged, &config_value)?;\n        }\n        \"\" => {\n            // No app specified, skip merging\n            return Ok(merged);\n        }\n        _ => {\n            return Err(AppError::InvalidInput(format!(\n                \"Invalid app type: {:?}\",\n                request.app\n            )))\n        }\n    }\n\n    Ok(merged)\n}\n\n/// Merge Claude configuration from config file\nfn merge_claude_config(\n    request: &mut DeepLinkImportRequest,\n    config: &serde_json::Value,\n) -> Result<(), AppError> {\n    let env = config\n        .get(\"env\")\n        .and_then(|v| v.as_object())\n        .ok_or_else(|| {\n            AppError::InvalidInput(\"Claude config must have 'env' object\".to_string())\n        })?;\n\n    // Auto-fill API key if not provided in URL\n    if request.api_key.as_ref().is_none_or(|s| s.is_empty()) {\n        if let Some(token) = env.get(\"ANTHROPIC_AUTH_TOKEN\").and_then(|v| v.as_str()) {\n            request.api_key = Some(token.to_string());\n        }\n    }\n\n    // Auto-fill endpoint if not provided in URL\n    if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) {\n        if let Some(base_url) = env.get(\"ANTHROPIC_BASE_URL\").and_then(|v| v.as_str()) {\n            request.endpoint = Some(base_url.to_string());\n        }\n    }\n\n    // Auto-fill homepage from endpoint if not provided\n    if request.homepage.as_ref().is_none_or(|s| s.is_empty()) {\n        if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) {\n            request.homepage = infer_homepage_from_endpoint(endpoint);\n            if request.homepage.is_none() {\n                request.homepage = Some(\"https://anthropic.com\".to_string());\n            }\n        }\n    }\n\n    // Auto-fill model fields (URL params take priority)\n    if request.model.is_none() {\n        request.model = env\n            .get(\"ANTHROPIC_MODEL\")\n            .and_then(|v| v.as_str())\n            .map(|s| s.to_string());\n    }\n    if request.haiku_model.is_none() {\n        request.haiku_model = env\n            .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n            .and_then(|v| v.as_str())\n            .map(|s| s.to_string());\n    }\n    if request.sonnet_model.is_none() {\n        request.sonnet_model = env\n            .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n            .and_then(|v| v.as_str())\n            .map(|s| s.to_string());\n    }\n    if request.opus_model.is_none() {\n        request.opus_model = env\n            .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n            .and_then(|v| v.as_str())\n            .map(|s| s.to_string());\n    }\n\n    Ok(())\n}\n\n/// Merge Codex configuration from config file\nfn merge_codex_config(\n    request: &mut DeepLinkImportRequest,\n    config: &serde_json::Value,\n) -> Result<(), AppError> {\n    // Auto-fill API key from auth.OPENAI_API_KEY\n    if request.api_key.as_ref().is_none_or(|s| s.is_empty()) {\n        if let Some(api_key) = config\n            .get(\"auth\")\n            .and_then(|v| v.get(\"OPENAI_API_KEY\"))\n            .and_then(|v| v.as_str())\n        {\n            request.api_key = Some(api_key.to_string());\n        }\n    }\n\n    // Auto-fill endpoint and model from config string\n    if let Some(config_str) = config.get(\"config\").and_then(|v| v.as_str()) {\n        // Parse TOML config string to extract base_url and model\n        if let Ok(toml_value) = toml::from_str::<toml::Value>(config_str) {\n            // Extract base_url from model_providers section\n            if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) {\n                if let Some(base_url) = extract_codex_base_url(&toml_value) {\n                    request.endpoint = Some(base_url);\n                }\n            }\n\n            // Extract model\n            if request.model.is_none() {\n                if let Some(model) = toml_value.get(\"model\").and_then(|v| v.as_str()) {\n                    request.model = Some(model.to_string());\n                }\n            }\n        }\n    }\n\n    // Auto-fill homepage from endpoint\n    if request.homepage.as_ref().is_none_or(|s| s.is_empty()) {\n        if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) {\n            request.homepage = infer_homepage_from_endpoint(endpoint);\n            if request.homepage.is_none() {\n                request.homepage = Some(\"https://openai.com\".to_string());\n            }\n        }\n    }\n\n    Ok(())\n}\n\n/// Merge Gemini configuration from config file\nfn merge_gemini_config(\n    request: &mut DeepLinkImportRequest,\n    config: &serde_json::Value,\n) -> Result<(), AppError> {\n    // Gemini uses flat env structure\n    if request.api_key.as_ref().is_none_or(|s| s.is_empty()) {\n        if let Some(api_key) = config.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) {\n            request.api_key = Some(api_key.to_string());\n        }\n    }\n\n    if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) {\n        if let Some(base_url) = config\n            .get(\"GOOGLE_GEMINI_BASE_URL\")\n            .or_else(|| config.get(\"GEMINI_BASE_URL\"))\n            .and_then(|v| v.as_str())\n        {\n            request.endpoint = Some(base_url.to_string());\n        }\n    }\n\n    if request.model.is_none() {\n        request.model = config\n            .get(\"GEMINI_MODEL\")\n            .and_then(|v| v.as_str())\n            .map(|s| s.to_string());\n    }\n\n    // Auto-fill homepage from endpoint\n    if request.homepage.as_ref().is_none_or(|s| s.is_empty()) {\n        if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) {\n            request.homepage = infer_homepage_from_endpoint(endpoint);\n            if request.homepage.is_none() {\n                request.homepage = Some(\"https://ai.google.dev\".to_string());\n            }\n        }\n    }\n\n    Ok(())\n}\n\n/// Merge configuration for additive mode apps (OpenClaw, OpenCode)\n///\n/// These apps use JSON config directly, so we only extract common fields\n/// (api_key, endpoint, model) from the config if not already set in URL params.\nfn merge_additive_config(\n    request: &mut DeepLinkImportRequest,\n    config: &serde_json::Value,\n) -> Result<(), AppError> {\n    // Extract api_key from config if not provided in URL\n    if request.api_key.as_ref().is_none_or(|s| s.is_empty()) {\n        if let Some(api_key) = config\n            .get(\"apiKey\")\n            .or_else(|| config.get(\"api_key\"))\n            .and_then(|v| v.as_str())\n        {\n            request.api_key = Some(api_key.to_string());\n        }\n    }\n\n    // Extract endpoint from config if not provided in URL\n    if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) {\n        if let Some(base_url) = config\n            .get(\"baseUrl\")\n            .or_else(|| config.get(\"base_url\"))\n            .or_else(|| config.get(\"options\").and_then(|o| o.get(\"baseURL\")))\n            .and_then(|v| v.as_str())\n        {\n            request.endpoint = Some(base_url.to_string());\n        }\n    }\n\n    // Auto-fill homepage from endpoint\n    if request.homepage.as_ref().is_none_or(|s| s.is_empty()) {\n        if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) {\n            request.homepage = infer_homepage_from_endpoint(endpoint);\n        }\n    }\n\n    Ok(())\n}\n\n/// Extract base_url from Codex TOML config\nfn extract_codex_base_url(toml_value: &toml::Value) -> Option<String> {\n    // Try to find base_url in model_providers section\n    if let Some(providers) = toml_value.get(\"model_providers\").and_then(|v| v.as_table()) {\n        for (_key, provider) in providers.iter() {\n            if let Some(base_url) = provider.get(\"base_url\").and_then(|v| v.as_str()) {\n                return Some(base_url.to_string());\n            }\n        }\n    }\n    None\n}\n"
  },
  {
    "path": "src-tauri/src/deeplink/skill.rs",
    "content": "//! Skill import from deep link\n//!\n//! Handles importing skill repository configurations via ccswitch:// URLs.\n\nuse super::DeepLinkImportRequest;\nuse crate::error::AppError;\nuse crate::services::skill::SkillRepo;\nuse crate::store::AppState;\n\n/// Import a skill from deep link request\npub fn import_skill_from_deeplink(\n    state: &AppState,\n    request: DeepLinkImportRequest,\n) -> Result<String, AppError> {\n    // Verify this is a skill request\n    if request.resource != \"skill\" {\n        return Err(AppError::InvalidInput(format!(\n            \"Expected skill resource, got '{}'\",\n            request.resource\n        )));\n    }\n\n    // Parse repo\n    let repo_str = request\n        .repo\n        .ok_or_else(|| AppError::InvalidInput(\"Missing 'repo' field for skill\".to_string()))?;\n\n    let parts: Vec<&str> = repo_str.split('/').collect();\n    if parts.len() != 2 {\n        return Err(AppError::InvalidInput(format!(\n            \"Invalid repo format: expected 'owner/name', got '{repo_str}'\"\n        )));\n    }\n    let owner = parts[0].to_string();\n    let name = parts[1].to_string();\n\n    // Create SkillRepo\n    let repo = SkillRepo {\n        owner: owner.clone(),\n        name: name.clone(),\n        branch: request.branch.unwrap_or_else(|| \"main\".to_string()),\n        enabled: request.enabled.unwrap_or(true),\n    };\n\n    // Save using Database\n    state.db.save_skill_repo(&repo)?;\n\n    log::info!(\"Successfully added skill repo '{owner}/{name}'\");\n\n    Ok(format!(\"{owner}/{name}\"))\n}\n"
  },
  {
    "path": "src-tauri/src/deeplink/tests.rs",
    "content": "//! Deep link module tests\n\nuse super::mcp::parse_mcp_apps;\nuse super::parser::parse_deeplink_url;\nuse super::prompt::import_prompt_from_deeplink;\nuse super::provider::parse_and_merge_config;\nuse super::utils::{infer_homepage_from_endpoint, validate_url};\nuse super::DeepLinkImportRequest;\nuse crate::AppType;\nuse crate::{store::AppState, Database};\nuse base64::prelude::*;\nuse std::sync::Arc;\n\n// =============================================================================\n// Parser Tests\n// =============================================================================\n\n#[test]\nfn test_parse_valid_claude_deeplink() {\n    let url = \"ccswitch://v1/import?resource=provider&app=claude&name=Test%20Provider&homepage=https%3A%2F%2Fexample.com&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-test-123&icon=claude\";\n\n    let request = parse_deeplink_url(url).unwrap();\n\n    assert_eq!(request.version, \"v1\");\n    assert_eq!(request.resource, \"provider\");\n    assert_eq!(request.app, Some(\"claude\".to_string()));\n    assert_eq!(request.name, Some(\"Test Provider\".to_string()));\n    assert_eq!(request.homepage, Some(\"https://example.com\".to_string()));\n    assert_eq!(\n        request.endpoint,\n        Some(\"https://api.example.com\".to_string())\n    );\n    assert_eq!(request.api_key, Some(\"sk-test-123\".to_string()));\n    assert_eq!(request.icon, Some(\"claude\".to_string()));\n}\n\n#[test]\nfn test_parse_deeplink_with_notes() {\n    let url = \"ccswitch://v1/import?resource=provider&app=codex&name=Codex&homepage=https%3A%2F%2Fcodex.com&endpoint=https%3A%2F%2Fapi.codex.com&apiKey=key123&notes=Test%20notes\";\n\n    let request = parse_deeplink_url(url).unwrap();\n\n    assert_eq!(request.notes, Some(\"Test notes\".to_string()));\n}\n\n#[test]\nfn test_parse_invalid_scheme() {\n    let url = \"https://v1/import?resource=provider&app=claude&name=Test\";\n\n    let result = parse_deeplink_url(url);\n    assert!(result.is_err());\n    assert!(result.unwrap_err().to_string().contains(\"Invalid scheme\"));\n}\n\n#[test]\nfn test_parse_unsupported_version() {\n    let url = \"ccswitch://v2/import?resource=provider&app=claude&name=Test\";\n\n    let result = parse_deeplink_url(url);\n    assert!(result.is_err());\n    assert!(result\n        .unwrap_err()\n        .to_string()\n        .contains(\"Unsupported protocol version\"));\n}\n\n#[test]\nfn test_parse_missing_required_field() {\n    // Name is still required even in v3.8+ (only homepage/endpoint/apiKey are optional)\n    let url = \"ccswitch://v1/import?resource=provider&app=claude\";\n\n    let result = parse_deeplink_url(url);\n    assert!(result.is_err());\n    assert!(result\n        .unwrap_err()\n        .to_string()\n        .contains(\"Missing 'name' parameter\"));\n}\n\n// =============================================================================\n// Utils Tests\n// =============================================================================\n\n#[test]\nfn test_validate_invalid_url() {\n    let result = validate_url(\"not-a-url\", \"test\");\n    assert!(result.is_err());\n}\n\n#[test]\nfn test_validate_invalid_scheme() {\n    let result = validate_url(\"ftp://example.com\", \"test\");\n    assert!(result.is_err());\n    assert!(result\n        .unwrap_err()\n        .to_string()\n        .contains(\"must be http or https\"));\n}\n\n#[test]\nfn test_infer_homepage() {\n    assert_eq!(\n        infer_homepage_from_endpoint(\"https://api.anthropic.com/v1\"),\n        Some(\"https://anthropic.com\".to_string())\n    );\n    assert_eq!(\n        infer_homepage_from_endpoint(\"https://api-test.company.com/v1\"),\n        Some(\"https://test.company.com\".to_string())\n    );\n    assert_eq!(\n        infer_homepage_from_endpoint(\"https://example.com\"),\n        Some(\"https://example.com\".to_string())\n    );\n}\n\n// =============================================================================\n// Provider Tests\n// =============================================================================\n\n#[test]\nfn test_build_gemini_provider_with_model() {\n    use super::provider::build_provider_from_request;\n\n    let request = DeepLinkImportRequest {\n        version: \"v1\".to_string(),\n        resource: \"provider\".to_string(),\n        app: Some(\"gemini\".to_string()),\n        name: Some(\"Test Gemini\".to_string()),\n        homepage: Some(\"https://example.com\".to_string()),\n        endpoint: Some(\"https://api.example.com\".to_string()),\n        api_key: Some(\"test-api-key\".to_string()),\n        icon: None,\n        model: Some(\"gemini-2.0-flash\".to_string()),\n        notes: None,\n        haiku_model: None,\n        sonnet_model: None,\n        opus_model: None,\n        config: None,\n        config_format: None,\n        config_url: None,\n        apps: None,\n        repo: None,\n        directory: None,\n        branch: None,\n        content: None,\n        description: None,\n        enabled: None,\n        usage_enabled: None,\n        usage_script: None,\n        usage_api_key: None,\n        usage_base_url: None,\n        usage_access_token: None,\n        usage_user_id: None,\n        usage_auto_interval: None,\n    };\n\n    let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap();\n\n    // Verify provider basic info\n    assert_eq!(provider.name, \"Test Gemini\");\n    assert_eq!(\n        provider.website_url,\n        Some(\"https://example.com\".to_string())\n    );\n\n    // Verify settings_config structure\n    let env = provider.settings_config[\"env\"].as_object().unwrap();\n    assert_eq!(env[\"GEMINI_API_KEY\"], \"test-api-key\");\n    assert_eq!(env[\"GOOGLE_GEMINI_BASE_URL\"], \"https://api.example.com\");\n    assert_eq!(env[\"GEMINI_MODEL\"], \"gemini-2.0-flash\");\n}\n\n#[test]\nfn test_build_gemini_provider_without_model() {\n    use super::provider::build_provider_from_request;\n\n    let request = DeepLinkImportRequest {\n        version: \"v1\".to_string(),\n        resource: \"provider\".to_string(),\n        app: Some(\"gemini\".to_string()),\n        name: Some(\"Test Gemini\".to_string()),\n        homepage: Some(\"https://example.com\".to_string()),\n        endpoint: Some(\"https://api.example.com\".to_string()),\n        api_key: Some(\"test-api-key\".to_string()),\n        icon: None,\n        model: None,\n        notes: None,\n        haiku_model: None,\n        sonnet_model: None,\n        opus_model: None,\n        config: None,\n        config_format: None,\n        config_url: None,\n        apps: None,\n        repo: None,\n        directory: None,\n        branch: None,\n        content: None,\n        description: None,\n        enabled: None,\n        usage_enabled: None,\n        usage_script: None,\n        usage_api_key: None,\n        usage_base_url: None,\n        usage_access_token: None,\n        usage_user_id: None,\n        usage_auto_interval: None,\n    };\n\n    let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap();\n\n    let env = provider.settings_config[\"env\"].as_object().unwrap();\n    assert_eq!(env[\"GEMINI_API_KEY\"], \"test-api-key\");\n    assert_eq!(env[\"GOOGLE_GEMINI_BASE_URL\"], \"https://api.example.com\");\n    // Model should not be present\n    assert!(env.get(\"GEMINI_MODEL\").is_none());\n}\n\n#[test]\nfn test_parse_and_merge_config_claude() {\n    // Prepare Base64 encoded Claude config\n    let config_json = r#\"{\"env\":{\"ANTHROPIC_AUTH_TOKEN\":\"sk-ant-xxx\",\"ANTHROPIC_BASE_URL\":\"https://api.anthropic.com/v1\",\"ANTHROPIC_MODEL\":\"claude-sonnet-4.5\"}}\"#;\n    let config_b64 = BASE64_STANDARD.encode(config_json.as_bytes());\n\n    let request = DeepLinkImportRequest {\n        version: \"v1\".to_string(),\n        resource: \"provider\".to_string(),\n        app: Some(\"claude\".to_string()),\n        name: Some(\"Test\".to_string()),\n        homepage: None,\n        endpoint: None,\n        api_key: None,\n        icon: None,\n        model: None,\n        notes: None,\n        haiku_model: None,\n        sonnet_model: None,\n        opus_model: None,\n        config: Some(config_b64),\n        config_format: Some(\"json\".to_string()),\n        config_url: None,\n        apps: None,\n        repo: None,\n        directory: None,\n        branch: None,\n        content: None,\n        description: None,\n        enabled: None,\n        usage_enabled: None,\n        usage_script: None,\n        usage_api_key: None,\n        usage_base_url: None,\n        usage_access_token: None,\n        usage_user_id: None,\n        usage_auto_interval: None,\n    };\n\n    let merged = parse_and_merge_config(&request).unwrap();\n\n    // Should auto-fill from config\n    assert_eq!(merged.api_key, Some(\"sk-ant-xxx\".to_string()));\n    assert_eq!(\n        merged.endpoint,\n        Some(\"https://api.anthropic.com/v1\".to_string())\n    );\n    assert_eq!(merged.homepage, Some(\"https://anthropic.com\".to_string()));\n    assert_eq!(merged.model, Some(\"claude-sonnet-4.5\".to_string()));\n}\n\n#[test]\nfn test_parse_and_merge_config_url_override() {\n    let config_json = r#\"{\"env\":{\"ANTHROPIC_AUTH_TOKEN\":\"sk-old\",\"ANTHROPIC_BASE_URL\":\"https://api.anthropic.com/v1\"}}\"#;\n    let config_b64 = BASE64_STANDARD.encode(config_json.as_bytes());\n\n    let request = DeepLinkImportRequest {\n        version: \"v1\".to_string(),\n        resource: \"provider\".to_string(),\n        app: Some(\"claude\".to_string()),\n        name: Some(\"Test\".to_string()),\n        homepage: None,\n        endpoint: None,\n        api_key: Some(\"sk-new\".to_string()), // URL param should override\n        icon: None,\n        model: None,\n        notes: None,\n        haiku_model: None,\n        sonnet_model: None,\n        opus_model: None,\n        config: Some(config_b64),\n        config_format: Some(\"json\".to_string()),\n        config_url: None,\n        apps: None,\n        repo: None,\n        directory: None,\n        branch: None,\n        content: None,\n        description: None,\n        enabled: None,\n        usage_enabled: None,\n        usage_script: None,\n        usage_api_key: None,\n        usage_base_url: None,\n        usage_access_token: None,\n        usage_user_id: None,\n        usage_auto_interval: None,\n    };\n\n    let merged = parse_and_merge_config(&request).unwrap();\n\n    // URL param should take priority\n    assert_eq!(merged.api_key, Some(\"sk-new\".to_string()));\n    // Config file value should be used\n    assert_eq!(\n        merged.endpoint,\n        Some(\"https://api.anthropic.com/v1\".to_string())\n    );\n}\n\n// =============================================================================\n// Prompt Tests\n// =============================================================================\n\n#[test]\nfn test_import_prompt_allows_space_in_base64_content() {\n    let url = \"ccswitch://v1/import?resource=prompt&app=codex&name=PromptPlus&content=Pj4+\";\n    let request = parse_deeplink_url(url).unwrap();\n\n    // URL decoded content may have \"+\" become space\n    assert_eq!(request.content.as_deref(), Some(\"Pj4 \"));\n\n    let db = Arc::new(Database::memory().expect(\"create memory db\"));\n    let state = AppState::new(db.clone());\n\n    let prompt_id = import_prompt_from_deeplink(&state, request.clone()).expect(\"import prompt\");\n\n    let prompts = state.db.get_prompts(\"codex\").expect(\"get prompts\");\n    let prompt = prompts.get(&prompt_id).expect(\"prompt saved\");\n\n    assert_eq!(prompt.content, \">>>\");\n    assert_eq!(prompt.name, request.name.unwrap());\n}\n\n// =============================================================================\n// MCP Tests\n// =============================================================================\n\n#[test]\nfn test_parse_mcp_apps() {\n    let apps = parse_mcp_apps(\"claude,codex\").unwrap();\n    assert!(apps.claude);\n    assert!(apps.codex);\n    assert!(!apps.gemini);\n\n    let apps = parse_mcp_apps(\"gemini\").unwrap();\n    assert!(!apps.claude);\n    assert!(!apps.codex);\n    assert!(apps.gemini);\n\n    let err = parse_mcp_apps(\"invalid\").unwrap_err();\n    assert!(err.to_string().contains(\"Invalid app\"));\n}\n\n#[test]\nfn test_parse_prompt_deeplink() {\n    let content = \"Hello World\";\n    let content_b64 = BASE64_STANDARD.encode(content);\n    let url = format!(\n        \"ccswitch://v1/import?resource=prompt&app=claude&name=test&content={}&description=desc&enabled=true\",\n        content_b64\n    );\n\n    let request = parse_deeplink_url(&url).unwrap();\n    assert_eq!(request.resource, \"prompt\");\n    assert_eq!(request.app.unwrap(), \"claude\");\n    assert_eq!(request.name.unwrap(), \"test\");\n    assert_eq!(request.content.unwrap(), content_b64);\n    assert_eq!(request.description.unwrap(), \"desc\");\n    assert!(request.enabled.unwrap());\n}\n\n#[test]\nfn test_parse_mcp_deeplink() {\n    let config = r#\"{\"mcpServers\":{\"test\":{\"command\":\"echo\"}}}\"#;\n    let config_b64 = BASE64_STANDARD.encode(config);\n    let url = format!(\n        \"ccswitch://v1/import?resource=mcp&apps=claude,codex&config={}&enabled=true\",\n        config_b64\n    );\n\n    let request = parse_deeplink_url(&url).unwrap();\n    assert_eq!(request.resource, \"mcp\");\n    assert_eq!(request.apps.unwrap(), \"claude,codex\");\n    assert_eq!(request.config.unwrap(), config_b64);\n    assert!(request.enabled.unwrap());\n}\n\n#[test]\nfn test_parse_skill_deeplink() {\n    let url = \"ccswitch://v1/import?resource=skill&repo=owner/repo&directory=skills&branch=dev\";\n    let request = parse_deeplink_url(url).unwrap();\n\n    assert_eq!(request.resource, \"skill\");\n    assert_eq!(request.repo.unwrap(), \"owner/repo\");\n    assert_eq!(request.directory.unwrap(), \"skills\");\n    assert_eq!(request.branch.unwrap(), \"dev\");\n}\n\n// =============================================================================\n// Multiple Endpoints Tests\n// =============================================================================\n\n#[test]\nfn test_parse_multiple_endpoints_comma_separated() {\n    let url = \"ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi1.example.com,https%3A%2F%2Fapi2.example.com,https%3A%2F%2Fapi3.example.com&apiKey=sk-test\";\n\n    let request = parse_deeplink_url(url).unwrap();\n\n    assert!(request.endpoint.is_some());\n    let endpoint = request.endpoint.unwrap();\n    // Should contain all endpoints comma-separated\n    assert!(endpoint.contains(\"https://api1.example.com\"));\n    assert!(endpoint.contains(\"https://api2.example.com\"));\n    assert!(endpoint.contains(\"https://api3.example.com\"));\n}\n\n#[test]\nfn test_parse_single_endpoint_backward_compatible() {\n    // Old format with single endpoint should still work\n    let url = \"ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-test\";\n\n    let request = parse_deeplink_url(url).unwrap();\n\n    assert_eq!(\n        request.endpoint,\n        Some(\"https://api.example.com\".to_string())\n    );\n}\n\n#[test]\nfn test_parse_endpoints_with_spaces_trimmed() {\n    let url = \"ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi1.example.com%20,%20https%3A%2F%2Fapi2.example.com&apiKey=sk-test\";\n\n    let request = parse_deeplink_url(url).unwrap();\n\n    // Validation should pass (spaces are trimmed during validation)\n    assert!(request.endpoint.is_some());\n}\n\n#[test]\nfn test_infer_homepage_from_endpoint_without_homepage() {\n    // Test that homepage is auto-inferred from endpoint when not provided\n    assert_eq!(\n        infer_homepage_from_endpoint(\"https://api.cubence.com/v1\"),\n        Some(\"https://cubence.com\".to_string())\n    );\n    assert_eq!(\n        infer_homepage_from_endpoint(\"https://cubence.com\"),\n        Some(\"https://cubence.com\".to_string())\n    );\n}\n"
  },
  {
    "path": "src-tauri/src/deeplink/utils.rs",
    "content": "//! Deep link utility functions\n//!\n//! Common helpers for URL validation, Base64 decoding, etc.\n\nuse crate::error::AppError;\nuse base64::prelude::*;\nuse url::Url;\n\n/// Validate that a string is a valid HTTP(S) URL\npub fn validate_url(url_str: &str, field_name: &str) -> Result<(), AppError> {\n    let url = Url::parse(url_str)\n        .map_err(|e| AppError::InvalidInput(format!(\"Invalid URL for '{field_name}': {e}\")))?;\n\n    let scheme = url.scheme();\n    if scheme != \"http\" && scheme != \"https\" {\n        return Err(AppError::InvalidInput(format!(\n            \"Invalid URL scheme for '{field_name}': must be http or https, got '{scheme}'\"\n        )));\n    }\n\n    Ok(())\n}\n\n/// Decode a Base64 parameter from deep link URL\n///\n/// This function handles common issues with Base64 in URLs:\n/// - `+` being decoded as space\n/// - Missing padding `=`\n/// - Both standard and URL-safe Base64 variants\npub fn decode_base64_param(field: &str, raw: &str) -> Result<Vec<u8>, AppError> {\n    let mut candidates: Vec<String> = Vec::new();\n    // Keep spaces (to restore `+`), but remove newlines\n    let trimmed = raw.trim_matches(|c| c == '\\r' || c == '\\n');\n\n    // First try restoring spaces to \"+\"\n    if trimmed.contains(' ') {\n        let replaced = trimmed.replace(' ', \"+\");\n        if !replaced.is_empty() && !candidates.contains(&replaced) {\n            candidates.push(replaced);\n        }\n    }\n\n    // Original value\n    if !trimmed.is_empty() && !candidates.contains(&trimmed.to_string()) {\n        candidates.push(trimmed.to_string());\n    }\n\n    // Add padding variants\n    let existing = candidates.clone();\n    for candidate in existing {\n        let mut padded = candidate.clone();\n        let remainder = padded.len() % 4;\n        if remainder != 0 {\n            padded.extend(std::iter::repeat_n('=', 4 - remainder));\n        }\n        if !candidates.contains(&padded) {\n            candidates.push(padded);\n        }\n    }\n\n    let mut last_error: Option<String> = None;\n    for candidate in candidates {\n        for engine in [\n            &BASE64_STANDARD,\n            &BASE64_STANDARD_NO_PAD,\n            &BASE64_URL_SAFE,\n            &BASE64_URL_SAFE_NO_PAD,\n        ] {\n            match engine.decode(&candidate) {\n                Ok(bytes) => return Ok(bytes),\n                Err(err) => last_error = Some(err.to_string()),\n            }\n        }\n    }\n\n    Err(AppError::InvalidInput(format!(\n        \"{field} 参数 Base64 解码失败：{}。请确认链接参数已用 Base64 编码并经过 URL 转义（尤其是将 '+' 编码为 %2B，或使用 URL-safe Base64）。\",\n        last_error.unwrap_or_else(|| \"未知错误\".to_string())\n    )))\n}\n\n/// Infer homepage URL from API endpoint\n///\n/// Examples:\n/// - https://api.anthropic.com/v1 → https://anthropic.com\n/// - https://api.openai.com/v1 → https://openai.com\n/// - https://api-test.company.com/v1 → https://company.com\npub fn infer_homepage_from_endpoint(endpoint: &str) -> Option<String> {\n    let url = Url::parse(endpoint).ok()?;\n    let host = url.host_str()?;\n\n    // Remove common API prefixes\n    let clean_host = host\n        .strip_prefix(\"api.\")\n        .or_else(|| host.strip_prefix(\"api-\"))\n        .unwrap_or(host);\n\n    Some(format!(\"https://{clean_host}\"))\n}\n"
  },
  {
    "path": "src-tauri/src/error.rs",
    "content": "use std::path::Path;\nuse std::sync::PoisonError;\n\nuse thiserror::Error;\n\n#[derive(Debug, Error)]\npub enum AppError {\n    #[error(\"配置错误: {0}\")]\n    Config(String),\n    #[error(\"无效输入: {0}\")]\n    InvalidInput(String),\n    #[error(\"IO 错误: {path}: {source}\")]\n    Io {\n        path: String,\n        #[source]\n        source: std::io::Error,\n    },\n    #[error(\"{context}: {source}\")]\n    IoContext {\n        context: String,\n        #[source]\n        source: std::io::Error,\n    },\n    #[error(\"JSON 解析错误: {path}: {source}\")]\n    Json {\n        path: String,\n        #[source]\n        source: serde_json::Error,\n    },\n    #[error(\"JSON 序列化失败: {source}\")]\n    JsonSerialize {\n        #[source]\n        source: serde_json::Error,\n    },\n    #[error(\"TOML 解析错误: {path}: {source}\")]\n    Toml {\n        path: String,\n        #[source]\n        source: toml::de::Error,\n    },\n    #[error(\"锁获取失败: {0}\")]\n    Lock(String),\n    #[error(\"MCP 校验失败: {0}\")]\n    McpValidation(String),\n    #[error(\"{0}\")]\n    Message(String),\n    #[error(\"{zh} ({en})\")]\n    Localized {\n        key: &'static str,\n        zh: String,\n        en: String,\n    },\n    #[error(\"数据库错误: {0}\")]\n    Database(String),\n    #[error(\"OMO 配置文件不存在\")]\n    OmoConfigNotFound,\n    #[error(\"所有供应商已熔断，无可用渠道\")]\n    AllProvidersCircuitOpen,\n    #[error(\"未配置供应商\")]\n    NoProvidersConfigured,\n}\n\nimpl AppError {\n    pub fn io(path: impl AsRef<Path>, source: std::io::Error) -> Self {\n        Self::Io {\n            path: path.as_ref().display().to_string(),\n            source,\n        }\n    }\n\n    pub fn json(path: impl AsRef<Path>, source: serde_json::Error) -> Self {\n        Self::Json {\n            path: path.as_ref().display().to_string(),\n            source,\n        }\n    }\n\n    pub fn toml(path: impl AsRef<Path>, source: toml::de::Error) -> Self {\n        Self::Toml {\n            path: path.as_ref().display().to_string(),\n            source,\n        }\n    }\n\n    pub fn localized(key: &'static str, zh: impl Into<String>, en: impl Into<String>) -> Self {\n        Self::Localized {\n            key,\n            zh: zh.into(),\n            en: en.into(),\n        }\n    }\n}\n\nimpl<T> From<PoisonError<T>> for AppError {\n    fn from(err: PoisonError<T>) -> Self {\n        Self::Lock(err.to_string())\n    }\n}\n\nimpl From<rusqlite::Error> for AppError {\n    fn from(err: rusqlite::Error) -> Self {\n        Self::Database(err.to_string())\n    }\n}\n\nimpl From<AppError> for String {\n    fn from(err: AppError) -> Self {\n        err.to_string()\n    }\n}\n\nimpl serde::Serialize for AppError {\n    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>\n    where\n        S: serde::Serializer,\n    {\n        serializer.serialize_str(&self.to_string())\n    }\n}\n\n/// 格式化为 JSON 错误字符串，前端可解析为结构化错误\npub fn format_skill_error(\n    code: &str,\n    context: &[(&str, &str)],\n    suggestion: Option<&str>,\n) -> String {\n    use serde_json::json;\n\n    let mut ctx_map = serde_json::Map::new();\n    for (key, value) in context {\n        ctx_map.insert(key.to_string(), json!(value));\n    }\n\n    let error_obj = json!({\n        \"code\": code,\n        \"context\": ctx_map,\n        \"suggestion\": suggestion,\n    });\n\n    serde_json::to_string(&error_obj).unwrap_or_else(|_| {\n        // 如果 JSON 序列化失败，返回简单格式\n        format!(\"ERROR:{code}\")\n    })\n}\n"
  },
  {
    "path": "src-tauri/src/gemini_config.rs",
    "content": "use crate::config::{get_home_dir, write_text_file};\nuse crate::error::AppError;\nuse serde_json::Value;\nuse std::collections::HashMap;\nuse std::fs;\nuse std::path::PathBuf;\n\n/// 获取 Gemini 配置目录路径（支持设置覆盖）\npub fn get_gemini_dir() -> PathBuf {\n    if let Some(custom) = crate::settings::get_gemini_override_dir() {\n        return custom;\n    }\n\n    get_home_dir().join(\".gemini\")\n}\n\n/// 获取 Gemini .env 文件路径\npub fn get_gemini_env_path() -> PathBuf {\n    get_gemini_dir().join(\".env\")\n}\n\n/// 解析 .env 文件内容为键值对\n///\n/// 此函数宽松地解析 .env 文件，跳过无效行。\n/// 对于需要严格验证的场景，请使用 `parse_env_file_strict`。\npub fn parse_env_file(content: &str) -> HashMap<String, String> {\n    let mut map = HashMap::new();\n\n    for line in content.lines() {\n        let line = line.trim();\n\n        // 跳过空行和注释\n        if line.is_empty() || line.starts_with('#') {\n            continue;\n        }\n\n        // 解析 KEY=VALUE\n        if let Some((key, value)) = line.split_once('=') {\n            let key = key.trim().to_string();\n            let value = value.trim().to_string();\n\n            // 验证 key 是否有效（不为空，只包含字母、数字和下划线）\n            if !key.is_empty() && key.chars().all(|c| c.is_alphanumeric() || c == '_') {\n                map.insert(key, value);\n            }\n        }\n    }\n\n    map\n}\n\n/// 严格解析 .env 文件内容，返回详细的错误信息\n///\n/// 与 `parse_env_file` 不同，此函数在遇到无效行时会返回错误，\n/// 包含行号和详细的错误信息。\n///\n/// # 错误\n///\n/// 返回 `AppError` 如果遇到以下情况：\n/// - 行不包含 `=` 分隔符\n/// - Key 为空或包含无效字符\n/// - Key 不符合环境变量命名规范\n///\n/// # 使用场景\n///\n/// 此函数为未来的严格验证场景预留，当前运行时使用宽松的 `parse_env_file`。\n/// 可用于：\n/// - 配置导入验证\n/// - CLI 工具的严格模式\n/// - 配置文件错误诊断\n///\n/// 已有完整的测试覆盖，可直接使用。\n#[allow(dead_code)]\npub fn parse_env_file_strict(content: &str) -> Result<HashMap<String, String>, AppError> {\n    let mut map = HashMap::new();\n\n    for (line_num, line) in content.lines().enumerate() {\n        let line = line.trim();\n        let line_number = line_num + 1; // 行号从 1 开始\n\n        // 跳过空行和注释\n        if line.is_empty() || line.starts_with('#') {\n            continue;\n        }\n\n        // 检查是否包含 =\n        if !line.contains('=') {\n            return Err(AppError::localized(\n                \"gemini.env.parse_error.no_equals\",\n                format!(\"Gemini .env 文件格式错误（第 {line_number} 行）：缺少 '=' 分隔符\\n行内容: {line}\"),\n                format!(\"Invalid Gemini .env format (line {line_number}): missing '=' separator\\nLine: {line}\"),\n            ));\n        }\n\n        // 解析 KEY=VALUE\n        if let Some((key, value)) = line.split_once('=') {\n            let key = key.trim();\n            let value = value.trim();\n\n            // 验证 key 不为空\n            if key.is_empty() {\n                return Err(AppError::localized(\n                    \"gemini.env.parse_error.empty_key\",\n                    format!(\"Gemini .env 文件格式错误（第 {line_number} 行）：环境变量名不能为空\\n行内容: {line}\"),\n                    format!(\"Invalid Gemini .env format (line {line_number}): variable name cannot be empty\\nLine: {line}\"),\n                ));\n            }\n\n            // 验证 key 只包含字母、数字和下划线\n            if !key.chars().all(|c| c.is_alphanumeric() || c == '_') {\n                return Err(AppError::localized(\n                    \"gemini.env.parse_error.invalid_key\",\n                    format!(\"Gemini .env 文件格式错误（第 {line_number} 行）：环境变量名只能包含字母、数字和下划线\\n变量名: {key}\"),\n                    format!(\"Invalid Gemini .env format (line {line_number}): variable name can only contain letters, numbers, and underscores\\nVariable: {key}\"),\n                ));\n            }\n\n            map.insert(key.to_string(), value.to_string());\n        }\n    }\n\n    Ok(map)\n}\n\n/// 将键值对序列化为 .env 格式\npub fn serialize_env_file(map: &HashMap<String, String>) -> String {\n    let mut lines = Vec::new();\n\n    // 按键排序以保证输出稳定\n    let mut keys: Vec<_> = map.keys().collect();\n    keys.sort();\n\n    for key in keys {\n        if let Some(value) = map.get(key) {\n            lines.push(format!(\"{key}={value}\"));\n        }\n    }\n\n    lines.join(\"\\n\")\n}\n\n/// 读取 Gemini .env 文件\npub fn read_gemini_env() -> Result<HashMap<String, String>, AppError> {\n    let path = get_gemini_env_path();\n\n    if !path.exists() {\n        return Ok(HashMap::new());\n    }\n\n    let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;\n\n    Ok(parse_env_file(&content))\n}\n\n/// 写入 Gemini .env 文件（原子操作）\npub fn write_gemini_env_atomic(map: &HashMap<String, String>) -> Result<(), AppError> {\n    let path = get_gemini_env_path();\n\n    // 确保目录存在\n    if let Some(parent) = path.parent() {\n        fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;\n\n        // 设置目录权限为 700（仅所有者可读写执行）\n        #[cfg(unix)]\n        {\n            use std::os::unix::fs::PermissionsExt;\n            let mut perms = fs::metadata(parent)\n                .map_err(|e| AppError::io(parent, e))?\n                .permissions();\n            perms.set_mode(0o700);\n            fs::set_permissions(parent, perms).map_err(|e| AppError::io(parent, e))?;\n        }\n    }\n\n    let content = serialize_env_file(map);\n    write_text_file(&path, &content)?;\n\n    // 设置文件权限为 600（仅所有者可读写）\n    #[cfg(unix)]\n    {\n        use std::os::unix::fs::PermissionsExt;\n        let mut perms = fs::metadata(&path)\n            .map_err(|e| AppError::io(&path, e))?\n            .permissions();\n        perms.set_mode(0o600);\n        fs::set_permissions(&path, perms).map_err(|e| AppError::io(&path, e))?;\n    }\n\n    Ok(())\n}\n\n/// 从 .env 格式转换为 Provider.settings_config (JSON Value)\npub fn env_to_json(env_map: &HashMap<String, String>) -> Value {\n    let mut json_map = serde_json::Map::new();\n\n    for (key, value) in env_map {\n        json_map.insert(key.clone(), Value::String(value.clone()));\n    }\n\n    serde_json::json!({ \"env\": json_map })\n}\n\n/// 从 Provider.settings_config (JSON Value) 提取 .env 格式\npub fn json_to_env(settings: &Value) -> Result<HashMap<String, String>, AppError> {\n    let mut env_map = HashMap::new();\n\n    if let Some(env_obj) = settings.get(\"env\").and_then(|v| v.as_object()) {\n        for (key, value) in env_obj {\n            if let Some(val_str) = value.as_str() {\n                env_map.insert(key.clone(), val_str.to_string());\n            }\n        }\n    }\n\n    Ok(env_map)\n}\n\n/// 验证 Gemini 配置的基本结构\n///\n/// 此函数只验证配置的基本格式，不强制要求 GEMINI_API_KEY。\n/// 这允许用户先创建供应商配置，稍后再填写 API Key。\n///\n/// API Key 的验证会在切换供应商时进行（通过 `validate_gemini_settings_strict`）。\npub fn validate_gemini_settings(settings: &Value) -> Result<(), AppError> {\n    // 只验证基本结构，不强制要求 GEMINI_API_KEY\n    // 如果有 env 字段，验证它是一个对象\n    if let Some(env) = settings.get(\"env\") {\n        if !env.is_object() {\n            return Err(AppError::localized(\n                \"gemini.validation.invalid_env\",\n                \"Gemini 配置格式错误: env 必须是对象\",\n                \"Gemini config invalid: env must be an object\",\n            ));\n        }\n    }\n\n    // 如果有 config 字段，验证它是对象或 null\n    if let Some(config) = settings.get(\"config\") {\n        if !(config.is_object() || config.is_null()) {\n            return Err(AppError::localized(\n                \"gemini.validation.invalid_config\",\n                \"Gemini 配置格式错误: config 必须是对象\",\n                \"Gemini config invalid: config must be an object\",\n            ));\n        }\n    }\n\n    Ok(())\n}\n\n/// 严格验证 Gemini 配置（要求必需字段）\n///\n/// 此函数在切换供应商时使用，确保配置包含所有必需的字段。\n/// 对于需要 API Key 的供应商（如 PackyCode），会验证 GEMINI_API_KEY 字段。\npub fn validate_gemini_settings_strict(settings: &Value) -> Result<(), AppError> {\n    // 先做基础格式验证（包含 env/config 类型）\n    validate_gemini_settings(settings)?;\n\n    let env_map = json_to_env(settings)?;\n\n    // 如果 env 为空，表示使用 OAuth（如 Google 官方），跳过验证\n    if env_map.is_empty() {\n        return Ok(());\n    }\n\n    // 如果 env 不为空，检查必需字段 GEMINI_API_KEY\n    if !env_map.contains_key(\"GEMINI_API_KEY\") {\n        return Err(AppError::localized(\n            \"gemini.validation.missing_api_key\",\n            \"Gemini 配置缺少必需字段: GEMINI_API_KEY\",\n            \"Gemini config missing required field: GEMINI_API_KEY\",\n        ));\n    }\n\n    Ok(())\n}\n\n/// 获取 Gemini settings.json 文件路径\n///\n/// 返回路径：`~/.gemini/settings.json`（与 `.env` 文件同级）\npub fn get_gemini_settings_path() -> PathBuf {\n    get_gemini_dir().join(\"settings.json\")\n}\n\n/// 更新 Gemini 目录 settings.json 中的 security.auth.selectedType 字段\n///\n/// 此函数会：\n/// 1. 读取现有的 settings.json（如果存在）\n/// 2. 只更新 `security.auth.selectedType` 字段，保留其他所有字段\n/// 3. 原子性写入文件\n///\n/// # 参数\n/// - `selected_type`: 要设置的 selectedType 值（如 \"gemini-api-key\" 或 \"oauth-personal\"）\nfn update_selected_type(selected_type: &str) -> Result<(), AppError> {\n    let settings_path = get_gemini_settings_path();\n\n    // 确保目录存在\n    if let Some(parent) = settings_path.parent() {\n        fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;\n    }\n\n    // 读取现有的 settings.json（如果存在）\n    let mut settings_content = if settings_path.exists() {\n        let content =\n            fs::read_to_string(&settings_path).map_err(|e| AppError::io(&settings_path, e))?;\n        serde_json::from_str::<Value>(&content).unwrap_or_else(|_| serde_json::json!({}))\n    } else {\n        serde_json::json!({})\n    };\n\n    // 只更新 security.auth.selectedType 字段\n    if let Some(obj) = settings_content.as_object_mut() {\n        let security = obj\n            .entry(\"security\")\n            .or_insert_with(|| serde_json::json!({}));\n\n        if let Some(security_obj) = security.as_object_mut() {\n            let auth = security_obj\n                .entry(\"auth\")\n                .or_insert_with(|| serde_json::json!({}));\n\n            if let Some(auth_obj) = auth.as_object_mut() {\n                auth_obj.insert(\n                    \"selectedType\".to_string(),\n                    Value::String(selected_type.to_string()),\n                );\n            }\n        }\n    }\n\n    // 写入文件\n    crate::config::write_json_file(&settings_path, &settings_content)?;\n\n    Ok(())\n}\n\n/// 为 Packycode Gemini 供应商写入 settings.json\n///\n/// 设置 `~/.gemini/settings.json` 中的：\n/// ```json\n/// {\n///   \"security\": {\n///     \"auth\": {\n///       \"selectedType\": \"gemini-api-key\"\n///     }\n///   }\n/// }\n/// ```\n///\n/// 保留文件中的其他所有字段。\npub fn write_packycode_settings() -> Result<(), AppError> {\n    update_selected_type(\"gemini-api-key\")\n}\n\n/// 为 Google 官方 Gemini 供应商写入 settings.json（OAuth 模式）\n///\n/// 设置 `~/.gemini/settings.json` 中的：\n/// ```json\n/// {\n///   \"security\": {\n///     \"auth\": {\n///       \"selectedType\": \"oauth-personal\"\n///     }\n///   }\n/// }\n/// ```\n///\n/// 保留文件中的其他所有字段。\npub fn write_google_oauth_settings() -> Result<(), AppError> {\n    update_selected_type(\"oauth-personal\")\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_parse_env_file() {\n        let content = r#\"\n# Comment line\nGOOGLE_GEMINI_BASE_URL=https://example.com\nGEMINI_API_KEY=sk-test123\nGEMINI_MODEL=gemini-3-pro-preview\n\n# Another comment\n\"#;\n\n        let map = parse_env_file(content);\n\n        assert_eq!(map.len(), 3);\n        assert_eq!(\n            map.get(\"GOOGLE_GEMINI_BASE_URL\"),\n            Some(&\"https://example.com\".to_string())\n        );\n        assert_eq!(map.get(\"GEMINI_API_KEY\"), Some(&\"sk-test123\".to_string()));\n        assert_eq!(\n            map.get(\"GEMINI_MODEL\"),\n            Some(&\"gemini-3-pro-preview\".to_string())\n        );\n    }\n\n    #[test]\n    fn test_serialize_env_file() {\n        let mut map = HashMap::new();\n        map.insert(\"GEMINI_API_KEY\".to_string(), \"sk-test\".to_string());\n        map.insert(\n            \"GEMINI_MODEL\".to_string(),\n            \"gemini-3-pro-preview\".to_string(),\n        );\n\n        let content = serialize_env_file(&map);\n\n        assert!(content.contains(\"GEMINI_API_KEY=sk-test\"));\n        assert!(content.contains(\"GEMINI_MODEL=gemini-3-pro-preview\"));\n    }\n\n    #[test]\n    fn test_env_json_conversion() {\n        let mut env_map = HashMap::new();\n        env_map.insert(\"GEMINI_API_KEY\".to_string(), \"test-key\".to_string());\n\n        let json = env_to_json(&env_map);\n        let converted = json_to_env(&json).unwrap();\n\n        assert_eq!(\n            converted.get(\"GEMINI_API_KEY\"),\n            Some(&\"test-key\".to_string())\n        );\n    }\n\n    #[test]\n    fn test_parse_env_file_strict_success() {\n        // 测试严格模式下正常解析\n        let content = r#\"\n# Comment line\nGOOGLE_GEMINI_BASE_URL=https://example.com\nGEMINI_API_KEY=sk-test123\nGEMINI_MODEL=gemini-3-pro-preview\n\n# Another comment\n\"#;\n\n        let result = parse_env_file_strict(content);\n        assert!(result.is_ok());\n\n        let map = result.unwrap();\n        assert_eq!(map.len(), 3);\n        assert_eq!(\n            map.get(\"GOOGLE_GEMINI_BASE_URL\"),\n            Some(&\"https://example.com\".to_string())\n        );\n        assert_eq!(map.get(\"GEMINI_API_KEY\"), Some(&\"sk-test123\".to_string()));\n        assert_eq!(\n            map.get(\"GEMINI_MODEL\"),\n            Some(&\"gemini-3-pro-preview\".to_string())\n        );\n    }\n\n    #[test]\n    fn test_parse_env_file_strict_missing_equals() {\n        // 测试严格模式下检测缺少 = 的行\n        let content = \"GOOGLE_GEMINI_BASE_URL=https://example.com\nINVALID_LINE_WITHOUT_EQUALS\nGEMINI_API_KEY=sk-test123\";\n\n        let result = parse_env_file_strict(content);\n        assert!(result.is_err());\n\n        let err = result.unwrap_err();\n        let err_msg = format!(\"{err:?}\");\n        assert!(err_msg.contains(\"第 2 行\") || err_msg.contains(\"line 2\"));\n        assert!(err_msg.contains(\"INVALID_LINE_WITHOUT_EQUALS\"));\n    }\n\n    #[test]\n    fn test_parse_env_file_strict_empty_key() {\n        // 测试严格模式下检测空 key\n        let content = \"GOOGLE_GEMINI_BASE_URL=https://example.com\n=value_without_key\nGEMINI_API_KEY=sk-test123\";\n\n        let result = parse_env_file_strict(content);\n        assert!(result.is_err());\n\n        let err = result.unwrap_err();\n        let err_msg = format!(\"{err:?}\");\n        assert!(err_msg.contains(\"第 2 行\") || err_msg.contains(\"line 2\"));\n        assert!(err_msg.contains(\"empty\") || err_msg.contains(\"空\"));\n    }\n\n    #[test]\n    fn test_parse_env_file_strict_invalid_key_characters() {\n        // 测试严格模式下检测无效字符（如空格、特殊符号）\n        let content = \"GOOGLE_GEMINI_BASE_URL=https://example.com\nINVALID KEY WITH SPACES=value\nGEMINI_API_KEY=sk-test123\";\n\n        let result = parse_env_file_strict(content);\n        assert!(result.is_err());\n\n        let err = result.unwrap_err();\n        let err_msg = format!(\"{err:?}\");\n        assert!(err_msg.contains(\"第 2 行\") || err_msg.contains(\"line 2\"));\n        assert!(err_msg.contains(\"INVALID KEY WITH SPACES\"));\n    }\n\n    #[test]\n    fn test_parse_env_file_lax_vs_strict() {\n        // 测试宽松模式和严格模式的差异\n        let content = \"VALID_KEY=value\nINVALID LINE\nKEY_WITH-DASH=value\";\n\n        // 宽松模式：跳过无效行，继续解析\n        let lax_result = parse_env_file(content);\n        assert_eq!(lax_result.len(), 1); // 只有 VALID_KEY\n        assert_eq!(lax_result.get(\"VALID_KEY\"), Some(&\"value\".to_string()));\n\n        // 严格模式：遇到无效行立即返回错误\n        let strict_result = parse_env_file_strict(content);\n        assert!(strict_result.is_err());\n    }\n\n    #[test]\n    fn test_packycode_settings_structure() {\n        // 验证 Packycode settings.json 的结构正确\n        let settings_content = serde_json::json!({\n            \"security\": {\n                \"auth\": {\n                    \"selectedType\": \"gemini-api-key\"\n                }\n            }\n        });\n\n        assert_eq!(\n            settings_content[\"security\"][\"auth\"][\"selectedType\"],\n            \"gemini-api-key\"\n        );\n    }\n\n    #[test]\n    fn test_packycode_settings_merge() {\n        // 测试合并逻辑：应该保留其他字段\n        let mut existing_settings = serde_json::json!({\n            \"otherField\": \"should-be-kept\",\n            \"security\": {\n                \"otherSetting\": \"also-kept\",\n                \"auth\": {\n                    \"otherAuth\": \"preserved\"\n                }\n            }\n        });\n\n        // 模拟更新 selectedType\n        if let Some(obj) = existing_settings.as_object_mut() {\n            let security = obj\n                .entry(\"security\")\n                .or_insert_with(|| serde_json::json!({}));\n\n            if let Some(security_obj) = security.as_object_mut() {\n                let auth = security_obj\n                    .entry(\"auth\")\n                    .or_insert_with(|| serde_json::json!({}));\n\n                if let Some(auth_obj) = auth.as_object_mut() {\n                    auth_obj.insert(\n                        \"selectedType\".to_string(),\n                        Value::String(\"gemini-api-key\".to_string()),\n                    );\n                }\n            }\n        }\n\n        // 验证所有字段都被保留\n        assert_eq!(existing_settings[\"otherField\"], \"should-be-kept\");\n        assert_eq!(existing_settings[\"security\"][\"otherSetting\"], \"also-kept\");\n        assert_eq!(\n            existing_settings[\"security\"][\"auth\"][\"otherAuth\"],\n            \"preserved\"\n        );\n        assert_eq!(\n            existing_settings[\"security\"][\"auth\"][\"selectedType\"],\n            \"gemini-api-key\"\n        );\n    }\n\n    #[test]\n    fn test_google_oauth_settings_structure() {\n        // 验证 Google OAuth settings.json 的结构正确\n        let settings_content = serde_json::json!({\n            \"security\": {\n                \"auth\": {\n                    \"selectedType\": \"oauth-personal\"\n                }\n            }\n        });\n\n        assert_eq!(\n            settings_content[\"security\"][\"auth\"][\"selectedType\"],\n            \"oauth-personal\"\n        );\n    }\n\n    #[test]\n    fn test_validate_empty_env_for_oauth() {\n        // 测试空 env（Google 官方 OAuth）可以通过基本验证\n        let settings = serde_json::json!({\n            \"env\": {}\n        });\n\n        assert!(validate_gemini_settings(&settings).is_ok());\n        // 严格验证也应该通过（空 env 表示 OAuth）\n        assert!(validate_gemini_settings_strict(&settings).is_ok());\n    }\n\n    #[test]\n    fn test_validate_env_with_api_key() {\n        // 测试有 API Key 的配置可以通过验证\n        let settings = serde_json::json!({\n            \"env\": {\n                \"GEMINI_API_KEY\": \"sk-test123\",\n                \"GEMINI_MODEL\": \"gemini-3-pro-preview\"\n            }\n        });\n\n        assert!(validate_gemini_settings(&settings).is_ok());\n        assert!(validate_gemini_settings_strict(&settings).is_ok());\n    }\n\n    #[test]\n    fn test_validate_env_without_api_key_relaxed() {\n        // 测试缺少 API Key 的非空配置在基本验证中可以通过（用户稍后填写）\n        let settings = serde_json::json!({\n            \"env\": {\n                \"GEMINI_MODEL\": \"gemini-3-pro-preview\"\n            }\n        });\n\n        // 基本验证应该通过（允许稍后填写 API Key）\n        assert!(validate_gemini_settings(&settings).is_ok());\n        // 严格验证应该失败（切换时要求完整配置）\n        assert!(validate_gemini_settings_strict(&settings).is_err());\n    }\n\n    #[test]\n    fn test_validate_invalid_env_type() {\n        // 测试 env 不是对象时会失败\n        let settings = serde_json::json!({\n            \"env\": \"invalid_string\"\n        });\n\n        assert!(validate_gemini_settings(&settings).is_err());\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/gemini_mcp.rs",
    "content": "use serde_json::{Map, Value};\nuse std::fs;\nuse std::path::{Path, PathBuf};\n\nuse crate::config::atomic_write;\nuse crate::error::AppError;\nuse crate::gemini_config::get_gemini_settings_path;\n\n/// 获取 Gemini MCP 配置文件路径（~/.gemini/settings.json）\nfn user_config_path() -> PathBuf {\n    get_gemini_settings_path()\n}\n\nfn read_json_value(path: &Path) -> Result<Value, AppError> {\n    if !path.exists() {\n        return Ok(serde_json::json!({}));\n    }\n    let content = fs::read_to_string(path).map_err(|e| AppError::io(path, e))?;\n    let value: Value = serde_json::from_str(&content).map_err(|e| AppError::json(path, e))?;\n    Ok(value)\n}\n\nfn write_json_value(path: &Path, value: &Value) -> Result<(), AppError> {\n    if let Some(parent) = path.parent() {\n        fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;\n    }\n    let json =\n        serde_json::to_string_pretty(value).map_err(|e| AppError::JsonSerialize { source: e })?;\n    atomic_write(path, json.as_bytes())\n}\n\n/// 读取 Gemini settings.json 中的 mcpServers 映射\n///\n/// 执行反向格式转换以保持与统一 MCP 结构的兼容性：\n/// - httpUrl → url + type: \"http\"\n/// - 仅有 url 字段 → 补齐 type: \"sse\"（Gemini 以字段名推断传输类型）\n/// - 仅有 command 字段 → 补齐 type: \"stdio\"\npub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>, AppError> {\n    let path = user_config_path();\n    if !path.exists() {\n        return Ok(std::collections::HashMap::new());\n    }\n\n    let root = read_json_value(&path)?;\n    let mut servers: std::collections::HashMap<String, Value> = root\n        .get(\"mcpServers\")\n        .and_then(|v| v.as_object())\n        .map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect())\n        .unwrap_or_default();\n\n    // 反向格式转换：Gemini 特有格式 → 统一 MCP 格式\n    for (_, spec) in servers.iter_mut() {\n        if let Some(obj) = spec.as_object_mut() {\n            // httpUrl → url + type: \"http\"\n            if let Some(http_url) = obj.remove(\"httpUrl\") {\n                obj.insert(\"url\".to_string(), http_url);\n                obj.insert(\"type\".to_string(), Value::String(\"http\".to_string()));\n            }\n\n            // Gemini CLI 不使用 type 字段：这里补齐成统一结构，便于校验与导入\n            if obj.get(\"type\").is_none() {\n                if obj.contains_key(\"command\") {\n                    obj.insert(\"type\".to_string(), Value::String(\"stdio\".to_string()));\n                } else if obj.contains_key(\"url\") {\n                    obj.insert(\"type\".to_string(), Value::String(\"sse\".to_string()));\n                }\n            }\n        }\n    }\n\n    Ok(servers)\n}\n\n/// 将给定的启用 MCP 服务器映射写入到 Gemini settings.json 的 mcpServers 字段\n/// 仅覆盖 mcpServers，其他字段保持不变\npub fn set_mcp_servers_map(\n    servers: &std::collections::HashMap<String, Value>,\n) -> Result<(), AppError> {\n    let path = user_config_path();\n    let mut root = if path.exists() {\n        read_json_value(&path)?\n    } else {\n        serde_json::json!({})\n    };\n\n    // 构建 mcpServers 对象：移除 UI 辅助字段（enabled/source），仅保留实际 MCP 规范\n    let mut out: Map<String, Value> = Map::new();\n    for (id, spec) in servers.iter() {\n        let mut obj = if let Some(map) = spec.as_object() {\n            map.clone()\n        } else {\n            return Err(AppError::McpValidation(format!(\n                \"MCP 服务器 '{id}' 不是对象\"\n            )));\n        };\n\n        // 提取 server 字段（如果存在）\n        if let Some(server_val) = obj.remove(\"server\") {\n            let server_obj = server_val.as_object().cloned().ok_or_else(|| {\n                AppError::McpValidation(format!(\"MCP 服务器 '{id}' server 字段不是对象\"))\n            })?;\n            obj = server_obj;\n        }\n\n        // Gemini CLI 格式转换：\n        // - Gemini 不使用 \"type\" 字段（从字段名推断传输类型）\n        // - HTTP 使用 \"httpUrl\" 字段，SSE 使用 \"url\" 字段\n        let transport_type = obj.get(\"type\").and_then(|v| v.as_str());\n        if transport_type == Some(\"http\") {\n            // HTTP streaming: 将 \"url\" 重命名为 \"httpUrl\"\n            if let Some(url_value) = obj.remove(\"url\") {\n                obj.insert(\"httpUrl\".to_string(), url_value);\n            }\n        }\n        // SSE 保持 \"url\" 字段不变\n\n        // 移除 UI 辅助字段和 type 字段（Gemini 不需要）\n        obj.remove(\"type\");\n        obj.remove(\"enabled\");\n        obj.remove(\"source\");\n        obj.remove(\"id\");\n        obj.remove(\"name\");\n        obj.remove(\"description\");\n        obj.remove(\"tags\");\n        obj.remove(\"homepage\");\n        obj.remove(\"docs\");\n\n        // Timeout 转换：Claude/Codex 使用 startup_timeout_sec/tool_timeout_sec\n        // Gemini CLI 只支持 timeout（单位 ms）\n        // 默认值：startup=10s, tool=60s\n        const DEFAULT_STARTUP_MS: u64 = 10_000;\n        const DEFAULT_TOOL_MS: u64 = 60_000;\n\n        let extract_timeout =\n            |obj: &mut Map<String, Value>, key: &str, multiplier: u64| -> Option<u64> {\n                obj.remove(key).and_then(|val| {\n                    val.as_u64()\n                        .map(|n| n * multiplier)\n                        .or_else(|| val.as_f64().map(|f| (f * multiplier as f64) as u64))\n                })\n            };\n\n        // 分别收集 startup 和 tool timeout，未设置时使用默认值\n        let startup_ms = extract_timeout(&mut obj, \"startup_timeout_sec\", 1000)\n            .or_else(|| extract_timeout(&mut obj, \"startup_timeout_ms\", 1))\n            .unwrap_or(DEFAULT_STARTUP_MS);\n        let tool_ms = extract_timeout(&mut obj, \"tool_timeout_sec\", 1000)\n            .or_else(|| extract_timeout(&mut obj, \"tool_timeout_ms\", 1))\n            .unwrap_or(DEFAULT_TOOL_MS);\n\n        // 取最大值作为 Gemini timeout\n        let final_timeout = startup_ms.max(tool_ms);\n        obj.insert(\"timeout\".to_string(), Value::Number(final_timeout.into()));\n\n        out.insert(id.clone(), Value::Object(obj));\n    }\n\n    {\n        let obj = root\n            .as_object_mut()\n            .ok_or_else(|| AppError::Config(\"~/.gemini/settings.json 根必须是对象\".into()))?;\n        obj.insert(\"mcpServers\".into(), Value::Object(out));\n    }\n\n    write_json_value(&path, &root)?;\n    Ok(())\n}\n"
  },
  {
    "path": "src-tauri/src/init_status.rs",
    "content": "use serde::Serialize;\nuse std::sync::{OnceLock, RwLock};\n\n#[derive(Debug, Clone, Serialize)]\npub struct InitErrorPayload {\n    pub path: String,\n    pub error: String,\n}\n\nstatic INIT_ERROR: OnceLock<RwLock<Option<InitErrorPayload>>> = OnceLock::new();\n\nfn cell() -> &'static RwLock<Option<InitErrorPayload>> {\n    INIT_ERROR.get_or_init(|| RwLock::new(None))\n}\n\n#[allow(dead_code)]\npub fn set_init_error(payload: InitErrorPayload) {\n    #[allow(clippy::unwrap_used)]\n    if let Ok(mut guard) = cell().write() {\n        *guard = Some(payload);\n    }\n}\n\npub fn get_init_error() -> Option<InitErrorPayload> {\n    cell().read().ok()?.clone()\n}\n\n// ============================================================\n// 迁移结果状态\n// ============================================================\n\nstatic MIGRATION_SUCCESS: OnceLock<RwLock<bool>> = OnceLock::new();\n\nfn migration_cell() -> &'static RwLock<bool> {\n    MIGRATION_SUCCESS.get_or_init(|| RwLock::new(false))\n}\n\npub fn set_migration_success() {\n    if let Ok(mut guard) = migration_cell().write() {\n        *guard = true;\n    }\n}\n\n/// 获取并消费迁移成功状态（只返回一次 true，之后返回 false）\npub fn take_migration_success() -> bool {\n    if let Ok(mut guard) = migration_cell().write() {\n        let val = *guard;\n        *guard = false;\n        val\n    } else {\n        false\n    }\n}\n\n// ============================================================\n// Skills SSOT 迁移结果状态\n// ============================================================\n\n#[derive(Debug, Clone, Serialize)]\npub struct SkillsMigrationPayload {\n    pub count: usize,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub error: Option<String>,\n}\n\nstatic SKILLS_MIGRATION_RESULT: OnceLock<RwLock<Option<SkillsMigrationPayload>>> = OnceLock::new();\n\nfn skills_migration_cell() -> &'static RwLock<Option<SkillsMigrationPayload>> {\n    SKILLS_MIGRATION_RESULT.get_or_init(|| RwLock::new(None))\n}\n\npub fn set_skills_migration_result(count: usize) {\n    if let Ok(mut guard) = skills_migration_cell().write() {\n        *guard = Some(SkillsMigrationPayload { count, error: None });\n    }\n}\n\npub fn set_skills_migration_error(error: String) {\n    if let Ok(mut guard) = skills_migration_cell().write() {\n        *guard = Some(SkillsMigrationPayload {\n            count: 0,\n            error: Some(error),\n        });\n    }\n}\n\n/// 获取并消费 Skills 迁移结果（只返回一次 Some，之后返回 None）\npub fn take_skills_migration_result() -> Option<SkillsMigrationPayload> {\n    if let Ok(mut guard) = skills_migration_cell().write() {\n        guard.take()\n    } else {\n        None\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn init_error_roundtrip() {\n        let payload = InitErrorPayload {\n            path: \"/tmp/config.json\".into(),\n            error: \"broken json\".into(),\n        };\n        set_init_error(payload.clone());\n        let got = get_init_error().expect(\"should get payload back\");\n        assert_eq!(got.path, payload.path);\n        assert_eq!(got.error, payload.error);\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/lib.rs",
    "content": "mod app_config;\nmod app_store;\nmod auto_launch;\nmod claude_mcp;\nmod claude_plugin;\nmod codex_config;\nmod commands;\nmod config;\nmod database;\nmod deeplink;\nmod error;\nmod gemini_config;\nmod gemini_mcp;\nmod init_status;\nmod mcp;\nmod openclaw_config;\nmod opencode_config;\nmod panic_hook;\nmod prompt;\nmod prompt_files;\nmod provider;\nmod provider_defaults;\nmod proxy;\nmod services;\nmod session_manager;\nmod settings;\nmod store;\n\nmod tray;\nmod usage_script;\n\npub use app_config::{AppType, InstalledSkill, McpApps, McpServer, MultiAppConfig, SkillApps};\npub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic};\npub use commands::open_provider_terminal;\npub use commands::*;\npub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};\npub use database::Database;\npub use deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest};\npub use error::AppError;\npub use mcp::{\n    import_from_claude, import_from_codex, import_from_gemini, remove_server_from_claude,\n    remove_server_from_codex, remove_server_from_gemini, sync_enabled_to_claude,\n    sync_enabled_to_codex, sync_enabled_to_gemini, sync_single_server_to_claude,\n    sync_single_server_to_codex, sync_single_server_to_gemini,\n};\npub use provider::{Provider, ProviderMeta};\npub use services::{\n    skill::{migrate_skills_to_ssot, ImportSkillSelection},\n    ConfigService, EndpointLatency, McpService, PromptService, ProviderService, ProxyService,\n    SkillService, SpeedtestService,\n};\npub use settings::{update_settings, AppSettings};\npub use store::AppState;\nuse tauri_plugin_deep_link::DeepLinkExt;\nuse tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};\n\nuse std::sync::Arc;\n#[cfg(target_os = \"macos\")]\nuse tauri::image::Image;\nuse tauri::tray::{TrayIconBuilder, TrayIconEvent};\nuse tauri::RunEvent;\nuse tauri::{Emitter, Manager};\n\nfn redact_url_for_log(url_str: &str) -> String {\n    match url::Url::parse(url_str) {\n        Ok(url) => {\n            let mut output = format!(\"{}://\", url.scheme());\n            if let Some(host) = url.host_str() {\n                output.push_str(host);\n            }\n            output.push_str(url.path());\n\n            let mut keys: Vec<String> = url.query_pairs().map(|(k, _)| k.to_string()).collect();\n            keys.sort();\n            keys.dedup();\n\n            if !keys.is_empty() {\n                output.push_str(\"?[keys:\");\n                output.push_str(&keys.join(\",\"));\n                output.push(']');\n            }\n\n            output\n        }\n        Err(_) => {\n            let base = url_str.split('#').next().unwrap_or(url_str);\n            match base.split_once('?') {\n                Some((prefix, _)) => format!(\"{prefix}?[redacted]\"),\n                None => base.to_string(),\n            }\n        }\n    }\n}\n\n/// 统一处理 ccswitch:// 深链接 URL\n///\n/// - 解析 URL\n/// - 向前端发射 `deeplink-import` / `deeplink-error` 事件\n/// - 可选：在成功时聚焦主窗口\nfn handle_deeplink_url(\n    app: &tauri::AppHandle,\n    url_str: &str,\n    focus_main_window: bool,\n    source: &str,\n) -> bool {\n    if !url_str.starts_with(\"ccswitch://\") {\n        return false;\n    }\n\n    let redacted_url = redact_url_for_log(url_str);\n    log::info!(\"✓ Deep link URL detected from {source}: {redacted_url}\");\n    log::debug!(\"Deep link URL (raw) from {source}: {url_str}\");\n\n    match crate::deeplink::parse_deeplink_url(url_str) {\n        Ok(request) => {\n            log::info!(\n                \"✓ Successfully parsed deep link: resource={}, app={:?}, name={:?}\",\n                request.resource,\n                request.app,\n                request.name\n            );\n\n            if let Err(e) = app.emit(\"deeplink-import\", &request) {\n                log::error!(\"✗ Failed to emit deeplink-import event: {e}\");\n            } else {\n                log::info!(\"✓ Emitted deeplink-import event to frontend\");\n            }\n\n            if focus_main_window {\n                if let Some(window) = app.get_webview_window(\"main\") {\n                    let _ = window.unminimize();\n                    let _ = window.show();\n                    let _ = window.set_focus();\n                    log::info!(\"✓ Window shown and focused\");\n                }\n            }\n        }\n        Err(e) => {\n            log::error!(\"✗ Failed to parse deep link URL: {e}\");\n\n            if let Err(emit_err) = app.emit(\n                \"deeplink-error\",\n                serde_json::json!({\n                    \"url\": url_str,\n                    \"error\": e.to_string()\n                }),\n            ) {\n                log::error!(\"✗ Failed to emit deeplink-error event: {emit_err}\");\n            }\n        }\n    }\n\n    true\n}\n\n/// 更新托盘菜单的Tauri命令\n#[tauri::command]\nasync fn update_tray_menu(\n    app: tauri::AppHandle,\n    state: tauri::State<'_, AppState>,\n) -> Result<bool, String> {\n    match tray::create_tray_menu(&app, state.inner()) {\n        Ok(new_menu) => {\n            if let Some(tray) = app.tray_by_id(\"main\") {\n                tray.set_menu(Some(new_menu))\n                    .map_err(|e| format!(\"更新托盘菜单失败: {e}\"))?;\n                return Ok(true);\n            }\n            Ok(false)\n        }\n        Err(err) => {\n            log::error!(\"创建托盘菜单失败: {err}\");\n            Ok(false)\n        }\n    }\n}\n\n#[cfg(target_os = \"macos\")]\nfn macos_tray_icon() -> Option<Image<'static>> {\n    const ICON_BYTES: &[u8] = include_bytes!(\"../icons/tray/macos/statusbar_template_3x.png\");\n\n    match Image::from_bytes(ICON_BYTES) {\n        Ok(icon) => Some(icon),\n        Err(err) => {\n            log::warn!(\"Failed to load macOS tray icon: {err}\");\n            None\n        }\n    }\n}\n\n#[cfg_attr(mobile, tauri::mobile_entry_point)]\npub fn run() {\n    // 设置 panic hook，在应用崩溃时记录日志到 <app_config_dir>/crash.log（默认 ~/.cc-switch/crash.log）\n    panic_hook::setup_panic_hook();\n\n    let mut builder = tauri::Builder::default();\n\n    #[cfg(any(target_os = \"macos\", target_os = \"windows\", target_os = \"linux\"))]\n    {\n        builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {\n            log::info!(\"=== Single Instance Callback Triggered ===\");\n            log::debug!(\"Args count: {}\", args.len());\n            for (i, arg) in args.iter().enumerate() {\n                log::debug!(\"  arg[{i}]: {}\", redact_url_for_log(arg));\n            }\n\n            // Check for deep link URL in args (mainly for Windows/Linux command line)\n            let mut found_deeplink = false;\n            for arg in &args {\n                if handle_deeplink_url(app, arg, false, \"single_instance args\") {\n                    found_deeplink = true;\n                    break;\n                }\n            }\n\n            if !found_deeplink {\n                log::info!(\"ℹ No deep link URL found in args (this is expected on macOS when launched via system)\");\n            }\n\n            // Show and focus window regardless\n            if let Some(window) = app.get_webview_window(\"main\") {\n                let _ = window.unminimize();\n                let _ = window.show();\n                let _ = window.set_focus();\n            }\n        }));\n    }\n\n    let builder = builder\n        // 注册 deep-link 插件（处理 macOS AppleEvent 和其他平台的深链接）\n        .plugin(tauri_plugin_deep_link::init())\n        // 拦截窗口关闭：根据设置决定是否最小化到托盘\n        .on_window_event(|window, event| {\n            if let tauri::WindowEvent::CloseRequested { api, .. } = event {\n                let settings = crate::settings::get_settings();\n\n                if settings.minimize_to_tray_on_close {\n                    api.prevent_close();\n                    let _ = window.hide();\n                    #[cfg(target_os = \"windows\")]\n                    {\n                        let _ = window.set_skip_taskbar(true);\n                    }\n                    #[cfg(target_os = \"macos\")]\n                    {\n                        tray::apply_tray_policy(window.app_handle(), false);\n                    }\n                } else {\n                    window.app_handle().exit(0);\n                }\n            }\n        })\n        .plugin(tauri_plugin_process::init())\n        .plugin(tauri_plugin_dialog::init())\n        .plugin(tauri_plugin_opener::init())\n        .plugin(tauri_plugin_store::Builder::new().build())\n        .setup(|app| {\n            // 预先刷新 Store 覆盖配置，确保后续路径读取正确（日志/数据库等）\n            app_store::refresh_app_config_dir_override(app.handle());\n            panic_hook::init_app_config_dir(crate::config::get_app_config_dir());\n\n            // 注册 Updater 插件（桌面端）\n            #[cfg(desktop)]\n            {\n                if let Err(e) = app\n                    .handle()\n                    .plugin(tauri_plugin_updater::Builder::new().build())\n                {\n                    // 若配置不完整（如缺少 pubkey），跳过 Updater 而不中断应用\n                    log::warn!(\"初始化 Updater 插件失败，已跳过：{e}\");\n                }\n            }\n            // 初始化日志（单文件输出到 <app_config_dir>/logs/cc-switch.log）\n            {\n                use tauri_plugin_log::{RotationStrategy, Target, TargetKind, TimezoneStrategy};\n\n                let log_dir = panic_hook::get_log_dir();\n\n                // 确保日志目录存在\n                if let Err(e) = std::fs::create_dir_all(&log_dir) {\n                    eprintln!(\"创建日志目录失败: {e}\");\n                }\n\n                // 启动时删除旧日志文件，实现单文件覆盖效果\n                let log_file_path = log_dir.join(\"cc-switch.log\");\n                let _ = std::fs::remove_file(&log_file_path);\n\n                app.handle().plugin(\n                    tauri_plugin_log::Builder::default()\n                        // 初始化为 Trace，允许后续通过 log::set_max_level() 动态调整级别\n                        .level(log::LevelFilter::Trace)\n                        .targets([\n                            Target::new(TargetKind::Stdout),\n                            Target::new(TargetKind::Folder {\n                                path: log_dir,\n                                file_name: Some(\"cc-switch\".into()),\n                            }),\n                        ])\n                        // 单文件模式：启动时删除旧文件，达到大小时轮转\n                        // 注意：KeepSome(n) 内部会做 n-2 运算，n=1 会导致 usize 下溢\n                        // KeepSome(2) 是最小安全值，表示不保留轮转文件\n                        .rotation_strategy(RotationStrategy::KeepSome(2))\n                        // 单文件大小限制 1GB\n                        .max_file_size(1024 * 1024 * 1024)\n                        .timezone_strategy(TimezoneStrategy::UseLocal)\n                        .build(),\n                )?;\n            }\n\n            // 初始化数据库\n            let app_config_dir = crate::config::get_app_config_dir();\n            let db_path = app_config_dir.join(\"cc-switch.db\");\n            let json_path = app_config_dir.join(\"config.json\");\n\n            // 检查是否需要从 config.json 迁移到 SQLite\n            let has_json = json_path.exists();\n            let has_db = db_path.exists();\n\n            // 如果需要迁移，先验证 config.json 是否可以加载（在创建数据库之前）\n            // 这样如果加载失败用户选择退出，数据库文件还没被创建，下次可以正常重试\n            let migration_config = if !has_db && has_json {\n                log::info!(\"检测到旧版配置文件，验证配置文件...\");\n\n                // 循环：支持用户重试加载配置文件\n                loop {\n                    match crate::app_config::MultiAppConfig::load() {\n                        Ok(config) => {\n                            log::info!(\"✓ 配置文件加载成功\");\n                            break Some(config);\n                        }\n                        Err(e) => {\n                            log::error!(\"加载旧配置文件失败: {e}\");\n                            // 弹出系统对话框让用户选择\n                            if !show_migration_error_dialog(app.handle(), &e.to_string()) {\n                                // 用户选择退出（此时数据库还没创建，下次启动可以重试）\n                                log::info!(\"用户选择退出程序\");\n                                std::process::exit(1);\n                            }\n                            // 用户选择重试，继续循环\n                            log::info!(\"用户选择重试加载配置文件\");\n                        }\n                    }\n                }\n            } else {\n                None\n            };\n\n            // 现在创建数据库（包含 Schema 迁移）\n            //\n            // 说明：从 v3.8.* 升级的用户通常会走到这里的 SQLite schema 迁移，\n            // 若迁移失败（数据库损坏/权限不足/user_version 过新等），需要给用户明确提示，\n            // 否则表现可能只是“应用打不开/闪退”。\n            let db = loop {\n                match crate::database::Database::init() {\n                    Ok(db) => break Arc::new(db),\n                    Err(e) => {\n                        log::error!(\"Failed to init database: {e}\");\n\n                        if !show_database_init_error_dialog(app.handle(), &db_path, &e.to_string())\n                        {\n                            log::info!(\"用户选择退出程序\");\n                            std::process::exit(1);\n                        }\n\n                        log::info!(\"用户选择重试初始化数据库\");\n                    }\n                }\n            };\n\n            // 如果有预加载的配置，执行迁移\n            if let Some(config) = migration_config {\n                log::info!(\"开始执行数据迁移...\");\n\n                match db.migrate_from_json(&config) {\n                    Ok(_) => {\n                        log::info!(\"✓ 配置迁移成功\");\n                        // 标记迁移成功，供前端显示 Toast\n                        crate::init_status::set_migration_success();\n                        // 归档旧配置文件（重命名而非删除，便于用户恢复）\n                        let archive_path = json_path.with_extension(\"json.migrated\");\n                        if let Err(e) = std::fs::rename(&json_path, &archive_path) {\n                            log::warn!(\"归档旧配置文件失败: {e}\");\n                        } else {\n                            log::info!(\"✓ 旧配置已归档为 config.json.migrated\");\n                        }\n                    }\n                    Err(e) => {\n                        // 配置加载成功但迁移失败的情况极少（磁盘满等），仅记录日志\n                        log::error!(\"配置迁移失败: {e}，将从现有配置导入\");\n                    }\n                }\n            }\n\n            let app_state = AppState::new(db);\n\n            // 设置 AppHandle 用于代理故障转移时的 UI 更新\n            app_state.proxy_service.set_app_handle(app.handle().clone());\n\n            // ============================================================\n            // 按表独立判断的导入逻辑（各类数据独立检查，互不影响）\n            // ============================================================\n\n            // 1. 初始化默认 Skills 仓库（已有内置检查：表非空则跳过）\n            match app_state.db.init_default_skill_repos() {\n                Ok(count) if count > 0 => {\n                    log::info!(\"✓ Initialized {count} default skill repositories\");\n                }\n                Ok(_) => {} // 表非空，静默跳过\n                Err(e) => log::warn!(\"✗ Failed to initialize default skill repos: {e}\"),\n            }\n\n            // 1.1. Skills 统一管理迁移：当数据库迁移到 v3 结构后，自动从各应用目录导入到 SSOT\n            // 触发条件由 schema 迁移设置 settings.skills_ssot_migration_pending = true 控制。\n            match app_state.db.get_setting(\"skills_ssot_migration_pending\") {\n                Ok(Some(flag)) if flag == \"true\" || flag == \"1\" => {\n                    // 安全保护：如果用户已经有 v3 结构的 Skills 数据，就不要自动清空重建。\n                    let has_existing = app_state\n                        .db\n                        .get_all_installed_skills()\n                        .map(|skills| !skills.is_empty())\n                        .unwrap_or(false);\n\n                    if has_existing {\n                        log::info!(\n                            \"Detected skills_ssot_migration_pending but skills table not empty; skipping auto import.\"\n                        );\n                        let _ = app_state\n                            .db\n                            .set_setting(\"skills_ssot_migration_pending\", \"false\");\n                    } else {\n                        match crate::services::skill::migrate_skills_to_ssot(&app_state.db) {\n                            Ok(count) => {\n                                log::info!(\"✓ Auto imported {count} skill(s) into SSOT\");\n                                if count > 0 {\n                                    crate::init_status::set_skills_migration_result(count);\n                                }\n                                let _ = app_state\n                                    .db\n                                    .set_setting(\"skills_ssot_migration_pending\", \"false\");\n                            }\n                            Err(e) => {\n                                log::warn!(\"✗ Failed to auto import legacy skills to SSOT: {e}\");\n                                crate::init_status::set_skills_migration_error(e.to_string());\n                                // 保留 pending 标志，方便下次启动重试\n                            }\n                        }\n                    }\n                }\n                Ok(_) => {} // 未开启迁移标志，静默跳过\n                Err(e) => log::warn!(\"✗ Failed to read skills migration flag: {e}\"),\n            }\n\n            // 2. OMO 配置导入（当数据库中无 OMO provider 时，从本地文件导入）\n            {\n                let has_omo = app_state\n                    .db\n                    .get_all_providers(\"opencode\")\n                    .map(|providers| providers.values().any(|p| p.category.as_deref() == Some(\"omo\")))\n                    .unwrap_or(false);\n                if !has_omo {\n                    match crate::services::OmoService::import_from_local(&app_state, &crate::services::omo::STANDARD) {\n                        Ok(provider) => {\n                            log::info!(\"✓ Imported OMO config from local as provider '{}'\", provider.name);\n                        }\n                        Err(AppError::OmoConfigNotFound) => {\n                            log::debug!(\"○ No OMO config to import\");\n                        }\n                        Err(e) => {\n                            log::warn!(\"✗ Failed to import OMO config from local: {e}\");\n                        }\n                    }\n                }\n            }\n\n            // 2.3 OMO Slim config import (when no omo-slim provider in DB, import from local)\n            {\n                let has_omo_slim = app_state\n                    .db\n                    .get_all_providers(\"opencode\")\n                    .map(|providers| {\n                        providers\n                            .values()\n                            .any(|p| p.category.as_deref() == Some(\"omo-slim\"))\n                    })\n                    .unwrap_or(false);\n                if !has_omo_slim {\n                    match crate::services::OmoService::import_from_local(&app_state, &crate::services::omo::SLIM) {\n                        Ok(provider) => {\n                            log::info!(\n                                \"✓ Imported OMO Slim config from local as provider '{}'\",\n                                provider.name\n                            );\n                        }\n                        Err(AppError::OmoConfigNotFound) => {\n                            log::debug!(\"○ No OMO Slim config to import\");\n                        }\n                        Err(e) => {\n                            log::warn!(\"✗ Failed to import OMO Slim config from local: {e}\");\n                        }\n                    }\n                }\n            }\n\n            // 3. 导入 MCP 服务器配置（表空时触发）\n            if app_state.db.is_mcp_table_empty().unwrap_or(false) {\n                log::info!(\"MCP table empty, importing from live configurations...\");\n\n                match crate::services::mcp::McpService::import_from_claude(&app_state) {\n                    Ok(count) if count > 0 => {\n                        log::info!(\"✓ Imported {count} MCP server(s) from Claude\");\n                    }\n                    Ok(_) => log::debug!(\"○ No Claude MCP servers found to import\"),\n                    Err(e) => log::warn!(\"✗ Failed to import Claude MCP: {e}\"),\n                }\n\n                match crate::services::mcp::McpService::import_from_codex(&app_state) {\n                    Ok(count) if count > 0 => {\n                        log::info!(\"✓ Imported {count} MCP server(s) from Codex\");\n                    }\n                    Ok(_) => log::debug!(\"○ No Codex MCP servers found to import\"),\n                    Err(e) => log::warn!(\"✗ Failed to import Codex MCP: {e}\"),\n                }\n\n                match crate::services::mcp::McpService::import_from_gemini(&app_state) {\n                    Ok(count) if count > 0 => {\n                        log::info!(\"✓ Imported {count} MCP server(s) from Gemini\");\n                    }\n                    Ok(_) => log::debug!(\"○ No Gemini MCP servers found to import\"),\n                    Err(e) => log::warn!(\"✗ Failed to import Gemini MCP: {e}\"),\n                }\n\n                match crate::services::mcp::McpService::import_from_opencode(&app_state) {\n                    Ok(count) if count > 0 => {\n                        log::info!(\"✓ Imported {count} MCP server(s) from OpenCode\");\n                    }\n                    Ok(_) => log::debug!(\"○ No OpenCode MCP servers found to import\"),\n                    Err(e) => log::warn!(\"✗ Failed to import OpenCode MCP: {e}\"),\n                }\n            }\n\n            // 4. 导入提示词文件（表空时触发）\n            if app_state.db.is_prompts_table_empty().unwrap_or(false) {\n                log::info!(\"Prompts table empty, importing from live configurations...\");\n\n                for app in [\n                    crate::app_config::AppType::Claude,\n                    crate::app_config::AppType::Codex,\n                    crate::app_config::AppType::Gemini,\n                    crate::app_config::AppType::OpenCode,\n                    crate::app_config::AppType::OpenClaw,\n                ] {\n                    match crate::services::prompt::PromptService::import_from_file_on_first_launch(\n                        &app_state,\n                        app.clone(),\n                    ) {\n                        Ok(count) if count > 0 => {\n                            log::info!(\"✓ Imported {count} prompt(s) for {}\", app.as_str());\n                        }\n                        Ok(_) => log::debug!(\"○ No prompt file found for {}\", app.as_str()),\n                        Err(e) => log::warn!(\"✗ Failed to import prompt for {}: {e}\", app.as_str()),\n                    }\n                }\n            }\n\n            // 迁移旧的 app_config_dir 配置到 Store\n            if let Err(e) = app_store::migrate_app_config_dir_from_settings(app.handle()) {\n                log::warn!(\"迁移 app_config_dir 失败: {e}\");\n            }\n\n            // 启动阶段不再无条件保存,避免意外覆盖用户配置。\n\n            // 注册 deep-link URL 处理器（使用正确的 DeepLinkExt API）\n            log::info!(\"=== Registering deep-link URL handler ===\");\n\n            // Linux 和 Windows 调试模式需要显式注册\n            #[cfg(any(target_os = \"linux\", all(debug_assertions, windows)))]\n            {\n                #[cfg(target_os = \"linux\")]\n                {\n                    // Use Tauri's path API to get correct path (includes app identifier)\n                    // tauri-plugin-deep-link writes to: ~/.local/share/com.ccswitch.desktop/applications/cc-switch-handler.desktop\n                    // Only register if .desktop file doesn't exist to avoid overwriting user customizations\n                    let should_register = app\n                        .path()\n                        .data_dir()\n                        .map(|d| !d.join(\"applications/cc-switch-handler.desktop\").exists())\n                        .unwrap_or(true);\n\n                    if should_register {\n                        if let Err(e) = app.deep_link().register_all() {\n                            log::error!(\"✗ Failed to register deep link schemes: {}\", e);\n                        } else {\n                            log::info!(\"✓ Deep link schemes registered (Linux)\");\n                        }\n                    } else {\n                        log::info!(\"⊘ Deep link handler already exists, skipping registration\");\n                    }\n                }\n\n                #[cfg(all(debug_assertions, windows))]\n                {\n                    if let Err(e) = app.deep_link().register_all() {\n                        log::error!(\"✗ Failed to register deep link schemes: {}\", e);\n                    } else {\n                        log::info!(\"✓ Deep link schemes registered (Windows debug)\");\n                    }\n                }\n            }\n\n            // 注册 URL 处理回调（所有平台通用）\n            app.deep_link().on_open_url({\n                let app_handle = app.handle().clone();\n                move |event| {\n                    log::info!(\"=== Deep Link Event Received (on_open_url) ===\");\n                    let urls = event.urls();\n                    log::info!(\"Received {} URL(s)\", urls.len());\n\n                    for (i, url) in urls.iter().enumerate() {\n                        let url_str = url.as_str();\n                        log::debug!(\"  URL[{i}]: {}\", redact_url_for_log(url_str));\n\n                        if handle_deeplink_url(&app_handle, url_str, true, \"on_open_url\") {\n                            break; // Process only first ccswitch:// URL\n                        }\n                    }\n                }\n            });\n            log::info!(\"✓ Deep-link URL handler registered\");\n\n            // 创建动态托盘菜单\n            let menu = tray::create_tray_menu(app.handle(), &app_state)?;\n\n            // 构建托盘\n            let mut tray_builder = TrayIconBuilder::with_id(\"main\")\n                .on_tray_icon_event(|_tray, event| match event {\n                    // 左键点击已通过 show_menu_on_left_click(true) 打开菜单，这里不再额外处理\n                    TrayIconEvent::Click { .. } => {}\n                    _ => log::debug!(\"unhandled event {event:?}\"),\n                })\n                .menu(&menu)\n                .on_menu_event(|app, event| {\n                    tray::handle_tray_menu_event(app, &event.id.0);\n                })\n                .show_menu_on_left_click(true);\n\n            // 使用平台对应的托盘图标（macOS 使用模板图标适配深浅色）\n            #[cfg(target_os = \"macos\")]\n            {\n                if let Some(icon) = macos_tray_icon() {\n                    tray_builder = tray_builder.icon(icon).icon_as_template(true);\n                } else if let Some(icon) = app.default_window_icon() {\n                    log::warn!(\"Falling back to default window icon for tray\");\n                    tray_builder = tray_builder.icon(icon.clone());\n                } else {\n                    log::warn!(\"Failed to load macOS tray icon for tray\");\n                }\n            }\n\n            #[cfg(not(target_os = \"macos\"))]\n            {\n                if let Some(icon) = app.default_window_icon() {\n                    tray_builder = tray_builder.icon(icon.clone());\n                } else {\n                    log::warn!(\"Failed to get default window icon for tray\");\n                }\n            }\n\n            let _tray = tray_builder.build(app)?;\n            crate::services::webdav_auto_sync::start_worker(\n                app_state.db.clone(),\n                app.handle().clone(),\n            );\n            // 将同一个实例注入到全局状态，避免重复创建导致的不一致\n            app.manage(app_state);\n\n            // 从数据库加载日志配置并应用\n            {\n                let db = &app.state::<AppState>().db;\n                if let Ok(log_config) = db.get_log_config() {\n                    log::set_max_level(log_config.to_level_filter());\n                    log::info!(\n                        \"已加载日志配置: enabled={}, level={}\",\n                        log_config.enabled,\n                        log_config.level\n                    );\n                }\n            }\n\n            // 初始化 SkillService\n            let skill_service = SkillService::new();\n            app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));\n\n            // 初始化 CopilotAuthManager\n            {\n                use crate::proxy::providers::copilot_auth::CopilotAuthManager;\n                use commands::CopilotAuthState;\n                use tokio::sync::RwLock;\n\n                let app_config_dir = crate::config::get_app_config_dir();\n                let copilot_auth_manager = CopilotAuthManager::new(app_config_dir);\n                app.manage(CopilotAuthState(Arc::new(RwLock::new(copilot_auth_manager))));\n                log::info!(\"✓ CopilotAuthManager initialized\");\n            }\n\n            // 初始化全局出站代理 HTTP 客户端\n            {\n                let db = &app.state::<AppState>().db;\n                let proxy_url = db.get_global_proxy_url().ok().flatten();\n\n                if let Err(e) = crate::proxy::http_client::init(proxy_url.as_deref()) {\n                    log::error!(\n                        \"[GlobalProxy] [GP-005] Failed to initialize with saved config: {e}\"\n                    );\n\n                    // 清除无效的代理配置\n                    if proxy_url.is_some() {\n                        log::warn!(\n                            \"[GlobalProxy] [GP-006] Clearing invalid proxy config from database\"\n                        );\n                        if let Err(clear_err) = db.set_global_proxy_url(None) {\n                            log::error!(\n                                \"[GlobalProxy] [GP-007] Failed to clear invalid config: {clear_err}\"\n                            );\n                        }\n                    }\n\n                    // 使用直连模式重新初始化\n                    if let Err(fallback_err) = crate::proxy::http_client::init(None) {\n                        log::error!(\n                            \"[GlobalProxy] [GP-008] Failed to initialize direct connection: {fallback_err}\"\n                        );\n                    }\n                }\n            }\n\n            // 异常退出恢复 + 代理状态自动恢复\n            let app_handle = app.handle().clone();\n            tauri::async_runtime::spawn(async move {\n                let state = app_handle.state::<AppState>();\n\n                // 检查是否有 Live 备份（表示上次异常退出时可能处于接管状态）\n                let has_backups = match state.db.has_any_live_backup().await {\n                    Ok(v) => v,\n                    Err(e) => {\n                        log::error!(\"检查 Live 备份失败: {e}\");\n                        false\n                    }\n                };\n                // 检查 Live 配置是否仍处于被接管状态（包含占位符）\n                let live_taken_over = state.proxy_service.detect_takeover_in_live_configs();\n\n                if has_backups || live_taken_over {\n                    log::warn!(\"检测到上次异常退出（存在接管残留），正在恢复 Live 配置...\");\n                    if let Err(e) = state.proxy_service.recover_from_crash().await {\n                        log::error!(\"恢复 Live 配置失败: {e}\");\n                    } else {\n                        log::info!(\"Live 配置已恢复\");\n                    }\n                }\n\n                initialize_common_config_snippets(&state);\n\n                // 检查 settings 表中的代理状态，自动恢复代理服务\n                restore_proxy_state_on_startup(&state).await;\n\n                // Periodic backup check (on startup)\n                if let Err(e) = state.db.periodic_backup_if_needed() {\n                    log::warn!(\"Periodic backup failed on startup: {e}\");\n                }\n\n                // Periodic maintenance timer: run once per day while the app is running\n                let db_for_timer = state.db.clone();\n                tauri::async_runtime::spawn(async move {\n                    const PERIODIC_MAINTENANCE_INTERVAL_SECS: u64 = 24 * 60 * 60;\n                    let mut interval = tokio::time::interval(std::time::Duration::from_secs(\n                        PERIODIC_MAINTENANCE_INTERVAL_SECS,\n                    ));\n                    interval.tick().await; // skip immediate first tick (already checked above)\n                    loop {\n                        interval.tick().await;\n                        if let Err(e) = db_for_timer.periodic_backup_if_needed() {\n                            log::warn!(\"Periodic maintenance timer failed: {e}\");\n                        }\n                    }\n                });\n            });\n\n            // Linux: 禁用 WebKitGTK 硬件加速，防止 EGL 初始化失败导致白屏\n            #[cfg(target_os = \"linux\")]\n            {\n                if let Some(window) = app.get_webview_window(\"main\") {\n                    let _ = window.with_webview(|webview| {\n                        use webkit2gtk::{WebViewExt, SettingsExt, HardwareAccelerationPolicy};\n                        let wk_webview = webview.inner();\n                        if let Some(settings) = WebViewExt::settings(&wk_webview) {\n                            SettingsExt::set_hardware_acceleration_policy(&settings, HardwareAccelerationPolicy::Never);\n                            log::info!(\"已禁用 WebKitGTK 硬件加速\");\n                        }\n                    });\n                }\n            }\n\n            // 静默启动：根据设置决定是否显示主窗口\n            let settings = crate::settings::get_settings();\n            if let Some(window) = app.get_webview_window(\"main\") {\n                if settings.silent_startup {\n                    // 静默启动模式：保持窗口隐藏\n                    let _ = window.hide();\n                    #[cfg(target_os = \"windows\")]\n                    let _ = window.set_skip_taskbar(true);\n                    #[cfg(target_os = \"macos\")]\n                    tray::apply_tray_policy(app.handle(), false);\n                    log::info!(\"静默启动模式：主窗口已隐藏\");\n                } else {\n                    // 正常启动模式：显示窗口\n                    let _ = window.show();\n                    log::info!(\"正常启动模式：主窗口已显示\");\n                }\n            }\n\n\n            Ok(())\n        })\n        .invoke_handler(tauri::generate_handler![\n            commands::get_providers,\n            commands::get_current_provider,\n            commands::add_provider,\n            commands::update_provider,\n            commands::delete_provider,\n            commands::remove_provider_from_live_config,\n            commands::switch_provider,\n            commands::import_default_config,\n            commands::get_claude_config_status,\n            commands::get_config_status,\n            commands::get_claude_code_config_path,\n            commands::get_config_dir,\n            commands::open_config_folder,\n            commands::pick_directory,\n            commands::open_external,\n            commands::get_init_error,\n            commands::get_migration_result,\n            commands::get_skills_migration_result,\n            commands::get_app_config_path,\n            commands::open_app_config_folder,\n            commands::get_claude_common_config_snippet,\n            commands::set_claude_common_config_snippet,\n            commands::get_common_config_snippet,\n            commands::set_common_config_snippet,\n            commands::extract_common_config_snippet,\n            commands::read_live_provider_settings,\n            commands::get_settings,\n            commands::save_settings,\n            commands::get_rectifier_config,\n            commands::set_rectifier_config,\n            commands::get_optimizer_config,\n            commands::set_optimizer_config,\n            commands::get_log_config,\n            commands::set_log_config,\n            commands::restart_app,\n            commands::check_for_updates,\n            commands::is_portable_mode,\n            commands::get_claude_plugin_status,\n            commands::read_claude_plugin_config,\n            commands::apply_claude_plugin_config,\n            commands::is_claude_plugin_applied,\n            commands::apply_claude_onboarding_skip,\n            commands::clear_claude_onboarding_skip,\n            // Claude MCP management\n            commands::get_claude_mcp_status,\n            commands::read_claude_mcp_config,\n            commands::upsert_claude_mcp_server,\n            commands::delete_claude_mcp_server,\n            commands::validate_mcp_command,\n            // usage query\n            commands::queryProviderUsage,\n            commands::testUsageScript,\n            // New MCP via config.json (SSOT)\n            commands::get_mcp_config,\n            commands::upsert_mcp_server_in_config,\n            commands::delete_mcp_server_in_config,\n            commands::set_mcp_enabled,\n            // Unified MCP management\n            commands::get_mcp_servers,\n            commands::upsert_mcp_server,\n            commands::delete_mcp_server,\n            commands::toggle_mcp_app,\n            commands::import_mcp_from_apps,\n            // Prompt management\n            commands::get_prompts,\n            commands::upsert_prompt,\n            commands::delete_prompt,\n            commands::enable_prompt,\n            commands::import_prompt_from_file,\n            commands::get_current_prompt_file_content,\n            // ours: endpoint speed test + custom endpoint management\n            commands::test_api_endpoints,\n            commands::get_custom_endpoints,\n            commands::add_custom_endpoint,\n            commands::remove_custom_endpoint,\n            commands::update_endpoint_last_used,\n            // app_config_dir override via Store\n            commands::get_app_config_dir_override,\n            commands::set_app_config_dir_override,\n            // provider sort order management\n            commands::update_providers_sort_order,\n            // theirs: config import/export and dialogs\n            commands::export_config_to_file,\n            commands::import_config_from_file,\n            commands::webdav_test_connection,\n            commands::webdav_sync_upload,\n            commands::webdav_sync_download,\n            commands::webdav_sync_save_settings,\n            commands::webdav_sync_fetch_remote_info,\n            commands::save_file_dialog,\n            commands::open_file_dialog,\n            commands::open_zip_file_dialog,\n            commands::create_db_backup,\n            commands::list_db_backups,\n            commands::restore_db_backup,\n            commands::rename_db_backup,\n            commands::delete_db_backup,\n            commands::sync_current_providers_live,\n            // Deep link import\n            commands::parse_deeplink,\n            commands::merge_deeplink_config,\n            commands::import_from_deeplink,\n            commands::import_from_deeplink_unified,\n            update_tray_menu,\n            // Environment variable management\n            commands::check_env_conflicts,\n            commands::delete_env_vars,\n            commands::restore_env_backup,\n            // Skill management (v3.10.0+ unified)\n            commands::get_installed_skills,\n            commands::get_skill_backups,\n            commands::delete_skill_backup,\n            commands::install_skill_unified,\n            commands::uninstall_skill_unified,\n            commands::restore_skill_backup,\n            commands::toggle_skill_app,\n            commands::scan_unmanaged_skills,\n            commands::import_skills_from_apps,\n            commands::discover_available_skills,\n            // Skill management (legacy API compatibility)\n            commands::get_skills,\n            commands::get_skills_for_app,\n            commands::install_skill,\n            commands::install_skill_for_app,\n            commands::uninstall_skill,\n            commands::uninstall_skill_for_app,\n            commands::get_skill_repos,\n            commands::add_skill_repo,\n            commands::remove_skill_repo,\n            commands::install_skills_from_zip,\n            // Auto launch\n            commands::set_auto_launch,\n            commands::get_auto_launch_status,\n            // Proxy server management\n            commands::start_proxy_server,\n            commands::stop_proxy_with_restore,\n            commands::get_proxy_takeover_status,\n            commands::set_proxy_takeover_for_app,\n            commands::get_proxy_status,\n            commands::get_proxy_config,\n            commands::update_proxy_config,\n            // Global & Per-App Config\n            commands::get_global_proxy_config,\n            commands::update_global_proxy_config,\n            commands::get_proxy_config_for_app,\n            commands::update_proxy_config_for_app,\n            commands::get_default_cost_multiplier,\n            commands::set_default_cost_multiplier,\n            commands::get_pricing_model_source,\n            commands::set_pricing_model_source,\n            commands::is_proxy_running,\n            commands::is_live_takeover_active,\n            commands::switch_proxy_provider,\n            // Proxy failover commands\n            commands::get_provider_health,\n            commands::reset_circuit_breaker,\n            commands::get_circuit_breaker_config,\n            commands::update_circuit_breaker_config,\n            commands::get_circuit_breaker_stats,\n            // Failover queue management\n            commands::get_failover_queue,\n            commands::get_available_providers_for_failover,\n            commands::add_to_failover_queue,\n            commands::remove_from_failover_queue,\n            commands::get_auto_failover_enabled,\n            commands::set_auto_failover_enabled,\n            // Usage statistics\n            commands::get_usage_summary,\n            commands::get_usage_trends,\n            commands::get_provider_stats,\n            commands::get_model_stats,\n            commands::get_request_logs,\n            commands::get_request_detail,\n            commands::get_model_pricing,\n            commands::update_model_pricing,\n            commands::delete_model_pricing,\n            commands::check_provider_limits,\n            // Stream health check\n            commands::stream_check_provider,\n            commands::stream_check_all_providers,\n            commands::get_stream_check_config,\n            commands::save_stream_check_config,\n            // Session manager\n            commands::list_sessions,\n            commands::get_session_messages,\n            commands::delete_session,\n            commands::launch_session_terminal,\n            commands::get_tool_versions,\n            // Provider terminal\n            commands::open_provider_terminal,\n            // Universal Provider management\n            commands::get_universal_providers,\n            commands::get_universal_provider,\n            commands::upsert_universal_provider,\n            commands::delete_universal_provider,\n            commands::sync_universal_provider,\n            // OpenCode specific\n            commands::import_opencode_providers_from_live,\n            commands::get_opencode_live_provider_ids,\n            // OpenClaw specific\n            commands::import_openclaw_providers_from_live,\n            commands::get_openclaw_live_provider_ids,\n            commands::get_openclaw_live_provider,\n            commands::scan_openclaw_config_health,\n            commands::get_openclaw_default_model,\n            commands::set_openclaw_default_model,\n            commands::get_openclaw_model_catalog,\n            commands::set_openclaw_model_catalog,\n            commands::get_openclaw_agents_defaults,\n            commands::set_openclaw_agents_defaults,\n            commands::get_openclaw_env,\n            commands::set_openclaw_env,\n            commands::get_openclaw_tools,\n            commands::set_openclaw_tools,\n            // Global upstream proxy\n            commands::get_global_proxy_url,\n            commands::set_global_proxy_url,\n            commands::test_proxy_url,\n            commands::get_upstream_proxy_status,\n            commands::scan_local_proxies,\n            // Window theme control\n            commands::set_window_theme,\n            // Generic managed auth commands\n            commands::auth_start_login,\n            commands::auth_poll_for_account,\n            commands::auth_list_accounts,\n            commands::auth_get_status,\n            commands::auth_remove_account,\n            commands::auth_set_default_account,\n            commands::auth_logout,\n            // Copilot OAuth commands (multi-account support)\n            commands::copilot_start_device_flow,\n            commands::copilot_poll_for_auth,\n            commands::copilot_poll_for_account,\n            commands::copilot_list_accounts,\n            commands::copilot_remove_account,\n            commands::copilot_set_default_account,\n            commands::copilot_get_auth_status,\n            commands::copilot_logout,\n            commands::copilot_is_authenticated,\n            commands::copilot_get_token,\n            commands::copilot_get_token_for_account,\n            commands::copilot_get_models,\n            commands::copilot_get_models_for_account,\n            commands::copilot_get_usage,\n            commands::copilot_get_usage_for_account,\n            // OMO commands\n            commands::read_omo_local_file,\n            commands::get_current_omo_provider_id,\n            commands::disable_current_omo,\n            commands::read_omo_slim_local_file,\n            commands::get_current_omo_slim_provider_id,\n            commands::disable_current_omo_slim,\n            // Workspace files (OpenClaw)\n            commands::read_workspace_file,\n            commands::write_workspace_file,\n            // Daily memory files (OpenClaw workspace)\n            commands::list_daily_memory_files,\n            commands::read_daily_memory_file,\n            commands::write_daily_memory_file,\n            commands::delete_daily_memory_file,\n            commands::search_daily_memory_files,\n            commands::open_workspace_directory,\n        ]);\n\n    let app = builder\n        .build(tauri::generate_context!())\n        .expect(\"error while running tauri application\");\n\n    app.run(|app_handle, event| {\n        // 处理退出请求（所有平台）\n        if let RunEvent::ExitRequested { api, code, .. } = &event {\n            // code 为 None 表示运行时自动触发（如隐藏窗口的 WebView 被回收导致无存活窗口），\n            // 此时应仅阻止退出、保持托盘后台运行；\n            // code 为 Some(_) 表示用户主动调用 app.exit() 退出（如托盘菜单\"退出\"），\n            // 此时执行清理后退出。\n            if code.is_none() {\n                log::info!(\"运行时触发退出请求（无存活窗口），阻止退出以保持托盘后台运行\");\n                api.prevent_exit();\n                return;\n            }\n\n            log::info!(\"收到用户主动退出请求 (code={code:?})，开始清理...\");\n            api.prevent_exit();\n\n            let app_handle = app_handle.clone();\n            tauri::async_runtime::spawn(async move {\n                cleanup_before_exit(&app_handle).await;\n                log::info!(\"清理完成，退出应用\");\n\n                // 短暂等待确保所有 I/O 操作（如数据库写入）刷新到磁盘\n                tokio::time::sleep(std::time::Duration::from_millis(100)).await;\n\n                // 使用 std::process::exit 避免再次触发 ExitRequested\n                std::process::exit(0);\n            });\n            return;\n        }\n\n        #[cfg(target_os = \"macos\")]\n        {\n            match event {\n                // macOS 在 Dock 图标被点击并重新激活应用时会触发 Reopen 事件，这里手动恢复主窗口\n                RunEvent::Reopen { .. } => {\n                    if let Some(window) = app_handle.get_webview_window(\"main\") {\n                        #[cfg(target_os = \"windows\")]\n                        {\n                            let _ = window.set_skip_taskbar(false);\n                        }\n                        let _ = window.unminimize();\n                        let _ = window.show();\n                        let _ = window.set_focus();\n                        tray::apply_tray_policy(app_handle, true);\n                    }\n                }\n                // 处理通过自定义 URL 协议触发的打开事件（例如 ccswitch://...）\n                RunEvent::Opened { urls } => {\n                    if let Some(url) = urls.first() {\n                        let url_str = url.to_string();\n                        log::info!(\"RunEvent::Opened with URL: {url_str}\");\n\n                        if url_str.starts_with(\"ccswitch://\") {\n                            // 解析并广播深链接事件，复用与 single_instance 相同的逻辑\n                            match crate::deeplink::parse_deeplink_url(&url_str) {\n                                Ok(request) => {\n                                    log::info!(\n                                        \"Successfully parsed deep link from RunEvent::Opened: resource={}, app={:?}\",\n                                        request.resource,\n                                        request.app\n                                    );\n\n                                    if let Err(e) =\n                                        app_handle.emit(\"deeplink-import\", &request)\n                                    {\n                                        log::error!(\n                                            \"Failed to emit deep link event from RunEvent::Opened: {e}\"\n                                        );\n                                    }\n                                }\n                                Err(e) => {\n                                    log::error!(\n                                        \"Failed to parse deep link URL from RunEvent::Opened: {e}\"\n                                    );\n\n                                    if let Err(emit_err) = app_handle.emit(\n                                        \"deeplink-error\",\n                                        serde_json::json!({\n                                            \"url\": url_str,\n                                            \"error\": e.to_string()\n                                        }),\n                                    ) {\n                                        log::error!(\n                                            \"Failed to emit deep link error event from RunEvent::Opened: {emit_err}\"\n                                        );\n                                    }\n                                }\n                            }\n\n                            // 确保主窗口可见\n                            if let Some(window) = app_handle.get_webview_window(\"main\") {\n                                let _ = window.unminimize();\n                                let _ = window.show();\n                                let _ = window.set_focus();\n                            }\n                        }\n                    }\n                }\n                _ => {}\n            }\n        }\n\n        #[cfg(not(target_os = \"macos\"))]\n        {\n            let _ = (app_handle, event);\n        }\n    });\n}\n\n// ============================================================\n// 应用退出清理\n// ============================================================\n\n/// 应用退出前的清理工作\n///\n/// 在应用退出前检查代理服务器状态，如果正在运行则停止代理并恢复 Live 配置。\n/// 确保 Claude Code/Codex/Gemini 的配置不会处于损坏状态。\n/// 使用 stop_with_restore_keep_state 保留 settings 表中的代理状态，下次启动时自动恢复。\npub async fn cleanup_before_exit(app_handle: &tauri::AppHandle) {\n    if let Some(state) = app_handle.try_state::<store::AppState>() {\n        let proxy_service = &state.proxy_service;\n\n        // 退出时也需要兜底：代理可能已崩溃/未运行，但 Live 接管残留仍在（占位符/备份）。\n        let has_backups = match state.db.has_any_live_backup().await {\n            Ok(v) => v,\n            Err(e) => {\n                log::error!(\"退出时检查 Live 备份失败: {e}\");\n                false\n            }\n        };\n        let live_taken_over = proxy_service.detect_takeover_in_live_configs();\n        let needs_restore = has_backups || live_taken_over;\n\n        if needs_restore {\n            log::info!(\"检测到接管残留，开始恢复 Live 配置（保留代理状态）...\");\n            // 使用 keep_state 版本，保留 settings 表中的代理状态\n            if let Err(e) = proxy_service.stop_with_restore_keep_state().await {\n                log::error!(\"退出时恢复 Live 配置失败: {e}\");\n            } else {\n                log::info!(\"已恢复 Live 配置（代理状态已保留，下次启动将自动恢复）\");\n            }\n            return;\n        }\n\n        // 非接管模式：代理在运行则仅停止代理\n        if proxy_service.is_running().await {\n            log::info!(\"检测到代理服务器正在运行，开始停止...\");\n            if let Err(e) = proxy_service.stop().await {\n                log::error!(\"退出时停止代理失败: {e}\");\n            }\n            log::info!(\"代理服务器清理完成\");\n        }\n    }\n}\n\n// ============================================================\n// 启动时恢复代理状态\n// ============================================================\n\n/// 启动时根据 proxy_config 表中的代理状态自动恢复代理服务\n///\n/// 检查 `proxy_config.enabled` 字段，如果有任一应用的状态为 `true`，\n/// 则自动启动代理服务并接管对应应用的 Live 配置。\nasync fn restore_proxy_state_on_startup(state: &store::AppState) {\n    // 收集需要恢复接管的应用列表（从 proxy_config.enabled 读取）\n    let mut apps_to_restore = Vec::new();\n    for app_type in [\"claude\", \"codex\", \"gemini\"] {\n        if let Ok(config) = state.db.get_proxy_config_for_app(app_type).await {\n            if config.enabled {\n                apps_to_restore.push(app_type);\n            }\n        }\n    }\n\n    if apps_to_restore.is_empty() {\n        log::debug!(\"启动时无需恢复代理状态\");\n        return;\n    }\n\n    log::info!(\"检测到上次代理状态需要恢复，应用列表: {apps_to_restore:?}\");\n\n    // 逐个恢复接管状态\n    for app_type in apps_to_restore {\n        match state\n            .proxy_service\n            .set_takeover_for_app(app_type, true)\n            .await\n        {\n            Ok(()) => {\n                log::info!(\"✓ 已恢复 {app_type} 的代理接管状态\");\n            }\n            Err(e) => {\n                log::error!(\"✗ 恢复 {app_type} 的代理接管状态失败: {e}\");\n                // 失败时清除该应用的状态，避免下次启动再次尝试\n                if let Err(clear_err) = state\n                    .proxy_service\n                    .set_takeover_for_app(app_type, false)\n                    .await\n                {\n                    log::error!(\"清除 {app_type} 代理状态失败: {clear_err}\");\n                }\n            }\n        }\n    }\n}\n\nfn initialize_common_config_snippets(state: &store::AppState) {\n    // Auto-extract common config snippets from clean live files when snippet is missing.\n    // This must run before proxy takeover is restored on startup, otherwise we'd read\n    // proxy-placeholder configs instead of the user's actual live settings.\n    for app_type in crate::app_config::AppType::all() {\n        if !state\n            .db\n            .should_auto_extract_config_snippet(app_type.as_str())\n            .unwrap_or(false)\n        {\n            continue;\n        }\n\n        let settings = match crate::services::provider::ProviderService::read_live_settings(\n            app_type.clone(),\n        ) {\n            Ok(s) => s,\n            Err(_) => continue,\n        };\n\n        match crate::services::provider::ProviderService::extract_common_config_snippet_from_settings(\n            app_type.clone(),\n            &settings,\n        ) {\n            Ok(snippet) if !snippet.is_empty() && snippet != \"{}\" => {\n                match state.db.set_config_snippet(app_type.as_str(), Some(snippet)) {\n                    Ok(()) => {\n                        let _ = state.db.set_config_snippet_cleared(app_type.as_str(), false);\n                        log::info!(\n                            \"✓ Auto-extracted common config snippet for {}\",\n                            app_type.as_str()\n                        );\n                    }\n                    Err(e) => log::warn!(\n                        \"✗ Failed to save config snippet for {}: {e}\",\n                        app_type.as_str()\n                    ),\n                }\n            }\n            Ok(_) => log::debug!(\n                \"○ Live config for {} has no extractable common fields\",\n                app_type.as_str()\n            ),\n            Err(e) => log::warn!(\n                \"✗ Failed to extract config snippet for {}: {e}\",\n                app_type.as_str()\n            ),\n        }\n    }\n\n    let should_run_legacy_migration = state\n        .db\n        .is_legacy_common_config_migrated()\n        .map(|done| !done)\n        .unwrap_or(true);\n\n    if should_run_legacy_migration {\n        for app_type in [\n            crate::app_config::AppType::Claude,\n            crate::app_config::AppType::Codex,\n            crate::app_config::AppType::Gemini,\n        ] {\n            if let Err(e) = crate::services::provider::ProviderService::migrate_legacy_common_config_usage_if_needed(\n                state,\n                app_type.clone(),\n            ) {\n                log::warn!(\n                    \"✗ Failed to migrate legacy common-config usage for {}: {e}\",\n                    app_type.as_str()\n                );\n            }\n        }\n\n        if let Err(e) = state.db.set_legacy_common_config_migrated(true) {\n            log::warn!(\"✗ Failed to persist legacy common-config migration flag: {e}\");\n        }\n    }\n}\n\n// ============================================================\n// 迁移错误对话框辅助函数\n// ============================================================\n\n/// 检测是否为中文环境\nfn is_chinese_locale() -> bool {\n    std::env::var(\"LANG\")\n        .or_else(|_| std::env::var(\"LC_ALL\"))\n        .or_else(|_| std::env::var(\"LC_MESSAGES\"))\n        .map(|lang| lang.starts_with(\"zh\"))\n        .unwrap_or(false)\n}\n\n/// 显示迁移错误对话框\n/// 返回 true 表示用户选择重试，false 表示用户选择退出\nfn show_migration_error_dialog(app: &tauri::AppHandle, error: &str) -> bool {\n    let title = if is_chinese_locale() {\n        \"配置迁移失败\"\n    } else {\n        \"Migration Failed\"\n    };\n\n    let message = if is_chinese_locale() {\n        format!(\n            \"从旧版本迁移配置时发生错误：\\n\\n{error}\\n\\n\\\n            您的数据尚未丢失，旧配置文件仍然保留。\\n\\\n            建议回退到旧版本 CC Switch 以保护数据。\\n\\n\\\n            点击「重试」重新尝试迁移\\n\\\n            点击「退出」关闭程序（可回退版本后重新打开）\"\n        )\n    } else {\n        format!(\n            \"An error occurred while migrating configuration:\\n\\n{error}\\n\\n\\\n            Your data is NOT lost - the old config file is still preserved.\\n\\\n            Consider rolling back to an older CC Switch version.\\n\\n\\\n            Click 'Retry' to attempt migration again\\n\\\n            Click 'Exit' to close the program\"\n        )\n    };\n\n    let retry_text = if is_chinese_locale() {\n        \"重试\"\n    } else {\n        \"Retry\"\n    };\n    let exit_text = if is_chinese_locale() {\n        \"退出\"\n    } else {\n        \"Exit\"\n    };\n\n    // 使用 blocking_show 同步等待用户响应\n    // OkCancelCustom: 第一个按钮（重试）返回 true，第二个按钮（退出）返回 false\n    app.dialog()\n        .message(&message)\n        .title(title)\n        .kind(MessageDialogKind::Error)\n        .buttons(MessageDialogButtons::OkCancelCustom(\n            retry_text.to_string(),\n            exit_text.to_string(),\n        ))\n        .blocking_show()\n}\n\n/// 显示数据库初始化/Schema 迁移失败对话框\n/// 返回 true 表示用户选择重试，false 表示用户选择退出\nfn show_database_init_error_dialog(\n    app: &tauri::AppHandle,\n    db_path: &std::path::Path,\n    error: &str,\n) -> bool {\n    let title = if is_chinese_locale() {\n        \"数据库初始化失败\"\n    } else {\n        \"Database Initialization Failed\"\n    };\n\n    let message = if is_chinese_locale() {\n        format!(\n            \"初始化数据库或迁移数据库结构时发生错误：\\n\\n{error}\\n\\n\\\n            数据库文件路径：\\n{db}\\n\\n\\\n            您的数据尚未丢失，应用不会自动删除数据库文件。\\n\\\n            常见原因包括：数据库版本过新、文件损坏、权限不足、磁盘空间不足等。\\n\\n\\\n            建议：\\n\\\n            1) 先备份整个配置目录（包含 cc-switch.db）\\n\\\n            2) 如果提示“数据库版本过新”，请升级到更新版本\\n\\\n            3) 如果刚升级出现异常，可回退旧版本导出/备份后再升级\\n\\n\\\n            点击「重试」重新尝试初始化\\n\\\n            点击「退出」关闭程序\",\n            db = db_path.display()\n        )\n    } else {\n        format!(\n            \"An error occurred while initializing or migrating the database:\\n\\n{error}\\n\\n\\\n            Database file path:\\n{db}\\n\\n\\\n            Your data is NOT lost - the app will not delete the database automatically.\\n\\\n            Common causes include: newer database version, corrupted file, permission issues, or low disk space.\\n\\n\\\n            Suggestions:\\n\\\n            1) Back up the entire config directory (including cc-switch.db)\\n\\\n            2) If you see “database version is newer”, please upgrade CC Switch\\n\\\n            3) If this happened right after upgrading, consider rolling back to export/backup then upgrade again\\n\\n\\\n            Click 'Retry' to attempt initialization again\\n\\\n            Click 'Exit' to close the program\",\n            db = db_path.display()\n        )\n    };\n\n    let retry_text = if is_chinese_locale() {\n        \"重试\"\n    } else {\n        \"Retry\"\n    };\n    let exit_text = if is_chinese_locale() {\n        \"退出\"\n    } else {\n        \"Exit\"\n    };\n\n    app.dialog()\n        .message(&message)\n        .title(title)\n        .kind(MessageDialogKind::Error)\n        .buttons(MessageDialogButtons::OkCancelCustom(\n            retry_text.to_string(),\n            exit_text.to_string(),\n        ))\n        .blocking_show()\n}\n"
  },
  {
    "path": "src-tauri/src/main.rs",
    "content": "// Prevents additional console window on Windows in release, DO NOT REMOVE!!\n#![cfg_attr(not(debug_assertions), windows_subsystem = \"windows\")]\n\nfn main() {\n    // 在 Linux 上设置 WebKit 环境变量以解决 DMA-BUF 渲染问题\n    // 某些 Linux 系统（如 Debian 13.2、Nvidia GPU）上 WebKitGTK 的 DMA-BUF 渲染器可能导致白屏/黑屏\n    // 参考: https://github.com/tauri-apps/tauri/issues/9394\n    #[cfg(target_os = \"linux\")]\n    {\n        if std::env::var(\"WEBKIT_DISABLE_DMABUF_RENDERER\").is_err() {\n            std::env::set_var(\"WEBKIT_DISABLE_DMABUF_RENDERER\", \"1\");\n        }\n    }\n\n    cc_switch_lib::run();\n}\n"
  },
  {
    "path": "src-tauri/src/mcp/claude.rs",
    "content": "//! Claude MCP 同步和导入模块\n\nuse serde_json::Value;\nuse std::collections::HashMap;\n\nuse crate::app_config::{McpApps, McpConfig, McpServer, MultiAppConfig};\nuse crate::error::AppError;\n\nuse super::validation::{extract_server_spec, validate_server_spec};\n\nfn should_sync_claude_mcp() -> bool {\n    // Claude 未安装/未初始化时：通常 ~/.claude 目录与 ~/.claude.json 都不存在。\n    // 按用户偏好：此时跳过写入/删除，不创建任何文件或目录。\n    crate::config::get_claude_config_dir().exists() || crate::config::get_claude_mcp_path().exists()\n}\n\n/// 返回已启用的 MCP 服务器（过滤 enabled==true）\nfn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {\n    let mut out = HashMap::new();\n    for (id, entry) in cfg.servers.iter() {\n        let enabled = entry\n            .get(\"enabled\")\n            .and_then(|v| v.as_bool())\n            .unwrap_or(false);\n        if !enabled {\n            continue;\n        }\n        match extract_server_spec(entry) {\n            Ok(spec) => {\n                out.insert(id.clone(), spec);\n            }\n            Err(err) => {\n                log::warn!(\"跳过无效的 MCP 条目 '{id}': {err}\");\n            }\n        }\n    }\n    out\n}\n\n/// 将 config.json 中 enabled==true 的项投影写入 ~/.claude.json\npub fn sync_enabled_to_claude(config: &MultiAppConfig) -> Result<(), AppError> {\n    if !should_sync_claude_mcp() {\n        return Ok(());\n    }\n    let enabled = collect_enabled_servers(&config.mcp.claude);\n    crate::claude_mcp::set_mcp_servers_map(&enabled)\n}\n\n/// 从 ~/.claude.json 导入 mcpServers 到统一结构（v3.7.0+）\n/// 已存在的服务器将启用 Claude 应用，不覆盖其他字段和应用状态\npub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError> {\n    let text_opt = crate::claude_mcp::read_mcp_json()?;\n    let Some(text) = text_opt else { return Ok(0) };\n\n    let v: Value = serde_json::from_str(&text)\n        .map_err(|e| AppError::McpValidation(format!(\"解析 ~/.claude.json 失败: {e}\")))?;\n    let Some(map) = v.get(\"mcpServers\").and_then(|x| x.as_object()) else {\n        return Ok(0);\n    };\n\n    // 确保新结构存在\n    let servers = config.mcp.servers.get_or_insert_with(HashMap::new);\n\n    let mut changed = 0;\n    let mut errors = Vec::new();\n\n    for (id, spec) in map.iter() {\n        // 校验：单项失败不中止，收集错误继续处理\n        if let Err(e) = validate_server_spec(spec) {\n            log::warn!(\"跳过无效 MCP 服务器 '{id}': {e}\");\n            errors.push(format!(\"{id}: {e}\"));\n            continue;\n        }\n\n        if let Some(existing) = servers.get_mut(id) {\n            // 已存在：仅启用 Claude 应用\n            if !existing.apps.claude {\n                existing.apps.claude = true;\n                changed += 1;\n                log::info!(\"MCP 服务器 '{id}' 已启用 Claude 应用\");\n            }\n        } else {\n            // 新建服务器：默认仅启用 Claude\n            servers.insert(\n                id.clone(),\n                McpServer {\n                    id: id.clone(),\n                    name: id.clone(),\n                    server: spec.clone(),\n                    apps: McpApps {\n                        claude: true,\n                        codex: false,\n                        gemini: false,\n                        opencode: false,\n                    },\n                    description: None,\n                    homepage: None,\n                    docs: None,\n                    tags: Vec::new(),\n                },\n            );\n            changed += 1;\n            log::info!(\"导入新 MCP 服务器 '{id}'\");\n        }\n    }\n\n    if !errors.is_empty() {\n        log::warn!(\"导入完成，但有 {} 项失败: {:?}\", errors.len(), errors);\n    }\n\n    Ok(changed)\n}\n\n/// 将单个 MCP 服务器同步到 Claude live 配置\npub fn sync_single_server_to_claude(\n    _config: &MultiAppConfig,\n    id: &str,\n    server_spec: &Value,\n) -> Result<(), AppError> {\n    if !should_sync_claude_mcp() {\n        return Ok(());\n    }\n    // 读取现有的 MCP 配置\n    let current = crate::claude_mcp::read_mcp_servers_map()?;\n\n    // 创建新的 HashMap，包含现有的所有服务器 + 当前要同步的服务器\n    let mut updated = current;\n    updated.insert(id.to_string(), server_spec.clone());\n\n    // 写回\n    crate::claude_mcp::set_mcp_servers_map(&updated)\n}\n\n/// 从 Claude live 配置中移除单个 MCP 服务器\npub fn remove_server_from_claude(id: &str) -> Result<(), AppError> {\n    if !should_sync_claude_mcp() {\n        return Ok(());\n    }\n    // 读取现有的 MCP 配置\n    let mut current = crate::claude_mcp::read_mcp_servers_map()?;\n\n    // 移除指定服务器\n    current.remove(id);\n\n    // 写回\n    crate::claude_mcp::set_mcp_servers_map(&current)\n}\n"
  },
  {
    "path": "src-tauri/src/mcp/codex.rs",
    "content": "//! Codex MCP 同步和导入模块\n//!\n//! 包含 Codex 的 MCP 配置管理：\n//! - 从 ~/.codex/config.toml 导入\n//! - 同步到 ~/.codex/config.toml\n//! - JSON 到 TOML 的转换逻辑\n\nuse serde_json::{json, Value};\nuse std::collections::HashMap;\n\nuse crate::app_config::{McpApps, McpConfig, McpServer, MultiAppConfig};\nuse crate::error::AppError;\n\nuse super::validation::{extract_server_spec, validate_server_spec};\n\nfn should_sync_codex_mcp() -> bool {\n    // Codex 未安装/未初始化时：~/.codex 目录不存在。\n    // 按用户偏好：目录缺失时跳过写入/删除，不创建任何文件或目录。\n    crate::codex_config::get_codex_config_dir().exists()\n}\n\n/// 返回已启用的 MCP 服务器（过滤 enabled==true）\nfn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {\n    let mut out = HashMap::new();\n    for (id, entry) in cfg.servers.iter() {\n        let enabled = entry\n            .get(\"enabled\")\n            .and_then(|v| v.as_bool())\n            .unwrap_or(false);\n        if !enabled {\n            continue;\n        }\n        match extract_server_spec(entry) {\n            Ok(spec) => {\n                out.insert(id.clone(), spec);\n            }\n            Err(err) => {\n                log::warn!(\"跳过无效的 MCP 条目 '{id}': {err}\");\n            }\n        }\n    }\n    out\n}\n\n/// 从 ~/.codex/config.toml 导入 MCP 到统一结构（v3.7.0+）\n///\n/// 格式支持：\n/// - 正确格式：[mcp_servers.*]（Codex 官方标准）\n/// - 错误格式：[mcp.servers.*]（容错读取，用于迁移错误写入的配置）\n///\n/// 已存在的服务器将启用 Codex 应用，不覆盖其他字段和应用状态\npub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError> {\n    let text = crate::codex_config::read_and_validate_codex_config_text()?;\n    if text.trim().is_empty() {\n        return Ok(0);\n    }\n\n    let root: toml::Table = toml::from_str(&text)\n        .map_err(|e| AppError::McpValidation(format!(\"解析 ~/.codex/config.toml 失败: {e}\")))?;\n\n    // 确保新结构存在\n    let servers = config.mcp.servers.get_or_insert_with(HashMap::new);\n\n    let mut changed_total = 0usize;\n\n    // helper：处理一组 servers 表\n    let mut import_servers_tbl = |servers_tbl: &toml::value::Table| {\n        let mut changed = 0usize;\n        for (id, entry_val) in servers_tbl.iter() {\n            let Some(entry_tbl) = entry_val.as_table() else {\n                continue;\n            };\n\n            // type 缺省为 stdio\n            let typ = entry_tbl\n                .get(\"type\")\n                .and_then(|v| v.as_str())\n                .unwrap_or(\"stdio\");\n\n            // 构建 JSON 规范\n            let mut spec = serde_json::Map::new();\n            spec.insert(\"type\".into(), json!(typ));\n\n            // 核心字段（需要手动处理的字段）\n            let core_fields = match typ {\n                \"stdio\" => vec![\"type\", \"command\", \"args\", \"env\", \"cwd\"],\n                \"http\" | \"sse\" => vec![\"type\", \"url\", \"http_headers\"],\n                _ => vec![\"type\"],\n            };\n\n            // 1. 处理核心字段（强类型）\n            match typ {\n                \"stdio\" => {\n                    if let Some(cmd) = entry_tbl.get(\"command\").and_then(|v| v.as_str()) {\n                        spec.insert(\"command\".into(), json!(cmd));\n                    }\n                    if let Some(args) = entry_tbl.get(\"args\").and_then(|v| v.as_array()) {\n                        let arr = args\n                            .iter()\n                            .filter_map(|x| x.as_str())\n                            .map(|s| json!(s))\n                            .collect::<Vec<_>>();\n                        if !arr.is_empty() {\n                            spec.insert(\"args\".into(), serde_json::Value::Array(arr));\n                        }\n                    }\n                    if let Some(cwd) = entry_tbl.get(\"cwd\").and_then(|v| v.as_str()) {\n                        if !cwd.trim().is_empty() {\n                            spec.insert(\"cwd\".into(), json!(cwd));\n                        }\n                    }\n                    if let Some(env_tbl) = entry_tbl.get(\"env\").and_then(|v| v.as_table()) {\n                        let mut env_json = serde_json::Map::new();\n                        for (k, v) in env_tbl.iter() {\n                            if let Some(sv) = v.as_str() {\n                                env_json.insert(k.clone(), json!(sv));\n                            }\n                        }\n                        if !env_json.is_empty() {\n                            spec.insert(\"env\".into(), serde_json::Value::Object(env_json));\n                        }\n                    }\n                }\n                \"http\" | \"sse\" => {\n                    if let Some(url) = entry_tbl.get(\"url\").and_then(|v| v.as_str()) {\n                        spec.insert(\"url\".into(), json!(url));\n                    }\n                    // Read from http_headers (correct Codex format) or headers (legacy) with priority to http_headers\n                    let headers_tbl = entry_tbl\n                        .get(\"http_headers\")\n                        .and_then(|v| v.as_table())\n                        .or_else(|| entry_tbl.get(\"headers\").and_then(|v| v.as_table()));\n\n                    if let Some(headers_tbl) = headers_tbl {\n                        let mut headers_json = serde_json::Map::new();\n                        for (k, v) in headers_tbl.iter() {\n                            if let Some(sv) = v.as_str() {\n                                headers_json.insert(k.clone(), json!(sv));\n                            }\n                        }\n                        if !headers_json.is_empty() {\n                            spec.insert(\"headers\".into(), serde_json::Value::Object(headers_json));\n                        }\n                    }\n                }\n                _ => {\n                    log::warn!(\"跳过未知类型 '{typ}' 的 Codex MCP 项 '{id}'\");\n                    return changed;\n                }\n            }\n\n            // 2. 处理扩展字段和其他未知字段（通用 TOML → JSON 转换）\n            for (key, toml_val) in entry_tbl.iter() {\n                // 跳过已处理的核心字段\n                if core_fields.contains(&key.as_str()) {\n                    continue;\n                }\n\n                // 通用 TOML 值到 JSON 值转换\n                let json_val = match toml_val {\n                    toml::Value::String(s) => Some(json!(s)),\n                    toml::Value::Integer(i) => Some(json!(i)),\n                    toml::Value::Float(f) => Some(json!(f)),\n                    toml::Value::Boolean(b) => Some(json!(b)),\n                    toml::Value::Array(arr) => {\n                        // 只支持简单类型数组\n                        let json_arr: Vec<serde_json::Value> = arr\n                            .iter()\n                            .filter_map(|item| match item {\n                                toml::Value::String(s) => Some(json!(s)),\n                                toml::Value::Integer(i) => Some(json!(i)),\n                                toml::Value::Float(f) => Some(json!(f)),\n                                toml::Value::Boolean(b) => Some(json!(b)),\n                                _ => None,\n                            })\n                            .collect();\n                        if !json_arr.is_empty() {\n                            Some(serde_json::Value::Array(json_arr))\n                        } else {\n                            log::debug!(\"跳过复杂数组字段 '{key}' (TOML → JSON)\");\n                            None\n                        }\n                    }\n                    toml::Value::Table(tbl) => {\n                        // 浅层表转为 JSON 对象（仅支持字符串值）\n                        let mut json_obj = serde_json::Map::new();\n                        for (k, v) in tbl.iter() {\n                            if let Some(s) = v.as_str() {\n                                json_obj.insert(k.clone(), json!(s));\n                            }\n                        }\n                        if !json_obj.is_empty() {\n                            Some(serde_json::Value::Object(json_obj))\n                        } else {\n                            log::debug!(\"跳过复杂对象字段 '{key}' (TOML → JSON)\");\n                            None\n                        }\n                    }\n                    toml::Value::Datetime(_) => {\n                        log::debug!(\"跳过日期时间字段 '{key}' (TOML → JSON)\");\n                        None\n                    }\n                };\n\n                if let Some(val) = json_val {\n                    spec.insert(key.clone(), val);\n                    log::debug!(\"导入扩展字段 '{key}' = {toml_val:?}\");\n                }\n            }\n\n            let spec_v = serde_json::Value::Object(spec);\n\n            // 校验：单项失败继续处理\n            if let Err(e) = validate_server_spec(&spec_v) {\n                log::warn!(\"跳过无效 Codex MCP 项 '{id}': {e}\");\n                continue;\n            }\n\n            if let Some(existing) = servers.get_mut(id) {\n                // 已存在：仅启用 Codex 应用\n                if !existing.apps.codex {\n                    existing.apps.codex = true;\n                    changed += 1;\n                    log::info!(\"MCP 服务器 '{id}' 已启用 Codex 应用\");\n                }\n            } else {\n                // 新建服务器：默认仅启用 Codex\n                servers.insert(\n                    id.clone(),\n                    McpServer {\n                        id: id.clone(),\n                        name: id.clone(),\n                        server: spec_v,\n                        apps: McpApps {\n                            claude: false,\n                            codex: true,\n                            gemini: false,\n                            opencode: false,\n                        },\n                        description: None,\n                        homepage: None,\n                        docs: None,\n                        tags: Vec::new(),\n                    },\n                );\n                changed += 1;\n                log::info!(\"导入新 MCP 服务器 '{id}'\");\n            }\n        }\n        changed\n    };\n\n    // 1) 处理 mcp.servers\n    if let Some(mcp_val) = root.get(\"mcp\") {\n        if let Some(mcp_tbl) = mcp_val.as_table() {\n            if let Some(servers_val) = mcp_tbl.get(\"servers\") {\n                if let Some(servers_tbl) = servers_val.as_table() {\n                    changed_total += import_servers_tbl(servers_tbl);\n                }\n            }\n        }\n    }\n\n    // 2) 处理 mcp_servers\n    if let Some(servers_val) = root.get(\"mcp_servers\") {\n        if let Some(servers_tbl) = servers_val.as_table() {\n            changed_total += import_servers_tbl(servers_tbl);\n        }\n    }\n\n    Ok(changed_total)\n}\n\n/// 将 config.json 中 Codex 的 enabled==true 项以 TOML 形式写入 ~/.codex/config.toml\n///\n/// 格式策略：\n/// - 唯一正确格式：[mcp_servers] 顶层表（Codex 官方标准）\n/// - 自动清理错误格式：[mcp.servers]（如果存在）\n/// - 读取现有 config.toml；若语法无效则报错，不尝试覆盖\n/// - 仅更新 `mcp_servers` 表，保留其它键\n/// - 仅写入启用项；无启用项时清理 mcp_servers 表\npub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), AppError> {\n    if !should_sync_codex_mcp() {\n        return Ok(());\n    }\n    use toml_edit::{Item, Table};\n\n    // 1) 收集启用项（Codex 维度）\n    let enabled = collect_enabled_servers(&config.mcp.codex);\n\n    // 2) 读取现有 config.toml 文本；保持无效 TOML 的错误返回（不覆盖文件）\n    let base_text = crate::codex_config::read_and_validate_codex_config_text()?;\n\n    // 3) 使用 toml_edit 解析（允许空文件）\n    let mut doc = if base_text.trim().is_empty() {\n        toml_edit::DocumentMut::default()\n    } else {\n        base_text\n            .parse::<toml_edit::DocumentMut>()\n            .map_err(|e| AppError::McpValidation(format!(\"解析 config.toml 失败: {e}\")))?\n    };\n\n    // 4) 清理可能存在的错误格式 [mcp.servers]\n    if let Some(mcp_item) = doc.get_mut(\"mcp\") {\n        if let Some(tbl) = mcp_item.as_table_like_mut() {\n            if tbl.contains_key(\"servers\") {\n                log::warn!(\"检测到错误的 MCP 格式 [mcp.servers]，正在清理并迁移到 [mcp_servers]\");\n                tbl.remove(\"servers\");\n            }\n        }\n    }\n\n    // 5) 构造目标 servers 表（稳定的键顺序）\n    if enabled.is_empty() {\n        // 无启用项：移除 mcp_servers 表\n        doc.as_table_mut().remove(\"mcp_servers\");\n    } else {\n        // 构建 servers 表\n        let mut servers_tbl = Table::new();\n        let mut ids: Vec<_> = enabled.keys().cloned().collect();\n        ids.sort();\n        for id in ids {\n            let spec = enabled.get(&id).expect(\"spec must exist\");\n            // 复用通用转换函数（已包含扩展字段支持）\n            match json_server_to_toml_table(spec) {\n                Ok(table) => {\n                    servers_tbl[&id[..]] = Item::Table(table);\n                }\n                Err(err) => {\n                    log::error!(\"跳过无效的 MCP 服务器 '{id}': {err}\");\n                }\n            }\n        }\n        // 使用唯一正确的格式：[mcp_servers]\n        doc[\"mcp_servers\"] = Item::Table(servers_tbl);\n    }\n\n    // 6) 写回（仅改 TOML，不触碰 auth.json）；toml_edit 会尽量保留未改区域的注释/空白/顺序\n    let new_text = doc.to_string();\n    let path = crate::codex_config::get_codex_config_path();\n    crate::config::write_text_file(&path, &new_text)?;\n    Ok(())\n}\n\n/// 将单个 MCP 服务器同步到 Codex live 配置\n/// 始终使用 Codex 官方格式 [mcp_servers]，并清理可能存在的错误格式 [mcp.servers]\npub fn sync_single_server_to_codex(\n    _config: &MultiAppConfig,\n    id: &str,\n    server_spec: &Value,\n) -> Result<(), AppError> {\n    if !should_sync_codex_mcp() {\n        return Ok(());\n    }\n    use toml_edit::Item;\n\n    // 读取现有的 config.toml\n    let config_path = crate::codex_config::get_codex_config_path();\n\n    let mut doc = if config_path.exists() {\n        let content =\n            std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;\n        // 尝试解析现有配置，如果失败则创建新文档（容错处理）\n        match content.parse::<toml_edit::DocumentMut>() {\n            Ok(doc) => doc,\n            Err(e) => {\n                log::warn!(\"解析 Codex config.toml 失败: {e}，将创建新配置\");\n                toml_edit::DocumentMut::new()\n            }\n        }\n    } else {\n        toml_edit::DocumentMut::new()\n    };\n\n    // 清理可能存在的错误格式 [mcp.servers]\n    if let Some(mcp_item) = doc.get_mut(\"mcp\") {\n        if let Some(tbl) = mcp_item.as_table_like_mut() {\n            if tbl.contains_key(\"servers\") {\n                log::warn!(\"检测到错误的 MCP 格式 [mcp.servers]，正在清理并迁移到 [mcp_servers]\");\n                tbl.remove(\"servers\");\n            }\n        }\n    }\n\n    // 确保 [mcp_servers] 表存在\n    if !doc.contains_key(\"mcp_servers\") {\n        doc[\"mcp_servers\"] = toml_edit::table();\n    }\n\n    // 将 JSON 服务器规范转换为 TOML 表\n    let toml_table = json_server_to_toml_table(server_spec)?;\n\n    // 使用唯一正确的格式：[mcp_servers]\n    doc[\"mcp_servers\"][id] = Item::Table(toml_table);\n\n    // 写回文件\n    let new_text = doc.to_string();\n    crate::config::write_text_file(&config_path, &new_text)?;\n\n    Ok(())\n}\n\n/// 从 Codex live 配置中移除单个 MCP 服务器\n/// 从正确的 [mcp_servers] 表中删除，同时清理可能存在于错误位置 [mcp.servers] 的数据\npub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {\n    if !should_sync_codex_mcp() {\n        return Ok(());\n    }\n    let config_path = crate::codex_config::get_codex_config_path();\n\n    if !config_path.exists() {\n        return Ok(()); // 文件不存在，无需删除\n    }\n\n    let content =\n        std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;\n\n    // 尝试解析现有配置，如果失败则直接返回（无法删除不存在的内容）\n    let mut doc = match content.parse::<toml_edit::DocumentMut>() {\n        Ok(doc) => doc,\n        Err(e) => {\n            log::warn!(\"解析 Codex config.toml 失败: {e}，跳过删除操作\");\n            return Ok(());\n        }\n    };\n\n    // 从正确的位置删除：[mcp_servers]\n    if let Some(mcp_servers) = doc.get_mut(\"mcp_servers\").and_then(|s| s.as_table_mut()) {\n        mcp_servers.remove(id);\n    }\n\n    // 同时清理可能存在于错误位置的数据：[mcp.servers]（如果存在）\n    if let Some(mcp_table) = doc.get_mut(\"mcp\").and_then(|t| t.as_table_mut()) {\n        if let Some(servers) = mcp_table.get_mut(\"servers\").and_then(|s| s.as_table_mut()) {\n            if servers.remove(id).is_some() {\n                log::warn!(\"从错误的 MCP 格式 [mcp.servers] 中清理了服务器 '{id}'\");\n            }\n        }\n    }\n\n    // 写回文件\n    let new_text = doc.to_string();\n    crate::config::write_text_file(&config_path, &new_text)?;\n\n    Ok(())\n}\n\n// ============================================================================\n// TOML 转换辅助函数\n// ============================================================================\n\n/// 通用 JSON 值到 TOML 值转换器（支持简单类型和浅层嵌套）\n///\n/// 支持的类型转换：\n/// - String → TOML String\n/// - Number (i64) → TOML Integer\n/// - Number (f64) → TOML Float\n/// - Boolean → TOML Boolean\n/// - Array[简单类型] → TOML Array\n/// - Object → TOML Inline Table (仅字符串值)\n///\n/// 不支持的类型（返回 None）：\n/// - null\n/// - 深度嵌套对象\n/// - 混合类型数组\nfn json_value_to_toml_item(value: &Value, field_name: &str) -> Option<toml_edit::Item> {\n    use toml_edit::{Array, InlineTable, Item};\n\n    match value {\n        Value::String(s) => Some(toml_edit::value(s.as_str())),\n\n        Value::Number(n) => {\n            if let Some(i) = n.as_i64() {\n                Some(toml_edit::value(i))\n            } else if let Some(f) = n.as_f64() {\n                Some(toml_edit::value(f))\n            } else {\n                log::warn!(\"跳过字段 '{field_name}': 无法转换的数字类型 {n}\");\n                None\n            }\n        }\n\n        Value::Bool(b) => Some(toml_edit::value(*b)),\n\n        Value::Array(arr) => {\n            // 只支持简单类型的数组（字符串、数字、布尔）\n            let mut toml_arr = Array::default();\n            let mut all_same_type = true;\n\n            for item in arr {\n                match item {\n                    Value::String(s) => toml_arr.push(s.as_str()),\n                    Value::Number(n) if n.is_i64() => {\n                        if let Some(i) = n.as_i64() {\n                            toml_arr.push(i);\n                        } else {\n                            all_same_type = false;\n                            break;\n                        }\n                    }\n                    Value::Number(n) if n.is_f64() => {\n                        if let Some(f) = n.as_f64() {\n                            toml_arr.push(f);\n                        } else {\n                            all_same_type = false;\n                            break;\n                        }\n                    }\n                    Value::Bool(b) => toml_arr.push(*b),\n                    _ => {\n                        all_same_type = false;\n                        break;\n                    }\n                }\n            }\n\n            if all_same_type && !toml_arr.is_empty() {\n                Some(Item::Value(toml_edit::Value::Array(toml_arr)))\n            } else {\n                log::warn!(\"跳过字段 '{field_name}': 不支持的数组类型（混合类型或嵌套结构）\");\n                None\n            }\n        }\n\n        Value::Object(obj) => {\n            // 只支持浅层对象（所有值都是字符串）→ TOML Inline Table\n            let mut inline_table = InlineTable::new();\n            let mut all_strings = true;\n\n            for (k, v) in obj {\n                if let Some(s) = v.as_str() {\n                    // InlineTable 需要 Value 类型，toml_edit::value() 返回 Item，需要提取内部的 Value\n                    inline_table.insert(k, s.into());\n                } else {\n                    all_strings = false;\n                    break;\n                }\n            }\n\n            if all_strings && !inline_table.is_empty() {\n                Some(Item::Value(toml_edit::Value::InlineTable(inline_table)))\n            } else {\n                log::warn!(\"跳过字段 '{field_name}': 对象值包含非字符串类型，建议使用子表语法\");\n                None\n            }\n        }\n\n        Value::Null => {\n            log::debug!(\"跳过字段 '{field_name}': TOML 不支持 null 值\");\n            None\n        }\n    }\n}\n\n/// Helper: 将 JSON MCP 服务器规范转换为 toml_edit::Table\n///\n/// 策略：\n/// 1. 核心字段（type, command, args, url, headers, env, cwd）使用强类型处理\n/// 2. 扩展字段（timeout、retry 等）通过白名单列表自动转换\n/// 3. 其他未知字段使用通用转换器尝试转换\nfn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table, AppError> {\n    use toml_edit::{Array, Item, Table};\n\n    let mut t = Table::new();\n    let typ = spec.get(\"type\").and_then(|v| v.as_str()).unwrap_or(\"stdio\");\n    t[\"type\"] = toml_edit::value(typ);\n\n    // 定义核心字段（已在下方处理，跳过通用转换）\n    let core_fields = match typ {\n        \"stdio\" => vec![\"type\", \"command\", \"args\", \"env\", \"cwd\"],\n        \"http\" | \"sse\" => vec![\"type\", \"url\", \"http_headers\"],\n        _ => vec![\"type\"],\n    };\n\n    // 定义扩展字段白名单（Codex 常见可选字段）\n    let extended_fields = [\n        // 通用字段\n        \"timeout\",\n        \"timeout_ms\",\n        \"startup_timeout_ms\",\n        \"startup_timeout_sec\",\n        \"connection_timeout\",\n        \"read_timeout\",\n        \"debug\",\n        \"log_level\",\n        \"disabled\",\n        // stdio 特有\n        \"shell\",\n        \"encoding\",\n        \"working_dir\",\n        \"restart_on_exit\",\n        \"max_restart_count\",\n        // http/sse 特有\n        \"retry_count\",\n        \"max_retry_attempts\",\n        \"retry_delay\",\n        \"cache_tools_list\",\n        \"verify_ssl\",\n        \"insecure\",\n        \"proxy\",\n    ];\n\n    // 1. 处理核心字段（强类型）\n    match typ {\n        \"stdio\" => {\n            let cmd = spec.get(\"command\").and_then(|v| v.as_str()).unwrap_or(\"\");\n            t[\"command\"] = toml_edit::value(cmd);\n\n            if let Some(args) = spec.get(\"args\").and_then(|v| v.as_array()) {\n                let mut arr_v = Array::default();\n                for a in args.iter().filter_map(|x| x.as_str()) {\n                    arr_v.push(a);\n                }\n                if !arr_v.is_empty() {\n                    t[\"args\"] = Item::Value(toml_edit::Value::Array(arr_v));\n                }\n            }\n\n            if let Some(cwd) = spec.get(\"cwd\").and_then(|v| v.as_str()) {\n                if !cwd.trim().is_empty() {\n                    t[\"cwd\"] = toml_edit::value(cwd);\n                }\n            }\n\n            if let Some(env) = spec.get(\"env\").and_then(|v| v.as_object()) {\n                let mut env_tbl = Table::new();\n                for (k, v) in env.iter() {\n                    if let Some(s) = v.as_str() {\n                        env_tbl[&k[..]] = toml_edit::value(s);\n                    }\n                }\n                if !env_tbl.is_empty() {\n                    t[\"env\"] = Item::Table(env_tbl);\n                }\n            }\n        }\n        \"http\" | \"sse\" => {\n            let url = spec.get(\"url\").and_then(|v| v.as_str()).unwrap_or(\"\");\n            t[\"url\"] = toml_edit::value(url);\n\n            if let Some(headers) = spec.get(\"headers\").and_then(|v| v.as_object()) {\n                let mut h_tbl = Table::new();\n                for (k, v) in headers.iter() {\n                    if let Some(s) = v.as_str() {\n                        h_tbl[&k[..]] = toml_edit::value(s);\n                    }\n                }\n                if !h_tbl.is_empty() {\n                    t[\"http_headers\"] = Item::Table(h_tbl);\n                }\n            }\n        }\n        _ => {}\n    }\n\n    // 2. 处理扩展字段和其他未知字段\n    if let Some(obj) = spec.as_object() {\n        for (key, value) in obj {\n            // 跳过已处理的核心字段\n            if core_fields.contains(&key.as_str()) {\n                continue;\n            }\n\n            // 尝试使用通用转换器\n            if let Some(toml_item) = json_value_to_toml_item(value, key) {\n                t[&key[..]] = toml_item;\n\n                // 记录扩展字段的处理\n                if extended_fields.contains(&key.as_str()) {\n                    log::debug!(\"已转换扩展字段 '{key}' = {value:?}\");\n                } else {\n                    log::info!(\"已转换自定义字段 '{key}' = {value:?}\");\n                }\n            }\n        }\n    }\n\n    Ok(t)\n}\n"
  },
  {
    "path": "src-tauri/src/mcp/gemini.rs",
    "content": "//! Gemini MCP 同步和导入模块\n\nuse serde_json::Value;\nuse std::collections::HashMap;\n\nuse crate::app_config::{McpApps, McpConfig, McpServer, MultiAppConfig};\nuse crate::error::AppError;\n\nuse super::validation::{extract_server_spec, validate_server_spec};\n\nfn should_sync_gemini_mcp() -> bool {\n    // Gemini 未安装/未初始化时：~/.gemini 目录不存在。\n    // 按用户偏好：目录缺失时跳过写入/删除，不创建任何文件或目录。\n    crate::gemini_config::get_gemini_dir().exists()\n}\n\n/// 返回已启用的 MCP 服务器（过滤 enabled==true）\nfn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {\n    let mut out = HashMap::new();\n    for (id, entry) in cfg.servers.iter() {\n        let enabled = entry\n            .get(\"enabled\")\n            .and_then(|v| v.as_bool())\n            .unwrap_or(false);\n        if !enabled {\n            continue;\n        }\n        match extract_server_spec(entry) {\n            Ok(spec) => {\n                out.insert(id.clone(), spec);\n            }\n            Err(err) => {\n                log::warn!(\"跳过无效的 MCP 条目 '{id}': {err}\");\n            }\n        }\n    }\n    out\n}\n\n/// 将 config.json 中 Gemini 的 enabled==true 项写入 Gemini MCP 配置\npub fn sync_enabled_to_gemini(config: &MultiAppConfig) -> Result<(), AppError> {\n    if !should_sync_gemini_mcp() {\n        return Ok(());\n    }\n    let enabled = collect_enabled_servers(&config.mcp.gemini);\n    crate::gemini_mcp::set_mcp_servers_map(&enabled)\n}\n\n/// 从 Gemini MCP 配置导入到统一结构（v3.7.0+）\n/// 已存在的服务器将启用 Gemini 应用，不覆盖其他字段和应用状态\npub fn import_from_gemini(config: &mut MultiAppConfig) -> Result<usize, AppError> {\n    let map = crate::gemini_mcp::read_mcp_servers_map()?;\n    if map.is_empty() {\n        return Ok(0);\n    }\n\n    // 确保新结构存在\n    let servers = config.mcp.servers.get_or_insert_with(HashMap::new);\n\n    let mut changed = 0;\n    let mut errors = Vec::new();\n\n    for (id, spec) in map.iter() {\n        // 校验：单项失败不中止，收集错误继续处理\n        if let Err(e) = validate_server_spec(spec) {\n            log::warn!(\"跳过无效 MCP 服务器 '{id}': {e}\");\n            errors.push(format!(\"{id}: {e}\"));\n            continue;\n        }\n\n        if let Some(existing) = servers.get_mut(id) {\n            // 已存在：仅启用 Gemini 应用\n            if !existing.apps.gemini {\n                existing.apps.gemini = true;\n                changed += 1;\n                log::info!(\"MCP 服务器 '{id}' 已启用 Gemini 应用\");\n            }\n        } else {\n            // 新建服务器：默认仅启用 Gemini\n            servers.insert(\n                id.clone(),\n                McpServer {\n                    id: id.clone(),\n                    name: id.clone(),\n                    server: spec.clone(),\n                    apps: McpApps {\n                        claude: false,\n                        codex: false,\n                        gemini: true,\n                        opencode: false,\n                    },\n                    description: None,\n                    homepage: None,\n                    docs: None,\n                    tags: Vec::new(),\n                },\n            );\n            changed += 1;\n            log::info!(\"导入新 MCP 服务器 '{id}'\");\n        }\n    }\n\n    if !errors.is_empty() {\n        log::warn!(\"导入完成，但有 {} 项失败: {:?}\", errors.len(), errors);\n    }\n\n    Ok(changed)\n}\n\n/// 将单个 MCP 服务器同步到 Gemini live 配置\npub fn sync_single_server_to_gemini(\n    _config: &MultiAppConfig,\n    id: &str,\n    server_spec: &Value,\n) -> Result<(), AppError> {\n    if !should_sync_gemini_mcp() {\n        return Ok(());\n    }\n    // 读取现有的 MCP 配置\n    let mut current = crate::gemini_mcp::read_mcp_servers_map()?;\n\n    // 添加/更新当前服务器\n    current.insert(id.to_string(), server_spec.clone());\n\n    // 写回\n    crate::gemini_mcp::set_mcp_servers_map(&current)\n}\n\n/// 从 Gemini live 配置中移除单个 MCP 服务器\npub fn remove_server_from_gemini(id: &str) -> Result<(), AppError> {\n    if !should_sync_gemini_mcp() {\n        return Ok(());\n    }\n    // 读取现有的 MCP 配置\n    let mut current = crate::gemini_mcp::read_mcp_servers_map()?;\n\n    // 移除指定服务器\n    current.remove(id);\n\n    // 写回\n    crate::gemini_mcp::set_mcp_servers_map(&current)\n}\n"
  },
  {
    "path": "src-tauri/src/mcp/mod.rs",
    "content": "//! MCP (Model Context Protocol) 服务器管理模块\n//!\n//! 本模块负责 MCP 服务器配置的验证、同步和导入导出。\n//!\n//! ## 模块结构\n//!\n//! - `validation` - 服务器配置验证\n//! - `claude` - Claude MCP 同步和导入\n//! - `codex` - Codex MCP 同步和导入（含 TOML 转换）\n//! - `gemini` - Gemini MCP 同步和导入\n//! - `opencode` - OpenCode MCP 同步和导入（含 local/remote 格式转换）\n\nmod claude;\nmod codex;\nmod gemini;\nmod opencode;\nmod validation;\n\n// 重新导出公共 API\npub use claude::{\n    import_from_claude, remove_server_from_claude, sync_enabled_to_claude,\n    sync_single_server_to_claude,\n};\npub use codex::{\n    import_from_codex, remove_server_from_codex, sync_enabled_to_codex, sync_single_server_to_codex,\n};\npub use gemini::{\n    import_from_gemini, remove_server_from_gemini, sync_enabled_to_gemini,\n    sync_single_server_to_gemini,\n};\npub use opencode::{\n    import_from_opencode, remove_server_from_opencode, sync_single_server_to_opencode,\n};\n"
  },
  {
    "path": "src-tauri/src/mcp/opencode.rs",
    "content": "//! OpenCode MCP 同步和导入模块\n//!\n//! 本模块处理 CC Switch 统一 MCP 格式与 OpenCode 格式之间的转换。\n//!\n//! ## 格式差异\n//!\n//! | CC Switch 统一格式    | OpenCode 格式       |\n//! |----------------------|---------------------|\n//! | `type: \"stdio\"`      | `type: \"local\"`     |\n//! | `command` + `args`   | `command: [cmd, ...args]` |\n//! | `env`                | `environment`       |\n//! | `type: \"sse\"/\"http\"` | `type: \"remote\"`    |\n//! | `url`                | `url`               |\n\nuse serde_json::{json, Value};\nuse std::collections::HashMap;\n\nuse crate::app_config::{McpApps, McpServer, MultiAppConfig};\nuse crate::error::AppError;\nuse crate::opencode_config;\n\nuse super::validation::validate_server_spec;\n\n// ============================================================================\n// Helper Functions\n// ============================================================================\n\n/// Check if OpenCode MCP sync should proceed\nfn should_sync_opencode_mcp() -> bool {\n    // Skip if OpenCode config directory doesn't exist\n    opencode_config::get_opencode_dir().exists()\n}\n\n// ============================================================================\n// Format Conversion: CC Switch → OpenCode\n// ============================================================================\n\n/// Convert CC Switch unified format to OpenCode format\n///\n/// Conversion rules:\n/// - `stdio` → `local`, command+args → command array, env → environment\n/// - `sse`/`http` → `remote`, url preserved\npub fn convert_to_opencode_format(spec: &Value) -> Result<Value, AppError> {\n    let obj = spec\n        .as_object()\n        .ok_or_else(|| AppError::McpValidation(\"MCP spec must be a JSON object\".into()))?;\n\n    let typ = obj.get(\"type\").and_then(|v| v.as_str()).unwrap_or(\"stdio\");\n\n    let mut result = serde_json::Map::new();\n\n    match typ {\n        \"stdio\" => {\n            // Convert to \"local\" type\n            result.insert(\"type\".into(), json!(\"local\"));\n\n            // Merge command and args into a single array\n            let cmd = obj.get(\"command\").and_then(|v| v.as_str()).unwrap_or(\"\");\n            let mut command_arr = vec![json!(cmd)];\n\n            if let Some(args) = obj.get(\"args\").and_then(|v| v.as_array()) {\n                for arg in args {\n                    command_arr.push(arg.clone());\n                }\n            }\n            result.insert(\"command\".into(), Value::Array(command_arr));\n\n            // Convert env → environment\n            if let Some(env) = obj.get(\"env\") {\n                if env.is_object() && !env.as_object().map(|o| o.is_empty()).unwrap_or(true) {\n                    result.insert(\"environment\".into(), env.clone());\n                }\n            }\n\n            // Add enabled flag (OpenCode expects this)\n            result.insert(\"enabled\".into(), json!(true));\n        }\n        \"sse\" | \"http\" => {\n            // Convert to \"remote\" type\n            result.insert(\"type\".into(), json!(\"remote\"));\n\n            // Preserve url\n            if let Some(url) = obj.get(\"url\") {\n                result.insert(\"url\".into(), url.clone());\n            }\n\n            // Convert headers if present\n            if let Some(headers) = obj.get(\"headers\") {\n                if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true)\n                {\n                    result.insert(\"headers\".into(), headers.clone());\n                }\n            }\n\n            // Add enabled flag\n            result.insert(\"enabled\".into(), json!(true));\n        }\n        _ => {\n            return Err(AppError::McpValidation(format!(\"Unknown MCP type: {typ}\")));\n        }\n    }\n\n    Ok(Value::Object(result))\n}\n\n// ============================================================================\n// Format Conversion: OpenCode → CC Switch\n// ============================================================================\n\n/// Convert OpenCode format to CC Switch unified format\n///\n/// Conversion rules:\n/// - `local` → `stdio`, command array → command+args, environment → env\n/// - `remote` → `sse`, url preserved\npub fn convert_from_opencode_format(spec: &Value) -> Result<Value, AppError> {\n    let obj = spec\n        .as_object()\n        .ok_or_else(|| AppError::McpValidation(\"OpenCode MCP spec must be a JSON object\".into()))?;\n\n    let typ = obj.get(\"type\").and_then(|v| v.as_str()).unwrap_or(\"local\");\n\n    let mut result = serde_json::Map::new();\n\n    match typ {\n        \"local\" => {\n            // Convert to \"stdio\" type\n            result.insert(\"type\".into(), json!(\"stdio\"));\n\n            // Split command array into command and args\n            if let Some(cmd_arr) = obj.get(\"command\").and_then(|v| v.as_array()) {\n                if !cmd_arr.is_empty() {\n                    // First element is the command\n                    if let Some(cmd) = cmd_arr.first().and_then(|v| v.as_str()) {\n                        result.insert(\"command\".into(), json!(cmd));\n                    }\n\n                    // Rest are args\n                    if cmd_arr.len() > 1 {\n                        let args: Vec<Value> = cmd_arr[1..].to_vec();\n                        result.insert(\"args\".into(), Value::Array(args));\n                    }\n                }\n            }\n\n            // Convert environment → env\n            if let Some(env) = obj.get(\"environment\") {\n                if env.is_object() && !env.as_object().map(|o| o.is_empty()).unwrap_or(true) {\n                    result.insert(\"env\".into(), env.clone());\n                }\n            }\n        }\n        \"remote\" => {\n            // Convert to \"sse\" type (default remote protocol)\n            result.insert(\"type\".into(), json!(\"sse\"));\n\n            // Preserve url\n            if let Some(url) = obj.get(\"url\") {\n                result.insert(\"url\".into(), url.clone());\n            }\n\n            // Preserve headers\n            if let Some(headers) = obj.get(\"headers\") {\n                if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true)\n                {\n                    result.insert(\"headers\".into(), headers.clone());\n                }\n            }\n        }\n        _ => {\n            return Err(AppError::McpValidation(format!(\n                \"Unknown OpenCode MCP type: {typ}\"\n            )));\n        }\n    }\n\n    Ok(Value::Object(result))\n}\n\n// ============================================================================\n// Public API: Sync Functions\n// ============================================================================\n\n/// Sync a single MCP server to OpenCode live config\npub fn sync_single_server_to_opencode(\n    _config: &MultiAppConfig,\n    id: &str,\n    server_spec: &Value,\n) -> Result<(), AppError> {\n    if !should_sync_opencode_mcp() {\n        return Ok(());\n    }\n\n    // Convert to OpenCode format\n    let opencode_spec = convert_to_opencode_format(server_spec)?;\n\n    // Set in OpenCode config\n    opencode_config::set_mcp_server(id, opencode_spec)\n}\n\n/// Remove a single MCP server from OpenCode live config\npub fn remove_server_from_opencode(id: &str) -> Result<(), AppError> {\n    if !should_sync_opencode_mcp() {\n        return Ok(());\n    }\n\n    opencode_config::remove_mcp_server(id)\n}\n\n/// Import MCP servers from OpenCode config to unified structure\n///\n/// Existing servers will have OpenCode app enabled without overwriting other fields.\npub fn import_from_opencode(config: &mut MultiAppConfig) -> Result<usize, AppError> {\n    let mcp_map = opencode_config::get_mcp_servers()?;\n    if mcp_map.is_empty() {\n        return Ok(0);\n    }\n\n    // Ensure servers map exists\n    let servers = config.mcp.servers.get_or_insert_with(HashMap::new);\n\n    let mut changed = 0;\n    let mut errors = Vec::new();\n\n    for (id, spec) in mcp_map {\n        // Convert from OpenCode format to unified format\n        let unified_spec = match convert_from_opencode_format(&spec) {\n            Ok(s) => s,\n            Err(e) => {\n                log::warn!(\"Skip invalid OpenCode MCP server '{id}': {e}\");\n                errors.push(format!(\"{id}: {e}\"));\n                continue;\n            }\n        };\n\n        // Validate the converted spec\n        if let Err(e) = validate_server_spec(&unified_spec) {\n            log::warn!(\"Skip invalid MCP server '{id}' after conversion: {e}\");\n            errors.push(format!(\"{id}: {e}\"));\n            continue;\n        }\n\n        if let Some(existing) = servers.get_mut(&id) {\n            // Existing server: just enable OpenCode app\n            if !existing.apps.opencode {\n                existing.apps.opencode = true;\n                changed += 1;\n                log::info!(\"MCP server '{id}' enabled for OpenCode\");\n            }\n        } else {\n            // New server: default to only OpenCode enabled\n            servers.insert(\n                id.clone(),\n                McpServer {\n                    id: id.clone(),\n                    name: id.clone(),\n                    server: unified_spec,\n                    apps: McpApps {\n                        claude: false,\n                        codex: false,\n                        gemini: false,\n                        opencode: true,\n                    },\n                    description: None,\n                    homepage: None,\n                    docs: None,\n                    tags: Vec::new(),\n                },\n            );\n            changed += 1;\n            log::info!(\"Imported new MCP server '{id}' from OpenCode\");\n        }\n    }\n\n    if !errors.is_empty() {\n        log::warn!(\n            \"Import completed with {} failures: {:?}\",\n            errors.len(),\n            errors\n        );\n    }\n\n    Ok(changed)\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_convert_stdio_to_local() {\n        let spec = json!({\n            \"type\": \"stdio\",\n            \"command\": \"npx\",\n            \"args\": [\"-y\", \"@modelcontextprotocol/server-filesystem\"],\n            \"env\": { \"HOME\": \"/Users/test\" }\n        });\n\n        let result = convert_to_opencode_format(&spec).unwrap();\n        assert_eq!(result[\"type\"], \"local\");\n        assert_eq!(result[\"command\"][0], \"npx\");\n        assert_eq!(result[\"command\"][1], \"-y\");\n        assert_eq!(\n            result[\"command\"][2],\n            \"@modelcontextprotocol/server-filesystem\"\n        );\n        assert_eq!(result[\"environment\"][\"HOME\"], \"/Users/test\");\n        assert_eq!(result[\"enabled\"], true);\n    }\n\n    #[test]\n    fn test_convert_sse_to_remote() {\n        let spec = json!({\n            \"type\": \"sse\",\n            \"url\": \"https://example.com/mcp\",\n            \"headers\": { \"Authorization\": \"Bearer xxx\" }\n        });\n\n        let result = convert_to_opencode_format(&spec).unwrap();\n        assert_eq!(result[\"type\"], \"remote\");\n        assert_eq!(result[\"url\"], \"https://example.com/mcp\");\n        assert_eq!(result[\"headers\"][\"Authorization\"], \"Bearer xxx\");\n        assert_eq!(result[\"enabled\"], true);\n    }\n\n    #[test]\n    fn test_convert_local_to_stdio() {\n        let spec = json!({\n            \"type\": \"local\",\n            \"command\": [\"npx\", \"-y\", \"@modelcontextprotocol/server-filesystem\"],\n            \"environment\": { \"HOME\": \"/Users/test\" }\n        });\n\n        let result = convert_from_opencode_format(&spec).unwrap();\n        assert_eq!(result[\"type\"], \"stdio\");\n        assert_eq!(result[\"command\"], \"npx\");\n        assert_eq!(result[\"args\"][0], \"-y\");\n        assert_eq!(result[\"args\"][1], \"@modelcontextprotocol/server-filesystem\");\n        assert_eq!(result[\"env\"][\"HOME\"], \"/Users/test\");\n    }\n\n    #[test]\n    fn test_convert_remote_to_sse() {\n        let spec = json!({\n            \"type\": \"remote\",\n            \"url\": \"https://example.com/mcp\",\n            \"headers\": { \"Authorization\": \"Bearer xxx\" }\n        });\n\n        let result = convert_from_opencode_format(&spec).unwrap();\n        assert_eq!(result[\"type\"], \"sse\");\n        assert_eq!(result[\"url\"], \"https://example.com/mcp\");\n        assert_eq!(result[\"headers\"][\"Authorization\"], \"Bearer xxx\");\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/mcp/validation.rs",
    "content": "//! MCP 服务器配置验证模块\n\nuse serde_json::Value;\n\nuse crate::error::AppError;\n\n/// 基础校验：允许 stdio/http/sse；或省略 type（视为 stdio）。对应必填字段存在\npub fn validate_server_spec(spec: &Value) -> Result<(), AppError> {\n    if !spec.is_object() {\n        return Err(AppError::McpValidation(\n            \"MCP 服务器连接定义必须为 JSON 对象\".into(),\n        ));\n    }\n    let t_opt = spec.get(\"type\").and_then(|x| x.as_str());\n    // 支持三种：stdio/http/sse；若缺省 type 则按 stdio 处理（与社区常见 .mcp.json 一致）\n    let is_stdio = t_opt.map(|t| t == \"stdio\").unwrap_or(true);\n    let is_http = t_opt.map(|t| t == \"http\").unwrap_or(false);\n    let is_sse = t_opt.map(|t| t == \"sse\").unwrap_or(false);\n\n    if !(is_stdio || is_http || is_sse) {\n        return Err(AppError::McpValidation(\n            \"MCP 服务器 type 必须是 'stdio'、'http' 或 'sse'（或省略表示 stdio）\".into(),\n        ));\n    }\n\n    if is_stdio {\n        let cmd = spec.get(\"command\").and_then(|x| x.as_str()).unwrap_or(\"\");\n        if cmd.trim().is_empty() {\n            return Err(AppError::McpValidation(\n                \"stdio 类型的 MCP 服务器缺少 command 字段\".into(),\n            ));\n        }\n    }\n    if is_http {\n        let url = spec.get(\"url\").and_then(|x| x.as_str()).unwrap_or(\"\");\n        if url.trim().is_empty() {\n            return Err(AppError::McpValidation(\n                \"http 类型的 MCP 服务器缺少 url 字段\".into(),\n            ));\n        }\n    }\n    if is_sse {\n        let url = spec.get(\"url\").and_then(|x| x.as_str()).unwrap_or(\"\");\n        if url.trim().is_empty() {\n            return Err(AppError::McpValidation(\n                \"sse 类型的 MCP 服务器缺少 url 字段\".into(),\n            ));\n        }\n    }\n    Ok(())\n}\n\n/// 从 MCP 条目中提取服务器规范\npub fn extract_server_spec(entry: &Value) -> Result<Value, AppError> {\n    let obj = entry\n        .as_object()\n        .ok_or_else(|| AppError::McpValidation(\"MCP 服务器条目必须为 JSON 对象\".into()))?;\n    let server = obj\n        .get(\"server\")\n        .ok_or_else(|| AppError::McpValidation(\"MCP 服务器条目缺少 server 字段\".into()))?;\n\n    if !server.is_object() {\n        return Err(AppError::McpValidation(\n            \"MCP 服务器 server 字段必须为 JSON 对象\".into(),\n        ));\n    }\n\n    Ok(server.clone())\n}\n"
  },
  {
    "path": "src-tauri/src/openclaw_config.rs",
    "content": "//! OpenClaw 配置文件读写模块\n//!\n//! 处理 `~/.openclaw/openclaw.json` 配置文件的读写操作（JSON5 格式）。\n//! OpenClaw 使用累加式供应商管理，所有供应商配置共存于同一配置文件中。\n\nuse crate::config::{atomic_write, get_app_config_dir};\nuse crate::error::AppError;\nuse crate::settings::{effective_backup_retain_count, get_openclaw_override_dir};\nuse chrono::Local;\nuse indexmap::IndexMap;\nuse json_five::parser::{FormatConfiguration, TrailingComma};\nuse json_five::rt::parser::{\n    from_str as rt_from_str, JSONKeyValuePair as RtJSONKeyValuePair,\n    JSONObjectContext as RtJSONObjectContext, JSONText as RtJSONText, JSONValue as RtJSONValue,\n    KeyValuePairContext as RtKeyValuePairContext,\n};\nuse serde::{Deserialize, Serialize};\nuse serde_json::{json, Map, Value};\nuse std::collections::HashMap;\nuse std::fs;\nuse std::path::{Path, PathBuf};\nuse std::sync::{Mutex, OnceLock};\n\nconst OPENCLAW_DEFAULT_SOURCE: &str =\n    \"{\\n  models: {\\n    mode: 'merge',\\n    providers: {},\\n  },\\n}\\n\";\nconst OPENCLAW_TOOLS_PROFILES: &[&str] = &[\"minimal\", \"coding\", \"messaging\", \"full\"];\n\n// ============================================================================\n// Path Functions\n// ============================================================================\n\n/// 获取 OpenClaw 配置目录\n///\n/// 默认路径: `~/.openclaw/`\n/// 可通过 settings.openclaw_config_dir 覆盖\npub fn get_openclaw_dir() -> PathBuf {\n    if let Some(override_dir) = get_openclaw_override_dir() {\n        return override_dir;\n    }\n\n    dirs::home_dir()\n        .map(|h| h.join(\".openclaw\"))\n        .unwrap_or_else(|| PathBuf::from(\".openclaw\"))\n}\n\n/// 获取 OpenClaw 配置文件路径\n///\n/// 返回 `~/.openclaw/openclaw.json`\npub fn get_openclaw_config_path() -> PathBuf {\n    get_openclaw_dir().join(\"openclaw.json\")\n}\n\nfn default_openclaw_config_value() -> Value {\n    json!({\n        \"models\": {\n            \"mode\": \"merge\",\n            \"providers\": {}\n        }\n    })\n}\n\nfn openclaw_write_lock() -> &'static Mutex<()> {\n    static LOCK: OnceLock<Mutex<()>> = OnceLock::new();\n    LOCK.get_or_init(|| Mutex::new(()))\n}\n\n// ============================================================================\n// Type Definitions\n// ============================================================================\n\n/// OpenClaw 健康检查警告\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]\n#[serde(rename_all = \"camelCase\")]\npub struct OpenClawHealthWarning {\n    pub code: String,\n    pub message: String,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub path: Option<String>,\n}\n\n/// OpenClaw 写入结果\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\n#[serde(rename_all = \"camelCase\")]\npub struct OpenClawWriteOutcome {\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub backup_path: Option<String>,\n    #[serde(default, skip_serializing_if = \"Vec::is_empty\")]\n    pub warnings: Vec<OpenClawHealthWarning>,\n}\n\n/// OpenClaw 供应商配置（对应 models.providers 中的条目）\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct OpenClawProviderConfig {\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub base_url: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub api_key: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub api: Option<String>,\n    #[serde(default, skip_serializing_if = \"Vec::is_empty\")]\n    pub models: Vec<OpenClawModelEntry>,\n    #[serde(default, skip_serializing_if = \"HashMap::is_empty\")]\n    pub headers: HashMap<String, String>,\n    #[serde(flatten)]\n    pub extra: HashMap<String, Value>,\n}\n\n/// OpenClaw 模型条目\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct OpenClawModelEntry {\n    pub id: String,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub name: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub alias: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub cost: Option<OpenClawModelCost>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub context_window: Option<u32>,\n    #[serde(flatten)]\n    pub extra: HashMap<String, Value>,\n}\n\n/// OpenClaw 模型成本配置\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OpenClawModelCost {\n    pub input: f64,\n    pub output: f64,\n    #[serde(flatten)]\n    pub extra: HashMap<String, Value>,\n}\n\n/// OpenClaw 默认模型配置（agents.defaults.model）\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OpenClawDefaultModel {\n    pub primary: String,\n    #[serde(default, skip_serializing_if = \"Vec::is_empty\")]\n    pub fallbacks: Vec<String>,\n    #[serde(flatten)]\n    pub extra: HashMap<String, Value>,\n}\n\n/// OpenClaw 模型目录条目（agents.defaults.models 中的值）\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OpenClawModelCatalogEntry {\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub alias: Option<String>,\n    #[serde(flatten)]\n    pub extra: HashMap<String, Value>,\n}\n\n/// OpenClaw agents.defaults 配置\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OpenClawAgentsDefaults {\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub model: Option<OpenClawDefaultModel>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub models: Option<HashMap<String, OpenClawModelCatalogEntry>>,\n    #[serde(flatten)]\n    pub extra: HashMap<String, Value>,\n}\n\n/// OpenClaw agents 顶层配置\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[allow(dead_code)]\npub struct OpenClawAgents {\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub defaults: Option<OpenClawAgentsDefaults>,\n    #[serde(flatten)]\n    pub extra: HashMap<String, Value>,\n}\n\n/// OpenClaw env 配置（openclaw.json 的 env 节点）\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OpenClawEnvConfig {\n    #[serde(flatten)]\n    pub vars: HashMap<String, Value>,\n}\n\n/// OpenClaw tools 配置（openclaw.json 的 tools 节点）\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OpenClawToolsConfig {\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub profile: Option<String>,\n    #[serde(default, skip_serializing_if = \"Vec::is_empty\")]\n    pub allow: Vec<String>,\n    #[serde(default, skip_serializing_if = \"Vec::is_empty\")]\n    pub deny: Vec<String>,\n    #[serde(flatten)]\n    pub extra: HashMap<String, Value>,\n}\n\n// ============================================================================\n// Core Read/Write Functions\n// ============================================================================\n\n/// 读取 OpenClaw 配置文件\n///\n/// 支持 JSON5 格式，返回完整的配置 JSON 对象\npub fn read_openclaw_config() -> Result<Value, AppError> {\n    let path = get_openclaw_config_path();\n    if !path.exists() {\n        return Ok(default_openclaw_config_value());\n    }\n\n    let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;\n    json5::from_str(&content)\n        .map_err(|e| AppError::Config(format!(\"Failed to parse OpenClaw config as JSON5: {e}\")))\n}\n\n/// 对现有 OpenClaw 配置做健康检查。\n///\n/// 解析失败时返回单条 parse 警告，不抛出错误。\npub fn scan_openclaw_config_health() -> Result<Vec<OpenClawHealthWarning>, AppError> {\n    let path = get_openclaw_config_path();\n    if !path.exists() {\n        return Ok(Vec::new());\n    }\n\n    let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;\n    match json5::from_str::<Value>(&content) {\n        Ok(config) => Ok(scan_openclaw_health_from_value(&config)),\n        Err(err) => Ok(vec![OpenClawHealthWarning {\n            code: \"config_parse_failed\".to_string(),\n            message: format!(\"OpenClaw config could not be parsed as JSON5: {err}\"),\n            path: Some(path.display().to_string()),\n        }]),\n    }\n}\n\nstruct OpenClawConfigDocument {\n    path: PathBuf,\n    original_source: Option<String>,\n    text: RtJSONText,\n}\n\nimpl OpenClawConfigDocument {\n    fn load() -> Result<Self, AppError> {\n        let path = get_openclaw_config_path();\n        let original_source = if path.exists() {\n            Some(fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?)\n        } else {\n            None\n        };\n\n        let source = original_source\n            .clone()\n            .unwrap_or_else(|| OPENCLAW_DEFAULT_SOURCE.to_string());\n        let text = rt_from_str(&source).map_err(|e| {\n            AppError::Config(format!(\n                \"Failed to parse OpenClaw config as round-trip JSON5 document: {}\",\n                e.message\n            ))\n        })?;\n\n        Ok(Self {\n            path,\n            original_source,\n            text,\n        })\n    }\n\n    fn set_root_section(&mut self, key: &str, value: &Value) -> Result<(), AppError> {\n        let RtJSONValue::JSONObject {\n            key_value_pairs,\n            context,\n        } = &mut self.text.value\n        else {\n            return Err(AppError::Config(\n                \"OpenClaw config root must be a JSON5 object\".to_string(),\n            ));\n        };\n\n        if key_value_pairs.is_empty()\n            && context\n                .as_ref()\n                .map(|ctx| ctx.wsc.0.is_empty())\n                .unwrap_or(true)\n        {\n            *context = Some(RtJSONObjectContext {\n                wsc: (\"\\n  \".to_string(),),\n            });\n        }\n\n        let leading_ws = context\n            .as_ref()\n            .map(|ctx| ctx.wsc.0.clone())\n            .unwrap_or_default();\n        let entry_separator_ws = derive_entry_separator(&leading_ws);\n        let child_indent = extract_trailing_indent(&leading_ws);\n        let new_value = value_to_rt_value(value, &child_indent)?;\n\n        if let Some(existing) = key_value_pairs\n            .iter_mut()\n            .find(|pair| json5_key_name(&pair.key).as_deref() == Some(key))\n        {\n            existing.value = new_value;\n            return Ok(());\n        }\n\n        let new_pair = if let Some(last_pair) = key_value_pairs.last_mut() {\n            let last_ctx = ensure_kvp_context(last_pair);\n            let closing_ws = if let Some(after_comma) = last_ctx.wsc.3.clone() {\n                last_ctx.wsc.3 = Some(entry_separator_ws.clone());\n                after_comma\n            } else {\n                let closing_ws = std::mem::take(&mut last_ctx.wsc.2);\n                last_ctx.wsc.3 = Some(entry_separator_ws.clone());\n                closing_ws\n            };\n\n            make_root_pair(key, new_value, closing_ws)\n        } else {\n            make_root_pair(\n                key,\n                new_value,\n                derive_closing_ws_from_separator(&leading_ws),\n            )\n        };\n\n        key_value_pairs.push(new_pair);\n        Ok(())\n    }\n\n    fn save(self) -> Result<OpenClawWriteOutcome, AppError> {\n        let _guard = openclaw_write_lock().lock()?;\n\n        let current_source = if self.path.exists() {\n            Some(fs::read_to_string(&self.path).map_err(|e| AppError::io(&self.path, e))?)\n        } else {\n            None\n        };\n\n        if current_source != self.original_source {\n            return Err(AppError::Config(\n                \"OpenClaw config changed on disk. Please reload and try again.\".to_string(),\n            ));\n        }\n\n        let next_source = self.text.to_string();\n        if current_source.as_deref() == Some(next_source.as_str()) {\n            let warnings = scan_openclaw_health_from_value(\n                &json5::from_str::<Value>(&next_source).map_err(|e| {\n                    AppError::Config(format!(\n                        \"Failed to parse unchanged OpenClaw config as JSON5: {e}\"\n                    ))\n                })?,\n            );\n\n            return Ok(OpenClawWriteOutcome {\n                backup_path: None,\n                warnings,\n            });\n        }\n\n        let backup_path = current_source\n            .as_ref()\n            .map(|source| create_openclaw_backup(source))\n            .transpose()?\n            .map(|path| path.display().to_string());\n\n        atomic_write(&self.path, next_source.as_bytes())?;\n\n        let warnings = scan_openclaw_health_from_value(\n            &json5::from_str::<Value>(&next_source).map_err(|e| {\n                AppError::Config(format!(\n                    \"Failed to parse newly written OpenClaw config as JSON5: {e}\"\n                ))\n            })?,\n        );\n\n        log::debug!(\"OpenClaw config written to {:?}\", self.path);\n        Ok(OpenClawWriteOutcome {\n            backup_path,\n            warnings,\n        })\n    }\n}\n\nfn write_root_section(section: &str, value: &Value) -> Result<OpenClawWriteOutcome, AppError> {\n    let mut document = OpenClawConfigDocument::load()?;\n    document.set_root_section(section, value)?;\n    document.save()\n}\n\nfn create_openclaw_backup(source: &str) -> Result<PathBuf, AppError> {\n    let backup_dir = get_app_config_dir().join(\"backups\").join(\"openclaw\");\n    fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?;\n\n    let base_id = format!(\"openclaw_{}\", Local::now().format(\"%Y%m%d_%H%M%S\"));\n    let mut filename = format!(\"{base_id}.json5\");\n    let mut backup_path = backup_dir.join(&filename);\n    let mut counter = 1;\n\n    while backup_path.exists() {\n        filename = format!(\"{base_id}_{counter}.json5\");\n        backup_path = backup_dir.join(&filename);\n        counter += 1;\n    }\n\n    atomic_write(&backup_path, source.as_bytes())?;\n    cleanup_openclaw_backups(&backup_dir)?;\n    Ok(backup_path)\n}\n\nfn cleanup_openclaw_backups(dir: &Path) -> Result<(), AppError> {\n    let retain = effective_backup_retain_count();\n    let mut entries = fs::read_dir(dir)\n        .map_err(|e| AppError::io(dir, e))?\n        .filter_map(|entry| entry.ok())\n        .filter(|entry| {\n            entry\n                .path()\n                .extension()\n                .map(|ext| ext == \"json5\" || ext == \"json\")\n                .unwrap_or(false)\n        })\n        .collect::<Vec<_>>();\n\n    if entries.len() <= retain {\n        return Ok(());\n    }\n\n    entries.sort_by_key(|entry| entry.metadata().and_then(|m| m.modified()).ok());\n    let remove_count = entries.len().saturating_sub(retain);\n    for entry in entries.into_iter().take(remove_count) {\n        if let Err(err) = fs::remove_file(entry.path()) {\n            log::warn!(\n                \"Failed to remove old OpenClaw config backup {}: {err}\",\n                entry.path().display()\n            );\n        }\n    }\n\n    Ok(())\n}\n\nfn ensure_object(value: &mut Value) -> &mut Map<String, Value> {\n    if !value.is_object() {\n        *value = Value::Object(Map::new());\n    }\n    value\n        .as_object_mut()\n        .expect(\"value should be object after normalization\")\n}\n\nfn ensure_kvp_context(pair: &mut RtJSONKeyValuePair) -> &mut RtKeyValuePairContext {\n    pair.context.get_or_insert_with(|| RtKeyValuePairContext {\n        wsc: (String::new(), \" \".to_string(), String::new(), None),\n    })\n}\n\nfn extract_trailing_indent(separator_ws: &str) -> String {\n    separator_ws\n        .rsplit_once('\\n')\n        .map(|(_, tail)| tail.to_string())\n        .unwrap_or_default()\n}\n\nfn derive_closing_ws_from_separator(separator_ws: &str) -> String {\n    let Some((prefix, indent)) = separator_ws.rsplit_once('\\n') else {\n        return String::new();\n    };\n\n    let reduced_indent = if indent.ends_with('\\t') {\n        &indent[..indent.len().saturating_sub(1)]\n    } else if indent.ends_with(\"  \") {\n        &indent[..indent.len().saturating_sub(2)]\n    } else if indent.ends_with(' ') {\n        &indent[..indent.len().saturating_sub(1)]\n    } else {\n        indent\n    };\n\n    format!(\"{prefix}\\n{reduced_indent}\")\n}\n\nfn derive_entry_separator(leading_ws: &str) -> String {\n    if leading_ws.is_empty() {\n        return String::new();\n    }\n\n    if leading_ws.contains('\\n') {\n        return format!(\"\\n{}\", extract_trailing_indent(leading_ws));\n    }\n\n    String::new()\n}\n\nfn value_to_rt_value(value: &Value, parent_indent: &str) -> Result<RtJSONValue, AppError> {\n    let source = json_five::to_string_formatted(\n        value,\n        FormatConfiguration::with_indent(2, TrailingComma::NONE),\n    )\n    .map_err(|e| AppError::Config(format!(\"Failed to serialize JSON5 section: {e}\")))?;\n\n    let adjusted = reindent_json5_block(&source, parent_indent);\n    let text = rt_from_str(&adjusted).map_err(|e| {\n        AppError::Config(format!(\n            \"Failed to parse generated JSON5 section: {}\",\n            e.message\n        ))\n    })?;\n    Ok(text.value)\n}\n\nfn reindent_json5_block(source: &str, parent_indent: &str) -> String {\n    let normalized = normalize_json_five_output(source);\n    if parent_indent.is_empty() || !normalized.contains('\\n') {\n        return normalized;\n    }\n\n    let mut lines = normalized.lines();\n    let Some(first_line) = lines.next() else {\n        return String::new();\n    };\n\n    let mut result = String::from(first_line);\n    for line in lines {\n        result.push('\\n');\n        result.push_str(parent_indent);\n        result.push_str(line);\n    }\n    result\n}\n\nfn normalize_json_five_output(source: &str) -> String {\n    source.replace(\"\\\\/\", \"/\")\n}\n\nfn make_root_pair(key: &str, value: RtJSONValue, closing_ws: String) -> RtJSONKeyValuePair {\n    RtJSONKeyValuePair {\n        key: make_json5_key(key),\n        value,\n        context: Some(RtKeyValuePairContext {\n            wsc: (String::new(), \" \".to_string(), closing_ws, None),\n        }),\n    }\n}\n\nfn make_json5_key(key: &str) -> RtJSONValue {\n    if is_identifier_key(key) {\n        RtJSONValue::Identifier(key.to_string())\n    } else {\n        RtJSONValue::DoubleQuotedString(key.to_string())\n    }\n}\n\nfn is_identifier_key(key: &str) -> bool {\n    let mut chars = key.chars();\n    let Some(first) = chars.next() else {\n        return false;\n    };\n\n    matches!(first, 'a'..='z' | 'A'..='Z' | '_' | '$')\n        && chars.all(|ch| matches!(ch, 'a'..='z' | 'A'..='Z' | '0'..='9' | '_' | '$'))\n}\n\nfn json5_key_name(key: &RtJSONValue) -> Option<&str> {\n    match key {\n        RtJSONValue::Identifier(name)\n        | RtJSONValue::DoubleQuotedString(name)\n        | RtJSONValue::SingleQuotedString(name) => Some(name),\n        _ => None,\n    }\n}\n\nfn warning(code: &str, message: impl Into<String>, path: Option<&str>) -> OpenClawHealthWarning {\n    OpenClawHealthWarning {\n        code: code.to_string(),\n        message: message.into(),\n        path: path.map(|value| value.to_string()),\n    }\n}\n\nfn scan_openclaw_health_from_value(config: &Value) -> Vec<OpenClawHealthWarning> {\n    let mut warnings = Vec::new();\n\n    if let Some(profile) = config\n        .get(\"tools\")\n        .and_then(|tools| tools.get(\"profile\"))\n        .and_then(Value::as_str)\n    {\n        if !OPENCLAW_TOOLS_PROFILES.contains(&profile) {\n            warnings.push(warning(\n                \"invalid_tools_profile\",\n                format!(\"tools.profile uses unsupported value '{profile}'.\"),\n                Some(\"tools.profile\"),\n            ));\n        }\n    }\n\n    if config\n        .get(\"agents\")\n        .and_then(|agents| agents.get(\"defaults\"))\n        .and_then(|defaults| defaults.get(\"timeout\"))\n        .is_some()\n    {\n        warnings.push(warning(\n            \"legacy_agents_timeout\",\n            \"agents.defaults.timeout is deprecated; use agents.defaults.timeoutSeconds.\",\n            Some(\"agents.defaults.timeout\"),\n        ));\n    }\n\n    if let Some(value) = config.get(\"env\").and_then(|env| env.get(\"vars\")) {\n        if !value.is_object() {\n            warnings.push(warning(\n                \"stringified_env_vars\",\n                \"env.vars should be an object. The current value looks stringified or malformed.\",\n                Some(\"env.vars\"),\n            ));\n        }\n    }\n\n    if let Some(value) = config.get(\"env\").and_then(|env| env.get(\"shellEnv\")) {\n        if !value.is_object() {\n            warnings.push(warning(\n                \"stringified_env_shell_env\",\n                \"env.shellEnv should be an object. The current value looks stringified or malformed.\",\n                Some(\"env.shellEnv\"),\n            ));\n        }\n    }\n\n    warnings\n}\n\nfn remove_legacy_timeout(defaults_value: &mut Value) {\n    if let Some(defaults_obj) = defaults_value.as_object_mut() {\n        defaults_obj.remove(\"timeout\");\n    }\n}\n\n// ============================================================================\n// Provider Functions (Untyped - for raw JSON operations)\n// ============================================================================\n\n/// 获取所有供应商配置（原始 JSON）\n///\n/// 从 `models.providers` 读取\npub fn get_providers() -> Result<Map<String, Value>, AppError> {\n    let config = read_openclaw_config()?;\n    Ok(config\n        .get(\"models\")\n        .and_then(|m| m.get(\"providers\"))\n        .and_then(Value::as_object)\n        .cloned()\n        .unwrap_or_default())\n}\n\n/// 获取单个供应商配置（原始 JSON）\npub fn get_provider(id: &str) -> Result<Option<Value>, AppError> {\n    Ok(get_providers()?.get(id).cloned())\n}\n\n/// 设置供应商配置（原始 JSON）\n///\n/// 写入到 `models.providers`\npub fn set_provider(id: &str, provider_config: Value) -> Result<OpenClawWriteOutcome, AppError> {\n    let mut full_config = read_openclaw_config()?;\n    let root = ensure_object(&mut full_config);\n    let models = root.entry(\"models\".to_string()).or_insert_with(|| {\n        json!({\n            \"mode\": \"merge\",\n            \"providers\": {}\n        })\n    });\n    let providers = ensure_object(models)\n        .entry(\"providers\".to_string())\n        .or_insert_with(|| Value::Object(Map::new()));\n    ensure_object(providers).insert(id.to_string(), provider_config);\n\n    let models_value = root.get(\"models\").cloned().unwrap_or_else(|| {\n        json!({\n            \"mode\": \"merge\",\n            \"providers\": {}\n        })\n    });\n    write_root_section(\"models\", &models_value)\n}\n\n/// 删除供应商配置\npub fn remove_provider(id: &str) -> Result<OpenClawWriteOutcome, AppError> {\n    let mut config = read_openclaw_config()?;\n    let mut removed = false;\n\n    if let Some(providers) = config\n        .get_mut(\"models\")\n        .and_then(|models| models.get_mut(\"providers\"))\n        .and_then(Value::as_object_mut)\n    {\n        removed = providers.remove(id).is_some();\n    }\n\n    if !removed {\n        return Ok(OpenClawWriteOutcome::default());\n    }\n\n    let models_value = config.get(\"models\").cloned().unwrap_or_else(|| {\n        json!({\n            \"mode\": \"merge\",\n            \"providers\": {}\n        })\n    });\n    write_root_section(\"models\", &models_value)\n}\n\n// ============================================================================\n// Provider Functions (Typed)\n// ============================================================================\n\n/// 获取所有供应商配置（类型化）\npub fn get_typed_providers() -> Result<IndexMap<String, OpenClawProviderConfig>, AppError> {\n    let providers = get_providers()?;\n    let mut result = IndexMap::new();\n\n    for (id, value) in providers {\n        match serde_json::from_value::<OpenClawProviderConfig>(value.clone()) {\n            Ok(config) => {\n                result.insert(id, config);\n            }\n            Err(e) => {\n                log::warn!(\"Failed to parse OpenClaw provider '{id}': {e}\");\n            }\n        }\n    }\n\n    Ok(result)\n}\n\n/// 设置供应商配置（类型化）\npub fn set_typed_provider(\n    id: &str,\n    config: &OpenClawProviderConfig,\n) -> Result<OpenClawWriteOutcome, AppError> {\n    let value = serde_json::to_value(config).map_err(|e| AppError::JsonSerialize { source: e })?;\n    set_provider(id, value)\n}\n\n// ============================================================================\n// Agents Configuration Functions\n// ============================================================================\n\n/// 读取默认模型配置（agents.defaults.model）\npub fn get_default_model() -> Result<Option<OpenClawDefaultModel>, AppError> {\n    let config = read_openclaw_config()?;\n\n    let Some(model_value) = config\n        .get(\"agents\")\n        .and_then(|a| a.get(\"defaults\"))\n        .and_then(|d| d.get(\"model\"))\n    else {\n        return Ok(None);\n    };\n\n    let model = serde_json::from_value(model_value.clone())\n        .map_err(|e| AppError::Config(format!(\"Failed to parse agents.defaults.model: {e}\")))?;\n    Ok(Some(model))\n}\n\n/// 设置默认模型配置（agents.defaults.model）\npub fn set_default_model(model: &OpenClawDefaultModel) -> Result<OpenClawWriteOutcome, AppError> {\n    let mut config = read_openclaw_config()?;\n    let root = ensure_object(&mut config);\n    let agents = root\n        .entry(\"agents\".to_string())\n        .or_insert_with(|| Value::Object(Map::new()));\n    let defaults = ensure_object(agents)\n        .entry(\"defaults\".to_string())\n        .or_insert_with(|| Value::Object(Map::new()));\n\n    let model_value =\n        serde_json::to_value(model).map_err(|e| AppError::JsonSerialize { source: e })?;\n    ensure_object(defaults).insert(\"model\".to_string(), model_value);\n\n    let agents_value = root\n        .get(\"agents\")\n        .cloned()\n        .unwrap_or_else(|| Value::Object(Map::new()));\n    write_root_section(\"agents\", &agents_value)\n}\n\n/// 读取模型目录/允许列表（agents.defaults.models）\npub fn get_model_catalog() -> Result<Option<HashMap<String, OpenClawModelCatalogEntry>>, AppError> {\n    let config = read_openclaw_config()?;\n\n    let Some(models_value) = config\n        .get(\"agents\")\n        .and_then(|a| a.get(\"defaults\"))\n        .and_then(|d| d.get(\"models\"))\n    else {\n        return Ok(None);\n    };\n\n    let catalog = serde_json::from_value(models_value.clone())\n        .map_err(|e| AppError::Config(format!(\"Failed to parse agents.defaults.models: {e}\")))?;\n    Ok(Some(catalog))\n}\n\n/// 设置模型目录/允许列表（agents.defaults.models）\npub fn set_model_catalog(\n    catalog: &HashMap<String, OpenClawModelCatalogEntry>,\n) -> Result<OpenClawWriteOutcome, AppError> {\n    let mut config = read_openclaw_config()?;\n    let root = ensure_object(&mut config);\n    let agents = root\n        .entry(\"agents\".to_string())\n        .or_insert_with(|| Value::Object(Map::new()));\n    let defaults = ensure_object(agents)\n        .entry(\"defaults\".to_string())\n        .or_insert_with(|| Value::Object(Map::new()));\n\n    let catalog_value =\n        serde_json::to_value(catalog).map_err(|e| AppError::JsonSerialize { source: e })?;\n    ensure_object(defaults).insert(\"models\".to_string(), catalog_value);\n\n    let agents_value = root\n        .get(\"agents\")\n        .cloned()\n        .unwrap_or_else(|| Value::Object(Map::new()));\n    write_root_section(\"agents\", &agents_value)\n}\n\n// ============================================================================\n// Full Agents Defaults Functions\n// ============================================================================\n\n/// Read the full agents.defaults config\npub fn get_agents_defaults() -> Result<Option<OpenClawAgentsDefaults>, AppError> {\n    let config = read_openclaw_config()?;\n\n    let Some(defaults_value) = config.get(\"agents\").and_then(|a| a.get(\"defaults\")) else {\n        return Ok(None);\n    };\n\n    let defaults = serde_json::from_value(defaults_value.clone())\n        .map_err(|e| AppError::Config(format!(\"Failed to parse agents.defaults: {e}\")))?;\n    Ok(Some(defaults))\n}\n\n/// Write the full agents.defaults config\npub fn set_agents_defaults(\n    defaults: &OpenClawAgentsDefaults,\n) -> Result<OpenClawWriteOutcome, AppError> {\n    let mut config = read_openclaw_config()?;\n    let root = ensure_object(&mut config);\n    let agents = root\n        .entry(\"agents\".to_string())\n        .or_insert_with(|| Value::Object(Map::new()));\n\n    let mut defaults_value =\n        serde_json::to_value(defaults).map_err(|e| AppError::JsonSerialize { source: e })?;\n    remove_legacy_timeout(&mut defaults_value);\n    ensure_object(agents).insert(\"defaults\".to_string(), defaults_value);\n\n    let agents_value = root\n        .get(\"agents\")\n        .cloned()\n        .unwrap_or_else(|| Value::Object(Map::new()));\n    write_root_section(\"agents\", &agents_value)\n}\n\n// ============================================================================\n// Env Configuration\n// ============================================================================\n\n/// Read the env config section\npub fn get_env_config() -> Result<OpenClawEnvConfig, AppError> {\n    let config = read_openclaw_config()?;\n\n    let Some(env_value) = config.get(\"env\") else {\n        return Ok(OpenClawEnvConfig {\n            vars: HashMap::new(),\n        });\n    };\n\n    serde_json::from_value(env_value.clone())\n        .map_err(|e| AppError::Config(format!(\"Failed to parse env config: {e}\")))\n}\n\n/// Write the env config section\npub fn set_env_config(env: &OpenClawEnvConfig) -> Result<OpenClawWriteOutcome, AppError> {\n    let value = serde_json::to_value(env).map_err(|e| AppError::JsonSerialize { source: e })?;\n    write_root_section(\"env\", &value)\n}\n\n// ============================================================================\n// Tools Configuration\n// ============================================================================\n\n/// Read the tools config section\npub fn get_tools_config() -> Result<OpenClawToolsConfig, AppError> {\n    let config = read_openclaw_config()?;\n\n    let Some(tools_value) = config.get(\"tools\") else {\n        return Ok(OpenClawToolsConfig {\n            profile: None,\n            allow: Vec::new(),\n            deny: Vec::new(),\n            extra: HashMap::new(),\n        });\n    };\n\n    serde_json::from_value(tools_value.clone())\n        .map_err(|e| AppError::Config(format!(\"Failed to parse tools config: {e}\")))\n}\n\n/// Write the tools config section\npub fn set_tools_config(tools: &OpenClawToolsConfig) -> Result<OpenClawWriteOutcome, AppError> {\n    let value = serde_json::to_value(tools).map_err(|e| AppError::JsonSerialize { source: e })?;\n    write_root_section(\"tools\", &value)\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use std::sync::{Mutex, OnceLock};\n\n    fn test_guard() -> std::sync::MutexGuard<'static, ()> {\n        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();\n        LOCK.get_or_init(|| Mutex::new(()))\n            .lock()\n            .unwrap_or_else(|err| err.into_inner())\n    }\n\n    fn with_test_paths<T>(source: &str, test: impl FnOnce(&Path) -> T) -> T {\n        let _guard = test_guard();\n        let temp = tempfile::tempdir().unwrap();\n        let openclaw_dir = temp.path().join(\".openclaw\");\n        fs::create_dir_all(&openclaw_dir).unwrap();\n        let config_path = openclaw_dir.join(\"openclaw.json\");\n        fs::write(&config_path, source).unwrap();\n        let old_test_home = std::env::var_os(\"CC_SWITCH_TEST_HOME\");\n        let old_home = std::env::var_os(\"HOME\");\n        std::env::set_var(\"CC_SWITCH_TEST_HOME\", temp.path());\n        std::env::set_var(\"HOME\", temp.path());\n        let result = test(&config_path);\n        match old_test_home {\n            Some(value) => std::env::set_var(\"CC_SWITCH_TEST_HOME\", value),\n            None => std::env::remove_var(\"CC_SWITCH_TEST_HOME\"),\n        }\n        match old_home {\n            Some(value) => std::env::set_var(\"HOME\", value),\n            None => std::env::remove_var(\"HOME\"),\n        }\n        result\n    }\n\n    #[test]\n    fn scan_health_detects_known_openclaw_issues() {\n        let config = json!({\n            \"tools\": { \"profile\": \"default\" },\n            \"agents\": { \"defaults\": { \"timeout\": 30 } },\n            \"env\": { \"vars\": \"[object Object]\", \"shellEnv\": \"oops\" }\n        });\n\n        let warnings = scan_openclaw_health_from_value(&config);\n        let codes = warnings\n            .into_iter()\n            .map(|warning| warning.code)\n            .collect::<Vec<_>>();\n        assert!(codes.contains(&\"invalid_tools_profile\".to_string()));\n        assert!(codes.contains(&\"legacy_agents_timeout\".to_string()));\n        assert!(codes.contains(&\"stringified_env_vars\".to_string()));\n        assert!(codes.contains(&\"stringified_env_shell_env\".to_string()));\n    }\n\n    #[test]\n    fn default_model_write_preserves_top_level_comments() {\n        let source = r#\"{\n  // top-level comment\n  models: {\n    mode: 'merge',\n    providers: {},\n  },\n}\n\"#;\n\n        with_test_paths(source, |_| {\n            let outcome = set_default_model(&OpenClawDefaultModel {\n                primary: \"provider/model\".to_string(),\n                fallbacks: Vec::new(),\n                extra: HashMap::new(),\n            })\n            .unwrap();\n\n            assert!(outcome.backup_path.is_some());\n\n            let written = fs::read_to_string(get_openclaw_config_path()).unwrap();\n            assert!(written.contains(\"// top-level comment\"));\n            assert!(written.contains(\"agents: {\"));\n            assert!(written.contains(\"provider/model\"));\n        });\n    }\n\n    #[test]\n    fn default_model_noop_write_skips_backup() {\n        let source = r#\"{\n  models: {\n    mode: 'merge',\n    providers: {},\n  },\n}\n\"#;\n\n        with_test_paths(source, |_| {\n            let model = OpenClawDefaultModel {\n                primary: \"provider/model\".to_string(),\n                fallbacks: vec![\"provider/fallback\".to_string()],\n                extra: HashMap::new(),\n            };\n\n            let first_outcome = set_default_model(&model).unwrap();\n            assert!(first_outcome.backup_path.is_some());\n\n            let first_written = fs::read_to_string(get_openclaw_config_path()).unwrap();\n            let backup_dir = get_app_config_dir().join(\"backups\").join(\"openclaw\");\n            let backup_count = fs::read_dir(&backup_dir).unwrap().count();\n            assert_eq!(backup_count, 1);\n\n            let second_outcome = set_default_model(&model).unwrap();\n            assert!(second_outcome.backup_path.is_none());\n\n            let second_written = fs::read_to_string(get_openclaw_config_path()).unwrap();\n            assert_eq!(second_written, first_written);\n            assert_eq!(fs::read_dir(&backup_dir).unwrap().count(), backup_count);\n        });\n    }\n\n    #[test]\n    fn save_detects_external_conflict() {\n        let source = r#\"{\n  models: {\n    mode: 'merge',\n    providers: {},\n  },\n}\n\"#;\n\n        with_test_paths(source, |config_path| {\n            let mut document = OpenClawConfigDocument::load().unwrap();\n            document\n                .set_root_section(\"env\", &json!({ \"TOKEN\": \"value\" }))\n                .unwrap();\n\n            fs::write(config_path, \"{ changedExternally: true }\\n\").unwrap();\n            let err = document.save().unwrap_err();\n            assert!(err.to_string().contains(\"OpenClaw config changed on disk\"));\n        });\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/opencode_config.rs",
    "content": "use crate::config::write_json_file;\nuse crate::error::AppError;\nuse crate::provider::OpenCodeProviderConfig;\nuse crate::settings::get_opencode_override_dir;\nuse indexmap::IndexMap;\nuse serde_json::{json, Map, Value};\nuse std::path::PathBuf;\n\npub fn get_opencode_dir() -> PathBuf {\n    if let Some(override_dir) = get_opencode_override_dir() {\n        return override_dir;\n    }\n\n    dirs::home_dir()\n        .map(|h| h.join(\".config\").join(\"opencode\"))\n        .unwrap_or_else(|| PathBuf::from(\".config\").join(\"opencode\"))\n}\n\npub fn get_opencode_config_path() -> PathBuf {\n    get_opencode_dir().join(\"opencode.json\")\n}\n\n#[allow(dead_code)]\npub fn get_opencode_env_path() -> PathBuf {\n    get_opencode_dir().join(\".env\")\n}\n\npub fn read_opencode_config() -> Result<Value, AppError> {\n    let path = get_opencode_config_path();\n\n    if !path.exists() {\n        return Ok(json!({\n            \"$schema\": \"https://opencode.ai/config.json\"\n        }));\n    }\n\n    let content = std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;\n    serde_json::from_str(&content).map_err(|e| AppError::json(&path, e))\n}\n\npub fn write_opencode_config(config: &Value) -> Result<(), AppError> {\n    let path = get_opencode_config_path();\n    write_json_file(&path, config)?;\n\n    log::debug!(\"OpenCode config written to {path:?}\");\n    Ok(())\n}\n\npub fn get_providers() -> Result<Map<String, Value>, AppError> {\n    let config = read_opencode_config()?;\n    Ok(config\n        .get(\"provider\")\n        .and_then(|v| v.as_object())\n        .cloned()\n        .unwrap_or_default())\n}\n\npub fn set_provider(id: &str, config: Value) -> Result<(), AppError> {\n    let mut full_config = read_opencode_config()?;\n\n    if full_config.get(\"provider\").is_none() {\n        full_config[\"provider\"] = json!({});\n    }\n\n    if let Some(providers) = full_config\n        .get_mut(\"provider\")\n        .and_then(|v| v.as_object_mut())\n    {\n        providers.insert(id.to_string(), config);\n    }\n\n    write_opencode_config(&full_config)\n}\n\npub fn remove_provider(id: &str) -> Result<(), AppError> {\n    let mut config = read_opencode_config()?;\n\n    if let Some(providers) = config.get_mut(\"provider\").and_then(|v| v.as_object_mut()) {\n        providers.remove(id);\n    }\n\n    write_opencode_config(&config)\n}\n\npub fn get_typed_providers() -> Result<IndexMap<String, OpenCodeProviderConfig>, AppError> {\n    let providers = get_providers()?;\n    let mut result = IndexMap::new();\n\n    for (id, value) in providers {\n        match serde_json::from_value::<OpenCodeProviderConfig>(value.clone()) {\n            Ok(config) => {\n                result.insert(id, config);\n            }\n            Err(e) => {\n                log::warn!(\"Failed to parse provider '{id}': {e}\");\n            }\n        }\n    }\n\n    Ok(result)\n}\n\npub fn set_typed_provider(id: &str, config: &OpenCodeProviderConfig) -> Result<(), AppError> {\n    let value = serde_json::to_value(config).map_err(|e| AppError::JsonSerialize { source: e })?;\n    set_provider(id, value)\n}\n\npub fn get_mcp_servers() -> Result<Map<String, Value>, AppError> {\n    let config = read_opencode_config()?;\n    Ok(config\n        .get(\"mcp\")\n        .and_then(|v| v.as_object())\n        .cloned()\n        .unwrap_or_default())\n}\n\npub fn set_mcp_server(id: &str, config: Value) -> Result<(), AppError> {\n    let mut full_config = read_opencode_config()?;\n\n    if full_config.get(\"mcp\").is_none() {\n        full_config[\"mcp\"] = json!({});\n    }\n\n    if let Some(mcp) = full_config.get_mut(\"mcp\").and_then(|v| v.as_object_mut()) {\n        mcp.insert(id.to_string(), config);\n    }\n\n    write_opencode_config(&full_config)\n}\n\npub fn remove_mcp_server(id: &str) -> Result<(), AppError> {\n    let mut config = read_opencode_config()?;\n\n    if let Some(mcp) = config.get_mut(\"mcp\").and_then(|v| v.as_object_mut()) {\n        mcp.remove(id);\n    }\n\n    write_opencode_config(&config)\n}\n\npub fn add_plugin(plugin_name: &str) -> Result<(), AppError> {\n    let mut config = read_opencode_config()?;\n\n    let plugins = config.get_mut(\"plugin\").and_then(|v| v.as_array_mut());\n\n    match plugins {\n        Some(arr) => {\n            // Mutual exclusion: standard OMO and OMO Slim cannot coexist as plugins\n            if plugin_name.starts_with(\"oh-my-opencode\")\n                && !plugin_name.starts_with(\"oh-my-opencode-slim\")\n            {\n                // Adding standard OMO -> remove all Slim variants\n                arr.retain(|v| {\n                    v.as_str()\n                        .map(|s| !s.starts_with(\"oh-my-opencode-slim\"))\n                        .unwrap_or(true)\n                });\n            } else if plugin_name.starts_with(\"oh-my-opencode-slim\") {\n                // Adding Slim -> remove all standard OMO variants (but keep slim)\n                arr.retain(|v| {\n                    v.as_str()\n                        .map(|s| {\n                            !s.starts_with(\"oh-my-opencode\") || s.starts_with(\"oh-my-opencode-slim\")\n                        })\n                        .unwrap_or(true)\n                });\n            }\n\n            let already_exists = arr.iter().any(|v| v.as_str() == Some(plugin_name));\n            if !already_exists {\n                arr.push(Value::String(plugin_name.to_string()));\n            }\n        }\n        None => {\n            config[\"plugin\"] = json!([plugin_name]);\n        }\n    }\n\n    write_opencode_config(&config)\n}\n\npub fn remove_plugin_by_prefix(prefix: &str) -> Result<(), AppError> {\n    let mut config = read_opencode_config()?;\n\n    if let Some(arr) = config.get_mut(\"plugin\").and_then(|v| v.as_array_mut()) {\n        arr.retain(|v| {\n            v.as_str()\n                .map(|s| {\n                    if !s.starts_with(prefix) {\n                        return true; // Keep: doesn't match prefix at all\n                    }\n                    let rest = &s[prefix.len()..];\n                    rest.starts_with('-')\n                })\n                .unwrap_or(true)\n        });\n\n        if arr.is_empty() {\n            config.as_object_mut().map(|obj| obj.remove(\"plugin\"));\n        }\n    }\n\n    write_opencode_config(&config)\n}\n"
  },
  {
    "path": "src-tauri/src/panic_hook.rs",
    "content": "//! Panic Hook 模块\n//!\n//! 在应用崩溃时捕获 panic 信息并记录到 `<app_config_dir>/crash.log` 文件中（默认 `~/.cc-switch/crash.log`）。\n//! 便于用户和开发者诊断闪退问题。\n\nuse std::fs::OpenOptions;\nuse std::io::Write;\nuse std::panic;\nuse std::path::PathBuf;\nuse std::sync::OnceLock;\n\n/// 应用版本号（从 Cargo.toml 读取）\nconst APP_VERSION: &str = env!(\"CARGO_PKG_VERSION\");\n\nstatic APP_CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();\n\npub fn init_app_config_dir(dir: PathBuf) {\n    let _ = APP_CONFIG_DIR.set(dir);\n}\n\n/// 获取默认应用配置目录（不会 panic）\nfn default_app_config_dir() -> PathBuf {\n    dirs::home_dir()\n        .unwrap_or_else(|| PathBuf::from(\".\"))\n        .join(\".cc-switch\")\n}\n\n/// 获取应用配置目录（优先使用初始化时写入的值；不会 panic）\nfn get_app_config_dir() -> PathBuf {\n    APP_CONFIG_DIR\n        .get()\n        .cloned()\n        .unwrap_or_else(default_app_config_dir)\n}\n\n/// 获取崩溃日志文件路径\nfn get_crash_log_path() -> PathBuf {\n    get_app_config_dir().join(\"crash.log\")\n}\n\n/// 获取日志目录路径\npub fn get_log_dir() -> PathBuf {\n    get_app_config_dir().join(\"logs\")\n}\n\n/// 安全获取环境信息（不会 panic）\nfn get_system_info() -> String {\n    let os = std::env::consts::OS;\n    let arch = std::env::consts::ARCH;\n    let family = std::env::consts::FAMILY;\n\n    // 安全获取当前工作目录\n    let cwd = std::env::current_dir()\n        .map(|p| p.display().to_string())\n        .unwrap_or_else(|_| \"unknown\".to_string());\n\n    // 安全获取当前线程信息\n    let thread = std::thread::current();\n    let thread_name = thread.name().unwrap_or(\"unnamed\");\n    let thread_id = format!(\"{:?}\", thread.id());\n\n    format!(\n        \"OS: {os} ({family})\\n\\\n         Arch: {arch}\\n\\\n         App Version: {APP_VERSION}\\n\\\n         Working Dir: {cwd}\\n\\\n         Thread: {thread_name} (ID: {thread_id})\"\n    )\n}\n\n/// 设置 panic hook，捕获崩溃信息并写入日志文件\n///\n/// 在应用启动时调用此函数，确保任何 panic 都会被记录。\n/// 日志格式包含：\n/// - 时间戳\n/// - 应用版本和系统信息\n/// - Panic 信息\n/// - 发生位置（文件:行号）\n/// - Backtrace（完整调用栈）\npub fn setup_panic_hook() {\n    // 启用 backtrace（确保 release 模式也能捕获）\n    if std::env::var(\"RUST_BACKTRACE\").is_err() {\n        std::env::set_var(\"RUST_BACKTRACE\", \"1\");\n    }\n\n    let default_hook = panic::take_hook();\n\n    panic::set_hook(Box::new(move |panic_info| {\n        let log_path = get_crash_log_path();\n\n        // 确保目录存在\n        if let Some(parent) = log_path.parent() {\n            let _ = std::fs::create_dir_all(parent);\n        }\n\n        // 构建崩溃信息（使用 catch_unwind 保护时间格式化，避免嵌套 panic）\n        let timestamp = std::panic::catch_unwind(|| {\n            chrono::Local::now()\n                .format(\"%Y-%m-%d %H:%M:%S%.3f\")\n                .to_string()\n        })\n        .unwrap_or_else(|_| {\n            // chrono panic 时回退到 unix timestamp\n            std::time::SystemTime::now()\n                .duration_since(std::time::UNIX_EPOCH)\n                .map(|d| format!(\"unix:{}.{:03}\", d.as_secs(), d.subsec_millis()))\n                .unwrap_or_else(|_| \"unknown\".to_string())\n        });\n\n        // 获取系统信息\n        let system_info = std::panic::catch_unwind(get_system_info)\n            .unwrap_or_else(|_| \"Failed to get system info\".to_string());\n\n        // 获取 panic 消息（尝试多种方式提取）\n        let message = if let Some(s) = panic_info.payload().downcast_ref::<&str>() {\n            s.to_string()\n        } else if let Some(s) = panic_info.payload().downcast_ref::<String>() {\n            s.clone()\n        } else {\n            // 尝试使用 Display trait\n            format!(\"{panic_info}\")\n        };\n\n        // 获取位置信息\n        let location = if let Some(loc) = panic_info.location() {\n            format!(\n                \"File: {}\\n         Line: {}\\n         Column: {}\",\n                loc.file(),\n                loc.line(),\n                loc.column()\n            )\n        } else {\n            \"Unknown location\".to_string()\n        };\n\n        // 捕获 backtrace（完整调用栈）\n        let backtrace = std::backtrace::Backtrace::force_capture();\n        let backtrace_str = format!(\"{backtrace}\");\n\n        // 格式化日志条目\n        let separator = \"=\".repeat(80);\n        let sub_separator = \"-\".repeat(40);\n        let crash_entry = format!(\n            r#\"\n{separator}\n[CRASH REPORT] {timestamp}\n{separator}\n\n{sub_separator}\nSystem Information\n{sub_separator}\n{system_info}\n\n{sub_separator}\nError Details\n{sub_separator}\nMessage: {message}\n\nLocation: {location}\n\n{sub_separator}\nStack Trace (Backtrace)\n{sub_separator}\n{backtrace_str}\n\n{separator}\n\"#\n        );\n\n        // 写入文件（追加模式）\n        if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(&log_path) {\n            let _ = file.write_all(crash_entry.as_bytes());\n            let _ = file.flush();\n\n            // 记录日志文件位置到 stderr\n            eprintln!(\"\\n[CC-Switch] Crash log saved to: {}\", log_path.display());\n        }\n\n        // 同时输出到 stderr（便于开发调试）\n        eprintln!(\"{crash_entry}\");\n\n        // 调用默认 hook\n        default_hook(panic_info);\n    }));\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_crash_log_path() {\n        let path = get_crash_log_path();\n        assert!(path.ends_with(\"crash.log\"));\n        assert!(path.to_string_lossy().contains(\".cc-switch\"));\n    }\n\n    #[test]\n    fn test_system_info() {\n        let info = get_system_info();\n        assert!(info.contains(\"OS:\"));\n        assert!(info.contains(\"Arch:\"));\n        assert!(info.contains(\"App Version:\"));\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/prompt.rs",
    "content": "use serde::{Deserialize, Serialize};\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct Prompt {\n    pub id: String,\n    pub name: String,\n    pub content: String,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub description: Option<String>,\n    #[serde(default)]\n    pub enabled: bool,\n    #[serde(rename = \"createdAt\", skip_serializing_if = \"Option::is_none\")]\n    pub created_at: Option<i64>,\n    #[serde(rename = \"updatedAt\", skip_serializing_if = \"Option::is_none\")]\n    pub updated_at: Option<i64>,\n}\n"
  },
  {
    "path": "src-tauri/src/prompt_files.rs",
    "content": "use std::path::PathBuf;\n\nuse crate::app_config::AppType;\nuse crate::codex_config::get_codex_auth_path;\nuse crate::config::get_claude_settings_path;\nuse crate::error::AppError;\nuse crate::gemini_config::get_gemini_dir;\nuse crate::openclaw_config::get_openclaw_dir;\nuse crate::opencode_config::get_opencode_dir;\n\n/// 返回指定应用所使用的提示词文件路径。\npub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {\n    let base_dir: PathBuf = match app {\n        AppType::Claude => get_base_dir_with_fallback(get_claude_settings_path(), \".claude\")?,\n        AppType::Codex => get_base_dir_with_fallback(get_codex_auth_path(), \".codex\")?,\n        AppType::Gemini => get_gemini_dir(),\n        AppType::OpenCode => get_opencode_dir(),\n        AppType::OpenClaw => get_openclaw_dir(),\n    };\n\n    let filename = match app {\n        AppType::Claude => \"CLAUDE.md\",\n        AppType::Codex => \"AGENTS.md\",\n        AppType::Gemini => \"GEMINI.md\",\n        AppType::OpenCode => \"AGENTS.md\",\n        AppType::OpenClaw => \"AGENTS.md\", // OpenClaw uses AGENTS.md for agent instructions\n    };\n\n    Ok(base_dir.join(filename))\n}\n\nfn get_base_dir_with_fallback(\n    primary_path: PathBuf,\n    fallback_dir: &str,\n) -> Result<PathBuf, AppError> {\n    primary_path\n        .parent()\n        .map(|p| p.to_path_buf())\n        .or_else(|| dirs::home_dir().map(|h| h.join(fallback_dir)))\n        .ok_or_else(|| {\n            AppError::localized(\n                \"home_dir_not_found\",\n                format!(\"无法确定 {fallback_dir} 配置目录：用户主目录不存在\"),\n                format!(\"Cannot determine {fallback_dir} config directory: user home not found\"),\n            )\n        })\n}\n"
  },
  {
    "path": "src-tauri/src/provider.rs",
    "content": "use indexmap::IndexMap;\nuse serde::{Deserialize, Serialize};\nuse serde_json::Value;\nuse std::collections::HashMap;\n\n// SSOT 模式：不再写供应商副本文件\n\n/// 供应商结构体\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct Provider {\n    pub id: String,\n    pub name: String,\n    #[serde(rename = \"settingsConfig\")]\n    pub settings_config: Value,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    #[serde(rename = \"websiteUrl\")]\n    pub website_url: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub category: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    #[serde(rename = \"createdAt\")]\n    pub created_at: Option<i64>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    #[serde(rename = \"sortIndex\")]\n    pub sort_index: Option<usize>,\n    /// 备注信息\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub notes: Option<String>,\n    /// 供应商元数据（不写入 live 配置，仅存于 ~/.cc-switch/config.json）\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub meta: Option<ProviderMeta>,\n    /// 图标名称（如 \"openai\", \"anthropic\"）\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub icon: Option<String>,\n    /// 图标颜色（Hex 格式，如 \"#00A67E\"）\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    #[serde(rename = \"iconColor\")]\n    pub icon_color: Option<String>,\n    /// 是否加入故障转移队列\n    #[serde(default)]\n    #[serde(rename = \"inFailoverQueue\")]\n    pub in_failover_queue: bool,\n}\n\nimpl Provider {\n    /// 从现有ID创建供应商\n    pub fn with_id(\n        id: String,\n        name: String,\n        settings_config: Value,\n        website_url: Option<String>,\n    ) -> Self {\n        Self {\n            id,\n            name,\n            settings_config,\n            website_url,\n            category: None,\n            created_at: None,\n            sort_index: None,\n            notes: None,\n            meta: None,\n            icon: None,\n            icon_color: None,\n            in_failover_queue: false,\n        }\n    }\n}\n\n/// 供应商管理器\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct ProviderManager {\n    pub providers: IndexMap<String, Provider>,\n    pub current: String,\n}\n\n/// 用量查询脚本配置\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct UsageScript {\n    pub enabled: bool,\n    pub language: String,\n    pub code: String,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub timeout: Option<u64>,\n    /// 用量查询专用的 API Key（通用模板使用）\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    #[serde(rename = \"apiKey\")]\n    pub api_key: Option<String>,\n    /// 用量查询专用的 Base URL（通用和 NewAPI 模板使用）\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    #[serde(rename = \"baseUrl\")]\n    pub base_url: Option<String>,\n    /// 访问令牌（用于需要登录的接口，NewAPI 模板使用）\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    #[serde(rename = \"accessToken\")]\n    pub access_token: Option<String>,\n    /// 用户ID（用于需要用户标识的接口，NewAPI 模板使用）\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    #[serde(rename = \"userId\")]\n    pub user_id: Option<String>,\n    /// 模板类型（用于后端判断验证规则）\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    #[serde(rename = \"templateType\")]\n    pub template_type: Option<String>,\n    /// 自动查询间隔（单位：分钟，0 表示禁用自动查询）\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    #[serde(rename = \"autoQueryInterval\")]\n    pub auto_query_interval: Option<u64>,\n}\n\n/// 用量数据\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct UsageData {\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    #[serde(rename = \"planName\")]\n    pub plan_name: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub extra: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    #[serde(rename = \"isValid\")]\n    pub is_valid: Option<bool>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    #[serde(rename = \"invalidMessage\")]\n    pub invalid_message: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub total: Option<f64>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub used: Option<f64>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub remaining: Option<f64>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub unit: Option<String>,\n}\n\n/// 用量查询结果（支持多套餐）\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct UsageResult {\n    pub success: bool,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub data: Option<Vec<UsageData>>, // 支持返回多个套餐\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub error: Option<String>,\n}\n\n/// 供应商单独的模型测试配置\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct ProviderTestConfig {\n    /// 是否启用单独配置（false 时使用全局配置）\n    #[serde(default)]\n    pub enabled: bool,\n    /// 测试用的模型名称（覆盖全局配置）\n    #[serde(rename = \"testModel\", skip_serializing_if = \"Option::is_none\")]\n    pub test_model: Option<String>,\n    /// 超时时间（秒）\n    #[serde(rename = \"timeoutSecs\", skip_serializing_if = \"Option::is_none\")]\n    pub timeout_secs: Option<u64>,\n    /// 测试提示词\n    #[serde(rename = \"testPrompt\", skip_serializing_if = \"Option::is_none\")]\n    pub test_prompt: Option<String>,\n    /// 降级阈值（毫秒）\n    #[serde(\n        rename = \"degradedThresholdMs\",\n        skip_serializing_if = \"Option::is_none\"\n    )]\n    pub degraded_threshold_ms: Option<u64>,\n    /// 最大重试次数\n    #[serde(rename = \"maxRetries\", skip_serializing_if = \"Option::is_none\")]\n    pub max_retries: Option<u32>,\n}\n\n/// 供应商单独的代理配置\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct ProviderProxyConfig {\n    /// 是否启用单独配置（false 时使用全局/系统代理）\n    #[serde(default)]\n    pub enabled: bool,\n    /// 代理类型：http, https, socks5\n    #[serde(rename = \"proxyType\", skip_serializing_if = \"Option::is_none\")]\n    pub proxy_type: Option<String>,\n    /// 代理主机\n    #[serde(rename = \"proxyHost\", skip_serializing_if = \"Option::is_none\")]\n    pub proxy_host: Option<String>,\n    /// 代理端口\n    #[serde(rename = \"proxyPort\", skip_serializing_if = \"Option::is_none\")]\n    pub proxy_port: Option<u16>,\n    /// 代理用户名（可选）\n    #[serde(rename = \"proxyUsername\", skip_serializing_if = \"Option::is_none\")]\n    pub proxy_username: Option<String>,\n    /// 代理密码（可选）\n    #[serde(rename = \"proxyPassword\", skip_serializing_if = \"Option::is_none\")]\n    pub proxy_password: Option<String>,\n}\n\n/// 认证绑定来源\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]\n#[serde(rename_all = \"snake_case\")]\npub enum AuthBindingSource {\n    /// 从 provider 自身配置读取认证信息（默认）\n    #[default]\n    ProviderConfig,\n    /// 使用托管账号认证（如 GitHub Copilot OAuth）\n    ManagedAccount,\n}\n\n/// 通用认证绑定\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct AuthBinding {\n    /// 认证来源\n    #[serde(default)]\n    pub source: AuthBindingSource,\n    /// 托管认证供应商标识（如 github_copilot）\n    #[serde(rename = \"authProvider\", skip_serializing_if = \"Option::is_none\")]\n    pub auth_provider: Option<String>,\n    /// 托管账号 ID；为空表示跟随该认证供应商的默认账号\n    #[serde(rename = \"accountId\", skip_serializing_if = \"Option::is_none\")]\n    pub account_id: Option<String>,\n}\n\n/// 供应商元数据\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct ProviderMeta {\n    /// 自定义端点列表（按 URL 去重存储）\n    #[serde(default, skip_serializing_if = \"HashMap::is_empty\")]\n    pub custom_endpoints: HashMap<String, crate::settings::CustomEndpoint>,\n    /// 是否在写入 live 时应用通用配置片段\n    #[serde(\n        rename = \"commonConfigEnabled\",\n        skip_serializing_if = \"Option::is_none\"\n    )]\n    pub common_config_enabled: Option<bool>,\n    /// 用量查询脚本配置\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub usage_script: Option<UsageScript>,\n    /// 请求地址管理：测速后自动选择最佳端点\n    #[serde(rename = \"endpointAutoSelect\", skip_serializing_if = \"Option::is_none\")]\n    pub endpoint_auto_select: Option<bool>,\n    /// 合作伙伴标记（前端使用 isPartner，保持字段名一致）\n    #[serde(rename = \"isPartner\", skip_serializing_if = \"Option::is_none\")]\n    pub is_partner: Option<bool>,\n    /// 合作伙伴促销 key，用于识别 PackyCode 等特殊供应商\n    #[serde(\n        rename = \"partnerPromotionKey\",\n        skip_serializing_if = \"Option::is_none\"\n    )]\n    pub partner_promotion_key: Option<String>,\n    /// 成本倍数（用于计算实际成本）\n    #[serde(rename = \"costMultiplier\", skip_serializing_if = \"Option::is_none\")]\n    pub cost_multiplier: Option<String>,\n    /// 计费模式来源（response/request）\n    #[serde(rename = \"pricingModelSource\", skip_serializing_if = \"Option::is_none\")]\n    pub pricing_model_source: Option<String>,\n    /// 每日消费限额（USD）\n    #[serde(rename = \"limitDailyUsd\", skip_serializing_if = \"Option::is_none\")]\n    pub limit_daily_usd: Option<String>,\n    /// 每月消费限额（USD）\n    #[serde(rename = \"limitMonthlyUsd\", skip_serializing_if = \"Option::is_none\")]\n    pub limit_monthly_usd: Option<String>,\n    /// 供应商单独的模型测试配置\n    #[serde(rename = \"testConfig\", skip_serializing_if = \"Option::is_none\")]\n    pub test_config: Option<ProviderTestConfig>,\n    /// 供应商单独的代理配置\n    #[serde(rename = \"proxyConfig\", skip_serializing_if = \"Option::is_none\")]\n    pub proxy_config: Option<ProviderProxyConfig>,\n    /// Claude API 格式（仅 Claude 供应商使用）\n    /// - \"anthropic\": 原生 Anthropic Messages API，直接透传\n    /// - \"openai_chat\": OpenAI Chat Completions 格式，需要转换\n    /// - \"openai_responses\": OpenAI Responses API 格式，需要转换\n    #[serde(rename = \"apiFormat\", skip_serializing_if = \"Option::is_none\")]\n    pub api_format: Option<String>,\n    /// 通用认证绑定（provider_config / managed_account）\n    ///\n    /// 新代码应只写入该字段；githubAccountId 仅保留兼容读取。\n    #[serde(rename = \"authBinding\", skip_serializing_if = \"Option::is_none\")]\n    pub auth_binding: Option<AuthBinding>,\n    /// Claude 认证字段名（\"ANTHROPIC_AUTH_TOKEN\" 或 \"ANTHROPIC_API_KEY\"）\n    #[serde(rename = \"apiKeyField\", skip_serializing_if = \"Option::is_none\")]\n    pub api_key_field: Option<String>,\n\n    /// Prompt cache key for OpenAI-compatible endpoints.\n    /// When set, injected into converted requests to improve cache hit rate.\n    /// If not set, provider ID is used automatically during format conversion.\n    #[serde(rename = \"promptCacheKey\", skip_serializing_if = \"Option::is_none\")]\n    pub prompt_cache_key: Option<String>,\n    /// 供应商类型标识（用于特殊供应商检测）\n    /// - \"github_copilot\": GitHub Copilot 供应商\n    #[serde(rename = \"providerType\", skip_serializing_if = \"Option::is_none\")]\n    pub provider_type: Option<String>,\n    /// GitHub Copilot 关联账号 ID（仅 github_copilot 供应商使用）\n    /// 用于多账号支持，关联到特定的 GitHub 账号\n    #[serde(rename = \"githubAccountId\", skip_serializing_if = \"Option::is_none\")]\n    pub github_account_id: Option<String>,\n}\n\nimpl ProviderMeta {\n    /// 解析指定托管认证供应商绑定的账号 ID。\n    ///\n    /// 新版优先读取 authBinding，旧版继续兼容 githubAccountId。\n    pub fn managed_account_id_for(&self, auth_provider: &str) -> Option<String> {\n        if let Some(binding) = self.auth_binding.as_ref() {\n            if binding.source == AuthBindingSource::ManagedAccount\n                && binding.auth_provider.as_deref() == Some(auth_provider)\n            {\n                return binding.account_id.clone();\n            }\n        }\n\n        if auth_provider == \"github_copilot\" {\n            return self.github_account_id.clone();\n        }\n\n        None\n    }\n}\n\nimpl ProviderManager {\n    /// 获取所有供应商\n    pub fn get_all_providers(&self) -> &IndexMap<String, Provider> {\n        &self.providers\n    }\n}\n\n// ============================================================================\n// 统一供应商（Universal Provider）- 跨应用共享配置\n// ============================================================================\n\n/// 统一供应商的应用启用状态\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct UniversalProviderApps {\n    #[serde(default)]\n    pub claude: bool,\n    #[serde(default)]\n    pub codex: bool,\n    #[serde(default)]\n    pub gemini: bool,\n}\n\n/// Claude 模型配置\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct ClaudeModelConfig {\n    /// 主模型\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub model: Option<String>,\n    /// Haiku 默认模型\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    #[serde(rename = \"haikuModel\")]\n    pub haiku_model: Option<String>,\n    /// Sonnet 默认模型\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    #[serde(rename = \"sonnetModel\")]\n    pub sonnet_model: Option<String>,\n    /// Opus 默认模型\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    #[serde(rename = \"opusModel\")]\n    pub opus_model: Option<String>,\n}\n\n/// Codex 模型配置\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct CodexModelConfig {\n    /// 模型名称\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub model: Option<String>,\n    /// 推理强度\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    #[serde(rename = \"reasoningEffort\")]\n    pub reasoning_effort: Option<String>,\n}\n\n/// Gemini 模型配置\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct GeminiModelConfig {\n    /// 模型名称\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub model: Option<String>,\n}\n\n/// 各应用的模型配置\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct UniversalProviderModels {\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub claude: Option<ClaudeModelConfig>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub codex: Option<CodexModelConfig>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub gemini: Option<GeminiModelConfig>,\n}\n\n/// 统一供应商（跨应用共享配置）\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct UniversalProvider {\n    /// 唯一标识\n    pub id: String,\n    /// 供应商名称\n    pub name: String,\n    /// 供应商类型（如 \"newapi\", \"custom\"）\n    #[serde(rename = \"providerType\")]\n    pub provider_type: String,\n    /// 应用启用状态\n    pub apps: UniversalProviderApps,\n    /// API 基础地址\n    #[serde(rename = \"baseUrl\")]\n    pub base_url: String,\n    /// API 密钥\n    #[serde(rename = \"apiKey\")]\n    pub api_key: String,\n    /// 各应用的模型配置\n    #[serde(default)]\n    pub models: UniversalProviderModels,\n    /// 网站链接\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    #[serde(rename = \"websiteUrl\")]\n    pub website_url: Option<String>,\n    /// 备注信息\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub notes: Option<String>,\n    /// 图标名称\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub icon: Option<String>,\n    /// 图标颜色\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    #[serde(rename = \"iconColor\")]\n    pub icon_color: Option<String>,\n    /// 元数据\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub meta: Option<ProviderMeta>,\n    /// 创建时间戳\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    #[serde(rename = \"createdAt\")]\n    pub created_at: Option<i64>,\n    /// 排序索引\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    #[serde(rename = \"sortIndex\")]\n    pub sort_index: Option<usize>,\n}\n\nimpl UniversalProvider {\n    /// 创建新的统一供应商\n    pub fn new(\n        id: String,\n        name: String,\n        provider_type: String,\n        base_url: String,\n        api_key: String,\n    ) -> Self {\n        Self {\n            id,\n            name,\n            provider_type,\n            apps: UniversalProviderApps::default(),\n            base_url,\n            api_key,\n            models: UniversalProviderModels::default(),\n            website_url: None,\n            notes: None,\n            icon: None,\n            icon_color: None,\n            meta: None,\n            created_at: Some(chrono::Utc::now().timestamp_millis()),\n            sort_index: None,\n        }\n    }\n\n    /// 生成 Claude 供应商配置\n    pub fn to_claude_provider(&self) -> Option<Provider> {\n        if !self.apps.claude {\n            return None;\n        }\n\n        let models = self.models.claude.as_ref();\n        let model = models\n            .and_then(|m| m.model.clone())\n            .unwrap_or_else(|| \"claude-sonnet-4-20250514\".to_string());\n        let haiku = models\n            .and_then(|m| m.haiku_model.clone())\n            .unwrap_or_else(|| model.clone());\n        let sonnet = models\n            .and_then(|m| m.sonnet_model.clone())\n            .unwrap_or_else(|| model.clone());\n        let opus = models\n            .and_then(|m| m.opus_model.clone())\n            .unwrap_or_else(|| model.clone());\n\n        let settings_config = serde_json::json!({\n            \"env\": {\n                \"ANTHROPIC_BASE_URL\": self.base_url,\n                \"ANTHROPIC_AUTH_TOKEN\": self.api_key,\n                \"ANTHROPIC_MODEL\": model,\n                \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": haiku,\n                \"ANTHROPIC_DEFAULT_SONNET_MODEL\": sonnet,\n                \"ANTHROPIC_DEFAULT_OPUS_MODEL\": opus,\n            }\n        });\n\n        Some(Provider {\n            id: format!(\"universal-claude-{}\", self.id),\n            name: self.name.clone(),\n            settings_config,\n            website_url: self.website_url.clone(),\n            category: Some(\"aggregator\".to_string()),\n            created_at: self.created_at,\n            sort_index: self.sort_index,\n            notes: self.notes.clone(),\n            meta: self.meta.clone(),\n            icon: self.icon.clone(),\n            icon_color: self.icon_color.clone(),\n            in_failover_queue: false,\n        })\n    }\n\n    /// 生成 Codex 供应商配置\n    pub fn to_codex_provider(&self) -> Option<Provider> {\n        if !self.apps.codex {\n            return None;\n        }\n\n        let models = self.models.codex.as_ref();\n        let model = models\n            .and_then(|m| m.model.clone())\n            .unwrap_or_else(|| \"gpt-4o\".to_string());\n        let reasoning_effort = models\n            .and_then(|m| m.reasoning_effort.clone())\n            .unwrap_or_else(|| \"high\".to_string());\n\n        // Codex/OpenAI 的 base_url 既可能是纯 origin（需要补 /v1），也可能包含自定义前缀（不应强行补版本）\n        let base_trimmed = self.base_url.trim_end_matches('/');\n        let origin_only = match base_trimmed.split_once(\"://\") {\n            Some((_scheme, rest)) => !rest.contains('/'),\n            None => !base_trimmed.contains('/'),\n        };\n        let codex_base_url = if base_trimmed.ends_with(\"/v1\") {\n            base_trimmed.to_string()\n        } else if origin_only {\n            format!(\"{base_trimmed}/v1\")\n        } else {\n            base_trimmed.to_string()\n        };\n\n        // 生成 Codex 的 config.toml 内容\n        let config_toml = format!(\n            r#\"model_provider = \"newapi\"\nmodel = \"{model}\"\nmodel_reasoning_effort = \"{reasoning_effort}\"\ndisable_response_storage = true\n\n[model_providers.newapi]\nname = \"NewAPI\"\nbase_url = \"{codex_base_url}\"\nwire_api = \"responses\"\nrequires_openai_auth = true\"#\n        );\n\n        let settings_config = serde_json::json!({\n            \"auth\": {\n                \"OPENAI_API_KEY\": self.api_key\n            },\n            \"config\": config_toml\n        });\n\n        Some(Provider {\n            id: format!(\"universal-codex-{}\", self.id),\n            name: self.name.clone(),\n            settings_config,\n            website_url: self.website_url.clone(),\n            category: Some(\"aggregator\".to_string()),\n            created_at: self.created_at,\n            sort_index: self.sort_index,\n            notes: self.notes.clone(),\n            meta: self.meta.clone(),\n            icon: self.icon.clone(),\n            icon_color: self.icon_color.clone(),\n            in_failover_queue: false,\n        })\n    }\n\n    /// 生成 Gemini 供应商配置\n    pub fn to_gemini_provider(&self) -> Option<Provider> {\n        if !self.apps.gemini {\n            return None;\n        }\n\n        let models = self.models.gemini.as_ref();\n        let model = models\n            .and_then(|m| m.model.clone())\n            .unwrap_or_else(|| \"gemini-2.5-pro\".to_string());\n\n        let settings_config = serde_json::json!({\n            \"env\": {\n                \"GOOGLE_GEMINI_BASE_URL\": self.base_url,\n                \"GEMINI_API_KEY\": self.api_key,\n                \"GEMINI_MODEL\": model,\n            }\n        });\n\n        Some(Provider {\n            id: format!(\"universal-gemini-{}\", self.id),\n            name: self.name.clone(),\n            settings_config,\n            website_url: self.website_url.clone(),\n            category: Some(\"aggregator\".to_string()),\n            created_at: self.created_at,\n            sort_index: self.sort_index,\n            notes: self.notes.clone(),\n            meta: self.meta.clone(),\n            icon: self.icon.clone(),\n            icon_color: self.icon_color.clone(),\n            in_failover_queue: false,\n        })\n    }\n}\n\n// ============================================================================\n// OpenCode 供应商配置结构\n// ============================================================================\n\n/// OpenCode 供应商的 settings_config 结构\n///\n/// OpenCode 使用 AI SDK 包名来指定供应商类型，与其他应用的配置格式不同。\n/// 配置示例：\n/// ```json\n/// {\n///   \"npm\": \"@ai-sdk/openai-compatible\",\n///   \"options\": { \"baseURL\": \"https://api.example.com/v1\", \"apiKey\": \"sk-xxx\" },\n///   \"models\": { \"gpt-4o\": { \"name\": \"GPT-4o\" } }\n/// }\n/// ```\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OpenCodeProviderConfig {\n    /// AI SDK 包名，如 \"@ai-sdk/openai-compatible\", \"@ai-sdk/anthropic\"\n    pub npm: String,\n\n    /// 供应商名称（可选，用于显示）\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub name: Option<String>,\n\n    /// 供应商选项（API 密钥、基础 URL 等）\n    #[serde(default)]\n    pub options: OpenCodeProviderOptions,\n\n    /// 模型定义映射\n    #[serde(default)]\n    pub models: HashMap<String, OpenCodeModel>,\n}\n\nimpl Default for OpenCodeProviderConfig {\n    fn default() -> Self {\n        Self {\n            npm: \"@ai-sdk/openai-compatible\".to_string(),\n            name: None,\n            options: OpenCodeProviderOptions::default(),\n            models: HashMap::new(),\n        }\n    }\n}\n\n/// OpenCode 供应商选项\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct OpenCodeProviderOptions {\n    /// API 基础 URL\n    #[serde(rename = \"baseURL\", skip_serializing_if = \"Option::is_none\")]\n    pub base_url: Option<String>,\n\n    /// API 密钥（支持环境变量引用，如 \"{env:API_KEY}\"）\n    #[serde(rename = \"apiKey\", skip_serializing_if = \"Option::is_none\")]\n    pub api_key: Option<String>,\n\n    /// 自定义请求头\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub headers: Option<HashMap<String, String>>,\n\n    /// 额外选项（timeout, setCacheKey 等）\n    /// 使用 flatten 捕获所有未明确定义的字段\n    #[serde(flatten, default, skip_serializing_if = \"HashMap::is_empty\")]\n    pub extra: HashMap<String, Value>,\n}\n\n/// OpenCode 模型定义\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OpenCodeModel {\n    /// 模型显示名称\n    pub name: String,\n\n    /// 模型限制（上下文和输出 token 数）\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub limit: Option<OpenCodeModelLimit>,\n\n    /// 模型额外选项（provider 路由等）\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub options: Option<HashMap<String, Value>>,\n\n    /// 额外字段（cost、modalities、thinking、variants 等）\n    /// 使用 flatten 捕获所有未明确定义的字段\n    #[serde(flatten, default, skip_serializing_if = \"HashMap::is_empty\")]\n    pub extra: HashMap<String, Value>,\n}\n\n/// OpenCode 模型限制\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct OpenCodeModelLimit {\n    /// 上下文 token 限制\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub context: Option<u64>,\n\n    /// 输出 token 限制\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub output: Option<u64>,\n}\n\n#[cfg(test)]\nmod tests {\n    use super::{\n        ClaudeModelConfig, CodexModelConfig, GeminiModelConfig, OpenCodeProviderConfig, Provider,\n        ProviderManager, ProviderMeta, UniversalProvider,\n    };\n    use serde_json::json;\n\n    #[test]\n    fn provider_meta_serializes_pricing_model_source() {\n        let mut meta = ProviderMeta::default();\n        meta.pricing_model_source = Some(\"response\".to_string());\n\n        let value = serde_json::to_value(&meta).expect(\"serialize ProviderMeta\");\n\n        assert_eq!(\n            value\n                .get(\"pricingModelSource\")\n                .and_then(|item| item.as_str()),\n            Some(\"response\")\n        );\n        assert!(value.get(\"pricing_model_source\").is_none());\n    }\n\n    #[test]\n    fn provider_meta_omits_pricing_model_source_when_none() {\n        let meta = ProviderMeta::default();\n        let value = serde_json::to_value(&meta).expect(\"serialize ProviderMeta\");\n\n        assert!(value.get(\"pricingModelSource\").is_none());\n    }\n\n    #[test]\n    fn provider_with_id_populates_defaults() {\n        let settings_config = json!({\n            \"env\": { \"API_KEY\": \"test\" }\n        });\n        let provider = Provider::with_id(\n            \"provider-1\".to_string(),\n            \"Provider\".to_string(),\n            settings_config.clone(),\n            Some(\"https://example.com\".to_string()),\n        );\n\n        assert_eq!(provider.id, \"provider-1\");\n        assert_eq!(provider.name, \"Provider\");\n        assert_eq!(provider.settings_config, settings_config);\n        assert_eq!(provider.website_url.as_deref(), Some(\"https://example.com\"));\n        assert!(provider.category.is_none());\n        assert!(provider.created_at.is_none());\n        assert!(provider.sort_index.is_none());\n        assert!(provider.notes.is_none());\n        assert!(provider.meta.is_none());\n        assert!(provider.icon.is_none());\n        assert!(provider.icon_color.is_none());\n        assert!(!provider.in_failover_queue);\n    }\n\n    #[test]\n    fn provider_manager_get_all_providers_returns_map() {\n        let mut manager = ProviderManager::default();\n        let provider = Provider::with_id(\n            \"provider-1\".to_string(),\n            \"Provider\".to_string(),\n            json!({ \"env\": {} }),\n            None,\n        );\n        manager.providers.insert(\"provider-1\".to_string(), provider);\n\n        assert_eq!(manager.get_all_providers().len(), 1);\n        assert!(manager.get_all_providers().contains_key(\"provider-1\"));\n    }\n\n    #[test]\n    fn universal_provider_to_claude_provider_uses_models() {\n        let mut universal = UniversalProvider::new(\n            \"u1\".to_string(),\n            \"Universal\".to_string(),\n            \"newapi\".to_string(),\n            \"https://api.example.com\".to_string(),\n            \"api-key\".to_string(),\n        );\n        universal.apps.claude = true;\n        universal.models.claude = Some(ClaudeModelConfig {\n            model: Some(\"claude-main\".to_string()),\n            haiku_model: Some(\"claude-haiku\".to_string()),\n            sonnet_model: Some(\"claude-sonnet\".to_string()),\n            opus_model: Some(\"claude-opus\".to_string()),\n        });\n\n        let provider = universal.to_claude_provider().expect(\"claude provider\");\n\n        assert_eq!(provider.id, \"universal-claude-u1\");\n        assert_eq!(provider.name, \"Universal\");\n        assert_eq!(provider.category.as_deref(), Some(\"aggregator\"));\n        assert_eq!(\n            provider\n                .settings_config\n                .pointer(\"/env/ANTHROPIC_MODEL\")\n                .and_then(|item| item.as_str()),\n            Some(\"claude-main\")\n        );\n        assert_eq!(\n            provider\n                .settings_config\n                .pointer(\"/env/ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n                .and_then(|item| item.as_str()),\n            Some(\"claude-haiku\")\n        );\n        assert_eq!(\n            provider\n                .settings_config\n                .pointer(\"/env/ANTHROPIC_DEFAULT_SONNET_MODEL\")\n                .and_then(|item| item.as_str()),\n            Some(\"claude-sonnet\")\n        );\n        assert_eq!(\n            provider\n                .settings_config\n                .pointer(\"/env/ANTHROPIC_DEFAULT_OPUS_MODEL\")\n                .and_then(|item| item.as_str()),\n            Some(\"claude-opus\")\n        );\n    }\n\n    #[test]\n    fn universal_provider_to_claude_provider_disabled_returns_none() {\n        let universal = UniversalProvider::new(\n            \"u1\".to_string(),\n            \"Universal\".to_string(),\n            \"newapi\".to_string(),\n            \"https://api.example.com\".to_string(),\n            \"api-key\".to_string(),\n        );\n\n        assert!(universal.to_claude_provider().is_none());\n    }\n\n    #[test]\n    fn universal_provider_to_codex_provider_appends_v1() {\n        let mut universal = UniversalProvider::new(\n            \"u1\".to_string(),\n            \"Universal\".to_string(),\n            \"newapi\".to_string(),\n            \"https://api.example.com\".to_string(),\n            \"api-key\".to_string(),\n        );\n        universal.apps.codex = true;\n        universal.models.codex = Some(CodexModelConfig {\n            model: Some(\"gpt-4o-mini\".to_string()),\n            reasoning_effort: Some(\"low\".to_string()),\n        });\n\n        let provider = universal.to_codex_provider().expect(\"codex provider\");\n        let config = provider\n            .settings_config\n            .get(\"config\")\n            .and_then(|item| item.as_str())\n            .expect(\"config toml\");\n\n        assert!(config.contains(\"base_url = \\\"https://api.example.com/v1\\\"\"));\n        assert_eq!(\n            provider\n                .settings_config\n                .pointer(\"/auth/OPENAI_API_KEY\")\n                .and_then(|item| item.as_str()),\n            Some(\"api-key\")\n        );\n    }\n\n    #[test]\n    fn universal_provider_to_codex_provider_keeps_v1_suffix() {\n        let mut universal = UniversalProvider::new(\n            \"u1\".to_string(),\n            \"Universal\".to_string(),\n            \"newapi\".to_string(),\n            \"https://api.example.com/v1\".to_string(),\n            \"api-key\".to_string(),\n        );\n        universal.apps.codex = true;\n\n        let provider = universal.to_codex_provider().expect(\"codex provider\");\n        let config = provider\n            .settings_config\n            .get(\"config\")\n            .and_then(|item| item.as_str())\n            .expect(\"config toml\");\n\n        assert!(config.contains(\"base_url = \\\"https://api.example.com/v1\\\"\"));\n    }\n\n    #[test]\n    fn universal_provider_to_codex_provider_disabled_returns_none() {\n        let universal = UniversalProvider::new(\n            \"u1\".to_string(),\n            \"Universal\".to_string(),\n            \"newapi\".to_string(),\n            \"https://api.example.com\".to_string(),\n            \"api-key\".to_string(),\n        );\n\n        assert!(universal.to_codex_provider().is_none());\n    }\n\n    #[test]\n    fn universal_provider_to_gemini_provider_defaults_model() {\n        let mut universal = UniversalProvider::new(\n            \"u1\".to_string(),\n            \"Universal\".to_string(),\n            \"newapi\".to_string(),\n            \"https://api.example.com\".to_string(),\n            \"api-key\".to_string(),\n        );\n        universal.apps.gemini = true;\n\n        let provider = universal.to_gemini_provider().expect(\"gemini provider\");\n\n        assert_eq!(\n            provider\n                .settings_config\n                .pointer(\"/env/GEMINI_MODEL\")\n                .and_then(|item| item.as_str()),\n            Some(\"gemini-2.5-pro\")\n        );\n    }\n\n    #[test]\n    fn universal_provider_to_gemini_provider_uses_model() {\n        let mut universal = UniversalProvider::new(\n            \"u1\".to_string(),\n            \"Universal\".to_string(),\n            \"newapi\".to_string(),\n            \"https://api.example.com\".to_string(),\n            \"api-key\".to_string(),\n        );\n        universal.apps.gemini = true;\n        universal.models.gemini = Some(GeminiModelConfig {\n            model: Some(\"gemini-custom\".to_string()),\n        });\n\n        let provider = universal.to_gemini_provider().expect(\"gemini provider\");\n\n        assert_eq!(\n            provider\n                .settings_config\n                .pointer(\"/env/GEMINI_MODEL\")\n                .and_then(|item| item.as_str()),\n            Some(\"gemini-custom\")\n        );\n    }\n\n    #[test]\n    fn opencode_provider_config_defaults() {\n        let config = OpenCodeProviderConfig::default();\n        assert_eq!(config.npm, \"@ai-sdk/openai-compatible\");\n        assert!(config.name.is_none());\n        assert!(config.models.is_empty());\n        assert!(config.options.base_url.is_none());\n        assert!(config.options.api_key.is_none());\n        assert!(config.options.headers.is_none());\n        assert!(config.options.extra.is_empty());\n    }\n\n    #[test]\n    fn universal_codex_provider_origin_base_url_adds_v1() {\n        let mut p = UniversalProvider::new(\n            \"id\".to_string(),\n            \"Test\".to_string(),\n            \"custom\".to_string(),\n            \"https://api.openai.com\".to_string(),\n            \"sk-test\".to_string(),\n        );\n        p.apps.codex = true;\n\n        let provider = p.to_codex_provider().expect(\"should build codex provider\");\n        let toml = provider\n            .settings_config\n            .get(\"config\")\n            .and_then(|v| v.as_str())\n            .expect(\"config should be a toml string\");\n\n        assert!(toml.contains(\"base_url = \\\"https://api.openai.com/v1\\\"\"));\n    }\n\n    #[test]\n    fn universal_codex_provider_custom_prefix_does_not_force_v1() {\n        let mut p = UniversalProvider::new(\n            \"id\".to_string(),\n            \"Test\".to_string(),\n            \"custom\".to_string(),\n            \"https://example.com/openai\".to_string(),\n            \"sk-test\".to_string(),\n        );\n        p.apps.codex = true;\n\n        let provider = p.to_codex_provider().expect(\"should build codex provider\");\n        let toml = provider\n            .settings_config\n            .get(\"config\")\n            .and_then(|v| v.as_str())\n            .expect(\"config should be a toml string\");\n\n        assert!(toml.contains(\"base_url = \\\"https://example.com/openai\\\"\"));\n        assert!(!toml.contains(\"https://example.com/openai/v1\"));\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/provider_defaults.rs",
    "content": "use once_cell::sync::Lazy;\nuse std::collections::HashMap;\n\n/// 供应商图标信息\n#[derive(Debug, Clone)]\n#[allow(dead_code)]\npub struct ProviderIcon {\n    pub name: &'static str,\n    pub color: &'static str,\n}\n\n/// 供应商名称到图标的默认映射\n#[allow(dead_code)]\npub static DEFAULT_PROVIDER_ICONS: Lazy<HashMap<&'static str, ProviderIcon>> = Lazy::new(|| {\n    let mut m = HashMap::new();\n\n    // AI 服务商\n    m.insert(\n        \"openai\",\n        ProviderIcon {\n            name: \"openai\",\n            color: \"#00A67E\",\n        },\n    );\n    m.insert(\n        \"anthropic\",\n        ProviderIcon {\n            name: \"anthropic\",\n            color: \"#D4915D\",\n        },\n    );\n    m.insert(\n        \"claude\",\n        ProviderIcon {\n            name: \"claude\",\n            color: \"#D4915D\",\n        },\n    );\n    m.insert(\n        \"google\",\n        ProviderIcon {\n            name: \"google\",\n            color: \"#4285F4\",\n        },\n    );\n    m.insert(\n        \"gemini\",\n        ProviderIcon {\n            name: \"gemini\",\n            color: \"#4285F4\",\n        },\n    );\n    m.insert(\n        \"deepseek\",\n        ProviderIcon {\n            name: \"deepseek\",\n            color: \"#1E88E5\",\n        },\n    );\n    m.insert(\n        \"kimi\",\n        ProviderIcon {\n            name: \"kimi\",\n            color: \"#6366F1\",\n        },\n    );\n    m.insert(\n        \"moonshot\",\n        ProviderIcon {\n            name: \"moonshot\",\n            color: \"#6366F1\",\n        },\n    );\n    m.insert(\n        \"zhipu\",\n        ProviderIcon {\n            name: \"zhipu\",\n            color: \"#0F62FE\",\n        },\n    );\n    m.insert(\n        \"minimax\",\n        ProviderIcon {\n            name: \"minimax\",\n            color: \"#FF6B6B\",\n        },\n    );\n    m.insert(\n        \"baidu\",\n        ProviderIcon {\n            name: \"baidu\",\n            color: \"#2932E1\",\n        },\n    );\n    m.insert(\n        \"alibaba\",\n        ProviderIcon {\n            name: \"alibaba\",\n            color: \"#FF6A00\",\n        },\n    );\n    m.insert(\n        \"tencent\",\n        ProviderIcon {\n            name: \"tencent\",\n            color: \"#00A4FF\",\n        },\n    );\n    m.insert(\n        \"meta\",\n        ProviderIcon {\n            name: \"meta\",\n            color: \"#0081FB\",\n        },\n    );\n    m.insert(\n        \"microsoft\",\n        ProviderIcon {\n            name: \"microsoft\",\n            color: \"#00A4EF\",\n        },\n    );\n    m.insert(\n        \"cohere\",\n        ProviderIcon {\n            name: \"cohere\",\n            color: \"#39594D\",\n        },\n    );\n    m.insert(\n        \"perplexity\",\n        ProviderIcon {\n            name: \"perplexity\",\n            color: \"#20808D\",\n        },\n    );\n    m.insert(\n        \"mistral\",\n        ProviderIcon {\n            name: \"mistral\",\n            color: \"#FF7000\",\n        },\n    );\n    m.insert(\n        \"huggingface\",\n        ProviderIcon {\n            name: \"huggingface\",\n            color: \"#FFD21E\",\n        },\n    );\n\n    // 云平台\n    m.insert(\n        \"aws\",\n        ProviderIcon {\n            name: \"aws\",\n            color: \"#FF9900\",\n        },\n    );\n    m.insert(\n        \"azure\",\n        ProviderIcon {\n            name: \"azure\",\n            color: \"#0078D4\",\n        },\n    );\n    m.insert(\n        \"huawei\",\n        ProviderIcon {\n            name: \"huawei\",\n            color: \"#FF0000\",\n        },\n    );\n    m.insert(\n        \"cloudflare\",\n        ProviderIcon {\n            name: \"cloudflare\",\n            color: \"#F38020\",\n        },\n    );\n\n    m\n});\n\n/// 根据供应商名称智能推断图标\n#[allow(dead_code)]\npub fn infer_provider_icon(provider_name: &str) -> Option<ProviderIcon> {\n    let name_lower = provider_name.to_lowercase();\n\n    // 精确匹配\n    if let Some(icon) = DEFAULT_PROVIDER_ICONS.get(name_lower.as_str()) {\n        return Some(icon.clone());\n    }\n\n    // 模糊匹配（包含关键词）\n    for (key, icon) in DEFAULT_PROVIDER_ICONS.iter() {\n        if name_lower.contains(key) {\n            return Some(icon.clone());\n        }\n    }\n\n    None\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_exact_match() {\n        let icon = infer_provider_icon(\"openai\");\n        assert!(icon.is_some());\n        let icon = icon.unwrap();\n        assert_eq!(icon.name, \"openai\");\n        assert_eq!(icon.color, \"#00A67E\");\n    }\n\n    #[test]\n    fn test_fuzzy_match() {\n        let icon = infer_provider_icon(\"OpenAI Official\");\n        assert!(icon.is_some());\n        let icon = icon.unwrap();\n        assert_eq!(icon.name, \"openai\");\n    }\n\n    #[test]\n    fn test_case_insensitive() {\n        let icon = infer_provider_icon(\"ANTHROPIC\");\n        assert!(icon.is_some());\n        assert_eq!(icon.unwrap().name, \"anthropic\");\n    }\n\n    #[test]\n    fn test_no_match() {\n        let icon = infer_provider_icon(\"unknown provider\");\n        assert!(icon.is_none());\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/body_filter.rs",
    "content": "//! 请求体过滤模块\n//!\n//! 过滤不应透传到上游的私有参数，防止内部信息泄露。\n//!\n//! ## 过滤规则\n//! - 以 `_` 开头的字段被视为私有参数，会被递归过滤\n//! - 支持白名单机制，允许透传特定的 `_` 前缀字段\n//! - 支持嵌套对象和数组的深度过滤\n//!\n//! ## 使用场景\n//! - `_internal_id`: 内部追踪 ID\n//! - `_debug_mode`: 调试标记\n//! - `_session_token`: 会话令牌\n//! - `_client_version`: 客户端版本\n\nuse serde_json::Value;\nuse std::collections::HashSet;\n\n/// 过滤私有参数（以 `_` 开头的字段）\n///\n/// 递归遍历 JSON 结构，移除所有以下划线开头的字段。\n///\n/// # Arguments\n/// * `body` - 原始请求体\n///\n/// # Returns\n/// 过滤后的请求体\n///\n/// # Example\n/// ```ignore\n/// let input = json!({\n///     \"model\": \"claude-3\",\n///     \"_internal_id\": \"abc123\",\n///     \"messages\": [{\"role\": \"user\", \"content\": \"hello\", \"_token\": \"secret\"}]\n/// });\n/// let output = filter_private_params(input);\n/// // output 中不包含 _internal_id 和 _token\n/// ```\n#[cfg(test)]\npub fn filter_private_params(body: Value) -> Value {\n    filter_private_params_with_whitelist(body, &[])\n}\n\n/// 过滤私有参数（支持白名单）\n///\n/// 递归遍历 JSON 结构，移除所有以下划线开头的字段，\n/// 但保留白名单中指定的字段。\n///\n/// # Arguments\n/// * `body` - 原始请求体\n/// * `whitelist` - 白名单字段列表（不过滤这些字段）\n///\n/// # Returns\n/// 过滤后的请求体\n///\n/// # Example\n/// ```ignore\n/// let input = json!({\n///     \"model\": \"claude-3\",\n///     \"_metadata\": {\"key\": \"value\"},  // 白名单中，保留\n///     \"_internal_id\": \"abc123\"        // 不在白名单中，过滤\n/// });\n/// let output = filter_private_params_with_whitelist(input, &[\"_metadata\"]);\n/// // output 包含 _metadata，不包含 _internal_id\n/// ```\npub fn filter_private_params_with_whitelist(body: Value, whitelist: &[String]) -> Value {\n    let whitelist_set: HashSet<&str> = whitelist.iter().map(|s| s.as_str()).collect();\n    filter_recursive_with_whitelist(body, &mut Vec::new(), &whitelist_set)\n}\n\n/// 递归过滤实现（支持白名单）\nfn filter_recursive_with_whitelist(\n    value: Value,\n    removed_keys: &mut Vec<String>,\n    whitelist: &HashSet<&str>,\n) -> Value {\n    match value {\n        Value::Object(map) => {\n            let filtered: serde_json::Map<String, Value> = map\n                .into_iter()\n                .filter_map(|(key, val)| {\n                    // 以 _ 开头且不在白名单中的字段被过滤\n                    if key.starts_with('_') && !whitelist.contains(key.as_str()) {\n                        removed_keys.push(key);\n                        None\n                    } else {\n                        Some((\n                            key,\n                            filter_recursive_with_whitelist(val, removed_keys, whitelist),\n                        ))\n                    }\n                })\n                .collect();\n\n            // 仅在有过滤时记录日志（避免每次请求都打印）\n            if !removed_keys.is_empty() {\n                log::debug!(\"[BodyFilter] 过滤私有参数: {removed_keys:?}\");\n                removed_keys.clear();\n            }\n\n            Value::Object(filtered)\n        }\n        Value::Array(arr) => Value::Array(\n            arr.into_iter()\n                .map(|v| filter_recursive_with_whitelist(v, removed_keys, whitelist))\n                .collect(),\n        ),\n        other => other,\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use serde_json::json;\n\n    #[test]\n    fn test_filter_top_level_private_params() {\n        let input = json!({\n            \"model\": \"claude-3\",\n            \"_internal_id\": \"abc123\",\n            \"_debug\": true,\n            \"max_tokens\": 1024\n        });\n\n        let output = filter_private_params(input);\n\n        assert!(output.get(\"model\").is_some());\n        assert!(output.get(\"max_tokens\").is_some());\n        assert!(output.get(\"_internal_id\").is_none());\n        assert!(output.get(\"_debug\").is_none());\n    }\n\n    #[test]\n    fn test_filter_nested_private_params() {\n        let input = json!({\n            \"model\": \"claude-3\",\n            \"messages\": [\n                {\n                    \"role\": \"user\",\n                    \"content\": \"hello\",\n                    \"_session_token\": \"secret\"\n                }\n            ],\n            \"metadata\": {\n                \"user_id\": \"user-1\",\n                \"_tracking_id\": \"track-1\"\n            }\n        });\n\n        let output = filter_private_params(input);\n\n        // 顶级字段保留\n        assert!(output.get(\"model\").is_some());\n        assert!(output.get(\"messages\").is_some());\n        assert!(output.get(\"metadata\").is_some());\n\n        // messages 数组中的私有参数被过滤\n        let messages = output.get(\"messages\").unwrap().as_array().unwrap();\n        assert!(messages[0].get(\"role\").is_some());\n        assert!(messages[0].get(\"content\").is_some());\n        assert!(messages[0].get(\"_session_token\").is_none());\n\n        // metadata 对象中的私有参数被过滤\n        let metadata = output.get(\"metadata\").unwrap();\n        assert!(metadata.get(\"user_id\").is_some());\n        assert!(metadata.get(\"_tracking_id\").is_none());\n    }\n\n    #[test]\n    fn test_filter_deeply_nested() {\n        let input = json!({\n            \"level1\": {\n                \"level2\": {\n                    \"level3\": {\n                        \"keep\": \"value\",\n                        \"_remove\": \"secret\"\n                    }\n                }\n            }\n        });\n\n        let output = filter_private_params(input);\n\n        let level3 = output\n            .get(\"level1\")\n            .unwrap()\n            .get(\"level2\")\n            .unwrap()\n            .get(\"level3\")\n            .unwrap();\n\n        assert!(level3.get(\"keep\").is_some());\n        assert!(level3.get(\"_remove\").is_none());\n    }\n\n    #[test]\n    fn test_filter_array_of_objects() {\n        let input = json!({\n            \"items\": [\n                {\"id\": 1, \"_secret\": \"a\"},\n                {\"id\": 2, \"_secret\": \"b\"},\n                {\"id\": 3, \"_secret\": \"c\"}\n            ]\n        });\n\n        let output = filter_private_params(input);\n        let items = output.get(\"items\").unwrap().as_array().unwrap();\n\n        for item in items {\n            assert!(item.get(\"id\").is_some());\n            assert!(item.get(\"_secret\").is_none());\n        }\n    }\n\n    #[test]\n    fn test_no_private_params() {\n        let input = json!({\n            \"model\": \"claude-3\",\n            \"messages\": [{\"role\": \"user\", \"content\": \"hello\"}]\n        });\n\n        let output = filter_private_params(input.clone());\n\n        // 无私有参数时，输出应与输入相同\n        assert_eq!(input, output);\n    }\n\n    #[test]\n    fn test_empty_object() {\n        let input = json!({});\n        let output = filter_private_params(input);\n        assert_eq!(output, json!({}));\n    }\n\n    #[test]\n    fn test_primitive_values() {\n        // 原始值不应被修改\n        assert_eq!(filter_private_params(json!(42)), json!(42));\n        assert_eq!(filter_private_params(json!(\"string\")), json!(\"string\"));\n        assert_eq!(filter_private_params(json!(true)), json!(true));\n        assert_eq!(filter_private_params(json!(null)), json!(null));\n    }\n\n    #[test]\n    fn test_whitelist_preserves_private_params() {\n        let input = json!({\n            \"model\": \"claude-3\",\n            \"_metadata\": {\"key\": \"value\"},\n            \"_internal_id\": \"abc123\",\n            \"_stream_options\": {\"include_usage\": true}\n        });\n\n        let whitelist = vec![\"_metadata\".to_string(), \"_stream_options\".to_string()];\n        let output = filter_private_params_with_whitelist(input, &whitelist);\n\n        // 白名单中的字段保留\n        assert!(output.get(\"_metadata\").is_some());\n        assert!(output.get(\"_stream_options\").is_some());\n        // 不在白名单中的私有字段被过滤\n        assert!(output.get(\"_internal_id\").is_none());\n        // 普通字段保留\n        assert!(output.get(\"model\").is_some());\n    }\n\n    #[test]\n    fn test_whitelist_nested() {\n        let input = json!({\n            \"data\": {\n                \"_allowed\": \"keep\",\n                \"_forbidden\": \"remove\",\n                \"normal\": \"value\"\n            }\n        });\n\n        let whitelist = vec![\"_allowed\".to_string()];\n        let output = filter_private_params_with_whitelist(input, &whitelist);\n\n        let data = output.get(\"data\").unwrap();\n        assert!(data.get(\"_allowed\").is_some());\n        assert!(data.get(\"_forbidden\").is_none());\n        assert!(data.get(\"normal\").is_some());\n    }\n\n    #[test]\n    fn test_empty_whitelist_same_as_default() {\n        let input = json!({\n            \"model\": \"claude-3\",\n            \"_internal_id\": \"abc123\"\n        });\n\n        let output1 = filter_private_params(input.clone());\n        let output2 = filter_private_params_with_whitelist(input, &[]);\n\n        assert_eq!(output1, output2);\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/cache_injector.rs",
    "content": "//! Cache 断点注入器\n//!\n//! 在请求转发前自动注入 cache_control 标记，启用 Bedrock Prompt Caching\n\nuse super::types::OptimizerConfig;\nuse serde_json::{json, Value};\n\n/// 在请求体关键位置注入 cache_control 断点\npub fn inject(body: &mut Value, config: &OptimizerConfig) {\n    if !config.cache_injection {\n        return;\n    }\n\n    let existing = count_existing(body);\n\n    // 升级已有断点的 TTL\n    upgrade_existing_ttl(body, &config.cache_ttl);\n\n    let mut budget = 4_usize.saturating_sub(existing);\n    if budget == 0 {\n        if existing > 0 {\n            log::info!(\n                \"[OPT] cache: ttl-upgrade({existing}->{},existing={existing})\",\n                config.cache_ttl\n            );\n        } else {\n            log::info!(\"[OPT] cache: no-op(existing={existing})\");\n        }\n        return;\n    }\n\n    let mut injected = Vec::new();\n\n    // (a) tools 末尾\n    if budget > 0 {\n        if let Some(tools) = body.get_mut(\"tools\").and_then(|t| t.as_array_mut()) {\n            if let Some(last) = tools.last_mut() {\n                if last.get(\"cache_control\").is_none() {\n                    if let Some(o) = last.as_object_mut() {\n                        o.insert(\n                            \"cache_control\".to_string(),\n                            make_cache_control(&config.cache_ttl),\n                        );\n                    }\n                    budget -= 1;\n                    injected.push(\"tools\");\n                }\n            }\n        }\n    }\n\n    // (b) system 末尾\n    if budget > 0 {\n        // 字符串 system → 转为数组\n        if body.get(\"system\").and_then(|s| s.as_str()).is_some() {\n            let text = body[\"system\"].as_str().unwrap().to_string();\n            body[\"system\"] = json!([{\"type\": \"text\", \"text\": text}]);\n        }\n\n        if let Some(system) = body.get_mut(\"system\").and_then(|s| s.as_array_mut()) {\n            if let Some(last) = system.last_mut() {\n                if last.get(\"cache_control\").is_none() {\n                    if let Some(o) = last.as_object_mut() {\n                        o.insert(\n                            \"cache_control\".to_string(),\n                            make_cache_control(&config.cache_ttl),\n                        );\n                    }\n                    budget -= 1;\n                    injected.push(\"system\");\n                }\n            }\n        }\n    }\n\n    // (c) 最后一条 assistant 消息的最后一个非 thinking block\n    if budget > 0 {\n        if let Some(messages) = body.get_mut(\"messages\").and_then(|m| m.as_array_mut()) {\n            if let Some(assistant_msg) = messages\n                .iter_mut()\n                .rev()\n                .find(|m| m.get(\"role\").and_then(|r| r.as_str()) == Some(\"assistant\"))\n            {\n                if let Some(content) = assistant_msg\n                    .get_mut(\"content\")\n                    .and_then(|c| c.as_array_mut())\n                {\n                    // 逆序找最后一个非 thinking/redacted_thinking block\n                    if let Some(block) = content.iter_mut().rev().find(|b| {\n                        let bt = b.get(\"type\").and_then(|t| t.as_str()).unwrap_or(\"\");\n                        bt != \"thinking\" && bt != \"redacted_thinking\"\n                    }) {\n                        if block.get(\"cache_control\").is_none() {\n                            if let Some(o) = block.as_object_mut() {\n                                o.insert(\n                                    \"cache_control\".to_string(),\n                                    make_cache_control(&config.cache_ttl),\n                                );\n                            }\n                            injected.push(\"msgs\");\n                        }\n                    }\n                }\n            }\n        }\n    }\n\n    log::info!(\n        \"[OPT] cache: {}bp({},{},pre={existing})\",\n        injected.len(),\n        injected.join(\"+\"),\n        config.cache_ttl,\n    );\n}\n\nfn make_cache_control(ttl: &str) -> Value {\n    if ttl == \"5m\" {\n        json!({\"type\": \"ephemeral\"})\n    } else {\n        json!({\"type\": \"ephemeral\", \"ttl\": ttl})\n    }\n}\n\nfn count_existing(body: &Value) -> usize {\n    let mut count = 0;\n\n    if let Some(tools) = body.get(\"tools\").and_then(|t| t.as_array()) {\n        count += tools\n            .iter()\n            .filter(|t| t.get(\"cache_control\").is_some())\n            .count();\n    }\n\n    if let Some(system) = body.get(\"system\").and_then(|s| s.as_array()) {\n        count += system\n            .iter()\n            .filter(|b| b.get(\"cache_control\").is_some())\n            .count();\n    }\n\n    if let Some(messages) = body.get(\"messages\").and_then(|m| m.as_array()) {\n        for msg in messages {\n            if let Some(content) = msg.get(\"content\").and_then(|c| c.as_array()) {\n                count += content\n                    .iter()\n                    .filter(|b| b.get(\"cache_control\").is_some())\n                    .count();\n            }\n        }\n    }\n\n    count\n}\n\nfn upgrade_existing_ttl(body: &mut Value, ttl: &str) {\n    let upgrade = |val: &mut Value| {\n        if let Some(cc) = val.get_mut(\"cache_control\").and_then(|c| c.as_object_mut()) {\n            if ttl == \"5m\" {\n                cc.remove(\"ttl\");\n            } else {\n                cc.insert(\"ttl\".to_string(), json!(ttl));\n            }\n        }\n    };\n\n    if let Some(tools) = body.get_mut(\"tools\").and_then(|t| t.as_array_mut()) {\n        for tool in tools.iter_mut() {\n            upgrade(tool);\n        }\n    }\n\n    if let Some(system) = body.get_mut(\"system\").and_then(|s| s.as_array_mut()) {\n        for block in system.iter_mut() {\n            upgrade(block);\n        }\n    }\n\n    if let Some(messages) = body.get_mut(\"messages\").and_then(|m| m.as_array_mut()) {\n        for msg in messages.iter_mut() {\n            if let Some(content) = msg.get_mut(\"content\").and_then(|c| c.as_array_mut()) {\n                for block in content.iter_mut() {\n                    upgrade(block);\n                }\n            }\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use serde_json::json;\n\n    fn default_config() -> OptimizerConfig {\n        OptimizerConfig {\n            enabled: true,\n            thinking_optimizer: true,\n            cache_injection: true,\n            cache_ttl: \"1h\".to_string(),\n        }\n    }\n\n    #[test]\n    fn test_empty_body_no_injection() {\n        let mut body = json!({\"model\": \"test\", \"messages\": [{\"role\": \"user\", \"content\": [{\"type\": \"text\", \"text\": \"hi\"}]}]});\n        let original = body.clone();\n        inject(&mut body, &default_config());\n        // No tools, no system, no assistant → no injection\n        assert_eq!(body, original);\n    }\n\n    #[test]\n    fn test_inject_three_breakpoints() {\n        let mut body = json!({\n            \"model\": \"test\",\n            \"tools\": [{\"name\": \"tool1\"}, {\"name\": \"tool2\"}],\n            \"system\": [{\"type\": \"text\", \"text\": \"sys prompt\"}],\n            \"messages\": [\n                {\"role\": \"user\", \"content\": [{\"type\": \"text\", \"text\": \"hi\"}]},\n                {\"role\": \"assistant\", \"content\": [\n                    {\"type\": \"text\", \"text\": \"hello\"}\n                ]}\n            ]\n        });\n\n        inject(&mut body, &default_config());\n\n        // tools last element\n        assert!(body[\"tools\"][1].get(\"cache_control\").is_some());\n        assert_eq!(body[\"tools\"][1][\"cache_control\"][\"ttl\"], \"1h\");\n        // system last element\n        assert!(body[\"system\"][0].get(\"cache_control\").is_some());\n        // assistant last non-thinking block\n        assert!(body[\"messages\"][1][\"content\"][0]\n            .get(\"cache_control\")\n            .is_some());\n    }\n\n    #[test]\n    fn test_existing_four_breakpoints_only_upgrades_ttl() {\n        let mut body = json!({\n            \"model\": \"test\",\n            \"tools\": [\n                {\"name\": \"t1\", \"cache_control\": {\"type\": \"ephemeral\", \"ttl\": \"5m\"}},\n                {\"name\": \"t2\", \"cache_control\": {\"type\": \"ephemeral\", \"ttl\": \"5m\"}}\n            ],\n            \"system\": [\n                {\"type\": \"text\", \"text\": \"sys\", \"cache_control\": {\"type\": \"ephemeral\", \"ttl\": \"5m\"}}\n            ],\n            \"messages\": [\n                {\"role\": \"assistant\", \"content\": [\n                    {\"type\": \"text\", \"text\": \"ok\", \"cache_control\": {\"type\": \"ephemeral\", \"ttl\": \"5m\"}}\n                ]}\n            ]\n        });\n\n        inject(&mut body, &default_config());\n\n        // All TTLs upgraded to 1h, no new breakpoints\n        assert_eq!(body[\"tools\"][0][\"cache_control\"][\"ttl\"], \"1h\");\n        assert_eq!(body[\"tools\"][1][\"cache_control\"][\"ttl\"], \"1h\");\n        assert_eq!(body[\"system\"][0][\"cache_control\"][\"ttl\"], \"1h\");\n        assert_eq!(\n            body[\"messages\"][0][\"content\"][0][\"cache_control\"][\"ttl\"],\n            \"1h\"\n        );\n    }\n\n    #[test]\n    fn test_existing_two_injects_two_more() {\n        let mut body = json!({\n            \"model\": \"test\",\n            \"tools\": [\n                {\"name\": \"t1\", \"cache_control\": {\"type\": \"ephemeral\"}},\n                {\"name\": \"t2\", \"cache_control\": {\"type\": \"ephemeral\"}}\n            ],\n            \"system\": [{\"type\": \"text\", \"text\": \"sys\"}],\n            \"messages\": [\n                {\"role\": \"assistant\", \"content\": [{\"type\": \"text\", \"text\": \"ok\"}]}\n            ]\n        });\n\n        inject(&mut body, &default_config());\n\n        // budget = 4 - 2 = 2, inject system + msgs\n        assert!(body[\"system\"][0].get(\"cache_control\").is_some());\n        assert!(body[\"messages\"][0][\"content\"][0]\n            .get(\"cache_control\")\n            .is_some());\n    }\n\n    #[test]\n    fn test_system_string_converted_to_array() {\n        let mut body = json!({\n            \"model\": \"test\",\n            \"system\": \"You are a helpful assistant\",\n            \"messages\": [{\"role\": \"user\", \"content\": [{\"type\": \"text\", \"text\": \"hi\"}]}]\n        });\n\n        inject(&mut body, &default_config());\n\n        assert!(body[\"system\"].is_array());\n        let sys = body[\"system\"].as_array().unwrap();\n        assert_eq!(sys.len(), 1);\n        assert_eq!(sys[0][\"type\"], \"text\");\n        assert_eq!(sys[0][\"text\"], \"You are a helpful assistant\");\n        assert!(sys[0].get(\"cache_control\").is_some());\n    }\n\n    #[test]\n    fn test_ttl_5m_no_ttl_field() {\n        let config = OptimizerConfig {\n            cache_ttl: \"5m\".to_string(),\n            ..default_config()\n        };\n        let mut body = json!({\n            \"model\": \"test\",\n            \"tools\": [{\"name\": \"tool1\"}],\n            \"messages\": [{\"role\": \"user\", \"content\": [{\"type\": \"text\", \"text\": \"hi\"}]}]\n        });\n\n        inject(&mut body, &config);\n\n        let cc = &body[\"tools\"][0][\"cache_control\"];\n        assert_eq!(cc[\"type\"], \"ephemeral\");\n        assert!(cc.get(\"ttl\").is_none() || cc[\"ttl\"].is_null());\n    }\n\n    #[test]\n    fn test_disabled_no_change() {\n        let config = OptimizerConfig {\n            cache_injection: false,\n            ..default_config()\n        };\n        let mut body = json!({\n            \"model\": \"test\",\n            \"tools\": [{\"name\": \"tool1\"}],\n            \"system\": [{\"type\": \"text\", \"text\": \"sys\"}],\n            \"messages\": [{\"role\": \"assistant\", \"content\": [{\"type\": \"text\", \"text\": \"ok\"}]}]\n        });\n        let original = body.clone();\n\n        inject(&mut body, &config);\n\n        assert_eq!(body, original);\n    }\n\n    #[test]\n    fn test_skip_thinking_blocks_in_assistant() {\n        let mut body = json!({\n            \"model\": \"test\",\n            \"messages\": [\n                {\"role\": \"assistant\", \"content\": [\n                    {\"type\": \"thinking\", \"thinking\": \"hmm\"},\n                    {\"type\": \"text\", \"text\": \"result\"},\n                    {\"type\": \"redacted_thinking\", \"data\": \"xxx\"}\n                ]}\n            ]\n        });\n\n        inject(&mut body, &default_config());\n\n        // Should inject on \"text\" block (last non-thinking), not on thinking/redacted_thinking\n        assert!(body[\"messages\"][0][\"content\"][1]\n            .get(\"cache_control\")\n            .is_some());\n        assert!(body[\"messages\"][0][\"content\"][0]\n            .get(\"cache_control\")\n            .is_none());\n        assert!(body[\"messages\"][0][\"content\"][2]\n            .get(\"cache_control\")\n            .is_none());\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/circuit_breaker.rs",
    "content": "//! 熔断器模块\n//!\n//! 实现熔断器模式，用于防止向不健康的供应商发送请求\n\nuse super::log_codes::cb as log_cb;\nuse serde::{Deserialize, Serialize};\nuse std::sync::atomic::{AtomicU32, Ordering};\nuse std::sync::Arc;\nuse std::time::Instant;\nuse tokio::sync::RwLock;\n\n/// 熔断器状态\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum CircuitState {\n    /// 关闭状态 - 正常工作\n    Closed,\n    /// 打开状态 - 熔断激活，拒绝请求\n    Open,\n    /// 半开状态 - 尝试恢复，允许部分请求通过\n    HalfOpen,\n}\n\nimpl std::fmt::Display for CircuitState {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        match self {\n            CircuitState::Closed => write!(f, \"closed\"),\n            CircuitState::Open => write!(f, \"open\"),\n            CircuitState::HalfOpen => write!(f, \"half_open\"),\n        }\n    }\n}\n\n/// 熔断器配置\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct CircuitBreakerConfig {\n    /// 失败阈值 - 连续失败多少次后打开熔断器\n    pub failure_threshold: u32,\n    /// 成功阈值 - 半开状态下成功多少次后关闭熔断器\n    pub success_threshold: u32,\n    /// 超时时间 - 熔断器打开后多久尝试半开（秒）\n    pub timeout_seconds: u64,\n    /// 错误率阈值 - 错误率超过此值时打开熔断器 (0.0-1.0)\n    pub error_rate_threshold: f64,\n    /// 最小请求数 - 计算错误率前的最小请求数\n    pub min_requests: u32,\n}\n\nimpl Default for CircuitBreakerConfig {\n    fn default() -> Self {\n        Self {\n            failure_threshold: 4,\n            success_threshold: 2,\n            timeout_seconds: 60,\n            error_rate_threshold: 0.6,\n            min_requests: 10,\n        }\n    }\n}\n\n/// 熔断器实例\npub struct CircuitBreaker {\n    /// 当前状态\n    state: Arc<RwLock<CircuitState>>,\n    /// 连续失败计数\n    consecutive_failures: Arc<AtomicU32>,\n    /// 连续成功计数（半开状态）\n    consecutive_successes: Arc<AtomicU32>,\n    /// 总请求计数\n    total_requests: Arc<AtomicU32>,\n    /// 失败请求计数\n    failed_requests: Arc<AtomicU32>,\n    /// 上次打开时间\n    last_opened_at: Arc<RwLock<Option<Instant>>>,\n    /// 配置（支持热更新）\n    config: Arc<RwLock<CircuitBreakerConfig>>,\n    /// 半开状态已放行的请求数（用于限流）\n    half_open_requests: Arc<AtomicU32>,\n}\n\n/// 熔断器放行结果\n///\n/// `used_half_open_permit` 表示本次放行是否占用了 HalfOpen 探测名额。\n/// 调用方应在请求结束后把该值传回 `record_success` / `record_failure` 用于正确释放名额。\n#[derive(Debug, Clone, Copy)]\npub struct AllowResult {\n    pub allowed: bool,\n    pub used_half_open_permit: bool,\n}\n\nimpl CircuitBreaker {\n    /// 创建新的熔断器\n    pub fn new(config: CircuitBreakerConfig) -> Self {\n        Self {\n            state: Arc::new(RwLock::new(CircuitState::Closed)),\n            consecutive_failures: Arc::new(AtomicU32::new(0)),\n            consecutive_successes: Arc::new(AtomicU32::new(0)),\n            total_requests: Arc::new(AtomicU32::new(0)),\n            failed_requests: Arc::new(AtomicU32::new(0)),\n            last_opened_at: Arc::new(RwLock::new(None)),\n            config: Arc::new(RwLock::new(config)),\n            half_open_requests: Arc::new(AtomicU32::new(0)),\n        }\n    }\n\n    /// 更新熔断器配置（热更新，不重置状态）\n    pub async fn update_config(&self, new_config: CircuitBreakerConfig) {\n        *self.config.write().await = new_config;\n    }\n\n    /// 判断当前 Provider 是否“可被纳入候选链路”\n    ///\n    /// 这个方法不会占用 HalfOpen 探测名额，仅用于路由选择阶段的“可用性判断”：\n    /// - Closed / HalfOpen：可用（返回 true）\n    /// - Open：若超时到达则切到 HalfOpen 并返回 true，否则返回 false\n    ///\n    /// 注意：真正发起请求前仍需调用 `allow_request()` 来获取 HalfOpen 探测名额，\n    /// 并在请求结束后通过 `record_success()` / `record_failure()` 释放。\n    pub async fn is_available(&self) -> bool {\n        let state = *self.state.read().await;\n        let config = self.config.read().await;\n\n        match state {\n            CircuitState::Closed | CircuitState::HalfOpen => true,\n            CircuitState::Open => {\n                if let Some(opened_at) = *self.last_opened_at.read().await {\n                    if opened_at.elapsed().as_secs() >= config.timeout_seconds {\n                        drop(config); // 释放读锁再转换状态\n                        log::info!(\n                            \"[{}] 熔断器 Open → HalfOpen (超时恢复)\",\n                            log_cb::OPEN_TO_HALF_OPEN\n                        );\n                        self.transition_to_half_open().await;\n                        return true;\n                    }\n                }\n                false\n            }\n        }\n    }\n\n    /// 检查是否允许请求通过\n    pub async fn allow_request(&self) -> AllowResult {\n        let state = *self.state.read().await;\n\n        match state {\n            CircuitState::Closed => AllowResult {\n                allowed: true,\n                used_half_open_permit: false,\n            },\n            CircuitState::Open => {\n                let config = self.config.read().await;\n                // 检查是否应该尝试半开\n                if let Some(opened_at) = *self.last_opened_at.read().await {\n                    if opened_at.elapsed().as_secs() >= config.timeout_seconds {\n                        drop(config); // 释放读锁再转换状态\n                        log::info!(\n                            \"[{}] 熔断器 Open → HalfOpen (超时恢复)\",\n                            log_cb::OPEN_TO_HALF_OPEN\n                        );\n                        self.transition_to_half_open().await;\n\n                        // 转换后按当前状态决定是否需要获取 HalfOpen 探测名额\n                        let current_state = *self.state.read().await;\n                        return match current_state {\n                            CircuitState::Closed => AllowResult {\n                                allowed: true,\n                                used_half_open_permit: false,\n                            },\n                            CircuitState::HalfOpen => self.allow_half_open_probe(),\n                            CircuitState::Open => AllowResult {\n                                allowed: false,\n                                used_half_open_permit: false,\n                            },\n                        };\n                    }\n                }\n\n                AllowResult {\n                    allowed: false,\n                    used_half_open_permit: false,\n                }\n            }\n            CircuitState::HalfOpen => self.allow_half_open_probe(),\n        }\n    }\n\n    /// 记录成功\n    pub async fn record_success(&self, used_half_open_permit: bool) {\n        let state = *self.state.read().await;\n        let config = self.config.read().await;\n\n        if used_half_open_permit {\n            self.release_half_open_permit();\n        }\n\n        // 重置失败计数\n        self.consecutive_failures.store(0, Ordering::SeqCst);\n        self.total_requests.fetch_add(1, Ordering::SeqCst);\n\n        if state == CircuitState::HalfOpen {\n            let successes = self.consecutive_successes.fetch_add(1, Ordering::SeqCst) + 1;\n\n            if successes >= config.success_threshold {\n                drop(config); // 释放读锁再转换状态\n                log::info!(\n                    \"[{}] 熔断器 HalfOpen → Closed (恢复正常)\",\n                    log_cb::HALF_OPEN_TO_CLOSED\n                );\n                self.transition_to_closed().await;\n            }\n        }\n    }\n\n    /// 记录失败\n    pub async fn record_failure(&self, used_half_open_permit: bool) {\n        let state = *self.state.read().await;\n        let config = self.config.read().await;\n\n        if used_half_open_permit {\n            self.release_half_open_permit();\n        }\n\n        // 更新计数器\n        let failures = self.consecutive_failures.fetch_add(1, Ordering::SeqCst) + 1;\n        self.total_requests.fetch_add(1, Ordering::SeqCst);\n        self.failed_requests.fetch_add(1, Ordering::SeqCst);\n\n        // 重置成功计数\n        self.consecutive_successes.store(0, Ordering::SeqCst);\n\n        // 检查是否应该打开熔断器\n        match state {\n            CircuitState::HalfOpen => {\n                // HalfOpen 状态下失败，立即转为 Open\n                log::warn!(\n                    \"[{}] 熔断器 HalfOpen 探测失败 → Open\",\n                    log_cb::HALF_OPEN_PROBE_FAILED\n                );\n                drop(config);\n                self.transition_to_open().await;\n            }\n            CircuitState::Closed => {\n                // 检查连续失败次数\n                if failures >= config.failure_threshold {\n                    log::warn!(\n                        \"[{}] 熔断器触发: 连续失败 {failures} 次 → Open\",\n                        log_cb::TRIGGERED_FAILURES\n                    );\n                    drop(config); // 释放读锁再转换状态\n                    self.transition_to_open().await;\n                } else {\n                    // 检查错误率\n                    let total = self.total_requests.load(Ordering::SeqCst);\n                    let failed = self.failed_requests.load(Ordering::SeqCst);\n\n                    if total >= config.min_requests {\n                        let error_rate = failed as f64 / total as f64;\n\n                        if error_rate >= config.error_rate_threshold {\n                            log::warn!(\n                                \"[{}] 熔断器触发: 错误率 {:.1}% → Open\",\n                                log_cb::TRIGGERED_ERROR_RATE,\n                                error_rate * 100.0\n                            );\n                            drop(config); // 释放读锁再转换状态\n                            self.transition_to_open().await;\n                        }\n                    }\n                }\n            }\n            _ => {}\n        }\n    }\n\n    /// 获取当前状态\n    #[allow(dead_code)]\n    pub async fn get_state(&self) -> CircuitState {\n        *self.state.read().await\n    }\n\n    /// 获取统计信息\n    #[allow(dead_code)]\n    pub async fn get_stats(&self) -> CircuitBreakerStats {\n        CircuitBreakerStats {\n            state: *self.state.read().await,\n            consecutive_failures: self.consecutive_failures.load(Ordering::SeqCst),\n            consecutive_successes: self.consecutive_successes.load(Ordering::SeqCst),\n            total_requests: self.total_requests.load(Ordering::SeqCst),\n            failed_requests: self.failed_requests.load(Ordering::SeqCst),\n        }\n    }\n\n    /// 重置熔断器（手动恢复）\n    #[allow(dead_code)]\n    pub async fn reset(&self) {\n        log::info!(\"[{}] 熔断器手动重置 → Closed\", log_cb::MANUAL_RESET);\n        self.transition_to_closed().await;\n    }\n\n    fn allow_half_open_probe(&self) -> AllowResult {\n        // 半开状态限流：只允许有限请求通过进行探测\n        let max_half_open_requests = 1u32;\n        let current = self.half_open_requests.fetch_add(1, Ordering::SeqCst);\n\n        if current < max_half_open_requests {\n            AllowResult {\n                allowed: true,\n                used_half_open_permit: true,\n            }\n        } else {\n            // 超过限额，回退计数，拒绝请求\n            self.half_open_requests.fetch_sub(1, Ordering::SeqCst);\n            AllowResult {\n                allowed: false,\n                used_half_open_permit: false,\n            }\n        }\n    }\n\n    /// 仅释放 HalfOpen permit，不影响健康统计\n    ///\n    /// 用于整流器等场景：请求结果不应计入 Provider 健康度，\n    /// 但仍需释放占用的探测名额，避免 HalfOpen 状态卡死\n    pub fn release_half_open_permit(&self) {\n        let mut current = self.half_open_requests.load(Ordering::SeqCst);\n        loop {\n            if current == 0 {\n                return;\n            }\n\n            match self.half_open_requests.compare_exchange(\n                current,\n                current - 1,\n                Ordering::SeqCst,\n                Ordering::SeqCst,\n            ) {\n                Ok(_) => return,\n                Err(actual) => current = actual,\n            }\n        }\n    }\n\n    /// 转换到打开状态\n    async fn transition_to_open(&self) {\n        *self.state.write().await = CircuitState::Open;\n        *self.last_opened_at.write().await = Some(Instant::now());\n        self.consecutive_failures.store(0, Ordering::SeqCst);\n        self.consecutive_successes.store(0, Ordering::SeqCst);\n    }\n\n    /// 转换到半开状态\n    async fn transition_to_half_open(&self) {\n        let mut state = self.state.write().await;\n        if *state != CircuitState::Open {\n            return;\n        }\n\n        *state = CircuitState::HalfOpen;\n        self.consecutive_successes.store(0, Ordering::SeqCst);\n        // 重置半开状态的请求限流计数\n        self.half_open_requests.store(0, Ordering::SeqCst);\n    }\n\n    /// 转换到关闭状态\n    async fn transition_to_closed(&self) {\n        *self.state.write().await = CircuitState::Closed;\n        self.consecutive_failures.store(0, Ordering::SeqCst);\n        self.consecutive_successes.store(0, Ordering::SeqCst);\n        // 重置计数器\n        self.total_requests.store(0, Ordering::SeqCst);\n        self.failed_requests.store(0, Ordering::SeqCst);\n    }\n}\n\n/// 熔断器统计信息\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct CircuitBreakerStats {\n    pub state: CircuitState,\n    pub consecutive_failures: u32,\n    pub consecutive_successes: u32,\n    pub total_requests: u32,\n    pub failed_requests: u32,\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[tokio::test]\n    async fn test_circuit_breaker_closed_to_open() {\n        let config = CircuitBreakerConfig {\n            failure_threshold: 3,\n            ..Default::default()\n        };\n        let breaker = CircuitBreaker::new(config);\n\n        // 初始状态应该是关闭\n        assert_eq!(breaker.get_state().await, CircuitState::Closed);\n        assert!(breaker.allow_request().await.allowed);\n\n        // 记录 3 次失败\n        for _ in 0..3 {\n            breaker.record_failure(false).await;\n        }\n\n        // 应该转换到打开状态\n        assert_eq!(breaker.get_state().await, CircuitState::Open);\n        assert!(!breaker.allow_request().await.allowed);\n    }\n\n    #[tokio::test]\n    async fn test_circuit_breaker_half_open_to_closed() {\n        let config = CircuitBreakerConfig {\n            failure_threshold: 2,\n            success_threshold: 2,\n            ..Default::default()\n        };\n        let breaker = CircuitBreaker::new(config);\n\n        // 打开熔断器\n        breaker.record_failure(false).await;\n        breaker.record_failure(false).await;\n        assert_eq!(breaker.get_state().await, CircuitState::Open);\n\n        // 手动转换到半开状态\n        breaker.transition_to_half_open().await;\n        assert_eq!(breaker.get_state().await, CircuitState::HalfOpen);\n\n        // 记录 2 次成功\n        breaker.record_success(false).await;\n        breaker.record_success(false).await;\n\n        // 应该转换到关闭状态\n        assert_eq!(breaker.get_state().await, CircuitState::Closed);\n    }\n\n    #[tokio::test]\n    async fn test_half_open_transition_does_not_reset_inflight_permit() {\n        let config = CircuitBreakerConfig {\n            timeout_seconds: 0,\n            ..Default::default()\n        };\n        let breaker = CircuitBreaker::new(config);\n\n        // 进入 Open，然后由于 timeout_seconds=0，allow_request 会立即切换到 HalfOpen 并占用探测名额\n        breaker.transition_to_open().await;\n        let first = breaker.allow_request().await;\n        assert!(first.allowed);\n        assert!(first.used_half_open_permit);\n        assert_eq!(breaker.get_state().await, CircuitState::HalfOpen);\n\n        // 模拟并发下的“重复 HalfOpen 转换调用”，不应重置 in-flight 计数\n        breaker.transition_to_half_open().await;\n\n        // 由于名额仍被占用，第二次请求应被拒绝\n        let second = breaker.allow_request().await;\n        assert!(!second.allowed);\n        assert!(!second.used_half_open_permit);\n    }\n\n    #[tokio::test]\n    async fn test_circuit_breaker_reset() {\n        let config = CircuitBreakerConfig {\n            failure_threshold: 2,\n            ..Default::default()\n        };\n        let breaker = CircuitBreaker::new(config);\n\n        // 打开熔断器\n        breaker.record_failure(false).await;\n        breaker.record_failure(false).await;\n        assert_eq!(breaker.get_state().await, CircuitState::Open);\n\n        // 重置\n        breaker.reset().await;\n        assert_eq!(breaker.get_state().await, CircuitState::Closed);\n        assert!(breaker.allow_request().await.allowed);\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/error.rs",
    "content": "use axum::{\n    http::StatusCode,\n    response::{IntoResponse, Response},\n    Json,\n};\nuse serde_json::json;\nuse thiserror::Error;\n\n#[derive(Debug, Error)]\npub enum ProxyError {\n    #[error(\"服务器已在运行\")]\n    AlreadyRunning,\n\n    #[error(\"服务器未运行\")]\n    NotRunning,\n\n    #[error(\"地址绑定失败: {0}\")]\n    BindFailed(String),\n\n    #[error(\"停止超时\")]\n    StopTimeout,\n\n    #[error(\"停止失败: {0}\")]\n    StopFailed(String),\n\n    #[error(\"请求转发失败: {0}\")]\n    ForwardFailed(String),\n\n    #[error(\"无可用的Provider\")]\n    NoAvailableProvider,\n\n    #[error(\"所有供应商已熔断，无可用渠道\")]\n    AllProvidersCircuitOpen,\n\n    #[error(\"未配置供应商\")]\n    NoProvidersConfigured,\n\n    #[allow(dead_code)]\n    #[error(\"Provider不健康: {0}\")]\n    ProviderUnhealthy(String),\n\n    #[error(\"上游错误 (状态码 {status}): {body:?}\")]\n    UpstreamError { status: u16, body: Option<String> },\n\n    #[error(\"超过最大重试次数\")]\n    MaxRetriesExceeded,\n\n    #[error(\"数据库错误: {0}\")]\n    DatabaseError(String),\n\n    #[error(\"配置错误: {0}\")]\n    ConfigError(String),\n\n    #[allow(dead_code)]\n    #[error(\"格式转换错误: {0}\")]\n    TransformError(String),\n\n    #[allow(dead_code)]\n    #[error(\"无效的请求: {0}\")]\n    InvalidRequest(String),\n\n    #[error(\"超时: {0}\")]\n    Timeout(String),\n\n    /// 流式响应空闲超时\n    #[allow(dead_code)]\n    #[error(\"流式响应空闲超时: {0}秒无数据\")]\n    StreamIdleTimeout(u64),\n\n    /// 认证错误\n    #[allow(dead_code)]\n    #[error(\"认证失败: {0}\")]\n    AuthError(String),\n\n    #[allow(dead_code)]\n    #[error(\"内部错误: {0}\")]\n    Internal(String),\n}\n\nimpl IntoResponse for ProxyError {\n    fn into_response(self) -> Response {\n        let (status, body) = match &self {\n            ProxyError::UpstreamError {\n                status: upstream_status,\n                body: upstream_body,\n            } => {\n                let http_status =\n                    StatusCode::from_u16(*upstream_status).unwrap_or(StatusCode::BAD_GATEWAY);\n\n                // 尝试解析上游响应体为 JSON，如果失败则包装为字符串\n                let error_body = if let Some(body_str) = upstream_body {\n                    if let Ok(json_body) = serde_json::from_str::<serde_json::Value>(body_str) {\n                        // 上游返回的是 JSON，直接透传\n                        json_body\n                    } else {\n                        // 上游返回的不是 JSON，包装为错误消息\n                        json!({\n                            \"error\": {\n                                \"message\": body_str,\n                                \"type\": \"upstream_error\",\n                            }\n                        })\n                    }\n                } else {\n                    json!({\n                        \"error\": {\n                            \"message\": format!(\"Upstream error (status {})\", upstream_status),\n                            \"type\": \"upstream_error\",\n                        }\n                    })\n                };\n\n                (http_status, error_body)\n            }\n            _ => {\n                let (http_status, message) = match &self {\n                    ProxyError::AlreadyRunning => (StatusCode::CONFLICT, self.to_string()),\n                    ProxyError::NotRunning => (StatusCode::SERVICE_UNAVAILABLE, self.to_string()),\n                    ProxyError::BindFailed(_) => {\n                        (StatusCode::INTERNAL_SERVER_ERROR, self.to_string())\n                    }\n                    ProxyError::StopTimeout => {\n                        (StatusCode::INTERNAL_SERVER_ERROR, self.to_string())\n                    }\n                    ProxyError::StopFailed(_) => {\n                        (StatusCode::INTERNAL_SERVER_ERROR, self.to_string())\n                    }\n                    ProxyError::ForwardFailed(_) => (StatusCode::BAD_GATEWAY, self.to_string()),\n                    ProxyError::NoAvailableProvider => {\n                        (StatusCode::SERVICE_UNAVAILABLE, self.to_string())\n                    }\n                    ProxyError::AllProvidersCircuitOpen => {\n                        (StatusCode::SERVICE_UNAVAILABLE, self.to_string())\n                    }\n                    ProxyError::NoProvidersConfigured => {\n                        (StatusCode::SERVICE_UNAVAILABLE, self.to_string())\n                    }\n                    ProxyError::ProviderUnhealthy(_) => {\n                        (StatusCode::SERVICE_UNAVAILABLE, self.to_string())\n                    }\n                    ProxyError::MaxRetriesExceeded => {\n                        (StatusCode::SERVICE_UNAVAILABLE, self.to_string())\n                    }\n                    ProxyError::DatabaseError(_) => {\n                        (StatusCode::INTERNAL_SERVER_ERROR, self.to_string())\n                    }\n                    ProxyError::ConfigError(_) => (StatusCode::BAD_REQUEST, self.to_string()),\n                    ProxyError::TransformError(_) => {\n                        (StatusCode::UNPROCESSABLE_ENTITY, self.to_string())\n                    }\n                    ProxyError::InvalidRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()),\n                    ProxyError::Timeout(_) => (StatusCode::GATEWAY_TIMEOUT, self.to_string()),\n                    ProxyError::StreamIdleTimeout(_) => {\n                        (StatusCode::GATEWAY_TIMEOUT, self.to_string())\n                    }\n                    ProxyError::AuthError(_) => (StatusCode::UNAUTHORIZED, self.to_string()),\n                    ProxyError::Internal(_) => {\n                        (StatusCode::INTERNAL_SERVER_ERROR, self.to_string())\n                    }\n                    ProxyError::UpstreamError { .. } => unreachable!(),\n                };\n\n                let error_body = json!({\n                    \"error\": {\n                        \"message\": message,\n                        \"type\": \"proxy_error\",\n                    }\n                });\n\n                (http_status, error_body)\n            }\n        };\n\n        (status, Json(body)).into_response()\n    }\n}\n\n/// 错误分类\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum ErrorCategory {\n    /// 可重试错误（网络问题、5xx）\n    Retryable, // 网络超时、5xx 错误\n    /// 不可重试错误（4xx、认证失败）\n    NonRetryable, // 认证失败、参数错误、4xx 错误\n    #[allow(dead_code)]\n    ClientAbort, // 客户端主动中断\n}\n\n/// 判断错误是否可重试\n#[allow(dead_code)]\npub fn categorize_error(error: &reqwest::Error) -> ErrorCategory {\n    if error.is_timeout() || error.is_connect() {\n        return ErrorCategory::Retryable;\n    }\n\n    if let Some(status) = error.status() {\n        if status.is_server_error() {\n            ErrorCategory::Retryable\n        } else if status.is_client_error() {\n            ErrorCategory::NonRetryable\n        } else {\n            ErrorCategory::Retryable\n        }\n    } else {\n        ErrorCategory::Retryable\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/error_mapper.rs",
    "content": "//! 错误类型到 HTTP 状态码的映射\n//!\n//! 将 ProxyError 映射到合适的 HTTP 状态码，用于日志记录\n\nuse super::ProxyError;\n\n/// 将 ProxyError 映射到 HTTP 状态码\n///\n/// 映射规则：\n/// - 上游错误：直接使用上游返回的状态码\n/// - 超时：504 Gateway Timeout\n/// - 连接失败：502 Bad Gateway\n/// - 无可用 Provider：503 Service Unavailable\n/// - 重试耗尽：503 Service Unavailable\n/// - 其他错误：500 Internal Server Error\npub fn map_proxy_error_to_status(error: &ProxyError) -> u16 {\n    match error {\n        // 上游错误：使用实际状态码\n        ProxyError::UpstreamError { status, .. } => *status,\n\n        // 超时错误：504 Gateway Timeout\n        ProxyError::Timeout(_) => 504,\n\n        // 转发失败/连接失败：502 Bad Gateway\n        ProxyError::ForwardFailed(_) => 502,\n\n        // 无可用 Provider：503 Service Unavailable\n        ProxyError::NoAvailableProvider => 503,\n\n        // 所有供应商已熔断：503 Service Unavailable\n        ProxyError::AllProvidersCircuitOpen => 503,\n\n        // 未配置供应商：503 Service Unavailable\n        ProxyError::NoProvidersConfigured => 503,\n\n        // 重试耗尽：503 Service Unavailable\n        ProxyError::MaxRetriesExceeded => 503,\n\n        // Provider 不健康：503 Service Unavailable\n        ProxyError::ProviderUnhealthy(_) => 503,\n\n        // 数据库错误：500 Internal Server Error\n        ProxyError::DatabaseError(_) => 500,\n\n        // 转换错误：500 Internal Server Error\n        ProxyError::TransformError(_) => 500,\n\n        // 其他未知错误：500 Internal Server Error\n        _ => 500,\n    }\n}\n\n/// 将 ProxyError 转换为用户友好的错误消息\npub fn get_error_message(error: &ProxyError) -> String {\n    match error {\n        ProxyError::UpstreamError { status, body } => {\n            if let Some(body) = body {\n                format!(\"上游错误 ({status}): {body}\")\n            } else {\n                format!(\"上游错误 ({status})\")\n            }\n        }\n        ProxyError::Timeout(msg) => format!(\"请求超时: {msg}\"),\n        ProxyError::ForwardFailed(msg) => format!(\"转发失败: {msg}\"),\n        ProxyError::NoAvailableProvider => \"无可用 Provider\".to_string(),\n        ProxyError::AllProvidersCircuitOpen => \"所有供应商已熔断，无可用渠道\".to_string(),\n        ProxyError::NoProvidersConfigured => \"未配置供应商\".to_string(),\n        ProxyError::MaxRetriesExceeded => \"所有 Provider 都失败，重试耗尽\".to_string(),\n        ProxyError::ProviderUnhealthy(msg) => format!(\"Provider 不健康: {msg}\"),\n        ProxyError::DatabaseError(msg) => format!(\"数据库错误: {msg}\"),\n        ProxyError::TransformError(msg) => format!(\"请求/响应转换错误: {msg}\"),\n        _ => error.to_string(),\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_map_upstream_error() {\n        let error = ProxyError::UpstreamError {\n            status: 401,\n            body: Some(\"Unauthorized\".to_string()),\n        };\n        assert_eq!(map_proxy_error_to_status(&error), 401);\n    }\n\n    #[test]\n    fn test_map_timeout_error() {\n        let error = ProxyError::Timeout(\"Request timeout\".to_string());\n        assert_eq!(map_proxy_error_to_status(&error), 504);\n    }\n\n    #[test]\n    fn test_map_connection_error() {\n        let error = ProxyError::ForwardFailed(\"Connection refused\".to_string());\n        assert_eq!(map_proxy_error_to_status(&error), 502);\n    }\n\n    #[test]\n    fn test_map_no_provider_error() {\n        let error = ProxyError::NoAvailableProvider;\n        assert_eq!(map_proxy_error_to_status(&error), 503);\n    }\n\n    #[test]\n    fn test_get_error_message() {\n        let error = ProxyError::UpstreamError {\n            status: 500,\n            body: Some(\"Internal Server Error\".to_string()),\n        };\n        let msg = get_error_message(&error);\n        assert!(msg.contains(\"上游错误\"));\n        assert!(msg.contains(\"500\"));\n        assert!(msg.contains(\"Internal Server Error\"));\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/failover_switch.rs",
    "content": "//! 故障转移切换模块\n//!\n//! 处理故障转移成功后的供应商切换逻辑，包括：\n//! - 去重控制（避免多个请求同时触发）\n//! - 数据库更新\n//! - 托盘菜单更新\n//! - 前端事件发射\n//! - Live 备份更新\n\nuse crate::database::Database;\nuse crate::error::AppError;\nuse std::collections::HashSet;\nuse std::str::FromStr;\nuse std::sync::Arc;\nuse tauri::{Emitter, Manager};\nuse tokio::sync::RwLock;\n\n/// 故障转移切换管理器\n///\n/// 负责处理故障转移成功后的供应商切换，确保 UI 能够直观反映当前使用的供应商。\n#[derive(Clone)]\npub struct FailoverSwitchManager {\n    /// 正在处理中的切换（key = \"app_type:provider_id\"）\n    pending_switches: Arc<RwLock<HashSet<String>>>,\n    db: Arc<Database>,\n}\n\nimpl FailoverSwitchManager {\n    pub fn new(db: Arc<Database>) -> Self {\n        Self {\n            pending_switches: Arc::new(RwLock::new(HashSet::new())),\n            db,\n        }\n    }\n\n    /// 尝试执行故障转移切换\n    ///\n    /// 如果相同的切换已在进行中，则跳过；否则执行切换逻辑。\n    ///\n    /// # Returns\n    /// - `Ok(true)` - 切换成功执行\n    /// - `Ok(false)` - 切换已在进行中，跳过\n    /// - `Err(e)` - 切换过程中发生错误\n    pub async fn try_switch(\n        &self,\n        app_handle: Option<&tauri::AppHandle>,\n        app_type: &str,\n        provider_id: &str,\n        provider_name: &str,\n    ) -> Result<bool, AppError> {\n        let switch_key = format!(\"{app_type}:{provider_id}\");\n\n        // 去重检查：如果相同切换已在进行中，跳过\n        {\n            let mut pending = self.pending_switches.write().await;\n            if pending.contains(&switch_key) {\n                log::debug!(\"[Failover] 切换已在进行中，跳过: {app_type} -> {provider_id}\");\n                return Ok(false);\n            }\n            pending.insert(switch_key.clone());\n        }\n\n        // 执行切换（确保最后清理 pending 标记）\n        let result = self\n            .do_switch(app_handle, app_type, provider_id, provider_name)\n            .await;\n\n        // 清理 pending 标记\n        {\n            let mut pending = self.pending_switches.write().await;\n            pending.remove(&switch_key);\n        }\n\n        result\n    }\n\n    async fn do_switch(\n        &self,\n        app_handle: Option<&tauri::AppHandle>,\n        app_type: &str,\n        provider_id: &str,\n        provider_name: &str,\n    ) -> Result<bool, AppError> {\n        // 检查该应用是否已被代理接管（enabled=true）\n        // 只有被接管的应用才允许执行故障转移切换\n        let app_enabled = match self.db.get_proxy_config_for_app(app_type).await {\n            Ok(config) => config.enabled,\n            Err(e) => {\n                log::warn!(\"[FO-002] 无法读取 {app_type} 配置: {e}，跳过切换\");\n                return Ok(false);\n            }\n        };\n\n        if !app_enabled {\n            log::debug!(\"[Failover] {app_type} 未启用代理，跳过切换\");\n            return Ok(false);\n        }\n\n        log::info!(\"[FO-001] 切换: {app_type} → {provider_name}\");\n\n        // 1. 更新数据库 is_current\n        self.db.set_current_provider(app_type, provider_id)?;\n\n        // 2. 更新本地 settings（设备级）\n        let app_type_enum = crate::app_config::AppType::from_str(app_type)\n            .map_err(|_| AppError::Message(format!(\"无效的应用类型: {app_type}\")))?;\n        crate::settings::set_current_provider(&app_type_enum, Some(provider_id))?;\n\n        // 3. 更新托盘菜单和发射事件\n        if let Some(app) = app_handle {\n            // 更新托盘菜单\n            if let Some(app_state) = app.try_state::<crate::store::AppState>() {\n                // 更新 Live 备份（确保代理停止时恢复正确配置）\n                if let Ok(Some(provider)) = self.db.get_provider_by_id(provider_id, app_type) {\n                    if let Err(e) = app_state\n                        .proxy_service\n                        .update_live_backup_from_provider(app_type, &provider)\n                        .await\n                    {\n                        log::warn!(\"[FO-003] Live 备份更新失败: {e}\");\n                    }\n                }\n\n                // 重建托盘菜单\n                if let Ok(new_menu) = crate::tray::create_tray_menu(app, app_state.inner()) {\n                    if let Some(tray) = app.tray_by_id(\"main\") {\n                        if let Err(e) = tray.set_menu(Some(new_menu)) {\n                            log::error!(\"[Failover] 更新托盘菜单失败: {e}\");\n                        }\n                    }\n                }\n            }\n\n            // 发射事件到前端\n            let event_data = serde_json::json!({\n                \"appType\": app_type,\n                \"providerId\": provider_id,\n                \"source\": \"failover\"  // 标识来源是故障转移\n            });\n            if let Err(e) = app.emit(\"provider-switched\", event_data) {\n                log::error!(\"[Failover] 发射事件失败: {e}\");\n            }\n        }\n\n        Ok(true)\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/forwarder.rs",
    "content": "//! 请求转发器\n//!\n//! 负责将请求转发到上游Provider，支持故障转移\n\nuse super::{\n    body_filter::filter_private_params_with_whitelist,\n    error::*,\n    failover_switch::FailoverSwitchManager,\n    log_codes::fwd as log_fwd,\n    provider_router::ProviderRouter,\n    providers::{get_adapter, AuthInfo, AuthStrategy, ProviderAdapter, ProviderType},\n    thinking_budget_rectifier::{rectify_thinking_budget, should_rectify_thinking_budget},\n    thinking_rectifier::{\n        normalize_thinking_type, rectify_anthropic_request, should_rectify_thinking_signature,\n    },\n    types::{OptimizerConfig, ProxyStatus, RectifierConfig},\n    ProxyError,\n};\nuse crate::commands::CopilotAuthState;\nuse crate::proxy::providers::copilot_auth::CopilotAuthManager;\nuse crate::{app_config::AppType, provider::Provider};\nuse reqwest::Response;\nuse serde_json::Value;\nuse std::sync::Arc;\nuse tauri::Manager;\nuse tokio::sync::RwLock;\n\n/// Headers 黑名单 - 不透传到上游的 Headers\n///\n/// 精简版黑名单，只过滤必须覆盖或可能导致问题的 header\n/// 参考成功透传的请求，保留更多原始 header\n///\n/// 注意：客户端 IP 类（x-forwarded-for, x-real-ip）默认透传\nconst HEADER_BLACKLIST: &[&str] = &[\n    // 认证类（会被覆盖）\n    \"authorization\",\n    \"x-api-key\",\n    \"x-goog-api-key\",\n    // 连接类（由 HTTP 客户端管理）\n    \"host\",\n    \"content-length\",\n    \"transfer-encoding\",\n    // 编码类（会被覆盖为 identity）\n    \"accept-encoding\",\n    // 代理转发类（保留 x-forwarded-for 和 x-real-ip）\n    \"x-forwarded-host\",\n    \"x-forwarded-port\",\n    \"x-forwarded-proto\",\n    \"forwarded\",\n    // CDN/云服务商特定头\n    \"cf-connecting-ip\",\n    \"cf-ipcountry\",\n    \"cf-ray\",\n    \"cf-visitor\",\n    \"true-client-ip\",\n    \"fastly-client-ip\",\n    \"x-azure-clientip\",\n    \"x-azure-fdid\",\n    \"x-azure-ref\",\n    \"akamai-origin-hop\",\n    \"x-akamai-config-log-detail\",\n    // 请求追踪类\n    \"x-request-id\",\n    \"x-correlation-id\",\n    \"x-trace-id\",\n    \"x-amzn-trace-id\",\n    \"x-b3-traceid\",\n    \"x-b3-spanid\",\n    \"x-b3-parentspanid\",\n    \"x-b3-sampled\",\n    \"traceparent\",\n    \"tracestate\",\n    // anthropic 特定头单独处理，避免重复\n    \"anthropic-beta\",\n    \"anthropic-version\",\n    // 客户端 IP 单独处理（默认透传）\n    \"x-forwarded-for\",\n    \"x-real-ip\",\n];\n\npub struct ForwardResult {\n    pub response: Response,\n    pub provider: Provider,\n}\n\npub struct ForwardError {\n    pub error: ProxyError,\n    pub provider: Option<Provider>,\n}\n\npub struct RequestForwarder {\n    /// 共享的 ProviderRouter（持有熔断器状态）\n    router: Arc<ProviderRouter>,\n    status: Arc<RwLock<ProxyStatus>>,\n    current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,\n    /// 故障转移切换管理器\n    failover_manager: Arc<FailoverSwitchManager>,\n    /// AppHandle，用于发射事件和更新托盘\n    app_handle: Option<tauri::AppHandle>,\n    /// 请求开始时的\"当前供应商 ID\"（用于判断是否需要同步 UI/托盘）\n    current_provider_id_at_start: String,\n    /// 整流器配置\n    rectifier_config: RectifierConfig,\n    /// 优化器配置\n    optimizer_config: OptimizerConfig,\n    /// 非流式请求超时（秒）\n    non_streaming_timeout: std::time::Duration,\n}\n\nimpl RequestForwarder {\n    #[allow(clippy::too_many_arguments)]\n    pub fn new(\n        router: Arc<ProviderRouter>,\n        non_streaming_timeout: u64,\n        status: Arc<RwLock<ProxyStatus>>,\n        current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,\n        failover_manager: Arc<FailoverSwitchManager>,\n        app_handle: Option<tauri::AppHandle>,\n        current_provider_id_at_start: String,\n        _streaming_first_byte_timeout: u64,\n        _streaming_idle_timeout: u64,\n        rectifier_config: RectifierConfig,\n        optimizer_config: OptimizerConfig,\n    ) -> Self {\n        Self {\n            router,\n            status,\n            current_providers,\n            failover_manager,\n            app_handle,\n            current_provider_id_at_start,\n            rectifier_config,\n            optimizer_config,\n            non_streaming_timeout: std::time::Duration::from_secs(non_streaming_timeout),\n        }\n    }\n\n    /// 转发请求（带故障转移）\n    ///\n    /// # Arguments\n    /// * `app_type` - 应用类型\n    /// * `endpoint` - API 端点\n    /// * `body` - 请求体\n    /// * `headers` - 请求头\n    /// * `providers` - 已选择的 Provider 列表（由 RequestContext 提供，避免重复调用 select_providers）\n    pub async fn forward_with_retry(\n        &self,\n        app_type: &AppType,\n        endpoint: &str,\n        body: Value,\n        headers: axum::http::HeaderMap,\n        providers: Vec<Provider>,\n    ) -> Result<ForwardResult, ForwardError> {\n        // 获取适配器\n        let adapter = get_adapter(app_type);\n        let app_type_str = app_type.as_str();\n\n        if providers.is_empty() {\n            return Err(ForwardError {\n                error: ProxyError::NoAvailableProvider,\n                provider: None,\n            });\n        }\n\n        let mut last_error = None;\n        let mut last_provider = None;\n        let mut attempted_providers = 0usize;\n\n        // 整流器重试标记：确保整流最多触发一次\n        let mut rectifier_retried = false;\n        let mut budget_rectifier_retried = false;\n\n        // 单 Provider 场景下跳过熔断器检查（故障转移关闭时）\n        let bypass_circuit_breaker = providers.len() == 1;\n\n        // 依次尝试每个供应商\n        for provider in providers.iter() {\n            // 发起请求前先获取熔断器放行许可（HalfOpen 会占用探测名额）\n            // 单 Provider 场景下跳过此检查，避免熔断器阻塞所有请求\n            let (allowed, used_half_open_permit) = if bypass_circuit_breaker {\n                (true, false)\n            } else {\n                let permit = self\n                    .router\n                    .allow_provider_request(&provider.id, app_type_str)\n                    .await;\n                (permit.allowed, permit.used_half_open_permit)\n            };\n\n            if !allowed {\n                continue;\n            }\n\n            // PRE-SEND 优化器：每个 provider 独立决定是否优化\n            // clone body 以避免 Bedrock 优化字段泄漏到非 Bedrock provider（failover 场景）\n            let mut provider_body =\n                if self.optimizer_config.enabled && is_bedrock_provider(provider) {\n                    let mut b = body.clone();\n                    if self.optimizer_config.thinking_optimizer {\n                        super::thinking_optimizer::optimize(&mut b, &self.optimizer_config);\n                    }\n                    if self.optimizer_config.cache_injection {\n                        super::cache_injector::inject(&mut b, &self.optimizer_config);\n                    }\n                    b\n                } else {\n                    body.clone()\n                };\n\n            attempted_providers += 1;\n\n            // 更新状态中的当前Provider信息\n            {\n                let mut status = self.status.write().await;\n                status.current_provider = Some(provider.name.clone());\n                status.current_provider_id = Some(provider.id.clone());\n                status.total_requests += 1;\n                status.last_request_at = Some(chrono::Utc::now().to_rfc3339());\n            }\n\n            // 转发请求（每个 Provider 只尝试一次，重试由客户端控制）\n            match self\n                .forward(\n                    provider,\n                    endpoint,\n                    &provider_body,\n                    &headers,\n                    adapter.as_ref(),\n                )\n                .await\n            {\n                Ok(response) => {\n                    // 成功：记录成功并更新熔断器\n                    let _ = self\n                        .router\n                        .record_result(\n                            &provider.id,\n                            app_type_str,\n                            used_half_open_permit,\n                            true,\n                            None,\n                        )\n                        .await;\n\n                    // 更新当前应用类型使用的 provider\n                    {\n                        let mut current_providers = self.current_providers.write().await;\n                        current_providers.insert(\n                            app_type_str.to_string(),\n                            (provider.id.clone(), provider.name.clone()),\n                        );\n                    }\n\n                    // 更新成功统计\n                    {\n                        let mut status = self.status.write().await;\n                        status.success_requests += 1;\n                        status.last_error = None;\n                        let should_switch =\n                            self.current_provider_id_at_start.as_str() != provider.id.as_str();\n                        if should_switch {\n                            status.failover_count += 1;\n\n                            // 异步触发供应商切换，更新 UI/托盘，并把“当前供应商”同步为实际使用的 provider\n                            let fm = self.failover_manager.clone();\n                            let ah = self.app_handle.clone();\n                            let pid = provider.id.clone();\n                            let pname = provider.name.clone();\n                            let at = app_type_str.to_string();\n\n                            tokio::spawn(async move {\n                                let _ = fm.try_switch(ah.as_ref(), &at, &pid, &pname).await;\n                            });\n                        }\n                        // 重新计算成功率\n                        if status.total_requests > 0 {\n                            status.success_rate = (status.success_requests as f32\n                                / status.total_requests as f32)\n                                * 100.0;\n                        }\n                    }\n\n                    return Ok(ForwardResult {\n                        response,\n                        provider: provider.clone(),\n                    });\n                }\n                Err(e) => {\n                    // 检测是否需要触发整流器（仅 Claude/ClaudeAuth 供应商）\n                    let provider_type = ProviderType::from_app_type_and_config(app_type, provider);\n                    let is_anthropic_provider = matches!(\n                        provider_type,\n                        ProviderType::Claude | ProviderType::ClaudeAuth\n                    );\n                    let mut signature_rectifier_non_retryable_client_error = false;\n\n                    if is_anthropic_provider {\n                        let error_message = extract_error_message(&e);\n                        if should_rectify_thinking_signature(\n                            error_message.as_deref(),\n                            &self.rectifier_config,\n                        ) {\n                            // 已经重试过：直接返回错误（不可重试客户端错误）\n                            if rectifier_retried {\n                                log::warn!(\"[{app_type_str}] [RECT-005] 整流器已触发过，不再重试\");\n                                // 释放 HalfOpen permit（不记录熔断器，这是客户端兼容性问题）\n                                self.router\n                                    .release_permit_neutral(\n                                        &provider.id,\n                                        app_type_str,\n                                        used_half_open_permit,\n                                    )\n                                    .await;\n                                let mut status = self.status.write().await;\n                                status.failed_requests += 1;\n                                status.last_error = Some(e.to_string());\n                                if status.total_requests > 0 {\n                                    status.success_rate = (status.success_requests as f32\n                                        / status.total_requests as f32)\n                                        * 100.0;\n                                }\n                                return Err(ForwardError {\n                                    error: e,\n                                    provider: Some(provider.clone()),\n                                });\n                            }\n\n                            // 首次触发：整流请求体\n                            let rectified = rectify_anthropic_request(&mut provider_body);\n\n                            // 整流未生效：继续尝试 budget 整流路径，避免误判后短路\n                            if !rectified.applied {\n                                log::warn!(\n                                    \"[{app_type_str}] [RECT-006] thinking 签名整流器触发但无可整流内容，继续检查 budget；若 budget 也未命中则按客户端错误返回\"\n                                );\n                                signature_rectifier_non_retryable_client_error = true;\n                            } else {\n                                log::info!(\n                                    \"[{}] [RECT-001] thinking 签名整流器触发, 移除 {} thinking blocks, {} redacted_thinking blocks, {} signature fields\",\n                                    app_type_str,\n                                    rectified.removed_thinking_blocks,\n                                    rectified.removed_redacted_thinking_blocks,\n                                    rectified.removed_signature_fields\n                                );\n\n                                // 标记已重试（当前逻辑下重试后必定 return，保留标记以备将来扩展）\n                                let _ = std::mem::replace(&mut rectifier_retried, true);\n\n                                // 使用同一供应商重试（不计入熔断器）\n                                match self\n                                    .forward(\n                                        provider,\n                                        endpoint,\n                                        &provider_body,\n                                        &headers,\n                                        adapter.as_ref(),\n                                    )\n                                    .await\n                                {\n                                    Ok(response) => {\n                                        log::info!(\"[{app_type_str}] [RECT-002] 整流重试成功\");\n                                        // 记录成功\n                                        let _ = self\n                                            .router\n                                            .record_result(\n                                                &provider.id,\n                                                app_type_str,\n                                                used_half_open_permit,\n                                                true,\n                                                None,\n                                            )\n                                            .await;\n\n                                        // 更新当前应用类型使用的 provider\n                                        {\n                                            let mut current_providers =\n                                                self.current_providers.write().await;\n                                            current_providers.insert(\n                                                app_type_str.to_string(),\n                                                (provider.id.clone(), provider.name.clone()),\n                                            );\n                                        }\n\n                                        // 更新成功统计\n                                        {\n                                            let mut status = self.status.write().await;\n                                            status.success_requests += 1;\n                                            status.last_error = None;\n                                            let should_switch =\n                                                self.current_provider_id_at_start.as_str()\n                                                    != provider.id.as_str();\n                                            if should_switch {\n                                                status.failover_count += 1;\n\n                                                // 异步触发供应商切换，更新 UI/托盘\n                                                let fm = self.failover_manager.clone();\n                                                let ah = self.app_handle.clone();\n                                                let pid = provider.id.clone();\n                                                let pname = provider.name.clone();\n                                                let at = app_type_str.to_string();\n\n                                                tokio::spawn(async move {\n                                                    let _ = fm\n                                                        .try_switch(ah.as_ref(), &at, &pid, &pname)\n                                                        .await;\n                                                });\n                                            }\n                                            if status.total_requests > 0 {\n                                                status.success_rate = (status.success_requests\n                                                    as f32\n                                                    / status.total_requests as f32)\n                                                    * 100.0;\n                                            }\n                                        }\n\n                                        return Ok(ForwardResult {\n                                            response,\n                                            provider: provider.clone(),\n                                        });\n                                    }\n                                    Err(retry_err) => {\n                                        // 整流重试仍失败：区分错误类型决定是否记录熔断器\n                                        log::warn!(\n                                            \"[{app_type_str}] [RECT-003] 整流重试仍失败: {retry_err}\"\n                                        );\n\n                                        // 区分错误类型：Provider 问题记录失败，客户端问题仅释放 permit\n                                        let is_provider_error = match &retry_err {\n                                            ProxyError::Timeout(_)\n                                            | ProxyError::ForwardFailed(_) => true,\n                                            ProxyError::UpstreamError { status, .. } => {\n                                                *status >= 500\n                                            }\n                                            _ => false,\n                                        };\n\n                                        if is_provider_error {\n                                            // Provider 问题：记录失败到熔断器\n                                            let _ = self\n                                                .router\n                                                .record_result(\n                                                    &provider.id,\n                                                    app_type_str,\n                                                    used_half_open_permit,\n                                                    false,\n                                                    Some(retry_err.to_string()),\n                                                )\n                                                .await;\n                                        } else {\n                                            // 客户端问题：仅释放 permit，不记录熔断器\n                                            self.router\n                                                .release_permit_neutral(\n                                                    &provider.id,\n                                                    app_type_str,\n                                                    used_half_open_permit,\n                                                )\n                                                .await;\n                                        }\n\n                                        let mut status = self.status.write().await;\n                                        status.failed_requests += 1;\n                                        status.last_error = Some(retry_err.to_string());\n                                        if status.total_requests > 0 {\n                                            status.success_rate = (status.success_requests as f32\n                                                / status.total_requests as f32)\n                                                * 100.0;\n                                        }\n                                        return Err(ForwardError {\n                                            error: retry_err,\n                                            provider: Some(provider.clone()),\n                                        });\n                                    }\n                                }\n                            }\n                        }\n                    }\n\n                    // 检测是否需要触发 budget 整流器（仅 Claude/ClaudeAuth 供应商）\n                    if is_anthropic_provider {\n                        let error_message = extract_error_message(&e);\n                        if should_rectify_thinking_budget(\n                            error_message.as_deref(),\n                            &self.rectifier_config,\n                        ) {\n                            // 已经重试过：直接返回错误（不可重试客户端错误）\n                            if budget_rectifier_retried {\n                                log::warn!(\n                                    \"[{app_type_str}] [RECT-013] budget 整流器已触发过，不再重试\"\n                                );\n                                self.router\n                                    .release_permit_neutral(\n                                        &provider.id,\n                                        app_type_str,\n                                        used_half_open_permit,\n                                    )\n                                    .await;\n                                let mut status = self.status.write().await;\n                                status.failed_requests += 1;\n                                status.last_error = Some(e.to_string());\n                                if status.total_requests > 0 {\n                                    status.success_rate = (status.success_requests as f32\n                                        / status.total_requests as f32)\n                                        * 100.0;\n                                }\n                                return Err(ForwardError {\n                                    error: e,\n                                    provider: Some(provider.clone()),\n                                });\n                            }\n\n                            let budget_rectified = rectify_thinking_budget(&mut provider_body);\n                            if !budget_rectified.applied {\n                                log::warn!(\n                                    \"[{app_type_str}] [RECT-014] budget 整流器触发但无可整流内容，不做无意义重试\"\n                                );\n                                self.router\n                                    .release_permit_neutral(\n                                        &provider.id,\n                                        app_type_str,\n                                        used_half_open_permit,\n                                    )\n                                    .await;\n                                let mut status = self.status.write().await;\n                                status.failed_requests += 1;\n                                status.last_error = Some(e.to_string());\n                                if status.total_requests > 0 {\n                                    status.success_rate = (status.success_requests as f32\n                                        / status.total_requests as f32)\n                                        * 100.0;\n                                }\n                                return Err(ForwardError {\n                                    error: e,\n                                    provider: Some(provider.clone()),\n                                });\n                            }\n\n                            log::info!(\n                                \"[{}] [RECT-010] thinking budget 整流器触发, before={:?}, after={:?}\",\n                                app_type_str,\n                                budget_rectified.before,\n                                budget_rectified.after\n                            );\n\n                            let _ = std::mem::replace(&mut budget_rectifier_retried, true);\n\n                            // 使用同一供应商重试（不计入熔断器）\n                            match self\n                                .forward(\n                                    provider,\n                                    endpoint,\n                                    &provider_body,\n                                    &headers,\n                                    adapter.as_ref(),\n                                )\n                                .await\n                            {\n                                Ok(response) => {\n                                    log::info!(\"[{app_type_str}] [RECT-011] budget 整流重试成功\");\n                                    let _ = self\n                                        .router\n                                        .record_result(\n                                            &provider.id,\n                                            app_type_str,\n                                            used_half_open_permit,\n                                            true,\n                                            None,\n                                        )\n                                        .await;\n\n                                    {\n                                        let mut current_providers =\n                                            self.current_providers.write().await;\n                                        current_providers.insert(\n                                            app_type_str.to_string(),\n                                            (provider.id.clone(), provider.name.clone()),\n                                        );\n                                    }\n\n                                    {\n                                        let mut status = self.status.write().await;\n                                        status.success_requests += 1;\n                                        status.last_error = None;\n                                        let should_switch =\n                                            self.current_provider_id_at_start.as_str()\n                                                != provider.id.as_str();\n                                        if should_switch {\n                                            status.failover_count += 1;\n                                            let fm = self.failover_manager.clone();\n                                            let ah = self.app_handle.clone();\n                                            let pid = provider.id.clone();\n                                            let pname = provider.name.clone();\n                                            let at = app_type_str.to_string();\n                                            tokio::spawn(async move {\n                                                let _ = fm\n                                                    .try_switch(ah.as_ref(), &at, &pid, &pname)\n                                                    .await;\n                                            });\n                                        }\n                                        if status.total_requests > 0 {\n                                            status.success_rate = (status.success_requests as f32\n                                                / status.total_requests as f32)\n                                                * 100.0;\n                                        }\n                                    }\n\n                                    return Ok(ForwardResult {\n                                        response,\n                                        provider: provider.clone(),\n                                    });\n                                }\n                                Err(retry_err) => {\n                                    log::warn!(\n                                        \"[{app_type_str}] [RECT-012] budget 整流重试仍失败: {retry_err}\"\n                                    );\n\n                                    let is_provider_error = match &retry_err {\n                                        ProxyError::Timeout(_) | ProxyError::ForwardFailed(_) => {\n                                            true\n                                        }\n                                        ProxyError::UpstreamError { status, .. } => *status >= 500,\n                                        _ => false,\n                                    };\n\n                                    if is_provider_error {\n                                        let _ = self\n                                            .router\n                                            .record_result(\n                                                &provider.id,\n                                                app_type_str,\n                                                used_half_open_permit,\n                                                false,\n                                                Some(retry_err.to_string()),\n                                            )\n                                            .await;\n                                    } else {\n                                        self.router\n                                            .release_permit_neutral(\n                                                &provider.id,\n                                                app_type_str,\n                                                used_half_open_permit,\n                                            )\n                                            .await;\n                                    }\n\n                                    let mut status = self.status.write().await;\n                                    status.failed_requests += 1;\n                                    status.last_error = Some(retry_err.to_string());\n                                    if status.total_requests > 0 {\n                                        status.success_rate = (status.success_requests as f32\n                                            / status.total_requests as f32)\n                                            * 100.0;\n                                    }\n                                    return Err(ForwardError {\n                                        error: retry_err,\n                                        provider: Some(provider.clone()),\n                                    });\n                                }\n                            }\n                        }\n                    }\n\n                    if signature_rectifier_non_retryable_client_error {\n                        self.router\n                            .release_permit_neutral(\n                                &provider.id,\n                                app_type_str,\n                                used_half_open_permit,\n                            )\n                            .await;\n                        let mut status = self.status.write().await;\n                        status.failed_requests += 1;\n                        status.last_error = Some(e.to_string());\n                        if status.total_requests > 0 {\n                            status.success_rate = (status.success_requests as f32\n                                / status.total_requests as f32)\n                                * 100.0;\n                        }\n                        return Err(ForwardError {\n                            error: e,\n                            provider: Some(provider.clone()),\n                        });\n                    }\n\n                    // 失败：记录失败并更新熔断器\n                    let _ = self\n                        .router\n                        .record_result(\n                            &provider.id,\n                            app_type_str,\n                            used_half_open_permit,\n                            false,\n                            Some(e.to_string()),\n                        )\n                        .await;\n\n                    // 分类错误\n                    let category = self.categorize_proxy_error(&e);\n\n                    match category {\n                        ErrorCategory::Retryable => {\n                            // 可重试：更新错误信息，继续尝试下一个供应商\n                            {\n                                let mut status = self.status.write().await;\n                                status.last_error =\n                                    Some(format!(\"Provider {} 失败: {}\", provider.name, e));\n                            }\n\n                            let (log_code, log_message) = build_retryable_failure_log(\n                                &provider.name,\n                                attempted_providers,\n                                providers.len(),\n                                &e,\n                            );\n                            log::warn!(\"[{app_type_str}] [{log_code}] {log_message}\");\n\n                            last_error = Some(e);\n                            last_provider = Some(provider.clone());\n                            // 继续尝试下一个供应商\n                            continue;\n                        }\n                        ErrorCategory::NonRetryable | ErrorCategory::ClientAbort => {\n                            // 不可重试：直接返回错误\n                            {\n                                let mut status = self.status.write().await;\n                                status.failed_requests += 1;\n                                status.last_error = Some(e.to_string());\n                                if status.total_requests > 0 {\n                                    status.success_rate = (status.success_requests as f32\n                                        / status.total_requests as f32)\n                                        * 100.0;\n                                }\n                            }\n                            return Err(ForwardError {\n                                error: e,\n                                provider: Some(provider.clone()),\n                            });\n                        }\n                    }\n                }\n            }\n        }\n\n        if attempted_providers == 0 {\n            // providers 列表非空，但全部被熔断器拒绝（典型：HalfOpen 探测名额被占用）\n            {\n                let mut status = self.status.write().await;\n                status.failed_requests += 1;\n                status.last_error = Some(\"所有供应商暂时不可用（熔断器限制）\".to_string());\n                if status.total_requests > 0 {\n                    status.success_rate =\n                        (status.success_requests as f32 / status.total_requests as f32) * 100.0;\n                }\n            }\n            return Err(ForwardError {\n                error: ProxyError::NoAvailableProvider,\n                provider: None,\n            });\n        }\n\n        // 所有供应商都失败了\n        {\n            let mut status = self.status.write().await;\n            status.failed_requests += 1;\n            status.last_error = Some(\"所有供应商都失败\".to_string());\n            if status.total_requests > 0 {\n                status.success_rate =\n                    (status.success_requests as f32 / status.total_requests as f32) * 100.0;\n            }\n        }\n\n        if let Some((log_code, log_message)) =\n            build_terminal_failure_log(attempted_providers, providers.len(), last_error.as_ref())\n        {\n            log::warn!(\"[{app_type_str}] [{log_code}] {log_message}\");\n        }\n\n        Err(ForwardError {\n            error: last_error.unwrap_or(ProxyError::MaxRetriesExceeded),\n            provider: last_provider,\n        })\n    }\n\n    /// 转发单个请求（使用适配器）\n    async fn forward(\n        &self,\n        provider: &Provider,\n        endpoint: &str,\n        body: &Value,\n        headers: &axum::http::HeaderMap,\n        adapter: &dyn ProviderAdapter,\n    ) -> Result<Response, ProxyError> {\n        // 使用适配器提取 base_url\n        let base_url = adapter.extract_base_url(provider)?;\n\n        // 检查是否需要格式转换\n        let needs_transform = adapter.needs_transform(provider);\n\n        // 确定有效端点\n        // GitHub Copilot API 使用 /chat/completions（无 /v1 前缀）\n        let is_copilot = provider\n            .meta\n            .as_ref()\n            .and_then(|m| m.provider_type.as_deref())\n            == Some(\"github_copilot\")\n            || base_url.contains(\"githubcopilot.com\");\n        let effective_endpoint =\n            if needs_transform && adapter.name() == \"Claude\" && endpoint == \"/v1/messages\" {\n                if is_copilot {\n                    // GitHub Copilot uses /chat/completions without /v1 prefix\n                    \"/chat/completions\"\n                } else {\n                    // 根据 api_format 选择目标端点\n                    let api_format = super::providers::get_claude_api_format(provider);\n                    if api_format == \"openai_responses\" {\n                        \"/v1/responses\"\n                    } else {\n                        \"/v1/chat/completions\"\n                    }\n                }\n            } else {\n                endpoint\n            };\n\n        // 使用适配器构建 URL\n        let url = adapter.build_url(&base_url, effective_endpoint);\n\n        // 应用模型映射（独立于格式转换）\n        let (mapped_body, _original_model, _mapped_model) =\n            super::model_mapper::apply_model_mapping(body.clone(), provider);\n\n        // 与 CCH 对齐：请求前不做 thinking 主动改写（仅保留兼容入口）\n        let mapped_body = normalize_thinking_type(mapped_body);\n\n        // 转换请求体（如果需要）\n        let request_body = if needs_transform {\n            adapter.transform_request(mapped_body, provider)?\n        } else {\n            mapped_body\n        };\n\n        // 过滤私有参数（以 `_` 开头的字段），防止内部信息泄露到上游\n        // 默认使用空白名单，过滤所有 _ 前缀字段\n        let filtered_body = filter_private_params_with_whitelist(request_body, &[]);\n\n        // 获取 HTTP 客户端：优先使用供应商单独代理配置，否则使用全局客户端\n        let proxy_config = provider.meta.as_ref().and_then(|m| m.proxy_config.as_ref());\n        let client = super::http_client::get_for_provider(proxy_config);\n        let mut request = client.post(&url);\n\n        // 只有当 timeout > 0 时才设置请求超时\n        // Duration::ZERO 在 reqwest 中表示\"立刻超时\"而不是\"禁用超时\"\n        // 故障转移关闭时会传入 0，此时应该使用 client 的默认超时（600秒）\n        if !self.non_streaming_timeout.is_zero() {\n            request = request.timeout(self.non_streaming_timeout);\n        }\n\n        // 过滤黑名单 Headers，保护隐私并避免冲突\n        for (key, value) in headers {\n            if HEADER_BLACKLIST\n                .iter()\n                .any(|h| key.as_str().eq_ignore_ascii_case(h))\n            {\n                continue;\n            }\n            request = request.header(key, value);\n        }\n\n        // 处理 anthropic-beta Header（仅 Claude）\n        // 关键：确保包含 claude-code-20250219 标记，这是上游服务验证请求来源的依据\n        // 如果客户端发送的 beta 标记中没有包含 claude-code-20250219，需要补充\n        if adapter.name() == \"Claude\" {\n            const CLAUDE_CODE_BETA: &str = \"claude-code-20250219\";\n            let beta_value = if let Some(beta) = headers.get(\"anthropic-beta\") {\n                if let Ok(beta_str) = beta.to_str() {\n                    // 检查是否已包含 claude-code-20250219\n                    if beta_str.contains(CLAUDE_CODE_BETA) {\n                        beta_str.to_string()\n                    } else {\n                        // 补充 claude-code-20250219\n                        format!(\"{CLAUDE_CODE_BETA},{beta_str}\")\n                    }\n                } else {\n                    CLAUDE_CODE_BETA.to_string()\n                }\n            } else {\n                // 如果客户端没有发送，使用默认值\n                CLAUDE_CODE_BETA.to_string()\n            };\n            request = request.header(\"anthropic-beta\", &beta_value);\n        }\n\n        // 客户端 IP 透传（默认开启）\n        if let Some(xff) = headers.get(\"x-forwarded-for\") {\n            if let Ok(xff_str) = xff.to_str() {\n                request = request.header(\"x-forwarded-for\", xff_str);\n            }\n        }\n        if let Some(real_ip) = headers.get(\"x-real-ip\") {\n            if let Ok(real_ip_str) = real_ip.to_str() {\n                request = request.header(\"x-real-ip\", real_ip_str);\n            }\n        }\n\n        // 流式请求保守禁用压缩，避免上游压缩 SSE 在连接中断时触发解压错误。\n        // 非流式请求不显式设置 Accept-Encoding，让 reqwest 自动协商压缩并透明解压。\n        if should_force_identity_encoding(effective_endpoint, &filtered_body, headers) {\n            request = request.header(\"accept-encoding\", \"identity\");\n        }\n\n        // 使用适配器添加认证头\n        if let Some(mut auth) = adapter.extract_auth(provider) {\n            // GitHub Copilot 特殊处理：从 CopilotAuthManager 获取真实 token\n            if auth.strategy == AuthStrategy::GitHubCopilot {\n                if let Some(app_handle) = &self.app_handle {\n                    let copilot_state = app_handle.state::<CopilotAuthState>();\n                    let copilot_auth: tokio::sync::RwLockReadGuard<'_, CopilotAuthManager> =\n                        copilot_state.0.read().await;\n\n                    // 从 provider.meta 获取关联的 GitHub 账号 ID（多账号支持）\n                    let account_id = provider\n                        .meta\n                        .as_ref()\n                        .and_then(|m| m.managed_account_id_for(\"github_copilot\"));\n\n                    // 根据账号 ID 获取对应 token（向后兼容：无账号 ID 时使用第一个账号）\n                    let token_result = match &account_id {\n                        Some(id) => {\n                            log::debug!(\"[Copilot] 使用指定账号 {id} 获取 token\");\n                            copilot_auth.get_valid_token_for_account(id).await\n                        }\n                        None => {\n                            log::debug!(\"[Copilot] 使用默认账号获取 token\");\n                            copilot_auth.get_valid_token().await\n                        }\n                    };\n\n                    match token_result {\n                        Ok(token) => {\n                            auth = AuthInfo::new(token, AuthStrategy::GitHubCopilot);\n                            log::debug!(\n                                \"[Copilot] 成功获取 Copilot token (account={})\",\n                                account_id.as_deref().unwrap_or(\"default\")\n                            );\n                        }\n                        Err(e) => {\n                            log::error!(\n                                \"[Copilot] 获取 Copilot token 失败 (account={}): {e}\",\n                                account_id.as_deref().unwrap_or(\"default\")\n                            );\n                            return Err(ProxyError::AuthError(format!(\n                                \"GitHub Copilot 认证失败: {e}\"\n                            )));\n                        }\n                    }\n                } else {\n                    log::error!(\"[Copilot] AppHandle 不可用\");\n                    return Err(ProxyError::AuthError(\n                        \"GitHub Copilot 认证不可用（无 AppHandle）\".to_string(),\n                    ));\n                }\n            }\n            request = adapter.add_auth_headers(request, &auth);\n        }\n\n        // anthropic-version 统一处理（仅 Claude）：优先使用客户端的版本号，否则使用默认值\n        // 注意：只设置一次，避免重复\n        if adapter.name() == \"Claude\" {\n            let version_str = headers\n                .get(\"anthropic-version\")\n                .and_then(|v| v.to_str().ok())\n                .unwrap_or(\"2023-06-01\");\n            request = request.header(\"anthropic-version\", version_str);\n        }\n\n        // 输出请求信息日志\n        let tag = adapter.name();\n        let request_model = filtered_body\n            .get(\"model\")\n            .and_then(|v| v.as_str())\n            .unwrap_or(\"<none>\");\n        log::info!(\"[{tag}] >>> 请求 URL: {url} (model={request_model})\");\n        if let Ok(body_str) = serde_json::to_string(&filtered_body) {\n            log::debug!(\n                \"[{tag}] >>> 请求体内容 ({}字节): {}\",\n                body_str.len(),\n                body_str\n            );\n        }\n\n        // 发送请求\n        let response = request.json(&filtered_body).send().await.map_err(|e| {\n            if e.is_timeout() {\n                ProxyError::Timeout(format!(\"请求超时: {e}\"))\n            } else if e.is_connect() {\n                ProxyError::ForwardFailed(format!(\"连接失败: {e}\"))\n            } else {\n                ProxyError::ForwardFailed(e.to_string())\n            }\n        })?;\n\n        // 检查响应状态\n        let status = response.status();\n\n        if status.is_success() {\n            Ok(response)\n        } else {\n            let status_code = status.as_u16();\n            let body_text = response.text().await.ok();\n\n            Err(ProxyError::UpstreamError {\n                status: status_code,\n                body: body_text,\n            })\n        }\n    }\n\n    fn categorize_proxy_error(&self, error: &ProxyError) -> ErrorCategory {\n        match error {\n            // 网络和上游错误：都应该尝试下一个供应商\n            ProxyError::Timeout(_) => ErrorCategory::Retryable,\n            ProxyError::ForwardFailed(_) => ErrorCategory::Retryable,\n            ProxyError::ProviderUnhealthy(_) => ErrorCategory::Retryable,\n            // 上游 HTTP 错误：无论状态码如何，都尝试下一个供应商\n            // 原因：不同供应商有不同的限制和认证，一个供应商的 4xx 错误\n            // 不代表其他供应商也会失败\n            ProxyError::UpstreamError { .. } => ErrorCategory::Retryable,\n            // Provider 级配置/转换问题：换一个 Provider 可能就能成功\n            ProxyError::ConfigError(_) => ErrorCategory::Retryable,\n            ProxyError::TransformError(_) => ErrorCategory::Retryable,\n            ProxyError::AuthError(_) => ErrorCategory::Retryable,\n            ProxyError::StreamIdleTimeout(_) => ErrorCategory::Retryable,\n            // 无可用供应商：所有供应商都试过了，无法重试\n            ProxyError::NoAvailableProvider => ErrorCategory::NonRetryable,\n            // 其他错误（数据库/内部错误等）：不是换供应商能解决的问题\n            _ => ErrorCategory::NonRetryable,\n        }\n    }\n}\n\n/// 从 ProxyError 中提取错误消息\nfn extract_error_message(error: &ProxyError) -> Option<String> {\n    match error {\n        ProxyError::UpstreamError { body, .. } => body.clone(),\n        _ => Some(error.to_string()),\n    }\n}\n\n/// 检测 Provider 是否为 Bedrock（通过 CLAUDE_CODE_USE_BEDROCK 环境变量判断）\nfn is_bedrock_provider(provider: &Provider) -> bool {\n    provider\n        .settings_config\n        .get(\"env\")\n        .and_then(|e| e.get(\"CLAUDE_CODE_USE_BEDROCK\"))\n        .and_then(|v| v.as_str())\n        .map(|v| v == \"1\")\n        .unwrap_or(false)\n}\n\nfn build_retryable_failure_log(\n    provider_name: &str,\n    attempted_providers: usize,\n    total_providers: usize,\n    error: &ProxyError,\n) -> (&'static str, String) {\n    let error_summary = summarize_proxy_error(error);\n\n    if total_providers <= 1 {\n        (\n            log_fwd::SINGLE_PROVIDER_FAILED,\n            format!(\"Provider {provider_name} 请求失败: {error_summary}\"),\n        )\n    } else {\n        (\n            log_fwd::PROVIDER_FAILED_RETRY,\n            format!(\n                \"Provider {provider_name} 失败，继续尝试下一个 ({attempted_providers}/{total_providers}): {error_summary}\"\n            ),\n        )\n    }\n}\n\nfn build_terminal_failure_log(\n    attempted_providers: usize,\n    total_providers: usize,\n    last_error: Option<&ProxyError>,\n) -> Option<(&'static str, String)> {\n    if total_providers <= 1 {\n        return None;\n    }\n\n    let error_summary = last_error\n        .map(summarize_proxy_error)\n        .unwrap_or_else(|| \"未知错误\".to_string());\n\n    Some((\n        log_fwd::ALL_PROVIDERS_FAILED,\n        format!(\n            \"已尝试 {attempted_providers}/{total_providers} 个 Provider，均失败。最后错误: {error_summary}\"\n        ),\n    ))\n}\n\nfn summarize_proxy_error(error: &ProxyError) -> String {\n    match error {\n        ProxyError::UpstreamError { status, body } => {\n            let body_summary = body\n                .as_deref()\n                .map(summarize_upstream_body)\n                .filter(|summary| !summary.is_empty());\n\n            match body_summary {\n                Some(summary) => format!(\"上游 HTTP {status}: {summary}\"),\n                None => format!(\"上游 HTTP {status}\"),\n            }\n        }\n        ProxyError::Timeout(message) => {\n            format!(\"请求超时: {}\", summarize_text_for_log(message, 180))\n        }\n        ProxyError::ForwardFailed(message) => {\n            format!(\"请求转发失败: {}\", summarize_text_for_log(message, 180))\n        }\n        ProxyError::TransformError(message) => {\n            format!(\"响应转换失败: {}\", summarize_text_for_log(message, 180))\n        }\n        ProxyError::ConfigError(message) => {\n            format!(\"配置错误: {}\", summarize_text_for_log(message, 180))\n        }\n        ProxyError::AuthError(message) => {\n            format!(\"认证失败: {}\", summarize_text_for_log(message, 180))\n        }\n        _ => summarize_text_for_log(&error.to_string(), 180),\n    }\n}\n\nfn summarize_upstream_body(body: &str) -> String {\n    if let Ok(json_body) = serde_json::from_str::<Value>(body) {\n        if let Some(message) = extract_json_error_message(&json_body) {\n            return summarize_text_for_log(&message, 180);\n        }\n\n        if let Ok(compact_json) = serde_json::to_string(&json_body) {\n            return summarize_text_for_log(&compact_json, 180);\n        }\n    }\n\n    summarize_text_for_log(body, 180)\n}\n\nfn extract_json_error_message(body: &Value) -> Option<String> {\n    let candidates = [\n        body.pointer(\"/error/message\"),\n        body.pointer(\"/message\"),\n        body.pointer(\"/detail\"),\n        body.pointer(\"/error\"),\n    ];\n\n    candidates\n        .into_iter()\n        .flatten()\n        .find_map(|value| value.as_str().map(ToString::to_string))\n}\n\nfn should_force_identity_encoding(\n    endpoint: &str,\n    body: &Value,\n    headers: &axum::http::HeaderMap,\n) -> bool {\n    if body\n        .get(\"stream\")\n        .and_then(|value| value.as_bool())\n        .unwrap_or(false)\n    {\n        return true;\n    }\n\n    if endpoint.contains(\"streamGenerateContent\") || endpoint.contains(\"alt=sse\") {\n        return true;\n    }\n\n    headers\n        .get(axum::http::header::ACCEPT)\n        .and_then(|value| value.to_str().ok())\n        .map(|accept| accept.contains(\"text/event-stream\"))\n        .unwrap_or(false)\n}\n\nfn summarize_text_for_log(text: &str, max_chars: usize) -> String {\n    let normalized = text.split_whitespace().collect::<Vec<_>>().join(\" \");\n    let trimmed = normalized.trim();\n\n    if trimmed.chars().count() <= max_chars {\n        return trimmed.to_string();\n    }\n\n    let truncated: String = trimmed.chars().take(max_chars).collect();\n    let truncated = truncated.trim_end();\n    format!(\"{truncated}...\")\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use axum::http::{header::ACCEPT, HeaderMap, HeaderValue};\n    use serde_json::json;\n\n    #[test]\n    fn single_provider_retryable_log_uses_single_provider_code() {\n        let error = ProxyError::UpstreamError {\n            status: 429,\n            body: Some(r#\"{\"error\":{\"message\":\"rate limit exceeded\"}}\"#.to_string()),\n        };\n\n        let (code, message) = build_retryable_failure_log(\"PackyCode-response\", 1, 1, &error);\n\n        assert_eq!(code, log_fwd::SINGLE_PROVIDER_FAILED);\n        assert!(message.contains(\"Provider PackyCode-response 请求失败\"));\n        assert!(message.contains(\"上游 HTTP 429\"));\n        assert!(message.contains(\"rate limit exceeded\"));\n        assert!(!message.contains(\"切换下一个\"));\n    }\n\n    #[test]\n    fn multi_provider_retryable_log_keeps_failover_wording() {\n        let error = ProxyError::Timeout(\"upstream timed out after 30s\".to_string());\n\n        let (code, message) = build_retryable_failure_log(\"primary\", 1, 3, &error);\n\n        assert_eq!(code, log_fwd::PROVIDER_FAILED_RETRY);\n        assert!(message.contains(\"继续尝试下一个 (1/3)\"));\n        assert!(message.contains(\"请求超时\"));\n    }\n\n    #[test]\n    fn single_provider_has_no_terminal_all_failed_log() {\n        assert!(build_terminal_failure_log(1, 1, None).is_none());\n    }\n\n    #[test]\n    fn multi_provider_terminal_log_contains_last_error_summary() {\n        let error = ProxyError::ForwardFailed(\"connection reset by peer\".to_string());\n\n        let (code, message) =\n            build_terminal_failure_log(2, 2, Some(&error)).expect(\"expected terminal log\");\n\n        assert_eq!(code, log_fwd::ALL_PROVIDERS_FAILED);\n        assert!(message.contains(\"已尝试 2/2 个 Provider，均失败\"));\n        assert!(message.contains(\"connection reset by peer\"));\n    }\n\n    #[test]\n    fn summarize_upstream_body_prefers_json_message() {\n        let body = json!({\n            \"error\": {\n                \"message\": \"invalid_request_error: unsupported field\"\n            },\n            \"request_id\": \"req_123\"\n        });\n\n        let summary = summarize_upstream_body(&body.to_string());\n\n        assert_eq!(summary, \"invalid_request_error: unsupported field\");\n    }\n\n    #[test]\n    fn summarize_text_for_log_collapses_whitespace_and_truncates() {\n        let summary = summarize_text_for_log(\"line1\\n\\n line2   line3\", 12);\n\n        assert_eq!(summary, \"line1 line2...\");\n    }\n\n    #[test]\n    fn force_identity_for_stream_flag_requests() {\n        let headers = HeaderMap::new();\n\n        assert!(should_force_identity_encoding(\n            \"/v1/responses\",\n            &json!({ \"stream\": true }),\n            &headers\n        ));\n    }\n\n    #[test]\n    fn force_identity_for_gemini_stream_endpoints() {\n        let headers = HeaderMap::new();\n\n        assert!(should_force_identity_encoding(\n            \"/v1beta/models/gemini-2.5-pro:streamGenerateContent?alt=sse\",\n            &json!({ \"model\": \"gemini-2.5-pro\" }),\n            &headers\n        ));\n    }\n\n    #[test]\n    fn force_identity_for_sse_accept_header() {\n        let mut headers = HeaderMap::new();\n        headers.insert(ACCEPT, HeaderValue::from_static(\"text/event-stream\"));\n\n        assert!(should_force_identity_encoding(\n            \"/v1/responses\",\n            &json!({ \"model\": \"gpt-5\" }),\n            &headers\n        ));\n    }\n\n    #[test]\n    fn non_streaming_requests_allow_automatic_compression() {\n        let headers = HeaderMap::new();\n\n        assert!(!should_force_identity_encoding(\n            \"/v1/responses\",\n            &json!({ \"model\": \"gpt-5\" }),\n            &headers\n        ));\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/handler_config.rs",
    "content": "//! Handler 配置模块\n//!\n//! 定义各 API 处理器的配置结构和使用量解析器\n\nuse crate::app_config::AppType;\nuse crate::proxy::usage::parser::TokenUsage;\nuse serde_json::Value;\n\n/// 使用量解析器类型别名\npub type StreamUsageParser = fn(&[Value]) -> Option<TokenUsage>;\npub type ResponseUsageParser = fn(&Value) -> Option<TokenUsage>;\n\n/// 模型提取器类型别名\n/// 参数: (流式事件列表, 请求中的模型名称) -> 最终使用的模型名称\npub type StreamModelExtractor = fn(&[Value], &str) -> String;\n\n/// 各 API 的使用量解析配置\n#[derive(Clone, Copy)]\npub struct UsageParserConfig {\n    /// 流式响应解析器\n    pub stream_parser: StreamUsageParser,\n    /// 非流式响应解析器\n    pub response_parser: ResponseUsageParser,\n    /// 流式响应中的模型提取器\n    pub model_extractor: StreamModelExtractor,\n    /// 应用类型字符串（用于日志记录）\n    pub app_type_str: &'static str,\n}\n\n// ============================================================================\n// 模型提取器实现\n// ============================================================================\n\n/// Claude 流式响应模型提取（优先使用 usage.model）\nfn claude_model_extractor(events: &[Value], request_model: &str) -> String {\n    // 首先尝试从解析的 usage 中获取模型\n    if let Some(usage) = TokenUsage::from_claude_stream_events(events) {\n        if let Some(model) = usage.model {\n            return model;\n        }\n    }\n    request_model.to_string()\n}\n\n/// OpenAI Chat Completions 流式响应模型提取（优先使用 usage.model）\nfn openai_model_extractor(events: &[Value], request_model: &str) -> String {\n    // 首先尝试从解析的 usage 中获取模型\n    if let Some(usage) = TokenUsage::from_openai_stream_events(events) {\n        if let Some(model) = usage.model {\n            return model;\n        }\n    }\n    // 回退：从事件中直接提取\n    events\n        .iter()\n        .find_map(|e| e.get(\"model\")?.as_str())\n        .unwrap_or(request_model)\n        .to_string()\n}\n\n/// Codex 智能流式响应模型提取（自动检测格式）\nfn codex_auto_model_extractor(events: &[Value], request_model: &str) -> String {\n    // 首先尝试从解析的 usage 中获取模型\n    if let Some(usage) = TokenUsage::from_codex_stream_events_auto(events) {\n        if let Some(model) = usage.model {\n            return model;\n        }\n    }\n    // 回退：从 response.completed 事件中提取\n    events\n        .iter()\n        .find_map(|e| {\n            if e.get(\"type\")?.as_str()? == \"response.completed\" {\n                e.get(\"response\")?.get(\"model\")?.as_str()\n            } else {\n                None\n            }\n        })\n        .or_else(|| {\n            // 再回退：从 OpenAI 格式事件中提取\n            events.iter().find_map(|e| e.get(\"model\")?.as_str())\n        })\n        .unwrap_or(request_model)\n        .to_string()\n}\n\n/// Gemini 流式响应模型提取（优先使用 usage.model）\nfn gemini_model_extractor(events: &[Value], request_model: &str) -> String {\n    // 首先尝试从解析的 usage 中获取模型\n    if let Some(usage) = TokenUsage::from_gemini_stream_chunks(events) {\n        if let Some(model) = usage.model {\n            return model;\n        }\n    }\n    request_model.to_string()\n}\n\n// ============================================================================\n// 预定义配置\n// ============================================================================\n\n/// Claude API 解析配置\npub const CLAUDE_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {\n    stream_parser: TokenUsage::from_claude_stream_events,\n    response_parser: TokenUsage::from_claude_response,\n    model_extractor: claude_model_extractor,\n    app_type_str: \"claude\",\n};\n\n/// OpenAI Chat Completions API 解析配置（用于 Codex /v1/chat/completions）\npub const OPENAI_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {\n    stream_parser: TokenUsage::from_openai_stream_events,\n    response_parser: TokenUsage::from_openai_response,\n    model_extractor: openai_model_extractor,\n    app_type_str: \"codex\",\n};\n\n/// Codex 智能解析配置（自动检测 OpenAI 或 Codex 格式）\npub const CODEX_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {\n    stream_parser: TokenUsage::from_codex_stream_events_auto,\n    response_parser: TokenUsage::from_codex_response_auto,\n    model_extractor: codex_auto_model_extractor,\n    app_type_str: \"codex\",\n};\n\n/// Gemini API 解析配置\npub const GEMINI_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {\n    stream_parser: TokenUsage::from_gemini_stream_chunks,\n    response_parser: TokenUsage::from_gemini_response,\n    model_extractor: gemini_model_extractor,\n    app_type_str: \"gemini\",\n};\n\n// ============================================================================\n// Handler 配置（预留，用于进一步简化）\n// ============================================================================\n\n/// Handler 基础配置\n///\n/// 预留结构，可用于进一步统一各 handler 的配置\n#[allow(dead_code)]\n#[derive(Clone)]\npub struct HandlerConfig {\n    /// 应用类型\n    pub app_type: AppType,\n    /// 日志标签\n    pub tag: &'static str,\n    /// 应用类型字符串\n    pub app_type_str: &'static str,\n    /// 使用量解析配置\n    pub parser_config: &'static UsageParserConfig,\n}\n\n/// Claude Handler 配置\n#[allow(dead_code)]\npub const CLAUDE_HANDLER_CONFIG: HandlerConfig = HandlerConfig {\n    app_type: AppType::Claude,\n    tag: \"Claude\",\n    app_type_str: \"claude\",\n    parser_config: &CLAUDE_PARSER_CONFIG,\n};\n\n/// Codex Chat Completions Handler 配置\n#[allow(dead_code)]\npub const CODEX_CHAT_HANDLER_CONFIG: HandlerConfig = HandlerConfig {\n    app_type: AppType::Codex,\n    tag: \"Codex\",\n    app_type_str: \"codex\",\n    parser_config: &OPENAI_PARSER_CONFIG,\n};\n\n/// Codex Responses Handler 配置\n#[allow(dead_code)]\npub const CODEX_RESPONSES_HANDLER_CONFIG: HandlerConfig = HandlerConfig {\n    app_type: AppType::Codex,\n    tag: \"Codex\",\n    app_type_str: \"codex\",\n    parser_config: &CODEX_PARSER_CONFIG,\n};\n\n/// Gemini Handler 配置\n#[allow(dead_code)]\npub const GEMINI_HANDLER_CONFIG: HandlerConfig = HandlerConfig {\n    app_type: AppType::Gemini,\n    tag: \"Gemini\",\n    app_type_str: \"gemini\",\n    parser_config: &GEMINI_PARSER_CONFIG,\n};\n"
  },
  {
    "path": "src-tauri/src/proxy/handler_context.rs",
    "content": "//! 请求上下文模块\n//!\n//! 提供请求生命周期的上下文管理，封装通用初始化逻辑\n\nuse crate::app_config::AppType;\nuse crate::provider::Provider;\nuse crate::proxy::{\n    extract_session_id,\n    forwarder::RequestForwarder,\n    server::ProxyState,\n    types::{AppProxyConfig, OptimizerConfig, RectifierConfig},\n    ProxyError,\n};\nuse axum::http::HeaderMap;\nuse std::time::Instant;\n\n/// 流式超时配置\n#[derive(Debug, Clone, Copy)]\npub struct StreamingTimeoutConfig {\n    /// 首字节超时（秒），0 表示禁用\n    pub first_byte_timeout: u64,\n    /// 静默期超时（秒），0 表示禁用\n    pub idle_timeout: u64,\n}\n\n/// 请求上下文\n///\n/// 贯穿整个请求生命周期，包含：\n/// - 计时信息\n/// - 应用级代理配置（per-app）\n/// - 选中的 Provider 列表（用于故障转移）\n/// - 请求模型名称\n/// - 日志标签\n/// - Session ID（用于日志关联）\npub struct RequestContext {\n    /// 请求开始时间\n    pub start_time: Instant,\n    /// 应用级代理配置（per-app，包含重试次数和超时配置）\n    pub app_config: AppProxyConfig,\n    /// 选中的 Provider（故障转移链的第一个）\n    pub provider: Provider,\n    /// 完整的 Provider 列表（用于故障转移）\n    providers: Vec<Provider>,\n    /// 请求开始时的\"当前供应商\"（用于判断是否需要同步 UI/托盘）\n    ///\n    /// 这里使用本地 settings 的设备级 current provider。\n    /// 代理模式下如果实际使用的 provider 与此不一致，会触发切换以确保 UI 始终准确。\n    pub current_provider_id: String,\n    /// 请求中的模型名称\n    pub request_model: String,\n    /// 日志标签（如 \"Claude\"、\"Codex\"、\"Gemini\"）\n    pub tag: &'static str,\n    /// 应用类型字符串（如 \"claude\"、\"codex\"、\"gemini\"）\n    pub app_type_str: &'static str,\n    /// 应用类型（预留，目前通过 app_type_str 使用）\n    #[allow(dead_code)]\n    pub app_type: AppType,\n    /// Session ID（从客户端请求提取或新生成）\n    pub session_id: String,\n    /// 整流器配置\n    pub rectifier_config: RectifierConfig,\n    /// 优化器配置\n    pub optimizer_config: OptimizerConfig,\n}\n\nimpl RequestContext {\n    /// 创建请求上下文\n    ///\n    /// # Arguments\n    /// * `state` - 代理服务器状态\n    /// * `body` - 请求体 JSON\n    /// * `headers` - 请求头（用于提取 Session ID）\n    /// * `app_type` - 应用类型\n    /// * `tag` - 日志标签\n    /// * `app_type_str` - 应用类型字符串\n    ///\n    /// # Errors\n    /// 返回 `ProxyError` 如果 Provider 选择失败\n    pub async fn new(\n        state: &ProxyState,\n        body: &serde_json::Value,\n        headers: &HeaderMap,\n        app_type: AppType,\n        tag: &'static str,\n        app_type_str: &'static str,\n    ) -> Result<Self, ProxyError> {\n        let start_time = Instant::now();\n\n        // 从数据库读取应用级代理配置（per-app）\n        let app_config = state\n            .db\n            .get_proxy_config_for_app(app_type_str)\n            .await\n            .map_err(|e| ProxyError::DatabaseError(e.to_string()))?;\n\n        // 从数据库读取整流器配置\n        let rectifier_config = state.db.get_rectifier_config().unwrap_or_default();\n        let optimizer_config = state.db.get_optimizer_config().unwrap_or_default();\n\n        let current_provider_id =\n            crate::settings::get_current_provider(&app_type).unwrap_or_default();\n\n        // 从请求体提取模型名称\n        let request_model = body\n            .get(\"model\")\n            .and_then(|m| m.as_str())\n            .unwrap_or(\"unknown\")\n            .to_string();\n\n        // 提取 Session ID\n        let session_result = extract_session_id(headers, body, app_type_str);\n        let session_id = session_result.session_id.clone();\n\n        log::debug!(\n            \"[{}] Session ID: {} (from {:?}, client_provided: {})\",\n            tag,\n            session_id,\n            session_result.source,\n            session_result.client_provided\n        );\n\n        // 使用共享的 ProviderRouter 选择 Provider（熔断器状态跨请求保持）\n        // 注意：只在这里调用一次，结果传递给 forwarder，避免重复消耗 HalfOpen 名额\n        let providers = state\n            .provider_router\n            .select_providers(app_type_str)\n            .await\n            .map_err(|e| match e {\n                crate::error::AppError::AllProvidersCircuitOpen => {\n                    ProxyError::AllProvidersCircuitOpen\n                }\n                crate::error::AppError::NoProvidersConfigured => ProxyError::NoProvidersConfigured,\n                _ => ProxyError::DatabaseError(e.to_string()),\n            })?;\n\n        let provider = providers\n            .first()\n            .cloned()\n            .ok_or(ProxyError::NoAvailableProvider)?;\n\n        log::debug!(\n            \"[{}] Provider: {}, model: {}, failover chain: {} providers, session: {}\",\n            tag,\n            provider.name,\n            request_model,\n            providers.len(),\n            session_id\n        );\n\n        Ok(Self {\n            start_time,\n            app_config,\n            provider,\n            providers,\n            current_provider_id,\n            request_model,\n            tag,\n            app_type_str,\n            app_type,\n            session_id,\n            rectifier_config,\n            optimizer_config,\n        })\n    }\n\n    /// 从 URI 提取模型名称（Gemini 专用）\n    ///\n    /// Gemini API 的模型名称在 URI 中，格式如：\n    /// `/v1beta/models/gemini-pro:generateContent`\n    pub fn with_model_from_uri(mut self, uri: &axum::http::Uri) -> Self {\n        let endpoint = uri\n            .path_and_query()\n            .map(|pq| pq.as_str())\n            .unwrap_or(uri.path());\n\n        self.request_model = endpoint\n            .split('/')\n            .find(|s| s.starts_with(\"models/\"))\n            .and_then(|s| s.strip_prefix(\"models/\"))\n            .map(|s| s.split(':').next().unwrap_or(s))\n            .unwrap_or(\"unknown\")\n            .to_string();\n\n        self\n    }\n\n    /// 创建 RequestForwarder\n    ///\n    /// 使用共享的 ProviderRouter，确保熔断器状态跨请求保持\n    ///\n    /// 配置生效规则：\n    /// - 故障转移开启：超时配置正常生效（0 表示禁用超时）\n    /// - 故障转移关闭：超时配置不生效（全部传入 0）\n    pub fn create_forwarder(&self, state: &ProxyState) -> RequestForwarder {\n        let (non_streaming_timeout, first_byte_timeout, idle_timeout) =\n            if self.app_config.auto_failover_enabled {\n                // 故障转移开启：使用配置的值（0 = 禁用超时）\n                (\n                    self.app_config.non_streaming_timeout as u64,\n                    self.app_config.streaming_first_byte_timeout as u64,\n                    self.app_config.streaming_idle_timeout as u64,\n                )\n            } else {\n                // 故障转移关闭：不启用超时配置\n                log::debug!(\n                    \"[{}] Failover disabled, timeout configs are bypassed\",\n                    self.tag\n                );\n                (0, 0, 0)\n            };\n\n        RequestForwarder::new(\n            state.provider_router.clone(),\n            non_streaming_timeout,\n            state.status.clone(),\n            state.current_providers.clone(),\n            state.failover_manager.clone(),\n            state.app_handle.clone(),\n            self.current_provider_id.clone(),\n            first_byte_timeout,\n            idle_timeout,\n            self.rectifier_config.clone(),\n            self.optimizer_config.clone(),\n        )\n    }\n\n    /// 获取 Provider 列表（用于故障转移）\n    ///\n    /// 返回在创建上下文时已选择的 providers，避免重复调用 select_providers()\n    pub fn get_providers(&self) -> Vec<Provider> {\n        self.providers.clone()\n    }\n\n    /// 计算请求延迟（毫秒）\n    #[inline]\n    pub fn latency_ms(&self) -> u64 {\n        self.start_time.elapsed().as_millis() as u64\n    }\n\n    /// 获取流式超时配置\n    ///\n    /// 配置生效规则：\n    /// - 故障转移开启：返回配置的值（0 表示禁用超时检查）\n    /// - 故障转移关闭：返回 0（禁用超时检查）\n    #[inline]\n    pub fn streaming_timeout_config(&self) -> StreamingTimeoutConfig {\n        if self.app_config.auto_failover_enabled {\n            // 故障转移开启：使用配置的值（0 = 禁用超时）\n            StreamingTimeoutConfig {\n                first_byte_timeout: self.app_config.streaming_first_byte_timeout as u64,\n                idle_timeout: self.app_config.streaming_idle_timeout as u64,\n            }\n        } else {\n            // 故障转移关闭：禁用流式超时检查\n            StreamingTimeoutConfig {\n                first_byte_timeout: 0,\n                idle_timeout: 0,\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/handlers.rs",
    "content": "//! 请求处理器\n//!\n//! 处理各种API端点的HTTP请求\n//!\n//! 重构后的结构：\n//! - 通用逻辑提取到 `handler_context` 和 `response_processor` 模块\n//! - 各 handler 只保留独特的业务逻辑\n//! - Claude 的格式转换逻辑保留在此文件（用于 OpenRouter 旧接口回退）\n\nuse super::{\n    error_mapper::{get_error_message, map_proxy_error_to_status},\n    handler_config::{\n        CLAUDE_PARSER_CONFIG, CODEX_PARSER_CONFIG, GEMINI_PARSER_CONFIG, OPENAI_PARSER_CONFIG,\n    },\n    handler_context::RequestContext,\n    providers::{\n        get_adapter, get_claude_api_format, streaming::create_anthropic_sse_stream,\n        streaming_responses::create_anthropic_sse_stream_from_responses, transform,\n        transform_responses,\n    },\n    response_processor::{create_logged_passthrough_stream, process_response, SseUsageCollector},\n    server::ProxyState,\n    types::*,\n    usage::parser::TokenUsage,\n    ProxyError,\n};\nuse crate::app_config::AppType;\nuse axum::{extract::State, http::StatusCode, response::IntoResponse, Json};\nuse bytes::Bytes;\nuse serde_json::{json, Value};\n\n// ============================================================================\n// 健康检查和状态查询（简单端点）\n// ============================================================================\n\n/// 健康检查\npub async fn health_check() -> (StatusCode, Json<Value>) {\n    (\n        StatusCode::OK,\n        Json(json!({\n            \"status\": \"healthy\",\n            \"timestamp\": chrono::Utc::now().to_rfc3339(),\n        })),\n    )\n}\n\n/// 获取服务状态\npub async fn get_status(State(state): State<ProxyState>) -> Result<Json<ProxyStatus>, ProxyError> {\n    let status = state.status.read().await.clone();\n    Ok(Json(status))\n}\n\n// ============================================================================\n// Claude API 处理器（包含格式转换逻辑）\n// ============================================================================\n\n/// 处理 /v1/messages 请求（Claude API）\n///\n/// Claude 处理器包含独特的格式转换逻辑：\n/// - 过去用于 OpenRouter 的 OpenAI Chat Completions 兼容接口（Anthropic ↔ OpenAI 转换）\n/// - 现在 OpenRouter 已推出 Claude Code 兼容接口，默认不再启用该转换（逻辑保留以备回退）\npub async fn handle_messages(\n    State(state): State<ProxyState>,\n    headers: axum::http::HeaderMap,\n    Json(body): Json<Value>,\n) -> Result<axum::response::Response, ProxyError> {\n    let mut ctx =\n        RequestContext::new(&state, &body, &headers, AppType::Claude, \"Claude\", \"claude\").await?;\n\n    let is_stream = body\n        .get(\"stream\")\n        .and_then(|s| s.as_bool())\n        .unwrap_or(false);\n\n    // 转发请求\n    let forwarder = ctx.create_forwarder(&state);\n    let result = match forwarder\n        .forward_with_retry(\n            &AppType::Claude,\n            \"/v1/messages\",\n            body.clone(),\n            headers,\n            ctx.get_providers(),\n        )\n        .await\n    {\n        Ok(result) => result,\n        Err(mut err) => {\n            if let Some(provider) = err.provider.take() {\n                ctx.provider = provider;\n            }\n            log_forward_error(&state, &ctx, is_stream, &err.error);\n            return Err(err.error);\n        }\n    };\n\n    ctx.provider = result.provider;\n    let response = result.response;\n\n    // 检查是否需要格式转换（OpenRouter 等中转服务）\n    let adapter = get_adapter(&AppType::Claude);\n    let needs_transform = adapter.needs_transform(&ctx.provider);\n\n    // Claude 特有：格式转换处理\n    if needs_transform {\n        return handle_claude_transform(response, &ctx, &state, &body, is_stream).await;\n    }\n\n    // 通用响应处理（透传模式）\n    process_response(response, &ctx, &state, &CLAUDE_PARSER_CONFIG).await\n}\n\n/// Claude 格式转换处理（独有逻辑）\n///\n/// 支持 OpenAI Chat Completions 和 Responses API 两种格式的转换\nasync fn handle_claude_transform(\n    response: reqwest::Response,\n    ctx: &RequestContext,\n    state: &ProxyState,\n    _original_body: &Value,\n    is_stream: bool,\n) -> Result<axum::response::Response, ProxyError> {\n    let status = response.status();\n    let api_format = get_claude_api_format(&ctx.provider);\n\n    if is_stream {\n        // 根据 api_format 选择流式转换器\n        let stream = response.bytes_stream();\n        let sse_stream: Box<\n            dyn futures::Stream<Item = Result<Bytes, std::io::Error>> + Send + Unpin,\n        > = if api_format == \"openai_responses\" {\n            Box::new(Box::pin(create_anthropic_sse_stream_from_responses(stream)))\n        } else {\n            Box::new(Box::pin(create_anthropic_sse_stream(stream)))\n        };\n\n        // 创建使用量收集器\n        let usage_collector = {\n            let state = state.clone();\n            let provider_id = ctx.provider.id.clone();\n            let model = ctx.request_model.clone();\n            let status_code = status.as_u16();\n            let start_time = ctx.start_time;\n\n            SseUsageCollector::new(start_time, move |events, first_token_ms| {\n                if let Some(usage) = TokenUsage::from_claude_stream_events(&events) {\n                    let latency_ms = start_time.elapsed().as_millis() as u64;\n                    let state = state.clone();\n                    let provider_id = provider_id.clone();\n                    let model = model.clone();\n\n                    tokio::spawn(async move {\n                        log_usage(\n                            &state,\n                            &provider_id,\n                            \"claude\",\n                            &model,\n                            &model,\n                            usage,\n                            latency_ms,\n                            first_token_ms,\n                            true,\n                            status_code,\n                        )\n                        .await;\n                    });\n                } else {\n                    log::debug!(\"[Claude] OpenRouter 流式响应缺少 usage 统计，跳过消费记录\");\n                }\n            })\n        };\n\n        // 获取流式超时配置\n        let timeout_config = ctx.streaming_timeout_config();\n\n        let logged_stream = create_logged_passthrough_stream(\n            sse_stream,\n            \"Claude/OpenRouter\",\n            Some(usage_collector),\n            timeout_config,\n        );\n\n        let mut headers = axum::http::HeaderMap::new();\n        headers.insert(\n            \"Content-Type\",\n            axum::http::HeaderValue::from_static(\"text/event-stream\"),\n        );\n        headers.insert(\n            \"Cache-Control\",\n            axum::http::HeaderValue::from_static(\"no-cache\"),\n        );\n        headers.insert(\n            \"Connection\",\n            axum::http::HeaderValue::from_static(\"keep-alive\"),\n        );\n\n        let body = axum::body::Body::from_stream(logged_stream);\n        return Ok((headers, body).into_response());\n    }\n\n    // 非流式响应转换 (OpenAI/Responses → Anthropic)\n    let response_headers = response.headers().clone();\n\n    let body_bytes = response.bytes().await.map_err(|e| {\n        log::error!(\"[Claude] 读取响应体失败: {e}\");\n        ProxyError::ForwardFailed(format!(\"Failed to read response body: {e}\"))\n    })?;\n\n    let body_str = String::from_utf8_lossy(&body_bytes);\n\n    let upstream_response: Value = serde_json::from_slice(&body_bytes).map_err(|e| {\n        log::error!(\"[Claude] 解析上游响应失败: {e}, body: {body_str}\");\n        ProxyError::TransformError(format!(\"Failed to parse upstream response: {e}\"))\n    })?;\n\n    // 根据 api_format 选择非流式转换器\n    let anthropic_response = if api_format == \"openai_responses\" {\n        transform_responses::responses_to_anthropic(upstream_response)\n    } else {\n        transform::openai_to_anthropic(upstream_response)\n    }\n    .map_err(|e| {\n        log::error!(\"[Claude] 转换响应失败: {e}\");\n        e\n    })?;\n\n    // 记录使用量\n    if let Some(usage) = TokenUsage::from_claude_response(&anthropic_response) {\n        let model = anthropic_response\n            .get(\"model\")\n            .and_then(|m| m.as_str())\n            .unwrap_or(\"unknown\");\n        let latency_ms = ctx.latency_ms();\n\n        let request_model = ctx.request_model.clone();\n        tokio::spawn({\n            let state = state.clone();\n            let provider_id = ctx.provider.id.clone();\n            let model = model.to_string();\n            async move {\n                log_usage(\n                    &state,\n                    &provider_id,\n                    \"claude\",\n                    &model,\n                    &request_model,\n                    usage,\n                    latency_ms,\n                    None,\n                    false,\n                    status.as_u16(),\n                )\n                .await;\n            }\n        });\n    }\n\n    // 构建响应\n    let mut builder = axum::response::Response::builder().status(status);\n\n    for (key, value) in response_headers.iter() {\n        if key.as_str().to_lowercase() != \"content-length\"\n            && key.as_str().to_lowercase() != \"transfer-encoding\"\n        {\n            builder = builder.header(key, value);\n        }\n    }\n\n    builder = builder.header(\"content-type\", \"application/json\");\n\n    let response_body = serde_json::to_vec(&anthropic_response).map_err(|e| {\n        log::error!(\"[Claude] 序列化响应失败: {e}\");\n        ProxyError::TransformError(format!(\"Failed to serialize response: {e}\"))\n    })?;\n\n    let body = axum::body::Body::from(response_body);\n    builder.body(body).map_err(|e| {\n        log::error!(\"[Claude] 构建响应失败: {e}\");\n        ProxyError::Internal(format!(\"Failed to build response: {e}\"))\n    })\n}\n\n// ============================================================================\n// Codex API 处理器\n// ============================================================================\n\n/// 处理 /v1/chat/completions 请求（OpenAI Chat Completions API - Codex CLI）\npub async fn handle_chat_completions(\n    State(state): State<ProxyState>,\n    headers: axum::http::HeaderMap,\n    Json(body): Json<Value>,\n) -> Result<axum::response::Response, ProxyError> {\n    let mut ctx =\n        RequestContext::new(&state, &body, &headers, AppType::Codex, \"Codex\", \"codex\").await?;\n\n    let is_stream = body\n        .get(\"stream\")\n        .and_then(|v| v.as_bool())\n        .unwrap_or(false);\n\n    let forwarder = ctx.create_forwarder(&state);\n    let result = match forwarder\n        .forward_with_retry(\n            &AppType::Codex,\n            \"/chat/completions\",\n            body,\n            headers,\n            ctx.get_providers(),\n        )\n        .await\n    {\n        Ok(result) => result,\n        Err(mut err) => {\n            if let Some(provider) = err.provider.take() {\n                ctx.provider = provider;\n            }\n            log_forward_error(&state, &ctx, is_stream, &err.error);\n            return Err(err.error);\n        }\n    };\n\n    ctx.provider = result.provider;\n    let response = result.response;\n\n    process_response(response, &ctx, &state, &OPENAI_PARSER_CONFIG).await\n}\n\n/// 处理 /v1/responses 请求（OpenAI Responses API - Codex CLI 透传）\npub async fn handle_responses(\n    State(state): State<ProxyState>,\n    headers: axum::http::HeaderMap,\n    Json(body): Json<Value>,\n) -> Result<axum::response::Response, ProxyError> {\n    let mut ctx =\n        RequestContext::new(&state, &body, &headers, AppType::Codex, \"Codex\", \"codex\").await?;\n\n    let is_stream = body\n        .get(\"stream\")\n        .and_then(|v| v.as_bool())\n        .unwrap_or(false);\n\n    let forwarder = ctx.create_forwarder(&state);\n    let result = match forwarder\n        .forward_with_retry(\n            &AppType::Codex,\n            \"/responses\",\n            body,\n            headers,\n            ctx.get_providers(),\n        )\n        .await\n    {\n        Ok(result) => result,\n        Err(mut err) => {\n            if let Some(provider) = err.provider.take() {\n                ctx.provider = provider;\n            }\n            log_forward_error(&state, &ctx, is_stream, &err.error);\n            return Err(err.error);\n        }\n    };\n\n    ctx.provider = result.provider;\n    let response = result.response;\n\n    process_response(response, &ctx, &state, &CODEX_PARSER_CONFIG).await\n}\n\n/// 处理 /v1/responses/compact 请求（OpenAI Responses Compact API - Codex CLI 透传）\npub async fn handle_responses_compact(\n    State(state): State<ProxyState>,\n    headers: axum::http::HeaderMap,\n    Json(body): Json<Value>,\n) -> Result<axum::response::Response, ProxyError> {\n    let mut ctx =\n        RequestContext::new(&state, &body, &headers, AppType::Codex, \"Codex\", \"codex\").await?;\n\n    let is_stream = body\n        .get(\"stream\")\n        .and_then(|v| v.as_bool())\n        .unwrap_or(false);\n\n    let forwarder = ctx.create_forwarder(&state);\n    let result = match forwarder\n        .forward_with_retry(\n            &AppType::Codex,\n            \"/responses/compact\",\n            body,\n            headers,\n            ctx.get_providers(),\n        )\n        .await\n    {\n        Ok(result) => result,\n        Err(mut err) => {\n            if let Some(provider) = err.provider.take() {\n                ctx.provider = provider;\n            }\n            log_forward_error(&state, &ctx, is_stream, &err.error);\n            return Err(err.error);\n        }\n    };\n\n    ctx.provider = result.provider;\n    let response = result.response;\n\n    process_response(response, &ctx, &state, &CODEX_PARSER_CONFIG).await\n}\n\n// ============================================================================\n// Gemini API 处理器\n// ============================================================================\n\n/// 处理 Gemini API 请求（透传，包括查询参数）\npub async fn handle_gemini(\n    State(state): State<ProxyState>,\n    uri: axum::http::Uri,\n    headers: axum::http::HeaderMap,\n    Json(body): Json<Value>,\n) -> Result<axum::response::Response, ProxyError> {\n    // Gemini 的模型名称在 URI 中\n    let mut ctx = RequestContext::new(&state, &body, &headers, AppType::Gemini, \"Gemini\", \"gemini\")\n        .await?\n        .with_model_from_uri(&uri);\n\n    // 提取完整的路径和查询参数\n    let endpoint = uri\n        .path_and_query()\n        .map(|pq| pq.as_str())\n        .unwrap_or(uri.path());\n\n    let is_stream = body\n        .get(\"stream\")\n        .and_then(|v| v.as_bool())\n        .unwrap_or(false);\n\n    let forwarder = ctx.create_forwarder(&state);\n    let result = match forwarder\n        .forward_with_retry(\n            &AppType::Gemini,\n            endpoint,\n            body,\n            headers,\n            ctx.get_providers(),\n        )\n        .await\n    {\n        Ok(result) => result,\n        Err(mut err) => {\n            if let Some(provider) = err.provider.take() {\n                ctx.provider = provider;\n            }\n            log_forward_error(&state, &ctx, is_stream, &err.error);\n            return Err(err.error);\n        }\n    };\n\n    ctx.provider = result.provider;\n    let response = result.response;\n\n    process_response(response, &ctx, &state, &GEMINI_PARSER_CONFIG).await\n}\n\n// ============================================================================\n// 使用量记录（保留用于 Claude 转换逻辑）\n// ============================================================================\n\nfn log_forward_error(\n    state: &ProxyState,\n    ctx: &RequestContext,\n    is_streaming: bool,\n    error: &ProxyError,\n) {\n    use super::usage::logger::UsageLogger;\n\n    let logger = UsageLogger::new(&state.db);\n    let status_code = map_proxy_error_to_status(error);\n    let error_message = get_error_message(error);\n    let request_id = uuid::Uuid::new_v4().to_string();\n\n    if let Err(e) = logger.log_error_with_context(\n        request_id,\n        ctx.provider.id.clone(),\n        ctx.app_type_str.to_string(),\n        ctx.request_model.clone(),\n        status_code,\n        error_message,\n        ctx.latency_ms(),\n        is_streaming,\n        Some(ctx.session_id.clone()),\n        None,\n    ) {\n        log::warn!(\"记录失败请求日志失败: {e}\");\n    }\n}\n\n/// 记录请求使用量\n#[allow(clippy::too_many_arguments)]\nasync fn log_usage(\n    state: &ProxyState,\n    provider_id: &str,\n    app_type: &str,\n    model: &str,\n    request_model: &str,\n    usage: TokenUsage,\n    latency_ms: u64,\n    first_token_ms: Option<u64>,\n    is_streaming: bool,\n    status_code: u16,\n) {\n    use super::usage::logger::UsageLogger;\n\n    let logger = UsageLogger::new(&state.db);\n\n    let (multiplier, pricing_model_source) =\n        logger.resolve_pricing_config(provider_id, app_type).await;\n    let pricing_model = if pricing_model_source == \"request\" {\n        request_model\n    } else {\n        model\n    };\n\n    let request_id = uuid::Uuid::new_v4().to_string();\n\n    if let Err(e) = logger.log_with_calculation(\n        request_id,\n        provider_id.to_string(),\n        app_type.to_string(),\n        model.to_string(),\n        request_model.to_string(),\n        pricing_model.to_string(),\n        usage,\n        multiplier,\n        latency_ms,\n        first_token_ms,\n        status_code,\n        None,\n        None, // provider_type\n        is_streaming,\n    ) {\n        log::warn!(\"[USG-001] 记录使用量失败: {e}\");\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/health.rs",
    "content": "//! 健康检查器\n//!\n//! 负责定期检查Provider健康状态（占位实现）\n\n// 占位实现，稍后添加完整逻辑\n#[allow(dead_code)]\npub struct HealthChecker;\n"
  },
  {
    "path": "src-tauri/src/proxy/http_client.rs",
    "content": "//! 全局 HTTP 客户端模块\n//!\n//! 提供支持全局代理配置的 HTTP 客户端。\n//! 所有需要发送 HTTP 请求的模块都应使用此模块提供的客户端。\n\nuse crate::provider::ProviderProxyConfig;\nuse once_cell::sync::OnceCell;\nuse reqwest::Client;\nuse std::env;\nuse std::net::IpAddr;\nuse std::sync::RwLock;\nuse std::time::Duration;\n\n/// 全局 HTTP 客户端实例\nstatic GLOBAL_CLIENT: OnceCell<RwLock<Client>> = OnceCell::new();\n\n/// 当前代理 URL（用于日志和状态查询）\nstatic CURRENT_PROXY_URL: OnceCell<RwLock<Option<String>>> = OnceCell::new();\n\n/// CC Switch 代理服务器当前监听的端口\nstatic CC_SWITCH_PROXY_PORT: OnceCell<RwLock<u16>> = OnceCell::new();\n\n/// 设置 CC Switch 代理服务器的监听端口\n///\n/// 应在代理服务器启动时调用，以便系统代理检测能正确识别自己的端口\npub fn set_proxy_port(port: u16) {\n    if let Some(lock) = CC_SWITCH_PROXY_PORT.get() {\n        if let Ok(mut current_port) = lock.write() {\n            *current_port = port;\n            log::debug!(\"[GlobalProxy] Updated CC Switch proxy port to {port}\");\n        }\n    } else {\n        let _ = CC_SWITCH_PROXY_PORT.set(RwLock::new(port));\n        log::debug!(\"[GlobalProxy] Initialized CC Switch proxy port to {port}\");\n    }\n}\n\n/// 获取 CC Switch 代理服务器的监听端口\nfn get_proxy_port() -> u16 {\n    CC_SWITCH_PROXY_PORT\n        .get()\n        .and_then(|lock| lock.read().ok())\n        .map(|port| *port)\n        .unwrap_or(15721) // 默认端口作为回退\n}\n\n/// 初始化全局 HTTP 客户端\n///\n/// 应在应用启动时调用一次。\n///\n/// # Arguments\n/// * `proxy_url` - 代理 URL，如 `http://127.0.0.1:7890` 或 `socks5://127.0.0.1:1080`\n///   传入 None 或空字符串表示直连\npub fn init(proxy_url: Option<&str>) -> Result<(), String> {\n    let effective_url = proxy_url.filter(|s| !s.trim().is_empty());\n    let client = build_client(effective_url)?;\n\n    // 尝试初始化全局客户端，如果已存在则记录警告并使用 apply_proxy 更新\n    if GLOBAL_CLIENT.set(RwLock::new(client.clone())).is_err() {\n        log::warn!(\n            \"[GlobalProxy] [GP-003] Already initialized, updating instead: {}\",\n            effective_url\n                .map(mask_url)\n                .unwrap_or_else(|| \"direct connection\".to_string())\n        );\n        // 已初始化，改用 apply_proxy 更新\n        return apply_proxy(proxy_url);\n    }\n\n    // 初始化代理 URL 记录\n    let _ = CURRENT_PROXY_URL.set(RwLock::new(effective_url.map(|s| s.to_string())));\n\n    log::info!(\n        \"[GlobalProxy] Initialized: {}\",\n        effective_url\n            .map(mask_url)\n            .unwrap_or_else(|| \"direct connection\".to_string())\n    );\n\n    Ok(())\n}\n\n/// 验证代理配置（不应用）\n///\n/// 只验证代理 URL 是否有效，不实际更新全局客户端。\n/// 用于在持久化之前验证配置的有效性。\n///\n/// # Arguments\n/// * `proxy_url` - 代理 URL，None 或空字符串表示直连\n///\n/// # Returns\n/// 验证成功返回 Ok(())，失败返回错误信息\npub fn validate_proxy(proxy_url: Option<&str>) -> Result<(), String> {\n    let effective_url = proxy_url.filter(|s| !s.trim().is_empty());\n    // 只调用 build_client 来验证，但不应用\n    build_client(effective_url)?;\n    Ok(())\n}\n\n/// 应用代理配置（假设已验证）\n///\n/// 直接应用代理配置到全局客户端，不做额外验证。\n/// 应在 validate_proxy 成功后调用。\n///\n/// # Arguments\n/// * `proxy_url` - 代理 URL，None 或空字符串表示直连\npub fn apply_proxy(proxy_url: Option<&str>) -> Result<(), String> {\n    let effective_url = proxy_url.filter(|s| !s.trim().is_empty());\n    let new_client = build_client(effective_url)?;\n\n    // 更新客户端\n    if let Some(lock) = GLOBAL_CLIENT.get() {\n        let mut client = lock.write().map_err(|e| {\n            log::error!(\"[GlobalProxy] [GP-001] Failed to acquire write lock: {e}\");\n            \"Failed to update proxy: lock poisoned\".to_string()\n        })?;\n        *client = new_client;\n    } else {\n        // 如果还没初始化，则初始化\n        return init(proxy_url);\n    }\n\n    // 更新代理 URL 记录\n    if let Some(lock) = CURRENT_PROXY_URL.get() {\n        let mut url = lock.write().map_err(|e| {\n            log::error!(\"[GlobalProxy] [GP-002] Failed to acquire URL write lock: {e}\");\n            \"Failed to update proxy URL record: lock poisoned\".to_string()\n        })?;\n        *url = effective_url.map(|s| s.to_string());\n    }\n\n    log::info!(\n        \"[GlobalProxy] Applied: {}\",\n        effective_url\n            .map(mask_url)\n            .unwrap_or_else(|| \"direct connection\".to_string())\n    );\n\n    Ok(())\n}\n\n/// 更新代理配置（热更新）\n///\n/// 可在运行时调用以更改代理设置，无需重启应用。\n/// 注意：此函数同时验证和应用，如果需要先验证后持久化再应用，\n/// 请使用 validate_proxy + apply_proxy 组合。\n///\n/// # Arguments\n/// * `proxy_url` - 新的代理 URL，None 或空字符串表示直连\n#[allow(dead_code)]\npub fn update_proxy(proxy_url: Option<&str>) -> Result<(), String> {\n    let effective_url = proxy_url.filter(|s| !s.trim().is_empty());\n    let new_client = build_client(effective_url)?;\n\n    // 更新客户端\n    if let Some(lock) = GLOBAL_CLIENT.get() {\n        let mut client = lock.write().map_err(|e| {\n            log::error!(\"[GlobalProxy] [GP-001] Failed to acquire write lock: {e}\");\n            \"Failed to update proxy: lock poisoned\".to_string()\n        })?;\n        *client = new_client;\n    } else {\n        // 如果还没初始化，则初始化\n        return init(proxy_url);\n    }\n\n    // 更新代理 URL 记录\n    if let Some(lock) = CURRENT_PROXY_URL.get() {\n        let mut url = lock.write().map_err(|e| {\n            log::error!(\"[GlobalProxy] [GP-002] Failed to acquire URL write lock: {e}\");\n            \"Failed to update proxy URL record: lock poisoned\".to_string()\n        })?;\n        *url = effective_url.map(|s| s.to_string());\n    }\n\n    log::info!(\n        \"[GlobalProxy] Updated: {}\",\n        effective_url\n            .map(mask_url)\n            .unwrap_or_else(|| \"direct connection\".to_string())\n    );\n\n    Ok(())\n}\n\n/// 获取全局 HTTP 客户端\n///\n/// 返回配置了代理的客户端（如果已配置代理），否则返回跟随系统代理的客户端。\npub fn get() -> Client {\n    GLOBAL_CLIENT\n        .get()\n        .and_then(|lock| lock.read().ok())\n        .map(|c| c.clone())\n        .unwrap_or_else(|| {\n            log::warn!(\"[GlobalProxy] [GP-004] Client not initialized, using fallback\");\n            build_client(None).unwrap_or_default()\n        })\n}\n\n/// 获取当前代理 URL\n///\n/// 返回当前配置的代理 URL，None 表示直连。\npub fn get_current_proxy_url() -> Option<String> {\n    CURRENT_PROXY_URL\n        .get()\n        .and_then(|lock| lock.read().ok())\n        .and_then(|url| url.clone())\n}\n\n/// 检查是否正在使用代理\n#[allow(dead_code)]\npub fn is_proxy_enabled() -> bool {\n    get_current_proxy_url().is_some()\n}\n\n/// 构建 HTTP 客户端\nfn build_client(proxy_url: Option<&str>) -> Result<Client, String> {\n    let mut builder = Client::builder()\n        .timeout(Duration::from_secs(600))\n        .connect_timeout(Duration::from_secs(30))\n        .pool_max_idle_per_host(10)\n        .tcp_keepalive(Duration::from_secs(60));\n\n    // 有代理地址则使用代理，否则跟随系统代理\n    if let Some(url) = proxy_url {\n        // 先验证 URL 格式和 scheme\n        let parsed = url::Url::parse(url)\n            .map_err(|e| format!(\"Invalid proxy URL '{}': {}\", mask_url(url), e))?;\n\n        let scheme = parsed.scheme();\n        if ![\"http\", \"https\", \"socks5\", \"socks5h\"].contains(&scheme) {\n            return Err(format!(\n                \"Invalid proxy scheme '{}' in URL '{}'. Supported: http, https, socks5, socks5h\",\n                scheme,\n                mask_url(url)\n            ));\n        }\n\n        let proxy = reqwest::Proxy::all(url)\n            .map_err(|e| format!(\"Invalid proxy URL '{}': {}\", mask_url(url), e))?;\n        builder = builder.proxy(proxy);\n        log::debug!(\"[GlobalProxy] Proxy configured: {}\", mask_url(url));\n    } else {\n        // 未设置全局代理时，让 reqwest 自动检测系统代理（环境变量）\n        // 若系统代理指向本机，禁用系统代理避免自环\n        if system_proxy_points_to_loopback() {\n            builder = builder.no_proxy();\n            log::warn!(\n                \"[GlobalProxy] System proxy points to localhost, bypassing to avoid recursion\"\n            );\n        } else {\n            log::debug!(\"[GlobalProxy] Following system proxy (no explicit proxy configured)\");\n        }\n    }\n\n    builder\n        .build()\n        .map_err(|e| format!(\"Failed to build HTTP client: {e}\"))\n}\n\nfn system_proxy_points_to_loopback() -> bool {\n    const KEYS: [&str; 6] = [\n        \"HTTP_PROXY\",\n        \"http_proxy\",\n        \"HTTPS_PROXY\",\n        \"https_proxy\",\n        \"ALL_PROXY\",\n        \"all_proxy\",\n    ];\n\n    KEYS.iter()\n        .filter_map(|key| env::var(key).ok())\n        .map(|value| value.trim().to_string())\n        .filter(|value| !value.is_empty())\n        .any(|value| proxy_points_to_loopback(&value))\n}\n\nfn proxy_points_to_loopback(value: &str) -> bool {\n    fn host_is_loopback(host: &str) -> bool {\n        if host.eq_ignore_ascii_case(\"localhost\") {\n            return true;\n        }\n        host.parse::<IpAddr>()\n            .map(|ip| ip.is_loopback())\n            .unwrap_or(false)\n    }\n\n    // 检查是否指向 CC Switch 自己的代理端口\n    // 只有指向自己的代理才需要跳过，避免递归\n    fn is_cc_switch_proxy_port(port: Option<u16>) -> bool {\n        let cc_switch_port = get_proxy_port();\n        port == Some(cc_switch_port)\n    }\n\n    if let Ok(parsed) = url::Url::parse(value) {\n        if let Some(host) = parsed.host_str() {\n            // 只有当主机是 loopback 且端口是 CC Switch 的端口时才返回 true\n            return host_is_loopback(host) && is_cc_switch_proxy_port(parsed.port());\n        }\n        return false;\n    }\n\n    let with_scheme = format!(\"http://{value}\");\n    if let Ok(parsed) = url::Url::parse(&with_scheme) {\n        if let Some(host) = parsed.host_str() {\n            return host_is_loopback(host) && is_cc_switch_proxy_port(parsed.port());\n        }\n    }\n\n    false\n}\n\n/// 隐藏 URL 中的敏感信息（用于日志）\npub fn mask_url(url: &str) -> String {\n    if let Ok(parsed) = url::Url::parse(url) {\n        // 隐藏用户名和密码，保留 scheme、host 和端口\n        let host = parsed.host_str().unwrap_or(\"?\");\n        match parsed.port() {\n            Some(port) => format!(\"{}://{}:{}\", parsed.scheme(), host, port),\n            None => format!(\"{}://{}\", parsed.scheme(), host),\n        }\n    } else {\n        // URL 解析失败，返回部分内容\n        if url.len() > 20 {\n            format!(\"{}...\", &url[..20])\n        } else {\n            url.to_string()\n        }\n    }\n}\n\n/// 根据供应商单独代理配置构建代理 URL\n///\n/// 将 ProviderProxyConfig 转换为代理 URL 字符串\nfn build_proxy_url_from_config(config: &ProviderProxyConfig) -> Option<String> {\n    let proxy_type = config.proxy_type.as_deref().unwrap_or(\"http\");\n    let host = config.proxy_host.as_deref()?;\n    let port = config.proxy_port?;\n\n    // 构建带认证的代理 URL\n    if let (Some(username), Some(password)) = (&config.proxy_username, &config.proxy_password) {\n        if !username.is_empty() && !password.is_empty() {\n            return Some(format!(\n                \"{proxy_type}://{username}:{password}@{host}:{port}\"\n            ));\n        }\n    }\n\n    Some(format!(\"{proxy_type}://{host}:{port}\"))\n}\n\n/// 根据供应商单独代理配置构建 HTTP 客户端\n///\n/// 如果供应商配置了单独代理（enabled = true），则使用该代理构建客户端；\n/// 否则返回 None，调用方应使用全局客户端。\n///\n/// # Arguments\n/// * `proxy_config` - 供应商的代理配置\n///\n/// # Returns\n/// 如果配置有效则返回 Some(Client)，否则返回 None\npub fn build_client_for_provider(proxy_config: Option<&ProviderProxyConfig>) -> Option<Client> {\n    let config = proxy_config.filter(|c| c.enabled)?;\n\n    let proxy_url = build_proxy_url_from_config(config)?;\n\n    log::debug!(\n        \"[ProviderProxy] Building client with proxy: {}\",\n        mask_url(&proxy_url)\n    );\n\n    // 构建带代理的客户端\n    let proxy = match reqwest::Proxy::all(&proxy_url) {\n        Ok(p) => p,\n        Err(e) => {\n            log::error!(\n                \"[ProviderProxy] Failed to create proxy from '{}': {}\",\n                mask_url(&proxy_url),\n                e\n            );\n            return None;\n        }\n    };\n\n    match Client::builder()\n        .timeout(Duration::from_secs(600))\n        .connect_timeout(Duration::from_secs(30))\n        .pool_max_idle_per_host(10)\n        .tcp_keepalive(Duration::from_secs(60))\n        .proxy(proxy)\n        .build()\n    {\n        Ok(client) => {\n            log::info!(\n                \"[ProviderProxy] Client built with proxy: {}\",\n                mask_url(&proxy_url)\n            );\n            Some(client)\n        }\n        Err(e) => {\n            log::error!(\"[ProviderProxy] Failed to build client: {e}\");\n            None\n        }\n    }\n}\n\n/// 获取供应商专用的 HTTP 客户端\n///\n/// 优先使用供应商单独代理配置，如果未启用则返回全局客户端。\n///\n/// # Arguments\n/// * `proxy_config` - 供应商的代理配置\n///\n/// # Returns\n/// 返回适合该供应商的 HTTP 客户端\npub fn get_for_provider(proxy_config: Option<&ProviderProxyConfig>) -> Client {\n    // 优先使用供应商单独代理\n    if let Some(client) = build_client_for_provider(proxy_config) {\n        return client;\n    }\n\n    // 回退到全局客户端\n    get()\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use std::sync::{Mutex, OnceLock};\n\n    fn env_lock() -> &'static Mutex<()> {\n        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();\n        LOCK.get_or_init(|| Mutex::new(()))\n    }\n\n    #[test]\n    fn test_mask_url() {\n        assert_eq!(mask_url(\"http://127.0.0.1:7890\"), \"http://127.0.0.1:7890\");\n        assert_eq!(\n            mask_url(\"http://user:pass@127.0.0.1:7890\"),\n            \"http://127.0.0.1:7890\"\n        );\n        assert_eq!(\n            mask_url(\"socks5://admin:secret@proxy.example.com:1080\"),\n            \"socks5://proxy.example.com:1080\"\n        );\n        // 无端口的 URL 不应显示 \":?\"\n        assert_eq!(\n            mask_url(\"http://proxy.example.com\"),\n            \"http://proxy.example.com\"\n        );\n        assert_eq!(\n            mask_url(\"https://user:pass@proxy.example.com\"),\n            \"https://proxy.example.com\"\n        );\n    }\n\n    #[test]\n    fn test_build_client_direct() {\n        let result = build_client(None);\n        assert!(result.is_ok());\n    }\n\n    #[test]\n    fn test_build_client_with_http_proxy() {\n        let result = build_client(Some(\"http://127.0.0.1:7890\"));\n        assert!(result.is_ok());\n    }\n\n    #[test]\n    fn test_build_client_with_socks5_proxy() {\n        let result = build_client(Some(\"socks5://127.0.0.1:1080\"));\n        assert!(result.is_ok());\n    }\n\n    #[test]\n    fn test_build_client_invalid_url() {\n        // reqwest::Proxy::all 对某些无效 URL 不会立即报错\n        // 使用明确无效的 scheme 来触发错误\n        let result = build_client(Some(\"invalid-scheme://127.0.0.1:7890\"));\n        assert!(result.is_err(), \"Should reject invalid proxy scheme\");\n    }\n\n    #[test]\n    fn test_proxy_points_to_loopback() {\n        // 设置 CC Switch 代理端口为 15721（默认值）\n        set_proxy_port(15721);\n\n        // 只有指向 CC Switch 自己端口的 loopback 地址才返回 true\n        assert!(proxy_points_to_loopback(\"http://127.0.0.1:15721\"));\n        assert!(proxy_points_to_loopback(\"socks5://localhost:15721\"));\n        assert!(proxy_points_to_loopback(\"127.0.0.1:15721\"));\n\n        // 其他 loopback 端口不应该被跳过（允许使用其他本地代理工具）\n        assert!(!proxy_points_to_loopback(\"http://127.0.0.1:7890\"));\n        assert!(!proxy_points_to_loopback(\"socks5://localhost:1080\"));\n\n        // 非 loopback 地址不应该被跳过\n        assert!(!proxy_points_to_loopback(\"http://192.168.1.10:7890\"));\n        assert!(!proxy_points_to_loopback(\"http://192.168.1.10:15721\"));\n    }\n\n    #[test]\n    fn test_system_proxy_points_to_loopback() {\n        let _guard = env_lock().lock().unwrap();\n\n        // 设置 CC Switch 代理端口\n        set_proxy_port(15721);\n\n        let keys = [\n            \"HTTP_PROXY\",\n            \"http_proxy\",\n            \"HTTPS_PROXY\",\n            \"https_proxy\",\n            \"ALL_PROXY\",\n            \"all_proxy\",\n        ];\n\n        for key in &keys {\n            std::env::remove_var(key);\n        }\n\n        // 指向 CC Switch 端口的代理应该被跳过\n        std::env::set_var(\"HTTP_PROXY\", \"http://127.0.0.1:15721\");\n        assert!(system_proxy_points_to_loopback());\n\n        // 指向其他端口的本地代理不应该被跳过\n        std::env::set_var(\"HTTP_PROXY\", \"http://127.0.0.1:7890\");\n        assert!(!system_proxy_points_to_loopback());\n\n        // 非 loopback 地址不应该被跳过\n        std::env::set_var(\"HTTP_PROXY\", \"http://10.0.0.2:7890\");\n        assert!(!system_proxy_points_to_loopback());\n\n        for key in &keys {\n            std::env::remove_var(key);\n        }\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/log_codes.rs",
    "content": "//! 代理模块日志错误码定义\n//!\n//! 格式: [模块-编号] 消息\n//! - CB: Circuit Breaker (熔断器)\n//! - SRV: Server (服务器)\n//! - FWD: Forwarder (转发器)\n//! - FO: Failover (故障转移)\n//! - RSP: Response (响应处理)\n//! - USG: Usage (使用量)\n\n#![allow(dead_code)]\n\n/// 熔断器日志码\npub mod cb {\n    pub const OPEN_TO_HALF_OPEN: &str = \"CB-001\";\n    pub const HALF_OPEN_TO_CLOSED: &str = \"CB-002\";\n    pub const HALF_OPEN_PROBE_FAILED: &str = \"CB-003\";\n    pub const TRIGGERED_FAILURES: &str = \"CB-004\";\n    pub const TRIGGERED_ERROR_RATE: &str = \"CB-005\";\n    pub const MANUAL_RESET: &str = \"CB-006\";\n}\n\n/// 服务器日志码\npub mod srv {\n    pub const STARTED: &str = \"SRV-001\";\n    pub const STOPPED: &str = \"SRV-002\";\n    pub const STOP_TIMEOUT: &str = \"SRV-003\";\n    pub const TASK_ERROR: &str = \"SRV-004\";\n}\n\n/// 转发器日志码\npub mod fwd {\n    pub const PROVIDER_FAILED_RETRY: &str = \"FWD-001\";\n    pub const ALL_PROVIDERS_FAILED: &str = \"FWD-002\";\n    pub const SINGLE_PROVIDER_FAILED: &str = \"FWD-003\";\n}\n\n/// 故障转移日志码\npub mod fo {\n    pub const SWITCH_SUCCESS: &str = \"FO-001\";\n    pub const CONFIG_READ_ERROR: &str = \"FO-002\";\n    pub const LIVE_BACKUP_ERROR: &str = \"FO-003\";\n    pub const ALL_CIRCUIT_OPEN: &str = \"FO-004\";\n    pub const NO_PROVIDERS: &str = \"FO-005\";\n}\n\n/// 响应处理日志码\npub mod rsp {\n    pub const BUILD_STREAM_ERROR: &str = \"RSP-001\";\n    pub const READ_BODY_ERROR: &str = \"RSP-002\";\n    pub const BUILD_RESPONSE_ERROR: &str = \"RSP-003\";\n    pub const STREAM_TIMEOUT: &str = \"RSP-004\";\n    pub const STREAM_ERROR: &str = \"RSP-005\";\n}\n\n/// 使用量日志码\npub mod usg {\n    pub const LOG_FAILED: &str = \"USG-001\";\n    pub const PRICING_NOT_FOUND: &str = \"USG-002\";\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/mod.rs",
    "content": "//! 代理服务器模块\n//!\n//! 提供本地HTTP代理服务，支持多Provider故障转移和请求透传\n\npub mod body_filter;\npub mod cache_injector;\npub mod circuit_breaker;\npub mod error;\npub mod error_mapper;\npub(crate) mod failover_switch;\nmod forwarder;\npub mod handler_config;\npub mod handler_context;\nmod handlers;\nmod health;\npub mod http_client;\npub mod log_codes;\npub mod model_mapper;\npub mod provider_router;\npub mod providers;\npub mod response_handler;\npub mod response_processor;\npub(crate) mod server;\npub mod session;\npub mod thinking_budget_rectifier;\npub mod thinking_optimizer;\npub mod thinking_rectifier;\npub(crate) mod types;\npub mod usage;\n\n// 公开导出给外部使用（commands, services等模块需要）\n#[allow(unused_imports)]\npub use circuit_breaker::{\n    CircuitBreaker, CircuitBreakerConfig, CircuitBreakerStats, CircuitState,\n};\n#[allow(unused_imports)]\npub use error::ProxyError;\n#[allow(unused_imports)]\npub use provider_router::ProviderRouter;\n#[allow(unused_imports)]\npub use response_handler::{NonStreamHandler, ResponseType, StreamHandler};\n#[allow(unused_imports)]\npub use session::{\n    extract_session_id, ClientFormat, ProxySession, SessionIdResult, SessionIdSource,\n};\n#[allow(unused_imports)]\npub use types::{ProxyConfig, ProxyServerInfo, ProxyStatus};\n\n// 内部模块间共享（供子模块使用）\n// 注意：这个导出用于模块内部，编译器可能警告未使用但实际被子模块使用\n#[allow(unused_imports)]\npub(crate) use types::*;\n"
  },
  {
    "path": "src-tauri/src/proxy/model_mapper.rs",
    "content": "//! 模型映射模块\n//!\n//! 在请求转发前，根据 Provider 配置替换请求中的模型名称\n\nuse crate::provider::Provider;\nuse serde_json::Value;\n\n/// 模型映射配置\npub struct ModelMapping {\n    pub haiku_model: Option<String>,\n    pub sonnet_model: Option<String>,\n    pub opus_model: Option<String>,\n    pub default_model: Option<String>,\n    pub reasoning_model: Option<String>,\n}\n\nimpl ModelMapping {\n    /// 从 Provider 配置中提取模型映射\n    pub fn from_provider(provider: &Provider) -> Self {\n        let env = provider.settings_config.get(\"env\");\n\n        Self {\n            haiku_model: env\n                .and_then(|e| e.get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL\"))\n                .and_then(|v| v.as_str())\n                .filter(|s| !s.is_empty())\n                .map(String::from),\n            sonnet_model: env\n                .and_then(|e| e.get(\"ANTHROPIC_DEFAULT_SONNET_MODEL\"))\n                .and_then(|v| v.as_str())\n                .filter(|s| !s.is_empty())\n                .map(String::from),\n            opus_model: env\n                .and_then(|e| e.get(\"ANTHROPIC_DEFAULT_OPUS_MODEL\"))\n                .and_then(|v| v.as_str())\n                .filter(|s| !s.is_empty())\n                .map(String::from),\n            default_model: env\n                .and_then(|e| e.get(\"ANTHROPIC_MODEL\"))\n                .and_then(|v| v.as_str())\n                .filter(|s| !s.is_empty())\n                .map(String::from),\n            reasoning_model: env\n                .and_then(|e| e.get(\"ANTHROPIC_REASONING_MODEL\"))\n                .and_then(|v| v.as_str())\n                .filter(|s| !s.is_empty())\n                .map(String::from),\n        }\n    }\n\n    /// 检查是否配置了任何模型映射\n    pub fn has_mapping(&self) -> bool {\n        self.haiku_model.is_some()\n            || self.sonnet_model.is_some()\n            || self.opus_model.is_some()\n            || self.default_model.is_some()\n            || self.reasoning_model.is_some()\n    }\n\n    /// 根据原始模型名称获取映射后的模型\n    pub fn map_model(&self, original_model: &str, has_thinking: bool) -> String {\n        let model_lower = original_model.to_lowercase();\n\n        // 1. thinking 模式优先使用推理模型\n        if has_thinking {\n            if let Some(ref m) = self.reasoning_model {\n                return m.clone();\n            }\n        }\n\n        // 2. 按模型类型匹配\n        if model_lower.contains(\"haiku\") {\n            if let Some(ref m) = self.haiku_model {\n                return m.clone();\n            }\n        }\n        if model_lower.contains(\"opus\") {\n            if let Some(ref m) = self.opus_model {\n                return m.clone();\n            }\n        }\n        if model_lower.contains(\"sonnet\") {\n            if let Some(ref m) = self.sonnet_model {\n                return m.clone();\n            }\n        }\n\n        // 3. 默认模型\n        if let Some(ref m) = self.default_model {\n            return m.clone();\n        }\n\n        // 4. 无映射，保持原样\n        original_model.to_string()\n    }\n}\n\n/// 检测请求是否启用了 thinking 模式\npub fn has_thinking_enabled(body: &Value) -> bool {\n    match body\n        .get(\"thinking\")\n        .and_then(|v| v.as_object())\n        .and_then(|o| o.get(\"type\"))\n        .and_then(|t| t.as_str())\n    {\n        Some(\"enabled\") | Some(\"adaptive\") => true,\n        Some(\"disabled\") | None => false,\n        Some(other) => {\n            log::warn!(\n                \"[ModelMapper] 未知 thinking.type='{other}'，按 disabled 处理以避免误路由 reasoning 模型\"\n            );\n            false\n        }\n    }\n}\n\n/// 对请求体应用模型映射\n///\n/// 返回 (映射后的请求体, 原始模型名, 映射后模型名)\npub fn apply_model_mapping(\n    mut body: Value,\n    provider: &Provider,\n) -> (Value, Option<String>, Option<String>) {\n    let mapping = ModelMapping::from_provider(provider);\n\n    // 如果没有配置映射，直接返回\n    if !mapping.has_mapping() {\n        let original = body.get(\"model\").and_then(|m| m.as_str()).map(String::from);\n        return (body, original, None);\n    }\n\n    // 提取原始模型名\n    let original_model = body.get(\"model\").and_then(|m| m.as_str()).map(String::from);\n\n    if let Some(ref original) = original_model {\n        let has_thinking = has_thinking_enabled(&body);\n        let mapped = mapping.map_model(original, has_thinking);\n\n        if mapped != *original {\n            log::debug!(\"[ModelMapper] 模型映射: {original} → {mapped}\");\n            body[\"model\"] = serde_json::json!(mapped);\n            return (body, Some(original.clone()), Some(mapped));\n        }\n    }\n\n    (body, original_model, None)\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use serde_json::json;\n\n    fn create_provider_with_mapping() -> Provider {\n        Provider {\n            id: \"test\".to_string(),\n            name: \"Test\".to_string(),\n            settings_config: json!({\n                \"env\": {\n                    \"ANTHROPIC_MODEL\": \"default-model\",\n                    \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"haiku-mapped\",\n                    \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"sonnet-mapped\",\n                    \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"opus-mapped\",\n                    \"ANTHROPIC_REASONING_MODEL\": \"reasoning-model\"\n                }\n            }),\n            website_url: None,\n            category: None,\n            created_at: None,\n            sort_index: None,\n            notes: None,\n            meta: None,\n            icon: None,\n            icon_color: None,\n            in_failover_queue: false,\n        }\n    }\n\n    fn create_provider_without_mapping() -> Provider {\n        Provider {\n            id: \"test\".to_string(),\n            name: \"Test\".to_string(),\n            settings_config: json!({}),\n            website_url: None,\n            category: None,\n            created_at: None,\n            sort_index: None,\n            notes: None,\n            meta: None,\n            icon: None,\n            icon_color: None,\n            in_failover_queue: false,\n        }\n    }\n\n    fn create_provider_with_reasoning_only() -> Provider {\n        Provider {\n            id: \"test\".to_string(),\n            name: \"Test\".to_string(),\n            settings_config: json!({\n                \"env\": {\n                    \"ANTHROPIC_REASONING_MODEL\": \"reasoning-only-model\"\n                }\n            }),\n            website_url: None,\n            category: None,\n            created_at: None,\n            sort_index: None,\n            notes: None,\n            meta: None,\n            icon: None,\n            icon_color: None,\n            in_failover_queue: false,\n        }\n    }\n\n    #[test]\n    fn test_sonnet_mapping() {\n        let provider = create_provider_with_mapping();\n        let body = json!({\"model\": \"claude-sonnet-4-5-20250929\"});\n        let (result, original, mapped) = apply_model_mapping(body, &provider);\n        assert_eq!(result[\"model\"], \"sonnet-mapped\");\n        assert_eq!(original, Some(\"claude-sonnet-4-5-20250929\".to_string()));\n        assert_eq!(mapped, Some(\"sonnet-mapped\".to_string()));\n    }\n\n    #[test]\n    fn test_haiku_mapping() {\n        let provider = create_provider_with_mapping();\n        let body = json!({\"model\": \"claude-haiku-4-5\"});\n        let (result, _, mapped) = apply_model_mapping(body, &provider);\n        assert_eq!(result[\"model\"], \"haiku-mapped\");\n        assert_eq!(mapped, Some(\"haiku-mapped\".to_string()));\n    }\n\n    #[test]\n    fn test_opus_mapping() {\n        let provider = create_provider_with_mapping();\n        let body = json!({\"model\": \"claude-opus-4-5\"});\n        let (result, _, mapped) = apply_model_mapping(body, &provider);\n        assert_eq!(result[\"model\"], \"opus-mapped\");\n        assert_eq!(mapped, Some(\"opus-mapped\".to_string()));\n    }\n\n    #[test]\n    fn test_thinking_mode() {\n        let provider = create_provider_with_mapping();\n        let body = json!({\n            \"model\": \"claude-sonnet-4-5\",\n            \"thinking\": {\"type\": \"enabled\"}\n        });\n        let (result, _, mapped) = apply_model_mapping(body, &provider);\n        assert_eq!(result[\"model\"], \"reasoning-model\");\n        assert_eq!(mapped, Some(\"reasoning-model\".to_string()));\n    }\n\n    #[test]\n    fn test_reasoning_only_mapping_in_thinking_mode() {\n        let provider = create_provider_with_reasoning_only();\n        let body = json!({\n            \"model\": \"claude-sonnet-4-5\",\n            \"thinking\": {\"type\": \"enabled\"}\n        });\n        let (result, _, mapped) = apply_model_mapping(body, &provider);\n        assert_eq!(result[\"model\"], \"reasoning-only-model\");\n        assert_eq!(mapped, Some(\"reasoning-only-model\".to_string()));\n    }\n\n    #[test]\n    fn test_reasoning_only_mapping_does_not_affect_non_thinking() {\n        let provider = create_provider_with_reasoning_only();\n        let body = json!({\n            \"model\": \"claude-sonnet-4-5\",\n            \"thinking\": {\"type\": \"disabled\"}\n        });\n        let (result, original, mapped) = apply_model_mapping(body, &provider);\n        assert_eq!(result[\"model\"], \"claude-sonnet-4-5\");\n        assert_eq!(original, Some(\"claude-sonnet-4-5\".to_string()));\n        assert!(mapped.is_none());\n    }\n\n    #[test]\n    fn test_thinking_disabled() {\n        let provider = create_provider_with_mapping();\n        let body = json!({\n            \"model\": \"claude-sonnet-4-5\",\n            \"thinking\": {\"type\": \"disabled\"}\n        });\n        let (result, _, mapped) = apply_model_mapping(body, &provider);\n        assert_eq!(result[\"model\"], \"sonnet-mapped\");\n        assert_eq!(mapped, Some(\"sonnet-mapped\".to_string()));\n    }\n\n    #[test]\n    fn test_unknown_model_uses_default() {\n        let provider = create_provider_with_mapping();\n        let body = json!({\"model\": \"some-unknown-model\"});\n        let (result, _, mapped) = apply_model_mapping(body, &provider);\n        assert_eq!(result[\"model\"], \"default-model\");\n        assert_eq!(mapped, Some(\"default-model\".to_string()));\n    }\n\n    #[test]\n    fn test_no_mapping_configured() {\n        let provider = create_provider_without_mapping();\n        let body = json!({\"model\": \"claude-sonnet-4-5\"});\n        let (result, original, mapped) = apply_model_mapping(body, &provider);\n        assert_eq!(result[\"model\"], \"claude-sonnet-4-5\");\n        assert_eq!(original, Some(\"claude-sonnet-4-5\".to_string()));\n        assert!(mapped.is_none());\n    }\n\n    #[test]\n    fn test_thinking_adaptive() {\n        let provider = create_provider_with_mapping();\n        let body = json!({\n            \"model\": \"claude-sonnet-4-5\",\n            \"thinking\": {\"type\": \"adaptive\"}\n        });\n        let (result, _, mapped) = apply_model_mapping(body, &provider);\n        assert_eq!(result[\"model\"], \"reasoning-model\");\n        assert_eq!(mapped, Some(\"reasoning-model\".to_string()));\n    }\n\n    #[test]\n    fn test_thinking_unknown_type() {\n        let provider = create_provider_with_mapping();\n        let body = json!({\n            \"model\": \"claude-sonnet-4-5\",\n            \"thinking\": {\"type\": \"some_future_type\"}\n        });\n        let (result, _, mapped) = apply_model_mapping(body, &provider);\n        assert_eq!(result[\"model\"], \"sonnet-mapped\");\n        assert_eq!(mapped, Some(\"sonnet-mapped\".to_string()));\n    }\n\n    #[test]\n    fn test_case_insensitive() {\n        let provider = create_provider_with_mapping();\n        let body = json!({\"model\": \"Claude-SONNET-4-5\"});\n        let (result, _, mapped) = apply_model_mapping(body, &provider);\n        assert_eq!(result[\"model\"], \"sonnet-mapped\");\n        assert_eq!(mapped, Some(\"sonnet-mapped\".to_string()));\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/provider_router.rs",
    "content": "//! 供应商路由器模块\n//!\n//! 负责选择和管理代理目标供应商，实现智能故障转移\n\nuse crate::app_config::AppType;\nuse crate::database::Database;\nuse crate::error::AppError;\nuse crate::provider::Provider;\nuse crate::proxy::circuit_breaker::{AllowResult, CircuitBreaker, CircuitBreakerConfig};\nuse std::collections::HashMap;\nuse std::str::FromStr;\nuse std::sync::Arc;\nuse tokio::sync::RwLock;\n\n/// 供应商路由器\npub struct ProviderRouter {\n    /// 数据库连接\n    db: Arc<Database>,\n    /// 熔断器管理器 - key 格式: \"app_type:provider_id\"\n    circuit_breakers: Arc<RwLock<HashMap<String, Arc<CircuitBreaker>>>>,\n}\n\nimpl ProviderRouter {\n    /// 创建新的供应商路由器\n    pub fn new(db: Arc<Database>) -> Self {\n        Self {\n            db,\n            circuit_breakers: Arc::new(RwLock::new(HashMap::new())),\n        }\n    }\n\n    /// 选择可用的供应商（支持故障转移）\n    ///\n    /// 返回按优先级排序的可用供应商列表：\n    /// - 故障转移关闭时：仅返回当前供应商\n    /// - 故障转移开启时：仅使用故障转移队列，按队列顺序依次尝试（P1 → P2 → ...）\n    pub async fn select_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> {\n        let mut result = Vec::new();\n        let mut total_providers = 0usize;\n        let mut circuit_open_count = 0usize;\n\n        // 检查该应用的自动故障转移开关是否开启（从 proxy_config 表读取）\n        let auto_failover_enabled = match self.db.get_proxy_config_for_app(app_type).await {\n            Ok(config) => config.auto_failover_enabled,\n            Err(e) => {\n                log::error!(\"[{app_type}] 读取 proxy_config 失败: {e}，默认禁用故障转移\");\n                false\n            }\n        };\n\n        if auto_failover_enabled {\n            // 故障转移开启：仅按队列顺序依次尝试（P1 → P2 → ...）\n            let all_providers = self.db.get_all_providers(app_type)?;\n\n            // 使用 DAO 返回的排序结果，确保和前端展示一致\n            let ordered_ids: Vec<String> = self\n                .db\n                .get_failover_queue(app_type)?\n                .into_iter()\n                .map(|item| item.provider_id)\n                .collect();\n\n            total_providers = ordered_ids.len();\n\n            for provider_id in ordered_ids {\n                let Some(provider) = all_providers.get(&provider_id).cloned() else {\n                    continue;\n                };\n\n                let circuit_key = format!(\"{app_type}:{}\", provider.id);\n                let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;\n\n                if breaker.is_available().await {\n                    result.push(provider);\n                } else {\n                    circuit_open_count += 1;\n                }\n            }\n        } else {\n            // 故障转移关闭：仅使用当前供应商，跳过熔断器检查\n            let current_id = AppType::from_str(app_type)\n                .ok()\n                .and_then(|app_enum| {\n                    crate::settings::get_effective_current_provider(&self.db, &app_enum)\n                        .ok()\n                        .flatten()\n                })\n                .or_else(|| self.db.get_current_provider(app_type).ok().flatten());\n\n            if let Some(current_id) = current_id {\n                if let Some(current) = self.db.get_provider_by_id(&current_id, app_type)? {\n                    total_providers = 1;\n                    result.push(current);\n                }\n            }\n        }\n\n        if result.is_empty() {\n            if total_providers > 0 && circuit_open_count == total_providers {\n                log::warn!(\"[{app_type}] [FO-004] 所有供应商均已熔断\");\n                return Err(AppError::AllProvidersCircuitOpen);\n            } else {\n                log::warn!(\"[{app_type}] [FO-005] 未配置供应商\");\n                return Err(AppError::NoProvidersConfigured);\n            }\n        }\n\n        Ok(result)\n    }\n\n    /// 请求执行前获取熔断器“放行许可”\n    ///\n    /// - Closed：直接放行\n    /// - Open：超时到达后切到 HalfOpen 并放行一次探测\n    /// - HalfOpen：按限流规则放行探测\n    ///\n    /// 注意：调用方必须在请求结束后通过 `record_result()` 释放 HalfOpen 名额，\n    /// 否则会导致该 Provider 长时间无法进入探测状态。\n    pub async fn allow_provider_request(&self, provider_id: &str, app_type: &str) -> AllowResult {\n        let circuit_key = format!(\"{app_type}:{provider_id}\");\n        let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;\n        breaker.allow_request().await\n    }\n\n    /// 记录供应商请求结果\n    pub async fn record_result(\n        &self,\n        provider_id: &str,\n        app_type: &str,\n        used_half_open_permit: bool,\n        success: bool,\n        error_msg: Option<String>,\n    ) -> Result<(), AppError> {\n        // 1. 按应用独立获取熔断器配置\n        let failure_threshold = match self.db.get_proxy_config_for_app(app_type).await {\n            Ok(app_config) => app_config.circuit_failure_threshold,\n            Err(_) => 5, // 默认值\n        };\n\n        // 2. 更新熔断器状态\n        let circuit_key = format!(\"{app_type}:{provider_id}\");\n        let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;\n\n        if success {\n            breaker.record_success(used_half_open_permit).await;\n        } else {\n            breaker.record_failure(used_half_open_permit).await;\n        }\n\n        // 3. 更新数据库健康状态（使用配置的阈值）\n        self.db\n            .update_provider_health_with_threshold(\n                provider_id,\n                app_type,\n                success,\n                error_msg.clone(),\n                failure_threshold,\n            )\n            .await?;\n\n        Ok(())\n    }\n\n    /// 重置熔断器（手动恢复）\n    pub async fn reset_circuit_breaker(&self, circuit_key: &str) {\n        let breakers = self.circuit_breakers.read().await;\n        if let Some(breaker) = breakers.get(circuit_key) {\n            breaker.reset().await;\n        }\n    }\n\n    /// 重置指定供应商的熔断器\n    pub async fn reset_provider_breaker(&self, provider_id: &str, app_type: &str) {\n        let circuit_key = format!(\"{app_type}:{provider_id}\");\n        self.reset_circuit_breaker(&circuit_key).await;\n    }\n\n    /// 仅释放 HalfOpen permit，不影响健康统计（neutral 接口）\n    ///\n    /// 用于整流器等场景：请求结果不应计入 Provider 健康度，\n    /// 但仍需释放占用的探测名额，避免 HalfOpen 状态卡死\n    pub async fn release_permit_neutral(\n        &self,\n        provider_id: &str,\n        app_type: &str,\n        used_half_open_permit: bool,\n    ) {\n        if !used_half_open_permit {\n            return;\n        }\n        let circuit_key = format!(\"{app_type}:{provider_id}\");\n        let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;\n        breaker.release_half_open_permit();\n    }\n\n    /// 更新所有熔断器的配置（热更新）\n    pub async fn update_all_configs(&self, config: CircuitBreakerConfig) {\n        let breakers = self.circuit_breakers.read().await;\n        for breaker in breakers.values() {\n            breaker.update_config(config.clone()).await;\n        }\n    }\n\n    /// 获取熔断器状态\n    #[allow(dead_code)]\n    pub async fn get_circuit_breaker_stats(\n        &self,\n        provider_id: &str,\n        app_type: &str,\n    ) -> Option<crate::proxy::circuit_breaker::CircuitBreakerStats> {\n        let circuit_key = format!(\"{app_type}:{provider_id}\");\n        let breakers = self.circuit_breakers.read().await;\n\n        if let Some(breaker) = breakers.get(&circuit_key) {\n            Some(breaker.get_stats().await)\n        } else {\n            None\n        }\n    }\n\n    /// 获取或创建熔断器\n    async fn get_or_create_circuit_breaker(&self, key: &str) -> Arc<CircuitBreaker> {\n        // 先尝试读锁获取\n        {\n            let breakers = self.circuit_breakers.read().await;\n            if let Some(breaker) = breakers.get(key) {\n                return breaker.clone();\n            }\n        }\n\n        // 如果不存在，获取写锁创建\n        let mut breakers = self.circuit_breakers.write().await;\n\n        // 双重检查，防止竞争条件\n        if let Some(breaker) = breakers.get(key) {\n            return breaker.clone();\n        }\n\n        // 从 key 中提取 app_type (格式: \"app_type:provider_id\")\n        let app_type = key.split(':').next().unwrap_or(\"claude\");\n\n        // 按应用独立读取熔断器配置\n        let config = match self.db.get_proxy_config_for_app(app_type).await {\n            Ok(app_config) => crate::proxy::circuit_breaker::CircuitBreakerConfig {\n                failure_threshold: app_config.circuit_failure_threshold,\n                success_threshold: app_config.circuit_success_threshold,\n                timeout_seconds: app_config.circuit_timeout_seconds as u64,\n                error_rate_threshold: app_config.circuit_error_rate_threshold,\n                min_requests: app_config.circuit_min_requests,\n            },\n            Err(_) => crate::proxy::circuit_breaker::CircuitBreakerConfig::default(),\n        };\n\n        let breaker = Arc::new(CircuitBreaker::new(config));\n        breakers.insert(key.to_string(), breaker.clone());\n\n        breaker\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::database::Database;\n    use serde_json::json;\n    use serial_test::serial;\n    use std::env;\n    use tempfile::TempDir;\n\n    struct TempHome {\n        #[allow(dead_code)]\n        dir: TempDir,\n        original_home: Option<String>,\n        original_userprofile: Option<String>,\n    }\n\n    impl TempHome {\n        fn new() -> Self {\n            let dir = TempDir::new().expect(\"failed to create temp home\");\n            let original_home = env::var(\"HOME\").ok();\n            let original_userprofile = env::var(\"USERPROFILE\").ok();\n\n            env::set_var(\"HOME\", dir.path());\n            env::set_var(\"USERPROFILE\", dir.path());\n            crate::settings::reload_settings().expect(\"reload settings\");\n\n            Self {\n                dir,\n                original_home,\n                original_userprofile,\n            }\n        }\n    }\n\n    impl Drop for TempHome {\n        fn drop(&mut self) {\n            match &self.original_home {\n                Some(value) => env::set_var(\"HOME\", value),\n                None => env::remove_var(\"HOME\"),\n            }\n\n            match &self.original_userprofile {\n                Some(value) => env::set_var(\"USERPROFILE\", value),\n                None => env::remove_var(\"USERPROFILE\"),\n            }\n        }\n    }\n\n    #[tokio::test]\n    #[serial]\n    async fn test_provider_router_creation() {\n        let _home = TempHome::new();\n        let db = Arc::new(Database::memory().unwrap());\n        let router = ProviderRouter::new(db);\n\n        let breaker = router.get_or_create_circuit_breaker(\"claude:test\").await;\n        assert!(breaker.allow_request().await.allowed);\n    }\n\n    #[tokio::test]\n    #[serial]\n    async fn test_failover_disabled_uses_current_provider() {\n        let _home = TempHome::new();\n        let db = Arc::new(Database::memory().unwrap());\n\n        let provider_a =\n            Provider::with_id(\"a\".to_string(), \"Provider A\".to_string(), json!({}), None);\n        let provider_b =\n            Provider::with_id(\"b\".to_string(), \"Provider B\".to_string(), json!({}), None);\n\n        db.save_provider(\"claude\", &provider_a).unwrap();\n        db.save_provider(\"claude\", &provider_b).unwrap();\n        db.set_current_provider(\"claude\", \"a\").unwrap();\n        db.add_to_failover_queue(\"claude\", \"b\").unwrap();\n\n        let router = ProviderRouter::new(db.clone());\n        let providers = router.select_providers(\"claude\").await.unwrap();\n\n        assert_eq!(providers.len(), 1);\n        assert_eq!(providers[0].id, \"a\");\n    }\n\n    #[tokio::test]\n    #[serial]\n    async fn test_failover_enabled_uses_queue_order_ignoring_current() {\n        let _home = TempHome::new();\n        let db = Arc::new(Database::memory().unwrap());\n\n        // 设置 sort_index 来控制顺序：b=1, a=2\n        let mut provider_a =\n            Provider::with_id(\"a\".to_string(), \"Provider A\".to_string(), json!({}), None);\n        provider_a.sort_index = Some(2);\n        let mut provider_b =\n            Provider::with_id(\"b\".to_string(), \"Provider B\".to_string(), json!({}), None);\n        provider_b.sort_index = Some(1);\n\n        db.save_provider(\"claude\", &provider_a).unwrap();\n        db.save_provider(\"claude\", &provider_b).unwrap();\n        db.set_current_provider(\"claude\", \"a\").unwrap();\n\n        db.add_to_failover_queue(\"claude\", \"b\").unwrap();\n        db.add_to_failover_queue(\"claude\", \"a\").unwrap();\n\n        // 启用自动故障转移（使用新的 proxy_config API）\n        let mut config = db.get_proxy_config_for_app(\"claude\").await.unwrap();\n        config.auto_failover_enabled = true;\n        db.update_proxy_config_for_app(config).await.unwrap();\n\n        let router = ProviderRouter::new(db.clone());\n        let providers = router.select_providers(\"claude\").await.unwrap();\n\n        assert_eq!(providers.len(), 2);\n        // 故障转移开启时：仅按队列顺序选择（忽略当前供应商）\n        assert_eq!(providers[0].id, \"b\");\n        assert_eq!(providers[1].id, \"a\");\n    }\n\n    #[tokio::test]\n    #[serial]\n    async fn test_failover_enabled_uses_queue_only_even_if_current_not_in_queue() {\n        let _home = TempHome::new();\n        let db = Arc::new(Database::memory().unwrap());\n\n        let provider_a =\n            Provider::with_id(\"a\".to_string(), \"Provider A\".to_string(), json!({}), None);\n        let mut provider_b =\n            Provider::with_id(\"b\".to_string(), \"Provider B\".to_string(), json!({}), None);\n        provider_b.sort_index = Some(1);\n\n        db.save_provider(\"claude\", &provider_a).unwrap();\n        db.save_provider(\"claude\", &provider_b).unwrap();\n        db.set_current_provider(\"claude\", \"a\").unwrap();\n\n        // 只把 b 加入故障转移队列（模拟“当前供应商不在队列里”的常见配置）\n        db.add_to_failover_queue(\"claude\", \"b\").unwrap();\n\n        let mut config = db.get_proxy_config_for_app(\"claude\").await.unwrap();\n        config.auto_failover_enabled = true;\n        db.update_proxy_config_for_app(config).await.unwrap();\n\n        let router = ProviderRouter::new(db.clone());\n        let providers = router.select_providers(\"claude\").await.unwrap();\n\n        assert_eq!(providers.len(), 1);\n        assert_eq!(providers[0].id, \"b\");\n    }\n\n    #[tokio::test]\n    #[serial]\n    async fn test_select_providers_does_not_consume_half_open_permit() {\n        let _home = TempHome::new();\n        let db = Arc::new(Database::memory().unwrap());\n\n        db.update_circuit_breaker_config(&CircuitBreakerConfig {\n            failure_threshold: 1,\n            timeout_seconds: 0,\n            ..Default::default()\n        })\n        .await\n        .unwrap();\n\n        let provider_a =\n            Provider::with_id(\"a\".to_string(), \"Provider A\".to_string(), json!({}), None);\n        let provider_b =\n            Provider::with_id(\"b\".to_string(), \"Provider B\".to_string(), json!({}), None);\n\n        db.save_provider(\"claude\", &provider_a).unwrap();\n        db.save_provider(\"claude\", &provider_b).unwrap();\n\n        db.add_to_failover_queue(\"claude\", \"a\").unwrap();\n        db.add_to_failover_queue(\"claude\", \"b\").unwrap();\n\n        // 启用自动故障转移（使用新的 proxy_config API）\n        let mut config = db.get_proxy_config_for_app(\"claude\").await.unwrap();\n        config.auto_failover_enabled = true;\n        db.update_proxy_config_for_app(config).await.unwrap();\n\n        let router = ProviderRouter::new(db.clone());\n\n        router\n            .record_result(\"b\", \"claude\", false, false, Some(\"fail\".to_string()))\n            .await\n            .unwrap();\n\n        let providers = router.select_providers(\"claude\").await.unwrap();\n        assert_eq!(providers.len(), 2);\n\n        assert!(router.allow_provider_request(\"b\", \"claude\").await.allowed);\n    }\n\n    #[tokio::test]\n    #[serial]\n    async fn test_release_permit_neutral_frees_half_open_slot() {\n        let _home = TempHome::new();\n        let db = Arc::new(Database::memory().unwrap());\n\n        // 配置熔断器：1 次失败即熔断，0 秒超时立即进入 HalfOpen\n        db.update_circuit_breaker_config(&CircuitBreakerConfig {\n            failure_threshold: 1,\n            timeout_seconds: 0,\n            ..Default::default()\n        })\n        .await\n        .unwrap();\n\n        let provider_a =\n            Provider::with_id(\"a\".to_string(), \"Provider A\".to_string(), json!({}), None);\n        db.save_provider(\"claude\", &provider_a).unwrap();\n        db.add_to_failover_queue(\"claude\", \"a\").unwrap();\n\n        // 启用自动故障转移\n        let mut config = db.get_proxy_config_for_app(\"claude\").await.unwrap();\n        config.auto_failover_enabled = true;\n        db.update_proxy_config_for_app(config).await.unwrap();\n\n        let router = ProviderRouter::new(db.clone());\n\n        // 触发熔断：1 次失败\n        router\n            .record_result(\"a\", \"claude\", false, false, Some(\"fail\".to_string()))\n            .await\n            .unwrap();\n\n        // 第一次请求：获取 HalfOpen 探测名额\n        let first = router.allow_provider_request(\"a\", \"claude\").await;\n        assert!(first.allowed);\n        assert!(first.used_half_open_permit);\n\n        // 第二次请求应被拒绝（名额已被占用）\n        let second = router.allow_provider_request(\"a\", \"claude\").await;\n        assert!(!second.allowed);\n\n        // 使用 release_permit_neutral 释放名额（不影响健康统计）\n        router\n            .release_permit_neutral(\"a\", \"claude\", first.used_half_open_permit)\n            .await;\n\n        // 第三次请求应被允许（名额已释放）\n        let third = router.allow_provider_request(\"a\", \"claude\").await;\n        assert!(third.allowed);\n        assert!(third.used_half_open_permit);\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/providers/adapter.rs",
    "content": "//! Provider Adapter Trait\n//!\n//! 定义供应商适配器的统一接口，抽象不同上游供应商的处理逻辑。\n\nuse super::auth::AuthInfo;\nuse crate::provider::Provider;\nuse crate::proxy::error::ProxyError;\nuse reqwest::RequestBuilder;\nuse serde_json::Value;\n\n/// 供应商适配器 Trait\n///\n/// 所有供应商适配器都需要实现此 trait，提供统一的接口来处理：\n/// - URL 构建\n/// - 认证信息提取和头部注入\n/// - 请求/响应格式转换（可选）\n///\n/// # 示例\n///\n/// ```ignore\n/// pub struct ClaudeAdapter;\n///\n/// impl ProviderAdapter for ClaudeAdapter {\n///     fn name(&self) -> &'static str { \"Claude\" }\n///     \n///     fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {\n///         // 从 provider 配置中提取 base_url\n///     }\n///     \n///     fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {\n///         // 从 provider 配置中提取认证信息\n///     }\n///     \n///     fn build_url(&self, base_url: &str, endpoint: &str) -> String {\n///         format!(\"{}{}\", base_url.trim_end_matches('/'), endpoint)\n///     }\n///     \n///     fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {\n///         // 添加认证头\n///     }\n/// }\n/// ```\npub trait ProviderAdapter: Send + Sync {\n    /// 适配器名称（用于日志和调试）\n    fn name(&self) -> &'static str;\n\n    /// 从 Provider 配置中提取 base_url\n    ///\n    /// # Arguments\n    /// * `provider` - Provider 配置\n    ///\n    /// # Returns\n    /// * `Ok(String)` - 提取到的 base_url（已去除尾部斜杠）\n    /// * `Err(ProxyError)` - 提取失败\n    fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError>;\n\n    /// 从 Provider 配置中提取认证信息\n    ///\n    /// # Arguments\n    /// * `provider` - Provider 配置\n    ///\n    /// # Returns\n    /// * `Some(AuthInfo)` - 提取到的认证信息\n    /// * `None` - 未找到认证信息\n    fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo>;\n\n    /// 构建请求 URL\n    ///\n    /// # Arguments\n    /// * `base_url` - 基础 URL\n    /// * `endpoint` - 请求端点（如 `/v1/messages`）\n    ///\n    /// # Returns\n    /// 完整的请求 URL\n    fn build_url(&self, base_url: &str, endpoint: &str) -> String;\n\n    /// 添加认证头到请求\n    ///\n    /// # Arguments\n    /// * `request` - reqwest RequestBuilder\n    /// * `auth` - 认证信息\n    ///\n    /// # Returns\n    /// 添加了认证头的 RequestBuilder\n    fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder;\n\n    /// 是否需要格式转换\n    ///\n    /// 默认返回 `false`（透传模式）。\n    /// 仅当供应商需要格式转换时（如 Claude + OpenRouter 旧 OpenAI 兼容接口）才返回 `true`。\n    ///\n    /// # Arguments\n    /// * `provider` - Provider 配置\n    fn needs_transform(&self, _provider: &Provider) -> bool {\n        false\n    }\n\n    /// 转换请求体\n    ///\n    /// 将请求体从一种格式转换为另一种格式（如 Anthropic → OpenAI）。\n    /// 默认实现直接返回原始请求体（透传）。\n    ///\n    /// # Arguments\n    /// * `body` - 原始请求体\n    /// * `provider` - Provider 配置（用于获取模型映射等）\n    ///\n    /// # Returns\n    /// * `Ok(Value)` - 转换后的请求体\n    /// * `Err(ProxyError)` - 转换失败\n    fn transform_request(&self, body: Value, _provider: &Provider) -> Result<Value, ProxyError> {\n        Ok(body)\n    }\n\n    /// 转换响应体\n    ///\n    /// 将响应体从一种格式转换为另一种格式（如 OpenAI → Anthropic）。\n    /// 默认实现直接返回原始响应体（透传）。\n    ///\n    /// # Arguments\n    /// * `body` - 原始响应体\n    ///\n    /// # Returns\n    /// * `Ok(Value)` - 转换后的响应体\n    /// * `Err(ProxyError)` - 转换失败\n    ///\n    /// Note: 响应转换将在 handler 层集成，目前预留接口\n    #[allow(dead_code)]\n    fn transform_response(&self, body: Value) -> Result<Value, ProxyError> {\n        Ok(body)\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/providers/auth.rs",
    "content": "//! Authentication Types\n//!\n//! 定义认证信息和认证策略，支持多种上游供应商的认证方式。\n\n/// 认证信息\n///\n/// 包含 API Key 和对应的认证策略\n#[derive(Debug, Clone)]\npub struct AuthInfo {\n    /// API Key\n    pub api_key: String,\n    /// 认证策略\n    pub strategy: AuthStrategy,\n    /// OAuth access_token（用于 GoogleOAuth 策略）\n    pub access_token: Option<String>,\n}\n\nimpl AuthInfo {\n    /// 创建新的认证信息\n    pub fn new(api_key: String, strategy: AuthStrategy) -> Self {\n        Self {\n            api_key,\n            strategy,\n            access_token: None,\n        }\n    }\n\n    /// 创建带有 access_token 的认证信息（用于 OAuth）\n    pub fn with_access_token(api_key: String, access_token: String) -> Self {\n        Self {\n            api_key,\n            strategy: AuthStrategy::GoogleOAuth,\n            access_token: Some(access_token),\n        }\n    }\n\n    /// 返回遮蔽后的 API Key（用于日志输出）\n    ///\n    /// 显示前4位和后4位，中间用 `...` 代替\n    /// 如果 key 长度不足8位，则返回 `***`\n    #[allow(dead_code)]\n    pub fn masked_key(&self) -> String {\n        if self.api_key.chars().count() > 8 {\n            let prefix: String = self.api_key.chars().take(4).collect();\n            let suffix: String = self\n                .api_key\n                .chars()\n                .rev()\n                .take(4)\n                .collect::<Vec<_>>()\n                .into_iter()\n                .rev()\n                .collect();\n            format!(\"{prefix}...{suffix}\")\n        } else {\n            \"***\".to_string()\n        }\n    }\n\n    /// 返回遮蔽后的 access_token（用于日志输出）\n    #[allow(dead_code)]\n    pub fn masked_access_token(&self) -> Option<String> {\n        self.access_token.as_ref().map(|token| {\n            if token.chars().count() > 8 {\n                let prefix: String = token.chars().take(4).collect();\n                let suffix: String = token\n                    .chars()\n                    .rev()\n                    .take(4)\n                    .collect::<Vec<_>>()\n                    .into_iter()\n                    .rev()\n                    .collect();\n                format!(\"{prefix}...{suffix}\")\n            } else {\n                \"***\".to_string()\n            }\n        })\n    }\n}\n\n/// 认证策略\n///\n/// 不同供应商使用不同的认证方式\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum AuthStrategy {\n    /// Anthropic 认证方式\n    /// - Header: `x-api-key: <api_key>`\n    /// - Header: `anthropic-version: 2023-06-01`\n    Anthropic,\n\n    /// Claude 中转服务认证方式（仅 Bearer，无 x-api-key）\n    ///\n    /// - Header: `Authorization: Bearer <api_key>`\n    ///\n    /// 用于不支持 x-api-key 的中转服务\n    ClaudeAuth,\n\n    /// Bearer Token 认证方式（OpenAI 等）\n    ///\n    /// - Header: `Authorization: Bearer <api_key>`\n    Bearer,\n\n    /// Google API Key 认证方式\n    ///\n    /// - Header: `x-goog-api-key: <api_key>`\n    Google,\n\n    /// Google OAuth 认证方式\n    ///\n    /// - Header: `Authorization: Bearer <access_token>`\n    ///\n    /// 用于 Gemini CLI 等需要 OAuth 的场景\n    GoogleOAuth,\n\n    /// GitHub Copilot 认证方式\n    ///\n    /// - Header: `Authorization: Bearer <copilot_token>`\n    ///\n    /// 使用动态获取的 Copilot Token（通过 GitHub OAuth 设备码流程获取）\n    GitHubCopilot,\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_masked_key_long() {\n        let auth = AuthInfo::new(\"sk-1234567890abcdef\".to_string(), AuthStrategy::Bearer);\n        assert_eq!(auth.masked_key(), \"sk-1...cdef\");\n    }\n\n    #[test]\n    fn test_masked_key_short() {\n        let auth = AuthInfo::new(\"short\".to_string(), AuthStrategy::Bearer);\n        assert_eq!(auth.masked_key(), \"***\");\n    }\n\n    #[test]\n    fn test_masked_key_exactly_8() {\n        let auth = AuthInfo::new(\"12345678\".to_string(), AuthStrategy::Bearer);\n        assert_eq!(auth.masked_key(), \"***\");\n    }\n\n    #[test]\n    fn test_masked_key_9_chars() {\n        let auth = AuthInfo::new(\"123456789\".to_string(), AuthStrategy::Bearer);\n        assert_eq!(auth.masked_key(), \"1234...6789\");\n    }\n\n    #[test]\n    fn test_masked_key_utf8_safe() {\n        let auth = AuthInfo::new(\"测试⚠️1234567890\".to_string(), AuthStrategy::Bearer);\n        let masked = auth.masked_key();\n        assert!(!masked.is_empty());\n    }\n\n    #[test]\n    fn test_auth_strategy_equality() {\n        assert_eq!(AuthStrategy::Anthropic, AuthStrategy::Anthropic);\n        assert_ne!(AuthStrategy::Anthropic, AuthStrategy::Bearer);\n        assert_ne!(AuthStrategy::Bearer, AuthStrategy::Google);\n    }\n\n    #[test]\n    fn test_auth_info_new_has_no_access_token() {\n        let auth = AuthInfo::new(\"api-key\".to_string(), AuthStrategy::Bearer);\n        assert!(auth.access_token.is_none());\n    }\n\n    #[test]\n    fn test_auth_info_with_access_token() {\n        let auth = AuthInfo::with_access_token(\n            \"refresh-token\".to_string(),\n            \"ya29.access-token-12345\".to_string(),\n        );\n        assert_eq!(auth.api_key, \"refresh-token\");\n        assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);\n        assert_eq!(\n            auth.access_token,\n            Some(\"ya29.access-token-12345\".to_string())\n        );\n    }\n\n    #[test]\n    fn test_masked_access_token_long() {\n        let auth =\n            AuthInfo::with_access_token(\"refresh\".to_string(), \"ya29.1234567890abcdef\".to_string());\n        assert_eq!(auth.masked_access_token(), Some(\"ya29...cdef\".to_string()));\n    }\n\n    #[test]\n    fn test_masked_access_token_utf8_safe() {\n        let auth =\n            AuthInfo::with_access_token(\"refresh\".to_string(), \"令牌⚠️1234567890\".to_string());\n        let masked = auth.masked_access_token().unwrap();\n        assert!(!masked.is_empty());\n    }\n\n    #[test]\n    fn test_masked_access_token_short() {\n        let auth = AuthInfo::with_access_token(\"refresh\".to_string(), \"short\".to_string());\n        assert_eq!(auth.masked_access_token(), Some(\"***\".to_string()));\n    }\n\n    #[test]\n    fn test_masked_access_token_none() {\n        let auth = AuthInfo::new(\"api-key\".to_string(), AuthStrategy::Bearer);\n        assert!(auth.masked_access_token().is_none());\n    }\n\n    #[test]\n    fn test_claude_auth_strategy() {\n        let auth = AuthInfo::new(\"sk-test\".to_string(), AuthStrategy::ClaudeAuth);\n        assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth);\n        assert_ne!(auth.strategy, AuthStrategy::Anthropic);\n        assert_ne!(auth.strategy, AuthStrategy::Bearer);\n    }\n\n    #[test]\n    fn test_google_oauth_strategy() {\n        let auth = AuthInfo::new(\"refresh-token\".to_string(), AuthStrategy::GoogleOAuth);\n        assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);\n        assert_ne!(auth.strategy, AuthStrategy::Google);\n    }\n\n    #[test]\n    fn test_all_strategies_are_distinct() {\n        let strategies = [\n            AuthStrategy::Anthropic,\n            AuthStrategy::ClaudeAuth,\n            AuthStrategy::Bearer,\n            AuthStrategy::Google,\n            AuthStrategy::GoogleOAuth,\n            AuthStrategy::GitHubCopilot,\n        ];\n\n        for (i, s1) in strategies.iter().enumerate() {\n            for (j, s2) in strategies.iter().enumerate() {\n                if i == j {\n                    assert_eq!(s1, s2);\n                } else {\n                    assert_ne!(s1, s2);\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/providers/claude.rs",
    "content": "//! Claude (Anthropic) Provider Adapter\n//!\n//! 支持透传模式和 OpenAI 格式转换模式\n//!\n//! ## API 格式\n//! - **anthropic** (默认): Anthropic Messages API 格式，直接透传\n//! - **openai_chat**: OpenAI Chat Completions 格式，需要 Anthropic ↔ OpenAI 转换\n//! - **openai_responses**: OpenAI Responses API 格式，需要 Anthropic ↔ Responses 转换\n//!\n//! ## 认证模式\n//! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version)\n//! - **ClaudeAuth**: 中转服务 (仅 Bearer 认证，无 x-api-key)\n//! - **OpenRouter**: 已支持 Claude Code 兼容接口，默认透传\n//! - **GitHubCopilot**: GitHub Copilot (OAuth + Copilot Token)\n\nuse super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};\nuse crate::provider::Provider;\nuse crate::proxy::error::ProxyError;\nuse reqwest::RequestBuilder;\n\n/// 获取 Claude 供应商的 API 格式\n///\n/// 供 handler/forwarder 外部使用的公开函数。\n/// 优先级：meta.apiFormat > settings_config.api_format > openrouter_compat_mode > 默认 \"anthropic\"\npub fn get_claude_api_format(provider: &Provider) -> &'static str {\n    // 1) Preferred: meta.apiFormat (SSOT, never written to Claude Code config)\n    if let Some(meta) = provider.meta.as_ref() {\n        if let Some(api_format) = meta.api_format.as_deref() {\n            return match api_format {\n                \"openai_chat\" => \"openai_chat\",\n                \"openai_responses\" => \"openai_responses\",\n                _ => \"anthropic\",\n            };\n        }\n    }\n\n    // 2) Backward compatibility: legacy settings_config.api_format\n    if let Some(api_format) = provider\n        .settings_config\n        .get(\"api_format\")\n        .and_then(|v| v.as_str())\n    {\n        return match api_format {\n            \"openai_chat\" => \"openai_chat\",\n            \"openai_responses\" => \"openai_responses\",\n            _ => \"anthropic\",\n        };\n    }\n\n    // 3) Backward compatibility: legacy openrouter_compat_mode (bool/number/string)\n    let raw = provider.settings_config.get(\"openrouter_compat_mode\");\n    let enabled = match raw {\n        Some(serde_json::Value::Bool(v)) => *v,\n        Some(serde_json::Value::Number(num)) => num.as_i64().unwrap_or(0) != 0,\n        Some(serde_json::Value::String(value)) => {\n            let normalized = value.trim().to_lowercase();\n            normalized == \"true\" || normalized == \"1\"\n        }\n        _ => false,\n    };\n\n    if enabled {\n        \"openai_chat\"\n    } else {\n        \"anthropic\"\n    }\n}\n\n/// Claude 适配器\npub struct ClaudeAdapter;\n\nimpl ClaudeAdapter {\n    pub fn new() -> Self {\n        Self\n    }\n\n    /// 获取供应商类型\n    ///\n    /// 根据 base_url 和 auth_mode 检测具体的供应商类型：\n    /// - GitHubCopilot: meta.provider_type 为 github_copilot 或 base_url 包含 githubcopilot.com\n    /// - OpenRouter: base_url 包含 openrouter.ai\n    /// - ClaudeAuth: auth_mode 为 bearer_only\n    /// - Claude: 默认 Anthropic 官方\n    pub fn provider_type(&self, provider: &Provider) -> ProviderType {\n        // 检测 GitHub Copilot\n        if self.is_github_copilot(provider) {\n            return ProviderType::GitHubCopilot;\n        }\n\n        // 检测 OpenRouter\n        if self.is_openrouter(provider) {\n            return ProviderType::OpenRouter;\n        }\n\n        // 检测 ClaudeAuth (仅 Bearer 认证)\n        if self.is_bearer_only_mode(provider) {\n            return ProviderType::ClaudeAuth;\n        }\n\n        ProviderType::Claude\n    }\n\n    /// 检测是否为 GitHub Copilot 供应商\n    fn is_github_copilot(&self, provider: &Provider) -> bool {\n        // 方式1: 检查 meta.provider_type\n        if let Some(meta) = provider.meta.as_ref() {\n            if meta.provider_type.as_deref() == Some(\"github_copilot\") {\n                return true;\n            }\n        }\n\n        // 方式2: 检查 base_url（兼容旧数据的 fallback，后续应优先依赖 providerType）\n        if let Ok(base_url) = self.extract_base_url(provider) {\n            if base_url.contains(\"githubcopilot.com\") {\n                return true;\n            }\n        }\n\n        false\n    }\n\n    /// 检测是否使用 OpenRouter\n    fn is_openrouter(&self, provider: &Provider) -> bool {\n        if let Ok(base_url) = self.extract_base_url(provider) {\n            return base_url.contains(\"openrouter.ai\");\n        }\n        false\n    }\n\n    /// 获取 API 格式\n    ///\n    /// 从 provider.meta.api_format 读取格式设置：\n    /// - \"anthropic\" (默认): Anthropic Messages API 格式，直接透传\n    /// - \"openai_chat\": OpenAI Chat Completions 格式，需要格式转换\n    /// - \"openai_responses\": OpenAI Responses API 格式，需要格式转换\n    fn get_api_format(&self, provider: &Provider) -> &'static str {\n        get_claude_api_format(provider)\n    }\n\n    /// 检测是否为仅 Bearer 认证模式\n    fn is_bearer_only_mode(&self, provider: &Provider) -> bool {\n        // 检查 settings_config 中的 auth_mode\n        if let Some(auth_mode) = provider\n            .settings_config\n            .get(\"auth_mode\")\n            .and_then(|v| v.as_str())\n        {\n            if auth_mode == \"bearer_only\" {\n                return true;\n            }\n        }\n\n        // 检查 env 中的 AUTH_MODE\n        if let Some(env) = provider.settings_config.get(\"env\") {\n            if let Some(auth_mode) = env.get(\"AUTH_MODE\").and_then(|v| v.as_str()) {\n                if auth_mode == \"bearer_only\" {\n                    return true;\n                }\n            }\n        }\n\n        false\n    }\n\n    /// 从 Provider 配置中提取 API Key\n    fn extract_key(&self, provider: &Provider) -> Option<String> {\n        if let Some(env) = provider.settings_config.get(\"env\") {\n            // Anthropic 标准 key\n            if let Some(key) = env\n                .get(\"ANTHROPIC_AUTH_TOKEN\")\n                .and_then(|v| v.as_str())\n                .filter(|s| !s.is_empty())\n            {\n                log::debug!(\"[Claude] 使用 ANTHROPIC_AUTH_TOKEN\");\n                return Some(key.to_string());\n            }\n            if let Some(key) = env\n                .get(\"ANTHROPIC_API_KEY\")\n                .and_then(|v| v.as_str())\n                .filter(|s| !s.is_empty())\n            {\n                log::debug!(\"[Claude] 使用 ANTHROPIC_API_KEY\");\n                return Some(key.to_string());\n            }\n            // OpenRouter key\n            if let Some(key) = env\n                .get(\"OPENROUTER_API_KEY\")\n                .and_then(|v| v.as_str())\n                .filter(|s| !s.is_empty())\n            {\n                log::debug!(\"[Claude] 使用 OPENROUTER_API_KEY\");\n                return Some(key.to_string());\n            }\n            // 备选 OpenAI key (用于 OpenRouter)\n            if let Some(key) = env\n                .get(\"OPENAI_API_KEY\")\n                .and_then(|v| v.as_str())\n                .filter(|s| !s.is_empty())\n            {\n                log::debug!(\"[Claude] 使用 OPENAI_API_KEY\");\n                return Some(key.to_string());\n            }\n        }\n\n        // 尝试直接获取\n        if let Some(key) = provider\n            .settings_config\n            .get(\"apiKey\")\n            .or_else(|| provider.settings_config.get(\"api_key\"))\n            .and_then(|v| v.as_str())\n            .filter(|s| !s.is_empty())\n        {\n            log::debug!(\"[Claude] 使用 apiKey/api_key\");\n            return Some(key.to_string());\n        }\n\n        log::warn!(\"[Claude] 未找到有效的 API Key\");\n        None\n    }\n}\n\nimpl Default for ClaudeAdapter {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n\nimpl ProviderAdapter for ClaudeAdapter {\n    fn name(&self) -> &'static str {\n        \"Claude\"\n    }\n\n    fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {\n        // 1. 从 env 中获取\n        if let Some(env) = provider.settings_config.get(\"env\") {\n            if let Some(url) = env.get(\"ANTHROPIC_BASE_URL\").and_then(|v| v.as_str()) {\n                return Ok(url.trim_end_matches('/').to_string());\n            }\n        }\n\n        // 2. 尝试直接获取\n        if let Some(url) = provider\n            .settings_config\n            .get(\"base_url\")\n            .and_then(|v| v.as_str())\n        {\n            return Ok(url.trim_end_matches('/').to_string());\n        }\n\n        if let Some(url) = provider\n            .settings_config\n            .get(\"baseURL\")\n            .and_then(|v| v.as_str())\n        {\n            return Ok(url.trim_end_matches('/').to_string());\n        }\n\n        if let Some(url) = provider\n            .settings_config\n            .get(\"apiEndpoint\")\n            .and_then(|v| v.as_str())\n        {\n            return Ok(url.trim_end_matches('/').to_string());\n        }\n\n        Err(ProxyError::ConfigError(\n            \"Claude Provider 缺少 base_url 配置\".to_string(),\n        ))\n    }\n\n    fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {\n        let provider_type = self.provider_type(provider);\n\n        // GitHub Copilot 使用特殊的认证策略\n        // 实际的 token 会在代理请求时动态获取\n        if provider_type == ProviderType::GitHubCopilot {\n            // 返回一个占位符，实际 token 由 CopilotAuthManager 动态提供\n            return Some(AuthInfo::new(\n                \"copilot_placeholder\".to_string(),\n                AuthStrategy::GitHubCopilot,\n            ));\n        }\n\n        let strategy = match provider_type {\n            ProviderType::OpenRouter => AuthStrategy::Bearer,\n            ProviderType::ClaudeAuth => AuthStrategy::ClaudeAuth,\n            _ => AuthStrategy::Anthropic,\n        };\n\n        self.extract_key(provider)\n            .map(|key| AuthInfo::new(key, strategy))\n    }\n\n    fn build_url(&self, base_url: &str, endpoint: &str) -> String {\n        // NOTE:\n        // 过去 OpenRouter 只有 OpenAI Chat Completions 兼容接口，需要把 Claude 的 `/v1/messages`\n        // 映射到 `/v1/chat/completions`，并做 Anthropic ↔ OpenAI 的格式转换。\n        //\n        // 现在 OpenRouter 已推出 Claude Code 兼容接口，因此默认直接透传 endpoint。\n        // 如需回退旧逻辑，可在 forwarder 中根据 needs_transform 改写 endpoint。\n\n        let mut base = format!(\n            \"{}/{}\",\n            base_url.trim_end_matches('/'),\n            endpoint.trim_start_matches('/')\n        );\n\n        // 去除重复的 /v1/v1（可能由 base_url 与 endpoint 都带版本导致）\n        while base.contains(\"/v1/v1\") {\n            base = base.replace(\"/v1/v1\", \"/v1\");\n        }\n\n        // GitHub Copilot 不需要 ?beta=true 参数\n        if base_url.contains(\"githubcopilot.com\") {\n            return base;\n        }\n\n        // 为 Claude 原生 /v1/messages 端点添加 ?beta=true 参数\n        // 这是某些上游服务（如 DuckCoding）验证请求来源的关键参数\n        // 注意：不要为 OpenAI Chat Completions (/v1/chat/completions) 添加此参数\n        //       当 apiFormat=\"openai_chat\" 时，请求会转发到 /v1/chat/completions，\n        //       但该端点是 OpenAI 标准，不支持 ?beta=true 参数\n        if endpoint.contains(\"/v1/messages\")\n            && !endpoint.contains(\"/v1/chat/completions\")\n            && !endpoint.contains('?')\n        {\n            format!(\"{base}?beta=true\")\n        } else {\n            base\n        }\n    }\n\n    fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {\n        // 注意：anthropic-version 由 forwarder.rs 统一处理（透传客户端值或设置默认值）\n        // 这里不再设置 anthropic-version，避免 header 重复\n        match auth.strategy {\n            // Anthropic 官方: Authorization Bearer + x-api-key\n            AuthStrategy::Anthropic => request\n                .header(\"Authorization\", format!(\"Bearer {}\", auth.api_key))\n                .header(\"x-api-key\", &auth.api_key),\n            // ClaudeAuth 中转服务: 仅 Bearer，无 x-api-key\n            AuthStrategy::ClaudeAuth => {\n                request.header(\"Authorization\", format!(\"Bearer {}\", auth.api_key))\n            }\n            // OpenRouter: Bearer\n            AuthStrategy::Bearer => {\n                request.header(\"Authorization\", format!(\"Bearer {}\", auth.api_key))\n            }\n            // GitHub Copilot: Bearer + 特定的 Editor headers\n            AuthStrategy::GitHubCopilot => request\n                .header(\"Authorization\", format!(\"Bearer {}\", auth.api_key))\n                .header(\"Editor-Version\", \"vscode/1.85.0\")\n                .header(\"Editor-Plugin-Version\", \"copilot/1.150.0\")\n                .header(\"Copilot-Integration-Id\", \"vscode-chat\"),\n            _ => request,\n        }\n    }\n\n    fn needs_transform(&self, provider: &Provider) -> bool {\n        // GitHub Copilot 总是需要格式转换 (Anthropic → OpenAI)\n        if self.is_github_copilot(provider) {\n            return true;\n        }\n\n        // 根据 api_format 配置决定是否需要格式转换\n        // - \"anthropic\" (默认): 直接透传，无需转换\n        // - \"openai_chat\": 需要 Anthropic ↔ OpenAI Chat Completions 格式转换\n        // - \"openai_responses\": 需要 Anthropic ↔ OpenAI Responses API 格式转换\n        matches!(\n            self.get_api_format(provider),\n            \"openai_chat\" | \"openai_responses\"\n        )\n    }\n\n    fn transform_request(\n        &self,\n        body: serde_json::Value,\n        provider: &Provider,\n    ) -> Result<serde_json::Value, ProxyError> {\n        // Use meta.prompt_cache_key if set by user, otherwise fall back to provider.id\n        let cache_key = provider\n            .meta\n            .as_ref()\n            .and_then(|m| m.prompt_cache_key.as_deref())\n            .unwrap_or(&provider.id);\n\n        match self.get_api_format(provider) {\n            \"openai_responses\" => {\n                super::transform_responses::anthropic_to_responses(body, Some(cache_key))\n            }\n            _ => super::transform::anthropic_to_openai(body, Some(cache_key)),\n        }\n    }\n\n    fn transform_response(&self, body: serde_json::Value) -> Result<serde_json::Value, ProxyError> {\n        // Heuristic: detect response format by presence of top-level fields.\n        // The ProviderAdapter trait's transform_response doesn't receive the Provider\n        // config, so we can't check api_format here. Instead we rely on the fact that\n        // Responses API always returns \"output\" while Chat Completions returns \"choices\".\n        // This is safe because the two formats are structurally disjoint.\n        if body.get(\"output\").is_some() {\n            super::transform_responses::responses_to_anthropic(body)\n        } else {\n            super::transform::openai_to_anthropic(body)\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::provider::ProviderMeta;\n    use serde_json::json;\n\n    fn create_provider(config: serde_json::Value) -> Provider {\n        Provider {\n            id: \"test\".to_string(),\n            name: \"Test Claude\".to_string(),\n            settings_config: config,\n            website_url: None,\n            category: Some(\"claude\".to_string()),\n            created_at: None,\n            sort_index: None,\n            notes: None,\n            meta: None,\n            icon: None,\n            icon_color: None,\n            in_failover_queue: false,\n        }\n    }\n\n    fn create_provider_with_meta(config: serde_json::Value, meta: ProviderMeta) -> Provider {\n        Provider {\n            id: \"test\".to_string(),\n            name: \"Test Claude\".to_string(),\n            settings_config: config,\n            website_url: None,\n            category: Some(\"claude\".to_string()),\n            created_at: None,\n            sort_index: None,\n            notes: None,\n            meta: Some(meta),\n            icon: None,\n            icon_color: None,\n            in_failover_queue: false,\n        }\n    }\n\n    #[test]\n    fn test_extract_base_url_from_env() {\n        let adapter = ClaudeAdapter::new();\n        let provider = create_provider(json!({\n            \"env\": {\n                \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\"\n            }\n        }));\n\n        let url = adapter.extract_base_url(&provider).unwrap();\n        assert_eq!(url, \"https://api.anthropic.com\");\n    }\n\n    #[test]\n    fn test_extract_auth_anthropic() {\n        let adapter = ClaudeAdapter::new();\n        let provider = create_provider(json!({\n            \"env\": {\n                \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n                \"ANTHROPIC_AUTH_TOKEN\": \"sk-ant-test-key\"\n            }\n        }));\n\n        let auth = adapter.extract_auth(&provider).unwrap();\n        assert_eq!(auth.api_key, \"sk-ant-test-key\");\n        assert_eq!(auth.strategy, AuthStrategy::Anthropic);\n    }\n\n    #[test]\n    fn test_extract_auth_anthropic_api_key() {\n        let adapter = ClaudeAdapter::new();\n        let provider = create_provider(json!({\n            \"env\": {\n                \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n                \"ANTHROPIC_API_KEY\": \"sk-ant-test-key\"\n            }\n        }));\n\n        let auth = adapter.extract_auth(&provider).unwrap();\n        assert_eq!(auth.api_key, \"sk-ant-test-key\");\n        assert_eq!(auth.strategy, AuthStrategy::Anthropic);\n    }\n\n    #[test]\n    fn test_extract_auth_openrouter() {\n        let adapter = ClaudeAdapter::new();\n        let provider = create_provider(json!({\n            \"env\": {\n                \"ANTHROPIC_BASE_URL\": \"https://openrouter.ai/api\",\n                \"OPENROUTER_API_KEY\": \"sk-or-test-key\"\n            }\n        }));\n\n        let auth = adapter.extract_auth(&provider).unwrap();\n        assert_eq!(auth.api_key, \"sk-or-test-key\");\n        assert_eq!(auth.strategy, AuthStrategy::Bearer);\n    }\n\n    #[test]\n    fn test_extract_auth_claude_auth_mode() {\n        let adapter = ClaudeAdapter::new();\n        let provider = create_provider(json!({\n            \"env\": {\n                \"ANTHROPIC_BASE_URL\": \"https://some-proxy.com\",\n                \"ANTHROPIC_AUTH_TOKEN\": \"sk-proxy-key\"\n            },\n            \"auth_mode\": \"bearer_only\"\n        }));\n\n        let auth = adapter.extract_auth(&provider).unwrap();\n        assert_eq!(auth.api_key, \"sk-proxy-key\");\n        assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth);\n    }\n\n    #[test]\n    fn test_extract_auth_claude_auth_env_mode() {\n        let adapter = ClaudeAdapter::new();\n        let provider = create_provider(json!({\n            \"env\": {\n                \"ANTHROPIC_BASE_URL\": \"https://some-proxy.com\",\n                \"ANTHROPIC_AUTH_TOKEN\": \"sk-proxy-key\",\n                \"AUTH_MODE\": \"bearer_only\"\n            }\n        }));\n\n        let auth = adapter.extract_auth(&provider).unwrap();\n        assert_eq!(auth.api_key, \"sk-proxy-key\");\n        assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth);\n    }\n\n    #[test]\n    fn test_provider_type_detection() {\n        let adapter = ClaudeAdapter::new();\n\n        // Anthropic 官方\n        let anthropic = create_provider(json!({\n            \"env\": {\n                \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n                \"ANTHROPIC_AUTH_TOKEN\": \"sk-ant-test\"\n            }\n        }));\n        assert_eq!(adapter.provider_type(&anthropic), ProviderType::Claude);\n\n        // OpenRouter\n        let openrouter = create_provider(json!({\n            \"env\": {\n                \"ANTHROPIC_BASE_URL\": \"https://openrouter.ai/api\",\n                \"OPENROUTER_API_KEY\": \"sk-or-test\"\n            }\n        }));\n        assert_eq!(adapter.provider_type(&openrouter), ProviderType::OpenRouter);\n\n        // ClaudeAuth\n        let claude_auth = create_provider(json!({\n            \"env\": {\n                \"ANTHROPIC_BASE_URL\": \"https://some-proxy.com\",\n                \"ANTHROPIC_AUTH_TOKEN\": \"sk-test\"\n            },\n            \"auth_mode\": \"bearer_only\"\n        }));\n        assert_eq!(\n            adapter.provider_type(&claude_auth),\n            ProviderType::ClaudeAuth\n        );\n    }\n\n    #[test]\n    fn test_build_url_anthropic() {\n        let adapter = ClaudeAdapter::new();\n        // /v1/messages 端点会自动添加 ?beta=true 参数\n        let url = adapter.build_url(\"https://api.anthropic.com\", \"/v1/messages\");\n        assert_eq!(url, \"https://api.anthropic.com/v1/messages?beta=true\");\n    }\n\n    #[test]\n    fn test_build_url_openrouter() {\n        let adapter = ClaudeAdapter::new();\n        // /v1/messages 端点会自动添加 ?beta=true 参数\n        let url = adapter.build_url(\"https://openrouter.ai/api\", \"/v1/messages\");\n        assert_eq!(url, \"https://openrouter.ai/api/v1/messages?beta=true\");\n    }\n\n    #[test]\n    fn test_build_url_no_beta_for_other_endpoints() {\n        let adapter = ClaudeAdapter::new();\n        // 非 /v1/messages 端点不添加 ?beta=true\n        let url = adapter.build_url(\"https://api.anthropic.com\", \"/v1/complete\");\n        assert_eq!(url, \"https://api.anthropic.com/v1/complete\");\n    }\n\n    #[test]\n    fn test_build_url_preserve_existing_query() {\n        let adapter = ClaudeAdapter::new();\n        // 已有查询参数时不重复添加\n        let url = adapter.build_url(\"https://api.anthropic.com\", \"/v1/messages?foo=bar\");\n        assert_eq!(url, \"https://api.anthropic.com/v1/messages?foo=bar\");\n    }\n\n    #[test]\n    fn test_build_url_no_beta_for_openai_chat_completions() {\n        let adapter = ClaudeAdapter::new();\n        // OpenAI Chat Completions 端点不添加 ?beta=true\n        // 这是 Nvidia 等 apiFormat=\"openai_chat\" 供应商使用的端点\n        let url = adapter.build_url(\"https://integrate.api.nvidia.com\", \"/v1/chat/completions\");\n        assert_eq!(url, \"https://integrate.api.nvidia.com/v1/chat/completions\");\n    }\n\n    #[test]\n    fn test_needs_transform() {\n        let adapter = ClaudeAdapter::new();\n\n        // Default: no transform (anthropic format) - no meta\n        let anthropic_provider = create_provider(json!({\n            \"env\": {\n                \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\"\n            }\n        }));\n        assert!(!adapter.needs_transform(&anthropic_provider));\n\n        // Explicit anthropic format in meta: no transform\n        let explicit_anthropic = create_provider_with_meta(\n            json!({\n                \"env\": {\n                    \"ANTHROPIC_BASE_URL\": \"https://api.example.com\"\n                }\n            }),\n            ProviderMeta {\n                api_format: Some(\"anthropic\".to_string()),\n                ..Default::default()\n            },\n        );\n        assert!(!adapter.needs_transform(&explicit_anthropic));\n\n        // Legacy settings_config.api_format: openai_chat should enable transform\n        let legacy_settings_api_format = create_provider(json!({\n            \"env\": {\n                \"ANTHROPIC_BASE_URL\": \"https://api.example.com\"\n            },\n            \"api_format\": \"openai_chat\"\n        }));\n        assert!(adapter.needs_transform(&legacy_settings_api_format));\n\n        // Legacy openrouter_compat_mode: bool/number/string should enable transform\n        let legacy_openrouter_bool = create_provider(json!({\n            \"env\": {\n                \"ANTHROPIC_BASE_URL\": \"https://api.example.com\"\n            },\n            \"openrouter_compat_mode\": true\n        }));\n        assert!(adapter.needs_transform(&legacy_openrouter_bool));\n\n        let legacy_openrouter_num = create_provider(json!({\n            \"env\": {\n                \"ANTHROPIC_BASE_URL\": \"https://api.example.com\"\n            },\n            \"openrouter_compat_mode\": 1\n        }));\n        assert!(adapter.needs_transform(&legacy_openrouter_num));\n\n        let legacy_openrouter_str = create_provider(json!({\n            \"env\": {\n                \"ANTHROPIC_BASE_URL\": \"https://api.example.com\"\n            },\n            \"openrouter_compat_mode\": \"true\"\n        }));\n        assert!(adapter.needs_transform(&legacy_openrouter_str));\n\n        // OpenAI Chat format in meta: needs transform\n        let openai_chat_provider = create_provider_with_meta(\n            json!({\n                \"env\": {\n                    \"ANTHROPIC_BASE_URL\": \"https://api.example.com\"\n                }\n            }),\n            ProviderMeta {\n                api_format: Some(\"openai_chat\".to_string()),\n                ..Default::default()\n            },\n        );\n        assert!(adapter.needs_transform(&openai_chat_provider));\n\n        // OpenAI Responses format in meta: needs transform\n        let openai_responses_provider = create_provider_with_meta(\n            json!({\n                \"env\": {\n                    \"ANTHROPIC_BASE_URL\": \"https://api.example.com\"\n                }\n            }),\n            ProviderMeta {\n                api_format: Some(\"openai_responses\".to_string()),\n                ..Default::default()\n            },\n        );\n        assert!(adapter.needs_transform(&openai_responses_provider));\n\n        // meta takes precedence over legacy settings_config fields\n        let meta_precedence_over_settings = create_provider_with_meta(\n            json!({\n                \"env\": {\n                    \"ANTHROPIC_BASE_URL\": \"https://api.example.com\"\n                },\n                \"api_format\": \"openai_chat\",\n                \"openrouter_compat_mode\": true\n            }),\n            ProviderMeta {\n                api_format: Some(\"anthropic\".to_string()),\n                ..Default::default()\n            },\n        );\n        assert!(!adapter.needs_transform(&meta_precedence_over_settings));\n\n        // Unknown format in meta: default to anthropic (no transform)\n        let unknown_format = create_provider_with_meta(\n            json!({\n                \"env\": {\n                    \"ANTHROPIC_BASE_URL\": \"https://api.example.com\"\n                }\n            }),\n            ProviderMeta {\n                api_format: Some(\"unknown\".to_string()),\n                ..Default::default()\n            },\n        );\n        assert!(!adapter.needs_transform(&unknown_format));\n    }\n\n    #[test]\n    fn test_github_copilot_detection_by_url() {\n        let adapter = ClaudeAdapter::new();\n\n        // GitHub Copilot by base_url\n        let copilot = create_provider(json!({\n            \"env\": {\n                \"ANTHROPIC_BASE_URL\": \"https://api.githubcopilot.com\"\n            }\n        }));\n        assert_eq!(adapter.provider_type(&copilot), ProviderType::GitHubCopilot);\n    }\n\n    #[test]\n    fn test_github_copilot_detection_by_meta() {\n        let adapter = ClaudeAdapter::new();\n\n        // GitHub Copilot by meta.provider_type\n        let copilot_meta = create_provider_with_meta(\n            json!({\n                \"env\": {\n                    \"ANTHROPIC_BASE_URL\": \"https://api.example.com\"\n                }\n            }),\n            ProviderMeta {\n                provider_type: Some(\"github_copilot\".to_string()),\n                ..Default::default()\n            },\n        );\n        assert_eq!(\n            adapter.provider_type(&copilot_meta),\n            ProviderType::GitHubCopilot\n        );\n    }\n\n    #[test]\n    fn test_github_copilot_auth() {\n        let adapter = ClaudeAdapter::new();\n\n        let copilot = create_provider(json!({\n            \"env\": {\n                \"ANTHROPIC_BASE_URL\": \"https://api.githubcopilot.com\"\n            }\n        }));\n\n        let auth = adapter.extract_auth(&copilot).unwrap();\n        assert_eq!(auth.strategy, AuthStrategy::GitHubCopilot);\n    }\n\n    #[test]\n    fn test_github_copilot_needs_transform() {\n        let adapter = ClaudeAdapter::new();\n\n        let copilot = create_provider(json!({\n            \"env\": {\n                \"ANTHROPIC_BASE_URL\": \"https://api.githubcopilot.com\"\n            }\n        }));\n\n        // GitHub Copilot always needs transform\n        assert!(adapter.needs_transform(&copilot));\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/providers/codex.rs",
    "content": "//! Codex (OpenAI) Provider Adapter\n//!\n//! 仅透传模式，支持直连 OpenAI API\n//!\n//! ## 客户端检测\n//! 支持检测官方 Codex 客户端 (codex_vscode, codex_cli_rs)\n\nuse super::{AuthInfo, AuthStrategy, ProviderAdapter};\nuse crate::provider::Provider;\nuse crate::proxy::error::ProxyError;\nuse regex::Regex;\nuse reqwest::RequestBuilder;\nuse std::sync::LazyLock;\n\n/// 官方 Codex 客户端 User-Agent 正则\n#[allow(dead_code)]\nstatic CODEX_CLIENT_REGEX: LazyLock<Regex> =\n    LazyLock::new(|| Regex::new(r\"^(codex_vscode|codex_cli_rs)/[\\d.]+\").unwrap());\n\n/// Codex 适配器\npub struct CodexAdapter;\n\nimpl CodexAdapter {\n    pub fn new() -> Self {\n        Self\n    }\n\n    /// 检测是否为官方 Codex 客户端\n    ///\n    /// 匹配 User-Agent 模式: `^(codex_vscode|codex_cli_rs)/[\\d.]+`\n    #[allow(dead_code)]\n    pub fn is_official_client(user_agent: &str) -> bool {\n        CODEX_CLIENT_REGEX.is_match(user_agent)\n    }\n\n    /// 从 Provider 配置中提取 API Key\n    fn extract_key(&self, provider: &Provider) -> Option<String> {\n        // 1. 尝试从 env 中获取\n        if let Some(env) = provider.settings_config.get(\"env\") {\n            if let Some(key) = env.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) {\n                return Some(key.to_string());\n            }\n        }\n\n        // 2. 尝试从 auth 中获取 (Codex CLI 格式)\n        if let Some(auth) = provider.settings_config.get(\"auth\") {\n            if let Some(key) = auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) {\n                return Some(key.to_string());\n            }\n        }\n\n        // 3. 尝试直接获取\n        if let Some(key) = provider\n            .settings_config\n            .get(\"apiKey\")\n            .or_else(|| provider.settings_config.get(\"api_key\"))\n            .and_then(|v| v.as_str())\n        {\n            return Some(key.to_string());\n        }\n\n        // 4. 尝试从 config 对象中获取\n        if let Some(config) = provider.settings_config.get(\"config\") {\n            if let Some(key) = config\n                .get(\"api_key\")\n                .or_else(|| config.get(\"apiKey\"))\n                .and_then(|v| v.as_str())\n            {\n                return Some(key.to_string());\n            }\n        }\n\n        None\n    }\n}\n\nimpl Default for CodexAdapter {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n\nimpl ProviderAdapter for CodexAdapter {\n    fn name(&self) -> &'static str {\n        \"Codex\"\n    }\n\n    fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {\n        // 1. 尝试直接获取 base_url 字段\n        if let Some(url) = provider\n            .settings_config\n            .get(\"base_url\")\n            .and_then(|v| v.as_str())\n        {\n            return Ok(url.trim_end_matches('/').to_string());\n        }\n\n        // 2. 尝试 baseURL\n        if let Some(url) = provider\n            .settings_config\n            .get(\"baseURL\")\n            .and_then(|v| v.as_str())\n        {\n            return Ok(url.trim_end_matches('/').to_string());\n        }\n\n        // 3. 尝试从 config 对象中获取\n        if let Some(config) = provider.settings_config.get(\"config\") {\n            if let Some(url) = config.get(\"base_url\").and_then(|v| v.as_str()) {\n                return Ok(url.trim_end_matches('/').to_string());\n            }\n\n            // 尝试解析 TOML 字符串格式\n            if let Some(config_str) = config.as_str() {\n                if let Some(start) = config_str.find(\"base_url = \\\"\") {\n                    let rest = &config_str[start + 12..];\n                    if let Some(end) = rest.find('\"') {\n                        return Ok(rest[..end].trim_end_matches('/').to_string());\n                    }\n                }\n                if let Some(start) = config_str.find(\"base_url = '\") {\n                    let rest = &config_str[start + 12..];\n                    if let Some(end) = rest.find('\\'') {\n                        return Ok(rest[..end].trim_end_matches('/').to_string());\n                    }\n                }\n            }\n        }\n\n        Err(ProxyError::ConfigError(\n            \"Codex Provider 缺少 base_url 配置\".to_string(),\n        ))\n    }\n\n    fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {\n        self.extract_key(provider)\n            .map(|key| AuthInfo::new(key, AuthStrategy::Bearer))\n    }\n\n    fn build_url(&self, base_url: &str, endpoint: &str) -> String {\n        let base_trimmed = base_url.trim_end_matches('/');\n        let endpoint_trimmed = endpoint.trim_start_matches('/');\n\n        // OpenAI/Codex 的 base_url 可能是：\n        // - 纯 origin: https://api.openai.com  (需要自动补 /v1)\n        // - 已含 /v1: https://api.openai.com/v1 (直接拼接)\n        // - 自定义前缀: https://xxx/openai (不添加 /v1，直接拼接)\n\n        // 检查 base_url 是否已经包含 /v1\n        let already_has_v1 = base_trimmed.ends_with(\"/v1\");\n\n        // 检查是否是纯 origin（没有路径部分）\n        let origin_only = match base_trimmed.split_once(\"://\") {\n            Some((_scheme, rest)) => !rest.contains('/'),\n            None => !base_trimmed.contains('/'),\n        };\n\n        let mut url = if already_has_v1 {\n            // 已经有 /v1，直接拼接\n            format!(\"{base_trimmed}/{endpoint_trimmed}\")\n        } else if origin_only {\n            // 纯 origin，添加 /v1\n            format!(\"{base_trimmed}/v1/{endpoint_trimmed}\")\n        } else {\n            // 自定义前缀，不添加 /v1，直接拼接\n            format!(\"{base_trimmed}/{endpoint_trimmed}\")\n        };\n\n        // 去除重复的 /v1/v1（可能由 base_url 与 endpoint 都带版本导致）\n        while url.contains(\"/v1/v1\") {\n            url = url.replace(\"/v1/v1\", \"/v1\");\n        }\n\n        url\n    }\n\n    fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {\n        request.header(\"Authorization\", format!(\"Bearer {}\", auth.api_key))\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use serde_json::json;\n\n    fn create_provider(config: serde_json::Value) -> Provider {\n        Provider {\n            id: \"test\".to_string(),\n            name: \"Test Codex\".to_string(),\n            settings_config: config,\n            website_url: None,\n            category: Some(\"codex\".to_string()),\n            created_at: None,\n            sort_index: None,\n            notes: None,\n            meta: None,\n            icon: None,\n            icon_color: None,\n            in_failover_queue: false,\n        }\n    }\n\n    #[test]\n    fn test_extract_base_url_direct() {\n        let adapter = CodexAdapter::new();\n        let provider = create_provider(json!({\n            \"base_url\": \"https://api.openai.com/v1\"\n        }));\n\n        let url = adapter.extract_base_url(&provider).unwrap();\n        assert_eq!(url, \"https://api.openai.com/v1\");\n    }\n\n    #[test]\n    fn test_extract_auth_from_auth_field() {\n        let adapter = CodexAdapter::new();\n        let provider = create_provider(json!({\n            \"auth\": {\n                \"OPENAI_API_KEY\": \"sk-test-key-12345678\"\n            }\n        }));\n\n        let auth = adapter.extract_auth(&provider).unwrap();\n        assert_eq!(auth.api_key, \"sk-test-key-12345678\");\n        assert_eq!(auth.strategy, AuthStrategy::Bearer);\n    }\n\n    #[test]\n    fn test_extract_auth_from_env() {\n        let adapter = CodexAdapter::new();\n        let provider = create_provider(json!({\n            \"env\": {\n                \"OPENAI_API_KEY\": \"sk-env-key-12345678\"\n            }\n        }));\n\n        let auth = adapter.extract_auth(&provider).unwrap();\n        assert_eq!(auth.api_key, \"sk-env-key-12345678\");\n    }\n\n    #[test]\n    fn test_build_url() {\n        let adapter = CodexAdapter::new();\n        let url = adapter.build_url(\"https://api.openai.com/v1\", \"/responses\");\n        assert_eq!(url, \"https://api.openai.com/v1/responses\");\n    }\n\n    #[test]\n    fn test_build_url_origin_adds_v1() {\n        let adapter = CodexAdapter::new();\n        let url = adapter.build_url(\"https://api.openai.com\", \"/responses\");\n        assert_eq!(url, \"https://api.openai.com/v1/responses\");\n    }\n\n    #[test]\n    fn test_build_url_custom_prefix_no_v1() {\n        let adapter = CodexAdapter::new();\n        let url = adapter.build_url(\"https://example.com/openai\", \"/responses\");\n        assert_eq!(url, \"https://example.com/openai/responses\");\n    }\n\n    #[test]\n    fn test_build_url_dedup_v1() {\n        let adapter = CodexAdapter::new();\n        // base_url 已包含 /v1，endpoint 也包含 /v1\n        let url = adapter.build_url(\"https://www.packyapi.com/v1\", \"/v1/responses\");\n        assert_eq!(url, \"https://www.packyapi.com/v1/responses\");\n    }\n\n    // 官方客户端检测测试\n    #[test]\n    fn test_is_official_client_vscode() {\n        assert!(CodexAdapter::is_official_client(\"codex_vscode/1.0.0\"));\n        assert!(CodexAdapter::is_official_client(\"codex_vscode/2.3.4\"));\n        assert!(CodexAdapter::is_official_client(\"codex_vscode/0.1\"));\n    }\n\n    #[test]\n    fn test_is_official_client_cli() {\n        assert!(CodexAdapter::is_official_client(\"codex_cli_rs/1.0.0\"));\n        assert!(CodexAdapter::is_official_client(\"codex_cli_rs/0.5.2\"));\n    }\n\n    #[test]\n    fn test_is_not_official_client() {\n        assert!(!CodexAdapter::is_official_client(\"Mozilla/5.0\"));\n        assert!(!CodexAdapter::is_official_client(\"curl/7.68.0\"));\n        assert!(!CodexAdapter::is_official_client(\"python-requests/2.25.1\"));\n        assert!(!CodexAdapter::is_official_client(\"codex_other/1.0.0\"));\n        assert!(!CodexAdapter::is_official_client(\"\"));\n    }\n\n    #[test]\n    fn test_is_official_client_partial_match() {\n        // 必须从开头匹配\n        assert!(!CodexAdapter::is_official_client(\"some codex_vscode/1.0.0\"));\n        assert!(!CodexAdapter::is_official_client(\n            \"prefix_codex_cli_rs/1.0.0\"\n        ));\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/providers/copilot_auth.rs",
    "content": "//! GitHub Copilot Authentication Module\n//!\n//! 实现 GitHub OAuth 设备码流程和 Copilot 令牌管理。\n//! 支持多账号认证，每个 Provider 可关联不同的 GitHub 账号。\n//!\n//! ## 认证流程\n//! 1. 启动设备码流程，获取 device_code 和 user_code\n//! 2. 用户在浏览器中完成 GitHub 授权\n//! 3. 轮询获取 access_token\n//! 4. 使用 GitHub token 获取 Copilot token\n//! 5. 自动刷新 Copilot token（到期前 60 秒）\n//!\n//! ## 多账号支持 (v3)\n//! - 每个 GitHub 账号独立存储 token\n//! - Provider 通过 meta.authBinding 关联账号\n//! - 自动迁移 v1 单账号格式到 v3 多账号 + 默认账号格式\n\nuse reqwest::Client;\nuse serde::{Deserialize, Serialize};\nuse std::collections::HashMap;\nuse std::fs;\nuse std::io::Write;\nuse std::path::PathBuf;\nuse std::sync::Arc;\nuse tokio::sync::{Mutex, RwLock};\n\n/// GitHub OAuth 客户端 ID（VS Code 使用的 ID）\nconst GITHUB_CLIENT_ID: &str = \"Iv1.b507a08c87ecfe98\";\n\n/// GitHub 设备码 URL\nconst GITHUB_DEVICE_CODE_URL: &str = \"https://github.com/login/device/code\";\n\n/// GitHub OAuth Token URL\nconst GITHUB_OAUTH_TOKEN_URL: &str = \"https://github.com/login/oauth/access_token\";\n\n/// Copilot Token URL\nconst COPILOT_TOKEN_URL: &str = \"https://api.github.com/copilot_internal/v2/token\";\n\n/// GitHub User API URL\nconst GITHUB_USER_URL: &str = \"https://api.github.com/user\";\n\n/// Token 刷新提前量（秒）\nconst TOKEN_REFRESH_BUFFER_SECONDS: i64 = 60;\n\n/// Copilot API 端点\nconst COPILOT_MODELS_URL: &str = \"https://api.githubcopilot.com/models\";\n\n/// Copilot API Header 常量\nconst COPILOT_EDITOR_VERSION: &str = \"vscode/1.96.0\";\nconst COPILOT_PLUGIN_VERSION: &str = \"copilot-chat/0.26.7\";\nconst COPILOT_USER_AGENT: &str = \"GitHubCopilotChat/0.26.7\";\nconst COPILOT_API_VERSION: &str = \"2025-04-01\";\n\n/// Copilot 使用量 API URL\nconst COPILOT_USAGE_URL: &str = \"https://api.github.com/copilot_internal/user\";\n\n/// Copilot 使用量响应\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct CopilotUsageResponse {\n    /// Copilot 计划类型\n    pub copilot_plan: String,\n    /// 配额重置日期\n    pub quota_reset_date: String,\n    /// 配额快照\n    pub quota_snapshots: QuotaSnapshots,\n}\n\n/// 配额快照\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct QuotaSnapshots {\n    /// Chat 配额\n    pub chat: QuotaDetail,\n    /// Completions 配额\n    pub completions: QuotaDetail,\n    /// Premium 交互配额\n    pub premium_interactions: QuotaDetail,\n}\n\n/// 配额详情\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct QuotaDetail {\n    /// 总配额\n    pub entitlement: i64,\n    /// 剩余配额\n    pub remaining: i64,\n    /// 剩余百分比\n    pub percent_remaining: f64,\n    /// 是否无限\n    pub unlimited: bool,\n}\n\n/// Copilot 可用模型\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct CopilotModel {\n    /// 模型 ID（用于 API 调用）\n    pub id: String,\n    /// 模型显示名称\n    pub name: String,\n    /// 模型供应商\n    pub vendor: String,\n    /// 是否在模型选择器中显示\n    pub model_picker_enabled: bool,\n}\n\n/// Copilot Models API 响应\n#[derive(Debug, Deserialize)]\nstruct CopilotModelsResponse {\n    data: Vec<CopilotModelsResponseItem>,\n}\n\n/// Copilot Models API 响应项\n#[derive(Debug, Deserialize)]\nstruct CopilotModelsResponseItem {\n    id: String,\n    name: String,\n    vendor: String,\n    model_picker_enabled: bool,\n}\n\n/// Copilot 认证错误\n#[derive(Debug, thiserror::Error)]\npub enum CopilotAuthError {\n    #[error(\"设备码流程未启动\")]\n    DeviceFlowNotStarted,\n\n    #[error(\"等待用户授权中\")]\n    AuthorizationPending,\n\n    #[error(\"用户拒绝授权\")]\n    AccessDenied,\n\n    #[error(\"设备码已过期\")]\n    ExpiredToken,\n\n    #[error(\"GitHub 令牌无效或已过期\")]\n    GitHubTokenInvalid,\n\n    #[error(\"Copilot 令牌获取失败: {0}\")]\n    CopilotTokenFetchFailed(String),\n\n    #[error(\"网络错误: {0}\")]\n    NetworkError(String),\n\n    #[error(\"解析错误: {0}\")]\n    ParseError(String),\n\n    #[error(\"IO 错误: {0}\")]\n    IoError(String),\n\n    #[error(\"用户未订阅 Copilot\")]\n    NoCopilotSubscription,\n\n    #[error(\"账号不存在: {0}\")]\n    AccountNotFound(String),\n}\n\nimpl From<reqwest::Error> for CopilotAuthError {\n    fn from(err: reqwest::Error) -> Self {\n        CopilotAuthError::NetworkError(err.to_string())\n    }\n}\n\nimpl From<std::io::Error> for CopilotAuthError {\n    fn from(err: std::io::Error) -> Self {\n        CopilotAuthError::IoError(err.to_string())\n    }\n}\n\n/// GitHub 设备码响应\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct GitHubDeviceCodeResponse {\n    /// 设备码（用于轮询）\n    pub device_code: String,\n    /// 用户码（显示给用户）\n    pub user_code: String,\n    /// 验证 URL\n    pub verification_uri: String,\n    /// 过期时间（秒）\n    pub expires_in: u64,\n    /// 轮询间隔（秒）\n    pub interval: u64,\n}\n\n/// GitHub OAuth Token 响应\n#[derive(Debug, Clone, Serialize, Deserialize)]\nstruct GitHubOAuthResponse {\n    access_token: Option<String>,\n    token_type: Option<String>,\n    scope: Option<String>,\n    error: Option<String>,\n    error_description: Option<String>,\n}\n\n/// Copilot Token\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct CopilotToken {\n    /// JWT Token\n    pub token: String,\n    /// 过期时间戳（Unix 秒）\n    pub expires_at: i64,\n}\n\nimpl CopilotToken {\n    /// 检查令牌是否即将过期（提前 60 秒）\n    pub fn is_expiring_soon(&self) -> bool {\n        let now = chrono::Utc::now().timestamp();\n        self.expires_at - now < TOKEN_REFRESH_BUFFER_SECONDS\n    }\n}\n\n/// Copilot Token API 响应\n#[derive(Debug, Deserialize)]\nstruct CopilotTokenResponse {\n    token: String,\n    expires_at: i64,\n    #[allow(dead_code)]\n    refresh_in: Option<i64>,\n}\n\n/// GitHub 用户信息\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct GitHubUser {\n    pub login: String,\n    pub id: u64,\n    pub avatar_url: Option<String>,\n}\n\n/// GitHub 账号（公开信息，返回给前端）\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct GitHubAccount {\n    /// GitHub 用户 ID（字符串形式，作为唯一标识）\n    pub id: String,\n    /// GitHub 用户名\n    pub login: String,\n    /// 头像 URL\n    pub avatar_url: Option<String>,\n    /// 认证时间戳\n    pub authenticated_at: i64,\n}\n\nimpl From<&GitHubAccountData> for GitHubAccount {\n    fn from(data: &GitHubAccountData) -> Self {\n        GitHubAccount {\n            id: data.user.id.to_string(),\n            login: data.user.login.clone(),\n            avatar_url: data.user.avatar_url.clone(),\n            authenticated_at: data.authenticated_at,\n        }\n    }\n}\n\n/// Copilot 认证状态（支持多账号）\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct CopilotAuthStatus {\n    /// 所有已认证的账号\n    pub accounts: Vec<GitHubAccount>,\n    /// 默认账号 ID（显式状态，避免依赖 HashMap 顺序）\n    pub default_account_id: Option<String>,\n    /// 旧认证数据迁移失败时的状态消息（用于前端提示）\n    pub migration_error: Option<String>,\n    /// 是否已认证（向后兼容：有任意账号即为 true）\n    pub authenticated: bool,\n    /// GitHub 用户名（向后兼容：第一个账号的用户名）\n    pub username: Option<String>,\n    /// Copilot 令牌过期时间（向后兼容：第一个账号的过期时间）\n    pub expires_at: Option<i64>,\n}\n\n/// 账号数据（内部存储结构）\n#[derive(Debug, Clone, Serialize, Deserialize)]\nstruct GitHubAccountData {\n    /// GitHub OAuth Token\n    ///\n    /// 安全说明：为了复用登录状态，本地会持久化该令牌。\n    /// 当前实现未接入系统钥匙串，依赖私有文件权限（Unix 下 0600）保护。\n    pub github_token: String,\n    /// 用户信息\n    pub user: GitHubUser,\n    /// 认证时间戳\n    pub authenticated_at: i64,\n}\n\n/// 持久化存储结构（v3 多账号 + 默认账号格式）\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\nstruct CopilotAuthStore {\n    /// 存储格式版本（3 = 多账号 + 默认账号格式）\n    #[serde(default)]\n    version: u32,\n    /// 多账号数据（key = GitHub user ID）\n    #[serde(default)]\n    accounts: HashMap<String, GitHubAccountData>,\n    /// 默认账号 ID\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    default_account_id: Option<String>,\n    /// 兼容 v1 单账号格式的字段\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    github_token: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    authenticated_at: Option<i64>,\n}\n\n/// Copilot 认证管理器（支持多账号）\npub struct CopilotAuthManager {\n    /// 所有 GitHub 账号（key = GitHub user ID）\n    accounts: Arc<RwLock<HashMap<String, GitHubAccountData>>>,\n    /// 默认账号 ID\n    default_account_id: Arc<RwLock<Option<String>>>,\n    /// 每个账号的刷新锁，避免并发刷新重复打 GitHub API\n    refresh_locks: Arc<RwLock<HashMap<String, Arc<Mutex<()>>>>>,\n    /// Copilot Token 缓存（key = GitHub user ID，内存缓存，自动刷新）\n    copilot_tokens: Arc<RwLock<HashMap<String, CopilotToken>>>,\n    /// HTTP 客户端\n    http_client: Client,\n    /// 存储路径\n    storage_path: PathBuf,\n    /// 待迁移的旧格式 token\n    pending_migration: Arc<RwLock<Option<String>>>,\n    /// 旧认证数据迁移失败时的状态消息\n    migration_error: Arc<RwLock<Option<String>>>,\n}\n\nimpl CopilotAuthManager {\n    /// 创建新的认证管理器\n    pub fn new(data_dir: PathBuf) -> Self {\n        let storage_path = data_dir.join(\"copilot_auth.json\");\n\n        let manager = Self {\n            accounts: Arc::new(RwLock::new(HashMap::new())),\n            default_account_id: Arc::new(RwLock::new(None)),\n            refresh_locks: Arc::new(RwLock::new(HashMap::new())),\n            copilot_tokens: Arc::new(RwLock::new(HashMap::new())),\n            http_client: Client::new(),\n            storage_path,\n            pending_migration: Arc::new(RwLock::new(None)),\n            migration_error: Arc::new(RwLock::new(None)),\n        };\n\n        // 尝试从磁盘加载（同步，不发起网络请求）\n        if let Err(e) = manager.load_from_disk_sync() {\n            log::warn!(\"[CopilotAuth] 加载存储失败: {}\", e);\n        }\n\n        manager\n    }\n\n    // ==================== 多账号管理方法 ====================\n\n    /// 列出所有已认证的账号\n    pub async fn list_accounts(&self) -> Vec<GitHubAccount> {\n        let accounts = self.accounts.read().await.clone();\n        let default_account_id = self.resolve_default_account_id().await;\n        Self::sorted_accounts(&accounts, default_account_id.as_deref())\n    }\n\n    /// 获取指定账号信息\n    pub async fn get_account(&self, account_id: &str) -> Option<GitHubAccount> {\n        let accounts = self.accounts.read().await;\n        accounts.get(account_id).map(GitHubAccount::from)\n    }\n\n    /// 移除指定账号\n    pub async fn remove_account(&self, account_id: &str) -> Result<(), CopilotAuthError> {\n        log::info!(\"[CopilotAuth] 移除账号: {}\", account_id);\n\n        {\n            let mut accounts = self.accounts.write().await;\n            if accounts.remove(account_id).is_none() {\n                return Err(CopilotAuthError::AccountNotFound(account_id.to_string()));\n            }\n        }\n\n        // 同时移除缓存的 Copilot token\n        {\n            let mut tokens = self.copilot_tokens.write().await;\n            tokens.remove(account_id);\n        }\n        {\n            let mut refresh_locks = self.refresh_locks.write().await;\n            refresh_locks.remove(account_id);\n        }\n\n        {\n            let accounts = self.accounts.read().await;\n            let mut default_account_id = self.default_account_id.write().await;\n            if default_account_id.as_deref() == Some(account_id) {\n                *default_account_id = Self::fallback_default_account_id(&accounts);\n            }\n        }\n\n        // 持久化\n        self.save_to_disk().await?;\n\n        Ok(())\n    }\n\n    /// 添加新账号（内部方法，在 OAuth 完成后调用）\n    async fn add_account_internal(\n        &self,\n        github_token: String,\n        user: GitHubUser,\n    ) -> Result<GitHubAccount, CopilotAuthError> {\n        let account_id = user.id.to_string();\n        let now = chrono::Utc::now().timestamp();\n\n        let account_data = GitHubAccountData {\n            github_token,\n            user: user.clone(),\n            authenticated_at: now,\n        };\n\n        let account = GitHubAccount {\n            id: account_id.clone(),\n            login: user.login.clone(),\n            avatar_url: user.avatar_url.clone(),\n            authenticated_at: now,\n        };\n\n        {\n            let mut accounts = self.accounts.write().await;\n            accounts.insert(account_id, account_data);\n        }\n\n        {\n            let mut default_account_id = self.default_account_id.write().await;\n            if default_account_id.is_none() {\n                *default_account_id = Some(account.id.clone());\n            }\n        }\n\n        self.set_migration_error(None).await;\n\n        // 持久化\n        self.save_to_disk().await?;\n\n        log::info!(\"[CopilotAuth] 添加账号成功: {}\", user.login);\n\n        Ok(account)\n    }\n\n    /// 设置默认账号\n    pub async fn set_default_account(&self, account_id: &str) -> Result<(), CopilotAuthError> {\n        {\n            let accounts = self.accounts.read().await;\n            if !accounts.contains_key(account_id) {\n                return Err(CopilotAuthError::AccountNotFound(account_id.to_string()));\n            }\n        }\n\n        {\n            let mut default_account_id = self.default_account_id.write().await;\n            *default_account_id = Some(account_id.to_string());\n        }\n\n        self.save_to_disk().await?;\n        Ok(())\n    }\n\n    // ==================== 设备码流程 ====================\n\n    /// 启动设备码流程\n    pub async fn start_device_flow(&self) -> Result<GitHubDeviceCodeResponse, CopilotAuthError> {\n        log::info!(\"[CopilotAuth] 启动设备码流程\");\n\n        let response = self\n            .http_client\n            .post(GITHUB_DEVICE_CODE_URL)\n            .header(\"Accept\", \"application/json\")\n            .form(&[(\"client_id\", GITHUB_CLIENT_ID), (\"scope\", \"read:user\")])\n            .send()\n            .await?;\n\n        if !response.status().is_success() {\n            let status = response.status();\n            let text = response.text().await.unwrap_or_default();\n            return Err(CopilotAuthError::NetworkError(format!(\n                \"GitHub 设备码请求失败: {} - {}\",\n                status, text\n            )));\n        }\n\n        let device_code: GitHubDeviceCodeResponse = response\n            .json()\n            .await\n            .map_err(|e| CopilotAuthError::ParseError(e.to_string()))?;\n\n        log::info!(\n            \"[CopilotAuth] 获取设备码成功，user_code: {}\",\n            device_code.user_code\n        );\n\n        Ok(device_code)\n    }\n\n    /// 轮询获取 OAuth Token（返回新添加的账号，如果成功）\n    pub async fn poll_for_token(\n        &self,\n        device_code: &str,\n    ) -> Result<Option<GitHubAccount>, CopilotAuthError> {\n        log::debug!(\"[CopilotAuth] 轮询 OAuth Token\");\n\n        let response = self\n            .http_client\n            .post(GITHUB_OAUTH_TOKEN_URL)\n            .header(\"Accept\", \"application/json\")\n            .form(&[\n                (\"client_id\", GITHUB_CLIENT_ID),\n                (\"device_code\", device_code),\n                (\"grant_type\", \"urn:ietf:params:oauth:grant-type:device_code\"),\n            ])\n            .send()\n            .await?;\n\n        let oauth_response: GitHubOAuthResponse = response\n            .json()\n            .await\n            .map_err(|e| CopilotAuthError::ParseError(e.to_string()))?;\n\n        // 检查错误\n        if let Some(error) = oauth_response.error {\n            return match error.as_str() {\n                \"authorization_pending\" => Err(CopilotAuthError::AuthorizationPending),\n                \"slow_down\" => Err(CopilotAuthError::AuthorizationPending),\n                \"expired_token\" => Err(CopilotAuthError::ExpiredToken),\n                \"access_denied\" => Err(CopilotAuthError::AccessDenied),\n                _ => Err(CopilotAuthError::NetworkError(format!(\n                    \"{}: {}\",\n                    error,\n                    oauth_response.error_description.unwrap_or_default()\n                ))),\n            };\n        }\n\n        // 获取 access_token\n        let access_token = oauth_response\n            .access_token\n            .ok_or_else(|| CopilotAuthError::ParseError(\"缺少 access_token\".to_string()))?;\n\n        log::info!(\"[CopilotAuth] OAuth Token 获取成功\");\n\n        // 获取用户信息\n        let user = self.fetch_user_info_with_token(&access_token).await?;\n\n        // 验证 Copilot 订阅（获取 Copilot Token）\n        self.fetch_copilot_token_with_github_token(&access_token, &user.id.to_string())\n            .await?;\n\n        // 添加账号\n        let account = self.add_account_internal(access_token, user).await?;\n\n        Ok(Some(account))\n    }\n\n    // ==================== Token 获取方法 ====================\n\n    /// 获取指定账号的有效 Copilot Token（自动刷新）\n    pub async fn get_valid_token_for_account(\n        &self,\n        account_id: &str,\n    ) -> Result<String, CopilotAuthError> {\n        // 确保迁移完成\n        self.ensure_migration_complete().await?;\n\n        // 检查缓存的 token\n        {\n            let tokens = self.copilot_tokens.read().await;\n            if let Some(copilot_token) = tokens.get(account_id) {\n                if !copilot_token.is_expiring_soon() {\n                    return Ok(copilot_token.token.clone());\n                }\n            }\n        }\n\n        // 需要刷新\n        log::info!(\n            \"[CopilotAuth] 账号 {} 的 Copilot Token 需要刷新\",\n            account_id\n        );\n\n        let refresh_lock = self.get_refresh_lock(account_id).await;\n        let _refresh_guard = refresh_lock.lock().await;\n\n        // double-check：等待锁期间可能已由其他请求刷新完成\n        {\n            let tokens = self.copilot_tokens.read().await;\n            if let Some(copilot_token) = tokens.get(account_id) {\n                if !copilot_token.is_expiring_soon() {\n                    return Ok(copilot_token.token.clone());\n                }\n            }\n        }\n\n        // 获取账号的 GitHub token\n        let github_token = {\n            let accounts = self.accounts.read().await;\n            accounts\n                .get(account_id)\n                .map(|a| a.github_token.clone())\n                .ok_or_else(|| CopilotAuthError::AccountNotFound(account_id.to_string()))?\n        };\n\n        // 刷新 Copilot token\n        self.fetch_copilot_token_with_github_token(&github_token, account_id)\n            .await?;\n\n        // 返回新 token\n        let tokens = self.copilot_tokens.read().await;\n        tokens.get(account_id).map(|t| t.token.clone()).ok_or(\n            CopilotAuthError::CopilotTokenFetchFailed(\"刷新后仍无令牌\".to_string()),\n        )\n    }\n\n    /// 获取有效的 Copilot Token（向后兼容：使用第一个账号）\n    pub async fn get_valid_token(&self) -> Result<String, CopilotAuthError> {\n        // 确保迁移完成\n        self.ensure_migration_complete().await?;\n\n        match self.resolve_default_account_id().await {\n            Some(id) => self.get_valid_token_for_account(&id).await,\n            None => Err(CopilotAuthError::GitHubTokenInvalid),\n        }\n    }\n\n    // ==================== 模型和使用量 ====================\n\n    /// 获取指定账号的 Copilot 可用模型列表\n    pub async fn fetch_models_for_account(\n        &self,\n        account_id: &str,\n    ) -> Result<Vec<CopilotModel>, CopilotAuthError> {\n        let copilot_token = self.get_valid_token_for_account(account_id).await?;\n\n        log::info!(\"[CopilotAuth] 获取账号 {} 的 Copilot 可用模型\", account_id);\n\n        let response = self\n            .http_client\n            .get(COPILOT_MODELS_URL)\n            .header(\"Authorization\", format!(\"Bearer {}\", copilot_token))\n            .header(\"Content-Type\", \"application/json\")\n            .header(\"copilot-integration-id\", \"vscode-chat\")\n            .header(\"editor-version\", COPILOT_EDITOR_VERSION)\n            .header(\"editor-plugin-version\", COPILOT_PLUGIN_VERSION)\n            .header(\"user-agent\", COPILOT_USER_AGENT)\n            .header(\"x-github-api-version\", COPILOT_API_VERSION)\n            .send()\n            .await?;\n\n        if !response.status().is_success() {\n            let status = response.status();\n            let text = response.text().await.unwrap_or_default();\n            return Err(CopilotAuthError::CopilotTokenFetchFailed(format!(\n                \"获取模型列表失败: {} - {}\",\n                status, text\n            )));\n        }\n\n        let models_response: CopilotModelsResponse = response\n            .json()\n            .await\n            .map_err(|e| CopilotAuthError::ParseError(e.to_string()))?;\n\n        let models: Vec<CopilotModel> = models_response\n            .data\n            .into_iter()\n            .filter(|m| m.model_picker_enabled)\n            .map(|m| CopilotModel {\n                id: m.id,\n                name: m.name,\n                vendor: m.vendor,\n                model_picker_enabled: m.model_picker_enabled,\n            })\n            .collect();\n\n        log::info!(\"[CopilotAuth] 获取到 {} 个可用模型\", models.len());\n\n        Ok(models)\n    }\n\n    /// 获取 Copilot 可用模型列表（向后兼容：使用第一个账号）\n    pub async fn fetch_models(&self) -> Result<Vec<CopilotModel>, CopilotAuthError> {\n        match self.resolve_default_account_id().await {\n            Some(id) => self.fetch_models_for_account(&id).await,\n            None => Err(CopilotAuthError::GitHubTokenInvalid),\n        }\n    }\n\n    /// 获取指定账号的 Copilot 使用量信息\n    pub async fn fetch_usage_for_account(\n        &self,\n        account_id: &str,\n    ) -> Result<CopilotUsageResponse, CopilotAuthError> {\n        let github_token = {\n            let accounts = self.accounts.read().await;\n            accounts\n                .get(account_id)\n                .map(|a| a.github_token.clone())\n                .ok_or_else(|| CopilotAuthError::AccountNotFound(account_id.to_string()))?\n        };\n\n        log::info!(\"[CopilotAuth] 获取账号 {} 的 Copilot 使用量\", account_id);\n\n        let response = self\n            .http_client\n            .get(COPILOT_USAGE_URL)\n            .header(\"Authorization\", format!(\"token {}\", github_token))\n            .header(\"Content-Type\", \"application/json\")\n            .header(\"editor-version\", COPILOT_EDITOR_VERSION)\n            .header(\"editor-plugin-version\", COPILOT_PLUGIN_VERSION)\n            .header(\"user-agent\", COPILOT_USER_AGENT)\n            .header(\"x-github-api-version\", COPILOT_API_VERSION)\n            .send()\n            .await?;\n\n        if response.status() == reqwest::StatusCode::UNAUTHORIZED {\n            return Err(CopilotAuthError::GitHubTokenInvalid);\n        }\n\n        if !response.status().is_success() {\n            let status = response.status();\n            let text = response.text().await.unwrap_or_default();\n            return Err(CopilotAuthError::CopilotTokenFetchFailed(format!(\n                \"获取使用量失败: {} - {}\",\n                status, text\n            )));\n        }\n\n        let usage: CopilotUsageResponse = response\n            .json()\n            .await\n            .map_err(|e| CopilotAuthError::ParseError(e.to_string()))?;\n\n        log::info!(\n            \"[CopilotAuth] 获取使用量成功，计划: {}, 重置日期: {}\",\n            usage.copilot_plan,\n            usage.quota_reset_date\n        );\n\n        Ok(usage)\n    }\n\n    /// 获取 Copilot 使用量信息（向后兼容：使用第一个账号）\n    pub async fn fetch_usage(&self) -> Result<CopilotUsageResponse, CopilotAuthError> {\n        match self.resolve_default_account_id().await {\n            Some(id) => self.fetch_usage_for_account(&id).await,\n            None => Err(CopilotAuthError::GitHubTokenInvalid),\n        }\n    }\n\n    // ==================== 状态查询 ====================\n\n    /// 获取认证状态（支持多账号）\n    pub async fn get_status(&self) -> CopilotAuthStatus {\n        // 确保迁移完成\n        let _ = self.ensure_migration_complete().await;\n\n        let accounts = self.accounts.read().await.clone();\n        let default_account_id = self.resolve_default_account_id().await;\n        let copilot_tokens = self.copilot_tokens.read().await.clone();\n        let migration_error = self.migration_error.read().await.clone();\n\n        let account_list = Self::sorted_accounts(&accounts, default_account_id.as_deref());\n        let authenticated = !account_list.is_empty();\n        let username = default_account_id\n            .as_ref()\n            .and_then(|id| accounts.get(id))\n            .map(|a| a.user.login.clone())\n            .or_else(|| account_list.first().map(|a| a.login.clone()));\n\n        // 获取默认账号的过期时间\n        let expires_at = default_account_id\n            .as_ref()\n            .and_then(|id| copilot_tokens.get(id))\n            .map(|t| t.expires_at);\n\n        CopilotAuthStatus {\n            accounts: account_list,\n            default_account_id,\n            migration_error,\n            authenticated,\n            username,\n            expires_at,\n        }\n    }\n\n    /// 检查是否已认证（有任意账号）\n    pub async fn is_authenticated(&self) -> bool {\n        let accounts = self.accounts.read().await;\n        !accounts.is_empty()\n    }\n\n    /// 清除所有认证（登出所有账号）\n    pub async fn clear_auth(&self) -> Result<(), CopilotAuthError> {\n        log::info!(\"[CopilotAuth] 清除所有认证\");\n\n        {\n            let mut accounts = self.accounts.write().await;\n            accounts.clear();\n        }\n        {\n            let mut default_account_id = self.default_account_id.write().await;\n            default_account_id.take();\n        }\n        self.set_migration_error(None).await;\n        {\n            let mut tokens = self.copilot_tokens.write().await;\n            tokens.clear();\n        }\n        {\n            let mut refresh_locks = self.refresh_locks.write().await;\n            refresh_locks.clear();\n        }\n\n        // 删除存储文件\n        if self.storage_path.exists() {\n            std::fs::remove_file(&self.storage_path)?;\n        }\n\n        Ok(())\n    }\n\n    // ==================== 内部方法 ====================\n\n    fn fallback_default_account_id(\n        accounts: &HashMap<String, GitHubAccountData>,\n    ) -> Option<String> {\n        accounts\n            .iter()\n            .max_by(|(id_a, a), (id_b, b)| {\n                a.authenticated_at\n                    .cmp(&b.authenticated_at)\n                    .then_with(|| id_b.cmp(id_a))\n            })\n            .map(|(id, _)| id.clone())\n    }\n\n    fn sorted_accounts(\n        accounts: &HashMap<String, GitHubAccountData>,\n        default_account_id: Option<&str>,\n    ) -> Vec<GitHubAccount> {\n        let mut account_list: Vec<GitHubAccount> =\n            accounts.values().map(GitHubAccount::from).collect();\n        account_list.sort_by(|a, b| {\n            let a_default = default_account_id == Some(a.id.as_str());\n            let b_default = default_account_id == Some(b.id.as_str());\n\n            b_default\n                .cmp(&a_default)\n                .then_with(|| b.authenticated_at.cmp(&a.authenticated_at))\n                .then_with(|| a.login.cmp(&b.login))\n        });\n        account_list\n    }\n\n    async fn resolve_default_account_id(&self) -> Option<String> {\n        let stored_default = self.default_account_id.read().await.clone();\n        let accounts = self.accounts.read().await;\n\n        if let Some(default_id) = stored_default {\n            if accounts.contains_key(&default_id) {\n                return Some(default_id);\n            }\n        }\n\n        Self::fallback_default_account_id(&accounts)\n    }\n\n    async fn get_refresh_lock(&self, account_id: &str) -> Arc<Mutex<()>> {\n        {\n            let refresh_locks = self.refresh_locks.read().await;\n            if let Some(lock) = refresh_locks.get(account_id) {\n                return Arc::clone(lock);\n            }\n        }\n\n        let mut refresh_locks = self.refresh_locks.write().await;\n        Arc::clone(\n            refresh_locks\n                .entry(account_id.to_string())\n                .or_insert_with(|| Arc::new(Mutex::new(()))),\n        )\n    }\n\n    async fn set_migration_error(&self, message: Option<String>) {\n        let mut migration_error = self.migration_error.write().await;\n        *migration_error = message;\n    }\n\n    fn write_store_atomic(&self, content: &str) -> Result<(), CopilotAuthError> {\n        if let Some(parent) = self.storage_path.parent() {\n            fs::create_dir_all(parent)?;\n        }\n\n        let parent = self\n            .storage_path\n            .parent()\n            .ok_or_else(|| CopilotAuthError::IoError(\"无效的存储路径\".to_string()))?;\n        let file_name = self\n            .storage_path\n            .file_name()\n            .ok_or_else(|| CopilotAuthError::IoError(\"无效的存储文件名\".to_string()))?\n            .to_string_lossy()\n            .to_string();\n        let ts = std::time::SystemTime::now()\n            .duration_since(std::time::UNIX_EPOCH)\n            .unwrap_or_default()\n            .as_nanos();\n        let tmp_path = parent.join(format!(\"{file_name}.tmp.{ts}\"));\n\n        #[cfg(unix)]\n        {\n            use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};\n\n            let mut file = fs::OpenOptions::new()\n                .create_new(true)\n                .write(true)\n                .mode(0o600)\n                .open(&tmp_path)?;\n            file.write_all(content.as_bytes())?;\n            file.flush()?;\n\n            fs::rename(&tmp_path, &self.storage_path)?;\n            fs::set_permissions(&self.storage_path, fs::Permissions::from_mode(0o600))?;\n        }\n\n        #[cfg(windows)]\n        {\n            let mut file = fs::OpenOptions::new()\n                .create_new(true)\n                .write(true)\n                .open(&tmp_path)?;\n            file.write_all(content.as_bytes())?;\n            file.flush()?;\n\n            if self.storage_path.exists() {\n                let _ = fs::remove_file(&self.storage_path);\n            }\n            fs::rename(&tmp_path, &self.storage_path)?;\n        }\n\n        Ok(())\n    }\n\n    /// 使用指定 token 获取 GitHub 用户信息\n    async fn fetch_user_info_with_token(\n        &self,\n        github_token: &str,\n    ) -> Result<GitHubUser, CopilotAuthError> {\n        let response = self\n            .http_client\n            .get(GITHUB_USER_URL)\n            .header(\"Authorization\", format!(\"token {}\", github_token))\n            .header(\"User-Agent\", \"CC-Switch\")\n            .send()\n            .await?;\n\n        if !response.status().is_success() {\n            return Err(CopilotAuthError::GitHubTokenInvalid);\n        }\n\n        let user: GitHubUser = response\n            .json()\n            .await\n            .map_err(|e| CopilotAuthError::ParseError(e.to_string()))?;\n\n        log::info!(\"[CopilotAuth] 获取用户信息成功: {}\", user.login);\n\n        Ok(user)\n    }\n\n    /// 使用 GitHub token 获取 Copilot Token\n    async fn fetch_copilot_token_with_github_token(\n        &self,\n        github_token: &str,\n        account_id: &str,\n    ) -> Result<(), CopilotAuthError> {\n        log::debug!(\"[CopilotAuth] 获取账号 {} 的 Copilot Token\", account_id);\n\n        let response = self\n            .http_client\n            .get(COPILOT_TOKEN_URL)\n            .header(\"Authorization\", format!(\"token {}\", github_token))\n            .header(\"User-Agent\", \"CC-Switch\")\n            .header(\"Editor-Version\", \"vscode/1.85.0\")\n            .header(\"Editor-Plugin-Version\", \"copilot/1.150.0\")\n            .send()\n            .await?;\n\n        if response.status() == reqwest::StatusCode::UNAUTHORIZED {\n            return Err(CopilotAuthError::GitHubTokenInvalid);\n        }\n\n        if response.status() == reqwest::StatusCode::FORBIDDEN {\n            return Err(CopilotAuthError::NoCopilotSubscription);\n        }\n\n        if !response.status().is_success() {\n            let status = response.status();\n            let text = response.text().await.unwrap_or_default();\n            return Err(CopilotAuthError::CopilotTokenFetchFailed(format!(\n                \"{}: {}\",\n                status, text\n            )));\n        }\n\n        let token_response: CopilotTokenResponse = response\n            .json()\n            .await\n            .map_err(|e| CopilotAuthError::ParseError(e.to_string()))?;\n\n        log::info!(\n            \"[CopilotAuth] 账号 {} 的 Copilot Token 获取成功，过期时间: {}\",\n            account_id,\n            token_response.expires_at\n        );\n\n        let copilot_token = CopilotToken {\n            token: token_response.token,\n            expires_at: token_response.expires_at,\n        };\n\n        let mut tokens = self.copilot_tokens.write().await;\n        tokens.insert(account_id.to_string(), copilot_token);\n\n        Ok(())\n    }\n\n    // ==================== 存储和迁移 ====================\n\n    /// 从磁盘加载（仅加载 token，不发起网络请求）\n    fn load_from_disk_sync(&self) -> Result<(), CopilotAuthError> {\n        if !self.storage_path.exists() {\n            return Ok(());\n        }\n\n        let content = std::fs::read_to_string(&self.storage_path)?;\n        let store: CopilotAuthStore = serde_json::from_str(&content)\n            .map_err(|e| CopilotAuthError::ParseError(e.to_string()))?;\n\n        if store.version >= 2 {\n            // v2 多账号格式\n            if let Ok(mut accounts) = self.accounts.try_write() {\n                *accounts = store.accounts;\n                log::info!(\"[CopilotAuth] 从磁盘加载 {} 个账号\", accounts.len());\n            }\n            if let Ok(mut default_account_id) = self.default_account_id.try_write() {\n                *default_account_id = store.default_account_id;\n                if default_account_id.is_none() {\n                    if let Ok(accounts) = self.accounts.try_read() {\n                        *default_account_id = Self::fallback_default_account_id(&accounts);\n                    }\n                }\n            }\n        } else if store.github_token.is_some() {\n            // v1 单账号格式，标记待迁移\n            log::info!(\"[CopilotAuth] 检测到旧格式，将在首次访问时迁移\");\n            if let Ok(mut pending) = self.pending_migration.try_write() {\n                *pending = store.github_token;\n            }\n        }\n\n        Ok(())\n    }\n\n    /// 确保迁移完成\n    async fn ensure_migration_complete(&self) -> Result<(), CopilotAuthError> {\n        let pending = {\n            let guard = self.pending_migration.read().await;\n            guard.clone()\n        };\n\n        if let Some(legacy_token) = pending {\n            log::info!(\"[CopilotAuth] 执行旧格式迁移\");\n\n            // 获取用户信息\n            match self.fetch_user_info_with_token(&legacy_token).await {\n                Ok(user) => {\n                    let account_id = user.id.to_string();\n\n                    // 尝试获取 Copilot token 验证订阅\n                    if let Err(e) = self\n                        .fetch_copilot_token_with_github_token(&legacy_token, &account_id)\n                        .await\n                    {\n                        log::warn!(\"[CopilotAuth] 迁移时验证 Copilot 订阅失败: {}\", e);\n                    }\n\n                    // 添加账号\n                    self.add_account_internal(legacy_token, user).await?;\n                    self.set_migration_error(None).await;\n\n                    log::info!(\"[CopilotAuth] 旧格式迁移完成\");\n                }\n                Err(e) => {\n                    self.set_migration_error(Some(format!(\n                        \"Legacy Copilot auth migration failed: {e}\"\n                    )))\n                    .await;\n                    log::warn!(\"[CopilotAuth] 迁移失败，旧 token 可能已失效: {}\", e);\n                }\n            }\n\n            // 清除待迁移标记\n            {\n                let mut pending = self.pending_migration.write().await;\n                *pending = None;\n            }\n        }\n\n        Ok(())\n    }\n\n    /// 保存到磁盘\n    async fn save_to_disk(&self) -> Result<(), CopilotAuthError> {\n        let accounts = self.accounts.read().await.clone();\n        let default_account_id = self.resolve_default_account_id().await;\n\n        let store = CopilotAuthStore {\n            version: 3,\n            accounts,\n            default_account_id,\n            github_token: None,\n            authenticated_at: None,\n        };\n\n        let content = serde_json::to_string_pretty(&store)\n            .map_err(|e| CopilotAuthError::ParseError(e.to_string()))?;\n\n        self.write_store_atomic(&content)?;\n\n        log::info!(\n            \"[CopilotAuth] 保存到磁盘成功（{} 个账号）\",\n            store.accounts.len()\n        );\n\n        Ok(())\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_copilot_token_expiry() {\n        let now = chrono::Utc::now().timestamp();\n\n        // 未过期的 token (1小时后过期，不在60秒缓冲期内)\n        let token = CopilotToken {\n            token: \"test\".to_string(),\n            expires_at: now + 3600,\n        };\n        assert!(!token.is_expiring_soon());\n\n        // 即将过期的 token (30秒后过期，在60秒缓冲期内)\n        let token = CopilotToken {\n            token: \"test\".to_string(),\n            expires_at: now + 30,\n        };\n        assert!(token.is_expiring_soon());\n\n        // 已过期的 token (也在缓冲期内)\n        let token = CopilotToken {\n            token: \"test\".to_string(),\n            expires_at: now - 100,\n        };\n        assert!(token.is_expiring_soon());\n    }\n\n    #[test]\n    fn test_auth_status_serialization() {\n        let status = CopilotAuthStatus {\n            accounts: vec![GitHubAccount {\n                id: \"12345\".to_string(),\n                login: \"testuser\".to_string(),\n                avatar_url: Some(\"https://example.com/avatar.png\".to_string()),\n                authenticated_at: 1234567890,\n            }],\n            default_account_id: Some(\"12345\".to_string()),\n            migration_error: None,\n            authenticated: true,\n            username: Some(\"testuser\".to_string()),\n            expires_at: Some(1234567890),\n        };\n\n        let json = serde_json::to_string(&status).unwrap();\n        let parsed: CopilotAuthStatus = serde_json::from_str(&json).unwrap();\n\n        assert!(parsed.authenticated);\n        assert_eq!(parsed.default_account_id, Some(\"12345\".to_string()));\n        assert_eq!(parsed.username, Some(\"testuser\".to_string()));\n        assert_eq!(parsed.expires_at, Some(1234567890));\n        assert_eq!(parsed.accounts.len(), 1);\n        assert_eq!(parsed.accounts[0].id, \"12345\");\n        assert_eq!(parsed.accounts[0].login, \"testuser\");\n    }\n\n    #[test]\n    fn test_multi_account_store_serialization() {\n        let mut accounts = HashMap::new();\n        accounts.insert(\n            \"12345\".to_string(),\n            GitHubAccountData {\n                github_token: \"gho_test_token\".to_string(),\n                user: GitHubUser {\n                    login: \"alice\".to_string(),\n                    id: 12345,\n                    avatar_url: Some(\"https://example.com/alice.png\".to_string()),\n                },\n                authenticated_at: 1700000000,\n            },\n        );\n        accounts.insert(\n            \"67890\".to_string(),\n            GitHubAccountData {\n                github_token: \"gho_test_token_2\".to_string(),\n                user: GitHubUser {\n                    login: \"bob\".to_string(),\n                    id: 67890,\n                    avatar_url: None,\n                },\n                authenticated_at: 1700000001,\n            },\n        );\n\n        let store = CopilotAuthStore {\n            version: 3,\n            accounts,\n            default_account_id: Some(\"67890\".to_string()),\n            github_token: None,\n            authenticated_at: None,\n        };\n\n        let json = serde_json::to_string_pretty(&store).unwrap();\n        let parsed: CopilotAuthStore = serde_json::from_str(&json).unwrap();\n\n        assert_eq!(parsed.version, 3);\n        assert_eq!(parsed.default_account_id, Some(\"67890\".to_string()));\n        assert_eq!(parsed.accounts.len(), 2);\n        assert!(parsed.accounts.contains_key(\"12345\"));\n        assert!(parsed.accounts.contains_key(\"67890\"));\n        assert_eq!(parsed.accounts[\"12345\"].user.login, \"alice\");\n        assert_eq!(parsed.accounts[\"67890\"].user.login, \"bob\");\n    }\n\n    #[test]\n    fn test_legacy_format_detection() {\n        // 旧格式（v1）\n        let legacy_json = r#\"{\n            \"github_token\": \"gho_legacy_token\",\n            \"authenticated_at\": 1700000000\n        }\"#;\n\n        let store: CopilotAuthStore = serde_json::from_str(legacy_json).unwrap();\n        assert_eq!(store.version, 0); // 默认值\n        assert!(store.github_token.is_some());\n        assert!(store.accounts.is_empty());\n    }\n\n    #[test]\n    fn test_github_account_from_data() {\n        let data = GitHubAccountData {\n            github_token: \"gho_test\".to_string(),\n            user: GitHubUser {\n                login: \"testuser\".to_string(),\n                id: 99999,\n                avatar_url: Some(\"https://example.com/avatar.png\".to_string()),\n            },\n            authenticated_at: 1700000000,\n        };\n\n        let account = GitHubAccount::from(&data);\n        assert_eq!(account.id, \"99999\");\n        assert_eq!(account.login, \"testuser\");\n        assert_eq!(\n            account.avatar_url,\n            Some(\"https://example.com/avatar.png\".to_string())\n        );\n        assert_eq!(account.authenticated_at, 1700000000);\n    }\n\n    #[test]\n    fn test_fallback_default_account_prefers_latest_authenticated() {\n        let mut accounts = HashMap::new();\n        accounts.insert(\n            \"12345\".to_string(),\n            GitHubAccountData {\n                github_token: \"gho_test_token\".to_string(),\n                user: GitHubUser {\n                    login: \"alice\".to_string(),\n                    id: 12345,\n                    avatar_url: None,\n                },\n                authenticated_at: 1700000000,\n            },\n        );\n        accounts.insert(\n            \"67890\".to_string(),\n            GitHubAccountData {\n                github_token: \"gho_test_token_2\".to_string(),\n                user: GitHubUser {\n                    login: \"bob\".to_string(),\n                    id: 67890,\n                    avatar_url: None,\n                },\n                authenticated_at: 1700000001,\n            },\n        );\n\n        assert_eq!(\n            CopilotAuthManager::fallback_default_account_id(&accounts),\n            Some(\"67890\".to_string())\n        );\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/providers/gemini.rs",
    "content": "//! Gemini (Google) Provider Adapter\n//!\n//! 支持 API Key 和 OAuth 两种认证方式\n//!\n//! ## 认证模式\n//! - **Gemini**: API Key 认证 (x-goog-api-key)\n//! - **GeminiCli**: OAuth Bearer 认证 (用于 Gemini CLI)\n\nuse super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};\nuse crate::provider::Provider;\nuse crate::proxy::error::ProxyError;\nuse reqwest::RequestBuilder;\n\n/// Gemini 适配器\npub struct GeminiAdapter;\n\n/// OAuth 凭证结构\n#[derive(Debug, Clone)]\n#[allow(dead_code)]\npub struct OAuthCredentials {\n    pub access_token: String,\n    pub refresh_token: Option<String>,\n    pub client_id: Option<String>,\n    pub client_secret: Option<String>,\n}\n\n#[allow(dead_code)]\nimpl OAuthCredentials {\n    /// 检查是否需要刷新 token（有 refresh_token 但没有有效的 access_token）\n    pub fn needs_refresh(&self) -> bool {\n        self.refresh_token.is_some() && self.access_token.is_empty()\n    }\n\n    /// 检查是否可以刷新 token\n    pub fn can_refresh(&self) -> bool {\n        self.refresh_token.is_some() && self.client_id.is_some() && self.client_secret.is_some()\n    }\n}\n\nimpl GeminiAdapter {\n    pub fn new() -> Self {\n        Self\n    }\n\n    /// 获取供应商类型\n    ///\n    /// 根据 API Key 格式检测：\n    /// - GeminiCli: access_token (ya29. 开头) 或 JSON 格式凭证\n    /// - Gemini: 普通 API Key\n    pub fn provider_type(&self, provider: &Provider) -> ProviderType {\n        if let Some(key) = self.extract_key_raw(provider) {\n            // OAuth access_token 以 ya29. 开头\n            if key.starts_with(\"ya29.\") {\n                return ProviderType::GeminiCli;\n            }\n            // JSON 格式的 OAuth 凭证\n            if key.starts_with('{') {\n                return ProviderType::GeminiCli;\n            }\n        }\n        ProviderType::Gemini\n    }\n\n    /// 检测认证类型\n    pub fn detect_auth_type(&self, provider: &Provider) -> AuthStrategy {\n        match self.provider_type(provider) {\n            ProviderType::GeminiCli => AuthStrategy::GoogleOAuth,\n            _ => AuthStrategy::Google,\n        }\n    }\n\n    /// 解析 OAuth 凭证\n    pub fn parse_oauth_credentials(&self, key: &str) -> Option<OAuthCredentials> {\n        // 直接是 access_token\n        if key.starts_with(\"ya29.\") {\n            return Some(OAuthCredentials {\n                access_token: key.to_string(),\n                refresh_token: None,\n                client_id: None,\n                client_secret: None,\n            });\n        }\n\n        // JSON 格式\n        if key.starts_with('{') {\n            if let Ok(json) = serde_json::from_str::<serde_json::Value>(key) {\n                let access_token = json\n                    .get(\"access_token\")\n                    .and_then(|v| v.as_str())\n                    .map(|s| s.to_string())\n                    .unwrap_or_default();\n                let refresh_token = json\n                    .get(\"refresh_token\")\n                    .and_then(|v| v.as_str())\n                    .map(|s| s.to_string());\n                let client_id = json\n                    .get(\"client_id\")\n                    .and_then(|v| v.as_str())\n                    .map(|s| s.to_string());\n                let client_secret = json\n                    .get(\"client_secret\")\n                    .and_then(|v| v.as_str())\n                    .map(|s| s.to_string());\n\n                // 如果有 access_token 或 refresh_token，返回凭证\n                if !access_token.is_empty() || refresh_token.is_some() {\n                    return Some(OAuthCredentials {\n                        access_token,\n                        refresh_token,\n                        client_id,\n                        client_secret,\n                    });\n                }\n            }\n        }\n\n        None\n    }\n\n    /// 从 Provider 配置中提取原始 API Key\n    fn extract_key_raw(&self, provider: &Provider) -> Option<String> {\n        if let Some(env) = provider.settings_config.get(\"env\") {\n            // 使用 GEMINI_API_KEY\n            if let Some(key) = env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) {\n                return Some(key.to_string());\n            }\n        }\n\n        // 尝试直接获取\n        if let Some(key) = provider\n            .settings_config\n            .get(\"apiKey\")\n            .or_else(|| provider.settings_config.get(\"api_key\"))\n            .and_then(|v| v.as_str())\n        {\n            return Some(key.to_string());\n        }\n\n        None\n    }\n}\n\nimpl Default for GeminiAdapter {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n\nimpl ProviderAdapter for GeminiAdapter {\n    fn name(&self) -> &'static str {\n        \"Gemini\"\n    }\n\n    fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {\n        // 从 env 中获取\n        if let Some(env) = provider.settings_config.get(\"env\") {\n            if let Some(url) = env.get(\"GOOGLE_GEMINI_BASE_URL\").and_then(|v| v.as_str()) {\n                return Ok(url.trim_end_matches('/').to_string());\n            }\n        }\n\n        // 尝试直接获取\n        if let Some(url) = provider\n            .settings_config\n            .get(\"base_url\")\n            .and_then(|v| v.as_str())\n        {\n            return Ok(url.trim_end_matches('/').to_string());\n        }\n\n        if let Some(url) = provider\n            .settings_config\n            .get(\"baseURL\")\n            .and_then(|v| v.as_str())\n        {\n            return Ok(url.trim_end_matches('/').to_string());\n        }\n\n        Err(ProxyError::ConfigError(\n            \"Gemini Provider 缺少 base_url 配置\".to_string(),\n        ))\n    }\n\n    fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {\n        let key = self.extract_key_raw(provider)?;\n        let strategy = self.detect_auth_type(provider);\n\n        match strategy {\n            AuthStrategy::GoogleOAuth => {\n                // 解析 OAuth 凭证\n                if let Some(creds) = self.parse_oauth_credentials(&key) {\n                    Some(AuthInfo::with_access_token(key, creds.access_token))\n                } else {\n                    // 回退到普通 API Key\n                    Some(AuthInfo::new(key, AuthStrategy::Google))\n                }\n            }\n            _ => Some(AuthInfo::new(key, AuthStrategy::Google)),\n        }\n    }\n\n    fn build_url(&self, base_url: &str, endpoint: &str) -> String {\n        let base_trimmed = base_url.trim_end_matches('/');\n        let endpoint_trimmed = endpoint.trim_start_matches('/');\n\n        let mut url = format!(\"{base_trimmed}/{endpoint_trimmed}\");\n\n        // 处理 /v1beta 路径去重\n        let version_patterns = [\"/v1beta\", \"/v1\"];\n        for pattern in &version_patterns {\n            let duplicate = format!(\"{pattern}{pattern}\");\n            if url.contains(&duplicate) {\n                url = url.replace(&duplicate, pattern);\n            }\n        }\n\n        url\n    }\n\n    fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {\n        match auth.strategy {\n            // OAuth Bearer 认证\n            AuthStrategy::GoogleOAuth => {\n                let token = auth.access_token.as_ref().unwrap_or(&auth.api_key);\n                request\n                    .header(\"Authorization\", format!(\"Bearer {token}\"))\n                    .header(\"x-goog-api-client\", \"GeminiCLI/1.0\")\n            }\n            // API Key 认证\n            _ => request.header(\"x-goog-api-key\", &auth.api_key),\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use serde_json::json;\n\n    fn create_provider(config: serde_json::Value) -> Provider {\n        Provider {\n            id: \"test\".to_string(),\n            name: \"Test Gemini\".to_string(),\n            settings_config: config,\n            website_url: None,\n            category: Some(\"gemini\".to_string()),\n            created_at: None,\n            sort_index: None,\n            notes: None,\n            meta: None,\n            icon: None,\n            icon_color: None,\n            in_failover_queue: false,\n        }\n    }\n\n    #[test]\n    fn test_extract_base_url_from_env() {\n        let adapter = GeminiAdapter::new();\n        let provider = create_provider(json!({\n            \"env\": {\n                \"GOOGLE_GEMINI_BASE_URL\": \"https://generativelanguage.googleapis.com/v1beta\"\n            }\n        }));\n\n        let url = adapter.extract_base_url(&provider).unwrap();\n        assert_eq!(url, \"https://generativelanguage.googleapis.com/v1beta\");\n    }\n\n    #[test]\n    fn test_extract_auth_api_key() {\n        let adapter = GeminiAdapter::new();\n        let provider = create_provider(json!({\n            \"env\": {\n                \"GEMINI_API_KEY\": \"AIza-test-key-12345678\"\n            }\n        }));\n\n        let auth = adapter.extract_auth(&provider).unwrap();\n        assert_eq!(auth.api_key, \"AIza-test-key-12345678\");\n        assert_eq!(auth.strategy, AuthStrategy::Google);\n        assert!(auth.access_token.is_none());\n    }\n\n    #[test]\n    fn test_extract_auth_oauth_access_token() {\n        let adapter = GeminiAdapter::new();\n        let provider = create_provider(json!({\n            \"env\": {\n                \"GEMINI_API_KEY\": \"ya29.test-access-token-12345\"\n            }\n        }));\n\n        let auth = adapter.extract_auth(&provider).unwrap();\n        assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);\n        assert_eq!(\n            auth.access_token,\n            Some(\"ya29.test-access-token-12345\".to_string())\n        );\n    }\n\n    #[test]\n    fn test_extract_auth_oauth_json() {\n        let adapter = GeminiAdapter::new();\n        let provider = create_provider(json!({\n            \"env\": {\n                \"GEMINI_API_KEY\": \"{\\\"access_token\\\":\\\"ya29.test-token\\\",\\\"refresh_token\\\":\\\"1//refresh\\\"}\"\n            }\n        }));\n\n        let auth = adapter.extract_auth(&provider).unwrap();\n        assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);\n        assert_eq!(auth.access_token, Some(\"ya29.test-token\".to_string()));\n    }\n\n    #[test]\n    fn test_provider_type_detection() {\n        let adapter = GeminiAdapter::new();\n\n        // API Key\n        let api_key_provider = create_provider(json!({\n            \"env\": {\n                \"GEMINI_API_KEY\": \"AIza-test-key\"\n            }\n        }));\n        assert_eq!(\n            adapter.provider_type(&api_key_provider),\n            ProviderType::Gemini\n        );\n\n        // OAuth access_token\n        let oauth_provider = create_provider(json!({\n            \"env\": {\n                \"GEMINI_API_KEY\": \"ya29.test-token\"\n            }\n        }));\n        assert_eq!(\n            adapter.provider_type(&oauth_provider),\n            ProviderType::GeminiCli\n        );\n\n        // OAuth JSON\n        let oauth_json_provider = create_provider(json!({\n            \"env\": {\n                \"GEMINI_API_KEY\": \"{\\\"access_token\\\":\\\"ya29.test\\\"}\"\n            }\n        }));\n        assert_eq!(\n            adapter.provider_type(&oauth_json_provider),\n            ProviderType::GeminiCli\n        );\n    }\n\n    #[test]\n    fn test_extract_auth_fallback() {\n        let adapter = GeminiAdapter::new();\n        let provider = create_provider(json!({\n            \"env\": {\n                \"GEMINI_API_KEY\": \"AIza-fallback-key\"\n            }\n        }));\n\n        let auth = adapter.extract_auth(&provider).unwrap();\n        assert_eq!(auth.api_key, \"AIza-fallback-key\");\n    }\n\n    #[test]\n    fn test_build_url_dedup() {\n        let adapter = GeminiAdapter::new();\n        // 模拟 base_url 已包含 /v1beta，endpoint 也包含 /v1beta\n        let url = adapter.build_url(\n            \"https://generativelanguage.googleapis.com/v1beta\",\n            \"/v1beta/models/gemini-pro:generateContent\",\n        );\n        assert_eq!(\n            url,\n            \"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent\"\n        );\n    }\n\n    #[test]\n    fn test_build_url_normal() {\n        let adapter = GeminiAdapter::new();\n        let url = adapter.build_url(\n            \"https://generativelanguage.googleapis.com/v1beta\",\n            \"/models/gemini-pro:generateContent\",\n        );\n        assert_eq!(\n            url,\n            \"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent\"\n        );\n    }\n\n    #[test]\n    fn test_parse_oauth_credentials_direct_token() {\n        let adapter = GeminiAdapter::new();\n        let creds = adapter\n            .parse_oauth_credentials(\"ya29.test-access-token\")\n            .unwrap();\n        assert_eq!(creds.access_token, \"ya29.test-access-token\");\n        assert!(creds.refresh_token.is_none());\n    }\n\n    #[test]\n    fn test_parse_oauth_credentials_json() {\n        let adapter = GeminiAdapter::new();\n        let creds = adapter\n            .parse_oauth_credentials(\n                \"{\\\"access_token\\\":\\\"ya29.test\\\",\\\"refresh_token\\\":\\\"1//refresh\\\"}\",\n            )\n            .unwrap();\n        assert_eq!(creds.access_token, \"ya29.test\");\n        assert_eq!(creds.refresh_token, Some(\"1//refresh\".to_string()));\n    }\n\n    #[test]\n    fn test_parse_oauth_credentials_invalid() {\n        let adapter = GeminiAdapter::new();\n        assert!(adapter.parse_oauth_credentials(\"AIza-api-key\").is_none());\n        assert!(adapter.parse_oauth_credentials(\"invalid-json{\").is_none());\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/providers/mod.rs",
    "content": "//! Provider Adapters Module\n//!\n//! 供应商适配器模块，提供统一的接口抽象不同上游供应商的处理逻辑。\n//!\n//! ## 模块结构\n//! - `adapter`: 定义 `ProviderAdapter` trait\n//! - `auth`: 认证类型和策略\n//! - `claude`: Claude (Anthropic) 适配器\n//! - `codex`: Codex (OpenAI) 适配器\n//! - `gemini`: Gemini (Google) 适配器\n//! - `models`: API 数据模型\n//! - `transform`: 格式转换\n\nmod adapter;\nmod auth;\nmod claude;\nmod codex;\npub mod copilot_auth;\nmod gemini;\npub mod models;\npub mod streaming;\npub mod streaming_responses;\npub mod transform;\npub mod transform_responses;\n\nuse crate::app_config::AppType;\nuse crate::provider::Provider;\nuse serde::{Deserialize, Serialize};\n\n// 公开导出\npub use adapter::ProviderAdapter;\npub use auth::{AuthInfo, AuthStrategy};\npub use claude::{get_claude_api_format, ClaudeAdapter};\npub use codex::CodexAdapter;\npub use gemini::GeminiAdapter;\n\n/// 供应商类型枚举\n///\n/// 区分不同供应商的具体实现方式，决定认证和请求处理逻辑。\n/// 比 AppType 更细粒度，支持同一 AppType 下的多种变体。\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum ProviderType {\n    /// Anthropic 官方 API (x-api-key + anthropic-version)\n    Claude,\n    /// Claude 中转服务 (仅 Bearer 认证，无 x-api-key)\n    ClaudeAuth,\n    /// OpenAI Codex Response API\n    Codex,\n    /// Google Gemini API (x-goog-api-key)\n    Gemini,\n    /// Google Gemini CLI (OAuth Bearer)\n    GeminiCli,\n    /// OpenRouter（已支持 Claude Code 兼容接口，默认透传；保留旧转换逻辑备用）\n    OpenRouter,\n    /// GitHub Copilot (OAuth + Copilot Token，需要 Anthropic ↔ OpenAI 转换)\n    GitHubCopilot,\n}\n\nimpl ProviderType {\n    /// 是否需要格式转换\n    ///\n    /// 过去 OpenRouter 需要将 Anthropic 格式转换为 OpenAI 格式；\n    /// 现在默认关闭转换（因为 OpenRouter 已支持 Claude Code 兼容接口）。\n    /// GitHub Copilot 需要转换（Anthropic → OpenAI 格式）。\n    #[allow(dead_code)]\n    pub fn needs_transform(&self) -> bool {\n        match self {\n            ProviderType::GitHubCopilot => true,\n            ProviderType::OpenRouter => false,\n            _ => false,\n        }\n    }\n\n    /// 获取默认端点\n    #[allow(dead_code)]\n    pub fn default_endpoint(&self) -> &'static str {\n        match self {\n            ProviderType::Claude | ProviderType::ClaudeAuth => \"https://api.anthropic.com\",\n            ProviderType::Codex => \"https://api.openai.com\",\n            ProviderType::Gemini | ProviderType::GeminiCli => {\n                \"https://generativelanguage.googleapis.com\"\n            }\n            ProviderType::OpenRouter => \"https://openrouter.ai/api\",\n            ProviderType::GitHubCopilot => \"https://api.githubcopilot.com\",\n        }\n    }\n\n    /// 从 AppType 和 Provider 配置推断供应商类型\n    ///\n    /// 根据配置中的 base_url、auth_mode、api_key 格式等信息推断具体的供应商类型\n    #[allow(dead_code)]\n    pub fn from_app_type_and_config(app_type: &AppType, provider: &Provider) -> Self {\n        match app_type {\n            AppType::Claude => {\n                // 检测是否为 GitHub Copilot\n                if let Some(meta) = provider.meta.as_ref() {\n                    if meta.provider_type.as_deref() == Some(\"github_copilot\") {\n                        return ProviderType::GitHubCopilot;\n                    }\n                }\n\n                // 检测 base_url 是否为 GitHub Copilot\n                let adapter = ClaudeAdapter::new();\n                if let Ok(base_url) = adapter.extract_base_url(provider) {\n                    if base_url.contains(\"githubcopilot.com\") {\n                        return ProviderType::GitHubCopilot;\n                    }\n                    // 检测是否为 OpenRouter\n                    if base_url.contains(\"openrouter.ai\") {\n                        return ProviderType::OpenRouter;\n                    }\n                }\n                // 检测是否为中转服务（仅 Bearer 认证）\n                // 注意：ProviderMeta 没有直接的 auth_mode 字段，\n                // 我们通过检查 settings_config 中的配置来判断\n                // 检查 settings_config 中的 auth_mode\n                if let Some(auth_mode) = provider\n                    .settings_config\n                    .get(\"auth_mode\")\n                    .and_then(|v| v.as_str())\n                {\n                    if auth_mode == \"bearer_only\" {\n                        return ProviderType::ClaudeAuth;\n                    }\n                }\n                // 检查 env 中的 auth_mode\n                if let Some(env) = provider.settings_config.get(\"env\") {\n                    if let Some(auth_mode) = env.get(\"AUTH_MODE\").and_then(|v| v.as_str()) {\n                        if auth_mode == \"bearer_only\" {\n                            return ProviderType::ClaudeAuth;\n                        }\n                    }\n                }\n                ProviderType::Claude\n            }\n            AppType::Codex => ProviderType::Codex,\n            AppType::Gemini => {\n                // 检测是否为 CLI 模式（OAuth）\n                let adapter = GeminiAdapter::new();\n                if let Some(auth) = adapter.extract_auth(provider) {\n                    let key = &auth.api_key;\n                    // OAuth access_token 以 ya29. 开头\n                    if key.starts_with(\"ya29.\") {\n                        return ProviderType::GeminiCli;\n                    }\n                    // JSON 格式的 OAuth 凭证\n                    if key.starts_with('{') {\n                        return ProviderType::GeminiCli;\n                    }\n                }\n                ProviderType::Gemini\n            }\n            AppType::OpenCode => {\n                // OpenCode doesn't support proxy, but return a default type for completeness\n                ProviderType::Codex // Fallback to Codex-like type\n            }\n            AppType::OpenClaw => {\n                // OpenClaw doesn't support proxy, but return a default type for completeness\n                ProviderType::Codex // Fallback to Codex-like type\n            }\n        }\n    }\n\n    /// 转换为字符串表示\n    pub fn as_str(&self) -> &'static str {\n        match self {\n            ProviderType::Claude => \"claude\",\n            ProviderType::ClaudeAuth => \"claude_auth\",\n            ProviderType::Codex => \"codex\",\n            ProviderType::Gemini => \"gemini\",\n            ProviderType::GeminiCli => \"gemini_cli\",\n            ProviderType::OpenRouter => \"openrouter\",\n            ProviderType::GitHubCopilot => \"github_copilot\",\n        }\n    }\n}\n\nimpl std::fmt::Display for ProviderType {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        write!(f, \"{}\", self.as_str())\n    }\n}\n\nimpl std::str::FromStr for ProviderType {\n    type Err = String;\n\n    fn from_str(s: &str) -> Result<Self, Self::Err> {\n        match s.to_lowercase().as_str() {\n            \"claude\" => Ok(ProviderType::Claude),\n            \"claude_auth\" | \"claude-auth\" => Ok(ProviderType::ClaudeAuth),\n            \"codex\" => Ok(ProviderType::Codex),\n            \"gemini\" => Ok(ProviderType::Gemini),\n            \"gemini_cli\" | \"gemini-cli\" => Ok(ProviderType::GeminiCli),\n            \"openrouter\" => Ok(ProviderType::OpenRouter),\n            \"github_copilot\" | \"github-copilot\" | \"githubcopilot\" => {\n                Ok(ProviderType::GitHubCopilot)\n            }\n            _ => Err(format!(\"Invalid provider type: {s}\")),\n        }\n    }\n}\n\n/// 根据 AppType 获取对应的适配器\npub fn get_adapter(app_type: &AppType) -> Box<dyn ProviderAdapter> {\n    match app_type {\n        AppType::Claude => Box::new(ClaudeAdapter::new()),\n        AppType::Codex => Box::new(CodexAdapter::new()),\n        AppType::Gemini => Box::new(GeminiAdapter::new()),\n        AppType::OpenCode => {\n            // OpenCode doesn't support proxy, fallback to Codex adapter\n            Box::new(CodexAdapter::new())\n        }\n        AppType::OpenClaw => {\n            // OpenClaw doesn't support proxy, fallback to Codex adapter\n            Box::new(CodexAdapter::new())\n        }\n    }\n}\n\n/// 根据 ProviderType 获取对应的适配器\n#[allow(dead_code)]\npub fn get_adapter_for_provider_type(provider_type: &ProviderType) -> Box<dyn ProviderAdapter> {\n    match provider_type {\n        ProviderType::Claude\n        | ProviderType::ClaudeAuth\n        | ProviderType::OpenRouter\n        | ProviderType::GitHubCopilot => Box::new(ClaudeAdapter::new()),\n        ProviderType::Codex => Box::new(CodexAdapter::new()),\n        ProviderType::Gemini | ProviderType::GeminiCli => Box::new(GeminiAdapter::new()),\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use serde_json::json;\n\n    fn create_provider(config: serde_json::Value) -> Provider {\n        Provider {\n            id: \"test\".to_string(),\n            name: \"Test Provider\".to_string(),\n            settings_config: config,\n            website_url: None,\n            category: None,\n            created_at: None,\n            sort_index: None,\n            notes: None,\n            meta: None,\n            icon: None,\n            icon_color: None,\n            in_failover_queue: false,\n        }\n    }\n\n    #[test]\n    fn test_provider_type_needs_transform() {\n        assert!(!ProviderType::Claude.needs_transform());\n        assert!(!ProviderType::ClaudeAuth.needs_transform());\n        assert!(!ProviderType::Codex.needs_transform());\n        assert!(!ProviderType::Gemini.needs_transform());\n        assert!(!ProviderType::GeminiCli.needs_transform());\n        assert!(!ProviderType::OpenRouter.needs_transform());\n        assert!(ProviderType::GitHubCopilot.needs_transform());\n    }\n\n    #[test]\n    fn test_provider_type_default_endpoint() {\n        assert_eq!(\n            ProviderType::Claude.default_endpoint(),\n            \"https://api.anthropic.com\"\n        );\n        assert_eq!(\n            ProviderType::ClaudeAuth.default_endpoint(),\n            \"https://api.anthropic.com\"\n        );\n        assert_eq!(\n            ProviderType::Codex.default_endpoint(),\n            \"https://api.openai.com\"\n        );\n        assert_eq!(\n            ProviderType::Gemini.default_endpoint(),\n            \"https://generativelanguage.googleapis.com\"\n        );\n        assert_eq!(\n            ProviderType::GeminiCli.default_endpoint(),\n            \"https://generativelanguage.googleapis.com\"\n        );\n        assert_eq!(\n            ProviderType::OpenRouter.default_endpoint(),\n            \"https://openrouter.ai/api\"\n        );\n        assert_eq!(\n            ProviderType::GitHubCopilot.default_endpoint(),\n            \"https://api.githubcopilot.com\"\n        );\n    }\n\n    #[test]\n    fn test_provider_type_from_str() {\n        assert_eq!(\n            \"claude\".parse::<ProviderType>().unwrap(),\n            ProviderType::Claude\n        );\n        assert_eq!(\n            \"claude_auth\".parse::<ProviderType>().unwrap(),\n            ProviderType::ClaudeAuth\n        );\n        assert_eq!(\n            \"claude-auth\".parse::<ProviderType>().unwrap(),\n            ProviderType::ClaudeAuth\n        );\n        assert_eq!(\n            \"codex\".parse::<ProviderType>().unwrap(),\n            ProviderType::Codex\n        );\n        assert_eq!(\n            \"gemini\".parse::<ProviderType>().unwrap(),\n            ProviderType::Gemini\n        );\n        assert_eq!(\n            \"gemini_cli\".parse::<ProviderType>().unwrap(),\n            ProviderType::GeminiCli\n        );\n        assert_eq!(\n            \"gemini-cli\".parse::<ProviderType>().unwrap(),\n            ProviderType::GeminiCli\n        );\n        assert_eq!(\n            \"openrouter\".parse::<ProviderType>().unwrap(),\n            ProviderType::OpenRouter\n        );\n        assert_eq!(\n            \"github_copilot\".parse::<ProviderType>().unwrap(),\n            ProviderType::GitHubCopilot\n        );\n        assert_eq!(\n            \"github-copilot\".parse::<ProviderType>().unwrap(),\n            ProviderType::GitHubCopilot\n        );\n        assert_eq!(\n            \"githubcopilot\".parse::<ProviderType>().unwrap(),\n            ProviderType::GitHubCopilot\n        );\n        assert!(\"invalid\".parse::<ProviderType>().is_err());\n    }\n\n    #[test]\n    fn test_provider_type_as_str() {\n        assert_eq!(ProviderType::Claude.as_str(), \"claude\");\n        assert_eq!(ProviderType::ClaudeAuth.as_str(), \"claude_auth\");\n        assert_eq!(ProviderType::Codex.as_str(), \"codex\");\n        assert_eq!(ProviderType::Gemini.as_str(), \"gemini\");\n        assert_eq!(ProviderType::GeminiCli.as_str(), \"gemini_cli\");\n        assert_eq!(ProviderType::OpenRouter.as_str(), \"openrouter\");\n        assert_eq!(ProviderType::GitHubCopilot.as_str(), \"github_copilot\");\n    }\n\n    #[test]\n    fn test_provider_type_serde() {\n        // Test serialization\n        let claude = ProviderType::Claude;\n        let serialized = serde_json::to_string(&claude).unwrap();\n        assert_eq!(serialized, \"\\\"claude\\\"\");\n\n        let claude_auth = ProviderType::ClaudeAuth;\n        let serialized = serde_json::to_string(&claude_auth).unwrap();\n        assert_eq!(serialized, \"\\\"claude_auth\\\"\");\n\n        // Test deserialization\n        let deserialized: ProviderType = serde_json::from_str(\"\\\"claude\\\"\").unwrap();\n        assert_eq!(deserialized, ProviderType::Claude);\n\n        let deserialized: ProviderType = serde_json::from_str(\"\\\"gemini_cli\\\"\").unwrap();\n        assert_eq!(deserialized, ProviderType::GeminiCli);\n    }\n\n    #[test]\n    fn test_from_app_type_claude_direct() {\n        let provider = create_provider(json!({\n            \"env\": {\n                \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n                \"ANTHROPIC_AUTH_TOKEN\": \"sk-ant-test\"\n            }\n        }));\n\n        let provider_type = ProviderType::from_app_type_and_config(&AppType::Claude, &provider);\n        assert_eq!(provider_type, ProviderType::Claude);\n    }\n\n    #[test]\n    fn test_from_app_type_claude_openrouter() {\n        let provider = create_provider(json!({\n            \"env\": {\n                \"ANTHROPIC_BASE_URL\": \"https://openrouter.ai/api\",\n                \"OPENROUTER_API_KEY\": \"sk-or-test\"\n            }\n        }));\n\n        let provider_type = ProviderType::from_app_type_and_config(&AppType::Claude, &provider);\n        assert_eq!(provider_type, ProviderType::OpenRouter);\n    }\n\n    #[test]\n    fn test_from_app_type_claude_auth() {\n        let provider = create_provider(json!({\n            \"env\": {\n                \"ANTHROPIC_BASE_URL\": \"https://some-proxy.com\",\n                \"ANTHROPIC_AUTH_TOKEN\": \"sk-test\"\n            },\n            \"auth_mode\": \"bearer_only\"\n        }));\n\n        let provider_type = ProviderType::from_app_type_and_config(&AppType::Claude, &provider);\n        assert_eq!(provider_type, ProviderType::ClaudeAuth);\n    }\n\n    #[test]\n    fn test_from_app_type_codex() {\n        let provider = create_provider(json!({\n            \"env\": {\n                \"OPENAI_API_KEY\": \"sk-test\"\n            }\n        }));\n\n        let provider_type = ProviderType::from_app_type_and_config(&AppType::Codex, &provider);\n        assert_eq!(provider_type, ProviderType::Codex);\n    }\n\n    #[test]\n    fn test_from_app_type_gemini_api_key() {\n        let provider = create_provider(json!({\n            \"env\": {\n                \"GEMINI_API_KEY\": \"AIza-test-key\"\n            }\n        }));\n\n        let provider_type = ProviderType::from_app_type_and_config(&AppType::Gemini, &provider);\n        assert_eq!(provider_type, ProviderType::Gemini);\n    }\n\n    #[test]\n    fn test_from_app_type_gemini_cli_oauth() {\n        let provider = create_provider(json!({\n            \"env\": {\n                \"GEMINI_API_KEY\": \"ya29.test-access-token\"\n            }\n        }));\n\n        let provider_type = ProviderType::from_app_type_and_config(&AppType::Gemini, &provider);\n        assert_eq!(provider_type, ProviderType::GeminiCli);\n    }\n\n    #[test]\n    fn test_from_app_type_gemini_cli_json() {\n        let provider = create_provider(json!({\n            \"env\": {\n                \"GEMINI_API_KEY\": \"{\\\"access_token\\\":\\\"ya29.test\\\",\\\"refresh_token\\\":\\\"1//test\\\"}\"\n            }\n        }));\n\n        let provider_type = ProviderType::from_app_type_and_config(&AppType::Gemini, &provider);\n        assert_eq!(provider_type, ProviderType::GeminiCli);\n    }\n\n    #[test]\n    fn test_get_adapter_for_provider_type() {\n        let adapter = get_adapter_for_provider_type(&ProviderType::Claude);\n        assert_eq!(adapter.name(), \"Claude\");\n\n        let adapter = get_adapter_for_provider_type(&ProviderType::ClaudeAuth);\n        assert_eq!(adapter.name(), \"Claude\");\n\n        let adapter = get_adapter_for_provider_type(&ProviderType::OpenRouter);\n        assert_eq!(adapter.name(), \"Claude\");\n\n        let adapter = get_adapter_for_provider_type(&ProviderType::GitHubCopilot);\n        assert_eq!(adapter.name(), \"Claude\");\n\n        let adapter = get_adapter_for_provider_type(&ProviderType::Codex);\n        assert_eq!(adapter.name(), \"Codex\");\n\n        let adapter = get_adapter_for_provider_type(&ProviderType::Gemini);\n        assert_eq!(adapter.name(), \"Gemini\");\n\n        let adapter = get_adapter_for_provider_type(&ProviderType::GeminiCli);\n        assert_eq!(adapter.name(), \"Gemini\");\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/providers/models/anthropic.rs",
    "content": "//! Anthropic API 数据模型\n//!\n//! 用于 Anthropic Messages API 的请求/响应格式转换\n\n#![allow(dead_code)]\n\nuse serde::{Deserialize, Serialize};\nuse serde_json::Value;\n\n/// Anthropic 请求\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct AnthropicRequest {\n    pub model: String,\n    pub messages: Vec<AnthropicMessage>,\n    pub max_tokens: u32,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub system: Option<Value>, // 可以是 String 或 Vec<SystemBlock>\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub temperature: Option<f32>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub stream: Option<bool>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub tools: Option<Vec<AnthropicTool>>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub tool_choice: Option<Value>,\n}\n\n/// Anthropic 消息\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct AnthropicMessage {\n    pub role: String,\n    pub content: Value, // String 或 Vec<ContentBlock>\n}\n\n/// Anthropic 内容块\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(tag = \"type\")]\npub enum AnthropicContentBlock {\n    #[serde(rename = \"text\")]\n    Text { text: String },\n    #[serde(rename = \"image\")]\n    Image { source: ImageSource },\n    #[serde(rename = \"tool_use\")]\n    ToolUse {\n        id: String,\n        name: String,\n        input: Value,\n    },\n    #[serde(rename = \"tool_result\")]\n    ToolResult { tool_use_id: String, content: Value },\n}\n\n/// 图片来源\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ImageSource {\n    #[serde(rename = \"type\")]\n    pub source_type: String,\n    pub media_type: String,\n    pub data: String,\n}\n\n/// Anthropic 工具定义\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct AnthropicTool {\n    pub name: String,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub description: Option<String>,\n    pub input_schema: Value,\n}\n\n/// Anthropic 响应\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct AnthropicResponse {\n    pub id: String,\n    #[serde(rename = \"type\")]\n    pub response_type: String,\n    pub role: String,\n    pub content: Vec<AnthropicResponseContent>,\n    pub model: String,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub stop_reason: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub stop_sequence: Option<String>,\n    pub usage: AnthropicUsage,\n}\n\n/// Anthropic 响应内容\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(tag = \"type\")]\npub enum AnthropicResponseContent {\n    #[serde(rename = \"text\")]\n    Text { text: String },\n    #[serde(rename = \"tool_use\")]\n    ToolUse {\n        id: String,\n        name: String,\n        input: Value,\n    },\n}\n\n/// Anthropic 使用量\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct AnthropicUsage {\n    pub input_tokens: u32,\n    pub output_tokens: u32,\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/providers/models/mod.rs",
    "content": "//! API 数据模型\n//!\n//! 定义 Anthropic 和 OpenAI API 的请求/响应结构\n\npub mod anthropic;\npub mod openai;\n"
  },
  {
    "path": "src-tauri/src/proxy/providers/models/openai.rs",
    "content": "//! OpenAI API 数据模型\n//!\n//! 用于 OpenAI Chat Completions API 的请求/响应格式转换\n\n#![allow(dead_code)]\n\nuse serde::{Deserialize, Serialize};\nuse serde_json::Value;\n\n/// OpenAI 请求\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OpenAIRequest {\n    pub model: String,\n    pub messages: Vec<OpenAIMessage>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub max_tokens: Option<u32>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub temperature: Option<f32>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub stream: Option<bool>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub tools: Option<Vec<OpenAITool>>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub tool_choice: Option<Value>,\n}\n\n/// OpenAI 消息\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OpenAIMessage {\n    pub role: String,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub content: Option<Value>, // String 或 Vec<ContentPart>\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub tool_calls: Option<Vec<OpenAIToolCall>>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub tool_call_id: Option<String>,\n}\n\n/// OpenAI 内容部分\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(tag = \"type\")]\npub enum OpenAIContentPart {\n    #[serde(rename = \"text\")]\n    Text { text: String },\n    #[serde(rename = \"image_url\")]\n    ImageUrl { image_url: ImageUrl },\n}\n\n/// 图片 URL\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ImageUrl {\n    pub url: String,\n}\n\n/// OpenAI 工具调用\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OpenAIToolCall {\n    pub id: String,\n    #[serde(rename = \"type\")]\n    pub call_type: String,\n    pub function: OpenAIFunction,\n}\n\n/// OpenAI 函数\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OpenAIFunction {\n    pub name: String,\n    pub arguments: String, // JSON 字符串\n}\n\n/// OpenAI 工具定义\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OpenAITool {\n    #[serde(rename = \"type\")]\n    pub tool_type: String,\n    pub function: OpenAIFunctionDef,\n}\n\n/// OpenAI 函数定义\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OpenAIFunctionDef {\n    pub name: String,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub description: Option<String>,\n    pub parameters: Value,\n}\n\n/// OpenAI 响应\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OpenAIResponse {\n    pub id: String,\n    pub object: String,\n    pub created: u64,\n    pub model: String,\n    pub choices: Vec<OpenAIChoice>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub usage: Option<OpenAIUsage>,\n}\n\n/// OpenAI 选择\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OpenAIChoice {\n    pub index: u32,\n    pub message: OpenAIMessage,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub finish_reason: Option<String>,\n}\n\n/// OpenAI 使用量\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OpenAIUsage {\n    pub prompt_tokens: u32,\n    pub completion_tokens: u32,\n    pub total_tokens: u32,\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/providers/streaming.rs",
    "content": "//! 流式响应转换模块\n//!\n//! 实现 OpenAI SSE → Anthropic SSE 格式转换\n\nuse bytes::Bytes;\nuse futures::stream::{Stream, StreamExt};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\nuse std::collections::{HashMap, HashSet};\n\n/// OpenAI 流式响应数据结构\n#[derive(Debug, Deserialize)]\nstruct OpenAIStreamChunk {\n    id: String,\n    model: String,\n    choices: Vec<StreamChoice>,\n    #[serde(default)]\n    usage: Option<Usage>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct StreamChoice {\n    delta: Delta,\n    #[serde(default)]\n    finish_reason: Option<String>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct Delta {\n    #[serde(default)]\n    content: Option<String>,\n    #[serde(default)]\n    reasoning: Option<String>, // OpenRouter 的推理内容\n    #[serde(default)]\n    tool_calls: Option<Vec<DeltaToolCall>>,\n}\n\n#[derive(Debug, Deserialize, Serialize)]\nstruct DeltaToolCall {\n    index: usize,\n    #[serde(default)]\n    id: Option<String>,\n    #[serde(rename = \"type\", default)]\n    call_type: Option<String>,\n    #[serde(default)]\n    function: Option<DeltaFunction>,\n}\n\n#[derive(Debug, Deserialize, Serialize)]\nstruct DeltaFunction {\n    #[serde(default)]\n    name: Option<String>,\n    #[serde(default)]\n    arguments: Option<String>,\n}\n\n/// OpenAI 流式响应的 usage 信息（完整版）\n#[derive(Debug, Deserialize)]\nstruct Usage {\n    #[serde(default)]\n    prompt_tokens: u32,\n    #[serde(default)]\n    completion_tokens: u32,\n    #[serde(default)]\n    prompt_tokens_details: Option<PromptTokensDetails>,\n    /// Some compatible servers return Anthropic-style cache fields directly\n    #[serde(default)]\n    cache_read_input_tokens: Option<u32>,\n    #[serde(default)]\n    cache_creation_input_tokens: Option<u32>,\n}\n\n/// Nested token details from OpenAI format\n#[derive(Debug, Deserialize)]\nstruct PromptTokensDetails {\n    #[serde(default)]\n    cached_tokens: u32,\n}\n\n#[derive(Debug, Clone)]\nstruct ToolBlockState {\n    anthropic_index: u32,\n    id: String,\n    name: String,\n    started: bool,\n    pending_args: String,\n}\n\n/// 创建 Anthropic SSE 流\npub fn create_anthropic_sse_stream(\n    stream: impl Stream<Item = Result<Bytes, reqwest::Error>> + Send + 'static,\n) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {\n    async_stream::stream! {\n        let mut buffer = String::new();\n        let mut message_id = None;\n        let mut current_model = None;\n        let mut next_content_index: u32 = 0;\n        let mut has_sent_message_start = false;\n        let mut current_non_tool_block_type: Option<&'static str> = None;\n        let mut current_non_tool_block_index: Option<u32> = None;\n        let mut tool_blocks_by_index: HashMap<usize, ToolBlockState> = HashMap::new();\n        let mut open_tool_block_indices: HashSet<u32> = HashSet::new();\n\n        tokio::pin!(stream);\n\n        while let Some(chunk) = stream.next().await {\n            match chunk {\n                Ok(bytes) => {\n                    let text = String::from_utf8_lossy(&bytes);\n                    buffer.push_str(&text);\n\n                    while let Some(pos) = buffer.find(\"\\n\\n\") {\n                        let line = buffer[..pos].to_string();\n                        buffer = buffer[pos + 2..].to_string();\n\n                        if line.trim().is_empty() {\n                            continue;\n                        }\n\n                        for l in line.lines() {\n                            if let Some(data) = l.strip_prefix(\"data: \") {\n                                if data.trim() == \"[DONE]\" {\n                                    log::debug!(\"[Claude/OpenRouter] <<< OpenAI SSE: [DONE]\");\n                                    let event = json!({\"type\": \"message_stop\"});\n                                    let sse_data = format!(\"event: message_stop\\ndata: {}\\n\\n\",\n                                        serde_json::to_string(&event).unwrap_or_default());\n                                    log::debug!(\"[Claude/OpenRouter] >>> Anthropic SSE: message_stop\");\n                                    yield Ok(Bytes::from(sse_data));\n                                    continue;\n                                }\n\n                                if let Ok(chunk) = serde_json::from_str::<OpenAIStreamChunk>(data) {\n                                    log::debug!(\"[Claude/OpenRouter] <<< SSE chunk received\");\n\n                                    if message_id.is_none() {\n                                        message_id = Some(chunk.id.clone());\n                                    }\n                                    if current_model.is_none() {\n                                        current_model = Some(chunk.model.clone());\n                                    }\n\n                                    if let Some(choice) = chunk.choices.first() {\n                                        if !has_sent_message_start {\n                                            // Build usage with cache tokens if available from first chunk\n                                            let mut start_usage = json!({\n                                                \"input_tokens\": 0,\n                                                \"output_tokens\": 0\n                                            });\n                                            if let Some(u) = &chunk.usage {\n                                                start_usage[\"input_tokens\"] = json!(u.prompt_tokens);\n                                                if let Some(cached) = extract_cache_read_tokens(u) {\n                                                    start_usage[\"cache_read_input_tokens\"] = json!(cached);\n                                                }\n                                                if let Some(created) = u.cache_creation_input_tokens {\n                                                    start_usage[\"cache_creation_input_tokens\"] = json!(created);\n                                                }\n                                            }\n\n                                            let event = json!({\n                                                \"type\": \"message_start\",\n                                                \"message\": {\n                                                    \"id\": message_id.clone().unwrap_or_default(),\n                                                    \"type\": \"message\",\n                                                    \"role\": \"assistant\",\n                                                    \"model\": current_model.clone().unwrap_or_default(),\n                                                    \"usage\": start_usage\n                                                }\n                                            });\n                                            let sse_data = format!(\"event: message_start\\ndata: {}\\n\\n\",\n                                                serde_json::to_string(&event).unwrap_or_default());\n                                            yield Ok(Bytes::from(sse_data));\n                                            has_sent_message_start = true;\n                                        }\n\n                                        // 处理 reasoning（thinking）\n                                        if let Some(reasoning) = &choice.delta.reasoning {\n                                            if current_non_tool_block_type != Some(\"thinking\") {\n                                                if let Some(index) = current_non_tool_block_index.take() {\n                                                    let event = json!({\n                                                        \"type\": \"content_block_stop\",\n                                                        \"index\": index\n                                                    });\n                                                    let sse_data = format!(\"event: content_block_stop\\ndata: {}\\n\\n\",\n                                                        serde_json::to_string(&event).unwrap_or_default());\n                                                    yield Ok(Bytes::from(sse_data));\n                                                }\n                                                let index = next_content_index;\n                                                next_content_index += 1;\n                                                let event = json!({\n                                                    \"type\": \"content_block_start\",\n                                                    \"index\": index,\n                                                    \"content_block\": {\n                                                        \"type\": \"thinking\",\n                                                        \"thinking\": \"\"\n                                                    }\n                                                });\n                                                let sse_data = format!(\"event: content_block_start\\ndata: {}\\n\\n\",\n                                                    serde_json::to_string(&event).unwrap_or_default());\n                                                yield Ok(Bytes::from(sse_data));\n                                                current_non_tool_block_type = Some(\"thinking\");\n                                                current_non_tool_block_index = Some(index);\n                                            }\n\n                                            if let Some(index) = current_non_tool_block_index {\n                                                let event = json!({\n                                                    \"type\": \"content_block_delta\",\n                                                    \"index\": index,\n                                                    \"delta\": {\n                                                        \"type\": \"thinking_delta\",\n                                                        \"thinking\": reasoning\n                                                    }\n                                                });\n                                                let sse_data = format!(\"event: content_block_delta\\ndata: {}\\n\\n\",\n                                                    serde_json::to_string(&event).unwrap_or_default());\n                                                yield Ok(Bytes::from(sse_data));\n                                            }\n                                        }\n\n                                        // 处理文本内容\n                                        if let Some(content) = &choice.delta.content {\n                                            if !content.is_empty() {\n                                                if current_non_tool_block_type != Some(\"text\") {\n                                                    if let Some(index) = current_non_tool_block_index.take() {\n                                                        let event = json!({\n                                                            \"type\": \"content_block_stop\",\n                                                            \"index\": index\n                                                        });\n                                                        let sse_data = format!(\"event: content_block_stop\\ndata: {}\\n\\n\",\n                                                            serde_json::to_string(&event).unwrap_or_default());\n                                                        yield Ok(Bytes::from(sse_data));\n                                                    }\n\n                                                    let index = next_content_index;\n                                                    next_content_index += 1;\n                                                    let event = json!({\n                                                        \"type\": \"content_block_start\",\n                                                        \"index\": index,\n                                                        \"content_block\": {\n                                                            \"type\": \"text\",\n                                                            \"text\": \"\"\n                                                        }\n                                                    });\n                                                    let sse_data = format!(\"event: content_block_start\\ndata: {}\\n\\n\",\n                                                        serde_json::to_string(&event).unwrap_or_default());\n                                                    yield Ok(Bytes::from(sse_data));\n                                                    current_non_tool_block_type = Some(\"text\");\n                                                    current_non_tool_block_index = Some(index);\n                                                }\n\n                                                if let Some(index) = current_non_tool_block_index {\n                                                    let event = json!({\n                                                        \"type\": \"content_block_delta\",\n                                                        \"index\": index,\n                                                        \"delta\": {\n                                                            \"type\": \"text_delta\",\n                                                            \"text\": content\n                                                        }\n                                                    });\n                                                    let sse_data = format!(\"event: content_block_delta\\ndata: {}\\n\\n\",\n                                                        serde_json::to_string(&event).unwrap_or_default());\n                                                    yield Ok(Bytes::from(sse_data));\n                                                }\n                                            }\n                                        }\n\n                                        // 处理工具调用\n                                        if let Some(tool_calls) = &choice.delta.tool_calls {\n                                            if let Some(index) = current_non_tool_block_index.take() {\n                                                let event = json!({\n                                                    \"type\": \"content_block_stop\",\n                                                    \"index\": index\n                                                });\n                                                let sse_data = format!(\"event: content_block_stop\\ndata: {}\\n\\n\",\n                                                    serde_json::to_string(&event).unwrap_or_default());\n                                                yield Ok(Bytes::from(sse_data));\n                                            }\n                                            current_non_tool_block_type = None;\n\n                                            for tool_call in tool_calls {\n                                                let (\n                                                    anthropic_index,\n                                                    id,\n                                                    name,\n                                                    should_start,\n                                                    pending_after_start,\n                                                    immediate_delta,\n                                                ) = {\n                                                    let state = tool_blocks_by_index\n                                                        .entry(tool_call.index)\n                                                        .or_insert_with(|| {\n                                                            let index = next_content_index;\n                                                            next_content_index += 1;\n                                                            ToolBlockState {\n                                                                anthropic_index: index,\n                                                                id: String::new(),\n                                                                name: String::new(),\n                                                                started: false,\n                                                                pending_args: String::new(),\n                                                            }\n                                                        });\n\n                                                    if let Some(id) = &tool_call.id {\n                                                        state.id = id.clone();\n                                                    }\n                                                    if let Some(function) = &tool_call.function {\n                                                        if let Some(name) = &function.name {\n                                                            state.name = name.clone();\n                                                        }\n                                                    }\n\n                                                    let should_start =\n                                                        !state.started\n                                                            && !state.id.is_empty()\n                                                            && !state.name.is_empty();\n                                                    if should_start {\n                                                        state.started = true;\n                                                    }\n                                                    let pending_after_start = if should_start\n                                                        && !state.pending_args.is_empty()\n                                                    {\n                                                        Some(std::mem::take(&mut state.pending_args))\n                                                    } else {\n                                                        None\n                                                    };\n                                                    let args_delta = tool_call\n                                                        .function\n                                                        .as_ref()\n                                                        .and_then(|f| f.arguments.clone());\n                                                    let immediate_delta = if let Some(args) = args_delta {\n                                                        if state.started {\n                                                            Some(args)\n                                                        } else {\n                                                            state.pending_args.push_str(&args);\n                                                            None\n                                                        }\n                                                    } else {\n                                                        None\n                                                    };\n                                                    (\n                                                        state.anthropic_index,\n                                                        state.id.clone(),\n                                                        state.name.clone(),\n                                                        should_start,\n                                                        pending_after_start,\n                                                        immediate_delta,\n                                                    )\n                                                };\n\n                                                if should_start {\n                                                    let event = json!({\n                                                        \"type\": \"content_block_start\",\n                                                        \"index\": anthropic_index,\n                                                        \"content_block\": {\n                                                            \"type\": \"tool_use\",\n                                                            \"id\": id,\n                                                            \"name\": name\n                                                        }\n                                                    });\n                                                    let sse_data = format!(\"event: content_block_start\\ndata: {}\\n\\n\",\n                                                        serde_json::to_string(&event).unwrap_or_default());\n                                                    yield Ok(Bytes::from(sse_data));\n                                                    open_tool_block_indices.insert(anthropic_index);\n                                                }\n\n                                                if let Some(args) = pending_after_start {\n                                                    let event = json!({\n                                                        \"type\": \"content_block_delta\",\n                                                        \"index\": anthropic_index,\n                                                        \"delta\": {\n                                                            \"type\": \"input_json_delta\",\n                                                            \"partial_json\": args\n                                                        }\n                                                    });\n                                                    let sse_data = format!(\"event: content_block_delta\\ndata: {}\\n\\n\",\n                                                        serde_json::to_string(&event).unwrap_or_default());\n                                                    yield Ok(Bytes::from(sse_data));\n                                                }\n\n                                                if let Some(args) = immediate_delta {\n                                                    let event = json!({\n                                                        \"type\": \"content_block_delta\",\n                                                        \"index\": anthropic_index,\n                                                        \"delta\": {\n                                                            \"type\": \"input_json_delta\",\n                                                            \"partial_json\": args\n                                                        }\n                                                    });\n                                                    let sse_data = format!(\"event: content_block_delta\\ndata: {}\\n\\n\",\n                                                        serde_json::to_string(&event).unwrap_or_default());\n                                                    yield Ok(Bytes::from(sse_data));\n                                                }\n                                            }\n                                        }\n\n                                        // 处理 finish_reason\n                                        if let Some(finish_reason) = &choice.finish_reason {\n                                            if let Some(index) = current_non_tool_block_index.take() {\n                                                let event = json!({\n                                                    \"type\": \"content_block_stop\",\n                                                    \"index\": index\n                                                });\n                                                let sse_data = format!(\"event: content_block_stop\\ndata: {}\\n\\n\",\n                                                    serde_json::to_string(&event).unwrap_or_default());\n                                                yield Ok(Bytes::from(sse_data));\n                                            }\n                                            current_non_tool_block_type = None;\n\n                                            // Late start for blocks that accumulated args before id/name arrived.\n                                            let mut late_tool_starts: Vec<(u32, String, String, String)> =\n                                                Vec::new();\n                                            for (tool_idx, state) in tool_blocks_by_index.iter_mut() {\n                                                if state.started {\n                                                    continue;\n                                                }\n                                                let has_payload = !state.pending_args.is_empty()\n                                                    || !state.id.is_empty()\n                                                    || !state.name.is_empty();\n                                                if !has_payload {\n                                                    continue;\n                                                }\n                                                let fallback_id = if state.id.is_empty() {\n                                                    format!(\"tool_call_{tool_idx}\")\n                                                } else {\n                                                    state.id.clone()\n                                                };\n                                                let fallback_name = if state.name.is_empty() {\n                                                    \"unknown_tool\".to_string()\n                                                } else {\n                                                    state.name.clone()\n                                                };\n                                                state.started = true;\n                                                let pending = std::mem::take(&mut state.pending_args);\n                                                late_tool_starts.push((\n                                                    state.anthropic_index,\n                                                    fallback_id,\n                                                    fallback_name,\n                                                    pending,\n                                                ));\n                                            }\n                                            late_tool_starts.sort_unstable_by_key(|(index, _, _, _)| *index);\n                                            for (index, id, name, pending) in late_tool_starts {\n                                                let event = json!({\n                                                    \"type\": \"content_block_start\",\n                                                    \"index\": index,\n                                                    \"content_block\": {\n                                                        \"type\": \"tool_use\",\n                                                        \"id\": id,\n                                                        \"name\": name\n                                                    }\n                                                });\n                                                let sse_data = format!(\"event: content_block_start\\ndata: {}\\n\\n\",\n                                                    serde_json::to_string(&event).unwrap_or_default());\n                                                yield Ok(Bytes::from(sse_data));\n                                                open_tool_block_indices.insert(index);\n                                                if !pending.is_empty() {\n                                                    let delta_event = json!({\n                                                        \"type\": \"content_block_delta\",\n                                                        \"index\": index,\n                                                        \"delta\": {\n                                                            \"type\": \"input_json_delta\",\n                                                            \"partial_json\": pending\n                                                        }\n                                                    });\n                                                    let delta_sse = format!(\"event: content_block_delta\\ndata: {}\\n\\n\",\n                                                        serde_json::to_string(&delta_event).unwrap_or_default());\n                                                    yield Ok(Bytes::from(delta_sse));\n                                                }\n                                            }\n\n                                            if !open_tool_block_indices.is_empty() {\n                                                let mut tool_indices: Vec<u32> =\n                                                    open_tool_block_indices.iter().copied().collect();\n                                                tool_indices.sort_unstable();\n                                                for index in tool_indices {\n                                                    let event = json!({\n                                                        \"type\": \"content_block_stop\",\n                                                        \"index\": index\n                                                    });\n                                                    let sse_data = format!(\"event: content_block_stop\\ndata: {}\\n\\n\",\n                                                        serde_json::to_string(&event).unwrap_or_default());\n                                                    yield Ok(Bytes::from(sse_data));\n                                                }\n                                                open_tool_block_indices.clear();\n                                            }\n\n                                            let stop_reason = map_stop_reason(Some(finish_reason));\n                                            // Build usage with cache token fields\n                                            let usage_json = chunk.usage.as_ref().map(|u| {\n                                                let mut uj = json!({\n                                                    \"input_tokens\": u.prompt_tokens,\n                                                    \"output_tokens\": u.completion_tokens\n                                                });\n                                                if let Some(cached) = extract_cache_read_tokens(u) {\n                                                    uj[\"cache_read_input_tokens\"] = json!(cached);\n                                                }\n                                                if let Some(created) = u.cache_creation_input_tokens {\n                                                    uj[\"cache_creation_input_tokens\"] = json!(created);\n                                                }\n                                                uj\n                                            });\n                                            let event = json!({\n                                                \"type\": \"message_delta\",\n                                                \"delta\": {\n                                                    \"stop_reason\": stop_reason,\n                                                    \"stop_sequence\": null\n                                                },\n                                                \"usage\": usage_json\n                                            });\n                                            let sse_data = format!(\"event: message_delta\\ndata: {}\\n\\n\",\n                                                serde_json::to_string(&event).unwrap_or_default());\n                                            yield Ok(Bytes::from(sse_data));\n                                        }\n                                    }\n                                }\n                            }\n                        }\n                    }\n                }\n                Err(e) => {\n                    log::error!(\"Stream error: {e}\");\n                    let error_event = json!({\n                        \"type\": \"error\",\n                        \"error\": {\n                            \"type\": \"stream_error\",\n                            \"message\": format!(\"Stream error: {e}\")\n                        }\n                    });\n                    let sse_data = format!(\"event: error\\ndata: {}\\n\\n\",\n                        serde_json::to_string(&error_event).unwrap_or_default());\n                    yield Ok(Bytes::from(sse_data));\n                    break;\n                }\n            }\n        }\n    }\n}\n\n/// Extract cache_read tokens from Usage, checking both direct field and nested details\nfn extract_cache_read_tokens(usage: &Usage) -> Option<u32> {\n    // Direct field takes priority (compatible servers)\n    if let Some(v) = usage.cache_read_input_tokens {\n        return Some(v);\n    }\n    // OpenAI standard: prompt_tokens_details.cached_tokens\n    usage\n        .prompt_tokens_details\n        .as_ref()\n        .map(|d| d.cached_tokens)\n        .filter(|&v| v > 0)\n}\n\n/// 映射停止原因\nfn map_stop_reason(finish_reason: Option<&str>) -> Option<String> {\n    finish_reason.map(|r| {\n        match r {\n            \"tool_calls\" | \"function_call\" => \"tool_use\",\n            \"stop\" => \"end_turn\",\n            \"length\" => \"max_tokens\",\n            \"content_filter\" => \"end_turn\",\n            other => {\n                log::warn!(\"[Claude/OpenRouter] Unknown finish_reason in streaming: {other}\");\n                \"end_turn\"\n            }\n        }\n        .to_string()\n    })\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use futures::stream;\n    use futures::StreamExt;\n    use serde_json::Value;\n    use std::collections::HashMap;\n\n    #[test]\n    fn test_map_stop_reason_legacy_and_filtered_values() {\n        assert_eq!(\n            map_stop_reason(Some(\"function_call\")),\n            Some(\"tool_use\".to_string())\n        );\n        assert_eq!(\n            map_stop_reason(Some(\"content_filter\")),\n            Some(\"end_turn\".to_string())\n        );\n    }\n\n    #[tokio::test]\n    async fn test_streaming_tool_calls_routed_by_index() {\n        let input = concat!(\n            \"data: {\\\"id\\\":\\\"chatcmpl_1\\\",\\\"model\\\":\\\"gpt-4o\\\",\\\"choices\\\":[{\\\"delta\\\":{\\\"tool_calls\\\":[{\\\"index\\\":0,\\\"id\\\":\\\"call_0\\\",\\\"type\\\":\\\"function\\\",\\\"function\\\":{\\\"name\\\":\\\"first_tool\\\"}}]}}]}\\n\\n\",\n            \"data: {\\\"id\\\":\\\"chatcmpl_1\\\",\\\"model\\\":\\\"gpt-4o\\\",\\\"choices\\\":[{\\\"delta\\\":{\\\"tool_calls\\\":[{\\\"index\\\":1,\\\"id\\\":\\\"call_1\\\",\\\"type\\\":\\\"function\\\",\\\"function\\\":{\\\"name\\\":\\\"second_tool\\\"}}]}}]}\\n\\n\",\n            \"data: {\\\"id\\\":\\\"chatcmpl_1\\\",\\\"model\\\":\\\"gpt-4o\\\",\\\"choices\\\":[{\\\"delta\\\":{\\\"tool_calls\\\":[{\\\"index\\\":1,\\\"function\\\":{\\\"arguments\\\":\\\"{\\\\\\\"b\\\\\\\":2}\\\"}}]}}]}\\n\\n\",\n            \"data: {\\\"id\\\":\\\"chatcmpl_1\\\",\\\"model\\\":\\\"gpt-4o\\\",\\\"choices\\\":[{\\\"delta\\\":{\\\"tool_calls\\\":[{\\\"index\\\":0,\\\"function\\\":{\\\"arguments\\\":\\\"{\\\\\\\"a\\\\\\\":1}\\\"}}]}}]}\\n\\n\",\n            \"data: {\\\"id\\\":\\\"chatcmpl_1\\\",\\\"model\\\":\\\"gpt-4o\\\",\\\"choices\\\":[{\\\"delta\\\":{},\\\"finish_reason\\\":\\\"tool_calls\\\"}],\\\"usage\\\":{\\\"prompt_tokens\\\":8,\\\"completion_tokens\\\":4}}\\n\\n\",\n            \"data: [DONE]\\n\\n\"\n        );\n\n        let upstream = stream::iter(vec![Ok(Bytes::from(input.as_bytes().to_vec()))]);\n        let converted = create_anthropic_sse_stream(upstream);\n        let chunks: Vec<_> = converted.collect().await;\n\n        let merged = chunks\n            .into_iter()\n            .map(|chunk| String::from_utf8_lossy(chunk.unwrap().as_ref()).to_string())\n            .collect::<String>();\n\n        let events: Vec<Value> = merged\n            .split(\"\\n\\n\")\n            .filter_map(|block| {\n                let data = block.lines().find_map(|line| line.strip_prefix(\"data: \"))?;\n                serde_json::from_str::<Value>(data).ok()\n            })\n            .collect();\n\n        let mut tool_index_by_call: HashMap<String, u64> = HashMap::new();\n        for event in &events {\n            if event.get(\"type\").and_then(|v| v.as_str()) == Some(\"content_block_start\")\n                && event\n                    .pointer(\"/content_block/type\")\n                    .and_then(|v| v.as_str())\n                    == Some(\"tool_use\")\n            {\n                if let (Some(call_id), Some(index)) = (\n                    event.pointer(\"/content_block/id\").and_then(|v| v.as_str()),\n                    event.get(\"index\").and_then(|v| v.as_u64()),\n                ) {\n                    tool_index_by_call.insert(call_id.to_string(), index);\n                }\n            }\n        }\n\n        assert_eq!(tool_index_by_call.len(), 2);\n        assert_ne!(\n            tool_index_by_call.get(\"call_0\"),\n            tool_index_by_call.get(\"call_1\")\n        );\n\n        let deltas: Vec<(u64, String)> = events\n            .iter()\n            .filter(|event| {\n                event.get(\"type\").and_then(|v| v.as_str()) == Some(\"content_block_delta\")\n                    && event.pointer(\"/delta/type\").and_then(|v| v.as_str())\n                        == Some(\"input_json_delta\")\n            })\n            .filter_map(|event| {\n                let index = event.get(\"index\").and_then(|v| v.as_u64())?;\n                let partial_json = event\n                    .pointer(\"/delta/partial_json\")\n                    .and_then(|v| v.as_str())?\n                    .to_string();\n                Some((index, partial_json))\n            })\n            .collect();\n\n        assert_eq!(deltas.len(), 2);\n        let second_idx = deltas\n            .iter()\n            .find_map(|(index, payload)| (payload == \"{\\\"b\\\":2}\").then_some(*index))\n            .unwrap();\n        let first_idx = deltas\n            .iter()\n            .find_map(|(index, payload)| (payload == \"{\\\"a\\\":1}\").then_some(*index))\n            .unwrap();\n\n        assert_eq!(second_idx, *tool_index_by_call.get(\"call_1\").unwrap());\n        assert_eq!(first_idx, *tool_index_by_call.get(\"call_0\").unwrap());\n\n        assert!(events.iter().any(|event| {\n            event.get(\"type\").and_then(|v| v.as_str()) == Some(\"message_delta\")\n                && event.pointer(\"/delta/stop_reason\").and_then(|v| v.as_str()) == Some(\"tool_use\")\n        }));\n    }\n\n    #[tokio::test]\n    async fn test_streaming_delays_tool_start_until_id_and_name_ready() {\n        let input = concat!(\n            \"data: {\\\"id\\\":\\\"chatcmpl_2\\\",\\\"model\\\":\\\"gpt-4o\\\",\\\"choices\\\":[{\\\"delta\\\":{\\\"tool_calls\\\":[{\\\"index\\\":0,\\\"function\\\":{\\\"arguments\\\":\\\"{\\\\\\\"a\\\\\\\":\\\"}}]}}]}\\n\\n\",\n            \"data: {\\\"id\\\":\\\"chatcmpl_2\\\",\\\"model\\\":\\\"gpt-4o\\\",\\\"choices\\\":[{\\\"delta\\\":{\\\"tool_calls\\\":[{\\\"index\\\":0,\\\"id\\\":\\\"call_0\\\",\\\"type\\\":\\\"function\\\",\\\"function\\\":{\\\"name\\\":\\\"first_tool\\\"}}]}}]}\\n\\n\",\n            \"data: {\\\"id\\\":\\\"chatcmpl_2\\\",\\\"model\\\":\\\"gpt-4o\\\",\\\"choices\\\":[{\\\"delta\\\":{\\\"tool_calls\\\":[{\\\"index\\\":0,\\\"function\\\":{\\\"arguments\\\":\\\"1}\\\"}}]}}]}\\n\\n\",\n            \"data: {\\\"id\\\":\\\"chatcmpl_2\\\",\\\"model\\\":\\\"gpt-4o\\\",\\\"choices\\\":[{\\\"delta\\\":{},\\\"finish_reason\\\":\\\"tool_calls\\\"}],\\\"usage\\\":{\\\"prompt_tokens\\\":6,\\\"completion_tokens\\\":2}}\\n\\n\",\n            \"data: [DONE]\\n\\n\"\n        );\n\n        let upstream = stream::iter(vec![Ok(Bytes::from(input.as_bytes().to_vec()))]);\n        let converted = create_anthropic_sse_stream(upstream);\n        let chunks: Vec<_> = converted.collect().await;\n        let merged = chunks\n            .into_iter()\n            .map(|chunk| String::from_utf8_lossy(chunk.unwrap().as_ref()).to_string())\n            .collect::<String>();\n\n        let events: Vec<Value> = merged\n            .split(\"\\n\\n\")\n            .filter_map(|block| {\n                let data = block.lines().find_map(|line| line.strip_prefix(\"data: \"))?;\n                serde_json::from_str::<Value>(data).ok()\n            })\n            .collect();\n\n        let starts: Vec<&Value> = events\n            .iter()\n            .filter(|event| {\n                event.get(\"type\").and_then(|v| v.as_str()) == Some(\"content_block_start\")\n                    && event\n                        .pointer(\"/content_block/type\")\n                        .and_then(|v| v.as_str())\n                        == Some(\"tool_use\")\n            })\n            .collect();\n        assert_eq!(starts.len(), 1);\n        assert_eq!(\n            starts[0]\n                .pointer(\"/content_block/id\")\n                .and_then(|v| v.as_str())\n                .unwrap_or(\"\"),\n            \"call_0\"\n        );\n        assert_eq!(\n            starts[0]\n                .pointer(\"/content_block/name\")\n                .and_then(|v| v.as_str())\n                .unwrap_or(\"\"),\n            \"first_tool\"\n        );\n\n        let deltas: Vec<&str> = events\n            .iter()\n            .filter(|event| {\n                event.get(\"type\").and_then(|v| v.as_str()) == Some(\"content_block_delta\")\n                    && event.pointer(\"/delta/type\").and_then(|v| v.as_str())\n                        == Some(\"input_json_delta\")\n            })\n            .filter_map(|event| {\n                event\n                    .pointer(\"/delta/partial_json\")\n                    .and_then(|v| v.as_str())\n            })\n            .collect();\n        assert!(deltas.contains(&\"{\\\"a\\\":\"));\n        assert!(deltas.contains(&\"1}\"));\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/providers/streaming_responses.rs",
    "content": "//! OpenAI Responses API 流式转换模块\n//!\n//! 实现 Responses API SSE → Anthropic SSE 格式转换。\n//!\n//! Responses API 使用命名事件 (named events) 的生命周期模型：\n//! response.created → output_item.added → content_part.added →\n//! output_text.delta → content_part.done → output_item.done → response.completed\n//!\n//! 与 Chat Completions 的 delta chunk 模型完全不同，需要独立的状态机处理。\n\nuse super::transform_responses::{build_anthropic_usage_from_responses, map_responses_stop_reason};\nuse bytes::Bytes;\nuse futures::stream::{Stream, StreamExt};\nuse serde_json::{json, Value};\nuse std::collections::{HashMap, HashSet};\n\n#[inline]\nfn response_object_from_event(data: &Value) -> &Value {\n    data.get(\"response\").unwrap_or(data)\n}\n\n#[inline]\nfn content_part_key(data: &Value) -> Option<String> {\n    if let (Some(item_id), Some(content_index)) = (\n        data.get(\"item_id\").and_then(|v| v.as_str()),\n        data.get(\"content_index\").and_then(|v| v.as_u64()),\n    ) {\n        return Some(format!(\"part:{item_id}:{content_index}\"));\n    }\n    if let (Some(output_index), Some(content_index)) = (\n        data.get(\"output_index\").and_then(|v| v.as_u64()),\n        data.get(\"content_index\").and_then(|v| v.as_u64()),\n    ) {\n        return Some(format!(\"part:out:{output_index}:{content_index}\"));\n    }\n    None\n}\n\n#[inline]\nfn tool_item_key_from_added(data: &Value, item: &Value) -> Option<String> {\n    if let Some(item_id) = item.get(\"id\").and_then(|v| v.as_str()) {\n        return Some(format!(\"tool:{item_id}\"));\n    }\n    if let Some(item_id) = data.get(\"item_id\").and_then(|v| v.as_str()) {\n        return Some(format!(\"tool:{item_id}\"));\n    }\n    if let Some(output_index) = data.get(\"output_index\").and_then(|v| v.as_u64()) {\n        return Some(format!(\"tool:out:{output_index}\"));\n    }\n    None\n}\n\n#[inline]\nfn tool_item_key_from_event(data: &Value) -> Option<String> {\n    if let Some(item_id) = data.get(\"item_id\").and_then(|v| v.as_str()) {\n        return Some(format!(\"tool:{item_id}\"));\n    }\n    if let Some(output_index) = data.get(\"output_index\").and_then(|v| v.as_u64()) {\n        return Some(format!(\"tool:out:{output_index}\"));\n    }\n    None\n}\n\n/// Resolve content index for a text/refusal content part event.\n///\n/// Uses `content_part_key` to look up or assign a stable index, falling back to\n/// `fallback_open_index` when no key is available.\n#[inline]\nfn resolve_content_index(\n    data: &Value,\n    next_content_index: &mut u32,\n    index_by_key: &mut HashMap<String, u32>,\n    fallback_open_index: &mut Option<u32>,\n) -> u32 {\n    if let Some(k) = content_part_key(data) {\n        if let Some(existing) = index_by_key.get(&k).copied() {\n            existing\n        } else {\n            let assigned = *next_content_index;\n            *next_content_index += 1;\n            index_by_key.insert(k, assigned);\n            assigned\n        }\n    } else if let Some(existing) = *fallback_open_index {\n        existing\n    } else {\n        let assigned = *next_content_index;\n        *next_content_index += 1;\n        *fallback_open_index = Some(assigned);\n        assigned\n    }\n}\n\n/// 创建从 Responses API SSE 到 Anthropic SSE 的转换流\n///\n/// 状态机跟踪: message_id, current_model, has_sent_message_start, item/content index map\n/// SSE 解析支持 named events (event: + data: 行)\npub fn create_anthropic_sse_stream_from_responses(\n    stream: impl Stream<Item = Result<Bytes, reqwest::Error>> + Send + 'static,\n) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {\n    async_stream::stream! {\n        let mut buffer = String::new();\n        let mut message_id: Option<String> = None;\n        let mut current_model: Option<String> = None;\n        let mut has_sent_message_start = false;\n        let mut has_tool_use = false;\n        let mut next_content_index: u32 = 0;\n        let mut index_by_key: HashMap<String, u32> = HashMap::new();\n        let mut open_indices: HashSet<u32> = HashSet::new();\n        let mut fallback_open_index: Option<u32> = None;\n        let mut tool_index_by_item_id: HashMap<String, u32> = HashMap::new();\n        let mut last_tool_index: Option<u32> = None;\n\n        tokio::pin!(stream);\n\n        while let Some(chunk) = stream.next().await {\n            match chunk {\n                Ok(bytes) => {\n                    let text = String::from_utf8_lossy(&bytes);\n                    buffer.push_str(&text);\n\n                    // SSE 事件由 \\n\\n 分隔\n                    while let Some(pos) = buffer.find(\"\\n\\n\") {\n                        let block = buffer[..pos].to_string();\n                        buffer = buffer[pos + 2..].to_string();\n\n                        if block.trim().is_empty() {\n                            continue;\n                        }\n\n                        // 解析 SSE 块：提取 event: 和 data: 行\n                        let mut event_type: Option<String> = None;\n                        let mut data_parts: Vec<String> = Vec::new();\n\n                        for line in block.lines() {\n                            if let Some(evt) = line.strip_prefix(\"event: \") {\n                                event_type = Some(evt.trim().to_string());\n                            } else if let Some(d) = line.strip_prefix(\"data: \") {\n                                data_parts.push(d.to_string());\n                            }\n                        }\n\n                        if data_parts.is_empty() {\n                            continue;\n                        }\n\n                        let data_str = data_parts.join(\"\\n\");\n                        let event_name = event_type.as_deref().unwrap_or(\"\");\n\n                        // 解析 JSON 数据\n                        let data: Value = match serde_json::from_str(&data_str) {\n                            Ok(v) => v,\n                            Err(_) => continue,\n                        };\n\n                        log::debug!(\"[Claude/Responses] <<< SSE event: {event_name}\");\n\n                        match event_name {\n                            // ================================================\n                            // response.created → message_start\n                            // ================================================\n                            \"response.created\" => {\n                                let response_obj = response_object_from_event(&data);\n                                if let Some(id) = response_obj.get(\"id\").and_then(|i| i.as_str()) {\n                                    message_id = Some(id.to_string());\n                                }\n                                if let Some(model) =\n                                    response_obj.get(\"model\").and_then(|m| m.as_str())\n                                {\n                                    current_model = Some(model.to_string());\n                                }\n\n                                has_sent_message_start = true;\n                                // Build usage with cache tokens if available\n                                let start_usage = build_anthropic_usage_from_responses(\n                                    response_obj.get(\"usage\"),\n                                );\n\n                                let event = json!({\n                                    \"type\": \"message_start\",\n                                    \"message\": {\n                                        \"id\": message_id.clone().unwrap_or_default(),\n                                        \"type\": \"message\",\n                                        \"role\": \"assistant\",\n                                        \"model\": current_model.clone().unwrap_or_default(),\n                                        \"usage\": start_usage\n                                    }\n                                });\n                                let sse = format!(\"event: message_start\\ndata: {}\\n\\n\",\n                                    serde_json::to_string(&event).unwrap_or_default());\n                                log::debug!(\"[Claude/Responses] >>> Anthropic SSE: message_start\");\n                                yield Ok(Bytes::from(sse));\n                            }\n\n                            // ================================================\n                            // response.content_part.added → content_block_start (text)\n                            // ================================================\n                            \"response.content_part.added\" => {\n                                // 确保 message_start 已发送\n                                if !has_sent_message_start {\n                                    let start_event = json!({\n                                        \"type\": \"message_start\",\n                                        \"message\": {\n                                            \"id\": message_id.clone().unwrap_or_default(),\n                                            \"type\": \"message\",\n                                            \"role\": \"assistant\",\n                                            \"model\": current_model.clone().unwrap_or_default(),\n                                            \"usage\": { \"input_tokens\": 0, \"output_tokens\": 0 }\n                                        }\n                                    });\n                                    let sse = format!(\"event: message_start\\ndata: {}\\n\\n\",\n                                        serde_json::to_string(&start_event).unwrap_or_default());\n                                    yield Ok(Bytes::from(sse));\n                                    has_sent_message_start = true;\n                                }\n\n                                if let Some(part) = data.get(\"part\") {\n                                    let part_type = part.get(\"type\").and_then(|t| t.as_str());\n                                    if matches!(part_type, Some(\"output_text\") | Some(\"refusal\")) {\n                                        let index = resolve_content_index(\n                                            &data,\n                                            &mut next_content_index,\n                                            &mut index_by_key,\n                                            &mut fallback_open_index,\n                                        );\n\n                                        if open_indices.contains(&index) {\n                                            continue;\n                                        }\n\n                                        let event = json!({\n                                            \"type\": \"content_block_start\",\n                                            \"index\": index,\n                                            \"content_block\": {\n                                                \"type\": \"text\",\n                                                \"text\": \"\"\n                                            }\n                                        });\n                                        let sse = format!(\"event: content_block_start\\ndata: {}\\n\\n\",\n                                            serde_json::to_string(&event).unwrap_or_default());\n                                        yield Ok(Bytes::from(sse));\n                                        open_indices.insert(index);\n                                    }\n                                }\n                            }\n\n                            // ================================================\n                            // response.output_text.delta → content_block_delta (text_delta)\n                            // ================================================\n                            \"response.output_text.delta\" => {\n                                if let Some(delta) = data.get(\"delta\").and_then(|d| d.as_str()) {\n                                    let index = resolve_content_index(\n                                        &data,\n                                        &mut next_content_index,\n                                        &mut index_by_key,\n                                        &mut fallback_open_index,\n                                    );\n\n                                    if !open_indices.contains(&index) {\n                                        let start_event = json!({\n                                            \"type\": \"content_block_start\",\n                                            \"index\": index,\n                                            \"content_block\": {\n                                                \"type\": \"text\",\n                                                \"text\": \"\"\n                                            }\n                                        });\n                                        let start_sse = format!(\"event: content_block_start\\ndata: {}\\n\\n\",\n                                            serde_json::to_string(&start_event).unwrap_or_default());\n                                        yield Ok(Bytes::from(start_sse));\n                                        open_indices.insert(index);\n                                    }\n                                    let event = json!({\n                                        \"type\": \"content_block_delta\",\n                                        \"index\": index,\n                                        \"delta\": {\n                                            \"type\": \"text_delta\",\n                                            \"text\": delta\n                                        }\n                                    });\n                                    let sse = format!(\"event: content_block_delta\\ndata: {}\\n\\n\",\n                                        serde_json::to_string(&event).unwrap_or_default());\n                                    yield Ok(Bytes::from(sse));\n                                }\n                            }\n\n                            // ================================================\n                            // response.refusal.delta → content_block_delta (text_delta)\n                            // ================================================\n                            \"response.refusal.delta\" => {\n                                if let Some(delta) = data.get(\"delta\").and_then(|d| d.as_str()) {\n                                    let index = resolve_content_index(\n                                        &data,\n                                        &mut next_content_index,\n                                        &mut index_by_key,\n                                        &mut fallback_open_index,\n                                    );\n\n                                    if !open_indices.contains(&index) {\n                                        let start_event = json!({\n                                            \"type\": \"content_block_start\",\n                                            \"index\": index,\n                                            \"content_block\": {\n                                                \"type\": \"text\",\n                                                \"text\": \"\"\n                                            }\n                                        });\n                                        let start_sse = format!(\"event: content_block_start\\ndata: {}\\n\\n\",\n                                            serde_json::to_string(&start_event).unwrap_or_default());\n                                        yield Ok(Bytes::from(start_sse));\n                                        open_indices.insert(index);\n                                    }\n\n                                    let event = json!({\n                                        \"type\": \"content_block_delta\",\n                                        \"index\": index,\n                                        \"delta\": {\n                                            \"type\": \"text_delta\",\n                                            \"text\": delta\n                                        }\n                                    });\n                                    let sse = format!(\"event: content_block_delta\\ndata: {}\\n\\n\",\n                                        serde_json::to_string(&event).unwrap_or_default());\n                                    yield Ok(Bytes::from(sse));\n                                }\n                            }\n\n                            // ================================================\n                            // response.content_part.done → content_block_stop\n                            // ================================================\n                            \"response.content_part.done\" => {\n                                let key = content_part_key(&data);\n                                let index = if let Some(k) = key {\n                                    index_by_key.get(&k).copied()\n                                } else {\n                                    fallback_open_index\n                                };\n                                if let Some(index) = index {\n                                    if !open_indices.remove(&index) {\n                                        continue;\n                                    }\n                                    let event = json!({\n                                        \"type\": \"content_block_stop\",\n                                        \"index\": index\n                                    });\n                                    let sse = format!(\"event: content_block_stop\\ndata: {}\\n\\n\",\n                                        serde_json::to_string(&event).unwrap_or_default());\n                                    yield Ok(Bytes::from(sse));\n                                    if fallback_open_index == Some(index) {\n                                        fallback_open_index = None;\n                                    }\n                                }\n                            }\n\n                            // ================================================\n                            // response.output_item.added (function_call) → content_block_start (tool_use)\n                            // ================================================\n                            \"response.output_item.added\" => {\n                                if let Some(item) = data.get(\"item\") {\n                                    let item_type = item.get(\"type\").and_then(|t| t.as_str()).unwrap_or(\"\");\n                                    if item_type == \"function_call\" {\n                                        has_tool_use = true;\n                                        // 确保 message_start 已发送\n                                        if !has_sent_message_start {\n                                            let start_event = json!({\n                                                \"type\": \"message_start\",\n                                                \"message\": {\n                                                    \"id\": message_id.clone().unwrap_or_default(),\n                                                    \"type\": \"message\",\n                                                    \"role\": \"assistant\",\n                                                    \"model\": current_model.clone().unwrap_or_default(),\n                                                    \"usage\": { \"input_tokens\": 0, \"output_tokens\": 0 }\n                                                }\n                                            });\n                                            let sse = format!(\"event: message_start\\ndata: {}\\n\\n\",\n                                                serde_json::to_string(&start_event).unwrap_or_default());\n                                            yield Ok(Bytes::from(sse));\n                                            has_sent_message_start = true;\n                                        }\n\n                                        let call_id = item.get(\"call_id\").and_then(|i| i.as_str()).unwrap_or(\"\");\n                                        let name = item.get(\"name\").and_then(|n| n.as_str()).unwrap_or(\"\");\n                                        let index = if let Some(k) = tool_item_key_from_added(&data, item) {\n                                            if let Some(existing) = index_by_key.get(&k).copied() {\n                                                existing\n                                            } else {\n                                                let assigned = next_content_index;\n                                                next_content_index += 1;\n                                                index_by_key.insert(k, assigned);\n                                                assigned\n                                            }\n                                        } else {\n                                            let assigned = next_content_index;\n                                            next_content_index += 1;\n                                            assigned\n                                        };\n                                        if let Some(item_id) = item\n                                            .get(\"id\")\n                                            .and_then(|v| v.as_str())\n                                            .or_else(|| data.get(\"item_id\").and_then(|v| v.as_str()))\n                                        {\n                                            tool_index_by_item_id.insert(item_id.to_string(), index);\n                                        }\n                                        last_tool_index = Some(index);\n\n                                        if open_indices.contains(&index) {\n                                            continue;\n                                        }\n\n                                        let event = json!({\n                                            \"type\": \"content_block_start\",\n                                            \"index\": index,\n                                            \"content_block\": {\n                                                \"type\": \"tool_use\",\n                                                \"id\": call_id,\n                                                \"name\": name\n                                            }\n                                        });\n                                        let sse = format!(\"event: content_block_start\\ndata: {}\\n\\n\",\n                                            serde_json::to_string(&event).unwrap_or_default());\n                                        yield Ok(Bytes::from(sse));\n                                        open_indices.insert(index);\n                                    }\n                                    // message type output_item.added is handled via content_part.added\n                                }\n                            }\n\n                            // ================================================\n                            // response.function_call_arguments.delta → content_block_delta (input_json_delta)\n                            // ================================================\n                            \"response.function_call_arguments.delta\" => {\n                                if let Some(delta) = data.get(\"delta\").and_then(|d| d.as_str()) {\n                                    let item_id = data.get(\"item_id\").and_then(|v| v.as_str());\n                                    let index = if let Some(id) = item_id {\n                                        tool_index_by_item_id.get(id).copied()\n                                    } else {\n                                        None\n                                    }\n                                    .or_else(|| {\n                                        tool_item_key_from_event(&data)\n                                            .and_then(|k| index_by_key.get(&k).copied())\n                                    })\n                                    .or(last_tool_index)\n                                    .unwrap_or_else(|| {\n                                        let assigned = next_content_index;\n                                        next_content_index += 1;\n                                        assigned\n                                    });\n\n                                    if !open_indices.contains(&index) {\n                                        let start_event = json!({\n                                            \"type\": \"content_block_start\",\n                                            \"index\": index,\n                                            \"content_block\": {\n                                                \"type\": \"tool_use\",\n                                                \"id\": data\n                                                    .get(\"call_id\")\n                                                    .and_then(|v| v.as_str())\n                                                    .or(item_id)\n                                                    .unwrap_or(\"\"),\n                                                \"name\": data\n                                                    .get(\"name\")\n                                                    .and_then(|v| v.as_str())\n                                                    .unwrap_or(\"\")\n                                            }\n                                        });\n                                        let start_sse = format!(\"event: content_block_start\\ndata: {}\\n\\n\",\n                                            serde_json::to_string(&start_event).unwrap_or_default());\n                                        yield Ok(Bytes::from(start_sse));\n                                        open_indices.insert(index);\n                                    }\n\n                                    let event = json!({\n                                        \"type\": \"content_block_delta\",\n                                        \"index\": index,\n                                        \"delta\": {\n                                            \"type\": \"input_json_delta\",\n                                            \"partial_json\": delta\n                                        }\n                                    });\n                                    let sse = format!(\"event: content_block_delta\\ndata: {}\\n\\n\",\n                                        serde_json::to_string(&event).unwrap_or_default());\n                                    yield Ok(Bytes::from(sse));\n                                }\n                            }\n\n                            // ================================================\n                            // response.function_call_arguments.done → content_block_stop\n                            // ================================================\n                            \"response.function_call_arguments.done\" => {\n                                let item_id = data.get(\"item_id\").and_then(|v| v.as_str());\n                                let index = if let Some(id) = item_id {\n                                    tool_index_by_item_id.get(id).copied()\n                                } else {\n                                    None\n                                }\n                                .or_else(|| {\n                                    tool_item_key_from_event(&data)\n                                        .and_then(|k| index_by_key.get(&k).copied())\n                                })\n                                .or(last_tool_index);\n                                if let Some(index) = index {\n                                    if !open_indices.remove(&index) {\n                                        continue;\n                                    }\n                                    let event = json!({\n                                        \"type\": \"content_block_stop\",\n                                        \"index\": index\n                                    });\n                                    let sse = format!(\"event: content_block_stop\\ndata: {}\\n\\n\",\n                                        serde_json::to_string(&event).unwrap_or_default());\n                                    yield Ok(Bytes::from(sse));\n                                    if let Some(item_id) = item_id {\n                                        tool_index_by_item_id.remove(item_id);\n                                    }\n                                }\n                            }\n\n                            // ================================================\n                            // response.refusal.done → content_block_stop\n                            // ================================================\n                            \"response.refusal.done\" => {\n                                let key = content_part_key(&data);\n                                let index = if let Some(k) = key {\n                                    index_by_key.get(&k).copied()\n                                } else {\n                                    fallback_open_index\n                                };\n                                if let Some(index) = index {\n                                    if !open_indices.remove(&index) {\n                                        continue;\n                                    }\n                                    let event = json!({\n                                        \"type\": \"content_block_stop\",\n                                        \"index\": index\n                                    });\n                                    let sse = format!(\"event: content_block_stop\\ndata: {}\\n\\n\",\n                                        serde_json::to_string(&event).unwrap_or_default());\n                                    yield Ok(Bytes::from(sse));\n                                    if fallback_open_index == Some(index) {\n                                        fallback_open_index = None;\n                                    }\n                                }\n                            }\n\n                            // ================================================\n                            // response.reasoning.delta → content_block_delta (thinking_delta)\n                            // ================================================\n                            \"response.reasoning.delta\" => {\n                                if let Some(delta) = data\n                                    .get(\"delta\")\n                                    .or_else(|| data.get(\"text\"))\n                                    .and_then(|d| d.as_str())\n                                {\n                                    let index = resolve_content_index(\n                                        &data,\n                                        &mut next_content_index,\n                                        &mut index_by_key,\n                                        &mut fallback_open_index,\n                                    );\n\n                                    if !open_indices.contains(&index) {\n                                        let start_event = json!({\n                                            \"type\": \"content_block_start\",\n                                            \"index\": index,\n                                            \"content_block\": {\n                                                \"type\": \"thinking\",\n                                                \"thinking\": \"\"\n                                            }\n                                        });\n                                        let start_sse = format!(\"event: content_block_start\\ndata: {}\\n\\n\",\n                                            serde_json::to_string(&start_event).unwrap_or_default());\n                                        yield Ok(Bytes::from(start_sse));\n                                        open_indices.insert(index);\n                                    }\n\n                                    let event = json!({\n                                        \"type\": \"content_block_delta\",\n                                        \"index\": index,\n                                        \"delta\": {\n                                            \"type\": \"thinking_delta\",\n                                            \"thinking\": delta\n                                        }\n                                    });\n                                    let sse = format!(\"event: content_block_delta\\ndata: {}\\n\\n\",\n                                        serde_json::to_string(&event).unwrap_or_default());\n                                    yield Ok(Bytes::from(sse));\n                                }\n                            }\n\n                            // ================================================\n                            // response.reasoning.done → content_block_stop\n                            // ================================================\n                            \"response.reasoning.done\" => {\n                                let key = content_part_key(&data);\n                                let index = if let Some(k) = key {\n                                    index_by_key.get(&k).copied()\n                                } else {\n                                    fallback_open_index\n                                };\n                                if let Some(index) = index {\n                                    if !open_indices.remove(&index) {\n                                        continue;\n                                    }\n                                    let event = json!({\n                                        \"type\": \"content_block_stop\",\n                                        \"index\": index\n                                    });\n                                    let sse = format!(\"event: content_block_stop\\ndata: {}\\n\\n\",\n                                        serde_json::to_string(&event).unwrap_or_default());\n                                    yield Ok(Bytes::from(sse));\n                                    if fallback_open_index == Some(index) {\n                                        fallback_open_index = None;\n                                    }\n                                }\n                            }\n\n                            // ================================================\n                            // response.completed → message_delta + message_stop\n                            // ================================================\n                            \"response.completed\" => {\n                                let response_obj = response_object_from_event(&data);\n                                let stop_reason = map_responses_stop_reason(\n                                    response_obj.get(\"status\").and_then(|s| s.as_str()),\n                                    has_tool_use,\n                                    response_obj\n                                        .pointer(\"/incomplete_details/reason\")\n                                        .and_then(|r| r.as_str()),\n                                );\n\n                                // Best effort: close any dangling blocks before message_delta/message_stop.\n                                if !open_indices.is_empty() {\n                                    let mut remaining: Vec<u32> = open_indices.iter().copied().collect();\n                                    remaining.sort_unstable();\n                                    for index in remaining {\n                                        let stop_event = json!({\n                                            \"type\": \"content_block_stop\",\n                                            \"index\": index\n                                        });\n                                        let stop_sse = format!(\"event: content_block_stop\\ndata: {}\\n\\n\",\n                                            serde_json::to_string(&stop_event).unwrap_or_default());\n                                        yield Ok(Bytes::from(stop_sse));\n                                        open_indices.remove(&index);\n                                    }\n                                }\n                                fallback_open_index = None;\n\n                                let usage_json = response_obj.get(\"usage\").map(|u| {\n                                    build_anthropic_usage_from_responses(Some(u))\n                                });\n\n                                // Emit message_delta (with usage + stop_reason)\n                                let delta_event = json!({\n                                    \"type\": \"message_delta\",\n                                    \"delta\": {\n                                        \"stop_reason\": stop_reason,\n                                        \"stop_sequence\": null\n                                    },\n                                    \"usage\": usage_json\n                                });\n                                let sse = format!(\"event: message_delta\\ndata: {}\\n\\n\",\n                                    serde_json::to_string(&delta_event).unwrap_or_default());\n                                log::debug!(\"[Claude/Responses] >>> Anthropic SSE: message_delta\");\n                                yield Ok(Bytes::from(sse));\n\n                                // Emit message_stop\n                                let stop_event = json!({\"type\": \"message_stop\"});\n                                let stop_sse = format!(\"event: message_stop\\ndata: {}\\n\\n\",\n                                    serde_json::to_string(&stop_event).unwrap_or_default());\n                                log::debug!(\"[Claude/Responses] >>> Anthropic SSE: message_stop\");\n                                yield Ok(Bytes::from(stop_sse));\n                            }\n\n                            // Lifecycle events that don't need Anthropic counterparts.\n                            // Listed explicitly so new events trigger a match-completeness review.\n                            \"response.output_text.done\"\n                            | \"response.output_item.done\"\n                            | \"response.in_progress\" => {}\n\n                            // Any other unknown/future events — silently skip.\n                            _ => {}\n                        }\n                    }\n                }\n                Err(e) => {\n                    log::error!(\"Responses stream error: {e}\");\n                    let error_event = json!({\n                        \"type\": \"error\",\n                        \"error\": {\n                            \"type\": \"stream_error\",\n                            \"message\": format!(\"Stream error: {e}\")\n                        }\n                    });\n                    let sse = format!(\"event: error\\ndata: {}\\n\\n\",\n                        serde_json::to_string(&error_event).unwrap_or_default());\n                    yield Ok(Bytes::from(sse));\n                    break;\n                }\n            }\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use futures::stream;\n    use futures::StreamExt;\n    use std::collections::HashMap;\n\n    #[test]\n    fn test_map_responses_stop_reason_tool_use() {\n        assert_eq!(\n            map_responses_stop_reason(Some(\"completed\"), true, None),\n            Some(\"tool_use\")\n        );\n        assert_eq!(\n            map_responses_stop_reason(Some(\"completed\"), false, None),\n            Some(\"end_turn\")\n        );\n        assert_eq!(\n            map_responses_stop_reason(Some(\"incomplete\"), false, Some(\"max_output_tokens\")),\n            Some(\"max_tokens\")\n        );\n        assert_eq!(\n            map_responses_stop_reason(Some(\"incomplete\"), false, Some(\"content_filter\")),\n            Some(\"end_turn\")\n        );\n    }\n\n    #[test]\n    fn test_response_object_from_event_with_wrapper() {\n        let data = json!({\n            \"type\": \"response.created\",\n            \"response\": {\n                \"id\": \"resp_1\",\n                \"model\": \"gpt-4o\"\n            }\n        });\n        let obj = response_object_from_event(&data);\n        assert_eq!(obj[\"id\"], \"resp_1\");\n        assert_eq!(obj[\"model\"], \"gpt-4o\");\n    }\n\n    #[tokio::test]\n    async fn test_streaming_conversion_with_wrapped_response_events() {\n        let input = concat!(\n            \"event: response.created\\n\",\n            \"data: {\\\"type\\\":\\\"response.created\\\",\\\"response\\\":{\\\"id\\\":\\\"resp_1\\\",\\\"model\\\":\\\"gpt-4o\\\",\\\"usage\\\":{\\\"input_tokens\\\":12,\\\"output_tokens\\\":0}}}\\n\\n\",\n            \"event: response.output_item.added\\n\",\n            \"data: {\\\"type\\\":\\\"response.output_item.added\\\",\\\"item\\\":{\\\"type\\\":\\\"function_call\\\",\\\"call_id\\\":\\\"call_1\\\",\\\"name\\\":\\\"get_weather\\\"}}\\n\\n\",\n            \"event: response.function_call_arguments.delta\\n\",\n            \"data: {\\\"type\\\":\\\"response.function_call_arguments.delta\\\",\\\"delta\\\":\\\"{\\\\\\\"city\\\\\\\":\\\\\\\"Tokyo\\\\\\\"}\\\"}\\n\\n\",\n            \"event: response.function_call_arguments.done\\n\",\n            \"data: {\\\"type\\\":\\\"response.function_call_arguments.done\\\"}\\n\\n\",\n            \"event: response.completed\\n\",\n            \"data: {\\\"type\\\":\\\"response.completed\\\",\\\"response\\\":{\\\"status\\\":\\\"completed\\\",\\\"usage\\\":{\\\"input_tokens\\\":12,\\\"output_tokens\\\":3}}}\\n\\n\"\n        );\n\n        let upstream = stream::iter(vec![Ok(Bytes::from(input.as_bytes().to_vec()))]);\n        let converted = create_anthropic_sse_stream_from_responses(upstream);\n        let chunks: Vec<_> = converted.collect().await;\n\n        let merged = chunks\n            .into_iter()\n            .map(|c| String::from_utf8_lossy(c.unwrap().as_ref()).to_string())\n            .collect::<String>();\n\n        assert!(merged.contains(\"\\\"type\\\":\\\"message_start\\\"\"));\n        assert!(merged.contains(\"\\\"id\\\":\\\"resp_1\\\"\"));\n        assert!(merged.contains(\"\\\"model\\\":\\\"gpt-4o\\\"\"));\n        assert!(merged.contains(\"\\\"type\\\":\\\"tool_use\\\"\"));\n        assert!(merged.contains(\"\\\"name\\\":\\\"get_weather\\\"\"));\n        assert!(merged.contains(\"\\\"type\\\":\\\"input_json_delta\\\"\"));\n        assert!(merged.contains(\"\\\"stop_reason\\\":\\\"tool_use\\\"\"));\n        assert!(merged.contains(\"\\\"input_tokens\\\":12\"));\n        assert!(merged.contains(\"\\\"output_tokens\\\":3\"));\n        assert!(merged.contains(\"\\\"type\\\":\\\"message_stop\\\"\"));\n    }\n\n    #[tokio::test]\n    async fn test_streaming_conversion_interleaved_tool_deltas_by_item_id() {\n        let input = concat!(\n            \"event: response.created\\n\",\n            \"data: {\\\"type\\\":\\\"response.created\\\",\\\"response\\\":{\\\"id\\\":\\\"resp_2\\\",\\\"model\\\":\\\"gpt-4o\\\"}}\\n\\n\",\n            \"event: response.output_item.added\\n\",\n            \"data: {\\\"type\\\":\\\"response.output_item.added\\\",\\\"item\\\":{\\\"id\\\":\\\"fc_1\\\",\\\"type\\\":\\\"function_call\\\",\\\"call_id\\\":\\\"call_1\\\",\\\"name\\\":\\\"first_tool\\\"}}\\n\\n\",\n            \"event: response.output_item.added\\n\",\n            \"data: {\\\"type\\\":\\\"response.output_item.added\\\",\\\"item\\\":{\\\"id\\\":\\\"fc_2\\\",\\\"type\\\":\\\"function_call\\\",\\\"call_id\\\":\\\"call_2\\\",\\\"name\\\":\\\"second_tool\\\"}}\\n\\n\",\n            \"event: response.function_call_arguments.delta\\n\",\n            \"data: {\\\"type\\\":\\\"response.function_call_arguments.delta\\\",\\\"item_id\\\":\\\"fc_2\\\",\\\"delta\\\":\\\"{\\\\\\\"b\\\\\\\":2}\\\"}\\n\\n\",\n            \"event: response.function_call_arguments.delta\\n\",\n            \"data: {\\\"type\\\":\\\"response.function_call_arguments.delta\\\",\\\"item_id\\\":\\\"fc_1\\\",\\\"delta\\\":\\\"{\\\\\\\"a\\\\\\\":1}\\\"}\\n\\n\",\n            \"event: response.function_call_arguments.done\\n\",\n            \"data: {\\\"type\\\":\\\"response.function_call_arguments.done\\\",\\\"item_id\\\":\\\"fc_1\\\"}\\n\\n\",\n            \"event: response.function_call_arguments.done\\n\",\n            \"data: {\\\"type\\\":\\\"response.function_call_arguments.done\\\",\\\"item_id\\\":\\\"fc_2\\\"}\\n\\n\",\n            \"event: response.completed\\n\",\n            \"data: {\\\"type\\\":\\\"response.completed\\\",\\\"response\\\":{\\\"status\\\":\\\"completed\\\",\\\"usage\\\":{\\\"input_tokens\\\":8,\\\"output_tokens\\\":4}}}\\n\\n\"\n        );\n\n        let upstream = stream::iter(vec![Ok(Bytes::from(input.as_bytes().to_vec()))]);\n        let converted = create_anthropic_sse_stream_from_responses(upstream);\n        let chunks: Vec<_> = converted.collect().await;\n        let merged = chunks\n            .into_iter()\n            .map(|c| String::from_utf8_lossy(c.unwrap().as_ref()).to_string())\n            .collect::<String>();\n\n        let events: Vec<Value> = merged\n            .split(\"\\n\\n\")\n            .filter_map(|block| {\n                let data = block.lines().find_map(|line| line.strip_prefix(\"data: \"))?;\n                serde_json::from_str::<Value>(data).ok()\n            })\n            .collect();\n\n        let mut tool_index_by_call: HashMap<String, u64> = HashMap::new();\n        for event in &events {\n            if event.get(\"type\").and_then(|v| v.as_str()) == Some(\"content_block_start\") {\n                let cb = event.get(\"content_block\");\n                if cb.and_then(|v| v.get(\"type\")).and_then(|v| v.as_str()) == Some(\"tool_use\") {\n                    if let (Some(call_id), Some(index)) = (\n                        cb.and_then(|v| v.get(\"id\")).and_then(|v| v.as_str()),\n                        event.get(\"index\").and_then(|v| v.as_u64()),\n                    ) {\n                        tool_index_by_call.insert(call_id.to_string(), index);\n                    }\n                }\n            }\n        }\n\n        let delta_indices: Vec<u64> = events\n            .iter()\n            .filter(|event| {\n                event.get(\"type\").and_then(|v| v.as_str()) == Some(\"content_block_delta\")\n                    && event.pointer(\"/delta/type\").and_then(|v| v.as_str())\n                        == Some(\"input_json_delta\")\n            })\n            .filter_map(|event| event.get(\"index\").and_then(|v| v.as_u64()))\n            .collect();\n\n        assert_eq!(delta_indices.len(), 2);\n        assert_eq!(delta_indices[0], *tool_index_by_call.get(\"call_2\").unwrap());\n        assert_eq!(delta_indices[1], *tool_index_by_call.get(\"call_1\").unwrap());\n        assert_ne!(\n            tool_index_by_call.get(\"call_1\"),\n            tool_index_by_call.get(\"call_2\")\n        );\n    }\n\n    #[tokio::test]\n    async fn test_streaming_reasoning_delta_emits_thinking_blocks() {\n        let input = concat!(\n            \"event: response.created\\n\",\n            \"data: {\\\"type\\\":\\\"response.created\\\",\\\"response\\\":{\\\"id\\\":\\\"resp_r\\\",\\\"model\\\":\\\"o3\\\",\\\"usage\\\":{\\\"input_tokens\\\":5,\\\"output_tokens\\\":0}}}\\n\\n\",\n            \"event: response.reasoning.delta\\n\",\n            \"data: {\\\"type\\\":\\\"response.reasoning.delta\\\",\\\"delta\\\":\\\"Let me think...\\\"}\\n\\n\",\n            \"event: response.reasoning.done\\n\",\n            \"data: {\\\"type\\\":\\\"response.reasoning.done\\\"}\\n\\n\",\n            \"event: response.content_part.added\\n\",\n            \"data: {\\\"type\\\":\\\"response.content_part.added\\\",\\\"part\\\":{\\\"type\\\":\\\"output_text\\\",\\\"text\\\":\\\"\\\"},\\\"output_index\\\":0,\\\"content_index\\\":0}\\n\\n\",\n            \"event: response.output_text.delta\\n\",\n            \"data: {\\\"type\\\":\\\"response.output_text.delta\\\",\\\"delta\\\":\\\"42\\\",\\\"output_index\\\":0,\\\"content_index\\\":0}\\n\\n\",\n            \"event: response.content_part.done\\n\",\n            \"data: {\\\"type\\\":\\\"response.content_part.done\\\",\\\"output_index\\\":0,\\\"content_index\\\":0}\\n\\n\",\n            \"event: response.completed\\n\",\n            \"data: {\\\"type\\\":\\\"response.completed\\\",\\\"response\\\":{\\\"status\\\":\\\"completed\\\",\\\"usage\\\":{\\\"input_tokens\\\":5,\\\"output_tokens\\\":10}}}\\n\\n\"\n        );\n\n        let upstream = stream::iter(vec![Ok(Bytes::from(input.as_bytes().to_vec()))]);\n        let converted = create_anthropic_sse_stream_from_responses(upstream);\n        let chunks: Vec<_> = converted.collect().await;\n        let merged = chunks\n            .into_iter()\n            .map(|c| String::from_utf8_lossy(c.unwrap().as_ref()).to_string())\n            .collect::<String>();\n\n        // Should contain thinking block start, thinking delta, and text content\n        assert!(\n            merged.contains(\"\\\"type\\\":\\\"thinking\\\"\"),\n            \"should emit thinking content_block_start\"\n        );\n        assert!(\n            merged.contains(\"\\\"type\\\":\\\"thinking_delta\\\"\"),\n            \"should emit thinking_delta\"\n        );\n        assert!(\n            merged.contains(\"\\\"thinking\\\":\\\"Let me think...\\\"\"),\n            \"should contain thinking text\"\n        );\n        assert!(\n            merged.contains(\"\\\"type\\\":\\\"text_delta\\\"\"),\n            \"should also emit text content\"\n        );\n        assert!(\n            merged.contains(\"\\\"text\\\":\\\"42\\\"\"),\n            \"should contain text delta\"\n        );\n        assert!(merged.contains(\"\\\"stop_reason\\\":\\\"end_turn\\\"\"));\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/providers/transform.rs",
    "content": "//! 格式转换模块\n//!\n//! 实现 Anthropic ↔ OpenAI 格式转换，用于 OpenRouter 支持\n//! 参考: anthropic-proxy-rs\n\nuse crate::proxy::error::ProxyError;\nuse serde_json::{json, Value};\n\n/// Detect OpenAI o-series reasoning models (o1, o3, o4-mini, etc.)\n/// These models require `max_completion_tokens` instead of `max_tokens`.\npub fn is_openai_o_series(model: &str) -> bool {\n    model.len() > 1\n        && model.starts_with('o')\n        && model.as_bytes().get(1).is_some_and(|b| b.is_ascii_digit())\n}\n\n/// Anthropic 请求 → OpenAI 请求\n///\n/// `cache_key`: optional prompt_cache_key to inject for improved cache routing\npub fn anthropic_to_openai(body: Value, cache_key: Option<&str>) -> Result<Value, ProxyError> {\n    let mut result = json!({});\n\n    // NOTE: 模型映射由上游统一处理（proxy::model_mapper），格式转换层只做结构转换。\n    if let Some(model) = body.get(\"model\").and_then(|m| m.as_str()) {\n        result[\"model\"] = json!(model);\n    }\n\n    let mut messages = Vec::new();\n\n    // 处理 system prompt\n    if let Some(system) = body.get(\"system\") {\n        if let Some(text) = system.as_str() {\n            // 单个字符串\n            messages.push(json!({\"role\": \"system\", \"content\": text}));\n        } else if let Some(arr) = system.as_array() {\n            // 多个 system message — preserve cache_control for compatible proxies\n            for msg in arr {\n                if let Some(text) = msg.get(\"text\").and_then(|t| t.as_str()) {\n                    let mut sys_msg = json!({\"role\": \"system\", \"content\": text});\n                    if let Some(cc) = msg.get(\"cache_control\") {\n                        sys_msg[\"cache_control\"] = cc.clone();\n                    }\n                    messages.push(sys_msg);\n                }\n            }\n        }\n    }\n\n    // 转换 messages\n    if let Some(msgs) = body.get(\"messages\").and_then(|m| m.as_array()) {\n        for msg in msgs {\n            let role = msg.get(\"role\").and_then(|r| r.as_str()).unwrap_or(\"user\");\n            let content = msg.get(\"content\");\n            let converted = convert_message_to_openai(role, content)?;\n            messages.extend(converted);\n        }\n    }\n\n    result[\"messages\"] = json!(messages);\n\n    // 转换参数 — o-series 模型需要 max_completion_tokens\n    let model = body.get(\"model\").and_then(|m| m.as_str()).unwrap_or(\"\");\n    if let Some(v) = body.get(\"max_tokens\") {\n        if is_openai_o_series(model) {\n            result[\"max_completion_tokens\"] = v.clone();\n        } else {\n            result[\"max_tokens\"] = v.clone();\n        }\n    }\n    if let Some(v) = body.get(\"temperature\") {\n        result[\"temperature\"] = v.clone();\n    }\n    if let Some(v) = body.get(\"top_p\") {\n        result[\"top_p\"] = v.clone();\n    }\n    if let Some(v) = body.get(\"stop_sequences\") {\n        result[\"stop\"] = v.clone();\n    }\n    if let Some(v) = body.get(\"stream\") {\n        result[\"stream\"] = v.clone();\n    }\n\n    // 转换 tools (过滤 BatchTool)\n    if let Some(tools) = body.get(\"tools\").and_then(|t| t.as_array()) {\n        let openai_tools: Vec<Value> = tools\n            .iter()\n            .filter(|t| t.get(\"type\").and_then(|v| v.as_str()) != Some(\"BatchTool\"))\n            .map(|t| {\n                let mut tool = json!({\n                    \"type\": \"function\",\n                    \"function\": {\n                        \"name\": t.get(\"name\").and_then(|n| n.as_str()).unwrap_or(\"\"),\n                        \"description\": t.get(\"description\"),\n                        \"parameters\": clean_schema(t.get(\"input_schema\").cloned().unwrap_or(json!({})))\n                    }\n                });\n                if let Some(cc) = t.get(\"cache_control\") {\n                    tool[\"cache_control\"] = cc.clone();\n                }\n                tool\n            })\n            .collect();\n\n        if !openai_tools.is_empty() {\n            result[\"tools\"] = json!(openai_tools);\n        }\n    }\n\n    if let Some(v) = body.get(\"tool_choice\") {\n        result[\"tool_choice\"] = v.clone();\n    }\n\n    // Inject prompt_cache_key for improved cache routing on OpenAI-compatible endpoints\n    if let Some(key) = cache_key {\n        result[\"prompt_cache_key\"] = json!(key);\n    }\n\n    Ok(result)\n}\n\n/// 转换单条消息到 OpenAI 格式（可能产生多条消息）\nfn convert_message_to_openai(\n    role: &str,\n    content: Option<&Value>,\n) -> Result<Vec<Value>, ProxyError> {\n    let mut result = Vec::new();\n\n    let content = match content {\n        Some(c) => c,\n        None => {\n            result.push(json!({\"role\": role, \"content\": null}));\n            return Ok(result);\n        }\n    };\n\n    // 字符串内容\n    if let Some(text) = content.as_str() {\n        result.push(json!({\"role\": role, \"content\": text}));\n        return Ok(result);\n    }\n\n    // 数组内容（多模态/工具调用）\n    if let Some(blocks) = content.as_array() {\n        let mut content_parts = Vec::new();\n        let mut tool_calls = Vec::new();\n\n        for block in blocks {\n            let block_type = block.get(\"type\").and_then(|t| t.as_str()).unwrap_or(\"\");\n\n            match block_type {\n                \"text\" => {\n                    if let Some(text) = block.get(\"text\").and_then(|t| t.as_str()) {\n                        let mut part = json!({\"type\": \"text\", \"text\": text});\n                        if let Some(cc) = block.get(\"cache_control\") {\n                            part[\"cache_control\"] = cc.clone();\n                        }\n                        content_parts.push(part);\n                    }\n                }\n                \"image\" => {\n                    if let Some(source) = block.get(\"source\") {\n                        let media_type = source\n                            .get(\"media_type\")\n                            .and_then(|m| m.as_str())\n                            .unwrap_or(\"image/png\");\n                        let data = source.get(\"data\").and_then(|d| d.as_str()).unwrap_or(\"\");\n                        content_parts.push(json!({\n                            \"type\": \"image_url\",\n                            \"image_url\": {\"url\": format!(\"data:{};base64,{}\", media_type, data)}\n                        }));\n                    }\n                }\n                \"tool_use\" => {\n                    let id = block.get(\"id\").and_then(|i| i.as_str()).unwrap_or(\"\");\n                    let name = block.get(\"name\").and_then(|n| n.as_str()).unwrap_or(\"\");\n                    let input = block.get(\"input\").cloned().unwrap_or(json!({}));\n                    tool_calls.push(json!({\n                        \"id\": id,\n                        \"type\": \"function\",\n                        \"function\": {\n                            \"name\": name,\n                            \"arguments\": serde_json::to_string(&input).unwrap_or_default()\n                        }\n                    }));\n                }\n                \"tool_result\" => {\n                    // tool_result 变成单独的 tool role 消息\n                    let tool_use_id = block\n                        .get(\"tool_use_id\")\n                        .and_then(|i| i.as_str())\n                        .unwrap_or(\"\");\n                    let content_val = block.get(\"content\");\n                    let content_str = match content_val {\n                        Some(Value::String(s)) => s.clone(),\n                        Some(v) => serde_json::to_string(v).unwrap_or_default(),\n                        None => String::new(),\n                    };\n                    result.push(json!({\n                        \"role\": \"tool\",\n                        \"tool_call_id\": tool_use_id,\n                        \"content\": content_str\n                    }));\n                }\n                \"thinking\" => {\n                    // 跳过 thinking blocks\n                }\n                _ => {}\n            }\n        }\n\n        // 添加带内容和/或工具调用的消息\n        if !content_parts.is_empty() || !tool_calls.is_empty() {\n            let mut msg = json!({\"role\": role});\n\n            // 内容处理\n            if content_parts.is_empty() {\n                msg[\"content\"] = Value::Null;\n            } else if content_parts.len() == 1 {\n                // When cache_control is present, keep array format to preserve it\n                let has_cache_control = content_parts[0].get(\"cache_control\").is_some();\n                if !has_cache_control {\n                    if let Some(text) = content_parts[0].get(\"text\") {\n                        msg[\"content\"] = text.clone();\n                    } else {\n                        msg[\"content\"] = json!(content_parts);\n                    }\n                } else {\n                    msg[\"content\"] = json!(content_parts);\n                }\n            } else {\n                msg[\"content\"] = json!(content_parts);\n            }\n\n            // 工具调用\n            if !tool_calls.is_empty() {\n                msg[\"tool_calls\"] = json!(tool_calls);\n            }\n\n            result.push(msg);\n        }\n\n        return Ok(result);\n    }\n\n    // 其他情况直接透传\n    result.push(json!({\"role\": role, \"content\": content}));\n    Ok(result)\n}\n\n/// 清理 JSON schema（移除不支持的 format）\npub fn clean_schema(mut schema: Value) -> Value {\n    if let Some(obj) = schema.as_object_mut() {\n        // 移除 \"format\": \"uri\"\n        if obj.get(\"format\").and_then(|v| v.as_str()) == Some(\"uri\") {\n            obj.remove(\"format\");\n        }\n\n        // 递归清理嵌套 schema\n        if let Some(properties) = obj.get_mut(\"properties\").and_then(|v| v.as_object_mut()) {\n            for (_, value) in properties.iter_mut() {\n                *value = clean_schema(value.clone());\n            }\n        }\n\n        if let Some(items) = obj.get_mut(\"items\") {\n            *items = clean_schema(items.clone());\n        }\n    }\n    schema\n}\n\n/// OpenAI 响应 → Anthropic 响应\npub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {\n    let choices = body\n        .get(\"choices\")\n        .and_then(|c| c.as_array())\n        .ok_or_else(|| ProxyError::TransformError(\"No choices in response\".to_string()))?;\n\n    let choice = choices\n        .first()\n        .ok_or_else(|| ProxyError::TransformError(\"Empty choices array\".to_string()))?;\n\n    let message = choice\n        .get(\"message\")\n        .ok_or_else(|| ProxyError::TransformError(\"No message in choice\".to_string()))?;\n\n    let mut content = Vec::new();\n    let mut has_tool_use = false;\n\n    // 文本/拒绝内容\n    if let Some(msg_content) = message.get(\"content\") {\n        if let Some(text) = msg_content.as_str() {\n            if !text.is_empty() {\n                content.push(json!({\"type\": \"text\", \"text\": text}));\n            }\n        } else if let Some(parts) = msg_content.as_array() {\n            for part in parts {\n                let part_type = part.get(\"type\").and_then(|t| t.as_str()).unwrap_or(\"\");\n                match part_type {\n                    \"text\" | \"output_text\" => {\n                        if let Some(text) = part.get(\"text\").and_then(|t| t.as_str()) {\n                            if !text.is_empty() {\n                                content.push(json!({\"type\": \"text\", \"text\": text}));\n                            }\n                        }\n                    }\n                    \"refusal\" => {\n                        if let Some(refusal) = part.get(\"refusal\").and_then(|r| r.as_str()) {\n                            if !refusal.is_empty() {\n                                content.push(json!({\"type\": \"text\", \"text\": refusal}));\n                            }\n                        }\n                    }\n                    _ => {}\n                }\n            }\n        }\n    }\n    // Some providers put refusal at message-level.\n    if let Some(refusal) = message.get(\"refusal\").and_then(|r| r.as_str()) {\n        if !refusal.is_empty() {\n            content.push(json!({\"type\": \"text\", \"text\": refusal}));\n        }\n    }\n\n    // 工具调用（tool_calls）\n    if let Some(tool_calls) = message.get(\"tool_calls\").and_then(|t| t.as_array()) {\n        if !tool_calls.is_empty() {\n            has_tool_use = true;\n        }\n        for tc in tool_calls {\n            let id = tc.get(\"id\").and_then(|i| i.as_str()).unwrap_or(\"\");\n            let empty_obj = json!({});\n            let func = tc.get(\"function\").unwrap_or(&empty_obj);\n            let name = func.get(\"name\").and_then(|n| n.as_str()).unwrap_or(\"\");\n            let args_str = func\n                .get(\"arguments\")\n                .and_then(|a| a.as_str())\n                .unwrap_or(\"{}\");\n            let input: Value = serde_json::from_str(args_str).unwrap_or(json!({}));\n\n            content.push(json!({\n                \"type\": \"tool_use\",\n                \"id\": id,\n                \"name\": name,\n                \"input\": input\n            }));\n        }\n    }\n    // 兼容旧格式（function_call）\n    if !has_tool_use {\n        if let Some(function_call) = message.get(\"function_call\") {\n            let id = function_call\n                .get(\"id\")\n                .and_then(|i| i.as_str())\n                .unwrap_or(\"\");\n            let name = function_call\n                .get(\"name\")\n                .and_then(|n| n.as_str())\n                .unwrap_or(\"\");\n            let has_arguments = function_call.get(\"arguments\").is_some();\n\n            let input = match function_call.get(\"arguments\") {\n                Some(Value::String(s)) => serde_json::from_str(s).unwrap_or(json!({})),\n                Some(v @ Value::Object(_)) | Some(v @ Value::Array(_)) => v.clone(),\n                _ => json!({}),\n            };\n\n            if !name.is_empty() || has_arguments {\n                content.push(json!({\n                    \"type\": \"tool_use\",\n                    \"id\": id,\n                    \"name\": name,\n                    \"input\": input\n                }));\n                has_tool_use = true;\n            }\n        }\n    }\n\n    // 映射 finish_reason → stop_reason\n    let stop_reason = choice\n        .get(\"finish_reason\")\n        .and_then(|r| r.as_str())\n        .map(|r| match r {\n            \"stop\" => \"end_turn\",\n            \"length\" => \"max_tokens\",\n            \"tool_calls\" | \"function_call\" => \"tool_use\",\n            \"content_filter\" => \"end_turn\",\n            other => {\n                log::warn!(\n                    \"[Claude/OpenAI] Unknown finish_reason in non-streaming response: {other}\"\n                );\n                \"end_turn\"\n            }\n        })\n        .or(if has_tool_use { Some(\"tool_use\") } else { None });\n\n    // usage — map cache tokens from OpenAI format to Anthropic format\n    let usage = body.get(\"usage\").cloned().unwrap_or(json!({}));\n    let input_tokens = usage\n        .get(\"prompt_tokens\")\n        .and_then(|v| v.as_u64())\n        .unwrap_or(0) as u32;\n    let output_tokens = usage\n        .get(\"completion_tokens\")\n        .and_then(|v| v.as_u64())\n        .unwrap_or(0) as u32;\n\n    let mut usage_json = json!({\n        \"input_tokens\": input_tokens,\n        \"output_tokens\": output_tokens\n    });\n\n    // OpenAI standard: prompt_tokens_details.cached_tokens\n    if let Some(cached) = usage\n        .pointer(\"/prompt_tokens_details/cached_tokens\")\n        .and_then(|v| v.as_u64())\n    {\n        usage_json[\"cache_read_input_tokens\"] = json!(cached);\n    }\n    // Some compatible servers return these fields directly\n    if let Some(v) = usage.get(\"cache_read_input_tokens\") {\n        usage_json[\"cache_read_input_tokens\"] = v.clone();\n    }\n    if let Some(v) = usage.get(\"cache_creation_input_tokens\") {\n        usage_json[\"cache_creation_input_tokens\"] = v.clone();\n    }\n\n    let result = json!({\n        \"id\": body.get(\"id\").and_then(|i| i.as_str()).unwrap_or(\"\"),\n        \"type\": \"message\",\n        \"role\": \"assistant\",\n        \"content\": content,\n        \"model\": body.get(\"model\").and_then(|m| m.as_str()).unwrap_or(\"\"),\n        \"stop_reason\": stop_reason,\n        \"stop_sequence\": null,\n        \"usage\": usage_json\n    });\n\n    Ok(result)\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_anthropic_to_openai_simple() {\n        let input = json!({\n            \"model\": \"claude-3-opus\",\n            \"max_tokens\": 1024,\n            \"messages\": [{\"role\": \"user\", \"content\": \"Hello\"}]\n        });\n\n        let result = anthropic_to_openai(input, None).unwrap();\n        assert_eq!(result[\"model\"], \"claude-3-opus\");\n        assert_eq!(result[\"max_tokens\"], 1024);\n        assert_eq!(result[\"messages\"][0][\"role\"], \"user\");\n        assert_eq!(result[\"messages\"][0][\"content\"], \"Hello\");\n    }\n\n    #[test]\n    fn test_anthropic_to_openai_with_system() {\n        let input = json!({\n            \"model\": \"claude-3-sonnet\",\n            \"max_tokens\": 1024,\n            \"system\": \"You are a helpful assistant.\",\n            \"messages\": [{\"role\": \"user\", \"content\": \"Hello\"}]\n        });\n\n        let result = anthropic_to_openai(input, None).unwrap();\n        assert_eq!(result[\"messages\"][0][\"role\"], \"system\");\n        assert_eq!(\n            result[\"messages\"][0][\"content\"],\n            \"You are a helpful assistant.\"\n        );\n        assert_eq!(result[\"messages\"][1][\"role\"], \"user\");\n    }\n\n    #[test]\n    fn test_anthropic_to_openai_with_tools() {\n        let input = json!({\n            \"model\": \"claude-3-opus\",\n            \"max_tokens\": 1024,\n            \"messages\": [{\"role\": \"user\", \"content\": \"What's the weather?\"}],\n            \"tools\": [{\n                \"name\": \"get_weather\",\n                \"description\": \"Get weather info\",\n                \"input_schema\": {\"type\": \"object\", \"properties\": {\"location\": {\"type\": \"string\"}}}\n            }]\n        });\n\n        let result = anthropic_to_openai(input, None).unwrap();\n        assert_eq!(result[\"tools\"][0][\"type\"], \"function\");\n        assert_eq!(result[\"tools\"][0][\"function\"][\"name\"], \"get_weather\");\n    }\n\n    #[test]\n    fn test_anthropic_to_openai_tool_use() {\n        let input = json!({\n            \"model\": \"claude-3-opus\",\n            \"max_tokens\": 1024,\n            \"messages\": [{\n                \"role\": \"assistant\",\n                \"content\": [\n                    {\"type\": \"text\", \"text\": \"Let me check\"},\n                    {\"type\": \"tool_use\", \"id\": \"call_123\", \"name\": \"get_weather\", \"input\": {\"location\": \"Tokyo\"}}\n                ]\n            }]\n        });\n\n        let result = anthropic_to_openai(input, None).unwrap();\n        let msg = &result[\"messages\"][0];\n        assert_eq!(msg[\"role\"], \"assistant\");\n        assert!(msg.get(\"tool_calls\").is_some());\n        assert_eq!(msg[\"tool_calls\"][0][\"id\"], \"call_123\");\n    }\n\n    #[test]\n    fn test_anthropic_to_openai_tool_result() {\n        let input = json!({\n            \"model\": \"claude-3-opus\",\n            \"max_tokens\": 1024,\n            \"messages\": [{\n                \"role\": \"user\",\n                \"content\": [\n                    {\"type\": \"tool_result\", \"tool_use_id\": \"call_123\", \"content\": \"Sunny, 25°C\"}\n                ]\n            }]\n        });\n\n        let result = anthropic_to_openai(input, None).unwrap();\n        let msg = &result[\"messages\"][0];\n        assert_eq!(msg[\"role\"], \"tool\");\n        assert_eq!(msg[\"tool_call_id\"], \"call_123\");\n        assert_eq!(msg[\"content\"], \"Sunny, 25°C\");\n    }\n\n    #[test]\n    fn test_openai_to_anthropic_simple() {\n        let input = json!({\n            \"id\": \"chatcmpl-123\",\n            \"object\": \"chat.completion\",\n            \"created\": 1234567890,\n            \"model\": \"gpt-4\",\n            \"choices\": [{\n                \"index\": 0,\n                \"message\": {\"role\": \"assistant\", \"content\": \"Hello!\"},\n                \"finish_reason\": \"stop\"\n            }],\n            \"usage\": {\"prompt_tokens\": 10, \"completion_tokens\": 5, \"total_tokens\": 15}\n        });\n\n        let result = openai_to_anthropic(input).unwrap();\n        assert_eq!(result[\"id\"], \"chatcmpl-123\");\n        assert_eq!(result[\"type\"], \"message\");\n        assert_eq!(result[\"content\"][0][\"type\"], \"text\");\n        assert_eq!(result[\"content\"][0][\"text\"], \"Hello!\");\n        assert_eq!(result[\"stop_reason\"], \"end_turn\");\n        assert_eq!(result[\"usage\"][\"input_tokens\"], 10);\n        assert_eq!(result[\"usage\"][\"output_tokens\"], 5);\n    }\n\n    #[test]\n    fn test_openai_to_anthropic_with_tool_calls() {\n        let input = json!({\n            \"id\": \"chatcmpl-123\",\n            \"object\": \"chat.completion\",\n            \"created\": 1234567890,\n            \"model\": \"gpt-4\",\n            \"choices\": [{\n                \"index\": 0,\n                \"message\": {\n                    \"role\": \"assistant\",\n                    \"content\": null,\n                    \"tool_calls\": [{\n                        \"id\": \"call_123\",\n                        \"type\": \"function\",\n                        \"function\": {\"name\": \"get_weather\", \"arguments\": \"{\\\"location\\\": \\\"Tokyo\\\"}\"}\n                    }]\n                },\n                \"finish_reason\": \"tool_calls\"\n            }],\n            \"usage\": {\"prompt_tokens\": 10, \"completion_tokens\": 5, \"total_tokens\": 15}\n        });\n\n        let result = openai_to_anthropic(input).unwrap();\n        assert_eq!(result[\"content\"][0][\"type\"], \"tool_use\");\n        assert_eq!(result[\"content\"][0][\"id\"], \"call_123\");\n        assert_eq!(result[\"content\"][0][\"name\"], \"get_weather\");\n        assert_eq!(result[\"content\"][0][\"input\"][\"location\"], \"Tokyo\");\n        assert_eq!(result[\"stop_reason\"], \"tool_use\");\n    }\n\n    #[test]\n    fn test_model_passthrough() {\n        // 格式转换层只做结构转换，模型映射由上游 proxy::model_mapper 处理\n        let input = json!({\n            \"model\": \"gpt-4o\",\n            \"max_tokens\": 1024,\n            \"messages\": [{\"role\": \"user\", \"content\": \"Hello\"}]\n        });\n\n        let result = anthropic_to_openai(input, None).unwrap();\n        assert_eq!(result[\"model\"], \"gpt-4o\");\n    }\n\n    #[test]\n    fn test_anthropic_to_openai_with_cache_key() {\n        let input = json!({\n            \"model\": \"claude-3-opus\",\n            \"max_tokens\": 1024,\n            \"messages\": [{\"role\": \"user\", \"content\": \"Hello\"}]\n        });\n\n        let result = anthropic_to_openai(input, Some(\"provider-123\")).unwrap();\n        assert_eq!(result[\"prompt_cache_key\"], \"provider-123\");\n    }\n\n    #[test]\n    fn test_anthropic_to_openai_no_cache_key() {\n        let input = json!({\n            \"model\": \"claude-3-opus\",\n            \"max_tokens\": 1024,\n            \"messages\": [{\"role\": \"user\", \"content\": \"Hello\"}]\n        });\n\n        let result = anthropic_to_openai(input, None).unwrap();\n        assert!(result.get(\"prompt_cache_key\").is_none());\n    }\n\n    #[test]\n    fn test_anthropic_to_openai_cache_control_preserved() {\n        let input = json!({\n            \"model\": \"claude-3-opus\",\n            \"max_tokens\": 1024,\n            \"system\": [\n                {\"type\": \"text\", \"text\": \"System prompt\", \"cache_control\": {\"type\": \"ephemeral\"}}\n            ],\n            \"messages\": [{\n                \"role\": \"user\",\n                \"content\": [\n                    {\"type\": \"text\", \"text\": \"Hello\", \"cache_control\": {\"type\": \"ephemeral\", \"ttl\": \"5m\"}}\n                ]\n            }],\n            \"tools\": [{\n                \"name\": \"get_weather\",\n                \"description\": \"Get weather\",\n                \"input_schema\": {\"type\": \"object\"},\n                \"cache_control\": {\"type\": \"ephemeral\"}\n            }]\n        });\n\n        let result = anthropic_to_openai(input, None).unwrap();\n        // System message cache_control preserved\n        assert_eq!(result[\"messages\"][0][\"cache_control\"][\"type\"], \"ephemeral\");\n        // Text block cache_control preserved\n        assert_eq!(\n            result[\"messages\"][1][\"content\"][0][\"cache_control\"][\"type\"],\n            \"ephemeral\"\n        );\n        assert_eq!(\n            result[\"messages\"][1][\"content\"][0][\"cache_control\"][\"ttl\"],\n            \"5m\"\n        );\n        // Tool cache_control preserved\n        assert_eq!(result[\"tools\"][0][\"cache_control\"][\"type\"], \"ephemeral\");\n    }\n\n    #[test]\n    fn test_openai_to_anthropic_with_cache_tokens() {\n        let input = json!({\n            \"id\": \"chatcmpl-123\",\n            \"model\": \"gpt-4\",\n            \"choices\": [{\n                \"index\": 0,\n                \"message\": {\"role\": \"assistant\", \"content\": \"Hello!\"},\n                \"finish_reason\": \"stop\"\n            }],\n            \"usage\": {\n                \"prompt_tokens\": 100,\n                \"completion_tokens\": 50,\n                \"prompt_tokens_details\": {\n                    \"cached_tokens\": 80\n                }\n            }\n        });\n\n        let result = openai_to_anthropic(input).unwrap();\n        assert_eq!(result[\"usage\"][\"input_tokens\"], 100);\n        assert_eq!(result[\"usage\"][\"output_tokens\"], 50);\n        assert_eq!(result[\"usage\"][\"cache_read_input_tokens\"], 80);\n    }\n\n    #[test]\n    fn test_openai_to_anthropic_with_direct_cache_fields() {\n        let input = json!({\n            \"id\": \"chatcmpl-123\",\n            \"model\": \"gpt-4\",\n            \"choices\": [{\n                \"index\": 0,\n                \"message\": {\"role\": \"assistant\", \"content\": \"Hello!\"},\n                \"finish_reason\": \"stop\"\n            }],\n            \"usage\": {\n                \"prompt_tokens\": 100,\n                \"completion_tokens\": 50,\n                \"cache_read_input_tokens\": 60,\n                \"cache_creation_input_tokens\": 20\n            }\n        });\n\n        let result = openai_to_anthropic(input).unwrap();\n        assert_eq!(result[\"usage\"][\"cache_read_input_tokens\"], 60);\n        assert_eq!(result[\"usage\"][\"cache_creation_input_tokens\"], 20);\n    }\n\n    #[test]\n    fn test_openai_to_anthropic_finish_reason_content_filter_maps_end_turn() {\n        let input = json!({\n            \"id\": \"chatcmpl-123\",\n            \"model\": \"gpt-4\",\n            \"choices\": [{\n                \"index\": 0,\n                \"message\": {\"role\": \"assistant\", \"content\": \"Blocked\"},\n                \"finish_reason\": \"content_filter\"\n            }],\n            \"usage\": {\"prompt_tokens\": 10, \"completion_tokens\": 1}\n        });\n\n        let result = openai_to_anthropic(input).unwrap();\n        assert_eq!(result[\"stop_reason\"], \"end_turn\");\n    }\n\n    #[test]\n    fn test_openai_to_anthropic_with_legacy_function_call() {\n        let input = json!({\n            \"id\": \"chatcmpl-123\",\n            \"model\": \"gpt-4\",\n            \"choices\": [{\n                \"index\": 0,\n                \"message\": {\n                    \"role\": \"assistant\",\n                    \"content\": null,\n                    \"function_call\": {\n                        \"name\": \"get_weather\",\n                        \"arguments\": \"{\\\"location\\\":\\\"Tokyo\\\"}\"\n                    }\n                },\n                \"finish_reason\": \"function_call\"\n            }],\n            \"usage\": {\"prompt_tokens\": 10, \"completion_tokens\": 5}\n        });\n\n        let result = openai_to_anthropic(input).unwrap();\n        assert_eq!(result[\"content\"][0][\"type\"], \"tool_use\");\n        assert_eq!(result[\"content\"][0][\"name\"], \"get_weather\");\n        assert_eq!(result[\"content\"][0][\"input\"][\"location\"], \"Tokyo\");\n        assert_eq!(result[\"stop_reason\"], \"tool_use\");\n    }\n\n    #[test]\n    fn test_openai_to_anthropic_with_content_parts_and_refusal() {\n        let input = json!({\n            \"id\": \"chatcmpl-123\",\n            \"model\": \"gpt-4\",\n            \"choices\": [{\n                \"index\": 0,\n                \"message\": {\n                    \"role\": \"assistant\",\n                    \"content\": [\n                        {\"type\": \"text\", \"text\": \"Hello\"},\n                        {\"type\": \"refusal\", \"refusal\": \"I can't do that\"}\n                    ]\n                },\n                \"finish_reason\": \"stop\"\n            }],\n            \"usage\": {\"prompt_tokens\": 10, \"completion_tokens\": 5}\n        });\n\n        let result = openai_to_anthropic(input).unwrap();\n        assert_eq!(result[\"content\"][0][\"type\"], \"text\");\n        assert_eq!(result[\"content\"][0][\"text\"], \"Hello\");\n        assert_eq!(result[\"content\"][1][\"type\"], \"text\");\n        assert_eq!(result[\"content\"][1][\"text\"], \"I can't do that\");\n    }\n\n    #[test]\n    fn test_is_openai_o_series() {\n        assert!(is_openai_o_series(\"o1\"));\n        assert!(is_openai_o_series(\"o1-preview\"));\n        assert!(is_openai_o_series(\"o1-mini\"));\n        assert!(is_openai_o_series(\"o3\"));\n        assert!(is_openai_o_series(\"o3-mini\"));\n        assert!(is_openai_o_series(\"o4-mini\"));\n        assert!(!is_openai_o_series(\"gpt-4o\"));\n        assert!(!is_openai_o_series(\"openai-gpt\"));\n        assert!(!is_openai_o_series(\"o\"));\n        assert!(!is_openai_o_series(\"\"));\n    }\n\n    #[test]\n    fn test_anthropic_to_openai_o_series_max_completion_tokens() {\n        for model in &[\"o1\", \"o3-mini\", \"o4-mini\"] {\n            let input = json!({\n                \"model\": model,\n                \"max_tokens\": 4096,\n                \"messages\": [{\"role\": \"user\", \"content\": \"Hello\"}]\n            });\n\n            let result = anthropic_to_openai(input, None).unwrap();\n            assert!(\n                result.get(\"max_tokens\").is_none(),\n                \"{model} should not have max_tokens\"\n            );\n            assert_eq!(\n                result[\"max_completion_tokens\"], 4096,\n                \"{model} should use max_completion_tokens\"\n            );\n        }\n    }\n\n    #[test]\n    fn test_anthropic_to_openai_non_o_series_keeps_max_tokens() {\n        let input = json!({\n            \"model\": \"gpt-4o\",\n            \"max_tokens\": 1024,\n            \"messages\": [{\"role\": \"user\", \"content\": \"Hello\"}]\n        });\n\n        let result = anthropic_to_openai(input, None).unwrap();\n        assert_eq!(result[\"max_tokens\"], 1024);\n        assert!(result.get(\"max_completion_tokens\").is_none());\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/providers/transform_responses.rs",
    "content": "//! OpenAI Responses API 格式转换模块\n//!\n//! 实现 Anthropic Messages ↔ OpenAI Responses API 格式转换。\n//! Responses API 是 OpenAI 2025 年推出的新一代 API，采用扁平化的 input/output 结构。\n//!\n//! 与 Chat Completions 的主要差异：\n//! - tool_use/tool_result 从 message content 中\"提升\"为顶层 input item\n//! - system prompt 使用 `instructions` 字段而非 system role message\n//! - usage 字段命名与 Anthropic 一致 (input_tokens/output_tokens)\n\nuse crate::proxy::error::ProxyError;\nuse serde_json::{json, Value};\n\n/// Anthropic 请求 → OpenAI Responses 请求\n///\n/// `cache_key`: optional prompt_cache_key to inject for improved cache routing\npub fn anthropic_to_responses(body: Value, cache_key: Option<&str>) -> Result<Value, ProxyError> {\n    let mut result = json!({});\n\n    // NOTE: 模型映射由上游统一处理（proxy::model_mapper），格式转换层只做结构转换。\n    if let Some(model) = body.get(\"model\").and_then(|m| m.as_str()) {\n        result[\"model\"] = json!(model);\n    }\n\n    // system → instructions (Responses API 使用 instructions 字段)\n    if let Some(system) = body.get(\"system\") {\n        let instructions = if let Some(text) = system.as_str() {\n            text.to_string()\n        } else if let Some(arr) = system.as_array() {\n            arr.iter()\n                .filter_map(|msg| msg.get(\"text\").and_then(|t| t.as_str()))\n                .collect::<Vec<_>>()\n                .join(\"\\n\\n\")\n        } else {\n            String::new()\n        };\n        if !instructions.is_empty() {\n            result[\"instructions\"] = json!(instructions);\n        }\n    }\n\n    // messages → input\n    if let Some(msgs) = body.get(\"messages\").and_then(|m| m.as_array()) {\n        let input = convert_messages_to_input(msgs)?;\n        result[\"input\"] = json!(input);\n    }\n\n    // max_tokens → max_output_tokens (Responses API uses max_output_tokens for all models)\n    if let Some(v) = body.get(\"max_tokens\") {\n        result[\"max_output_tokens\"] = v.clone();\n    }\n\n    // 直接透传的参数\n    if let Some(v) = body.get(\"temperature\") {\n        result[\"temperature\"] = v.clone();\n    }\n    if let Some(v) = body.get(\"top_p\") {\n        result[\"top_p\"] = v.clone();\n    }\n    if let Some(v) = body.get(\"stream\") {\n        result[\"stream\"] = v.clone();\n    }\n\n    // stop_sequences → 丢弃 (Responses API 不支持)\n\n    // 转换 tools (过滤 BatchTool)\n    if let Some(tools) = body.get(\"tools\").and_then(|t| t.as_array()) {\n        let response_tools: Vec<Value> = tools\n            .iter()\n            .filter(|t| t.get(\"type\").and_then(|v| v.as_str()) != Some(\"BatchTool\"))\n            .map(|t| {\n                json!({\n                    \"type\": \"function\",\n                    \"name\": t.get(\"name\").and_then(|n| n.as_str()).unwrap_or(\"\"),\n                    \"description\": t.get(\"description\"),\n                    \"parameters\": super::transform::clean_schema(\n                        t.get(\"input_schema\").cloned().unwrap_or(json!({}))\n                    )\n                })\n            })\n            .collect();\n\n        if !response_tools.is_empty() {\n            result[\"tools\"] = json!(response_tools);\n        }\n    }\n\n    if let Some(v) = body.get(\"tool_choice\") {\n        result[\"tool_choice\"] = map_tool_choice_to_responses(v);\n    }\n\n    // Inject prompt_cache_key for improved cache routing on OpenAI-compatible endpoints\n    if let Some(key) = cache_key {\n        result[\"prompt_cache_key\"] = json!(key);\n    }\n\n    Ok(result)\n}\n\nfn map_tool_choice_to_responses(tool_choice: &Value) -> Value {\n    match tool_choice {\n        Value::String(_) => tool_choice.clone(),\n        Value::Object(obj) => match obj.get(\"type\").and_then(|t| t.as_str()) {\n            // Anthropic \"any\" means at least one tool call is required\n            Some(\"any\") => json!(\"required\"),\n            Some(\"auto\") => json!(\"auto\"),\n            Some(\"none\") => json!(\"none\"),\n            // Anthropic forced tool -> Responses function tool selector\n            Some(\"tool\") => {\n                let name = obj.get(\"name\").and_then(|n| n.as_str()).unwrap_or(\"\");\n                json!({\n                    \"type\": \"function\",\n                    \"name\": name\n                })\n            }\n            _ => tool_choice.clone(),\n        },\n        _ => tool_choice.clone(),\n    }\n}\n\npub(crate) fn map_responses_stop_reason(\n    status: Option<&str>,\n    has_tool_use: bool,\n    incomplete_reason: Option<&str>,\n) -> Option<&'static str> {\n    status.map(|s| match s {\n        \"completed\" => {\n            if has_tool_use {\n                \"tool_use\"\n            } else {\n                \"end_turn\"\n            }\n        }\n        \"incomplete\" => {\n            if matches!(\n                incomplete_reason,\n                Some(\"max_output_tokens\") | Some(\"max_tokens\")\n            ) || incomplete_reason.is_none()\n            {\n                \"max_tokens\"\n            } else {\n                \"end_turn\"\n            }\n        }\n        _ => \"end_turn\",\n    })\n}\n\n/// Build Anthropic-style usage JSON from Responses API usage, including cache tokens.\n///\n/// Priority order:\n/// 1. OpenAI nested details (`input_tokens_details.cached_tokens`, `prompt_tokens_details.cached_tokens`) as initial value\n/// 2. Direct Anthropic-style fields (`cache_read_input_tokens`, `cache_creation_input_tokens`) override if present\npub(crate) fn build_anthropic_usage_from_responses(usage: Option<&Value>) -> Value {\n    let u = match usage {\n        Some(v) if !v.is_null() => v,\n        _ => {\n            return json!({\n                \"input_tokens\": 0,\n                \"output_tokens\": 0\n            })\n        }\n    };\n\n    let input = u.get(\"input_tokens\").and_then(|v| v.as_u64()).unwrap_or(0);\n    let output = u.get(\"output_tokens\").and_then(|v| v.as_u64()).unwrap_or(0);\n\n    let mut result = json!({\n        \"input_tokens\": input,\n        \"output_tokens\": output\n    });\n\n    // Step 1: OpenAI nested details as fallback\n    // OpenAI Responses API: input_tokens_details.cached_tokens\n    if let Some(cached) = u\n        .pointer(\"/input_tokens_details/cached_tokens\")\n        .and_then(|v| v.as_u64())\n    {\n        result[\"cache_read_input_tokens\"] = json!(cached);\n    }\n    // OpenAI standard: prompt_tokens_details.cached_tokens\n    if let Some(cached) = u\n        .pointer(\"/prompt_tokens_details/cached_tokens\")\n        .and_then(|v| v.as_u64())\n    {\n        if result.get(\"cache_read_input_tokens\").is_none() {\n            result[\"cache_read_input_tokens\"] = json!(cached);\n        }\n    }\n\n    // Step 2: Direct Anthropic-style fields override (authoritative if present)\n    if let Some(v) = u.get(\"cache_read_input_tokens\") {\n        result[\"cache_read_input_tokens\"] = v.clone();\n    }\n    if let Some(v) = u.get(\"cache_creation_input_tokens\") {\n        result[\"cache_creation_input_tokens\"] = v.clone();\n    }\n\n    result\n}\n\n/// 将 Anthropic messages 数组转换为 Responses API input 数组\n///\n/// 核心转换逻辑：\n/// - user/assistant 的 text 内容 → 对应 role 的 message item\n/// - tool_use 从 assistant message 中\"提升\"为独立的 function_call item\n/// - tool_result 从 user message 中\"提升\"为独立的 function_call_output item\n/// - thinking blocks → 丢弃\nfn convert_messages_to_input(messages: &[Value]) -> Result<Vec<Value>, ProxyError> {\n    let mut input = Vec::new();\n\n    for msg in messages {\n        let role = msg.get(\"role\").and_then(|r| r.as_str()).unwrap_or(\"user\");\n        let content = msg.get(\"content\");\n\n        match content {\n            // 字符串内容\n            Some(Value::String(text)) => {\n                let content_type = if role == \"assistant\" {\n                    \"output_text\"\n                } else {\n                    \"input_text\"\n                };\n                input.push(json!({\n                    \"role\": role,\n                    \"content\": [{ \"type\": content_type, \"text\": text }]\n                }));\n            }\n\n            // 数组内容（多模态/工具调用）\n            Some(Value::Array(blocks)) => {\n                let mut message_content = Vec::new();\n\n                for block in blocks {\n                    let block_type = block.get(\"type\").and_then(|t| t.as_str()).unwrap_or(\"\");\n\n                    match block_type {\n                        \"text\" => {\n                            if let Some(text) = block.get(\"text\").and_then(|t| t.as_str()) {\n                                let content_type = if role == \"assistant\" {\n                                    \"output_text\"\n                                } else {\n                                    \"input_text\"\n                                };\n                                // OpenAI Responses API does not accept Anthropic cache_control\n                                // under input[].content[].\n                                message_content.push(json!({ \"type\": content_type, \"text\": text }));\n                            }\n                        }\n\n                        \"image\" => {\n                            if let Some(source) = block.get(\"source\") {\n                                let media_type = source\n                                    .get(\"media_type\")\n                                    .and_then(|m| m.as_str())\n                                    .unwrap_or(\"image/png\");\n                                let data =\n                                    source.get(\"data\").and_then(|d| d.as_str()).unwrap_or(\"\");\n                                message_content.push(json!({\n                                    \"type\": \"input_image\",\n                                    \"image_url\": format!(\"data:{media_type};base64,{data}\")\n                                }));\n                            }\n                        }\n\n                        \"tool_use\" => {\n                            // 先刷新已累积的消息内容\n                            if !message_content.is_empty() {\n                                input.push(json!({\n                                    \"role\": role,\n                                    \"content\": message_content.clone()\n                                }));\n                                message_content.clear();\n                            }\n\n                            // 提升为独立的 function_call item\n                            let id = block.get(\"id\").and_then(|i| i.as_str()).unwrap_or(\"\");\n                            let name = block.get(\"name\").and_then(|n| n.as_str()).unwrap_or(\"\");\n                            let arguments = block.get(\"input\").cloned().unwrap_or(json!({}));\n\n                            input.push(json!({\n                                \"type\": \"function_call\",\n                                \"call_id\": id,\n                                \"name\": name,\n                                \"arguments\": serde_json::to_string(&arguments).unwrap_or_default()\n                            }));\n                        }\n\n                        \"tool_result\" => {\n                            // 先刷新已累积的消息内容\n                            if !message_content.is_empty() {\n                                input.push(json!({\n                                    \"role\": role,\n                                    \"content\": message_content.clone()\n                                }));\n                                message_content.clear();\n                            }\n\n                            // 提升为独立的 function_call_output item\n                            let call_id = block\n                                .get(\"tool_use_id\")\n                                .and_then(|i| i.as_str())\n                                .unwrap_or(\"\");\n                            let output = match block.get(\"content\") {\n                                Some(Value::String(s)) => s.clone(),\n                                Some(v) => serde_json::to_string(v).unwrap_or_default(),\n                                None => String::new(),\n                            };\n\n                            input.push(json!({\n                                \"type\": \"function_call_output\",\n                                \"call_id\": call_id,\n                                \"output\": output\n                            }));\n                        }\n\n                        \"thinking\" => {\n                            // 丢弃 thinking blocks（与 openai_chat 一致）\n                        }\n\n                        _ => {}\n                    }\n                }\n\n                // 刷新剩余的消息内容\n                if !message_content.is_empty() {\n                    input.push(json!({\n                        \"role\": role,\n                        \"content\": message_content\n                    }));\n                }\n            }\n\n            _ => {\n                // 无内容或 null\n                input.push(json!({ \"role\": role }));\n            }\n        }\n    }\n\n    Ok(input)\n}\n\n/// OpenAI Responses 响应 → Anthropic 响应\npub fn responses_to_anthropic(body: Value) -> Result<Value, ProxyError> {\n    let output = body\n        .get(\"output\")\n        .and_then(|o| o.as_array())\n        .ok_or_else(|| ProxyError::TransformError(\"No output in response\".to_string()))?;\n\n    let mut content = Vec::new();\n\n    let mut has_tool_use = false;\n    for item in output {\n        let item_type = item.get(\"type\").and_then(|t| t.as_str()).unwrap_or(\"\");\n\n        match item_type {\n            \"message\" => {\n                if let Some(msg_content) = item.get(\"content\").and_then(|c| c.as_array()) {\n                    for block in msg_content {\n                        let block_type = block.get(\"type\").and_then(|t| t.as_str()).unwrap_or(\"\");\n                        if block_type == \"output_text\" {\n                            if let Some(text) = block.get(\"text\").and_then(|t| t.as_str()) {\n                                if !text.is_empty() {\n                                    content.push(json!({\"type\": \"text\", \"text\": text}));\n                                }\n                            }\n                        } else if block_type == \"refusal\" {\n                            if let Some(refusal) = block.get(\"refusal\").and_then(|t| t.as_str()) {\n                                if !refusal.is_empty() {\n                                    content.push(json!({\"type\": \"text\", \"text\": refusal}));\n                                }\n                            }\n                        }\n                    }\n                }\n            }\n\n            \"function_call\" => {\n                let call_id = item.get(\"call_id\").and_then(|i| i.as_str()).unwrap_or(\"\");\n                let name = item.get(\"name\").and_then(|n| n.as_str()).unwrap_or(\"\");\n                let args_str = item\n                    .get(\"arguments\")\n                    .and_then(|a| a.as_str())\n                    .unwrap_or(\"{}\");\n                let input: Value = serde_json::from_str(args_str).unwrap_or(json!({}));\n\n                content.push(json!({\n                    \"type\": \"tool_use\",\n                    \"id\": call_id,\n                    \"name\": name,\n                    \"input\": input\n                }));\n                has_tool_use = true;\n            }\n\n            \"reasoning\" => {\n                // 映射 reasoning summary → thinking block\n                if let Some(summary) = item.get(\"summary\").and_then(|s| s.as_array()) {\n                    let thinking_text: String = summary\n                        .iter()\n                        .filter_map(|s| {\n                            if s.get(\"type\").and_then(|t| t.as_str()) == Some(\"summary_text\") {\n                                s.get(\"text\").and_then(|t| t.as_str())\n                            } else {\n                                None\n                            }\n                        })\n                        .collect::<Vec<_>>()\n                        .join(\"\");\n\n                    if !thinking_text.is_empty() {\n                        content.push(json!({\n                            \"type\": \"thinking\",\n                            \"thinking\": thinking_text\n                        }));\n                    }\n                }\n            }\n\n            _ => {}\n        }\n    }\n\n    // status → stop_reason\n    let stop_reason = map_responses_stop_reason(\n        body.get(\"status\").and_then(|s| s.as_str()),\n        has_tool_use,\n        body.pointer(\"/incomplete_details/reason\")\n            .and_then(|r| r.as_str()),\n    );\n\n    let usage_json = build_anthropic_usage_from_responses(body.get(\"usage\"));\n\n    let result = json!({\n        \"id\": body.get(\"id\").and_then(|i| i.as_str()).unwrap_or(\"\"),\n        \"type\": \"message\",\n        \"role\": \"assistant\",\n        \"content\": content,\n        \"model\": body.get(\"model\").and_then(|m| m.as_str()).unwrap_or(\"\"),\n        \"stop_reason\": stop_reason,\n        \"stop_sequence\": null,\n        \"usage\": usage_json\n    });\n\n    Ok(result)\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_anthropic_to_responses_simple() {\n        let input = json!({\n            \"model\": \"gpt-4o\",\n            \"max_tokens\": 1024,\n            \"messages\": [{\"role\": \"user\", \"content\": \"Hello\"}]\n        });\n\n        let result = anthropic_to_responses(input, None).unwrap();\n        assert_eq!(result[\"model\"], \"gpt-4o\");\n        assert_eq!(result[\"max_output_tokens\"], 1024);\n        assert_eq!(result[\"input\"][0][\"role\"], \"user\");\n        assert_eq!(result[\"input\"][0][\"content\"][0][\"type\"], \"input_text\");\n        assert_eq!(result[\"input\"][0][\"content\"][0][\"text\"], \"Hello\");\n        // stop_sequences should not appear\n        assert!(result.get(\"stop_sequences\").is_none());\n    }\n\n    #[test]\n    fn test_anthropic_to_responses_with_system_string() {\n        let input = json!({\n            \"model\": \"gpt-4o\",\n            \"max_tokens\": 1024,\n            \"system\": \"You are a helpful assistant.\",\n            \"messages\": [{\"role\": \"user\", \"content\": \"Hello\"}]\n        });\n\n        let result = anthropic_to_responses(input, None).unwrap();\n        assert_eq!(result[\"instructions\"], \"You are a helpful assistant.\");\n        // system should not appear in input\n        assert_eq!(result[\"input\"].as_array().unwrap().len(), 1);\n    }\n\n    #[test]\n    fn test_anthropic_to_responses_with_system_array() {\n        let input = json!({\n            \"model\": \"gpt-4o\",\n            \"max_tokens\": 1024,\n            \"system\": [\n                {\"type\": \"text\", \"text\": \"Part 1\"},\n                {\"type\": \"text\", \"text\": \"Part 2\"}\n            ],\n            \"messages\": [{\"role\": \"user\", \"content\": \"Hello\"}]\n        });\n\n        let result = anthropic_to_responses(input, None).unwrap();\n        assert_eq!(result[\"instructions\"], \"Part 1\\n\\nPart 2\");\n    }\n\n    #[test]\n    fn test_anthropic_to_responses_with_tools() {\n        let input = json!({\n            \"model\": \"gpt-4o\",\n            \"max_tokens\": 1024,\n            \"messages\": [{\"role\": \"user\", \"content\": \"Weather?\"}],\n            \"tools\": [{\n                \"name\": \"get_weather\",\n                \"description\": \"Get weather info\",\n                \"input_schema\": {\"type\": \"object\", \"properties\": {\"location\": {\"type\": \"string\"}}}\n            }]\n        });\n\n        let result = anthropic_to_responses(input, None).unwrap();\n        assert_eq!(result[\"tools\"][0][\"type\"], \"function\");\n        assert_eq!(result[\"tools\"][0][\"name\"], \"get_weather\");\n        assert!(result[\"tools\"][0].get(\"parameters\").is_some());\n        // input_schema should not appear\n        assert!(result[\"tools\"][0].get(\"input_schema\").is_none());\n    }\n\n    #[test]\n    fn test_anthropic_to_responses_tool_choice_any_to_required() {\n        let input = json!({\n            \"model\": \"gpt-4o\",\n            \"max_tokens\": 1024,\n            \"messages\": [{\"role\": \"user\", \"content\": \"Weather?\"}],\n            \"tool_choice\": {\"type\": \"any\"}\n        });\n\n        let result = anthropic_to_responses(input, None).unwrap();\n        assert_eq!(result[\"tool_choice\"], \"required\");\n    }\n\n    #[test]\n    fn test_anthropic_to_responses_tool_choice_tool_to_function() {\n        let input = json!({\n            \"model\": \"gpt-4o\",\n            \"max_tokens\": 1024,\n            \"messages\": [{\"role\": \"user\", \"content\": \"Weather?\"}],\n            \"tool_choice\": {\"type\": \"tool\", \"name\": \"get_weather\"}\n        });\n\n        let result = anthropic_to_responses(input, None).unwrap();\n        assert_eq!(result[\"tool_choice\"][\"type\"], \"function\");\n        assert_eq!(result[\"tool_choice\"][\"name\"], \"get_weather\");\n    }\n\n    #[test]\n    fn test_anthropic_to_responses_tool_use_lifting() {\n        let input = json!({\n            \"model\": \"gpt-4o\",\n            \"max_tokens\": 1024,\n            \"messages\": [{\n                \"role\": \"assistant\",\n                \"content\": [\n                    {\"type\": \"text\", \"text\": \"Let me check\"},\n                    {\"type\": \"tool_use\", \"id\": \"call_123\", \"name\": \"get_weather\", \"input\": {\"location\": \"Tokyo\"}}\n                ]\n            }]\n        });\n\n        let result = anthropic_to_responses(input, None).unwrap();\n        let input_arr = result[\"input\"].as_array().unwrap();\n\n        // Should produce: assistant message (text) + function_call item\n        assert_eq!(input_arr.len(), 2);\n\n        // First: assistant message with output_text\n        assert_eq!(input_arr[0][\"role\"], \"assistant\");\n        assert_eq!(input_arr[0][\"content\"][0][\"type\"], \"output_text\");\n        assert_eq!(input_arr[0][\"content\"][0][\"text\"], \"Let me check\");\n\n        // Second: function_call item (lifted from message)\n        assert_eq!(input_arr[1][\"type\"], \"function_call\");\n        assert_eq!(input_arr[1][\"call_id\"], \"call_123\");\n        assert_eq!(input_arr[1][\"name\"], \"get_weather\");\n    }\n\n    #[test]\n    fn test_anthropic_to_responses_tool_result_lifting() {\n        let input = json!({\n            \"model\": \"gpt-4o\",\n            \"max_tokens\": 1024,\n            \"messages\": [{\n                \"role\": \"user\",\n                \"content\": [\n                    {\"type\": \"tool_result\", \"tool_use_id\": \"call_123\", \"content\": \"Sunny, 25°C\"}\n                ]\n            }]\n        });\n\n        let result = anthropic_to_responses(input, None).unwrap();\n        let input_arr = result[\"input\"].as_array().unwrap();\n\n        // Should produce: function_call_output item (lifted)\n        assert_eq!(input_arr.len(), 1);\n        assert_eq!(input_arr[0][\"type\"], \"function_call_output\");\n        assert_eq!(input_arr[0][\"call_id\"], \"call_123\");\n        assert_eq!(input_arr[0][\"output\"], \"Sunny, 25°C\");\n    }\n\n    #[test]\n    fn test_anthropic_to_responses_thinking_discarded() {\n        let input = json!({\n            \"model\": \"gpt-4o\",\n            \"max_tokens\": 1024,\n            \"messages\": [{\n                \"role\": \"assistant\",\n                \"content\": [\n                    {\"type\": \"thinking\", \"thinking\": \"Let me think...\"},\n                    {\"type\": \"text\", \"text\": \"The answer is 42\"}\n                ]\n            }]\n        });\n\n        let result = anthropic_to_responses(input, None).unwrap();\n        let input_arr = result[\"input\"].as_array().unwrap();\n\n        // thinking should be discarded, only text remains\n        assert_eq!(input_arr.len(), 1);\n        assert_eq!(input_arr[0][\"content\"][0][\"type\"], \"output_text\");\n        assert_eq!(input_arr[0][\"content\"][0][\"text\"], \"The answer is 42\");\n    }\n\n    #[test]\n    fn test_anthropic_to_responses_image() {\n        let input = json!({\n            \"model\": \"gpt-4o\",\n            \"max_tokens\": 1024,\n            \"messages\": [{\n                \"role\": \"user\",\n                \"content\": [\n                    {\"type\": \"text\", \"text\": \"What is this?\"},\n                    {\"type\": \"image\", \"source\": {\"type\": \"base64\", \"media_type\": \"image/png\", \"data\": \"abc123\"}}\n                ]\n            }]\n        });\n\n        let result = anthropic_to_responses(input, None).unwrap();\n        let content = result[\"input\"][0][\"content\"].as_array().unwrap();\n\n        assert_eq!(content[0][\"type\"], \"input_text\");\n        assert_eq!(content[1][\"type\"], \"input_image\");\n        assert_eq!(content[1][\"image_url\"], \"data:image/png;base64,abc123\");\n    }\n\n    #[test]\n    fn test_responses_to_anthropic_simple() {\n        let input = json!({\n            \"id\": \"resp_123\",\n            \"object\": \"response\",\n            \"status\": \"completed\",\n            \"model\": \"gpt-4o\",\n            \"output\": [{\n                \"type\": \"message\",\n                \"id\": \"msg_123\",\n                \"role\": \"assistant\",\n                \"content\": [{\"type\": \"output_text\", \"text\": \"Hello!\"}]\n            }],\n            \"usage\": {\"input_tokens\": 10, \"output_tokens\": 5, \"total_tokens\": 15}\n        });\n\n        let result = responses_to_anthropic(input).unwrap();\n        assert_eq!(result[\"id\"], \"resp_123\");\n        assert_eq!(result[\"type\"], \"message\");\n        assert_eq!(result[\"content\"][0][\"type\"], \"text\");\n        assert_eq!(result[\"content\"][0][\"text\"], \"Hello!\");\n        assert_eq!(result[\"stop_reason\"], \"end_turn\");\n        assert_eq!(result[\"usage\"][\"input_tokens\"], 10);\n        assert_eq!(result[\"usage\"][\"output_tokens\"], 5);\n    }\n\n    #[test]\n    fn test_responses_to_anthropic_with_function_call() {\n        let input = json!({\n            \"id\": \"resp_123\",\n            \"object\": \"response\",\n            \"status\": \"completed\",\n            \"model\": \"gpt-4o\",\n            \"output\": [{\n                \"type\": \"function_call\",\n                \"id\": \"fc_123\",\n                \"call_id\": \"call_123\",\n                \"name\": \"get_weather\",\n                \"arguments\": \"{\\\"location\\\": \\\"Tokyo\\\"}\",\n                \"status\": \"completed\"\n            }],\n            \"usage\": {\"input_tokens\": 10, \"output_tokens\": 15}\n        });\n\n        let result = responses_to_anthropic(input).unwrap();\n        assert_eq!(result[\"content\"][0][\"type\"], \"tool_use\");\n        assert_eq!(result[\"content\"][0][\"id\"], \"call_123\");\n        assert_eq!(result[\"content\"][0][\"name\"], \"get_weather\");\n        assert_eq!(result[\"content\"][0][\"input\"][\"location\"], \"Tokyo\");\n        assert_eq!(result[\"stop_reason\"], \"tool_use\");\n    }\n\n    #[test]\n    fn test_responses_to_anthropic_with_refusal_block() {\n        let input = json!({\n            \"id\": \"resp_123\",\n            \"status\": \"completed\",\n            \"model\": \"gpt-4o\",\n            \"output\": [{\n                \"type\": \"message\",\n                \"content\": [{\"type\": \"refusal\", \"refusal\": \"I can't help with that.\"}]\n            }],\n            \"usage\": {\"input_tokens\": 10, \"output_tokens\": 5}\n        });\n\n        let result = responses_to_anthropic(input).unwrap();\n        assert_eq!(result[\"content\"][0][\"type\"], \"text\");\n        assert_eq!(result[\"content\"][0][\"text\"], \"I can't help with that.\");\n        assert_eq!(result[\"stop_reason\"], \"end_turn\");\n    }\n\n    #[test]\n    fn test_responses_to_anthropic_with_reasoning() {\n        let input = json!({\n            \"id\": \"resp_123\",\n            \"object\": \"response\",\n            \"status\": \"completed\",\n            \"model\": \"gpt-4o\",\n            \"output\": [\n                {\n                    \"type\": \"reasoning\",\n                    \"id\": \"rs_123\",\n                    \"summary\": [\n                        {\"type\": \"summary_text\", \"text\": \"Thinking about the problem...\"}\n                    ]\n                },\n                {\n                    \"type\": \"message\",\n                    \"id\": \"msg_123\",\n                    \"role\": \"assistant\",\n                    \"content\": [{\"type\": \"output_text\", \"text\": \"The answer is 42\"}]\n                }\n            ],\n            \"usage\": {\"input_tokens\": 10, \"output_tokens\": 20}\n        });\n\n        let result = responses_to_anthropic(input).unwrap();\n        // Should have thinking + text\n        assert_eq!(result[\"content\"][0][\"type\"], \"thinking\");\n        assert_eq!(\n            result[\"content\"][0][\"thinking\"],\n            \"Thinking about the problem...\"\n        );\n        assert_eq!(result[\"content\"][1][\"type\"], \"text\");\n        assert_eq!(result[\"content\"][1][\"text\"], \"The answer is 42\");\n    }\n\n    #[test]\n    fn test_responses_to_anthropic_incomplete_status() {\n        let input = json!({\n            \"id\": \"resp_123\",\n            \"status\": \"incomplete\",\n            \"model\": \"gpt-4o\",\n            \"output\": [{\n                \"type\": \"message\",\n                \"content\": [{\"type\": \"output_text\", \"text\": \"Partial...\"}]\n            }],\n            \"usage\": {\"input_tokens\": 10, \"output_tokens\": 4096}\n        });\n\n        let result = responses_to_anthropic(input).unwrap();\n        assert_eq!(result[\"stop_reason\"], \"max_tokens\");\n    }\n\n    #[test]\n    fn test_responses_to_anthropic_incomplete_non_token_reason() {\n        let input = json!({\n            \"id\": \"resp_123\",\n            \"status\": \"incomplete\",\n            \"incomplete_details\": {\"reason\": \"content_filter\"},\n            \"model\": \"gpt-4o\",\n            \"output\": [{\n                \"type\": \"message\",\n                \"content\": [{\"type\": \"output_text\", \"text\": \"Blocked\"}]\n            }],\n            \"usage\": {\"input_tokens\": 10, \"output_tokens\": 1}\n        });\n\n        let result = responses_to_anthropic(input).unwrap();\n        assert_eq!(result[\"stop_reason\"], \"end_turn\");\n    }\n\n    #[test]\n    fn test_model_passthrough() {\n        let input = json!({\n            \"model\": \"o3-mini\",\n            \"max_tokens\": 1024,\n            \"messages\": [{\"role\": \"user\", \"content\": \"Hello\"}]\n        });\n\n        let result = anthropic_to_responses(input, None).unwrap();\n        assert_eq!(result[\"model\"], \"o3-mini\");\n    }\n\n    #[test]\n    fn test_anthropic_to_responses_with_cache_key() {\n        let input = json!({\n            \"model\": \"gpt-4o\",\n            \"max_tokens\": 1024,\n            \"messages\": [{\"role\": \"user\", \"content\": \"Hello\"}]\n        });\n\n        let result = anthropic_to_responses(input, Some(\"my-provider-id\")).unwrap();\n        assert_eq!(result[\"prompt_cache_key\"], \"my-provider-id\");\n    }\n\n    #[test]\n    fn test_anthropic_to_responses_strip_cache_control_on_tools() {\n        let input = json!({\n            \"model\": \"gpt-4o\",\n            \"max_tokens\": 1024,\n            \"messages\": [{\"role\": \"user\", \"content\": \"Weather?\"}],\n            \"tools\": [{\n                \"name\": \"get_weather\",\n                \"description\": \"Get weather\",\n                \"input_schema\": {\"type\": \"object\"},\n                \"cache_control\": {\"type\": \"ephemeral\"}\n            }]\n        });\n\n        let result = anthropic_to_responses(input, None).unwrap();\n        assert!(result[\"tools\"][0].get(\"cache_control\").is_none());\n    }\n\n    #[test]\n    fn test_anthropic_to_responses_strip_cache_control_on_text() {\n        let input = json!({\n            \"model\": \"gpt-4o\",\n            \"max_tokens\": 1024,\n            \"messages\": [{\n                \"role\": \"user\",\n                \"content\": [\n                    {\"type\": \"text\", \"text\": \"Hello\", \"cache_control\": {\"type\": \"ephemeral\"}}\n                ]\n            }]\n        });\n\n        let result = anthropic_to_responses(input, None).unwrap();\n        assert!(result[\"input\"][0][\"content\"][0]\n            .get(\"cache_control\")\n            .is_none());\n    }\n\n    #[test]\n    fn test_responses_to_anthropic_with_cache_tokens() {\n        let input = json!({\n            \"id\": \"resp_123\",\n            \"status\": \"completed\",\n            \"model\": \"gpt-4o\",\n            \"output\": [{\n                \"type\": \"message\",\n                \"content\": [{\"type\": \"output_text\", \"text\": \"Hello!\"}]\n            }],\n            \"usage\": {\n                \"input_tokens\": 100,\n                \"output_tokens\": 50,\n                \"input_tokens_details\": {\n                    \"cached_tokens\": 80\n                }\n            }\n        });\n\n        let result = responses_to_anthropic(input).unwrap();\n        assert_eq!(result[\"usage\"][\"input_tokens\"], 100);\n        assert_eq!(result[\"usage\"][\"output_tokens\"], 50);\n        assert_eq!(result[\"usage\"][\"cache_read_input_tokens\"], 80);\n    }\n\n    #[test]\n    fn test_responses_to_anthropic_with_direct_cache_fields() {\n        let input = json!({\n            \"id\": \"resp_123\",\n            \"status\": \"completed\",\n            \"model\": \"gpt-4o\",\n            \"output\": [{\n                \"type\": \"message\",\n                \"content\": [{\"type\": \"output_text\", \"text\": \"Hello!\"}]\n            }],\n            \"usage\": {\n                \"input_tokens\": 100,\n                \"output_tokens\": 50,\n                \"cache_read_input_tokens\": 60,\n                \"cache_creation_input_tokens\": 20\n            }\n        });\n\n        let result = responses_to_anthropic(input).unwrap();\n        assert_eq!(result[\"usage\"][\"cache_read_input_tokens\"], 60);\n        assert_eq!(result[\"usage\"][\"cache_creation_input_tokens\"], 20);\n    }\n\n    #[test]\n    fn test_anthropic_to_responses_o_series_uses_max_output_tokens() {\n        // Responses API always uses max_output_tokens, even for o-series models\n        let input = json!({\n            \"model\": \"o3-mini\",\n            \"max_tokens\": 4096,\n            \"messages\": [{\"role\": \"user\", \"content\": \"Hello\"}]\n        });\n        let result = anthropic_to_responses(input, None).unwrap();\n        assert_eq!(result[\"max_output_tokens\"], 4096);\n        assert!(result.get(\"max_completion_tokens\").is_none());\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/response_handler.rs",
    "content": "//! Response Handler - 统一响应处理\n//!\n//! 提供流式和非流式响应的统一处理接口\n\nuse super::session::ProxySession;\nuse super::usage::parser::TokenUsage;\nuse super::ProxyError;\nuse bytes::Bytes;\nuse futures::stream::{Stream, StreamExt};\nuse serde_json::Value;\nuse std::sync::Arc;\nuse std::time::{Duration, Instant};\nuse tokio::sync::Mutex;\nuse tokio::time::timeout;\n\n/// 响应类型\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\n#[allow(dead_code)]\npub enum ResponseType {\n    /// 流式响应 (SSE)\n    Stream,\n    /// 非流式响应\n    NonStream,\n}\n\nimpl ResponseType {\n    /// 从 Content-Type 检测响应类型\n    #[allow(dead_code)]\n    pub fn from_content_type(content_type: &str) -> Self {\n        if content_type.contains(\"text/event-stream\") {\n            ResponseType::Stream\n        } else {\n            ResponseType::NonStream\n        }\n    }\n}\n\n/// 流式响应处理器\n#[allow(dead_code)]\npub struct StreamHandler {\n    /// 空闲超时时间\n    idle_timeout: Duration,\n    /// 收集的事件\n    events: Arc<Mutex<Vec<Value>>>,\n}\n\n#[allow(dead_code)]\nimpl StreamHandler {\n    /// 创建新的流式处理器\n    pub fn new(idle_timeout_secs: u64) -> Self {\n        Self {\n            idle_timeout: Duration::from_secs(idle_timeout_secs),\n            events: Arc::new(Mutex::new(Vec::new())),\n        }\n    }\n\n    /// 处理流式响应，返回分流后的客户端流\n    ///\n    /// 客户端流立即返回，内部流在后台收集事件\n    pub fn handle_stream<S>(\n        &self,\n        stream: S,\n    ) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send\n    where\n        S: Stream<Item = Result<Bytes, reqwest::Error>> + Send + 'static,\n    {\n        let events = self.events.clone();\n        let idle_timeout = self.idle_timeout;\n\n        async_stream::stream! {\n            let mut _last_activity = Instant::now();\n            let mut buffer = String::new();\n\n            tokio::pin!(stream);\n\n            loop {\n                let chunk_result = timeout(idle_timeout, stream.next()).await;\n\n                match chunk_result {\n                    Ok(Some(Ok(bytes))) => {\n                        _last_activity = Instant::now();\n\n                        // 解析 SSE 事件\n                        let text = String::from_utf8_lossy(&bytes);\n                        buffer.push_str(&text);\n\n                        // 提取完整事件\n                        while let Some(pos) = buffer.find(\"\\n\\n\") {\n                            let event_text = buffer[..pos].to_string();\n                            buffer = buffer[pos + 2..].to_string();\n\n                            for line in event_text.lines() {\n                                if let Some(data) = line.strip_prefix(\"data: \") {\n                                    if data.trim() != \"[DONE]\" {\n                                        if let Ok(json) = serde_json::from_str::<Value>(data) {\n                                            let mut guard = events.lock().await;\n                                            guard.push(json);\n                                        }\n                                    }\n                                }\n                            }\n                        }\n\n                        yield Ok(bytes);\n                    }\n                    Ok(Some(Err(e))) => {\n                        log::error!(\"流错误: {e}\");\n                        yield Err(std::io::Error::other(e.to_string()));\n                        break;\n                    }\n                    Ok(None) => {\n                        // 流结束\n                        break;\n                    }\n                    Err(_) => {\n                        // 空闲超时\n                        log::warn!(\"流式响应空闲超时: {idle_timeout:?} 无数据\");\n                        yield Err(std::io::Error::other(\"Stream idle timeout\"));\n                        break;\n                    }\n                }\n            }\n        }\n    }\n\n    /// 获取收集的事件\n    pub async fn get_events(&self) -> Vec<Value> {\n        let guard = self.events.lock().await;\n        guard.clone()\n    }\n\n    /// 从收集的事件中提取 Token 使用量\n    pub async fn extract_usage(&self, session: &ProxySession) -> Option<TokenUsage> {\n        let events = self.get_events().await;\n\n        match session.client_format {\n            super::session::ClientFormat::Claude => TokenUsage::from_claude_stream_events(&events),\n            super::session::ClientFormat::Codex => TokenUsage::from_codex_stream_events(&events),\n            super::session::ClientFormat::Gemini | super::session::ClientFormat::GeminiCli => {\n                TokenUsage::from_gemini_stream_chunks(&events)\n            }\n            _ => None,\n        }\n    }\n}\n\n/// 非流式响应处理器\n#[allow(dead_code)]\npub struct NonStreamHandler;\n\n#[allow(dead_code)]\nimpl NonStreamHandler {\n    /// 处理非流式响应\n    ///\n    /// 克隆响应体用于后台解析，原始响应立即返回\n    pub async fn handle_response(\n        body: &[u8],\n        session: &ProxySession,\n    ) -> Result<Option<TokenUsage>, ProxyError> {\n        let json: Value = serde_json::from_slice(body)\n            .map_err(|e| ProxyError::TransformError(format!(\"Failed to parse response: {e}\")))?;\n\n        let usage = match session.client_format {\n            super::session::ClientFormat::Claude => TokenUsage::from_claude_response(&json),\n            super::session::ClientFormat::Codex => TokenUsage::from_codex_response_adjusted(&json),\n            super::session::ClientFormat::Gemini | super::session::ClientFormat::GeminiCli => {\n                TokenUsage::from_gemini_response(&json)\n            }\n            super::session::ClientFormat::OpenAI => TokenUsage::from_openrouter_response(&json),\n            _ => None,\n        };\n\n        Ok(usage)\n    }\n}\n\n/// 统一响应分发器\n#[allow(dead_code)]\npub struct ResponseDispatcher;\n\n#[allow(dead_code)]\nimpl ResponseDispatcher {\n    /// 判断响应类型\n    pub fn detect_type(content_type: &str) -> ResponseType {\n        ResponseType::from_content_type(content_type)\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_response_type_detection() {\n        assert_eq!(\n            ResponseType::from_content_type(\"text/event-stream\"),\n            ResponseType::Stream\n        );\n        assert_eq!(\n            ResponseType::from_content_type(\"text/event-stream; charset=utf-8\"),\n            ResponseType::Stream\n        );\n        assert_eq!(\n            ResponseType::from_content_type(\"application/json\"),\n            ResponseType::NonStream\n        );\n    }\n\n    #[test]\n    fn test_stream_handler_creation() {\n        let handler = StreamHandler::new(30);\n        assert_eq!(handler.idle_timeout, Duration::from_secs(30));\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/response_processor.rs",
    "content": "//! 响应处理器模块\n//!\n//! 统一处理流式和非流式 API 响应\n\nuse super::{\n    handler_config::UsageParserConfig,\n    handler_context::{RequestContext, StreamingTimeoutConfig},\n    server::ProxyState,\n    usage::parser::TokenUsage,\n    ProxyError,\n};\nuse axum::response::{IntoResponse, Response};\nuse bytes::Bytes;\nuse futures::stream::{Stream, StreamExt};\nuse reqwest::header::HeaderMap;\nuse serde_json::Value;\nuse std::{\n    sync::{\n        atomic::{AtomicBool, Ordering},\n        Arc,\n    },\n    time::Duration,\n};\nuse tokio::sync::Mutex;\n\n// ============================================================================\n// 公共接口\n// ============================================================================\n\n/// 检测响应是否为 SSE 流式响应\n#[inline]\npub fn is_sse_response(response: &reqwest::Response) -> bool {\n    response\n        .headers()\n        .get(\"content-type\")\n        .and_then(|v| v.to_str().ok())\n        .map(|ct| ct.contains(\"text/event-stream\"))\n        .unwrap_or(false)\n}\n\n/// 处理流式响应\npub async fn handle_streaming(\n    response: reqwest::Response,\n    ctx: &RequestContext,\n    state: &ProxyState,\n    parser_config: &UsageParserConfig,\n) -> Response {\n    let status = response.status();\n    log::debug!(\n        \"[{}] 已接收上游流式响应: status={}, headers={}\",\n        ctx.tag,\n        status.as_u16(),\n        format_headers(response.headers())\n    );\n    let mut builder = axum::response::Response::builder().status(status);\n\n    // 复制响应头\n    for (key, value) in response.headers() {\n        builder = builder.header(key, value);\n    }\n\n    // 创建字节流\n    let stream = response\n        .bytes_stream()\n        .map(|chunk| chunk.map_err(|e| std::io::Error::other(e.to_string())));\n\n    // 创建使用量收集器\n    let usage_collector = create_usage_collector(ctx, state, status.as_u16(), parser_config);\n\n    // 获取流式超时配置\n    let timeout_config = ctx.streaming_timeout_config();\n\n    // 创建带日志和超时的透传流\n    let logged_stream =\n        create_logged_passthrough_stream(stream, ctx.tag, Some(usage_collector), timeout_config);\n\n    let body = axum::body::Body::from_stream(logged_stream);\n    match builder.body(body) {\n        Ok(resp) => resp,\n        Err(e) => {\n            log::error!(\"[{}] 构建流式响应失败: {e}\", ctx.tag);\n            ProxyError::Internal(format!(\"Failed to build streaming response: {e}\")).into_response()\n        }\n    }\n}\n\n/// 处理非流式响应\npub async fn handle_non_streaming(\n    response: reqwest::Response,\n    ctx: &RequestContext,\n    state: &ProxyState,\n    parser_config: &UsageParserConfig,\n) -> Result<Response, ProxyError> {\n    let response_headers = response.headers().clone();\n    let status = response.status();\n\n    // 读取响应体\n    let body_bytes = response.bytes().await.map_err(|e| {\n        log::error!(\"[{}] 读取响应失败: {e}\", ctx.tag);\n        ProxyError::ForwardFailed(format!(\"Failed to read response body: {e}\"))\n    })?;\n    log::debug!(\n        \"[{}] 已接收上游响应体: status={}, bytes={}, headers={}\",\n        ctx.tag,\n        status.as_u16(),\n        body_bytes.len(),\n        format_headers(&response_headers)\n    );\n\n    log::debug!(\n        \"[{}] 上游响应体内容: {}\",\n        ctx.tag,\n        String::from_utf8_lossy(&body_bytes)\n    );\n\n    // 解析并记录使用量\n    if let Ok(json_value) = serde_json::from_slice::<Value>(&body_bytes) {\n        // 解析使用量\n        if let Some(usage) = (parser_config.response_parser)(&json_value) {\n            // 优先使用 usage 中解析出的模型名称，其次使用响应中的 model 字段，最后回退到请求模型\n            let model = if let Some(ref m) = usage.model {\n                m.clone()\n            } else if let Some(m) = json_value.get(\"model\").and_then(|m| m.as_str()) {\n                m.to_string()\n            } else {\n                ctx.request_model.clone()\n            };\n\n            spawn_log_usage(\n                state,\n                ctx,\n                usage,\n                &model,\n                &ctx.request_model,\n                status.as_u16(),\n                false,\n            );\n        } else {\n            let model = json_value\n                .get(\"model\")\n                .and_then(|m| m.as_str())\n                .unwrap_or(&ctx.request_model)\n                .to_string();\n            spawn_log_usage(\n                state,\n                ctx,\n                TokenUsage::default(),\n                &model,\n                &ctx.request_model,\n                status.as_u16(),\n                false,\n            );\n            log::debug!(\n                \"[{}] 未能解析 usage 信息，跳过记录\",\n                parser_config.app_type_str\n            );\n        }\n    } else {\n        log::debug!(\n            \"[{}] <<< 响应 (非 JSON): {} bytes\",\n            ctx.tag,\n            body_bytes.len()\n        );\n        spawn_log_usage(\n            state,\n            ctx,\n            TokenUsage::default(),\n            &ctx.request_model,\n            &ctx.request_model,\n            status.as_u16(),\n            false,\n        );\n    }\n\n    // 构建响应\n    let mut builder = axum::response::Response::builder().status(status);\n    for (key, value) in response_headers.iter() {\n        builder = builder.header(key, value);\n    }\n\n    let body = axum::body::Body::from(body_bytes);\n    builder.body(body).map_err(|e| {\n        log::error!(\"[{}] 构建响应失败: {e}\", ctx.tag);\n        ProxyError::Internal(format!(\"Failed to build response: {e}\"))\n    })\n}\n\n/// 通用响应处理入口\n///\n/// 根据响应类型自动选择流式或非流式处理\npub async fn process_response(\n    response: reqwest::Response,\n    ctx: &RequestContext,\n    state: &ProxyState,\n    parser_config: &UsageParserConfig,\n) -> Result<Response, ProxyError> {\n    if is_sse_response(&response) {\n        Ok(handle_streaming(response, ctx, state, parser_config).await)\n    } else {\n        handle_non_streaming(response, ctx, state, parser_config).await\n    }\n}\n\n// ============================================================================\n// SSE 使用量收集器\n// ============================================================================\n\ntype UsageCallbackWithTiming = Arc<dyn Fn(Vec<Value>, Option<u64>) + Send + Sync + 'static>;\n\n/// SSE 使用量收集器\n#[derive(Clone)]\npub struct SseUsageCollector {\n    inner: Arc<SseUsageCollectorInner>,\n}\n\nstruct SseUsageCollectorInner {\n    events: Mutex<Vec<Value>>,\n    first_event_time: Mutex<Option<std::time::Instant>>,\n    start_time: std::time::Instant,\n    on_complete: UsageCallbackWithTiming,\n    finished: AtomicBool,\n}\n\nimpl SseUsageCollector {\n    /// 创建新的使用量收集器\n    pub fn new(\n        start_time: std::time::Instant,\n        callback: impl Fn(Vec<Value>, Option<u64>) + Send + Sync + 'static,\n    ) -> Self {\n        let on_complete: UsageCallbackWithTiming = Arc::new(callback);\n        Self {\n            inner: Arc::new(SseUsageCollectorInner {\n                events: Mutex::new(Vec::new()),\n                first_event_time: Mutex::new(None),\n                start_time,\n                on_complete,\n                finished: AtomicBool::new(false),\n            }),\n        }\n    }\n\n    /// 推送 SSE 事件\n    pub async fn push(&self, event: Value) {\n        // 记录首个事件时间\n        {\n            let mut first_time = self.inner.first_event_time.lock().await;\n            if first_time.is_none() {\n                *first_time = Some(std::time::Instant::now());\n            }\n        }\n        let mut events = self.inner.events.lock().await;\n        events.push(event);\n    }\n\n    /// 完成收集并触发回调\n    pub async fn finish(&self) {\n        if self.inner.finished.swap(true, Ordering::SeqCst) {\n            return;\n        }\n\n        let events = {\n            let mut guard = self.inner.events.lock().await;\n            std::mem::take(&mut *guard)\n        };\n\n        let first_token_ms = {\n            let first_time = self.inner.first_event_time.lock().await;\n            first_time.map(|t| (t - self.inner.start_time).as_millis() as u64)\n        };\n\n        (self.inner.on_complete)(events, first_token_ms);\n    }\n}\n\n// ============================================================================\n// 内部辅助函数\n// ============================================================================\n\n/// 创建使用量收集器\nfn create_usage_collector(\n    ctx: &RequestContext,\n    state: &ProxyState,\n    status_code: u16,\n    parser_config: &UsageParserConfig,\n) -> SseUsageCollector {\n    let logging_enabled = state\n        .config\n        .try_read()\n        .map(|c| c.enable_logging)\n        .unwrap_or(true);\n    let state = state.clone();\n    let provider_id = ctx.provider.id.clone();\n    let request_model = ctx.request_model.clone();\n    let app_type_str = parser_config.app_type_str;\n    let tag = ctx.tag;\n    let start_time = ctx.start_time;\n    let stream_parser = parser_config.stream_parser;\n    let model_extractor = parser_config.model_extractor;\n    let session_id = ctx.session_id.clone();\n\n    SseUsageCollector::new(start_time, move |events, first_token_ms| {\n        if !logging_enabled {\n            return;\n        }\n        if let Some(usage) = stream_parser(&events) {\n            let model = model_extractor(&events, &request_model);\n            let latency_ms = start_time.elapsed().as_millis() as u64;\n\n            let state = state.clone();\n            let provider_id = provider_id.clone();\n            let session_id = session_id.clone();\n            let request_model = request_model.clone();\n\n            tokio::spawn(async move {\n                log_usage_internal(\n                    &state,\n                    &provider_id,\n                    app_type_str,\n                    &model,\n                    &request_model,\n                    usage,\n                    latency_ms,\n                    first_token_ms,\n                    true, // is_streaming\n                    status_code,\n                    Some(session_id),\n                )\n                .await;\n            });\n        } else {\n            let model = model_extractor(&events, &request_model);\n            let latency_ms = start_time.elapsed().as_millis() as u64;\n            let state = state.clone();\n            let provider_id = provider_id.clone();\n            let session_id = session_id.clone();\n            let request_model = request_model.clone();\n\n            tokio::spawn(async move {\n                log_usage_internal(\n                    &state,\n                    &provider_id,\n                    app_type_str,\n                    &model,\n                    &request_model,\n                    TokenUsage::default(),\n                    latency_ms,\n                    first_token_ms,\n                    true, // is_streaming\n                    status_code,\n                    Some(session_id),\n                )\n                .await;\n            });\n            log::debug!(\"[{tag}] 流式响应缺少 usage 统计，跳过消费记录\");\n        }\n    })\n}\n\n/// 异步记录使用量\nfn spawn_log_usage(\n    state: &ProxyState,\n    ctx: &RequestContext,\n    usage: TokenUsage,\n    model: &str,\n    request_model: &str,\n    status_code: u16,\n    is_streaming: bool,\n) {\n    // Check enable_logging before spawning the log task\n    if let Ok(config) = state.config.try_read() {\n        if !config.enable_logging {\n            return;\n        }\n    }\n\n    let state = state.clone();\n    let provider_id = ctx.provider.id.clone();\n    let app_type_str = ctx.app_type_str.to_string();\n    let model = model.to_string();\n    let request_model = request_model.to_string();\n    let latency_ms = ctx.latency_ms();\n    let session_id = ctx.session_id.clone();\n\n    tokio::spawn(async move {\n        log_usage_internal(\n            &state,\n            &provider_id,\n            &app_type_str,\n            &model,\n            &request_model,\n            usage,\n            latency_ms,\n            None,\n            is_streaming,\n            status_code,\n            Some(session_id),\n        )\n        .await;\n    });\n}\n\n/// 内部使用量记录函数\n#[allow(clippy::too_many_arguments)]\nasync fn log_usage_internal(\n    state: &ProxyState,\n    provider_id: &str,\n    app_type: &str,\n    model: &str,\n    request_model: &str,\n    usage: TokenUsage,\n    latency_ms: u64,\n    first_token_ms: Option<u64>,\n    is_streaming: bool,\n    status_code: u16,\n    session_id: Option<String>,\n) {\n    use super::usage::logger::UsageLogger;\n\n    let logger = UsageLogger::new(&state.db);\n    let (multiplier, pricing_model_source) =\n        logger.resolve_pricing_config(provider_id, app_type).await;\n    let pricing_model = if pricing_model_source == \"request\" {\n        request_model\n    } else {\n        model\n    };\n\n    let request_id = uuid::Uuid::new_v4().to_string();\n\n    log::debug!(\n        \"[{app_type}] 记录请求日志: id={request_id}, provider={provider_id}, model={model}, streaming={is_streaming}, status={status_code}, latency_ms={latency_ms}, first_token_ms={first_token_ms:?}, session={}, input={}, output={}, cache_read={}, cache_creation={}\",\n        session_id.as_deref().unwrap_or(\"none\"),\n        usage.input_tokens,\n        usage.output_tokens,\n        usage.cache_read_tokens,\n        usage.cache_creation_tokens\n    );\n\n    if let Err(e) = logger.log_with_calculation(\n        request_id,\n        provider_id.to_string(),\n        app_type.to_string(),\n        model.to_string(),\n        request_model.to_string(),\n        pricing_model.to_string(),\n        usage,\n        multiplier,\n        latency_ms,\n        first_token_ms,\n        status_code,\n        session_id,\n        None, // provider_type\n        is_streaming,\n    ) {\n        log::warn!(\"[USG-001] 记录使用量失败: {e}\");\n    }\n}\n\n/// 创建带日志记录和超时控制的透传流\npub fn create_logged_passthrough_stream(\n    stream: impl Stream<Item = Result<Bytes, std::io::Error>> + Send + 'static,\n    tag: &'static str,\n    usage_collector: Option<SseUsageCollector>,\n    timeout_config: StreamingTimeoutConfig,\n) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {\n    async_stream::stream! {\n        let mut buffer = String::new();\n        let mut collector = usage_collector;\n        let mut is_first_chunk = true;\n\n        // 超时配置\n        let first_byte_timeout = if timeout_config.first_byte_timeout > 0 {\n            Some(Duration::from_secs(timeout_config.first_byte_timeout))\n        } else {\n            None\n        };\n        let idle_timeout = if timeout_config.idle_timeout > 0 {\n            Some(Duration::from_secs(timeout_config.idle_timeout))\n        } else {\n            None\n        };\n\n        tokio::pin!(stream);\n\n        loop {\n            // 选择超时时间：首字节超时或静默期超时\n            let timeout_duration = if is_first_chunk {\n                first_byte_timeout\n            } else {\n                idle_timeout\n            };\n\n            let chunk_result = match timeout_duration {\n                Some(duration) => {\n                    match tokio::time::timeout(duration, stream.next()).await {\n                        Ok(Some(chunk)) => Some(chunk),\n                        Ok(None) => None, // 流结束\n                        Err(_) => {\n                            // 超时\n                            let timeout_type = if is_first_chunk { \"首字节\" } else { \"静默期\" };\n                            log::error!(\"[{tag}] 流式响应{}超时 ({}秒)\", timeout_type, duration.as_secs());\n                            yield Err(std::io::Error::other(format!(\"流式响应{timeout_type}超时\")));\n                            break;\n                        }\n                    }\n                }\n                None => stream.next().await, // 无超时限制\n            };\n\n            match chunk_result {\n                Some(Ok(bytes)) => {\n                    if is_first_chunk {\n                        log::debug!(\n                            \"[{tag}] 已接收上游流式首包: bytes={}\",\n                            bytes.len()\n                        );\n                    }\n                    is_first_chunk = false;\n                    let text = String::from_utf8_lossy(&bytes);\n                    buffer.push_str(&text);\n\n                    // 尝试解析并记录完整的 SSE 事件\n                    while let Some(pos) = buffer.find(\"\\n\\n\") {\n                        let event_text = buffer[..pos].to_string();\n                        buffer = buffer[pos + 2..].to_string();\n\n                        if !event_text.trim().is_empty() {\n                            // 提取 data 部分并尝试解析为 JSON\n                            for line in event_text.lines() {\n                                if let Some(data) = line.strip_prefix(\"data: \") {\n                                    if data.trim() != \"[DONE]\" {\n                                        if let Ok(json_value) = serde_json::from_str::<Value>(data) {\n                                            if let Some(c) = &collector {\n                                                c.push(json_value.clone()).await;\n                                            }\n                                            log::debug!(\"[{tag}] <<< SSE 事件: {data}\");\n                                        } else {\n                                            log::debug!(\"[{tag}] <<< SSE 数据: {data}\");\n                                        }\n                                    } else {\n                                        log::debug!(\"[{tag}] <<< SSE: [DONE]\");\n                                    }\n                                }\n                            }\n                        }\n                    }\n\n                    yield Ok(bytes);\n                }\n                Some(Err(e)) => {\n                    log::error!(\"[{tag}] 流错误: {e}\");\n                    yield Err(std::io::Error::other(e.to_string()));\n                    break;\n                }\n                None => {\n                    // 流正常结束\n                    break;\n                }\n            }\n        }\n\n        if let Some(c) = collector.take() {\n            c.finish().await;\n        }\n    }\n}\n\nfn format_headers(headers: &HeaderMap) -> String {\n    headers\n        .iter()\n        .map(|(key, value)| {\n            let value_str = value.to_str().unwrap_or(\"<non-utf8>\");\n            format!(\"{key}={value_str}\")\n        })\n        .collect::<Vec<_>>()\n        .join(\", \")\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::database::Database;\n    use crate::error::AppError;\n    use crate::provider::ProviderMeta;\n    use crate::proxy::failover_switch::FailoverSwitchManager;\n    use crate::proxy::provider_router::ProviderRouter;\n    use crate::proxy::types::{ProxyConfig, ProxyStatus};\n    use rust_decimal::Decimal;\n    use std::collections::HashMap;\n    use std::str::FromStr;\n    use std::sync::Arc;\n    use tokio::sync::RwLock;\n\n    fn build_state(db: Arc<Database>) -> ProxyState {\n        ProxyState {\n            db: db.clone(),\n            config: Arc::new(RwLock::new(ProxyConfig::default())),\n            status: Arc::new(RwLock::new(ProxyStatus::default())),\n            start_time: Arc::new(RwLock::new(None)),\n            current_providers: Arc::new(RwLock::new(HashMap::new())),\n            provider_router: Arc::new(ProviderRouter::new(db.clone())),\n            app_handle: None,\n            failover_manager: Arc::new(FailoverSwitchManager::new(db)),\n        }\n    }\n\n    fn seed_pricing(db: &Database) -> Result<(), AppError> {\n        let conn = crate::database::lock_conn!(db.conn);\n        conn.execute(\n            \"INSERT OR REPLACE INTO model_pricing (model_id, display_name, input_cost_per_million, output_cost_per_million)\n             VALUES (?1, ?2, ?3, ?4)\",\n            rusqlite::params![\"resp-model\", \"Resp Model\", \"1.0\", \"0\"],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n        conn.execute(\n            \"INSERT OR REPLACE INTO model_pricing (model_id, display_name, input_cost_per_million, output_cost_per_million)\n             VALUES (?1, ?2, ?3, ?4)\",\n            rusqlite::params![\"req-model\", \"Req Model\", \"2.0\", \"0\"],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n        Ok(())\n    }\n\n    fn insert_provider(\n        db: &Database,\n        id: &str,\n        app_type: &str,\n        meta: ProviderMeta,\n    ) -> Result<(), AppError> {\n        let meta_json =\n            serde_json::to_string(&meta).map_err(|e| AppError::Database(e.to_string()))?;\n        let conn = crate::database::lock_conn!(db.conn);\n        conn.execute(\n            \"INSERT INTO providers (id, app_type, name, settings_config, meta)\n             VALUES (?1, ?2, ?3, ?4, ?5)\",\n            rusqlite::params![id, app_type, \"Test Provider\", \"{}\", meta_json],\n        )\n        .map_err(|e| AppError::Database(e.to_string()))?;\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_log_usage_uses_provider_override_config() -> Result<(), AppError> {\n        let db = Arc::new(Database::memory()?);\n        let app_type = \"claude\";\n\n        db.set_default_cost_multiplier(app_type, \"1.5\").await?;\n        db.set_pricing_model_source(app_type, \"response\").await?;\n        seed_pricing(&db)?;\n\n        let mut meta = ProviderMeta::default();\n        meta.cost_multiplier = Some(\"2\".to_string());\n        meta.pricing_model_source = Some(\"request\".to_string());\n        insert_provider(&db, \"provider-1\", app_type, meta)?;\n\n        let state = build_state(db.clone());\n        let usage = TokenUsage {\n            input_tokens: 1_000_000,\n            output_tokens: 0,\n            cache_read_tokens: 0,\n            cache_creation_tokens: 0,\n            model: None,\n        };\n\n        log_usage_internal(\n            &state,\n            \"provider-1\",\n            app_type,\n            \"resp-model\",\n            \"req-model\",\n            usage,\n            10,\n            None,\n            false,\n            200,\n            None,\n        )\n        .await;\n\n        let conn = crate::database::lock_conn!(db.conn);\n        let (model, request_model, total_cost, cost_multiplier): (String, String, String, String) =\n            conn.query_row(\n                \"SELECT model, request_model, total_cost_usd, cost_multiplier\n                 FROM proxy_request_logs WHERE provider_id = ?1\",\n                [\"provider-1\"],\n                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),\n            )\n            .map_err(|e| AppError::Database(e.to_string()))?;\n\n        assert_eq!(model, \"resp-model\");\n        assert_eq!(request_model, \"req-model\");\n        assert_eq!(\n            Decimal::from_str(&cost_multiplier).unwrap(),\n            Decimal::from_str(\"2\").unwrap()\n        );\n        assert_eq!(\n            Decimal::from_str(&total_cost).unwrap(),\n            Decimal::from_str(\"4\").unwrap()\n        );\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_log_usage_falls_back_to_global_defaults() -> Result<(), AppError> {\n        let db = Arc::new(Database::memory()?);\n        let app_type = \"claude\";\n\n        db.set_default_cost_multiplier(app_type, \"1.5\").await?;\n        db.set_pricing_model_source(app_type, \"response\").await?;\n        seed_pricing(&db)?;\n\n        let meta = ProviderMeta::default();\n        insert_provider(&db, \"provider-2\", app_type, meta)?;\n\n        let state = build_state(db.clone());\n        let usage = TokenUsage {\n            input_tokens: 1_000_000,\n            output_tokens: 0,\n            cache_read_tokens: 0,\n            cache_creation_tokens: 0,\n            model: None,\n        };\n\n        log_usage_internal(\n            &state,\n            \"provider-2\",\n            app_type,\n            \"resp-model\",\n            \"req-model\",\n            usage,\n            10,\n            None,\n            false,\n            200,\n            None,\n        )\n        .await;\n\n        let conn = crate::database::lock_conn!(db.conn);\n        let (total_cost, cost_multiplier): (String, String) = conn\n            .query_row(\n                \"SELECT total_cost_usd, cost_multiplier\n                 FROM proxy_request_logs WHERE provider_id = ?1\",\n                [\"provider-2\"],\n                |row| Ok((row.get(0)?, row.get(1)?)),\n            )\n            .map_err(|e| AppError::Database(e.to_string()))?;\n\n        assert_eq!(\n            Decimal::from_str(&cost_multiplier).unwrap(),\n            Decimal::from_str(\"1.5\").unwrap()\n        );\n        assert_eq!(\n            Decimal::from_str(&total_cost).unwrap(),\n            Decimal::from_str(\"1.5\").unwrap()\n        );\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/server.rs",
    "content": "//! HTTP代理服务器\n//!\n//! 基于Axum的HTTP服务器，处理代理请求\n\nuse super::{\n    failover_switch::FailoverSwitchManager, handlers, log_codes::srv as log_srv,\n    provider_router::ProviderRouter, types::*, ProxyError,\n};\nuse crate::database::Database;\nuse axum::{\n    extract::DefaultBodyLimit,\n    routing::{get, post},\n    Router,\n};\nuse std::net::SocketAddr;\nuse std::sync::Arc;\nuse tokio::sync::{oneshot, RwLock};\nuse tokio::task::JoinHandle;\nuse tower_http::cors::{Any, CorsLayer};\n\n/// 代理服务器状态（共享）\n#[derive(Clone)]\npub struct ProxyState {\n    pub db: Arc<Database>,\n    pub config: Arc<RwLock<ProxyConfig>>,\n    pub status: Arc<RwLock<ProxyStatus>>,\n    pub start_time: Arc<RwLock<Option<std::time::Instant>>>,\n    /// 每个应用类型当前使用的 provider (app_type -> (provider_id, provider_name))\n    pub current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,\n    /// 共享的 ProviderRouter（持有熔断器状态，跨请求保持）\n    pub provider_router: Arc<ProviderRouter>,\n    /// AppHandle，用于发射事件和更新托盘菜单\n    pub app_handle: Option<tauri::AppHandle>,\n    /// 故障转移切换管理器\n    pub failover_manager: Arc<FailoverSwitchManager>,\n}\n\n/// 代理HTTP服务器\npub struct ProxyServer {\n    config: ProxyConfig,\n    state: ProxyState,\n    shutdown_tx: Arc<RwLock<Option<oneshot::Sender<()>>>>,\n    /// 服务器任务句柄，用于等待服务器实际关闭\n    server_handle: Arc<RwLock<Option<JoinHandle<()>>>>,\n}\n\nimpl ProxyServer {\n    pub fn new(\n        config: ProxyConfig,\n        db: Arc<Database>,\n        app_handle: Option<tauri::AppHandle>,\n    ) -> Self {\n        // 创建共享的 ProviderRouter（熔断器状态将跨所有请求保持）\n        let provider_router = Arc::new(ProviderRouter::new(db.clone()));\n        // 创建故障转移切换管理器\n        let failover_manager = Arc::new(FailoverSwitchManager::new(db.clone()));\n\n        let state = ProxyState {\n            db,\n            config: Arc::new(RwLock::new(config.clone())),\n            status: Arc::new(RwLock::new(ProxyStatus::default())),\n            start_time: Arc::new(RwLock::new(None)),\n            current_providers: Arc::new(RwLock::new(std::collections::HashMap::new())),\n            provider_router,\n            app_handle,\n            failover_manager,\n        };\n\n        Self {\n            config,\n            state,\n            shutdown_tx: Arc::new(RwLock::new(None)),\n            server_handle: Arc::new(RwLock::new(None)),\n        }\n    }\n\n    pub async fn start(&self) -> Result<ProxyServerInfo, ProxyError> {\n        // 检查是否已在运行\n        if self.shutdown_tx.read().await.is_some() {\n            return Err(ProxyError::AlreadyRunning);\n        }\n\n        let addr: SocketAddr =\n            format!(\"{}:{}\", self.config.listen_address, self.config.listen_port)\n                .parse()\n                .map_err(|e| ProxyError::BindFailed(format!(\"无效的地址: {e}\")))?;\n\n        // 创建关闭通道\n        let (shutdown_tx, shutdown_rx) = oneshot::channel();\n\n        // 构建路由\n        let app = self.build_router();\n\n        // 绑定监听器\n        let listener = tokio::net::TcpListener::bind(&addr)\n            .await\n            .map_err(|e| ProxyError::BindFailed(e.to_string()))?;\n\n        log::info!(\"[{}] 代理服务器启动于 {addr}\", log_srv::STARTED);\n\n        // 更新全局代理端口，用于系统代理检测\n        crate::proxy::http_client::set_proxy_port(self.config.listen_port);\n\n        // 保存关闭句柄\n        *self.shutdown_tx.write().await = Some(shutdown_tx);\n\n        // 更新状态\n        let mut status = self.state.status.write().await;\n        status.running = true;\n        status.address = self.config.listen_address.clone();\n        status.port = self.config.listen_port;\n        drop(status);\n\n        // 记录启动时间\n        *self.state.start_time.write().await = Some(std::time::Instant::now());\n\n        // 启动服务器\n        let state = self.state.clone();\n        let handle = tokio::spawn(async move {\n            axum::serve(listener, app)\n                .with_graceful_shutdown(async {\n                    shutdown_rx.await.ok();\n                })\n                .await\n                .ok();\n\n            // 服务器停止后更新状态\n            state.status.write().await.running = false;\n            *state.start_time.write().await = None;\n        });\n\n        // 保存服务器任务句柄\n        *self.server_handle.write().await = Some(handle);\n\n        Ok(ProxyServerInfo {\n            address: self.config.listen_address.clone(),\n            port: self.config.listen_port,\n            started_at: chrono::Utc::now().to_rfc3339(),\n        })\n    }\n\n    pub async fn stop(&self) -> Result<(), ProxyError> {\n        // 1. 发送关闭信号\n        if let Some(tx) = self.shutdown_tx.write().await.take() {\n            let _ = tx.send(());\n        } else {\n            return Err(ProxyError::NotRunning);\n        }\n\n        // 2. 等待服务器任务结束（带 5 秒超时保护）\n        if let Some(handle) = self.server_handle.write().await.take() {\n            match tokio::time::timeout(std::time::Duration::from_secs(5), handle).await {\n                Ok(Ok(())) => {\n                    log::info!(\"[{}] 代理服务器已完全停止\", log_srv::STOPPED);\n                    Ok(())\n                }\n                Ok(Err(e)) => {\n                    log::warn!(\"[{}] 代理服务器任务异常终止: {e}\", log_srv::TASK_ERROR);\n                    Err(ProxyError::StopFailed(e.to_string()))\n                }\n                Err(_) => {\n                    log::warn!(\n                        \"[{}] 代理服务器停止超时（5秒），强制继续\",\n                        log_srv::STOP_TIMEOUT\n                    );\n                    Err(ProxyError::StopTimeout)\n                }\n            }\n        } else {\n            Ok(())\n        }\n    }\n\n    pub async fn get_status(&self) -> ProxyStatus {\n        let mut status = self.state.status.read().await.clone();\n\n        // 计算运行时间\n        if let Some(start) = *self.state.start_time.read().await {\n            status.uptime_seconds = start.elapsed().as_secs();\n        }\n\n        // 从 current_providers HashMap 获取每个应用类型当前正在使用的 provider\n        let current_providers = self.state.current_providers.read().await;\n        status.active_targets = current_providers\n            .iter()\n            .map(|(app_type, (provider_id, provider_name))| ActiveTarget {\n                app_type: app_type.clone(),\n                provider_id: provider_id.clone(),\n                provider_name: provider_name.clone(),\n            })\n            .collect();\n\n        status\n    }\n\n    /// 更新某个应用类型当前“目标供应商”（用于 UI 展示 active_targets）\n    ///\n    /// 注意：这不代表该供应商一定已经处理过请求，而是用于“热切换/启用故障转移立即切 P1”\n    /// 等场景下，让 UI 能立刻反映最新目标。\n    pub async fn set_active_target(&self, app_type: &str, provider_id: &str, provider_name: &str) {\n        let mut current_providers = self.state.current_providers.write().await;\n        current_providers.insert(\n            app_type.to_string(),\n            (provider_id.to_string(), provider_name.to_string()),\n        );\n    }\n\n    fn build_router(&self) -> Router {\n        let cors = CorsLayer::new()\n            .allow_origin(Any)\n            .allow_methods(Any)\n            .allow_headers(Any);\n\n        Router::new()\n            // 健康检查\n            .route(\"/health\", get(handlers::health_check))\n            .route(\"/status\", get(handlers::get_status))\n            // Claude API (支持带前缀和不带前缀两种格式)\n            .route(\"/v1/messages\", post(handlers::handle_messages))\n            .route(\"/claude/v1/messages\", post(handlers::handle_messages))\n            // OpenAI Chat Completions API (Codex CLI，支持带前缀和不带前缀)\n            .route(\"/chat/completions\", post(handlers::handle_chat_completions))\n            .route(\n                \"/v1/chat/completions\",\n                post(handlers::handle_chat_completions),\n            )\n            .route(\n                \"/v1/v1/chat/completions\",\n                post(handlers::handle_chat_completions),\n            )\n            .route(\n                \"/codex/v1/chat/completions\",\n                post(handlers::handle_chat_completions),\n            )\n            // OpenAI Responses API (Codex CLI，支持带前缀和不带前缀)\n            .route(\"/responses\", post(handlers::handle_responses))\n            .route(\"/v1/responses\", post(handlers::handle_responses))\n            .route(\"/v1/v1/responses\", post(handlers::handle_responses))\n            .route(\"/codex/v1/responses\", post(handlers::handle_responses))\n            // OpenAI Responses Compact API (Codex CLI 远程压缩，透传)\n            .route(\n                \"/responses/compact\",\n                post(handlers::handle_responses_compact),\n            )\n            .route(\n                \"/v1/responses/compact\",\n                post(handlers::handle_responses_compact),\n            )\n            .route(\n                \"/v1/v1/responses/compact\",\n                post(handlers::handle_responses_compact),\n            )\n            .route(\n                \"/codex/v1/responses/compact\",\n                post(handlers::handle_responses_compact),\n            )\n            // Gemini API (支持带前缀和不带前缀)\n            .route(\"/v1beta/*path\", post(handlers::handle_gemini))\n            .route(\"/gemini/v1beta/*path\", post(handlers::handle_gemini))\n            // 提高默认请求体大小限制（避免 413 Payload Too Large）\n            .layer(DefaultBodyLimit::max(200 * 1024 * 1024))\n            .layer(cors)\n            .with_state(self.state.clone())\n    }\n\n    /// 在不重启服务的情况下更新运行时配置\n    pub async fn apply_runtime_config(&self, config: &ProxyConfig) {\n        *self.state.config.write().await = config.clone();\n    }\n\n    /// 热更新熔断器配置\n    ///\n    /// 将新配置应用到所有已创建的熔断器实例\n    pub async fn update_circuit_breaker_configs(\n        &self,\n        config: super::circuit_breaker::CircuitBreakerConfig,\n    ) {\n        self.state.provider_router.update_all_configs(config).await;\n    }\n\n    /// 重置指定 Provider 的熔断器\n    pub async fn reset_provider_circuit_breaker(&self, provider_id: &str, app_type: &str) {\n        self.state\n            .provider_router\n            .reset_provider_breaker(provider_id, app_type)\n            .await;\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/session.rs",
    "content": "//! Proxy Session - 请求会话管理\n//!\n//! 为每个代理请求创建会话上下文，在整个请求生命周期中跟踪状态和元数据。\n//!\n//! ## Session ID 提取\n//!\n//! 支持从客户端请求中提取 Session ID，用于关联同一对话的多个请求：\n//! - Claude: 从 `metadata.user_id` (格式: `user_xxx_session_yyy`) 或 `metadata.session_id` 提取\n//! - Codex: 从 `previous_response_id` 或 headers 中的 `session_id` 提取\n//! - 其他: 生成新的 UUID\n\nuse axum::http::HeaderMap;\nuse std::time::Instant;\nuse uuid::Uuid;\n\n/// 客户端请求格式\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\n#[allow(dead_code)]\npub enum ClientFormat {\n    /// Claude Messages API (/v1/messages)\n    Claude,\n    /// Codex Response API (/v1/responses)\n    Codex,\n    /// OpenAI Chat Completions API (/v1/chat/completions)\n    OpenAI,\n    /// Gemini API (/v1beta/models/*/generateContent)\n    Gemini,\n    /// Gemini CLI API (/v1internal/models/*/generateContent)\n    GeminiCli,\n    /// 未知格式\n    Unknown,\n}\n\n#[allow(dead_code)]\nimpl ClientFormat {\n    /// 从请求路径检测格式\n    pub fn from_path(path: &str) -> Self {\n        if path.contains(\"/v1/messages\") {\n            ClientFormat::Claude\n        } else if path.contains(\"/v1/responses\") {\n            ClientFormat::Codex\n        } else if path.contains(\"/v1/chat/completions\") {\n            ClientFormat::OpenAI\n        } else if path.contains(\"/v1internal/\") && path.contains(\"generateContent\") {\n            // Gemini CLI 使用 /v1internal/ 路径\n            ClientFormat::GeminiCli\n        } else if (path.contains(\"/v1beta/\") || path.contains(\"/v1/\"))\n            && path.contains(\"generateContent\")\n        {\n            // Gemini API 使用 /v1beta/ 或 /v1/ 路径\n            ClientFormat::Gemini\n        } else if path.contains(\"generateContent\") {\n            // 通用 Gemini 端点\n            ClientFormat::Gemini\n        } else {\n            ClientFormat::Unknown\n        }\n    }\n\n    /// 从请求体内容检测格式（回退方案）\n    pub fn from_body(body: &serde_json::Value) -> Self {\n        // Claude 格式特征: messages 数组 + model 字段 + 无 response_format\n        if body.get(\"messages\").is_some()\n            && body.get(\"model\").is_some()\n            && body.get(\"response_format\").is_none()\n            && body.get(\"contents\").is_none()\n        {\n            // 区分 Claude 和 OpenAI\n            if body.get(\"max_tokens\").is_some() {\n                return ClientFormat::Claude;\n            }\n            return ClientFormat::OpenAI;\n        }\n\n        // Codex 格式特征: input 字段\n        if body.get(\"input\").is_some() {\n            return ClientFormat::Codex;\n        }\n\n        // Gemini 格式特征: contents 数组\n        if body.get(\"contents\").is_some() {\n            return ClientFormat::Gemini;\n        }\n\n        ClientFormat::Unknown\n    }\n\n    /// 转换为字符串\n    pub fn as_str(&self) -> &'static str {\n        match self {\n            ClientFormat::Claude => \"claude\",\n            ClientFormat::Codex => \"codex\",\n            ClientFormat::OpenAI => \"openai\",\n            ClientFormat::Gemini => \"gemini\",\n            ClientFormat::GeminiCli => \"gemini_cli\",\n            ClientFormat::Unknown => \"unknown\",\n        }\n    }\n}\n\nimpl std::fmt::Display for ClientFormat {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        write!(f, \"{}\", self.as_str())\n    }\n}\n\n/// 代理会话\n///\n/// 包含请求全生命周期的上下文数据\n#[derive(Debug, Clone)]\n#[allow(dead_code)]\npub struct ProxySession {\n    /// 唯一会话 ID\n    pub session_id: String,\n    /// 请求开始时间\n    pub start_time: Instant,\n    /// HTTP 方法\n    pub method: String,\n    /// 请求 URL\n    pub request_url: String,\n    /// User-Agent\n    pub user_agent: Option<String>,\n    /// 客户端请求格式\n    pub client_format: ClientFormat,\n    /// 选定的供应商 ID\n    pub provider_id: Option<String>,\n    /// 模型名称\n    pub model: Option<String>,\n    /// 是否为流式请求\n    pub is_streaming: bool,\n}\n\n#[allow(dead_code)]\nimpl ProxySession {\n    /// 从请求创建会话\n    pub fn from_request(\n        method: &str,\n        request_url: &str,\n        user_agent: Option<&str>,\n        body: Option<&serde_json::Value>,\n    ) -> Self {\n        // 检测客户端格式\n        let mut client_format = ClientFormat::from_path(request_url);\n        if client_format == ClientFormat::Unknown {\n            if let Some(body) = body {\n                client_format = ClientFormat::from_body(body);\n            }\n        }\n\n        // 检测是否为流式请求\n        let is_streaming = body\n            .and_then(|b| b.get(\"stream\"))\n            .and_then(|v| v.as_bool())\n            .unwrap_or(false);\n\n        // 提取模型名称\n        let model = body\n            .and_then(|b| b.get(\"model\"))\n            .and_then(|v| v.as_str())\n            .map(|s| s.to_string());\n\n        Self {\n            session_id: Uuid::new_v4().to_string(),\n            start_time: Instant::now(),\n            method: method.to_string(),\n            request_url: request_url.to_string(),\n            user_agent: user_agent.map(|s| s.to_string()),\n            client_format,\n            provider_id: None,\n            model,\n            is_streaming,\n        }\n    }\n\n    /// 设置供应商 ID\n    pub fn with_provider(mut self, provider_id: &str) -> Self {\n        self.provider_id = Some(provider_id.to_string());\n        self\n    }\n\n    /// 获取请求延迟（毫秒）\n    pub fn latency_ms(&self) -> u64 {\n        self.start_time.elapsed().as_millis() as u64\n    }\n}\n\n// ============================================================================\n// Session ID 提取器\n// ============================================================================\n\n/// Session ID 来源\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum SessionIdSource {\n    /// 从 metadata.user_id 提取 (Claude)\n    MetadataUserId,\n    /// 从 metadata.session_id 提取\n    MetadataSessionId,\n    /// 从 headers 提取 (Codex)\n    Header,\n    /// 从 previous_response_id 提取 (Codex)\n    PreviousResponseId,\n    /// 新生成\n    Generated,\n}\n\n/// Session ID 提取结果\n#[derive(Debug, Clone)]\npub struct SessionIdResult {\n    /// 提取或生成的 Session ID\n    pub session_id: String,\n    /// Session ID 来源\n    pub source: SessionIdSource,\n    /// 是否为客户端提供的 ID（非新生成）\n    pub client_provided: bool,\n}\n\n/// 从请求中提取或生成 Session ID\n///\n/// 轻量化实现，仅提取 session_id 用于日志记录，不做复杂的 Session 管理。\n///\n/// ## 提取优先级\n///\n/// ### Claude 请求\n/// 1. `metadata.user_id` (格式: `user_xxx_session_yyy`) → 提取 `yyy` 部分\n/// 2. `metadata.session_id` → 直接使用\n/// 3. 生成新 UUID\n///\n/// ### Codex 请求\n/// 1. Headers: `session_id` 或 `x-session-id`\n/// 2. `metadata.session_id`\n/// 3. `previous_response_id` (对话延续)\n/// 4. 生成新 UUID\n///\n/// ## 示例\n///\n/// ```ignore\n/// let result = extract_session_id(&headers, &body, \"claude\");\n/// println!(\"Session ID: {} (from {:?})\", result.session_id, result.source);\n/// ```\npub fn extract_session_id(\n    headers: &HeaderMap,\n    body: &serde_json::Value,\n    client_format: &str,\n) -> SessionIdResult {\n    // Codex 请求特殊处理\n    if client_format == \"codex\" || client_format == \"openai\" {\n        if let Some(result) = extract_codex_session(headers, body) {\n            return result;\n        }\n    }\n\n    // Claude 请求：从 metadata 提取\n    if let Some(result) = extract_from_metadata(body) {\n        return result;\n    }\n\n    // 兜底：生成新 Session ID\n    generate_new_session_id()\n}\n\n/// 提取 Codex Session ID\nfn extract_codex_session(headers: &HeaderMap, body: &serde_json::Value) -> Option<SessionIdResult> {\n    // 1. 从 headers 提取\n    for header_name in &[\"session_id\", \"x-session-id\"] {\n        if let Some(value) = headers.get(*header_name) {\n            if let Ok(session_id) = value.to_str() {\n                // Codex Session ID 通常较长（UUID 格式）\n                if session_id.len() > 20 {\n                    return Some(SessionIdResult {\n                        session_id: format!(\"codex_{session_id}\"),\n                        source: SessionIdSource::Header,\n                        client_provided: true,\n                    });\n                }\n            }\n        }\n    }\n\n    // 2. 从 body.metadata.session_id 提取\n    if let Some(session_id) = body\n        .get(\"metadata\")\n        .and_then(|m| m.get(\"session_id\"))\n        .and_then(|v| v.as_str())\n    {\n        if session_id.len() > 10 {\n            return Some(SessionIdResult {\n                session_id: format!(\"codex_{session_id}\"),\n                source: SessionIdSource::MetadataSessionId,\n                client_provided: true,\n            });\n        }\n    }\n\n    // 3. 从 previous_response_id 提取（对话延续）\n    if let Some(prev_id) = body.get(\"previous_response_id\").and_then(|v| v.as_str()) {\n        if prev_id.len() > 10 {\n            return Some(SessionIdResult {\n                session_id: format!(\"codex_{prev_id}\"),\n                source: SessionIdSource::PreviousResponseId,\n                client_provided: true,\n            });\n        }\n    }\n\n    None\n}\n\n/// 从 metadata 提取 Session ID (Claude)\nfn extract_from_metadata(body: &serde_json::Value) -> Option<SessionIdResult> {\n    let metadata = body.get(\"metadata\")?;\n\n    // 1. 从 metadata.user_id 提取（格式: user_xxx_session_yyy）\n    if let Some(user_id) = metadata.get(\"user_id\").and_then(|v| v.as_str()) {\n        if let Some(session_id) = parse_session_from_user_id(user_id) {\n            return Some(SessionIdResult {\n                session_id,\n                source: SessionIdSource::MetadataUserId,\n                client_provided: true,\n            });\n        }\n    }\n\n    // 2. 直接从 metadata.session_id 提取\n    if let Some(session_id) = metadata.get(\"session_id\").and_then(|v| v.as_str()) {\n        if !session_id.is_empty() {\n            return Some(SessionIdResult {\n                session_id: session_id.to_string(),\n                source: SessionIdSource::MetadataSessionId,\n                client_provided: true,\n            });\n        }\n    }\n\n    None\n}\n\n/// 从 user_id 解析 session_id\n///\n/// 格式: `user_identifier_session_actual_session_id`\nfn parse_session_from_user_id(user_id: &str) -> Option<String> {\n    // 查找 \"_session_\" 分隔符\n    if let Some(pos) = user_id.find(\"_session_\") {\n        let session_id = &user_id[pos + 9..]; // \"_session_\" 长度为 9\n        if !session_id.is_empty() {\n            return Some(session_id.to_string());\n        }\n    }\n    None\n}\n\n/// 生成新的 Session ID\nfn generate_new_session_id() -> SessionIdResult {\n    SessionIdResult {\n        session_id: Uuid::new_v4().to_string(),\n        source: SessionIdSource::Generated,\n        client_provided: false,\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use serde_json::json;\n\n    #[test]\n    fn test_client_format_from_path_claude() {\n        assert_eq!(\n            ClientFormat::from_path(\"/v1/messages\"),\n            ClientFormat::Claude\n        );\n        assert_eq!(\n            ClientFormat::from_path(\"/api/v1/messages\"),\n            ClientFormat::Claude\n        );\n    }\n\n    #[test]\n    fn test_client_format_from_path_codex() {\n        assert_eq!(\n            ClientFormat::from_path(\"/v1/responses\"),\n            ClientFormat::Codex\n        );\n    }\n\n    #[test]\n    fn test_client_format_from_path_openai() {\n        assert_eq!(\n            ClientFormat::from_path(\"/v1/chat/completions\"),\n            ClientFormat::OpenAI\n        );\n    }\n\n    #[test]\n    fn test_client_format_from_path_gemini() {\n        assert_eq!(\n            ClientFormat::from_path(\"/v1beta/models/gemini-pro:generateContent\"),\n            ClientFormat::Gemini\n        );\n    }\n\n    #[test]\n    fn test_client_format_from_path_gemini_cli() {\n        assert_eq!(\n            ClientFormat::from_path(\"/v1internal/models/gemini-pro:generateContent\"),\n            ClientFormat::GeminiCli\n        );\n    }\n\n    #[test]\n    fn test_client_format_from_body_claude() {\n        let body = json!({\n            \"model\": \"claude-3-5-sonnet\",\n            \"messages\": [{\"role\": \"user\", \"content\": \"Hello\"}],\n            \"max_tokens\": 1024\n        });\n        assert_eq!(ClientFormat::from_body(&body), ClientFormat::Claude);\n    }\n\n    #[test]\n    fn test_client_format_from_body_codex() {\n        let body = json!({\n            \"input\": \"Write a function\"\n        });\n        assert_eq!(ClientFormat::from_body(&body), ClientFormat::Codex);\n    }\n\n    #[test]\n    fn test_client_format_from_body_gemini() {\n        let body = json!({\n            \"contents\": [{\"parts\": [{\"text\": \"Hello\"}]}]\n        });\n        assert_eq!(ClientFormat::from_body(&body), ClientFormat::Gemini);\n    }\n\n    #[test]\n    fn test_session_id_uniqueness() {\n        let session1 = ProxySession::from_request(\"POST\", \"/v1/messages\", None, None);\n        let session2 = ProxySession::from_request(\"POST\", \"/v1/messages\", None, None);\n        assert_ne!(session1.session_id, session2.session_id);\n    }\n\n    #[test]\n    fn test_session_from_request() {\n        let body = json!({\n            \"model\": \"claude-3-5-sonnet\",\n            \"messages\": [{\"role\": \"user\", \"content\": \"Hello\"}],\n            \"max_tokens\": 1024,\n            \"stream\": true\n        });\n\n        let session =\n            ProxySession::from_request(\"POST\", \"/v1/messages\", Some(\"Mozilla/5.0\"), Some(&body));\n\n        assert_eq!(session.method, \"POST\");\n        assert_eq!(session.request_url, \"/v1/messages\");\n        assert_eq!(session.user_agent, Some(\"Mozilla/5.0\".to_string()));\n        assert_eq!(session.client_format, ClientFormat::Claude);\n        assert_eq!(session.model, Some(\"claude-3-5-sonnet\".to_string()));\n        assert!(session.is_streaming);\n    }\n\n    #[test]\n    fn test_session_with_provider() {\n        let session = ProxySession::from_request(\"POST\", \"/v1/messages\", None, None)\n            .with_provider(\"provider-123\");\n\n        assert_eq!(session.provider_id, Some(\"provider-123\".to_string()));\n    }\n\n    #[test]\n    fn test_client_format_as_str() {\n        assert_eq!(ClientFormat::Claude.as_str(), \"claude\");\n        assert_eq!(ClientFormat::Codex.as_str(), \"codex\");\n        assert_eq!(ClientFormat::OpenAI.as_str(), \"openai\");\n        assert_eq!(ClientFormat::Gemini.as_str(), \"gemini\");\n        assert_eq!(ClientFormat::GeminiCli.as_str(), \"gemini_cli\");\n        assert_eq!(ClientFormat::Unknown.as_str(), \"unknown\");\n    }\n\n    // ========== Session ID 提取测试 ==========\n\n    #[test]\n    fn test_extract_session_from_claude_metadata_user_id() {\n        let headers = HeaderMap::new();\n        let body = json!({\n            \"model\": \"claude-3-5-sonnet\",\n            \"messages\": [{\"role\": \"user\", \"content\": \"Hello\"}],\n            \"metadata\": {\n                \"user_id\": \"user_john_doe_session_abc123def456\"\n            }\n        });\n\n        let result = extract_session_id(&headers, &body, \"claude\");\n\n        assert_eq!(result.session_id, \"abc123def456\");\n        assert_eq!(result.source, SessionIdSource::MetadataUserId);\n        assert!(result.client_provided);\n    }\n\n    #[test]\n    fn test_extract_session_from_claude_metadata_session_id() {\n        let headers = HeaderMap::new();\n        let body = json!({\n            \"model\": \"claude-3-5-sonnet\",\n            \"messages\": [{\"role\": \"user\", \"content\": \"Hello\"}],\n            \"metadata\": {\n                \"session_id\": \"my-session-123\"\n            }\n        });\n\n        let result = extract_session_id(&headers, &body, \"claude\");\n\n        assert_eq!(result.session_id, \"my-session-123\");\n        assert_eq!(result.source, SessionIdSource::MetadataSessionId);\n        assert!(result.client_provided);\n    }\n\n    #[test]\n    fn test_extract_session_from_codex_previous_response_id() {\n        let headers = HeaderMap::new();\n        let body = json!({\n            \"input\": \"Write a function\",\n            \"previous_response_id\": \"resp_abc123def456789\"\n        });\n\n        let result = extract_session_id(&headers, &body, \"codex\");\n\n        assert_eq!(result.session_id, \"codex_resp_abc123def456789\");\n        assert_eq!(result.source, SessionIdSource::PreviousResponseId);\n        assert!(result.client_provided);\n    }\n\n    #[test]\n    fn test_extract_session_generates_new_when_not_found() {\n        let headers = HeaderMap::new();\n        let body = json!({\n            \"model\": \"claude-3-5-sonnet\",\n            \"messages\": [{\"role\": \"user\", \"content\": \"Hello\"}]\n        });\n\n        let result = extract_session_id(&headers, &body, \"claude\");\n\n        assert!(!result.session_id.is_empty());\n        assert_eq!(result.source, SessionIdSource::Generated);\n        assert!(!result.client_provided);\n    }\n\n    #[test]\n    fn test_parse_session_from_user_id() {\n        assert_eq!(\n            parse_session_from_user_id(\"user_john_session_abc123\"),\n            Some(\"abc123\".to_string())\n        );\n        assert_eq!(\n            parse_session_from_user_id(\"my_app_session_xyz789\"),\n            Some(\"xyz789\".to_string())\n        );\n        // 注意: \"_session_\" 是分隔符，所以下面的字符串会匹配\n        assert_eq!(\n            parse_session_from_user_id(\"no_session_marker\"),\n            Some(\"marker\".to_string())\n        );\n        // 没有 \"_session_\" 分隔符的情况\n        assert_eq!(parse_session_from_user_id(\"user_john_abc123\"), None);\n        assert_eq!(parse_session_from_user_id(\"_session_\"), None);\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/thinking_budget_rectifier.rs",
    "content": "//! Thinking Budget 整流器\n//!\n//! 用于自动修复 Anthropic API 中因 thinking budget 约束导致的请求错误。\n//! 当上游 API 返回 budget_tokens 相关错误时，系统会自动调整 budget 参数并重试。\n\nuse super::types::RectifierConfig;\nuse serde_json::Value;\n\n/// 最大 thinking budget tokens\nconst MAX_THINKING_BUDGET: u64 = 32000;\n\n/// 最大 max_tokens 值\nconst MAX_TOKENS_VALUE: u64 = 64000;\n\n/// max_tokens 必须大于 budget_tokens\nconst MIN_MAX_TOKENS_FOR_BUDGET: u64 = MAX_THINKING_BUDGET + 1;\n\n/// Budget 整流结果\n#[derive(Debug, Clone, Default, PartialEq, Eq)]\npub struct BudgetRectifySnapshot {\n    /// max_tokens\n    pub max_tokens: Option<u64>,\n    /// thinking.type\n    pub thinking_type: Option<String>,\n    /// thinking.budget_tokens\n    pub thinking_budget_tokens: Option<u64>,\n}\n\n/// Budget 整流结果\n#[derive(Debug, Clone, Default, PartialEq, Eq)]\npub struct BudgetRectifyResult {\n    /// 是否应用了整流\n    pub applied: bool,\n    /// 整流前快照\n    pub before: BudgetRectifySnapshot,\n    /// 整流后快照\n    pub after: BudgetRectifySnapshot,\n}\n\n/// 检测是否需要触发 thinking budget 整流器\n///\n/// 检测条件：error message 同时包含 `budget_tokens` + `thinking` 相关约束\npub fn should_rectify_thinking_budget(\n    error_message: Option<&str>,\n    config: &RectifierConfig,\n) -> bool {\n    // 检查总开关\n    if !config.enabled {\n        return false;\n    }\n    // 检查子开关\n    if !config.request_thinking_budget {\n        return false;\n    }\n\n    let Some(msg) = error_message else {\n        return false;\n    };\n    let lower = msg.to_lowercase();\n\n    // 与 CCH 对齐：仅在包含 budget_tokens + thinking + 1024 约束时触发\n    let has_budget_tokens_reference =\n        lower.contains(\"budget_tokens\") || lower.contains(\"budget tokens\");\n    let has_thinking_reference = lower.contains(\"thinking\");\n    let has_1024_constraint = lower.contains(\"greater than or equal to 1024\")\n        || lower.contains(\">= 1024\")\n        || (lower.contains(\"1024\") && lower.contains(\"input should be\"));\n    if has_budget_tokens_reference && has_thinking_reference && has_1024_constraint {\n        return true;\n    }\n\n    false\n}\n\n/// 对请求体执行 budget 整流\n///\n/// 整流动作：\n/// - `thinking.type = \"enabled\"`\n/// - `thinking.budget_tokens = 32000`\n/// - 如果 `max_tokens < 32001`，设为 `64000`\npub fn rectify_thinking_budget(body: &mut Value) -> BudgetRectifyResult {\n    let before = snapshot_budget(body);\n\n    // 与 CCH 对齐：adaptive 请求不改写\n    if before.thinking_type.as_deref() == Some(\"adaptive\") {\n        return BudgetRectifyResult {\n            applied: false,\n            before: before.clone(),\n            after: before,\n        };\n    }\n\n    // 与 CCH 对齐：缺少/非法 thinking 时自动创建后再整流\n    if !body.get(\"thinking\").is_some_and(Value::is_object) {\n        body[\"thinking\"] = Value::Object(serde_json::Map::new());\n    }\n\n    let Some(thinking) = body.get_mut(\"thinking\").and_then(|t| t.as_object_mut()) else {\n        return BudgetRectifyResult {\n            applied: false,\n            before: before.clone(),\n            after: before,\n        };\n    };\n\n    thinking.insert(\"type\".to_string(), Value::String(\"enabled\".to_string()));\n    thinking.insert(\n        \"budget_tokens\".to_string(),\n        Value::Number(MAX_THINKING_BUDGET.into()),\n    );\n\n    if before.max_tokens.is_none() || before.max_tokens < Some(MIN_MAX_TOKENS_FOR_BUDGET) {\n        body[\"max_tokens\"] = Value::Number(MAX_TOKENS_VALUE.into());\n    }\n\n    let after = snapshot_budget(body);\n    BudgetRectifyResult {\n        applied: before != after,\n        before,\n        after,\n    }\n}\n\nfn snapshot_budget(body: &Value) -> BudgetRectifySnapshot {\n    let max_tokens = body.get(\"max_tokens\").and_then(|v| v.as_u64());\n    let thinking = body.get(\"thinking\").and_then(|t| t.as_object());\n    let thinking_type = thinking\n        .and_then(|t| t.get(\"type\"))\n        .and_then(|v| v.as_str())\n        .map(ToString::to_string);\n    let thinking_budget_tokens = thinking\n        .and_then(|t| t.get(\"budget_tokens\"))\n        .and_then(|v| v.as_u64());\n    BudgetRectifySnapshot {\n        max_tokens,\n        thinking_type,\n        thinking_budget_tokens,\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use serde_json::json;\n\n    fn enabled_config() -> RectifierConfig {\n        RectifierConfig {\n            enabled: true,\n            request_thinking_signature: true,\n            request_thinking_budget: true,\n        }\n    }\n\n    fn budget_disabled_config() -> RectifierConfig {\n        RectifierConfig {\n            enabled: true,\n            request_thinking_signature: true,\n            request_thinking_budget: false,\n        }\n    }\n\n    fn master_disabled_config() -> RectifierConfig {\n        RectifierConfig {\n            enabled: false,\n            request_thinking_signature: true,\n            request_thinking_budget: true,\n        }\n    }\n\n    // ==================== should_rectify_thinking_budget 测试 ====================\n\n    #[test]\n    fn test_detect_budget_tokens_thinking_error() {\n        assert!(should_rectify_thinking_budget(\n            Some(\"thinking.budget_tokens: Input should be greater than or equal to 1024\"),\n            &enabled_config()\n        ));\n    }\n\n    #[test]\n    fn test_detect_budget_tokens_max_tokens_error() {\n        assert!(!should_rectify_thinking_budget(\n            Some(\"budget_tokens must be less than max_tokens\"),\n            &enabled_config()\n        ));\n    }\n\n    #[test]\n    fn test_detect_budget_tokens_1024_error() {\n        assert!(!should_rectify_thinking_budget(\n            Some(\"budget_tokens: value must be at least 1024\"),\n            &enabled_config()\n        ));\n    }\n\n    #[test]\n    fn test_detect_budget_tokens_with_thinking_and_1024_error() {\n        assert!(should_rectify_thinking_budget(\n            Some(\"thinking budget_tokens must be >= 1024\"),\n            &enabled_config()\n        ));\n    }\n\n    #[test]\n    fn test_no_trigger_for_unrelated_error() {\n        assert!(!should_rectify_thinking_budget(\n            Some(\"Request timeout\"),\n            &enabled_config()\n        ));\n        assert!(!should_rectify_thinking_budget(None, &enabled_config()));\n    }\n\n    #[test]\n    fn test_disabled_budget_config() {\n        assert!(!should_rectify_thinking_budget(\n            Some(\"thinking.budget_tokens: Input should be greater than or equal to 1024\"),\n            &budget_disabled_config()\n        ));\n    }\n\n    #[test]\n    fn test_master_disabled() {\n        assert!(!should_rectify_thinking_budget(\n            Some(\"thinking.budget_tokens: Input should be greater than or equal to 1024\"),\n            &master_disabled_config()\n        ));\n    }\n\n    // ==================== rectify_thinking_budget 测试 ====================\n\n    #[test]\n    fn test_rectify_budget_basic() {\n        let mut body = json!({\n            \"model\": \"claude-test\",\n            \"thinking\": { \"type\": \"enabled\", \"budget_tokens\": 512 },\n            \"max_tokens\": 1024\n        });\n\n        let result = rectify_thinking_budget(&mut body);\n\n        assert!(result.applied);\n        assert_eq!(result.before.thinking_type.as_deref(), Some(\"enabled\"));\n        assert_eq!(result.after.thinking_type.as_deref(), Some(\"enabled\"));\n        assert_eq!(result.before.thinking_budget_tokens, Some(512));\n        assert_eq!(\n            result.after.thinking_budget_tokens,\n            Some(MAX_THINKING_BUDGET)\n        );\n        assert_eq!(result.before.max_tokens, Some(1024));\n        assert_eq!(result.after.max_tokens, Some(MAX_TOKENS_VALUE));\n        assert_eq!(body[\"thinking\"][\"type\"], \"enabled\");\n        assert_eq!(body[\"thinking\"][\"budget_tokens\"], MAX_THINKING_BUDGET);\n        assert_eq!(body[\"max_tokens\"], MAX_TOKENS_VALUE);\n    }\n\n    #[test]\n    fn test_rectify_budget_skips_adaptive() {\n        let mut body = json!({\n            \"model\": \"claude-test\",\n            \"thinking\": { \"type\": \"adaptive\", \"budget_tokens\": 512 },\n            \"max_tokens\": 1024\n        });\n\n        let result = rectify_thinking_budget(&mut body);\n\n        assert!(!result.applied);\n        assert_eq!(result.before, result.after);\n        assert_eq!(body[\"thinking\"][\"type\"], \"adaptive\");\n        assert_eq!(body[\"thinking\"][\"budget_tokens\"], 512);\n        assert_eq!(body[\"max_tokens\"], 1024);\n    }\n\n    #[test]\n    fn test_rectify_budget_preserves_large_max_tokens() {\n        let mut body = json!({\n            \"model\": \"claude-test\",\n            \"thinking\": { \"type\": \"enabled\", \"budget_tokens\": 512 },\n            \"max_tokens\": 100000\n        });\n\n        let result = rectify_thinking_budget(&mut body);\n\n        assert!(result.applied);\n        assert_eq!(result.before.max_tokens, Some(100000));\n        assert_eq!(result.after.max_tokens, Some(100000));\n        assert_eq!(body[\"max_tokens\"], 100000);\n    }\n\n    #[test]\n    fn test_rectify_budget_creates_thinking_object_when_missing() {\n        let mut body = json!({\n            \"model\": \"claude-test\",\n            \"max_tokens\": 1024\n        });\n\n        let result = rectify_thinking_budget(&mut body);\n\n        assert!(result.applied);\n        assert_eq!(result.before.thinking_type, None);\n        assert_eq!(result.after.thinking_type.as_deref(), Some(\"enabled\"));\n        assert_eq!(\n            result.after.thinking_budget_tokens,\n            Some(MAX_THINKING_BUDGET)\n        );\n        assert_eq!(result.after.max_tokens, Some(MAX_TOKENS_VALUE));\n        assert_eq!(body[\"thinking\"][\"type\"], \"enabled\");\n        assert_eq!(body[\"thinking\"][\"budget_tokens\"], MAX_THINKING_BUDGET);\n        assert_eq!(body[\"max_tokens\"], MAX_TOKENS_VALUE);\n    }\n\n    #[test]\n    fn test_rectify_budget_no_max_tokens() {\n        let mut body = json!({\n            \"model\": \"claude-test\",\n            \"thinking\": { \"type\": \"enabled\", \"budget_tokens\": 512 }\n        });\n\n        let result = rectify_thinking_budget(&mut body);\n\n        assert!(result.applied);\n        assert_eq!(result.before.max_tokens, None);\n        assert_eq!(result.after.max_tokens, Some(MAX_TOKENS_VALUE));\n        assert_eq!(body[\"max_tokens\"], MAX_TOKENS_VALUE);\n    }\n\n    #[test]\n    fn test_rectify_budget_normalizes_non_enabled_type() {\n        let mut body = json!({\n            \"model\": \"claude-test\",\n            \"thinking\": { \"type\": \"disabled\", \"budget_tokens\": 512 },\n            \"max_tokens\": 1024\n        });\n\n        let result = rectify_thinking_budget(&mut body);\n\n        assert!(result.applied);\n        assert_eq!(result.before.thinking_type.as_deref(), Some(\"disabled\"));\n        assert_eq!(result.after.thinking_type.as_deref(), Some(\"enabled\"));\n        assert_eq!(body[\"thinking\"][\"type\"], \"enabled\");\n        assert_eq!(body[\"thinking\"][\"budget_tokens\"], MAX_THINKING_BUDGET);\n        assert_eq!(body[\"max_tokens\"], MAX_TOKENS_VALUE);\n    }\n\n    #[test]\n    fn test_rectify_budget_no_change_when_already_valid() {\n        let mut body = json!({\n            \"model\": \"claude-test\",\n            \"thinking\": { \"type\": \"enabled\", \"budget_tokens\": 32000 },\n            \"max_tokens\": 64001\n        });\n\n        let result = rectify_thinking_budget(&mut body);\n\n        assert!(!result.applied);\n        assert_eq!(result.before, result.after);\n        assert_eq!(body[\"thinking\"][\"budget_tokens\"], 32000);\n        assert_eq!(body[\"max_tokens\"], 64001);\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/thinking_optimizer.rs",
    "content": "//! Thinking 优化器\n\nuse super::types::OptimizerConfig;\nuse serde_json::{json, Value};\n\n/// 根据模型类型自动优化 thinking 配置\n///\n/// 三路径分发：\n/// - skip: haiku 模型直接跳过\n/// - adaptive: opus-4-6 / sonnet-4-6 使用 adaptive thinking\n/// - legacy: 其他模型注入 enabled thinking + budget_tokens\npub fn optimize(body: &mut Value, config: &OptimizerConfig) {\n    if !config.thinking_optimizer {\n        return;\n    }\n\n    let model = match body.get(\"model\").and_then(|m| m.as_str()) {\n        Some(m) => m.to_lowercase(),\n        None => return,\n    };\n\n    if model.contains(\"haiku\") {\n        log::info!(\"[OPT] thinking: skip(haiku)\");\n        return;\n    }\n\n    if model.contains(\"opus-4-6\") || model.contains(\"sonnet-4-6\") {\n        log::info!(\"[OPT] thinking: adaptive({})\", model);\n        body[\"thinking\"] = json!({\"type\": \"adaptive\"});\n        body[\"output_config\"] = json!({\"effort\": \"max\"});\n        append_beta(body, \"context-1m-2025-08-07\");\n        return;\n    }\n\n    // legacy path\n    log::info!(\"[OPT] thinking: legacy({})\", model);\n\n    let max_tokens = body\n        .get(\"max_tokens\")\n        .and_then(|v| v.as_u64())\n        .unwrap_or(16384);\n\n    let budget_target = max_tokens.saturating_sub(1);\n\n    let thinking_type = body\n        .get(\"thinking\")\n        .and_then(|t| t.get(\"type\"))\n        .and_then(|t| t.as_str())\n        .map(|s| s.to_string());\n\n    match thinking_type.as_deref() {\n        None | Some(\"disabled\") => {\n            body[\"thinking\"] = json!({\n                \"type\": \"enabled\",\n                \"budget_tokens\": budget_target\n            });\n            append_beta(body, \"interleaved-thinking-2025-05-14\");\n        }\n        Some(\"enabled\") => {\n            let current_budget = body\n                .get(\"thinking\")\n                .and_then(|t| t.get(\"budget_tokens\"))\n                .and_then(|b| b.as_u64())\n                .unwrap_or(0);\n            if current_budget < budget_target {\n                body[\"thinking\"][\"budget_tokens\"] = json!(budget_target);\n            }\n            append_beta(body, \"interleaved-thinking-2025-05-14\");\n        }\n        _ => {\n            append_beta(body, \"interleaved-thinking-2025-05-14\");\n        }\n    }\n}\n\n/// 追加 beta 标识到 anthropic_beta 数组（去重）\nfn append_beta(body: &mut Value, beta: &str) {\n    match body.get(\"anthropic_beta\") {\n        Some(Value::Array(arr)) => {\n            if arr.iter().any(|v| v.as_str() == Some(beta)) {\n                return;\n            }\n            body[\"anthropic_beta\"]\n                .as_array_mut()\n                .unwrap()\n                .push(json!(beta));\n        }\n        Some(Value::Null) | None => {\n            body[\"anthropic_beta\"] = json!([beta]);\n        }\n        _ => {\n            body[\"anthropic_beta\"] = json!([beta]);\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use serde_json::json;\n\n    fn enabled_config() -> OptimizerConfig {\n        OptimizerConfig {\n            enabled: true,\n            thinking_optimizer: true,\n            cache_injection: true,\n            cache_ttl: \"1h\".to_string(),\n        }\n    }\n\n    fn disabled_config() -> OptimizerConfig {\n        OptimizerConfig {\n            enabled: true,\n            thinking_optimizer: false,\n            cache_injection: true,\n            cache_ttl: \"1h\".to_string(),\n        }\n    }\n\n    #[test]\n    fn test_adaptive_opus_4_6() {\n        let mut body = json!({\n            \"model\": \"anthropic.claude-opus-4-6-20250514-v1:0\",\n            \"max_tokens\": 16384,\n            \"thinking\": {\"type\": \"enabled\", \"budget_tokens\": 8000},\n            \"messages\": [{\"role\": \"user\", \"content\": \"hello\"}]\n        });\n\n        optimize(&mut body, &enabled_config());\n\n        assert_eq!(body[\"thinking\"][\"type\"], \"adaptive\");\n        assert!(body[\"thinking\"].get(\"budget_tokens\").is_none());\n        assert_eq!(body[\"output_config\"][\"effort\"], \"max\");\n        let betas = body[\"anthropic_beta\"].as_array().unwrap();\n        assert!(betas.iter().any(|v| v == \"context-1m-2025-08-07\"));\n    }\n\n    #[test]\n    fn test_adaptive_sonnet_4_6() {\n        let mut body = json!({\n            \"model\": \"anthropic.claude-sonnet-4-6-20250514-v1:0\",\n            \"max_tokens\": 16384,\n            \"messages\": [{\"role\": \"user\", \"content\": \"hello\"}]\n        });\n\n        optimize(&mut body, &enabled_config());\n\n        assert_eq!(body[\"thinking\"][\"type\"], \"adaptive\");\n        assert!(body[\"thinking\"].get(\"budget_tokens\").is_none());\n        assert_eq!(body[\"output_config\"][\"effort\"], \"max\");\n        let betas = body[\"anthropic_beta\"].as_array().unwrap();\n        assert!(betas.iter().any(|v| v == \"context-1m-2025-08-07\"));\n    }\n\n    #[test]\n    fn test_legacy_sonnet_4_5_thinking_null() {\n        let mut body = json!({\n            \"model\": \"anthropic.claude-sonnet-4-5-20250514-v1:0\",\n            \"max_tokens\": 16384,\n            \"messages\": [{\"role\": \"user\", \"content\": \"hello\"}]\n        });\n\n        optimize(&mut body, &enabled_config());\n\n        assert_eq!(body[\"thinking\"][\"type\"], \"enabled\");\n        assert_eq!(body[\"thinking\"][\"budget_tokens\"], 16383);\n        let betas = body[\"anthropic_beta\"].as_array().unwrap();\n        assert!(betas.iter().any(|v| v == \"interleaved-thinking-2025-05-14\"));\n    }\n\n    #[test]\n    fn test_legacy_budget_too_small_upgraded() {\n        let mut body = json!({\n            \"model\": \"anthropic.claude-sonnet-4-5-20250514-v1:0\",\n            \"max_tokens\": 16384,\n            \"thinking\": {\"type\": \"enabled\", \"budget_tokens\": 1024},\n            \"messages\": [{\"role\": \"user\", \"content\": \"hello\"}]\n        });\n\n        optimize(&mut body, &enabled_config());\n\n        assert_eq!(body[\"thinking\"][\"type\"], \"enabled\");\n        assert_eq!(body[\"thinking\"][\"budget_tokens\"], 16383);\n    }\n\n    #[test]\n    fn test_skip_haiku() {\n        let mut body = json!({\n            \"model\": \"anthropic.claude-haiku-4-5-20250514-v1:0\",\n            \"max_tokens\": 8192,\n            \"messages\": [{\"role\": \"user\", \"content\": \"hello\"}]\n        });\n        let original = body.clone();\n\n        optimize(&mut body, &enabled_config());\n\n        assert_eq!(body, original);\n    }\n\n    #[test]\n    fn test_thinking_optimizer_disabled() {\n        let mut body = json!({\n            \"model\": \"anthropic.claude-opus-4-6-20250514-v1:0\",\n            \"max_tokens\": 16384,\n            \"messages\": [{\"role\": \"user\", \"content\": \"hello\"}]\n        });\n        let original = body.clone();\n\n        optimize(&mut body, &disabled_config());\n\n        assert_eq!(body, original);\n    }\n\n    #[test]\n    fn test_adaptive_dedup_beta() {\n        let mut body = json!({\n            \"model\": \"anthropic.claude-opus-4-6-20250514-v1:0\",\n            \"max_tokens\": 16384,\n            \"anthropic_beta\": [\"context-1m-2025-08-07\"],\n            \"messages\": [{\"role\": \"user\", \"content\": \"hello\"}]\n        });\n\n        optimize(&mut body, &enabled_config());\n\n        let betas = body[\"anthropic_beta\"].as_array().unwrap();\n        let count = betas\n            .iter()\n            .filter(|v| v == &&json!(\"context-1m-2025-08-07\"))\n            .count();\n        assert_eq!(count, 1);\n    }\n\n    #[test]\n    fn test_legacy_disabled_thinking_injected() {\n        let mut body = json!({\n            \"model\": \"anthropic.claude-sonnet-4-5-20250514-v1:0\",\n            \"max_tokens\": 8192,\n            \"thinking\": {\"type\": \"disabled\"},\n            \"messages\": [{\"role\": \"user\", \"content\": \"hello\"}]\n        });\n\n        optimize(&mut body, &enabled_config());\n\n        assert_eq!(body[\"thinking\"][\"type\"], \"enabled\");\n        assert_eq!(body[\"thinking\"][\"budget_tokens\"], 8191);\n    }\n\n    #[test]\n    fn test_legacy_default_max_tokens() {\n        let mut body = json!({\n            \"model\": \"anthropic.claude-sonnet-4-5-20250514-v1:0\",\n            \"messages\": [{\"role\": \"user\", \"content\": \"hello\"}]\n        });\n\n        optimize(&mut body, &enabled_config());\n\n        assert_eq!(body[\"thinking\"][\"type\"], \"enabled\");\n        assert_eq!(body[\"thinking\"][\"budget_tokens\"], 16383);\n    }\n\n    #[test]\n    fn test_append_beta_null_field() {\n        let mut body = json!({\n            \"model\": \"anthropic.claude-opus-4-6-20250514-v1:0\",\n            \"anthropic_beta\": null,\n            \"messages\": [{\"role\": \"user\", \"content\": \"hello\"}]\n        });\n\n        optimize(&mut body, &enabled_config());\n\n        let betas = body[\"anthropic_beta\"].as_array().unwrap();\n        assert!(betas.iter().any(|v| v == \"context-1m-2025-08-07\"));\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/thinking_rectifier.rs",
    "content": "//! Thinking Signature 整流器\n//!\n//! 用于自动修复 Anthropic API 中因签名校验失败导致的请求错误。\n//! 当上游 API 返回签名相关错误时，系统会自动移除有问题的签名字段并重试请求。\n\nuse super::types::RectifierConfig;\nuse serde_json::Value;\n\n/// 整流结果\n#[derive(Debug, Clone, Default)]\npub struct RectifyResult {\n    /// 是否应用了整流\n    pub applied: bool,\n    /// 移除的 thinking block 数量\n    pub removed_thinking_blocks: usize,\n    /// 移除的 redacted_thinking block 数量\n    pub removed_redacted_thinking_blocks: usize,\n    /// 移除的 signature 字段数量\n    pub removed_signature_fields: usize,\n}\n\n/// 检测是否需要触发 thinking 签名整流器\n///\n/// 返回 `true` 表示需要触发整流器，`false` 表示不需要。\n/// 会检查配置开关。\npub fn should_rectify_thinking_signature(\n    error_message: Option<&str>,\n    config: &RectifierConfig,\n) -> bool {\n    // 检查总开关\n    if !config.enabled {\n        return false;\n    }\n    // 检查子开关\n    if !config.request_thinking_signature {\n        return false;\n    }\n\n    // 检测错误类型\n    let Some(msg) = error_message else {\n        return false;\n    };\n    let lower = msg.to_lowercase();\n\n    // 场景1: thinking block 中的签名无效\n    // 错误示例: \"Invalid 'signature' in 'thinking' block\"\n    if lower.contains(\"invalid\")\n        && lower.contains(\"signature\")\n        && lower.contains(\"thinking\")\n        && lower.contains(\"block\")\n    {\n        return true;\n    }\n\n    // 场景2: assistant 消息必须以 thinking block 开头\n    // 错误示例: \"must start with a thinking block\"\n    if lower.contains(\"must start with a thinking block\") {\n        return true;\n    }\n\n    // 场景3: expected thinking or redacted_thinking, found tool_use\n    // 与 CCH 对齐：要求明确包含 tool_use，避免过宽匹配。\n    // 错误示例: \"Expected `thinking` or `redacted_thinking`, but found `tool_use`\"\n    if lower.contains(\"expected\")\n        && (lower.contains(\"thinking\") || lower.contains(\"redacted_thinking\"))\n        && lower.contains(\"found\")\n        && lower.contains(\"tool_use\")\n    {\n        return true;\n    }\n\n    // 场景4: signature 字段必需但缺失\n    // 错误示例: \"signature: Field required\"\n    if lower.contains(\"signature\") && lower.contains(\"field required\") {\n        return true;\n    }\n\n    // 场景5: signature 字段不被接受（第三方渠道）\n    // 错误示例: \"xxx.signature: Extra inputs are not permitted\"\n    if lower.contains(\"signature\") && lower.contains(\"extra inputs are not permitted\") {\n        return true;\n    }\n\n    // 场景6: thinking/redacted_thinking 块被修改\n    // 错误示例: \"thinking or redacted_thinking blocks ... cannot be modified\"\n    if (lower.contains(\"thinking\") || lower.contains(\"redacted_thinking\"))\n        && lower.contains(\"cannot be modified\")\n    {\n        return true;\n    }\n\n    // 场景7: 非法请求（与 CCH 对齐，按 invalid request 统一兜底）\n    if lower.contains(\"非法请求\")\n        || lower.contains(\"illegal request\")\n        || lower.contains(\"invalid request\")\n    {\n        return true;\n    }\n\n    false\n}\n\n/// 对 Anthropic 请求体做最小侵入整流\n///\n/// - 移除 messages[*].content 中的 thinking/redacted_thinking block\n/// - 移除非 thinking block 上遗留的 signature 字段\n/// - 特定条件下删除顶层 thinking 字段\n///\n/// 注意：该函数会原地修改 body 对象\npub fn rectify_anthropic_request(body: &mut Value) -> RectifyResult {\n    let mut result = RectifyResult::default();\n\n    let messages = match body.get_mut(\"messages\").and_then(|m| m.as_array_mut()) {\n        Some(m) => m,\n        None => return result,\n    };\n\n    // 遍历所有消息\n    for msg in messages.iter_mut() {\n        let content = match msg.get_mut(\"content\").and_then(|c| c.as_array_mut()) {\n            Some(c) => c,\n            None => continue,\n        };\n\n        let mut new_content = Vec::with_capacity(content.len());\n        let mut content_modified = false;\n\n        for block in content.iter() {\n            let block_type = block.get(\"type\").and_then(|t| t.as_str());\n\n            match block_type {\n                Some(\"thinking\") => {\n                    result.removed_thinking_blocks += 1;\n                    content_modified = true;\n                    continue;\n                }\n                Some(\"redacted_thinking\") => {\n                    result.removed_redacted_thinking_blocks += 1;\n                    content_modified = true;\n                    continue;\n                }\n                _ => {}\n            }\n\n            // 移除非 thinking block 上的 signature 字段\n            if block.get(\"signature\").is_some() {\n                let mut block_clone = block.clone();\n                if let Some(obj) = block_clone.as_object_mut() {\n                    obj.remove(\"signature\");\n                    result.removed_signature_fields += 1;\n                    content_modified = true;\n                    new_content.push(Value::Object(obj.clone()));\n                    continue;\n                }\n            }\n\n            new_content.push(block.clone());\n        }\n\n        if content_modified {\n            result.applied = true;\n            *content = new_content;\n        }\n    }\n\n    // 兜底处理：thinking 启用 + 工具调用链路中最后一条 assistant 消息未以 thinking 开头\n    let messages_snapshot: Vec<Value> = body\n        .get(\"messages\")\n        .and_then(|m| m.as_array())\n        .map(|a| a.to_vec())\n        .unwrap_or_default();\n\n    if should_remove_top_level_thinking(body, &messages_snapshot) {\n        if let Some(obj) = body.as_object_mut() {\n            obj.remove(\"thinking\");\n            result.applied = true;\n        }\n    }\n\n    result\n}\n\n/// 判断是否需要删除顶层 thinking 字段\nfn should_remove_top_level_thinking(body: &Value, messages: &[Value]) -> bool {\n    // 检查 thinking 是否启用\n    let thinking_type = body\n        .get(\"thinking\")\n        .and_then(|t| t.get(\"type\"))\n        .and_then(|t| t.as_str());\n\n    // 与 CCH 对齐：仅 type=enabled 视为开启\n    let thinking_enabled = thinking_type == Some(\"enabled\");\n\n    if !thinking_enabled {\n        return false;\n    }\n\n    // 找到最后一条 assistant 消息\n    let last_assistant = messages\n        .iter()\n        .rev()\n        .find(|m| m.get(\"role\").and_then(|r| r.as_str()) == Some(\"assistant\"));\n\n    let last_assistant_content = match last_assistant\n        .and_then(|m| m.get(\"content\"))\n        .and_then(|c| c.as_array())\n    {\n        Some(c) if !c.is_empty() => c,\n        _ => return false,\n    };\n\n    // 检查首块是否为 thinking/redacted_thinking\n    let first_block_type = last_assistant_content\n        .first()\n        .and_then(|b| b.get(\"type\"))\n        .and_then(|t| t.as_str());\n\n    let missing_thinking_prefix =\n        first_block_type != Some(\"thinking\") && first_block_type != Some(\"redacted_thinking\");\n\n    if !missing_thinking_prefix {\n        return false;\n    }\n\n    // 检查是否存在 tool_use\n    last_assistant_content\n        .iter()\n        .any(|b| b.get(\"type\").and_then(|t| t.as_str()) == Some(\"tool_use\"))\n}\n\n/// 与 CCH 对齐：请求前不做 thinking type 主动改写。\npub fn normalize_thinking_type(body: Value) -> Value {\n    body\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use serde_json::json;\n\n    fn enabled_config() -> RectifierConfig {\n        RectifierConfig {\n            enabled: true,\n            request_thinking_signature: true,\n            request_thinking_budget: true,\n        }\n    }\n\n    fn disabled_config() -> RectifierConfig {\n        RectifierConfig {\n            enabled: true,\n            request_thinking_signature: false,\n            request_thinking_budget: false,\n        }\n    }\n\n    fn master_disabled_config() -> RectifierConfig {\n        RectifierConfig {\n            enabled: false,\n            request_thinking_signature: true,\n            request_thinking_budget: true,\n        }\n    }\n\n    // ==================== should_rectify_thinking_signature 测试 ====================\n\n    #[test]\n    fn test_detect_invalid_signature() {\n        assert!(should_rectify_thinking_signature(\n            Some(\"messages.1.content.0: Invalid `signature` in `thinking` block\"),\n            &enabled_config()\n        ));\n    }\n\n    #[test]\n    fn test_detect_invalid_signature_no_backticks() {\n        assert!(should_rectify_thinking_signature(\n            Some(\"Messages.1.Content.0: invalid signature in thinking block\"),\n            &enabled_config()\n        ));\n    }\n\n    #[test]\n    fn test_detect_invalid_signature_nested_json() {\n        // 测试嵌套 JSON 格式的错误消息（第三方渠道常见格式）\n        let nested_error = r#\"{\"error\":{\"message\":\"{\\\"type\\\":\\\"error\\\",\\\"error\\\":{\\\"type\\\":\\\"invalid_request_error\\\",\\\"message\\\":\\\"***.content.0: Invalid `signature` in `thinking` block\\\"},\\\"request_id\\\":\\\"req_xxx\\\"}\"}}\"#;\n        assert!(should_rectify_thinking_signature(\n            Some(nested_error),\n            &enabled_config()\n        ));\n    }\n\n    #[test]\n    fn test_detect_thinking_expected() {\n        assert!(should_rectify_thinking_signature(\n            Some(\"messages.69.content.0.type: Expected `thinking` or `redacted_thinking`, but found `tool_use`.\"),\n            &enabled_config()\n        ));\n    }\n\n    #[test]\n    fn test_no_detect_thinking_expected_without_tool_use() {\n        assert!(!should_rectify_thinking_signature(\n            Some(\"messages.69.content.0.type: Expected `thinking` or `redacted_thinking`, but found `text`.\"),\n            &enabled_config()\n        ));\n    }\n\n    #[test]\n    fn test_detect_must_start_with_thinking() {\n        assert!(should_rectify_thinking_signature(\n            Some(\"a final `assistant` message must start with a thinking block\"),\n            &enabled_config()\n        ));\n    }\n\n    #[test]\n    fn test_no_trigger_for_unrelated_error() {\n        assert!(!should_rectify_thinking_signature(\n            Some(\"Request timeout\"),\n            &enabled_config()\n        ));\n        assert!(!should_rectify_thinking_signature(\n            Some(\"Connection refused\"),\n            &enabled_config()\n        ));\n        assert!(!should_rectify_thinking_signature(None, &enabled_config()));\n    }\n\n    #[test]\n    fn test_detect_signature_field_required() {\n        // 场景4: signature 字段缺失\n        assert!(should_rectify_thinking_signature(\n            Some(\"***.***.***.***.***.signature: Field required\"),\n            &enabled_config()\n        ));\n        // 嵌套 JSON 格式\n        let nested_error = r#\"{\"error\":{\"type\":\"<nil>\",\"message\":\"{\\\"type\\\":\\\"error\\\",\\\"error\\\":{\\\"type\\\":\\\"invalid_request_error\\\",\\\"message\\\":\\\"***.***.***.***.***.signature: Field required\\\"},\\\"request_id\\\":\\\"req_xxx\\\"}\"}}\"#;\n        assert!(should_rectify_thinking_signature(\n            Some(nested_error),\n            &enabled_config()\n        ));\n    }\n\n    #[test]\n    fn test_disabled_config() {\n        // 即使错误匹配，配置关闭时也不触发\n        assert!(!should_rectify_thinking_signature(\n            Some(\"Invalid `signature` in `thinking` block\"),\n            &disabled_config()\n        ));\n    }\n\n    #[test]\n    fn test_master_disabled() {\n        // 总开关关闭时，即使子开关开启也不触发\n        assert!(!should_rectify_thinking_signature(\n            Some(\"Invalid `signature` in `thinking` block\"),\n            &master_disabled_config()\n        ));\n    }\n\n    // ==================== rectify_anthropic_request 测试 ====================\n\n    #[test]\n    fn test_rectify_removes_thinking_blocks() {\n        let mut body = json!({\n            \"model\": \"claude-test\",\n            \"messages\": [{\n                \"role\": \"assistant\",\n                \"content\": [\n                    { \"type\": \"thinking\", \"thinking\": \"t\", \"signature\": \"sig\" },\n                    { \"type\": \"text\", \"text\": \"hello\", \"signature\": \"sig_text\" },\n                    { \"type\": \"tool_use\", \"id\": \"toolu_1\", \"name\": \"WebSearch\", \"input\": {}, \"signature\": \"sig_tool\" },\n                    { \"type\": \"redacted_thinking\", \"data\": \"r\", \"signature\": \"sig_redacted\" }\n                ]\n            }]\n        });\n\n        let result = rectify_anthropic_request(&mut body);\n\n        assert!(result.applied);\n        assert_eq!(result.removed_thinking_blocks, 1);\n        assert_eq!(result.removed_redacted_thinking_blocks, 1);\n        assert_eq!(result.removed_signature_fields, 2);\n\n        let content = body[\"messages\"][0][\"content\"].as_array().unwrap();\n        assert_eq!(content.len(), 2);\n        assert_eq!(content[0][\"type\"], \"text\");\n        assert!(content[0].get(\"signature\").is_none());\n        assert_eq!(content[1][\"type\"], \"tool_use\");\n        assert!(content[1].get(\"signature\").is_none());\n    }\n\n    #[test]\n    fn test_rectify_removes_top_level_thinking() {\n        let mut body = json!({\n            \"model\": \"claude-test\",\n            \"thinking\": { \"type\": \"enabled\", \"budget_tokens\": 1024 },\n            \"messages\": [{\n                \"role\": \"assistant\",\n                \"content\": [\n                    { \"type\": \"tool_use\", \"id\": \"toolu_1\", \"name\": \"WebSearch\", \"input\": {} }\n                ]\n            }, {\n                \"role\": \"user\",\n                \"content\": [{ \"type\": \"tool_result\", \"tool_use_id\": \"toolu_1\", \"content\": \"ok\" }]\n            }]\n        });\n\n        let result = rectify_anthropic_request(&mut body);\n\n        assert!(result.applied);\n        assert!(body.get(\"thinking\").is_none());\n    }\n\n    #[test]\n    fn test_rectify_no_change_when_no_issues() {\n        let mut body = json!({\n            \"model\": \"claude-test\",\n            \"messages\": [{\n                \"role\": \"user\",\n                \"content\": [{ \"type\": \"text\", \"text\": \"hello\" }]\n            }]\n        });\n\n        let result = rectify_anthropic_request(&mut body);\n\n        assert!(!result.applied);\n        assert_eq!(result.removed_thinking_blocks, 0);\n    }\n\n    #[test]\n    fn test_rectify_no_messages() {\n        let mut body = json!({ \"model\": \"claude-test\" });\n        let result = rectify_anthropic_request(&mut body);\n        assert!(!result.applied);\n    }\n\n    #[test]\n    fn test_rectify_preserves_thinking_when_prefix_exists() {\n        let mut body = json!({\n            \"model\": \"claude-test\",\n            \"thinking\": { \"type\": \"enabled\" },\n            \"messages\": [{\n                \"role\": \"assistant\",\n                \"content\": [\n                    { \"type\": \"thinking\", \"thinking\": \"some thought\" },\n                    { \"type\": \"tool_use\", \"id\": \"toolu_1\", \"name\": \"Test\", \"input\": {} }\n                ]\n            }]\n        });\n\n        let result = rectify_anthropic_request(&mut body);\n\n        // thinking block 被移除，但顶层 thinking 不应被移除（因为原本有 thinking 前缀）\n        assert!(result.applied);\n        assert_eq!(result.removed_thinking_blocks, 1);\n        // 注意：由于 thinking block 被移除后，首块变成了 tool_use，\n        // 此时会触发删除顶层 thinking 的逻辑\n        // 这是预期行为：整流后如果仍然不符合要求，就删除顶层 thinking\n    }\n\n    // ==================== 新增错误场景检测测试 ====================\n\n    #[test]\n    fn test_detect_signature_extra_inputs() {\n        // 场景5: signature 字段不被接受\n        assert!(should_rectify_thinking_signature(\n            Some(\"xxx.signature: Extra inputs are not permitted\"),\n            &enabled_config()\n        ));\n    }\n\n    #[test]\n    fn test_detect_thinking_cannot_be_modified() {\n        // 场景6: thinking blocks cannot be modified\n        assert!(should_rectify_thinking_signature(\n            Some(\"thinking or redacted_thinking blocks in the response cannot be modified\"),\n            &enabled_config()\n        ));\n    }\n\n    #[test]\n    fn test_detect_invalid_request() {\n        // 场景7: 非法请求（与 CCH 对齐，统一触发）\n        assert!(should_rectify_thinking_signature(\n            Some(\"非法请求：thinking signature 不合法\"),\n            &enabled_config()\n        ));\n        assert!(should_rectify_thinking_signature(\n            Some(\"illegal request: tool_use block mismatch\"),\n            &enabled_config()\n        ));\n        assert!(should_rectify_thinking_signature(\n            Some(\"invalid request: malformed JSON\"),\n            &enabled_config()\n        ));\n    }\n\n    #[test]\n    fn test_do_not_detect_thinking_type_tag_mismatch() {\n        // 与 CCH 对齐：adaptive tag mismatch 不触发签名整流器\n        assert!(!should_rectify_thinking_signature(\n            Some(\"Input tag 'adaptive' found using 'type' does not match expected tags\"),\n            &enabled_config()\n        ));\n    }\n\n    // ==================== adaptive thinking type 测试 ====================\n\n    #[test]\n    fn test_rectify_keeps_adaptive_when_no_legacy_blocks() {\n        let mut body = json!({\n            \"model\": \"claude-test\",\n            \"thinking\": { \"type\": \"adaptive\" },\n            \"messages\": [{\n                \"role\": \"user\",\n                \"content\": [{ \"type\": \"text\", \"text\": \"hello\" }]\n            }]\n        });\n\n        let result = rectify_anthropic_request(&mut body);\n\n        assert!(!result.applied);\n        assert_eq!(body[\"thinking\"][\"type\"], \"adaptive\");\n        assert!(body[\"thinking\"].get(\"budget_tokens\").is_none());\n    }\n\n    #[test]\n    fn test_rectify_adaptive_preserves_existing_budget_tokens() {\n        let mut body = json!({\n            \"model\": \"claude-test\",\n            \"thinking\": { \"type\": \"adaptive\", \"budget_tokens\": 5000 },\n            \"messages\": [{\n                \"role\": \"user\",\n                \"content\": [{ \"type\": \"text\", \"text\": \"hello\" }]\n            }]\n        });\n\n        let result = rectify_anthropic_request(&mut body);\n\n        assert!(!result.applied);\n        assert_eq!(body[\"thinking\"][\"type\"], \"adaptive\");\n        assert_eq!(body[\"thinking\"][\"budget_tokens\"], 5000);\n    }\n\n    #[test]\n    fn test_rectify_does_not_change_enabled_type() {\n        let mut body = json!({\n            \"model\": \"claude-test\",\n            \"thinking\": { \"type\": \"enabled\", \"budget_tokens\": 1024 },\n            \"messages\": [{\n                \"role\": \"user\",\n                \"content\": [{ \"type\": \"text\", \"text\": \"hello\" }]\n            }]\n        });\n\n        let result = rectify_anthropic_request(&mut body);\n\n        assert!(!result.applied);\n        assert_eq!(body[\"thinking\"][\"type\"], \"enabled\");\n    }\n\n    #[test]\n    fn test_rectify_removes_top_level_thinking_adaptive() {\n        // 顶层 thinking 仅在 type=enabled 且 tool_use 场景才会删除，adaptive 不删除\n        let mut body = json!({\n            \"model\": \"claude-test\",\n            \"thinking\": { \"type\": \"adaptive\" },\n            \"messages\": [{\n                \"role\": \"assistant\",\n                \"content\": [\n                    { \"type\": \"tool_use\", \"id\": \"toolu_1\", \"name\": \"WebSearch\", \"input\": {} }\n                ]\n            }, {\n                \"role\": \"user\",\n                \"content\": [{ \"type\": \"tool_result\", \"tool_use_id\": \"toolu_1\", \"content\": \"ok\" }]\n            }]\n        });\n\n        let result = rectify_anthropic_request(&mut body);\n\n        assert!(!result.applied);\n        assert_eq!(body[\"thinking\"][\"type\"], \"adaptive\");\n    }\n\n    #[test]\n    fn test_rectify_adaptive_still_cleans_legacy_signature_blocks() {\n        let mut body = json!({\n            \"model\": \"claude-test\",\n            \"thinking\": { \"type\": \"adaptive\" },\n            \"messages\": [{\n                \"role\": \"assistant\",\n                \"content\": [\n                    { \"type\": \"thinking\", \"thinking\": \"t\", \"signature\": \"sig_thinking\" },\n                    { \"type\": \"text\", \"text\": \"hello\", \"signature\": \"sig_text\" }\n                ]\n            }]\n        });\n\n        let result = rectify_anthropic_request(&mut body);\n\n        assert!(result.applied);\n        assert_eq!(result.removed_thinking_blocks, 1);\n        let content = body[\"messages\"][0][\"content\"].as_array().unwrap();\n        assert_eq!(content.len(), 1);\n        assert_eq!(content[0][\"type\"], \"text\");\n        assert!(content[0].get(\"signature\").is_none());\n        assert_eq!(body[\"thinking\"][\"type\"], \"adaptive\");\n    }\n\n    // ==================== normalize_thinking_type 测试 ====================\n\n    #[test]\n    fn test_normalize_thinking_type_adaptive_unchanged() {\n        let body = json!({\n            \"model\": \"claude-test\",\n            \"thinking\": { \"type\": \"adaptive\" }\n        });\n\n        let result = normalize_thinking_type(body);\n\n        assert_eq!(result[\"thinking\"][\"type\"], \"adaptive\");\n        assert!(result[\"thinking\"].get(\"budget_tokens\").is_none());\n    }\n\n    #[test]\n    fn test_normalize_thinking_type_enabled_unchanged() {\n        let body = json!({\n            \"model\": \"claude-test\",\n            \"thinking\": { \"type\": \"enabled\", \"budget_tokens\": 2048 }\n        });\n\n        let result = normalize_thinking_type(body);\n\n        assert_eq!(result[\"thinking\"][\"type\"], \"enabled\");\n        assert_eq!(result[\"thinking\"][\"budget_tokens\"], 2048);\n    }\n\n    #[test]\n    fn test_normalize_thinking_type_disabled_unchanged() {\n        let body = json!({\n            \"model\": \"claude-test\",\n            \"thinking\": { \"type\": \"disabled\" }\n        });\n\n        let result = normalize_thinking_type(body);\n\n        assert_eq!(result[\"thinking\"][\"type\"], \"disabled\");\n    }\n\n    #[test]\n    fn test_normalize_thinking_type_preserves_budget() {\n        let body = json!({\n            \"model\": \"claude-test\",\n            \"thinking\": { \"type\": \"adaptive\", \"budget_tokens\": 5000 }\n        });\n\n        let result = normalize_thinking_type(body);\n\n        assert_eq!(result[\"thinking\"][\"type\"], \"adaptive\");\n        assert_eq!(result[\"thinking\"][\"budget_tokens\"], 5000);\n    }\n\n    #[test]\n    fn test_normalize_thinking_type_no_thinking() {\n        let body = json!({\n            \"model\": \"claude-test\"\n        });\n\n        let result = normalize_thinking_type(body);\n\n        assert!(result.get(\"thinking\").is_none());\n    }\n\n    #[test]\n    fn test_normalize_thinking_type_unknown_unchanged() {\n        let body = json!({\n            \"model\": \"claude-test\",\n            \"thinking\": { \"type\": \"unexpected\", \"budget_tokens\": 100 }\n        });\n\n        let result = normalize_thinking_type(body);\n\n        assert_eq!(result[\"thinking\"][\"type\"], \"unexpected\");\n        assert_eq!(result[\"thinking\"][\"budget_tokens\"], 100);\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/types.rs",
    "content": "use serde::{Deserialize, Serialize};\n\n/// 代理服务器配置\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ProxyConfig {\n    /// 监听地址\n    pub listen_address: String,\n    /// 监听端口\n    pub listen_port: u16,\n    /// 最大重试次数\n    pub max_retries: u8,\n    /// 请求超时时间（秒）- 已废弃，保留兼容\n    pub request_timeout: u64,\n    /// 是否启用日志\n    pub enable_logging: bool,\n    /// 是否正在接管 Live 配置\n    #[serde(default)]\n    pub live_takeover_active: bool,\n    /// 流式首字超时（秒）- 等待首个数据块的最大时间，范围 1-120 秒，默认 60 秒\n    #[serde(default = \"default_streaming_first_byte_timeout\")]\n    pub streaming_first_byte_timeout: u64,\n    /// 流式静默超时（秒）- 两个数据块之间的最大间隔，范围 60-600 秒，填 0 禁用（防止中途卡住）\n    #[serde(default = \"default_streaming_idle_timeout\")]\n    pub streaming_idle_timeout: u64,\n    /// 非流式总超时（秒）- 非流式请求的总超时时间，范围 60-1200 秒，默认 600 秒（10 分钟）\n    #[serde(default = \"default_non_streaming_timeout\")]\n    pub non_streaming_timeout: u64,\n}\n\nfn default_streaming_first_byte_timeout() -> u64 {\n    60\n}\n\nfn default_streaming_idle_timeout() -> u64 {\n    120\n}\n\nfn default_non_streaming_timeout() -> u64 {\n    600\n}\n\nimpl Default for ProxyConfig {\n    fn default() -> Self {\n        Self {\n            listen_address: \"127.0.0.1\".to_string(),\n            listen_port: 15721, // 使用较少占用的高位端口\n            max_retries: 3,\n            request_timeout: 600,\n            enable_logging: true,\n            live_takeover_active: false,\n            streaming_first_byte_timeout: 60,\n            streaming_idle_timeout: 120,\n            non_streaming_timeout: 600,\n        }\n    }\n}\n\n/// 代理服务器状态\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct ProxyStatus {\n    /// 是否运行中\n    pub running: bool,\n    /// 监听地址\n    pub address: String,\n    /// 监听端口\n    pub port: u16,\n    /// 活跃连接数\n    pub active_connections: usize,\n    /// 总请求数\n    pub total_requests: u64,\n    /// 成功请求数\n    pub success_requests: u64,\n    /// 失败请求数\n    pub failed_requests: u64,\n    /// 成功率 (0-100)\n    pub success_rate: f32,\n    /// 运行时间（秒）\n    pub uptime_seconds: u64,\n    /// 当前使用的Provider名称\n    pub current_provider: Option<String>,\n    /// 当前Provider的ID\n    pub current_provider_id: Option<String>,\n    /// 最后一次请求时间\n    pub last_request_at: Option<String>,\n    /// 最后一次错误信息\n    pub last_error: Option<String>,\n    /// Provider故障转移次数\n    pub failover_count: u64,\n    /// 当前活跃的代理目标列表\n    #[serde(default)]\n    pub active_targets: Vec<ActiveTarget>,\n}\n\n/// 活跃的代理目标信息\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ActiveTarget {\n    pub app_type: String, // \"Claude\" | \"Codex\" | \"Gemini\"\n    pub provider_name: String,\n    pub provider_id: String,\n}\n\n/// 代理服务器信息\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ProxyServerInfo {\n    pub address: String,\n    pub port: u16,\n    pub started_at: String,\n}\n\n/// 各应用的接管状态（是否改写该应用的 Live 配置指向本地代理）\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct ProxyTakeoverStatus {\n    pub claude: bool,\n    pub codex: bool,\n    pub gemini: bool,\n    pub opencode: bool,\n    pub openclaw: bool,\n}\n\n/// API 格式类型（预留，当前不需要格式转换）\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\n#[allow(dead_code)]\npub enum ApiFormat {\n    Claude,\n    OpenAI,\n    Gemini,\n}\n\n/// Provider健康状态\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ProviderHealth {\n    pub provider_id: String,\n    pub app_type: String,\n    pub is_healthy: bool,\n    pub consecutive_failures: u32,\n    pub last_success_at: Option<String>,\n    pub last_failure_at: Option<String>,\n    pub last_error: Option<String>,\n    pub updated_at: String,\n}\n\n/// Live 配置备份记录\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct LiveBackup {\n    /// 应用类型 (claude/codex/gemini)\n    pub app_type: String,\n    /// 原始配置 JSON\n    pub original_config: String,\n    /// 备份时间\n    pub backed_up_at: String,\n}\n\n/// 全局代理配置（统一字段，三行镜像）\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct GlobalProxyConfig {\n    /// 代理总开关\n    pub proxy_enabled: bool,\n    /// 监听地址\n    pub listen_address: String,\n    /// 监听端口\n    pub listen_port: u16,\n    /// 是否启用日志\n    pub enable_logging: bool,\n}\n\n/// 应用级代理配置（每个 app 独立）\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct AppProxyConfig {\n    /// 应用类型 (claude/codex/gemini)\n    pub app_type: String,\n    /// 该 app 代理启用开关\n    pub enabled: bool,\n    /// 该 app 自动故障转移开关\n    pub auto_failover_enabled: bool,\n    /// 最大重试次数\n    pub max_retries: u32,\n    /// 流式首字超时（秒）\n    pub streaming_first_byte_timeout: u32,\n    /// 流式静默超时（秒）\n    pub streaming_idle_timeout: u32,\n    /// 非流式总超时（秒）\n    pub non_streaming_timeout: u32,\n    /// 熔断失败阈值\n    pub circuit_failure_threshold: u32,\n    /// 熔断恢复阈值\n    pub circuit_success_threshold: u32,\n    /// 熔断恢复等待时间（秒）\n    pub circuit_timeout_seconds: u32,\n    /// 错误率阈值\n    pub circuit_error_rate_threshold: f64,\n    /// 计算错误率的最小请求数\n    pub circuit_min_requests: u32,\n}\n\n/// 整流器配置\n///\n/// 存储在 settings 表中\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct RectifierConfig {\n    /// 总开关：是否启用整流器（默认开启）\n    #[serde(default = \"default_true\")]\n    pub enabled: bool,\n    /// 请求整流：启用 thinking 签名整流器（默认开启）\n    ///\n    /// 处理错误：Invalid 'signature' in 'thinking' block\n    #[serde(default = \"default_true\")]\n    pub request_thinking_signature: bool,\n    /// 请求整流：启用 thinking budget 整流器（默认开启）\n    ///\n    /// 处理错误：budget_tokens + thinking 相关约束\n    #[serde(default = \"default_true\")]\n    pub request_thinking_budget: bool,\n}\n\nfn default_true() -> bool {\n    true\n}\n\nfn default_log_level() -> String {\n    \"info\".to_string()\n}\n\nimpl Default for RectifierConfig {\n    fn default() -> Self {\n        Self {\n            enabled: true,\n            request_thinking_signature: true,\n            request_thinking_budget: true,\n        }\n    }\n}\n\n/// 请求优化器配置\n///\n/// 存储在 settings 表中，key = \"optimizer_config\"\n/// 仅对 Bedrock provider 生效（CLAUDE_CODE_USE_BEDROCK = \"1\"）\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct OptimizerConfig {\n    /// 总开关（默认关闭，用户需手动启用）\n    #[serde(default)]\n    pub enabled: bool,\n    /// Thinking 优化子开关（总开关开启后默认生效）\n    #[serde(default = \"default_true\")]\n    pub thinking_optimizer: bool,\n    /// Cache 注入子开关（总开关开启后默认生效）\n    #[serde(default = \"default_true\")]\n    pub cache_injection: bool,\n    /// Cache TTL: \"5m\" | \"1h\"（默认 \"1h\"）\n    #[serde(default = \"default_cache_ttl\")]\n    pub cache_ttl: String,\n}\n\nfn default_cache_ttl() -> String {\n    \"1h\".to_string()\n}\n\nimpl Default for OptimizerConfig {\n    fn default() -> Self {\n        Self {\n            enabled: false,\n            thinking_optimizer: true,\n            cache_injection: true,\n            cache_ttl: \"1h\".to_string(),\n        }\n    }\n}\n\n/// 日志配置\n///\n/// 存储在 settings 表的 log_config 字段中（JSON 格式）\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct LogConfig {\n    /// 总开关：是否启用日志\n    #[serde(default = \"default_true\")]\n    pub enabled: bool,\n    /// 日志级别: error, warn, info, debug, trace\n    #[serde(default = \"default_log_level\")]\n    pub level: String,\n}\n\nimpl Default for LogConfig {\n    fn default() -> Self {\n        Self {\n            enabled: true,\n            level: \"info\".to_string(),\n        }\n    }\n}\n\nimpl LogConfig {\n    /// 将配置转换为 log::LevelFilter\n    pub fn to_level_filter(&self) -> log::LevelFilter {\n        if !self.enabled {\n            return log::LevelFilter::Off;\n        }\n        match self.level.to_lowercase().as_str() {\n            \"error\" => log::LevelFilter::Error,\n            \"warn\" => log::LevelFilter::Warn,\n            \"info\" => log::LevelFilter::Info,\n            \"debug\" => log::LevelFilter::Debug,\n            \"trace\" => log::LevelFilter::Trace,\n            _ => log::LevelFilter::Info,\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_rectifier_config_default_enabled() {\n        // 验证 RectifierConfig::default() 返回全开启状态\n        let config = RectifierConfig::default();\n        assert!(config.enabled, \"整流器总开关默认应为 true\");\n        assert!(\n            config.request_thinking_signature,\n            \"thinking 签名整流器默认应为 true\"\n        );\n        assert!(\n            config.request_thinking_budget,\n            \"thinking budget 整流器默认应为 true\"\n        );\n    }\n\n    #[test]\n    fn test_rectifier_config_serde_default() {\n        // 验证反序列化缺字段时使用默认值 true\n        let json = \"{}\";\n        let config: RectifierConfig = serde_json::from_str(json).unwrap();\n        assert!(config.enabled);\n        assert!(config.request_thinking_signature);\n        assert!(config.request_thinking_budget);\n    }\n\n    #[test]\n    fn test_rectifier_config_serde_explicit_true() {\n        // 验证显式设置 true 时正确反序列化\n        let json =\n            r#\"{\"enabled\": true, \"requestThinkingSignature\": true, \"requestThinkingBudget\": true}\"#;\n        let config: RectifierConfig = serde_json::from_str(json).unwrap();\n        assert!(config.enabled);\n        assert!(config.request_thinking_signature);\n        assert!(config.request_thinking_budget);\n    }\n\n    #[test]\n    fn test_rectifier_config_serde_partial_fields() {\n        // 验证只设置部分字段时，缺失字段使用默认值 true\n        let json = r#\"{\"enabled\": true, \"requestThinkingSignature\": false}\"#;\n        let config: RectifierConfig = serde_json::from_str(json).unwrap();\n        assert!(config.enabled);\n        assert!(!config.request_thinking_signature);\n        assert!(config.request_thinking_budget);\n    }\n\n    #[test]\n    fn test_log_config_default() {\n        let config = LogConfig::default();\n        assert!(config.enabled);\n        assert_eq!(config.level, \"info\");\n    }\n\n    #[test]\n    fn test_log_config_serde_default() {\n        let json = \"{}\";\n        let config: LogConfig = serde_json::from_str(json).unwrap();\n        assert!(config.enabled);\n        assert_eq!(config.level, \"info\");\n    }\n\n    #[test]\n    fn test_log_config_to_level_filter() {\n        let config = LogConfig {\n            level: \"error\".to_string(),\n            ..Default::default()\n        };\n        assert_eq!(config.to_level_filter(), log::LevelFilter::Error);\n\n        let config = LogConfig {\n            level: \"warn\".to_string(),\n            ..Default::default()\n        };\n        assert_eq!(config.to_level_filter(), log::LevelFilter::Warn);\n\n        let config = LogConfig {\n            level: \"info\".to_string(),\n            ..Default::default()\n        };\n        assert_eq!(config.to_level_filter(), log::LevelFilter::Info);\n\n        let config = LogConfig {\n            level: \"debug\".to_string(),\n            ..Default::default()\n        };\n        assert_eq!(config.to_level_filter(), log::LevelFilter::Debug);\n\n        let config = LogConfig {\n            level: \"trace\".to_string(),\n            ..Default::default()\n        };\n        assert_eq!(config.to_level_filter(), log::LevelFilter::Trace);\n\n        // 无效级别回退到 info\n        let config = LogConfig {\n            level: \"invalid\".to_string(),\n            ..Default::default()\n        };\n        assert_eq!(config.to_level_filter(), log::LevelFilter::Info);\n\n        // 禁用时返回 Off\n        let config = LogConfig {\n            enabled: false,\n            level: \"debug\".to_string(),\n        };\n        assert_eq!(config.to_level_filter(), log::LevelFilter::Off);\n    }\n\n    #[test]\n    fn test_log_config_serde_roundtrip() {\n        let config = LogConfig {\n            enabled: true,\n            level: \"debug\".to_string(),\n        };\n        let json = serde_json::to_string(&config).unwrap();\n        let parsed: LogConfig = serde_json::from_str(&json).unwrap();\n        assert!(parsed.enabled);\n        assert_eq!(parsed.level, \"debug\");\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/usage/calculator.rs",
    "content": "//! Cost Calculator - 计算 API 请求成本\n//!\n//! 使用高精度 Decimal 类型避免浮点数精度问题\n\nuse super::parser::TokenUsage;\nuse rust_decimal::Decimal;\nuse std::str::FromStr;\n\n/// 成本明细\n#[derive(Debug, Clone)]\npub struct CostBreakdown {\n    pub input_cost: Decimal,\n    pub output_cost: Decimal,\n    pub cache_read_cost: Decimal,\n    pub cache_creation_cost: Decimal,\n    pub total_cost: Decimal,\n}\n\n/// 模型定价信息\n#[derive(Debug, Clone)]\npub struct ModelPricing {\n    pub input_cost_per_million: Decimal,\n    pub output_cost_per_million: Decimal,\n    pub cache_read_cost_per_million: Decimal,\n    pub cache_creation_cost_per_million: Decimal,\n}\n\n/// 成本计算器\npub struct CostCalculator;\n\nimpl CostCalculator {\n    /// 计算请求成本\n    ///\n    /// # 参数\n    /// - `usage`: Token 使用量\n    /// - `pricing`: 模型定价\n    /// - `cost_multiplier`: 成本倍数 (provider 自定义)\n    ///\n    /// # 计算逻辑\n    /// - input_cost: (input_tokens - cache_read_tokens) × 输入价格\n    /// - cache_read_cost: cache_read_tokens × 缓存读取价格\n    /// - 这样避免缓存部分被重复计费\n    /// - total_cost: 各项成本之和 × 倍率（倍率只作用于最终总价）\n    pub fn calculate(\n        usage: &TokenUsage,\n        pricing: &ModelPricing,\n        cost_multiplier: Decimal,\n    ) -> CostBreakdown {\n        let million = Decimal::from(1_000_000);\n\n        // 计算实际需要按输入价格计费的 token 数（减去缓存命中部分）\n        let billable_input_tokens = usage.input_tokens.saturating_sub(usage.cache_read_tokens);\n\n        // 各项基础成本（不含倍率）\n        let input_cost =\n            Decimal::from(billable_input_tokens) * pricing.input_cost_per_million / million;\n        let output_cost =\n            Decimal::from(usage.output_tokens) * pricing.output_cost_per_million / million;\n        let cache_read_cost =\n            Decimal::from(usage.cache_read_tokens) * pricing.cache_read_cost_per_million / million;\n        let cache_creation_cost = Decimal::from(usage.cache_creation_tokens)\n            * pricing.cache_creation_cost_per_million\n            / million;\n\n        // 总成本 = 各项基础成本之和 × 倍率\n        let base_total = input_cost + output_cost + cache_read_cost + cache_creation_cost;\n        let total_cost = base_total * cost_multiplier;\n\n        CostBreakdown {\n            input_cost,\n            output_cost,\n            cache_read_cost,\n            cache_creation_cost,\n            total_cost,\n        }\n    }\n\n    /// 尝试计算成本，如果模型未知则返回 None\n    pub fn try_calculate(\n        usage: &TokenUsage,\n        pricing: Option<&ModelPricing>,\n        cost_multiplier: Decimal,\n    ) -> Option<CostBreakdown> {\n        pricing.map(|p| Self::calculate(usage, p, cost_multiplier))\n    }\n}\n\nimpl ModelPricing {\n    /// 从字符串创建定价信息\n    pub fn from_strings(\n        input: &str,\n        output: &str,\n        cache_read: &str,\n        cache_creation: &str,\n    ) -> Result<Self, rust_decimal::Error> {\n        Ok(Self {\n            input_cost_per_million: Decimal::from_str(input)?,\n            output_cost_per_million: Decimal::from_str(output)?,\n            cache_read_cost_per_million: Decimal::from_str(cache_read)?,\n            cache_creation_cost_per_million: Decimal::from_str(cache_creation)?,\n        })\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_cost_calculation() {\n        let usage = TokenUsage {\n            input_tokens: 1000,\n            output_tokens: 500,\n            cache_read_tokens: 200,\n            cache_creation_tokens: 100,\n            model: None,\n        };\n\n        let pricing = ModelPricing::from_strings(\"3.0\", \"15.0\", \"0.3\", \"3.75\").unwrap();\n        let multiplier = Decimal::from_str(\"1.0\").unwrap();\n\n        let cost = CostCalculator::calculate(&usage, &pricing, multiplier);\n\n        // input: (1000 - 200) * 3.0 / 1M = 0.0024 (只计算非缓存部分)\n        assert_eq!(cost.input_cost, Decimal::from_str(\"0.0024\").unwrap());\n        // output: 500 * 15.0 / 1M = 0.0075\n        assert_eq!(cost.output_cost, Decimal::from_str(\"0.0075\").unwrap());\n        // cache_read: 200 * 0.3 / 1M = 0.00006\n        assert_eq!(cost.cache_read_cost, Decimal::from_str(\"0.00006\").unwrap());\n        // cache_creation: 100 * 3.75 / 1M = 0.000375\n        assert_eq!(\n            cost.cache_creation_cost,\n            Decimal::from_str(\"0.000375\").unwrap()\n        );\n        // total: 0.0024 + 0.0075 + 0.00006 + 0.000375 = 0.010335\n        assert_eq!(cost.total_cost, Decimal::from_str(\"0.010335\").unwrap());\n    }\n\n    #[test]\n    fn test_cost_multiplier() {\n        let usage = TokenUsage {\n            input_tokens: 1000,\n            output_tokens: 0,\n            cache_read_tokens: 0,\n            cache_creation_tokens: 0,\n            model: None,\n        };\n\n        let pricing = ModelPricing::from_strings(\"3.0\", \"15.0\", \"0\", \"0\").unwrap();\n        let multiplier = Decimal::from_str(\"1.5\").unwrap();\n\n        let cost = CostCalculator::calculate(&usage, &pricing, multiplier);\n\n        // input_cost: 基础价格（不含倍率）= 1000 * 3.0 / 1M = 0.003\n        assert_eq!(cost.input_cost, Decimal::from_str(\"0.003\").unwrap());\n        // total_cost: 基础价格 × 倍率 = 0.003 * 1.5 = 0.0045\n        assert_eq!(cost.total_cost, Decimal::from_str(\"0.0045\").unwrap());\n    }\n\n    #[test]\n    fn test_unknown_model_handling() {\n        let usage = TokenUsage {\n            input_tokens: 1000,\n            output_tokens: 500,\n            cache_read_tokens: 0,\n            cache_creation_tokens: 0,\n            model: None,\n        };\n\n        let multiplier = Decimal::from_str(\"1.0\").unwrap();\n        let cost = CostCalculator::try_calculate(&usage, None, multiplier);\n\n        assert!(cost.is_none());\n    }\n\n    #[test]\n    fn test_decimal_precision() {\n        let usage = TokenUsage {\n            input_tokens: 1,\n            output_tokens: 1,\n            cache_read_tokens: 1,\n            cache_creation_tokens: 1,\n            model: None,\n        };\n\n        let pricing = ModelPricing::from_strings(\"0.075\", \"0.3\", \"0.01875\", \"0.075\").unwrap();\n        let multiplier = Decimal::from_str(\"1.0\").unwrap();\n\n        let cost = CostCalculator::calculate(&usage, &pricing, multiplier);\n\n        // 验证高精度计算\n        assert!(cost.total_cost > Decimal::ZERO);\n        assert!(cost.total_cost.to_string().len() > 2); // 确保保留了小数位\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/usage/logger.rs",
    "content": "//! Usage Logger - 记录 API 请求使用情况\n\nuse super::calculator::{CostBreakdown, CostCalculator, ModelPricing};\nuse super::parser::TokenUsage;\nuse crate::database::Database;\nuse crate::error::AppError;\nuse crate::services::usage_stats::find_model_pricing_row;\nuse rust_decimal::Decimal;\nuse std::{str::FromStr, time::SystemTime};\n\n/// 请求日志\n#[derive(Debug, Clone)]\npub struct RequestLog {\n    pub request_id: String,\n    pub provider_id: String,\n    pub app_type: String,\n    pub model: String,\n    pub request_model: String,\n    pub usage: TokenUsage,\n    pub cost: Option<CostBreakdown>,\n    pub latency_ms: u64,\n    pub first_token_ms: Option<u64>,\n    pub status_code: u16,\n    pub error_message: Option<String>,\n    pub session_id: Option<String>,\n    /// 供应商类型 (claude, claude_auth, codex, gemini, gemini_cli, openrouter)\n    pub provider_type: Option<String>,\n    /// 是否为流式请求\n    pub is_streaming: bool,\n    /// 成本倍数\n    pub cost_multiplier: String,\n}\n\n/// 使用量记录器\npub struct UsageLogger<'a> {\n    db: &'a Database,\n}\n\nimpl<'a> UsageLogger<'a> {\n    pub fn new(db: &'a Database) -> Self {\n        Self { db }\n    }\n\n    /// 记录成功的请求\n    pub fn log_request(&self, log: &RequestLog) -> Result<(), AppError> {\n        let conn = crate::database::lock_conn!(self.db.conn);\n\n        let (input_cost, output_cost, cache_read_cost, cache_creation_cost, total_cost) =\n            if let Some(cost) = &log.cost {\n                (\n                    cost.input_cost.to_string(),\n                    cost.output_cost.to_string(),\n                    cost.cache_read_cost.to_string(),\n                    cost.cache_creation_cost.to_string(),\n                    cost.total_cost.to_string(),\n                )\n            } else {\n                (\n                    \"0\".to_string(),\n                    \"0\".to_string(),\n                    \"0\".to_string(),\n                    \"0\".to_string(),\n                    \"0\".to_string(),\n                )\n            };\n\n        let created_at = SystemTime::now()\n            .duration_since(SystemTime::UNIX_EPOCH)\n            .map(|d| d.as_secs() as i64)\n            .unwrap_or_else(|e| {\n                log::warn!(\"SystemTime is before UNIX_EPOCH, falling back to 0: {e}\");\n                0\n            });\n\n        conn.execute(\n            \"INSERT INTO proxy_request_logs (\n                request_id, provider_id, app_type, model, request_model,\n                input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,\n                input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,\n                latency_ms, first_token_ms, status_code, error_message, session_id,\n                provider_type, is_streaming, cost_multiplier, created_at\n            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23)\",\n            rusqlite::params![\n                log.request_id,\n                log.provider_id,\n                log.app_type,\n                log.model,\n                log.request_model,\n                log.usage.input_tokens,\n                log.usage.output_tokens,\n                log.usage.cache_read_tokens,\n                log.usage.cache_creation_tokens,\n                input_cost,\n                output_cost,\n                cache_read_cost,\n                cache_creation_cost,\n                total_cost,\n                log.latency_ms as i64,\n                log.first_token_ms.map(|v| v as i64),\n                log.status_code as i64,\n                log.error_message,\n                log.session_id,\n                log.provider_type,\n                log.is_streaming as i64,\n                log.cost_multiplier,\n                created_at,\n            ],\n        )\n        .map_err(|e| AppError::Database(format!(\"记录请求日志失败: {e}\")))?;\n\n        Ok(())\n    }\n\n    /// 记录失败的请求\n    ///\n    /// 用于记录无法从上游获取 usage 信息的失败请求\n    #[allow(dead_code, clippy::too_many_arguments)]\n    pub fn log_error(\n        &self,\n        request_id: String,\n        provider_id: String,\n        app_type: String,\n        model: String,\n        status_code: u16,\n        error_message: String,\n        latency_ms: u64,\n    ) -> Result<(), AppError> {\n        let request_model = model.clone();\n        let log = RequestLog {\n            request_id,\n            provider_id,\n            app_type,\n            model,\n            request_model,\n            usage: TokenUsage::default(),\n            cost: None,\n            latency_ms,\n            first_token_ms: None,\n            status_code,\n            error_message: Some(error_message),\n            session_id: None,\n            provider_type: None,\n            is_streaming: false,\n            cost_multiplier: \"1.0\".to_string(),\n        };\n\n        self.log_request(&log)\n    }\n\n    /// 记录失败的请求（带更多上下文信息）\n    ///\n    /// 相比 log_error，这个方法接受更多参数以提供完整的请求上下文\n    #[allow(clippy::too_many_arguments)]\n    pub fn log_error_with_context(\n        &self,\n        request_id: String,\n        provider_id: String,\n        app_type: String,\n        model: String,\n        status_code: u16,\n        error_message: String,\n        latency_ms: u64,\n        is_streaming: bool,\n        session_id: Option<String>,\n        provider_type: Option<String>,\n    ) -> Result<(), AppError> {\n        let request_model = model.clone();\n        let log = RequestLog {\n            request_id,\n            provider_id,\n            app_type,\n            model,\n            request_model,\n            usage: TokenUsage::default(),\n            cost: None,\n            latency_ms,\n            first_token_ms: None,\n            status_code,\n            error_message: Some(error_message),\n            session_id,\n            provider_type,\n            is_streaming,\n            cost_multiplier: \"1.0\".to_string(),\n        };\n\n        self.log_request(&log)\n    }\n\n    /// 获取模型定价\n    pub fn get_model_pricing(&self, model_id: &str) -> Result<Option<ModelPricing>, AppError> {\n        let conn = crate::database::lock_conn!(self.db.conn);\n        let row = find_model_pricing_row(&conn, model_id)?;\n        match row {\n            Some((input, output, cache_read, cache_creation)) => {\n                ModelPricing::from_strings(&input, &output, &cache_read, &cache_creation)\n                    .map(Some)\n                    .map_err(|e| AppError::Database(format!(\"解析定价数据失败: {e}\")))\n            }\n            None => Ok(None),\n        }\n    }\n\n    /// 获取有效的倍率与计费模式来源（供应商优先，未配置则回退全局默认）\n    pub async fn resolve_pricing_config(\n        &self,\n        provider_id: &str,\n        app_type: &str,\n    ) -> (Decimal, String) {\n        let default_multiplier_raw = match self.db.get_default_cost_multiplier(app_type).await {\n            Ok(value) => value,\n            Err(e) => {\n                log::warn!(\"[USG-003] 获取默认倍率失败 (app_type={app_type}): {e}\");\n                \"1\".to_string()\n            }\n        };\n        let default_multiplier = match Decimal::from_str(&default_multiplier_raw) {\n            Ok(value) => value,\n            Err(e) => {\n                log::warn!(\n                    \"[USG-003] 默认倍率解析失败 (app_type={app_type}): {default_multiplier_raw} - {e}\"\n                );\n                Decimal::from(1)\n            }\n        };\n\n        let default_pricing_source_raw = match self.db.get_pricing_model_source(app_type).await {\n            Ok(value) => value,\n            Err(e) => {\n                log::warn!(\"[USG-003] 获取默认计费模式失败 (app_type={app_type}): {e}\");\n                \"response\".to_string()\n            }\n        };\n        let default_pricing_source =\n            if matches!(default_pricing_source_raw.as_str(), \"response\" | \"request\") {\n                default_pricing_source_raw\n            } else {\n                log::warn!(\n                \"[USG-003] 默认计费模式无效 (app_type={app_type}): {default_pricing_source_raw}\"\n            );\n                \"response\".to_string()\n            };\n\n        let provider = self\n            .db\n            .get_provider_by_id(provider_id, app_type)\n            .ok()\n            .flatten();\n\n        let (provider_multiplier, provider_pricing_source) = provider\n            .as_ref()\n            .and_then(|p| p.meta.as_ref())\n            .map(|meta| {\n                (\n                    meta.cost_multiplier.as_deref(),\n                    meta.pricing_model_source.as_deref(),\n                )\n            })\n            .unwrap_or((None, None));\n\n        let cost_multiplier = match provider_multiplier {\n            Some(value) => match Decimal::from_str(value) {\n                Ok(parsed) => parsed,\n                Err(e) => {\n                    log::warn!(\n                        \"[USG-003] 供应商倍率解析失败 (provider_id={provider_id}): {value} - {e}\"\n                    );\n                    default_multiplier\n                }\n            },\n            None => default_multiplier,\n        };\n\n        let pricing_model_source = match provider_pricing_source {\n            Some(value) if matches!(value, \"response\" | \"request\") => value.to_string(),\n            Some(value) => {\n                log::warn!(\"[USG-003] 供应商计费模式无效 (provider_id={provider_id}): {value}\");\n                default_pricing_source.clone()\n            }\n            None => default_pricing_source.clone(),\n        };\n\n        (cost_multiplier, pricing_model_source)\n    }\n\n    /// 计算并记录请求\n    #[allow(clippy::too_many_arguments)]\n    pub fn log_with_calculation(\n        &self,\n        request_id: String,\n        provider_id: String,\n        app_type: String,\n        model: String,\n        request_model: String,\n        pricing_model: String,\n        usage: TokenUsage,\n        cost_multiplier: Decimal,\n        latency_ms: u64,\n        first_token_ms: Option<u64>,\n        status_code: u16,\n        session_id: Option<String>,\n        provider_type: Option<String>,\n        is_streaming: bool,\n    ) -> Result<(), AppError> {\n        let pricing = self.get_model_pricing(&pricing_model)?;\n\n        if pricing.is_none() {\n            log::warn!(\"[USG-002] 模型定价未找到，成本将记录为 0: {pricing_model}\");\n        }\n\n        let cost = CostCalculator::try_calculate(&usage, pricing.as_ref(), cost_multiplier);\n\n        let log = RequestLog {\n            request_id,\n            provider_id,\n            app_type,\n            model,\n            request_model,\n            usage,\n            cost,\n            latency_ms,\n            first_token_ms,\n            status_code,\n            error_message: None,\n            session_id,\n            provider_type,\n            is_streaming,\n            cost_multiplier: cost_multiplier.to_string(),\n        };\n\n        self.log_request(&log)\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_log_request() -> Result<(), AppError> {\n        let db = Database::memory()?;\n\n        // 插入测试定价\n        {\n            let conn = crate::database::lock_conn!(db.conn);\n            conn.execute(\n                \"INSERT INTO model_pricing (model_id, display_name, input_cost_per_million, output_cost_per_million)\n                 VALUES ('test-model', 'Test Model', '3.0', '15.0')\",\n                [],\n            )\n            .unwrap();\n        }\n\n        let logger = UsageLogger::new(&db);\n\n        let usage = TokenUsage {\n            input_tokens: 1000,\n            output_tokens: 500,\n            cache_read_tokens: 0,\n            cache_creation_tokens: 0,\n            model: None,\n        };\n\n        logger.log_with_calculation(\n            \"req-123\".to_string(),\n            \"provider-1\".to_string(),\n            \"claude\".to_string(),\n            \"test-model\".to_string(),\n            \"req-model\".to_string(),\n            \"test-model\".to_string(),\n            usage,\n            Decimal::from(1),\n            100,\n            None,\n            200,\n            None,\n            Some(\"claude\".to_string()),\n            false,\n        )?;\n\n        // 验证记录已插入\n        let conn = crate::database::lock_conn!(db.conn);\n        let (count, request_model): (i64, String) = conn\n            .query_row(\n                \"SELECT COUNT(*), request_model FROM proxy_request_logs WHERE request_id = 'req-123'\",\n                [],\n                |row| Ok((row.get(0)?, row.get(1)?)),\n            )\n            .unwrap();\n        assert_eq!(count, 1);\n        assert_eq!(request_model, \"req-model\");\n        Ok(())\n    }\n\n    #[test]\n    fn test_log_error() -> Result<(), AppError> {\n        let db = Database::memory()?;\n        let logger = UsageLogger::new(&db);\n\n        logger.log_error(\n            \"req-error\".to_string(),\n            \"provider-1\".to_string(),\n            \"claude\".to_string(),\n            \"unknown-model\".to_string(),\n            500,\n            \"Internal Server Error\".to_string(),\n            50,\n        )?;\n\n        // 验证错误记录已插入\n        let conn = crate::database::lock_conn!(db.conn);\n        let (status, error): (i64, Option<String>) = conn\n            .query_row(\n                \"SELECT status_code, error_message FROM proxy_request_logs WHERE request_id = 'req-error'\",\n                [],\n                |row| Ok((row.get(0)?, row.get(1)?)),\n            )\n            .unwrap();\n        assert_eq!(status, 500);\n        assert_eq!(error, Some(\"Internal Server Error\".to_string()));\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/proxy/usage/mod.rs",
    "content": "//! Proxy Usage Tracking Module\n//!\n//! 提供 API 请求的使用量跟踪、成本计算和日志记录功能\n\npub mod calculator;\npub mod logger;\npub mod parser;\n\n// 仅导出内部使用的类型,避免未使用警告\n#[allow(unused_imports)]\npub use calculator::{CostBreakdown, CostCalculator, ModelPricing};\n#[allow(unused_imports)]\npub use logger::{RequestLog, UsageLogger};\n#[allow(unused_imports)]\npub use parser::{ApiType, TokenUsage};\n"
  },
  {
    "path": "src-tauri/src/proxy/usage/parser.rs",
    "content": "//! Response Parser - 从 API 响应中提取 token 使用量\n//!\n//! 支持多种 API 格式：\n//! - Claude API (非流式和流式)\n//! - OpenRouter (OpenAI 格式)\n//! - Codex API (非流式和流式)\n//! - Gemini API (非流式和流式)\n\nuse serde::{Deserialize, Serialize};\nuse serde_json::Value;\n\n/// Token 使用量统计\n#[derive(Debug, Clone, Default, Serialize, Deserialize)]\npub struct TokenUsage {\n    pub input_tokens: u32,\n    pub output_tokens: u32,\n    pub cache_read_tokens: u32,\n    pub cache_creation_tokens: u32,\n    /// 从响应中提取的实际模型名称（如果可用）\n    pub model: Option<String>,\n}\n\n/// API 类型\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\n#[allow(dead_code)]\npub enum ApiType {\n    Claude,\n    OpenRouter,\n    Codex,\n    Gemini,\n}\n\nimpl TokenUsage {\n    /// 从 Claude API 非流式响应解析\n    pub fn from_claude_response(body: &Value) -> Option<Self> {\n        let usage = body.get(\"usage\")?;\n        // 提取响应中的模型名称\n        let model = body\n            .get(\"model\")\n            .and_then(|v| v.as_str())\n            .map(|s| s.to_string());\n\n        Some(Self {\n            input_tokens: usage.get(\"input_tokens\")?.as_u64()? as u32,\n            output_tokens: usage.get(\"output_tokens\")?.as_u64()? as u32,\n            cache_read_tokens: usage\n                .get(\"cache_read_input_tokens\")\n                .and_then(|v| v.as_u64())\n                .unwrap_or(0) as u32,\n            cache_creation_tokens: usage\n                .get(\"cache_creation_input_tokens\")\n                .and_then(|v| v.as_u64())\n                .unwrap_or(0) as u32,\n            model,\n        })\n    }\n\n    /// 从 Claude API 流式响应解析\n    #[allow(dead_code)]\n    pub fn from_claude_stream_events(events: &[Value]) -> Option<Self> {\n        let mut usage = Self::default();\n        let mut model: Option<String> = None;\n\n        for event in events {\n            if let Some(event_type) = event.get(\"type\").and_then(|v| v.as_str()) {\n                match event_type {\n                    \"message_start\" => {\n                        // 从 message_start 提取模型名称\n                        if model.is_none() {\n                            if let Some(message) = event.get(\"message\") {\n                                if let Some(m) = message.get(\"model\").and_then(|v| v.as_str()) {\n                                    model = Some(m.to_string());\n                                }\n                            }\n                        }\n                        if let Some(msg_usage) = event.get(\"message\").and_then(|m| m.get(\"usage\")) {\n                            // 从 message_start 获取 input_tokens（原生 Claude API）\n                            if let Some(input) =\n                                msg_usage.get(\"input_tokens\").and_then(|v| v.as_u64())\n                            {\n                                usage.input_tokens = input as u32;\n                            }\n                            usage.cache_read_tokens = msg_usage\n                                .get(\"cache_read_input_tokens\")\n                                .and_then(|v| v.as_u64())\n                                .unwrap_or(0)\n                                as u32;\n                            usage.cache_creation_tokens = msg_usage\n                                .get(\"cache_creation_input_tokens\")\n                                .and_then(|v| v.as_u64())\n                                .unwrap_or(0)\n                                as u32;\n                        }\n                    }\n                    \"message_delta\" => {\n                        if let Some(delta_usage) = event.get(\"usage\") {\n                            // 从 message_delta 获取 output_tokens\n                            if let Some(output) =\n                                delta_usage.get(\"output_tokens\").and_then(|v| v.as_u64())\n                            {\n                                usage.output_tokens = output as u32;\n                            }\n                            // OpenRouter 转换后的流式响应：input_tokens 也在 message_delta 中\n                            // 如果 message_start 中没有 input_tokens，则从 message_delta 获取\n                            if usage.input_tokens == 0 {\n                                if let Some(input) =\n                                    delta_usage.get(\"input_tokens\").and_then(|v| v.as_u64())\n                                {\n                                    usage.input_tokens = input as u32;\n                                }\n                            }\n                            // 从 message_delta 中处理缓存命中(cache_read_input_tokens)\n                            if usage.cache_read_tokens == 0 {\n                                if let Some(cache_read) = delta_usage\n                                    .get(\"cache_read_input_tokens\")\n                                    .and_then(|v| v.as_u64())\n                                {\n                                    usage.cache_read_tokens = cache_read as u32;\n                                }\n                            }\n                            // 从 message_delta 中处理缓存创建(cache_creation_input_tokens)\n                            // 注: 现在 zhipu 没有返回 cache_creation_input_tokens 字段\n                            if usage.cache_creation_tokens == 0 {\n                                if let Some(cache_creation) = delta_usage\n                                    .get(\"cache_creation_input_tokens\")\n                                    .and_then(|v| v.as_u64())\n                                {\n                                    usage.cache_creation_tokens = cache_creation as u32;\n                                }\n                            }\n                        }\n                    }\n                    _ => {}\n                }\n            }\n        }\n\n        if usage.input_tokens > 0 || usage.output_tokens > 0 {\n            usage.model = model;\n            Some(usage)\n        } else {\n            None\n        }\n    }\n\n    /// 从 OpenRouter 响应解析 (OpenAI 格式)\n    #[allow(dead_code)]\n    pub fn from_openrouter_response(body: &Value) -> Option<Self> {\n        let usage = body.get(\"usage\")?;\n        Some(Self {\n            input_tokens: usage.get(\"prompt_tokens\")?.as_u64()? as u32,\n            output_tokens: usage.get(\"completion_tokens\")?.as_u64()? as u32,\n            cache_read_tokens: 0,\n            cache_creation_tokens: 0,\n            model: None,\n        })\n    }\n\n    /// 从 Codex API 非流式响应解析\n    pub fn from_codex_response(body: &Value) -> Option<Self> {\n        let usage = body.get(\"usage\");\n        if usage.is_none() {\n            log::debug!(\n                \"[Codex] 响应中没有 usage 字段，body keys: {:?}\",\n                body.as_object().map(|o| o.keys().collect::<Vec<_>>())\n            );\n            return None;\n        }\n        let usage = usage?;\n\n        let input_tokens = usage.get(\"input_tokens\").and_then(|v| v.as_u64());\n        let output_tokens = usage.get(\"output_tokens\").and_then(|v| v.as_u64());\n\n        if input_tokens.is_none() || output_tokens.is_none() {\n            log::debug!(\"[Codex] usage 字段缺少 input_tokens 或 output_tokens，usage: {usage:?}\");\n            return None;\n        }\n\n        // 提取响应中的模型名称\n        let model = body\n            .get(\"model\")\n            .and_then(|v| v.as_str())\n            .map(|s| s.to_string());\n\n        let cached_tokens = usage\n            .get(\"cache_read_input_tokens\")\n            .and_then(|v| v.as_u64())\n            .or_else(|| {\n                usage\n                    .get(\"input_tokens_details\")\n                    .and_then(|d| d.get(\"cached_tokens\"))\n                    .and_then(|v| v.as_u64())\n            })\n            .unwrap_or(0) as u32;\n\n        Some(Self {\n            input_tokens: input_tokens? as u32,\n            output_tokens: output_tokens? as u32,\n            cache_read_tokens: cached_tokens,\n            cache_creation_tokens: usage\n                .get(\"cache_creation_input_tokens\")\n                .and_then(|v| v.as_u64())\n                .unwrap_or(0) as u32,\n            model,\n        })\n    }\n\n    /// 从 Codex API 响应解析并调整 input_tokens\n    ///\n    /// Codex 的 input_tokens 需要减去 cached_tokens 以获得实际计费的 token 数\n    /// 公式: adjusted_input = max(input_tokens - cached_tokens, 0)\n    #[allow(dead_code)]\n    pub fn from_codex_response_adjusted(body: &Value) -> Option<Self> {\n        let usage = body.get(\"usage\")?;\n        let input_tokens = usage.get(\"input_tokens\")?.as_u64()? as u32;\n        let output_tokens = usage.get(\"output_tokens\")?.as_u64()? as u32;\n\n        // 获取 cached_tokens (可能在 cache_read_input_tokens 或 input_tokens_details 中)\n        let cached_tokens = usage\n            .get(\"cache_read_input_tokens\")\n            .and_then(|v| v.as_u64())\n            .or_else(|| {\n                usage\n                    .get(\"input_tokens_details\")\n                    .and_then(|d| d.get(\"cached_tokens\"))\n                    .and_then(|v| v.as_u64())\n            })\n            .unwrap_or(0) as u32;\n\n        // 调整 input_tokens: 减去 cached_tokens\n        let adjusted_input = input_tokens.saturating_sub(cached_tokens);\n\n        // 提取响应中的模型名称\n        let model = body\n            .get(\"model\")\n            .and_then(|v| v.as_str())\n            .map(|s| s.to_string());\n\n        Some(Self {\n            input_tokens: adjusted_input,\n            output_tokens,\n            cache_read_tokens: cached_tokens,\n            cache_creation_tokens: usage\n                .get(\"cache_creation_input_tokens\")\n                .and_then(|v| v.as_u64())\n                .unwrap_or(0) as u32,\n            model,\n        })\n    }\n\n    /// 从 Codex API 流式响应解析\n    #[allow(dead_code)]\n    pub fn from_codex_stream_events(events: &[Value]) -> Option<Self> {\n        log::debug!(\"[Codex] 解析流式事件，共 {} 个事件\", events.len());\n        for event in events {\n            if let Some(event_type) = event.get(\"type\").and_then(|v| v.as_str()) {\n                log::debug!(\"[Codex] 事件类型: {event_type}\");\n                if event_type == \"response.completed\" {\n                    if let Some(response) = event.get(\"response\") {\n                        log::debug!(\"[Codex] 找到 response.completed 事件，解析 usage\");\n                        return Self::from_codex_response_adjusted(response);\n                    }\n                }\n            }\n        }\n        log::debug!(\"[Codex] 未找到 response.completed 事件\");\n        None\n    }\n\n    /// 智能 Codex 响应解析 - 自动检测 OpenAI 或 Codex 格式\n    ///\n    /// Codex 支持两种 API 格式：\n    /// - `/v1/responses`: 使用 input_tokens/output_tokens\n    /// - `/v1/chat/completions`: 使用 prompt_tokens/completion_tokens (OpenAI 格式)\n    ///\n    /// 注意：记录原始 input_tokens，费用计算时再减去 cached_tokens\n    pub fn from_codex_response_auto(body: &Value) -> Option<Self> {\n        let usage = body.get(\"usage\")?;\n\n        // 检测格式：OpenAI 使用 prompt_tokens，Codex 使用 input_tokens\n        if usage.get(\"prompt_tokens\").is_some() {\n            log::debug!(\"[Codex] 检测到 OpenAI 格式 (prompt_tokens)\");\n            Self::from_openai_response(body)\n        } else if usage.get(\"input_tokens\").is_some() {\n            log::debug!(\"[Codex] 检测到 Codex 格式 (input_tokens)\");\n            // 使用非调整版本，记录原始 input_tokens\n            Self::from_codex_response(body)\n        } else {\n            log::debug!(\"[Codex] 无法识别响应格式，usage: {usage:?}\");\n            None\n        }\n    }\n\n    /// 智能 Codex 流式响应解析 - 自动检测 OpenAI 或 Codex 格式\n    pub fn from_codex_stream_events_auto(events: &[Value]) -> Option<Self> {\n        log::debug!(\"[Codex] 智能解析流式事件，共 {} 个事件\", events.len());\n\n        // 先尝试 Codex Responses API 格式 (response.completed 事件)\n        for event in events {\n            if let Some(event_type) = event.get(\"type\").and_then(|v| v.as_str()) {\n                if event_type == \"response.completed\" {\n                    if let Some(response) = event.get(\"response\") {\n                        log::debug!(\"[Codex] 找到 response.completed 事件\");\n                        return Self::from_codex_response_auto(response);\n                    }\n                }\n            }\n        }\n\n        // 回退到 OpenAI Chat Completions 格式 (最后一个 chunk 包含 usage)\n        log::debug!(\"[Codex] 尝试 OpenAI 流式格式\");\n        Self::from_openai_stream_events(events)\n    }\n\n    /// 从 OpenAI Chat Completions API 响应解析 (prompt_tokens, completion_tokens)\n    pub fn from_openai_response(body: &Value) -> Option<Self> {\n        let usage = body.get(\"usage\")?;\n\n        // OpenAI 使用 prompt_tokens 和 completion_tokens\n        let prompt_tokens = usage.get(\"prompt_tokens\").and_then(|v| v.as_u64())?;\n        let completion_tokens = usage.get(\"completion_tokens\").and_then(|v| v.as_u64())?;\n\n        // 获取 cached_tokens (可能在 prompt_tokens_details 中)\n        let cached_tokens = usage\n            .get(\"prompt_tokens_details\")\n            .and_then(|d| d.get(\"cached_tokens\"))\n            .and_then(|v| v.as_u64())\n            .unwrap_or(0) as u32;\n\n        // 提取响应中的模型名称\n        let model = body\n            .get(\"model\")\n            .and_then(|v| v.as_str())\n            .map(|s| s.to_string());\n\n        Some(Self {\n            input_tokens: prompt_tokens as u32,\n            output_tokens: completion_tokens as u32,\n            cache_read_tokens: cached_tokens,\n            cache_creation_tokens: 0,\n            model,\n        })\n    }\n\n    /// 从 OpenAI Chat Completions API 流式响应解析\n    pub fn from_openai_stream_events(events: &[Value]) -> Option<Self> {\n        log::debug!(\"[Codex] 解析 OpenAI 流式事件，共 {} 个事件\", events.len());\n        // OpenAI 流式响应在最后一个 chunk 中包含 usage\n        for event in events.iter().rev() {\n            if let Some(usage) = event.get(\"usage\") {\n                if !usage.is_null() {\n                    log::debug!(\"[Codex] 找到 usage: {usage:?}\");\n                    return Self::from_openai_response(event);\n                }\n            }\n        }\n        log::debug!(\"[Codex] 未找到 usage 信息\");\n        None\n    }\n\n    /// 从 Gemini API 非流式响应解析\n    pub fn from_gemini_response(body: &Value) -> Option<Self> {\n        let usage = body.get(\"usageMetadata\")?;\n        // 提取实际使用的模型名称（modelVersion 字段）\n        let model = body\n            .get(\"modelVersion\")\n            .and_then(|v| v.as_str())\n            .map(|s| s.to_string());\n\n        let prompt_tokens = usage.get(\"promptTokenCount\")?.as_u64()? as u32;\n        let total_tokens = usage.get(\"totalTokenCount\")?.as_u64()? as u32;\n\n        // 输出 tokens = 总 tokens - 输入 tokens\n        // 这包含了 candidatesTokenCount + thoughtsTokenCount\n        let output_tokens = total_tokens.saturating_sub(prompt_tokens);\n\n        Some(Self {\n            input_tokens: prompt_tokens,\n            output_tokens,\n            cache_read_tokens: usage\n                .get(\"cachedContentTokenCount\")\n                .and_then(|v| v.as_u64())\n                .unwrap_or(0) as u32,\n            cache_creation_tokens: 0,\n            model,\n        })\n    }\n\n    /// 从 Gemini API 流式响应解析\n    #[allow(dead_code)]\n    pub fn from_gemini_stream_chunks(chunks: &[Value]) -> Option<Self> {\n        let mut total_input = 0u32;\n        let mut total_tokens = 0u32;\n        let mut total_cache_read = 0u32;\n        let mut model: Option<String> = None;\n\n        for chunk in chunks {\n            if let Some(usage) = chunk.get(\"usageMetadata\") {\n                // 输入 tokens (通常在所有 chunk 中保持不变)\n                total_input = usage\n                    .get(\"promptTokenCount\")\n                    .and_then(|v| v.as_u64())\n                    .unwrap_or(0) as u32;\n\n                // 总 tokens (包含输入 + 输出 + 思考)\n                total_tokens = usage\n                    .get(\"totalTokenCount\")\n                    .and_then(|v| v.as_u64())\n                    .unwrap_or(0) as u32;\n\n                // 缓存读取 tokens\n                total_cache_read = usage\n                    .get(\"cachedContentTokenCount\")\n                    .and_then(|v| v.as_u64())\n                    .unwrap_or(0) as u32;\n            }\n\n            // 提取实际使用的模型名称（modelVersion 字段）\n            if model.is_none() {\n                if let Some(model_version) = chunk.get(\"modelVersion\").and_then(|v| v.as_str()) {\n                    model = Some(model_version.to_string());\n                }\n            }\n        }\n\n        // 输出 tokens = 总 tokens - 输入 tokens\n        let total_output = total_tokens.saturating_sub(total_input);\n\n        if total_input > 0 || total_output > 0 {\n            Some(Self {\n                input_tokens: total_input,\n                output_tokens: total_output,\n                cache_read_tokens: total_cache_read,\n                cache_creation_tokens: 0,\n                model,\n            })\n        } else {\n            None\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use serde_json::json;\n\n    #[test]\n    fn test_claude_response_parsing() {\n        let response = json!({\n            \"model\": \"claude-sonnet-4-20250514\",\n            \"usage\": {\n                \"input_tokens\": 100,\n                \"output_tokens\": 50,\n                \"cache_read_input_tokens\": 20,\n                \"cache_creation_input_tokens\": 10\n            }\n        });\n\n        let usage = TokenUsage::from_claude_response(&response).unwrap();\n        assert_eq!(usage.input_tokens, 100);\n        assert_eq!(usage.output_tokens, 50);\n        assert_eq!(usage.cache_read_tokens, 20);\n        assert_eq!(usage.cache_creation_tokens, 10);\n        assert_eq!(usage.model, Some(\"claude-sonnet-4-20250514\".to_string()));\n    }\n\n    #[test]\n    fn test_claude_response_parsing_no_model() {\n        let response = json!({\n            \"usage\": {\n                \"input_tokens\": 100,\n                \"output_tokens\": 50,\n                \"cache_read_input_tokens\": 20,\n                \"cache_creation_input_tokens\": 10\n            }\n        });\n\n        let usage = TokenUsage::from_claude_response(&response).unwrap();\n        assert_eq!(usage.input_tokens, 100);\n        assert_eq!(usage.output_tokens, 50);\n        assert_eq!(usage.cache_read_tokens, 20);\n        assert_eq!(usage.cache_creation_tokens, 10);\n        assert_eq!(usage.model, None);\n    }\n\n    #[test]\n    fn test_claude_stream_parsing() {\n        let events = vec![\n            json!({\n                \"type\": \"message_start\",\n                \"message\": {\n                    \"model\": \"claude-sonnet-4-20250514\",\n                    \"usage\": {\n                        \"input_tokens\": 100,\n                        \"cache_read_input_tokens\": 20,\n                        \"cache_creation_input_tokens\": 10\n                    }\n                }\n            }),\n            json!({\n                \"type\": \"message_delta\",\n                \"usage\": {\n                    \"output_tokens\": 50\n                }\n            }),\n        ];\n\n        let usage = TokenUsage::from_claude_stream_events(&events).unwrap();\n        assert_eq!(usage.input_tokens, 100);\n        assert_eq!(usage.output_tokens, 50);\n        assert_eq!(usage.cache_read_tokens, 20);\n        assert_eq!(usage.cache_creation_tokens, 10);\n        assert_eq!(usage.model, Some(\"claude-sonnet-4-20250514\".to_string()));\n    }\n\n    #[test]\n    fn test_claude_stream_parsing_no_model() {\n        let events = vec![\n            json!({\n                \"type\": \"message_start\",\n                \"message\": {\n                    \"usage\": {\n                        \"input_tokens\": 100,\n                        \"cache_read_input_tokens\": 20,\n                        \"cache_creation_input_tokens\": 10\n                    }\n                }\n            }),\n            json!({\n                \"type\": \"message_delta\",\n                \"usage\": {\n                    \"output_tokens\": 50\n                }\n            }),\n        ];\n\n        let usage = TokenUsage::from_claude_stream_events(&events).unwrap();\n        assert_eq!(usage.input_tokens, 100);\n        assert_eq!(usage.output_tokens, 50);\n        assert_eq!(usage.cache_read_tokens, 20);\n        assert_eq!(usage.cache_creation_tokens, 10);\n        assert_eq!(usage.model, None);\n    }\n\n    #[test]\n    fn test_openrouter_response_parsing() {\n        let response = json!({\n            \"usage\": {\n                \"prompt_tokens\": 100,\n                \"completion_tokens\": 50\n            }\n        });\n\n        let usage = TokenUsage::from_openrouter_response(&response).unwrap();\n        assert_eq!(usage.input_tokens, 100);\n        assert_eq!(usage.output_tokens, 50);\n        assert_eq!(usage.cache_read_tokens, 0);\n        assert_eq!(usage.cache_creation_tokens, 0);\n    }\n\n    #[test]\n    fn test_gemini_response_parsing() {\n        let response = json!({\n            \"modelVersion\": \"gemini-3-pro-high\",\n            \"usageMetadata\": {\n                \"promptTokenCount\": 8383,\n                \"candidatesTokenCount\": 50,\n                \"thoughtsTokenCount\": 114,\n                \"totalTokenCount\": 8547,\n                \"cachedContentTokenCount\": 20\n            }\n        });\n\n        let usage = TokenUsage::from_gemini_response(&response).unwrap();\n        assert_eq!(usage.input_tokens, 8383);\n        // output_tokens = totalTokenCount - promptTokenCount = 8547 - 8383 = 164\n        assert_eq!(usage.output_tokens, 164);\n        assert_eq!(usage.cache_read_tokens, 20);\n        assert_eq!(usage.cache_creation_tokens, 0);\n        assert_eq!(usage.model, Some(\"gemini-3-pro-high\".to_string()));\n    }\n\n    #[test]\n    fn test_gemini_response_parsing_no_model() {\n        // 测试没有 modelVersion 字段的情况\n        let response = json!({\n            \"usageMetadata\": {\n                \"promptTokenCount\": 100,\n                \"totalTokenCount\": 150,\n                \"cachedContentTokenCount\": 20\n            }\n        });\n\n        let usage = TokenUsage::from_gemini_response(&response).unwrap();\n        assert_eq!(usage.input_tokens, 100);\n        // output_tokens = totalTokenCount - promptTokenCount = 150 - 100 = 50\n        assert_eq!(usage.output_tokens, 50);\n        assert_eq!(usage.cache_read_tokens, 20);\n        assert_eq!(usage.cache_creation_tokens, 0);\n        assert_eq!(usage.model, None);\n    }\n\n    #[test]\n    fn test_gemini_response_with_thoughts() {\n        // 测试包含 thoughtsTokenCount 的实际响应\n        // 这是用户报告的真实场景\n        let response = json!({\n            \"candidates\": [\n                {\n                    \"content\": {\n                        \"parts\": [\n                            {\n                                \"text\": \"\",\n                                \"thoughtSignature\": \"EvcECvQE...\"\n                            }\n                        ],\n                        \"role\": \"model\"\n                    },\n                    \"finishReason\": \"STOP\"\n                }\n            ],\n            \"modelVersion\": \"gemini-3-pro-high\",\n            \"responseId\": \"yupTafqLDu-PjMcPhrOx4QQ\",\n            \"usageMetadata\": {\n                \"candidatesTokenCount\": 50,\n                \"promptTokenCount\": 8383,\n                \"thoughtsTokenCount\": 114,\n                \"totalTokenCount\": 8547\n            }\n        });\n\n        let usage = TokenUsage::from_gemini_response(&response).unwrap();\n        assert_eq!(usage.input_tokens, 8383);\n        // output_tokens = totalTokenCount - promptTokenCount\n        // = 8547 - 8383 = 164 (包含 candidatesTokenCount 50 + thoughtsTokenCount 114)\n        assert_eq!(usage.output_tokens, 164);\n        assert_eq!(usage.cache_read_tokens, 0);\n        assert_eq!(usage.cache_creation_tokens, 0);\n        assert_eq!(usage.model, Some(\"gemini-3-pro-high\".to_string()));\n    }\n\n    #[test]\n    fn test_codex_response_parsing_cached_tokens_in_details() {\n        let response = json!({\n            \"usage\": {\n                \"input_tokens\": 1000,\n                \"output_tokens\": 500,\n                \"input_tokens_details\": {\n                    \"cached_tokens\": 300\n                }\n            }\n        });\n\n        let usage = TokenUsage::from_codex_response(&response).unwrap();\n        // 非调整模式：input_tokens 保持原值，但应记录缓存命中\n        assert_eq!(usage.input_tokens, 1000);\n        assert_eq!(usage.output_tokens, 500);\n        assert_eq!(usage.cache_read_tokens, 300);\n    }\n\n    #[test]\n    fn test_codex_response_adjusted() {\n        let response = json!({\n            \"usage\": {\n                \"input_tokens\": 1000,\n                \"output_tokens\": 500,\n                \"input_tokens_details\": {\n                    \"cached_tokens\": 300\n                }\n            }\n        });\n\n        let usage = TokenUsage::from_codex_response_adjusted(&response).unwrap();\n        // input_tokens 应该被调整: 1000 - 300 = 700\n        assert_eq!(usage.input_tokens, 700);\n        assert_eq!(usage.output_tokens, 500);\n        assert_eq!(usage.cache_read_tokens, 300);\n    }\n\n    #[test]\n    fn test_codex_response_adjusted_no_cache() {\n        let response = json!({\n            \"usage\": {\n                \"input_tokens\": 1000,\n                \"output_tokens\": 500\n            }\n        });\n\n        let usage = TokenUsage::from_codex_response_adjusted(&response).unwrap();\n        // 没有 cached_tokens，input_tokens 保持不变\n        assert_eq!(usage.input_tokens, 1000);\n        assert_eq!(usage.output_tokens, 500);\n        assert_eq!(usage.cache_read_tokens, 0);\n    }\n\n    #[test]\n    fn test_codex_response_adjusted_cache_read_input_tokens() {\n        let response = json!({\n            \"usage\": {\n                \"input_tokens\": 1000,\n                \"output_tokens\": 500,\n                \"cache_read_input_tokens\": 200\n            }\n        });\n\n        let usage = TokenUsage::from_codex_response_adjusted(&response).unwrap();\n        assert_eq!(usage.input_tokens, 800);\n        assert_eq!(usage.output_tokens, 500);\n        assert_eq!(usage.cache_read_tokens, 200);\n    }\n\n    #[test]\n    fn test_codex_response_adjusted_saturating_sub() {\n        // 测试 cached_tokens > input_tokens 的边界情况\n        let response = json!({\n            \"usage\": {\n                \"input_tokens\": 100,\n                \"output_tokens\": 50,\n                \"input_tokens_details\": {\n                    \"cached_tokens\": 200\n                }\n            }\n        });\n\n        let usage = TokenUsage::from_codex_response_adjusted(&response).unwrap();\n        // saturating_sub 确保不会下溢\n        assert_eq!(usage.input_tokens, 0);\n        assert_eq!(usage.cache_read_tokens, 200);\n    }\n\n    #[test]\n    fn test_openrouter_stream_parsing() {\n        // 测试 OpenRouter 转换后的流式响应解析\n        // OpenRouter 流式响应经过转换后，input_tokens 在 message_delta 中\n        let events = vec![\n            json!({\n                \"type\": \"message_start\",\n                \"message\": {\n                    \"model\": \"claude-sonnet-4-20250514\",\n                    \"usage\": {\n                        \"input_tokens\": 0,\n                        \"output_tokens\": 0\n                    }\n                }\n            }),\n            json!({\n                \"type\": \"message_delta\",\n                \"delta\": {\n                    \"stop_reason\": \"end_turn\"\n                },\n                \"usage\": {\n                    \"input_tokens\": 150,\n                    \"output_tokens\": 75\n                }\n            }),\n        ];\n\n        let usage = TokenUsage::from_claude_stream_events(&events).unwrap();\n        assert_eq!(usage.input_tokens, 150);\n        assert_eq!(usage.output_tokens, 75);\n        assert_eq!(usage.model, Some(\"claude-sonnet-4-20250514\".to_string()));\n    }\n\n    #[test]\n    fn test_native_claude_stream_parsing() {\n        // 测试原生 Claude API 流式响应解析\n        // 原生 Claude API 的 input_tokens 在 message_start 中\n        let events = vec![\n            json!({\n                \"type\": \"message_start\",\n                \"message\": {\n                    \"model\": \"claude-sonnet-4-20250514\",\n                    \"usage\": {\n                        \"input_tokens\": 200,\n                        \"cache_read_input_tokens\": 50\n                    }\n                }\n            }),\n            json!({\n                \"type\": \"message_delta\",\n                \"usage\": {\n                    \"output_tokens\": 100\n                }\n            }),\n        ];\n\n        let usage = TokenUsage::from_claude_stream_events(&events).unwrap();\n        assert_eq!(usage.input_tokens, 200);\n        assert_eq!(usage.output_tokens, 100);\n        assert_eq!(usage.cache_read_tokens, 50);\n        assert_eq!(usage.model, Some(\"claude-sonnet-4-20250514\".to_string()));\n    }\n\n    // ============================================================================\n    // 智能 Codex 解析测试\n    // ============================================================================\n\n    #[test]\n    fn test_codex_response_auto_openai_format() {\n        // OpenAI 格式 (prompt_tokens/completion_tokens)\n        let response = json!({\n            \"model\": \"gpt-4o\",\n            \"usage\": {\n                \"prompt_tokens\": 1000,\n                \"completion_tokens\": 500,\n                \"prompt_tokens_details\": {\n                    \"cached_tokens\": 200\n                }\n            }\n        });\n\n        let usage = TokenUsage::from_codex_response_auto(&response).unwrap();\n        assert_eq!(usage.input_tokens, 1000);\n        assert_eq!(usage.output_tokens, 500);\n        assert_eq!(usage.cache_read_tokens, 200);\n        assert_eq!(usage.model, Some(\"gpt-4o\".to_string()));\n    }\n\n    #[test]\n    fn test_codex_response_auto_codex_format() {\n        // Codex 格式 (input_tokens/output_tokens)\n        let response = json!({\n            \"model\": \"o3\",\n            \"usage\": {\n                \"input_tokens\": 1000,\n                \"output_tokens\": 500,\n                \"input_tokens_details\": {\n                    \"cached_tokens\": 300\n                }\n            }\n        });\n\n        let usage = TokenUsage::from_codex_response_auto(&response).unwrap();\n        // 记录原始 input_tokens，不调整\n        assert_eq!(usage.input_tokens, 1000);\n        assert_eq!(usage.output_tokens, 500);\n        assert_eq!(usage.cache_read_tokens, 300);\n        assert_eq!(usage.model, Some(\"o3\".to_string()));\n    }\n\n    #[test]\n    fn test_codex_stream_events_auto_codex_format() {\n        // Codex Responses API 流式格式 (response.completed 事件)\n        let events = vec![\n            json!({\n                \"type\": \"response.created\",\n                \"response\": {\n                    \"id\": \"resp_123\"\n                }\n            }),\n            json!({\n                \"type\": \"response.completed\",\n                \"response\": {\n                    \"model\": \"o3\",\n                    \"usage\": {\n                        \"input_tokens\": 1000,\n                        \"output_tokens\": 500,\n                        \"input_tokens_details\": {\n                            \"cached_tokens\": 200\n                        }\n                    }\n                }\n            }),\n        ];\n\n        let usage = TokenUsage::from_codex_stream_events_auto(&events).unwrap();\n        // 记录原始 input_tokens，不调整\n        assert_eq!(usage.input_tokens, 1000);\n        assert_eq!(usage.output_tokens, 500);\n        assert_eq!(usage.cache_read_tokens, 200);\n        assert_eq!(usage.model, Some(\"o3\".to_string()));\n    }\n\n    #[test]\n    fn test_codex_stream_events_auto_openai_format() {\n        // OpenAI Chat Completions 流式格式 (最后一个 chunk 包含 usage)\n        let events = vec![\n            json!({\n                \"id\": \"chatcmpl-123\",\n                \"model\": \"gpt-4o\",\n                \"choices\": [{\"delta\": {\"content\": \"Hello\"}}]\n            }),\n            json!({\n                \"id\": \"chatcmpl-123\",\n                \"model\": \"gpt-4o\",\n                \"choices\": [{\"delta\": {}}],\n                \"usage\": {\n                    \"prompt_tokens\": 100,\n                    \"completion_tokens\": 50\n                }\n            }),\n        ];\n\n        let usage = TokenUsage::from_codex_stream_events_auto(&events).unwrap();\n        assert_eq!(usage.input_tokens, 100);\n        assert_eq!(usage.output_tokens, 50);\n        assert_eq!(usage.model, Some(\"gpt-4o\".to_string()));\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/services/config.rs",
    "content": "use super::provider::{sanitize_claude_settings_for_live, ProviderService};\nuse crate::app_config::{AppType, MultiAppConfig};\nuse crate::error::AppError;\nuse crate::provider::Provider;\nuse chrono::Utc;\nuse serde_json::Value;\nuse std::fs;\nuse std::path::Path;\n\nconst MAX_BACKUPS: usize = 10;\n\n/// 配置导入导出相关业务逻辑\npub struct ConfigService;\n\nimpl ConfigService {\n    /// 为当前 config.json 创建备份，返回备份 ID（若文件不存在则返回空字符串）。\n    pub fn create_backup(config_path: &Path) -> Result<String, AppError> {\n        if !config_path.exists() {\n            return Ok(String::new());\n        }\n\n        let timestamp = Utc::now().format(\"%Y%m%d_%H%M%S\");\n        let backup_id = format!(\"backup_{timestamp}\");\n\n        let backup_dir = config_path\n            .parent()\n            .ok_or_else(|| AppError::Config(\"Invalid config path\".into()))?\n            .join(\"backups\");\n\n        fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?;\n\n        let backup_path = backup_dir.join(format!(\"{backup_id}.json\"));\n        let contents = fs::read(config_path).map_err(|e| AppError::io(config_path, e))?;\n        fs::write(&backup_path, contents).map_err(|e| AppError::io(&backup_path, e))?;\n\n        Self::cleanup_old_backups(&backup_dir, MAX_BACKUPS)?;\n\n        Ok(backup_id)\n    }\n\n    fn cleanup_old_backups(backup_dir: &Path, retain: usize) -> Result<(), AppError> {\n        if retain == 0 {\n            return Ok(());\n        }\n\n        let entries = match fs::read_dir(backup_dir) {\n            Ok(iter) => iter\n                .filter_map(|entry| entry.ok())\n                .filter(|entry| {\n                    entry\n                        .path()\n                        .extension()\n                        .map(|ext| ext == \"json\")\n                        .unwrap_or(false)\n                })\n                .collect::<Vec<_>>(),\n            Err(_) => return Ok(()),\n        };\n\n        if entries.len() <= retain {\n            return Ok(());\n        }\n\n        let remove_count = entries.len().saturating_sub(retain);\n        let mut sorted = entries;\n\n        sorted.sort_by(|a, b| {\n            let a_time = a.metadata().and_then(|m| m.modified()).ok();\n            let b_time = b.metadata().and_then(|m| m.modified()).ok();\n            a_time.cmp(&b_time)\n        });\n\n        for entry in sorted.into_iter().take(remove_count) {\n            if let Err(err) = fs::remove_file(entry.path()) {\n                log::warn!(\n                    \"Failed to remove old backup {}: {}\",\n                    entry.path().display(),\n                    err\n                );\n            }\n        }\n\n        Ok(())\n    }\n\n    /// 同步当前供应商到对应的 live 配置。\n    pub fn sync_current_providers_to_live(config: &mut MultiAppConfig) -> Result<(), AppError> {\n        Self::sync_current_provider_for_app(config, &AppType::Claude)?;\n        Self::sync_current_provider_for_app(config, &AppType::Codex)?;\n        Self::sync_current_provider_for_app(config, &AppType::Gemini)?;\n        Ok(())\n    }\n\n    fn sync_current_provider_for_app(\n        config: &mut MultiAppConfig,\n        app_type: &AppType,\n    ) -> Result<(), AppError> {\n        let (current_id, provider) = {\n            let manager = match config.get_manager(app_type) {\n                Some(manager) => manager,\n                None => return Ok(()),\n            };\n\n            if manager.current.is_empty() {\n                return Ok(());\n            }\n\n            let current_id = manager.current.clone();\n            let provider = match manager.providers.get(&current_id) {\n                Some(provider) => provider.clone(),\n                None => {\n                    log::warn!(\n                        \"当前应用 {app_type:?} 的供应商 {current_id} 不存在，跳过 live 同步\"\n                    );\n                    return Ok(());\n                }\n            };\n            (current_id, provider)\n        };\n\n        match app_type {\n            AppType::Codex => Self::sync_codex_live(config, &current_id, &provider)?,\n            AppType::Claude => Self::sync_claude_live(config, &current_id, &provider)?,\n            AppType::Gemini => Self::sync_gemini_live(config, &current_id, &provider)?,\n            AppType::OpenCode => {\n                // OpenCode uses additive mode, no live sync needed\n                // OpenCode providers are managed directly in the config file\n            }\n            AppType::OpenClaw => {\n                // OpenClaw uses additive mode, no live sync needed\n                // OpenClaw providers are managed directly in the config file\n            }\n        }\n\n        Ok(())\n    }\n\n    fn sync_codex_live(\n        config: &mut MultiAppConfig,\n        provider_id: &str,\n        provider: &Provider,\n    ) -> Result<(), AppError> {\n        let settings = provider.settings_config.as_object().ok_or_else(|| {\n            AppError::Config(format!(\"供应商 {provider_id} 的 Codex 配置必须是对象\"))\n        })?;\n        let auth = settings.get(\"auth\").ok_or_else(|| {\n            AppError::Config(format!(\"供应商 {provider_id} 的 Codex 配置缺少 auth 字段\"))\n        })?;\n        if !auth.is_object() {\n            return Err(AppError::Config(format!(\n                \"供应商 {provider_id} 的 Codex auth 配置必须是 JSON 对象\"\n            )));\n        }\n        let cfg_text = settings.get(\"config\").and_then(Value::as_str);\n\n        crate::codex_config::write_codex_live_atomic(auth, cfg_text)?;\n        // 注意：MCP 同步在 v3.7.0 中已通过 McpService 进行，不再在此调用\n        // sync_enabled_to_codex 使用旧的 config.mcp.codex 结构，在新架构中为空\n        // MCP 的启用/禁用应通过 McpService::toggle_app 进行\n\n        let cfg_text_after = crate::codex_config::read_and_validate_codex_config_text()?;\n        if let Some(manager) = config.get_manager_mut(&AppType::Codex) {\n            if let Some(target) = manager.providers.get_mut(provider_id) {\n                if let Some(obj) = target.settings_config.as_object_mut() {\n                    obj.insert(\n                        \"config\".to_string(),\n                        serde_json::Value::String(cfg_text_after),\n                    );\n                }\n            }\n        }\n\n        Ok(())\n    }\n\n    fn sync_claude_live(\n        config: &mut MultiAppConfig,\n        provider_id: &str,\n        provider: &Provider,\n    ) -> Result<(), AppError> {\n        use crate::config::{read_json_file, write_json_file};\n\n        let settings_path = crate::config::get_claude_settings_path();\n        if let Some(parent) = settings_path.parent() {\n            fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;\n        }\n\n        let settings = sanitize_claude_settings_for_live(&provider.settings_config);\n        write_json_file(&settings_path, &settings)?;\n\n        let live_after = read_json_file::<serde_json::Value>(&settings_path)?;\n        if let Some(manager) = config.get_manager_mut(&AppType::Claude) {\n            if let Some(target) = manager.providers.get_mut(provider_id) {\n                target.settings_config = live_after;\n            }\n        }\n\n        Ok(())\n    }\n\n    fn sync_gemini_live(\n        config: &mut MultiAppConfig,\n        provider_id: &str,\n        provider: &Provider,\n    ) -> Result<(), AppError> {\n        use crate::gemini_config::{env_to_json, read_gemini_env};\n\n        ProviderService::write_gemini_live(provider)?;\n\n        // 读回实际写入的内容并更新到配置中（包含 settings.json）\n        let live_after_env = read_gemini_env()?;\n        let settings_path = crate::gemini_config::get_gemini_settings_path();\n        let live_after_config = if settings_path.exists() {\n            crate::config::read_json_file(&settings_path)?\n        } else {\n            serde_json::json!({})\n        };\n        let mut live_after = env_to_json(&live_after_env);\n        if let Some(obj) = live_after.as_object_mut() {\n            obj.insert(\"config\".to_string(), live_after_config);\n        }\n\n        if let Some(manager) = config.get_manager_mut(&AppType::Gemini) {\n            if let Some(target) = manager.providers.get_mut(provider_id) {\n                target.settings_config = live_after;\n            }\n        }\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/services/env_checker.rs",
    "content": "use serde::{Deserialize, Serialize};\n#[cfg(not(target_os = \"windows\"))]\nuse std::fs;\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct EnvConflict {\n    pub var_name: String,\n    pub var_value: String,\n    pub source_type: String, // \"system\" | \"file\"\n    pub source_path: String, // Registry path or file path\n}\n\n#[cfg(target_os = \"windows\")]\nuse winreg::enums::*;\n#[cfg(target_os = \"windows\")]\nuse winreg::RegKey;\n\n/// Check environment variables for conflicts\npub fn check_env_conflicts(app: &str) -> Result<Vec<EnvConflict>, String> {\n    let keywords = get_keywords_for_app(app);\n    let mut conflicts = Vec::new();\n\n    // Check system environment variables\n    conflicts.extend(check_system_env(&keywords)?);\n\n    // Check shell configuration files (Unix only)\n    #[cfg(not(target_os = \"windows\"))]\n    conflicts.extend(check_shell_configs(&keywords)?);\n\n    Ok(conflicts)\n}\n\n/// Get relevant keywords for each app\nfn get_keywords_for_app(app: &str) -> Vec<&str> {\n    match app.to_lowercase().as_str() {\n        \"claude\" => vec![\"ANTHROPIC\"],\n        \"codex\" => vec![\"OPENAI\"],\n        \"gemini\" => vec![\"GEMINI\", \"GOOGLE_GEMINI\"],\n        _ => vec![],\n    }\n}\n\n/// Check system environment variables (Windows Registry or Unix env)\n#[cfg(target_os = \"windows\")]\nfn check_system_env(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {\n    let mut conflicts = Vec::new();\n\n    // Check HKEY_CURRENT_USER\\Environment\n    if let Ok(hkcu) = RegKey::predef(HKEY_CURRENT_USER).open_subkey(\"Environment\") {\n        for (name, value) in hkcu.enum_values().filter_map(Result::ok) {\n            if keywords.iter().any(|k| name.to_uppercase().contains(k)) {\n                conflicts.push(EnvConflict {\n                    var_name: name.clone(),\n                    var_value: value.to_string(),\n                    source_type: \"system\".to_string(),\n                    source_path: \"HKEY_CURRENT_USER\\\\Environment\".to_string(),\n                });\n            }\n        }\n    }\n\n    // Check HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment\n    if let Ok(hklm) = RegKey::predef(HKEY_LOCAL_MACHINE)\n        .open_subkey(\"SYSTEM\\\\CurrentControlSet\\\\Control\\\\Session Manager\\\\Environment\")\n    {\n        for (name, value) in hklm.enum_values().filter_map(Result::ok) {\n            if keywords.iter().any(|k| name.to_uppercase().contains(k)) {\n                conflicts.push(EnvConflict {\n                    var_name: name.clone(),\n                    var_value: value.to_string(),\n                    source_type: \"system\".to_string(),\n                    source_path: \"HKEY_LOCAL_MACHINE\\\\SYSTEM\\\\CurrentControlSet\\\\Control\\\\Session Manager\\\\Environment\".to_string(),\n                });\n            }\n        }\n    }\n\n    Ok(conflicts)\n}\n\n#[cfg(not(target_os = \"windows\"))]\nfn check_system_env(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {\n    let mut conflicts = Vec::new();\n\n    // Check current process environment\n    for (key, value) in std::env::vars() {\n        if keywords.iter().any(|k| key.to_uppercase().contains(k)) {\n            conflicts.push(EnvConflict {\n                var_name: key,\n                var_value: value,\n                source_type: \"system\".to_string(),\n                source_path: \"Process Environment\".to_string(),\n            });\n        }\n    }\n\n    Ok(conflicts)\n}\n\n/// Check shell configuration files for environment variable exports (Unix only)\n#[cfg(not(target_os = \"windows\"))]\nfn check_shell_configs(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {\n    let mut conflicts = Vec::new();\n\n    let home = std::env::var(\"HOME\").unwrap_or_else(|_| \"/tmp\".to_string());\n    let config_files = vec![\n        format!(\"{}/.bashrc\", home),\n        format!(\"{}/.bash_profile\", home),\n        format!(\"{}/.zshrc\", home),\n        format!(\"{}/.zprofile\", home),\n        format!(\"{}/.profile\", home),\n        \"/etc/profile\".to_string(),\n        \"/etc/bashrc\".to_string(),\n    ];\n\n    for file_path in config_files {\n        if let Ok(content) = fs::read_to_string(&file_path) {\n            // Parse lines for export statements\n            for (line_num, line) in content.lines().enumerate() {\n                let trimmed = line.trim();\n\n                // Match patterns like: export VAR=value or VAR=value\n                if trimmed.starts_with(\"export \")\n                    || (!trimmed.starts_with('#') && trimmed.contains('='))\n                {\n                    let export_line = trimmed.strip_prefix(\"export \").unwrap_or(trimmed);\n\n                    if let Some(eq_pos) = export_line.find('=') {\n                        let var_name = export_line[..eq_pos].trim();\n                        let var_value = export_line[eq_pos + 1..].trim();\n\n                        // Check if variable name contains any keyword\n                        if keywords.iter().any(|k| var_name.to_uppercase().contains(k)) {\n                            conflicts.push(EnvConflict {\n                                var_name: var_name.to_string(),\n                                var_value: var_value\n                                    .trim_matches('\"')\n                                    .trim_matches('\\'')\n                                    .to_string(),\n                                source_type: \"file\".to_string(),\n                                source_path: format!(\"{}:{}\", file_path, line_num + 1),\n                            });\n                        }\n                    }\n                }\n            }\n        }\n    }\n\n    Ok(conflicts)\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_get_keywords() {\n        assert_eq!(get_keywords_for_app(\"claude\"), vec![\"ANTHROPIC\"]);\n        assert_eq!(get_keywords_for_app(\"codex\"), vec![\"OPENAI\"]);\n        assert_eq!(\n            get_keywords_for_app(\"gemini\"),\n            vec![\"GEMINI\", \"GOOGLE_GEMINI\"]\n        );\n        assert_eq!(get_keywords_for_app(\"unknown\"), Vec::<&str>::new());\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/services/env_manager.rs",
    "content": "use super::env_checker::EnvConflict;\nuse chrono::Utc;\nuse serde::{Deserialize, Serialize};\nuse std::fs;\nuse std::path::PathBuf;\n\n#[cfg(target_os = \"windows\")]\nuse winreg::enums::*;\n#[cfg(target_os = \"windows\")]\nuse winreg::RegKey;\n\n#[derive(Debug, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct BackupInfo {\n    pub backup_path: String,\n    pub timestamp: String,\n    pub conflicts: Vec<EnvConflict>,\n}\n\n/// Delete environment variables with automatic backup\npub fn delete_env_vars(conflicts: Vec<EnvConflict>) -> Result<BackupInfo, String> {\n    // Step 1: Create backup\n    let backup_info = create_backup(&conflicts)?;\n\n    // Step 2: Delete variables\n    for conflict in &conflicts {\n        match delete_single_env(conflict) {\n            Ok(_) => {}\n            Err(e) => {\n                // If deletion fails, we keep the backup but return error\n                return Err(format!(\n                    \"删除环境变量失败: {}. 备份已保存到: {}\",\n                    e, backup_info.backup_path\n                ));\n            }\n        }\n    }\n\n    Ok(backup_info)\n}\n\n/// Create backup file before deletion\nfn create_backup(conflicts: &[EnvConflict]) -> Result<BackupInfo, String> {\n    // Get backup directory\n    let backup_dir = get_backup_dir()?;\n    fs::create_dir_all(&backup_dir).map_err(|e| format!(\"创建备份目录失败: {e}\"))?;\n\n    // Generate backup file name with timestamp\n    let timestamp = Utc::now().format(\"%Y%m%d_%H%M%S\").to_string();\n    let backup_file = backup_dir.join(format!(\"env-backup-{timestamp}.json\"));\n\n    // Create backup data\n    let backup_info = BackupInfo {\n        backup_path: backup_file.to_string_lossy().to_string(),\n        timestamp: timestamp.clone(),\n        conflicts: conflicts.to_vec(),\n    };\n\n    // Write backup file\n    let json = serde_json::to_string_pretty(&backup_info)\n        .map_err(|e| format!(\"序列化备份数据失败: {e}\"))?;\n\n    fs::write(&backup_file, json).map_err(|e| format!(\"写入备份文件失败: {e}\"))?;\n\n    Ok(backup_info)\n}\n\n/// Get backup directory path\nfn get_backup_dir() -> Result<PathBuf, String> {\n    let home = dirs::home_dir().ok_or(\"无法获取用户主目录\")?;\n    Ok(home.join(\".cc-switch\").join(\"backups\"))\n}\n\n/// Delete a single environment variable\n#[cfg(target_os = \"windows\")]\nfn delete_single_env(conflict: &EnvConflict) -> Result<(), String> {\n    match conflict.source_type.as_str() {\n        \"system\" => {\n            if conflict.source_path.contains(\"HKEY_CURRENT_USER\") {\n                let hkcu = RegKey::predef(HKEY_CURRENT_USER)\n                    .open_subkey_with_flags(\"Environment\", KEY_ALL_ACCESS)\n                    .map_err(|e| format!(\"打开注册表失败: {}\", e))?;\n\n                hkcu.delete_value(&conflict.var_name)\n                    .map_err(|e| format!(\"删除注册表项失败: {}\", e))?;\n            } else if conflict.source_path.contains(\"HKEY_LOCAL_MACHINE\") {\n                let hklm = RegKey::predef(HKEY_LOCAL_MACHINE)\n                    .open_subkey_with_flags(\n                        \"SYSTEM\\\\CurrentControlSet\\\\Control\\\\Session Manager\\\\Environment\",\n                        KEY_ALL_ACCESS,\n                    )\n                    .map_err(|e| format!(\"打开系统注册表失败 (需要管理员权限): {}\", e))?;\n\n                hklm.delete_value(&conflict.var_name)\n                    .map_err(|e| format!(\"删除系统注册表项失败: {}\", e))?;\n            }\n            Ok(())\n        }\n        \"file\" => Err(\"Windows 系统不应该有文件类型的环境变量\".to_string()),\n        _ => Err(format!(\"未知的环境变量来源类型: {}\", conflict.source_type)),\n    }\n}\n\n#[cfg(not(target_os = \"windows\"))]\nfn delete_single_env(conflict: &EnvConflict) -> Result<(), String> {\n    match conflict.source_type.as_str() {\n        \"file\" => {\n            // Parse file path and line number from source_path (format: \"path:line\")\n            let parts: Vec<&str> = conflict.source_path.split(':').collect();\n            if parts.len() < 2 {\n                return Err(\"无效的文件路径格式\".to_string());\n            }\n\n            let file_path = parts[0];\n\n            // Read file content\n            let content = fs::read_to_string(file_path)\n                .map_err(|e| format!(\"读取文件失败 {file_path}: {e}\"))?;\n\n            // Filter out the line containing the environment variable\n            let new_content: Vec<String> = content\n                .lines()\n                .filter(|line| {\n                    let trimmed = line.trim();\n                    let export_line = trimmed.strip_prefix(\"export \").unwrap_or(trimmed);\n\n                    // Check if this line sets the target variable\n                    if let Some(eq_pos) = export_line.find('=') {\n                        let var_name = export_line[..eq_pos].trim();\n                        var_name != conflict.var_name\n                    } else {\n                        true\n                    }\n                })\n                .map(|s| s.to_string())\n                .collect();\n\n            // Write back to file\n            fs::write(file_path, new_content.join(\"\\n\"))\n                .map_err(|e| format!(\"写入文件失败 {file_path}: {e}\"))?;\n\n            Ok(())\n        }\n        \"system\" => {\n            // On Unix, we can't directly delete process environment variables\n            Ok(())\n        }\n        _ => Err(format!(\"未知的环境变量来源类型: {}\", conflict.source_type)),\n    }\n}\n\n/// Restore environment variables from backup\npub fn restore_from_backup(backup_path: String) -> Result<(), String> {\n    // Read backup file\n    let content = fs::read_to_string(&backup_path).map_err(|e| format!(\"读取备份文件失败: {e}\"))?;\n\n    let backup_info: BackupInfo =\n        serde_json::from_str(&content).map_err(|e| format!(\"解析备份文件失败: {e}\"))?;\n\n    // Restore each variable\n    for conflict in &backup_info.conflicts {\n        restore_single_env(conflict)?;\n    }\n\n    Ok(())\n}\n\n/// Restore a single environment variable\n#[cfg(target_os = \"windows\")]\nfn restore_single_env(conflict: &EnvConflict) -> Result<(), String> {\n    match conflict.source_type.as_str() {\n        \"system\" => {\n            if conflict.source_path.contains(\"HKEY_CURRENT_USER\") {\n                let (hkcu, _) = RegKey::predef(HKEY_CURRENT_USER)\n                    .create_subkey(\"Environment\")\n                    .map_err(|e| format!(\"打开注册表失败: {}\", e))?;\n\n                hkcu.set_value(&conflict.var_name, &conflict.var_value)\n                    .map_err(|e| format!(\"恢复注册表项失败: {}\", e))?;\n            } else if conflict.source_path.contains(\"HKEY_LOCAL_MACHINE\") {\n                let (hklm, _) = RegKey::predef(HKEY_LOCAL_MACHINE)\n                    .create_subkey(\n                        \"SYSTEM\\\\CurrentControlSet\\\\Control\\\\Session Manager\\\\Environment\",\n                    )\n                    .map_err(|e| format!(\"打开系统注册表失败 (需要管理员权限): {}\", e))?;\n\n                hklm.set_value(&conflict.var_name, &conflict.var_value)\n                    .map_err(|e| format!(\"恢复系统注册表项失败: {}\", e))?;\n            }\n            Ok(())\n        }\n        _ => Err(format!(\n            \"无法恢复类型为 {} 的环境变量\",\n            conflict.source_type\n        )),\n    }\n}\n\n#[cfg(not(target_os = \"windows\"))]\nfn restore_single_env(conflict: &EnvConflict) -> Result<(), String> {\n    match conflict.source_type.as_str() {\n        \"file\" => {\n            // Parse file path from source_path\n            let parts: Vec<&str> = conflict.source_path.split(':').collect();\n            if parts.is_empty() {\n                return Err(\"无效的文件路径格式\".to_string());\n            }\n\n            let file_path = parts[0];\n\n            // Read file content\n            let mut content = fs::read_to_string(file_path)\n                .map_err(|e| format!(\"读取文件失败 {file_path}: {e}\"))?;\n\n            // Append the environment variable line\n            let export_line = format!(\"\\nexport {}={}\", conflict.var_name, conflict.var_value);\n            content.push_str(&export_line);\n\n            // Write back to file\n            fs::write(file_path, content).map_err(|e| format!(\"写入文件失败 {file_path}: {e}\"))?;\n\n            Ok(())\n        }\n        _ => Err(format!(\n            \"无法恢复类型为 {} 的环境变量\",\n            conflict.source_type\n        )),\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_backup_dir_creation() {\n        let backup_dir = get_backup_dir();\n        assert!(backup_dir.is_ok());\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/services/mcp.rs",
    "content": "use indexmap::IndexMap;\nuse std::collections::HashMap;\n\nuse crate::app_config::{AppType, McpServer};\nuse crate::error::AppError;\nuse crate::mcp;\nuse crate::store::AppState;\n\n/// MCP 相关业务逻辑（v3.7.0 统一结构）\npub struct McpService;\n\nimpl McpService {\n    /// 获取所有 MCP 服务器（统一结构）\n    pub fn get_all_servers(state: &AppState) -> Result<IndexMap<String, McpServer>, AppError> {\n        state.db.get_all_mcp_servers()\n    }\n\n    /// 添加或更新 MCP 服务器\n    pub fn upsert_server(state: &AppState, server: McpServer) -> Result<(), AppError> {\n        // 读取旧状态：用于处理“编辑时取消勾选某个应用”的场景（需要从对应 live 配置中移除）\n        let prev_apps = state\n            .db\n            .get_all_mcp_servers()?\n            .get(&server.id)\n            .map(|s| s.apps.clone())\n            .unwrap_or_default();\n\n        state.db.save_mcp_server(&server)?;\n\n        // 处理禁用：若旧版本启用但新版本取消，则需要从该应用的 live 配置移除\n        if prev_apps.claude && !server.apps.claude {\n            Self::remove_server_from_app(state, &server.id, &AppType::Claude)?;\n        }\n        if prev_apps.codex && !server.apps.codex {\n            Self::remove_server_from_app(state, &server.id, &AppType::Codex)?;\n        }\n        if prev_apps.gemini && !server.apps.gemini {\n            Self::remove_server_from_app(state, &server.id, &AppType::Gemini)?;\n        }\n        if prev_apps.opencode && !server.apps.opencode {\n            Self::remove_server_from_app(state, &server.id, &AppType::OpenCode)?;\n        }\n\n        // 同步到各个启用的应用\n        Self::sync_server_to_apps(state, &server)?;\n\n        Ok(())\n    }\n\n    /// 删除 MCP 服务器\n    pub fn delete_server(state: &AppState, id: &str) -> Result<bool, AppError> {\n        let server = state.db.get_all_mcp_servers()?.shift_remove(id);\n\n        if let Some(server) = server {\n            state.db.delete_mcp_server(id)?;\n\n            // 从所有应用的 live 配置中移除\n            Self::remove_server_from_all_apps(state, id, &server)?;\n            Ok(true)\n        } else {\n            Ok(false)\n        }\n    }\n\n    /// 切换指定应用的启用状态\n    pub fn toggle_app(\n        state: &AppState,\n        server_id: &str,\n        app: AppType,\n        enabled: bool,\n    ) -> Result<(), AppError> {\n        let mut servers = state.db.get_all_mcp_servers()?;\n\n        if let Some(server) = servers.get_mut(server_id) {\n            server.apps.set_enabled_for(&app, enabled);\n            state.db.save_mcp_server(server)?;\n\n            // 同步到对应应用\n            if enabled {\n                Self::sync_server_to_app(state, server, &app)?;\n            } else {\n                Self::remove_server_from_app(state, server_id, &app)?;\n            }\n        }\n\n        Ok(())\n    }\n\n    /// 将 MCP 服务器同步到所有启用的应用\n    fn sync_server_to_apps(_state: &AppState, server: &McpServer) -> Result<(), AppError> {\n        for app in server.apps.enabled_apps() {\n            Self::sync_server_to_app_no_config(server, &app)?;\n        }\n\n        Ok(())\n    }\n\n    /// 将 MCP 服务器同步到指定应用\n    fn sync_server_to_app(\n        _state: &AppState,\n        server: &McpServer,\n        app: &AppType,\n    ) -> Result<(), AppError> {\n        Self::sync_server_to_app_no_config(server, app)\n    }\n\n    fn sync_server_to_app_no_config(server: &McpServer, app: &AppType) -> Result<(), AppError> {\n        match app {\n            AppType::Claude => {\n                mcp::sync_single_server_to_claude(&Default::default(), &server.id, &server.server)?;\n            }\n            AppType::Codex => {\n                // Codex uses TOML format, must use the correct function\n                mcp::sync_single_server_to_codex(&Default::default(), &server.id, &server.server)?;\n            }\n            AppType::Gemini => {\n                mcp::sync_single_server_to_gemini(&Default::default(), &server.id, &server.server)?;\n            }\n            AppType::OpenCode => {\n                mcp::sync_single_server_to_opencode(\n                    &Default::default(),\n                    &server.id,\n                    &server.server,\n                )?;\n            }\n            AppType::OpenClaw => {\n                // OpenClaw MCP support is still in development (Issue #4834)\n                // Skip for now\n                log::debug!(\"OpenClaw MCP support is still in development, skipping sync\");\n            }\n        }\n        Ok(())\n    }\n\n    /// 从所有曾启用过该服务器的应用中移除\n    fn remove_server_from_all_apps(\n        state: &AppState,\n        id: &str,\n        server: &McpServer,\n    ) -> Result<(), AppError> {\n        // 从所有曾启用的应用中移除\n        for app in server.apps.enabled_apps() {\n            Self::remove_server_from_app(state, id, &app)?;\n        }\n        Ok(())\n    }\n\n    fn remove_server_from_app(_state: &AppState, id: &str, app: &AppType) -> Result<(), AppError> {\n        match app {\n            AppType::Claude => mcp::remove_server_from_claude(id)?,\n            AppType::Codex => mcp::remove_server_from_codex(id)?,\n            AppType::Gemini => mcp::remove_server_from_gemini(id)?,\n            AppType::OpenCode => {\n                mcp::remove_server_from_opencode(id)?;\n            }\n            AppType::OpenClaw => {\n                // OpenClaw MCP support is still in development\n                log::debug!(\"OpenClaw MCP support is still in development, skipping remove\");\n            }\n        }\n        Ok(())\n    }\n\n    /// 手动同步所有启用的 MCP 服务器到对应的应用\n    pub fn sync_all_enabled(state: &AppState) -> Result<(), AppError> {\n        let servers = Self::get_all_servers(state)?;\n\n        for app in AppType::all() {\n            if matches!(app, AppType::OpenClaw) {\n                continue;\n            }\n\n            for server in servers.values() {\n                if server.apps.is_enabled_for(&app) {\n                    Self::sync_server_to_app(state, server, &app)?;\n                } else {\n                    Self::remove_server_from_app(state, &server.id, &app)?;\n                }\n            }\n        }\n\n        Ok(())\n    }\n\n    // ========================================================================\n    // 兼容层：支持旧的 v3.6.x 命令（已废弃，将在 v4.0 移除）\n    // ========================================================================\n\n    /// [已废弃] 获取指定应用的 MCP 服务器（兼容旧 API）\n    #[deprecated(since = \"3.7.0\", note = \"Use get_all_servers instead\")]\n    pub fn get_servers(\n        state: &AppState,\n        app: AppType,\n    ) -> Result<HashMap<String, serde_json::Value>, AppError> {\n        let all_servers = Self::get_all_servers(state)?;\n        let mut result = HashMap::new();\n\n        for (id, server) in all_servers {\n            if server.apps.is_enabled_for(&app) {\n                result.insert(id, server.server);\n            }\n        }\n\n        Ok(result)\n    }\n\n    /// [已废弃] 设置 MCP 服务器在指定应用的启用状态（兼容旧 API）\n    #[deprecated(since = \"3.7.0\", note = \"Use toggle_app instead\")]\n    pub fn set_enabled(\n        state: &AppState,\n        app: AppType,\n        id: &str,\n        enabled: bool,\n    ) -> Result<bool, AppError> {\n        Self::toggle_app(state, id, app, enabled)?;\n        Ok(true)\n    }\n\n    /// [已废弃] 同步启用的 MCP 到指定应用（兼容旧 API）\n    #[deprecated(since = \"3.7.0\", note = \"Use sync_all_enabled instead\")]\n    pub fn sync_enabled(state: &AppState, app: AppType) -> Result<(), AppError> {\n        let servers = Self::get_all_servers(state)?;\n\n        for server in servers.values() {\n            if server.apps.is_enabled_for(&app) {\n                Self::sync_server_to_app(state, server, &app)?;\n            }\n        }\n\n        Ok(())\n    }\n\n    /// 从 Claude 导入 MCP（v3.7.0 已更新为统一结构）\n    pub fn import_from_claude(state: &AppState) -> Result<usize, AppError> {\n        // 创建临时 MultiAppConfig 用于导入\n        let mut temp_config = crate::app_config::MultiAppConfig::default();\n\n        // 调用原有的导入逻辑（从 mcp.rs）\n        let count = crate::mcp::import_from_claude(&mut temp_config)?;\n\n        let mut new_count = 0;\n\n        // 如果有导入的服务器，保存到数据库\n        if count > 0 {\n            if let Some(servers) = &temp_config.mcp.servers {\n                let mut existing = state.db.get_all_mcp_servers()?;\n                for server in servers.values() {\n                    // 已存在：仅启用 Claude，不覆盖其他字段（与导入模块语义保持一致）\n                    let to_save = if let Some(existing_server) = existing.get(&server.id) {\n                        let mut merged = existing_server.clone();\n                        merged.apps.claude = true;\n                        merged\n                    } else {\n                        // 真正的新服务器\n                        new_count += 1;\n                        server.clone()\n                    };\n\n                    state.db.save_mcp_server(&to_save)?;\n                    existing.insert(to_save.id.clone(), to_save.clone());\n\n                    // 同步到对应应用 live 配置\n                    Self::sync_server_to_apps(state, &to_save)?;\n                }\n            }\n        }\n\n        Ok(new_count)\n    }\n\n    /// 从 Codex 导入 MCP（v3.7.0 已更新为统一结构）\n    pub fn import_from_codex(state: &AppState) -> Result<usize, AppError> {\n        // 创建临时 MultiAppConfig 用于导入\n        let mut temp_config = crate::app_config::MultiAppConfig::default();\n\n        // 调用原有的导入逻辑（从 mcp.rs）\n        let count = crate::mcp::import_from_codex(&mut temp_config)?;\n\n        let mut new_count = 0;\n\n        // 如果有导入的服务器，保存到数据库\n        if count > 0 {\n            if let Some(servers) = &temp_config.mcp.servers {\n                let mut existing = state.db.get_all_mcp_servers()?;\n                for server in servers.values() {\n                    // 已存在：仅启用 Codex，不覆盖其他字段（与导入模块语义保持一致）\n                    let to_save = if let Some(existing_server) = existing.get(&server.id) {\n                        let mut merged = existing_server.clone();\n                        merged.apps.codex = true;\n                        merged\n                    } else {\n                        // 真正的新服务器\n                        new_count += 1;\n                        server.clone()\n                    };\n\n                    state.db.save_mcp_server(&to_save)?;\n                    existing.insert(to_save.id.clone(), to_save.clone());\n\n                    // 同步到对应应用 live 配置\n                    Self::sync_server_to_apps(state, &to_save)?;\n                }\n            }\n        }\n\n        Ok(new_count)\n    }\n\n    /// 从 Gemini 导入 MCP（v3.7.0 已更新为统一结构）\n    pub fn import_from_gemini(state: &AppState) -> Result<usize, AppError> {\n        // 创建临时 MultiAppConfig 用于导入\n        let mut temp_config = crate::app_config::MultiAppConfig::default();\n\n        // 调用原有的导入逻辑（从 mcp.rs）\n        let count = crate::mcp::import_from_gemini(&mut temp_config)?;\n\n        let mut new_count = 0;\n\n        // 如果有导入的服务器，保存到数据库\n        if count > 0 {\n            if let Some(servers) = &temp_config.mcp.servers {\n                let mut existing = state.db.get_all_mcp_servers()?;\n                for server in servers.values() {\n                    // 已存在：仅启用 Gemini，不覆盖其他字段（与导入模块语义保持一致）\n                    let to_save = if let Some(existing_server) = existing.get(&server.id) {\n                        let mut merged = existing_server.clone();\n                        merged.apps.gemini = true;\n                        merged\n                    } else {\n                        // 真正的新服务器\n                        new_count += 1;\n                        server.clone()\n                    };\n\n                    state.db.save_mcp_server(&to_save)?;\n                    existing.insert(to_save.id.clone(), to_save.clone());\n\n                    // 同步到对应应用 live 配置\n                    Self::sync_server_to_apps(state, &to_save)?;\n                }\n            }\n        }\n\n        Ok(new_count)\n    }\n\n    /// 从 OpenCode 导入 MCP（v3.9.2+ 新增）\n    pub fn import_from_opencode(state: &AppState) -> Result<usize, AppError> {\n        // 创建临时 MultiAppConfig 用于导入\n        let mut temp_config = crate::app_config::MultiAppConfig::default();\n\n        // 调用原有的导入逻辑（从 mcp/opencode.rs）\n        let count = crate::mcp::import_from_opencode(&mut temp_config)?;\n\n        let mut new_count = 0;\n\n        // 如果有导入的服务器，保存到数据库\n        if count > 0 {\n            if let Some(servers) = &temp_config.mcp.servers {\n                let mut existing = state.db.get_all_mcp_servers()?;\n                for server in servers.values() {\n                    // 已存在：仅启用 OpenCode，不覆盖其他字段（与导入模块语义保持一致）\n                    let to_save = if let Some(existing_server) = existing.get(&server.id) {\n                        let mut merged = existing_server.clone();\n                        merged.apps.opencode = true;\n                        merged\n                    } else {\n                        // 真正的新服务器\n                        new_count += 1;\n                        server.clone()\n                    };\n\n                    state.db.save_mcp_server(&to_save)?;\n                    existing.insert(to_save.id.clone(), to_save.clone());\n\n                    // 同步到对应应用 live 配置\n                    Self::sync_server_to_apps(state, &to_save)?;\n                }\n            }\n        }\n\n        Ok(new_count)\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/services/mod.rs",
    "content": "pub mod config;\npub mod env_checker;\npub mod env_manager;\npub mod mcp;\npub mod omo;\npub mod prompt;\npub mod provider;\npub mod proxy;\npub mod skill;\npub mod speedtest;\npub mod stream_check;\npub mod usage_stats;\npub mod webdav;\npub mod webdav_auto_sync;\npub mod webdav_sync;\n\npub use config::ConfigService;\npub use mcp::McpService;\npub use omo::OmoService;\npub use prompt::PromptService;\npub use provider::{ProviderService, ProviderSortUpdate, SwitchResult};\npub use proxy::ProxyService;\n#[allow(unused_imports)]\npub use skill::{DiscoverableSkill, Skill, SkillRepo, SkillService};\npub use speedtest::{EndpointLatency, SpeedtestService};\n#[allow(unused_imports)]\npub use usage_stats::{\n    DailyStats, LogFilters, ModelStats, PaginatedLogs, ProviderLimitStatus, ProviderStats,\n    RequestLogDetail, UsageSummary,\n};\n"
  },
  {
    "path": "src-tauri/src/services/omo.rs",
    "content": "use crate::config::write_json_file;\nuse crate::error::AppError;\nuse crate::opencode_config::get_opencode_dir;\nuse crate::store::AppState;\nuse serde::{Deserialize, Serialize};\nuse serde_json::{Map, Value};\nuse std::path::{Path, PathBuf};\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct OmoLocalFileData {\n    pub agents: Option<Value>,\n    pub categories: Option<Value>,\n    pub other_fields: Option<Value>,\n    pub file_path: String,\n    pub last_modified: Option<String>,\n}\n\ntype OmoProfileData = (Option<Value>, Option<Value>, Option<Value>);\n\n// ── Variant descriptor ─────────────────────────────────────────\n\npub struct OmoVariant {\n    pub filename: &'static str,\n    pub category: &'static str,\n    pub provider_prefix: &'static str,\n    pub plugin_name: &'static str,\n    pub plugin_prefix: &'static str,\n    pub has_categories: bool,\n    pub label: &'static str,\n    pub import_label: &'static str,\n}\n\npub const STANDARD: OmoVariant = OmoVariant {\n    filename: \"oh-my-opencode.jsonc\",\n    category: \"omo\",\n    provider_prefix: \"omo-\",\n    plugin_name: \"oh-my-opencode@latest\",\n    plugin_prefix: \"oh-my-opencode\",\n    has_categories: true,\n    label: \"OMO\",\n    import_label: \"Imported\",\n};\n\npub const SLIM: OmoVariant = OmoVariant {\n    filename: \"oh-my-opencode-slim.jsonc\",\n    category: \"omo-slim\",\n    provider_prefix: \"omo-slim-\",\n    plugin_name: \"oh-my-opencode-slim@latest\",\n    plugin_prefix: \"oh-my-opencode-slim\",\n    has_categories: false,\n    label: \"OMO Slim\",\n    import_label: \"Imported Slim\",\n};\n\n// ── Service ────────────────────────────────────────────────────\n\npub struct OmoService;\n\nimpl OmoService {\n    // ── Path helpers ────────────────────────────────────────\n\n    fn config_path(v: &OmoVariant) -> PathBuf {\n        get_opencode_dir().join(v.filename)\n    }\n\n    fn resolve_local_config_path(v: &OmoVariant) -> Result<PathBuf, AppError> {\n        let config_path = Self::config_path(v);\n        if config_path.exists() {\n            return Ok(config_path);\n        }\n\n        let json_path = config_path.with_extension(\"json\");\n        if json_path.exists() {\n            return Ok(json_path);\n        }\n\n        Err(AppError::OmoConfigNotFound)\n    }\n\n    fn read_jsonc_object(path: &Path) -> Result<Map<String, Value>, AppError> {\n        let content = std::fs::read_to_string(path).map_err(|e| AppError::io(path, e))?;\n        let cleaned = Self::strip_jsonc_comments(&content);\n        let parsed: Value = serde_json::from_str(&cleaned)\n            .map_err(|e| AppError::Config(format!(\"Failed to parse oh-my-opencode config: {e}\")))?;\n        parsed\n            .as_object()\n            .cloned()\n            .ok_or_else(|| AppError::Config(\"Expected JSON object\".to_string()))\n    }\n\n    // ── Field extraction ───────────────────────────────────\n\n    fn extract_other_fields_with_keys(\n        obj: &Map<String, Value>,\n        known: &[&str],\n    ) -> Map<String, Value> {\n        let mut other = Map::new();\n        for (k, v) in obj {\n            if !known.contains(&k.as_str()) {\n                other.insert(k.clone(), v.clone());\n            }\n        }\n        other\n    }\n\n    // ── Merge helpers ──────────────────────────────────────\n\n    fn insert_opt_value(result: &mut Map<String, Value>, key: &str, value: &Option<Value>) {\n        if let Some(v) = value {\n            result.insert(key.to_string(), v.clone());\n        }\n    }\n\n    fn insert_object_entries(result: &mut Map<String, Value>, value: Option<&Value>) {\n        if let Some(Value::Object(map)) = value {\n            for (k, v) in map {\n                result.insert(k.clone(), v.clone());\n            }\n        }\n    }\n\n    // ── Public API (variant-parameterized) ─────────────────\n\n    pub fn delete_config_file(v: &OmoVariant) -> Result<(), AppError> {\n        let config_path = Self::config_path(v);\n        if config_path.exists() {\n            std::fs::remove_file(&config_path).map_err(|e| AppError::io(&config_path, e))?;\n            log::info!(\"{} config file deleted: {config_path:?}\", v.label);\n        }\n        crate::opencode_config::remove_plugin_by_prefix(v.plugin_prefix)?;\n        Ok(())\n    }\n\n    pub fn write_config_to_file(state: &AppState, v: &OmoVariant) -> Result<(), AppError> {\n        let current_omo = state.db.get_current_omo_provider(\"opencode\", v.category)?;\n        let profile_data = current_omo.as_ref().map(|p| {\n            let agents = p.settings_config.get(\"agents\").cloned();\n            let categories = if v.has_categories {\n                p.settings_config.get(\"categories\").cloned()\n            } else {\n                None\n            };\n            let other_fields = p.settings_config.get(\"otherFields\").cloned();\n            (agents, categories, other_fields)\n        });\n\n        let merged = Self::build_config(v, profile_data.as_ref());\n        let config_path = Self::config_path(v);\n\n        if let Some(parent) = config_path.parent() {\n            std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;\n        }\n\n        write_json_file(&config_path, &merged)?;\n        crate::opencode_config::add_plugin(v.plugin_name)?;\n        log::info!(\"{} config written to {config_path:?}\", v.label);\n        Ok(())\n    }\n\n    fn build_config(v: &OmoVariant, profile_data: Option<&OmoProfileData>) -> Value {\n        let mut result = Map::new();\n        if let Some((agents, categories, other_fields)) = profile_data {\n            Self::insert_object_entries(&mut result, other_fields.as_ref());\n            Self::insert_opt_value(&mut result, \"agents\", agents);\n            if v.has_categories {\n                Self::insert_opt_value(&mut result, \"categories\", categories);\n            }\n        }\n        Value::Object(result)\n    }\n\n    pub fn import_from_local(\n        state: &AppState,\n        v: &OmoVariant,\n    ) -> Result<crate::provider::Provider, AppError> {\n        let actual_path = Self::resolve_local_config_path(v)?;\n        let obj = Self::read_jsonc_object(&actual_path)?;\n\n        let mut settings = Map::new();\n        if let Some(agents) = obj.get(\"agents\") {\n            settings.insert(\"agents\".to_string(), agents.clone());\n        }\n        if v.has_categories {\n            if let Some(categories) = obj.get(\"categories\") {\n                settings.insert(\"categories\".to_string(), categories.clone());\n            }\n        }\n\n        let other = Self::extract_other_fields_with_keys(&obj, &[\"agents\", \"categories\"]);\n        if !other.is_empty() {\n            settings.insert(\"otherFields\".to_string(), Value::Object(other));\n        }\n\n        let provider_id = format!(\"{}{}\", v.provider_prefix, uuid::Uuid::new_v4());\n        let name = format!(\n            \"{} {}\",\n            v.import_label,\n            chrono::Local::now().format(\"%Y-%m-%d %H:%M\")\n        );\n        let settings_config =\n            serde_json::to_value(&settings).unwrap_or_else(|_| serde_json::json!({}));\n\n        let provider = crate::provider::Provider {\n            id: provider_id,\n            name,\n            settings_config,\n            website_url: None,\n            category: Some(v.category.to_string()),\n            created_at: Some(chrono::Utc::now().timestamp_millis()),\n            sort_index: None,\n            notes: None,\n            meta: None,\n            icon: None,\n            icon_color: None,\n            in_failover_queue: false,\n        };\n\n        state.db.save_provider(\"opencode\", &provider)?;\n        state\n            .db\n            .set_omo_provider_current(\"opencode\", &provider.id, v.category)?;\n        Self::write_config_to_file(state, v)?;\n        Ok(provider)\n    }\n\n    pub fn read_local_file(v: &OmoVariant) -> Result<OmoLocalFileData, AppError> {\n        let actual_path = Self::resolve_local_config_path(v)?;\n        let metadata = std::fs::metadata(&actual_path).ok();\n        let last_modified = metadata\n            .and_then(|m| m.modified().ok())\n            .map(|t| chrono::DateTime::<chrono::Utc>::from(t).to_rfc3339());\n\n        let obj = Self::read_jsonc_object(&actual_path)?;\n\n        Ok(Self::build_local_file_data(\n            v,\n            &obj,\n            actual_path.to_string_lossy().to_string(),\n            last_modified,\n        ))\n    }\n\n    fn build_local_file_data(\n        v: &OmoVariant,\n        obj: &Map<String, Value>,\n        file_path: String,\n        last_modified: Option<String>,\n    ) -> OmoLocalFileData {\n        let agents = obj.get(\"agents\").cloned();\n        let categories = if v.has_categories {\n            obj.get(\"categories\").cloned()\n        } else {\n            None\n        };\n\n        let other = Self::extract_other_fields_with_keys(obj, &[\"agents\", \"categories\"]);\n        let other_fields = if other.is_empty() {\n            None\n        } else {\n            Some(Value::Object(other))\n        };\n\n        OmoLocalFileData {\n            agents,\n            categories,\n            other_fields,\n            file_path,\n            last_modified,\n        }\n    }\n\n    fn strip_jsonc_comments(input: &str) -> String {\n        let mut result = String::with_capacity(input.len());\n        let mut chars = input.chars().peekable();\n        let mut in_string = false;\n        let mut escape = false;\n\n        while let Some(&c) = chars.peek() {\n            if in_string {\n                result.push(c);\n                chars.next();\n                if escape {\n                    escape = false;\n                } else if c == '\\\\' {\n                    escape = true;\n                } else if c == '\"' {\n                    in_string = false;\n                }\n            } else if c == '\"' {\n                in_string = true;\n                result.push(c);\n                chars.next();\n            } else if c == '/' {\n                chars.next();\n                match chars.peek() {\n                    Some('/') => {\n                        chars.next();\n                        while let Some(&nc) = chars.peek() {\n                            if nc == '\\n' {\n                                break;\n                            }\n                            chars.next();\n                        }\n                    }\n                    Some('*') => {\n                        chars.next();\n                        while let Some(nc) = chars.next() {\n                            if nc == '*' {\n                                if let Some(&'/') = chars.peek() {\n                                    chars.next();\n                                    break;\n                                }\n                            }\n                        }\n                    }\n                    _ => {\n                        result.push('/');\n                    }\n                }\n            } else {\n                result.push(c);\n                chars.next();\n            }\n        }\n        result\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_strip_jsonc_comments() {\n        let input = r#\"{\n  // This is a comment\n  \"key\": \"value\", // inline comment\n  /* multi\n     line */\n  \"key2\": \"val//ue\"\n}\"#;\n        let result = OmoService::strip_jsonc_comments(input);\n        let parsed: Value = serde_json::from_str(&result).unwrap();\n        assert_eq!(parsed[\"key\"], \"value\");\n        assert_eq!(parsed[\"key2\"], \"val//ue\");\n    }\n\n    #[test]\n    fn test_build_config_empty() {\n        let merged = OmoService::build_config(&STANDARD, None);\n        assert!(merged.is_object());\n        assert!(merged.as_object().unwrap().is_empty());\n    }\n\n    #[test]\n    fn test_build_config_with_profile() {\n        let agents = Some(serde_json::json!({\n            \"sisyphus\": { \"model\": \"claude-opus-4-5\" }\n        }));\n        let categories = None;\n        let other_fields = Some(serde_json::json!({\n            \"$schema\": \"https://example.com/schema.json\",\n            \"disabled_agents\": [\"explore\"]\n        }));\n        let profile_data = (agents, categories, other_fields);\n        let merged = OmoService::build_config(&STANDARD, Some(&profile_data));\n        let obj = merged.as_object().unwrap();\n\n        assert_eq!(obj[\"$schema\"], \"https://example.com/schema.json\");\n        assert_eq!(obj[\"disabled_agents\"], serde_json::json!([\"explore\"]));\n        assert!(obj.contains_key(\"agents\"));\n        assert_eq!(obj[\"agents\"][\"sisyphus\"][\"model\"], \"claude-opus-4-5\");\n    }\n\n    #[test]\n    fn test_build_local_file_data_keeps_all_non_agent_category_fields_in_other() {\n        let obj = serde_json::json!({\n            \"$schema\": \"https://example.com/schema.json\",\n            \"disabled_agents\": [\"oracle\"],\n            \"agents\": {\n                \"sisyphus\": { \"model\": \"claude-opus-4-6\" }\n            },\n            \"categories\": {\n                \"code\": { \"model\": \"gpt-5.3\" }\n            },\n            \"custom_top_level\": {\n                \"enabled\": true\n            }\n        });\n        let obj_map = obj.as_object().unwrap().clone();\n\n        let data = OmoService::build_local_file_data(\n            &STANDARD,\n            &obj_map,\n            \"/tmp/oh-my-opencode.jsonc\".to_string(),\n            None,\n        );\n\n        // All non-agents/categories fields should be in other_fields\n        let other = data.other_fields.unwrap();\n        let other_obj = other.as_object().unwrap();\n        assert_eq!(\n            other_obj.get(\"$schema\").unwrap(),\n            \"https://example.com/schema.json\"\n        );\n        assert_eq!(\n            other_obj.get(\"disabled_agents\").unwrap(),\n            &serde_json::json!([\"oracle\"])\n        );\n        assert_eq!(\n            other_obj.get(\"custom_top_level\").unwrap(),\n            &serde_json::json!({\"enabled\": true})\n        );\n        // agents and categories should NOT be in other_fields\n        assert!(!other_obj.contains_key(\"agents\"));\n        assert!(!other_obj.contains_key(\"categories\"));\n    }\n\n    #[test]\n    fn test_build_config_ignores_non_object_other_fields() {\n        let agents = None;\n        let categories = None;\n        let other_fields = Some(serde_json::json!(\"profile_non_object\"));\n        let profile_data = (agents, categories, other_fields);\n\n        let merged = OmoService::build_config(&STANDARD, Some(&profile_data));\n        let obj = merged.as_object().unwrap();\n\n        assert!(!obj.contains_key(\"profile_non_object\"));\n    }\n\n    #[test]\n    fn test_build_config_slim_excludes_categories() {\n        let agents = Some(serde_json::json!({\"orchestrator\": {\"model\": \"k2\"}}));\n        let categories = Some(serde_json::json!({\"code\": {\"model\": \"gpt\"}}));\n        let other_fields = Some(serde_json::json!({\n            \"$schema\": \"https://slim.schema\",\n            \"disabled_agents\": [\"oracle\"]\n        }));\n        let profile_data = (agents, categories, other_fields);\n\n        let merged = OmoService::build_config(&SLIM, Some(&profile_data));\n        let obj = merged.as_object().unwrap();\n\n        // Slim should NOT include categories\n        assert!(!obj.contains_key(\"categories\"));\n\n        // Slim SHOULD include these\n        assert_eq!(obj[\"$schema\"], \"https://slim.schema\");\n        assert!(obj.contains_key(\"agents\"));\n        assert!(obj.contains_key(\"disabled_agents\"));\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/services/prompt.rs",
    "content": "use indexmap::IndexMap;\n\nuse crate::app_config::AppType;\nuse crate::config::write_text_file;\nuse crate::error::AppError;\nuse crate::prompt::Prompt;\nuse crate::prompt_files::prompt_file_path;\nuse crate::store::AppState;\n\n/// 安全地获取当前 Unix 时间戳\nfn get_unix_timestamp() -> Result<i64, AppError> {\n    std::time::SystemTime::now()\n        .duration_since(std::time::UNIX_EPOCH)\n        .map(|d| d.as_secs() as i64)\n        .map_err(|e| AppError::Message(format!(\"Failed to get system time: {e}\")))\n}\n\npub struct PromptService;\n\nimpl PromptService {\n    pub fn get_prompts(\n        state: &AppState,\n        app: AppType,\n    ) -> Result<IndexMap<String, Prompt>, AppError> {\n        state.db.get_prompts(app.as_str())\n    }\n\n    pub fn upsert_prompt(\n        state: &AppState,\n        app: AppType,\n        _id: &str,\n        prompt: Prompt,\n    ) -> Result<(), AppError> {\n        // 检查是否为已启用的提示词\n        let is_enabled = prompt.enabled;\n\n        state.db.save_prompt(app.as_str(), &prompt)?;\n\n        if is_enabled {\n            // 启用提示词：写入内容到文件\n            let target_path = prompt_file_path(&app)?;\n            write_text_file(&target_path, &prompt.content)?;\n        } else {\n            // 禁用提示词：检查是否还有其他已启用的提示词\n            let prompts = state.db.get_prompts(app.as_str())?;\n            let any_enabled = prompts.values().any(|p| p.enabled);\n\n            if !any_enabled {\n                // 所有提示词都已禁用，清空文件\n                let target_path = prompt_file_path(&app)?;\n                if target_path.exists() {\n                    write_text_file(&target_path, \"\")?;\n                }\n            }\n        }\n\n        Ok(())\n    }\n\n    pub fn delete_prompt(state: &AppState, app: AppType, id: &str) -> Result<(), AppError> {\n        let prompts = state.db.get_prompts(app.as_str())?;\n\n        if let Some(prompt) = prompts.get(id) {\n            if prompt.enabled {\n                return Err(AppError::InvalidInput(\"无法删除已启用的提示词\".to_string()));\n            }\n        }\n\n        state.db.delete_prompt(app.as_str(), id)?;\n        Ok(())\n    }\n\n    pub fn enable_prompt(state: &AppState, app: AppType, id: &str) -> Result<(), AppError> {\n        // 回填当前 live 文件内容到已启用的提示词，或创建备份\n        let target_path = prompt_file_path(&app)?;\n        if target_path.exists() {\n            if let Ok(live_content) = std::fs::read_to_string(&target_path) {\n                if !live_content.trim().is_empty() {\n                    let mut prompts = state.db.get_prompts(app.as_str())?;\n\n                    // 尝试回填到当前已启用的提示词\n                    if let Some((enabled_id, enabled_prompt)) = prompts\n                        .iter_mut()\n                        .find(|(_, p)| p.enabled)\n                        .map(|(id, p)| (id.clone(), p))\n                    {\n                        let timestamp = get_unix_timestamp()?;\n                        enabled_prompt.content = live_content.clone();\n                        enabled_prompt.updated_at = Some(timestamp);\n                        log::info!(\"回填 live 提示词内容到已启用项: {enabled_id}\");\n                        state.db.save_prompt(app.as_str(), enabled_prompt)?;\n                    } else {\n                        // 没有已启用的提示词，则创建一次备份（避免重复备份）\n                        let content_exists = prompts\n                            .values()\n                            .any(|p| p.content.trim() == live_content.trim());\n                        if !content_exists {\n                            let timestamp = std::time::SystemTime::now()\n                                .duration_since(std::time::UNIX_EPOCH)\n                                .unwrap_or_default()\n                                .as_secs() as i64;\n                            let backup_id = format!(\"backup-{timestamp}\");\n                            let backup_prompt = Prompt {\n                                id: backup_id.clone(),\n                                name: format!(\n                                    \"原始提示词 {}\",\n                                    chrono::Local::now().format(\"%Y-%m-%d %H:%M\")\n                                ),\n                                content: live_content,\n                                description: Some(\"自动备份的原始提示词\".to_string()),\n                                enabled: false,\n                                created_at: Some(timestamp),\n                                updated_at: Some(timestamp),\n                            };\n                            log::info!(\"回填 live 提示词内容，创建备份: {backup_id}\");\n                            state.db.save_prompt(app.as_str(), &backup_prompt)?;\n                        }\n                    }\n                }\n            }\n        }\n\n        // 启用目标提示词并写入文件\n        let mut prompts = state.db.get_prompts(app.as_str())?;\n\n        for prompt in prompts.values_mut() {\n            prompt.enabled = false;\n        }\n\n        if let Some(prompt) = prompts.get_mut(id) {\n            prompt.enabled = true;\n            write_text_file(&target_path, &prompt.content)?; // 原子写入\n            state.db.save_prompt(app.as_str(), prompt)?;\n        } else {\n            return Err(AppError::InvalidInput(format!(\"提示词 {id} 不存在\")));\n        }\n\n        // Save all prompts to disable others\n        for (_, prompt) in prompts.iter() {\n            state.db.save_prompt(app.as_str(), prompt)?;\n        }\n\n        Ok(())\n    }\n\n    pub fn import_from_file(state: &AppState, app: AppType) -> Result<String, AppError> {\n        let file_path = prompt_file_path(&app)?;\n\n        if !file_path.exists() {\n            return Err(AppError::Message(\"提示词文件不存在\".to_string()));\n        }\n\n        let content =\n            std::fs::read_to_string(&file_path).map_err(|e| AppError::io(&file_path, e))?;\n        let timestamp = get_unix_timestamp()?;\n\n        let id = format!(\"imported-{timestamp}\");\n        let prompt = Prompt {\n            id: id.clone(),\n            name: format!(\n                \"导入的提示词 {}\",\n                chrono::Local::now().format(\"%Y-%m-%d %H:%M\")\n            ),\n            content,\n            description: Some(\"从现有配置文件导入\".to_string()),\n            enabled: false,\n            created_at: Some(timestamp),\n            updated_at: Some(timestamp),\n        };\n\n        Self::upsert_prompt(state, app, &id, prompt)?;\n        Ok(id)\n    }\n\n    pub fn get_current_file_content(app: AppType) -> Result<Option<String>, AppError> {\n        let file_path = prompt_file_path(&app)?;\n        if !file_path.exists() {\n            return Ok(None);\n        }\n        let content =\n            std::fs::read_to_string(&file_path).map_err(|e| AppError::io(&file_path, e))?;\n        Ok(Some(content))\n    }\n\n    /// 首次启动时从现有提示词文件自动导入（如果存在）\n    /// 返回导入的数量\n    pub fn import_from_file_on_first_launch(\n        state: &AppState,\n        app: AppType,\n    ) -> Result<usize, AppError> {\n        // 幂等性保护：该应用已有提示词则跳过\n        let existing = state.db.get_prompts(app.as_str())?;\n        if !existing.is_empty() {\n            return Ok(0);\n        }\n\n        let file_path = prompt_file_path(&app)?;\n\n        // 检查文件是否存在\n        if !file_path.exists() {\n            return Ok(0);\n        }\n\n        // 读取文件内容\n        let content = match std::fs::read_to_string(&file_path) {\n            Ok(c) => c,\n            Err(e) => {\n                log::warn!(\"读取提示词文件失败: {file_path:?}, 错误: {e}\");\n                return Ok(0);\n            }\n        };\n\n        // 检查内容是否为空\n        if content.trim().is_empty() {\n            return Ok(0);\n        }\n\n        log::info!(\"发现提示词文件，自动导入: {file_path:?}\");\n\n        // 创建提示词对象\n        let timestamp = get_unix_timestamp()?;\n        let id = format!(\"auto-imported-{timestamp}\");\n        let prompt = Prompt {\n            id: id.clone(),\n            name: format!(\n                \"Auto-imported Prompt {}\",\n                chrono::Local::now().format(\"%Y-%m-%d %H:%M\")\n            ),\n            content,\n            description: Some(\"Automatically imported on first launch\".to_string()),\n            enabled: true, // 首次导入时自动启用\n            created_at: Some(timestamp),\n            updated_at: Some(timestamp),\n        };\n\n        // 保存到数据库\n        state.db.save_prompt(app.as_str(), &prompt)?;\n\n        log::info!(\"自动导入完成: {}\", app.as_str());\n        Ok(1)\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/services/provider/endpoints.rs",
    "content": "//! Custom endpoints management\n//!\n//! Handles CRUD operations for provider custom endpoints.\n\nuse std::time::{SystemTime, UNIX_EPOCH};\n\nuse crate::app_config::AppType;\nuse crate::error::AppError;\nuse crate::settings::CustomEndpoint;\nuse crate::store::AppState;\n\n/// Get custom endpoints list for a provider\npub fn get_custom_endpoints(\n    state: &AppState,\n    app_type: AppType,\n    provider_id: &str,\n) -> Result<Vec<CustomEndpoint>, AppError> {\n    let providers = state.db.get_all_providers(app_type.as_str())?;\n    let Some(provider) = providers.get(provider_id) else {\n        return Ok(vec![]);\n    };\n    let Some(meta) = provider.meta.as_ref() else {\n        return Ok(vec![]);\n    };\n    if meta.custom_endpoints.is_empty() {\n        return Ok(vec![]);\n    }\n\n    let mut result: Vec<_> = meta.custom_endpoints.values().cloned().collect();\n    result.sort_by(|a, b| b.added_at.cmp(&a.added_at));\n    Ok(result)\n}\n\n/// Add a custom endpoint to a provider\npub fn add_custom_endpoint(\n    state: &AppState,\n    app_type: AppType,\n    provider_id: &str,\n    url: String,\n) -> Result<(), AppError> {\n    let normalized = url.trim().trim_end_matches('/').to_string();\n    if normalized.is_empty() {\n        return Err(AppError::localized(\n            \"provider.endpoint.url_required\",\n            \"URL 不能为空\",\n            \"URL cannot be empty\",\n        ));\n    }\n\n    state\n        .db\n        .add_custom_endpoint(app_type.as_str(), provider_id, &normalized)?;\n    Ok(())\n}\n\n/// Remove a custom endpoint from a provider\npub fn remove_custom_endpoint(\n    state: &AppState,\n    app_type: AppType,\n    provider_id: &str,\n    url: String,\n) -> Result<(), AppError> {\n    let normalized = url.trim().trim_end_matches('/').to_string();\n    state\n        .db\n        .remove_custom_endpoint(app_type.as_str(), provider_id, &normalized)?;\n    Ok(())\n}\n\n/// Update endpoint last used timestamp\npub fn update_endpoint_last_used(\n    state: &AppState,\n    app_type: AppType,\n    provider_id: &str,\n    url: String,\n) -> Result<(), AppError> {\n    let normalized = url.trim().trim_end_matches('/').to_string();\n\n    // Get provider, update last_used, save back\n    let mut providers = state.db.get_all_providers(app_type.as_str())?;\n    if let Some(provider) = providers.get_mut(provider_id) {\n        if let Some(meta) = provider.meta.as_mut() {\n            if let Some(endpoint) = meta.custom_endpoints.get_mut(&normalized) {\n                endpoint.last_used = Some(now_millis());\n                state.db.save_provider(app_type.as_str(), provider)?;\n            }\n        }\n    }\n    Ok(())\n}\n\n/// Get current timestamp in milliseconds\nfn now_millis() -> i64 {\n    SystemTime::now()\n        .duration_since(UNIX_EPOCH)\n        .unwrap_or_default()\n        .as_millis() as i64\n}\n"
  },
  {
    "path": "src-tauri/src/services/provider/gemini_auth.rs",
    "content": "//! Gemini authentication type detection\n//!\n//! Detects whether a Gemini provider uses PackyCode API Key, Google OAuth, or generic API Key.\n\nuse crate::error::AppError;\nuse crate::provider::Provider;\n\n/// Gemini authentication type enumeration\n///\n/// Used to optimize performance by avoiding repeated provider type detection.\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub(crate) enum GeminiAuthType {\n    /// PackyCode provider (uses API Key)\n    Packycode,\n    /// Google Official (uses OAuth)\n    GoogleOfficial,\n    /// Generic Gemini provider (uses API Key)\n    Generic,\n}\n\n// Partner Promotion Key constants\nconst PACKYCODE_PARTNER_KEY: &str = \"packycode\";\nconst GOOGLE_OFFICIAL_PARTNER_KEY: &str = \"google-official\";\n\n// PackyCode keyword constants\nconst PACKYCODE_KEYWORDS: [&str; 3] = [\"packycode\", \"packyapi\", \"packy\"];\n\n/// Detect Gemini provider authentication type\n///\n/// One-time detection to avoid repeated calls to `is_packycode_gemini` and `is_google_official_gemini`.\n///\n/// # Returns\n///\n/// - `GeminiAuthType::GoogleOfficial`: Google official, uses OAuth\n/// - `GeminiAuthType::Packycode`: PackyCode provider, uses API Key\n/// - `GeminiAuthType::Generic`: Other generic providers, uses API Key\npub(crate) fn detect_gemini_auth_type(provider: &Provider) -> GeminiAuthType {\n    // Priority 1: Check partner_promotion_key (most reliable)\n    if let Some(key) = provider\n        .meta\n        .as_ref()\n        .and_then(|meta| meta.partner_promotion_key.as_deref())\n    {\n        if key.eq_ignore_ascii_case(GOOGLE_OFFICIAL_PARTNER_KEY) {\n            return GeminiAuthType::GoogleOfficial;\n        }\n        if key.eq_ignore_ascii_case(PACKYCODE_PARTNER_KEY) {\n            return GeminiAuthType::Packycode;\n        }\n    }\n\n    // Priority 2: Check Google Official (name matching)\n    let name_lower = provider.name.to_ascii_lowercase();\n    if name_lower == \"google\" || name_lower.starts_with(\"google \") {\n        return GeminiAuthType::GoogleOfficial;\n    }\n\n    // Priority 3: Check PackyCode keywords\n    if contains_packycode_keyword(&provider.name) {\n        return GeminiAuthType::Packycode;\n    }\n\n    if let Some(site) = provider.website_url.as_deref() {\n        if contains_packycode_keyword(site) {\n            return GeminiAuthType::Packycode;\n        }\n    }\n\n    if let Some(base_url) = provider\n        .settings_config\n        .pointer(\"/env/GOOGLE_GEMINI_BASE_URL\")\n        .and_then(|v| v.as_str())\n    {\n        if contains_packycode_keyword(base_url) {\n            return GeminiAuthType::Packycode;\n        }\n    }\n\n    GeminiAuthType::Generic\n}\n\n/// Check if string contains PackyCode related keywords (case-insensitive)\n///\n/// Keyword list: [\"packycode\", \"packyapi\", \"packy\"]\nfn contains_packycode_keyword(value: &str) -> bool {\n    let lower = value.to_ascii_lowercase();\n    PACKYCODE_KEYWORDS\n        .iter()\n        .any(|keyword| lower.contains(keyword))\n}\n\n/// Detect if provider is Google Official Gemini (uses OAuth authentication)\n///\n/// Google Official Gemini uses OAuth personal authentication, no API Key needed.\n///\n/// This is a convenience wrapper around `detect_gemini_auth_type`.\npub(crate) fn is_google_official_gemini(provider: &Provider) -> bool {\n    detect_gemini_auth_type(provider) == GeminiAuthType::GoogleOfficial\n}\n\n/// Ensure Google Official Gemini provider security flag is correctly set (OAuth mode)\n///\n/// Google Official Gemini uses OAuth personal authentication, no API Key needed.\n///\n/// # What it does\n///\n/// Writes to **`~/.gemini/settings.json`** (Gemini client config).\n///\n/// # Value set\n///\n/// ```json\n/// {\n///   \"security\": {\n///     \"auth\": {\n///       \"selectedType\": \"oauth-personal\"\n///     }\n///   }\n/// }\n/// ```\n///\n/// # OAuth authentication flow\n///\n/// 1. User switches to Google Official provider\n/// 2. CC-Switch sets `selectedType = \"oauth-personal\"`\n/// 3. User's first use of Gemini CLI will auto-open browser for OAuth login\n/// 4. After successful login, credentials saved in Gemini credential store\n/// 5. Subsequent requests auto-use saved credentials\n///\n/// # Error handling\n///\n/// If provider is not Google Official, function returns `Ok(())` immediately without any operation.\npub(crate) fn ensure_google_oauth_security_flag(provider: &Provider) -> Result<(), AppError> {\n    if !is_google_official_gemini(provider) {\n        return Ok(());\n    }\n\n    // Write to Gemini directory settings.json (~/.gemini/settings.json)\n    use crate::gemini_config::write_google_oauth_settings;\n    write_google_oauth_settings()?;\n\n    Ok(())\n}\n"
  },
  {
    "path": "src-tauri/src/services/provider/live.rs",
    "content": "//! Live configuration operations\n//!\n//! Handles reading and writing live configuration files for Claude, Codex, and Gemini.\n\nuse std::collections::HashMap;\n\nuse serde_json::{json, Value};\nuse toml_edit::{DocumentMut, Item, TableLike};\n\nuse crate::app_config::AppType;\nuse crate::codex_config::{get_codex_auth_path, get_codex_config_path};\nuse crate::config::{delete_file, get_claude_settings_path, read_json_file, write_json_file};\nuse crate::database::Database;\nuse crate::error::AppError;\nuse crate::provider::Provider;\nuse crate::services::mcp::McpService;\nuse crate::store::AppState;\n\nuse super::gemini_auth::{\n    detect_gemini_auth_type, ensure_google_oauth_security_flag, GeminiAuthType,\n};\nuse super::normalize_claude_models_in_value;\n\npub(crate) fn sanitize_claude_settings_for_live(settings: &Value) -> Value {\n    let mut v = settings.clone();\n    if let Some(obj) = v.as_object_mut() {\n        // Internal-only fields - never write to Claude Code settings.json\n        obj.remove(\"api_format\");\n        obj.remove(\"apiFormat\");\n        obj.remove(\"openrouter_compat_mode\");\n        obj.remove(\"openrouterCompatMode\");\n    }\n    v\n}\n\nfn json_is_subset(target: &Value, source: &Value) -> bool {\n    match source {\n        Value::Object(source_map) => {\n            let Some(target_map) = target.as_object() else {\n                return false;\n            };\n            source_map.iter().all(|(key, source_value)| {\n                target_map\n                    .get(key)\n                    .is_some_and(|target_value| json_is_subset(target_value, source_value))\n            })\n        }\n        Value::Array(source_arr) => {\n            let Some(target_arr) = target.as_array() else {\n                return false;\n            };\n            json_array_contains_subset(target_arr, source_arr)\n        }\n        _ => target == source,\n    }\n}\n\nfn json_array_contains_subset(target_arr: &[Value], source_arr: &[Value]) -> bool {\n    let mut matched = vec![false; target_arr.len()];\n\n    source_arr.iter().all(|source_item| {\n        if let Some((index, _)) = target_arr.iter().enumerate().find(|(index, target_item)| {\n            !matched[*index] && json_is_subset(target_item, source_item)\n        }) {\n            matched[index] = true;\n            true\n        } else {\n            false\n        }\n    })\n}\n\nfn json_remove_array_items(target_arr: &mut Vec<Value>, source_arr: &[Value]) {\n    for source_item in source_arr {\n        if let Some(index) = target_arr\n            .iter()\n            .position(|target_item| json_is_subset(target_item, source_item))\n        {\n            target_arr.remove(index);\n        }\n    }\n}\n\nfn json_deep_merge(target: &mut Value, source: &Value) {\n    match (target, source) {\n        (Value::Object(target_map), Value::Object(source_map)) => {\n            for (key, source_value) in source_map {\n                match target_map.get_mut(key) {\n                    Some(target_value) => json_deep_merge(target_value, source_value),\n                    None => {\n                        target_map.insert(key.clone(), source_value.clone());\n                    }\n                }\n            }\n        }\n        (target_value, source_value) => {\n            *target_value = source_value.clone();\n        }\n    }\n}\n\nfn json_deep_remove(target: &mut Value, source: &Value) {\n    let (Some(target_map), Some(source_map)) = (target.as_object_mut(), source.as_object()) else {\n        return;\n    };\n\n    for (key, source_value) in source_map {\n        let mut remove_key = false;\n\n        if let Some(target_value) = target_map.get_mut(key) {\n            if source_value.is_object() && target_value.is_object() {\n                json_deep_remove(target_value, source_value);\n                remove_key = target_value.as_object().is_some_and(|obj| obj.is_empty());\n            } else if let (Some(target_arr), Some(source_arr)) =\n                (target_value.as_array_mut(), source_value.as_array())\n            {\n                json_remove_array_items(target_arr, source_arr);\n                remove_key = target_arr.is_empty();\n            } else if json_is_subset(target_value, source_value) {\n                remove_key = true;\n            }\n        }\n\n        if remove_key {\n            target_map.remove(key);\n        }\n    }\n}\n\nfn toml_value_is_subset(target: &toml_edit::Value, source: &toml_edit::Value) -> bool {\n    match (target, source) {\n        (toml_edit::Value::String(target), toml_edit::Value::String(source)) => {\n            target.value() == source.value()\n        }\n        (toml_edit::Value::Integer(target), toml_edit::Value::Integer(source)) => {\n            target.value() == source.value()\n        }\n        (toml_edit::Value::Float(target), toml_edit::Value::Float(source)) => {\n            target.value() == source.value()\n        }\n        (toml_edit::Value::Boolean(target), toml_edit::Value::Boolean(source)) => {\n            target.value() == source.value()\n        }\n        (toml_edit::Value::Datetime(target), toml_edit::Value::Datetime(source)) => {\n            target.value() == source.value()\n        }\n        (toml_edit::Value::Array(target), toml_edit::Value::Array(source)) => {\n            toml_array_contains_subset(target, source)\n        }\n        (toml_edit::Value::InlineTable(target), toml_edit::Value::InlineTable(source)) => {\n            source.iter().all(|(key, source_item)| {\n                target\n                    .get(key)\n                    .is_some_and(|target_item| toml_value_is_subset(target_item, source_item))\n            })\n        }\n        _ => false,\n    }\n}\n\nfn toml_array_contains_subset(target: &toml_edit::Array, source: &toml_edit::Array) -> bool {\n    let mut matched = vec![false; target.len()];\n    let target_items: Vec<&toml_edit::Value> = target.iter().collect();\n\n    source.iter().all(|source_item| {\n        if let Some((index, _)) = target_items\n            .iter()\n            .enumerate()\n            .find(|(index, target_item)| {\n                !matched[*index] && toml_value_is_subset(target_item, source_item)\n            })\n        {\n            matched[index] = true;\n            true\n        } else {\n            false\n        }\n    })\n}\n\nfn toml_remove_array_items(target: &mut toml_edit::Array, source: &toml_edit::Array) {\n    for source_item in source.iter() {\n        let index = {\n            let target_items: Vec<&toml_edit::Value> = target.iter().collect();\n            target_items\n                .iter()\n                .enumerate()\n                .find(|(_, target_item)| toml_value_is_subset(target_item, source_item))\n                .map(|(index, _)| index)\n        };\n\n        if let Some(index) = index {\n            target.remove(index);\n        }\n    }\n}\n\nfn toml_item_is_subset(target: &Item, source: &Item) -> bool {\n    if let Some(source_table) = source.as_table_like() {\n        let Some(target_table) = target.as_table_like() else {\n            return false;\n        };\n        return source_table.iter().all(|(key, source_item)| {\n            target_table\n                .get(key)\n                .is_some_and(|target_item| toml_item_is_subset(target_item, source_item))\n        });\n    }\n\n    match (target.as_value(), source.as_value()) {\n        (Some(target_value), Some(source_value)) => {\n            toml_value_is_subset(target_value, source_value)\n        }\n        _ => false,\n    }\n}\n\nfn merge_toml_item(target: &mut Item, source: &Item) {\n    if let Some(source_table) = source.as_table_like() {\n        if let Some(target_table) = target.as_table_like_mut() {\n            merge_toml_table_like(target_table, source_table);\n            return;\n        }\n    }\n\n    *target = source.clone();\n}\n\nfn merge_toml_table_like(target: &mut dyn TableLike, source: &dyn TableLike) {\n    for (key, source_item) in source.iter() {\n        match target.get_mut(key) {\n            Some(target_item) => merge_toml_item(target_item, source_item),\n            None => {\n                target.insert(key, source_item.clone());\n            }\n        }\n    }\n}\n\nfn remove_toml_item(target: &mut Item, source: &Item) {\n    if let Some(source_table) = source.as_table_like() {\n        if let Some(target_table) = target.as_table_like_mut() {\n            remove_toml_table_like(target_table, source_table);\n            if target_table.is_empty() {\n                *target = Item::None;\n            }\n            return;\n        }\n    }\n\n    if let Some(source_value) = source.as_value() {\n        let mut remove_item = false;\n\n        if let Some(target_value) = target.as_value_mut() {\n            match (target_value, source_value) {\n                (toml_edit::Value::Array(target_arr), toml_edit::Value::Array(source_arr)) => {\n                    toml_remove_array_items(target_arr, source_arr);\n                    remove_item = target_arr.is_empty();\n                }\n                (target_value, source_value)\n                    if toml_value_is_subset(target_value, source_value) =>\n                {\n                    remove_item = true;\n                }\n                _ => {}\n            }\n        }\n\n        if remove_item {\n            *target = Item::None;\n        }\n    }\n}\n\nfn remove_toml_table_like(target: &mut dyn TableLike, source: &dyn TableLike) {\n    let keys: Vec<String> = source.iter().map(|(key, _)| key.to_string()).collect();\n\n    for key in keys {\n        let mut remove_key = false;\n        if let (Some(target_item), Some(source_item)) = (target.get_mut(&key), source.get(&key)) {\n            remove_toml_item(target_item, source_item);\n            remove_key = target_item.is_none()\n                || target_item\n                    .as_table_like()\n                    .is_some_and(|table_like| table_like.is_empty());\n        }\n\n        if remove_key {\n            target.remove(&key);\n        }\n    }\n}\n\nfn settings_contain_common_config(app_type: &AppType, settings: &Value, snippet: &str) -> bool {\n    let trimmed = snippet.trim();\n    if trimmed.is_empty() {\n        return false;\n    }\n\n    match app_type {\n        AppType::Claude => match serde_json::from_str::<Value>(trimmed) {\n            Ok(source) if source.is_object() => json_is_subset(settings, &source),\n            _ => false,\n        },\n        AppType::Codex => {\n            let config_toml = settings.get(\"config\").and_then(Value::as_str).unwrap_or(\"\");\n            if config_toml.trim().is_empty() {\n                return false;\n            }\n\n            let target_doc = match config_toml.parse::<DocumentMut>() {\n                Ok(doc) => doc,\n                Err(_) => return false,\n            };\n            let source_doc = match trimmed.parse::<DocumentMut>() {\n                Ok(doc) => doc,\n                Err(_) => return false,\n            };\n\n            toml_item_is_subset(target_doc.as_item(), source_doc.as_item())\n        }\n        AppType::Gemini => match serde_json::from_str::<Value>(trimmed) {\n            Ok(Value::Object(source_map)) => {\n                let Some(target_map) = settings.get(\"env\").and_then(Value::as_object) else {\n                    return false;\n                };\n                source_map.iter().all(|(key, source_value)| {\n                    target_map\n                        .get(key)\n                        .is_some_and(|target_value| json_is_subset(target_value, source_value))\n                })\n            }\n            _ => false,\n        },\n        AppType::OpenCode | AppType::OpenClaw => false,\n    }\n}\n\npub(crate) fn provider_uses_common_config(\n    app_type: &AppType,\n    provider: &Provider,\n    snippet: Option<&str>,\n) -> bool {\n    match provider\n        .meta\n        .as_ref()\n        .and_then(|meta| meta.common_config_enabled)\n    {\n        Some(explicit) => explicit && snippet.is_some_and(|value| !value.trim().is_empty()),\n        None => snippet.is_some_and(|value| {\n            settings_contain_common_config(app_type, &provider.settings_config, value)\n        }),\n    }\n}\n\npub(crate) fn remove_common_config_from_settings(\n    app_type: &AppType,\n    settings: &Value,\n    snippet: &str,\n) -> Result<Value, AppError> {\n    let trimmed = snippet.trim();\n    if trimmed.is_empty() {\n        return Ok(settings.clone());\n    }\n\n    match app_type {\n        AppType::Claude => {\n            let source = serde_json::from_str::<Value>(trimmed)\n                .map_err(|e| AppError::Message(format!(\"Invalid Claude common config: {e}\")))?;\n            let mut result = settings.clone();\n            json_deep_remove(&mut result, &source);\n            Ok(result)\n        }\n        AppType::Codex => {\n            let mut result = settings.clone();\n            let config_toml = settings.get(\"config\").and_then(Value::as_str).unwrap_or(\"\");\n            let mut target_doc = if config_toml.trim().is_empty() {\n                DocumentMut::new()\n            } else {\n                config_toml.parse::<DocumentMut>().map_err(|e| {\n                    AppError::Message(format!(\n                        \"Invalid Codex config.toml while removing common config: {e}\"\n                    ))\n                })?\n            };\n            let source_doc = trimmed.parse::<DocumentMut>().map_err(|e| {\n                AppError::Message(format!(\"Invalid Codex common config snippet: {e}\"))\n            })?;\n\n            remove_toml_table_like(target_doc.as_table_mut(), source_doc.as_table());\n            if let Some(obj) = result.as_object_mut() {\n                obj.insert(\"config\".to_string(), Value::String(target_doc.to_string()));\n            }\n            Ok(result)\n        }\n        AppType::Gemini => {\n            let source = serde_json::from_str::<Value>(trimmed)\n                .map_err(|e| AppError::Message(format!(\"Invalid Gemini common config: {e}\")))?;\n            let mut result = settings.clone();\n            if let Some(env) = result.get_mut(\"env\") {\n                json_deep_remove(env, &source);\n            }\n            Ok(result)\n        }\n        AppType::OpenCode | AppType::OpenClaw => Ok(settings.clone()),\n    }\n}\n\nfn apply_common_config_to_settings(\n    app_type: &AppType,\n    settings: &Value,\n    snippet: &str,\n) -> Result<Value, AppError> {\n    let trimmed = snippet.trim();\n    if trimmed.is_empty() {\n        return Ok(settings.clone());\n    }\n\n    match app_type {\n        AppType::Claude => {\n            let source = serde_json::from_str::<Value>(trimmed)\n                .map_err(|e| AppError::Message(format!(\"Invalid Claude common config: {e}\")))?;\n            let mut result = settings.clone();\n            json_deep_merge(&mut result, &source);\n            Ok(result)\n        }\n        AppType::Codex => {\n            let mut result = settings.clone();\n            let config_toml = settings.get(\"config\").and_then(Value::as_str).unwrap_or(\"\");\n            let mut target_doc = if config_toml.trim().is_empty() {\n                DocumentMut::new()\n            } else {\n                config_toml.parse::<DocumentMut>().map_err(|e| {\n                    AppError::Message(format!(\n                        \"Invalid Codex config.toml while applying common config: {e}\"\n                    ))\n                })?\n            };\n            let source_doc = trimmed.parse::<DocumentMut>().map_err(|e| {\n                AppError::Message(format!(\"Invalid Codex common config snippet: {e}\"))\n            })?;\n\n            merge_toml_table_like(target_doc.as_table_mut(), source_doc.as_table());\n            if let Some(obj) = result.as_object_mut() {\n                obj.insert(\"config\".to_string(), Value::String(target_doc.to_string()));\n            }\n            Ok(result)\n        }\n        AppType::Gemini => {\n            let source = serde_json::from_str::<Value>(trimmed)\n                .map_err(|e| AppError::Message(format!(\"Invalid Gemini common config: {e}\")))?;\n            let mut result = settings.clone();\n            if let Some(env) = result.get_mut(\"env\") {\n                json_deep_merge(env, &source);\n            } else if let Some(obj) = result.as_object_mut() {\n                obj.insert(\"env\".to_string(), source);\n            }\n            Ok(result)\n        }\n        AppType::OpenCode | AppType::OpenClaw => Ok(settings.clone()),\n    }\n}\n\npub(crate) fn build_effective_settings_with_common_config(\n    db: &Database,\n    app_type: &AppType,\n    provider: &Provider,\n) -> Result<Value, AppError> {\n    let snippet = db.get_config_snippet(app_type.as_str())?;\n    let mut effective_settings = provider.settings_config.clone();\n\n    if provider_uses_common_config(app_type, provider, snippet.as_deref()) {\n        if let Some(snippet_text) = snippet.as_deref() {\n            match apply_common_config_to_settings(app_type, &effective_settings, snippet_text) {\n                Ok(settings) => effective_settings = settings,\n                Err(err) => {\n                    log::warn!(\n                        \"Failed to apply common config for {} provider '{}': {err}\",\n                        app_type.as_str(),\n                        provider.id\n                    );\n                }\n            }\n        }\n    }\n\n    Ok(effective_settings)\n}\n\npub(crate) fn write_live_with_common_config(\n    db: &Database,\n    app_type: &AppType,\n    provider: &Provider,\n) -> Result<(), AppError> {\n    let mut effective_provider = provider.clone();\n    effective_provider.settings_config =\n        build_effective_settings_with_common_config(db, app_type, provider)?;\n\n    write_live_snapshot(app_type, &effective_provider)\n}\n\npub(crate) fn strip_common_config_from_live_settings(\n    db: &Database,\n    app_type: &AppType,\n    provider: &Provider,\n    live_settings: Value,\n) -> Value {\n    let snippet = match db.get_config_snippet(app_type.as_str()) {\n        Ok(snippet) => snippet,\n        Err(err) => {\n            log::warn!(\n                \"Failed to load common config for {} while backfilling '{}': {err}\",\n                app_type.as_str(),\n                provider.id\n            );\n            return live_settings;\n        }\n    };\n\n    if !provider_uses_common_config(app_type, provider, snippet.as_deref()) {\n        return live_settings;\n    }\n\n    let Some(snippet_text) = snippet.as_deref() else {\n        return live_settings;\n    };\n\n    match remove_common_config_from_settings(app_type, &live_settings, snippet_text) {\n        Ok(settings) => settings,\n        Err(err) => {\n            log::warn!(\n                \"Failed to strip common config for {} provider '{}': {err}\",\n                app_type.as_str(),\n                provider.id\n            );\n            live_settings\n        }\n    }\n}\n\npub(crate) fn normalize_provider_common_config_for_storage(\n    db: &Database,\n    app_type: &AppType,\n    provider: &mut Provider,\n) -> Result<(), AppError> {\n    let uses_common_config = provider\n        .meta\n        .as_ref()\n        .and_then(|meta| meta.common_config_enabled)\n        .unwrap_or(false);\n\n    if !uses_common_config {\n        return Ok(());\n    }\n\n    let Some(snippet) = db.get_config_snippet(app_type.as_str())? else {\n        return Ok(());\n    };\n\n    if snippet.trim().is_empty() {\n        return Ok(());\n    }\n\n    match remove_common_config_from_settings(app_type, &provider.settings_config, &snippet) {\n        Ok(settings) => provider.settings_config = settings,\n        Err(err) => {\n            log::warn!(\n                \"Failed to normalize common config before saving {} provider '{}': {err}\",\n                app_type.as_str(),\n                provider.id\n            );\n        }\n    }\n\n    Ok(())\n}\n\n/// Live configuration snapshot for backup/restore\n#[derive(Clone)]\n#[allow(dead_code)]\npub(crate) enum LiveSnapshot {\n    Claude {\n        settings: Option<Value>,\n    },\n    Codex {\n        auth: Option<Value>,\n        config: Option<String>,\n    },\n    Gemini {\n        env: Option<HashMap<String, String>>,\n        config: Option<Value>,\n    },\n}\n\nimpl LiveSnapshot {\n    #[allow(dead_code)]\n    pub(crate) fn restore(&self) -> Result<(), AppError> {\n        match self {\n            LiveSnapshot::Claude { settings } => {\n                let path = get_claude_settings_path();\n                if let Some(value) = settings {\n                    write_json_file(&path, value)?;\n                } else if path.exists() {\n                    delete_file(&path)?;\n                }\n            }\n            LiveSnapshot::Codex { auth, config } => {\n                let auth_path = get_codex_auth_path();\n                let config_path = get_codex_config_path();\n                if let Some(value) = auth {\n                    write_json_file(&auth_path, value)?;\n                } else if auth_path.exists() {\n                    delete_file(&auth_path)?;\n                }\n\n                if let Some(text) = config {\n                    crate::config::write_text_file(&config_path, text)?;\n                } else if config_path.exists() {\n                    delete_file(&config_path)?;\n                }\n            }\n            LiveSnapshot::Gemini { env, .. } => {\n                use crate::gemini_config::{\n                    get_gemini_env_path, get_gemini_settings_path, write_gemini_env_atomic,\n                };\n                let path = get_gemini_env_path();\n                if let Some(env_map) = env {\n                    write_gemini_env_atomic(env_map)?;\n                } else if path.exists() {\n                    delete_file(&path)?;\n                }\n\n                let settings_path = get_gemini_settings_path();\n                match self {\n                    LiveSnapshot::Gemini {\n                        config: Some(cfg), ..\n                    } => {\n                        write_json_file(&settings_path, cfg)?;\n                    }\n                    LiveSnapshot::Gemini { config: None, .. } if settings_path.exists() => {\n                        delete_file(&settings_path)?;\n                    }\n                    _ => {}\n                }\n            }\n        }\n        Ok(())\n    }\n}\n\n/// Write live configuration snapshot for a provider\npub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Result<(), AppError> {\n    match app_type {\n        AppType::Claude => {\n            let path = get_claude_settings_path();\n            let settings = sanitize_claude_settings_for_live(&provider.settings_config);\n            write_json_file(&path, &settings)?;\n        }\n        AppType::Codex => {\n            let obj = provider\n                .settings_config\n                .as_object()\n                .ok_or_else(|| AppError::Config(\"Codex 供应商配置必须是 JSON 对象\".to_string()))?;\n            let auth = obj\n                .get(\"auth\")\n                .ok_or_else(|| AppError::Config(\"Codex 供应商配置缺少 'auth' 字段\".to_string()))?;\n            let config_str = obj.get(\"config\").and_then(|v| v.as_str()).ok_or_else(|| {\n                AppError::Config(\"Codex 供应商配置缺少 'config' 字段或不是字符串\".to_string())\n            })?;\n\n            let auth_path = get_codex_auth_path();\n            write_json_file(&auth_path, auth)?;\n            let config_path = get_codex_config_path();\n            std::fs::write(&config_path, config_str).map_err(|e| AppError::io(&config_path, e))?;\n        }\n        AppType::Gemini => {\n            // Delegate to write_gemini_live which handles env file writing correctly\n            write_gemini_live(provider)?;\n        }\n        AppType::OpenCode => {\n            // OpenCode uses additive mode - write provider to config\n            use crate::opencode_config;\n            use crate::provider::OpenCodeProviderConfig;\n\n            // Defensive check: if settings_config is a full config structure, extract provider fragment\n            let config_to_write = if let Some(obj) = provider.settings_config.as_object() {\n                // Detect full config structure (has $schema or top-level provider field)\n                if obj.contains_key(\"$schema\") || obj.contains_key(\"provider\") {\n                    log::warn!(\n                        \"OpenCode provider '{}' has full config structure in settings_config, attempting to extract fragment\",\n                        provider.id\n                    );\n                    // Try to extract from provider.{id}\n                    obj.get(\"provider\")\n                        .and_then(|p| p.get(&provider.id))\n                        .cloned()\n                        .unwrap_or_else(|| provider.settings_config.clone())\n                } else {\n                    provider.settings_config.clone()\n                }\n            } else {\n                provider.settings_config.clone()\n            };\n\n            // Convert settings_config to OpenCodeProviderConfig\n            let opencode_config_result =\n                serde_json::from_value::<OpenCodeProviderConfig>(config_to_write.clone());\n\n            match opencode_config_result {\n                Ok(config) => {\n                    opencode_config::set_typed_provider(&provider.id, &config)?;\n                    log::info!(\"OpenCode provider '{}' written to live config\", provider.id);\n                }\n                Err(e) => {\n                    log::warn!(\n                        \"Failed to parse OpenCode provider config for '{}': {}\",\n                        provider.id,\n                        e\n                    );\n                    // Only write if config looks like a valid provider fragment\n                    if config_to_write.get(\"npm\").is_some()\n                        || config_to_write.get(\"options\").is_some()\n                    {\n                        opencode_config::set_provider(&provider.id, config_to_write)?;\n                        log::info!(\n                            \"OpenCode provider '{}' written as raw JSON to live config\",\n                            provider.id\n                        );\n                    } else {\n                        log::error!(\n                            \"OpenCode provider '{}' has invalid config structure, skipping write\",\n                            provider.id\n                        );\n                    }\n                }\n            }\n        }\n        AppType::OpenClaw => {\n            // OpenClaw uses additive mode - write provider to config\n            use crate::openclaw_config;\n            use crate::openclaw_config::OpenClawProviderConfig;\n\n            // Convert settings_config to OpenClawProviderConfig\n            let openclaw_config_result =\n                serde_json::from_value::<OpenClawProviderConfig>(provider.settings_config.clone());\n\n            match openclaw_config_result {\n                Ok(config) => {\n                    openclaw_config::set_typed_provider(&provider.id, &config)?;\n                    log::info!(\"OpenClaw provider '{}' written to live config\", provider.id);\n                }\n                Err(e) => {\n                    log::warn!(\n                        \"Failed to parse OpenClaw provider config for '{}': {}\",\n                        provider.id,\n                        e\n                    );\n                    // Try to write as raw JSON if it looks valid\n                    if provider.settings_config.get(\"baseUrl\").is_some()\n                        || provider.settings_config.get(\"api\").is_some()\n                        || provider.settings_config.get(\"models\").is_some()\n                    {\n                        openclaw_config::set_provider(\n                            &provider.id,\n                            provider.settings_config.clone(),\n                        )?;\n                        log::info!(\n                            \"OpenClaw provider '{}' written as raw JSON to live config\",\n                            provider.id\n                        );\n                    } else {\n                        log::error!(\n                            \"OpenClaw provider '{}' has invalid config structure, skipping write\",\n                            provider.id\n                        );\n                    }\n                }\n            }\n        }\n    }\n    Ok(())\n}\n\n/// Sync all providers to live configuration (for additive mode apps)\n///\n/// Writes all providers from the database to the live configuration file.\n/// Used for OpenCode and other additive mode applications.\nfn sync_all_providers_to_live(state: &AppState, app_type: &AppType) -> Result<(), AppError> {\n    let providers = state.db.get_all_providers(app_type.as_str())?;\n\n    for provider in providers.values() {\n        if let Err(e) = write_live_with_common_config(state.db.as_ref(), app_type, provider) {\n            log::warn!(\n                \"Failed to sync {:?} provider '{}' to live: {e}\",\n                app_type,\n                provider.id\n            );\n            // Continue syncing other providers, don't abort\n        }\n    }\n\n    log::info!(\n        \"Synced {} {:?} providers to live config\",\n        providers.len(),\n        app_type\n    );\n    Ok(())\n}\n\npub(crate) fn sync_current_provider_for_app_to_live(\n    state: &AppState,\n    app_type: &AppType,\n) -> Result<(), AppError> {\n    if app_type.is_additive_mode() {\n        sync_all_providers_to_live(state, app_type)?;\n    } else {\n        let current_id = match crate::settings::get_effective_current_provider(&state.db, app_type)?\n        {\n            Some(id) => id,\n            None => return Ok(()),\n        };\n\n        let providers = state.db.get_all_providers(app_type.as_str())?;\n        if let Some(provider) = providers.get(&current_id) {\n            write_live_with_common_config(state.db.as_ref(), app_type, provider)?;\n        }\n    }\n\n    McpService::sync_all_enabled(state)?;\n\n    Ok(())\n}\n\n/// Sync current provider to live configuration\n///\n/// 使用有效的当前供应商 ID（验证过存在性）。\n/// 优先从本地 settings 读取，验证后 fallback 到数据库的 is_current 字段。\n/// 这确保了配置导入后无效 ID 会自动 fallback 到数据库。\n///\n/// For additive mode apps (OpenCode), all providers are synced instead of just the current one.\npub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {\n    // Sync providers based on mode\n    for app_type in AppType::all() {\n        if app_type.is_additive_mode() {\n            // Additive mode: sync ALL providers\n            sync_all_providers_to_live(state, &app_type)?;\n        } else {\n            // Switch mode: sync only current provider\n            let current_id =\n                match crate::settings::get_effective_current_provider(&state.db, &app_type)? {\n                    Some(id) => id,\n                    None => continue,\n                };\n\n            let providers = state.db.get_all_providers(app_type.as_str())?;\n            if let Some(provider) = providers.get(&current_id) {\n                write_live_with_common_config(state.db.as_ref(), &app_type, provider)?;\n            }\n            // Note: get_effective_current_provider already validates existence,\n            // so providers.get() should always succeed here\n        }\n    }\n\n    // MCP sync\n    McpService::sync_all_enabled(state)?;\n\n    // Skill sync\n    for app_type in AppType::all() {\n        if let Err(e) = crate::services::skill::SkillService::sync_to_app(&state.db, &app_type) {\n            log::warn!(\"同步 Skill 到 {app_type:?} 失败: {e}\");\n            // Continue syncing other apps, don't abort\n        }\n    }\n\n    Ok(())\n}\n\n/// Read current live settings for an app type\npub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {\n    match app_type {\n        AppType::Codex => {\n            let auth_path = get_codex_auth_path();\n            if !auth_path.exists() {\n                return Err(AppError::localized(\n                    \"codex.auth.missing\",\n                    \"Codex 配置文件不存在：缺少 auth.json\",\n                    \"Codex configuration missing: auth.json not found\",\n                ));\n            }\n            let auth: Value = read_json_file(&auth_path)?;\n            let cfg_text = crate::codex_config::read_and_validate_codex_config_text()?;\n            Ok(json!({ \"auth\": auth, \"config\": cfg_text }))\n        }\n        AppType::Claude => {\n            let path = get_claude_settings_path();\n            if !path.exists() {\n                return Err(AppError::localized(\n                    \"claude.live.missing\",\n                    \"Claude Code 配置文件不存在\",\n                    \"Claude settings file is missing\",\n                ));\n            }\n            read_json_file(&path)\n        }\n        AppType::Gemini => {\n            use crate::gemini_config::{\n                env_to_json, get_gemini_env_path, get_gemini_settings_path, read_gemini_env,\n            };\n\n            // Read .env file (environment variables)\n            let env_path = get_gemini_env_path();\n            if !env_path.exists() {\n                return Err(AppError::localized(\n                    \"gemini.env.missing\",\n                    \"Gemini .env 文件不存在\",\n                    \"Gemini .env file not found\",\n                ));\n            }\n\n            let env_map = read_gemini_env()?;\n            let env_json = env_to_json(&env_map);\n            let env_obj = env_json.get(\"env\").cloned().unwrap_or_else(|| json!({}));\n\n            // Read settings.json file (MCP config etc.)\n            let settings_path = get_gemini_settings_path();\n            let config_obj = if settings_path.exists() {\n                read_json_file(&settings_path)?\n            } else {\n                json!({})\n            };\n\n            // Return complete structure: { \"env\": {...}, \"config\": {...} }\n            Ok(json!({\n                \"env\": env_obj,\n                \"config\": config_obj\n            }))\n        }\n        AppType::OpenCode => {\n            use crate::opencode_config::{get_opencode_config_path, read_opencode_config};\n\n            let config_path = get_opencode_config_path();\n            if !config_path.exists() {\n                return Err(AppError::localized(\n                    \"opencode.config.missing\",\n                    \"OpenCode 配置文件不存在\",\n                    \"OpenCode configuration file not found\",\n                ));\n            }\n\n            let config = read_opencode_config()?;\n            Ok(config)\n        }\n        AppType::OpenClaw => {\n            use crate::openclaw_config::{get_openclaw_config_path, read_openclaw_config};\n\n            let config_path = get_openclaw_config_path();\n            if !config_path.exists() {\n                return Err(AppError::localized(\n                    \"openclaw.config.missing\",\n                    \"OpenClaw 配置文件不存在\",\n                    \"OpenClaw configuration file not found\",\n                ));\n            }\n\n            let config = read_openclaw_config()?;\n            Ok(config)\n        }\n    }\n}\n\n/// Import default configuration from live files\n///\n/// Returns `Ok(true)` if a provider was actually imported,\n/// `Ok(false)` if skipped (providers already exist for this app).\npub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool, AppError> {\n    // Additive mode apps (OpenCode, OpenClaw) should use their dedicated\n    // import_xxx_providers_from_live functions, not this generic default config import\n    if app_type.is_additive_mode() {\n        return Ok(false);\n    }\n\n    {\n        let providers = state.db.get_all_providers(app_type.as_str())?;\n        if !providers.is_empty() {\n            return Ok(false); // 已有供应商，跳过\n        }\n    }\n\n    let settings_config = match app_type {\n        AppType::Codex => {\n            let auth_path = get_codex_auth_path();\n            if !auth_path.exists() {\n                return Err(AppError::localized(\n                    \"codex.live.missing\",\n                    \"Codex 配置文件不存在\",\n                    \"Codex configuration file is missing\",\n                ));\n            }\n            let auth: Value = read_json_file(&auth_path)?;\n            let config_str = crate::codex_config::read_and_validate_codex_config_text()?;\n            json!({ \"auth\": auth, \"config\": config_str })\n        }\n        AppType::Claude => {\n            let settings_path = get_claude_settings_path();\n            if !settings_path.exists() {\n                return Err(AppError::localized(\n                    \"claude.live.missing\",\n                    \"Claude Code 配置文件不存在\",\n                    \"Claude settings file is missing\",\n                ));\n            }\n            let mut v = read_json_file::<Value>(&settings_path)?;\n            let _ = normalize_claude_models_in_value(&mut v);\n            v\n        }\n        AppType::Gemini => {\n            use crate::gemini_config::{\n                env_to_json, get_gemini_env_path, get_gemini_settings_path, read_gemini_env,\n            };\n\n            // Read .env file (environment variables)\n            let env_path = get_gemini_env_path();\n            if !env_path.exists() {\n                return Err(AppError::localized(\n                    \"gemini.live.missing\",\n                    \"Gemini 配置文件不存在\",\n                    \"Gemini configuration file is missing\",\n                ));\n            }\n\n            let env_map = read_gemini_env()?;\n            let env_json = env_to_json(&env_map);\n            let env_obj = env_json.get(\"env\").cloned().unwrap_or_else(|| json!({}));\n\n            // Read settings.json file (MCP config etc.)\n            let settings_path = get_gemini_settings_path();\n            let config_obj = if settings_path.exists() {\n                read_json_file(&settings_path)?\n            } else {\n                json!({})\n            };\n\n            // Return complete structure: { \"env\": {...}, \"config\": {...} }\n            json!({\n                \"env\": env_obj,\n                \"config\": config_obj\n            })\n        }\n        // OpenCode and OpenClaw use additive mode and are handled by early return above\n        AppType::OpenCode | AppType::OpenClaw => {\n            unreachable!(\"additive mode apps are handled by early return\")\n        }\n    };\n\n    let mut provider = Provider::with_id(\n        \"default\".to_string(),\n        \"default\".to_string(),\n        settings_config,\n        None,\n    );\n    provider.category = Some(\"custom\".to_string());\n\n    state.db.save_provider(app_type.as_str(), &provider)?;\n    state\n        .db\n        .set_current_provider(app_type.as_str(), &provider.id)?;\n\n    Ok(true) // 真正导入了\n}\n\n/// Write Gemini live configuration with authentication handling\npub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {\n    use crate::gemini_config::{\n        get_gemini_settings_path, json_to_env, validate_gemini_settings_strict,\n        write_gemini_env_atomic,\n    };\n\n    // One-time auth type detection to avoid repeated detection\n    let auth_type = detect_gemini_auth_type(provider);\n\n    let mut env_map = json_to_env(&provider.settings_config)?;\n\n    // Prepare config to write to ~/.gemini/settings.json\n    // Behavior:\n    // - config is object: use it (merge with existing to preserve mcpServers etc.)\n    // - config is null or absent: preserve existing file content\n    let settings_path = get_gemini_settings_path();\n    let mut config_to_write: Option<Value> = None;\n\n    if let Some(config_value) = provider.settings_config.get(\"config\") {\n        if config_value.is_object() {\n            // Merge with existing settings to preserve mcpServers and other fields\n            let mut merged = if settings_path.exists() {\n                read_json_file::<Value>(&settings_path).unwrap_or_else(|_| json!({}))\n            } else {\n                json!({})\n            };\n\n            // Merge provider config into existing settings\n            if let (Some(merged_obj), Some(config_obj)) =\n                (merged.as_object_mut(), config_value.as_object())\n            {\n                for (k, v) in config_obj {\n                    merged_obj.insert(k.clone(), v.clone());\n                }\n            }\n            config_to_write = Some(merged);\n        } else if !config_value.is_null() {\n            return Err(AppError::localized(\n                \"gemini.validation.invalid_config\",\n                \"Gemini 配置格式错误: config 必须是对象或 null\",\n                \"Gemini config invalid: config must be an object or null\",\n            ));\n        }\n        // config is null: don't modify existing settings.json (preserve mcpServers etc.)\n    }\n\n    // If no config specified or config is null, preserve existing file\n    if config_to_write.is_none() && settings_path.exists() {\n        config_to_write = Some(read_json_file(&settings_path)?);\n    }\n\n    match auth_type {\n        GeminiAuthType::GoogleOfficial => {\n            // Google official uses OAuth, clear env\n            env_map.clear();\n            write_gemini_env_atomic(&env_map)?;\n        }\n        GeminiAuthType::Packycode => {\n            // PackyCode provider, uses API Key (strict validation on switch)\n            validate_gemini_settings_strict(&provider.settings_config)?;\n            write_gemini_env_atomic(&env_map)?;\n        }\n        GeminiAuthType::Generic => {\n            // Generic provider, uses API Key (strict validation on switch)\n            validate_gemini_settings_strict(&provider.settings_config)?;\n            write_gemini_env_atomic(&env_map)?;\n        }\n    }\n\n    if let Some(config_value) = config_to_write {\n        write_json_file(&settings_path, &config_value)?;\n    }\n\n    // Set security.auth.selectedType based on auth type\n    // - Google Official: OAuth mode\n    // - All others: API Key mode\n    match auth_type {\n        GeminiAuthType::GoogleOfficial => ensure_google_oauth_security_flag(provider)?,\n        GeminiAuthType::Packycode | GeminiAuthType::Generic => {\n            crate::gemini_config::write_packycode_settings()?;\n        }\n    }\n\n    Ok(())\n}\n\n/// Remove an OpenCode provider from the live configuration\n///\n/// This is specific to OpenCode's additive mode - removing a provider\n/// from the opencode.json file.\npub(crate) fn remove_opencode_provider_from_live(provider_id: &str) -> Result<(), AppError> {\n    use crate::opencode_config;\n\n    // Check if OpenCode config directory exists\n    if !opencode_config::get_opencode_dir().exists() {\n        log::debug!(\"OpenCode config directory doesn't exist, skipping removal of '{provider_id}'\");\n        return Ok(());\n    }\n\n    opencode_config::remove_provider(provider_id)?;\n    log::info!(\"OpenCode provider '{provider_id}' removed from live config\");\n\n    Ok(())\n}\n\n/// Import all providers from OpenCode live config to database\n///\n/// This imports existing providers from ~/.config/opencode/opencode.json\n/// into the CC Switch database. Each provider found will be added to the\n/// database with is_current set to false.\npub fn import_opencode_providers_from_live(state: &AppState) -> Result<usize, AppError> {\n    use crate::opencode_config;\n\n    let providers = opencode_config::get_typed_providers()?;\n    if providers.is_empty() {\n        return Ok(0);\n    }\n\n    let mut imported = 0;\n    let existing = state.db.get_all_providers(\"opencode\")?;\n\n    for (id, config) in providers {\n        // Skip if already exists in database\n        if existing.contains_key(&id) {\n            log::debug!(\"OpenCode provider '{id}' already exists in database, skipping\");\n            continue;\n        }\n\n        // Convert to Value for settings_config\n        let settings_config = match serde_json::to_value(&config) {\n            Ok(v) => v,\n            Err(e) => {\n                log::warn!(\"Failed to serialize OpenCode provider '{id}': {e}\");\n                continue;\n            }\n        };\n\n        // Create provider\n        let provider = Provider::with_id(\n            id.clone(),\n            config.name.clone().unwrap_or_else(|| id.clone()),\n            settings_config,\n            None,\n        );\n\n        // Save to database\n        if let Err(e) = state.db.save_provider(\"opencode\", &provider) {\n            log::warn!(\"Failed to import OpenCode provider '{id}': {e}\");\n            continue;\n        }\n\n        imported += 1;\n        log::info!(\"Imported OpenCode provider '{id}' from live config\");\n    }\n\n    Ok(imported)\n}\n\n/// Import all providers from OpenClaw live config to database\n///\n/// This imports existing providers from ~/.openclaw/openclaw.json\n/// into the CC Switch database. Each provider found will be added to the\n/// database with is_current set to false.\npub fn import_openclaw_providers_from_live(state: &AppState) -> Result<usize, AppError> {\n    use crate::openclaw_config;\n\n    let providers = openclaw_config::get_typed_providers()?;\n    if providers.is_empty() {\n        return Ok(0);\n    }\n\n    let mut imported = 0;\n    let existing = state.db.get_all_providers(\"openclaw\")?;\n\n    for (id, config) in providers {\n        // Validate: skip entries with empty id or no models\n        if id.trim().is_empty() {\n            log::warn!(\"Skipping OpenClaw provider with empty id\");\n            continue;\n        }\n        if config.models.is_empty() {\n            log::warn!(\"Skipping OpenClaw provider '{id}': no models defined\");\n            continue;\n        }\n\n        // Skip if already exists in database\n        if existing.contains_key(&id) {\n            log::debug!(\"OpenClaw provider '{id}' already exists in database, skipping\");\n            continue;\n        }\n\n        // Convert to Value for settings_config\n        let settings_config = match serde_json::to_value(&config) {\n            Ok(v) => v,\n            Err(e) => {\n                log::warn!(\"Failed to serialize OpenClaw provider '{id}': {e}\");\n                continue;\n            }\n        };\n\n        // Determine display name: use first model name if available, otherwise use id\n        let display_name = config\n            .models\n            .first()\n            .and_then(|m| m.name.clone())\n            .unwrap_or_else(|| id.clone());\n\n        // Create provider\n        let provider = Provider::with_id(id.clone(), display_name, settings_config, None);\n\n        // Save to database\n        if let Err(e) = state.db.save_provider(\"openclaw\", &provider) {\n            log::warn!(\"Failed to import OpenClaw provider '{id}': {e}\");\n            continue;\n        }\n\n        imported += 1;\n        log::info!(\"Imported OpenClaw provider '{id}' from live config\");\n    }\n\n    Ok(imported)\n}\n\n/// Remove an OpenClaw provider from live config\n///\n/// This removes a specific provider from ~/.openclaw/openclaw.json\n/// without affecting other providers in the file.\npub fn remove_openclaw_provider_from_live(provider_id: &str) -> Result<(), AppError> {\n    use crate::openclaw_config;\n\n    // Check if OpenClaw config directory exists\n    if !openclaw_config::get_openclaw_dir().exists() {\n        log::debug!(\"OpenClaw config directory doesn't exist, skipping removal of '{provider_id}'\");\n        return Ok(());\n    }\n\n    openclaw_config::remove_provider(provider_id)?;\n    log::info!(\"OpenClaw provider '{provider_id}' removed from live config\");\n\n    Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use serde_json::json;\n\n    #[test]\n    fn claude_common_config_apply_and_remove_roundtrip_for_non_overlapping_fields() {\n        let settings = json!({\n            \"env\": {\n                \"ANTHROPIC_API_KEY\": \"sk-test\"\n            }\n        });\n        let snippet = r#\"{\n  \"includeCoAuthoredBy\": false,\n  \"env\": {\n    \"CLAUDE_CODE_USE_BEDROCK\": \"1\"\n  }\n}\"#;\n\n        let applied =\n            apply_common_config_to_settings(&AppType::Claude, &settings, snippet).unwrap();\n        assert_eq!(applied[\"includeCoAuthoredBy\"], json!(false));\n        assert_eq!(applied[\"env\"][\"CLAUDE_CODE_USE_BEDROCK\"], json!(\"1\"));\n\n        let stripped =\n            remove_common_config_from_settings(&AppType::Claude, &applied, snippet).unwrap();\n        assert_eq!(stripped, settings);\n    }\n\n    #[test]\n    fn codex_common_config_apply_and_remove_roundtrip_for_non_overlapping_fields() {\n        let settings = json!({\n            \"auth\": {\n                \"OPENAI_API_KEY\": \"sk-test\"\n            },\n            \"config\": \"model_provider = \\\"openai\\\"\\n[general]\\nmodel = \\\"gpt-5\\\"\\n\"\n        });\n        let snippet = \"[shared]\\nreasoning = \\\"medium\\\"\\n\";\n\n        let applied = apply_common_config_to_settings(&AppType::Codex, &settings, snippet).unwrap();\n        let applied_config = applied[\"config\"].as_str().unwrap_or_default();\n        assert!(applied_config.contains(\"[shared]\"));\n        assert!(applied_config.contains(\"reasoning = \\\"medium\\\"\"));\n\n        let stripped =\n            remove_common_config_from_settings(&AppType::Codex, &applied, snippet).unwrap();\n        assert_eq!(stripped, settings);\n    }\n\n    #[test]\n    fn explicit_common_config_flag_overrides_legacy_subset_detection() {\n        let mut provider = Provider::with_id(\n            \"claude-test\".to_string(),\n            \"Claude Test\".to_string(),\n            json!({\n                \"includeCoAuthoredBy\": false\n            }),\n            None,\n        );\n        provider.meta = Some(crate::provider::ProviderMeta {\n            common_config_enabled: Some(false),\n            ..Default::default()\n        });\n\n        assert!(\n            !provider_uses_common_config(\n                &AppType::Claude,\n                &provider,\n                Some(r#\"{ \"includeCoAuthoredBy\": false }\"#),\n            ),\n            \"explicit false should win over legacy subset detection\"\n        );\n    }\n\n    #[test]\n    fn claude_common_config_array_subset_detection_and_strip_preserve_extra_items() {\n        let settings = json!({\n            \"allowedTools\": [\"tool1\", \"tool2\"]\n        });\n        let snippet = r#\"{\n  \"allowedTools\": [\"tool1\"]\n}\"#;\n\n        assert!(\n            settings_contain_common_config(&AppType::Claude, &settings, snippet),\n            \"array subset should be detected for legacy providers\"\n        );\n\n        let stripped =\n            remove_common_config_from_settings(&AppType::Claude, &settings, snippet).unwrap();\n        assert_eq!(\n            stripped,\n            json!({\n                \"allowedTools\": [\"tool2\"]\n            })\n        );\n    }\n\n    #[test]\n    fn codex_common_config_array_subset_detection_and_strip_preserve_extra_items() {\n        let settings = json!({\n            \"auth\": {},\n            \"config\": \"allowed_tools = [\\\"tool1\\\", \\\"tool2\\\"]\\n\"\n        });\n        let snippet = \"allowed_tools = [\\\"tool1\\\"]\\n\";\n\n        assert!(\n            settings_contain_common_config(&AppType::Codex, &settings, snippet),\n            \"TOML array subset should be detected for legacy providers\"\n        );\n\n        let stripped =\n            remove_common_config_from_settings(&AppType::Codex, &settings, snippet).unwrap();\n        assert_eq!(stripped[\"auth\"], json!({}));\n        let stripped_config = stripped[\"config\"].as_str().unwrap_or_default();\n        let parsed = stripped_config\n            .parse::<DocumentMut>()\n            .expect(\"stripped codex config should remain valid TOML\");\n        let allowed_tools = parsed[\"allowed_tools\"]\n            .as_array()\n            .expect(\"allowed_tools should remain an array\");\n        let values: Vec<&str> = allowed_tools\n            .iter()\n            .map(|value| value.as_str().expect(\"tool id should be string\"))\n            .collect();\n        assert_eq!(values, vec![\"tool2\"]);\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/services/provider/mod.rs",
    "content": "//! Provider service module\n//!\n//! Handles provider CRUD operations, switching, and configuration management.\n\nmod endpoints;\nmod gemini_auth;\nmod live;\nmod usage;\n\nuse indexmap::IndexMap;\nuse regex::Regex;\nuse serde::Deserialize;\nuse serde_json::Value;\n\nuse crate::app_config::AppType;\nuse crate::error::AppError;\nuse crate::provider::{Provider, UsageResult};\nuse crate::services::mcp::McpService;\nuse crate::settings::CustomEndpoint;\nuse crate::store::AppState;\n\n// Re-export sub-module functions for external access\npub use live::{\n    import_default_config, import_openclaw_providers_from_live,\n    import_opencode_providers_from_live, read_live_settings, sync_current_to_live,\n};\n\n// Internal re-exports (pub(crate))\npub(crate) use live::sanitize_claude_settings_for_live;\npub(crate) use live::{\n    build_effective_settings_with_common_config, normalize_provider_common_config_for_storage,\n    strip_common_config_from_live_settings, sync_current_provider_for_app_to_live,\n    write_live_with_common_config,\n};\n\n// Internal re-exports\nuse live::{\n    remove_openclaw_provider_from_live, remove_opencode_provider_from_live, write_gemini_live,\n};\nuse usage::validate_usage_script;\n\n/// Provider business logic service\npub struct ProviderService;\n\n/// Result of a provider switch operation, including any non-fatal warnings\n#[derive(Debug, serde::Serialize, Default)]\n#[serde(rename_all = \"camelCase\")]\npub struct SwitchResult {\n    pub warnings: Vec<String>,\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use serde_json::json;\n\n    #[test]\n    fn validate_provider_settings_rejects_missing_auth() {\n        let provider = Provider::with_id(\n            \"codex\".into(),\n            \"Codex\".into(),\n            json!({ \"config\": \"base_url = \\\"https://example.com\\\"\" }),\n            None,\n        );\n        let err = ProviderService::validate_provider_settings(&AppType::Codex, &provider)\n            .expect_err(\"missing auth should be rejected\");\n        assert!(\n            err.to_string().contains(\"auth\"),\n            \"expected auth error, got {err:?}\"\n        );\n    }\n\n    #[test]\n    fn extract_credentials_returns_expected_values() {\n        let provider = Provider::with_id(\n            \"claude\".into(),\n            \"Claude\".into(),\n            json!({\n                \"env\": {\n                    \"ANTHROPIC_AUTH_TOKEN\": \"token\",\n                    \"ANTHROPIC_BASE_URL\": \"https://claude.example\"\n                }\n            }),\n            None,\n        );\n        let (api_key, base_url) =\n            ProviderService::extract_credentials(&provider, &AppType::Claude).unwrap();\n        assert_eq!(api_key, \"token\");\n        assert_eq!(base_url, \"https://claude.example\");\n    }\n\n    #[test]\n    fn extract_codex_common_config_preserves_mcp_servers_base_url() {\n        let config_toml = r#\"model_provider = \"azure\"\nmodel = \"gpt-4\"\ndisable_response_storage = true\n\n[model_providers.azure]\nname = \"Azure OpenAI\"\nbase_url = \"https://azure.example/v1\"\nwire_api = \"responses\"\n\n[mcp_servers.my_server]\nbase_url = \"http://localhost:8080\"\n\"#;\n\n        let settings = json!({ \"config\": config_toml });\n        let extracted = ProviderService::extract_codex_common_config(&settings)\n            .expect(\"extract_codex_common_config should succeed\");\n\n        assert!(\n            !extracted\n                .lines()\n                .any(|line| line.trim_start().starts_with(\"model_provider\")),\n            \"should remove top-level model_provider\"\n        );\n        assert!(\n            !extracted\n                .lines()\n                .any(|line| line.trim_start().starts_with(\"model =\")),\n            \"should remove top-level model\"\n        );\n        assert!(\n            !extracted.contains(\"[model_providers\"),\n            \"should remove entire model_providers table\"\n        );\n        assert!(\n            extracted.contains(\"http://localhost:8080\"),\n            \"should keep mcp_servers.* base_url\"\n        );\n    }\n}\n\nimpl ProviderService {\n    fn normalize_provider_if_claude(app_type: &AppType, provider: &mut Provider) {\n        if matches!(app_type, AppType::Claude) {\n            let mut v = provider.settings_config.clone();\n            if normalize_claude_models_in_value(&mut v) {\n                provider.settings_config = v;\n            }\n        }\n    }\n\n    /// List all providers for an app type\n    pub fn list(\n        state: &AppState,\n        app_type: AppType,\n    ) -> Result<IndexMap<String, Provider>, AppError> {\n        state.db.get_all_providers(app_type.as_str())\n    }\n\n    /// Get current provider ID\n    ///\n    /// 使用有效的当前供应商 ID（验证过存在性）。\n    /// 优先从本地 settings 读取，验证后 fallback 到数据库的 is_current 字段。\n    /// 这确保了云同步场景下多设备可以独立选择供应商，且返回的 ID 一定有效。\n    ///\n    /// 对于累加模式应用（OpenCode, OpenClaw），不存在\"当前供应商\"概念，直接返回空字符串。\n    pub fn current(state: &AppState, app_type: AppType) -> Result<String, AppError> {\n        // Additive mode apps have no \"current\" provider concept\n        if app_type.is_additive_mode() {\n            return Ok(String::new());\n        }\n        crate::settings::get_effective_current_provider(&state.db, &app_type)\n            .map(|opt| opt.unwrap_or_default())\n    }\n\n    /// Add a new provider\n    pub fn add(state: &AppState, app_type: AppType, provider: Provider) -> Result<bool, AppError> {\n        let mut provider = provider;\n        // Normalize Claude model keys\n        Self::normalize_provider_if_claude(&app_type, &mut provider);\n        Self::validate_provider_settings(&app_type, &provider)?;\n        normalize_provider_common_config_for_storage(state.db.as_ref(), &app_type, &mut provider)?;\n\n        // Save to database\n        state.db.save_provider(app_type.as_str(), &provider)?;\n\n        // Additive mode apps (OpenCode, OpenClaw) - always write to live config\n        if app_type.is_additive_mode() {\n            // OMO / OMO Slim providers use exclusive mode and write to dedicated config file.\n            if matches!(app_type, AppType::OpenCode)\n                && matches!(provider.category.as_deref(), Some(\"omo\") | Some(\"omo-slim\"))\n            {\n                // Do not auto-enable newly added OMO / OMO Slim providers.\n                // Users must explicitly switch/apply an OMO provider to activate it.\n                return Ok(true);\n            }\n            write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;\n            return Ok(true);\n        }\n\n        // For other apps: Check if sync is needed (if this is current provider, or no current provider)\n        let current = state.db.get_current_provider(app_type.as_str())?;\n        if current.is_none() {\n            // No current provider, set as current and sync\n            state\n                .db\n                .set_current_provider(app_type.as_str(), &provider.id)?;\n            write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;\n        }\n\n        Ok(true)\n    }\n\n    /// Update a provider\n    pub fn update(\n        state: &AppState,\n        app_type: AppType,\n        provider: Provider,\n    ) -> Result<bool, AppError> {\n        let mut provider = provider;\n        // Normalize Claude model keys\n        Self::normalize_provider_if_claude(&app_type, &mut provider);\n        Self::validate_provider_settings(&app_type, &provider)?;\n        normalize_provider_common_config_for_storage(state.db.as_ref(), &app_type, &mut provider)?;\n\n        // Save to database\n        state.db.save_provider(app_type.as_str(), &provider)?;\n\n        // Additive mode apps (OpenCode, OpenClaw) - always update in live config\n        if app_type.is_additive_mode() {\n            if matches!(app_type, AppType::OpenCode) && provider.category.as_deref() == Some(\"omo\")\n            {\n                let is_omo_current =\n                    state\n                        .db\n                        .is_omo_provider_current(app_type.as_str(), &provider.id, \"omo\")?;\n                if is_omo_current {\n                    crate::services::OmoService::write_config_to_file(\n                        state,\n                        &crate::services::omo::STANDARD,\n                    )?;\n                }\n                return Ok(true);\n            }\n            if matches!(app_type, AppType::OpenCode)\n                && provider.category.as_deref() == Some(\"omo-slim\")\n            {\n                let is_current = state.db.is_omo_provider_current(\n                    app_type.as_str(),\n                    &provider.id,\n                    \"omo-slim\",\n                )?;\n                if is_current {\n                    crate::services::OmoService::write_config_to_file(\n                        state,\n                        &crate::services::omo::SLIM,\n                    )?;\n                }\n                return Ok(true);\n            }\n            write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;\n            return Ok(true);\n        }\n\n        // For other apps: Check if this is current provider (use effective current, not just DB)\n        let effective_current =\n            crate::settings::get_effective_current_provider(&state.db, &app_type)?;\n        let is_current = effective_current.as_deref() == Some(provider.id.as_str());\n\n        if is_current {\n            // 如果代理接管模式处于激活状态，并且代理服务正在运行：\n            // - 不写 Live 配置（否则会破坏接管）\n            // - 仅更新 Live 备份（保证关闭代理时能恢复到最新配置）\n            let is_app_taken_over =\n                futures::executor::block_on(state.db.get_live_backup(app_type.as_str()))\n                    .ok()\n                    .flatten()\n                    .is_some();\n            let is_proxy_running = futures::executor::block_on(state.proxy_service.is_running());\n            let should_skip_live_write = is_app_taken_over && is_proxy_running;\n\n            if should_skip_live_write {\n                futures::executor::block_on(\n                    state\n                        .proxy_service\n                        .update_live_backup_from_provider(app_type.as_str(), &provider),\n                )\n                .map_err(|e| AppError::Message(format!(\"更新 Live 备份失败: {e}\")))?;\n            } else {\n                write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;\n                // Sync MCP\n                McpService::sync_all_enabled(state)?;\n            }\n        }\n\n        Ok(true)\n    }\n\n    /// Delete a provider\n    ///\n    /// 同时检查本地 settings 和数据库的当前供应商，防止删除任一端正在使用的供应商。\n    /// 对于累加模式应用（OpenCode, OpenClaw），可以随时删除任意供应商，同时从 live 配置中移除。\n    pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {\n        // Additive mode apps - no current provider concept\n        if app_type.is_additive_mode() {\n            if matches!(app_type, AppType::OpenCode) {\n                let provider_category = state\n                    .db\n                    .get_provider_by_id(id, app_type.as_str())?\n                    .and_then(|p| p.category);\n\n                if provider_category.as_deref() == Some(\"omo\") {\n                    let was_current =\n                        state\n                            .db\n                            .is_omo_provider_current(app_type.as_str(), id, \"omo\")?;\n\n                    state.db.delete_provider(app_type.as_str(), id)?;\n                    if was_current {\n                        crate::services::OmoService::delete_config_file(\n                            &crate::services::omo::STANDARD,\n                        )?;\n                    }\n                    return Ok(());\n                }\n\n                if provider_category.as_deref() == Some(\"omo-slim\") {\n                    let was_current =\n                        state\n                            .db\n                            .is_omo_provider_current(app_type.as_str(), id, \"omo-slim\")?;\n\n                    state.db.delete_provider(app_type.as_str(), id)?;\n                    if was_current {\n                        crate::services::OmoService::delete_config_file(\n                            &crate::services::omo::SLIM,\n                        )?;\n                    }\n                    return Ok(());\n                }\n            }\n            // Remove from database\n            state.db.delete_provider(app_type.as_str(), id)?;\n            // Also remove from live config\n            match app_type {\n                AppType::OpenCode => remove_opencode_provider_from_live(id)?,\n                AppType::OpenClaw => remove_openclaw_provider_from_live(id)?,\n                _ => {} // Should not reach here\n            }\n            return Ok(());\n        }\n\n        // For other apps: Check both local settings and database\n        let local_current = crate::settings::get_current_provider(&app_type);\n        let db_current = state.db.get_current_provider(app_type.as_str())?;\n\n        if local_current.as_deref() == Some(id) || db_current.as_deref() == Some(id) {\n            return Err(AppError::Message(\n                \"无法删除当前正在使用的供应商\".to_string(),\n            ));\n        }\n\n        state.db.delete_provider(app_type.as_str(), id)\n    }\n\n    /// Remove provider from live config only (for additive mode apps like OpenCode, OpenClaw)\n    ///\n    /// Does NOT delete from database - provider remains in the list.\n    /// This is used when user wants to \"remove\" a provider from active config\n    /// but keep it available for future use.\n    pub fn remove_from_live_config(\n        state: &AppState,\n        app_type: AppType,\n        id: &str,\n    ) -> Result<(), AppError> {\n        match app_type {\n            AppType::OpenCode => {\n                let provider_category = state\n                    .db\n                    .get_provider_by_id(id, app_type.as_str())?\n                    .and_then(|p| p.category);\n\n                if provider_category.as_deref() == Some(\"omo\") {\n                    state\n                        .db\n                        .clear_omo_provider_current(app_type.as_str(), id, \"omo\")?;\n                    let still_has_current = state\n                        .db\n                        .get_current_omo_provider(\"opencode\", \"omo\")?\n                        .is_some();\n                    if still_has_current {\n                        crate::services::OmoService::write_config_to_file(\n                            state,\n                            &crate::services::omo::STANDARD,\n                        )?;\n                    } else {\n                        crate::services::OmoService::delete_config_file(\n                            &crate::services::omo::STANDARD,\n                        )?;\n                    }\n                } else if provider_category.as_deref() == Some(\"omo-slim\") {\n                    state\n                        .db\n                        .clear_omo_provider_current(app_type.as_str(), id, \"omo-slim\")?;\n                    let still_has_current = state\n                        .db\n                        .get_current_omo_provider(\"opencode\", \"omo-slim\")?\n                        .is_some();\n                    if still_has_current {\n                        crate::services::OmoService::write_config_to_file(\n                            state,\n                            &crate::services::omo::SLIM,\n                        )?;\n                    } else {\n                        crate::services::OmoService::delete_config_file(\n                            &crate::services::omo::SLIM,\n                        )?;\n                    }\n                } else {\n                    remove_opencode_provider_from_live(id)?;\n                }\n            }\n            AppType::OpenClaw => {\n                remove_openclaw_provider_from_live(id)?;\n            }\n            _ => {\n                return Err(AppError::Message(format!(\n                    \"App {} does not support remove from live config\",\n                    app_type.as_str()\n                )));\n            }\n        }\n        Ok(())\n    }\n\n    /// Switch to a provider\n    ///\n    /// Switch flow:\n    /// 1. Validate target provider exists\n    /// 2. Check if proxy takeover mode is active AND proxy server is running\n    /// 3. If takeover mode active: hot-switch proxy target only (no Live config write)\n    /// 4. If normal mode:\n    ///    a. **Backfill mechanism**: Backfill current live config to current provider\n    ///    b. Update local settings current_provider_xxx (device-level)\n    ///    c. Update database is_current (as default for new devices)\n    ///    d. Write target provider config to live files\n    ///    e. Sync MCP configuration\n    pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result<SwitchResult, AppError> {\n        // Check if provider exists\n        let providers = state.db.get_all_providers(app_type.as_str())?;\n        let _provider = providers\n            .get(id)\n            .ok_or_else(|| AppError::Message(format!(\"供应商 {id} 不存在\")))?;\n\n        // OMO providers are switched through their own exclusive path.\n        if matches!(app_type, AppType::OpenCode) && _provider.category.as_deref() == Some(\"omo\") {\n            return Self::switch_normal(state, app_type, id, &providers);\n        }\n\n        // OMO Slim providers are switched through their own exclusive path.\n        if matches!(app_type, AppType::OpenCode)\n            && _provider.category.as_deref() == Some(\"omo-slim\")\n        {\n            return Self::switch_normal(state, app_type, id, &providers);\n        }\n\n        // Check if proxy takeover mode is active AND proxy server is actually running\n        // Both conditions must be true to use hot-switch mode\n        // Use blocking wait since this is a sync function\n        let is_app_taken_over =\n            futures::executor::block_on(state.db.get_live_backup(app_type.as_str()))\n                .ok()\n                .flatten()\n                .is_some();\n        let is_proxy_running = futures::executor::block_on(state.proxy_service.is_running());\n        let live_taken_over = state\n            .proxy_service\n            .detect_takeover_in_live_config_for_app(&app_type);\n\n        // Hot-switch only when BOTH: this app is taken over AND proxy server is actually running\n        let should_hot_switch = (is_app_taken_over || live_taken_over) && is_proxy_running;\n\n        if should_hot_switch {\n            // Proxy takeover mode: hot-switch only, don't write Live config\n            log::info!(\n                \"代理接管模式：热切换 {} 的目标供应商为 {}\",\n                app_type.as_str(),\n                id\n            );\n\n            // 获取新供应商的完整配置（用于更新备份）\n            let provider = providers\n                .get(id)\n                .ok_or_else(|| AppError::Message(format!(\"供应商 {id} 不存在\")))?;\n\n            // Update database is_current\n            state.db.set_current_provider(app_type.as_str(), id)?;\n\n            // Update local settings for consistency\n            crate::settings::set_current_provider(&app_type, Some(id))?;\n\n            // 更新 Live 备份（确保代理关闭时恢复正确的供应商配置）\n            futures::executor::block_on(\n                state\n                    .proxy_service\n                    .update_live_backup_from_provider(app_type.as_str(), provider),\n            )\n            .map_err(|e| AppError::Message(format!(\"更新 Live 备份失败: {e}\")))?;\n\n            // 关键修复：接管模式下切换供应商不会写回 Live 配置，\n            // 需要主动清理 Claude Live 中的“模型覆盖”字段，避免仍以旧模型名发起请求。\n            if matches!(app_type, AppType::Claude) {\n                if let Err(e) = state.proxy_service.cleanup_claude_model_overrides_in_live() {\n                    log::warn!(\"清理 Claude Live 模型字段失败（不影响切换结果）: {e}\");\n                }\n            }\n\n            // Note: No Live config write, no MCP sync\n            // The proxy server will route requests to the new provider via is_current\n            return Ok(SwitchResult::default());\n        }\n\n        // Normal mode: full switch with Live config write\n        Self::switch_normal(state, app_type, id, &providers)\n    }\n\n    /// Normal switch flow (non-proxy mode)\n    fn switch_normal(\n        state: &AppState,\n        app_type: AppType,\n        id: &str,\n        providers: &indexmap::IndexMap<String, Provider>,\n    ) -> Result<SwitchResult, AppError> {\n        let provider = providers\n            .get(id)\n            .ok_or_else(|| AppError::Message(format!(\"供应商 {id} 不存在\")))?;\n\n        if matches!(app_type, AppType::OpenCode) && provider.category.as_deref() == Some(\"omo\") {\n            state\n                .db\n                .set_omo_provider_current(app_type.as_str(), id, \"omo\")?;\n            crate::services::OmoService::write_config_to_file(\n                state,\n                &crate::services::omo::STANDARD,\n            )?;\n            // OMO ↔ OMO Slim mutually exclusive: remove Slim config\n            let _ = crate::services::OmoService::delete_config_file(&crate::services::omo::SLIM);\n            return Ok(SwitchResult::default());\n        }\n\n        if matches!(app_type, AppType::OpenCode) && provider.category.as_deref() == Some(\"omo-slim\")\n        {\n            state\n                .db\n                .set_omo_provider_current(app_type.as_str(), id, \"omo-slim\")?;\n            crate::services::OmoService::write_config_to_file(state, &crate::services::omo::SLIM)?;\n            // OMO ↔ OMO Slim mutually exclusive: remove Standard config\n            let _ =\n                crate::services::OmoService::delete_config_file(&crate::services::omo::STANDARD);\n            return Ok(SwitchResult::default());\n        }\n\n        let mut result = SwitchResult::default();\n\n        // Backfill: Backfill current live config to current provider\n        // Use effective current provider (validated existence) to ensure backfill targets valid provider\n        let current_id = crate::settings::get_effective_current_provider(&state.db, &app_type)?;\n\n        if let Some(current_id) = current_id {\n            if current_id != id {\n                // Additive mode apps - all providers coexist in the same file,\n                // no backfill needed (backfill is for exclusive mode apps like Claude/Codex/Gemini)\n                if !app_type.is_additive_mode() {\n                    // Only backfill when switching to a different provider\n                    if let Ok(live_config) = read_live_settings(app_type.clone()) {\n                        if let Some(mut current_provider) = providers.get(&current_id).cloned() {\n                            current_provider.settings_config =\n                                strip_common_config_from_live_settings(\n                                    state.db.as_ref(),\n                                    &app_type,\n                                    &current_provider,\n                                    live_config,\n                                );\n                            if let Err(e) =\n                                state.db.save_provider(app_type.as_str(), &current_provider)\n                            {\n                                log::warn!(\"Backfill failed: {e}\");\n                                result\n                                    .warnings\n                                    .push(format!(\"backfill_failed:{current_id}\"));\n                            }\n                        }\n                    }\n                }\n            }\n        }\n\n        // Additive mode apps skip setting is_current (no such concept)\n        if !app_type.is_additive_mode() {\n            // Update local settings (device-level, takes priority)\n            crate::settings::set_current_provider(&app_type, Some(id))?;\n\n            // Update database is_current (as default for new devices)\n            state.db.set_current_provider(app_type.as_str(), id)?;\n        }\n\n        // Sync to live (write_gemini_live handles security flag internally for Gemini)\n        write_live_with_common_config(state.db.as_ref(), &app_type, provider)?;\n\n        // Sync MCP\n        McpService::sync_all_enabled(state)?;\n\n        Ok(result)\n    }\n\n    /// Sync current provider to live configuration (re-export)\n    pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {\n        sync_current_to_live(state)\n    }\n\n    pub fn sync_current_provider_for_app(\n        state: &AppState,\n        app_type: AppType,\n    ) -> Result<(), AppError> {\n        if app_type.is_additive_mode() {\n            return sync_current_provider_for_app_to_live(state, &app_type);\n        }\n\n        let current_id =\n            match crate::settings::get_effective_current_provider(&state.db, &app_type)? {\n                Some(id) => id,\n                None => return Ok(()),\n            };\n\n        let providers = state.db.get_all_providers(app_type.as_str())?;\n        let Some(provider) = providers.get(&current_id) else {\n            return Ok(());\n        };\n\n        let takeover_enabled =\n            futures::executor::block_on(state.db.get_proxy_config_for_app(app_type.as_str()))\n                .map(|config| config.enabled)\n                .unwrap_or(false);\n\n        let has_live_backup =\n            futures::executor::block_on(state.db.get_live_backup(app_type.as_str()))\n                .ok()\n                .flatten()\n                .is_some();\n\n        let live_taken_over = state\n            .proxy_service\n            .detect_takeover_in_live_config_for_app(&app_type);\n\n        if takeover_enabled && (has_live_backup || live_taken_over) {\n            futures::executor::block_on(\n                state\n                    .proxy_service\n                    .update_live_backup_from_provider(app_type.as_str(), provider),\n            )\n            .map_err(|e| AppError::Message(format!(\"更新 Live 备份失败: {e}\")))?;\n            return Ok(());\n        }\n\n        sync_current_provider_for_app_to_live(state, &app_type)\n    }\n\n    pub fn migrate_legacy_common_config_usage(\n        state: &AppState,\n        app_type: AppType,\n        legacy_snippet: &str,\n    ) -> Result<(), AppError> {\n        if app_type.is_additive_mode() || legacy_snippet.trim().is_empty() {\n            return Ok(());\n        }\n\n        let providers = state.db.get_all_providers(app_type.as_str())?;\n\n        for provider in providers.values() {\n            if provider\n                .meta\n                .as_ref()\n                .and_then(|meta| meta.common_config_enabled)\n                .is_some()\n            {\n                continue;\n            }\n\n            if !live::provider_uses_common_config(&app_type, provider, Some(legacy_snippet)) {\n                continue;\n            }\n\n            let mut updated_provider = provider.clone();\n            updated_provider\n                .meta\n                .get_or_insert_with(Default::default)\n                .common_config_enabled = Some(true);\n\n            match live::remove_common_config_from_settings(\n                &app_type,\n                &updated_provider.settings_config,\n                legacy_snippet,\n            ) {\n                Ok(settings) => updated_provider.settings_config = settings,\n                Err(err) => {\n                    log::warn!(\n                        \"Failed to normalize legacy common config for {} provider '{}': {err}\",\n                        app_type.as_str(),\n                        updated_provider.id\n                    );\n                }\n            }\n\n            state\n                .db\n                .save_provider(app_type.as_str(), &updated_provider)?;\n        }\n\n        Ok(())\n    }\n\n    pub fn migrate_legacy_common_config_usage_if_needed(\n        state: &AppState,\n        app_type: AppType,\n    ) -> Result<(), AppError> {\n        if app_type.is_additive_mode() {\n            return Ok(());\n        }\n\n        let Some(snippet) = state.db.get_config_snippet(app_type.as_str())? else {\n            return Ok(());\n        };\n\n        if snippet.trim().is_empty() {\n            return Ok(());\n        }\n\n        Self::migrate_legacy_common_config_usage(state, app_type, &snippet)\n    }\n\n    /// Extract common config snippet from current provider\n    ///\n    /// Extracts the current provider's configuration and removes provider-specific fields\n    /// (API keys, model settings, endpoints) to create a reusable common config snippet.\n    pub fn extract_common_config_snippet(\n        state: &AppState,\n        app_type: AppType,\n    ) -> Result<String, AppError> {\n        // Get current provider\n        let current_id = Self::current(state, app_type.clone())?;\n        if current_id.is_empty() {\n            return Err(AppError::Message(\"No current provider\".to_string()));\n        }\n\n        let providers = state.db.get_all_providers(app_type.as_str())?;\n        let provider = providers\n            .get(&current_id)\n            .ok_or_else(|| AppError::Message(format!(\"Provider {current_id} not found\")))?;\n\n        match app_type {\n            AppType::Claude => Self::extract_claude_common_config(&provider.settings_config),\n            AppType::Codex => Self::extract_codex_common_config(&provider.settings_config),\n            AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config),\n            AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config),\n            AppType::OpenClaw => Self::extract_openclaw_common_config(&provider.settings_config),\n        }\n    }\n\n    /// Extract common config snippet from a config value (e.g. editor content).\n    pub fn extract_common_config_snippet_from_settings(\n        app_type: AppType,\n        settings_config: &Value,\n    ) -> Result<String, AppError> {\n        match app_type {\n            AppType::Claude => Self::extract_claude_common_config(settings_config),\n            AppType::Codex => Self::extract_codex_common_config(settings_config),\n            AppType::Gemini => Self::extract_gemini_common_config(settings_config),\n            AppType::OpenCode => Self::extract_opencode_common_config(settings_config),\n            AppType::OpenClaw => Self::extract_openclaw_common_config(settings_config),\n        }\n    }\n\n    /// Extract common config for Claude (JSON format)\n    fn extract_claude_common_config(settings: &Value) -> Result<String, AppError> {\n        let mut config = settings.clone();\n\n        // Fields to exclude from common config\n        const ENV_EXCLUDES: &[&str] = &[\n            // Auth\n            \"ANTHROPIC_API_KEY\",\n            \"ANTHROPIC_AUTH_TOKEN\",\n            // Models (5 fields)\n            \"ANTHROPIC_MODEL\",\n            \"ANTHROPIC_REASONING_MODEL\",\n            \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n            \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n            \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n            // Endpoint\n            \"ANTHROPIC_BASE_URL\",\n        ];\n\n        const TOP_LEVEL_EXCLUDES: &[&str] = &[\n            \"apiBaseUrl\",\n            // Legacy model fields\n            \"primaryModel\",\n            \"smallFastModel\",\n        ];\n\n        // Remove env fields\n        if let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n            for key in ENV_EXCLUDES {\n                env.remove(*key);\n            }\n            // If env is empty after removal, remove the env object itself\n            if env.is_empty() {\n                config.as_object_mut().map(|obj| obj.remove(\"env\"));\n            }\n        }\n\n        // Remove top-level fields\n        if let Some(obj) = config.as_object_mut() {\n            for key in TOP_LEVEL_EXCLUDES {\n                obj.remove(*key);\n            }\n        }\n\n        // Check if result is empty\n        if config.as_object().is_none_or(|obj| obj.is_empty()) {\n            return Ok(\"{}\".to_string());\n        }\n\n        serde_json::to_string_pretty(&config)\n            .map_err(|e| AppError::Message(format!(\"Serialization failed: {e}\")))\n    }\n\n    /// Extract common config for Codex (TOML format)\n    fn extract_codex_common_config(settings: &Value) -> Result<String, AppError> {\n        // Codex config is stored as { \"auth\": {...}, \"config\": \"toml string\" }\n        let config_toml = settings\n            .get(\"config\")\n            .and_then(|v| v.as_str())\n            .unwrap_or(\"\");\n\n        if config_toml.is_empty() {\n            return Ok(String::new());\n        }\n\n        let mut doc = config_toml\n            .parse::<toml_edit::DocumentMut>()\n            .map_err(|e| AppError::Message(format!(\"TOML parse error: {e}\")))?;\n\n        // Remove provider-specific fields.\n        let root = doc.as_table_mut();\n        root.remove(\"model\");\n        root.remove(\"model_provider\");\n        // Legacy/alt formats might use a top-level base_url.\n        root.remove(\"base_url\");\n\n        // Remove entire model_providers table (provider-specific configuration)\n        root.remove(\"model_providers\");\n\n        // Clean up multiple empty lines (keep at most one blank line).\n        let mut cleaned = String::new();\n        let mut blank_run = 0usize;\n        for line in doc.to_string().lines() {\n            if line.trim().is_empty() {\n                blank_run += 1;\n                if blank_run <= 1 {\n                    cleaned.push('\\n');\n                }\n                continue;\n            }\n            blank_run = 0;\n            cleaned.push_str(line);\n            cleaned.push('\\n');\n        }\n\n        Ok(cleaned.trim().to_string())\n    }\n\n    /// Extract common config for Gemini (JSON format)\n    ///\n    /// Extracts `.env` values while excluding provider-specific credentials:\n    /// - GOOGLE_GEMINI_BASE_URL\n    /// - GEMINI_API_KEY\n    fn extract_gemini_common_config(settings: &Value) -> Result<String, AppError> {\n        let env = settings.get(\"env\").and_then(|v| v.as_object());\n\n        let mut snippet = serde_json::Map::new();\n        if let Some(env) = env {\n            for (key, value) in env {\n                if key == \"GOOGLE_GEMINI_BASE_URL\" || key == \"GEMINI_API_KEY\" {\n                    continue;\n                }\n                let Value::String(v) = value else {\n                    continue;\n                };\n                let trimmed = v.trim();\n                if !trimmed.is_empty() {\n                    snippet.insert(key.to_string(), Value::String(trimmed.to_string()));\n                }\n            }\n        }\n\n        if snippet.is_empty() {\n            return Ok(\"{}\".to_string());\n        }\n\n        serde_json::to_string_pretty(&Value::Object(snippet))\n            .map_err(|e| AppError::Message(format!(\"Serialization failed: {e}\")))\n    }\n\n    /// Extract common config for OpenCode (JSON format)\n    fn extract_opencode_common_config(settings: &Value) -> Result<String, AppError> {\n        // OpenCode uses a different config structure with npm, options, models\n        // For common config, we exclude provider-specific fields like apiKey\n        let mut config = settings.clone();\n\n        // Remove provider-specific fields\n        if let Some(obj) = config.as_object_mut() {\n            if let Some(options) = obj.get_mut(\"options\").and_then(|v| v.as_object_mut()) {\n                options.remove(\"apiKey\");\n                options.remove(\"baseURL\");\n            }\n            // Keep npm and models as they might be common\n        }\n\n        if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) {\n            return Ok(\"{}\".to_string());\n        }\n\n        serde_json::to_string_pretty(&config)\n            .map_err(|e| AppError::Message(format!(\"Serialization failed: {e}\")))\n    }\n\n    /// Extract common config for OpenClaw (JSON format)\n    fn extract_openclaw_common_config(settings: &Value) -> Result<String, AppError> {\n        // OpenClaw uses a different config structure with baseUrl, apiKey, api, models\n        // For common config, we exclude provider-specific fields like apiKey\n        let mut config = settings.clone();\n\n        // Remove provider-specific fields\n        if let Some(obj) = config.as_object_mut() {\n            obj.remove(\"apiKey\");\n            obj.remove(\"baseUrl\");\n            // Keep api and models as they might be common\n        }\n\n        if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) {\n            return Ok(\"{}\".to_string());\n        }\n\n        serde_json::to_string_pretty(&config)\n            .map_err(|e| AppError::Message(format!(\"Serialization failed: {e}\")))\n    }\n\n    /// Import default configuration from live files (re-export)\n    ///\n    /// Returns `Ok(true)` if imported, `Ok(false)` if skipped.\n    pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool, AppError> {\n        import_default_config(state, app_type)\n    }\n\n    /// Read current live settings (re-export)\n    pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {\n        read_live_settings(app_type)\n    }\n\n    /// Get custom endpoints list (re-export)\n    pub fn get_custom_endpoints(\n        state: &AppState,\n        app_type: AppType,\n        provider_id: &str,\n    ) -> Result<Vec<CustomEndpoint>, AppError> {\n        endpoints::get_custom_endpoints(state, app_type, provider_id)\n    }\n\n    /// Add custom endpoint (re-export)\n    pub fn add_custom_endpoint(\n        state: &AppState,\n        app_type: AppType,\n        provider_id: &str,\n        url: String,\n    ) -> Result<(), AppError> {\n        endpoints::add_custom_endpoint(state, app_type, provider_id, url)\n    }\n\n    /// Remove custom endpoint (re-export)\n    pub fn remove_custom_endpoint(\n        state: &AppState,\n        app_type: AppType,\n        provider_id: &str,\n        url: String,\n    ) -> Result<(), AppError> {\n        endpoints::remove_custom_endpoint(state, app_type, provider_id, url)\n    }\n\n    /// Update endpoint last used timestamp (re-export)\n    pub fn update_endpoint_last_used(\n        state: &AppState,\n        app_type: AppType,\n        provider_id: &str,\n        url: String,\n    ) -> Result<(), AppError> {\n        endpoints::update_endpoint_last_used(state, app_type, provider_id, url)\n    }\n\n    /// Update provider sort order\n    pub fn update_sort_order(\n        state: &AppState,\n        app_type: AppType,\n        updates: Vec<ProviderSortUpdate>,\n    ) -> Result<bool, AppError> {\n        let mut providers = state.db.get_all_providers(app_type.as_str())?;\n\n        for update in updates {\n            if let Some(provider) = providers.get_mut(&update.id) {\n                provider.sort_index = Some(update.sort_index);\n                state.db.save_provider(app_type.as_str(), provider)?;\n            }\n        }\n\n        Ok(true)\n    }\n\n    /// Query provider usage (re-export)\n    pub async fn query_usage(\n        state: &AppState,\n        app_type: AppType,\n        provider_id: &str,\n    ) -> Result<UsageResult, AppError> {\n        usage::query_usage(state, app_type, provider_id).await\n    }\n\n    /// Test usage script (re-export)\n    #[allow(clippy::too_many_arguments)]\n    pub async fn test_usage_script(\n        state: &AppState,\n        app_type: AppType,\n        provider_id: &str,\n        script_code: &str,\n        timeout: u64,\n        api_key: Option<&str>,\n        base_url: Option<&str>,\n        access_token: Option<&str>,\n        user_id: Option<&str>,\n        template_type: Option<&str>,\n    ) -> Result<UsageResult, AppError> {\n        usage::test_usage_script(\n            state,\n            app_type,\n            provider_id,\n            script_code,\n            timeout,\n            api_key,\n            base_url,\n            access_token,\n            user_id,\n            template_type,\n        )\n        .await\n    }\n\n    pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {\n        write_gemini_live(provider)\n    }\n\n    fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), AppError> {\n        match app_type {\n            AppType::Claude => {\n                if !provider.settings_config.is_object() {\n                    return Err(AppError::localized(\n                        \"provider.claude.settings.not_object\",\n                        \"Claude 配置必须是 JSON 对象\",\n                        \"Claude configuration must be a JSON object\",\n                    ));\n                }\n            }\n            AppType::Codex => {\n                let settings = provider.settings_config.as_object().ok_or_else(|| {\n                    AppError::localized(\n                        \"provider.codex.settings.not_object\",\n                        \"Codex 配置必须是 JSON 对象\",\n                        \"Codex configuration must be a JSON object\",\n                    )\n                })?;\n\n                let auth = settings.get(\"auth\").ok_or_else(|| {\n                    AppError::localized(\n                        \"provider.codex.auth.missing\",\n                        format!(\"供应商 {} 缺少 auth 配置\", provider.id),\n                        format!(\"Provider {} is missing auth configuration\", provider.id),\n                    )\n                })?;\n                if !auth.is_object() {\n                    return Err(AppError::localized(\n                        \"provider.codex.auth.not_object\",\n                        format!(\"供应商 {} 的 auth 配置必须是 JSON 对象\", provider.id),\n                        format!(\n                            \"Provider {} auth configuration must be a JSON object\",\n                            provider.id\n                        ),\n                    ));\n                }\n\n                if let Some(config_value) = settings.get(\"config\") {\n                    if !(config_value.is_string() || config_value.is_null()) {\n                        return Err(AppError::localized(\n                            \"provider.codex.config.invalid_type\",\n                            \"Codex config 字段必须是字符串\",\n                            \"Codex config field must be a string\",\n                        ));\n                    }\n                    if let Some(cfg_text) = config_value.as_str() {\n                        crate::codex_config::validate_config_toml(cfg_text)?;\n                    }\n                }\n            }\n            AppType::Gemini => {\n                use crate::gemini_config::validate_gemini_settings;\n                validate_gemini_settings(&provider.settings_config)?\n            }\n            AppType::OpenCode => {\n                // OpenCode uses a different config structure: { npm, options, models }\n                // Basic validation - must be an object\n                if !provider.settings_config.is_object() {\n                    return Err(AppError::localized(\n                        \"provider.opencode.settings.not_object\",\n                        \"OpenCode 配置必须是 JSON 对象\",\n                        \"OpenCode configuration must be a JSON object\",\n                    ));\n                }\n            }\n            AppType::OpenClaw => {\n                // OpenClaw uses config structure: { baseUrl, apiKey, api, models }\n                // Basic validation - must be an object\n                if !provider.settings_config.is_object() {\n                    return Err(AppError::localized(\n                        \"provider.openclaw.settings.not_object\",\n                        \"OpenClaw 配置必须是 JSON 对象\",\n                        \"OpenClaw configuration must be a JSON object\",\n                    ));\n                }\n            }\n        }\n\n        // Validate and clean UsageScript configuration (common for all app types)\n        if let Some(meta) = &provider.meta {\n            if let Some(usage_script) = &meta.usage_script {\n                validate_usage_script(usage_script)?;\n            }\n        }\n\n        Ok(())\n    }\n\n    #[allow(dead_code)]\n    fn extract_credentials(\n        provider: &Provider,\n        app_type: &AppType,\n    ) -> Result<(String, String), AppError> {\n        match app_type {\n            AppType::Claude => {\n                let env = provider\n                    .settings_config\n                    .get(\"env\")\n                    .and_then(|v| v.as_object())\n                    .ok_or_else(|| {\n                        AppError::localized(\n                            \"provider.claude.env.missing\",\n                            \"配置格式错误: 缺少 env\",\n                            \"Invalid configuration: missing env section\",\n                        )\n                    })?;\n\n                let api_key = env\n                    .get(\"ANTHROPIC_AUTH_TOKEN\")\n                    .or_else(|| env.get(\"ANTHROPIC_API_KEY\"))\n                    .and_then(|v| v.as_str())\n                    .ok_or_else(|| {\n                        AppError::localized(\n                            \"provider.claude.api_key.missing\",\n                            \"缺少 API Key\",\n                            \"API key is missing\",\n                        )\n                    })?\n                    .to_string();\n\n                let base_url = env\n                    .get(\"ANTHROPIC_BASE_URL\")\n                    .and_then(|v| v.as_str())\n                    .ok_or_else(|| {\n                        AppError::localized(\n                            \"provider.claude.base_url.missing\",\n                            \"缺少 ANTHROPIC_BASE_URL 配置\",\n                            \"Missing ANTHROPIC_BASE_URL configuration\",\n                        )\n                    })?\n                    .to_string();\n\n                Ok((api_key, base_url))\n            }\n            AppType::Codex => {\n                let auth = provider\n                    .settings_config\n                    .get(\"auth\")\n                    .and_then(|v| v.as_object())\n                    .ok_or_else(|| {\n                        AppError::localized(\n                            \"provider.codex.auth.missing\",\n                            \"配置格式错误: 缺少 auth\",\n                            \"Invalid configuration: missing auth section\",\n                        )\n                    })?;\n\n                let api_key = auth\n                    .get(\"OPENAI_API_KEY\")\n                    .and_then(|v| v.as_str())\n                    .ok_or_else(|| {\n                        AppError::localized(\n                            \"provider.codex.api_key.missing\",\n                            \"缺少 API Key\",\n                            \"API key is missing\",\n                        )\n                    })?\n                    .to_string();\n\n                let config_toml = provider\n                    .settings_config\n                    .get(\"config\")\n                    .and_then(|v| v.as_str())\n                    .unwrap_or(\"\");\n\n                let base_url = if config_toml.contains(\"base_url\") {\n                    let re = Regex::new(r#\"base_url\\s*=\\s*[\"']([^\"']+)[\"']\"#).map_err(|e| {\n                        AppError::localized(\n                            \"provider.regex_init_failed\",\n                            format!(\"正则初始化失败: {e}\"),\n                            format!(\"Failed to initialize regex: {e}\"),\n                        )\n                    })?;\n                    re.captures(config_toml)\n                        .and_then(|caps| caps.get(1))\n                        .map(|m| m.as_str().to_string())\n                        .ok_or_else(|| {\n                            AppError::localized(\n                                \"provider.codex.base_url.invalid\",\n                                \"config.toml 中 base_url 格式错误\",\n                                \"base_url in config.toml has invalid format\",\n                            )\n                        })?\n                } else {\n                    return Err(AppError::localized(\n                        \"provider.codex.base_url.missing\",\n                        \"config.toml 中缺少 base_url 配置\",\n                        \"base_url is missing from config.toml\",\n                    ));\n                };\n\n                Ok((api_key, base_url))\n            }\n            AppType::Gemini => {\n                use crate::gemini_config::json_to_env;\n\n                let env_map = json_to_env(&provider.settings_config)?;\n\n                let api_key = env_map.get(\"GEMINI_API_KEY\").cloned().ok_or_else(|| {\n                    AppError::localized(\n                        \"gemini.missing_api_key\",\n                        \"缺少 GEMINI_API_KEY\",\n                        \"Missing GEMINI_API_KEY\",\n                    )\n                })?;\n\n                let base_url = env_map\n                    .get(\"GOOGLE_GEMINI_BASE_URL\")\n                    .cloned()\n                    .unwrap_or_else(|| \"https://generativelanguage.googleapis.com\".to_string());\n\n                Ok((api_key, base_url))\n            }\n            AppType::OpenCode => {\n                // OpenCode uses options.apiKey and options.baseURL\n                let options = provider\n                    .settings_config\n                    .get(\"options\")\n                    .and_then(|v| v.as_object())\n                    .ok_or_else(|| {\n                        AppError::localized(\n                            \"provider.opencode.options.missing\",\n                            \"配置格式错误: 缺少 options\",\n                            \"Invalid configuration: missing options section\",\n                        )\n                    })?;\n\n                let api_key = options\n                    .get(\"apiKey\")\n                    .and_then(|v| v.as_str())\n                    .ok_or_else(|| {\n                        AppError::localized(\n                            \"provider.opencode.api_key.missing\",\n                            \"缺少 API Key\",\n                            \"API key is missing\",\n                        )\n                    })?\n                    .to_string();\n\n                let base_url = options\n                    .get(\"baseURL\")\n                    .and_then(|v| v.as_str())\n                    .unwrap_or(\"\")\n                    .to_string();\n\n                Ok((api_key, base_url))\n            }\n            AppType::OpenClaw => {\n                // OpenClaw uses apiKey and baseUrl directly on the object\n                let api_key = provider\n                    .settings_config\n                    .get(\"apiKey\")\n                    .and_then(|v| v.as_str())\n                    .ok_or_else(|| {\n                        AppError::localized(\n                            \"provider.openclaw.api_key.missing\",\n                            \"缺少 API Key\",\n                            \"API key is missing\",\n                        )\n                    })?\n                    .to_string();\n\n                let base_url = provider\n                    .settings_config\n                    .get(\"baseUrl\")\n                    .and_then(|v| v.as_str())\n                    .unwrap_or(\"\")\n                    .to_string();\n\n                Ok((api_key, base_url))\n            }\n        }\n    }\n}\n\n/// Normalize Claude model keys in a JSON value\n///\n/// Reads old key (ANTHROPIC_SMALL_FAST_MODEL), writes new keys (DEFAULT_*), and deletes old key.\npub(crate) fn normalize_claude_models_in_value(settings: &mut Value) -> bool {\n    let mut changed = false;\n    let env = match settings.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n        Some(obj) => obj,\n        None => return changed,\n    };\n\n    let model = env\n        .get(\"ANTHROPIC_MODEL\")\n        .and_then(|v| v.as_str())\n        .map(|s| s.to_string());\n    let small_fast = env\n        .get(\"ANTHROPIC_SMALL_FAST_MODEL\")\n        .and_then(|v| v.as_str())\n        .map(|s| s.to_string());\n\n    let current_haiku = env\n        .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n        .and_then(|v| v.as_str())\n        .map(|s| s.to_string());\n    let current_sonnet = env\n        .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n        .and_then(|v| v.as_str())\n        .map(|s| s.to_string());\n    let current_opus = env\n        .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n        .and_then(|v| v.as_str())\n        .map(|s| s.to_string());\n\n    let target_haiku = current_haiku\n        .or_else(|| small_fast.clone())\n        .or_else(|| model.clone());\n    let target_sonnet = current_sonnet\n        .or_else(|| model.clone())\n        .or_else(|| small_fast.clone());\n    let target_opus = current_opus\n        .or_else(|| model.clone())\n        .or_else(|| small_fast.clone());\n\n    if env.get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL\").is_none() {\n        if let Some(v) = target_haiku {\n            env.insert(\n                \"ANTHROPIC_DEFAULT_HAIKU_MODEL\".to_string(),\n                Value::String(v),\n            );\n            changed = true;\n        }\n    }\n    if env.get(\"ANTHROPIC_DEFAULT_SONNET_MODEL\").is_none() {\n        if let Some(v) = target_sonnet {\n            env.insert(\n                \"ANTHROPIC_DEFAULT_SONNET_MODEL\".to_string(),\n                Value::String(v),\n            );\n            changed = true;\n        }\n    }\n    if env.get(\"ANTHROPIC_DEFAULT_OPUS_MODEL\").is_none() {\n        if let Some(v) = target_opus {\n            env.insert(\"ANTHROPIC_DEFAULT_OPUS_MODEL\".to_string(), Value::String(v));\n            changed = true;\n        }\n    }\n\n    if env.remove(\"ANTHROPIC_SMALL_FAST_MODEL\").is_some() {\n        changed = true;\n    }\n\n    changed\n}\n\n#[derive(Debug, Clone, Deserialize)]\npub struct ProviderSortUpdate {\n    pub id: String,\n    #[serde(rename = \"sortIndex\")]\n    pub sort_index: usize,\n}\n\n// ============================================================================\n// 统一供应商（Universal Provider）服务方法\n// ============================================================================\n\nuse crate::provider::UniversalProvider;\nuse std::collections::HashMap;\n\nimpl ProviderService {\n    /// 获取所有统一供应商\n    pub fn list_universal(\n        state: &AppState,\n    ) -> Result<HashMap<String, UniversalProvider>, AppError> {\n        state.db.get_all_universal_providers()\n    }\n\n    /// 获取单个统一供应商\n    pub fn get_universal(\n        state: &AppState,\n        id: &str,\n    ) -> Result<Option<UniversalProvider>, AppError> {\n        state.db.get_universal_provider(id)\n    }\n\n    /// 添加或更新统一供应商（不自动同步，需手动调用 sync_universal_to_apps）\n    pub fn upsert_universal(\n        state: &AppState,\n        provider: UniversalProvider,\n    ) -> Result<bool, AppError> {\n        // 保存统一供应商\n        state.db.save_universal_provider(&provider)?;\n\n        Ok(true)\n    }\n\n    /// 删除统一供应商\n    pub fn delete_universal(state: &AppState, id: &str) -> Result<bool, AppError> {\n        // 获取统一供应商（用于删除生成的子供应商）\n        let provider = state.db.get_universal_provider(id)?;\n\n        // 删除统一供应商\n        state.db.delete_universal_provider(id)?;\n\n        // 删除生成的子供应商\n        if let Some(p) = provider {\n            if p.apps.claude {\n                let claude_id = format!(\"universal-claude-{id}\");\n                let _ = state.db.delete_provider(\"claude\", &claude_id);\n            }\n            if p.apps.codex {\n                let codex_id = format!(\"universal-codex-{id}\");\n                let _ = state.db.delete_provider(\"codex\", &codex_id);\n            }\n            if p.apps.gemini {\n                let gemini_id = format!(\"universal-gemini-{id}\");\n                let _ = state.db.delete_provider(\"gemini\", &gemini_id);\n            }\n        }\n\n        Ok(true)\n    }\n\n    /// 同步统一供应商到各应用\n    pub fn sync_universal_to_apps(state: &AppState, id: &str) -> Result<bool, AppError> {\n        let provider = state\n            .db\n            .get_universal_provider(id)?\n            .ok_or_else(|| AppError::Message(format!(\"统一供应商 {id} 不存在\")))?;\n\n        // 同步到 Claude\n        if let Some(mut claude_provider) = provider.to_claude_provider() {\n            // 合并已有配置\n            if let Some(existing) = state.db.get_provider_by_id(&claude_provider.id, \"claude\")? {\n                let mut merged = existing.settings_config.clone();\n                Self::merge_json(&mut merged, &claude_provider.settings_config);\n                claude_provider.settings_config = merged;\n            }\n            state.db.save_provider(\"claude\", &claude_provider)?;\n        } else {\n            // 如果禁用了 Claude，删除对应的子供应商\n            let claude_id = format!(\"universal-claude-{id}\");\n            let _ = state.db.delete_provider(\"claude\", &claude_id);\n        }\n\n        // 同步到 Codex\n        if let Some(mut codex_provider) = provider.to_codex_provider() {\n            // 合并已有配置\n            if let Some(existing) = state.db.get_provider_by_id(&codex_provider.id, \"codex\")? {\n                let mut merged = existing.settings_config.clone();\n                Self::merge_json(&mut merged, &codex_provider.settings_config);\n                codex_provider.settings_config = merged;\n            }\n            state.db.save_provider(\"codex\", &codex_provider)?;\n        } else {\n            let codex_id = format!(\"universal-codex-{id}\");\n            let _ = state.db.delete_provider(\"codex\", &codex_id);\n        }\n\n        // 同步到 Gemini\n        if let Some(mut gemini_provider) = provider.to_gemini_provider() {\n            // 合并已有配置\n            if let Some(existing) = state.db.get_provider_by_id(&gemini_provider.id, \"gemini\")? {\n                let mut merged = existing.settings_config.clone();\n                Self::merge_json(&mut merged, &gemini_provider.settings_config);\n                gemini_provider.settings_config = merged;\n            }\n            state.db.save_provider(\"gemini\", &gemini_provider)?;\n        } else {\n            let gemini_id = format!(\"universal-gemini-{id}\");\n            let _ = state.db.delete_provider(\"gemini\", &gemini_id);\n        }\n\n        Ok(true)\n    }\n\n    /// 递归合并 JSON：base 为底，patch 覆盖同名字段\n    fn merge_json(base: &mut serde_json::Value, patch: &serde_json::Value) {\n        use serde_json::Value;\n\n        match (base, patch) {\n            (Value::Object(base_map), Value::Object(patch_map)) => {\n                for (k, v_patch) in patch_map {\n                    match base_map.get_mut(k) {\n                        Some(v_base) => Self::merge_json(v_base, v_patch),\n                        None => {\n                            base_map.insert(k.clone(), v_patch.clone());\n                        }\n                    }\n                }\n            }\n            // 其它类型：直接覆盖\n            (base_val, patch_val) => {\n                *base_val = patch_val.clone();\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/services/provider/usage.rs",
    "content": "//! Usage script execution\n//!\n//! Handles executing and formatting usage query results.\n\nuse crate::app_config::AppType;\nuse crate::error::AppError;\nuse crate::provider::{UsageData, UsageResult, UsageScript};\nuse crate::settings;\nuse crate::store::AppState;\nuse crate::usage_script;\n\n/// Execute usage script and format result (private helper method)\npub(crate) async fn execute_and_format_usage_result(\n    script_code: &str,\n    api_key: &str,\n    base_url: &str,\n    timeout: u64,\n    access_token: Option<&str>,\n    user_id: Option<&str>,\n    template_type: Option<&str>,\n) -> Result<UsageResult, AppError> {\n    match usage_script::execute_usage_script(\n        script_code,\n        api_key,\n        base_url,\n        timeout,\n        access_token,\n        user_id,\n        template_type,\n    )\n    .await\n    {\n        Ok(data) => {\n            let usage_list: Vec<UsageData> = if data.is_array() {\n                serde_json::from_value(data).map_err(|e| {\n                    AppError::localized(\n                        \"usage_script.data_format_error\",\n                        format!(\"数据格式错误: {e}\"),\n                        format!(\"Data format error: {e}\"),\n                    )\n                })?\n            } else {\n                let single: UsageData = serde_json::from_value(data).map_err(|e| {\n                    AppError::localized(\n                        \"usage_script.data_format_error\",\n                        format!(\"数据格式错误: {e}\"),\n                        format!(\"Data format error: {e}\"),\n                    )\n                })?;\n                vec![single]\n            };\n\n            Ok(UsageResult {\n                success: true,\n                data: Some(usage_list),\n                error: None,\n            })\n        }\n        Err(err) => {\n            let lang = settings::get_settings()\n                .language\n                .unwrap_or_else(|| \"zh\".to_string());\n\n            let msg = match err {\n                AppError::Localized { zh, en, .. } => {\n                    if lang == \"en\" {\n                        en\n                    } else {\n                        zh\n                    }\n                }\n                other => other.to_string(),\n            };\n\n            Ok(UsageResult {\n                success: false,\n                data: None,\n                error: Some(msg),\n            })\n        }\n    }\n}\n\n/// Extract API key from provider configuration\nfn extract_api_key_from_provider(provider: &crate::provider::Provider) -> Option<String> {\n    if let Some(env) = provider.settings_config.get(\"env\") {\n        // Try multiple possible API key fields\n        env.get(\"ANTHROPIC_AUTH_TOKEN\")\n            .or_else(|| env.get(\"ANTHROPIC_API_KEY\"))\n            .or_else(|| env.get(\"OPENROUTER_API_KEY\"))\n            .or_else(|| env.get(\"GOOGLE_API_KEY\"))\n            .and_then(|v| v.as_str())\n            .map(|s| s.to_string())\n    } else {\n        None\n    }\n}\n\n/// Extract base URL from provider configuration\nfn extract_base_url_from_provider(provider: &crate::provider::Provider) -> Option<String> {\n    if let Some(env) = provider.settings_config.get(\"env\") {\n        // Try multiple possible base URL fields\n        env.get(\"ANTHROPIC_BASE_URL\")\n            .or_else(|| env.get(\"GOOGLE_GEMINI_BASE_URL\"))\n            .and_then(|v| v.as_str())\n            .map(|s| s.trim_end_matches('/').to_string())\n    } else {\n        None\n    }\n}\n\n/// Query provider usage (using saved script configuration)\npub async fn query_usage(\n    state: &AppState,\n    app_type: AppType,\n    provider_id: &str,\n) -> Result<UsageResult, AppError> {\n    let (script_code, timeout, api_key, base_url, access_token, user_id, template_type) = {\n        let providers = state.db.get_all_providers(app_type.as_str())?;\n        let provider = providers.get(provider_id).ok_or_else(|| {\n            AppError::localized(\n                \"provider.not_found\",\n                format!(\"供应商不存在: {provider_id}\"),\n                format!(\"Provider not found: {provider_id}\"),\n            )\n        })?;\n\n        let usage_script = provider\n            .meta\n            .as_ref()\n            .and_then(|m| m.usage_script.as_ref())\n            .ok_or_else(|| {\n                AppError::localized(\n                    \"provider.usage.script.missing\",\n                    \"未配置用量查询脚本\",\n                    \"Usage script is not configured\",\n                )\n            })?;\n        if !usage_script.enabled {\n            return Err(AppError::localized(\n                \"provider.usage.disabled\",\n                \"用量查询未启用\",\n                \"Usage query is disabled\",\n            ));\n        }\n\n        // Get credentials: prioritize UsageScript values, fallback to provider config\n        let api_key = usage_script\n            .api_key\n            .clone()\n            .filter(|k| !k.is_empty())\n            .or_else(|| extract_api_key_from_provider(provider))\n            .unwrap_or_default();\n\n        let base_url = usage_script\n            .base_url\n            .clone()\n            .filter(|u| !u.is_empty())\n            .or_else(|| extract_base_url_from_provider(provider))\n            .unwrap_or_default();\n\n        (\n            usage_script.code.clone(),\n            usage_script.timeout.unwrap_or(10),\n            api_key,\n            base_url,\n            usage_script.access_token.clone(),\n            usage_script.user_id.clone(),\n            usage_script.template_type.clone(),\n        )\n    };\n\n    execute_and_format_usage_result(\n        &script_code,\n        &api_key,\n        &base_url,\n        timeout,\n        access_token.as_deref(),\n        user_id.as_deref(),\n        template_type.as_deref(),\n    )\n    .await\n}\n\n/// Test usage script (using temporary script content, not saved)\n#[allow(clippy::too_many_arguments)]\npub async fn test_usage_script(\n    _state: &AppState,\n    _app_type: AppType,\n    _provider_id: &str,\n    script_code: &str,\n    timeout: u64,\n    api_key: Option<&str>,\n    base_url: Option<&str>,\n    access_token: Option<&str>,\n    user_id: Option<&str>,\n    template_type: Option<&str>,\n) -> Result<UsageResult, AppError> {\n    // Use provided credential parameters directly for testing\n    execute_and_format_usage_result(\n        script_code,\n        api_key.unwrap_or(\"\"),\n        base_url.unwrap_or(\"\"),\n        timeout,\n        access_token,\n        user_id,\n        template_type,\n    )\n    .await\n}\n\n/// Validate UsageScript configuration (boundary checks)\npub(crate) fn validate_usage_script(script: &UsageScript) -> Result<(), AppError> {\n    // Validate auto query interval (0-1440 minutes, max 24 hours)\n    if let Some(interval) = script.auto_query_interval {\n        if interval > 1440 {\n            return Err(AppError::localized(\n                \"usage_script.interval_too_large\",\n                format!(\"自动查询间隔不能超过 1440 分钟（24小时），当前值: {interval}\"),\n                format!(\n                    \"Auto query interval cannot exceed 1440 minutes (24 hours), current: {interval}\"\n                ),\n            ));\n        }\n    }\n\n    Ok(())\n}\n"
  },
  {
    "path": "src-tauri/src/services/proxy.rs",
    "content": "//! 代理服务业务逻辑层\n//!\n//! 提供代理服务器的启动、停止和配置管理\n\nuse crate::app_config::AppType;\nuse crate::config::{get_claude_settings_path, read_json_file, write_json_file};\nuse crate::database::Database;\nuse crate::provider::Provider;\nuse crate::proxy::server::ProxyServer;\nuse crate::proxy::types::*;\nuse crate::services::provider::{\n    build_effective_settings_with_common_config, write_live_with_common_config,\n};\nuse serde_json::{json, Value};\nuse std::str::FromStr;\nuse std::sync::Arc;\nuse tokio::sync::RwLock;\n\n/// 用于接管 Live 配置时的占位符（避免客户端提示缺少 key，同时不泄露真实 Token）\nconst PROXY_TOKEN_PLACEHOLDER: &str = \"PROXY_MANAGED\";\n\n/// 代理接管模式下需要从 Claude Live 配置中移除的\"模型覆盖\"字段。\n///\n/// 原因：接管模式切换供应商时不会写回 Live 配置，如果保留这些字段，\n/// Claude Code 会继续以旧模型名发起请求，导致新供应商不支持时失败。\nconst CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 6] = [\n    \"ANTHROPIC_MODEL\",\n    \"ANTHROPIC_REASONING_MODEL\",\n    \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n    \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n    \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n    // Legacy key (已废弃)：历史版本使用该字段区分 small/fast 模型\n    \"ANTHROPIC_SMALL_FAST_MODEL\",\n];\n\n#[derive(Clone)]\npub struct ProxyService {\n    db: Arc<Database>,\n    server: Arc<RwLock<Option<ProxyServer>>>,\n    /// AppHandle，用于传递给 ProxyServer 以支持故障转移时的 UI 更新\n    app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,\n}\n\nimpl ProxyService {\n    pub fn new(db: Arc<Database>) -> Self {\n        Self {\n            db,\n            server: Arc::new(RwLock::new(None)),\n            app_handle: Arc::new(RwLock::new(None)),\n        }\n    }\n\n    /// 清理接管模式下 Claude Live 配置中的模型覆盖字段。\n    ///\n    /// 这可以避免\"接管开启后切换供应商仍使用旧模型\"的问题。\n    /// 注意：此方法不会修改 Token/Base URL 的接管占位符，仅移除模型字段。\n    pub fn cleanup_claude_model_overrides_in_live(&self) -> Result<(), String> {\n        let mut config = self.read_claude_live()?;\n\n        let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n            return Ok(());\n        };\n\n        let mut changed = false;\n        for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n            if env.remove(key).is_some() {\n                changed = true;\n            }\n        }\n\n        if changed {\n            self.write_claude_live(&config)?;\n        }\n\n        Ok(())\n    }\n\n    /// 设置 AppHandle（在应用初始化时调用）\n    pub fn set_app_handle(&self, handle: tauri::AppHandle) {\n        futures::executor::block_on(async {\n            *self.app_handle.write().await = Some(handle);\n        });\n    }\n\n    /// 启动代理服务器\n    pub async fn start(&self) -> Result<ProxyServerInfo, String> {\n        // 1. 启动时自动设置 proxy_enabled = true\n        let mut global_config = self\n            .db\n            .get_global_proxy_config()\n            .await\n            .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n        if !global_config.proxy_enabled {\n            global_config.proxy_enabled = true;\n            self.db\n                .update_global_proxy_config(global_config.clone())\n                .await\n                .map_err(|e| format!(\"更新代理总开关失败: {e}\"))?;\n        }\n\n        // 2. 获取配置\n        let config = self\n            .db\n            .get_proxy_config()\n            .await\n            .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n        // 3. 若已在运行：确保持久化状态（如需要）并返回当前信息\n        if let Some(server) = self.server.read().await.as_ref() {\n            let status = server.get_status().await;\n            return Ok(ProxyServerInfo {\n                address: status.address,\n                port: status.port,\n                // 无法精确取回首次启动时间，返回当前时间用于 UI 展示即可\n                started_at: chrono::Utc::now().to_rfc3339(),\n            });\n        }\n\n        // 4. 创建并启动服务器\n        let app_handle = self.app_handle.read().await.clone();\n        let server = ProxyServer::new(config.clone(), self.db.clone(), app_handle);\n        let info = server\n            .start()\n            .await\n            .map_err(|e| format!(\"启动代理服务器失败: {e}\"))?;\n\n        // 5. 保存服务器实例\n        *self.server.write().await = Some(server);\n\n        log::info!(\"代理服务器已启动: {}:{}\", info.address, info.port);\n        Ok(info)\n    }\n\n    /// 启动代理服务器（带 Live 配置接管）\n    pub async fn start_with_takeover(&self) -> Result<ProxyServerInfo, String> {\n        // 1. 备份各应用的 Live 配置\n        self.backup_live_configs().await?;\n\n        // 2. 同步 Live 配置中的 Token 到数据库（确保代理能读到最新的 Token）\n        if let Err(e) = self.sync_live_to_providers().await {\n            // 同步失败时尚未写入接管配置，但备份可能包含敏感信息，尽量清理\n            if let Err(clean_err) = self.db.delete_all_live_backups().await {\n                log::warn!(\"清理 Live 备份失败: {clean_err}\");\n            }\n            return Err(e);\n        }\n\n        // 3. 在写入接管配置之前先落盘接管标志：\n        //    这样即使在接管过程中断电/kill，下次启动也能检测到并自动恢复。\n        if let Err(e) = self.db.set_live_takeover_active(true).await {\n            if let Err(clean_err) = self.db.delete_all_live_backups().await {\n                log::warn!(\"清理 Live 备份失败: {clean_err}\");\n            }\n            return Err(format!(\"设置接管状态失败: {e}\"));\n        }\n\n        // 4. 接管各应用的 Live 配置（写入代理地址，清空 Token）\n        if let Err(e) = self.takeover_live_configs().await {\n            // 接管失败（可能是部分写入），尝试恢复原始配置；若恢复失败则保留标志与备份，等待下次启动自动恢复。\n            log::error!(\"接管 Live 配置失败，尝试恢复原始配置: {e}\");\n            match self.restore_live_configs().await {\n                Ok(()) => {\n                    let _ = self.db.set_live_takeover_active(false).await;\n                    let _ = self.db.delete_all_live_backups().await;\n                }\n                Err(restore_err) => {\n                    log::error!(\"恢复原始配置失败，将保留备份以便下次启动恢复: {restore_err}\");\n                }\n            }\n            return Err(e);\n        }\n\n        // 5. 启动代理服务器\n        match self.start().await {\n            Ok(info) => Ok(info),\n            Err(e) => {\n                // 启动失败，恢复原始配置\n                log::error!(\"代理启动失败，尝试恢复原始配置: {e}\");\n                match self.restore_live_configs().await {\n                    Ok(()) => {\n                        let _ = self.db.set_live_takeover_active(false).await;\n                        let _ = self.db.delete_all_live_backups().await;\n                    }\n                    Err(restore_err) => {\n                        log::error!(\"恢复原始配置失败，将保留备份以便下次启动恢复: {restore_err}\");\n                    }\n                }\n                Err(e)\n            }\n        }\n    }\n\n    /// 获取各应用的接管状态（是否改写该应用的 Live 配置指向本地代理）\n    pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> {\n        // 从 proxy_config.enabled 读取（优先），兼容旧的 live_backup 备份检测\n        let claude_enabled = self\n            .db\n            .get_proxy_config_for_app(\"claude\")\n            .await\n            .map(|c| c.enabled)\n            .unwrap_or(false);\n        let codex_enabled = self\n            .db\n            .get_proxy_config_for_app(\"codex\")\n            .await\n            .map(|c| c.enabled)\n            .unwrap_or(false);\n        let gemini_enabled = self\n            .db\n            .get_proxy_config_for_app(\"gemini\")\n            .await\n            .map(|c| c.enabled)\n            .unwrap_or(false);\n        // OpenCode and OpenClaw don't support proxy features, always return false\n        let opencode_enabled = false;\n        let openclaw_enabled = false;\n\n        Ok(ProxyTakeoverStatus {\n            claude: claude_enabled,\n            codex: codex_enabled,\n            gemini: gemini_enabled,\n            opencode: opencode_enabled,\n            openclaw: openclaw_enabled,\n        })\n    }\n\n    /// 为指定应用开启/关闭 Live 接管\n    ///\n    /// - 开启：自动启动代理服务，仅接管当前 app 的 Live 配置\n    /// - 关闭：仅恢复当前 app 的 Live 配置；若无其它接管，则自动停止代理服务\n    pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> {\n        let app = AppType::from_str(app_type).map_err(|e| format!(\"无效的应用类型: {e}\"))?;\n        let app_type_str = app.as_str();\n\n        if enabled {\n            // 1) 代理服务未运行则自动启动\n            if !self.is_running().await {\n                self.start().await?;\n            }\n\n            // 2) 已接管则直接返回（幂等）；但如果缺少备份或占位符残留，需要重建接管\n            let current_config = self\n                .db\n                .get_proxy_config_for_app(app_type_str)\n                .await\n                .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n            if current_config.enabled {\n                let has_backup = match self.db.get_live_backup(app_type_str).await {\n                    Ok(v) => v.is_some(),\n                    Err(e) => {\n                        log::warn!(\"读取 {app_type_str} 备份失败（将继续重建接管）: {e}\");\n                        false\n                    }\n                };\n                let live_taken_over = self.detect_takeover_in_live_config_for_app(&app);\n\n                if has_backup || live_taken_over {\n                    return Ok(());\n                }\n\n                log::warn!(\n                    \"{app_type_str} 标记为已接管，但缺少备份或占位符，正在重新接管并补齐备份\"\n                );\n            }\n\n            // 3) 备份 Live 配置（严格：目标 app 不存在则报错）\n            self.backup_live_config_strict(&app).await?;\n\n            // 4) 同步 Live Token 到数据库（仅当前 app）\n            if let Err(e) = self.sync_live_to_provider(&app).await {\n                let _ = self.db.delete_live_backup(app_type_str).await;\n                return Err(e);\n            }\n\n            // 5) 写入接管配置（仅当前 app）\n            if let Err(e) = self.takeover_live_config_strict(&app).await {\n                log::error!(\"{app_type_str} 接管 Live 配置失败，尝试恢复: {e}\");\n                match self.restore_live_config_for_app(&app).await {\n                    Ok(()) => {\n                        // 恢复成功才清理备份，避免失败场景下丢失唯一可回滚来源\n                        let _ = self.db.delete_live_backup(app_type_str).await;\n                    }\n                    Err(restore_err) => {\n                        log::error!(\n                            \"{app_type_str} 恢复 Live 配置失败，将保留备份以便下次启动恢复: {restore_err}\"\n                        );\n                    }\n                }\n                return Err(e);\n            }\n\n            // 6) 设置 proxy_config.enabled = true\n            let mut updated_config = self\n                .db\n                .get_proxy_config_for_app(app_type_str)\n                .await\n                .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n            updated_config.enabled = true;\n            self.db\n                .update_proxy_config_for_app(updated_config)\n                .await\n                .map_err(|e| format!(\"设置 {app_type_str} enabled 状态失败: {e}\"))?;\n\n            // 7) 兼容旧逻辑：写入 any-of 标志（失败不影响功能）\n            let _ = self.db.set_live_takeover_active(true).await;\n            return Ok(());\n        }\n\n        // 关闭接管：检查 enabled 状态\n        let current_config = self\n            .db\n            .get_proxy_config_for_app(app_type_str)\n            .await\n            .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n        if !current_config.enabled {\n            return Ok(()); // 未接管，幂等返回\n        }\n\n        // 1) 恢复 Live 配置\n        self.restore_live_config_for_app(&app).await?;\n\n        // 2) 删除该 app 的备份（避免长期存储敏感 Token）\n        self.db\n            .delete_live_backup(app_type_str)\n            .await\n            .map_err(|e| format!(\"删除 {app_type_str} Live 备份失败: {e}\"))?;\n\n        // 3) 设置 proxy_config.enabled = false\n        let mut updated_config = self\n            .db\n            .get_proxy_config_for_app(app_type_str)\n            .await\n            .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n        updated_config.enabled = false;\n        self.db\n            .update_proxy_config_for_app(updated_config)\n            .await\n            .map_err(|e| format!(\"清除 {app_type_str} enabled 状态失败: {e}\"))?;\n\n        // 4) 清除该应用的健康状态（关闭代理时重置队列状态）\n        self.db\n            .clear_provider_health_for_app(app_type_str)\n            .await\n            .map_err(|e| format!(\"清除 {app_type_str} 健康状态失败: {e}\"))?;\n\n        // 5) 若无其它接管，更新旧标志，并停止代理服务\n        // 检查是否还有其它 app 的 enabled = true\n        let any_enabled = self\n            .db\n            .is_live_takeover_active()\n            .await\n            .map_err(|e| format!(\"检查接管状态失败: {e}\"))?;\n\n        if !any_enabled {\n            let _ = self.db.set_live_takeover_active(false).await;\n\n            if self.is_running().await {\n                // 此时没有任何 app 处于接管状态，停止服务即可\n                let _ = self.stop().await;\n            }\n        }\n\n        Ok(())\n    }\n\n    /// 同步 Live 配置中的 Token 到数据库\n    ///\n    /// 在清空 Live Token 之前调用，确保数据库中的 Provider 配置有最新的 Token。\n    /// 这样代理才能从数据库读取到正确的认证信息。\n    async fn sync_live_to_provider(&self, app_type: &AppType) -> Result<(), String> {\n        let live_config = match app_type {\n            AppType::Claude => self.read_claude_live()?,\n            AppType::Codex => self.read_codex_live()?,\n            AppType::Gemini => self.read_gemini_live()?,\n            AppType::OpenCode => {\n                // OpenCode doesn't support proxy features\n                return Err(\"OpenCode 不支持代理功能\".to_string());\n            }\n            AppType::OpenClaw => {\n                // OpenClaw doesn't support proxy features\n                return Err(\"OpenClaw 不支持代理功能\".to_string());\n            }\n        };\n\n        self.sync_live_config_to_provider(app_type, &live_config)\n            .await\n    }\n\n    async fn sync_live_config_to_provider(\n        &self,\n        app_type: &AppType,\n        live_config: &Value,\n    ) -> Result<(), String> {\n        match app_type {\n            AppType::Claude => {\n                let provider_id =\n                    crate::settings::get_effective_current_provider(&self.db, &AppType::Claude)\n                        .map_err(|e| format!(\"获取 Claude 当前供应商失败: {e}\"))?;\n\n                if let Some(provider_id) = provider_id {\n                    if let Ok(Some(mut provider)) =\n                        self.db.get_provider_by_id(&provider_id, \"claude\")\n                    {\n                        if let Some(env) = live_config.get(\"env\").and_then(|v| v.as_object()) {\n                            let token_pair = [\n                                \"ANTHROPIC_AUTH_TOKEN\",\n                                \"ANTHROPIC_API_KEY\",\n                                \"OPENROUTER_API_KEY\",\n                                \"OPENAI_API_KEY\",\n                            ]\n                            .into_iter()\n                            .find_map(|key| {\n                                env.get(key)\n                                    .and_then(|v| v.as_str())\n                                    .map(|s| (key, s.trim()))\n                            })\n                            .filter(|(_, token)| {\n                                !token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER\n                            });\n\n                            if let Some((token_key, token)) = token_pair {\n                                let env_obj = provider\n                                    .settings_config\n                                    .get_mut(\"env\")\n                                    .and_then(|v| v.as_object_mut());\n\n                                match env_obj {\n                                    Some(obj) => {\n                                        if token_key == \"ANTHROPIC_AUTH_TOKEN\"\n                                            || token_key == \"ANTHROPIC_API_KEY\"\n                                        {\n                                            let mut updated = false;\n                                            if obj.contains_key(\"ANTHROPIC_AUTH_TOKEN\") {\n                                                obj.insert(\n                                                    \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n                                                    json!(token),\n                                                );\n                                                updated = true;\n                                            }\n                                            if obj.contains_key(\"ANTHROPIC_API_KEY\") {\n                                                obj.insert(\n                                                    \"ANTHROPIC_API_KEY\".to_string(),\n                                                    json!(token),\n                                                );\n                                                updated = true;\n                                            }\n                                            if !updated {\n                                                obj.insert(token_key.to_string(), json!(token));\n                                            }\n                                        } else {\n                                            obj.insert(token_key.to_string(), json!(token));\n                                        }\n                                    }\n                                    None => {\n                                        // 至少写入一份可用的 Token\n                                        if provider.settings_config.is_null() {\n                                            provider.settings_config = json!({});\n                                        }\n\n                                        if let Some(root) = provider.settings_config.as_object_mut()\n                                        {\n                                            root.insert(\n                                                \"env\".to_string(),\n                                                json!({ token_key: token }),\n                                            );\n                                        } else {\n                                            log::warn!(\n                                                \"Claude provider settings_config 格式异常（非对象），跳过写入 Token (provider: {provider_id})\"\n                                            );\n                                        }\n                                    }\n                                }\n\n                                if let Err(e) = self.db.update_provider_settings_config(\n                                    \"claude\",\n                                    &provider_id,\n                                    &provider.settings_config,\n                                ) {\n                                    log::warn!(\"同步 Claude Token 到数据库失败: {e}\");\n                                } else {\n                                    log::info!(\n                                        \"已同步 Claude Token 到数据库 (provider: {provider_id})\"\n                                    );\n                                }\n                            }\n                        }\n                    }\n                }\n            }\n            AppType::Codex => {\n                let provider_id =\n                    crate::settings::get_effective_current_provider(&self.db, &AppType::Codex)\n                        .map_err(|e| format!(\"获取 Codex 当前供应商失败: {e}\"))?;\n\n                if let Some(provider_id) = provider_id {\n                    if let Ok(Some(mut provider)) =\n                        self.db.get_provider_by_id(&provider_id, \"codex\")\n                    {\n                        if let Some(token) = live_config\n                            .get(\"auth\")\n                            .and_then(|v| v.get(\"OPENAI_API_KEY\"))\n                            .and_then(|v| v.as_str())\n                            .map(|s| s.trim())\n                            .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n                        {\n                            if let Some(auth_obj) = provider\n                                .settings_config\n                                .get_mut(\"auth\")\n                                .and_then(|v| v.as_object_mut())\n                            {\n                                auth_obj.insert(\"OPENAI_API_KEY\".to_string(), json!(token));\n                            } else {\n                                if provider.settings_config.is_null() {\n                                    provider.settings_config = json!({});\n                                }\n\n                                if let Some(root) = provider.settings_config.as_object_mut() {\n                                    root.insert(\n                                        \"auth\".to_string(),\n                                        json!({ \"OPENAI_API_KEY\": token }),\n                                    );\n                                } else {\n                                    log::warn!(\n                                        \"Codex provider settings_config 格式异常（非对象），跳过写入 Token (provider: {provider_id})\"\n                                    );\n                                }\n                            }\n\n                            if let Err(e) = self.db.update_provider_settings_config(\n                                \"codex\",\n                                &provider_id,\n                                &provider.settings_config,\n                            ) {\n                                log::warn!(\"同步 Codex Token 到数据库失败: {e}\");\n                            } else {\n                                log::info!(\"已同步 Codex Token 到数据库 (provider: {provider_id})\");\n                            }\n                        }\n                    }\n                }\n            }\n            AppType::Gemini => {\n                let provider_id =\n                    crate::settings::get_effective_current_provider(&self.db, &AppType::Gemini)\n                        .map_err(|e| format!(\"获取 Gemini 当前供应商失败: {e}\"))?;\n\n                if let Some(provider_id) = provider_id {\n                    if let Ok(Some(mut provider)) =\n                        self.db.get_provider_by_id(&provider_id, \"gemini\")\n                    {\n                        if let Some(token) = live_config\n                            .get(\"env\")\n                            .and_then(|v| v.get(\"GEMINI_API_KEY\"))\n                            .and_then(|v| v.as_str())\n                            .map(|s| s.trim())\n                            .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n                        {\n                            if let Some(env_obj) = provider\n                                .settings_config\n                                .get_mut(\"env\")\n                                .and_then(|v| v.as_object_mut())\n                            {\n                                env_obj.insert(\"GEMINI_API_KEY\".to_string(), json!(token));\n                            } else {\n                                if provider.settings_config.is_null() {\n                                    provider.settings_config = json!({});\n                                }\n\n                                if let Some(root) = provider.settings_config.as_object_mut() {\n                                    root.insert(\n                                        \"env\".to_string(),\n                                        json!({ \"GEMINI_API_KEY\": token }),\n                                    );\n                                } else {\n                                    log::warn!(\n                                        \"Gemini provider settings_config 格式异常（非对象），跳过写入 Token (provider: {provider_id})\"\n                                    );\n                                }\n                            }\n\n                            if let Err(e) = self.db.update_provider_settings_config(\n                                \"gemini\",\n                                &provider_id,\n                                &provider.settings_config,\n                            ) {\n                                log::warn!(\"同步 Gemini Token 到数据库失败: {e}\");\n                            } else {\n                                log::info!(\n                                    \"已同步 Gemini Token 到数据库 (provider: {provider_id})\"\n                                );\n                            }\n                        }\n                    }\n                }\n            }\n            AppType::OpenCode => {\n                // OpenCode doesn't support proxy features, skip silently\n            }\n            AppType::OpenClaw => {\n                // OpenClaw doesn't support proxy features, skip silently\n            }\n        }\n\n        Ok(())\n    }\n\n    async fn sync_live_to_providers(&self) -> Result<(), String> {\n        if let Ok(live_config) = self.read_claude_live() {\n            self.sync_live_config_to_provider(&AppType::Claude, &live_config)\n                .await?;\n        }\n\n        if let Ok(live_config) = self.read_codex_live() {\n            self.sync_live_config_to_provider(&AppType::Codex, &live_config)\n                .await?;\n        }\n\n        if let Ok(live_config) = self.read_gemini_live() {\n            self.sync_live_config_to_provider(&AppType::Gemini, &live_config)\n                .await?;\n        }\n\n        log::info!(\"Live 配置 Token 同步完成\");\n        Ok(())\n    }\n\n    /// 停止代理服务器\n    pub async fn stop(&self) -> Result<(), String> {\n        if let Some(server) = self.server.write().await.take() {\n            server\n                .stop()\n                .await\n                .map_err(|e| format!(\"停止代理服务器失败: {e}\"))?;\n\n            // 停止时设置 proxy_enabled = false\n            let mut global_config = self\n                .db\n                .get_global_proxy_config()\n                .await\n                .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n            if global_config.proxy_enabled {\n                global_config.proxy_enabled = false;\n                if let Err(e) = self.db.update_global_proxy_config(global_config).await {\n                    log::warn!(\"更新代理总开关失败: {e}\");\n                }\n            }\n\n            log::info!(\"代理服务器已停止\");\n            Ok(())\n        } else {\n            Err(\"代理服务器未运行\".to_string())\n        }\n    }\n\n    /// 停止代理服务器（恢复 Live 配置，用户手动关闭时使用）\n    ///\n    /// 会清除 settings 表中的代理状态，下次启动不会自动恢复。\n    pub async fn stop_with_restore(&self) -> Result<(), String> {\n        // 1. 停止代理服务器（即使未运行也继续执行恢复逻辑）\n        if let Err(e) = self.stop().await {\n            log::warn!(\"停止代理服务器失败（将继续恢复 Live 配置）: {e}\");\n        }\n\n        // 2. 恢复原始 Live 配置\n        self.restore_live_configs().await?;\n\n        // 3. 清除 proxy_config 表中的接管状态（兼容旧版）\n        self.db\n            .set_live_takeover_active(false)\n            .await\n            .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n        // 4. 清除所有应用的 enabled 状态（用户手动关闭，不需要下次自动恢复）\n        for app_type in [\"claude\", \"codex\", \"gemini\"] {\n            if let Ok(mut config) = self.db.get_proxy_config_for_app(app_type).await {\n                if config.enabled {\n                    config.enabled = false;\n                    if let Err(e) = self.db.update_proxy_config_for_app(config).await {\n                        log::warn!(\"清除 {app_type} enabled 状态失败: {e}\");\n                    }\n                }\n            }\n        }\n\n        // 5. 删除备份\n        self.db\n            .delete_all_live_backups()\n            .await\n            .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n        // 6. 重置健康状态（让健康徽章恢复为正常）\n        self.db\n            .clear_all_provider_health()\n            .await\n            .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n        // 注意：不清除故障转移队列和开关状态，保留供下次开启代理时使用\n        log::info!(\"代理已停止，Live 配置已恢复\");\n        Ok(())\n    }\n\n    /// 停止代理服务器（恢复 Live 配置，但保留 settings 表中的代理状态）\n    ///\n    /// 用于程序正常退出时，保留代理状态以便下次启动时自动恢复\n    pub async fn stop_with_restore_keep_state(&self) -> Result<(), String> {\n        // 1. 停止代理服务器（即使未运行也继续执行恢复逻辑）\n        if let Err(e) = self.stop().await {\n            log::warn!(\"停止代理服务器失败（将继续恢复 Live 配置）: {e}\");\n        }\n\n        // 2. 恢复原始 Live 配置\n        self.restore_live_configs().await?;\n\n        // 3. 更新 proxy_config 表中的 live_takeover_active 标志（兼容旧版）\n        //    注意：保留 proxy_config.enabled 状态，下次启动时自动恢复\n        if let Ok(mut config) = self.db.get_proxy_config().await {\n            config.live_takeover_active = false;\n            let _ = self.db.update_proxy_config(config).await;\n        }\n\n        // 4. 删除备份（Live 配置已恢复，备份不再需要）\n        self.db\n            .delete_all_live_backups()\n            .await\n            .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n        // 5. 重置健康状态\n        self.db\n            .clear_all_provider_health()\n            .await\n            .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n        log::info!(\"代理已停止，Live 配置已恢复（保留代理状态，下次启动将自动恢复）\");\n        Ok(())\n    }\n\n    /// 备份各应用的 Live 配置\n    async fn backup_live_configs(&self) -> Result<(), String> {\n        // Claude\n        if let Ok(config) = self.read_claude_live() {\n            let json_str = serde_json::to_string(&config)\n                .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?;\n            self.db\n                .save_live_backup(\"claude\", &json_str)\n                .await\n                .map_err(|e| format!(\"备份 Claude 配置失败: {e}\"))?;\n        }\n\n        // Codex\n        if let Ok(config) = self.read_codex_live() {\n            let json_str = serde_json::to_string(&config)\n                .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?;\n            self.db\n                .save_live_backup(\"codex\", &json_str)\n                .await\n                .map_err(|e| format!(\"备份 Codex 配置失败: {e}\"))?;\n        }\n\n        // Gemini\n        if let Ok(config) = self.read_gemini_live() {\n            let json_str = serde_json::to_string(&config)\n                .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?;\n            self.db\n                .save_live_backup(\"gemini\", &json_str)\n                .await\n                .map_err(|e| format!(\"备份 Gemini 配置失败: {e}\"))?;\n        }\n\n        log::info!(\"已备份所有应用的 Live 配置\");\n        Ok(())\n    }\n\n    /// 备份指定应用的 Live 配置（严格模式：目标配置不存在则返回错误）\n    async fn backup_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n        let (app_type_str, config) = match app_type {\n            AppType::Claude => (\"claude\", self.read_claude_live()?),\n            AppType::Codex => (\"codex\", self.read_codex_live()?),\n            AppType::Gemini => (\"gemini\", self.read_gemini_live()?),\n            AppType::OpenCode => {\n                // OpenCode doesn't support proxy features\n                return Err(\"OpenCode 不支持代理功能\".to_string());\n            }\n            AppType::OpenClaw => {\n                // OpenClaw doesn't support proxy features\n                return Err(\"OpenClaw 不支持代理功能\".to_string());\n            }\n        };\n\n        let json_str = serde_json::to_string(&config)\n            .map_err(|e| format!(\"序列化 {app_type_str} 配置失败: {e}\"))?;\n        self.db\n            .save_live_backup(app_type_str, &json_str)\n            .await\n            .map_err(|e| format!(\"备份 {app_type_str} 配置失败: {e}\"))?;\n\n        Ok(())\n    }\n\n    /// 构造写入 Live 的代理地址（处理 0.0.0.0 / IPv6 等特殊情况）\n    async fn build_proxy_urls(&self) -> Result<(String, String), String> {\n        let config = self\n            .db\n            .get_proxy_config()\n            .await\n            .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n        // listen_address 可能是 0.0.0.0（用于监听所有网卡），但客户端无法用 0.0.0.0 连接；\n        // 因此写回到各应用配置时，优先使用本机回环地址。\n        let connect_host = match config.listen_address.as_str() {\n            \"0.0.0.0\" => \"127.0.0.1\".to_string(),\n            \"::\" => \"::1\".to_string(),\n            _ => config.listen_address.clone(),\n        };\n        let connect_host_for_url = if connect_host.contains(':') && !connect_host.starts_with('[') {\n            format!(\"[{connect_host}]\")\n        } else {\n            connect_host\n        };\n\n        let proxy_origin = format!(\"http://{}:{}\", connect_host_for_url, config.listen_port);\n        let proxy_url = proxy_origin.clone();\n        let proxy_codex_base_url = format!(\"{}/v1\", proxy_origin.trim_end_matches('/'));\n\n        Ok((proxy_url, proxy_codex_base_url))\n    }\n\n    /// 接管各应用的 Live 配置（写入代理地址）\n    ///\n    /// 代理服务器的路由已经根据 API 端点自动区分应用类型：\n    /// - `/v1/messages` → Claude\n    /// - `/v1/chat/completions`, `/v1/responses` → Codex\n    /// - `/v1beta/*` → Gemini\n    ///\n    /// 因此不需要在 URL 中添加应用前缀。\n    async fn takeover_live_configs(&self) -> Result<(), String> {\n        let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n        // Claude: 修改 ANTHROPIC_BASE_URL，使用占位符替代真实 Token（代理会注入真实 Token）\n        if let Ok(mut live_config) = self.read_claude_live() {\n            if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n                env.insert(\"ANTHROPIC_BASE_URL\".to_string(), json!(&proxy_url));\n                // 关键：接管模式下移除模型覆盖字段，避免切换供应商后仍用旧模型名发起请求\n                for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n                    env.remove(key);\n                }\n                // 仅覆盖已存在的 Token 字段，避免新增字段导致用户困惑；\n                // 若完全没有 Token 字段，则写入 ANTHROPIC_AUTH_TOKEN 占位符用于避免客户端警告。\n                let token_keys = [\n                    \"ANTHROPIC_AUTH_TOKEN\",\n                    \"ANTHROPIC_API_KEY\",\n                    \"OPENROUTER_API_KEY\",\n                    \"OPENAI_API_KEY\",\n                ];\n\n                let mut replaced_any = false;\n                for key in token_keys {\n                    if env.contains_key(key) {\n                        env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n                        replaced_any = true;\n                    }\n                }\n\n                if !replaced_any {\n                    env.insert(\n                        \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n                        json!(PROXY_TOKEN_PLACEHOLDER),\n                    );\n                }\n            } else {\n                live_config[\"env\"] = json!({\n                    \"ANTHROPIC_BASE_URL\": &proxy_url,\n                    \"ANTHROPIC_AUTH_TOKEN\": PROXY_TOKEN_PLACEHOLDER\n                });\n            }\n            self.write_claude_live(&live_config)?;\n            log::info!(\"Claude Live 配置已接管，代理地址: {proxy_url}\");\n        }\n\n        // Codex: 修改 config.toml 的 base_url，auth.json 的 OPENAI_API_KEY（代理会注入真实 Token）\n        if let Ok(mut live_config) = self.read_codex_live() {\n            // 1. 修改 auth.json 中的 OPENAI_API_KEY（使用占位符）\n            if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n                auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n            }\n\n            // 2. 修改 config.toml 中的 base_url\n            let config_str = live_config\n                .get(\"config\")\n                .and_then(|v| v.as_str())\n                .unwrap_or(\"\");\n            let updated_config = Self::update_toml_base_url(config_str, &proxy_codex_base_url);\n            live_config[\"config\"] = json!(updated_config);\n\n            self.write_codex_live(&live_config)?;\n            log::info!(\"Codex Live 配置已接管，代理地址: {proxy_codex_base_url}\");\n        }\n\n        // Gemini: 修改 GOOGLE_GEMINI_BASE_URL，使用占位符替代真实 Token（代理会注入真实 Token）\n        if let Ok(mut live_config) = self.read_gemini_live() {\n            if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n                env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n                // 使用占位符，避免显示缺少 key 的警告\n                env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n            } else {\n                live_config[\"env\"] = json!({\n                    \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n                    \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n                });\n            }\n            self.write_gemini_live(&live_config)?;\n            log::info!(\"Gemini Live 配置已接管，代理地址: {proxy_url}\");\n        }\n\n        Ok(())\n    }\n\n    /// 接管指定应用的 Live 配置（严格模式：目标配置不存在则返回错误）\n    async fn takeover_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n        let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n        match app_type {\n            AppType::Claude => {\n                let mut live_config = self.read_claude_live()?;\n                if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n                    env.insert(\"ANTHROPIC_BASE_URL\".to_string(), json!(&proxy_url));\n                    // 关键：接管模式下移除模型覆盖字段，避免切换供应商后仍用旧模型名发起请求\n                    for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n                        env.remove(key);\n                    }\n\n                    let token_keys = [\n                        \"ANTHROPIC_AUTH_TOKEN\",\n                        \"ANTHROPIC_API_KEY\",\n                        \"OPENROUTER_API_KEY\",\n                        \"OPENAI_API_KEY\",\n                    ];\n\n                    let mut replaced_any = false;\n                    for key in token_keys {\n                        if env.contains_key(key) {\n                            env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n                            replaced_any = true;\n                        }\n                    }\n\n                    if !replaced_any {\n                        env.insert(\n                            \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n                            json!(PROXY_TOKEN_PLACEHOLDER),\n                        );\n                    }\n                } else {\n                    live_config[\"env\"] = json!({\n                        \"ANTHROPIC_BASE_URL\": &proxy_url,\n                        \"ANTHROPIC_AUTH_TOKEN\": PROXY_TOKEN_PLACEHOLDER\n                    });\n                }\n\n                self.write_claude_live(&live_config)?;\n                log::info!(\"Claude Live 配置已接管，代理地址: {proxy_url}\");\n            }\n            AppType::Codex => {\n                let mut live_config = self.read_codex_live()?;\n\n                if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n                    auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n                }\n\n                let config_str = live_config\n                    .get(\"config\")\n                    .and_then(|v| v.as_str())\n                    .unwrap_or(\"\");\n                let updated_config = Self::update_toml_base_url(config_str, &proxy_codex_base_url);\n                live_config[\"config\"] = json!(updated_config);\n\n                self.write_codex_live(&live_config)?;\n                log::info!(\"Codex Live 配置已接管，代理地址: {proxy_codex_base_url}\");\n            }\n            AppType::Gemini => {\n                let mut live_config = self.read_gemini_live()?;\n\n                if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n                    env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n                    env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n                } else {\n                    live_config[\"env\"] = json!({\n                        \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n                        \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n                    });\n                }\n\n                self.write_gemini_live(&live_config)?;\n                log::info!(\"Gemini Live 配置已接管，代理地址: {proxy_url}\");\n            }\n            AppType::OpenCode => {\n                // OpenCode doesn't support proxy features\n                return Err(\"OpenCode 不支持代理功能\".to_string());\n            }\n            AppType::OpenClaw => {\n                // OpenClaw doesn't support proxy features\n                return Err(\"OpenClaw 不支持代理功能\".to_string());\n            }\n        }\n\n        Ok(())\n    }\n\n    /// 接管指定应用的 Live 配置（尽力而为：配置不存在/读取失败则跳过）\n    async fn takeover_live_config_best_effort(&self, app_type: &AppType) -> Result<(), String> {\n        let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n        match app_type {\n            AppType::Claude => {\n                if let Ok(mut live_config) = self.read_claude_live() {\n                    if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n                        env.insert(\"ANTHROPIC_BASE_URL\".to_string(), json!(&proxy_url));\n                        // 关键：接管模式下移除模型覆盖字段，避免切换供应商后仍用旧模型名发起请求\n                        for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n                            env.remove(key);\n                        }\n\n                        let token_keys = [\n                            \"ANTHROPIC_AUTH_TOKEN\",\n                            \"ANTHROPIC_API_KEY\",\n                            \"OPENROUTER_API_KEY\",\n                            \"OPENAI_API_KEY\",\n                        ];\n\n                        let mut replaced_any = false;\n                        for key in token_keys {\n                            if env.contains_key(key) {\n                                env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n                                replaced_any = true;\n                            }\n                        }\n\n                        if !replaced_any {\n                            env.insert(\n                                \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n                                json!(PROXY_TOKEN_PLACEHOLDER),\n                            );\n                        }\n                    } else {\n                        live_config[\"env\"] = json!({\n                            \"ANTHROPIC_BASE_URL\": &proxy_url,\n                            \"ANTHROPIC_AUTH_TOKEN\": PROXY_TOKEN_PLACEHOLDER\n                        });\n                    }\n\n                    let _ = self.write_claude_live(&live_config);\n                }\n            }\n            AppType::Codex => {\n                if let Ok(mut live_config) = self.read_codex_live() {\n                    if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut())\n                    {\n                        auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n                    }\n\n                    let config_str = live_config\n                        .get(\"config\")\n                        .and_then(|v| v.as_str())\n                        .unwrap_or(\"\");\n                    let updated_config =\n                        Self::update_toml_base_url(config_str, &proxy_codex_base_url);\n                    live_config[\"config\"] = json!(updated_config);\n\n                    let _ = self.write_codex_live(&live_config);\n                }\n            }\n            AppType::Gemini => {\n                if let Ok(mut live_config) = self.read_gemini_live() {\n                    if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n                        env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n                        env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n                    } else {\n                        live_config[\"env\"] = json!({\n                            \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n                            \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n                        });\n                    }\n\n                    let _ = self.write_gemini_live(&live_config);\n                }\n            }\n            AppType::OpenCode => {\n                // OpenCode doesn't support proxy features, skip silently\n            }\n            AppType::OpenClaw => {\n                // OpenClaw doesn't support proxy features, skip silently\n            }\n        }\n\n        Ok(())\n    }\n\n    /// 恢复指定应用的 Live 配置（若无备份则不做任何操作）\n    async fn restore_live_config_for_app(&self, app_type: &AppType) -> Result<(), String> {\n        match app_type {\n            AppType::Claude => {\n                if let Ok(Some(backup)) = self.db.get_live_backup(\"claude\").await {\n                    let config: Value = serde_json::from_str(&backup.original_config)\n                        .map_err(|e| format!(\"解析 Claude 备份失败: {e}\"))?;\n                    self.write_claude_live(&config)?;\n                    log::info!(\"Claude Live 配置已恢复\");\n                }\n            }\n            AppType::Codex => {\n                if let Ok(Some(backup)) = self.db.get_live_backup(\"codex\").await {\n                    let config: Value = serde_json::from_str(&backup.original_config)\n                        .map_err(|e| format!(\"解析 Codex 备份失败: {e}\"))?;\n                    self.write_codex_live(&config)?;\n                    log::info!(\"Codex Live 配置已恢复\");\n                }\n            }\n            AppType::Gemini => {\n                if let Ok(Some(backup)) = self.db.get_live_backup(\"gemini\").await {\n                    let config: Value = serde_json::from_str(&backup.original_config)\n                        .map_err(|e| format!(\"解析 Gemini 备份失败: {e}\"))?;\n                    self.write_gemini_live(&config)?;\n                    log::info!(\"Gemini Live 配置已恢复\");\n                }\n            }\n            AppType::OpenCode => {\n                // OpenCode doesn't support proxy features, skip silently\n            }\n            AppType::OpenClaw => {\n                // OpenClaw doesn't support proxy features, skip silently\n            }\n        }\n\n        Ok(())\n    }\n\n    /// 恢复原始 Live 配置\n    async fn restore_live_configs(&self) -> Result<(), String> {\n        let mut errors = Vec::new();\n\n        for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {\n            if let Err(e) = self\n                .restore_live_config_for_app_with_fallback(&app_type)\n                .await\n            {\n                errors.push(e);\n            }\n        }\n\n        if errors.is_empty() {\n            Ok(())\n        } else {\n            Err(errors.join(\"；\"))\n        }\n    }\n\n    async fn restore_live_config_for_app_with_fallback(\n        &self,\n        app_type: &AppType,\n    ) -> Result<(), String> {\n        let app_type_str = app_type.as_str();\n\n        // 1) 优先从 Live 备份恢复（这是\"原始 Live\"的唯一可靠来源）\n        let backup = self\n            .db\n            .get_live_backup(app_type_str)\n            .await\n            .map_err(|e| format!(\"获取 {app_type_str} Live 备份失败: {e}\"))?;\n        if let Some(backup) = backup {\n            let config: Value = serde_json::from_str(&backup.original_config)\n                .map_err(|e| format!(\"解析 {app_type_str} 备份失败: {e}\"))?;\n            self.write_live_config_for_app(app_type, &config)?;\n            log::info!(\"{app_type_str} Live 配置已从备份恢复\");\n            return Ok(());\n        }\n\n        // 2) 兜底：备份缺失，但 Live 仍包含接管占位符（异常退出/历史 bug 场景）\n        if !self.detect_takeover_in_live_config_for_app(app_type) {\n            return Ok(());\n        }\n\n        // 2.1) 优先从 SSOT（当前供应商）重建 Live（比\"清理字段\"更可用）\n        match self.restore_live_from_ssot_for_app(app_type) {\n            Ok(true) => {\n                log::info!(\"{app_type_str} Live 配置已从 SSOT 恢复（无备份兜底）\");\n                return Ok(());\n            }\n            Ok(false) => {\n                log::warn!(\n                    \"{app_type_str} Live 备份缺失，且无法从 SSOT 恢复，将尝试清理接管占位符\"\n                );\n            }\n            Err(e) => {\n                log::error!(\n                    \"{app_type_str} Live 备份缺失，SSOT 恢复失败，将尝试清理接管占位符: {e}\"\n                );\n            }\n        }\n\n        // 2.2) 最后兜底：尽力清理占位符与本地代理地址，避免长期卡在代理占位符状态\n        self.cleanup_takeover_placeholders_in_live_for_app(app_type)?;\n        log::info!(\"{app_type_str} Live 接管占位符已清理（无备份兜底）\");\n        Ok(())\n    }\n\n    fn write_live_config_for_app(&self, app_type: &AppType, config: &Value) -> Result<(), String> {\n        match app_type {\n            AppType::Claude => self.write_claude_live(config),\n            AppType::Codex => self.write_codex_live(config),\n            AppType::Gemini => self.write_gemini_live(config),\n            AppType::OpenCode => {\n                // OpenCode doesn't support proxy features\n                Err(\"OpenCode 不支持代理功能\".to_string())\n            }\n            AppType::OpenClaw => {\n                // OpenClaw doesn't support proxy features\n                Err(\"OpenClaw 不支持代理功能\".to_string())\n            }\n        }\n    }\n\n    pub fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool {\n        match app_type {\n            AppType::Claude => match self.read_claude_live() {\n                Ok(config) => Self::is_claude_live_taken_over(&config),\n                Err(_) => false,\n            },\n            AppType::Codex => match self.read_codex_live() {\n                Ok(config) => Self::is_codex_live_taken_over(&config),\n                Err(_) => false,\n            },\n            AppType::Gemini => match self.read_gemini_live() {\n                Ok(config) => Self::is_gemini_live_taken_over(&config),\n                Err(_) => false,\n            },\n            AppType::OpenCode => {\n                // OpenCode doesn't support proxy takeover\n                false\n            }\n            AppType::OpenClaw => {\n                // OpenClaw doesn't support proxy takeover\n                false\n            }\n        }\n    }\n\n    /// 当 Live 备份缺失时，尝试用 SSOT（当前供应商）写回 Live，以解除占位符接管。\n    ///\n    /// 返回值：\n    /// - Ok(true)：已成功写回\n    /// - Ok(false)：缺少当前供应商/供应商不存在，无法写回\n    fn restore_live_from_ssot_for_app(&self, app_type: &AppType) -> Result<bool, String> {\n        let current_id = crate::settings::get_effective_current_provider(&self.db, app_type)\n            .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?;\n\n        let Some(current_id) = current_id else {\n            return Ok(false);\n        };\n\n        let providers = self\n            .db\n            .get_all_providers(app_type.as_str())\n            .map_err(|e| format!(\"读取 {app_type:?} 供应商列表失败: {e}\"))?;\n\n        let Some(provider) = providers.get(&current_id) else {\n            return Ok(false);\n        };\n\n        write_live_with_common_config(self.db.as_ref(), app_type, provider)\n            .map_err(|e| format!(\"写入 {app_type:?} Live 配置失败: {e}\"))?;\n\n        Ok(true)\n    }\n\n    fn cleanup_takeover_placeholders_in_live_for_app(\n        &self,\n        app_type: &AppType,\n    ) -> Result<(), String> {\n        match app_type {\n            AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),\n            AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),\n            AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),\n            AppType::OpenCode => {\n                // OpenCode doesn't support proxy features\n                Ok(())\n            }\n            AppType::OpenClaw => {\n                // OpenClaw doesn't support proxy features\n                Ok(())\n            }\n        }\n    }\n\n    fn is_local_proxy_url(url: &str) -> bool {\n        let url = url.trim();\n        if !url.starts_with(\"http://\") {\n            return false;\n        }\n        let rest = &url[\"http://\".len()..];\n        rest.starts_with(\"127.0.0.1\")\n            || rest.starts_with(\"localhost\")\n            || rest.starts_with(\"0.0.0.0\")\n            || rest.starts_with(\"[::1]\")\n            || rest.starts_with(\"[::]\")\n            || rest.starts_with(\"::1\")\n            || rest.starts_with(\"::\")\n    }\n\n    fn cleanup_claude_takeover_placeholders_in_live(&self) -> Result<(), String> {\n        let mut config = self.read_claude_live()?;\n\n        let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n            return Ok(());\n        };\n\n        for key in [\n            \"ANTHROPIC_AUTH_TOKEN\",\n            \"ANTHROPIC_API_KEY\",\n            \"OPENROUTER_API_KEY\",\n            \"OPENAI_API_KEY\",\n        ] {\n            if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n                env.remove(key);\n            }\n        }\n\n        if env\n            .get(\"ANTHROPIC_BASE_URL\")\n            .and_then(|v| v.as_str())\n            .map(Self::is_local_proxy_url)\n            .unwrap_or(false)\n        {\n            env.remove(\"ANTHROPIC_BASE_URL\");\n        }\n\n        self.write_claude_live(&config)?;\n        Ok(())\n    }\n\n    fn cleanup_codex_takeover_placeholders_in_live(&self) -> Result<(), String> {\n        let mut config = self.read_codex_live()?;\n\n        if let Some(auth) = config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n            if auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n            {\n                auth.remove(\"OPENAI_API_KEY\");\n            }\n        }\n\n        if let Some(cfg_str) = config.get(\"config\").and_then(|v| v.as_str()) {\n            let updated = Self::remove_local_toml_base_url(cfg_str);\n            config[\"config\"] = json!(updated);\n        }\n\n        self.write_codex_live(&config)?;\n        Ok(())\n    }\n\n    /// Remove local proxy base_url from TOML（委托给 codex_config 共享实现）\n    fn remove_local_toml_base_url(toml_str: &str) -> String {\n        crate::codex_config::remove_codex_toml_base_url_if(toml_str, Self::is_local_proxy_url)\n    }\n\n    fn cleanup_gemini_takeover_placeholders_in_live(&self) -> Result<(), String> {\n        let mut config = self.read_gemini_live()?;\n\n        let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n            return Ok(());\n        };\n\n        if env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n            env.remove(\"GEMINI_API_KEY\");\n        }\n\n        if env\n            .get(\"GOOGLE_GEMINI_BASE_URL\")\n            .and_then(|v| v.as_str())\n            .map(Self::is_local_proxy_url)\n            .unwrap_or(false)\n        {\n            env.remove(\"GOOGLE_GEMINI_BASE_URL\");\n        }\n\n        self.write_gemini_live(&config)?;\n        Ok(())\n    }\n\n    /// 检查是否处于 Live 接管模式\n    pub async fn is_takeover_active(&self) -> Result<bool, String> {\n        let status = self.get_takeover_status().await?;\n        Ok(status.claude || status.codex || status.gemini)\n    }\n\n    /// 从异常退出中恢复（启动时调用）\n    ///\n    /// 检测到 Live 备份残留时调用此方法。\n    /// 会恢复 Live 配置、清除接管标志、删除备份。\n    pub async fn recover_from_crash(&self) -> Result<(), String> {\n        // 1. 恢复 Live 配置\n        self.restore_live_configs().await?;\n\n        // 2. 清除接管标志\n        self.db\n            .set_live_takeover_active(false)\n            .await\n            .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n        // 3. 删除备份\n        self.db\n            .delete_all_live_backups()\n            .await\n            .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n        log::info!(\"已从异常退出中恢复 Live 配置\");\n        Ok(())\n    }\n\n    /// 检测 Live 配置是否处于\"被接管\"的残留状态\n    ///\n    /// 用于兜底处理：当数据库备份缺失但 Live 文件已经写成代理占位符时，\n    /// 启动流程可以据此触发恢复逻辑。\n    pub fn detect_takeover_in_live_configs(&self) -> bool {\n        if let Ok(config) = self.read_claude_live() {\n            if Self::is_claude_live_taken_over(&config) {\n                return true;\n            }\n        }\n\n        if let Ok(config) = self.read_codex_live() {\n            if Self::is_codex_live_taken_over(&config) {\n                return true;\n            }\n        }\n\n        if let Ok(config) = self.read_gemini_live() {\n            if Self::is_gemini_live_taken_over(&config) {\n                return true;\n            }\n        }\n\n        false\n    }\n\n    fn is_claude_live_taken_over(config: &Value) -> bool {\n        let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n            Some(env) => env,\n            None => return false,\n        };\n\n        for key in [\n            \"ANTHROPIC_AUTH_TOKEN\",\n            \"ANTHROPIC_API_KEY\",\n            \"OPENROUTER_API_KEY\",\n            \"OPENAI_API_KEY\",\n        ] {\n            if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n                return true;\n            }\n        }\n\n        false\n    }\n\n    fn is_codex_live_taken_over(config: &Value) -> bool {\n        let auth = match config.get(\"auth\").and_then(|v| v.as_object()) {\n            Some(auth) => auth,\n            None => return false,\n        };\n        auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n    }\n\n    fn is_gemini_live_taken_over(config: &Value) -> bool {\n        let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n            Some(env) => env,\n            None => return false,\n        };\n        env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n    }\n\n    /// 从供应商配置更新 Live 备份（用于代理模式下的热切换）\n    ///\n    /// 与 backup_live_configs() 不同，此方法从供应商的 settings_config 生成备份，\n    /// 而不是从 Live 文件读取（因为 Live 文件已被代理接管）。\n    pub async fn update_live_backup_from_provider(\n        &self,\n        app_type: &str,\n        provider: &Provider,\n    ) -> Result<(), String> {\n        let app_type_enum =\n            AppType::from_str(app_type).map_err(|_| format!(\"未知的应用类型: {app_type}\"))?;\n        let mut effective_settings =\n            build_effective_settings_with_common_config(self.db.as_ref(), &app_type_enum, provider)\n                .map_err(|e| format!(\"构建 {app_type} 有效配置失败: {e}\"))?;\n\n        if matches!(app_type_enum, AppType::Codex) {\n            let existing_backup = self\n                .db\n                .get_live_backup(app_type)\n                .await\n                .map_err(|e| format!(\"读取 {app_type} 现有备份失败: {e}\"))?;\n\n            if let Some(existing_backup) = existing_backup {\n                let existing_value: Value = serde_json::from_str(&existing_backup.original_config)\n                    .map_err(|e| format!(\"解析 {app_type} 现有备份失败: {e}\"))?;\n                Self::preserve_codex_mcp_servers_in_backup(\n                    &mut effective_settings,\n                    &existing_value,\n                )?;\n            }\n        }\n\n        let backup_json = match app_type_enum {\n            AppType::Claude => serde_json::to_string(&effective_settings)\n                .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?,\n            AppType::Codex => serde_json::to_string(&effective_settings)\n                .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?,\n            AppType::Gemini => {\n                // Gemini takeover 仅修改 .env；settings.json（含 mcpServers）保持原样。\n                let env_backup = if let Some(env) = effective_settings.get(\"env\") {\n                    json!({ \"env\": env })\n                } else {\n                    json!({ \"env\": {} })\n                };\n                serde_json::to_string(&env_backup)\n                    .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?\n            }\n            AppType::OpenCode | AppType::OpenClaw => {\n                return Err(format!(\"未知的应用类型: {app_type}\"));\n            }\n        };\n\n        self.db\n            .save_live_backup(app_type, &backup_json)\n            .await\n            .map_err(|e| format!(\"更新 {app_type} 备份失败: {e}\"))?;\n\n        log::info!(\"已更新 {app_type} Live 备份（热切换）\");\n        Ok(())\n    }\n\n    fn preserve_codex_mcp_servers_in_backup(\n        target_settings: &mut Value,\n        existing_backup: &Value,\n    ) -> Result<(), String> {\n        let target_obj = target_settings\n            .as_object_mut()\n            .ok_or_else(|| \"Codex 备份必须是 JSON 对象\".to_string())?;\n\n        let target_config = target_obj\n            .get(\"config\")\n            .and_then(|v| v.as_str())\n            .unwrap_or(\"\");\n        let mut target_doc = if target_config.trim().is_empty() {\n            toml_edit::DocumentMut::new()\n        } else {\n            target_config\n                .parse::<toml_edit::DocumentMut>()\n                .map_err(|e| format!(\"解析新的 Codex config.toml 失败: {e}\"))?\n        };\n\n        let existing_config = existing_backup\n            .get(\"config\")\n            .and_then(|v| v.as_str())\n            .unwrap_or(\"\");\n        if existing_config.trim().is_empty() {\n            target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n            return Ok(());\n        }\n\n        let existing_doc = existing_config\n            .parse::<toml_edit::DocumentMut>()\n            .map_err(|e| format!(\"解析现有 Codex 备份失败: {e}\"))?;\n\n        if let Some(existing_mcp_servers) = existing_doc.get(\"mcp_servers\") {\n            match target_doc.get_mut(\"mcp_servers\") {\n                Some(target_mcp_servers) => {\n                    if let (Some(target_table), Some(existing_table)) = (\n                        target_mcp_servers.as_table_like_mut(),\n                        existing_mcp_servers.as_table_like(),\n                    ) {\n                        for (server_id, server_item) in existing_table.iter() {\n                            if target_table.get(server_id).is_none() {\n                                target_table.insert(server_id, server_item.clone());\n                            }\n                        }\n                    } else {\n                        log::warn!(\n                            \"Codex config contains a non-table mcp_servers section; skipping backup MCP merge\"\n                        );\n                    }\n                }\n                None => {\n                    target_doc[\"mcp_servers\"] = existing_mcp_servers.clone();\n                }\n            }\n        }\n\n        target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n        Ok(())\n    }\n\n    /// 代理模式下切换供应商（热切换，不写 Live）\n    pub async fn switch_proxy_target(\n        &self,\n        app_type: &str,\n        provider_id: &str,\n    ) -> Result<(), String> {\n        // 代理模式切换供应商（热切换）：\n        // - 更新 SSOT（数据库 is_current）\n        // - 同步本地 settings（设备级 current_provider_*）\n        // - 若该应用正处于接管模式，则同步更新 Live 备份（用于停止代理时恢复）\n        let app_type_enum =\n            AppType::from_str(app_type).map_err(|_| format!(\"无效的应用类型: {app_type}\"))?;\n\n        self.db\n            .set_current_provider(app_type_enum.as_str(), provider_id)\n            .map_err(|e| format!(\"更新当前供应商失败: {e}\"))?;\n\n        // 同步本地 settings（设备级优先）\n        crate::settings::set_current_provider(&app_type_enum, Some(provider_id))\n            .map_err(|e| format!(\"更新本地当前供应商失败: {e}\"))?;\n\n        // 仅在确实处于接管状态时才更新 Live 备份，避免无接管时误写覆盖 Live\n        let has_backup = self\n            .db\n            .get_live_backup(app_type_enum.as_str())\n            .await\n            .ok()\n            .flatten()\n            .is_some();\n        let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);\n\n        if let Ok(Some(provider)) = self.db.get_provider_by_id(provider_id, app_type) {\n            // 同步更新 Live 备份（用于 stop_with_restore 恢复）\n            if has_backup || live_taken_over {\n                self.update_live_backup_from_provider(app_type, &provider)\n                    .await?;\n            }\n\n            // 同步更新 ProxyStatus.active_targets（用于 UI 立即反映切换目标）\n            if let Some(server) = self.server.read().await.as_ref() {\n                server\n                    .set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)\n                    .await;\n            }\n        }\n\n        log::info!(\"代理模式：已切换 {app_type} 的目标供应商为 {provider_id}\");\n        Ok(())\n    }\n\n    // ==================== Live 配置读写辅助方法 ====================\n\n    /// 更新 TOML 字符串中的 base_url（委托给 codex_config 共享实现）\n    fn update_toml_base_url(toml_str: &str, new_url: &str) -> String {\n        crate::codex_config::update_codex_toml_field(toml_str, \"base_url\", new_url)\n            .unwrap_or_else(|_| toml_str.to_string())\n    }\n\n    fn read_claude_live(&self) -> Result<Value, String> {\n        let path = get_claude_settings_path();\n        if !path.exists() {\n            return Err(\"Claude 配置文件不存在\".to_string());\n        }\n\n        let mut value: Value =\n            read_json_file(&path).map_err(|e| format!(\"读取 Claude 配置失败: {e}\"))?;\n\n        if value.is_null() {\n            value = json!({});\n        }\n\n        if !value.is_object() {\n            let kind = match &value {\n                Value::Null => \"null\",\n                Value::Bool(_) => \"boolean\",\n                Value::Number(_) => \"number\",\n                Value::String(_) => \"string\",\n                Value::Array(_) => \"array\",\n                Value::Object(_) => \"object\",\n            };\n            return Err(format!(\n                \"Claude 配置文件格式错误：根节点必须是 JSON 对象（当前为 {kind}），路径: {}\",\n                path.display()\n            ));\n        }\n\n        Ok(value)\n    }\n\n    fn write_claude_live(&self, config: &Value) -> Result<(), String> {\n        let path = get_claude_settings_path();\n        let settings = crate::services::provider::sanitize_claude_settings_for_live(config);\n        write_json_file(&path, &settings).map_err(|e| format!(\"写入 Claude 配置失败: {e}\"))\n    }\n\n    fn read_codex_live(&self) -> Result<Value, String> {\n        use crate::codex_config::{get_codex_auth_path, get_codex_config_path};\n\n        let auth_path = get_codex_auth_path();\n        if !auth_path.exists() {\n            return Err(\"Codex auth.json 不存在\".to_string());\n        }\n\n        let auth: Value =\n            read_json_file(&auth_path).map_err(|e| format!(\"读取 Codex auth 失败: {e}\"))?;\n\n        let config_path = get_codex_config_path();\n        let config_str = if config_path.exists() {\n            std::fs::read_to_string(&config_path)\n                .map_err(|e| format!(\"读取 Codex config 失败: {e}\"))?\n        } else {\n            String::new()\n        };\n\n        Ok(json!({\n            \"auth\": auth,\n            \"config\": config_str\n        }))\n    }\n\n    fn write_codex_live(&self, config: &Value) -> Result<(), String> {\n        use crate::codex_config::{\n            get_codex_auth_path, get_codex_config_path, write_codex_live_atomic,\n        };\n\n        let auth = config.get(\"auth\");\n        let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n        match (auth, config_str) {\n            (Some(auth), Some(cfg)) => write_codex_live_atomic(auth, Some(cfg))\n                .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?,\n            (Some(auth), None) => {\n                let auth_path = get_codex_auth_path();\n                write_json_file(&auth_path, auth)\n                    .map_err(|e| format!(\"写入 Codex auth 失败: {e}\"))?;\n            }\n            (None, Some(cfg)) => {\n                let config_path = get_codex_config_path();\n                crate::config::write_text_file(&config_path, cfg)\n                    .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n            }\n            (None, None) => {}\n        }\n\n        Ok(())\n    }\n\n    fn read_gemini_live(&self) -> Result<Value, String> {\n        use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};\n\n        let env_path = get_gemini_env_path();\n        if !env_path.exists() {\n            return Err(\"Gemini .env 文件不存在\".to_string());\n        }\n\n        let env_map = read_gemini_env().map_err(|e| format!(\"读取 Gemini env 失败: {e}\"))?;\n        Ok(env_to_json(&env_map))\n    }\n\n    fn write_gemini_live(&self, config: &Value) -> Result<(), String> {\n        use crate::gemini_config::{json_to_env, write_gemini_env_atomic};\n\n        let env_map = json_to_env(config).map_err(|e| format!(\"转换 Gemini 配置失败: {e}\"))?;\n        write_gemini_env_atomic(&env_map).map_err(|e| format!(\"写入 Gemini env 失败: {e}\"))?;\n        Ok(())\n    }\n\n    // ==================== 原有方法 ====================\n\n    /// 获取服务器状态\n    pub async fn get_status(&self) -> Result<ProxyStatus, String> {\n        if let Some(server) = self.server.read().await.as_ref() {\n            Ok(server.get_status().await)\n        } else {\n            // 服务器未运行时返回默认状态\n            Ok(ProxyStatus {\n                running: false,\n                ..Default::default()\n            })\n        }\n    }\n\n    /// 获取代理配置\n    pub async fn get_config(&self) -> Result<ProxyConfig, String> {\n        self.db\n            .get_proxy_config()\n            .await\n            .map_err(|e| format!(\"获取代理配置失败: {e}\"))\n    }\n\n    /// 更新代理配置\n    pub async fn update_config(&self, config: &ProxyConfig) -> Result<(), String> {\n        // 记录旧配置用于判定是否需要重启\n        let previous = self\n            .db\n            .get_proxy_config()\n            .await\n            .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n        // 保存到数据库（保持 live_takeover_active 状态不变）\n        let mut new_config = config.clone();\n        new_config.live_takeover_active = previous.live_takeover_active;\n\n        self.db\n            .update_proxy_config(new_config.clone())\n            .await\n            .map_err(|e| format!(\"保存代理配置失败: {e}\"))?;\n\n        // 检查服务器当前状态\n        let mut server_guard = self.server.write().await;\n        if server_guard.is_none() {\n            return Ok(());\n        }\n\n        // 判断是否需要重启（地址或端口变更）\n        let require_restart = new_config.listen_address != previous.listen_address\n            || new_config.listen_port != previous.listen_port;\n\n        if require_restart {\n            if let Some(server) = server_guard.take() {\n                server\n                    .stop()\n                    .await\n                    .map_err(|e| format!(\"重启前停止代理服务器失败: {e}\"))?;\n            }\n\n            let app_handle = self.app_handle.read().await.clone();\n            let new_server = ProxyServer::new(new_config, self.db.clone(), app_handle);\n            new_server\n                .start()\n                .await\n                .map_err(|e| format!(\"重启代理服务器失败: {e}\"))?;\n\n            *server_guard = Some(new_server);\n            log::info!(\"代理配置已更新，服务器已自动重启应用最新配置\");\n\n            // 如果当前存在任意 app 的 Live 接管，需要同步更新 Live 中的代理地址（否则客户端仍指向旧端口）\n            drop(server_guard);\n            if let Ok(takeover) = self.get_takeover_status().await {\n                let mut updated_any = false;\n\n                if takeover.claude {\n                    self.takeover_live_config_best_effort(&AppType::Claude)\n                        .await?;\n                    updated_any = true;\n                }\n                if takeover.codex {\n                    self.takeover_live_config_best_effort(&AppType::Codex)\n                        .await?;\n                    updated_any = true;\n                }\n                if takeover.gemini {\n                    self.takeover_live_config_best_effort(&AppType::Gemini)\n                        .await?;\n                    updated_any = true;\n                }\n\n                if updated_any {\n                    log::info!(\"已同步更新 Live 配置中的代理地址\");\n                }\n            }\n\n            return Ok(());\n        } else if let Some(server) = server_guard.as_ref() {\n            server.apply_runtime_config(&new_config).await;\n            log::info!(\"代理配置已实时应用，无需重启代理服务器\");\n        }\n\n        Ok(())\n    }\n\n    /// 检查服务器是否正在运行\n    pub async fn is_running(&self) -> bool {\n        self.server.read().await.is_some()\n    }\n\n    /// 热更新熔断器配置\n    ///\n    /// 如果代理服务器正在运行，将新配置应用到所有已创建的熔断器实例\n    pub async fn update_circuit_breaker_configs(\n        &self,\n        config: crate::proxy::CircuitBreakerConfig,\n    ) -> Result<(), String> {\n        if let Some(server) = self.server.read().await.as_ref() {\n            server.update_circuit_breaker_configs(config).await;\n            log::info!(\"已热更新运行中的熔断器配置\");\n        } else {\n            log::debug!(\"代理服务器未运行，熔断器配置将在下次启动时生效\");\n        }\n        Ok(())\n    }\n\n    /// 重置指定 Provider 的熔断器\n    ///\n    /// 如果代理服务器正在运行，立即重置内存中的熔断器状态\n    pub async fn reset_provider_circuit_breaker(\n        &self,\n        provider_id: &str,\n        app_type: &str,\n    ) -> Result<(), String> {\n        if let Some(server) = self.server.read().await.as_ref() {\n            server\n                .reset_provider_circuit_breaker(provider_id, app_type)\n                .await;\n            log::info!(\"已重置 Provider {provider_id} (app: {app_type}) 的熔断器\");\n        }\n        Ok(())\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::provider::ProviderMeta;\n    use serial_test::serial;\n    use std::env;\n    use tempfile::TempDir;\n\n    struct TempHome {\n        #[allow(dead_code)]\n        dir: TempDir,\n        original_home: Option<String>,\n        original_userprofile: Option<String>,\n    }\n\n    impl TempHome {\n        fn new() -> Self {\n            let dir = TempDir::new().expect(\"failed to create temp home\");\n            let original_home = env::var(\"HOME\").ok();\n            let original_userprofile = env::var(\"USERPROFILE\").ok();\n\n            env::set_var(\"HOME\", dir.path());\n            env::set_var(\"USERPROFILE\", dir.path());\n\n            Self {\n                dir,\n                original_home,\n                original_userprofile,\n            }\n        }\n    }\n\n    impl Drop for TempHome {\n        fn drop(&mut self) {\n            match &self.original_home {\n                Some(value) => env::set_var(\"HOME\", value),\n                None => env::remove_var(\"HOME\"),\n            }\n\n            match &self.original_userprofile {\n                Some(value) => env::set_var(\"USERPROFILE\", value),\n                None => env::remove_var(\"USERPROFILE\"),\n            }\n        }\n    }\n\n    #[test]\n    fn update_toml_base_url_updates_active_model_provider_base_url() {\n        let input = r#\"\nmodel_provider = \"any\"\nmodel = \"gpt-5.1-codex\"\ndisable_response_storage = true\n\n[model_providers.any]\nname = \"any\"\nbase_url = \"https://anyrouter.top/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n        let new_url = \"http://127.0.0.1:5000/v1\";\n        let output = ProxyService::update_toml_base_url(input, new_url);\n\n        let parsed: toml::Value =\n            toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n        let base_url = parsed\n            .get(\"model_providers\")\n            .and_then(|v| v.get(\"any\"))\n            .and_then(|v| v.get(\"base_url\"))\n            .and_then(|v| v.as_str())\n            .expect(\"model_providers.any.base_url should exist\");\n\n        assert_eq!(base_url, new_url);\n        assert!(\n            parsed.get(\"base_url\").is_none(),\n            \"should not write top-level base_url\"\n        );\n\n        let wire_api = parsed\n            .get(\"model_providers\")\n            .and_then(|v| v.get(\"any\"))\n            .and_then(|v| v.get(\"wire_api\"))\n            .and_then(|v| v.as_str())\n            .expect(\"model_providers.any.wire_api should exist\");\n        assert_eq!(wire_api, \"responses\");\n    }\n\n    #[test]\n    fn update_toml_base_url_falls_back_to_top_level_base_url() {\n        let input = r#\"\nmodel = \"gpt-5.1-codex\"\n\"#;\n\n        let new_url = \"http://127.0.0.1:5000/v1\";\n        let output = ProxyService::update_toml_base_url(input, new_url);\n\n        let parsed: toml::Value =\n            toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n        let base_url = parsed\n            .get(\"base_url\")\n            .and_then(|v| v.as_str())\n            .expect(\"base_url should exist\");\n\n        assert_eq!(base_url, new_url);\n    }\n\n    #[tokio::test]\n    #[serial]\n    async fn sync_claude_token_does_not_add_anthropic_api_key() {\n        let _home = TempHome::new();\n        crate::settings::reload_settings().expect(\"reload settings\");\n\n        let db = Arc::new(Database::memory().expect(\"init db\"));\n        let service = ProxyService::new(db.clone());\n\n        let provider = Provider::with_id(\n            \"p1\".to_string(),\n            \"P1\".to_string(),\n            json!({\n                \"env\": {\n                    \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n                    \"ANTHROPIC_AUTH_TOKEN\": \"stale\"\n                }\n            }),\n            None,\n        );\n        db.save_provider(\"claude\", &provider)\n            .expect(\"save provider\");\n        db.set_current_provider(\"claude\", \"p1\")\n            .expect(\"set current provider\");\n\n        let live_config = json!({\n            \"env\": {\n                \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n            }\n        });\n\n        service\n            .sync_live_config_to_provider(&AppType::Claude, &live_config)\n            .await\n            .expect(\"sync\");\n\n        let updated = db\n            .get_provider_by_id(\"p1\", \"claude\")\n            .expect(\"get provider\")\n            .expect(\"provider exists\");\n        let env = updated\n            .settings_config\n            .get(\"env\")\n            .and_then(|v| v.as_object())\n            .expect(\"env object\");\n\n        assert_eq!(\n            env.get(\"ANTHROPIC_AUTH_TOKEN\").and_then(|v| v.as_str()),\n            Some(\"fresh\")\n        );\n        assert!(\n            !env.contains_key(\"ANTHROPIC_API_KEY\"),\n            \"should not add ANTHROPIC_API_KEY when absent\"\n        );\n    }\n\n    #[tokio::test]\n    #[serial]\n    async fn sync_claude_token_respects_existing_api_key_field() {\n        let _home = TempHome::new();\n        crate::settings::reload_settings().expect(\"reload settings\");\n\n        let db = Arc::new(Database::memory().expect(\"init db\"));\n        let service = ProxyService::new(db.clone());\n\n        let provider = Provider::with_id(\n            \"p1\".to_string(),\n            \"P1\".to_string(),\n            json!({\n                \"env\": {\n                    \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n                    \"ANTHROPIC_API_KEY\": \"stale\"\n                }\n            }),\n            None,\n        );\n        db.save_provider(\"claude\", &provider)\n            .expect(\"save provider\");\n        db.set_current_provider(\"claude\", \"p1\")\n            .expect(\"set current provider\");\n\n        let live_config = json!({\n            \"env\": {\n                \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n            }\n        });\n\n        service\n            .sync_live_config_to_provider(&AppType::Claude, &live_config)\n            .await\n            .expect(\"sync\");\n\n        let updated = db\n            .get_provider_by_id(\"p1\", \"claude\")\n            .expect(\"get provider\")\n            .expect(\"provider exists\");\n        let env = updated\n            .settings_config\n            .get(\"env\")\n            .and_then(|v| v.as_object())\n            .expect(\"env object\");\n\n        assert_eq!(\n            env.get(\"ANTHROPIC_API_KEY\").and_then(|v| v.as_str()),\n            Some(\"fresh\")\n        );\n        assert!(\n            !env.contains_key(\"ANTHROPIC_AUTH_TOKEN\"),\n            \"should not add ANTHROPIC_AUTH_TOKEN when absent\"\n        );\n    }\n\n    #[tokio::test]\n    #[serial]\n    async fn switch_proxy_target_updates_live_backup_when_taken_over() {\n        let _home = TempHome::new();\n        crate::settings::reload_settings().expect(\"reload settings\");\n\n        let db = Arc::new(Database::memory().expect(\"init db\"));\n        let service = ProxyService::new(db.clone());\n\n        let provider_a = Provider::with_id(\n            \"a\".to_string(),\n            \"A\".to_string(),\n            json!({\n                \"env\": {\n                    \"ANTHROPIC_API_KEY\": \"a-key\"\n                }\n            }),\n            None,\n        );\n        let provider_b = Provider::with_id(\n            \"b\".to_string(),\n            \"B\".to_string(),\n            json!({\n                \"env\": {\n                    \"ANTHROPIC_API_KEY\": \"b-key\"\n                }\n            }),\n            None,\n        );\n        db.save_provider(\"claude\", &provider_a)\n            .expect(\"save provider a\");\n        db.save_provider(\"claude\", &provider_b)\n            .expect(\"save provider b\");\n        db.set_current_provider(\"claude\", \"a\")\n            .expect(\"set current provider\");\n\n        // 模拟\"已接管\"状态：存在 Live 备份（内容不重要，会被热切换更新）\n        db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n            .await\n            .expect(\"seed live backup\");\n\n        service\n            .switch_proxy_target(\"claude\", \"b\")\n            .await\n            .expect(\"switch proxy target\");\n\n        // 断言：本地 settings 的 current provider 已同步\n        assert_eq!(\n            crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n            Some(\"b\")\n        );\n\n        // 断言：Live 备份已更新为目标供应商配置（用于 stop_with_restore 恢复）\n        let backup = db\n            .get_live_backup(\"claude\")\n            .await\n            .expect(\"get live backup\")\n            .expect(\"backup exists\");\n        let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n        assert_eq!(backup.original_config, expected);\n    }\n\n    #[tokio::test]\n    #[serial]\n    async fn update_live_backup_from_provider_applies_claude_common_config() {\n        let _home = TempHome::new();\n        crate::settings::reload_settings().expect(\"reload settings\");\n\n        let db = Arc::new(Database::memory().expect(\"init db\"));\n        db.set_config_snippet(\n            \"claude\",\n            Some(\n                serde_json::json!({\n                    \"includeCoAuthoredBy\": false\n                })\n                .to_string(),\n            ),\n        )\n        .expect(\"set common config snippet\");\n\n        let service = ProxyService::new(db.clone());\n\n        let mut provider = Provider::with_id(\n            \"p1\".to_string(),\n            \"P1\".to_string(),\n            json!({\n                \"env\": {\n                    \"ANTHROPIC_AUTH_TOKEN\": \"token\",\n                    \"ANTHROPIC_BASE_URL\": \"https://claude.example\"\n                }\n            }),\n            None,\n        );\n        provider.meta = Some(ProviderMeta {\n            common_config_enabled: Some(true),\n            ..Default::default()\n        });\n\n        service\n            .update_live_backup_from_provider(\"claude\", &provider)\n            .await\n            .expect(\"update live backup\");\n\n        let backup = db\n            .get_live_backup(\"claude\")\n            .await\n            .expect(\"get live backup\")\n            .expect(\"backup exists\");\n        let stored: Value =\n            serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n\n        assert_eq!(\n            stored.get(\"includeCoAuthoredBy\").and_then(|v| v.as_bool()),\n            Some(false),\n            \"common config should be applied into Claude restore backup\"\n        );\n    }\n\n    #[tokio::test]\n    #[serial]\n    async fn update_live_backup_from_provider_applies_codex_common_config() {\n        let _home = TempHome::new();\n        crate::settings::reload_settings().expect(\"reload settings\");\n\n        let db = Arc::new(Database::memory().expect(\"init db\"));\n        db.set_config_snippet(\n            \"codex\",\n            Some(\"disable_response_storage = true\\n\".to_string()),\n        )\n        .expect(\"set common config snippet\");\n\n        let service = ProxyService::new(db.clone());\n\n        let mut provider = Provider::with_id(\n            \"p1\".to_string(),\n            \"P1\".to_string(),\n            json!({\n                \"auth\": {\n                    \"OPENAI_API_KEY\": \"token\"\n                },\n                \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://codex.example/v1\"\n\"#\n            }),\n            None,\n        );\n        provider.meta = Some(ProviderMeta {\n            common_config_enabled: Some(true),\n            ..Default::default()\n        });\n\n        service\n            .update_live_backup_from_provider(\"codex\", &provider)\n            .await\n            .expect(\"update live backup\");\n\n        let backup = db\n            .get_live_backup(\"codex\")\n            .await\n            .expect(\"get live backup\")\n            .expect(\"backup exists\");\n        let stored: Value =\n            serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n        let config = stored\n            .get(\"config\")\n            .and_then(|v| v.as_str())\n            .expect(\"config string\");\n\n        assert!(\n            config.contains(\"disable_response_storage = true\"),\n            \"common config should be applied into Codex restore backup\"\n        );\n    }\n\n    #[tokio::test]\n    #[serial]\n    async fn update_live_backup_from_provider_preserves_codex_mcp_servers() {\n        let _home = TempHome::new();\n        crate::settings::reload_settings().expect(\"reload settings\");\n\n        let db = Arc::new(Database::memory().expect(\"init db\"));\n        let service = ProxyService::new(db.clone());\n\n        db.save_live_backup(\n            \"codex\",\n            &serde_json::to_string(&json!({\n                \"auth\": {\n                    \"OPENAI_API_KEY\": \"old-token\"\n                },\n                \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-4\"\n\n[model_providers.any]\nbase_url = \"https://old.example/v1\"\n\n[mcp_servers.echo]\ncommand = \"npx\"\nargs = [\"echo-server\"]\n\"#\n            }))\n            .expect(\"serialize seed backup\"),\n        )\n        .await\n        .expect(\"seed live backup\");\n\n        let provider = Provider::with_id(\n            \"p2\".to_string(),\n            \"P2\".to_string(),\n            json!({\n                \"auth\": {\n                    \"OPENAI_API_KEY\": \"new-token\"\n                },\n                \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://new.example/v1\"\n\"#\n            }),\n            None,\n        );\n\n        service\n            .update_live_backup_from_provider(\"codex\", &provider)\n            .await\n            .expect(\"update live backup\");\n\n        let backup = db\n            .get_live_backup(\"codex\")\n            .await\n            .expect(\"get live backup\")\n            .expect(\"backup exists\");\n        let stored: Value =\n            serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n        let config = stored\n            .get(\"config\")\n            .and_then(|v| v.as_str())\n            .expect(\"config string\");\n\n        assert!(\n            config.contains(\"[mcp_servers.echo]\"),\n            \"existing Codex MCP section should survive proxy hot-switch backup update\"\n        );\n        assert!(\n            config.contains(\"https://new.example/v1\"),\n            \"provider-specific base_url should still update to the new provider\"\n        );\n    }\n\n    #[tokio::test]\n    #[serial]\n    async fn update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict() {\n        let _home = TempHome::new();\n        crate::settings::reload_settings().expect(\"reload settings\");\n\n        let db = Arc::new(Database::memory().expect(\"init db\"));\n        let service = ProxyService::new(db.clone());\n\n        db.save_live_backup(\n            \"codex\",\n            &serde_json::to_string(&json!({\n                \"auth\": {\n                    \"OPENAI_API_KEY\": \"old-token\"\n                },\n                \"config\": r#\"[mcp_servers.shared]\ncommand = \"old-command\"\n\n[mcp_servers.legacy]\ncommand = \"legacy-command\"\n\"#\n            }))\n            .expect(\"serialize seed backup\"),\n        )\n        .await\n        .expect(\"seed live backup\");\n\n        let provider = Provider::with_id(\n            \"p2\".to_string(),\n            \"P2\".to_string(),\n            json!({\n                \"auth\": {\n                    \"OPENAI_API_KEY\": \"new-token\"\n                },\n                \"config\": r#\"[mcp_servers.shared]\ncommand = \"new-command\"\n\n[mcp_servers.latest]\ncommand = \"latest-command\"\n\"#\n            }),\n            None,\n        );\n\n        service\n            .update_live_backup_from_provider(\"codex\", &provider)\n            .await\n            .expect(\"update live backup\");\n\n        let backup = db\n            .get_live_backup(\"codex\")\n            .await\n            .expect(\"get live backup\")\n            .expect(\"backup exists\");\n        let stored: Value =\n            serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n        let config = stored\n            .get(\"config\")\n            .and_then(|v| v.as_str())\n            .expect(\"config string\");\n        let parsed: toml::Value = toml::from_str(config).expect(\"parse merged codex config\");\n\n        let mcp_servers = parsed\n            .get(\"mcp_servers\")\n            .expect(\"mcp_servers should be present\");\n        assert_eq!(\n            mcp_servers\n                .get(\"shared\")\n                .and_then(|v| v.get(\"command\"))\n                .and_then(|v| v.as_str()),\n            Some(\"new-command\"),\n            \"new provider/common-config MCP definition should win on conflict\"\n        );\n        assert_eq!(\n            mcp_servers\n                .get(\"legacy\")\n                .and_then(|v| v.get(\"command\"))\n                .and_then(|v| v.as_str()),\n            Some(\"legacy-command\"),\n            \"backup-only MCP entries should still be preserved\"\n        );\n        assert_eq!(\n            mcp_servers\n                .get(\"latest\")\n                .and_then(|v| v.get(\"command\"))\n                .and_then(|v| v.as_str()),\n            Some(\"latest-command\"),\n            \"new MCP entries should remain in the restore backup\"\n        );\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/services/skill.rs",
    "content": "//! Skills 服务层\n//!\n//! v3.10.0+ 统一管理架构：\n//! - SSOT（单一事实源）：`~/.cc-switch/skills/`\n//! - 安装时下载到 SSOT，按需同步到各应用目录\n//! - 数据库存储安装记录和启用状态\n\nuse anyhow::{anyhow, Context, Result};\nuse chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse std::collections::{HashMap, HashSet};\nuse std::fs;\nuse std::path::{Component, Path, PathBuf};\nuse std::sync::Arc;\nuse tokio::time::timeout;\n\nuse crate::app_config::{AppType, InstalledSkill, SkillApps, UnmanagedSkill};\nuse crate::config::get_app_config_dir;\nuse crate::database::Database;\nuse crate::error::format_skill_error;\n\n// ========== 数据结构 ==========\n\n/// Skill 同步方式\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]\n#[serde(rename_all = \"lowercase\")]\npub enum SyncMethod {\n    /// 自动选择：优先 symlink，失败时回退到 copy\n    #[default]\n    Auto,\n    /// 符号链接（推荐，节省磁盘空间）\n    Symlink,\n    /// 文件复制（兼容模式）\n    Copy,\n}\n\n/// 可发现的技能（来自仓库）\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct DiscoverableSkill {\n    /// 唯一标识: \"owner/name:directory\"\n    pub key: String,\n    /// 显示名称 (从 SKILL.md 解析)\n    pub name: String,\n    /// 技能描述\n    pub description: String,\n    /// 目录名称 (安装路径的最后一段)\n    pub directory: String,\n    /// GitHub README URL\n    #[serde(rename = \"readmeUrl\")]\n    pub readme_url: Option<String>,\n    /// 仓库所有者\n    #[serde(rename = \"repoOwner\")]\n    pub repo_owner: String,\n    /// 仓库名称\n    #[serde(rename = \"repoName\")]\n    pub repo_name: String,\n    /// 分支名称\n    #[serde(rename = \"repoBranch\")]\n    pub repo_branch: String,\n}\n\n/// 技能对象（兼容旧 API，内部使用 DiscoverableSkill）\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct Skill {\n    /// 唯一标识: \"owner/name:directory\" 或 \"local:directory\"\n    pub key: String,\n    /// 显示名称 (从 SKILL.md 解析)\n    pub name: String,\n    /// 技能描述\n    pub description: String,\n    /// 目录名称 (安装路径的最后一段)\n    pub directory: String,\n    /// GitHub README URL\n    #[serde(rename = \"readmeUrl\")]\n    pub readme_url: Option<String>,\n    /// 是否已安装\n    pub installed: bool,\n    /// 仓库所有者\n    #[serde(rename = \"repoOwner\")]\n    pub repo_owner: Option<String>,\n    /// 仓库名称\n    #[serde(rename = \"repoName\")]\n    pub repo_name: Option<String>,\n    /// 分支名称\n    #[serde(rename = \"repoBranch\")]\n    pub repo_branch: Option<String>,\n}\n\n/// 仓库配置\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct SkillRepo {\n    /// GitHub 用户/组织名\n    pub owner: String,\n    /// 仓库名称\n    pub name: String,\n    /// 分支 (默认 \"main\")\n    pub branch: String,\n    /// 是否启用\n    pub enabled: bool,\n}\n\n/// 技能安装状态（旧版兼容）\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct SkillState {\n    /// 是否已安装\n    pub installed: bool,\n    /// 安装时间\n    #[serde(rename = \"installedAt\")]\n    pub installed_at: DateTime<Utc>,\n}\n\n/// 持久化存储结构（仓库配置）\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct SkillStore {\n    /// directory -> 安装状态（旧版兼容，新版不使用）\n    pub skills: HashMap<String, SkillState>,\n    /// 仓库列表\n    pub repos: Vec<SkillRepo>,\n}\n\nimpl Default for SkillStore {\n    fn default() -> Self {\n        SkillStore {\n            skills: HashMap::new(),\n            repos: vec![\n                SkillRepo {\n                    owner: \"anthropics\".to_string(),\n                    name: \"skills\".to_string(),\n                    branch: \"main\".to_string(),\n                    enabled: true,\n                },\n                SkillRepo {\n                    owner: \"ComposioHQ\".to_string(),\n                    name: \"awesome-claude-skills\".to_string(),\n                    branch: \"master\".to_string(),\n                    enabled: true,\n                },\n                SkillRepo {\n                    owner: \"cexll\".to_string(),\n                    name: \"myclaude\".to_string(),\n                    branch: \"master\".to_string(),\n                    enabled: true,\n                },\n                SkillRepo {\n                    owner: \"JimLiu\".to_string(),\n                    name: \"baoyu-skills\".to_string(),\n                    branch: \"main\".to_string(),\n                    enabled: true,\n                },\n            ],\n        }\n    }\n}\n\n/// Skill 卸载结果\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct SkillUninstallResult {\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub backup_path: Option<String>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct SkillBackupEntry {\n    pub backup_id: String,\n    pub backup_path: String,\n    pub created_at: i64,\n    pub skill: InstalledSkill,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct SkillBackupMetadata {\n    skill: InstalledSkill,\n    backup_created_at: i64,\n    source_path: String,\n}\n\nconst SKILL_BACKUP_RETAIN_COUNT: usize = 20;\n\n/// 技能元数据 (从 SKILL.md 解析)\n#[derive(Debug, Clone, Deserialize)]\npub struct SkillMetadata {\n    pub name: Option<String>,\n    pub description: Option<String>,\n}\n\n/// 导入已有 Skill 时，前端显式提交的启用应用选择\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct ImportSkillSelection {\n    pub directory: String,\n    #[serde(default)]\n    pub apps: SkillApps,\n}\n\n#[derive(Debug, Clone, Deserialize)]\nstruct LegacySkillMigrationRow {\n    directory: String,\n    app_type: String,\n}\n\n// ========== ~/.agents/ lock 文件解析 ==========\n\n/// `~/.agents/.skill-lock.json` 文件结构\n#[derive(Deserialize)]\nstruct AgentsLockFile {\n    skills: HashMap<String, AgentsLockSkill>,\n}\n\n/// lock 文件中单个 skill 的信息\n#[derive(Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct AgentsLockSkill {\n    source: Option<String>,\n    source_type: Option<String>,\n    source_url: Option<String>,\n    skill_path: Option<String>,\n    branch: Option<String>,\n    source_branch: Option<String>,\n}\n\n#[derive(Debug, Clone)]\nstruct LockRepoInfo {\n    owner: String,\n    repo: String,\n    skill_path: Option<String>,\n    branch: Option<String>,\n}\n\nfn normalize_optional_branch(branch: Option<String>) -> Option<String> {\n    branch.and_then(|b| {\n        let trimmed = b.trim();\n        if trimmed.is_empty() {\n            None\n        } else {\n            Some(trimmed.to_string())\n        }\n    })\n}\n\nfn parse_branch_from_source_url(source_url: Option<&str>) -> Option<String> {\n    let source_url = source_url?;\n    let source_url = source_url.trim();\n    if source_url.is_empty() {\n        return None;\n    }\n\n    // 支持 https://github.com/owner/repo/tree/<branch>/...\n    if let Some((_, after_tree)) = source_url.split_once(\"/tree/\") {\n        let branch = after_tree\n            .split('/')\n            .next()\n            .map(str::trim)\n            .filter(|s| !s.is_empty())?;\n        return Some(branch.to_string());\n    }\n\n    // 支持 URL fragment: ...git#branch\n    if let Some((_, fragment)) = source_url.split_once('#') {\n        let branch = fragment\n            .split('&')\n            .next()\n            .map(str::trim)\n            .filter(|s| !s.is_empty())?;\n        return Some(branch.to_string());\n    }\n\n    // 支持 query: ...?branch=xxx / ?ref=xxx\n    if let Some((_, query)) = source_url.split_once('?') {\n        for pair in query.split('&') {\n            let Some((key, value)) = pair.split_once('=') else {\n                continue;\n            };\n            if matches!(key, \"branch\" | \"ref\") {\n                let branch = value.trim();\n                if !branch.is_empty() {\n                    return Some(branch.to_string());\n                }\n            }\n        }\n    }\n\n    None\n}\n\n/// 获取 `~/.agents/skills/` 目录（存在时返回）\nfn get_agents_skills_dir() -> Option<PathBuf> {\n    dirs::home_dir()\n        .map(|h| h.join(\".agents\").join(\"skills\"))\n        .filter(|p| p.exists())\n}\n\n/// 解析 `~/.agents/.skill-lock.json`，返回 skill_name -> 仓库信息\nfn parse_agents_lock() -> HashMap<String, LockRepoInfo> {\n    let path = match dirs::home_dir() {\n        Some(h) => h.join(\".agents\").join(\".skill-lock.json\"),\n        None => {\n            log::warn!(\"无法获取 HOME 目录，跳过解析 agents lock 文件\");\n            return HashMap::new();\n        }\n    };\n    let content = match fs::read_to_string(&path) {\n        Ok(c) => c,\n        Err(e) => {\n            if e.kind() == std::io::ErrorKind::NotFound {\n                log::debug!(\"未找到 agents lock 文件: {}\", path.display());\n            } else {\n                log::warn!(\"读取 agents lock 文件失败 ({}): {}\", path.display(), e);\n            }\n            return HashMap::new();\n        }\n    };\n    let lock: AgentsLockFile = match serde_json::from_str(&content) {\n        Ok(l) => l,\n        Err(e) => {\n            log::warn!(\"解析 agents lock 文件失败 ({}): {}\", path.display(), e);\n            return HashMap::new();\n        }\n    };\n    let parsed: HashMap<String, LockRepoInfo> = lock\n        .skills\n        .into_iter()\n        .filter_map(|(name, skill)| {\n            let source = skill.source?;\n            if skill.source_type.as_deref() != Some(\"github\") {\n                return None;\n            }\n            let (owner, repo) = source.split_once('/')?;\n            let branch = normalize_optional_branch(skill.branch)\n                .or_else(|| normalize_optional_branch(skill.source_branch))\n                .or_else(|| parse_branch_from_source_url(skill.source_url.as_deref()));\n            Some((\n                name,\n                LockRepoInfo {\n                    owner: owner.to_string(),\n                    repo: repo.to_string(),\n                    skill_path: skill.skill_path,\n                    branch,\n                },\n            ))\n        })\n        .collect();\n    log::info!(\n        \"agents lock 文件解析完成，共识别 {} 个 github skill\",\n        parsed.len()\n    );\n    parsed\n}\n\n// ========== SkillService ==========\n\npub struct SkillService;\n\nimpl Default for SkillService {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n\nimpl SkillService {\n    pub fn new() -> Self {\n        Self\n    }\n\n    /// 构建 Skill 文档 URL（指向仓库中的 SKILL.md 文件）\n    fn build_skill_doc_url(owner: &str, repo: &str, branch: &str, doc_path: &str) -> String {\n        format!(\"https://github.com/{owner}/{repo}/blob/{branch}/{doc_path}\")\n    }\n\n    /// 从旧 readme_url 中提取仓库内文档路径，兼容 `blob`/`tree` 两种格式\n    fn extract_doc_path_from_url(url: &str) -> Option<String> {\n        let marker = if url.contains(\"/blob/\") {\n            \"/blob/\"\n        } else if url.contains(\"/tree/\") {\n            \"/tree/\"\n        } else {\n            return None;\n        };\n\n        let (_, tail) = url.split_once(marker)?;\n        let (_, path) = tail.split_once('/')?;\n        if path.is_empty() {\n            return None;\n        }\n        Some(path.to_string())\n    }\n\n    // ========== 路径管理 ==========\n\n    /// 获取 SSOT 目录（~/.cc-switch/skills/）\n    pub fn get_ssot_dir() -> Result<PathBuf> {\n        let dir = get_app_config_dir().join(\"skills\");\n        fs::create_dir_all(&dir)?;\n        Ok(dir)\n    }\n\n    /// 获取 Skill 卸载备份目录（~/.cc-switch/skill-backups/）\n    fn get_backup_dir() -> Result<PathBuf> {\n        let dir = get_app_config_dir().join(\"skill-backups\");\n        fs::create_dir_all(&dir)?;\n        Ok(dir)\n    }\n\n    /// 获取应用的 skills 目录\n    pub fn get_app_skills_dir(app: &AppType) -> Result<PathBuf> {\n        // 目录覆盖：优先使用用户在 settings.json 中配置的 override 目录\n        match app {\n            AppType::Claude => {\n                if let Some(custom) = crate::settings::get_claude_override_dir() {\n                    return Ok(custom.join(\"skills\"));\n                }\n            }\n            AppType::Codex => {\n                if let Some(custom) = crate::settings::get_codex_override_dir() {\n                    return Ok(custom.join(\"skills\"));\n                }\n            }\n            AppType::Gemini => {\n                if let Some(custom) = crate::settings::get_gemini_override_dir() {\n                    return Ok(custom.join(\"skills\"));\n                }\n            }\n            AppType::OpenCode => {\n                if let Some(custom) = crate::settings::get_opencode_override_dir() {\n                    return Ok(custom.join(\"skills\"));\n                }\n            }\n            AppType::OpenClaw => {\n                if let Some(custom) = crate::settings::get_openclaw_override_dir() {\n                    return Ok(custom.join(\"skills\"));\n                }\n            }\n        }\n\n        // 默认路径：回退到用户主目录下的标准位置\n        let home = dirs::home_dir().context(format_skill_error(\n            \"GET_HOME_DIR_FAILED\",\n            &[],\n            Some(\"checkPermission\"),\n        ))?;\n\n        Ok(match app {\n            AppType::Claude => home.join(\".claude\").join(\"skills\"),\n            AppType::Codex => home.join(\".codex\").join(\"skills\"),\n            AppType::Gemini => home.join(\".gemini\").join(\"skills\"),\n            AppType::OpenCode => home.join(\".config\").join(\"opencode\").join(\"skills\"),\n            AppType::OpenClaw => home.join(\".openclaw\").join(\"skills\"),\n        })\n    }\n\n    // ========== 统一管理方法 ==========\n\n    /// 获取所有已安装的 Skills\n    pub fn get_all_installed(db: &Arc<Database>) -> Result<Vec<InstalledSkill>> {\n        let skills = db.get_all_installed_skills()?;\n        Ok(skills.into_values().collect())\n    }\n\n    /// 安装 Skill\n    ///\n    /// 流程：\n    /// 1. 下载到 SSOT 目录\n    /// 2. 保存到数据库\n    /// 3. 同步到启用的应用目录\n    pub async fn install(\n        &self,\n        db: &Arc<Database>,\n        skill: &DiscoverableSkill,\n        current_app: &AppType,\n    ) -> Result<InstalledSkill> {\n        let ssot_dir = Self::get_ssot_dir()?;\n\n        // 允许多级目录（如 a/b/c），但必须是安全的相对路径。\n        let source_rel = Self::sanitize_skill_source_path(&skill.directory).ok_or_else(|| {\n            anyhow!(format_skill_error(\n                \"INVALID_SKILL_DIRECTORY\",\n                &[(\"directory\", &skill.directory)],\n                Some(\"checkZipContent\"),\n            ))\n        })?;\n        // 安装目录名始终使用最后一段，避免在 SSOT 中创建多级目录。\n        let install_name = source_rel\n            .file_name()\n            .and_then(|name| Self::sanitize_install_name(&name.to_string_lossy()))\n            .ok_or_else(|| {\n                anyhow!(format_skill_error(\n                    \"INVALID_SKILL_DIRECTORY\",\n                    &[(\"directory\", &skill.directory)],\n                    Some(\"checkZipContent\"),\n                ))\n            })?;\n\n        // 检查数据库中是否已有同名 directory 的 skill（来自其他仓库）\n        let existing_skills = db.get_all_installed_skills()?;\n        for existing in existing_skills.values() {\n            if existing.directory.eq_ignore_ascii_case(&install_name) {\n                // 检查是否来自同一仓库\n                let same_repo = existing.repo_owner.as_deref() == Some(&skill.repo_owner)\n                    && existing.repo_name.as_deref() == Some(&skill.repo_name);\n                if same_repo {\n                    // 同一仓库的同名 skill，返回现有记录（可能需要更新启用状态）\n                    let mut updated = existing.clone();\n                    updated.apps.set_enabled_for(current_app, true);\n                    db.save_skill(&updated)?;\n                    Self::sync_to_app_dir(&updated.directory, current_app)?;\n                    log::info!(\n                        \"Skill {} 已存在，更新 {:?} 启用状态\",\n                        updated.name,\n                        current_app\n                    );\n                    return Ok(updated);\n                } else {\n                    // 不同仓库的同名 skill，报错\n                    return Err(anyhow!(format_skill_error(\n                        \"SKILL_DIRECTORY_CONFLICT\",\n                        &[\n                            (\"directory\", &install_name),\n                            (\n                                \"existing_repo\",\n                                &format!(\n                                    \"{}/{}\",\n                                    existing.repo_owner.as_deref().unwrap_or(\"unknown\"),\n                                    existing.repo_name.as_deref().unwrap_or(\"unknown\")\n                                )\n                            ),\n                            (\n                                \"new_repo\",\n                                &format!(\"{}/{}\", skill.repo_owner, skill.repo_name)\n                            ),\n                        ],\n                        Some(\"uninstallFirst\"),\n                    )));\n                }\n            }\n        }\n\n        let dest = ssot_dir.join(&install_name);\n\n        let mut repo_branch = skill.repo_branch.clone();\n\n        // 如果已存在则跳过下载\n        if !dest.exists() {\n            let repo = SkillRepo {\n                owner: skill.repo_owner.clone(),\n                name: skill.repo_name.clone(),\n                branch: skill.repo_branch.clone(),\n                enabled: true,\n            };\n\n            // 下载仓库\n            let (temp_dir, used_branch) = timeout(\n                std::time::Duration::from_secs(60),\n                self.download_repo(&repo),\n            )\n            .await\n            .map_err(|_| {\n                anyhow!(format_skill_error(\n                    \"DOWNLOAD_TIMEOUT\",\n                    &[\n                        (\"owner\", &repo.owner),\n                        (\"name\", &repo.name),\n                        (\"timeout\", \"60\")\n                    ],\n                    Some(\"checkNetwork\"),\n                ))\n            })??;\n            repo_branch = used_branch;\n\n            // 复制到 SSOT\n            let source = temp_dir.join(&source_rel);\n            if !source.exists() {\n                let _ = fs::remove_dir_all(&temp_dir);\n                return Err(anyhow!(format_skill_error(\n                    \"SKILL_DIR_NOT_FOUND\",\n                    &[(\"path\", &source.display().to_string())],\n                    Some(\"checkRepoUrl\"),\n                )));\n            }\n\n            let canonical_temp = temp_dir.canonicalize().unwrap_or_else(|_| temp_dir.clone());\n            let canonical_source = source.canonicalize().map_err(|_| {\n                anyhow!(format_skill_error(\n                    \"SKILL_DIR_NOT_FOUND\",\n                    &[(\"path\", &source.display().to_string())],\n                    Some(\"checkRepoUrl\"),\n                ))\n            })?;\n            if !canonical_source.starts_with(&canonical_temp) || !canonical_source.is_dir() {\n                let _ = fs::remove_dir_all(&temp_dir);\n                return Err(anyhow!(format_skill_error(\n                    \"INVALID_SKILL_DIRECTORY\",\n                    &[(\"directory\", &skill.directory)],\n                    Some(\"checkZipContent\"),\n                )));\n            }\n\n            Self::copy_dir_recursive(&canonical_source, &dest)?;\n            let _ = fs::remove_dir_all(&temp_dir);\n\n            // 使用实际下载成功的分支，避免 readme_url / repo_branch 与真实分支不一致。\n            if repo_branch != skill.repo_branch {\n                log::info!(\n                    \"Skill {}/{} 分支自动回退: {} -> {}\",\n                    skill.repo_owner,\n                    skill.repo_name,\n                    skill.repo_branch,\n                    repo_branch\n                );\n            }\n        }\n\n        let doc_path = skill\n            .readme_url\n            .as_deref()\n            .and_then(Self::extract_doc_path_from_url)\n            .map(|path| {\n                if path.ends_with(\"/SKILL.md\") || path == \"SKILL.md\" {\n                    path\n                } else {\n                    format!(\"{}/SKILL.md\", path.trim_end_matches('/'))\n                }\n            })\n            .unwrap_or_else(|| format!(\"{}/SKILL.md\", skill.directory.trim_end_matches('/')));\n\n        let readme_url = Some(Self::build_skill_doc_url(\n            &skill.repo_owner,\n            &skill.repo_name,\n            &repo_branch,\n            &doc_path,\n        ));\n\n        // 创建 InstalledSkill 记录\n        let installed_skill = InstalledSkill {\n            id: skill.key.clone(),\n            name: skill.name.clone(),\n            description: if skill.description.is_empty() {\n                None\n            } else {\n                Some(skill.description.clone())\n            },\n            directory: install_name.clone(),\n            repo_owner: Some(skill.repo_owner.clone()),\n            repo_name: Some(skill.repo_name.clone()),\n            repo_branch: Some(repo_branch),\n            readme_url,\n            apps: SkillApps::only(current_app),\n            installed_at: chrono::Utc::now().timestamp(),\n        };\n\n        // 保存到数据库\n        db.save_skill(&installed_skill)?;\n\n        // 同步到当前应用目录\n        Self::sync_to_app_dir(&install_name, current_app)?;\n\n        log::info!(\n            \"Skill {} 安装成功，已启用 {:?}\",\n            installed_skill.name,\n            current_app\n        );\n\n        Ok(installed_skill)\n    }\n\n    /// 卸载 Skill\n    ///\n    /// 流程：\n    /// 1. 从所有应用目录删除\n    /// 2. 从 SSOT 删除\n    /// 3. 从数据库删除\n    pub fn uninstall(db: &Arc<Database>, id: &str) -> Result<SkillUninstallResult> {\n        // 获取 skill 信息\n        let skill = db\n            .get_installed_skill(id)?\n            .ok_or_else(|| anyhow!(\"Skill not found: {id}\"))?;\n\n        let backup_path =\n            Self::create_uninstall_backup(&skill)?.map(|path| path.to_string_lossy().to_string());\n\n        // 从所有应用目录删除\n        for app in AppType::all() {\n            let _ = Self::remove_from_app(&skill.directory, &app);\n        }\n\n        // 从 SSOT 删除\n        let ssot_dir = Self::get_ssot_dir()?;\n        let skill_path = ssot_dir.join(&skill.directory);\n        if skill_path.exists() {\n            fs::remove_dir_all(&skill_path)?;\n        }\n\n        // 从数据库删除\n        db.delete_skill(id)?;\n\n        log::info!(\n            \"Skill {} 卸载成功{}\",\n            skill.name,\n            backup_path\n                .as_deref()\n                .map(|path| format!(\", backup: {path}\"))\n                .unwrap_or_default()\n        );\n\n        Ok(SkillUninstallResult { backup_path })\n    }\n\n    pub fn list_backups() -> Result<Vec<SkillBackupEntry>> {\n        let backup_dir = Self::get_backup_dir()?;\n        let mut entries = Vec::new();\n\n        for entry in fs::read_dir(&backup_dir)? {\n            let entry = match entry {\n                Ok(entry) => entry,\n                Err(err) => {\n                    log::warn!(\"读取 Skill 备份目录项失败: {err}\");\n                    continue;\n                }\n            };\n            let path = entry.path();\n            if !path.is_dir() {\n                continue;\n            }\n\n            match Self::read_backup_metadata(&path) {\n                Ok(metadata) => entries.push(SkillBackupEntry {\n                    backup_id: entry.file_name().to_string_lossy().to_string(),\n                    backup_path: path.to_string_lossy().to_string(),\n                    created_at: metadata.backup_created_at,\n                    skill: metadata.skill,\n                }),\n                Err(err) => {\n                    log::warn!(\"解析 Skill 备份失败 {}: {err:#}\", path.display());\n                }\n            }\n        }\n\n        entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));\n        Ok(entries)\n    }\n\n    pub fn delete_backup(backup_id: &str) -> Result<()> {\n        let backup_path = Self::backup_path_for_id(backup_id)?;\n        let metadata = fs::symlink_metadata(&backup_path)\n            .with_context(|| format!(\"failed to access {}\", backup_path.display()))?;\n\n        if !metadata.is_dir() {\n            return Err(anyhow!(\n                \"Skill backup is not a directory: {}\",\n                backup_path.display()\n            ));\n        }\n\n        fs::remove_dir_all(&backup_path)\n            .with_context(|| format!(\"failed to delete {}\", backup_path.display()))?;\n\n        log::info!(\"Skill 备份已删除: {}\", backup_path.display());\n        Ok(())\n    }\n\n    pub fn restore_from_backup(\n        db: &Arc<Database>,\n        backup_id: &str,\n        current_app: &AppType,\n    ) -> Result<InstalledSkill> {\n        let backup_path = Self::backup_path_for_id(backup_id)?;\n        let metadata = Self::read_backup_metadata(&backup_path)?;\n        let backup_skill_dir = backup_path.join(\"skill\");\n        if !backup_skill_dir.join(\"SKILL.md\").exists() {\n            return Err(anyhow!(\n                \"Skill backup is invalid or missing SKILL.md: {}\",\n                backup_path.display()\n            ));\n        }\n\n        let existing_skills = db.get_all_installed_skills()?;\n        if existing_skills.contains_key(&metadata.skill.id)\n            || existing_skills.values().any(|skill| {\n                skill\n                    .directory\n                    .eq_ignore_ascii_case(&metadata.skill.directory)\n            })\n        {\n            return Err(anyhow!(\n                \"Skill already exists, please uninstall the current one first: {}\",\n                metadata.skill.directory\n            ));\n        }\n\n        let ssot_dir = Self::get_ssot_dir()?;\n        let restore_path = ssot_dir.join(&metadata.skill.directory);\n        if restore_path.exists() || Self::is_symlink(&restore_path) {\n            return Err(anyhow!(\n                \"Restore target already exists: {}\",\n                restore_path.display()\n            ));\n        }\n\n        let mut restored_skill = metadata.skill;\n        restored_skill.installed_at = Utc::now().timestamp();\n        restored_skill.apps = SkillApps::only(current_app);\n\n        Self::copy_dir_recursive(&backup_skill_dir, &restore_path)?;\n\n        if let Err(err) = db.save_skill(&restored_skill) {\n            let _ = fs::remove_dir_all(&restore_path);\n            return Err(err.into());\n        }\n\n        if !restored_skill.apps.is_empty() {\n            if let Err(err) = Self::sync_to_app_dir(&restored_skill.directory, current_app) {\n                let _ = db.delete_skill(&restored_skill.id);\n                let _ = fs::remove_dir_all(&restore_path);\n                return Err(err);\n            }\n        }\n\n        log::info!(\n            \"Skill {} 已从备份恢复到 {}\",\n            restored_skill.name,\n            restore_path.display()\n        );\n\n        Ok(restored_skill)\n    }\n\n    /// 切换应用启用状态\n    ///\n    /// 启用：复制到应用目录\n    /// 禁用：从应用目录删除\n    pub fn toggle_app(db: &Arc<Database>, id: &str, app: &AppType, enabled: bool) -> Result<()> {\n        // 获取当前 skill\n        let mut skill = db\n            .get_installed_skill(id)?\n            .ok_or_else(|| anyhow!(\"Skill not found: {id}\"))?;\n\n        // 更新状态\n        skill.apps.set_enabled_for(app, enabled);\n\n        // 同步文件\n        if enabled {\n            Self::sync_to_app_dir(&skill.directory, app)?;\n        } else {\n            Self::remove_from_app(&skill.directory, app)?;\n        }\n\n        // 更新数据库\n        db.update_skill_apps(id, &skill.apps)?;\n\n        log::info!(\"Skill {} 的 {:?} 状态已更新为 {}\", skill.name, app, enabled);\n\n        Ok(())\n    }\n\n    /// 扫描未管理的 Skills\n    ///\n    /// 扫描各应用目录，找出未被 CC Switch 管理的 Skills\n    pub fn scan_unmanaged(db: &Arc<Database>) -> Result<Vec<UnmanagedSkill>> {\n        let managed_skills = db.get_all_installed_skills()?;\n        let managed_dirs: HashSet<String> = managed_skills\n            .values()\n            .map(|s| s.directory.clone())\n            .collect();\n\n        // 收集所有待扫描的目录及其来源标签\n        let mut scan_sources: Vec<(PathBuf, String)> = Vec::new();\n        for app in AppType::all() {\n            if let Ok(d) = Self::get_app_skills_dir(&app) {\n                scan_sources.push((d, app.as_str().to_string()));\n            }\n        }\n        if let Some(agents_dir) = get_agents_skills_dir() {\n            scan_sources.push((agents_dir, \"agents\".to_string()));\n        }\n        if let Ok(ssot_dir) = Self::get_ssot_dir() {\n            scan_sources.push((ssot_dir, \"cc-switch\".to_string()));\n        }\n\n        let mut unmanaged: HashMap<String, UnmanagedSkill> = HashMap::new();\n\n        for (scan_dir, label) in &scan_sources {\n            let entries = match fs::read_dir(scan_dir) {\n                Ok(e) => e,\n                Err(_) => continue,\n            };\n            for entry in entries.flatten() {\n                let path = entry.path();\n                if !path.is_dir() {\n                    continue;\n                }\n                let dir_name = entry.file_name().to_string_lossy().to_string();\n                if dir_name.starts_with('.') || managed_dirs.contains(&dir_name) {\n                    continue;\n                }\n\n                let skill_md = path.join(\"SKILL.md\");\n                if !skill_md.exists() {\n                    continue;\n                }\n                let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name);\n\n                unmanaged\n                    .entry(dir_name.clone())\n                    .and_modify(|s| s.found_in.push(label.clone()))\n                    .or_insert(UnmanagedSkill {\n                        directory: dir_name,\n                        name,\n                        description,\n                        found_in: vec![label.clone()],\n                        path: path.display().to_string(),\n                    });\n            }\n        }\n\n        Ok(unmanaged.into_values().collect())\n    }\n\n    /// 从应用目录导入 Skills\n    ///\n    /// 将未管理的 Skills 导入到 CC Switch 统一管理\n    pub fn import_from_apps(\n        db: &Arc<Database>,\n        imports: Vec<ImportSkillSelection>,\n    ) -> Result<Vec<InstalledSkill>> {\n        let ssot_dir = Self::get_ssot_dir()?;\n        let agents_lock = parse_agents_lock();\n        let mut imported = Vec::new();\n\n        // 将 lock 文件中发现的仓库保存到 skill_repos\n        save_repos_from_lock(\n            db,\n            &agents_lock,\n            imports.iter().map(|selection| selection.directory.as_str()),\n        );\n\n        // 收集所有候选搜索目录\n        let mut search_sources: Vec<(PathBuf, String)> = Vec::new();\n        for app in AppType::all() {\n            if let Ok(d) = Self::get_app_skills_dir(&app) {\n                search_sources.push((d, app.as_str().to_string()));\n            }\n        }\n        if let Some(agents_dir) = get_agents_skills_dir() {\n            search_sources.push((agents_dir, \"agents\".to_string()));\n        }\n        search_sources.push((ssot_dir.clone(), \"cc-switch\".to_string()));\n\n        for selection in imports {\n            let dir_name = selection.directory;\n            // 在所有候选目录中查找\n            let mut source_path: Option<PathBuf> = None;\n\n            for (base, label) in &search_sources {\n                let skill_path = base.join(&dir_name);\n                if skill_path.exists() {\n                    if source_path.is_none() {\n                        source_path = Some(skill_path);\n                    }\n                    log::debug!(\"Skill '{}' found in source '{}'\", dir_name, label);\n                }\n            }\n\n            let source = match source_path {\n                Some(p) => p,\n                None => continue,\n            };\n            if !source.join(\"SKILL.md\").exists() {\n                log::warn!(\n                    \"Skip importing '{}' because source '{}' has no SKILL.md\",\n                    dir_name,\n                    source.display()\n                );\n                continue;\n            }\n\n            // 复制到 SSOT\n            let dest = ssot_dir.join(&dir_name);\n            if !dest.exists() {\n                Self::copy_dir_recursive(&source, &dest)?;\n            }\n\n            // 解析元数据\n            let skill_md = dest.join(\"SKILL.md\");\n            let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name);\n\n            // 启用状态仅信任用户本次显式选择，不再根据“在哪些位置找到”自动推断。\n            let apps = selection.apps;\n\n            // 从 lock 文件提取仓库信息\n            let (id, repo_owner, repo_name, repo_branch, readme_url) =\n                build_repo_info_from_lock(&agents_lock, &dir_name);\n\n            // 创建记录\n            let skill = InstalledSkill {\n                id,\n                name,\n                description,\n                directory: dir_name,\n                repo_owner,\n                repo_name,\n                repo_branch,\n                readme_url,\n                apps,\n                installed_at: chrono::Utc::now().timestamp(),\n            };\n\n            // 保存到数据库\n            db.save_skill(&skill)?;\n            imported.push(skill);\n        }\n\n        log::info!(\"成功导入 {} 个 Skills\", imported.len());\n\n        Ok(imported)\n    }\n\n    // ========== 文件同步方法 ==========\n\n    /// 创建符号链接（跨平台）\n    ///\n    /// - Unix: 使用 std::os::unix::fs::symlink\n    /// - Windows: 使用 std::os::windows::fs::symlink_dir\n    #[cfg(unix)]\n    fn create_symlink(src: &Path, dest: &Path) -> Result<()> {\n        std::os::unix::fs::symlink(src, dest)\n            .with_context(|| format!(\"创建符号链接失败: {} -> {}\", src.display(), dest.display()))\n    }\n\n    #[cfg(windows)]\n    fn create_symlink(src: &Path, dest: &Path) -> Result<()> {\n        std::os::windows::fs::symlink_dir(src, dest)\n            .with_context(|| format!(\"创建符号链接失败: {} -> {}\", src.display(), dest.display()))\n    }\n\n    /// 检查路径是否为符号链接\n    fn is_symlink(path: &Path) -> bool {\n        path.symlink_metadata()\n            .map(|m| m.file_type().is_symlink())\n            .unwrap_or(false)\n    }\n\n    /// 获取当前同步方式配置\n    fn get_sync_method() -> SyncMethod {\n        crate::settings::get_skill_sync_method()\n    }\n\n    /// 同步 Skill 到应用目录（使用 symlink 或 copy）\n    ///\n    /// 根据配置和平台选择最佳同步方式：\n    /// - Auto: 优先尝试 symlink，失败时回退到 copy\n    /// - Symlink: 仅使用 symlink\n    /// - Copy: 仅使用文件复制\n    pub fn sync_to_app_dir(directory: &str, app: &AppType) -> Result<()> {\n        let ssot_dir = Self::get_ssot_dir()?;\n        let source = ssot_dir.join(directory);\n\n        if !source.exists() {\n            return Err(anyhow!(\"Skill 不存在于 SSOT: {directory}\"));\n        }\n\n        let app_dir = Self::get_app_skills_dir(app)?;\n        fs::create_dir_all(&app_dir)?;\n\n        let dest = app_dir.join(directory);\n\n        // 如果已存在则先删除（无论是 symlink 还是真实目录）\n        if dest.exists() || Self::is_symlink(&dest) {\n            Self::remove_path(&dest)?;\n        }\n\n        let sync_method = Self::get_sync_method();\n\n        match sync_method {\n            SyncMethod::Auto => {\n                // 优先尝试 symlink\n                match Self::create_symlink(&source, &dest) {\n                    Ok(()) => {\n                        log::debug!(\"Skill {directory} 已通过 symlink 同步到 {app:?}\");\n                        return Ok(());\n                    }\n                    Err(err) => {\n                        log::warn!(\n                            \"Symlink 创建失败，将回退到文件复制: {} -> {}. 错误: {err:#}\",\n                            source.display(),\n                            dest.display()\n                        );\n                    }\n                }\n                // Fallback 到 copy\n                Self::copy_dir_recursive(&source, &dest)?;\n                log::debug!(\"Skill {directory} 已通过复制同步到 {app:?}\");\n            }\n            SyncMethod::Symlink => {\n                Self::create_symlink(&source, &dest)?;\n                log::debug!(\"Skill {directory} 已通过 symlink 同步到 {app:?}\");\n            }\n            SyncMethod::Copy => {\n                Self::copy_dir_recursive(&source, &dest)?;\n                log::debug!(\"Skill {directory} 已通过复制同步到 {app:?}\");\n            }\n        }\n\n        Ok(())\n    }\n\n    /// 复制 Skill 到应用目录（保留用于向后兼容）\n    #[deprecated(note = \"请使用 sync_to_app_dir() 代替\")]\n    pub fn copy_to_app(directory: &str, app: &AppType) -> Result<()> {\n        Self::sync_to_app_dir(directory, app)\n    }\n\n    /// 删除路径（支持 symlink 和真实目录）\n    fn remove_path(path: &Path) -> Result<()> {\n        if Self::is_symlink(path) {\n            // 符号链接：仅删除链接本身，不影响源文件\n            #[cfg(unix)]\n            fs::remove_file(path)?;\n            #[cfg(windows)]\n            fs::remove_dir(path)?; // Windows 的目录 symlink 需要用 remove_dir\n        } else if path.is_dir() {\n            // 真实目录：递归删除\n            fs::remove_dir_all(path)?;\n        } else if path.exists() {\n            // 普通文件\n            fs::remove_file(path)?;\n        }\n        Ok(())\n    }\n\n    /// 判断路径是否为指向 SSOT 目录内的符号链接。\n    fn is_symlink_to_ssot(path: &Path, ssot_dir: &Path) -> bool {\n        if !Self::is_symlink(path) {\n            return false;\n        }\n\n        let Ok(target) = fs::read_link(path) else {\n            return false;\n        };\n\n        if target.is_absolute() && target.starts_with(ssot_dir) {\n            return true;\n        }\n\n        let resolved = path\n            .parent()\n            .map(|parent| parent.join(&target))\n            .unwrap_or(target.clone());\n\n        let canonical_ssot = ssot_dir\n            .canonicalize()\n            .unwrap_or_else(|_| ssot_dir.to_path_buf());\n        let canonical_target = resolved.canonicalize().unwrap_or(resolved);\n\n        canonical_target.starts_with(&canonical_ssot)\n    }\n\n    /// 从应用目录删除 Skill（支持 symlink 和真实目录）\n    pub fn remove_from_app(directory: &str, app: &AppType) -> Result<()> {\n        let app_dir = Self::get_app_skills_dir(app)?;\n        let skill_path = app_dir.join(directory);\n\n        if skill_path.exists() || Self::is_symlink(&skill_path) {\n            Self::remove_path(&skill_path)?;\n            log::debug!(\"Skill {directory} 已从 {app:?} 删除\");\n        }\n\n        Ok(())\n    }\n\n    /// 同步所有已启用的 Skills 到指定应用\n    pub fn sync_to_app(db: &Arc<Database>, app: &AppType) -> Result<()> {\n        let skills = db.get_all_installed_skills()?;\n        let ssot_dir = Self::get_ssot_dir()?;\n        let app_dir = Self::get_app_skills_dir(app)?;\n\n        let indexed_skills: HashMap<String, &InstalledSkill> = skills\n            .values()\n            .map(|skill| (skill.directory.to_lowercase(), skill))\n            .collect();\n\n        if app_dir.exists() {\n            for entry in fs::read_dir(&app_dir)? {\n                let entry = entry?;\n                let path = entry.path();\n                let dir_name = entry.file_name().to_string_lossy().to_string();\n\n                if dir_name.starts_with('.') {\n                    continue;\n                }\n\n                if let Some(skill) = indexed_skills.get(&dir_name.to_lowercase()) {\n                    if !skill.apps.is_enabled_for(app) {\n                        Self::remove_path(&path)?;\n                    }\n                    continue;\n                }\n\n                if Self::is_symlink_to_ssot(&path, &ssot_dir) {\n                    Self::remove_path(&path)?;\n                }\n            }\n        }\n\n        for skill in skills.values() {\n            if skill.apps.is_enabled_for(app) {\n                Self::sync_to_app_dir(&skill.directory, app)?;\n            }\n        }\n\n        Ok(())\n    }\n\n    // ========== 发现功能（保留原有逻辑）==========\n\n    /// 列出所有可发现的技能（从仓库获取）\n    pub async fn discover_available(\n        &self,\n        repos: Vec<SkillRepo>,\n    ) -> Result<Vec<DiscoverableSkill>> {\n        let mut skills = Vec::new();\n\n        // 仅使用启用的仓库\n        let enabled_repos: Vec<SkillRepo> = repos.into_iter().filter(|repo| repo.enabled).collect();\n\n        let fetch_tasks = enabled_repos\n            .iter()\n            .map(|repo| self.fetch_repo_skills(repo));\n\n        let results: Vec<Result<Vec<DiscoverableSkill>>> =\n            futures::future::join_all(fetch_tasks).await;\n\n        for (repo, result) in enabled_repos.into_iter().zip(results.into_iter()) {\n            match result {\n                Ok(repo_skills) => skills.extend(repo_skills),\n                Err(e) => log::warn!(\"获取仓库 {}/{} 技能失败: {}\", repo.owner, repo.name, e),\n            }\n        }\n\n        // 去重并排序\n        Self::deduplicate_discoverable_skills(&mut skills);\n        skills.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));\n\n        Ok(skills)\n    }\n\n    /// 列出所有技能（兼容旧 API）\n    pub async fn list_skills(\n        &self,\n        repos: Vec<SkillRepo>,\n        db: &Arc<Database>,\n    ) -> Result<Vec<Skill>> {\n        // 获取可发现的技能\n        let discoverable = self.discover_available(repos).await?;\n\n        // 获取已安装的技能\n        let installed = db.get_all_installed_skills()?;\n        let installed_dirs: HashSet<String> =\n            installed.values().map(|s| s.directory.clone()).collect();\n\n        // 转换为 Skill 格式\n        let mut skills: Vec<Skill> = discoverable\n            .into_iter()\n            .map(|d| {\n                let install_name = Path::new(&d.directory)\n                    .file_name()\n                    .map(|s| s.to_string_lossy().to_string())\n                    .unwrap_or_else(|| d.directory.clone());\n\n                Skill {\n                    key: d.key,\n                    name: d.name,\n                    description: d.description,\n                    directory: d.directory,\n                    readme_url: d.readme_url,\n                    installed: installed_dirs.contains(&install_name),\n                    repo_owner: Some(d.repo_owner),\n                    repo_name: Some(d.repo_name),\n                    repo_branch: Some(d.repo_branch),\n                }\n            })\n            .collect();\n\n        // 添加本地已安装但不在仓库中的技能\n        for skill in installed.values() {\n            let already_in_list = skills.iter().any(|s| {\n                let s_install_name = Path::new(&s.directory)\n                    .file_name()\n                    .map(|n| n.to_string_lossy().to_string())\n                    .unwrap_or_else(|| s.directory.clone());\n                s_install_name == skill.directory\n            });\n\n            if !already_in_list {\n                skills.push(Skill {\n                    key: skill.id.clone(),\n                    name: skill.name.clone(),\n                    description: skill.description.clone().unwrap_or_default(),\n                    directory: skill.directory.clone(),\n                    readme_url: skill.readme_url.clone(),\n                    installed: true,\n                    repo_owner: skill.repo_owner.clone(),\n                    repo_name: skill.repo_name.clone(),\n                    repo_branch: skill.repo_branch.clone(),\n                });\n            }\n        }\n\n        skills.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));\n\n        Ok(skills)\n    }\n\n    /// 从仓库获取技能列表\n    async fn fetch_repo_skills(&self, repo: &SkillRepo) -> Result<Vec<DiscoverableSkill>> {\n        let (temp_dir, resolved_branch) =\n            timeout(std::time::Duration::from_secs(60), self.download_repo(repo))\n                .await\n                .map_err(|_| {\n                    anyhow!(format_skill_error(\n                        \"DOWNLOAD_TIMEOUT\",\n                        &[\n                            (\"owner\", &repo.owner),\n                            (\"name\", &repo.name),\n                            (\"timeout\", \"60\")\n                        ],\n                        Some(\"checkNetwork\"),\n                    ))\n                })??;\n\n        let mut skills = Vec::new();\n        let scan_dir = temp_dir.clone();\n        let mut resolved_repo = repo.clone();\n        resolved_repo.branch = resolved_branch;\n        self.scan_dir_recursive(&scan_dir, &scan_dir, &resolved_repo, &mut skills)?;\n\n        let _ = fs::remove_dir_all(&temp_dir);\n\n        Ok(skills)\n    }\n\n    /// 递归扫描目录查找 SKILL.md\n    fn scan_dir_recursive(\n        &self,\n        current_dir: &Path,\n        base_dir: &Path,\n        repo: &SkillRepo,\n        skills: &mut Vec<DiscoverableSkill>,\n    ) -> Result<()> {\n        let skill_md = current_dir.join(\"SKILL.md\");\n\n        if skill_md.exists() {\n            let directory = if current_dir == base_dir {\n                repo.name.clone()\n            } else {\n                current_dir\n                    .strip_prefix(base_dir)\n                    .unwrap_or(current_dir)\n                    .to_string_lossy()\n                    .to_string()\n            };\n\n            let doc_path = skill_md\n                .strip_prefix(base_dir)\n                .unwrap_or(skill_md.as_path())\n                .to_string_lossy()\n                .replace('\\\\', \"/\");\n\n            if let Ok(skill) =\n                self.build_skill_from_metadata(&skill_md, &directory, &doc_path, repo)\n            {\n                skills.push(skill);\n            }\n\n            return Ok(());\n        }\n\n        for entry in fs::read_dir(current_dir)? {\n            let entry = entry?;\n            let path = entry.path();\n\n            if path.is_dir() {\n                self.scan_dir_recursive(&path, base_dir, repo, skills)?;\n            }\n        }\n\n        Ok(())\n    }\n\n    /// 从 SKILL.md 构建技能对象\n    fn build_skill_from_metadata(\n        &self,\n        skill_md: &Path,\n        directory: &str,\n        doc_path: &str,\n        repo: &SkillRepo,\n    ) -> Result<DiscoverableSkill> {\n        let meta = self.parse_skill_metadata(skill_md)?;\n\n        Ok(DiscoverableSkill {\n            key: format!(\"{}/{}:{}\", repo.owner, repo.name, directory),\n            name: meta.name.unwrap_or_else(|| directory.to_string()),\n            description: meta.description.unwrap_or_default(),\n            directory: directory.to_string(),\n            readme_url: Some(Self::build_skill_doc_url(\n                &repo.owner,\n                &repo.name,\n                &repo.branch,\n                doc_path,\n            )),\n            repo_owner: repo.owner.clone(),\n            repo_name: repo.name.clone(),\n            repo_branch: repo.branch.clone(),\n        })\n    }\n\n    /// 解析技能元数据\n    fn parse_skill_metadata(&self, path: &Path) -> Result<SkillMetadata> {\n        Self::parse_skill_metadata_static(path)\n    }\n\n    /// 静态方法：解析技能元数据\n    fn parse_skill_metadata_static(path: &Path) -> Result<SkillMetadata> {\n        let content = fs::read_to_string(path)?;\n        let content = content.trim_start_matches('\\u{feff}');\n\n        let parts: Vec<&str> = content.splitn(3, \"---\").collect();\n        if parts.len() < 3 {\n            return Ok(SkillMetadata {\n                name: None,\n                description: None,\n            });\n        }\n\n        let front_matter = parts[1].trim();\n        let meta: SkillMetadata = serde_yaml::from_str(front_matter).unwrap_or(SkillMetadata {\n            name: None,\n            description: None,\n        });\n\n        Ok(meta)\n    }\n\n    /// 从 SKILL.md 读取名称和描述，不存在则用目录名兜底\n    fn read_skill_name_desc(skill_md: &Path, fallback_name: &str) -> (String, Option<String>) {\n        if skill_md.exists() {\n            match Self::parse_skill_metadata_static(skill_md) {\n                Ok(meta) => (\n                    meta.name.unwrap_or_else(|| fallback_name.to_string()),\n                    meta.description,\n                ),\n                Err(_) => (fallback_name.to_string(), None),\n            }\n        } else {\n            (fallback_name.to_string(), None)\n        }\n    }\n\n    /// 校验并规范化技能源路径（允许多级目录），拒绝路径穿越和绝对路径\n    fn sanitize_skill_source_path(raw: &str) -> Option<PathBuf> {\n        let trimmed = raw.trim();\n        if trimmed.is_empty() {\n            return None;\n        }\n\n        let mut normalized = PathBuf::new();\n        let mut has_component = false;\n\n        for component in Path::new(trimmed).components() {\n            match component {\n                Component::Normal(name) => {\n                    let segment = name.to_string_lossy().trim().to_string();\n                    if segment.is_empty() || segment == \".\" || segment == \"..\" {\n                        return None;\n                    }\n                    normalized.push(segment);\n                    has_component = true;\n                }\n                Component::CurDir\n                | Component::ParentDir\n                | Component::RootDir\n                | Component::Prefix(_) => {\n                    return None;\n                }\n            }\n        }\n\n        has_component.then_some(normalized)\n    }\n\n    /// 校验并规范化安装目录名（最终落盘目录名，仅单段）\n    fn sanitize_install_name(raw: &str) -> Option<String> {\n        let trimmed = raw.trim();\n        if trimmed.is_empty() {\n            return None;\n        }\n\n        let path = Path::new(trimmed);\n        let mut components = path.components();\n        match (components.next(), components.next()) {\n            (Some(Component::Normal(name)), None) => {\n                let normalized = name.to_string_lossy().trim().to_string();\n                if normalized.is_empty()\n                    || normalized == \".\"\n                    || normalized == \"..\"\n                    || normalized.starts_with('.')\n                {\n                    None\n                } else {\n                    Some(normalized)\n                }\n            }\n            _ => None,\n        }\n    }\n\n    /// 去重技能列表（基于完整 key，不同仓库的同名 skill 分开显示）\n    fn deduplicate_discoverable_skills(skills: &mut Vec<DiscoverableSkill>) {\n        let mut seen = HashMap::new();\n        skills.retain(|skill| {\n            // 使用完整 key（owner/repo:directory）作为唯一标识\n            // 这样不同仓库的同名 skill 会分开显示\n            let unique_key = skill.key.to_lowercase();\n            if let std::collections::hash_map::Entry::Vacant(e) = seen.entry(unique_key) {\n                e.insert(true);\n                true\n            } else {\n                false\n            }\n        });\n    }\n\n    /// 下载仓库\n    async fn download_repo(&self, repo: &SkillRepo) -> Result<(PathBuf, String)> {\n        let temp_dir = tempfile::tempdir()?;\n        let temp_path = temp_dir.path().to_path_buf();\n        let _ = temp_dir.keep();\n\n        let mut branches = Vec::new();\n        if !repo.branch.is_empty() && !repo.branch.eq_ignore_ascii_case(\"HEAD\") {\n            branches.push(repo.branch.as_str());\n        }\n        if !branches.contains(&\"main\") {\n            branches.push(\"main\");\n        }\n        if !branches.contains(&\"master\") {\n            branches.push(\"master\");\n        }\n\n        let mut last_error = None;\n        for branch in branches {\n            let url = format!(\n                \"https://github.com/{}/{}/archive/refs/heads/{}.zip\",\n                repo.owner, repo.name, branch\n            );\n\n            match self.download_and_extract(&url, &temp_path).await {\n                Ok(_) => {\n                    return Ok((temp_path, branch.to_string()));\n                }\n                Err(e) => {\n                    last_error = Some(e);\n                    continue;\n                }\n            }\n        }\n\n        Err(last_error.unwrap_or_else(|| anyhow::anyhow!(\"所有分支下载失败\")))\n    }\n\n    /// 下载并解压 ZIP\n    async fn download_and_extract(&self, url: &str, dest: &Path) -> Result<()> {\n        let client = crate::proxy::http_client::get();\n        let response = client.get(url).send().await?;\n        if !response.status().is_success() {\n            let status = response.status().as_u16().to_string();\n            return Err(anyhow::anyhow!(format_skill_error(\n                \"DOWNLOAD_FAILED\",\n                &[(\"status\", &status)],\n                match status.as_str() {\n                    \"403\" => Some(\"http403\"),\n                    \"404\" => Some(\"http404\"),\n                    \"429\" => Some(\"http429\"),\n                    _ => Some(\"checkNetwork\"),\n                },\n            )));\n        }\n\n        let bytes = response.bytes().await?;\n        let cursor = std::io::Cursor::new(bytes);\n        let mut archive = zip::ZipArchive::new(cursor)?;\n\n        let root_name = if !archive.is_empty() {\n            let first_file = archive.by_index(0)?;\n            let name = first_file.name();\n            name.split('/').next().unwrap_or(\"\").to_string()\n        } else {\n            return Err(anyhow::anyhow!(format_skill_error(\n                \"EMPTY_ARCHIVE\",\n                &[],\n                Some(\"checkRepoUrl\"),\n            )));\n        };\n\n        // 第一遍：解压普通文件和目录，收集 symlink 条目\n        let mut symlinks: Vec<(PathBuf, String)> = Vec::new();\n\n        for i in 0..archive.len() {\n            let mut file = archive.by_index(i)?;\n            let file_path = file.name().to_string();\n\n            let relative_path =\n                if let Some(stripped) = file_path.strip_prefix(&format!(\"{root_name}/\")) {\n                    stripped\n                } else {\n                    continue;\n                };\n\n            if relative_path.is_empty() {\n                continue;\n            }\n\n            let outpath = dest.join(relative_path);\n\n            if file.is_symlink() {\n                // 读取 symlink 目标路径\n                let mut target = String::new();\n                std::io::Read::read_to_string(&mut file, &mut target)?;\n                symlinks.push((outpath, target.trim().to_string()));\n            } else if file.is_dir() {\n                fs::create_dir_all(&outpath)?;\n            } else {\n                if let Some(parent) = outpath.parent() {\n                    fs::create_dir_all(parent)?;\n                }\n                let mut outfile = fs::File::create(&outpath)?;\n                std::io::copy(&mut file, &mut outfile)?;\n            }\n        }\n\n        // 第二遍：解析 symlink，将目标内容复制到 symlink 位置\n        Self::resolve_symlinks_in_dir(dest, &symlinks)?;\n\n        Ok(())\n    }\n\n    /// 递归复制目录\n    fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<()> {\n        fs::create_dir_all(dest)?;\n\n        for entry in fs::read_dir(src)? {\n            let entry = entry?;\n            let path = entry.path();\n            let dest_path = dest.join(entry.file_name());\n\n            if path.is_dir() {\n                Self::copy_dir_recursive(&path, &dest_path)?;\n            } else {\n                fs::copy(&path, &dest_path)?;\n            }\n        }\n\n        Ok(())\n    }\n\n    fn resolve_uninstall_backup_source(skill: &InstalledSkill) -> Result<Option<PathBuf>> {\n        let ssot_path = Self::get_ssot_dir()?.join(&skill.directory);\n        if ssot_path.is_dir() {\n            return Ok(Some(ssot_path));\n        }\n\n        for app in AppType::all() {\n            let app_dir = match Self::get_app_skills_dir(&app) {\n                Ok(dir) => dir,\n                Err(_) => continue,\n            };\n            let candidate = app_dir.join(&skill.directory);\n            if candidate.is_dir() {\n                return Ok(Some(candidate));\n            }\n        }\n\n        Ok(None)\n    }\n\n    fn sanitize_backup_segment(segment: &str) -> String {\n        let sanitized = segment\n            .chars()\n            .map(|c| match c {\n                'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => c,\n                _ => '-',\n            })\n            .collect::<String>()\n            .trim_matches('-')\n            .to_string();\n\n        if sanitized.is_empty() {\n            \"skill\".to_string()\n        } else {\n            sanitized\n        }\n    }\n\n    fn cleanup_old_skill_backups(dir: &Path) -> Result<()> {\n        let mut entries = fs::read_dir(dir)?\n            .filter_map(|entry| entry.ok())\n            .filter_map(|entry| {\n                let metadata = entry.metadata().ok()?;\n                if !metadata.is_dir() {\n                    return None;\n                }\n                Some((entry.path(), metadata.modified().ok()))\n            })\n            .collect::<Vec<_>>();\n\n        if entries.len() <= SKILL_BACKUP_RETAIN_COUNT {\n            return Ok(());\n        }\n\n        entries.sort_by_key(|(_, modified)| *modified);\n        let remove_count = entries.len().saturating_sub(SKILL_BACKUP_RETAIN_COUNT);\n\n        for (path, _) in entries.into_iter().take(remove_count) {\n            fs::remove_dir_all(&path)?;\n        }\n\n        Ok(())\n    }\n\n    fn backup_path_for_id(backup_id: &str) -> Result<PathBuf> {\n        if backup_id.contains(\"..\")\n            || backup_id.contains('/')\n            || backup_id.contains('\\\\')\n            || backup_id.trim().is_empty()\n        {\n            return Err(anyhow!(\"Invalid backup id: {backup_id}\"));\n        }\n\n        Ok(Self::get_backup_dir()?.join(backup_id))\n    }\n\n    fn read_backup_metadata(backup_path: &Path) -> Result<SkillBackupMetadata> {\n        let metadata_path = backup_path.join(\"meta.json\");\n        let content = fs::read_to_string(&metadata_path)\n            .with_context(|| format!(\"failed to read {}\", metadata_path.display()))?;\n        serde_json::from_str(&content)\n            .with_context(|| format!(\"failed to parse {}\", metadata_path.display()))\n    }\n\n    fn create_uninstall_backup(skill: &InstalledSkill) -> Result<Option<PathBuf>> {\n        let Some(source_path) = Self::resolve_uninstall_backup_source(skill)? else {\n            log::warn!(\n                \"Skill {} 卸载前未找到可备份的目录，将跳过备份\",\n                skill.directory\n            );\n            return Ok(None);\n        };\n\n        let backup_root = Self::get_backup_dir()?;\n        let timestamp = Utc::now().format(\"%Y%m%d_%H%M%S\");\n        let slug = Self::sanitize_backup_segment(&skill.directory);\n        let mut backup_path = backup_root.join(format!(\"{timestamp}_{slug}\"));\n        let mut counter = 1;\n        while backup_path.exists() {\n            backup_path = backup_root.join(format!(\"{timestamp}_{slug}_{counter}\"));\n            counter += 1;\n        }\n\n        let write_backup = || -> Result<()> {\n            let skill_backup_dir = backup_path.join(\"skill\");\n            Self::copy_dir_recursive(&source_path, &skill_backup_dir)?;\n\n            let metadata = SkillBackupMetadata {\n                skill: skill.clone(),\n                backup_created_at: Utc::now().timestamp(),\n                source_path: source_path.to_string_lossy().to_string(),\n            };\n            let metadata_path = backup_path.join(\"meta.json\");\n            let metadata_json = serde_json::to_string_pretty(&metadata)\n                .context(\"failed to serialize skill backup metadata\")?;\n            fs::write(&metadata_path, metadata_json)\n                .with_context(|| format!(\"failed to write {}\", metadata_path.display()))?;\n            Ok(())\n        };\n\n        if let Err(err) = write_backup() {\n            let _ = fs::remove_dir_all(&backup_path);\n            return Err(err);\n        }\n\n        if let Err(err) = Self::cleanup_old_skill_backups(&backup_root) {\n            log::warn!(\"清理旧 Skill 备份失败: {err:#}\");\n        }\n\n        log::info!(\n            \"Skill {} 已在卸载前备份到 {}\",\n            skill.name,\n            backup_path.display()\n        );\n\n        Ok(Some(backup_path))\n    }\n\n    /// 解析 ZIP 中的符号链接：将目标内容复制到 symlink 位置\n    ///\n    /// GitHub ZIP 归档保留了 symlink 元数据，解压时可通过 `is_symlink()` 检测。\n    /// 此方法将 symlink 解析为实际文件/目录内容（而非创建真实 symlink），\n    /// 以确保跨平台兼容且 skill 内容自包含。\n    fn resolve_symlinks_in_dir(base_dir: &Path, symlinks: &[(PathBuf, String)]) -> Result<()> {\n        // 规范化 base_dir（macOS 上 /tmp → /private/tmp，需保持一致）\n        let canonical_base = base_dir\n            .canonicalize()\n            .unwrap_or_else(|_| base_dir.to_path_buf());\n\n        for (link_path, target) in symlinks {\n            // 计算 symlink 的父目录，然后拼接目标的相对路径\n            let parent = link_path.parent().unwrap_or(base_dir);\n            let resolved = parent.join(target);\n\n            // 规范化路径（解析 .. 等）\n            let resolved = match resolved.canonicalize() {\n                Ok(p) => p,\n                Err(_) => {\n                    log::warn!(\n                        \"Symlink 目标不存在，跳过: {} -> {}\",\n                        link_path.display(),\n                        target\n                    );\n                    continue;\n                }\n            };\n\n            // 安全检查：确保目标在 base_dir 内（防止路径穿越）\n            if !resolved.starts_with(&canonical_base) {\n                log::warn!(\n                    \"Symlink 目标超出仓库范围，跳过: {} -> {}\",\n                    link_path.display(),\n                    resolved.display()\n                );\n                continue;\n            }\n\n            // 复制目标内容到 symlink 位置\n            if resolved.is_dir() {\n                Self::copy_dir_recursive(&resolved, link_path)?;\n            } else if resolved.is_file() {\n                if let Some(parent) = link_path.parent() {\n                    fs::create_dir_all(parent)?;\n                }\n                fs::copy(&resolved, link_path)?;\n            }\n        }\n        Ok(())\n    }\n\n    // ========== 从 ZIP 文件安装 ==========\n\n    /// 从本地 ZIP 文件安装 Skills\n    ///\n    /// 流程：\n    /// 1. 解压 ZIP 到临时目录\n    /// 2. 扫描目录查找包含 SKILL.md 的技能\n    /// 3. 复制到 SSOT 并保存到数据库\n    /// 4. 同步到当前应用目录\n    pub fn install_from_zip(\n        db: &Arc<Database>,\n        zip_path: &Path,\n        current_app: &AppType,\n    ) -> Result<Vec<InstalledSkill>> {\n        // 解压到临时目录\n        let temp_dir = Self::extract_local_zip(zip_path)?;\n\n        // 扫描所有包含 SKILL.md 的目录\n        let skill_dirs = Self::scan_skills_in_dir(&temp_dir)?;\n\n        if skill_dirs.is_empty() {\n            let _ = fs::remove_dir_all(&temp_dir);\n            return Err(anyhow!(format_skill_error(\n                \"NO_SKILLS_IN_ZIP\",\n                &[],\n                Some(\"checkZipContent\"),\n            )));\n        }\n\n        let ssot_dir = Self::get_ssot_dir()?;\n        let mut installed = Vec::new();\n        let existing_skills = db.get_all_installed_skills()?;\n        let zip_stem = zip_path\n            .file_stem()\n            .and_then(|s| s.to_str())\n            .map(|s| s.to_string());\n\n        for skill_dir in skill_dirs {\n            // 解析元数据（提前解析，用于确定安装名）\n            let skill_md = skill_dir.join(\"SKILL.md\");\n            let meta = if skill_md.exists() {\n                Self::parse_skill_metadata_static(&skill_md).ok()\n            } else {\n                None\n            };\n\n            // 获取目录名称作为安装名\n            // 当 SKILL.md 在 ZIP 根目录时，skill_dir == temp_dir，\n            // file_name() 会返回临时目录名（如 .tmpDZKGpF），需要回退到其他来源\n            let install_name = {\n                let dir_name = skill_dir\n                    .file_name()\n                    .map(|s| s.to_string_lossy().to_string())\n                    .unwrap_or_default();\n\n                if skill_dir == temp_dir || dir_name.is_empty() || dir_name.starts_with('.') {\n                    // SKILL.md 在根目录：优先用元数据 name，否则用 ZIP 文件名\n                    meta.as_ref()\n                        .and_then(|m| m.name.as_deref())\n                        .and_then(Self::sanitize_install_name)\n                        .or_else(|| zip_stem.as_deref().and_then(Self::sanitize_install_name))\n                } else {\n                    Self::sanitize_install_name(&dir_name)\n                        .or_else(|| {\n                            meta.as_ref()\n                                .and_then(|m| m.name.as_deref())\n                                .and_then(Self::sanitize_install_name)\n                        })\n                        .or_else(|| zip_stem.as_deref().and_then(Self::sanitize_install_name))\n                }\n            };\n            let install_name = match install_name {\n                Some(name) => name,\n                None => {\n                    let _ = fs::remove_dir_all(&temp_dir);\n                    return Err(anyhow!(format_skill_error(\n                        \"INVALID_SKILL_DIRECTORY\",\n                        &[(\"zip\", &zip_path.display().to_string())],\n                        Some(\"checkZipContent\"),\n                    )));\n                }\n            };\n\n            // 检查是否已有同名 directory 的 skill\n            let conflict = existing_skills\n                .values()\n                .find(|s| s.directory.eq_ignore_ascii_case(&install_name));\n\n            if let Some(existing) = conflict {\n                log::warn!(\n                    \"Skill directory '{}' already exists (from {}), skipping\",\n                    install_name,\n                    existing.id\n                );\n                continue;\n            }\n\n            let (name, description) = match meta {\n                Some(m) => (\n                    m.name.unwrap_or_else(|| install_name.clone()),\n                    m.description,\n                ),\n                None => (install_name.clone(), None),\n            };\n\n            // 复制到 SSOT\n            let dest = ssot_dir.join(&install_name);\n            if dest.exists() {\n                let _ = fs::remove_dir_all(&dest);\n            }\n            Self::copy_dir_recursive(&skill_dir, &dest)?;\n\n            // 创建 InstalledSkill 记录\n            let skill = InstalledSkill {\n                id: format!(\"local:{install_name}\"),\n                name,\n                description,\n                directory: install_name.clone(),\n                repo_owner: None,\n                repo_name: None,\n                repo_branch: None,\n                readme_url: None,\n                apps: SkillApps::only(current_app),\n                installed_at: chrono::Utc::now().timestamp(),\n            };\n\n            // 保存到数据库\n            db.save_skill(&skill)?;\n\n            // 同步到当前应用目录\n            Self::sync_to_app_dir(&install_name, current_app)?;\n\n            log::info!(\n                \"Skill {} installed from ZIP, enabled for {:?}\",\n                skill.name,\n                current_app\n            );\n            installed.push(skill);\n        }\n\n        // 清理临时目录\n        let _ = fs::remove_dir_all(&temp_dir);\n\n        Ok(installed)\n    }\n\n    /// 解压本地 ZIP 文件到临时目录\n    fn extract_local_zip(zip_path: &Path) -> Result<PathBuf> {\n        let file = fs::File::open(zip_path)\n            .with_context(|| format!(\"Failed to open ZIP file: {}\", zip_path.display()))?;\n\n        let mut archive = zip::ZipArchive::new(file)\n            .with_context(|| format!(\"Failed to read ZIP file: {}\", zip_path.display()))?;\n\n        if archive.is_empty() {\n            return Err(anyhow!(format_skill_error(\n                \"EMPTY_ARCHIVE\",\n                &[],\n                Some(\"checkZipContent\"),\n            )));\n        }\n\n        let temp_dir = tempfile::tempdir()?;\n        let temp_path = temp_dir.path().to_path_buf();\n        let _ = temp_dir.keep(); // Keep the directory, we'll clean up later\n\n        let mut symlinks: Vec<(PathBuf, String)> = Vec::new();\n\n        for i in 0..archive.len() {\n            let mut file = archive.by_index(i)?;\n            let file_path = match file.enclosed_name() {\n                Some(path) => path.to_owned(),\n                None => continue,\n            };\n\n            let outpath = temp_path.join(&file_path);\n\n            if file.is_symlink() {\n                let mut target = String::new();\n                std::io::Read::read_to_string(&mut file, &mut target)?;\n                symlinks.push((outpath, target.trim().to_string()));\n            } else if file.is_dir() {\n                fs::create_dir_all(&outpath)?;\n            } else {\n                if let Some(parent) = outpath.parent() {\n                    fs::create_dir_all(parent)?;\n                }\n                let mut outfile = fs::File::create(&outpath)?;\n                std::io::copy(&mut file, &mut outfile)?;\n            }\n        }\n\n        // 解析 symlink\n        Self::resolve_symlinks_in_dir(&temp_path, &symlinks)?;\n\n        Ok(temp_path)\n    }\n\n    /// 递归扫描目录查找包含 SKILL.md 的技能目录\n    fn scan_skills_in_dir(dir: &Path) -> Result<Vec<PathBuf>> {\n        let mut skill_dirs = Vec::new();\n        Self::scan_skills_recursive(dir, &mut skill_dirs)?;\n        Ok(skill_dirs)\n    }\n\n    /// 递归扫描辅助函数\n    fn scan_skills_recursive(current: &Path, results: &mut Vec<PathBuf>) -> Result<()> {\n        // 检查当前目录是否包含 SKILL.md\n        let skill_md = current.join(\"SKILL.md\");\n        if skill_md.exists() {\n            results.push(current.to_path_buf());\n            // 找到后不再递归子目录（一个 skill 目录）\n            return Ok(());\n        }\n\n        // 递归子目录\n        if let Ok(entries) = fs::read_dir(current) {\n            for entry in entries.flatten() {\n                let path = entry.path();\n                if path.is_dir() {\n                    // 跳过隐藏目录\n                    let dir_name = entry.file_name().to_string_lossy().to_string();\n                    if dir_name.starts_with('.') {\n                        continue;\n                    }\n                    Self::scan_skills_recursive(&path, results)?;\n                }\n            }\n        }\n\n        Ok(())\n    }\n\n    // ========== 仓库管理（保留原有逻辑）==========\n\n    /// 列出仓库\n    pub fn list_repos(&self, store: &SkillStore) -> Vec<SkillRepo> {\n        store.repos.clone()\n    }\n\n    /// 添加仓库\n    pub fn add_repo(&self, store: &mut SkillStore, repo: SkillRepo) -> Result<()> {\n        if let Some(pos) = store\n            .repos\n            .iter()\n            .position(|r| r.owner == repo.owner && r.name == repo.name)\n        {\n            store.repos[pos] = repo;\n        } else {\n            store.repos.push(repo);\n        }\n\n        Ok(())\n    }\n\n    /// 删除仓库\n    pub fn remove_repo(&self, store: &mut SkillStore, owner: String, name: String) -> Result<()> {\n        store\n            .repos\n            .retain(|r| !(r.owner == owner && r.name == name));\n\n        Ok(())\n    }\n}\n\n// ========== 迁移支持 ==========\n\n/// 从 lock 文件信息构建 skill 的 ID、仓库字段和 readme URL\n///\n/// 返回 (id, repo_owner, repo_name, repo_branch, readme_url)\nfn build_repo_info_from_lock(\n    lock: &HashMap<String, LockRepoInfo>,\n    dir_name: &str,\n) -> (\n    String,\n    Option<String>,\n    Option<String>,\n    Option<String>,\n    Option<String>,\n) {\n    match lock.get(dir_name) {\n        Some(info) => {\n            let branch = info.branch.clone();\n            let url_branch = branch.clone().unwrap_or_else(|| \"HEAD\".to_string());\n            // 优先使用 lock 文件中的 skillPath，否则回退到 dir_name/SKILL.md\n            let fallback = format!(\"{dir_name}/SKILL.md\");\n            let doc_path = info.skill_path.as_deref().unwrap_or(&fallback);\n            let url = Some(SkillService::build_skill_doc_url(\n                &info.owner,\n                &info.repo,\n                &url_branch,\n                doc_path,\n            ));\n            (\n                format!(\"{}/{}:{dir_name}\", info.owner, info.repo),\n                Some(info.owner.clone()),\n                Some(info.repo.clone()),\n                branch,\n                url,\n            )\n        }\n        None => (format!(\"local:{dir_name}\"), None, None, None, None),\n    }\n}\n\n/// 将 lock 文件中发现的仓库保存到 skill_repos（去重）\nfn save_repos_from_lock(\n    db: &Arc<Database>,\n    lock: &HashMap<String, LockRepoInfo>,\n    directories: impl Iterator<Item = impl AsRef<str>>,\n) {\n    let existing_repos: HashSet<(String, String)> = db\n        .get_skill_repos()\n        .unwrap_or_default()\n        .into_iter()\n        .map(|r| (r.owner, r.name))\n        .collect();\n    let mut added = HashSet::new();\n\n    for dir_name in directories {\n        if let Some(info) = lock.get(dir_name.as_ref()) {\n            let key = (info.owner.clone(), info.repo.clone());\n            if !existing_repos.contains(&key) && added.insert(key) {\n                let skill_repo = SkillRepo {\n                    owner: info.owner.clone(),\n                    name: info.repo.clone(),\n                    // 未知分支时使用 HEAD 语义，后续下载会回退到 main/master。\n                    branch: info.branch.clone().unwrap_or_else(|| \"HEAD\".to_string()),\n                    enabled: true,\n                };\n                if let Err(e) = db.save_skill_repo(&skill_repo) {\n                    log::warn!(\"保存 skill 仓库 {}/{} 失败: {}\", info.owner, info.repo, e);\n                } else {\n                    log::info!(\n                        \"从 agents lock 文件发现并添加仓库: {}/{} ({})\",\n                        info.owner,\n                        info.repo,\n                        skill_repo.branch\n                    );\n                }\n            }\n        }\n    }\n}\n\n/// 首次启动迁移：扫描应用目录，重建数据库\npub fn migrate_skills_to_ssot(db: &Arc<Database>) -> Result<usize> {\n    let ssot_dir = SkillService::get_ssot_dir()?;\n    let agents_lock = parse_agents_lock();\n    let snapshot: Vec<LegacySkillMigrationRow> =\n        match db.get_setting(\"skills_ssot_migration_snapshot\")? {\n            Some(value) if !value.trim().is_empty() => match serde_json::from_str(&value) {\n                Ok(rows) => rows,\n                Err(err) => {\n                    log::warn!(\"解析 skills 迁移快照失败，将回退到文件系统扫描: {err}\");\n                    Vec::new()\n                }\n            },\n            _ => Vec::new(),\n        };\n\n    let has_snapshot = !snapshot.is_empty();\n    let mut discovered: HashMap<String, SkillApps> = HashMap::new();\n\n    if has_snapshot {\n        for row in &snapshot {\n            if let Ok(app) = row.app_type.parse::<AppType>() {\n                discovered\n                    .entry(row.directory.clone())\n                    .or_default()\n                    .set_enabled_for(&app, true);\n            }\n        }\n    }\n\n    // 扫描各应用目录\n    for app in AppType::all() {\n        let app_dir = match SkillService::get_app_skills_dir(&app) {\n            Ok(d) => d,\n            Err(_) => continue,\n        };\n\n        let entries = match fs::read_dir(&app_dir) {\n            Ok(e) => e,\n            Err(_) => continue,\n        };\n\n        for entry in entries.flatten() {\n            let path = entry.path();\n            if !path.is_dir() {\n                continue;\n            }\n\n            let dir_name = entry.file_name().to_string_lossy().to_string();\n            if dir_name.starts_with('.') {\n                continue;\n            }\n            if !path.join(\"SKILL.md\").exists() {\n                continue;\n            }\n            if has_snapshot && !discovered.contains_key(&dir_name) {\n                continue;\n            }\n\n            // 复制到 SSOT（如果不存在）\n            let ssot_path = ssot_dir.join(&dir_name);\n            if !ssot_path.exists() {\n                SkillService::copy_dir_recursive(&path, &ssot_path)?;\n            }\n\n            if !has_snapshot {\n                discovered\n                    .entry(dir_name)\n                    .or_default()\n                    .set_enabled_for(&app, true);\n            }\n        }\n    }\n\n    // 重建数据库\n    db.clear_skills()?;\n\n    // 将 lock 文件中发现的仓库保存到 skill_repos\n    save_repos_from_lock(db, &agents_lock, discovered.keys());\n\n    let mut count = 0;\n    for (directory, apps) in discovered {\n        let ssot_path = ssot_dir.join(&directory);\n        let skill_md = ssot_path.join(\"SKILL.md\");\n\n        let (name, description) = SkillService::read_skill_name_desc(&skill_md, &directory);\n\n        let (id, repo_owner, repo_name, repo_branch, readme_url) =\n            build_repo_info_from_lock(&agents_lock, &directory);\n\n        let skill = InstalledSkill {\n            id,\n            name,\n            description,\n            directory,\n            repo_owner,\n            repo_name,\n            repo_branch,\n            readme_url,\n            apps,\n            installed_at: chrono::Utc::now().timestamp(),\n        };\n\n        db.save_skill(&skill)?;\n        count += 1;\n    }\n\n    let _ = db.set_setting(\"skills_ssot_migration_snapshot\", \"\");\n\n    log::info!(\"Skills 迁移完成，共 {count} 个\");\n\n    Ok(count)\n}\n"
  },
  {
    "path": "src-tauri/src/services/speedtest.rs",
    "content": "use futures::future::join_all;\nuse reqwest::{Client, Url};\nuse serde::Serialize;\nuse std::time::Instant;\n\nuse crate::error::AppError;\n\nconst DEFAULT_TIMEOUT_SECS: u64 = 8;\nconst MAX_TIMEOUT_SECS: u64 = 30;\nconst MIN_TIMEOUT_SECS: u64 = 2;\n\n/// 端点测速结果\n#[derive(Debug, Clone, Serialize)]\npub struct EndpointLatency {\n    pub url: String,\n    pub latency: Option<u128>,\n    pub status: Option<u16>,\n    pub error: Option<String>,\n}\n\n/// 网络测速相关业务\npub struct SpeedtestService;\n\nimpl SpeedtestService {\n    /// 测试一组端点的响应延迟。\n    pub async fn test_endpoints(\n        urls: Vec<String>,\n        timeout_secs: Option<u64>,\n    ) -> Result<Vec<EndpointLatency>, AppError> {\n        if urls.is_empty() {\n            return Ok(vec![]);\n        }\n\n        let mut results: Vec<Option<EndpointLatency>> = vec![None; urls.len()];\n        let mut valid_targets = Vec::new();\n\n        for (idx, raw_url) in urls.into_iter().enumerate() {\n            let trimmed = raw_url.trim().to_string();\n\n            if trimmed.is_empty() {\n                results[idx] = Some(EndpointLatency {\n                    url: raw_url,\n                    latency: None,\n                    status: None,\n                    error: Some(\"URL 不能为空\".to_string()),\n                });\n                continue;\n            }\n\n            match Url::parse(&trimmed) {\n                Ok(parsed_url) => valid_targets.push((idx, trimmed, parsed_url)),\n                Err(err) => {\n                    results[idx] = Some(EndpointLatency {\n                        url: trimmed,\n                        latency: None,\n                        status: None,\n                        error: Some(format!(\"URL 无效: {err}\")),\n                    });\n                }\n            }\n        }\n\n        if valid_targets.is_empty() {\n            return Ok(results.into_iter().flatten().collect::<Vec<_>>());\n        }\n\n        let timeout = Self::sanitize_timeout(timeout_secs);\n        let (client, request_timeout) = Self::build_client(timeout)?;\n\n        let tasks = valid_targets.into_iter().map(|(idx, trimmed, parsed_url)| {\n            let client = client.clone();\n            async move {\n                // 先进行一次热身请求，忽略结果，仅用于复用连接/绕过首包惩罚。\n                let _ = client\n                    .get(parsed_url.clone())\n                    .timeout(request_timeout)\n                    .send()\n                    .await;\n\n                // 第二次请求开始计时，并将其作为结果返回。\n                let start = Instant::now();\n                let latency = match client.get(parsed_url).timeout(request_timeout).send().await {\n                    Ok(resp) => EndpointLatency {\n                        url: trimmed,\n                        latency: Some(start.elapsed().as_millis()),\n                        status: Some(resp.status().as_u16()),\n                        error: None,\n                    },\n                    Err(err) => {\n                        let status = err.status().map(|s| s.as_u16());\n                        let error_message = if err.is_timeout() {\n                            \"请求超时\".to_string()\n                        } else if err.is_connect() {\n                            \"连接失败\".to_string()\n                        } else {\n                            err.to_string()\n                        };\n\n                        EndpointLatency {\n                            url: trimmed,\n                            latency: None,\n                            status,\n                            error: Some(error_message),\n                        }\n                    }\n                };\n\n                (idx, latency)\n            }\n        });\n\n        for (idx, latency) in join_all(tasks).await {\n            results[idx] = Some(latency);\n        }\n\n        Ok(results.into_iter().flatten().collect::<Vec<_>>())\n    }\n\n    fn build_client(timeout_secs: u64) -> Result<(Client, std::time::Duration), AppError> {\n        // 使用全局 HTTP 客户端（已包含代理配置）\n        // 返回 timeout Duration 供请求级别使用\n        let timeout = std::time::Duration::from_secs(timeout_secs);\n        Ok((crate::proxy::http_client::get(), timeout))\n    }\n\n    fn sanitize_timeout(timeout_secs: Option<u64>) -> u64 {\n        let secs = timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS);\n        secs.clamp(MIN_TIMEOUT_SECS, MAX_TIMEOUT_SECS)\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn sanitize_timeout_clamps_values() {\n        assert_eq!(\n            SpeedtestService::sanitize_timeout(Some(1)),\n            MIN_TIMEOUT_SECS\n        );\n        assert_eq!(\n            SpeedtestService::sanitize_timeout(Some(999)),\n            MAX_TIMEOUT_SECS\n        );\n        assert_eq!(\n            SpeedtestService::sanitize_timeout(Some(10)),\n            10.clamp(MIN_TIMEOUT_SECS, MAX_TIMEOUT_SECS)\n        );\n        assert_eq!(\n            SpeedtestService::sanitize_timeout(None),\n            DEFAULT_TIMEOUT_SECS\n        );\n    }\n\n    #[test]\n    fn test_endpoints_handles_empty_list() {\n        let result =\n            tauri::async_runtime::block_on(SpeedtestService::test_endpoints(Vec::new(), Some(5)))\n                .expect(\"empty list should succeed\");\n        assert!(result.is_empty());\n    }\n\n    #[test]\n    fn test_endpoints_reports_invalid_url() {\n        let result = tauri::async_runtime::block_on(SpeedtestService::test_endpoints(\n            vec![\"not a url\".into(), \"\".into()],\n            None,\n        ))\n        .expect(\"invalid inputs should still succeed\");\n\n        assert_eq!(result.len(), 2);\n        assert!(\n            result[0]\n                .error\n                .as_deref()\n                .unwrap_or_default()\n                .starts_with(\"URL 无效\"),\n            \"invalid url should yield parse error\"\n        );\n        assert_eq!(\n            result[1].error.as_deref(),\n            Some(\"URL 不能为空\"),\n            \"empty url should report validation error\"\n        );\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/services/stream_check.rs",
    "content": "//! 流式健康检查服务\n//!\n//! 使用流式 API 进行快速健康检查，只需接收首个 chunk 即判定成功。\n\nuse futures::StreamExt;\nuse regex::Regex;\nuse reqwest::Client;\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\nuse std::time::Instant;\n\nuse crate::app_config::AppType;\nuse crate::error::AppError;\nuse crate::provider::Provider;\nuse crate::proxy::providers::transform::anthropic_to_openai;\nuse crate::proxy::providers::{get_adapter, AuthInfo, AuthStrategy};\n\n/// 健康状态枚举\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]\n#[serde(rename_all = \"lowercase\")]\npub enum HealthStatus {\n    Operational,\n    Degraded,\n    Failed,\n}\n\n/// 流式检查配置\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct StreamCheckConfig {\n    pub timeout_secs: u64,\n    pub max_retries: u32,\n    pub degraded_threshold_ms: u64,\n    /// Claude 测试模型\n    pub claude_model: String,\n    /// Codex 测试模型\n    pub codex_model: String,\n    /// Gemini 测试模型\n    pub gemini_model: String,\n    /// 检查提示词\n    #[serde(default = \"default_test_prompt\")]\n    pub test_prompt: String,\n}\n\nfn default_test_prompt() -> String {\n    \"Who are you?\".to_string()\n}\n\nimpl Default for StreamCheckConfig {\n    fn default() -> Self {\n        Self {\n            timeout_secs: 45,\n            max_retries: 2,\n            degraded_threshold_ms: 6000,\n            claude_model: \"claude-haiku-4-5-20251001\".to_string(),\n            codex_model: \"gpt-5.1-codex@low\".to_string(),\n            gemini_model: \"gemini-3-pro-preview\".to_string(),\n            test_prompt: default_test_prompt(),\n        }\n    }\n}\n\n/// 流式检查结果\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct StreamCheckResult {\n    pub status: HealthStatus,\n    pub success: bool,\n    pub message: String,\n    pub response_time_ms: Option<u64>,\n    pub http_status: Option<u16>,\n    pub model_used: String,\n    pub tested_at: i64,\n    pub retry_count: u32,\n}\n\n/// 流式健康检查服务\npub struct StreamCheckService;\n\nimpl StreamCheckService {\n    /// 执行流式健康检查（带重试）\n    ///\n    /// 如果 Provider 配置了单独的测试配置（meta.testConfig），则使用该配置覆盖全局配置\n    pub async fn check_with_retry(\n        app_type: &AppType,\n        provider: &Provider,\n        config: &StreamCheckConfig,\n        auth_override: Option<AuthInfo>,\n    ) -> Result<StreamCheckResult, AppError> {\n        // 合并供应商单独配置和全局配置\n        let effective_config = Self::merge_provider_config(provider, config);\n        let mut last_result = None;\n\n        for attempt in 0..=effective_config.max_retries {\n            let result =\n                Self::check_once(app_type, provider, &effective_config, auth_override.clone())\n                    .await;\n\n            match &result {\n                Ok(r) if r.success => {\n                    return Ok(StreamCheckResult {\n                        retry_count: attempt,\n                        ..r.clone()\n                    });\n                }\n                Ok(r) => {\n                    // 失败但非异常，判断是否重试\n                    if Self::should_retry(&r.message) && attempt < effective_config.max_retries {\n                        last_result = Some(r.clone());\n                        continue;\n                    }\n                    return Ok(StreamCheckResult {\n                        retry_count: attempt,\n                        ..r.clone()\n                    });\n                }\n                Err(e) => {\n                    if Self::should_retry(&e.to_string()) && attempt < effective_config.max_retries\n                    {\n                        continue;\n                    }\n                    return Err(AppError::Message(e.to_string()));\n                }\n            }\n        }\n\n        Ok(last_result.unwrap_or_else(|| StreamCheckResult {\n            status: HealthStatus::Failed,\n            success: false,\n            message: \"Check failed\".to_string(),\n            response_time_ms: None,\n            http_status: None,\n            model_used: String::new(),\n            tested_at: chrono::Utc::now().timestamp(),\n            retry_count: effective_config.max_retries,\n        }))\n    }\n\n    /// 合并供应商单独配置和全局配置\n    ///\n    /// 如果供应商配置了 meta.testConfig 且 enabled 为 true，则使用供应商配置覆盖全局配置\n    fn merge_provider_config(\n        provider: &Provider,\n        global_config: &StreamCheckConfig,\n    ) -> StreamCheckConfig {\n        let test_config = provider\n            .meta\n            .as_ref()\n            .and_then(|m| m.test_config.as_ref())\n            .filter(|tc| tc.enabled);\n\n        match test_config {\n            Some(tc) => StreamCheckConfig {\n                timeout_secs: tc.timeout_secs.unwrap_or(global_config.timeout_secs),\n                max_retries: tc.max_retries.unwrap_or(global_config.max_retries),\n                degraded_threshold_ms: tc\n                    .degraded_threshold_ms\n                    .unwrap_or(global_config.degraded_threshold_ms),\n                claude_model: tc\n                    .test_model\n                    .clone()\n                    .unwrap_or_else(|| global_config.claude_model.clone()),\n                codex_model: tc\n                    .test_model\n                    .clone()\n                    .unwrap_or_else(|| global_config.codex_model.clone()),\n                gemini_model: tc\n                    .test_model\n                    .clone()\n                    .unwrap_or_else(|| global_config.gemini_model.clone()),\n                test_prompt: tc\n                    .test_prompt\n                    .clone()\n                    .unwrap_or_else(|| global_config.test_prompt.clone()),\n            },\n            None => global_config.clone(),\n        }\n    }\n\n    /// 单次流式检查\n    async fn check_once(\n        app_type: &AppType,\n        provider: &Provider,\n        config: &StreamCheckConfig,\n        auth_override: Option<AuthInfo>,\n    ) -> Result<StreamCheckResult, AppError> {\n        let start = Instant::now();\n        let adapter = get_adapter(app_type);\n\n        let base_url = adapter\n            .extract_base_url(provider)\n            .map_err(|e| AppError::Message(format!(\"Failed to extract base_url: {e}\")))?;\n\n        let auth = auth_override\n            .or_else(|| adapter.extract_auth(provider))\n            .ok_or_else(|| AppError::Message(\"API Key not found\".to_string()))?;\n\n        // 获取 HTTP 客户端：优先使用供应商单独代理配置，否则使用全局客户端\n        let proxy_config = provider.meta.as_ref().and_then(|m| m.proxy_config.as_ref());\n        let client = crate::proxy::http_client::get_for_provider(proxy_config);\n        let request_timeout = std::time::Duration::from_secs(config.timeout_secs);\n\n        let model_to_test = Self::resolve_test_model(app_type, provider, config);\n        let test_prompt = &config.test_prompt;\n\n        let result = match app_type {\n            AppType::Claude => {\n                Self::check_claude_stream(\n                    &client,\n                    &base_url,\n                    &auth,\n                    &model_to_test,\n                    test_prompt,\n                    request_timeout,\n                    provider,\n                )\n                .await\n            }\n            AppType::Codex => {\n                Self::check_codex_stream(\n                    &client,\n                    &base_url,\n                    &auth,\n                    &model_to_test,\n                    test_prompt,\n                    request_timeout,\n                )\n                .await\n            }\n            AppType::Gemini => {\n                Self::check_gemini_stream(\n                    &client,\n                    &base_url,\n                    &auth,\n                    &model_to_test,\n                    test_prompt,\n                    request_timeout,\n                )\n                .await\n            }\n            AppType::OpenCode => {\n                // OpenCode doesn't support stream check yet\n                return Err(AppError::localized(\n                    \"opencode_no_stream_check\",\n                    \"OpenCode 暂不支持健康检查\",\n                    \"OpenCode does not support health check yet\",\n                ));\n            }\n            AppType::OpenClaw => {\n                // OpenClaw doesn't support stream check yet\n                return Err(AppError::localized(\n                    \"openclaw_no_stream_check\",\n                    \"OpenClaw 暂不支持健康检查\",\n                    \"OpenClaw does not support health check yet\",\n                ));\n            }\n        };\n\n        let response_time = start.elapsed().as_millis() as u64;\n        let tested_at = chrono::Utc::now().timestamp();\n\n        match result {\n            Ok((status_code, model)) => {\n                let health_status =\n                    Self::determine_status(response_time, config.degraded_threshold_ms);\n                Ok(StreamCheckResult {\n                    status: health_status,\n                    success: true,\n                    message: \"Check succeeded\".to_string(),\n                    response_time_ms: Some(response_time),\n                    http_status: Some(status_code),\n                    model_used: model,\n                    tested_at,\n                    retry_count: 0,\n                })\n            }\n            Err(e) => Ok(StreamCheckResult {\n                status: HealthStatus::Failed,\n                success: false,\n                message: e.to_string(),\n                response_time_ms: Some(response_time),\n                http_status: None,\n                model_used: String::new(),\n                tested_at,\n                retry_count: 0,\n            }),\n        }\n    }\n\n    /// Claude 流式检查\n    ///\n    /// 根据供应商的 api_format 选择请求格式：\n    /// - \"anthropic\" (默认): Anthropic Messages API (/v1/messages)\n    /// - \"openai_chat\": OpenAI Chat Completions API (/v1/chat/completions)\n    async fn check_claude_stream(\n        client: &Client,\n        base_url: &str,\n        auth: &AuthInfo,\n        model: &str,\n        test_prompt: &str,\n        timeout: std::time::Duration,\n        provider: &Provider,\n    ) -> Result<(u16, String), AppError> {\n        let base = base_url.trim_end_matches('/');\n        let is_github_copilot = auth.strategy == AuthStrategy::GitHubCopilot;\n\n        // Detect api_format: meta.api_format > settings_config.api_format > default \"anthropic\"\n        let api_format = provider\n            .meta\n            .as_ref()\n            .and_then(|m| m.api_format.as_deref())\n            .or_else(|| {\n                provider\n                    .settings_config\n                    .get(\"api_format\")\n                    .and_then(|v| v.as_str())\n            })\n            .unwrap_or(\"anthropic\");\n\n        let is_openai_chat = is_github_copilot || api_format == \"openai_chat\";\n\n        // URL:\n        // - GitHub Copilot: /chat/completions (no /v1 prefix)\n        // - OpenAI-compatible: /v1/chat/completions\n        // - Anthropic native: /v1/messages?beta=true\n        let url = if is_github_copilot {\n            format!(\"{base}/chat/completions\")\n        } else if is_openai_chat {\n            if base.ends_with(\"/v1\") {\n                format!(\"{base}/chat/completions\")\n            } else {\n                format!(\"{base}/v1/chat/completions\")\n            }\n        } else {\n            // ?beta=true is required by some relay services to verify request origin\n            if base.ends_with(\"/v1\") {\n                format!(\"{base}/messages?beta=true\")\n            } else {\n                format!(\"{base}/v1/messages?beta=true\")\n            }\n        };\n\n        // Build from Anthropic-native shape first, then convert for OpenAI-compatible targets.\n        let anthropic_body = json!({\n            \"model\": model,\n            \"max_tokens\": 1,\n            \"messages\": [{ \"role\": \"user\", \"content\": test_prompt }],\n            \"stream\": true\n        });\n        let body = if is_openai_chat {\n            anthropic_to_openai(anthropic_body, Some(&provider.id))\n                .map_err(|e| AppError::Message(format!(\"Failed to build test request: {e}\")))?\n        } else {\n            anthropic_body\n        };\n\n        let mut request_builder = client.post(&url);\n\n        if is_github_copilot {\n            request_builder = request_builder\n                .header(\"authorization\", format!(\"Bearer {}\", auth.api_key))\n                .header(\"content-type\", \"application/json\")\n                .header(\"accept\", \"text/event-stream\")\n                .header(\"accept-encoding\", \"identity\")\n                .header(\"editor-version\", \"vscode/1.85.0\")\n                .header(\"editor-plugin-version\", \"copilot/1.150.0\")\n                .header(\"copilot-integration-id\", \"vscode-chat\");\n        } else if is_openai_chat {\n            // OpenAI-compatible: Bearer auth + standard headers only\n            request_builder = request_builder\n                .header(\"authorization\", format!(\"Bearer {}\", auth.api_key))\n                .header(\"content-type\", \"application/json\")\n                .header(\"accept\", \"text/event-stream\")\n                .header(\"accept-encoding\", \"identity\");\n        } else {\n            // Anthropic native: full Claude CLI headers\n            let os_name = Self::get_os_name();\n            let arch_name = Self::get_arch_name();\n\n            request_builder =\n                request_builder.header(\"authorization\", format!(\"Bearer {}\", auth.api_key));\n\n            // Only Anthropic official strategy adds x-api-key\n            if auth.strategy == AuthStrategy::Anthropic {\n                request_builder = request_builder.header(\"x-api-key\", &auth.api_key);\n            }\n\n            request_builder = request_builder\n                // Anthropic required headers\n                .header(\"anthropic-version\", \"2023-06-01\")\n                .header(\n                    \"anthropic-beta\",\n                    \"claude-code-20250219,interleaved-thinking-2025-05-14\",\n                )\n                .header(\"anthropic-dangerous-direct-browser-access\", \"true\")\n                // Content type headers\n                .header(\"content-type\", \"application/json\")\n                .header(\"accept\", \"application/json\")\n                .header(\"accept-encoding\", \"identity\")\n                .header(\"accept-language\", \"*\")\n                // Client identification headers\n                .header(\"user-agent\", \"claude-cli/2.1.2 (external, cli)\")\n                .header(\"x-app\", \"cli\")\n                // x-stainless SDK headers (dynamic local system info)\n                .header(\"x-stainless-lang\", \"js\")\n                .header(\"x-stainless-package-version\", \"0.70.0\")\n                .header(\"x-stainless-os\", os_name)\n                .header(\"x-stainless-arch\", arch_name)\n                .header(\"x-stainless-runtime\", \"node\")\n                .header(\"x-stainless-runtime-version\", \"v22.20.0\")\n                .header(\"x-stainless-retry-count\", \"0\")\n                .header(\"x-stainless-timeout\", \"600\")\n                // Other headers\n                .header(\"sec-fetch-mode\", \"cors\")\n                .header(\"connection\", \"keep-alive\");\n        }\n\n        let response = request_builder\n            .timeout(timeout)\n            .json(&body)\n            .send()\n            .await\n            .map_err(Self::map_request_error)?;\n\n        let status = response.status().as_u16();\n\n        if !response.status().is_success() {\n            let error_text = response.text().await.unwrap_or_default();\n            return Err(AppError::Message(format!(\"HTTP {status}: {error_text}\")));\n        }\n\n        // 流式读取：只需首个 chunk\n        let mut stream = response.bytes_stream();\n        if let Some(chunk) = stream.next().await {\n            match chunk {\n                Ok(_) => Ok((status, model.to_string())),\n                Err(e) => Err(AppError::Message(format!(\"Stream read failed: {e}\"))),\n            }\n        } else {\n            Err(AppError::Message(\"No response data received\".to_string()))\n        }\n    }\n\n    /// Codex 流式检查\n    ///\n    /// 严格按照 Codex CLI 真实请求格式构建请求 (Responses API)\n    async fn check_codex_stream(\n        client: &Client,\n        base_url: &str,\n        auth: &AuthInfo,\n        model: &str,\n        test_prompt: &str,\n        timeout: std::time::Duration,\n    ) -> Result<(u16, String), AppError> {\n        let base = base_url.trim_end_matches('/');\n        // Codex CLI 的 base_url 语义：base_url 是 API base（可能已包含 /v1 或其他自定义前缀），\n        // Responses 端点为 `/responses`。\n        //\n        // 兼容：如果 base_url 配成纯 origin（如 https://api.openai.com），则需要补 `/v1`。\n        // 优先尝试 `{base}/responses`，若 404 再回退 `{base}/v1/responses`。\n        let urls = if base.ends_with(\"/v1\") {\n            vec![format!(\"{base}/responses\")]\n        } else {\n            vec![format!(\"{base}/responses\"), format!(\"{base}/v1/responses\")]\n        };\n\n        // 解析模型名和推理等级 (支持 model@level 或 model#level 格式)\n        let (actual_model, reasoning_effort) = Self::parse_model_with_effort(model);\n\n        // 获取本地系统信息\n        let os_name = Self::get_os_name();\n        let arch_name = Self::get_arch_name();\n\n        // Responses API 请求体格式 (input 必须是数组)\n        let mut body = json!({\n            \"model\": actual_model,\n            \"input\": [{ \"role\": \"user\", \"content\": test_prompt }],\n            \"stream\": true\n        });\n\n        // 如果是推理模型，添加 reasoning_effort\n        if let Some(effort) = reasoning_effort {\n            body[\"reasoning\"] = json!({ \"effort\": effort });\n        }\n\n        for (i, url) in urls.iter().enumerate() {\n            // 严格按照 Codex CLI 请求格式设置 headers\n            let response = client\n                .post(url)\n                .header(\"authorization\", format!(\"Bearer {}\", auth.api_key))\n                .header(\"content-type\", \"application/json\")\n                .header(\"accept\", \"text/event-stream\")\n                .header(\"accept-encoding\", \"identity\")\n                .header(\n                    \"user-agent\",\n                    format!(\"codex_cli_rs/0.80.0 ({os_name} 15.7.2; {arch_name}) Terminal\"),\n                )\n                .header(\"originator\", \"codex_cli_rs\")\n                .timeout(timeout)\n                .json(&body)\n                .send()\n                .await\n                .map_err(Self::map_request_error)?;\n\n            let status = response.status().as_u16();\n\n            if !response.status().is_success() {\n                let error_text = response.text().await.unwrap_or_default();\n                // 回退策略：仅当首选 URL 返回 404 时尝试下一个\n                if i == 0 && status == 404 && urls.len() > 1 {\n                    continue;\n                }\n                return Err(AppError::Message(format!(\"HTTP {status}: {error_text}\")));\n            }\n\n            let mut stream = response.bytes_stream();\n            if let Some(chunk) = stream.next().await {\n                match chunk {\n                    Ok(_) => return Ok((status, actual_model)),\n                    Err(e) => return Err(AppError::Message(format!(\"Stream read failed: {e}\"))),\n                }\n            }\n\n            return Err(AppError::Message(\"No response data received\".to_string()));\n        }\n\n        Err(AppError::Message(\n            \"No valid Codex responses endpoint found\".to_string(),\n        ))\n    }\n\n    /// Gemini 流式检查\n    ///\n    /// 使用 Gemini 原生 API 格式 (streamGenerateContent)\n    async fn check_gemini_stream(\n        client: &Client,\n        base_url: &str,\n        auth: &AuthInfo,\n        model: &str,\n        test_prompt: &str,\n        timeout: std::time::Duration,\n    ) -> Result<(u16, String), AppError> {\n        let base = base_url.trim_end_matches('/');\n        // Gemini 原生 API: /v1beta/models/{model}:streamGenerateContent?alt=sse\n        // 智能处理 /v1beta 路径：如果 base_url 不包含版本路径，则添加 /v1beta\n        // alt=sse 参数使 API 返回 SSE 格式（text/event-stream）而非 JSON 数组\n        let url = if base.contains(\"/v1beta\") || base.contains(\"/v1/\") {\n            format!(\"{base}/models/{model}:streamGenerateContent?alt=sse\")\n        } else {\n            format!(\"{base}/v1beta/models/{model}:streamGenerateContent?alt=sse\")\n        };\n\n        // Gemini 原生请求体格式\n        let body = json!({\n            \"contents\": [{\n                \"role\": \"user\",\n                \"parts\": [{ \"text\": test_prompt }]\n            }]\n        });\n\n        let response = client\n            .post(&url)\n            .header(\"x-goog-api-key\", &auth.api_key)\n            .header(\"Content-Type\", \"application/json\")\n            .header(\"Accept\", \"text/event-stream\")\n            .timeout(timeout)\n            .json(&body)\n            .send()\n            .await\n            .map_err(Self::map_request_error)?;\n\n        let status = response.status().as_u16();\n\n        if !response.status().is_success() {\n            let error_text = response.text().await.unwrap_or_default();\n            return Err(AppError::Message(format!(\"HTTP {status}: {error_text}\")));\n        }\n\n        let mut stream = response.bytes_stream();\n        if let Some(chunk) = stream.next().await {\n            match chunk {\n                Ok(_) => Ok((status, model.to_string())),\n                Err(e) => Err(AppError::Message(format!(\"Stream read failed: {e}\"))),\n            }\n        } else {\n            Err(AppError::Message(\"No response data received\".to_string()))\n        }\n    }\n\n    fn determine_status(latency_ms: u64, threshold: u64) -> HealthStatus {\n        if latency_ms <= threshold {\n            HealthStatus::Operational\n        } else {\n            HealthStatus::Degraded\n        }\n    }\n\n    /// 解析模型名和推理等级 (支持 model@level 或 model#level 格式)\n    /// 返回 (实际模型名, Option<推理等级>)\n    fn parse_model_with_effort(model: &str) -> (String, Option<String>) {\n        if let Some(pos) = model.find('@').or_else(|| model.find('#')) {\n            let actual_model = model[..pos].to_string();\n            let effort = model[pos + 1..].to_string();\n            if !effort.is_empty() {\n                return (actual_model, Some(effort));\n            }\n        }\n        (model.to_string(), None)\n    }\n\n    fn should_retry(msg: &str) -> bool {\n        let lower = msg.to_lowercase();\n        lower.contains(\"timeout\") || lower.contains(\"abort\") || lower.contains(\"timed out\")\n    }\n\n    fn map_request_error(e: reqwest::Error) -> AppError {\n        if e.is_timeout() {\n            AppError::Message(\"Request timeout\".to_string())\n        } else if e.is_connect() {\n            AppError::Message(format!(\"Connection failed: {e}\"))\n        } else {\n            AppError::Message(e.to_string())\n        }\n    }\n\n    fn resolve_test_model(\n        app_type: &AppType,\n        provider: &Provider,\n        config: &StreamCheckConfig,\n    ) -> String {\n        match app_type {\n            AppType::Claude => Self::extract_env_model(provider, \"ANTHROPIC_MODEL\")\n                .unwrap_or_else(|| config.claude_model.clone()),\n            AppType::Codex => {\n                Self::extract_codex_model(provider).unwrap_or_else(|| config.codex_model.clone())\n            }\n            AppType::Gemini => Self::extract_env_model(provider, \"GEMINI_MODEL\")\n                .unwrap_or_else(|| config.gemini_model.clone()),\n            AppType::OpenCode => {\n                // OpenCode uses models map in settings_config\n                // Try to extract first model from the models object\n                Self::extract_opencode_model(provider).unwrap_or_else(|| \"gpt-4o\".to_string())\n            }\n            AppType::OpenClaw => {\n                // OpenClaw uses models array in settings_config\n                // Try to extract first model from the models array\n                Self::extract_openclaw_model(provider).unwrap_or_else(|| \"gpt-4o\".to_string())\n            }\n        }\n    }\n\n    fn extract_opencode_model(provider: &Provider) -> Option<String> {\n        let models = provider\n            .settings_config\n            .get(\"models\")\n            .and_then(|m| m.as_object())?;\n\n        // Return the first model ID from the models map\n        models.keys().next().map(|s| s.to_string())\n    }\n\n    fn extract_openclaw_model(provider: &Provider) -> Option<String> {\n        // OpenClaw uses models array: [{ \"id\": \"model-id\", \"name\": \"Model Name\" }]\n        let models = provider\n            .settings_config\n            .get(\"models\")\n            .and_then(|m| m.as_array())?;\n\n        // Return the first model ID from the models array\n        models\n            .first()\n            .and_then(|m| m.get(\"id\"))\n            .and_then(|id| id.as_str())\n            .map(|s| s.to_string())\n    }\n\n    fn extract_env_model(provider: &Provider, key: &str) -> Option<String> {\n        provider\n            .settings_config\n            .get(\"env\")\n            .and_then(|env| env.get(key))\n            .and_then(|value| value.as_str())\n            .map(|value| value.trim().to_string())\n            .filter(|value| !value.is_empty())\n    }\n\n    fn extract_codex_model(provider: &Provider) -> Option<String> {\n        let config_text = provider\n            .settings_config\n            .get(\"config\")\n            .and_then(|value| value.as_str())?;\n        if config_text.trim().is_empty() {\n            return None;\n        }\n\n        let re = Regex::new(r#\"^model\\s*=\\s*[\"']([^\"']+)[\"']\"#).ok()?;\n        re.captures(config_text)\n            .and_then(|caps| caps.get(1))\n            .map(|m| m.as_str().trim().to_string())\n            .filter(|value| !value.is_empty())\n    }\n\n    /// 获取操作系统名称（映射为 Claude CLI 使用的格式）\n    fn get_os_name() -> &'static str {\n        match std::env::consts::OS {\n            \"macos\" => \"MacOS\",\n            \"linux\" => \"Linux\",\n            \"windows\" => \"Windows\",\n            other => other,\n        }\n    }\n\n    /// 获取 CPU 架构名称（映射为 Claude CLI 使用的格式）\n    fn get_arch_name() -> &'static str {\n        match std::env::consts::ARCH {\n            \"aarch64\" => \"arm64\",\n            \"x86_64\" => \"x86_64\",\n            \"x86\" => \"x86\",\n            other => other,\n        }\n    }\n\n    #[cfg(test)]\n    fn resolve_claude_stream_url(\n        base_url: &str,\n        auth_strategy: AuthStrategy,\n        api_format: &str,\n    ) -> String {\n        let base = base_url.trim_end_matches('/');\n        let is_github_copilot = auth_strategy == AuthStrategy::GitHubCopilot;\n        let is_openai_chat = is_github_copilot || api_format == \"openai_chat\";\n\n        if is_github_copilot {\n            format!(\"{base}/chat/completions\")\n        } else if is_openai_chat {\n            if base.ends_with(\"/v1\") {\n                format!(\"{base}/chat/completions\")\n            } else {\n                format!(\"{base}/v1/chat/completions\")\n            }\n        } else if base.ends_with(\"/v1\") {\n            format!(\"{base}/messages?beta=true\")\n        } else {\n            format!(\"{base}/v1/messages?beta=true\")\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_determine_status() {\n        assert_eq!(\n            StreamCheckService::determine_status(3000, 6000),\n            HealthStatus::Operational\n        );\n        assert_eq!(\n            StreamCheckService::determine_status(6000, 6000),\n            HealthStatus::Operational\n        );\n        assert_eq!(\n            StreamCheckService::determine_status(6001, 6000),\n            HealthStatus::Degraded\n        );\n    }\n\n    #[test]\n    fn test_should_retry() {\n        assert!(StreamCheckService::should_retry(\"Request timeout\"));\n        assert!(StreamCheckService::should_retry(\"request timed out\"));\n        assert!(StreamCheckService::should_retry(\"connection abort\"));\n        assert!(!StreamCheckService::should_retry(\"API Key invalid\"));\n    }\n\n    #[test]\n    fn test_default_config() {\n        let config = StreamCheckConfig::default();\n        assert_eq!(config.timeout_secs, 45);\n        assert_eq!(config.max_retries, 2);\n        assert_eq!(config.degraded_threshold_ms, 6000);\n    }\n\n    #[test]\n    fn test_parse_model_with_effort() {\n        // 带 @ 分隔符\n        let (model, effort) = StreamCheckService::parse_model_with_effort(\"gpt-5.1-codex@low\");\n        assert_eq!(model, \"gpt-5.1-codex\");\n        assert_eq!(effort, Some(\"low\".to_string()));\n\n        // 带 # 分隔符\n        let (model, effort) = StreamCheckService::parse_model_with_effort(\"o1-preview#high\");\n        assert_eq!(model, \"o1-preview\");\n        assert_eq!(effort, Some(\"high\".to_string()));\n\n        // 无分隔符\n        let (model, effort) = StreamCheckService::parse_model_with_effort(\"gpt-4o-mini\");\n        assert_eq!(model, \"gpt-4o-mini\");\n        assert_eq!(effort, None);\n    }\n\n    #[test]\n    fn test_get_os_name() {\n        let os_name = StreamCheckService::get_os_name();\n        // 确保返回非空字符串\n        assert!(!os_name.is_empty());\n        // 在 macOS 上应该返回 \"MacOS\"\n        #[cfg(target_os = \"macos\")]\n        assert_eq!(os_name, \"MacOS\");\n        // 在 Linux 上应该返回 \"Linux\"\n        #[cfg(target_os = \"linux\")]\n        assert_eq!(os_name, \"Linux\");\n        // 在 Windows 上应该返回 \"Windows\"\n        #[cfg(target_os = \"windows\")]\n        assert_eq!(os_name, \"Windows\");\n    }\n\n    #[test]\n    fn test_get_arch_name() {\n        let arch_name = StreamCheckService::get_arch_name();\n        // 确保返回非空字符串\n        assert!(!arch_name.is_empty());\n        // 在 ARM64 上应该返回 \"arm64\"\n        #[cfg(target_arch = \"aarch64\")]\n        assert_eq!(arch_name, \"arm64\");\n        // 在 x86_64 上应该返回 \"x86_64\"\n        #[cfg(target_arch = \"x86_64\")]\n        assert_eq!(arch_name, \"x86_64\");\n    }\n\n    #[test]\n    fn test_auth_strategy_imports() {\n        // 验证 AuthStrategy 枚举可以正常使用\n        let anthropic = AuthStrategy::Anthropic;\n        let claude_auth = AuthStrategy::ClaudeAuth;\n        let bearer = AuthStrategy::Bearer;\n\n        // 验证不同的策略是不相等的\n        assert_ne!(anthropic, claude_auth);\n        assert_ne!(anthropic, bearer);\n        assert_ne!(claude_auth, bearer);\n\n        // 验证相同策略是相等的\n        assert_eq!(anthropic, AuthStrategy::Anthropic);\n        assert_eq!(claude_auth, AuthStrategy::ClaudeAuth);\n        assert_eq!(bearer, AuthStrategy::Bearer);\n    }\n\n    #[test]\n    fn test_resolve_claude_stream_url_for_github_copilot() {\n        let url = StreamCheckService::resolve_claude_stream_url(\n            \"https://api.githubcopilot.com\",\n            AuthStrategy::GitHubCopilot,\n            \"anthropic\",\n        );\n\n        assert_eq!(url, \"https://api.githubcopilot.com/chat/completions\");\n    }\n\n    #[test]\n    fn test_resolve_claude_stream_url_for_openai_chat() {\n        let url = StreamCheckService::resolve_claude_stream_url(\n            \"https://example.com/v1\",\n            AuthStrategy::Bearer,\n            \"openai_chat\",\n        );\n\n        assert_eq!(url, \"https://example.com/v1/chat/completions\");\n    }\n\n    #[test]\n    fn test_resolve_claude_stream_url_for_anthropic() {\n        let url = StreamCheckService::resolve_claude_stream_url(\n            \"https://api.anthropic.com\",\n            AuthStrategy::Anthropic,\n            \"anthropic\",\n        );\n\n        assert_eq!(url, \"https://api.anthropic.com/v1/messages?beta=true\");\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/services/usage_stats.rs",
    "content": "//! 使用统计服务\n//!\n//! 提供使用量数据的聚合查询功能\n\nuse crate::database::{lock_conn, Database};\nuse crate::error::AppError;\nuse chrono::{Local, TimeZone};\nuse rusqlite::{params, Connection, OptionalExtension};\nuse serde::{Deserialize, Serialize};\nuse serde_json::Value;\nuse std::collections::HashMap;\nuse std::str::FromStr;\n\n/// 使用量汇总\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct UsageSummary {\n    pub total_requests: u64,\n    pub total_cost: String,\n    pub total_input_tokens: u64,\n    pub total_output_tokens: u64,\n    pub total_cache_creation_tokens: u64,\n    pub total_cache_read_tokens: u64,\n    pub success_rate: f32,\n}\n\n/// 每日统计\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct DailyStats {\n    pub date: String,\n    pub request_count: u64,\n    pub total_cost: String,\n    pub total_tokens: u64,\n    pub total_input_tokens: u64,\n    pub total_output_tokens: u64,\n    pub total_cache_creation_tokens: u64,\n    pub total_cache_read_tokens: u64,\n}\n\n/// Provider 统计\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct ProviderStats {\n    pub provider_id: String,\n    pub provider_name: String,\n    pub request_count: u64,\n    pub total_tokens: u64,\n    pub total_cost: String,\n    pub success_rate: f32,\n    pub avg_latency_ms: u64,\n}\n\n/// 模型统计\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct ModelStats {\n    pub model: String,\n    pub request_count: u64,\n    pub total_tokens: u64,\n    pub total_cost: String,\n    pub avg_cost_per_request: String,\n}\n\n/// 请求日志过滤器\n#[derive(Debug, Clone, Default, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct LogFilters {\n    pub app_type: Option<String>,\n    pub provider_name: Option<String>,\n    pub model: Option<String>,\n    pub status_code: Option<u16>,\n    pub start_date: Option<i64>,\n    pub end_date: Option<i64>,\n}\n\n/// 分页请求日志响应\n#[derive(Debug, Clone, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct PaginatedLogs {\n    pub data: Vec<RequestLogDetail>,\n    pub total: u32,\n    pub page: u32,\n    pub page_size: u32,\n}\n\n/// 请求日志详情\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct RequestLogDetail {\n    pub request_id: String,\n    pub provider_id: String,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub provider_name: Option<String>,\n    pub app_type: String,\n    pub model: String,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub request_model: Option<String>,\n    pub cost_multiplier: String,\n    pub input_tokens: u32,\n    pub output_tokens: u32,\n    pub cache_read_tokens: u32,\n    pub cache_creation_tokens: u32,\n    pub input_cost_usd: String,\n    pub output_cost_usd: String,\n    pub cache_read_cost_usd: String,\n    pub cache_creation_cost_usd: String,\n    pub total_cost_usd: String,\n    pub is_streaming: bool,\n    pub latency_ms: u64,\n    pub first_token_ms: Option<u64>,\n    pub duration_ms: Option<u64>,\n    pub status_code: u16,\n    pub error_message: Option<String>,\n    pub created_at: i64,\n}\n\nimpl Database {\n    /// 获取使用量汇总\n    pub fn get_usage_summary(\n        &self,\n        start_date: Option<i64>,\n        end_date: Option<i64>,\n    ) -> Result<UsageSummary, AppError> {\n        let conn = lock_conn!(self.conn);\n\n        let (where_clause, params_vec) = if start_date.is_some() || end_date.is_some() {\n            let mut conditions = Vec::new();\n            let mut params = Vec::new();\n\n            if let Some(start) = start_date {\n                conditions.push(\"created_at >= ?\");\n                params.push(start);\n            }\n            if let Some(end) = end_date {\n                conditions.push(\"created_at <= ?\");\n                params.push(end);\n            }\n\n            (format!(\"WHERE {}\", conditions.join(\" AND \")), params)\n        } else {\n            (String::new(), Vec::new())\n        };\n\n        // Build rollup WHERE clause using date strings (use ? for sequential binding)\n        let (rollup_where, rollup_params) = if start_date.is_some() || end_date.is_some() {\n            let mut conditions: Vec<String> = Vec::new();\n            let mut params = Vec::new();\n\n            if let Some(start) = start_date {\n                conditions.push(\"date >= date(?, 'unixepoch', 'localtime')\".to_string());\n                params.push(start);\n            }\n            if let Some(end) = end_date {\n                conditions.push(\"date <= date(?, 'unixepoch', 'localtime')\".to_string());\n                params.push(end);\n            }\n\n            (format!(\"WHERE {}\", conditions.join(\" AND \")), params)\n        } else {\n            (String::new(), Vec::new())\n        };\n\n        let sql = format!(\n            \"SELECT\n                COALESCE(d.total_requests, 0) + COALESCE(r.total_requests, 0),\n                COALESCE(d.total_cost, 0) + COALESCE(r.total_cost, 0),\n                COALESCE(d.total_input_tokens, 0) + COALESCE(r.total_input_tokens, 0),\n                COALESCE(d.total_output_tokens, 0) + COALESCE(r.total_output_tokens, 0),\n                COALESCE(d.total_cache_creation_tokens, 0) + COALESCE(r.total_cache_creation_tokens, 0),\n                COALESCE(d.total_cache_read_tokens, 0) + COALESCE(r.total_cache_read_tokens, 0),\n                COALESCE(d.success_count, 0) + COALESCE(r.success_count, 0)\n            FROM\n                (SELECT\n                    COUNT(*) as total_requests,\n                    COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,\n                    COALESCE(SUM(input_tokens), 0) as total_input_tokens,\n                    COALESCE(SUM(output_tokens), 0) as total_output_tokens,\n                    COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens,\n                    COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens,\n                    COALESCE(SUM(CASE WHEN status_code >= 200 AND status_code < 300 THEN 1 ELSE 0 END), 0) as success_count\n                 FROM proxy_request_logs {where_clause}) d,\n                (SELECT\n                    COALESCE(SUM(request_count), 0) as total_requests,\n                    COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,\n                    COALESCE(SUM(input_tokens), 0) as total_input_tokens,\n                    COALESCE(SUM(output_tokens), 0) as total_output_tokens,\n                    COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens,\n                    COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens,\n                    COALESCE(SUM(success_count), 0) as success_count\n                 FROM usage_daily_rollups {rollup_where}) r\"\n        );\n\n        // Combine params: detail params first, then rollup params\n        let mut all_params: Vec<i64> = params_vec;\n        all_params.extend(rollup_params);\n\n        let result = conn.query_row(&sql, rusqlite::params_from_iter(all_params), |row| {\n            let total_requests: i64 = row.get(0)?;\n            let total_cost: f64 = row.get(1)?;\n            let total_input_tokens: i64 = row.get(2)?;\n            let total_output_tokens: i64 = row.get(3)?;\n            let total_cache_creation_tokens: i64 = row.get(4)?;\n            let total_cache_read_tokens: i64 = row.get(5)?;\n            let success_count: i64 = row.get(6)?;\n\n            let success_rate = if total_requests > 0 {\n                (success_count as f32 / total_requests as f32) * 100.0\n            } else {\n                0.0\n            };\n\n            Ok(UsageSummary {\n                total_requests: total_requests as u64,\n                total_cost: format!(\"{total_cost:.6}\"),\n                total_input_tokens: total_input_tokens as u64,\n                total_output_tokens: total_output_tokens as u64,\n                total_cache_creation_tokens: total_cache_creation_tokens as u64,\n                total_cache_read_tokens: total_cache_read_tokens as u64,\n                success_rate,\n            })\n        })?;\n\n        Ok(result)\n    }\n\n    /// 获取每日趋势（滑动窗口，<=24h 按小时，>24h 按天，窗口与汇总一致）\n    pub fn get_daily_trends(\n        &self,\n        start_date: Option<i64>,\n        end_date: Option<i64>,\n    ) -> Result<Vec<DailyStats>, AppError> {\n        let conn = lock_conn!(self.conn);\n\n        let end_ts = end_date.unwrap_or_else(|| Local::now().timestamp());\n        let mut start_ts = start_date.unwrap_or_else(|| end_ts - 24 * 60 * 60);\n\n        if start_ts >= end_ts {\n            start_ts = end_ts - 24 * 60 * 60;\n        }\n\n        let duration = end_ts - start_ts;\n        let bucket_seconds: i64 = if duration <= 24 * 60 * 60 {\n            60 * 60\n        } else {\n            24 * 60 * 60\n        };\n        let mut bucket_count: i64 = if duration <= 0 {\n            1\n        } else {\n            ((duration as f64) / bucket_seconds as f64).ceil() as i64\n        };\n\n        // 固定 24 小时窗口为 24 个小时桶，避免浮点误差\n        if bucket_seconds == 60 * 60 {\n            bucket_count = 24;\n        }\n\n        if bucket_count < 1 {\n            bucket_count = 1;\n        }\n\n        // Query detail logs\n        let sql = \"\n            SELECT\n                CAST((created_at - ?1) / ?3 AS INTEGER) as bucket_idx,\n                COUNT(*) as request_count,\n                COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,\n                COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,\n                COALESCE(SUM(input_tokens), 0) as total_input_tokens,\n                COALESCE(SUM(output_tokens), 0) as total_output_tokens,\n                COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens,\n                COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens\n            FROM proxy_request_logs\n            WHERE created_at >= ?1 AND created_at <= ?2\n            GROUP BY bucket_idx\n            ORDER BY bucket_idx ASC\";\n\n        let mut stmt = conn.prepare(sql)?;\n        let rows = stmt.query_map(params![start_ts, end_ts, bucket_seconds], |row| {\n            Ok((\n                row.get::<_, i64>(0)?,\n                DailyStats {\n                    date: String::new(),\n                    request_count: row.get::<_, i64>(1)? as u64,\n                    total_cost: format!(\"{:.6}\", row.get::<_, f64>(2)?),\n                    total_tokens: row.get::<_, i64>(3)? as u64,\n                    total_input_tokens: row.get::<_, i64>(4)? as u64,\n                    total_output_tokens: row.get::<_, i64>(5)? as u64,\n                    total_cache_creation_tokens: row.get::<_, i64>(6)? as u64,\n                    total_cache_read_tokens: row.get::<_, i64>(7)? as u64,\n                },\n            ))\n        })?;\n\n        let mut map: HashMap<i64, DailyStats> = HashMap::new();\n        for row in rows {\n            let (mut bucket_idx, stat) = row?;\n            if bucket_idx < 0 {\n                continue;\n            }\n            if bucket_idx >= bucket_count {\n                bucket_idx = bucket_count - 1;\n            }\n            map.insert(bucket_idx, stat);\n        }\n\n        // Also query rollup data (daily granularity, only useful for daily buckets)\n        if bucket_seconds >= 86400 {\n            let rollup_sql = \"\n                SELECT\n                    CAST((CAST(strftime('%s', date) AS INTEGER) - ?1) / ?3 AS INTEGER) as bucket_idx,\n                    COALESCE(SUM(request_count), 0),\n                    COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0),\n                    COALESCE(SUM(input_tokens + output_tokens), 0),\n                    COALESCE(SUM(input_tokens), 0),\n                    COALESCE(SUM(output_tokens), 0),\n                    COALESCE(SUM(cache_creation_tokens), 0),\n                    COALESCE(SUM(cache_read_tokens), 0)\n                FROM usage_daily_rollups\n                WHERE date >= date(?1, 'unixepoch', 'localtime') AND date <= date(?2, 'unixepoch', 'localtime')\n                GROUP BY bucket_idx\n                ORDER BY bucket_idx ASC\";\n\n            let mut rstmt = conn.prepare(rollup_sql)?;\n            let rrows = rstmt.query_map(params![start_ts, end_ts, bucket_seconds], |row| {\n                Ok((\n                    row.get::<_, i64>(0)?,\n                    (\n                        row.get::<_, i64>(1)? as u64,\n                        row.get::<_, f64>(2)?,\n                        row.get::<_, i64>(3)? as u64,\n                        row.get::<_, i64>(4)? as u64,\n                        row.get::<_, i64>(5)? as u64,\n                        row.get::<_, i64>(6)? as u64,\n                        row.get::<_, i64>(7)? as u64,\n                    ),\n                ))\n            })?;\n\n            for row in rrows {\n                let (mut bucket_idx, (req, cost, tok, inp, out, cc, cr)) = row?;\n                if bucket_idx < 0 {\n                    continue;\n                }\n                if bucket_idx >= bucket_count {\n                    bucket_idx = bucket_count - 1;\n                }\n                let entry = map.entry(bucket_idx).or_insert_with(|| DailyStats {\n                    date: String::new(),\n                    request_count: 0,\n                    total_cost: \"0.000000\".to_string(),\n                    total_tokens: 0,\n                    total_input_tokens: 0,\n                    total_output_tokens: 0,\n                    total_cache_creation_tokens: 0,\n                    total_cache_read_tokens: 0,\n                });\n                entry.request_count += req;\n                let existing_cost: f64 = entry.total_cost.parse().unwrap_or(0.0);\n                entry.total_cost = format!(\"{:.6}\", existing_cost + cost);\n                entry.total_tokens += tok;\n                entry.total_input_tokens += inp;\n                entry.total_output_tokens += out;\n                entry.total_cache_creation_tokens += cc;\n                entry.total_cache_read_tokens += cr;\n            }\n        }\n\n        let mut stats = Vec::with_capacity(bucket_count as usize);\n        for i in 0..bucket_count {\n            let bucket_start_ts = start_ts + i * bucket_seconds;\n            let bucket_start = Local\n                .timestamp_opt(bucket_start_ts, 0)\n                .single()\n                .unwrap_or_else(Local::now);\n\n            let date = bucket_start.to_rfc3339();\n\n            if let Some(mut stat) = map.remove(&i) {\n                stat.date = date;\n                stats.push(stat);\n            } else {\n                stats.push(DailyStats {\n                    date,\n                    request_count: 0,\n                    total_cost: \"0.000000\".to_string(),\n                    total_tokens: 0,\n                    total_input_tokens: 0,\n                    total_output_tokens: 0,\n                    total_cache_creation_tokens: 0,\n                    total_cache_read_tokens: 0,\n                });\n            }\n        }\n\n        Ok(stats)\n    }\n\n    /// 获取 Provider 统计\n    pub fn get_provider_stats(&self) -> Result<Vec<ProviderStats>, AppError> {\n        let conn = lock_conn!(self.conn);\n\n        // UNION detail logs + rollup data, then aggregate\n        let sql = \"SELECT\n                provider_id, app_type, provider_name,\n                SUM(request_count) as request_count,\n                SUM(total_tokens) as total_tokens,\n                SUM(total_cost) as total_cost,\n                SUM(success_count) as success_count,\n                CASE WHEN SUM(request_count) > 0\n                    THEN SUM(latency_sum) / SUM(request_count)\n                    ELSE 0 END as avg_latency\n            FROM (\n                SELECT l.provider_id, l.app_type,\n                    p.name as provider_name,\n                    COUNT(*) as request_count,\n                    COALESCE(SUM(l.input_tokens + l.output_tokens), 0) as total_tokens,\n                    COALESCE(SUM(CAST(l.total_cost_usd AS REAL)), 0) as total_cost,\n                    COALESCE(SUM(CASE WHEN l.status_code >= 200 AND l.status_code < 300 THEN 1 ELSE 0 END), 0) as success_count,\n                    COALESCE(SUM(l.latency_ms), 0) as latency_sum\n                FROM proxy_request_logs l\n                LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type\n                GROUP BY l.provider_id, l.app_type\n                UNION ALL\n                SELECT r.provider_id, r.app_type,\n                    p2.name as provider_name,\n                    COALESCE(SUM(r.request_count), 0),\n                    COALESCE(SUM(r.input_tokens + r.output_tokens), 0),\n                    COALESCE(SUM(CAST(r.total_cost_usd AS REAL)), 0),\n                    COALESCE(SUM(r.success_count), 0),\n                    COALESCE(SUM(r.avg_latency_ms * r.request_count), 0)\n                FROM usage_daily_rollups r\n                LEFT JOIN providers p2 ON r.provider_id = p2.id AND r.app_type = p2.app_type\n                GROUP BY r.provider_id, r.app_type\n            )\n            GROUP BY provider_id, app_type\n            ORDER BY total_cost DESC\";\n\n        let mut stmt = conn.prepare(sql)?;\n        let rows = stmt.query_map([], |row| {\n            let request_count: i64 = row.get(3)?;\n            let success_count: i64 = row.get(6)?;\n            let success_rate = if request_count > 0 {\n                (success_count as f32 / request_count as f32) * 100.0\n            } else {\n                0.0\n            };\n\n            Ok(ProviderStats {\n                provider_id: row.get(0)?,\n                provider_name: row\n                    .get::<_, Option<String>>(2)?\n                    .unwrap_or_else(|| \"Unknown\".to_string()),\n                request_count: request_count as u64,\n                total_tokens: row.get::<_, i64>(4)? as u64,\n                total_cost: format!(\"{:.6}\", row.get::<_, f64>(5)?),\n                success_rate,\n                avg_latency_ms: row.get::<_, f64>(7)? as u64,\n            })\n        })?;\n\n        let mut stats = Vec::new();\n        for row in rows {\n            stats.push(row?);\n        }\n\n        Ok(stats)\n    }\n\n    /// 获取模型统计\n    pub fn get_model_stats(&self) -> Result<Vec<ModelStats>, AppError> {\n        let conn = lock_conn!(self.conn);\n\n        // UNION detail logs + rollup data\n        let sql = \"SELECT\n                model,\n                SUM(request_count) as request_count,\n                SUM(total_tokens) as total_tokens,\n                SUM(total_cost) as total_cost\n            FROM (\n                SELECT model,\n                    COUNT(*) as request_count,\n                    COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,\n                    COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost\n                FROM proxy_request_logs\n                GROUP BY model\n                UNION ALL\n                SELECT model,\n                    COALESCE(SUM(request_count), 0),\n                    COALESCE(SUM(input_tokens + output_tokens), 0),\n                    COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0)\n                FROM usage_daily_rollups\n                GROUP BY model\n            )\n            GROUP BY model\n            ORDER BY total_cost DESC\";\n\n        let mut stmt = conn.prepare(sql)?;\n        let rows = stmt.query_map([], |row| {\n            let request_count: i64 = row.get(1)?;\n            let total_cost: f64 = row.get(3)?;\n            let avg_cost = if request_count > 0 {\n                total_cost / request_count as f64\n            } else {\n                0.0\n            };\n\n            Ok(ModelStats {\n                model: row.get(0)?,\n                request_count: request_count as u64,\n                total_tokens: row.get::<_, i64>(2)? as u64,\n                total_cost: format!(\"{total_cost:.6}\"),\n                avg_cost_per_request: format!(\"{avg_cost:.6}\"),\n            })\n        })?;\n\n        let mut stats = Vec::new();\n        for row in rows {\n            stats.push(row?);\n        }\n\n        Ok(stats)\n    }\n\n    /// 获取请求日志列表（分页）\n    pub fn get_request_logs(\n        &self,\n        filters: &LogFilters,\n        page: u32,\n        page_size: u32,\n    ) -> Result<PaginatedLogs, AppError> {\n        let conn = lock_conn!(self.conn);\n\n        let mut conditions = Vec::new();\n        let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();\n\n        if let Some(ref app_type) = filters.app_type {\n            conditions.push(\"l.app_type = ?\");\n            params.push(Box::new(app_type.clone()));\n        }\n        if let Some(ref provider_name) = filters.provider_name {\n            conditions.push(\"p.name LIKE ?\");\n            params.push(Box::new(format!(\"%{provider_name}%\")));\n        }\n        if let Some(ref model) = filters.model {\n            conditions.push(\"l.model LIKE ?\");\n            params.push(Box::new(format!(\"%{model}%\")));\n        }\n        if let Some(status) = filters.status_code {\n            conditions.push(\"l.status_code = ?\");\n            params.push(Box::new(status as i64));\n        }\n        if let Some(start) = filters.start_date {\n            conditions.push(\"l.created_at >= ?\");\n            params.push(Box::new(start));\n        }\n        if let Some(end) = filters.end_date {\n            conditions.push(\"l.created_at <= ?\");\n            params.push(Box::new(end));\n        }\n\n        let where_clause = if conditions.is_empty() {\n            String::new()\n        } else {\n            format!(\"WHERE {}\", conditions.join(\" AND \"))\n        };\n\n        // 获取总数\n        let count_sql = format!(\n            \"SELECT COUNT(*) FROM proxy_request_logs l\n             LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type\n             {where_clause}\"\n        );\n        let count_params: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();\n        let total: u32 = conn.query_row(&count_sql, count_params.as_slice(), |row| {\n            row.get::<_, i64>(0).map(|v| v as u32)\n        })?;\n\n        // 获取数据\n        let offset = page * page_size;\n        params.push(Box::new(page_size as i64));\n        params.push(Box::new(offset as i64));\n\n        let sql = format!(\n            \"SELECT l.request_id, l.provider_id, p.name as provider_name, l.app_type, l.model,\n                    l.request_model, l.cost_multiplier,\n                    l.input_tokens, l.output_tokens, l.cache_read_tokens, l.cache_creation_tokens,\n                    l.input_cost_usd, l.output_cost_usd, l.cache_read_cost_usd, l.cache_creation_cost_usd, l.total_cost_usd,\n                    l.is_streaming, l.latency_ms, l.first_token_ms, l.duration_ms,\n                    l.status_code, l.error_message, l.created_at\n             FROM proxy_request_logs l\n             LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type\n             {where_clause}\n             ORDER BY l.created_at DESC\n             LIMIT ? OFFSET ?\"\n        );\n\n        let mut stmt = conn.prepare(&sql)?;\n        let params_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();\n        let rows = stmt.query_map(params_refs.as_slice(), |row| {\n            Ok(RequestLogDetail {\n                request_id: row.get(0)?,\n                provider_id: row.get(1)?,\n                provider_name: row.get(2)?,\n                app_type: row.get(3)?,\n                model: row.get(4)?,\n                request_model: row.get(5)?,\n                cost_multiplier: row\n                    .get::<_, Option<String>>(6)?\n                    .unwrap_or_else(|| \"1\".to_string()),\n                input_tokens: row.get::<_, i64>(7)? as u32,\n                output_tokens: row.get::<_, i64>(8)? as u32,\n                cache_read_tokens: row.get::<_, i64>(9)? as u32,\n                cache_creation_tokens: row.get::<_, i64>(10)? as u32,\n                input_cost_usd: row.get(11)?,\n                output_cost_usd: row.get(12)?,\n                cache_read_cost_usd: row.get(13)?,\n                cache_creation_cost_usd: row.get(14)?,\n                total_cost_usd: row.get(15)?,\n                is_streaming: row.get::<_, i64>(16)? != 0,\n                latency_ms: row.get::<_, i64>(17)? as u64,\n                first_token_ms: row.get::<_, Option<i64>>(18)?.map(|v| v as u64),\n                duration_ms: row.get::<_, Option<i64>>(19)?.map(|v| v as u64),\n                status_code: row.get::<_, i64>(20)? as u16,\n                error_message: row.get(21)?,\n                created_at: row.get(22)?,\n            })\n        })?;\n\n        let mut logs = Vec::new();\n        let mut provider_cache = HashMap::new();\n        let mut pricing_cache = HashMap::new();\n\n        for row in rows {\n            let mut log = row?;\n            Self::maybe_backfill_log_costs(\n                &conn,\n                &mut log,\n                &mut provider_cache,\n                &mut pricing_cache,\n            )?;\n            logs.push(log);\n        }\n\n        Ok(PaginatedLogs {\n            data: logs,\n            total,\n            page,\n            page_size,\n        })\n    }\n\n    /// 获取单个请求详情\n    pub fn get_request_detail(\n        &self,\n        request_id: &str,\n    ) -> Result<Option<RequestLogDetail>, AppError> {\n        let conn = lock_conn!(self.conn);\n\n        let result = conn.query_row(\n            \"SELECT l.request_id, l.provider_id, p.name as provider_name, l.app_type, l.model,\n                    l.request_model, l.cost_multiplier,\n                    input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,\n                    input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,\n                    is_streaming, latency_ms, first_token_ms, duration_ms,\n                    status_code, error_message, created_at\n             FROM proxy_request_logs l\n             LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type\n             WHERE l.request_id = ?\",\n            [request_id],\n            |row| {\n                Ok(RequestLogDetail {\n                    request_id: row.get(0)?,\n                    provider_id: row.get(1)?,\n                    provider_name: row.get(2)?,\n                    app_type: row.get(3)?,\n                    model: row.get(4)?,\n                    request_model: row.get(5)?,\n                    cost_multiplier: row.get::<_, Option<String>>(6)?.unwrap_or_else(|| \"1\".to_string()),\n                    input_tokens: row.get::<_, i64>(7)? as u32,\n                    output_tokens: row.get::<_, i64>(8)? as u32,\n                    cache_read_tokens: row.get::<_, i64>(9)? as u32,\n                    cache_creation_tokens: row.get::<_, i64>(10)? as u32,\n                    input_cost_usd: row.get(11)?,\n                    output_cost_usd: row.get(12)?,\n                    cache_read_cost_usd: row.get(13)?,\n                    cache_creation_cost_usd: row.get(14)?,\n                    total_cost_usd: row.get(15)?,\n                    is_streaming: row.get::<_, i64>(16)? != 0,\n                    latency_ms: row.get::<_, i64>(17)? as u64,\n                    first_token_ms: row.get::<_, Option<i64>>(18)?.map(|v| v as u64),\n                    duration_ms: row.get::<_, Option<i64>>(19)?.map(|v| v as u64),\n                    status_code: row.get::<_, i64>(20)? as u16,\n                    error_message: row.get(21)?,\n                    created_at: row.get(22)?,\n                })\n            },\n        );\n\n        match result {\n            Ok(mut detail) => {\n                let mut provider_cache = HashMap::new();\n                let mut pricing_cache = HashMap::new();\n                Self::maybe_backfill_log_costs(\n                    &conn,\n                    &mut detail,\n                    &mut provider_cache,\n                    &mut pricing_cache,\n                )?;\n                Ok(Some(detail))\n            }\n            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),\n            Err(e) => Err(AppError::Database(e.to_string())),\n        }\n    }\n\n    /// 检查 Provider 使用限额\n    pub fn check_provider_limits(\n        &self,\n        provider_id: &str,\n        app_type: &str,\n    ) -> Result<ProviderLimitStatus, AppError> {\n        let conn = lock_conn!(self.conn);\n\n        // 获取 provider 的限额设置\n        let (limit_daily, limit_monthly) = conn\n            .query_row(\n                \"SELECT meta FROM providers WHERE id = ? AND app_type = ?\",\n                params![provider_id, app_type],\n                |row| {\n                    let meta_str: String = row.get(0)?;\n                    Ok(meta_str)\n                },\n            )\n            .ok()\n            .and_then(|meta_str| serde_json::from_str::<serde_json::Value>(&meta_str).ok())\n            .map(|meta| {\n                let daily = meta\n                    .get(\"limitDailyUsd\")\n                    .and_then(|v| v.as_str())\n                    .and_then(|s| s.parse::<f64>().ok());\n                let monthly = meta\n                    .get(\"limitMonthlyUsd\")\n                    .and_then(|v| v.as_str())\n                    .and_then(|s| s.parse::<f64>().ok());\n                (daily, monthly)\n            })\n            .unwrap_or((None, None));\n\n        // 计算今日使用量 (detail logs + rollup)\n        let daily_usage: f64 = conn\n            .query_row(\n                \"SELECT COALESCE(SUM(cost), 0) FROM (\n                    SELECT CAST(total_cost_usd AS REAL) as cost\n                    FROM proxy_request_logs\n                    WHERE provider_id = ? AND app_type = ?\n                      AND date(datetime(created_at, 'unixepoch', 'localtime')) = date('now', 'localtime')\n                    UNION ALL\n                    SELECT CAST(total_cost_usd AS REAL)\n                    FROM usage_daily_rollups\n                    WHERE provider_id = ? AND app_type = ?\n                      AND date = date('now', 'localtime')\n                )\",\n                params![provider_id, app_type, provider_id, app_type],\n                |row| row.get(0),\n            )\n            .unwrap_or(0.0);\n\n        // 计算本月使用量 (detail logs + rollup)\n        let monthly_usage: f64 = conn\n            .query_row(\n                \"SELECT COALESCE(SUM(cost), 0) FROM (\n                    SELECT CAST(total_cost_usd AS REAL) as cost\n                    FROM proxy_request_logs\n                    WHERE provider_id = ? AND app_type = ?\n                      AND strftime('%Y-%m', datetime(created_at, 'unixepoch', 'localtime')) = strftime('%Y-%m', 'now', 'localtime')\n                    UNION ALL\n                    SELECT CAST(total_cost_usd AS REAL)\n                    FROM usage_daily_rollups\n                    WHERE provider_id = ? AND app_type = ?\n                      AND strftime('%Y-%m', date) = strftime('%Y-%m', 'now', 'localtime')\n                )\",\n                params![provider_id, app_type, provider_id, app_type],\n                |row| row.get(0),\n            )\n            .unwrap_or(0.0);\n\n        let daily_exceeded = limit_daily\n            .map(|limit| daily_usage >= limit)\n            .unwrap_or(false);\n        let monthly_exceeded = limit_monthly\n            .map(|limit| monthly_usage >= limit)\n            .unwrap_or(false);\n\n        Ok(ProviderLimitStatus {\n            provider_id: provider_id.to_string(),\n            daily_usage: format!(\"{daily_usage:.6}\"),\n            daily_limit: limit_daily.map(|l| format!(\"{l:.2}\")),\n            daily_exceeded,\n            monthly_usage: format!(\"{monthly_usage:.6}\"),\n            monthly_limit: limit_monthly.map(|l| format!(\"{l:.2}\")),\n            monthly_exceeded,\n        })\n    }\n}\n\n/// Provider 限额状态\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct ProviderLimitStatus {\n    pub provider_id: String,\n    pub daily_usage: String,\n    pub daily_limit: Option<String>,\n    pub daily_exceeded: bool,\n    pub monthly_usage: String,\n    pub monthly_limit: Option<String>,\n    pub monthly_exceeded: bool,\n}\n\n#[derive(Clone)]\nstruct PricingInfo {\n    input: rust_decimal::Decimal,\n    output: rust_decimal::Decimal,\n    cache_read: rust_decimal::Decimal,\n    cache_creation: rust_decimal::Decimal,\n}\n\nimpl Database {\n    fn maybe_backfill_log_costs(\n        conn: &Connection,\n        log: &mut RequestLogDetail,\n        provider_cache: &mut HashMap<(String, String), rust_decimal::Decimal>,\n        pricing_cache: &mut HashMap<String, PricingInfo>,\n    ) -> Result<(), AppError> {\n        let total_cost = rust_decimal::Decimal::from_str(&log.total_cost_usd)\n            .unwrap_or(rust_decimal::Decimal::ZERO);\n        let has_cost = total_cost > rust_decimal::Decimal::ZERO;\n        let has_usage = log.input_tokens > 0\n            || log.output_tokens > 0\n            || log.cache_read_tokens > 0\n            || log.cache_creation_tokens > 0;\n\n        if has_cost || !has_usage {\n            return Ok(());\n        }\n\n        let pricing = match Self::get_model_pricing_cached(conn, pricing_cache, &log.model)? {\n            Some(info) => info,\n            None => return Ok(()),\n        };\n        let multiplier = Self::get_cost_multiplier_cached(\n            conn,\n            provider_cache,\n            &log.provider_id,\n            &log.app_type,\n        )?;\n\n        let million = rust_decimal::Decimal::from(1_000_000u64);\n\n        // 与 CostCalculator::calculate 保持一致的计算逻辑：\n        // 1. input_cost 需要扣除 cache_read_tokens（避免缓存部分被重复计费）\n        // 2. 各项成本是基础成本（不含倍率）\n        // 3. 倍率只作用于最终总价\n        let billable_input_tokens =\n            (log.input_tokens as u64).saturating_sub(log.cache_read_tokens as u64);\n        let input_cost =\n            rust_decimal::Decimal::from(billable_input_tokens) * pricing.input / million;\n        let output_cost =\n            rust_decimal::Decimal::from(log.output_tokens as u64) * pricing.output / million;\n        let cache_read_cost = rust_decimal::Decimal::from(log.cache_read_tokens as u64)\n            * pricing.cache_read\n            / million;\n        let cache_creation_cost = rust_decimal::Decimal::from(log.cache_creation_tokens as u64)\n            * pricing.cache_creation\n            / million;\n        // 总成本 = 基础成本之和 × 倍率\n        let base_total = input_cost + output_cost + cache_read_cost + cache_creation_cost;\n        let total_cost = base_total * multiplier;\n\n        log.input_cost_usd = format!(\"{input_cost:.6}\");\n        log.output_cost_usd = format!(\"{output_cost:.6}\");\n        log.cache_read_cost_usd = format!(\"{cache_read_cost:.6}\");\n        log.cache_creation_cost_usd = format!(\"{cache_creation_cost:.6}\");\n        log.total_cost_usd = format!(\"{total_cost:.6}\");\n\n        conn.execute(\n            \"UPDATE proxy_request_logs\n             SET input_cost_usd = ?1,\n                 output_cost_usd = ?2,\n                 cache_read_cost_usd = ?3,\n                 cache_creation_cost_usd = ?4,\n                 total_cost_usd = ?5\n             WHERE request_id = ?6\",\n            params![\n                log.input_cost_usd,\n                log.output_cost_usd,\n                log.cache_read_cost_usd,\n                log.cache_creation_cost_usd,\n                log.total_cost_usd,\n                log.request_id\n            ],\n        )\n        .map_err(|e| AppError::Database(format!(\"更新请求成本失败: {e}\")))?;\n\n        Ok(())\n    }\n\n    fn get_cost_multiplier_cached(\n        conn: &Connection,\n        cache: &mut HashMap<(String, String), rust_decimal::Decimal>,\n        provider_id: &str,\n        app_type: &str,\n    ) -> Result<rust_decimal::Decimal, AppError> {\n        let key = (provider_id.to_string(), app_type.to_string());\n        if let Some(multiplier) = cache.get(&key) {\n            return Ok(*multiplier);\n        }\n\n        let meta_json: Option<String> = conn\n            .query_row(\n                \"SELECT meta FROM providers WHERE id = ? AND app_type = ?\",\n                params![provider_id, app_type],\n                |row| row.get(0),\n            )\n            .optional()\n            .map_err(|e| AppError::Database(format!(\"查询 provider meta 失败: {e}\")))?;\n\n        let multiplier = meta_json\n            .and_then(|meta| serde_json::from_str::<Value>(&meta).ok())\n            .and_then(|value| value.get(\"costMultiplier\").cloned())\n            .and_then(|val| {\n                val.as_str()\n                    .and_then(|s| rust_decimal::Decimal::from_str(s).ok())\n            })\n            .unwrap_or(rust_decimal::Decimal::ONE);\n\n        cache.insert(key, multiplier);\n        Ok(multiplier)\n    }\n\n    fn get_model_pricing_cached(\n        conn: &Connection,\n        cache: &mut HashMap<String, PricingInfo>,\n        model: &str,\n    ) -> Result<Option<PricingInfo>, AppError> {\n        if let Some(info) = cache.get(model) {\n            return Ok(Some(info.clone()));\n        }\n\n        let row = find_model_pricing_row(conn, model)?;\n        let Some((input, output, cache_read, cache_creation)) = row else {\n            return Ok(None);\n        };\n\n        let pricing = PricingInfo {\n            input: rust_decimal::Decimal::from_str(&input)\n                .map_err(|e| AppError::Database(format!(\"解析输入价格失败: {e}\")))?,\n            output: rust_decimal::Decimal::from_str(&output)\n                .map_err(|e| AppError::Database(format!(\"解析输出价格失败: {e}\")))?,\n            cache_read: rust_decimal::Decimal::from_str(&cache_read)\n                .map_err(|e| AppError::Database(format!(\"解析缓存读取价格失败: {e}\")))?,\n            cache_creation: rust_decimal::Decimal::from_str(&cache_creation)\n                .map_err(|e| AppError::Database(format!(\"解析缓存写入价格失败: {e}\")))?,\n        };\n\n        cache.insert(model.to_string(), pricing.clone());\n        Ok(Some(pricing))\n    }\n}\n\npub(crate) fn find_model_pricing_row(\n    conn: &Connection,\n    model_id: &str,\n) -> Result<Option<(String, String, String, String)>, AppError> {\n    // 清洗模型名称：去前缀(/)、去后缀(:)、@ 替换为 -\n    // 例如 moonshotai/gpt-5.2-codex@low:v2 → gpt-5.2-codex-low\n    let cleaned = model_id\n        .rsplit_once('/')\n        .map_or(model_id, |(_, r)| r)\n        .split(':')\n        .next()\n        .unwrap_or(model_id)\n        .trim()\n        .replace('@', \"-\");\n\n    // 精确匹配清洗后的名称\n    let exact = conn\n        .query_row(\n            \"SELECT input_cost_per_million, output_cost_per_million,\n                    cache_read_cost_per_million, cache_creation_cost_per_million\n             FROM model_pricing\n             WHERE model_id = ?1\",\n            [&cleaned],\n            |row| {\n                Ok((\n                    row.get::<_, String>(0)?,\n                    row.get::<_, String>(1)?,\n                    row.get::<_, String>(2)?,\n                    row.get::<_, String>(3)?,\n                ))\n            },\n        )\n        .optional()\n        .map_err(|e| AppError::Database(format!(\"查询模型定价失败: {e}\")))?;\n\n    if exact.is_none() {\n        log::warn!(\"模型 {model_id}（清洗后: {cleaned}）未找到定价信息，成本将记录为 0\");\n    }\n\n    Ok(exact)\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_get_usage_summary() -> Result<(), AppError> {\n        let db = Database::memory()?;\n\n        // 插入测试数据\n        {\n            let conn = lock_conn!(db.conn);\n            conn.execute(\n                \"INSERT INTO proxy_request_logs (\n                    request_id, provider_id, app_type, model,\n                    input_tokens, output_tokens, total_cost_usd,\n                    latency_ms, status_code, created_at\n                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\",\n                params![\"req1\", \"p1\", \"claude\", \"claude-3\", 100, 50, \"0.01\", 100, 200, 1000],\n            )?;\n            conn.execute(\n                \"INSERT INTO proxy_request_logs (\n                    request_id, provider_id, app_type, model,\n                    input_tokens, output_tokens, total_cost_usd,\n                    latency_ms, status_code, created_at\n                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\",\n                params![\"req2\", \"p1\", \"claude\", \"claude-3\", 200, 100, \"0.02\", 150, 200, 2000],\n            )?;\n        }\n\n        let summary = db.get_usage_summary(None, None)?;\n        assert_eq!(summary.total_requests, 2);\n        assert_eq!(summary.success_rate, 100.0);\n\n        Ok(())\n    }\n\n    #[test]\n    fn test_get_model_stats() -> Result<(), AppError> {\n        let db = Database::memory()?;\n\n        // 插入测试数据\n        {\n            let conn = lock_conn!(db.conn);\n            conn.execute(\n                \"INSERT INTO proxy_request_logs (\n                    request_id, provider_id, app_type, model,\n                    input_tokens, output_tokens, total_cost_usd,\n                    latency_ms, status_code, created_at\n                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\",\n                params![\n                    \"req1\",\n                    \"p1\",\n                    \"claude\",\n                    \"claude-3-sonnet\",\n                    100,\n                    50,\n                    \"0.01\",\n                    100,\n                    200,\n                    1000\n                ],\n            )?;\n        }\n\n        let stats = db.get_model_stats()?;\n        assert_eq!(stats.len(), 1);\n        assert_eq!(stats[0].model, \"claude-3-sonnet\");\n        assert_eq!(stats[0].request_count, 1);\n\n        Ok(())\n    }\n\n    #[test]\n    fn test_model_pricing_matching() -> Result<(), AppError> {\n        let db = Database::memory()?;\n        let conn = lock_conn!(db.conn);\n\n        // 准备额外定价数据，覆盖前缀/后缀清洗场景\n        conn.execute(\n            \"INSERT OR REPLACE INTO model_pricing (\n                model_id, display_name, input_cost_per_million, output_cost_per_million,\n                cache_read_cost_per_million, cache_creation_cost_per_million\n            ) VALUES (?, ?, ?, ?, ?, ?)\",\n            params![\n                \"claude-haiku-4.5\",\n                \"Claude Haiku 4.5\",\n                \"1.0\",\n                \"2.0\",\n                \"0.0\",\n                \"0.0\"\n            ],\n        )?;\n\n        // 测试精确匹配（seed_model_pricing 已预置 claude-sonnet-4-5-20250929）\n        let result = find_model_pricing_row(&conn, \"claude-sonnet-4-5-20250929\")?;\n        assert!(\n            result.is_some(),\n            \"应该能精确匹配 claude-sonnet-4-5-20250929\"\n        );\n\n        // 清洗：去除前缀和冒号后缀\n        let result = find_model_pricing_row(&conn, \"anthropic/claude-haiku-4.5\")?;\n        assert!(\n            result.is_some(),\n            \"带前缀的模型 anthropic/claude-haiku-4.5 应能匹配到 claude-haiku-4.5\"\n        );\n        let result = find_model_pricing_row(&conn, \"moonshotai/kimi-k2-0905:exa\")?;\n        assert!(\n            result.is_some(),\n            \"带前缀+冒号后缀的模型应清洗后匹配到 kimi-k2-0905\"\n        );\n\n        // 清洗：@ 替换为 -（seed_model_pricing 已预置 gpt-5.2-codex-low）\n        let result = find_model_pricing_row(&conn, \"gpt-5.2-codex@low\")?;\n        assert!(\n            result.is_some(),\n            \"带 @ 分隔符的模型 gpt-5.2-codex@low 应能匹配到 gpt-5.2-codex-low\"\n        );\n\n        // 测试不存在的模型\n        let result = find_model_pricing_row(&conn, \"unknown-model-123\")?;\n        assert!(result.is_none(), \"不应该匹配不存在的模型\");\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/services/webdav.rs",
    "content": "//! WebDAV HTTP transport layer.\n//!\n//! Low-level HTTP primitives for WebDAV operations (PUT, GET, HEAD, MKCOL, PROPFIND).\n//! The sync protocol logic lives in [`super::webdav_sync`].\n\nuse reqwest::{Method, RequestBuilder, StatusCode, Url};\nuse std::time::Duration;\n\nuse crate::error::AppError;\nuse crate::proxy::http_client;\nuse futures::StreamExt;\n\nconst DEFAULT_TIMEOUT_SECS: u64 = 30;\n/// Timeout for large file transfers (PUT/GET of db.sql, skills.zip).\nconst TRANSFER_TIMEOUT_SECS: u64 = 300;\n\n/// Auth pair: `(username, Some(password))`.\npub type WebDavAuth = Option<(String, Option<String>)>;\n\n// ─── WebDAV extension methods ────────────────────────────────\n\nfn method_propfind() -> Method {\n    Method::from_bytes(b\"PROPFIND\").expect(\"PROPFIND is a valid HTTP method\")\n}\n\nfn method_mkcol() -> Method {\n    Method::from_bytes(b\"MKCOL\").expect(\"MKCOL is a valid HTTP method\")\n}\n\n// ─── URL utilities ───────────────────────────────────────────\n\n/// Parse and validate a WebDAV base URL (must be http or https).\npub fn parse_base_url(raw: &str) -> Result<Url, AppError> {\n    let trimmed = raw.trim();\n    if trimmed.is_empty() {\n        return Err(AppError::localized(\n            \"webdav.base_url.required\",\n            \"WebDAV 地址不能为空\",\n            \"WebDAV URL is required.\",\n        ));\n    }\n    let url = Url::parse(trimmed).map_err(|e| {\n        AppError::localized(\n            \"webdav.base_url.invalid\",\n            format!(\"WebDAV 地址无效: {e}\"),\n            format!(\"Invalid WebDAV URL: {e}\"),\n        )\n    })?;\n    match url.scheme() {\n        \"http\" | \"https\" => Ok(url),\n        _ => Err(AppError::localized(\n            \"webdav.base_url.scheme_invalid\",\n            \"WebDAV 仅支持 http/https 地址\",\n            \"WebDAV URL must use http or https.\",\n        )),\n    }\n}\n\n/// Build a full URL from a base URL string and path segments.\n///\n/// Each segment is individually percent-encoded by the `url` crate.\npub fn build_remote_url(base_url: &str, segments: &[String]) -> Result<String, AppError> {\n    let mut url = parse_base_url(base_url)?;\n    {\n        let mut path = url.path_segments_mut().map_err(|_| {\n            AppError::localized(\n                \"webdav.base_url.unusable\",\n                \"WebDAV 地址格式不支持追加路径\",\n                \"WebDAV URL format does not support appending path segments.\",\n            )\n        })?;\n        path.pop_if_empty();\n        for seg in segments {\n            path.push(seg);\n        }\n    }\n    Ok(url.to_string())\n}\n\n/// Split a slash-delimited path into non-empty segments.\npub fn path_segments(raw: &str) -> impl Iterator<Item = &str> {\n    raw.trim_matches('/').split('/').filter(|s| !s.is_empty())\n}\n\n// ─── Auth ────────────────────────────────────────────────────\n\n/// Build auth from username/password. Returns `None` if username is blank.\npub fn auth_from_credentials(username: &str, password: &str) -> WebDavAuth {\n    let user = username.trim();\n    if user.is_empty() {\n        return None;\n    }\n    Some((user.to_string(), Some(password.to_string())))\n}\n\n/// Apply Basic-Auth to a request builder if auth is present.\nfn apply_auth(builder: RequestBuilder, auth: &WebDavAuth) -> RequestBuilder {\n    match auth {\n        Some((user, pass)) => builder.basic_auth(user, pass.as_deref()),\n        None => builder,\n    }\n}\n\nfn webdav_transport_error(\n    key: &'static str,\n    op_zh: &str,\n    op_en: &str,\n    target_url: &str,\n    err: &reqwest::Error,\n) -> AppError {\n    let (zh_reason, en_reason) = if err.is_timeout() {\n        (\"请求超时\", \"request timed out\")\n    } else if err.is_connect() {\n        (\"连接失败\", \"connection failed\")\n    } else if err.is_request() {\n        (\"请求构造失败\", \"request build failed\")\n    } else {\n        (\"网络请求失败\", \"network request failed\")\n    };\n\n    let safe_url = redact_url(target_url);\n    AppError::localized(\n        key,\n        format!(\"WebDAV {op_zh}失败（{zh_reason}）: {safe_url}\"),\n        format!(\"WebDAV {op_en} failed ({en_reason}): {safe_url}\"),\n    )\n}\n\n// ─── HTTP operations ─────────────────────────────────────────\n\n/// Test WebDAV connectivity via PROPFIND Depth=0 on the base URL.\npub async fn test_connection(base_url: &str, auth: &WebDavAuth) -> Result<(), AppError> {\n    let url = parse_base_url(base_url)?;\n    let client = http_client::get();\n\n    let resp = apply_auth(\n        client\n            .request(method_propfind(), url)\n            .header(\"Depth\", \"0\")\n            .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS)),\n        auth,\n    )\n    .send()\n    .await\n    .map_err(|e| {\n        webdav_transport_error(\n            \"webdav.connection_failed\",\n            \"连接\",\n            \"connection\",\n            base_url,\n            &e,\n        )\n    })?;\n\n    if resp.status().is_success() || resp.status() == StatusCode::MULTI_STATUS {\n        return Ok(());\n    }\n    Err(webdav_status_error(\"PROPFIND\", resp.status(), base_url))\n}\n\n/// Ensure a chain of remote directories exists.\n///\n/// Uses optimistic MKCOL: try creating first, fall back to PROPFIND verification\n/// on ambiguous responses. This halves the round-trips vs PROPFIND-first approach.\npub async fn ensure_remote_directories(\n    base_url: &str,\n    segments: &[String],\n    auth: &WebDavAuth,\n) -> Result<(), AppError> {\n    if segments.is_empty() {\n        return Ok(());\n    }\n    let client = http_client::get();\n\n    for depth in 1..=segments.len() {\n        let prefix = &segments[..depth];\n        let url = build_remote_url(base_url, prefix)?;\n        let dir_url = if url.ends_with('/') {\n            url\n        } else {\n            format!(\"{url}/\")\n        };\n\n        let resp = apply_auth(\n            client\n                .request(method_mkcol(), &dir_url)\n                .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS)),\n            auth,\n        )\n        .send()\n        .await\n        .map_err(|e| {\n            webdav_transport_error(\n                \"webdav.mkcol_failed\",\n                \"MKCOL 请求\",\n                \"MKCOL request\",\n                &dir_url,\n                &e,\n            )\n        })?;\n\n        let status = resp.status();\n        match status {\n            s if s == StatusCode::CREATED || s.is_success() => {\n                log::info!(\"[WebDAV] MKCOL ok: {}\", redact_url(&dir_url));\n            }\n            // 405 commonly means \"already exists\" on many WebDAV servers\n            StatusCode::METHOD_NOT_ALLOWED => {}\n            // Ambiguous — verify directory actually exists via PROPFIND\n            s if s == StatusCode::CONFLICT || s.is_redirection() => {\n                if !propfind_exists(&client, &dir_url, auth).await? {\n                    return Err(webdav_status_error(\"MKCOL\", status, &dir_url));\n                }\n            }\n            _ => {\n                return Err(webdav_status_error(\"MKCOL\", status, &dir_url));\n            }\n        }\n    }\n    Ok(())\n}\n\n/// PUT bytes to a remote WebDAV URL.\npub async fn put_bytes(\n    url: &str,\n    auth: &WebDavAuth,\n    bytes: Vec<u8>,\n    content_type: &str,\n) -> Result<(), AppError> {\n    let client = http_client::get();\n    let resp = apply_auth(\n        client\n            .put(url)\n            .header(\"Content-Type\", content_type)\n            .body(bytes)\n            .timeout(Duration::from_secs(TRANSFER_TIMEOUT_SECS)),\n        auth,\n    )\n    .send()\n    .await\n    .map_err(|e| webdav_transport_error(\"webdav.put_failed\", \"PUT 请求\", \"PUT request\", url, &e))?;\n\n    if resp.status().is_success() {\n        return Ok(());\n    }\n    Err(webdav_status_error(\"PUT\", resp.status(), url))\n}\n\n/// GET bytes from a remote WebDAV URL. Returns `None` on 404.\n///\n/// On success returns `(body_bytes, optional_etag)`.\npub async fn get_bytes(\n    url: &str,\n    auth: &WebDavAuth,\n    max_bytes: usize,\n) -> Result<Option<(Vec<u8>, Option<String>)>, AppError> {\n    let client = http_client::get();\n    let resp = apply_auth(\n        client\n            .get(url)\n            .timeout(Duration::from_secs(TRANSFER_TIMEOUT_SECS)),\n        auth,\n    )\n    .send()\n    .await\n    .map_err(|e| webdav_transport_error(\"webdav.get_failed\", \"GET 请求\", \"GET request\", url, &e))?;\n\n    if resp.status() == StatusCode::NOT_FOUND {\n        return Ok(None);\n    }\n    if !resp.status().is_success() {\n        return Err(webdav_status_error(\"GET\", resp.status(), url));\n    }\n    ensure_content_length_within_limit(resp.headers(), max_bytes, url)?;\n\n    let etag = resp\n        .headers()\n        .get(\"etag\")\n        .and_then(|v| v.to_str().ok())\n        .map(|s| s.to_string());\n    let mut bytes = Vec::new();\n    let mut stream = resp.bytes_stream();\n    while let Some(chunk) = stream.next().await {\n        let chunk = chunk.map_err(|e| {\n            AppError::localized(\n                \"webdav.response_read_failed\",\n                format!(\"读取 WebDAV 响应失败: {e}\"),\n                format!(\"Failed to read WebDAV response: {e}\"),\n            )\n        })?;\n        if bytes.len().saturating_add(chunk.len()) > max_bytes {\n            return Err(response_too_large_error(url, max_bytes));\n        }\n        bytes.extend_from_slice(&chunk);\n    }\n    Ok(Some((bytes, etag)))\n}\n\n/// HEAD request to retrieve the ETag. Returns `None` on 404.\npub async fn head_etag(url: &str, auth: &WebDavAuth) -> Result<Option<String>, AppError> {\n    let client = http_client::get();\n    let resp = apply_auth(\n        client\n            .head(url)\n            .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS)),\n        auth,\n    )\n    .send()\n    .await\n    .map_err(|e| {\n        webdav_transport_error(\"webdav.head_failed\", \"HEAD 请求\", \"HEAD request\", url, &e)\n    })?;\n\n    if resp.status() == StatusCode::NOT_FOUND {\n        return Ok(None);\n    }\n    if !resp.status().is_success() {\n        return Err(webdav_status_error(\"HEAD\", resp.status(), url));\n    }\n    Ok(resp\n        .headers()\n        .get(\"etag\")\n        .and_then(|v| v.to_str().ok())\n        .map(|s| s.to_string()))\n}\n\n// ─── Internal helpers ────────────────────────────────────────\n\n/// PROPFIND Depth=0 to check if a remote resource exists.\nasync fn propfind_exists(\n    client: &reqwest::Client,\n    url: &str,\n    auth: &WebDavAuth,\n) -> Result<bool, AppError> {\n    let resp = apply_auth(\n        client\n            .request(method_propfind(), url)\n            .header(\"Depth\", \"0\")\n            .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS)),\n        auth,\n    )\n    .send()\n    .await;\n    match resp {\n        Ok(r) => Ok(r.status().is_success() || r.status() == StatusCode::MULTI_STATUS),\n        Err(e) => {\n            log::warn!(\n                \"[WebDAV] PROPFIND check failed for {}: {e}\",\n                redact_url(url)\n            );\n            Ok(false)\n        }\n    }\n}\n\n// ─── Service detection & error helpers ───────────────────────\n\n/// Check if a URL points to Jianguoyun (坚果云).\npub fn is_jianguoyun(url: &str) -> bool {\n    Url::parse(url)\n        .ok()\n        .and_then(|u| u.host_str().map(|h| h.to_lowercase()))\n        .map(|host| host.contains(\"jianguoyun.com\") || host.contains(\"nutstore\"))\n        .unwrap_or(false)\n}\n\n/// Build an `AppError` with service-specific hints for WebDAV failures.\npub fn webdav_status_error(op: &str, status: StatusCode, url: &str) -> AppError {\n    let safe_url = redact_url(url);\n    let mut zh = format!(\"WebDAV {op} 失败: {status} ({safe_url})\");\n    let mut en = format!(\"WebDAV {op} failed: {status} ({safe_url})\");\n    let jgy = is_jianguoyun(url);\n\n    if matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) {\n        if jgy {\n            zh.push_str(\"。坚果云请使用「第三方应用密码」，并确认地址指向 /dav/ 下的目录。\");\n            en.push_str(\n                \". For Jianguoyun, use an app-specific password and ensure the URL points under /dav/.\",\n            );\n        } else {\n            zh.push_str(\"。请检查 WebDAV 用户名、密码及目录读写权限。\");\n            en.push_str(\". Please check WebDAV username/password and directory permissions.\");\n        }\n    } else if jgy && (status == StatusCode::NOT_FOUND || status.is_redirection()) {\n        zh.push_str(\"。坚果云常见原因：地址不在 /dav/ 可写目录下。\");\n        en.push_str(\". Common Jianguoyun cause: URL is outside a writable /dav/ directory.\");\n    } else if op == \"MKCOL\" && status == StatusCode::CONFLICT {\n        if jgy {\n            zh.push_str(\"。坚果云不允许自动创建顶层文件夹，请先在网页端手动创建后重试。\");\n            en.push_str(\n                \". Jianguoyun does not allow creating top-level folders automatically; create it manually first.\",\n            );\n        } else {\n            zh.push_str(\"。请确认上级目录存在。\");\n            en.push_str(\". Please ensure the parent directory exists.\");\n        }\n    }\n\n    AppError::localized(\"webdav.http.status\", zh, en)\n}\n\nfn redact_url(raw: &str) -> String {\n    match Url::parse(raw) {\n        Ok(mut parsed) => {\n            let _ = parsed.set_username(\"\");\n            let _ = parsed.set_password(None);\n\n            let mut out = format!(\"{}://\", parsed.scheme());\n            if let Some(host) = parsed.host_str() {\n                out.push_str(host);\n            }\n            if let Some(port) = parsed.port() {\n                out.push(':');\n                out.push_str(&port.to_string());\n            }\n            out.push_str(parsed.path());\n\n            let mut keys: Vec<String> = parsed.query_pairs().map(|(k, _)| k.into_owned()).collect();\n            keys.sort();\n            keys.dedup();\n            if !keys.is_empty() {\n                out.push_str(\"?[keys:\");\n                out.push_str(&keys.join(\",\"));\n                out.push(']');\n            }\n            out\n        }\n        Err(_) => raw.split('?').next().unwrap_or(raw).to_string(),\n    }\n}\n\nfn response_too_large_error(url: &str, max_bytes: usize) -> AppError {\n    let max_mb = max_bytes / 1024 / 1024;\n    AppError::localized(\n        \"webdav.response_too_large\",\n        format!(\n            \"WebDAV 响应体超过上限（{} MB）: {}\",\n            max_mb,\n            redact_url(url)\n        ),\n        format!(\n            \"WebDAV response body exceeds limit ({} MB): {}\",\n            max_mb,\n            redact_url(url)\n        ),\n    )\n}\n\nfn ensure_content_length_within_limit(\n    headers: &reqwest::header::HeaderMap,\n    max_bytes: usize,\n    url: &str,\n) -> Result<(), AppError> {\n    let Some(content_length) = headers.get(reqwest::header::CONTENT_LENGTH) else {\n        return Ok(());\n    };\n    let Ok(raw) = content_length.to_str() else {\n        return Ok(());\n    };\n    let Ok(value) = raw.parse::<u64>() else {\n        return Ok(());\n    };\n    if value > max_bytes as u64 {\n        return Err(response_too_large_error(url, max_bytes));\n    }\n    Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use reqwest::header::{HeaderMap, HeaderValue, CONTENT_LENGTH};\n\n    #[test]\n    fn build_remote_url_encodes_path_segments() {\n        let url = build_remote_url(\n            \"https://dav.example.com/remote.php/dav/files/demo/\",\n            &[\n                \"cc switch-sync\".to_string(),\n                \"v2\".to_string(),\n                \"db-v6\".to_string(),\n                \"default profile\".to_string(),\n                \"manifest.json\".to_string(),\n            ],\n        )\n        .unwrap();\n        assert_eq!(\n            url,\n            \"https://dav.example.com/remote.php/dav/files/demo/cc%20switch-sync/v2/db-v6/default%20profile/manifest.json\"\n        );\n        assert!(!url.contains(\"//cc\"), \"should not have double-slash\");\n    }\n\n    #[test]\n    fn is_jianguoyun_detects_correctly() {\n        assert!(is_jianguoyun(\"https://dav.jianguoyun.com/dav\"));\n        assert!(is_jianguoyun(\"https://dav.jianguoyun.com/dav/folder\"));\n        assert!(!is_jianguoyun(\"https://nextcloud.example.com/dav\"));\n    }\n\n    #[test]\n    fn path_segments_splits_correctly() {\n        let segs: Vec<_> = path_segments(\"/a/b/c/\").collect();\n        assert_eq!(segs, vec![\"a\", \"b\", \"c\"]);\n\n        let segs: Vec<_> = path_segments(\"single\").collect();\n        assert_eq!(segs, vec![\"single\"]);\n\n        let segs: Vec<_> = path_segments(\"\").collect();\n        assert!(segs.is_empty());\n    }\n\n    #[test]\n    fn auth_from_credentials_trims_and_rejects_blank() {\n        assert!(auth_from_credentials(\"  \", \"pass\").is_none());\n        let auth = auth_from_credentials(\" user \", \"pass\");\n        assert_eq!(auth, Some((\"user\".to_string(), Some(\"pass\".to_string()))));\n    }\n\n    #[test]\n    fn redact_url_hides_credentials_and_query_values() {\n        let redacted = redact_url(\"https://alice:secret@example.com:8443/dav?token=abc&foo=1\");\n        assert_eq!(redacted, \"https://example.com:8443/dav?[keys:foo,token]\");\n        assert!(!redacted.contains(\"secret\"));\n    }\n\n    #[test]\n    fn ensure_content_length_within_limit_accepts_missing_or_small_values() {\n        let empty = HeaderMap::new();\n        assert!(\n            ensure_content_length_within_limit(&empty, 1024, \"https://dav.example.com\").is_ok()\n        );\n\n        let mut small = HeaderMap::new();\n        small.insert(CONTENT_LENGTH, HeaderValue::from_static(\"1024\"));\n        assert!(\n            ensure_content_length_within_limit(&small, 1024, \"https://dav.example.com\").is_ok()\n        );\n    }\n\n    #[test]\n    fn ensure_content_length_within_limit_rejects_oversized_values() {\n        let mut large = HeaderMap::new();\n        large.insert(CONTENT_LENGTH, HeaderValue::from_static(\"2048\"));\n\n        let err = ensure_content_length_within_limit(&large, 1024, \"https://dav.example.com\")\n            .expect_err(\"oversized response should be rejected\");\n        assert!(\n            err.to_string().contains(\"too large\") || err.to_string().contains(\"超过\"),\n            \"unexpected error: {err}\"\n        );\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/services/webdav_auto_sync.rs",
    "content": "use std::sync::atomic::{AtomicUsize, Ordering};\nuse std::sync::Arc;\nuse std::sync::OnceLock;\nuse std::time::{Duration, Instant};\n\nuse serde_json::json;\nuse tauri::{AppHandle, Emitter};\nuse tokio::sync::mpsc::error::TrySendError;\nuse tokio::sync::mpsc::{channel, Receiver, Sender};\n\nuse crate::error::AppError;\nuse crate::services::webdav_sync as webdav_sync_service;\nuse crate::settings::{self, WebDavSyncSettings};\n\nconst AUTO_SYNC_DEBOUNCE_MS: u64 = 1000;\npub(crate) const MAX_AUTO_SYNC_WAIT_MS: u64 = 10_000;\n\nstatic DB_CHANGE_TX: OnceLock<Sender<String>> = OnceLock::new();\nstatic AUTO_SYNC_SUPPRESS_DEPTH: AtomicUsize = AtomicUsize::new(0);\n\npub(crate) struct AutoSyncSuppressionGuard;\n\nimpl AutoSyncSuppressionGuard {\n    pub fn new() -> Self {\n        AUTO_SYNC_SUPPRESS_DEPTH.fetch_add(1, Ordering::SeqCst);\n        Self\n    }\n}\n\nimpl Drop for AutoSyncSuppressionGuard {\n    fn drop(&mut self) {\n        let _ =\n            AUTO_SYNC_SUPPRESS_DEPTH.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |value| {\n                Some(value.saturating_sub(1))\n            });\n    }\n}\n\npub(crate) fn is_auto_sync_suppressed() -> bool {\n    AUTO_SYNC_SUPPRESS_DEPTH.load(Ordering::SeqCst) > 0\n}\n\npub fn should_trigger_for_table(table: &str) -> bool {\n    let normalized = table.trim().to_ascii_lowercase();\n    matches!(\n        normalized.as_str(),\n        \"providers\"\n            | \"provider_endpoints\"\n            | \"mcp_servers\"\n            | \"prompts\"\n            | \"skills\"\n            | \"skill_repos\"\n            | \"settings\"\n            | \"proxy_config\"\n    )\n}\n\npub(crate) fn enqueue_change_signal(tx: &Sender<String>, table: &str) -> bool {\n    match tx.try_send(table.to_string()) {\n        Ok(()) => true,\n        Err(TrySendError::Full(_)) | Err(TrySendError::Closed(_)) => false,\n    }\n}\n\npub(crate) fn auto_sync_wait_duration(started_at: Instant, now: Instant) -> Option<Duration> {\n    let max_wait = Duration::from_millis(MAX_AUTO_SYNC_WAIT_MS);\n    let debounce = Duration::from_millis(AUTO_SYNC_DEBOUNCE_MS);\n    let elapsed = now.saturating_duration_since(started_at);\n    if elapsed >= max_wait {\n        return None;\n    }\n    Some(debounce.min(max_wait - elapsed))\n}\n\nfn should_run_auto_sync(settings: Option<&WebDavSyncSettings>) -> bool {\n    let Some(sync) = settings else {\n        return false;\n    };\n    sync.enabled && sync.auto_sync\n}\n\nfn persist_auto_sync_error(settings: &mut WebDavSyncSettings, error: &AppError) {\n    settings.status.last_error = Some(error.to_string());\n    settings.status.last_error_source = Some(\"auto\".to_string());\n    let _ = settings::update_webdav_sync_status(settings.status.clone());\n}\n\nfn emit_auto_sync_status_updated(app: &AppHandle, status: &str, error: Option<&str>) {\n    let payload = match error {\n        Some(message) => json!({\n            \"source\": \"auto\",\n            \"status\": status,\n            \"error\": message,\n        }),\n        None => json!({\n            \"source\": \"auto\",\n            \"status\": status,\n        }),\n    };\n\n    if let Err(err) = app.emit(\"webdav-sync-status-updated\", payload) {\n        log::debug!(\"[WebDAV] failed to emit sync status update event: {err}\");\n    }\n}\n\nasync fn run_auto_sync_upload(\n    db: &crate::database::Database,\n    app: &AppHandle,\n) -> Result<(), AppError> {\n    let mut settings = settings::get_webdav_sync_settings();\n    if !should_run_auto_sync(settings.as_ref()) {\n        return Ok(());\n    }\n\n    let mut sync_settings = match settings.take() {\n        Some(value) => value,\n        None => return Ok(()),\n    };\n\n    let result = webdav_sync_service::run_with_sync_lock(webdav_sync_service::upload(\n        db,\n        &mut sync_settings,\n    ))\n    .await;\n    match result {\n        Ok(_) => {\n            emit_auto_sync_status_updated(app, \"success\", None);\n            Ok(())\n        }\n        Err(err) => {\n            persist_auto_sync_error(&mut sync_settings, &err);\n            emit_auto_sync_status_updated(app, \"error\", Some(&err.to_string()));\n            Err(err)\n        }\n    }\n}\n\npub fn notify_db_changed(table: &str) {\n    if is_auto_sync_suppressed() {\n        return;\n    }\n    if !should_trigger_for_table(table) {\n        return;\n    }\n    let Some(tx) = DB_CHANGE_TX.get() else {\n        return;\n    };\n    let _ = enqueue_change_signal(tx, table);\n}\n\npub fn start_worker(db: Arc<crate::database::Database>, app: tauri::AppHandle) {\n    if DB_CHANGE_TX.get().is_some() {\n        return;\n    }\n\n    // Buffer size 1 is enough: we only need \"dirty\" signals, not every event.\n    let (tx, rx) = channel::<String>(1);\n    if DB_CHANGE_TX.set(tx).is_err() {\n        return;\n    }\n\n    tauri::async_runtime::spawn(async move {\n        run_worker_loop(db, rx, app).await;\n    });\n}\n\nasync fn run_worker_loop(\n    db: Arc<crate::database::Database>,\n    mut rx: Receiver<String>,\n    app: tauri::AppHandle,\n) {\n    while let Some(first_table) = rx.recv().await {\n        let started_at = Instant::now();\n        let mut merged_count = 1usize;\n\n        loop {\n            let Some(wait_for) = auto_sync_wait_duration(started_at, Instant::now()) else {\n                break;\n            };\n            let timeout = tokio::time::timeout(wait_for, rx.recv()).await;\n\n            match timeout {\n                Ok(Some(_)) => merged_count += 1,\n                Ok(None) => return,\n                Err(_) => break,\n            }\n        }\n\n        log::debug!(\n            \"[WebDAV][AutoSync] Triggered by table={first_table}, merged_changes={merged_count}\"\n        );\n\n        if let Err(err) = run_auto_sync_upload(&db, &app).await {\n            log::warn!(\"[WebDAV][AutoSync] Upload failed: {err}\");\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::{\n        auto_sync_wait_duration, enqueue_change_signal, is_auto_sync_suppressed,\n        should_run_auto_sync, should_trigger_for_table, AutoSyncSuppressionGuard,\n        MAX_AUTO_SYNC_WAIT_MS,\n    };\n    use crate::settings::WebDavSyncSettings;\n    use std::time::{Duration, Instant};\n    use tokio::sync::mpsc::channel;\n\n    #[test]\n    fn should_trigger_sync_for_config_tables_only() {\n        assert!(should_trigger_for_table(\"providers\"));\n        assert!(should_trigger_for_table(\"settings\"));\n        assert!(!should_trigger_for_table(\"proxy_request_logs\"));\n        assert!(!should_trigger_for_table(\"provider_health\"));\n    }\n\n    #[test]\n    fn suppression_guard_enables_and_restores_state() {\n        assert!(!is_auto_sync_suppressed());\n        {\n            let _guard = AutoSyncSuppressionGuard::new();\n            assert!(is_auto_sync_suppressed());\n        }\n        assert!(!is_auto_sync_suppressed());\n    }\n\n    #[test]\n    fn max_wait_caps_flush_latency_for_continuous_events() {\n        let started = Instant::now();\n        let later = started + Duration::from_millis(MAX_AUTO_SYNC_WAIT_MS + 1);\n        assert!(auto_sync_wait_duration(started, later).is_none());\n    }\n\n    #[tokio::test]\n    async fn enqueue_change_signal_drops_when_channel_is_full() {\n        let (tx, _rx) = channel::<String>(1);\n        assert!(enqueue_change_signal(&tx, \"providers\"));\n        assert!(!enqueue_change_signal(&tx, \"providers\"));\n    }\n\n    #[test]\n    fn should_run_auto_sync_requires_enabled_and_auto_sync_flag() {\n        assert!(!should_run_auto_sync(None));\n\n        let disabled = WebDavSyncSettings {\n            enabled: false,\n            auto_sync: true,\n            ..WebDavSyncSettings::default()\n        };\n        assert!(!should_run_auto_sync(Some(&disabled)));\n\n        let auto_sync_off = WebDavSyncSettings {\n            enabled: true,\n            auto_sync: false,\n            ..WebDavSyncSettings::default()\n        };\n        assert!(!should_run_auto_sync(Some(&auto_sync_off)));\n\n        let enabled = WebDavSyncSettings {\n            enabled: true,\n            auto_sync: true,\n            ..WebDavSyncSettings::default()\n        };\n        assert!(should_run_auto_sync(Some(&enabled)));\n    }\n\n    #[test]\n    fn service_layer_does_not_depend_on_commands_layer() {\n        let source = include_str!(\"webdav_auto_sync.rs\");\n        let needle = [\"crate\", \"commands\", \"\"].join(\"::\");\n        assert!(\n            !source.contains(&needle),\n            \"services layer should not depend on commands layer\"\n        );\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/services/webdav_sync/archive.rs",
    "content": "use std::collections::HashSet;\nuse std::fs;\nuse std::io::{Read, Write};\nuse std::path::{Path, PathBuf};\n\nuse tempfile::{tempdir, TempDir};\nuse zip::write::SimpleFileOptions;\nuse zip::DateTime;\n\nuse crate::error::AppError;\nuse crate::services::skill::SkillService;\n\nuse super::{io_context_localized, localized, MAX_SYNC_ARTIFACT_BYTES, REMOTE_SKILLS_ZIP};\n\n/// Maximum number of entries allowed in a zip archive.\nconst MAX_EXTRACT_ENTRIES: usize = 10_000;\n\npub(super) struct SkillsBackup {\n    _tmp: TempDir,\n    backup_dir: PathBuf,\n    ssot_path: PathBuf,\n    existed: bool,\n}\n\npub(super) fn zip_skills_ssot(dest_path: &Path) -> Result<(), AppError> {\n    let source = SkillService::get_ssot_dir().map_err(|e| {\n        localized(\n            \"webdav.sync.skills_ssot_dir_failed\",\n            format!(\"获取 Skills SSOT 目录失败: {e}\"),\n            format!(\"Failed to resolve Skills SSOT directory: {e}\"),\n        )\n    })?;\n    if let Some(parent) = dest_path.parent() {\n        fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;\n    }\n\n    let file = fs::File::create(dest_path).map_err(|e| AppError::io(dest_path, e))?;\n    let mut writer = zip::ZipWriter::new(file);\n    let options = SimpleFileOptions::default()\n        .compression_method(zip::CompressionMethod::Deflated)\n        .last_modified_time(DateTime::default());\n\n    if source.exists() {\n        let canonical_root = fs::canonicalize(&source).unwrap_or_else(|_| source.clone());\n        let mut visited = HashSet::new();\n        mark_visited_dir(&canonical_root, &mut visited)?;\n        zip_dir_recursive(\n            &canonical_root,\n            &canonical_root,\n            &mut writer,\n            options,\n            &mut visited,\n        )?;\n    }\n\n    writer.finish().map_err(|e| {\n        localized(\n            \"webdav.sync.skills_zip_write_failed\",\n            format!(\"写入 skills.zip 失败: {e}\"),\n            format!(\"Failed to write skills.zip: {e}\"),\n        )\n    })?;\n    Ok(())\n}\n\npub(super) fn restore_skills_zip(raw: &[u8]) -> Result<(), AppError> {\n    let tmp = tempdir().map_err(|e| {\n        io_context_localized(\n            \"webdav.sync.skills_extract_tmpdir_failed\",\n            \"创建 skills 解压临时目录失败\",\n            \"Failed to create temporary directory for skills extraction\",\n            e,\n        )\n    })?;\n    let zip_path = tmp.path().join(REMOTE_SKILLS_ZIP);\n    fs::write(&zip_path, raw).map_err(|e| AppError::io(&zip_path, e))?;\n\n    let file = fs::File::open(&zip_path).map_err(|e| AppError::io(&zip_path, e))?;\n    let mut archive = zip::ZipArchive::new(file).map_err(|e| {\n        localized(\n            \"webdav.sync.skills_zip_parse_failed\",\n            format!(\"解析 skills.zip 失败: {e}\"),\n            format!(\"Failed to parse skills.zip: {e}\"),\n        )\n    })?;\n\n    let extracted = tmp.path().join(\"skills-extracted\");\n    fs::create_dir_all(&extracted).map_err(|e| AppError::io(&extracted, e))?;\n\n    if archive.len() > MAX_EXTRACT_ENTRIES {\n        return Err(localized(\n            \"webdav.sync.skills_zip_too_many_entries\",\n            format!(\n                \"skills.zip 条目数过多（{}），上限 {MAX_EXTRACT_ENTRIES}\",\n                archive.len()\n            ),\n            format!(\n                \"skills.zip has too many entries ({}), limit is {MAX_EXTRACT_ENTRIES}\",\n                archive.len()\n            ),\n        ));\n    }\n\n    let mut total_bytes: u64 = 0;\n    for idx in 0..archive.len() {\n        let mut entry = archive.by_index(idx).map_err(|e| {\n            localized(\n                \"webdav.sync.skills_zip_entry_read_failed\",\n                format!(\"读取 ZIP 项失败: {e}\"),\n                format!(\"Failed to read ZIP entry: {e}\"),\n            )\n        })?;\n        let Some(safe_name) = entry.enclosed_name() else {\n            continue;\n        };\n        let out_path = extracted.join(safe_name);\n        if entry.is_dir() {\n            fs::create_dir_all(&out_path).map_err(|e| AppError::io(&out_path, e))?;\n            continue;\n        }\n        if let Some(parent) = out_path.parent() {\n            fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;\n        }\n        let mut out = fs::File::create(&out_path).map_err(|e| AppError::io(&out_path, e))?;\n        let _written = copy_entry_with_total_limit(\n            &mut entry,\n            &mut out,\n            &mut total_bytes,\n            MAX_SYNC_ARTIFACT_BYTES,\n            &out_path,\n        )?;\n    }\n\n    let ssot = SkillService::get_ssot_dir().map_err(|e| {\n        localized(\n            \"webdav.sync.skills_ssot_dir_failed\",\n            format!(\"获取 Skills SSOT 目录失败: {e}\"),\n            format!(\"Failed to resolve Skills SSOT directory: {e}\"),\n        )\n    })?;\n    let bak = ssot.with_extension(\"bak\");\n\n    if ssot.exists() {\n        if bak.exists() {\n            let _ = fs::remove_dir_all(&bak);\n        }\n        fs::rename(&ssot, &bak).map_err(|e| AppError::io(&ssot, e))?;\n    }\n\n    if let Err(e) = copy_dir_recursive(&extracted, &ssot) {\n        if bak.exists() {\n            let _ = fs::remove_dir_all(&ssot);\n            let _ = fs::rename(&bak, &ssot);\n        }\n        return Err(e);\n    }\n\n    let _ = fs::remove_dir_all(&bak);\n    Ok(())\n}\n\npub(super) fn backup_current_skills() -> Result<SkillsBackup, AppError> {\n    let ssot = SkillService::get_ssot_dir().map_err(|e| {\n        localized(\n            \"webdav.sync.skills_ssot_dir_failed\",\n            format!(\"获取 Skills SSOT 目录失败: {e}\"),\n            format!(\"Failed to resolve Skills SSOT directory: {e}\"),\n        )\n    })?;\n    let tmp = tempdir().map_err(|e| {\n        io_context_localized(\n            \"webdav.sync.skills_backup_tmpdir_failed\",\n            \"创建 skills 备份临时目录失败\",\n            \"Failed to create temporary directory for skills backup\",\n            e,\n        )\n    })?;\n    let backup_dir = tmp.path().join(\"skills-backup\");\n\n    let existed = ssot.exists();\n    if existed {\n        copy_dir_recursive(&ssot, &backup_dir)?;\n    }\n\n    Ok(SkillsBackup {\n        _tmp: tmp,\n        backup_dir,\n        ssot_path: ssot,\n        existed,\n    })\n}\n\npub(super) fn restore_skills_from_backup(backup: &SkillsBackup) -> Result<(), AppError> {\n    if backup.ssot_path.exists() {\n        fs::remove_dir_all(&backup.ssot_path).map_err(|e| AppError::io(&backup.ssot_path, e))?;\n    }\n\n    if backup.existed {\n        copy_dir_recursive(&backup.backup_dir, &backup.ssot_path)?;\n    }\n\n    Ok(())\n}\n\nfn zip_dir_recursive(\n    root: &Path,\n    current: &Path,\n    writer: &mut zip::ZipWriter<fs::File>,\n    options: SimpleFileOptions,\n    visited: &mut HashSet<PathBuf>,\n) -> Result<(), AppError> {\n    let mut entries: Vec<_> = fs::read_dir(current)\n        .map_err(|e| AppError::io(current, e))?\n        .collect::<Result<Vec<_>, _>>()\n        .map_err(|e| AppError::io(current, e))?;\n    entries.sort_by_key(|e| e.file_name());\n\n    for entry in entries {\n        let path = entry.path();\n        let name = entry.file_name();\n        let name_str = name.to_string_lossy();\n\n        if name_str.starts_with('.') {\n            continue;\n        }\n\n        let real_path = match fs::canonicalize(&path) {\n            Ok(p) if p.starts_with(root) => p,\n            Ok(_) => {\n                log::warn!(\n                    \"[WebDAV] Skipping symlink outside skills root: {}\",\n                    path.display()\n                );\n                continue;\n            }\n            Err(_) => path.clone(),\n        };\n\n        let rel = real_path\n            .strip_prefix(root)\n            .or_else(|_| path.strip_prefix(root))\n            .map_err(|e| {\n                localized(\n                    \"webdav.sync.zip_relative_path_failed\",\n                    format!(\"生成 ZIP 相对路径失败: {e}\"),\n                    format!(\"Failed to build relative ZIP path: {e}\"),\n                )\n            })?;\n        let rel_str = rel.to_string_lossy().replace('\\\\', \"/\");\n\n        if real_path.is_dir() {\n            if !mark_visited_dir(&real_path, visited)? {\n                log::warn!(\n                    \"[WebDAV] Skipping already visited directory: {}\",\n                    real_path.display()\n                );\n                continue;\n            }\n            writer\n                .add_directory(format!(\"{rel_str}/\"), options)\n                .map_err(|e| {\n                    localized(\n                        \"webdav.sync.zip_add_directory_failed\",\n                        format!(\"写入 ZIP 目录失败: {e}\"),\n                        format!(\"Failed to write ZIP directory entry: {e}\"),\n                    )\n                })?;\n            zip_dir_recursive(root, &real_path, writer, options, visited)?;\n        } else {\n            writer.start_file(&rel_str, options).map_err(|e| {\n                localized(\n                    \"webdav.sync.zip_start_file_failed\",\n                    format!(\"写入 ZIP 文件头失败: {e}\"),\n                    format!(\"Failed to start ZIP file entry: {e}\"),\n                )\n            })?;\n            let mut file = fs::File::open(&real_path).map_err(|e| AppError::io(&real_path, e))?;\n            let mut buf = Vec::new();\n            file.read_to_end(&mut buf)\n                .map_err(|e| AppError::io(&real_path, e))?;\n            writer.write_all(&buf).map_err(|e| {\n                localized(\n                    \"webdav.sync.zip_write_file_failed\",\n                    format!(\"写入 ZIP 文件内容失败: {e}\"),\n                    format!(\"Failed to write ZIP file content: {e}\"),\n                )\n            })?;\n        }\n    }\n    Ok(())\n}\n\nfn copy_dir_recursive(src: &Path, dest: &Path) -> Result<(), AppError> {\n    let mut visited = HashSet::new();\n    copy_dir_recursive_inner(src, dest, &mut visited)\n}\n\nfn copy_dir_recursive_inner(\n    src: &Path,\n    dest: &Path,\n    visited: &mut HashSet<PathBuf>,\n) -> Result<(), AppError> {\n    if !src.exists() {\n        return Ok(());\n    }\n    if !mark_visited_dir(src, visited)? {\n        log::warn!(\n            \"[WebDAV] Skipping already visited copy path: {}\",\n            src.display()\n        );\n        return Ok(());\n    }\n    fs::create_dir_all(dest).map_err(|e| AppError::io(dest, e))?;\n    for entry in fs::read_dir(src).map_err(|e| AppError::io(src, e))? {\n        let entry = entry.map_err(|e| AppError::io(src, e))?;\n        let path = entry.path();\n        let dest_path = dest.join(entry.file_name());\n        if path.is_dir() {\n            copy_dir_recursive_inner(&path, &dest_path, visited)?;\n        } else {\n            fs::copy(&path, &dest_path).map_err(|e| AppError::io(&dest_path, e))?;\n        }\n    }\n    Ok(())\n}\n\nfn mark_visited_dir(path: &Path, visited: &mut HashSet<PathBuf>) -> Result<bool, AppError> {\n    let canonical = fs::canonicalize(path).map_err(|e| AppError::io(path, e))?;\n    Ok(visited.insert(canonical))\n}\n\nfn copy_entry_with_total_limit<R: Read, W: Write>(\n    reader: &mut R,\n    writer: &mut W,\n    total_bytes: &mut u64,\n    max_total_bytes: u64,\n    out_path: &Path,\n) -> Result<u64, AppError> {\n    let mut buffer = [0u8; 16 * 1024];\n    let mut written = 0u64;\n    loop {\n        let n = reader\n            .read(&mut buffer)\n            .map_err(|e| AppError::io(out_path, e))?;\n        if n == 0 {\n            break;\n        }\n\n        if total_bytes.saturating_add(n as u64) > max_total_bytes {\n            let max_mb = max_total_bytes / 1024 / 1024;\n            return Err(localized(\n                \"webdav.sync.skills_zip_too_large\",\n                format!(\"skills.zip 解压后体积超过上限（{} MB）\", max_mb),\n                format!(\"skills.zip extracted size exceeds limit ({} MB)\", max_mb),\n            ));\n        }\n\n        writer\n            .write_all(&buffer[..n])\n            .map_err(|e| AppError::io(out_path, e))?;\n        *total_bytes += n as u64;\n        written += n as u64;\n    }\n    Ok(written)\n}\n\n#[cfg(test)]\nmod tests {\n    use super::{copy_entry_with_total_limit, mark_visited_dir};\n    use std::collections::HashSet;\n    use std::io::Cursor;\n    use std::path::Path;\n    use tempfile::tempdir;\n\n    #[test]\n    fn mark_visited_dir_tracks_canonical_duplicates() {\n        let temp = tempdir().expect(\"tempdir\");\n        let dir = temp.path().join(\"skills\");\n        std::fs::create_dir_all(&dir).expect(\"create dir\");\n\n        let mut visited = HashSet::new();\n        assert!(mark_visited_dir(&dir, &mut visited).expect(\"first visit\"));\n        assert!(!mark_visited_dir(&dir, &mut visited).expect(\"second visit\"));\n    }\n\n    #[test]\n    fn copy_entry_with_total_limit_rejects_oversized_stream_before_write() {\n        let mut reader = Cursor::new(vec![1u8; 16]);\n        let mut writer = Vec::new();\n        let mut total_bytes = 0u64;\n\n        let err = copy_entry_with_total_limit(\n            &mut reader,\n            &mut writer,\n            &mut total_bytes,\n            8,\n            Path::new(\"skills-extracted/file.bin\"),\n        )\n        .expect_err(\"stream larger than limit should be rejected\");\n        assert!(\n            err.to_string().contains(\"too large\") || err.to_string().contains(\"超过\"),\n            \"unexpected error: {err}\"\n        );\n        assert_eq!(\n            writer.len(),\n            0,\n            \"should not write when the first chunk exceeds limit\"\n        );\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/services/webdav_sync.rs",
    "content": "//! WebDAV v2 sync protocol layer with DB compatibility subdirectories.\n//!\n//! Implements manifest-based synchronization on top of the HTTP transport\n//! primitives in [`super::webdav`]. Artifact set: `db.sql` + `skills.zip`.\n\nuse std::collections::BTreeMap;\nuse std::fs;\nuse std::future::Future;\nuse std::process::Command;\nuse std::sync::OnceLock;\n\nuse chrono::Utc;\nuse serde::{Deserialize, Serialize};\nuse serde_json::Value;\nuse sha2::{Digest, Sha256};\nuse tempfile::tempdir;\n\nuse crate::error::AppError;\nuse crate::services::webdav::{\n    auth_from_credentials, build_remote_url, ensure_remote_directories, get_bytes, head_etag,\n    path_segments, put_bytes, test_connection, WebDavAuth,\n};\nuse crate::settings::{update_webdav_sync_status, WebDavSyncSettings, WebDavSyncStatus};\n\nmod archive;\nuse archive::{\n    backup_current_skills, restore_skills_from_backup, restore_skills_zip, zip_skills_ssot,\n};\n\n// ─── Protocol constants ──────────────────────────────────────\n\nconst PROTOCOL_FORMAT: &str = \"cc-switch-webdav-sync\";\nconst PROTOCOL_VERSION: u32 = 2;\nconst DB_COMPAT_VERSION: u32 = 6;\nconst LEGACY_DB_COMPAT_VERSION: u32 = 5;\nconst REMOTE_DB_SQL: &str = \"db.sql\";\nconst REMOTE_SKILLS_ZIP: &str = \"skills.zip\";\nconst REMOTE_MANIFEST: &str = \"manifest.json\";\nconst MAX_DEVICE_NAME_LEN: usize = 64;\nconst MAX_MANIFEST_BYTES: usize = 1024 * 1024;\npub(super) const MAX_SYNC_ARTIFACT_BYTES: u64 = 512 * 1024 * 1024;\n\npub fn sync_mutex() -> &'static tokio::sync::Mutex<()> {\n    static LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();\n    LOCK.get_or_init(|| tokio::sync::Mutex::new(()))\n}\n\npub async fn run_with_sync_lock<T, Fut>(operation: Fut) -> Result<T, AppError>\nwhere\n    Fut: Future<Output = Result<T, AppError>>,\n{\n    let _guard = sync_mutex().lock().await;\n    operation.await\n}\n\nfn localized(key: &'static str, zh: impl Into<String>, en: impl Into<String>) -> AppError {\n    AppError::localized(key, zh, en)\n}\n\nfn io_context_localized(\n    _key: &'static str,\n    zh: impl Into<String>,\n    en: impl Into<String>,\n    source: std::io::Error,\n) -> AppError {\n    let zh_msg = zh.into();\n    let en_msg = en.into();\n    AppError::IoContext {\n        context: format!(\"{zh_msg} ({en_msg})\"),\n        source,\n    }\n}\n\n// ─── Types ───────────────────────────────────────────────────\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct SyncManifest {\n    format: String,\n    version: u32,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    db_compat_version: Option<u32>,\n    device_name: String,\n    created_at: String,\n    artifacts: BTreeMap<String, ArtifactMeta>,\n    snapshot_id: String,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\nstruct ArtifactMeta {\n    sha256: String,\n    size: u64,\n}\n\nstruct LocalSnapshot {\n    db_sql: Vec<u8>,\n    skills_zip: Vec<u8>,\n    manifest_bytes: Vec<u8>,\n    manifest_hash: String,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum RemoteLayout {\n    Current,\n    Legacy,\n}\n\nimpl RemoteLayout {\n    fn as_str(self) -> &'static str {\n        match self {\n            Self::Current => \"current\",\n            Self::Legacy => \"legacy\",\n        }\n    }\n}\n\nstruct RemoteSnapshot {\n    layout: RemoteLayout,\n    manifest: SyncManifest,\n    manifest_bytes: Vec<u8>,\n    manifest_etag: Option<String>,\n}\n\n// ─── Public API ──────────────────────────────────────────────\n\n/// Check WebDAV connectivity and ensure remote directory structure.\npub async fn check_connection(settings: &WebDavSyncSettings) -> Result<(), AppError> {\n    settings.validate()?;\n    let auth = auth_for(settings);\n    test_connection(&settings.base_url, &auth).await?;\n    let dir_segs = remote_dir_segments(settings, RemoteLayout::Current);\n    ensure_remote_directories(&settings.base_url, &dir_segs, &auth).await?;\n    Ok(())\n}\n\n/// Upload local snapshot (db + skills) to remote.\npub async fn upload(\n    db: &crate::database::Database,\n    settings: &mut WebDavSyncSettings,\n) -> Result<Value, AppError> {\n    settings.validate()?;\n    let auth = auth_for(settings);\n    let dir_segs = remote_dir_segments(settings, RemoteLayout::Current);\n    ensure_remote_directories(&settings.base_url, &dir_segs, &auth).await?;\n\n    let snapshot = build_local_snapshot(db, settings)?;\n\n    // Upload order: artifacts first, manifest last (best-effort consistency)\n    let db_url = remote_file_url(settings, RemoteLayout::Current, REMOTE_DB_SQL)?;\n    put_bytes(&db_url, &auth, snapshot.db_sql, \"application/sql\").await?;\n\n    let skills_url = remote_file_url(settings, RemoteLayout::Current, REMOTE_SKILLS_ZIP)?;\n    put_bytes(&skills_url, &auth, snapshot.skills_zip, \"application/zip\").await?;\n\n    let manifest_url = remote_file_url(settings, RemoteLayout::Current, REMOTE_MANIFEST)?;\n    put_bytes(\n        &manifest_url,\n        &auth,\n        snapshot.manifest_bytes,\n        \"application/json\",\n    )\n    .await?;\n\n    // Fetch etag (best-effort, don't fail the upload)\n    let etag = match head_etag(&manifest_url, &auth).await {\n        Ok(e) => e,\n        Err(e) => {\n            log::debug!(\"[WebDAV] Failed to fetch ETag after upload: {e}\");\n            None\n        }\n    };\n\n    let _persisted = persist_sync_success_best_effort(\n        settings,\n        snapshot.manifest_hash,\n        etag,\n        persist_sync_success,\n    );\n    Ok(serde_json::json!({ \"status\": \"uploaded\" }))\n}\n\n/// Download remote snapshot and apply to local database + skills.\npub async fn download(\n    db: &crate::database::Database,\n    settings: &mut WebDavSyncSettings,\n) -> Result<Value, AppError> {\n    settings.validate()?;\n    let auth = auth_for(settings);\n    let snapshot = find_remote_snapshot(settings, &auth)\n        .await?\n        .ok_or_else(|| {\n            localized(\n                \"webdav.sync.remote_empty\",\n                \"远端没有可下载的同步数据\",\n                \"No downloadable sync data found on the remote.\",\n            )\n        })?;\n\n    validate_manifest_compat(&snapshot.manifest, snapshot.layout)?;\n\n    // Download and verify artifacts\n    let db_sql = download_and_verify(\n        settings,\n        &auth,\n        snapshot.layout,\n        REMOTE_DB_SQL,\n        &snapshot.manifest.artifacts,\n    )\n    .await?;\n    let skills_zip = download_and_verify(\n        settings,\n        &auth,\n        snapshot.layout,\n        REMOTE_SKILLS_ZIP,\n        &snapshot.manifest.artifacts,\n    )\n    .await?;\n\n    // Apply snapshot\n    apply_snapshot(db, &db_sql, &skills_zip)?;\n\n    let manifest_hash = sha256_hex(&snapshot.manifest_bytes);\n    let _persisted = persist_sync_success_best_effort(\n        settings,\n        manifest_hash,\n        snapshot.manifest_etag,\n        persist_sync_success,\n    );\n    Ok(serde_json::json!({\n        \"status\": \"downloaded\",\n        \"sourceLayout\": snapshot.layout.as_str(),\n        \"sourcePath\": remote_dir_display(settings, snapshot.layout),\n    }))\n}\n\n/// Fetch remote manifest info without downloading artifacts.\npub async fn fetch_remote_info(settings: &WebDavSyncSettings) -> Result<Option<Value>, AppError> {\n    settings.validate()?;\n    let auth = auth_for(settings);\n    let Some(snapshot) = find_remote_snapshot(settings, &auth).await? else {\n        return Ok(None);\n    };\n    let compatible = validate_manifest_compat(&snapshot.manifest, snapshot.layout).is_ok();\n    let db_compat_version = effective_db_compat_version(&snapshot.manifest, snapshot.layout);\n\n    let payload = serde_json::json!({\n        \"deviceName\": snapshot.manifest.device_name,\n        \"createdAt\": snapshot.manifest.created_at,\n        \"snapshotId\": snapshot.manifest.snapshot_id,\n        \"version\": snapshot.manifest.version,\n        \"protocolVersion\": snapshot.manifest.version,\n        \"dbCompatVersion\": db_compat_version,\n        \"compatible\": compatible,\n        \"artifacts\": snapshot.manifest.artifacts.keys().collect::<Vec<_>>(),\n        \"layout\": snapshot.layout.as_str(),\n        \"remotePath\": remote_dir_display(settings, snapshot.layout),\n    });\n\n    Ok(Some(payload))\n}\n\n// ─── Sync status persistence (I3: deduplicated) ─────────────\n\nfn persist_sync_success(\n    settings: &mut WebDavSyncSettings,\n    manifest_hash: String,\n    etag: Option<String>,\n) -> Result<(), AppError> {\n    let status = WebDavSyncStatus {\n        last_sync_at: Some(Utc::now().timestamp()),\n        last_error: None,\n        last_error_source: None,\n        last_local_manifest_hash: Some(manifest_hash.clone()),\n        last_remote_manifest_hash: Some(manifest_hash),\n        last_remote_etag: etag,\n    };\n    settings.status = status.clone();\n    update_webdav_sync_status(status)\n}\n\nfn persist_sync_success_best_effort<F>(\n    settings: &mut WebDavSyncSettings,\n    manifest_hash: String,\n    etag: Option<String>,\n    persist_fn: F,\n) -> bool\nwhere\n    F: FnOnce(&mut WebDavSyncSettings, String, Option<String>) -> Result<(), AppError>,\n{\n    match persist_fn(settings, manifest_hash, etag) {\n        Ok(()) => true,\n        Err(err) => {\n            log::warn!(\"[WebDAV] Persist sync status failed, keep operation success: {err}\");\n            false\n        }\n    }\n}\n\n// ─── Snapshot building ───────────────────────────────────────\n\nfn build_local_snapshot(\n    db: &crate::database::Database,\n    _settings: &WebDavSyncSettings,\n) -> Result<LocalSnapshot, AppError> {\n    // Export database to SQL string\n    let sql_string = db.export_sql_string_for_sync()?;\n    let db_sql = sql_string.into_bytes();\n\n    // Pack skills into deterministic ZIP\n    let tmp = tempdir().map_err(|e| {\n        io_context_localized(\n            \"webdav.sync.snapshot_tmpdir_failed\",\n            \"创建 WebDAV 快照临时目录失败\",\n            \"Failed to create temporary directory for WebDAV snapshot\",\n            e,\n        )\n    })?;\n    let skills_zip_path = tmp.path().join(REMOTE_SKILLS_ZIP);\n    zip_skills_ssot(&skills_zip_path)?;\n    let skills_zip = fs::read(&skills_zip_path).map_err(|e| AppError::io(&skills_zip_path, e))?;\n\n    // Build artifact map and compute hashes\n    let mut artifacts = BTreeMap::new();\n    artifacts.insert(\n        REMOTE_DB_SQL.to_string(),\n        ArtifactMeta {\n            sha256: sha256_hex(&db_sql),\n            size: db_sql.len() as u64,\n        },\n    );\n    artifacts.insert(\n        REMOTE_SKILLS_ZIP.to_string(),\n        ArtifactMeta {\n            sha256: sha256_hex(&skills_zip),\n            size: skills_zip.len() as u64,\n        },\n    );\n\n    let snapshot_id = compute_snapshot_id(&artifacts);\n    let manifest = SyncManifest {\n        format: PROTOCOL_FORMAT.to_string(),\n        version: PROTOCOL_VERSION,\n        db_compat_version: Some(DB_COMPAT_VERSION),\n        device_name: detect_system_device_name().unwrap_or_else(|| \"Unknown Device\".to_string()),\n        created_at: Utc::now().to_rfc3339(),\n        artifacts,\n        snapshot_id,\n    };\n    let manifest_bytes =\n        serde_json::to_vec_pretty(&manifest).map_err(|e| AppError::JsonSerialize { source: e })?;\n    let manifest_hash = sha256_hex(&manifest_bytes);\n\n    Ok(LocalSnapshot {\n        db_sql,\n        skills_zip,\n        manifest_bytes,\n        manifest_hash,\n    })\n}\n\n/// Compute a deterministic snapshot identity from artifact hashes.\n///\n/// BTreeMap iteration order is sorted by key, ensuring stability.\nfn compute_snapshot_id(artifacts: &BTreeMap<String, ArtifactMeta>) -> String {\n    let parts: Vec<String> = artifacts\n        .iter()\n        .map(|(name, meta)| format!(\"{}:{}\", name, meta.sha256))\n        .collect();\n    sha256_hex(parts.join(\"|\").as_bytes())\n}\n\nfn sha256_hex(bytes: &[u8]) -> String {\n    let mut hasher = Sha256::new();\n    hasher.update(bytes);\n    format!(\"{:x}\", hasher.finalize())\n}\n\nfn detect_system_device_name() -> Option<String> {\n    let env_name = [\"CC_SWITCH_DEVICE_NAME\", \"COMPUTERNAME\", \"HOSTNAME\"]\n        .iter()\n        .filter_map(|key| std::env::var(key).ok())\n        .find_map(|value| normalize_device_name(&value));\n\n    if env_name.is_some() {\n        return env_name;\n    }\n\n    let output = Command::new(\"hostname\").output().ok()?;\n    if !output.status.success() {\n        return None;\n    }\n    let hostname = String::from_utf8(output.stdout).ok()?;\n    normalize_device_name(&hostname)\n}\n\nfn normalize_device_name(raw: &str) -> Option<String> {\n    let compact = raw\n        .chars()\n        .fold(String::with_capacity(raw.len()), |mut acc, ch| {\n            if ch.is_whitespace() {\n                acc.push(' ');\n            } else if !ch.is_control() {\n                acc.push(ch);\n            }\n            acc\n        });\n    let normalized = compact.split_whitespace().collect::<Vec<_>>().join(\" \");\n    let trimmed = normalized.trim();\n    if trimmed.is_empty() {\n        return None;\n    }\n\n    let limited = trimmed\n        .chars()\n        .take(MAX_DEVICE_NAME_LEN)\n        .collect::<String>();\n    if limited.is_empty() {\n        None\n    } else {\n        Some(limited)\n    }\n}\n\nfn effective_db_compat_version(manifest: &SyncManifest, layout: RemoteLayout) -> Option<u32> {\n    manifest\n        .db_compat_version\n        .or_else(|| (layout == RemoteLayout::Legacy).then_some(LEGACY_DB_COMPAT_VERSION))\n}\n\nfn validate_manifest_compat(manifest: &SyncManifest, layout: RemoteLayout) -> Result<(), AppError> {\n    if manifest.format != PROTOCOL_FORMAT {\n        return Err(localized(\n            \"webdav.sync.manifest_format_incompatible\",\n            format!(\"远端 manifest 格式不兼容: {}\", manifest.format),\n            format!(\n                \"Remote manifest format is incompatible: {}\",\n                manifest.format\n            ),\n        ));\n    }\n    if manifest.version != PROTOCOL_VERSION {\n        return Err(localized(\n            \"webdav.sync.manifest_version_incompatible\",\n            format!(\n                \"远端 manifest 协议版本不兼容: v{} (本地 v{PROTOCOL_VERSION})\",\n                manifest.version\n            ),\n            format!(\n                \"Remote manifest protocol version is incompatible: v{} (local v{PROTOCOL_VERSION})\",\n                manifest.version\n            ),\n        ));\n    }\n    let Some(db_compat_version) = effective_db_compat_version(manifest, layout) else {\n        return Err(localized(\n            \"webdav.sync.manifest_db_version_missing\",\n            \"远端 manifest 缺少数据库兼容版本\",\n            \"Remote manifest is missing the database compatibility version.\",\n        ));\n    };\n    match layout {\n        RemoteLayout::Current if db_compat_version != DB_COMPAT_VERSION => {\n            return Err(localized(\n                \"webdav.sync.manifest_db_version_incompatible\",\n                format!(\n                    \"远端数据库快照版本不兼容: db-v{} (本地 db-v{DB_COMPAT_VERSION})\",\n                    db_compat_version\n                ),\n                format!(\n                    \"Remote database snapshot version is incompatible: db-v{} (local db-v{DB_COMPAT_VERSION})\",\n                    db_compat_version\n                ),\n            ));\n        }\n        RemoteLayout::Legacy if db_compat_version > DB_COMPAT_VERSION => {\n            return Err(localized(\n                \"webdav.sync.manifest_db_version_incompatible\",\n                format!(\n                    \"远端数据库快照版本不兼容: db-v{} (本地最高支持 db-v{DB_COMPAT_VERSION})\",\n                    db_compat_version\n                ),\n                format!(\n                    \"Remote database snapshot version is incompatible: db-v{} (local supports up to db-v{DB_COMPAT_VERSION})\",\n                    db_compat_version\n                ),\n            ));\n        }\n        _ => {}\n    }\n    Ok(())\n}\n\nasync fn find_remote_snapshot(\n    settings: &WebDavSyncSettings,\n    auth: &WebDavAuth,\n) -> Result<Option<RemoteSnapshot>, AppError> {\n    if let Some(snapshot) = fetch_remote_snapshot(settings, auth, RemoteLayout::Current).await? {\n        return Ok(Some(snapshot));\n    }\n    fetch_remote_snapshot(settings, auth, RemoteLayout::Legacy).await\n}\n\nasync fn fetch_remote_snapshot(\n    settings: &WebDavSyncSettings,\n    auth: &WebDavAuth,\n    layout: RemoteLayout,\n) -> Result<Option<RemoteSnapshot>, AppError> {\n    let manifest_url = remote_file_url(settings, layout, REMOTE_MANIFEST)?;\n    let Some((manifest_bytes, manifest_etag)) =\n        get_bytes(&manifest_url, auth, MAX_MANIFEST_BYTES).await?\n    else {\n        return Ok(None);\n    };\n\n    let manifest: SyncManifest =\n        serde_json::from_slice(&manifest_bytes).map_err(|e| AppError::Json {\n            path: REMOTE_MANIFEST.to_string(),\n            source: e,\n        })?;\n\n    Ok(Some(RemoteSnapshot {\n        layout,\n        manifest,\n        manifest_bytes,\n        manifest_etag,\n    }))\n}\n\n// ─── Download & verify ───────────────────────────────────────\n\nasync fn download_and_verify(\n    settings: &WebDavSyncSettings,\n    auth: &WebDavAuth,\n    layout: RemoteLayout,\n    artifact_name: &str,\n    artifacts: &BTreeMap<String, ArtifactMeta>,\n) -> Result<Vec<u8>, AppError> {\n    let meta = artifacts.get(artifact_name).ok_or_else(|| {\n        localized(\n            \"webdav.sync.manifest_missing_artifact\",\n            format!(\"manifest 中缺少 artifact: {artifact_name}\"),\n            format!(\"Manifest missing artifact: {artifact_name}\"),\n        )\n    })?;\n    validate_artifact_size_limit(artifact_name, meta.size)?;\n\n    let url = remote_file_url(settings, layout, artifact_name)?;\n    let (bytes, _) = get_bytes(&url, auth, MAX_SYNC_ARTIFACT_BYTES as usize)\n        .await?\n        .ok_or_else(|| {\n            localized(\n                \"webdav.sync.remote_missing_artifact\",\n                format!(\"远端缺少 artifact 文件: {artifact_name}\"),\n                format!(\"Remote artifact file missing: {artifact_name}\"),\n            )\n        })?;\n\n    // Quick size check before expensive hash\n    if bytes.len() as u64 != meta.size {\n        return Err(localized(\n            \"webdav.sync.artifact_size_mismatch\",\n            format!(\n                \"artifact {artifact_name} 大小不匹配 (expected: {}, got: {})\",\n                meta.size,\n                bytes.len(),\n            ),\n            format!(\n                \"Artifact {artifact_name} size mismatch (expected: {}, got: {})\",\n                meta.size,\n                bytes.len(),\n            ),\n        ));\n    }\n\n    let actual_hash = sha256_hex(&bytes);\n    if actual_hash != meta.sha256 {\n        return Err(localized(\n            \"webdav.sync.artifact_hash_mismatch\",\n            format!(\n                \"artifact {artifact_name} SHA256 校验失败 (expected: {}..., got: {}...)\",\n                meta.sha256.get(..8).unwrap_or(&meta.sha256),\n                actual_hash.get(..8).unwrap_or(&actual_hash),\n            ),\n            format!(\n                \"Artifact {artifact_name} SHA256 verification failed (expected: {}..., got: {}...)\",\n                meta.sha256.get(..8).unwrap_or(&meta.sha256),\n                actual_hash.get(..8).unwrap_or(&actual_hash),\n            ),\n        ));\n    }\n    Ok(bytes)\n}\n\nfn apply_snapshot(\n    db: &crate::database::Database,\n    db_sql: &[u8],\n    skills_zip: &[u8],\n) -> Result<(), AppError> {\n    let sql_str = std::str::from_utf8(db_sql).map_err(|e| {\n        localized(\n            \"webdav.sync.sql_not_utf8\",\n            format!(\"SQL 非 UTF-8: {e}\"),\n            format!(\"SQL is not valid UTF-8: {e}\"),\n        )\n    })?;\n    let skills_backup = backup_current_skills()?;\n\n    // 先替换 skills，再导入数据库；若导入失败则回滚 skills，避免“半恢复”。\n    restore_skills_zip(skills_zip)?;\n\n    if let Err(db_err) = db.import_sql_string_for_sync(sql_str) {\n        if let Err(rollback_err) = restore_skills_from_backup(&skills_backup) {\n            return Err(localized(\n                \"webdav.sync.db_import_and_rollback_failed\",\n                format!(\"导入数据库失败: {db_err}; 同时回滚 Skills 失败: {rollback_err}\"),\n                format!(\n                    \"Database import failed: {db_err}; skills rollback also failed: {rollback_err}\"\n                ),\n            ));\n        }\n        return Err(db_err);\n    }\n\n    Ok(())\n}\n\n// ─── Remote path helpers ─────────────────────────────────────\n\nfn remote_dir_segments(settings: &WebDavSyncSettings, layout: RemoteLayout) -> Vec<String> {\n    let mut segs = Vec::new();\n    segs.extend(path_segments(&settings.remote_root).map(str::to_string));\n    segs.push(format!(\"v{PROTOCOL_VERSION}\"));\n    if layout == RemoteLayout::Current {\n        segs.push(format!(\"db-v{DB_COMPAT_VERSION}\"));\n    }\n    segs.extend(path_segments(&settings.profile).map(str::to_string));\n    segs\n}\n\nfn remote_file_url(\n    settings: &WebDavSyncSettings,\n    layout: RemoteLayout,\n    file_name: &str,\n) -> Result<String, AppError> {\n    let mut segs = remote_dir_segments(settings, layout);\n    segs.extend(path_segments(file_name).map(str::to_string));\n    build_remote_url(&settings.base_url, &segs)\n}\n\nfn remote_dir_display(settings: &WebDavSyncSettings, layout: RemoteLayout) -> String {\n    let segs = remote_dir_segments(settings, layout);\n    format!(\"/{}\", segs.join(\"/\"))\n}\n\nfn auth_for(settings: &WebDavSyncSettings) -> WebDavAuth {\n    auth_from_credentials(&settings.username, &settings.password)\n}\n\nfn validate_artifact_size_limit(artifact_name: &str, size: u64) -> Result<(), AppError> {\n    if size > MAX_SYNC_ARTIFACT_BYTES {\n        let max_mb = MAX_SYNC_ARTIFACT_BYTES / 1024 / 1024;\n        return Err(localized(\n            \"webdav.sync.artifact_too_large\",\n            format!(\"artifact {artifact_name} 超过下载上限（{} MB）\", max_mb),\n            format!(\n                \"Artifact {artifact_name} exceeds download limit ({} MB)\",\n                max_mb\n            ),\n        ));\n    }\n    Ok(())\n}\n\n// ─── Tests ───────────────────────────────────────────────────\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    fn artifact(sha256: &str, size: u64) -> ArtifactMeta {\n        ArtifactMeta {\n            sha256: sha256.to_string(),\n            size,\n        }\n    }\n\n    #[test]\n    fn snapshot_id_is_stable() {\n        let mut artifacts = BTreeMap::new();\n        artifacts.insert(\"db.sql\".to_string(), artifact(\"abc123\", 100));\n        artifacts.insert(\"skills.zip\".to_string(), artifact(\"def456\", 200));\n\n        let id1 = compute_snapshot_id(&artifacts);\n        let id2 = compute_snapshot_id(&artifacts);\n        assert_eq!(id1, id2);\n    }\n\n    #[test]\n    fn snapshot_id_changes_with_artifacts() {\n        let mut a1 = BTreeMap::new();\n        a1.insert(\"db.sql\".to_string(), artifact(\"hash-a\", 1));\n\n        let mut a2 = BTreeMap::new();\n        a2.insert(\"db.sql\".to_string(), artifact(\"hash-b\", 1));\n\n        assert_ne!(compute_snapshot_id(&a1), compute_snapshot_id(&a2));\n    }\n\n    #[test]\n    fn remote_dir_segments_uses_current_layout() {\n        let settings = WebDavSyncSettings {\n            remote_root: \"cc-switch-sync\".to_string(),\n            profile: \"default\".to_string(),\n            ..WebDavSyncSettings::default()\n        };\n        let segs = remote_dir_segments(&settings, RemoteLayout::Current);\n        assert_eq!(segs, vec![\"cc-switch-sync\", \"v2\", \"db-v6\", \"default\"]);\n    }\n\n    #[test]\n    fn remote_dir_segments_uses_legacy_layout() {\n        let settings = WebDavSyncSettings {\n            remote_root: \"cc-switch-sync\".to_string(),\n            profile: \"default\".to_string(),\n            ..WebDavSyncSettings::default()\n        };\n        let segs = remote_dir_segments(&settings, RemoteLayout::Legacy);\n        assert_eq!(segs, vec![\"cc-switch-sync\", \"v2\", \"default\"]);\n    }\n\n    #[test]\n    fn sha256_hex_is_correct() {\n        let hash = sha256_hex(b\"hello\");\n        assert_eq!(\n            hash,\n            \"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824\"\n        );\n    }\n\n    #[test]\n    fn persist_best_effort_returns_true_on_success() {\n        let mut settings = WebDavSyncSettings::default();\n        let ok = persist_sync_success_best_effort(\n            &mut settings,\n            \"hash\".to_string(),\n            Some(\"etag\".to_string()),\n            |_settings, _hash, _etag| Ok(()),\n        );\n        assert!(ok);\n    }\n\n    #[test]\n    fn persist_best_effort_returns_false_on_error() {\n        let mut settings = WebDavSyncSettings::default();\n        let ok = persist_sync_success_best_effort(\n            &mut settings,\n            \"hash\".to_string(),\n            None,\n            |_settings, _hash, _etag| Err(AppError::Config(\"boom\".to_string())),\n        );\n        assert!(!ok);\n    }\n\n    fn manifest_with(format: &str, version: u32, db_compat_version: Option<u32>) -> SyncManifest {\n        let mut artifacts = BTreeMap::new();\n        artifacts.insert(\"db.sql\".to_string(), artifact(\"abc\", 1));\n        artifacts.insert(\"skills.zip\".to_string(), artifact(\"def\", 2));\n        SyncManifest {\n            format: format.to_string(),\n            version,\n            db_compat_version,\n            device_name: \"My MacBook\".to_string(),\n            created_at: \"2026-02-12T00:00:00Z\".to_string(),\n            artifacts,\n            snapshot_id: \"snap-1\".to_string(),\n        }\n    }\n\n    #[test]\n    fn validate_manifest_compat_accepts_supported_manifest() {\n        let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION, Some(DB_COMPAT_VERSION));\n        assert!(validate_manifest_compat(&manifest, RemoteLayout::Current).is_ok());\n    }\n\n    #[test]\n    fn validate_manifest_compat_rejects_wrong_format() {\n        let manifest = manifest_with(\"other-format\", PROTOCOL_VERSION, Some(DB_COMPAT_VERSION));\n        assert!(validate_manifest_compat(&manifest, RemoteLayout::Current).is_err());\n    }\n\n    #[test]\n    fn validate_manifest_compat_rejects_wrong_version() {\n        let manifest = manifest_with(\n            PROTOCOL_FORMAT,\n            PROTOCOL_VERSION + 1,\n            Some(DB_COMPAT_VERSION),\n        );\n        assert!(validate_manifest_compat(&manifest, RemoteLayout::Current).is_err());\n    }\n\n    #[test]\n    fn validate_manifest_compat_accepts_legacy_manifest_without_db_compat() {\n        let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION, None);\n        assert!(validate_manifest_compat(&manifest, RemoteLayout::Legacy).is_ok());\n    }\n\n    #[test]\n    fn validate_manifest_compat_rejects_current_manifest_with_wrong_db_compat() {\n        let manifest = manifest_with(\n            PROTOCOL_FORMAT,\n            PROTOCOL_VERSION,\n            Some(LEGACY_DB_COMPAT_VERSION),\n        );\n        assert!(validate_manifest_compat(&manifest, RemoteLayout::Current).is_err());\n    }\n\n    #[test]\n    fn validate_manifest_compat_rejects_legacy_manifest_from_newer_db_generation() {\n        let manifest = manifest_with(\n            PROTOCOL_FORMAT,\n            PROTOCOL_VERSION,\n            Some(DB_COMPAT_VERSION + 1),\n        );\n        assert!(validate_manifest_compat(&manifest, RemoteLayout::Legacy).is_err());\n    }\n\n    #[test]\n    fn effective_db_compat_version_defaults_legacy_layout_to_v5() {\n        let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION, None);\n        assert_eq!(\n            effective_db_compat_version(&manifest, RemoteLayout::Legacy),\n            Some(LEGACY_DB_COMPAT_VERSION)\n        );\n        assert_eq!(\n            effective_db_compat_version(&manifest, RemoteLayout::Current),\n            None\n        );\n    }\n\n    #[test]\n    fn normalize_device_name_returns_none_for_blank_input() {\n        assert_eq!(normalize_device_name(\"   \\n\\t  \"), None);\n    }\n\n    #[test]\n    fn normalize_device_name_collapses_whitespace_and_drops_control_chars() {\n        assert_eq!(\n            normalize_device_name(\"  Mac\\tBook \\n Pro\\u{0007} \"),\n            Some(\"Mac Book Pro\".to_string())\n        );\n    }\n\n    #[test]\n    fn normalize_device_name_truncates_to_max_len() {\n        let long = \"a\".repeat(80);\n        assert_eq!(normalize_device_name(&long).map(|s| s.len()), Some(64));\n    }\n\n    #[test]\n    fn manifest_serialization_uses_device_name_only() {\n        let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION, Some(DB_COMPAT_VERSION));\n        let value = serde_json::to_value(&manifest).expect(\"serialize manifest\");\n        assert!(\n            value.get(\"deviceName\").is_some(),\n            \"manifest should contain deviceName\"\n        );\n        assert_eq!(\n            value.get(\"dbCompatVersion\").and_then(|v| v.as_u64()),\n            Some(DB_COMPAT_VERSION as u64)\n        );\n        assert!(\n            value.get(\"deviceId\").is_none(),\n            \"manifest should not contain deviceId\"\n        );\n    }\n\n    #[test]\n    fn validate_artifact_size_limit_rejects_oversized_artifacts() {\n        let err = validate_artifact_size_limit(\"skills.zip\", MAX_SYNC_ARTIFACT_BYTES + 1)\n            .expect_err(\"artifact larger than limit should be rejected\");\n        assert!(\n            err.to_string().contains(\"too large\") || err.to_string().contains(\"超过\"),\n            \"unexpected error: {err}\"\n        );\n    }\n\n    #[test]\n    fn validate_artifact_size_limit_accepts_limit_boundary() {\n        assert!(validate_artifact_size_limit(\"skills.zip\", MAX_SYNC_ARTIFACT_BYTES).is_ok());\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/session_manager/mod.rs",
    "content": "pub mod providers;\npub mod terminal;\n\nuse serde::Serialize;\nuse std::path::{Path, PathBuf};\n\nuse providers::{claude, codex, gemini, openclaw, opencode};\n\n#[derive(Debug, Clone, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct SessionMeta {\n    pub provider_id: String,\n    pub session_id: String,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub title: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub summary: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub project_dir: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub created_at: Option<i64>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub last_active_at: Option<i64>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub source_path: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub resume_command: Option<String>,\n}\n\n#[derive(Debug, Clone, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct SessionMessage {\n    pub role: String,\n    pub content: String,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub ts: Option<i64>,\n}\n\npub fn scan_sessions() -> Vec<SessionMeta> {\n    let (r1, r2, r3, r4, r5) = std::thread::scope(|s| {\n        let h1 = s.spawn(codex::scan_sessions);\n        let h2 = s.spawn(claude::scan_sessions);\n        let h3 = s.spawn(opencode::scan_sessions);\n        let h4 = s.spawn(openclaw::scan_sessions);\n        let h5 = s.spawn(gemini::scan_sessions);\n        (\n            h1.join().unwrap_or_default(),\n            h2.join().unwrap_or_default(),\n            h3.join().unwrap_or_default(),\n            h4.join().unwrap_or_default(),\n            h5.join().unwrap_or_default(),\n        )\n    });\n\n    let mut sessions = Vec::new();\n    sessions.extend(r1);\n    sessions.extend(r2);\n    sessions.extend(r3);\n    sessions.extend(r4);\n    sessions.extend(r5);\n\n    sessions.sort_by(|a, b| {\n        let a_ts = a.last_active_at.or(a.created_at).unwrap_or(0);\n        let b_ts = b.last_active_at.or(b.created_at).unwrap_or(0);\n        b_ts.cmp(&a_ts)\n    });\n\n    sessions\n}\n\npub fn load_messages(provider_id: &str, source_path: &str) -> Result<Vec<SessionMessage>, String> {\n    let path = Path::new(source_path);\n    match provider_id {\n        \"codex\" => codex::load_messages(path),\n        \"claude\" => claude::load_messages(path),\n        \"opencode\" => opencode::load_messages(path),\n        \"openclaw\" => openclaw::load_messages(path),\n        \"gemini\" => gemini::load_messages(path),\n        _ => Err(format!(\"Unsupported provider: {provider_id}\")),\n    }\n}\n\npub fn delete_session(\n    provider_id: &str,\n    session_id: &str,\n    source_path: &str,\n) -> Result<bool, String> {\n    let root = provider_root(provider_id)?;\n    delete_session_with_root(provider_id, session_id, Path::new(source_path), &root)\n}\n\nfn delete_session_with_root(\n    provider_id: &str,\n    session_id: &str,\n    source_path: &Path,\n    root: &Path,\n) -> Result<bool, String> {\n    let validated_root = canonicalize_existing_path(root, \"session root\")?;\n    let validated_source = canonicalize_existing_path(source_path, \"session source\")?;\n\n    if !validated_source.starts_with(&validated_root) {\n        return Err(format!(\n            \"Session source path is outside provider root: {}\",\n            source_path.display()\n        ));\n    }\n\n    match provider_id {\n        \"codex\" => codex::delete_session(&validated_root, &validated_source, session_id),\n        \"claude\" => claude::delete_session(&validated_root, &validated_source, session_id),\n        \"opencode\" => opencode::delete_session(&validated_root, &validated_source, session_id),\n        \"openclaw\" => openclaw::delete_session(&validated_root, &validated_source, session_id),\n        \"gemini\" => gemini::delete_session(&validated_root, &validated_source, session_id),\n        _ => Err(format!(\"Unsupported provider: {provider_id}\")),\n    }\n}\n\nfn provider_root(provider_id: &str) -> Result<PathBuf, String> {\n    let root = match provider_id {\n        \"codex\" => crate::codex_config::get_codex_config_dir().join(\"sessions\"),\n        \"claude\" => crate::config::get_claude_config_dir().join(\"projects\"),\n        \"opencode\" => opencode::get_opencode_data_dir(),\n        \"openclaw\" => crate::openclaw_config::get_openclaw_dir().join(\"agents\"),\n        \"gemini\" => crate::gemini_config::get_gemini_dir().join(\"tmp\"),\n        _ => return Err(format!(\"Unsupported provider: {provider_id}\")),\n    };\n\n    Ok(root)\n}\n\nfn canonicalize_existing_path(path: &Path, label: &str) -> Result<PathBuf, String> {\n    if !path.exists() {\n        return Err(format!(\"{label} not found: {}\", path.display()));\n    }\n\n    path.canonicalize()\n        .map_err(|e| format!(\"Failed to resolve {label} {}: {e}\", path.display()))\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use tempfile::tempdir;\n\n    #[test]\n    fn rejects_source_path_outside_provider_root() {\n        let root = tempdir().expect(\"tempdir\");\n        let outside = tempdir().expect(\"tempdir\");\n        let source = outside.path().join(\"session.jsonl\");\n        std::fs::write(&source, \"{}\").expect(\"write source\");\n\n        let err = delete_session_with_root(\"codex\", \"session-1\", &source, root.path())\n            .expect_err(\"expected outside-root path to be rejected\");\n\n        assert!(err.contains(\"outside provider root\"));\n    }\n\n    #[test]\n    fn rejects_missing_source_path() {\n        let root = tempdir().expect(\"tempdir\");\n        let missing = root.path().join(\"missing.jsonl\");\n\n        let err = delete_session_with_root(\"codex\", \"session-1\", &missing, root.path())\n            .expect_err(\"expected missing source path to fail\");\n\n        assert!(err.contains(\"session source not found\"));\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/session_manager/providers/claude.rs",
    "content": "use std::fs::File;\nuse std::io::{BufRead, BufReader};\nuse std::path::{Path, PathBuf};\n\nuse serde_json::Value;\n\nuse crate::config::get_claude_config_dir;\nuse crate::session_manager::{SessionMessage, SessionMeta};\n\nuse super::utils::{\n    extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary,\n};\n\nconst PROVIDER_ID: &str = \"claude\";\n\npub fn scan_sessions() -> Vec<SessionMeta> {\n    let root = get_claude_config_dir().join(\"projects\");\n    let mut files = Vec::new();\n    collect_jsonl_files(&root, &mut files);\n\n    let mut sessions = Vec::new();\n    for path in files {\n        if let Some(meta) = parse_session(&path) {\n            sessions.push(meta);\n        }\n    }\n\n    sessions\n}\n\npub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {\n    let file = File::open(path).map_err(|e| format!(\"Failed to open session file: {e}\"))?;\n    let reader = BufReader::new(file);\n    let mut messages = Vec::new();\n\n    for line in reader.lines() {\n        let line = match line {\n            Ok(value) => value,\n            Err(_) => continue,\n        };\n        let value: Value = match serde_json::from_str(&line) {\n            Ok(parsed) => parsed,\n            Err(_) => continue,\n        };\n\n        if value.get(\"isMeta\").and_then(Value::as_bool) == Some(true) {\n            continue;\n        }\n\n        let message = match value.get(\"message\") {\n            Some(message) => message,\n            None => continue,\n        };\n\n        let role = message\n            .get(\"role\")\n            .and_then(Value::as_str)\n            .unwrap_or(\"unknown\")\n            .to_string();\n        let content = message.get(\"content\").map(extract_text).unwrap_or_default();\n        if content.trim().is_empty() {\n            continue;\n        }\n\n        let ts = value.get(\"timestamp\").and_then(parse_timestamp_to_ms);\n\n        messages.push(SessionMessage { role, content, ts });\n    }\n\n    Ok(messages)\n}\n\npub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result<bool, String> {\n    let meta = parse_session(path).ok_or_else(|| {\n        format!(\n            \"Failed to parse Claude session metadata: {}\",\n            path.display()\n        )\n    })?;\n\n    if meta.session_id != session_id {\n        return Err(format!(\n            \"Claude session ID mismatch: expected {session_id}, found {}\",\n            meta.session_id\n        ));\n    }\n\n    if let Some(stem) = path.file_stem() {\n        let sibling = path.parent().unwrap_or_else(|| Path::new(\"\")).join(stem);\n        remove_path_if_exists(&sibling).map_err(|e| {\n            format!(\n                \"Failed to delete Claude session sidecar {}: {e}\",\n                sibling.display()\n            )\n        })?;\n    }\n\n    std::fs::remove_file(path).map_err(|e| {\n        format!(\n            \"Failed to delete Claude session file {}: {e}\",\n            path.display()\n        )\n    })?;\n\n    Ok(true)\n}\n\nfn parse_session(path: &Path) -> Option<SessionMeta> {\n    if is_agent_session(path) {\n        return None;\n    }\n\n    let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?;\n\n    let mut session_id: Option<String> = None;\n    let mut project_dir: Option<String> = None;\n    let mut created_at: Option<i64> = None;\n\n    // Extract metadata from head lines\n    for line in &head {\n        let value: Value = match serde_json::from_str(line) {\n            Ok(parsed) => parsed,\n            Err(_) => continue,\n        };\n        if session_id.is_none() {\n            session_id = value\n                .get(\"sessionId\")\n                .and_then(Value::as_str)\n                .map(|s| s.to_string());\n        }\n        if project_dir.is_none() {\n            project_dir = value\n                .get(\"cwd\")\n                .and_then(Value::as_str)\n                .map(|s| s.to_string());\n        }\n        if created_at.is_none() {\n            created_at = value.get(\"timestamp\").and_then(parse_timestamp_to_ms);\n        }\n    }\n\n    // Extract last_active_at and summary from tail lines (reverse order)\n    let mut last_active_at: Option<i64> = None;\n    let mut summary: Option<String> = None;\n\n    for line in tail.iter().rev() {\n        let value: Value = match serde_json::from_str(line) {\n            Ok(parsed) => parsed,\n            Err(_) => continue,\n        };\n        if last_active_at.is_none() {\n            last_active_at = value.get(\"timestamp\").and_then(parse_timestamp_to_ms);\n        }\n        if summary.is_none() {\n            if value.get(\"isMeta\").and_then(Value::as_bool) == Some(true) {\n                continue;\n            }\n            if let Some(message) = value.get(\"message\") {\n                let text = message.get(\"content\").map(extract_text).unwrap_or_default();\n                if !text.trim().is_empty() {\n                    summary = Some(text);\n                }\n            }\n        }\n        if last_active_at.is_some() && summary.is_some() {\n            break;\n        }\n    }\n\n    let session_id = session_id.or_else(|| infer_session_id_from_filename(path));\n    let session_id = session_id?;\n\n    let title = project_dir\n        .as_deref()\n        .and_then(path_basename)\n        .map(|value| value.to_string());\n\n    let summary = summary.map(|text| truncate_summary(&text, 160));\n\n    Some(SessionMeta {\n        provider_id: PROVIDER_ID.to_string(),\n        session_id: session_id.clone(),\n        title,\n        summary,\n        project_dir,\n        created_at,\n        last_active_at,\n        source_path: Some(path.to_string_lossy().to_string()),\n        resume_command: Some(format!(\"claude --resume {session_id}\")),\n    })\n}\n\nfn is_agent_session(path: &Path) -> bool {\n    path.file_name()\n        .and_then(|name| name.to_str())\n        .map(|name| name.starts_with(\"agent-\"))\n        .unwrap_or(false)\n}\n\nfn infer_session_id_from_filename(path: &Path) -> Option<String> {\n    path.file_stem()\n        .and_then(|stem| stem.to_str())\n        .map(|stem| stem.to_string())\n}\n\nfn collect_jsonl_files(root: &Path, files: &mut Vec<PathBuf>) {\n    if !root.exists() {\n        return;\n    }\n\n    let entries = match std::fs::read_dir(root) {\n        Ok(entries) => entries,\n        Err(_) => return,\n    };\n\n    for entry in entries.flatten() {\n        let path = entry.path();\n        if path.is_dir() {\n            collect_jsonl_files(&path, files);\n        } else if path.extension().and_then(|ext| ext.to_str()) == Some(\"jsonl\") {\n            files.push(path);\n        }\n    }\n}\n\nfn remove_path_if_exists(path: &Path) -> std::io::Result<()> {\n    match std::fs::metadata(path) {\n        Ok(meta) => {\n            if meta.is_dir() {\n                std::fs::remove_dir_all(path)\n            } else {\n                std::fs::remove_file(path)\n            }\n        }\n        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),\n        Err(err) => Err(err),\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use tempfile::tempdir;\n\n    #[test]\n    fn delete_session_removes_main_file_and_sidecar_directory() {\n        let temp = tempdir().expect(\"tempdir\");\n        let path = temp.path().join(\"abc123-session.jsonl\");\n        let sidecar = temp.path().join(\"abc123-session\");\n        let subagents = sidecar.join(\"subagents\");\n        let tool_results = sidecar.join(\"tool-results\");\n\n        std::fs::create_dir_all(&subagents).expect(\"create subagents\");\n        std::fs::create_dir_all(&tool_results).expect(\"create tool-results\");\n        std::fs::write(subagents.join(\"agent-1.jsonl\"), \"{}\").expect(\"write subagent\");\n        std::fs::write(tool_results.join(\"tool-1.txt\"), \"result\").expect(\"write tool result\");\n        std::fs::write(\n            &path,\n            concat!(\n                \"{\\\"sessionId\\\":\\\"session-123\\\",\\\"cwd\\\":\\\"/tmp/project\\\",\\\"timestamp\\\":\\\"2026-03-06T10:00:00Z\\\"}\\n\",\n                \"{\\\"message\\\":{\\\"role\\\":\\\"user\\\",\\\"content\\\":\\\"hello\\\"},\\\"timestamp\\\":\\\"2026-03-06T10:01:00Z\\\"}\\n\"\n            ),\n        )\n        .expect(\"write session\");\n\n        delete_session(temp.path(), &path, \"session-123\").expect(\"delete session\");\n\n        assert!(!path.exists());\n        assert!(!sidecar.exists());\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/session_manager/providers/codex.rs",
    "content": "use std::fs::File;\nuse std::io::{BufRead, BufReader};\nuse std::path::{Path, PathBuf};\nuse std::sync::LazyLock;\n\nuse regex::Regex;\nuse serde_json::Value;\n\nuse crate::codex_config::get_codex_config_dir;\nuse crate::session_manager::{SessionMessage, SessionMeta};\n\nuse super::utils::{\n    extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary,\n};\n\nconst PROVIDER_ID: &str = \"codex\";\n\nstatic UUID_RE: LazyLock<Regex> = LazyLock::new(|| {\n    Regex::new(r\"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\")\n        .unwrap()\n});\n\npub fn scan_sessions() -> Vec<SessionMeta> {\n    let root = get_codex_config_dir().join(\"sessions\");\n    let mut files = Vec::new();\n    collect_jsonl_files(&root, &mut files);\n\n    let mut sessions = Vec::new();\n    for path in files {\n        if let Some(meta) = parse_session(&path) {\n            sessions.push(meta);\n        }\n    }\n\n    sessions\n}\n\npub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {\n    let file = File::open(path).map_err(|e| format!(\"Failed to open session file: {e}\"))?;\n    let reader = BufReader::new(file);\n    let mut messages = Vec::new();\n\n    for line in reader.lines() {\n        let line = match line {\n            Ok(value) => value,\n            Err(_) => continue,\n        };\n        let value: Value = match serde_json::from_str(&line) {\n            Ok(parsed) => parsed,\n            Err(_) => continue,\n        };\n\n        if value.get(\"type\").and_then(Value::as_str) != Some(\"response_item\") {\n            continue;\n        }\n\n        let payload = match value.get(\"payload\") {\n            Some(payload) => payload,\n            None => continue,\n        };\n\n        if payload.get(\"type\").and_then(Value::as_str) != Some(\"message\") {\n            continue;\n        }\n\n        let role = payload\n            .get(\"role\")\n            .and_then(Value::as_str)\n            .unwrap_or(\"unknown\")\n            .to_string();\n        let content = payload.get(\"content\").map(extract_text).unwrap_or_default();\n        if content.trim().is_empty() {\n            continue;\n        }\n\n        let ts = value.get(\"timestamp\").and_then(parse_timestamp_to_ms);\n\n        messages.push(SessionMessage { role, content, ts });\n    }\n\n    Ok(messages)\n}\n\npub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result<bool, String> {\n    let meta = parse_session(path)\n        .ok_or_else(|| format!(\"Failed to parse Codex session metadata: {}\", path.display()))?;\n\n    if meta.session_id != session_id {\n        return Err(format!(\n            \"Codex session ID mismatch: expected {session_id}, found {}\",\n            meta.session_id\n        ));\n    }\n\n    std::fs::remove_file(path).map_err(|e| {\n        format!(\n            \"Failed to delete Codex session file {}: {e}\",\n            path.display()\n        )\n    })?;\n\n    Ok(true)\n}\n\nfn parse_session(path: &Path) -> Option<SessionMeta> {\n    let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?;\n\n    let mut session_id: Option<String> = None;\n    let mut project_dir: Option<String> = None;\n    let mut created_at: Option<i64> = None;\n\n    // Extract metadata from head lines\n    for line in &head {\n        let value: Value = match serde_json::from_str(line) {\n            Ok(parsed) => parsed,\n            Err(_) => continue,\n        };\n        if created_at.is_none() {\n            created_at = value.get(\"timestamp\").and_then(parse_timestamp_to_ms);\n        }\n        if value.get(\"type\").and_then(Value::as_str) == Some(\"session_meta\") {\n            if let Some(payload) = value.get(\"payload\") {\n                if session_id.is_none() {\n                    session_id = payload\n                        .get(\"id\")\n                        .and_then(Value::as_str)\n                        .map(|s| s.to_string());\n                }\n                if project_dir.is_none() {\n                    project_dir = payload\n                        .get(\"cwd\")\n                        .and_then(Value::as_str)\n                        .map(|s| s.to_string());\n                }\n                if let Some(ts) = payload.get(\"timestamp\").and_then(parse_timestamp_to_ms) {\n                    created_at.get_or_insert(ts);\n                }\n            }\n        }\n    }\n\n    // Extract last_active_at and summary from tail lines (reverse order)\n    let mut last_active_at: Option<i64> = None;\n    let mut summary: Option<String> = None;\n\n    for line in tail.iter().rev() {\n        let value: Value = match serde_json::from_str(line) {\n            Ok(parsed) => parsed,\n            Err(_) => continue,\n        };\n        if last_active_at.is_none() {\n            last_active_at = value.get(\"timestamp\").and_then(parse_timestamp_to_ms);\n        }\n        if summary.is_none() && value.get(\"type\").and_then(Value::as_str) == Some(\"response_item\") {\n            if let Some(payload) = value.get(\"payload\") {\n                if payload.get(\"type\").and_then(Value::as_str) == Some(\"message\") {\n                    let text = payload.get(\"content\").map(extract_text).unwrap_or_default();\n                    if !text.trim().is_empty() {\n                        summary = Some(text);\n                    }\n                }\n            }\n        }\n        if last_active_at.is_some() && summary.is_some() {\n            break;\n        }\n    }\n\n    let session_id = session_id.or_else(|| infer_session_id_from_filename(path));\n    let session_id = session_id?;\n\n    let title = project_dir\n        .as_deref()\n        .and_then(path_basename)\n        .map(|value| value.to_string());\n\n    let summary = summary.map(|text| truncate_summary(&text, 160));\n\n    Some(SessionMeta {\n        provider_id: PROVIDER_ID.to_string(),\n        session_id: session_id.clone(),\n        title,\n        summary,\n        project_dir,\n        created_at,\n        last_active_at,\n        source_path: Some(path.to_string_lossy().to_string()),\n        resume_command: Some(format!(\"codex resume {session_id}\")),\n    })\n}\n\nfn infer_session_id_from_filename(path: &Path) -> Option<String> {\n    let file_name = path.file_name()?.to_string_lossy();\n    UUID_RE.find(&file_name).map(|mat| mat.as_str().to_string())\n}\n\nfn collect_jsonl_files(root: &Path, files: &mut Vec<PathBuf>) {\n    if !root.exists() {\n        return;\n    }\n\n    let entries = match std::fs::read_dir(root) {\n        Ok(entries) => entries,\n        Err(_) => return,\n    };\n\n    for entry in entries.flatten() {\n        let path = entry.path();\n        if path.is_dir() {\n            collect_jsonl_files(&path, files);\n        } else if path.extension().and_then(|ext| ext.to_str()) == Some(\"jsonl\") {\n            files.push(path);\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use tempfile::tempdir;\n\n    #[test]\n    fn delete_session_removes_jsonl_file() {\n        let temp = tempdir().expect(\"tempdir\");\n        let path = temp\n            .path()\n            .join(\"rollout-2026-03-06T21-50-12-019cc369-bd7c-7891-b371-7b20b4fe0b18.jsonl\");\n        std::fs::write(\n            &path,\n            concat!(\n                \"{\\\"timestamp\\\":\\\"2026-03-06T21:50:12Z\\\",\\\"type\\\":\\\"session_meta\\\",\\\"payload\\\":{\\\"id\\\":\\\"019cc369-bd7c-7891-b371-7b20b4fe0b18\\\",\\\"cwd\\\":\\\"/tmp/project\\\"}}\\n\",\n                \"{\\\"timestamp\\\":\\\"2026-03-06T21:50:13Z\\\",\\\"type\\\":\\\"response_item\\\",\\\"payload\\\":{\\\"type\\\":\\\"message\\\",\\\"role\\\":\\\"user\\\",\\\"content\\\":\\\"hello\\\"}}\\n\"\n            ),\n        )\n        .expect(\"write session\");\n\n        delete_session(temp.path(), &path, \"019cc369-bd7c-7891-b371-7b20b4fe0b18\")\n            .expect(\"delete session\");\n\n        assert!(!path.exists());\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/session_manager/providers/gemini.rs",
    "content": "use std::path::Path;\n\nuse serde_json::Value;\n\nuse crate::session_manager::{SessionMessage, SessionMeta};\n\nuse super::utils::{parse_timestamp_to_ms, truncate_summary};\n\nconst PROVIDER_ID: &str = \"gemini\";\n\npub fn scan_sessions() -> Vec<SessionMeta> {\n    let gemini_dir = crate::gemini_config::get_gemini_dir();\n    let tmp_dir = gemini_dir.join(\"tmp\");\n    if !tmp_dir.exists() {\n        return Vec::new();\n    }\n\n    let mut sessions = Vec::new();\n\n    // Iterate over project hash directories: tmp/<project_hash>/chats/session-*.json\n    let project_dirs = match std::fs::read_dir(&tmp_dir) {\n        Ok(entries) => entries,\n        Err(_) => return Vec::new(),\n    };\n\n    for entry in project_dirs.flatten() {\n        let chats_dir = entry.path().join(\"chats\");\n        if !chats_dir.is_dir() {\n            continue;\n        }\n\n        let chat_files = match std::fs::read_dir(&chats_dir) {\n            Ok(entries) => entries,\n            Err(_) => continue,\n        };\n\n        for file_entry in chat_files.flatten() {\n            let path = file_entry.path();\n            if path.extension().and_then(|e| e.to_str()) != Some(\"json\") {\n                continue;\n            }\n            if let Some(meta) = parse_session(&path) {\n                sessions.push(meta);\n            }\n        }\n    }\n\n    sessions\n}\n\npub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {\n    let data = std::fs::read_to_string(path).map_err(|e| format!(\"Failed to read session: {e}\"))?;\n    let value: Value =\n        serde_json::from_str(&data).map_err(|e| format!(\"Failed to parse session JSON: {e}\"))?;\n\n    let messages = value\n        .get(\"messages\")\n        .and_then(Value::as_array)\n        .ok_or_else(|| \"No messages array found\".to_string())?;\n\n    let mut result = Vec::new();\n    for msg in messages {\n        let content = match msg.get(\"content\").and_then(Value::as_str) {\n            Some(c) if !c.trim().is_empty() => c.to_string(),\n            _ => continue,\n        };\n\n        let role = match msg.get(\"type\").and_then(Value::as_str) {\n            Some(\"gemini\") => \"assistant\".to_string(),\n            Some(\"user\") => \"user\".to_string(),\n            Some(other) => other.to_string(),\n            None => continue,\n        };\n\n        let ts = msg.get(\"timestamp\").and_then(parse_timestamp_to_ms);\n\n        result.push(SessionMessage { role, content, ts });\n    }\n\n    Ok(result)\n}\n\npub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result<bool, String> {\n    let meta = parse_session(path).ok_or_else(|| {\n        format!(\n            \"Failed to parse Gemini session metadata: {}\",\n            path.display()\n        )\n    })?;\n\n    if meta.session_id != session_id {\n        return Err(format!(\n            \"Gemini session ID mismatch: expected {session_id}, found {}\",\n            meta.session_id\n        ));\n    }\n\n    std::fs::remove_file(path).map_err(|e| {\n        format!(\n            \"Failed to delete Gemini session file {}: {e}\",\n            path.display()\n        )\n    })?;\n\n    Ok(true)\n}\n\nfn parse_session(path: &Path) -> Option<SessionMeta> {\n    let data = std::fs::read_to_string(path).ok()?;\n    let value: Value = serde_json::from_str(&data).ok()?;\n\n    let session_id = value.get(\"sessionId\").and_then(Value::as_str)?.to_string();\n\n    let created_at = value.get(\"startTime\").and_then(parse_timestamp_to_ms);\n    let last_active_at = value.get(\"lastUpdated\").and_then(parse_timestamp_to_ms);\n\n    // Derive title from first user message\n    let title = value\n        .get(\"messages\")\n        .and_then(Value::as_array)\n        .and_then(|msgs| {\n            msgs.iter()\n                .find(|m| m.get(\"type\").and_then(Value::as_str) == Some(\"user\"))\n                .and_then(|m| m.get(\"content\").and_then(Value::as_str))\n                .filter(|s| !s.trim().is_empty())\n                .map(|s| truncate_summary(s, 160))\n        });\n\n    let source_path = path.to_string_lossy().to_string();\n\n    Some(SessionMeta {\n        provider_id: PROVIDER_ID.to_string(),\n        session_id: session_id.clone(),\n        title: title.clone(),\n        summary: title,\n        project_dir: None, // project hash is not reversible\n        created_at,\n        last_active_at: last_active_at.or(created_at),\n        source_path: Some(source_path),\n        resume_command: Some(format!(\"gemini --resume {session_id}\")),\n    })\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use tempfile::tempdir;\n\n    #[test]\n    fn delete_session_removes_json_file() {\n        let temp = tempdir().expect(\"tempdir\");\n        let path = temp.path().join(\"session-2026-03-06T10-17-test.json\");\n        std::fs::write(\n            &path,\n            r#\"{\n              \"sessionId\": \"gemini-session-123\",\n              \"startTime\": \"2026-03-06T10:17:58.000Z\",\n              \"lastUpdated\": \"2026-03-06T10:20:00.000Z\",\n              \"messages\": [\n                {\n                  \"id\": \"msg-1\",\n                  \"timestamp\": \"2026-03-06T10:17:58.000Z\",\n                  \"type\": \"user\",\n                  \"content\": \"hello\"\n                }\n              ]\n            }\"#,\n        )\n        .expect(\"write session\");\n\n        delete_session(temp.path(), &path, \"gemini-session-123\").expect(\"delete session\");\n\n        assert!(!path.exists());\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/session_manager/providers/mod.rs",
    "content": "pub mod claude;\npub mod codex;\npub mod gemini;\npub mod openclaw;\npub mod opencode;\nmod utils;\n"
  },
  {
    "path": "src-tauri/src/session_manager/providers/openclaw.rs",
    "content": "use std::fs::File;\nuse std::io::{BufRead, BufReader};\nuse std::path::Path;\n\nuse serde_json::Value;\n\nuse crate::openclaw_config::get_openclaw_dir;\nuse crate::{\n    config::write_json_file,\n    session_manager::{SessionMessage, SessionMeta},\n};\n\nuse super::utils::{\n    extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary,\n};\n\nconst PROVIDER_ID: &str = \"openclaw\";\n\npub fn scan_sessions() -> Vec<SessionMeta> {\n    let agents_dir = get_openclaw_dir().join(\"agents\");\n    if !agents_dir.exists() {\n        return Vec::new();\n    }\n\n    let mut sessions = Vec::new();\n\n    // Traverse each agent directory\n    let agent_entries = match std::fs::read_dir(&agents_dir) {\n        Ok(entries) => entries,\n        Err(_) => return sessions,\n    };\n\n    for agent_entry in agent_entries.flatten() {\n        let agent_path = agent_entry.path();\n        if !agent_path.is_dir() {\n            continue;\n        }\n\n        let sessions_dir = agent_path.join(\"sessions\");\n        if !sessions_dir.is_dir() {\n            continue;\n        }\n\n        let session_entries = match std::fs::read_dir(&sessions_dir) {\n            Ok(entries) => entries,\n            Err(_) => continue,\n        };\n\n        for entry in session_entries.flatten() {\n            let path = entry.path();\n            if path.extension().and_then(|ext| ext.to_str()) != Some(\"jsonl\") {\n                continue;\n            }\n            // Skip sessions.json index file\n            if path\n                .file_name()\n                .and_then(|n| n.to_str())\n                .map(|n| n == \"sessions.json\")\n                .unwrap_or(false)\n            {\n                continue;\n            }\n\n            if let Some(meta) = parse_session(&path) {\n                sessions.push(meta);\n            }\n        }\n    }\n\n    sessions\n}\n\npub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {\n    let file = File::open(path).map_err(|e| format!(\"Failed to open session file: {e}\"))?;\n    let reader = BufReader::new(file);\n    let mut messages = Vec::new();\n\n    for line in reader.lines() {\n        let line = match line {\n            Ok(value) => value,\n            Err(_) => continue,\n        };\n        let value: Value = match serde_json::from_str(&line) {\n            Ok(parsed) => parsed,\n            Err(_) => continue,\n        };\n\n        if value.get(\"type\").and_then(Value::as_str) != Some(\"message\") {\n            continue;\n        }\n\n        let message = match value.get(\"message\") {\n            Some(msg) => msg,\n            None => continue,\n        };\n\n        let raw_role = message\n            .get(\"role\")\n            .and_then(Value::as_str)\n            .unwrap_or(\"unknown\");\n\n        // Map OpenClaw roles to our standard roles\n        let role = match raw_role {\n            \"toolResult\" => \"tool\".to_string(),\n            other => other.to_string(),\n        };\n\n        let content = message.get(\"content\").map(extract_text).unwrap_or_default();\n        if content.trim().is_empty() {\n            continue;\n        }\n\n        let ts = value.get(\"timestamp\").and_then(parse_timestamp_to_ms);\n\n        messages.push(SessionMessage { role, content, ts });\n    }\n\n    Ok(messages)\n}\n\npub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result<bool, String> {\n    let meta = parse_session(path).ok_or_else(|| {\n        format!(\n            \"Failed to parse OpenClaw session metadata: {}\",\n            path.display()\n        )\n    })?;\n\n    if meta.session_id != session_id {\n        return Err(format!(\n            \"OpenClaw session ID mismatch: expected {session_id}, found {}\",\n            meta.session_id\n        ));\n    }\n\n    let index_path = path\n        .parent()\n        .unwrap_or_else(|| Path::new(\"\"))\n        .join(\"sessions.json\");\n    prune_sessions_index(&index_path, session_id, path)?;\n\n    std::fs::remove_file(path).map_err(|e| {\n        format!(\n            \"Failed to delete OpenClaw session file {}: {e}\",\n            path.display()\n        )\n    })?;\n\n    Ok(true)\n}\n\nfn parse_session(path: &Path) -> Option<SessionMeta> {\n    let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?;\n\n    let mut session_id: Option<String> = None;\n    let mut cwd: Option<String> = None;\n    let mut created_at: Option<i64> = None;\n    let mut summary: Option<String> = None;\n\n    // Extract metadata and first message summary from head lines\n    for line in &head {\n        let value: Value = match serde_json::from_str(line) {\n            Ok(parsed) => parsed,\n            Err(_) => continue,\n        };\n\n        if created_at.is_none() {\n            created_at = value.get(\"timestamp\").and_then(parse_timestamp_to_ms);\n        }\n\n        let event_type = value.get(\"type\").and_then(Value::as_str).unwrap_or(\"\");\n\n        if event_type == \"session\" {\n            if session_id.is_none() {\n                session_id = value\n                    .get(\"id\")\n                    .and_then(Value::as_str)\n                    .map(|s| s.to_string());\n            }\n            if cwd.is_none() {\n                cwd = value\n                    .get(\"cwd\")\n                    .and_then(Value::as_str)\n                    .map(|s| s.to_string());\n            }\n            if let Some(ts) = value.get(\"timestamp\").and_then(parse_timestamp_to_ms) {\n                created_at.get_or_insert(ts);\n            }\n            continue;\n        }\n\n        // OpenClaw summary is the first message content\n        if event_type == \"message\" && summary.is_none() {\n            if let Some(message) = value.get(\"message\") {\n                let text = message.get(\"content\").map(extract_text).unwrap_or_default();\n                if !text.trim().is_empty() {\n                    summary = Some(text);\n                }\n            }\n        }\n    }\n\n    // Extract last_active_at from tail lines (reverse order)\n    let mut last_active_at: Option<i64> = None;\n    for line in tail.iter().rev() {\n        let value: Value = match serde_json::from_str(line) {\n            Ok(parsed) => parsed,\n            Err(_) => continue,\n        };\n        if let Some(ts) = value.get(\"timestamp\").and_then(parse_timestamp_to_ms) {\n            last_active_at = Some(ts);\n            break;\n        }\n    }\n\n    // Fall back to filename as session ID\n    let session_id = session_id.or_else(|| {\n        path.file_stem()\n            .and_then(|s| s.to_str())\n            .map(|s| s.to_string())\n    });\n    let session_id = session_id?;\n\n    let title = cwd\n        .as_deref()\n        .and_then(path_basename)\n        .map(|s| s.to_string());\n\n    let summary = summary.map(|text| truncate_summary(&text, 160));\n\n    Some(SessionMeta {\n        provider_id: PROVIDER_ID.to_string(),\n        session_id: session_id.clone(),\n        title,\n        summary,\n        project_dir: cwd,\n        created_at,\n        last_active_at,\n        source_path: Some(path.to_string_lossy().to_string()),\n        resume_command: None, // OpenClaw sessions are gateway-managed, no CLI resume\n    })\n}\n\nfn prune_sessions_index(\n    index_path: &Path,\n    session_id: &str,\n    source_path: &Path,\n) -> Result<(), String> {\n    if !index_path.exists() {\n        return Ok(());\n    }\n\n    let content = std::fs::read_to_string(index_path).map_err(|e| {\n        format!(\n            \"Failed to read OpenClaw sessions index {}: {e}\",\n            index_path.display()\n        )\n    })?;\n    let mut index: serde_json::Map<String, Value> =\n        serde_json::from_str(&content).map_err(|e| {\n            format!(\n                \"Failed to parse OpenClaw sessions index {}: {e}\",\n                index_path.display()\n            )\n        })?;\n\n    let source = source_path.to_string_lossy();\n    index.retain(|_, entry| {\n        let same_id = entry.get(\"sessionId\").and_then(Value::as_str) == Some(session_id);\n        let same_file = entry.get(\"sessionFile\").and_then(Value::as_str) == Some(source.as_ref());\n        !(same_id || same_file)\n    });\n\n    write_json_file(index_path, &index).map_err(|e| {\n        format!(\n            \"Failed to update OpenClaw sessions index {}: {e}\",\n            index_path.display()\n        )\n    })\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use tempfile::tempdir;\n\n    #[test]\n    fn delete_session_updates_index_and_removes_jsonl() {\n        let temp = tempdir().expect(\"tempdir\");\n        let sessions_dir = temp.path().join(\"main\").join(\"sessions\");\n        std::fs::create_dir_all(&sessions_dir).expect(\"create sessions dir\");\n\n        let session_path = sessions_dir.join(\"session-123.jsonl\");\n        std::fs::write(\n            &session_path,\n            concat!(\n                \"{\\\"type\\\":\\\"session\\\",\\\"id\\\":\\\"session-123\\\",\\\"cwd\\\":\\\"/tmp/project\\\",\\\"timestamp\\\":\\\"2026-03-06T10:00:00Z\\\"}\\n\",\n                \"{\\\"type\\\":\\\"message\\\",\\\"message\\\":{\\\"role\\\":\\\"user\\\",\\\"content\\\":\\\"hello\\\"},\\\"timestamp\\\":\\\"2026-03-06T10:01:00Z\\\"}\\n\"\n            ),\n        )\n        .expect(\"write session\");\n        std::fs::write(\n            sessions_dir.join(\"sessions.json\"),\n            format!(\n                r#\"{{\n                  \"agent:main:main\": {{\n                    \"sessionId\": \"session-123\",\n                    \"sessionFile\": \"{}\"\n                  }},\n                  \"agent:main:other\": {{\n                    \"sessionId\": \"session-456\",\n                    \"sessionFile\": \"{}/session-456.jsonl\"\n                  }}\n                }}\"#,\n                session_path.display(),\n                sessions_dir.display()\n            ),\n        )\n        .expect(\"write index\");\n\n        delete_session(temp.path(), &session_path, \"session-123\").expect(\"delete session\");\n\n        assert!(!session_path.exists());\n        let updated: serde_json::Value = serde_json::from_str(\n            &std::fs::read_to_string(sessions_dir.join(\"sessions.json\")).expect(\"read index\"),\n        )\n        .expect(\"parse index\");\n        assert!(updated.get(\"agent:main:main\").is_none());\n        assert!(updated.get(\"agent:main:other\").is_some());\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/session_manager/providers/opencode.rs",
    "content": "use std::path::{Path, PathBuf};\n\nuse serde_json::Value;\n\nuse crate::session_manager::{SessionMessage, SessionMeta};\n\nuse super::utils::{parse_timestamp_to_ms, path_basename, truncate_summary};\n\nconst PROVIDER_ID: &str = \"opencode\";\n\n/// Return the OpenCode data directory.\n///\n/// Respects `XDG_DATA_HOME` on all platforms; falls back to\n/// `~/.local/share/opencode/storage/`.\npub(crate) fn get_opencode_data_dir() -> PathBuf {\n    if let Ok(xdg) = std::env::var(\"XDG_DATA_HOME\") {\n        if !xdg.is_empty() {\n            return PathBuf::from(xdg).join(\"opencode\").join(\"storage\");\n        }\n    }\n    dirs::home_dir()\n        .map(|h| h.join(\".local/share/opencode/storage\"))\n        .unwrap_or_else(|| PathBuf::from(\".local/share/opencode/storage\"))\n}\n\npub fn scan_sessions() -> Vec<SessionMeta> {\n    let storage = get_opencode_data_dir();\n    let session_dir = storage.join(\"session\");\n    if !session_dir.exists() {\n        return Vec::new();\n    }\n\n    let mut json_files = Vec::new();\n    collect_json_files(&session_dir, &mut json_files);\n\n    let mut sessions = Vec::new();\n    for path in json_files {\n        if let Some(meta) = parse_session(&storage, &path) {\n            sessions.push(meta);\n        }\n    }\n    sessions\n}\n\npub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {\n    // `path` is the message directory: storage/message/{sessionID}/\n    if !path.is_dir() {\n        return Err(format!(\"Message directory not found: {}\", path.display()));\n    }\n\n    let storage = path\n        .parent()\n        .and_then(|p| p.parent())\n        .ok_or_else(|| \"Cannot determine storage root from message path\".to_string())?;\n\n    let mut msg_files = Vec::new();\n    collect_json_files(path, &mut msg_files);\n\n    // Parse all messages and collect (created_ts, message_id, role, parts_text)\n    let mut entries: Vec<(i64, String, String, String)> = Vec::new();\n\n    for msg_path in &msg_files {\n        let data = match std::fs::read_to_string(msg_path) {\n            Ok(d) => d,\n            Err(_) => continue,\n        };\n        let value: Value = match serde_json::from_str(&data) {\n            Ok(v) => v,\n            Err(_) => continue,\n        };\n\n        let msg_id = match value.get(\"id\").and_then(Value::as_str) {\n            Some(id) => id.to_string(),\n            None => continue,\n        };\n\n        let role = value\n            .get(\"role\")\n            .and_then(Value::as_str)\n            .unwrap_or(\"unknown\")\n            .to_string();\n\n        let created_ts = value\n            .get(\"time\")\n            .and_then(|t| t.get(\"created\"))\n            .and_then(parse_timestamp_to_ms)\n            .unwrap_or(0);\n\n        // Collect text parts from storage/part/{messageID}/\n        let part_dir = storage.join(\"part\").join(&msg_id);\n        let text = collect_parts_text(&part_dir);\n        if text.trim().is_empty() {\n            continue;\n        }\n\n        entries.push((created_ts, msg_id, role, text));\n    }\n\n    // Sort by created timestamp\n    entries.sort_by_key(|(ts, _, _, _)| *ts);\n\n    let messages = entries\n        .into_iter()\n        .map(|(ts, _, role, content)| SessionMessage {\n            role,\n            content,\n            ts: if ts > 0 { Some(ts) } else { None },\n        })\n        .collect();\n\n    Ok(messages)\n}\n\npub fn delete_session(storage: &Path, path: &Path, session_id: &str) -> Result<bool, String> {\n    if path.file_name().and_then(|name| name.to_str()) != Some(session_id) {\n        return Err(format!(\n            \"OpenCode session path does not match session ID: expected {session_id}, found {}\",\n            path.display()\n        ));\n    }\n\n    let mut message_files = Vec::new();\n    collect_json_files(path, &mut message_files);\n\n    let mut message_ids = Vec::new();\n    for message_path in &message_files {\n        let data = match std::fs::read_to_string(message_path) {\n            Ok(data) => data,\n            Err(_) => continue,\n        };\n        let value: Value = match serde_json::from_str(&data) {\n            Ok(value) => value,\n            Err(_) => continue,\n        };\n        if let Some(message_id) = value.get(\"id\").and_then(Value::as_str) {\n            message_ids.push(message_id.to_string());\n        }\n    }\n\n    for message_id in &message_ids {\n        let part_dir = storage.join(\"part\").join(message_id);\n        remove_dir_all_if_exists(&part_dir).map_err(|e| {\n            format!(\n                \"Failed to delete OpenCode part directory {}: {e}\",\n                part_dir.display()\n            )\n        })?;\n    }\n\n    let session_diff_path = storage\n        .join(\"session_diff\")\n        .join(format!(\"{session_id}.json\"));\n    remove_file_if_exists(&session_diff_path).map_err(|e| {\n        format!(\n            \"Failed to delete OpenCode session diff {}: {e}\",\n            session_diff_path.display()\n        )\n    })?;\n\n    remove_dir_all_if_exists(path).map_err(|e| {\n        format!(\n            \"Failed to delete OpenCode message directory {}: {e}\",\n            path.display()\n        )\n    })?;\n\n    if let Some(session_file) = find_session_file(storage, session_id) {\n        remove_file_if_exists(&session_file).map_err(|e| {\n            format!(\n                \"Failed to delete OpenCode session file {}: {e}\",\n                session_file.display()\n            )\n        })?;\n    }\n\n    Ok(true)\n}\n\nfn parse_session(storage: &Path, path: &Path) -> Option<SessionMeta> {\n    let data = std::fs::read_to_string(path).ok()?;\n    let value: Value = serde_json::from_str(&data).ok()?;\n\n    let session_id = value.get(\"id\").and_then(Value::as_str)?.to_string();\n    let title = value\n        .get(\"title\")\n        .and_then(Value::as_str)\n        .filter(|s| !s.is_empty())\n        .map(|s| s.to_string());\n    let directory = value\n        .get(\"directory\")\n        .and_then(Value::as_str)\n        .map(|s| s.to_string());\n\n    let created_at = value\n        .get(\"time\")\n        .and_then(|t| t.get(\"created\"))\n        .and_then(parse_timestamp_to_ms);\n    let updated_at = value\n        .get(\"time\")\n        .and_then(|t| t.get(\"updated\"))\n        .and_then(parse_timestamp_to_ms);\n\n    // Derive title from directory basename if no explicit title\n    let has_title = title.is_some();\n    let display_title = title.or_else(|| {\n        directory\n            .as_deref()\n            .and_then(path_basename)\n            .map(|s| s.to_string())\n    });\n\n    // Build source_path = message directory for this session\n    let msg_dir = storage.join(\"message\").join(&session_id);\n    let source_path = msg_dir.to_string_lossy().to_string();\n\n    // Skip expensive I/O if title already available from session JSON\n    let summary = if has_title {\n        display_title.clone()\n    } else {\n        get_first_user_summary(storage, &session_id)\n    };\n\n    Some(SessionMeta {\n        provider_id: PROVIDER_ID.to_string(),\n        session_id: session_id.clone(),\n        title: display_title,\n        summary,\n        project_dir: directory,\n        created_at,\n        last_active_at: updated_at.or(created_at),\n        source_path: Some(source_path),\n        resume_command: Some(format!(\"opencode session resume {session_id}\")),\n    })\n}\n\n/// Read the first user message's first text part to use as summary.\nfn get_first_user_summary(storage: &Path, session_id: &str) -> Option<String> {\n    let msg_dir = storage.join(\"message\").join(session_id);\n    if !msg_dir.is_dir() {\n        return None;\n    }\n\n    let mut msg_files = Vec::new();\n    collect_json_files(&msg_dir, &mut msg_files);\n\n    // Collect user messages with timestamps for ordering\n    let mut user_msgs: Vec<(i64, String)> = Vec::new();\n    for msg_path in &msg_files {\n        let data = match std::fs::read_to_string(msg_path) {\n            Ok(d) => d,\n            Err(_) => continue,\n        };\n        let value: Value = match serde_json::from_str(&data) {\n            Ok(v) => v,\n            Err(_) => continue,\n        };\n\n        if value.get(\"role\").and_then(Value::as_str) != Some(\"user\") {\n            continue;\n        }\n\n        let msg_id = match value.get(\"id\").and_then(Value::as_str) {\n            Some(id) => id.to_string(),\n            None => continue,\n        };\n\n        let ts = value\n            .get(\"time\")\n            .and_then(|t| t.get(\"created\"))\n            .and_then(parse_timestamp_to_ms)\n            .unwrap_or(0);\n\n        user_msgs.push((ts, msg_id));\n    }\n\n    user_msgs.sort_by_key(|(ts, _)| *ts);\n\n    // Take first user message and get its parts\n    let (_, first_id) = user_msgs.first()?;\n    let part_dir = storage.join(\"part\").join(first_id);\n    let text = collect_parts_text(&part_dir);\n    if text.trim().is_empty() {\n        return None;\n    }\n    Some(truncate_summary(&text, 160))\n}\n\n/// Collect text content from all parts in a part directory.\nfn collect_parts_text(part_dir: &Path) -> String {\n    if !part_dir.is_dir() {\n        return String::new();\n    }\n\n    let mut parts = Vec::new();\n    collect_json_files(part_dir, &mut parts);\n\n    let mut texts = Vec::new();\n    for part_path in &parts {\n        let data = match std::fs::read_to_string(part_path) {\n            Ok(d) => d,\n            Err(_) => continue,\n        };\n        let value: Value = match serde_json::from_str(&data) {\n            Ok(v) => v,\n            Err(_) => continue,\n        };\n\n        // Only include text-type parts\n        if value.get(\"type\").and_then(Value::as_str) != Some(\"text\") {\n            continue;\n        }\n\n        if let Some(text) = value.get(\"text\").and_then(Value::as_str) {\n            if !text.trim().is_empty() {\n                texts.push(text.to_string());\n            }\n        }\n    }\n\n    texts.join(\"\\n\")\n}\n\nfn collect_json_files(root: &Path, files: &mut Vec<PathBuf>) {\n    if !root.exists() {\n        return;\n    }\n\n    let entries = match std::fs::read_dir(root) {\n        Ok(entries) => entries,\n        Err(_) => return,\n    };\n\n    for entry in entries.flatten() {\n        let path = entry.path();\n        if path.is_dir() {\n            collect_json_files(&path, files);\n        } else if path.extension().and_then(|ext| ext.to_str()) == Some(\"json\") {\n            files.push(path);\n        }\n    }\n}\n\nfn find_session_file(storage: &Path, session_id: &str) -> Option<PathBuf> {\n    let session_root = storage.join(\"session\");\n    let mut files = Vec::new();\n    collect_json_files(&session_root, &mut files);\n    let expected = format!(\"{session_id}.json\");\n\n    files\n        .into_iter()\n        .find(|path| path.file_name().and_then(|name| name.to_str()) == Some(expected.as_str()))\n}\n\nfn remove_file_if_exists(path: &Path) -> std::io::Result<()> {\n    match std::fs::remove_file(path) {\n        Ok(()) => Ok(()),\n        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),\n        Err(err) => Err(err),\n    }\n}\n\nfn remove_dir_all_if_exists(path: &Path) -> std::io::Result<()> {\n    match std::fs::remove_dir_all(path) {\n        Ok(()) => Ok(()),\n        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),\n        Err(err) => Err(err),\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use tempfile::tempdir;\n\n    #[test]\n    fn delete_session_removes_session_diff_messages_and_parts() {\n        let temp = tempdir().expect(\"tempdir\");\n        let storage = temp.path();\n        let project_id = \"project-123\";\n        let session_id = \"ses_123\";\n        let session_dir = storage.join(\"session\").join(project_id);\n        let message_dir = storage.join(\"message\").join(session_id);\n        let session_diff = storage\n            .join(\"session_diff\")\n            .join(format!(\"{session_id}.json\"));\n        let part_dir = storage.join(\"part\").join(\"msg_1\");\n        let session_file = session_dir.join(format!(\"{session_id}.json\"));\n\n        std::fs::create_dir_all(&session_dir).expect(\"create session dir\");\n        std::fs::create_dir_all(&message_dir).expect(\"create message dir\");\n        std::fs::create_dir_all(&part_dir).expect(\"create part dir\");\n        std::fs::create_dir_all(storage.join(\"project\")).expect(\"create project dir\");\n        std::fs::create_dir_all(storage.join(\"session_diff\")).expect(\"create session diff dir\");\n\n        std::fs::write(\n            &session_file,\n            format!(\n                r#\"{{\n                  \"id\": \"{session_id}\",\n                  \"projectID\": \"{project_id}\",\n                  \"directory\": \"/tmp/project\",\n                  \"time\": {{ \"created\": 1, \"updated\": 2 }}\n                }}\"#\n            ),\n        )\n        .expect(\"write session file\");\n        std::fs::write(\n            message_dir.join(\"msg_1.json\"),\n            format!(r#\"{{\"id\":\"msg_1\",\"sessionID\":\"{session_id}\",\"role\":\"user\"}}\"#),\n        )\n        .expect(\"write message file\");\n        std::fs::write(\n            part_dir.join(\"prt_1.json\"),\n            r#\"{\"id\":\"prt_1\",\"messageID\":\"msg_1\"}\"#,\n        )\n        .expect(\"write part file\");\n        std::fs::write(&session_diff, \"[]\").expect(\"write session diff\");\n        std::fs::write(\n            storage.join(\"project\").join(format!(\"{project_id}.json\")),\n            r#\"{\"id\":\"project-123\"}\"#,\n        )\n        .expect(\"write project file\");\n\n        delete_session(storage, &message_dir, session_id).expect(\"delete session\");\n\n        assert!(!session_file.exists());\n        assert!(!message_dir.exists());\n        assert!(!session_diff.exists());\n        assert!(!part_dir.exists());\n        assert!(storage\n            .join(\"project\")\n            .join(format!(\"{project_id}.json\"))\n            .exists());\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/session_manager/providers/utils.rs",
    "content": "use std::fs::File;\nuse std::io::{self, BufRead, BufReader, Seek, SeekFrom};\nuse std::path::Path;\n\nuse chrono::{DateTime, FixedOffset};\nuse serde_json::Value;\n\n/// Read the first `head_n` lines and last `tail_n` lines from a file.\n/// For small files (< 16 KB), reads all lines once to avoid unnecessary seeking.\npub fn read_head_tail_lines(\n    path: &Path,\n    head_n: usize,\n    tail_n: usize,\n) -> io::Result<(Vec<String>, Vec<String>)> {\n    let file = File::open(path)?;\n    let file_len = file.metadata()?.len();\n\n    // For small files, read all lines once and split\n    if file_len < 16_384 {\n        let reader = BufReader::new(file);\n        let all: Vec<String> = reader.lines().map_while(Result::ok).collect();\n        let head = all.iter().take(head_n).cloned().collect();\n        let skip = all.len().saturating_sub(tail_n);\n        let tail = all.into_iter().skip(skip).collect();\n        return Ok((head, tail));\n    }\n\n    // Read head lines from the beginning\n    let reader = BufReader::new(file);\n    let head: Vec<String> = reader.lines().take(head_n).map_while(Result::ok).collect();\n\n    // Seek to last ~16 KB for tail lines\n    let seek_pos = file_len.saturating_sub(16_384);\n    let mut file2 = File::open(path)?;\n    file2.seek(SeekFrom::Start(seek_pos))?;\n    let tail_reader = BufReader::new(file2);\n    let all_tail: Vec<String> = tail_reader.lines().map_while(Result::ok).collect();\n\n    // Skip first partial line if we seeked into the middle of a line\n    let skip_first = if seek_pos > 0 { 1 } else { 0 };\n    let usable: Vec<String> = all_tail.into_iter().skip(skip_first).collect();\n    let skip = usable.len().saturating_sub(tail_n);\n    let tail = usable.into_iter().skip(skip).collect();\n\n    Ok((head, tail))\n}\n\npub fn parse_timestamp_to_ms(value: &Value) -> Option<i64> {\n    let raw = value.as_str()?;\n    DateTime::parse_from_rfc3339(raw)\n        .ok()\n        .map(|dt: DateTime<FixedOffset>| dt.timestamp_millis())\n}\n\npub fn extract_text(content: &Value) -> String {\n    match content {\n        Value::String(text) => text.to_string(),\n        Value::Array(items) => items\n            .iter()\n            .filter_map(extract_text_from_item)\n            .filter(|text| !text.trim().is_empty())\n            .collect::<Vec<_>>()\n            .join(\"\\n\"),\n        Value::Object(map) => map\n            .get(\"text\")\n            .and_then(|v| v.as_str())\n            .unwrap_or_default()\n            .to_string(),\n        _ => String::new(),\n    }\n}\n\nfn extract_text_from_item(item: &Value) -> Option<String> {\n    if let Some(text) = item.get(\"text\").and_then(|v| v.as_str()) {\n        return Some(text.to_string());\n    }\n\n    if let Some(text) = item.get(\"input_text\").and_then(|v| v.as_str()) {\n        return Some(text.to_string());\n    }\n\n    if let Some(text) = item.get(\"output_text\").and_then(|v| v.as_str()) {\n        return Some(text.to_string());\n    }\n\n    if let Some(content) = item.get(\"content\") {\n        let text = extract_text(content);\n        if !text.is_empty() {\n            return Some(text);\n        }\n    }\n\n    None\n}\n\npub fn truncate_summary(text: &str, max_chars: usize) -> String {\n    let trimmed = text.trim();\n    if trimmed.is_empty() {\n        return String::new();\n    }\n    if trimmed.chars().count() <= max_chars {\n        return trimmed.to_string();\n    }\n\n    let mut result = trimmed.chars().take(max_chars).collect::<String>();\n    result.push_str(\"...\");\n    result\n}\n\npub fn path_basename(value: &str) -> Option<String> {\n    let trimmed = value.trim();\n    if trimmed.is_empty() {\n        return None;\n    }\n    let normalized = trimmed.trim_end_matches(['/', '\\\\']);\n    let last = normalized\n        .split(['/', '\\\\'])\n        .next_back()\n        .filter(|segment| !segment.is_empty())?;\n    Some(last.to_string())\n}\n"
  },
  {
    "path": "src-tauri/src/session_manager/terminal/mod.rs",
    "content": "use std::process::Command;\n\npub fn launch_terminal(\n    target: &str,\n    command: &str,\n    cwd: Option<&str>,\n    custom_config: Option<&str>,\n) -> Result<(), String> {\n    if command.trim().is_empty() {\n        return Err(\"Resume command is empty\".to_string());\n    }\n\n    if !cfg!(target_os = \"macos\") {\n        return Err(\"Terminal resume is only supported on macOS\".to_string());\n    }\n\n    match target {\n        \"terminal\" => launch_macos_terminal(command, cwd),\n        \"iTerm\" | \"iterm\" => launch_iterm(command, cwd),\n        \"ghostty\" => launch_ghostty(command, cwd),\n        \"kitty\" => launch_kitty(command, cwd),\n        \"wezterm\" => launch_wezterm(command, cwd),\n        \"alacritty\" => launch_alacritty(command, cwd),\n        \"custom\" => launch_custom(command, cwd, custom_config),\n        _ => Err(format!(\"Unsupported terminal target: {target}\")),\n    }\n}\n\nfn launch_macos_terminal(command: &str, cwd: Option<&str>) -> Result<(), String> {\n    let full_command = build_shell_command(command, cwd);\n    let escaped = escape_osascript(&full_command);\n    let script = format!(\n        r#\"tell application \"Terminal\"\n    activate\n    do script \"{escaped}\"\nend tell\"#\n    );\n\n    let status = Command::new(\"osascript\")\n        .arg(\"-e\")\n        .arg(script)\n        .status()\n        .map_err(|e| format!(\"Failed to launch Terminal: {e}\"))?;\n\n    if status.success() {\n        Ok(())\n    } else {\n        Err(\"Terminal command execution failed\".to_string())\n    }\n}\n\nfn launch_iterm(command: &str, cwd: Option<&str>) -> Result<(), String> {\n    let full_command = build_shell_command(command, cwd);\n    let escaped = escape_osascript(&full_command);\n    // iTerm2 AppleScript to create a new window and execute command\n    let script = format!(\n        r#\"tell application \"iTerm\"\n    activate\n    create window with default profile\n    tell current session of current window\n        write text \"{escaped}\"\n    end tell\nend tell\"#\n    );\n\n    let status = Command::new(\"osascript\")\n        .arg(\"-e\")\n        .arg(script)\n        .status()\n        .map_err(|e| format!(\"Failed to launch iTerm: {e}\"))?;\n\n    if status.success() {\n        Ok(())\n    } else {\n        Err(\"iTerm command execution failed\".to_string())\n    }\n}\n\nfn launch_ghostty(command: &str, cwd: Option<&str>) -> Result<(), String> {\n    let args = build_ghostty_args(command, cwd);\n\n    let status = Command::new(\"open\")\n        .args(args.iter().map(String::as_str))\n        .status()\n        .map_err(|e| format!(\"Failed to launch Ghostty: {e}\"))?;\n\n    if status.success() {\n        Ok(())\n    } else {\n        Err(\"Failed to launch Ghostty. Make sure it is installed.\".to_string())\n    }\n}\n\nfn build_ghostty_args(command: &str, cwd: Option<&str>) -> Vec<String> {\n    let input = ghostty_raw_input(command);\n\n    let mut args = vec![\n        \"-na\".to_string(),\n        \"Ghostty\".to_string(),\n        \"--args\".to_string(),\n        \"--quit-after-last-window-closed=true\".to_string(),\n    ];\n\n    if let Some(dir) = cwd {\n        if !dir.trim().is_empty() {\n            args.push(format!(\"--working-directory={dir}\"));\n        }\n    }\n\n    args.push(format!(\"--input={input}\"));\n    args\n}\n\nfn ghostty_raw_input(command: &str) -> String {\n    let mut escaped = String::from(\"raw:\");\n    for ch in command.chars() {\n        match ch {\n            '\\\\' => escaped.push_str(\"\\\\\\\\\"),\n            '\\n' => escaped.push_str(\"\\\\n\"),\n            '\\r' => escaped.push_str(\"\\\\r\"),\n            _ => escaped.push(ch),\n        }\n    }\n    escaped.push_str(\"\\\\n\");\n    escaped\n}\n\nfn launch_kitty(command: &str, cwd: Option<&str>) -> Result<(), String> {\n    let full_command = build_shell_command(command, cwd);\n\n    // 获取用户默认 shell\n    let shell = std::env::var(\"SHELL\").unwrap_or_else(|_| \"/bin/zsh\".to_string());\n\n    let status = Command::new(\"open\")\n        .arg(\"-na\")\n        .arg(\"kitty\")\n        .arg(\"--args\")\n        .arg(\"-e\")\n        .arg(&shell)\n        .arg(\"-l\")\n        .arg(\"-c\")\n        .arg(&full_command)\n        .status()\n        .map_err(|e| format!(\"Failed to launch Kitty: {e}\"))?;\n\n    if status.success() {\n        Ok(())\n    } else {\n        Err(\"Failed to launch Kitty. Make sure it is installed.\".to_string())\n    }\n}\n\nfn launch_wezterm(command: &str, cwd: Option<&str>) -> Result<(), String> {\n    // wezterm start --cwd ... -- command\n    // To invoke via `open`, we use `open -na \"WezTerm\" --args start ...`\n\n    let full_command = build_shell_command(command, None);\n\n    let mut args = vec![\"-na\", \"WezTerm\", \"--args\", \"start\"];\n\n    if let Some(dir) = cwd {\n        args.push(\"--cwd\");\n        args.push(dir);\n    }\n\n    // Invoke shell to run the command string (to handle pipes, etc)\n    let shell = std::env::var(\"SHELL\").unwrap_or_else(|_| \"/bin/zsh\".to_string());\n    args.push(\"--\");\n    args.push(&shell);\n    args.push(\"-c\");\n    args.push(&full_command);\n\n    let status = Command::new(\"open\")\n        .args(&args)\n        .status()\n        .map_err(|e| format!(\"Failed to launch WezTerm: {e}\"))?;\n\n    if status.success() {\n        Ok(())\n    } else {\n        Err(\"Failed to launch WezTerm.\".to_string())\n    }\n}\n\nfn launch_alacritty(command: &str, cwd: Option<&str>) -> Result<(), String> {\n    // Alacritty: open -na Alacritty --args --working-directory ... -e shell -c command\n    let full_command = build_shell_command(command, None);\n    let shell = std::env::var(\"SHELL\").unwrap_or_else(|_| \"/bin/zsh\".to_string());\n\n    let mut args = vec![\"-na\", \"Alacritty\", \"--args\"];\n\n    if let Some(dir) = cwd {\n        args.push(\"--working-directory\");\n        args.push(dir);\n    }\n\n    args.push(\"-e\");\n    args.push(&shell);\n    args.push(\"-c\");\n    args.push(&full_command);\n\n    let status = Command::new(\"open\")\n        .args(&args)\n        .status()\n        .map_err(|e| format!(\"Failed to launch Alacritty: {e}\"))?;\n\n    if status.success() {\n        Ok(())\n    } else {\n        Err(\"Failed to launch Alacritty.\".to_string())\n    }\n}\n\nfn launch_custom(\n    command: &str,\n    cwd: Option<&str>,\n    custom_config: Option<&str>,\n) -> Result<(), String> {\n    let template = custom_config.ok_or(\"No custom terminal config provided\")?;\n\n    if template.trim().is_empty() {\n        return Err(\"Custom terminal command template is empty\".to_string());\n    }\n\n    let cmd_str = command;\n    let dir_str = cwd.unwrap_or(\".\");\n\n    let final_cmd_line = template\n        .replace(\"{command}\", cmd_str)\n        .replace(\"{cwd}\", dir_str);\n\n    // Execute via sh -c\n    let status = Command::new(\"sh\")\n        .arg(\"-c\")\n        .arg(&final_cmd_line)\n        .status()\n        .map_err(|e| format!(\"Failed to execute custom terminal launcher: {e}\"))?;\n\n    if status.success() {\n        Ok(())\n    } else {\n        Err(\"Custom terminal execution returned error code\".to_string())\n    }\n}\n\nfn build_shell_command(command: &str, cwd: Option<&str>) -> String {\n    match cwd {\n        Some(dir) if !dir.trim().is_empty() => {\n            format!(\"cd {} && {}\", shell_escape(dir), command)\n        }\n        _ => command.to_string(),\n    }\n}\n\nfn shell_escape(value: &str) -> String {\n    let escaped = value.replace('\\\\', \"\\\\\\\\\").replace('\"', \"\\\\\\\"\");\n    format!(\"\\\"{escaped}\\\"\")\n}\n\nfn escape_osascript(value: &str) -> String {\n    value.replace('\\\\', \"\\\\\\\\\").replace('\"', \"\\\\\\\"\")\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn ghostty_uses_shell_mode_for_resume_commands() {\n        let args = build_ghostty_args(\"claude --resume abc-123\", Some(\"/tmp/project dir\"));\n\n        assert_eq!(\n            args,\n            vec![\n                \"-na\",\n                \"Ghostty\",\n                \"--args\",\n                \"--quit-after-last-window-closed=true\",\n                \"--working-directory=/tmp/project dir\",\n                \"--input=raw:claude --resume abc-123\\\\n\",\n            ]\n        );\n    }\n\n    #[test]\n    fn ghostty_keeps_command_without_cwd_prefix_when_not_provided() {\n        let args = build_ghostty_args(\"claude --resume abc-123\", None);\n\n        assert_eq!(\n            args,\n            vec![\n                \"-na\",\n                \"Ghostty\",\n                \"--args\",\n                \"--quit-after-last-window-closed=true\",\n                \"--input=raw:claude --resume abc-123\\\\n\",\n            ]\n        );\n    }\n\n    #[test]\n    fn ghostty_escapes_newlines_and_backslashes_in_input() {\n        assert_eq!(\n            ghostty_raw_input(\"echo foo\\\\\\\\bar\\npwd\"),\n            \"raw:echo foo\\\\\\\\\\\\\\\\bar\\\\npwd\\\\n\"\n        );\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/settings.rs",
    "content": "use serde::{Deserialize, Serialize};\nuse std::fs;\nuse std::io::Write;\nuse std::path::PathBuf;\nuse std::sync::{OnceLock, RwLock};\n\nuse crate::app_config::AppType;\nuse crate::error::AppError;\nuse crate::services::skill::SyncMethod;\n\n/// 自定义端点配置（历史兼容，实际存储在 provider.meta.custom_endpoints）\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct CustomEndpoint {\n    pub url: String,\n    pub added_at: i64,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub last_used: Option<i64>,\n}\n\nfn default_true() -> bool {\n    true\n}\n\n/// 主页面显示的应用配置\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct VisibleApps {\n    #[serde(default = \"default_true\")]\n    pub claude: bool,\n    #[serde(default = \"default_true\")]\n    pub codex: bool,\n    #[serde(default = \"default_true\")]\n    pub gemini: bool,\n    #[serde(default = \"default_true\")]\n    pub opencode: bool,\n    #[serde(default = \"default_true\")]\n    pub openclaw: bool,\n}\n\nimpl Default for VisibleApps {\n    fn default() -> Self {\n        Self {\n            claude: true,\n            codex: true,\n            gemini: true,\n            opencode: true,\n            openclaw: true,\n        }\n    }\n}\n\nimpl VisibleApps {\n    /// Check if the specified app is visible\n    pub fn is_visible(&self, app: &AppType) -> bool {\n        match app {\n            AppType::Claude => self.claude,\n            AppType::Codex => self.codex,\n            AppType::Gemini => self.gemini,\n            AppType::OpenCode => self.opencode,\n            AppType::OpenClaw => self.openclaw,\n        }\n    }\n}\n\n/// WebDAV 同步状态（持久化同步进度信息）\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\n#[serde(rename_all = \"camelCase\")]\npub struct WebDavSyncStatus {\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub last_sync_at: Option<i64>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub last_error: Option<String>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub last_error_source: Option<String>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub last_remote_etag: Option<String>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub last_local_manifest_hash: Option<String>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub last_remote_manifest_hash: Option<String>,\n}\n\nfn default_remote_root() -> String {\n    \"cc-switch-sync\".to_string()\n}\nfn default_profile() -> String {\n    \"default\".to_string()\n}\n\n/// WebDAV 同步设置\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct WebDavSyncSettings {\n    #[serde(default)]\n    pub enabled: bool,\n    #[serde(default)]\n    pub auto_sync: bool,\n    #[serde(default)]\n    pub base_url: String,\n    #[serde(default)]\n    pub username: String,\n    #[serde(default)]\n    pub password: String,\n    #[serde(default = \"default_remote_root\")]\n    pub remote_root: String,\n    #[serde(default = \"default_profile\")]\n    pub profile: String,\n    #[serde(default)]\n    pub status: WebDavSyncStatus,\n}\n\nimpl Default for WebDavSyncSettings {\n    fn default() -> Self {\n        Self {\n            enabled: false,\n            auto_sync: false,\n            base_url: String::new(),\n            username: String::new(),\n            password: String::new(),\n            remote_root: default_remote_root(),\n            profile: default_profile(),\n            status: WebDavSyncStatus::default(),\n        }\n    }\n}\n\nimpl WebDavSyncSettings {\n    pub fn validate(&self) -> Result<(), crate::error::AppError> {\n        if self.base_url.trim().is_empty() {\n            return Err(crate::error::AppError::localized(\n                \"webdav.base_url.required\",\n                \"WebDAV 地址不能为空\",\n                \"WebDAV URL is required.\",\n            ));\n        }\n        if self.username.trim().is_empty() {\n            return Err(crate::error::AppError::localized(\n                \"webdav.username.required\",\n                \"WebDAV 用户名不能为空\",\n                \"WebDAV username is required.\",\n            ));\n        }\n        Ok(())\n    }\n\n    pub fn normalize(&mut self) {\n        self.base_url = self.base_url.trim().to_string();\n        self.username = self.username.trim().to_string();\n        self.remote_root = self.remote_root.trim().to_string();\n        self.profile = self.profile.trim().to_string();\n        if self.remote_root.is_empty() {\n            self.remote_root = default_remote_root();\n        }\n        if self.profile.is_empty() {\n            self.profile = default_profile();\n        }\n    }\n\n    /// Returns true if all credential fields are blank (no config to persist).\n    fn is_empty(&self) -> bool {\n        self.base_url.is_empty() && self.username.is_empty() && self.password.is_empty()\n    }\n}\n\n/// 应用设置结构\n///\n/// 存储设备级别设置，保存在本地 `~/.cc-switch/settings.json`，不随数据库同步。\n/// 这确保了云同步场景下多设备可以独立运作。\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct AppSettings {\n    // ===== 设备级 UI 设置 =====\n    #[serde(default = \"default_show_in_tray\")]\n    pub show_in_tray: bool,\n    #[serde(default = \"default_minimize_to_tray_on_close\")]\n    pub minimize_to_tray_on_close: bool,\n    /// 是否启用 Claude 插件联动\n    #[serde(default)]\n    pub enable_claude_plugin_integration: bool,\n    /// 是否跳过 Claude Code 初次安装确认\n    #[serde(default)]\n    pub skip_claude_onboarding: bool,\n    /// 是否开机自启\n    #[serde(default)]\n    pub launch_on_startup: bool,\n    /// 静默启动（程序启动时不显示主窗口，仅托盘运行）\n    #[serde(default)]\n    pub silent_startup: bool,\n    /// 是否在主页面启用本地代理功能（默认关闭）\n    #[serde(default)]\n    pub enable_local_proxy: bool,\n    /// User has confirmed the local proxy first-run notice\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub proxy_confirmed: Option<bool>,\n    /// User has confirmed the usage query first-run notice\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub usage_confirmed: Option<bool>,\n    /// User has confirmed the stream check first-run notice\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub stream_check_confirmed: Option<bool>,\n    /// Whether to show the failover toggle independently on the main page\n    #[serde(default)]\n    pub enable_failover_toggle: bool,\n    /// User has confirmed the failover toggle first-run notice\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub failover_confirmed: Option<bool>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub language: Option<String>,\n\n    // ===== 主页面显示的应用 =====\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub visible_apps: Option<VisibleApps>,\n\n    // ===== 设备级目录覆盖 =====\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub claude_config_dir: Option<String>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub codex_config_dir: Option<String>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub gemini_config_dir: Option<String>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub opencode_config_dir: Option<String>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub openclaw_config_dir: Option<String>,\n\n    // ===== 当前供应商 ID（设备级）=====\n    /// 当前 Claude 供应商 ID（本地存储，优先于数据库 is_current）\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub current_provider_claude: Option<String>,\n    /// 当前 Codex 供应商 ID（本地存储，优先于数据库 is_current）\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub current_provider_codex: Option<String>,\n    /// 当前 Gemini 供应商 ID（本地存储，优先于数据库 is_current）\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub current_provider_gemini: Option<String>,\n    /// 当前 OpenCode 供应商 ID（本地存储，对 OpenCode 可能无意义，但保持结构一致）\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub current_provider_opencode: Option<String>,\n    /// 当前 OpenClaw 供应商 ID（本地存储，对 OpenClaw 可能无意义，但保持结构一致）\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub current_provider_openclaw: Option<String>,\n\n    // ===== Skill 同步设置 =====\n    /// Skill 同步方式：auto（默认，优先 symlink）、symlink、copy\n    #[serde(default)]\n    pub skill_sync_method: SyncMethod,\n\n    // ===== WebDAV 同步设置 =====\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub webdav_sync: Option<WebDavSyncSettings>,\n\n    // ===== WebDAV 备份设置（旧版，保留向后兼容）=====\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub webdav_backup: Option<serde_json::Value>,\n\n    // ===== 备份策略设置 =====\n    /// Auto-backup interval in hours (default 24, 0 = disabled)\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub backup_interval_hours: Option<u32>,\n    /// Maximum number of backup files to retain (default 10)\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub backup_retain_count: Option<u32>,\n\n    // ===== 终端设置 =====\n    /// 首选终端应用（可选，默认使用系统默认终端）\n    /// - macOS: \"terminal\" | \"iterm2\" | \"warp\" | \"alacritty\" | \"kitty\" | \"ghostty\"\n    /// - Windows: \"cmd\" | \"powershell\" | \"wt\" (Windows Terminal)\n    /// - Linux: \"gnome-terminal\" | \"konsole\" | \"xfce4-terminal\" | \"alacritty\" | \"kitty\" | \"ghostty\"\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub preferred_terminal: Option<String>,\n}\n\nfn default_show_in_tray() -> bool {\n    true\n}\n\nfn default_minimize_to_tray_on_close() -> bool {\n    true\n}\n\nimpl Default for AppSettings {\n    fn default() -> Self {\n        Self {\n            show_in_tray: true,\n            minimize_to_tray_on_close: true,\n            enable_claude_plugin_integration: false,\n            skip_claude_onboarding: false,\n            launch_on_startup: false,\n            silent_startup: false,\n            enable_local_proxy: false,\n            proxy_confirmed: None,\n            usage_confirmed: None,\n            stream_check_confirmed: None,\n            enable_failover_toggle: false,\n            failover_confirmed: None,\n            language: None,\n            visible_apps: None,\n            claude_config_dir: None,\n            codex_config_dir: None,\n            gemini_config_dir: None,\n            opencode_config_dir: None,\n            openclaw_config_dir: None,\n            current_provider_claude: None,\n            current_provider_codex: None,\n            current_provider_gemini: None,\n            current_provider_opencode: None,\n            current_provider_openclaw: None,\n            skill_sync_method: SyncMethod::default(),\n            webdav_sync: None,\n            webdav_backup: None,\n            backup_interval_hours: None,\n            backup_retain_count: None,\n            preferred_terminal: None,\n        }\n    }\n}\n\nimpl AppSettings {\n    fn settings_path() -> Option<PathBuf> {\n        // settings.json 保留用于旧版本迁移和无数据库场景\n        Some(\n            crate::config::get_home_dir()\n                .join(\".cc-switch\")\n                .join(\"settings.json\"),\n        )\n    }\n\n    fn normalize_paths(&mut self) {\n        self.claude_config_dir = self\n            .claude_config_dir\n            .as_ref()\n            .map(|s| s.trim())\n            .filter(|s| !s.is_empty())\n            .map(|s| s.to_string());\n\n        self.codex_config_dir = self\n            .codex_config_dir\n            .as_ref()\n            .map(|s| s.trim())\n            .filter(|s| !s.is_empty())\n            .map(|s| s.to_string());\n\n        self.gemini_config_dir = self\n            .gemini_config_dir\n            .as_ref()\n            .map(|s| s.trim())\n            .filter(|s| !s.is_empty())\n            .map(|s| s.to_string());\n\n        self.opencode_config_dir = self\n            .opencode_config_dir\n            .as_ref()\n            .map(|s| s.trim())\n            .filter(|s| !s.is_empty())\n            .map(|s| s.to_string());\n\n        self.openclaw_config_dir = self\n            .openclaw_config_dir\n            .as_ref()\n            .map(|s| s.trim())\n            .filter(|s| !s.is_empty())\n            .map(|s| s.to_string());\n\n        self.language = self\n            .language\n            .as_ref()\n            .map(|s| s.trim())\n            .filter(|s| matches!(*s, \"en\" | \"zh\" | \"ja\"))\n            .map(|s| s.to_string());\n\n        if let Some(sync) = &mut self.webdav_sync {\n            sync.normalize();\n            if sync.is_empty() {\n                self.webdav_sync = None;\n            }\n        }\n    }\n\n    fn load_from_file() -> Self {\n        let Some(path) = Self::settings_path() else {\n            return Self::default();\n        };\n        if let Ok(content) = fs::read_to_string(&path) {\n            match serde_json::from_str::<AppSettings>(&content) {\n                Ok(mut settings) => {\n                    settings.normalize_paths();\n                    settings\n                }\n                Err(err) => {\n                    log::warn!(\n                        \"解析设置文件失败，将使用默认设置。路径: {}, 错误: {}\",\n                        path.display(),\n                        err\n                    );\n                    Self::default()\n                }\n            }\n        } else {\n            Self::default()\n        }\n    }\n}\n\nfn save_settings_file(settings: &AppSettings) -> Result<(), AppError> {\n    let mut normalized = settings.clone();\n    normalized.normalize_paths();\n    let Some(path) = AppSettings::settings_path() else {\n        return Err(AppError::Config(\"无法获取用户主目录\".to_string()));\n    };\n\n    if let Some(parent) = path.parent() {\n        fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;\n    }\n\n    let json = serde_json::to_string_pretty(&normalized)\n        .map_err(|e| AppError::JsonSerialize { source: e })?;\n    #[cfg(unix)]\n    {\n        use std::fs::OpenOptions;\n        use std::os::unix::fs::OpenOptionsExt;\n\n        let mut file = OpenOptions::new()\n            .create(true)\n            .write(true)\n            .truncate(true)\n            .mode(0o600)\n            .open(&path)\n            .map_err(|e| AppError::io(&path, e))?;\n        file.write_all(json.as_bytes())\n            .map_err(|e| AppError::io(&path, e))?;\n    }\n\n    #[cfg(not(unix))]\n    {\n        fs::write(&path, json).map_err(|e| AppError::io(&path, e))?;\n    }\n\n    Ok(())\n}\n\nstatic SETTINGS_STORE: OnceLock<RwLock<AppSettings>> = OnceLock::new();\n\nfn settings_store() -> &'static RwLock<AppSettings> {\n    SETTINGS_STORE.get_or_init(|| RwLock::new(AppSettings::load_from_file()))\n}\n\nfn resolve_override_path(raw: &str) -> PathBuf {\n    if raw == \"~\" {\n        if let Some(home) = dirs::home_dir() {\n            return home;\n        }\n    } else if let Some(stripped) = raw.strip_prefix(\"~/\") {\n        if let Some(home) = dirs::home_dir() {\n            return home.join(stripped);\n        }\n    } else if let Some(stripped) = raw.strip_prefix(\"~\\\\\") {\n        if let Some(home) = dirs::home_dir() {\n            return home.join(stripped);\n        }\n    }\n\n    PathBuf::from(raw)\n}\n\npub fn get_settings() -> AppSettings {\n    settings_store()\n        .read()\n        .unwrap_or_else(|e| {\n            log::warn!(\"设置锁已毒化，使用恢复值: {e}\");\n            e.into_inner()\n        })\n        .clone()\n}\n\npub fn get_settings_for_frontend() -> AppSettings {\n    let mut settings = get_settings();\n    if let Some(sync) = &mut settings.webdav_sync {\n        sync.password.clear();\n    }\n    settings.webdav_backup = None;\n    settings\n}\n\npub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {\n    new_settings.normalize_paths();\n    save_settings_file(&new_settings)?;\n\n    let mut guard = settings_store().write().unwrap_or_else(|e| {\n        log::warn!(\"设置锁已毒化，使用恢复值: {e}\");\n        e.into_inner()\n    });\n    *guard = new_settings;\n    Ok(())\n}\n\nfn mutate_settings<F>(mutator: F) -> Result<(), AppError>\nwhere\n    F: FnOnce(&mut AppSettings),\n{\n    let mut guard = settings_store().write().unwrap_or_else(|e| {\n        log::warn!(\"设置锁已毒化，使用恢复值: {e}\");\n        e.into_inner()\n    });\n    let mut next = guard.clone();\n    mutator(&mut next);\n    next.normalize_paths();\n    save_settings_file(&next)?;\n    *guard = next;\n    Ok(())\n}\n\n/// 从文件重新加载设置到内存缓存\n/// 用于导入配置等场景，确保内存缓存与文件同步\npub fn reload_settings() -> Result<(), AppError> {\n    let fresh_settings = AppSettings::load_from_file();\n    let mut guard = settings_store().write().unwrap_or_else(|e| {\n        log::warn!(\"设置锁已毒化，使用恢复值: {e}\");\n        e.into_inner()\n    });\n    *guard = fresh_settings;\n    Ok(())\n}\n\npub fn get_claude_override_dir() -> Option<PathBuf> {\n    let settings = settings_store().read().ok()?;\n    settings\n        .claude_config_dir\n        .as_ref()\n        .map(|p| resolve_override_path(p))\n}\n\npub fn get_codex_override_dir() -> Option<PathBuf> {\n    let settings = settings_store().read().ok()?;\n    settings\n        .codex_config_dir\n        .as_ref()\n        .map(|p| resolve_override_path(p))\n}\n\npub fn get_gemini_override_dir() -> Option<PathBuf> {\n    let settings = settings_store().read().ok()?;\n    settings\n        .gemini_config_dir\n        .as_ref()\n        .map(|p| resolve_override_path(p))\n}\n\npub fn get_opencode_override_dir() -> Option<PathBuf> {\n    let settings = settings_store().read().ok()?;\n    settings\n        .opencode_config_dir\n        .as_ref()\n        .map(|p| resolve_override_path(p))\n}\n\npub fn get_openclaw_override_dir() -> Option<PathBuf> {\n    let settings = settings_store().read().ok()?;\n    settings\n        .openclaw_config_dir\n        .as_ref()\n        .map(|p| resolve_override_path(p))\n}\n\n// ===== 当前供应商管理函数 =====\n\n/// 获取指定应用类型的当前供应商 ID（从本地 settings 读取）\n///\n/// 这是设备级别的设置，不随数据库同步。\n/// 如果本地没有设置，调用者应该 fallback 到数据库的 `is_current` 字段。\npub fn get_current_provider(app_type: &AppType) -> Option<String> {\n    let settings = settings_store().read().ok()?;\n    match app_type {\n        AppType::Claude => settings.current_provider_claude.clone(),\n        AppType::Codex => settings.current_provider_codex.clone(),\n        AppType::Gemini => settings.current_provider_gemini.clone(),\n        AppType::OpenCode => settings.current_provider_opencode.clone(),\n        AppType::OpenClaw => settings.current_provider_openclaw.clone(),\n    }\n}\n\n/// 设置指定应用类型的当前供应商 ID（保存到本地 settings）\n///\n/// 这是设备级别的设置，不随数据库同步。\n/// 传入 `None` 会清除当前供应商设置。\npub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(), AppError> {\n    let mut settings = get_settings();\n\n    match app_type {\n        AppType::Claude => settings.current_provider_claude = id.map(|s| s.to_string()),\n        AppType::Codex => settings.current_provider_codex = id.map(|s| s.to_string()),\n        AppType::Gemini => settings.current_provider_gemini = id.map(|s| s.to_string()),\n        AppType::OpenCode => settings.current_provider_opencode = id.map(|s| s.to_string()),\n        AppType::OpenClaw => settings.current_provider_openclaw = id.map(|s| s.to_string()),\n    }\n\n    update_settings(settings)\n}\n\n/// 获取有效的当前供应商 ID（验证存在性）\n///\n/// 逻辑：\n/// 1. 从本地 settings 读取当前供应商 ID\n/// 2. 验证该 ID 在数据库中存在\n/// 3. 如果不存在则清理本地 settings，fallback 到数据库的 is_current\n///\n/// 这确保了返回的 ID 一定是有效的（在数据库中存在）。\n/// 多设备云同步场景下，配置导入后本地 ID 可能失效，此函数会自动修复。\npub fn get_effective_current_provider(\n    db: &crate::database::Database,\n    app_type: &AppType,\n) -> Result<Option<String>, AppError> {\n    // 1. 从本地 settings 读取\n    if let Some(local_id) = get_current_provider(app_type) {\n        // 2. 验证该 ID 在数据库中存在\n        let providers = db.get_all_providers(app_type.as_str())?;\n        if providers.contains_key(&local_id) {\n            // 存在，直接返回\n            return Ok(Some(local_id));\n        }\n\n        // 3. 不存在，清理本地 settings\n        log::warn!(\n            \"本地 settings 中的供应商 {} ({}) 在数据库中不存在，将清理并 fallback 到数据库\",\n            local_id,\n            app_type.as_str()\n        );\n        let _ = set_current_provider(app_type, None);\n    }\n\n    // Fallback 到数据库的 is_current\n    db.get_current_provider(app_type.as_str())\n}\n\n// ===== Skill 同步方式管理函数 =====\n\n/// 获取 Skill 同步方式配置\npub fn get_skill_sync_method() -> SyncMethod {\n    settings_store()\n        .read()\n        .unwrap_or_else(|e| {\n            log::warn!(\"设置锁已毒化，使用恢复值: {e}\");\n            e.into_inner()\n        })\n        .skill_sync_method\n}\n\n// ===== 备份策略管理函数 =====\n\n/// Get the effective auto-backup interval in hours (default 24)\npub fn effective_backup_interval_hours() -> u32 {\n    settings_store()\n        .read()\n        .unwrap_or_else(|e| {\n            log::warn!(\"设置锁已毒化，使用恢复值: {e}\");\n            e.into_inner()\n        })\n        .backup_interval_hours\n        .unwrap_or(24)\n}\n\n/// Get the effective backup retain count (default 10, minimum 1)\npub fn effective_backup_retain_count() -> usize {\n    settings_store()\n        .read()\n        .unwrap_or_else(|e| {\n            log::warn!(\"设置锁已毒化，使用恢复值: {e}\");\n            e.into_inner()\n        })\n        .backup_retain_count\n        .map(|n| (n as usize).max(1))\n        .unwrap_or(10)\n}\n\n// ===== 终端设置管理函数 =====\n\n/// 获取首选终端应用\npub fn get_preferred_terminal() -> Option<String> {\n    settings_store()\n        .read()\n        .unwrap_or_else(|e| {\n            log::warn!(\"设置锁已毒化，使用恢复值: {e}\");\n            e.into_inner()\n        })\n        .preferred_terminal\n        .clone()\n}\n\n// ===== WebDAV 同步设置管理函数 =====\n\n/// 获取 WebDAV 同步设置\npub fn get_webdav_sync_settings() -> Option<WebDavSyncSettings> {\n    settings_store().read().ok()?.webdav_sync.clone()\n}\n\n/// 保存 WebDAV 同步设置\npub fn set_webdav_sync_settings(settings: Option<WebDavSyncSettings>) -> Result<(), AppError> {\n    mutate_settings(|current| {\n        current.webdav_sync = settings;\n    })\n}\n\n/// 仅更新 WebDAV 同步状态，避免覆写 credentials/root/profile 等字段\npub fn update_webdav_sync_status(status: WebDavSyncStatus) -> Result<(), AppError> {\n    mutate_settings(|current| {\n        if let Some(sync) = current.webdav_sync.as_mut() {\n            sync.status = status;\n        }\n    })\n}\n"
  },
  {
    "path": "src-tauri/src/store.rs",
    "content": "use crate::database::Database;\nuse crate::services::ProxyService;\nuse std::sync::Arc;\n\n/// 全局应用状态\npub struct AppState {\n    pub db: Arc<Database>,\n    pub proxy_service: ProxyService,\n}\n\nimpl AppState {\n    /// 创建新的应用状态\n    pub fn new(db: Arc<Database>) -> Self {\n        let proxy_service = ProxyService::new(db.clone());\n\n        Self { db, proxy_service }\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/tray.rs",
    "content": "//! 托盘菜单管理模块\n//!\n//! 负责系统托盘图标和菜单的创建、更新和事件处理。\n\nuse tauri::menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem};\nuse tauri::{Emitter, Manager};\n\nuse crate::app_config::AppType;\nuse crate::error::AppError;\nuse crate::store::AppState;\n\n/// 托盘菜单文本（国际化）\n#[derive(Clone, Copy)]\npub struct TrayTexts {\n    pub show_main: &'static str,\n    pub no_provider_hint: &'static str,\n    pub quit: &'static str,\n    pub _auto_label: &'static str,\n}\n\nimpl TrayTexts {\n    pub fn from_language(language: &str) -> Self {\n        match language {\n            \"en\" => Self {\n                show_main: \"Open main window\",\n                no_provider_hint: \"  (No providers yet, please add them from the main window)\",\n                quit: \"Quit\",\n                _auto_label: \"Auto (Failover)\",\n            },\n            \"ja\" => Self {\n                show_main: \"メインウィンドウを開く\",\n                no_provider_hint:\n                    \"  (プロバイダーがまだありません。メイン画面から追加してください)\",\n                quit: \"終了\",\n                _auto_label: \"自動 (フェイルオーバー)\",\n            },\n            _ => Self {\n                show_main: \"打开主界面\",\n                no_provider_hint: \"  (无供应商，请在主界面添加)\",\n                quit: \"退出\",\n                _auto_label: \"自动 (故障转移)\",\n            },\n        }\n    }\n}\n\n/// 托盘应用分区配置\npub struct TrayAppSection {\n    pub app_type: AppType,\n    pub prefix: &'static str,\n    pub header_id: &'static str,\n    pub empty_id: &'static str,\n    pub header_label: &'static str,\n    pub log_name: &'static str,\n}\n\n/// Auto 菜单项后缀\npub const AUTO_SUFFIX: &str = \"auto\";\n\npub const TRAY_SECTIONS: [TrayAppSection; 3] = [\n    TrayAppSection {\n        app_type: AppType::Claude,\n        prefix: \"claude_\",\n        header_id: \"claude_header\",\n        empty_id: \"claude_empty\",\n        header_label: \"Claude\",\n        log_name: \"Claude\",\n    },\n    TrayAppSection {\n        app_type: AppType::Codex,\n        prefix: \"codex_\",\n        header_id: \"codex_header\",\n        empty_id: \"codex_empty\",\n        header_label: \"Codex\",\n        log_name: \"Codex\",\n    },\n    TrayAppSection {\n        app_type: AppType::Gemini,\n        prefix: \"gemini_\",\n        header_id: \"gemini_header\",\n        empty_id: \"gemini_empty\",\n        header_label: \"Gemini\",\n        log_name: \"Gemini\",\n    },\n];\n\n/// 添加供应商分区到菜单\nfn append_provider_section<'a>(\n    app: &'a tauri::AppHandle,\n    mut menu_builder: MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>,\n    manager: Option<&crate::provider::ProviderManager>,\n    section: &TrayAppSection,\n    tray_texts: &TrayTexts,\n    _app_state: &AppState,\n) -> Result<MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>, AppError> {\n    let Some(manager) = manager else {\n        return Ok(menu_builder);\n    };\n\n    let header = MenuItem::with_id(\n        app,\n        section.header_id,\n        section.header_label,\n        false,\n        None::<&str>,\n    )\n    .map_err(|e| AppError::Message(format!(\"创建{}标题失败: {e}\", section.log_name)))?;\n    menu_builder = menu_builder.item(&header);\n\n    if manager.providers.is_empty() {\n        let empty_hint = MenuItem::with_id(\n            app,\n            section.empty_id,\n            tray_texts.no_provider_hint,\n            false,\n            None::<&str>,\n        )\n        .map_err(|e| AppError::Message(format!(\"创建{}空提示失败: {e}\", section.log_name)))?;\n        return Ok(menu_builder.item(&empty_hint));\n    }\n\n    // Auto (Failover) menu item is hidden from tray; the feature is still\n    // accessible from the Settings page.  Keep the surrounding code intact so\n    // it can be re-enabled easily in the future.\n\n    let mut sorted_providers: Vec<_> = manager.providers.iter().collect();\n    sorted_providers.sort_by(|(_, a), (_, b)| {\n        match (a.sort_index, b.sort_index) {\n            (Some(idx_a), Some(idx_b)) => return idx_a.cmp(&idx_b),\n            (Some(_), None) => return std::cmp::Ordering::Less,\n            (None, Some(_)) => return std::cmp::Ordering::Greater,\n            _ => {}\n        }\n\n        match (a.created_at, b.created_at) {\n            (Some(time_a), Some(time_b)) => return time_a.cmp(&time_b),\n            (Some(_), None) => return std::cmp::Ordering::Greater,\n            (None, Some(_)) => return std::cmp::Ordering::Less,\n            _ => {}\n        }\n\n        a.name.cmp(&b.name)\n    });\n\n    for (id, provider) in sorted_providers {\n        let is_current = manager.current == *id;\n        let item = CheckMenuItem::with_id(\n            app,\n            format!(\"{}{}\", section.prefix, id),\n            &provider.name,\n            true,\n            is_current,\n            None::<&str>,\n        )\n        .map_err(|e| AppError::Message(format!(\"创建{}菜单项失败: {e}\", section.log_name)))?;\n        menu_builder = menu_builder.item(&item);\n    }\n\n    Ok(menu_builder)\n}\n\n/// 处理供应商托盘事件\npub fn handle_provider_tray_event(app: &tauri::AppHandle, event_id: &str) -> bool {\n    for section in TRAY_SECTIONS.iter() {\n        if let Some(suffix) = event_id.strip_prefix(section.prefix) {\n            // 处理 Auto 点击\n            if suffix == AUTO_SUFFIX {\n                log::info!(\"切换到{} Auto模式\", section.log_name);\n                let app_handle = app.clone();\n                let app_type = section.app_type.clone();\n                tauri::async_runtime::spawn_blocking(move || {\n                    if let Err(e) = handle_auto_click(&app_handle, &app_type) {\n                        log::error!(\"切换{}Auto模式失败: {e}\", section.log_name);\n                    }\n                });\n                return true;\n            }\n\n            // 处理供应商点击\n            log::info!(\"切换到{}供应商: {suffix}\", section.log_name);\n            let app_handle = app.clone();\n            let provider_id = suffix.to_string();\n            let app_type = section.app_type.clone();\n            tauri::async_runtime::spawn_blocking(move || {\n                if let Err(e) = handle_provider_click(&app_handle, &app_type, &provider_id) {\n                    log::error!(\"切换{}供应商失败: {e}\", section.log_name);\n                }\n            });\n            return true;\n        }\n    }\n    false\n}\n\n/// 处理 Auto 点击：启用 proxy 和 auto_failover\nfn handle_auto_click(app: &tauri::AppHandle, app_type: &AppType) -> Result<(), AppError> {\n    if let Some(app_state) = app.try_state::<AppState>() {\n        let app_type_str = app_type.as_str();\n\n        // 强一致语义：Auto 模式开启后立即切到队列 P1（P1→P2→...）\n        // 若队列为空，则尝试把“当前供应商”自动加入队列作为 P1，避免用户陷入无法开启的死锁。\n        let mut queue = app_state.db.get_failover_queue(app_type_str)?;\n        if queue.is_empty() {\n            let current_id =\n                crate::settings::get_effective_current_provider(&app_state.db, app_type)?;\n            let Some(current_id) = current_id else {\n                return Err(AppError::Message(\n                    \"故障转移队列为空，且未设置当前供应商，无法启用 Auto 模式\".to_string(),\n                ));\n            };\n            app_state\n                .db\n                .add_to_failover_queue(app_type_str, &current_id)?;\n            queue = app_state.db.get_failover_queue(app_type_str)?;\n        }\n\n        let p1_provider_id = queue\n            .first()\n            .map(|item| item.provider_id.clone())\n            .ok_or_else(|| AppError::Message(\"故障转移队列为空，无法启用 Auto 模式\".to_string()))?;\n\n        // 真正启用 failover：启动代理服务 + 执行接管 + 开启 auto_failover\n        let proxy_service = &app_state.proxy_service;\n\n        // 1) 确保代理服务运行（会自动设置 proxy_enabled = true）\n        let is_running = futures::executor::block_on(proxy_service.is_running());\n        if !is_running {\n            log::info!(\"[Tray] Auto 模式：启动代理服务\");\n            if let Err(e) = futures::executor::block_on(proxy_service.start()) {\n                log::error!(\"[Tray] 启动代理服务失败: {e}\");\n                return Err(AppError::Message(format!(\"启动代理服务失败: {e}\")));\n            }\n        }\n\n        // 2) 执行 Live 配置接管（确保该 app 被代理接管）\n        log::info!(\"[Tray] Auto 模式：对 {app_type_str} 执行接管\");\n        if let Err(e) =\n            futures::executor::block_on(proxy_service.set_takeover_for_app(app_type_str, true))\n        {\n            log::error!(\"[Tray] 执行接管失败: {e}\");\n            return Err(AppError::Message(format!(\"执行接管失败: {e}\")));\n        }\n\n        // 3) 设置 auto_failover_enabled = true\n        app_state\n            .db\n            .set_proxy_flags_sync(app_type_str, true, true)?;\n\n        // 3.1) 立即切到队列 P1（热切换：不写 Live，仅更新 DB/settings/备份）\n        if let Err(e) = futures::executor::block_on(\n            proxy_service.switch_proxy_target(app_type_str, &p1_provider_id),\n        ) {\n            log::error!(\"[Tray] Auto 模式切换到队列 P1 失败: {e}\");\n            return Err(AppError::Message(format!(\n                \"Auto 模式切换到队列 P1 失败: {e}\"\n            )));\n        }\n\n        // 4) 更新托盘菜单\n        if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) {\n            if let Some(tray) = app.tray_by_id(\"main\") {\n                let _ = tray.set_menu(Some(new_menu));\n            }\n        }\n\n        // 5) 发射事件到前端\n        let event_data = serde_json::json!({\n            \"appType\": app_type_str,\n            \"proxyEnabled\": true,\n            \"autoFailoverEnabled\": true,\n            \"providerId\": p1_provider_id\n        });\n        if let Err(e) = app.emit(\"proxy-flags-changed\", event_data.clone()) {\n            log::error!(\"发射 proxy-flags-changed 事件失败: {e}\");\n        }\n        // 发射 provider-switched 事件（保持向后兼容，Auto 切换也算一种切换）\n        if let Err(e) = app.emit(\"provider-switched\", event_data) {\n            log::error!(\"发射 provider-switched 事件失败: {e}\");\n        }\n    }\n    Ok(())\n}\n\n/// 处理供应商点击：关闭 auto_failover + 切换供应商\nfn handle_provider_click(\n    app: &tauri::AppHandle,\n    app_type: &AppType,\n    provider_id: &str,\n) -> Result<(), AppError> {\n    if let Some(app_state) = app.try_state::<AppState>() {\n        let app_type_str = app_type.as_str();\n\n        // 获取当前 proxy 状态，保持 enabled 不变，只关闭 auto_failover\n        let (proxy_enabled, _) = app_state.db.get_proxy_flags_sync(app_type_str);\n        app_state\n            .db\n            .set_proxy_flags_sync(app_type_str, proxy_enabled, false)?;\n\n        // 切换供应商\n        crate::commands::switch_provider(\n            app_state.clone(),\n            app_type_str.to_string(),\n            provider_id.to_string(),\n        )\n        .map_err(AppError::Message)?;\n\n        // 更新托盘菜单\n        if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) {\n            if let Some(tray) = app.tray_by_id(\"main\") {\n                let _ = tray.set_menu(Some(new_menu));\n            }\n        }\n\n        // 发射事件到前端\n        let event_data = serde_json::json!({\n            \"appType\": app_type_str,\n            \"proxyEnabled\": proxy_enabled,\n            \"autoFailoverEnabled\": false,\n            \"providerId\": provider_id\n        });\n        if let Err(e) = app.emit(\"proxy-flags-changed\", event_data.clone()) {\n            log::error!(\"发射 proxy-flags-changed 事件失败: {e}\");\n        }\n        // 发射 provider-switched 事件（保持向后兼容）\n        if let Err(e) = app.emit(\"provider-switched\", event_data) {\n            log::error!(\"发射 provider-switched 事件失败: {e}\");\n        }\n    }\n    Ok(())\n}\n\n/// 创建动态托盘菜单\npub fn create_tray_menu(\n    app: &tauri::AppHandle,\n    app_state: &AppState,\n) -> Result<Menu<tauri::Wry>, AppError> {\n    let app_settings = crate::settings::get_settings();\n    let tray_texts = TrayTexts::from_language(app_settings.language.as_deref().unwrap_or(\"zh\"));\n\n    // Get visible apps setting, default to all visible\n    let visible_apps = app_settings.visible_apps.unwrap_or_default();\n\n    let mut menu_builder = MenuBuilder::new(app);\n\n    // 顶部：打开主界面\n    let show_main_item =\n        MenuItem::with_id(app, \"show_main\", tray_texts.show_main, true, None::<&str>)\n            .map_err(|e| AppError::Message(format!(\"创建打开主界面菜单失败: {e}\")))?;\n    menu_builder = menu_builder.item(&show_main_item).separator();\n\n    // 直接添加所有供应商到主菜单（扁平化结构，更简单可靠）\n    // Only add visible app sections\n    for section in TRAY_SECTIONS.iter() {\n        // Skip hidden apps\n        if !visible_apps.is_visible(&section.app_type) {\n            continue;\n        }\n\n        let app_type_str = section.app_type.as_str();\n        let providers = app_state.db.get_all_providers(app_type_str)?;\n\n        // 使用有效的当前供应商 ID（验证存在性，自动清理失效 ID）\n        let current_id =\n            crate::settings::get_effective_current_provider(&app_state.db, &section.app_type)?\n                .unwrap_or_default();\n\n        let manager = crate::provider::ProviderManager {\n            providers,\n            current: current_id,\n        };\n\n        menu_builder = append_provider_section(\n            app,\n            menu_builder,\n            Some(&manager),\n            section,\n            &tray_texts,\n            app_state,\n        )?;\n\n        // 在每个 section 后添加分隔符\n        menu_builder = menu_builder.separator();\n    }\n\n    // 退出菜单（分隔符已在上面的 section 循环中添加）\n    let quit_item = MenuItem::with_id(app, \"quit\", tray_texts.quit, true, None::<&str>)\n        .map_err(|e| AppError::Message(format!(\"创建退出菜单失败: {e}\")))?;\n\n    menu_builder = menu_builder.item(&quit_item);\n\n    menu_builder\n        .build()\n        .map_err(|e| AppError::Message(format!(\"构建菜单失败: {e}\")))\n}\n\n#[cfg(target_os = \"macos\")]\npub fn apply_tray_policy(app: &tauri::AppHandle, dock_visible: bool) {\n    use tauri::ActivationPolicy;\n\n    let desired_policy = if dock_visible {\n        ActivationPolicy::Regular\n    } else {\n        ActivationPolicy::Accessory\n    };\n\n    if let Err(err) = app.set_dock_visibility(dock_visible) {\n        log::warn!(\"设置 Dock 显示状态失败: {err}\");\n    }\n\n    if let Err(err) = app.set_activation_policy(desired_policy) {\n        log::warn!(\"设置激活策略失败: {err}\");\n    }\n}\n\n/// 处理托盘菜单事件\npub fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {\n    log::info!(\"处理托盘菜单事件: {event_id}\");\n\n    match event_id {\n        \"show_main\" => {\n            if let Some(window) = app.get_webview_window(\"main\") {\n                #[cfg(target_os = \"windows\")]\n                {\n                    let _ = window.set_skip_taskbar(false);\n                }\n                let _ = window.unminimize();\n                let _ = window.show();\n                let _ = window.set_focus();\n                #[cfg(target_os = \"macos\")]\n                {\n                    apply_tray_policy(app, true);\n                }\n            }\n        }\n        \"quit\" => {\n            log::info!(\"退出应用\");\n            app.exit(0);\n        }\n        _ => {\n            if handle_provider_tray_event(app, event_id) {\n                return;\n            }\n            log::warn!(\"未处理的菜单事件: {event_id}\");\n        }\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/usage_script.rs",
    "content": "use rquickjs::{Context, Function, Runtime};\nuse serde_json::Value;\nuse std::collections::HashMap;\nuse url::{Host, Url};\n\nuse crate::error::AppError;\n\n/// 执行用量查询脚本\npub async fn execute_usage_script(\n    script_code: &str,\n    api_key: &str,\n    base_url: &str,\n    timeout_secs: u64,\n    access_token: Option<&str>,\n    user_id: Option<&str>,\n    template_type: Option<&str>,\n) -> Result<Value, AppError> {\n    // 检测是否为自定义模板模式\n    // 优先使用前端传递的 template_type\n    let is_custom_template = template_type.map(|t| t == \"custom\").unwrap_or(false);\n\n    // 1. 替换模板变量，避免泄露敏感信息\n    let script_with_vars =\n        build_script_with_vars(script_code, api_key, base_url, access_token, user_id);\n\n    // 2. 验证 base_url 的安全性（仅当提供了 base_url 时）\n    // 自定义模板模式下，用户可能不使用模板变量，而是直接在脚本中写完整 URL\n    if !base_url.is_empty() {\n        validate_base_url(base_url)?;\n    }\n\n    // 3. 在独立作用域中提取 request 配置（确保 Runtime/Context 在 await 前释放）\n    let request_config = {\n        let runtime = Runtime::new().map_err(|e| {\n            AppError::localized(\n                \"usage_script.runtime_create_failed\",\n                format!(\"创建 JS 运行时失败: {e}\"),\n                format!(\"Failed to create JS runtime: {e}\"),\n            )\n        })?;\n        let context = Context::full(&runtime).map_err(|e| {\n            AppError::localized(\n                \"usage_script.context_create_failed\",\n                format!(\"创建 JS 上下文失败: {e}\"),\n                format!(\"Failed to create JS context: {e}\"),\n            )\n        })?;\n\n        context.with(|ctx| {\n            // 执行用户代码，获取配置对象\n            let config: rquickjs::Object = ctx.eval(script_with_vars.clone()).map_err(|e| {\n                AppError::localized(\n                    \"usage_script.config_parse_failed\",\n                    format!(\"解析配置失败: {e}\"),\n                    format!(\"Failed to parse config: {e}\"),\n                )\n            })?;\n\n            // 提取 request 配置\n            let request: rquickjs::Object = config.get(\"request\").map_err(|e| {\n                AppError::localized(\n                    \"usage_script.request_missing\",\n                    format!(\"缺少 request 配置: {e}\"),\n                    format!(\"Missing request config: {e}\"),\n                )\n            })?;\n\n            // 将 request 转换为 JSON 字符串\n            let request_json: String = ctx\n                .json_stringify(request)\n                .map_err(|e| {\n                    AppError::localized(\n                        \"usage_script.request_serialize_failed\",\n                        format!(\"序列化 request 失败: {e}\"),\n                        format!(\"Failed to serialize request: {e}\"),\n                    )\n                })?\n                .ok_or_else(|| {\n                    AppError::localized(\n                        \"usage_script.serialize_none\",\n                        \"序列化返回 None\",\n                        \"Serialization returned None\",\n                    )\n                })?\n                .get()\n                .map_err(|e| {\n                    AppError::localized(\n                        \"usage_script.get_string_failed\",\n                        format!(\"获取字符串失败: {e}\"),\n                        format!(\"Failed to get string: {e}\"),\n                    )\n                })?;\n\n            Ok::<_, AppError>(request_json)\n        })?\n    }; // Runtime 和 Context 在这里被 drop\n\n    // 4. 解析 request 配置\n    let request: RequestConfig = serde_json::from_str(&request_config).map_err(|e| {\n        AppError::localized(\n            \"usage_script.request_format_invalid\",\n            format!(\"request 配置格式错误: {e}\"),\n            format!(\"Invalid request config format: {e}\"),\n        )\n    })?;\n\n    // 5. 验证请求 URL 是否安全（防止 SSRF）\n    // 如果提供了 base_url，则验证同源；否则只做基本安全检查\n    validate_request_url(&request.url, base_url, is_custom_template)?;\n\n    // 6. 发送 HTTP 请求\n    let response_data = send_http_request(&request, timeout_secs).await?;\n\n    // 7. 在独立作用域中执行 extractor（确保 Runtime/Context 在函数结束前释放）\n    let result: Value = {\n        let runtime = Runtime::new().map_err(|e| {\n            AppError::localized(\n                \"usage_script.runtime_create_failed\",\n                format!(\"创建 JS 运行时失败: {e}\"),\n                format!(\"Failed to create JS runtime: {e}\"),\n            )\n        })?;\n        let context = Context::full(&runtime).map_err(|e| {\n            AppError::localized(\n                \"usage_script.context_create_failed\",\n                format!(\"创建 JS 上下文失败: {e}\"),\n                format!(\"Failed to create JS context: {e}\"),\n            )\n        })?;\n\n        context.with(|ctx| {\n            // 重新 eval 获取配置对象\n            let config: rquickjs::Object = ctx.eval(script_with_vars.clone()).map_err(|e| {\n                AppError::localized(\n                    \"usage_script.config_reparse_failed\",\n                    format!(\"重新解析配置失败: {e}\"),\n                    format!(\"Failed to re-parse config: {e}\"),\n                )\n            })?;\n\n            // 提取 extractor 函数\n            let extractor: Function = config.get(\"extractor\").map_err(|e| {\n                AppError::localized(\n                    \"usage_script.extractor_missing\",\n                    format!(\"缺少 extractor 函数: {e}\"),\n                    format!(\"Missing extractor function: {e}\"),\n                )\n            })?;\n\n            // 将响应数据转换为 JS 值\n            let response_js: rquickjs::Value =\n                ctx.json_parse(response_data.as_str()).map_err(|e| {\n                    AppError::localized(\n                        \"usage_script.response_parse_failed\",\n                        format!(\"解析响应 JSON 失败: {e}\"),\n                        format!(\"Failed to parse response JSON: {e}\"),\n                    )\n                })?;\n\n            // 调用 extractor(response)\n            let result_js: rquickjs::Value = extractor.call((response_js,)).map_err(|e| {\n                AppError::localized(\n                    \"usage_script.extractor_exec_failed\",\n                    format!(\"执行 extractor 失败: {e}\"),\n                    format!(\"Failed to execute extractor: {e}\"),\n                )\n            })?;\n\n            // 转换为 JSON 字符串\n            let result_json: String = ctx\n                .json_stringify(result_js)\n                .map_err(|e| {\n                    AppError::localized(\n                        \"usage_script.result_serialize_failed\",\n                        format!(\"序列化结果失败: {e}\"),\n                        format!(\"Failed to serialize result: {e}\"),\n                    )\n                })?\n                .ok_or_else(|| {\n                    AppError::localized(\n                        \"usage_script.serialize_none\",\n                        \"序列化返回 None\",\n                        \"Serialization returned None\",\n                    )\n                })?\n                .get()\n                .map_err(|e| {\n                    AppError::localized(\n                        \"usage_script.get_string_failed\",\n                        format!(\"获取字符串失败: {e}\"),\n                        format!(\"Failed to get string: {e}\"),\n                    )\n                })?;\n\n            // 解析为 serde_json::Value\n            serde_json::from_str(&result_json).map_err(|e| {\n                AppError::localized(\n                    \"usage_script.json_parse_failed\",\n                    format!(\"JSON 解析失败: {e}\"),\n                    format!(\"JSON parse failed: {e}\"),\n                )\n            })\n        })?\n    }; // Runtime 和 Context 在这里被 drop\n\n    // 8. 验证返回值格式\n    validate_result(&result)?;\n\n    Ok(result)\n}\n\n/// 请求配置结构\n#[derive(Debug, serde::Deserialize)]\nstruct RequestConfig {\n    url: String,\n    method: String,\n    #[serde(default)]\n    headers: HashMap<String, String>,\n    #[serde(default)]\n    body: Option<String>,\n}\n\n/// 发送 HTTP 请求\nasync fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<String, AppError> {\n    // 使用全局 HTTP 客户端（已包含代理配置）\n    let client = crate::proxy::http_client::get();\n    // 约束超时范围，防止异常配置导致长时间阻塞（最小 2 秒，最大 30 秒）\n    let request_timeout = std::time::Duration::from_secs(timeout_secs.clamp(2, 30));\n\n    // 严格校验 HTTP 方法，非法值不回退为 GET\n    let method: reqwest::Method = config.method.parse().map_err(|_| {\n        AppError::localized(\n            \"usage_script.invalid_http_method\",\n            format!(\"不支持的 HTTP 方法: {}\", config.method),\n            format!(\"Unsupported HTTP method: {}\", config.method),\n        )\n    })?;\n\n    let mut req = client\n        .request(method.clone(), &config.url)\n        .timeout(request_timeout);\n\n    // 添加请求头\n    for (k, v) in &config.headers {\n        req = req.header(k, v);\n    }\n\n    // 添加请求体\n    if let Some(body) = &config.body {\n        req = req.body(body.clone());\n    }\n\n    // 发送请求\n    let resp = req.send().await.map_err(|e| {\n        AppError::localized(\n            \"usage_script.request_failed\",\n            format!(\"请求失败: {e}\"),\n            format!(\"Request failed: {e}\"),\n        )\n    })?;\n\n    let status = resp.status();\n    let text = resp.text().await.map_err(|e| {\n        AppError::localized(\n            \"usage_script.read_response_failed\",\n            format!(\"读取响应失败: {e}\"),\n            format!(\"Failed to read response: {e}\"),\n        )\n    })?;\n\n    if !status.is_success() {\n        let preview = if text.len() > 200 {\n            let mut safe_cut = 200usize;\n            while !text.is_char_boundary(safe_cut) {\n                safe_cut = safe_cut.saturating_sub(1);\n            }\n            format!(\"{}...\", &text[..safe_cut])\n        } else {\n            text.clone()\n        };\n        return Err(AppError::localized(\n            \"usage_script.http_error\",\n            format!(\"HTTP {status} : {preview}\"),\n            format!(\"HTTP {status} : {preview}\"),\n        ));\n    }\n\n    Ok(text)\n}\n\n/// 验证脚本返回值（支持单对象或数组）\nfn validate_result(result: &Value) -> Result<(), AppError> {\n    // 如果是数组，验证每个元素\n    if let Some(arr) = result.as_array() {\n        if arr.is_empty() {\n            return Err(AppError::localized(\n                \"usage_script.empty_array\",\n                \"脚本返回的数组不能为空\",\n                \"Script returned empty array\",\n            ));\n        }\n        for (idx, item) in arr.iter().enumerate() {\n            validate_single_usage(item).map_err(|e| {\n                AppError::localized(\n                    \"usage_script.array_validation_failed\",\n                    format!(\"数组索引[{idx}]验证失败: {e}\"),\n                    format!(\"Validation failed at index [{idx}]: {e}\"),\n                )\n            })?;\n        }\n        return Ok(());\n    }\n\n    // 如果是单对象，直接验证（向后兼容）\n    validate_single_usage(result)\n}\n\n/// 验证单个用量数据对象\nfn validate_single_usage(result: &Value) -> Result<(), AppError> {\n    let obj = result.as_object().ok_or_else(|| {\n        AppError::localized(\n            \"usage_script.must_return_object\",\n            \"脚本必须返回对象或对象数组\",\n            \"Script must return object or array of objects\",\n        )\n    })?;\n\n    // 所有字段均为可选，只进行类型检查\n    if obj.contains_key(\"isValid\")\n        && !result[\"isValid\"].is_null()\n        && !result[\"isValid\"].is_boolean()\n    {\n        return Err(AppError::localized(\n            \"usage_script.isvalid_type_error\",\n            \"isValid 必须是布尔值或 null\",\n            \"isValid must be boolean or null\",\n        ));\n    }\n    if obj.contains_key(\"invalidMessage\")\n        && !result[\"invalidMessage\"].is_null()\n        && !result[\"invalidMessage\"].is_string()\n    {\n        return Err(AppError::localized(\n            \"usage_script.invalidmessage_type_error\",\n            \"invalidMessage 必须是字符串或 null\",\n            \"invalidMessage must be string or null\",\n        ));\n    }\n    if obj.contains_key(\"remaining\")\n        && !result[\"remaining\"].is_null()\n        && !result[\"remaining\"].is_number()\n    {\n        return Err(AppError::localized(\n            \"usage_script.remaining_type_error\",\n            \"remaining 必须是数字或 null\",\n            \"remaining must be number or null\",\n        ));\n    }\n    if obj.contains_key(\"unit\") && !result[\"unit\"].is_null() && !result[\"unit\"].is_string() {\n        return Err(AppError::localized(\n            \"usage_script.unit_type_error\",\n            \"unit 必须是字符串或 null\",\n            \"unit must be string or null\",\n        ));\n    }\n    if obj.contains_key(\"total\") && !result[\"total\"].is_null() && !result[\"total\"].is_number() {\n        return Err(AppError::localized(\n            \"usage_script.total_type_error\",\n            \"total 必须是数字或 null\",\n            \"total must be number or null\",\n        ));\n    }\n    if obj.contains_key(\"used\") && !result[\"used\"].is_null() && !result[\"used\"].is_number() {\n        return Err(AppError::localized(\n            \"usage_script.used_type_error\",\n            \"used 必须是数字或 null\",\n            \"used must be number or null\",\n        ));\n    }\n    if obj.contains_key(\"planName\")\n        && !result[\"planName\"].is_null()\n        && !result[\"planName\"].is_string()\n    {\n        return Err(AppError::localized(\n            \"usage_script.planname_type_error\",\n            \"planName 必须是字符串或 null\",\n            \"planName must be string or null\",\n        ));\n    }\n    if obj.contains_key(\"extra\") && !result[\"extra\"].is_null() && !result[\"extra\"].is_string() {\n        return Err(AppError::localized(\n            \"usage_script.extra_type_error\",\n            \"extra 必须是字符串或 null\",\n            \"extra must be string or null\",\n        ));\n    }\n\n    Ok(())\n}\n\n/// 构建替换变量后的脚本，保持与旧版脚本的兼容性\nfn build_script_with_vars(\n    script_code: &str,\n    api_key: &str,\n    base_url: &str,\n    access_token: Option<&str>,\n    user_id: Option<&str>,\n) -> String {\n    let mut replaced = script_code\n        .replace(\"{{apiKey}}\", api_key)\n        .replace(\"{{baseUrl}}\", base_url);\n\n    if let Some(token) = access_token {\n        replaced = replaced.replace(\"{{accessToken}}\", token);\n    }\n    if let Some(uid) = user_id {\n        replaced = replaced.replace(\"{{userId}}\", uid);\n    }\n\n    replaced\n}\n\n/// 验证 base_url 的基本安全性\nfn validate_base_url(base_url: &str) -> Result<(), AppError> {\n    if base_url.is_empty() {\n        return Err(AppError::localized(\n            \"usage_script.base_url_empty\",\n            \"base_url 不能为空\",\n            \"base_url cannot be empty\",\n        ));\n    }\n\n    // 解析 URL\n    let parsed_url = Url::parse(base_url).map_err(|e| {\n        AppError::localized(\n            \"usage_script.base_url_invalid\",\n            format!(\"无效的 base_url: {e}\"),\n            format!(\"Invalid base_url: {e}\"),\n        )\n    })?;\n\n    let is_loopback = is_loopback_host(&parsed_url);\n\n    // 必须是 HTTPS（允许 localhost 用于开发）\n    if parsed_url.scheme() != \"https\" && !is_loopback {\n        return Err(AppError::localized(\n            \"usage_script.base_url_https_required\",\n            \"base_url 必须使用 HTTPS 协议（localhost 除外）\",\n            \"base_url must use HTTPS (localhost allowed)\",\n        ));\n    }\n\n    // 检查主机名格式有效性\n    let hostname = parsed_url.host_str().ok_or_else(|| {\n        AppError::localized(\n            \"usage_script.base_url_hostname_missing\",\n            \"base_url 必须包含有效的主机名\",\n            \"base_url must include a valid hostname\",\n        )\n    })?;\n\n    // 基本的主机名格式检查\n    if hostname.is_empty() {\n        return Err(AppError::localized(\n            \"usage_script.base_url_hostname_empty\",\n            \"base_url 主机名不能为空\",\n            \"base_url hostname cannot be empty\",\n        ));\n    }\n\n    // 检查是否为明显的私有IP（但在 base_url 阶段不过于严格，主要在 request_url 阶段检查）\n    if is_suspicious_hostname(hostname) {\n        return Err(AppError::localized(\n            \"usage_script.base_url_suspicious\",\n            \"base_url 包含可疑的主机名\",\n            \"base_url contains a suspicious hostname\",\n        ));\n    }\n\n    Ok(())\n}\n\n/// 验证请求 URL 是否安全（防止 SSRF）\nfn validate_request_url(\n    request_url: &str,\n    base_url: &str,\n    is_custom_template: bool,\n) -> Result<(), AppError> {\n    // 解析请求 URL\n    let parsed_request = Url::parse(request_url).map_err(|e| {\n        AppError::localized(\n            \"usage_script.request_url_invalid\",\n            format!(\"无效的请求 URL: {e}\"),\n            format!(\"Invalid request URL: {e}\"),\n        )\n    })?;\n\n    let is_request_loopback = is_loopback_host(&parsed_request);\n\n    // 必须使用 HTTPS（允许 localhost 用于开发）\n    // 自定义模板模式下，允许用户自行决定是否使用 HTTP（用户需自行承担安全风险）\n    if !is_custom_template && parsed_request.scheme() != \"https\" && !is_request_loopback {\n        return Err(AppError::localized(\n            \"usage_script.request_https_required\",\n            \"请求 URL 必须使用 HTTPS 协议（localhost 除外）\",\n            \"Request URL must use HTTPS (localhost allowed)\",\n        ));\n    }\n\n    // 如果提供了 base_url（非空），则进行同源检查\n    // 🔧 自定义模板模式下，用户可以自由访问任意 HTTPS 域名，跳过同源检查\n    if !base_url.is_empty() && !is_custom_template {\n        // 解析 base URL\n        let parsed_base = Url::parse(base_url).map_err(|e| {\n            AppError::localized(\n                \"usage_script.base_url_invalid\",\n                format!(\"无效的 base_url: {e}\"),\n                format!(\"Invalid base_url: {e}\"),\n            )\n        })?;\n\n        // 核心安全检查：必须与 base_url 同源（相同域名和端口）\n        if parsed_request.host_str() != parsed_base.host_str() {\n            return Err(AppError::localized(\n                \"usage_script.request_host_mismatch\",\n                format!(\n                    \"请求域名 {} 与 base_url 域名 {} 不匹配（必须是同源请求）\",\n                    parsed_request.host_str().unwrap_or(\"unknown\"),\n                    parsed_base.host_str().unwrap_or(\"unknown\")\n                ),\n                format!(\n                    \"Request host {} must match base_url host {} (same-origin required)\",\n                    parsed_request.host_str().unwrap_or(\"unknown\"),\n                    parsed_base.host_str().unwrap_or(\"unknown\")\n                ),\n            ));\n        }\n\n        // 检查端口是否匹配（考虑默认端口）\n        // 使用 port_or_known_default() 会自动处理默认端口（http->80, https->443）\n        match (\n            parsed_request.port_or_known_default(),\n            parsed_base.port_or_known_default(),\n        ) {\n            (Some(request_port), Some(base_port)) if request_port == base_port => {\n                // 端口匹配，继续执行\n            }\n            (Some(request_port), Some(base_port)) => {\n                return Err(AppError::localized(\n                    \"usage_script.request_port_mismatch\",\n                    format!(\"请求端口 {request_port} 必须与 base_url 端口 {base_port} 匹配\"),\n                    format!(\"Request port {request_port} must match base_url port {base_port}\"),\n                ));\n            }\n            _ => {\n                // 理论上不会发生，因为 port_or_known_default() 应该总是返回 Some\n                return Err(AppError::localized(\n                    \"usage_script.request_port_unknown\",\n                    \"无法确定端口号\",\n                    \"Unable to determine port number\",\n                ));\n            }\n        }\n\n        // 禁止私有 IP 地址访问（除非 base_url 本身就是私有地址，用于开发环境）\n        if let Some(host) = parsed_request.host_str() {\n            let base_host = parsed_base.host_str().unwrap_or(\"\");\n\n            // 如果 base_url 不是私有地址，则禁止访问私有IP\n            if !is_private_ip(base_host) && is_private_ip(host) {\n                return Err(AppError::localized(\n                    \"usage_script.private_ip_blocked\",\n                    \"禁止访问私有 IP 地址\",\n                    \"Access to private IP addresses is blocked\",\n                ));\n            }\n        }\n    } else {\n        // 自定义模板模式：没有 base_url，需要额外的安全检查\n        // 禁止访问私有 IP 地址（SSRF 防护）\n        if let Some(host) = parsed_request.host_str() {\n            if is_private_ip(host) && !is_request_loopback {\n                return Err(AppError::localized(\n                    \"usage_script.private_ip_blocked\",\n                    \"禁止访问私有 IP 地址（localhost 除外）\",\n                    \"Access to private IP addresses is blocked (localhost allowed)\",\n                ));\n            }\n        }\n    }\n\n    Ok(())\n}\n\n/// 检查是否为私有 IP 地址\nfn is_private_ip(host: &str) -> bool {\n    // localhost 检查\n    if host.eq_ignore_ascii_case(\"localhost\") {\n        return true;\n    }\n\n    // 尝试解析为IP地址\n    if let Ok(ip_addr) = host.parse::<std::net::IpAddr>() {\n        return is_private_ip_addr(ip_addr);\n    }\n\n    // 如果不是IP地址，不是私有IP\n    false\n}\n\n/// 使用标准库API检查IP地址是否为私有地址\nfn is_private_ip_addr(ip: std::net::IpAddr) -> bool {\n    match ip {\n        std::net::IpAddr::V4(ipv4) => {\n            let octets = ipv4.octets();\n\n            // 0.0.0.0/8 (包括未指定地址)\n            if octets[0] == 0 {\n                return true;\n            }\n\n            // RFC1918 私有地址范围\n            // 10.0.0.0/8\n            if octets[0] == 10 {\n                return true;\n            }\n\n            // 172.16.0.0/12 (172.16.0.0 - 172.31.255.255)\n            if octets[0] == 172 && octets[1] >= 16 && octets[1] <= 31 {\n                return true;\n            }\n\n            // 192.168.0.0/16\n            if octets[0] == 192 && octets[1] == 168 {\n                return true;\n            }\n\n            // 其他特殊地址\n            // 169.254.0.0/16 (链路本地地址)\n            if octets[0] == 169 && octets[1] == 254 {\n                return true;\n            }\n\n            // 127.0.0.0/8 (环回地址)\n            if octets[0] == 127 {\n                return true;\n            }\n\n            false\n        }\n        std::net::IpAddr::V6(ipv6) => {\n            // IPv6 私有地址检查 - 使用标准库方法\n\n            // ::1 (环回地址)\n            if ipv6.is_loopback() {\n                return true;\n            }\n\n            // 唯一本地地址 (fc00::/7)\n            // Rust 1.70+ 可以使用 ipv6.is_unique_local()\n            // 但为了兼容性，我们手动检查\n            let first_segment = ipv6.segments()[0];\n            if (first_segment & 0xfe00) == 0xfc00 {\n                return true;\n            }\n\n            // 链路本地地址 (fe80::/10)\n            if (first_segment & 0xffc0) == 0xfe80 {\n                return true;\n            }\n\n            // 未指定地址 ::\n            if ipv6.is_unspecified() {\n                return true;\n            }\n\n            false\n        }\n    }\n}\n\n/// 检查是否为可疑的主机名（只检查明显不安全的模式）\nfn is_suspicious_hostname(hostname: &str) -> bool {\n    // 空主机名\n    if hostname.is_empty() {\n        return true;\n    }\n\n    // 检查明显的主机名格式问题\n    if hostname.contains(\"..\") || hostname.starts_with(\".\") || hostname.ends_with(\".\") {\n        return true;\n    }\n\n    // 检查是否为纯IP地址但没有合理格式（过于宽松的检查在这里可能不够，但主要依赖后续的同源检查）\n    if hostname.parse::<std::net::IpAddr>().is_ok() {\n        // IP地址格式的，在这里不直接拒绝，让同源检查来处理\n        return false;\n    }\n\n    // 检查是否包含明显不当的字符\n    let suspicious_chars = ['<', '>', '\"', '\\'', '\\n', '\\r', '\\t', '\\0'];\n    if hostname.chars().any(|c| suspicious_chars.contains(&c)) {\n        return true;\n    }\n\n    false\n}\n\n/// 判断 URL 是否指向本机（localhost / loopback）\nfn is_loopback_host(url: &Url) -> bool {\n    match url.host() {\n        Some(Host::Domain(d)) => d.eq_ignore_ascii_case(\"localhost\"),\n        Some(Host::Ipv4(ip)) => ip.is_loopback(),\n        Some(Host::Ipv6(ip)) => ip.is_loopback(),\n        _ => false,\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_private_ip_validation() {\n        // 测试IPv4私网地址\n\n        // RFC1918私网地址 - 应该返回true\n        assert!(is_private_ip(\"10.0.0.1\"));\n        assert!(is_private_ip(\"10.255.255.254\"));\n        assert!(is_private_ip(\"172.16.0.1\"));\n        assert!(is_private_ip(\"172.31.255.255\"));\n        assert!(is_private_ip(\"192.168.0.1\"));\n        assert!(is_private_ip(\"192.168.255.255\"));\n\n        // 链路本地地址 - 应该返回true\n        assert!(is_private_ip(\"169.254.0.1\"));\n        assert!(is_private_ip(\"169.254.255.255\"));\n\n        // 环回地址 - 应该返回true\n        assert!(is_private_ip(\"127.0.0.1\"));\n        assert!(is_private_ip(\"localhost\"));\n\n        // 公网172.x.x.x地址 - 应该返回false（这是修复的重点）\n        assert!(!is_private_ip(\"172.0.0.1\"));\n        assert!(!is_private_ip(\"172.15.255.255\"));\n        assert!(!is_private_ip(\"172.32.0.1\"));\n        assert!(!is_private_ip(\"172.64.0.1\"));\n        assert!(!is_private_ip(\"172.67.0.1\")); // Cloudflare CDN\n        assert!(!is_private_ip(\"172.68.0.1\"));\n        assert!(!is_private_ip(\"172.100.50.25\"));\n        assert!(!is_private_ip(\"172.255.255.255\"));\n\n        // 其他公网地址 - 应该返回false\n        assert!(!is_private_ip(\"8.8.8.8\")); // Google DNS\n        assert!(!is_private_ip(\"1.1.1.1\")); // Cloudflare DNS\n        assert!(!is_private_ip(\"208.67.222.222\")); // OpenDNS\n        assert!(!is_private_ip(\"180.76.76.76\")); // Baidu DNS\n\n        // 域名 - 应该返回false\n        assert!(!is_private_ip(\"api.example.com\"));\n        assert!(!is_private_ip(\"www.google.com\"));\n    }\n\n    #[test]\n    fn test_ipv6_private_validation() {\n        // IPv6私网地址\n        assert!(is_private_ip(\"::1\")); // 环回地址\n        assert!(is_private_ip(\"fc00::1\")); // 唯一本地地址\n        assert!(is_private_ip(\"fd00::1\")); // 唯一本地地址\n        assert!(is_private_ip(\"fe80::1\")); // 链路本地地址\n        assert!(is_private_ip(\"::\")); // 未指定地址\n\n        // IPv6公网地址 - 应该返回false（修复的重点）\n        assert!(!is_private_ip(\"2001:4860:4860::8888\")); // Google DNS IPv6\n        assert!(!is_private_ip(\"2606:4700:4700::1111\")); // Cloudflare DNS IPv6\n        assert!(!is_private_ip(\"2404:6800:4001:c01::67\")); // Google DNS IPv6 (其他格式)\n        assert!(!is_private_ip(\"2001:db8::1\")); // 文档地址（非私网）\n\n        // 测试包含 ::1 子串但不是环回地址的公网地址\n        assert!(!is_private_ip(\"2001:db8::1abc\")); // 包含 ::1abc 但不是环回\n        assert!(!is_private_ip(\"2606:4700::1\")); // 包含 ::1 但不是环回\n    }\n\n    #[test]\n    fn test_hostname_bypass_prevention() {\n        // 看起来像本地，但实际是域名\n        assert!(!is_private_ip(\"127.0.0.1.evil.com\"));\n        assert!(!is_private_ip(\"localhost.evil.com\"));\n\n        // 0.0.0.0 应该被视为本地/阻断\n        assert!(is_private_ip(\"0.0.0.0\"));\n    }\n\n    #[test]\n    fn test_https_bypass_prevention() {\n        // 非本地域名的 HTTP 应该被拒绝\n        let result = validate_base_url(\"http://127.0.0.1.evil.com/api\");\n        assert!(\n            result.is_err(),\n            \"Should reject HTTP for non-localhost domains\"\n        );\n    }\n\n    #[test]\n    fn test_edge_cases() {\n        // 边界情况测试\n        assert!(is_private_ip(\"172.16.0.0\")); // RFC1918起始\n        assert!(is_private_ip(\"172.31.255.255\")); // RFC1918结束\n        assert!(is_private_ip(\"10.0.0.0\")); // 10.0.0.0/8起始\n        assert!(is_private_ip(\"10.255.255.255\")); // 10.0.0.0/8结束\n        assert!(is_private_ip(\"192.168.0.0\")); // 192.168.0.0/16起始\n        assert!(is_private_ip(\"192.168.255.255\")); // 192.168.0.0/16结束\n\n        // 紧邻RFC1918的公网地址 - 应该返回false\n        assert!(!is_private_ip(\"172.15.255.255\")); // 172.16.0.0的前一个\n        assert!(!is_private_ip(\"172.32.0.0\")); // 172.31.255.255的后一个\n    }\n\n    #[test]\n    fn test_ip_addr_parsing() {\n        // 测试IP地址解析功能\n        let ipv4_private = \"10.0.0.1\".parse::<std::net::IpAddr>().unwrap();\n        assert!(is_private_ip_addr(ipv4_private));\n\n        let ipv4_public = \"172.67.0.1\".parse::<std::net::IpAddr>().unwrap();\n        assert!(!is_private_ip_addr(ipv4_public));\n\n        let ipv6_private = \"fc00::1\".parse::<std::net::IpAddr>().unwrap();\n        assert!(is_private_ip_addr(ipv6_private));\n\n        let ipv6_public = \"2001:4860:4860::8888\".parse::<std::net::IpAddr>().unwrap();\n        assert!(!is_private_ip_addr(ipv6_public));\n    }\n\n    #[test]\n    fn test_port_comparison() {\n        // 测试端口比较逻辑是否正确处理默认端口和显式端口\n\n        // 测试用例：(base_url, request_url, should_match)\n        let test_cases = vec![\n            // HTTPS默认端口测试\n            (\n                \"https://api.example.com\",\n                \"https://api.example.com/v1/test\",\n                true,\n            ),\n            (\n                \"https://api.example.com\",\n                \"https://api.example.com:443/v1/test\",\n                true,\n            ),\n            (\n                \"https://api.example.com:443\",\n                \"https://api.example.com/v1/test\",\n                true,\n            ),\n            (\n                \"https://api.example.com:443\",\n                \"https://api.example.com:443/v1/test\",\n                true,\n            ),\n            // 端口不匹配测试\n            (\n                \"https://api.example.com\",\n                \"https://api.example.com:8443/v1/test\",\n                false,\n            ),\n            (\n                \"https://api.example.com:443\",\n                \"https://api.example.com:8443/v1/test\",\n                false,\n            ),\n        ];\n\n        for (base_url, request_url, should_match) in test_cases {\n            let result = validate_request_url(request_url, base_url, false);\n\n            if should_match {\n                assert!(\n                    result.is_ok(),\n                    \"应该匹配的URL被拒绝: base_url={}, request_url={}, error={}\",\n                    base_url,\n                    request_url,\n                    result.unwrap_err()\n                );\n            } else {\n                assert!(\n                    result.is_err(),\n                    \"应该不匹配的URL被允许: base_url={}, request_url={}\",\n                    base_url,\n                    request_url\n                );\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src-tauri/tauri.conf.json",
    "content": "{\n  \"$schema\": \"https://schema.tauri.app/config/2\",\n  \"productName\": \"CC Switch\",\n  \"version\": \"3.12.3\",\n  \"identifier\": \"com.ccswitch.desktop\",\n  \"build\": {\n    \"frontendDist\": \"../dist\",\n    \"devUrl\": \"http://localhost:3000\",\n    \"beforeDevCommand\": \"pnpm run dev:renderer\",\n    \"beforeBuildCommand\": \"pnpm run build:renderer\"\n  },\n  \"app\": {\n    \"windows\": [\n      {\n        \"label\": \"main\",\n        \"title\": \"\",\n        \"titleBarStyle\": \"Overlay\",\n        \"width\": 1000,\n        \"height\": 650,\n        \"minWidth\": 900,\n        \"minHeight\": 600,\n        \"visible\": false,\n        \"resizable\": true,\n        \"fullscreen\": false,\n        \"center\": true\n      }\n    ],\n    \"security\": {\n      \"csp\": \"default-src 'self'; img-src 'self' data: https: http:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ipc: http://ipc.localhost https: http:\",\n      \"assetProtocol\": {\n        \"enable\": true,\n        \"scope\": []\n      }\n    }\n  },\n  \"bundle\": {\n    \"active\": true,\n    \"targets\": \"all\",\n    \"createUpdaterArtifacts\": true,\n    \"icon\": [\n      \"icons/32x32.png\",\n      \"icons/128x128.png\",\n      \"icons/128x128@2x.png\",\n      \"icons/icon.icns\",\n      \"icons/icon.ico\"\n    ],\n    \"windows\": {\n      \"wix\": {\n        \"template\": \"wix/per-user-main.wxs\"\n      }\n    },\n    \"macOS\": {\n      \"minimumSystemVersion\": \"12.0\"\n    }\n  },\n  \"plugins\": {\n    \"deep-link\": {\n      \"desktop\": {\n        \"schemes\": [\"ccswitch\"]\n      }\n    },\n    \"updater\": {\n      \"pubkey\": \"dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEM4MDI4QzlBNTczOTI4RTMKUldUaktEbFhtb3dDeUM5US9kT0FmdGR5Ti9vQzcwa2dTMlpibDVDUmQ2M0VGTzVOWnd0SGpFVlEK\",\n      \"endpoints\": [\n        \"https://github.com/farion1231/cc-switch/releases/latest/download/latest.json\"\n      ]\n    }\n  }\n}\n"
  },
  {
    "path": "src-tauri/tauri.windows.conf.json",
    "content": "{\n  \"$schema\": \"https://schema.tauri.app/config/2\",\n  \"app\": {\n    \"windows\": [\n      {\n        \"label\": \"main\",\n        \"title\": \"CC Switch\",\n        \"titleBarStyle\": \"Visible\",\n        \"visible\": false,\n        \"minWidth\": 900,\n        \"minHeight\": 600\n      }\n    ]\n  }\n}\n"
  },
  {
    "path": "src-tauri/tests/app_config_load.rs",
    "content": "use std::fs;\nuse std::path::PathBuf;\n\nuse cc_switch_lib::{AppError, MultiAppConfig};\n\nmod support;\nuse support::{ensure_test_home, reset_test_fs, test_mutex};\n\nfn cfg_path() -> PathBuf {\n    let home = std::env::var(\"HOME\").expect(\"HOME should be set by ensure_test_home\");\n    PathBuf::from(home).join(\".cc-switch\").join(\"config.json\")\n}\n\n#[test]\nfn load_v1_config_returns_error_and_does_not_write() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let home = ensure_test_home();\n    let path = cfg_path();\n    fs::create_dir_all(path.parent().unwrap()).expect(\"create cfg dir\");\n\n    // 最小 v1 形状：providers + current，且不含 version/apps/mcp\n    let v1_json = r#\"{\"providers\":{},\"current\":\"\"}\"#;\n    fs::write(&path, v1_json).expect(\"seed v1 json\");\n    let before = fs::read_to_string(&path).expect(\"read before\");\n\n    let err = MultiAppConfig::load().expect_err(\"v1 should not be auto-migrated\");\n    match err {\n        AppError::Localized { key, .. } => assert_eq!(key, \"config.unsupported_v1\"),\n        other => panic!(\"expected Localized v1 error, got {other:?}\"),\n    }\n\n    // 文件不应有任何变化，且不应生成 .bak\n    let after = fs::read_to_string(&path).expect(\"read after\");\n    assert_eq!(before, after, \"config.json should not be modified\");\n    let bak = home.join(\".cc-switch\").join(\"config.json.bak\");\n    assert!(!bak.exists(), \".bak should not be created on load error\");\n}\n\n#[test]\nfn load_v1_with_extra_version_still_treated_as_v1() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let home = ensure_test_home();\n    let path = cfg_path();\n    std::fs::create_dir_all(path.parent().unwrap()).expect(\"create cfg dir\");\n\n    // 畸形：包含 providers + current + version，但没有 apps，应按 v1 处理\n    let v1_like = r#\"{\"providers\":{},\"current\":\"\",\"version\":2}\"#;\n    std::fs::write(&path, v1_like).expect(\"seed v1-like json\");\n    let before = std::fs::read_to_string(&path).expect(\"read before\");\n\n    let err = MultiAppConfig::load().expect_err(\"v1-like should not be parsed as v2\");\n    match err {\n        AppError::Localized { key, .. } => assert_eq!(key, \"config.unsupported_v1\"),\n        other => panic!(\"expected Localized v1 error, got {other:?}\"),\n    }\n\n    let after = std::fs::read_to_string(&path).expect(\"read after\");\n    assert_eq!(before, after, \"config.json should not be modified\");\n    let bak = home.join(\".cc-switch\").join(\"config.json.bak\");\n    assert!(!bak.exists(), \".bak should not be created on v1-like error\");\n}\n\n#[test]\nfn load_invalid_json_returns_parse_error_and_does_not_write() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let home = ensure_test_home();\n    let path = cfg_path();\n    fs::create_dir_all(path.parent().unwrap()).expect(\"create cfg dir\");\n\n    fs::write(&path, \"{not json\").expect(\"seed invalid json\");\n    let before = fs::read_to_string(&path).expect(\"read before\");\n\n    let err = MultiAppConfig::load().expect_err(\"invalid json should error\");\n    match err {\n        AppError::Json { .. } => {}\n        other => panic!(\"expected Json error, got {other:?}\"),\n    }\n\n    let after = fs::read_to_string(&path).expect(\"read after\");\n    assert_eq!(before, after, \"config.json should remain unchanged\");\n    let bak = home.join(\".cc-switch\").join(\"config.json.bak\");\n    assert!(!bak.exists(), \".bak should not be created on parse error\");\n}\n\n#[test]\nfn load_valid_v2_config_succeeds() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let _home = ensure_test_home();\n    let path = cfg_path();\n    fs::create_dir_all(path.parent().unwrap()).expect(\"create cfg dir\");\n\n    // 使用默认结构序列化为 v2\n    let default_cfg = MultiAppConfig::default();\n    let json = serde_json::to_string_pretty(&default_cfg).expect(\"serialize default cfg\");\n    fs::write(&path, json).expect(\"write v2 json\");\n\n    let loaded = MultiAppConfig::load().expect(\"v2 should load successfully\");\n    assert_eq!(loaded.version, 2);\n    assert!(loaded\n        .get_manager(&cc_switch_lib::AppType::Claude)\n        .is_some());\n    assert!(loaded.get_manager(&cc_switch_lib::AppType::Codex).is_some());\n}\n"
  },
  {
    "path": "src-tauri/tests/app_type_parse.rs",
    "content": "use std::str::FromStr;\n\nuse cc_switch_lib::AppType;\n\n#[test]\nfn parse_known_apps_case_insensitive_and_trim() {\n    assert!(matches!(AppType::from_str(\"claude\"), Ok(AppType::Claude)));\n    assert!(matches!(AppType::from_str(\"codex\"), Ok(AppType::Codex)));\n    assert!(matches!(\n        AppType::from_str(\" ClAuDe \\n\"),\n        Ok(AppType::Claude)\n    ));\n    assert!(matches!(AppType::from_str(\"\\tcoDeX\\t\"), Ok(AppType::Codex)));\n}\n\n#[test]\nfn parse_unknown_app_returns_localized_error_message() {\n    let err = AppType::from_str(\"unknown\").unwrap_err();\n    let msg = err.to_string();\n    assert!(msg.contains(\"可选值\") || msg.contains(\"Allowed\"));\n    assert!(msg.contains(\"unknown\"));\n}\n"
  },
  {
    "path": "src-tauri/tests/deeplink_import.rs",
    "content": "use std::sync::Arc;\n\nuse cc_switch_lib::{\n    import_provider_from_deeplink, parse_deeplink_url, AppState, Database, ProxyService,\n};\n\n#[path = \"support.rs\"]\nmod support;\nuse support::{ensure_test_home, reset_test_fs, test_mutex};\n\n#[test]\nfn deeplink_import_claude_provider_persists_to_db() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let _home = ensure_test_home();\n\n    let url = \"ccswitch://v1/import?resource=provider&app=claude&name=DeepLink%20Claude&homepage=https%3A%2F%2Fexample.com&endpoint=https%3A%2F%2Fapi.example.com%2Fv1&apiKey=sk-test-claude-key&model=claude-sonnet-4&icon=claude\";\n    let request = parse_deeplink_url(url).expect(\"parse deeplink url\");\n\n    let db = Arc::new(Database::memory().expect(\"create memory db\"));\n    let proxy_service = ProxyService::new(db.clone());\n    let state = AppState {\n        db: db.clone(),\n        proxy_service,\n    };\n\n    let provider_id = import_provider_from_deeplink(&state, request.clone())\n        .expect(\"import provider from deeplink\");\n\n    // Verify DB state\n    let providers = db.get_all_providers(\"claude\").expect(\"get providers\");\n    let provider = providers\n        .get(&provider_id)\n        .expect(\"provider created via deeplink\");\n\n    assert_eq!(provider.name, request.name.clone().unwrap());\n    assert_eq!(provider.website_url.as_deref(), request.homepage.as_deref());\n    assert_eq!(provider.icon.as_deref(), Some(\"claude\"));\n    let auth_token = provider\n        .settings_config\n        .pointer(\"/env/ANTHROPIC_AUTH_TOKEN\")\n        .and_then(|v| v.as_str());\n    let base_url = provider\n        .settings_config\n        .pointer(\"/env/ANTHROPIC_BASE_URL\")\n        .and_then(|v| v.as_str());\n    assert_eq!(auth_token, request.api_key.as_deref());\n    assert_eq!(base_url, request.endpoint.as_deref());\n}\n\n#[test]\nfn deeplink_import_codex_provider_builds_auth_and_config() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let _home = ensure_test_home();\n\n    let url = \"ccswitch://v1/import?resource=provider&app=codex&name=DeepLink%20Codex&homepage=https%3A%2F%2Fopenai.example&endpoint=https%3A%2F%2Fapi.openai.example%2Fv1&apiKey=sk-test-codex-key&model=gpt-4o&icon=openai\";\n    let request = parse_deeplink_url(url).expect(\"parse deeplink url\");\n\n    let db = Arc::new(Database::memory().expect(\"create memory db\"));\n    let proxy_service = ProxyService::new(db.clone());\n    let state = AppState {\n        db: db.clone(),\n        proxy_service,\n    };\n\n    let provider_id = import_provider_from_deeplink(&state, request.clone())\n        .expect(\"import provider from deeplink\");\n\n    let providers = db.get_all_providers(\"codex\").expect(\"get providers\");\n    let provider = providers\n        .get(&provider_id)\n        .expect(\"provider created via deeplink\");\n\n    assert_eq!(provider.name, request.name.clone().unwrap());\n    assert_eq!(provider.website_url.as_deref(), request.homepage.as_deref());\n    assert_eq!(provider.icon.as_deref(), Some(\"openai\"));\n    let auth_value = provider\n        .settings_config\n        .pointer(\"/auth/OPENAI_API_KEY\")\n        .and_then(|v| v.as_str());\n    let config_text = provider\n        .settings_config\n        .get(\"config\")\n        .and_then(|v| v.as_str())\n        .unwrap_or_default();\n    assert_eq!(auth_value, request.api_key.as_deref());\n    assert!(\n        config_text.contains(request.endpoint.as_deref().unwrap()),\n        \"config.toml content should contain endpoint\"\n    );\n    assert!(\n        config_text.contains(\"model = \\\"gpt-4o\\\"\"),\n        \"config.toml content should contain model setting\"\n    );\n}\n"
  },
  {
    "path": "src-tauri/tests/import_export_sync.rs",
    "content": "use serde_json::json;\nuse std::fs;\nuse std::path::PathBuf;\n\nuse cc_switch_lib::{\n    get_claude_settings_path, read_json_file, AppError, AppType, ConfigService, MultiAppConfig,\n    Provider, ProviderMeta,\n};\n\n#[path = \"support.rs\"]\nmod support;\nuse support::{\n    create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex,\n};\n\n#[test]\nfn sync_claude_provider_writes_live_settings() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let home = ensure_test_home();\n\n    let mut config = MultiAppConfig::default();\n    let provider_config = json!({\n        \"env\": {\n            \"ANTHROPIC_AUTH_TOKEN\": \"test-key\",\n            \"ANTHROPIC_BASE_URL\": \"https://api.test\"\n        },\n        \"ui\": {\n            \"displayName\": \"Test Provider\"\n        }\n    });\n\n    let provider = Provider::with_id(\n        \"prov-1\".to_string(),\n        \"Test Claude\".to_string(),\n        provider_config.clone(),\n        None,\n    );\n\n    let manager = config\n        .get_manager_mut(&AppType::Claude)\n        .expect(\"claude manager\");\n    manager.providers.insert(\"prov-1\".to_string(), provider);\n    manager.current = \"prov-1\".to_string();\n\n    ConfigService::sync_current_providers_to_live(&mut config).expect(\"sync live settings\");\n\n    let settings_path = get_claude_settings_path();\n    assert!(\n        settings_path.exists(),\n        \"live settings should be written to {}\",\n        settings_path.display()\n    );\n\n    let live_value: serde_json::Value = read_json_file(&settings_path).expect(\"read live file\");\n    assert_eq!(live_value, provider_config);\n\n    // 确认 SSOT 中的供应商也同步了最新内容\n    let updated = config\n        .get_manager(&AppType::Claude)\n        .and_then(|m| m.providers.get(\"prov-1\"))\n        .expect(\"provider in config\");\n    assert_eq!(updated.settings_config, provider_config);\n\n    // 额外确认写入位置位于测试 HOME 下\n    assert!(\n        settings_path.starts_with(home),\n        \"settings path {settings_path:?} should reside under test HOME {home:?}\"\n    );\n}\n\n#[test]\nfn sync_codex_provider_writes_auth_and_config() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n\n    let mut config = MultiAppConfig::default();\n\n    // 注意：v3.7.0 后 MCP 同步由 McpService 独立处理，不再通过 provider 切换触发\n    // 此测试仅验证 auth.json 和 config.toml 基础配置的写入\n\n    let provider_config = json!({\n        \"auth\": {\n            \"OPENAI_API_KEY\": \"codex-key\"\n        },\n        \"config\": r#\"base_url = \"https://codex.test\"\"#\n    });\n\n    let provider = Provider::with_id(\n        \"codex-1\".to_string(),\n        \"Codex Test\".to_string(),\n        provider_config.clone(),\n        None,\n    );\n\n    let manager = config\n        .get_manager_mut(&AppType::Codex)\n        .expect(\"codex manager\");\n    manager.providers.insert(\"codex-1\".to_string(), provider);\n    manager.current = \"codex-1\".to_string();\n\n    ConfigService::sync_current_providers_to_live(&mut config).expect(\"sync codex live\");\n\n    let auth_path = cc_switch_lib::get_codex_auth_path();\n    let config_path = cc_switch_lib::get_codex_config_path();\n\n    assert!(\n        auth_path.exists(),\n        \"auth.json should exist at {}\",\n        auth_path.display()\n    );\n    assert!(\n        config_path.exists(),\n        \"config.toml should exist at {}\",\n        config_path.display()\n    );\n\n    let auth_value: serde_json::Value = read_json_file(&auth_path).expect(\"read auth\");\n    assert_eq!(\n        auth_value,\n        provider_config.get(\"auth\").cloned().expect(\"auth object\")\n    );\n\n    let toml_text = fs::read_to_string(&config_path).expect(\"read config.toml\");\n    // 验证基础配置正确写入\n    assert!(\n        toml_text.contains(\"base_url\"),\n        \"config.toml should contain base_url from provider config\"\n    );\n\n    // 当前供应商应同步最新 config 文本\n    let manager = config.get_manager(&AppType::Codex).expect(\"codex manager\");\n    let synced = manager.providers.get(\"codex-1\").expect(\"codex provider\");\n    let synced_cfg = synced\n        .settings_config\n        .get(\"config\")\n        .and_then(|v| v.as_str())\n        .expect(\"config string\");\n    assert_eq!(synced_cfg, toml_text);\n}\n\n#[test]\nfn sync_enabled_to_codex_writes_enabled_servers() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n\n    // 模拟 Codex 已安装/已初始化：存在 ~/.codex 目录\n    let path = cc_switch_lib::get_codex_config_path();\n    if let Some(parent) = path.parent() {\n        fs::create_dir_all(parent).expect(\"create codex dir\");\n    }\n\n    let mut config = MultiAppConfig::default();\n    config.mcp.codex.servers.insert(\n        \"stdio-enabled\".into(),\n        json!({\n            \"id\": \"stdio-enabled\",\n            \"enabled\": true,\n            \"server\": {\n                \"type\": \"stdio\",\n                \"command\": \"echo\",\n                \"args\": [\"ok\"],\n            }\n        }),\n    );\n\n    cc_switch_lib::sync_enabled_to_codex(&config).expect(\"sync codex\");\n\n    assert!(path.exists(), \"config.toml should be created\");\n    let text = fs::read_to_string(&path).expect(\"read config.toml\");\n    assert!(\n        text.contains(\"mcp_servers\") && text.contains(\"stdio-enabled\"),\n        \"enabled servers should be serialized\"\n    );\n}\n\n#[test]\nfn sync_enabled_to_codex_preserves_non_mcp_content_and_style() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n\n    // 预置含有顶层注释与非 MCP 键的 config.toml\n    let path = cc_switch_lib::get_codex_config_path();\n    if let Some(parent) = path.parent() {\n        fs::create_dir_all(parent).expect(\"create codex dir\");\n    }\n    let seed = r#\"# top-comment\ntitle = \"keep-me\"\n\n[profile]\nmode = \"dev\"\n\"#;\n    fs::write(&path, seed).expect(\"seed config.toml\");\n\n    // 启用一个 MCP 项，触发增量写入\n    let mut config = MultiAppConfig::default();\n    config.mcp.codex.servers.insert(\n        \"echo\".into(),\n        json!({\n            \"id\": \"echo\",\n            \"enabled\": true,\n            \"server\": { \"type\": \"stdio\", \"command\": \"echo\" }\n        }),\n    );\n\n    cc_switch_lib::sync_enabled_to_codex(&config).expect(\"sync codex\");\n\n    let text = fs::read_to_string(&path).expect(\"read config.toml\");\n    // 顶层注释与非 MCP 键应保留\n    assert!(\n        text.contains(\"# top-comment\"),\n        \"top comment should be preserved\"\n    );\n    assert!(\n        text.contains(\"title = \\\"keep-me\\\"\"),\n        \"top key should be preserved\"\n    );\n    assert!(\n        text.contains(\"[profile]\"),\n        \"non-MCP table should be preserved\"\n    );\n    assert!(\n        text.contains(\"mcp_servers\"),\n        \"mcp_servers table should be present\"\n    );\n    assert!(\n        !text.contains(\"[mcp.servers]\"),\n        \"invalid [mcp.servers] table should not appear\"\n    );\n    assert!(\n        text.contains(\"echo\") && text.contains(\"command = \\\"echo\\\"\"),\n        \"echo server should be serialized\"\n    );\n}\n\n#[test]\nfn sync_enabled_to_codex_migrates_erroneous_mcp_dot_servers_to_mcp_servers() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let path = cc_switch_lib::get_codex_config_path();\n    if let Some(parent) = path.parent() {\n        fs::create_dir_all(parent).expect(\"create codex dir\");\n    }\n    // 预置错误的 mcp.servers 风格（应迁移为顶层 mcp_servers）\n    let seed = r#\"[mcp]\n  other = \"keep\"\n  [mcp.servers]\n\"#;\n    fs::write(&path, seed).expect(\"seed config.toml\");\n\n    let mut config = MultiAppConfig::default();\n    config.mcp.codex.servers.insert(\n        \"echo\".into(),\n        json!({\n            \"id\": \"echo\",\n            \"enabled\": true,\n            \"server\": { \"type\": \"stdio\", \"command\": \"echo\" }\n        }),\n    );\n\n    cc_switch_lib::sync_enabled_to_codex(&config).expect(\"sync codex\");\n    let text = fs::read_to_string(&path).expect(\"read config.toml\");\n    // 应迁移到顶层 mcp_servers，并移除错误的 mcp.servers 表\n    assert!(\n        text.contains(\"mcp_servers\"),\n        \"should migrate to mcp_servers table\"\n    );\n    assert!(\n        !text.contains(\"[mcp.servers]\"),\n        \"invalid [mcp.servers] table should be removed\"\n    );\n}\n\n#[test]\nfn sync_enabled_to_codex_removes_servers_when_none_enabled() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let path = cc_switch_lib::get_codex_config_path();\n    if let Some(parent) = path.parent() {\n        fs::create_dir_all(parent).expect(\"create codex dir\");\n    }\n    fs::write(\n        &path,\n        r#\"[mcp_servers]\ndisabled = { type = \"stdio\", command = \"noop\" }\n\"#,\n    )\n    .expect(\"seed config file\");\n\n    let config = MultiAppConfig::default(); // 无启用项\n    cc_switch_lib::sync_enabled_to_codex(&config).expect(\"sync codex\");\n\n    let text = fs::read_to_string(&path).expect(\"read config.toml\");\n    assert!(\n        !text.contains(\"mcp_servers\") && !text.contains(\"servers\"),\n        \"disabled entries should be removed from config.toml\"\n    );\n}\n\n#[test]\nfn sync_enabled_to_codex_returns_error_on_invalid_toml() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let path = cc_switch_lib::get_codex_config_path();\n    if let Some(parent) = path.parent() {\n        fs::create_dir_all(parent).expect(\"create codex dir\");\n    }\n    fs::write(&path, \"invalid = [\").expect(\"write invalid config\");\n\n    let mut config = MultiAppConfig::default();\n    config.mcp.codex.servers.insert(\n        \"broken\".into(),\n        json!({\n            \"id\": \"broken\",\n            \"enabled\": true,\n            \"server\": {\n                \"type\": \"stdio\",\n                \"command\": \"echo\"\n            }\n        }),\n    );\n\n    let err = cc_switch_lib::sync_enabled_to_codex(&config).expect_err(\"sync should fail\");\n    match err {\n        cc_switch_lib::AppError::Toml { path, .. } => {\n            assert!(\n                path.ends_with(\"config.toml\"),\n                \"path should reference config.toml\"\n            );\n        }\n        cc_switch_lib::AppError::McpValidation(msg) => {\n            assert!(\n                msg.contains(\"config.toml\"),\n                \"error message should mention config.toml\"\n            );\n        }\n        other => panic!(\"unexpected error: {other:?}\"),\n    }\n}\n\n#[test]\nfn sync_codex_provider_missing_auth_returns_error() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n\n    let mut config = MultiAppConfig::default();\n    let provider = Provider::with_id(\n        \"codex-missing-auth\".to_string(),\n        \"No Auth\".to_string(),\n        json!({\n            \"config\": \"model = \\\"test\\\"\"\n        }),\n        None,\n    );\n    let manager = config\n        .get_manager_mut(&AppType::Codex)\n        .expect(\"codex manager\");\n    manager.providers.insert(provider.id.clone(), provider);\n    manager.current = \"codex-missing-auth\".to_string();\n\n    let err = ConfigService::sync_current_providers_to_live(&mut config)\n        .expect_err(\"sync should fail when auth missing\");\n    match err {\n        cc_switch_lib::AppError::Config(msg) => {\n            assert!(msg.contains(\"auth\"), \"error message should mention auth\");\n        }\n        other => panic!(\"unexpected error variant: {other:?}\"),\n    }\n\n    // 确认未产生任何 live 配置文件\n    assert!(\n        !cc_switch_lib::get_codex_auth_path().exists(),\n        \"auth.json should not be created on failure\"\n    );\n    assert!(\n        !cc_switch_lib::get_codex_config_path().exists(),\n        \"config.toml should not be created on failure\"\n    );\n}\n\n#[test]\nfn write_codex_live_atomic_persists_auth_and_config() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n\n    let auth = json!({ \"OPENAI_API_KEY\": \"dev-key\" });\n    let config_text = r#\"\n[mcp_servers.echo]\ntype = \"stdio\"\ncommand = \"echo\"\nargs = [\"ok\"]\n\"#;\n\n    cc_switch_lib::write_codex_live_atomic(&auth, Some(config_text))\n        .expect(\"atomic write should succeed\");\n\n    let auth_path = cc_switch_lib::get_codex_auth_path();\n    let config_path = cc_switch_lib::get_codex_config_path();\n    assert!(auth_path.exists(), \"auth.json should be created\");\n    assert!(config_path.exists(), \"config.toml should be created\");\n\n    let stored_auth: serde_json::Value =\n        cc_switch_lib::read_json_file(&auth_path).expect(\"read auth\");\n    assert_eq!(stored_auth, auth, \"auth.json should match input\");\n\n    let stored_config = std::fs::read_to_string(&config_path).expect(\"read config\");\n    assert!(\n        stored_config.contains(\"mcp_servers.echo\"),\n        \"config.toml should contain serialized table\"\n    );\n}\n\n#[test]\nfn write_codex_live_atomic_rolls_back_auth_when_config_write_fails() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n\n    let auth_path = cc_switch_lib::get_codex_auth_path();\n    if let Some(parent) = auth_path.parent() {\n        std::fs::create_dir_all(parent).expect(\"create codex dir\");\n    }\n    std::fs::write(&auth_path, r#\"{\"OPENAI_API_KEY\":\"legacy\"}\"#).expect(\"seed auth\");\n\n    let config_path = cc_switch_lib::get_codex_config_path();\n    std::fs::create_dir_all(&config_path).expect(\"create blocking directory\");\n\n    let auth = json!({ \"OPENAI_API_KEY\": \"new-key\" });\n    let config_text = r#\"[mcp_servers.sample]\ntype = \"stdio\"\ncommand = \"noop\"\n\"#;\n\n    let err = cc_switch_lib::write_codex_live_atomic(&auth, Some(config_text))\n        .expect_err(\"config write should fail when target is directory\");\n    match err {\n        cc_switch_lib::AppError::Io { path, .. } => {\n            assert!(\n                path.ends_with(\"config.toml\"),\n                \"io error path should point to config.toml\"\n            );\n        }\n        cc_switch_lib::AppError::IoContext { context, .. } => {\n            assert!(\n                context.contains(\"config.toml\"),\n                \"error context should mention config path\"\n            );\n        }\n        other => panic!(\"unexpected error variant: {other:?}\"),\n    }\n\n    let stored = std::fs::read_to_string(&auth_path).expect(\"read existing auth\");\n    assert!(\n        stored.contains(\"legacy\"),\n        \"auth.json should roll back to legacy content\"\n    );\n    assert!(\n        std::fs::metadata(&config_path)\n            .expect(\"config path metadata\")\n            .is_dir(),\n        \"config path should remain a directory after failure\"\n    );\n}\n\n#[test]\nfn import_from_codex_adds_servers_from_mcp_servers_table() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let path = cc_switch_lib::get_codex_config_path();\n    if let Some(parent) = path.parent() {\n        fs::create_dir_all(parent).expect(\"create codex dir\");\n    }\n    fs::write(\n        &path,\n        r#\"[mcp_servers.echo_server]\ntype = \"stdio\"\ncommand = \"echo\"\nargs = [\"hello\"]\n\n[mcp_servers.http_server]\ntype = \"http\"\nurl = \"https://example.com\"\n\"#,\n    )\n    .expect(\"write codex config\");\n\n    let mut config = MultiAppConfig::default();\n    let changed = cc_switch_lib::import_from_codex(&mut config).expect(\"import codex\");\n    assert!(changed >= 2, \"should import both servers\");\n\n    // v3.7.0: 检查统一结构\n    let servers = config\n        .mcp\n        .servers\n        .as_ref()\n        .expect(\"unified servers should exist\");\n\n    let echo = servers.get(\"echo_server\").expect(\"echo server\");\n    assert!(\n        echo.apps.codex,\n        \"Codex app should be enabled for echo_server\"\n    );\n    let server_spec = echo.server.as_object().expect(\"server spec\");\n    assert_eq!(\n        server_spec\n            .get(\"command\")\n            .and_then(|v| v.as_str())\n            .unwrap_or(\"\"),\n        \"echo\"\n    );\n\n    let http = servers.get(\"http_server\").expect(\"http server\");\n    assert!(\n        http.apps.codex,\n        \"Codex app should be enabled for http_server\"\n    );\n    let http_spec = http.server.as_object().expect(\"http spec\");\n    assert_eq!(\n        http_spec.get(\"url\").and_then(|v| v.as_str()).unwrap_or(\"\"),\n        \"https://example.com\"\n    );\n}\n\n#[test]\nfn import_from_codex_merges_into_existing_entries() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let path = cc_switch_lib::get_codex_config_path();\n    if let Some(parent) = path.parent() {\n        fs::create_dir_all(parent).expect(\"create codex dir\");\n    }\n    fs::write(\n        &path,\n        r#\"[mcp.servers.existing]\ntype = \"stdio\"\ncommand = \"echo\"\n\"#,\n    )\n    .expect(\"write codex config\");\n\n    let mut config = MultiAppConfig::default();\n    // v3.7.0: 在统一结构中创建已存在的服务器\n    config.mcp.servers = Some(std::collections::HashMap::new());\n    config.mcp.servers.as_mut().unwrap().insert(\n        \"existing\".to_string(),\n        cc_switch_lib::McpServer {\n            id: \"existing\".to_string(),\n            name: \"existing\".to_string(),\n            server: json!({\n                \"type\": \"stdio\",\n                \"command\": \"prev\"\n            }),\n            apps: cc_switch_lib::McpApps {\n                claude: false,\n                codex: false, // 初始未启用\n                gemini: false,\n                opencode: false,\n            },\n            description: None,\n            homepage: None,\n            docs: None,\n            tags: Vec::new(),\n        },\n    );\n\n    let changed = cc_switch_lib::import_from_codex(&mut config).expect(\"import codex\");\n    assert!(changed >= 1, \"should mark change for enabled flag\");\n\n    // v3.7.0: 检查统一结构\n    let entry = config\n        .mcp\n        .servers\n        .as_ref()\n        .unwrap()\n        .get(\"existing\")\n        .expect(\"existing entry\");\n\n    // 验证 Codex 应用已启用\n    assert!(entry.apps.codex, \"Codex app should be enabled after import\");\n\n    // 验证现有配置被保留（server 不应被覆盖）\n    let spec = entry.server.as_object().expect(\"server spec\");\n    assert_eq!(\n        spec.get(\"command\").and_then(|v| v.as_str()),\n        Some(\"prev\"),\n        \"existing server config should be preserved, not overwritten by import\"\n    );\n}\n\n#[test]\nfn sync_claude_enabled_mcp_projects_to_user_config() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let home = ensure_test_home();\n\n    // 模拟 Claude 已安装/已初始化：存在 ~/.claude 目录\n    fs::create_dir_all(home.join(\".claude\")).expect(\"create claude dir\");\n\n    let mut config = MultiAppConfig::default();\n\n    config.mcp.claude.servers.insert(\n        \"stdio-enabled\".into(),\n        json!({\n            \"id\": \"stdio-enabled\",\n            \"enabled\": true,\n            \"server\": {\n                \"type\": \"stdio\",\n                \"command\": \"echo\",\n                \"args\": [\"hi\"],\n            }\n        }),\n    );\n    config.mcp.claude.servers.insert(\n        \"http-disabled\".into(),\n        json!({\n            \"id\": \"http-disabled\",\n            \"enabled\": false,\n            \"server\": {\n                \"type\": \"http\",\n                \"url\": \"https://example.com\",\n            }\n        }),\n    );\n\n    cc_switch_lib::sync_enabled_to_claude(&config).expect(\"sync Claude MCP\");\n\n    let claude_path = cc_switch_lib::get_claude_mcp_path();\n    assert!(claude_path.exists(), \"claude config should exist\");\n    let text = fs::read_to_string(&claude_path).expect(\"read .claude.json\");\n    let value: serde_json::Value = serde_json::from_str(&text).expect(\"parse claude json\");\n    let servers = value\n        .get(\"mcpServers\")\n        .and_then(|v| v.as_object())\n        .expect(\"mcpServers map\");\n    assert_eq!(servers.len(), 1, \"only enabled entries should be written\");\n    let enabled = servers.get(\"stdio-enabled\").expect(\"enabled entry\");\n    assert_eq!(\n        enabled\n            .get(\"command\")\n            .and_then(|v| v.as_str())\n            .unwrap_or_default(),\n        \"echo\"\n    );\n    assert!(servers.get(\"http-disabled\").is_none());\n}\n\n#[test]\nfn import_from_claude_merges_into_config() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let home = ensure_test_home();\n    let claude_path = home.join(\".claude.json\");\n\n    fs::write(\n        &claude_path,\n        serde_json::to_string_pretty(&json!({\n            \"mcpServers\": {\n                \"stdio-enabled\": {\n                    \"type\": \"stdio\",\n                    \"command\": \"echo\",\n                    \"args\": [\"hello\"]\n                }\n            }\n        }))\n        .unwrap(),\n    )\n    .expect(\"write claude json\");\n\n    let mut config = MultiAppConfig::default();\n    // v3.7.0: 在统一结构中创建已存在的服务器\n    config.mcp.servers = Some(std::collections::HashMap::new());\n    config.mcp.servers.as_mut().unwrap().insert(\n        \"stdio-enabled\".to_string(),\n        cc_switch_lib::McpServer {\n            id: \"stdio-enabled\".to_string(),\n            name: \"stdio-enabled\".to_string(),\n            server: json!({\n                \"type\": \"stdio\",\n                \"command\": \"prev\"\n            }),\n            apps: cc_switch_lib::McpApps {\n                claude: false, // 初始未启用\n                codex: false,\n                gemini: false,\n                opencode: false,\n            },\n            description: None,\n            homepage: None,\n            docs: None,\n            tags: Vec::new(),\n        },\n    );\n\n    let changed = cc_switch_lib::import_from_claude(&mut config).expect(\"import from claude\");\n    assert!(changed >= 1, \"should mark at least one change\");\n\n    // v3.7.0: 检查统一结构\n    let entry = config\n        .mcp\n        .servers\n        .as_ref()\n        .unwrap()\n        .get(\"stdio-enabled\")\n        .expect(\"entry exists\");\n\n    // 验证 Claude 应用已启用\n    assert!(\n        entry.apps.claude,\n        \"Claude app should be enabled after import\"\n    );\n\n    // 验证现有配置被保留（server 不应被覆盖）\n    let server = entry.server.as_object().expect(\"server obj\");\n    assert_eq!(\n        server.get(\"command\").and_then(|v| v.as_str()).unwrap_or(\"\"),\n        \"prev\",\n        \"existing server config should be preserved\"\n    );\n}\n\n#[test]\nfn create_backup_skips_missing_file() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let home = ensure_test_home();\n    let config_path = home.join(\".cc-switch\").join(\"config.json\");\n\n    // 未创建文件时应返回空字符串，不报错\n    let result = ConfigService::create_backup(&config_path).expect(\"create backup\");\n    assert!(\n        result.is_empty(),\n        \"expected empty backup id when config file missing\"\n    );\n}\n\n#[test]\nfn create_backup_generates_snapshot_file() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let home = ensure_test_home();\n    let config_dir = home.join(\".cc-switch\");\n    let config_path = config_dir.join(\"config.json\");\n    fs::create_dir_all(&config_dir).expect(\"prepare config dir\");\n    fs::write(&config_path, r#\"{\"version\":2}\"#).expect(\"write config file\");\n\n    let backup_id = ConfigService::create_backup(&config_path).expect(\"backup success\");\n    assert!(\n        !backup_id.is_empty(),\n        \"backup id should contain timestamp information\"\n    );\n\n    let backup_path = config_dir.join(\"backups\").join(format!(\"{backup_id}.json\"));\n    assert!(\n        backup_path.exists(),\n        \"expected backup file at {}\",\n        backup_path.display()\n    );\n\n    let backup_content = fs::read_to_string(&backup_path).expect(\"read backup\");\n    assert!(\n        backup_content.contains(r#\"\"version\":2\"#),\n        \"backup content should match original config\"\n    );\n}\n\n#[test]\nfn create_backup_retains_only_latest_entries() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let home = ensure_test_home();\n    let config_dir = home.join(\".cc-switch\");\n    let config_path = config_dir.join(\"config.json\");\n    fs::create_dir_all(&config_dir).expect(\"prepare config dir\");\n    fs::write(&config_path, r#\"{\"version\":3}\"#).expect(\"write config file\");\n\n    let backups_dir = config_dir.join(\"backups\");\n    fs::create_dir_all(&backups_dir).expect(\"create backups dir\");\n    for idx in 0..12 {\n        let manual = backups_dir.join(format!(\"manual_{idx:02}.json\"));\n        fs::write(&manual, format!(\"{{\\\"idx\\\":{idx}}}\")).expect(\"seed manual backup\");\n    }\n\n    std::thread::sleep(std::time::Duration::from_secs(1));\n\n    let latest_backup_id =\n        ConfigService::create_backup(&config_path).expect(\"create backup with cleanup\");\n    assert!(\n        !latest_backup_id.is_empty(),\n        \"backup id should not be empty when config exists\"\n    );\n\n    let entries: Vec<_> = fs::read_dir(&backups_dir)\n        .expect(\"read backups dir\")\n        .filter_map(|entry| entry.ok())\n        .collect();\n    assert!(\n        entries.len() <= 10,\n        \"expected backups to be trimmed to at most 10 files, got {}\",\n        entries.len()\n    );\n\n    let latest_path = backups_dir.join(format!(\"{latest_backup_id}.json\"));\n    assert!(\n        latest_path.exists(),\n        \"latest backup {} should be preserved\",\n        latest_path.display()\n    );\n\n    // 进一步确认保留的条目包含一些历史文件，说明清理逻辑仅裁剪多余部分\n    let manual_kept = entries\n        .iter()\n        .filter_map(|entry| entry.file_name().into_string().ok())\n        .any(|name| name.starts_with(\"manual_\"));\n    assert!(\n        manual_kept,\n        \"cleanup should keep part of the older backups to maintain history\"\n    );\n}\n\n#[test]\nfn sync_gemini_packycode_sets_security_selected_type() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let home = ensure_test_home();\n\n    let mut config = MultiAppConfig::default();\n    {\n        let manager = config\n            .get_manager_mut(&AppType::Gemini)\n            .expect(\"gemini manager\");\n        manager.current = \"packy-1\".to_string();\n        manager.providers.insert(\n            \"packy-1\".to_string(),\n            Provider::with_id(\n                \"packy-1\".to_string(),\n                \"PackyCode\".to_string(),\n                json!({\n                    \"env\": {\n                        \"GEMINI_API_KEY\": \"pk-key\",\n                        \"GOOGLE_GEMINI_BASE_URL\": \"https://api-slb.packyapi.com\"\n                    }\n                }),\n                Some(\"https://www.packyapi.com\".to_string()),\n            ),\n        );\n    }\n\n    ConfigService::sync_current_providers_to_live(&mut config)\n        .expect(\"syncing gemini live should succeed\");\n\n    // security field is written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json\n    let gemini_settings = home.join(\".gemini\").join(\"settings.json\");\n    assert!(\n        gemini_settings.exists(),\n        \"Gemini settings.json should exist at {}\",\n        gemini_settings.display()\n    );\n\n    let raw = std::fs::read_to_string(&gemini_settings).expect(\"read gemini settings.json\");\n    let value: serde_json::Value = serde_json::from_str(&raw).expect(\"parse gemini settings.json\");\n    assert_eq!(\n        value\n            .pointer(\"/security/auth/selectedType\")\n            .and_then(|v| v.as_str()),\n        Some(\"gemini-api-key\"),\n        \"syncing PackyCode Gemini should enforce security.auth.selectedType in Gemini settings\"\n    );\n}\n\n#[test]\nfn sync_gemini_google_official_sets_oauth_security() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let home = ensure_test_home();\n\n    let mut config = MultiAppConfig::default();\n    {\n        let manager = config\n            .get_manager_mut(&AppType::Gemini)\n            .expect(\"gemini manager\");\n        manager.current = \"google-official\".to_string();\n        let mut provider = Provider::with_id(\n            \"google-official\".to_string(),\n            \"Google\".to_string(),\n            json!({\n                \"env\": {}\n            }),\n            Some(\"https://ai.google.dev\".to_string()),\n        );\n        provider.meta = Some(ProviderMeta {\n            partner_promotion_key: Some(\"google-official\".to_string()),\n            ..ProviderMeta::default()\n        });\n        manager\n            .providers\n            .insert(\"google-official\".to_string(), provider);\n    }\n\n    ConfigService::sync_current_providers_to_live(&mut config)\n        .expect(\"syncing google official gemini should succeed\");\n\n    // security field is written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json\n    let gemini_settings = home.join(\".gemini\").join(\"settings.json\");\n    assert!(\n        gemini_settings.exists(),\n        \"Gemini settings should exist at {}\",\n        gemini_settings.display()\n    );\n    let gemini_raw = std::fs::read_to_string(&gemini_settings).expect(\"read gemini settings\");\n    let gemini_value: serde_json::Value =\n        serde_json::from_str(&gemini_raw).expect(\"parse gemini settings json\");\n    assert_eq!(\n        gemini_value\n            .pointer(\"/security/auth/selectedType\")\n            .and_then(|v| v.as_str()),\n        Some(\"oauth-personal\"),\n        \"Gemini settings should record oauth-personal for Google Official\"\n    );\n}\n\n#[test]\nfn export_sql_writes_to_target_path() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let home = ensure_test_home();\n\n    // Create test state with some data\n    let mut config = MultiAppConfig::default();\n    {\n        let manager = config\n            .get_manager_mut(&AppType::Claude)\n            .expect(\"claude manager\");\n        manager.current = \"test-provider\".to_string();\n        manager.providers.insert(\n            \"test-provider\".to_string(),\n            Provider::with_id(\n                \"test-provider\".to_string(),\n                \"Test Provider\".to_string(),\n                json!({\"env\": {\"ANTHROPIC_API_KEY\": \"test-key\"}}),\n                None,\n            ),\n        );\n    }\n\n    let state = create_test_state_with_config(&config).expect(\"create test state\");\n\n    // Export to SQL file\n    let export_path = home.join(\"test-export.sql\");\n    state\n        .db\n        .export_sql(&export_path)\n        .expect(\"export should succeed\");\n\n    // Verify file exists and contains data\n    assert!(export_path.exists(), \"export file should exist\");\n    let content = fs::read_to_string(&export_path).expect(\"read exported file\");\n    assert!(\n        content.contains(\"INSERT INTO\") && content.contains(\"providers\"),\n        \"exported SQL should contain INSERT statements for providers\"\n    );\n    assert!(\n        content.contains(\"test-provider\"),\n        \"exported SQL should contain test data\"\n    );\n}\n\n#[test]\nfn export_sql_returns_error_for_invalid_path() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let _home = ensure_test_home();\n\n    let state = create_test_state().expect(\"create test state\");\n\n    // Try to export to an invalid path (nonexistent parent or invalid name on Windows)\n    let invalid_parent = if cfg!(windows) {\n        std::env::temp_dir().join(\"cc-switch-test-invalid<>dir\")\n    } else {\n        PathBuf::from(\"/nonexistent/directory\")\n    };\n    let invalid_path = invalid_parent.join(\"export.sql\");\n    let err = state\n        .db\n        .export_sql(&invalid_path)\n        .expect_err(\"export to invalid path should fail\");\n    let invalid_prefix = invalid_parent.to_string_lossy();\n\n    // The error can be either IoContext or Io depending on where it fails\n    match err {\n        AppError::IoContext { context, .. } => {\n            assert!(\n                context.contains(\"原子写入失败\") || context.contains(\"写入失败\"),\n                \"expected IO error message about atomic write failure, got: {context}\"\n            );\n        }\n        AppError::Io { path, .. } => {\n            assert!(\n                path.starts_with(invalid_prefix.as_ref()),\n                \"expected error for {invalid_parent:?}, got: {path:?}\"\n            );\n        }\n        other => panic!(\"expected IoContext or Io error, got {other:?}\"),\n    }\n}\n\n#[test]\nfn import_sql_rejects_non_cc_switch_backup() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let home = ensure_test_home();\n\n    let state = create_test_state().expect(\"create test state\");\n\n    let import_path = home.join(\"not-cc-switch.sql\");\n    fs::write(&import_path, \"CREATE TABLE x (id INTEGER);\").expect(\"write import sql\");\n\n    let err = state\n        .db\n        .import_sql(&import_path)\n        .expect_err(\"non-cc-switch sql should be rejected\");\n\n    match err {\n        AppError::Localized { key, .. } => {\n            assert_eq!(key, \"backup.sql.invalid_format\");\n        }\n        other => panic!(\"expected Localized error, got {other:?}\"),\n    }\n}\n\n#[test]\nfn import_sql_accepts_cc_switch_exported_backup() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let home = ensure_test_home();\n\n    // Create a database with some data and export it.\n    let mut config = MultiAppConfig::default();\n    {\n        let manager = config\n            .get_manager_mut(&AppType::Claude)\n            .expect(\"claude manager\");\n        manager.current = \"test-provider\".to_string();\n        manager.providers.insert(\n            \"test-provider\".to_string(),\n            Provider::with_id(\n                \"test-provider\".to_string(),\n                \"Test Provider\".to_string(),\n                json!({\"env\": {\"ANTHROPIC_API_KEY\": \"test-key\"}}),\n                None,\n            ),\n        );\n    }\n\n    let state = create_test_state_with_config(&config).expect(\"create test state\");\n    let export_path = home.join(\"cc-switch-export.sql\");\n    state\n        .db\n        .export_sql(&export_path)\n        .expect(\"export should succeed\");\n\n    // Reset database, then import into a fresh one.\n    reset_test_fs();\n    let state = create_test_state().expect(\"create test state\");\n    state\n        .db\n        .import_sql(&export_path)\n        .expect(\"import should succeed\");\n\n    let providers = state\n        .db\n        .get_all_providers(AppType::Claude.as_str())\n        .expect(\"load providers\");\n    assert!(\n        providers.contains_key(\"test-provider\"),\n        \"imported providers should contain test-provider\"\n    );\n}\n"
  },
  {
    "path": "src-tauri/tests/mcp_commands.rs",
    "content": "use std::collections::HashMap;\nuse std::fs;\n\nuse serde_json::json;\n\nuse cc_switch_lib::{\n    get_claude_mcp_path, get_claude_settings_path, import_default_config_test_hook, AppError,\n    AppType, McpApps, McpServer, McpService, MultiAppConfig,\n};\n\n#[path = \"support.rs\"]\nmod support;\nuse support::{\n    create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex,\n};\n\n#[test]\nfn import_default_config_claude_persists_provider() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let home = ensure_test_home();\n\n    let settings_path = get_claude_settings_path();\n    if let Some(parent) = settings_path.parent() {\n        fs::create_dir_all(parent).expect(\"create claude settings dir\");\n    }\n    let settings = json!({\n        \"env\": {\n            \"ANTHROPIC_AUTH_TOKEN\": \"test-key\",\n            \"ANTHROPIC_BASE_URL\": \"https://api.test\"\n        }\n    });\n    fs::write(\n        &settings_path,\n        serde_json::to_string_pretty(&settings).expect(\"serialize settings\"),\n    )\n    .expect(\"seed claude settings.json\");\n\n    let mut config = MultiAppConfig::default();\n    config.ensure_app(&AppType::Claude);\n    let state = create_test_state_with_config(&config).expect(\"create test state\");\n\n    import_default_config_test_hook(&state, AppType::Claude)\n        .expect(\"import default config succeeds\");\n\n    // 验证内存状态\n    let providers = state\n        .db\n        .get_all_providers(AppType::Claude.as_str())\n        .expect(\"get all providers\");\n    let current_id = state\n        .db\n        .get_current_provider(AppType::Claude.as_str())\n        .expect(\"get current provider\");\n    assert_eq!(current_id.as_deref(), Some(\"default\"));\n    let default_provider = providers.get(\"default\").expect(\"default provider\");\n    assert_eq!(\n        default_provider.settings_config, settings,\n        \"default provider should capture live settings\"\n    );\n\n    // 验证数据已持久化到数据库（v3.7.0+ 使用 SQLite 而非 config.json）\n    let db_path = home.join(\".cc-switch\").join(\"cc-switch.db\");\n    assert!(\n        db_path.exists(),\n        \"importing default config should persist to cc-switch.db\"\n    );\n}\n\n#[test]\nfn import_default_config_without_live_file_returns_error() {\n    use support::create_test_state;\n\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let _home = ensure_test_home();\n\n    let state = create_test_state().expect(\"create test state\");\n\n    let err = import_default_config_test_hook(&state, AppType::Claude)\n        .expect_err(\"missing live file should error\");\n    match err {\n        AppError::Localized { zh, .. } => assert!(\n            zh.contains(\"Claude Code 配置文件不存在\"),\n            \"unexpected error message: {zh}\"\n        ),\n        AppError::Message(msg) => assert!(\n            msg.contains(\"Claude Code 配置文件不存在\"),\n            \"unexpected error message: {msg}\"\n        ),\n        other => panic!(\"unexpected error variant: {other:?}\"),\n    }\n\n    // 使用数据库架构，不再检查 config.json\n    // 失败的导入不应该向数据库写入任何供应商\n    let providers = state\n        .db\n        .get_all_providers(AppType::Claude.as_str())\n        .expect(\"get all providers\");\n    assert!(\n        providers.is_empty(),\n        \"failed import should not create any providers in database\"\n    );\n}\n\n#[test]\nfn import_mcp_from_claude_creates_config_and_enables_servers() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let home = ensure_test_home();\n\n    let mcp_path = get_claude_mcp_path();\n    let claude_json = json!({\n        \"mcpServers\": {\n            \"echo\": {\n                \"type\": \"stdio\",\n                \"command\": \"echo\"\n            }\n        }\n    });\n    fs::write(\n        &mcp_path,\n        serde_json::to_string_pretty(&claude_json).expect(\"serialize claude mcp\"),\n    )\n    .expect(\"seed ~/.claude.json\");\n\n    let config = MultiAppConfig::default();\n    let state = create_test_state_with_config(&config).expect(\"create test state\");\n\n    let changed = McpService::import_from_claude(&state).expect(\"import mcp from claude succeeds\");\n    assert!(\n        changed > 0,\n        \"import should report inserted or normalized entries\"\n    );\n\n    let servers = state.db.get_all_mcp_servers().expect(\"get all mcp servers\");\n    let entry = servers\n        .get(\"echo\")\n        .expect(\"server imported into unified structure\");\n    assert!(\n        entry.apps.claude,\n        \"imported server should have Claude app enabled\"\n    );\n\n    // 验证数据已持久化到数据库\n    let db_path = home.join(\".cc-switch\").join(\"cc-switch.db\");\n    assert!(\n        db_path.exists(),\n        \"state.save should persist to cc-switch.db when changes detected\"\n    );\n}\n\n#[test]\nfn import_mcp_from_claude_invalid_json_preserves_state() {\n    use support::create_test_state;\n\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let _home = ensure_test_home();\n\n    let mcp_path = get_claude_mcp_path();\n    fs::write(&mcp_path, \"{\\\"mcpServers\\\":\") // 不完整 JSON\n        .expect(\"seed invalid ~/.claude.json\");\n\n    let state = create_test_state().expect(\"create test state\");\n\n    let err =\n        McpService::import_from_claude(&state).expect_err(\"invalid json should bubble up error\");\n    match err {\n        AppError::McpValidation(msg) => assert!(\n            msg.contains(\"解析 ~/.claude.json 失败\"),\n            \"unexpected error message: {msg}\"\n        ),\n        other => panic!(\"unexpected error variant: {other:?}\"),\n    }\n\n    // 使用数据库架构，检查 MCP 服务器未被写入\n    let servers = state.db.get_all_mcp_servers().expect(\"get all mcp servers\");\n    assert!(\n        servers.is_empty(),\n        \"failed import should not persist any MCP servers to database\"\n    );\n}\n\n#[test]\nfn set_mcp_enabled_for_codex_writes_live_config() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let home = ensure_test_home();\n\n    // 创建 Codex 配置目录和文件\n    let codex_dir = home.join(\".codex\");\n    fs::create_dir_all(&codex_dir).expect(\"create codex dir\");\n    fs::write(\n        codex_dir.join(\"auth.json\"),\n        r#\"{\"OPENAI_API_KEY\":\"test-key\"}\"#,\n    )\n    .expect(\"create auth.json\");\n    fs::write(codex_dir.join(\"config.toml\"), \"\").expect(\"create empty config.toml\");\n\n    let mut config = MultiAppConfig::default();\n    config.ensure_app(&AppType::Codex);\n\n    // v3.7.0: 使用统一结构\n    config.mcp.servers = Some(HashMap::new());\n    config.mcp.servers.as_mut().unwrap().insert(\n        \"codex-server\".into(),\n        McpServer {\n            id: \"codex-server\".to_string(),\n            name: \"Codex Server\".to_string(),\n            server: json!({\n                \"type\": \"stdio\",\n                \"command\": \"echo\"\n            }),\n            apps: McpApps {\n                claude: false,\n                codex: false, // 初始未启用\n                gemini: false,\n                opencode: false,\n            },\n            description: None,\n            homepage: None,\n            docs: None,\n            tags: Vec::new(),\n        },\n    );\n\n    let state = create_test_state_with_config(&config).expect(\"create test state\");\n\n    // v3.7.0: 使用 toggle_app 替代 set_enabled\n    McpService::toggle_app(&state, \"codex-server\", AppType::Codex, true)\n        .expect(\"toggle_app should succeed\");\n\n    let servers = state.db.get_all_mcp_servers().expect(\"get all mcp servers\");\n    let entry = servers.get(\"codex-server\").expect(\"codex server exists\");\n    assert!(\n        entry.apps.codex,\n        \"server should have Codex app enabled after toggle\"\n    );\n\n    let toml_path = cc_switch_lib::get_codex_config_path();\n    assert!(\n        toml_path.exists(),\n        \"enabling server should trigger sync to ~/.codex/config.toml\"\n    );\n    let toml_text = fs::read_to_string(&toml_path).expect(\"read codex config\");\n    assert!(\n        toml_text.contains(\"codex-server\"),\n        \"codex config should include the enabled server definition\"\n    );\n}\n\n#[test]\nfn enabling_codex_mcp_skips_when_codex_dir_missing() {\n    use support::create_test_state;\n\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let home = ensure_test_home();\n\n    // 确认 Codex 配置目录不存在（模拟“未安装/未运行过 Codex CLI”）\n    assert!(\n        !home.join(\".codex\").exists(),\n        \"~/.codex should not exist in fresh test environment\"\n    );\n\n    let state = create_test_state().expect(\"create test state\");\n\n    // 先插入一个未启用 Codex 的 MCP 服务器（避免 upsert 触发同步）\n    McpService::upsert_server(\n        &state,\n        McpServer {\n            id: \"codex-server\".to_string(),\n            name: \"Codex Server\".to_string(),\n            server: json!({\n                \"type\": \"stdio\",\n                \"command\": \"echo\"\n            }),\n            apps: McpApps {\n                claude: false,\n                codex: false,\n                gemini: false,\n                opencode: false,\n            },\n            description: None,\n            homepage: None,\n            docs: None,\n            tags: Vec::new(),\n        },\n    )\n    .expect(\"insert server without syncing\");\n\n    // 启用 Codex：目录缺失时应跳过写入（不创建 ~/.codex/config.toml）\n    McpService::toggle_app(&state, \"codex-server\", AppType::Codex, true)\n        .expect(\"toggle codex should succeed even when ~/.codex is missing\");\n\n    assert!(\n        !home.join(\".codex\").exists(),\n        \"~/.codex should still not exist after skipped sync\"\n    );\n}\n\n#[test]\nfn upsert_mcp_server_disabling_app_removes_from_claude_live_config() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let home = ensure_test_home();\n\n    // 模拟 Claude 已安装/已初始化：存在 ~/.claude 目录\n    fs::create_dir_all(home.join(\".claude\")).expect(\"create ~/.claude dir\");\n\n    // 先创建一个启用 Claude 的 MCP 服务器\n    let state = support::create_test_state().expect(\"create test state\");\n    McpService::upsert_server(\n        &state,\n        McpServer {\n            id: \"echo\".to_string(),\n            name: \"echo\".to_string(),\n            server: json!({\n                \"type\": \"stdio\",\n                \"command\": \"echo\"\n            }),\n            apps: McpApps {\n                claude: true,\n                codex: false,\n                gemini: false,\n                opencode: false,\n            },\n            description: None,\n            homepage: None,\n            docs: None,\n            tags: Vec::new(),\n        },\n    )\n    .expect(\"upsert should sync to Claude live config\");\n\n    // 确认已写入 ~/.claude.json\n    let mcp_path = get_claude_mcp_path();\n    let text = fs::read_to_string(&mcp_path).expect(\"read ~/.claude.json\");\n    let v: serde_json::Value = serde_json::from_str(&text).expect(\"parse ~/.claude.json\");\n    assert!(\n        v.pointer(\"/mcpServers/echo\").is_some(),\n        \"echo should exist in Claude live config after enabling\"\n    );\n\n    // 再次 upsert：取消勾选 Claude（apps.claude=false），应从 Claude live 配置中移除\n    McpService::upsert_server(\n        &state,\n        McpServer {\n            id: \"echo\".to_string(),\n            name: \"echo\".to_string(),\n            server: json!({\n                \"type\": \"stdio\",\n                \"command\": \"echo\"\n            }),\n            apps: McpApps {\n                claude: false,\n                codex: false,\n                gemini: false,\n                opencode: false,\n            },\n            description: None,\n            homepage: None,\n            docs: None,\n            tags: Vec::new(),\n        },\n    )\n    .expect(\"upsert disabling app should remove from Claude live config\");\n\n    let text = fs::read_to_string(&mcp_path).expect(\"read ~/.claude.json after disable\");\n    let v: serde_json::Value = serde_json::from_str(&text).expect(\"parse ~/.claude.json\");\n    assert!(\n        v.pointer(\"/mcpServers/echo\").is_none(),\n        \"echo should be removed from Claude live config after disabling\"\n    );\n}\n\n#[test]\nfn import_mcp_from_multiple_apps_merges_enabled_flags() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let home = ensure_test_home();\n\n    // 1) Claude: ~/.claude.json\n    let mcp_path = get_claude_mcp_path();\n    let claude_json = json!({\n        \"mcpServers\": {\n            \"shared\": {\n                \"type\": \"stdio\",\n                \"command\": \"echo\"\n            }\n        }\n    });\n    fs::write(\n        &mcp_path,\n        serde_json::to_string_pretty(&claude_json).expect(\"serialize claude mcp\"),\n    )\n    .expect(\"seed ~/.claude.json\");\n\n    // 2) Codex: ~/.codex/config.toml\n    let codex_dir = home.join(\".codex\");\n    fs::create_dir_all(&codex_dir).expect(\"create codex dir\");\n    fs::write(\n        codex_dir.join(\"config.toml\"),\n        r#\"[mcp_servers.shared]\ntype = \"stdio\"\ncommand = \"echo\"\n\"#,\n    )\n    .expect(\"seed ~/.codex/config.toml\");\n\n    let state = support::create_test_state().expect(\"create test state\");\n\n    McpService::import_from_claude(&state).expect(\"import from claude\");\n    McpService::import_from_codex(&state).expect(\"import from codex\");\n\n    let servers = state.db.get_all_mcp_servers().expect(\"get all mcp servers\");\n    let entry = servers.get(\"shared\").expect(\"shared server exists\");\n    assert!(entry.apps.claude, \"shared should enable Claude\");\n    assert!(entry.apps.codex, \"shared should enable Codex\");\n}\n\n#[test]\nfn import_mcp_from_gemini_sse_url_only_is_valid() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let home = ensure_test_home();\n\n    // Gemini MCP 位于 ~/.gemini/settings.json\n    let gemini_dir = home.join(\".gemini\");\n    fs::create_dir_all(&gemini_dir).expect(\"create gemini dir\");\n    let settings_path = gemini_dir.join(\"settings.json\");\n\n    // Gemini SSE：只包含 url（Gemini 不使用 type 字段）\n    let gemini_settings = json!({\n        \"mcpServers\": {\n            \"sse-server\": {\n                \"url\": \"https://example.com/sse\"\n            }\n        }\n    });\n    fs::write(\n        &settings_path,\n        serde_json::to_string_pretty(&gemini_settings).expect(\"serialize gemini settings\"),\n    )\n    .expect(\"seed ~/.gemini/settings.json\");\n\n    let state = support::create_test_state().expect(\"create test state\");\n    let changed = McpService::import_from_gemini(&state).expect(\"import from gemini\");\n    assert!(changed > 0, \"should import at least 1 server\");\n\n    let servers = state.db.get_all_mcp_servers().expect(\"get all mcp servers\");\n    let entry = servers.get(\"sse-server\").expect(\"sse-server exists\");\n    assert!(entry.apps.gemini, \"imported server should enable Gemini\");\n    assert_eq!(\n        entry.server.get(\"type\").and_then(|v| v.as_str()),\n        Some(\"sse\"),\n        \"Gemini url-only server should be normalized to type=sse in unified structure\"\n    );\n}\n\n#[test]\nfn enabling_gemini_mcp_skips_when_gemini_dir_missing() {\n    use support::create_test_state;\n\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let home = ensure_test_home();\n\n    // 确认 Gemini 配置目录不存在（模拟“未安装/未运行过 Gemini CLI”）\n    assert!(\n        !home.join(\".gemini\").exists(),\n        \"~/.gemini should not exist in fresh test environment\"\n    );\n\n    let state = create_test_state().expect(\"create test state\");\n\n    // 先插入一个未启用 Gemini 的 MCP 服务器（避免 upsert 触发同步）\n    McpService::upsert_server(\n        &state,\n        McpServer {\n            id: \"gemini-server\".to_string(),\n            name: \"Gemini Server\".to_string(),\n            server: json!({\n                \"type\": \"sse\",\n                \"url\": \"https://example.com/sse\"\n            }),\n            apps: McpApps {\n                claude: false,\n                codex: false,\n                gemini: false,\n                opencode: false,\n            },\n            description: None,\n            homepage: None,\n            docs: None,\n            tags: Vec::new(),\n        },\n    )\n    .expect(\"insert server without syncing\");\n\n    // 启用 Gemini：目录缺失时应跳过写入（不创建 ~/.gemini/settings.json）\n    McpService::toggle_app(&state, \"gemini-server\", AppType::Gemini, true)\n        .expect(\"toggle gemini should succeed even when ~/.gemini is missing\");\n\n    assert!(\n        !home.join(\".gemini\").exists(),\n        \"~/.gemini should still not exist after skipped sync\"\n    );\n}\n\n#[test]\nfn enabling_claude_mcp_skips_when_claude_config_absent() {\n    use support::create_test_state;\n\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let home = ensure_test_home();\n\n    // 确认 Claude 相关目录/文件都不存在（模拟“未安装/未运行过 Claude”）\n    assert!(\n        !home.join(\".claude\").exists(),\n        \"~/.claude should not exist in fresh test environment\"\n    );\n    assert!(\n        !home.join(\".claude.json\").exists(),\n        \"~/.claude.json should not exist in fresh test environment\"\n    );\n\n    let state = create_test_state().expect(\"create test state\");\n\n    // 先插入一个未启用 Claude 的 MCP 服务器（避免 upsert 触发同步）\n    McpService::upsert_server(\n        &state,\n        McpServer {\n            id: \"claude-server\".to_string(),\n            name: \"Claude Server\".to_string(),\n            server: json!({\n                \"type\": \"stdio\",\n                \"command\": \"echo\"\n            }),\n            apps: McpApps {\n                claude: false,\n                codex: false,\n                gemini: false,\n                opencode: false,\n            },\n            description: None,\n            homepage: None,\n            docs: None,\n            tags: Vec::new(),\n        },\n    )\n    .expect(\"insert server without syncing\");\n\n    // 启用 Claude：配置缺失时应跳过写入（不创建 ~/.claude.json）\n    McpService::toggle_app(&state, \"claude-server\", AppType::Claude, true)\n        .expect(\"toggle claude should succeed even when ~/.claude is missing\");\n\n    assert!(\n        !home.join(\".claude.json\").exists(),\n        \"~/.claude.json should still not exist after skipped sync\"\n    );\n}\n\n#[test]\nfn sync_all_enabled_removes_known_disabled_but_preserves_unknown_live_entries() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let _home = ensure_test_home();\n\n    let mcp_path = get_claude_mcp_path();\n    fs::write(\n        &mcp_path,\n        serde_json::to_string_pretty(&json!({\n            \"mcpServers\": {\n                \"managed-disabled\": {\n                    \"type\": \"stdio\",\n                    \"command\": \"echo\"\n                },\n                \"external-only\": {\n                    \"type\": \"stdio\",\n                    \"command\": \"external\"\n                }\n            }\n        }))\n        .expect(\"serialize claude mcp\"),\n    )\n    .expect(\"seed claude mcp\");\n\n    let state = create_test_state().expect(\"create test state\");\n\n    state\n        .db\n        .save_mcp_server(&McpServer {\n            id: \"managed-disabled\".to_string(),\n            name: \"Managed Disabled\".to_string(),\n            server: json!({\n                \"type\": \"stdio\",\n                \"command\": \"echo\"\n            }),\n            apps: McpApps {\n                claude: false,\n                codex: false,\n                gemini: false,\n                opencode: false,\n            },\n            description: None,\n            homepage: None,\n            docs: None,\n            tags: Vec::new(),\n        })\n        .expect(\"save disabled server\");\n    state\n        .db\n        .save_mcp_server(&McpServer {\n            id: \"managed-enabled\".to_string(),\n            name: \"Managed Enabled\".to_string(),\n            server: json!({\n                \"type\": \"stdio\",\n                \"command\": \"managed\"\n            }),\n            apps: McpApps {\n                claude: true,\n                codex: false,\n                gemini: false,\n                opencode: false,\n            },\n            description: None,\n            homepage: None,\n            docs: None,\n            tags: Vec::new(),\n        })\n        .expect(\"save enabled server\");\n\n    McpService::sync_all_enabled(&state).expect(\"reconcile mcp\");\n\n    let text = fs::read_to_string(&mcp_path).expect(\"read claude mcp\");\n    let value: serde_json::Value = serde_json::from_str(&text).expect(\"parse claude mcp\");\n    let servers = value\n        .get(\"mcpServers\")\n        .and_then(|entry| entry.as_object())\n        .expect(\"mcpServers object\");\n\n    assert!(\n        !servers.contains_key(\"managed-disabled\"),\n        \"DB-known disabled server should be removed from live config\"\n    );\n    assert!(\n        servers.contains_key(\"managed-enabled\"),\n        \"DB-known enabled server should be present in live config\"\n    );\n    assert!(\n        servers.contains_key(\"external-only\"),\n        \"live entries unknown to DB should be preserved\"\n    );\n}\n"
  },
  {
    "path": "src-tauri/tests/provider_commands.rs",
    "content": "use serde_json::json;\n\nuse cc_switch_lib::{\n    get_codex_auth_path, get_codex_config_path, read_json_file, switch_provider_test_hook,\n    write_codex_live_atomic, AppError, AppType, McpApps, McpServer, MultiAppConfig, Provider,\n};\n\n#[path = \"support.rs\"]\nmod support;\nuse std::collections::HashMap;\nuse support::{create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex};\n\n#[test]\nfn switch_provider_updates_codex_live_and_state() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let _home = ensure_test_home();\n\n    let legacy_auth = json!({\"OPENAI_API_KEY\": \"legacy-key\"});\n    let legacy_config = r#\"[mcp_servers.legacy]\ntype = \"stdio\"\ncommand = \"echo\"\n\"#;\n    write_codex_live_atomic(&legacy_auth, Some(legacy_config))\n        .expect(\"seed existing codex live config\");\n\n    let mut config = MultiAppConfig::default();\n    {\n        let manager = config\n            .get_manager_mut(&AppType::Codex)\n            .expect(\"codex manager\");\n        manager.current = \"old-provider\".to_string();\n        manager.providers.insert(\n            \"old-provider\".to_string(),\n            Provider::with_id(\n                \"old-provider\".to_string(),\n                \"Legacy\".to_string(),\n                json!({\n                    \"auth\": {\"OPENAI_API_KEY\": \"stale\"},\n                    \"config\": \"stale-config\"\n                }),\n                None,\n            ),\n        );\n        manager.providers.insert(\n            \"new-provider\".to_string(),\n            Provider::with_id(\n                \"new-provider\".to_string(),\n                \"Latest\".to_string(),\n                json!({\n                    \"auth\": {\"OPENAI_API_KEY\": \"fresh-key\"},\n                    \"config\": r#\"[mcp_servers.latest]\ntype = \"stdio\"\ncommand = \"say\"\n\"#\n                }),\n                None,\n            ),\n        );\n    }\n\n    // v3.7.0+: 使用统一的 MCP 结构\n    config.mcp.servers = Some(HashMap::new());\n    config.mcp.servers.as_mut().unwrap().insert(\n        \"echo-server\".into(),\n        McpServer {\n            id: \"echo-server\".to_string(),\n            name: \"Echo Server\".to_string(),\n            server: json!({\n                \"type\": \"stdio\",\n                \"command\": \"echo\"\n            }),\n            apps: McpApps {\n                claude: false,\n                codex: true, // 启用 Codex\n                gemini: false,\n                opencode: false,\n            },\n            description: None,\n            homepage: None,\n            docs: None,\n            tags: Vec::new(),\n        },\n    );\n\n    let app_state = create_test_state_with_config(&config).expect(\"create test state\");\n\n    switch_provider_test_hook(&app_state, AppType::Codex, \"new-provider\")\n        .expect(\"switch provider should succeed\");\n\n    let auth_value: serde_json::Value =\n        read_json_file(&get_codex_auth_path()).expect(\"read auth.json\");\n    assert_eq!(\n        auth_value\n            .get(\"OPENAI_API_KEY\")\n            .and_then(|v| v.as_str())\n            .unwrap_or(\"\"),\n        \"fresh-key\",\n        \"live auth.json should reflect new provider\"\n    );\n\n    let config_text = std::fs::read_to_string(get_codex_config_path()).expect(\"read config.toml\");\n    assert!(\n        config_text.contains(\"mcp_servers.echo-server\"),\n        \"config.toml should contain synced MCP servers\"\n    );\n\n    let current_id = app_state\n        .db\n        .get_current_provider(AppType::Codex.as_str())\n        .expect(\"get current provider\");\n    assert_eq!(\n        current_id.as_deref(),\n        Some(\"new-provider\"),\n        \"current provider updated\"\n    );\n\n    let providers = app_state\n        .db\n        .get_all_providers(AppType::Codex.as_str())\n        .expect(\"get all providers\");\n\n    let new_provider = providers.get(\"new-provider\").expect(\"new provider exists\");\n    let new_config_text = new_provider\n        .settings_config\n        .get(\"config\")\n        .and_then(|v| v.as_str())\n        .unwrap_or_default();\n    // 供应商配置应该包含在 live 文件中\n    // 注意：live 文件还会包含 MCP 同步后的内容\n    assert!(\n        config_text.contains(\"mcp_servers.latest\"),\n        \"live file should contain provider's original config\"\n    );\n    assert!(\n        new_config_text.contains(\"mcp_servers.latest\"),\n        \"provider snapshot should contain provider's original config\"\n    );\n\n    let legacy = providers\n        .get(\"old-provider\")\n        .expect(\"legacy provider still exists\");\n    let legacy_auth_value = legacy\n        .settings_config\n        .get(\"auth\")\n        .and_then(|v| v.get(\"OPENAI_API_KEY\"))\n        .and_then(|v| v.as_str())\n        .unwrap_or(\"\");\n    // 回填机制：切换前会将 live 配置回填到当前供应商\n    // 这保护了用户在 live 文件中的手动修改\n    assert_eq!(\n        legacy_auth_value, \"legacy-key\",\n        \"previous provider should be backfilled with live auth\"\n    );\n}\n\n#[test]\nfn switch_provider_missing_provider_returns_error() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n\n    let mut config = MultiAppConfig::default();\n    config\n        .get_manager_mut(&AppType::Claude)\n        .expect(\"claude manager\")\n        .current = \"does-not-exist\".to_string();\n\n    let app_state = create_test_state_with_config(&config).expect(\"create test state\");\n\n    let err = switch_provider_test_hook(&app_state, AppType::Claude, \"missing-provider\")\n        .expect_err(\"switching to a missing provider should fail\");\n\n    let err_str = err.to_string();\n    assert!(\n        err_str.contains(\"供应商不存在\")\n            || err_str.contains(\"Provider not found\")\n            || err_str.contains(\"missing-provider\"),\n        \"error message should mention missing provider, got: {err_str}\"\n    );\n}\n\n#[test]\nfn switch_provider_updates_claude_live_and_state() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let _home = ensure_test_home();\n\n    let settings_path = cc_switch_lib::get_claude_settings_path();\n    if let Some(parent) = settings_path.parent() {\n        std::fs::create_dir_all(parent).expect(\"create claude settings dir\");\n    }\n    let legacy_live = json!({\n        \"env\": {\n            \"ANTHROPIC_API_KEY\": \"legacy-key\"\n        },\n        \"workspace\": {\n            \"path\": \"/tmp/workspace\"\n        }\n    });\n    std::fs::write(\n        &settings_path,\n        serde_json::to_string_pretty(&legacy_live).expect(\"serialize legacy live\"),\n    )\n    .expect(\"seed claude live config\");\n\n    let mut config = MultiAppConfig::default();\n    {\n        let manager = config\n            .get_manager_mut(&AppType::Claude)\n            .expect(\"claude manager\");\n        manager.current = \"old-provider\".to_string();\n        manager.providers.insert(\n            \"old-provider\".to_string(),\n            Provider::with_id(\n                \"old-provider\".to_string(),\n                \"Legacy Claude\".to_string(),\n                json!({\n                    \"env\": { \"ANTHROPIC_API_KEY\": \"stale-key\" }\n                }),\n                None,\n            ),\n        );\n        manager.providers.insert(\n            \"new-provider\".to_string(),\n            Provider::with_id(\n                \"new-provider\".to_string(),\n                \"Fresh Claude\".to_string(),\n                json!({\n                    \"env\": { \"ANTHROPIC_API_KEY\": \"fresh-key\" },\n                    \"workspace\": { \"path\": \"/tmp/new-workspace\" }\n                }),\n                None,\n            ),\n        );\n    }\n\n    let app_state = create_test_state_with_config(&config).expect(\"create test state\");\n\n    switch_provider_test_hook(&app_state, AppType::Claude, \"new-provider\")\n        .expect(\"switch provider should succeed\");\n\n    let live_after: serde_json::Value =\n        read_json_file(&settings_path).expect(\"read claude live settings\");\n    assert_eq!(\n        live_after\n            .get(\"env\")\n            .and_then(|env| env.get(\"ANTHROPIC_API_KEY\"))\n            .and_then(|key| key.as_str()),\n        Some(\"fresh-key\"),\n        \"live settings.json should reflect new provider auth\"\n    );\n\n    let current_id = app_state\n        .db\n        .get_current_provider(AppType::Claude.as_str())\n        .expect(\"get current provider\");\n    assert_eq!(\n        current_id.as_deref(),\n        Some(\"new-provider\"),\n        \"current provider updated\"\n    );\n\n    let providers = app_state\n        .db\n        .get_all_providers(AppType::Claude.as_str())\n        .expect(\"get all providers\");\n\n    let legacy_provider = providers\n        .get(\"old-provider\")\n        .expect(\"legacy provider still exists\");\n    // 回填机制：切换前会将 live 配置回填到当前供应商\n    // 这保护了用户在 live 文件中的手动修改\n    assert_eq!(\n        legacy_provider.settings_config, legacy_live,\n        \"previous provider should be backfilled with live config\"\n    );\n\n    let new_provider = providers.get(\"new-provider\").expect(\"new provider exists\");\n    assert_eq!(\n        new_provider\n            .settings_config\n            .get(\"env\")\n            .and_then(|env| env.get(\"ANTHROPIC_API_KEY\"))\n            .and_then(|key| key.as_str()),\n        Some(\"fresh-key\"),\n        \"new provider snapshot should retain fresh auth\"\n    );\n\n    // v3.7.0+ 使用 SQLite 数据库而非 config.json\n    // 验证数据已持久化到数据库\n    let home_dir = std::env::var(\"HOME\").expect(\"HOME should be set by ensure_test_home\");\n    let db_path = std::path::Path::new(&home_dir)\n        .join(\".cc-switch\")\n        .join(\"cc-switch.db\");\n    assert!(\n        db_path.exists(),\n        \"switching provider should persist to cc-switch.db\"\n    );\n\n    // 验证当前供应商已更新\n    let current_id = app_state\n        .db\n        .get_current_provider(AppType::Claude.as_str())\n        .expect(\"get current provider\");\n    assert_eq!(\n        current_id.as_deref(),\n        Some(\"new-provider\"),\n        \"database should record the new current provider\"\n    );\n}\n\n#[test]\nfn switch_provider_codex_missing_auth_returns_error_and_keeps_state() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let _home = ensure_test_home();\n\n    let mut config = MultiAppConfig::default();\n    {\n        let manager = config\n            .get_manager_mut(&AppType::Codex)\n            .expect(\"codex manager\");\n        manager.providers.insert(\n            \"invalid\".to_string(),\n            Provider::with_id(\n                \"invalid\".to_string(),\n                \"Broken Codex\".to_string(),\n                json!({\n                    \"config\": \"[mcp_servers.test]\\ncommand = \\\"noop\\\"\"\n                }),\n                None,\n            ),\n        );\n    }\n\n    let app_state = create_test_state_with_config(&config).expect(\"create test state\");\n\n    let err = switch_provider_test_hook(&app_state, AppType::Codex, \"invalid\")\n        .expect_err(\"switching should fail when auth missing\");\n    match err {\n        AppError::Config(msg) => assert!(\n            msg.contains(\"auth\"),\n            \"expected auth missing error message, got {msg}\"\n        ),\n        other => panic!(\"expected config error, got {other:?}\"),\n    }\n\n    let current_id = app_state\n        .db\n        .get_current_provider(AppType::Codex.as_str())\n        .expect(\"get current provider\");\n    // 切换失败后，由于数据库操作是先设置再验证，current 可能已被设为 \"invalid\"\n    // 但由于 live 配置写入失败，状态应该回滚\n    // 注意：这个行为取决于 switch_provider 的具体实现\n    assert!(\n        current_id.is_none() || current_id.as_deref() == Some(\"invalid\"),\n        \"current provider should remain empty or be the attempted id on failure, got: {current_id:?}\"\n    );\n}\n"
  },
  {
    "path": "src-tauri/tests/provider_service.rs",
    "content": "use serde_json::json;\n\nuse cc_switch_lib::{\n    get_claude_settings_path, read_json_file, write_codex_live_atomic, AppError, AppType, McpApps,\n    McpServer, MultiAppConfig, Provider, ProviderMeta, ProviderService,\n};\n\n#[path = \"support.rs\"]\nmod support;\nuse support::{\n    create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex,\n};\n\nfn sanitize_provider_name(name: &str) -> String {\n    name.chars()\n        .map(|c| match c {\n            '<' | '>' | ':' | '\"' | '/' | '\\\\' | '|' | '?' | '*' => '-',\n            _ => c,\n        })\n        .collect::<String>()\n        .to_lowercase()\n}\n\n#[test]\nfn migrate_legacy_common_config_usage_marks_historical_provider_enabled() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let _home = ensure_test_home();\n\n    let mut config = MultiAppConfig::default();\n    {\n        let manager = config\n            .get_manager_mut(&AppType::Claude)\n            .expect(\"claude manager\");\n        manager.current = \"legacy-provider\".to_string();\n        manager.providers.insert(\n            \"legacy-provider\".to_string(),\n            Provider::with_id(\n                \"legacy-provider\".to_string(),\n                \"Legacy\".to_string(),\n                json!({\n                    \"includeCoAuthoredBy\": false,\n                    \"env\": {\n                        \"ANTHROPIC_API_KEY\": \"legacy-key\"\n                    }\n                }),\n                None,\n            ),\n        );\n    }\n\n    let state = create_test_state_with_config(&config).expect(\"create test state\");\n    state\n        .db\n        .set_config_snippet(\n            AppType::Claude.as_str(),\n            Some(r#\"{ \"includeCoAuthoredBy\": false }\"#.to_string()),\n        )\n        .expect(\"set common config snippet\");\n\n    ProviderService::migrate_legacy_common_config_usage_if_needed(&state, AppType::Claude)\n        .expect(\"migrate legacy common config\");\n\n    let providers = state\n        .db\n        .get_all_providers(AppType::Claude.as_str())\n        .expect(\"get providers after migration\");\n    let provider = providers\n        .get(\"legacy-provider\")\n        .expect(\"legacy provider exists\");\n\n    assert_eq!(\n        provider\n            .meta\n            .as_ref()\n            .and_then(|meta| meta.common_config_enabled),\n        Some(true),\n        \"historical provider should be explicitly marked as using common config\"\n    );\n    assert!(\n        provider\n            .settings_config\n            .get(\"includeCoAuthoredBy\")\n            .is_none(),\n        \"common config fields should be stripped from provider storage after migration\"\n    );\n    assert_eq!(\n        provider\n            .settings_config\n            .get(\"env\")\n            .and_then(|v| v.get(\"ANTHROPIC_API_KEY\"))\n            .and_then(|v| v.as_str()),\n        Some(\"legacy-key\"),\n        \"provider-specific auth should remain untouched\"\n    );\n}\n\n#[test]\nfn provider_service_switch_codex_updates_live_and_config() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let _home = ensure_test_home();\n\n    let legacy_auth = json!({ \"OPENAI_API_KEY\": \"legacy-key\" });\n    let legacy_config = r#\"[mcp_servers.legacy]\ntype = \"stdio\"\ncommand = \"echo\"\n\"#;\n    write_codex_live_atomic(&legacy_auth, Some(legacy_config))\n        .expect(\"seed existing codex live config\");\n\n    let mut initial_config = MultiAppConfig::default();\n    {\n        let manager = initial_config\n            .get_manager_mut(&AppType::Codex)\n            .expect(\"codex manager\");\n        manager.current = \"old-provider\".to_string();\n        manager.providers.insert(\n            \"old-provider\".to_string(),\n            Provider::with_id(\n                \"old-provider\".to_string(),\n                \"Legacy\".to_string(),\n                json!({\n                    \"auth\": {\"OPENAI_API_KEY\": \"stale\"},\n                    \"config\": \"stale-config\"\n                }),\n                None,\n            ),\n        );\n        manager.providers.insert(\n            \"new-provider\".to_string(),\n            Provider::with_id(\n                \"new-provider\".to_string(),\n                \"Latest\".to_string(),\n                json!({\n                    \"auth\": {\"OPENAI_API_KEY\": \"fresh-key\"},\n                    \"config\": r#\"[mcp_servers.latest]\ntype = \"stdio\"\ncommand = \"say\"\n\"#\n                }),\n                None,\n            ),\n        );\n    }\n\n    // 使用新的统一 MCP 结构（v3.7.0+）\n    let servers = initial_config\n        .mcp\n        .servers\n        .get_or_insert_with(Default::default);\n    servers.insert(\n        \"echo-server\".into(),\n        McpServer {\n            id: \"echo-server\".into(),\n            name: \"Echo Server\".into(),\n            server: json!({\n                \"type\": \"stdio\",\n                \"command\": \"echo\"\n            }),\n            apps: McpApps {\n                claude: false,\n                codex: true,\n                gemini: false,\n                opencode: false,\n            },\n            description: None,\n            homepage: None,\n            docs: None,\n            tags: Vec::new(),\n        },\n    );\n\n    let state = create_test_state_with_config(&initial_config).expect(\"create test state\");\n\n    ProviderService::switch(&state, AppType::Codex, \"new-provider\")\n        .expect(\"switch provider should succeed\");\n\n    let auth_value: serde_json::Value =\n        read_json_file(&cc_switch_lib::get_codex_auth_path()).expect(\"read auth.json\");\n    assert_eq!(\n        auth_value.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()),\n        Some(\"fresh-key\"),\n        \"live auth.json should reflect new provider\"\n    );\n\n    let config_text =\n        std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect(\"read config.toml\");\n    assert!(\n        config_text.contains(\"mcp_servers.echo-server\"),\n        \"config.toml should contain synced MCP servers\"\n    );\n\n    let current_id = state\n        .db\n        .get_current_provider(AppType::Codex.as_str())\n        .expect(\"read current provider after switch\");\n    assert_eq!(\n        current_id.as_deref(),\n        Some(\"new-provider\"),\n        \"current provider updated\"\n    );\n\n    let providers = state\n        .db\n        .get_all_providers(AppType::Codex.as_str())\n        .expect(\"read providers after switch\");\n\n    let new_provider = providers.get(\"new-provider\").expect(\"new provider exists\");\n    let new_config_text = new_provider\n        .settings_config\n        .get(\"config\")\n        .and_then(|v| v.as_str())\n        .unwrap_or_default();\n    // provider 存储的是原始配置，不包含 MCP 同步后的内容\n    assert!(\n        new_config_text.contains(\"mcp_servers.latest\"),\n        \"provider config should contain original MCP servers\"\n    );\n    // live 文件额外包含同步的 MCP 服务器\n    assert!(\n        config_text.contains(\"mcp_servers.echo-server\"),\n        \"live config should include synced MCP servers\"\n    );\n\n    let legacy = providers\n        .get(\"old-provider\")\n        .expect(\"legacy provider still exists\");\n    let legacy_auth_value = legacy\n        .settings_config\n        .get(\"auth\")\n        .and_then(|v| v.get(\"OPENAI_API_KEY\"))\n        .and_then(|v| v.as_str())\n        .unwrap_or(\"\");\n    assert_eq!(\n        legacy_auth_value, \"legacy-key\",\n        \"previous provider should be backfilled with live auth\"\n    );\n}\n\n#[test]\nfn sync_current_provider_for_app_keeps_live_takeover_and_updates_restore_backup() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let _home = ensure_test_home();\n\n    let mut config = MultiAppConfig::default();\n    {\n        let manager = config\n            .get_manager_mut(&AppType::Claude)\n            .expect(\"claude manager\");\n        manager.current = \"current-provider\".to_string();\n\n        let mut provider = Provider::with_id(\n            \"current-provider\".to_string(),\n            \"Current\".to_string(),\n            json!({\n                \"env\": {\n                    \"ANTHROPIC_AUTH_TOKEN\": \"real-token\",\n                    \"ANTHROPIC_BASE_URL\": \"https://claude.example\"\n                }\n            }),\n            None,\n        );\n        provider.meta = Some(ProviderMeta {\n            common_config_enabled: Some(true),\n            ..Default::default()\n        });\n\n        manager\n            .providers\n            .insert(\"current-provider\".to_string(), provider);\n    }\n\n    let state = create_test_state_with_config(&config).expect(\"create test state\");\n    state\n        .db\n        .set_config_snippet(\n            AppType::Claude.as_str(),\n            Some(r#\"{ \"includeCoAuthoredBy\": false }\"#.to_string()),\n        )\n        .expect(\"set common config snippet\");\n\n    let taken_over_live = json!({\n        \"env\": {\n            \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:5000\",\n            \"ANTHROPIC_AUTH_TOKEN\": \"PROXY_MANAGED\"\n        }\n    });\n    let settings_path = get_claude_settings_path();\n    std::fs::create_dir_all(settings_path.parent().expect(\"settings dir\")).expect(\"create dir\");\n    std::fs::write(\n        &settings_path,\n        serde_json::to_string_pretty(&taken_over_live).expect(\"serialize taken over live\"),\n    )\n    .expect(\"write taken over live\");\n\n    futures::executor::block_on(state.db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\"))\n        .expect(\"seed live backup\");\n\n    let mut proxy_config = futures::executor::block_on(state.db.get_proxy_config_for_app(\"claude\"))\n        .expect(\"get proxy config\");\n    proxy_config.enabled = true;\n    futures::executor::block_on(state.db.update_proxy_config_for_app(proxy_config))\n        .expect(\"enable takeover\");\n\n    ProviderService::sync_current_provider_for_app(&state, AppType::Claude)\n        .expect(\"sync current provider should succeed\");\n\n    let live_after: serde_json::Value =\n        read_json_file(&settings_path).expect(\"read live settings after sync\");\n    assert_eq!(\n        live_after, taken_over_live,\n        \"sync should not overwrite live config while takeover is active\"\n    );\n\n    let backup = futures::executor::block_on(state.db.get_live_backup(\"claude\"))\n        .expect(\"get live backup\")\n        .expect(\"backup exists\");\n    let backup_value: serde_json::Value =\n        serde_json::from_str(&backup.original_config).expect(\"parse backup value\");\n\n    assert_eq!(\n        backup_value\n            .get(\"includeCoAuthoredBy\")\n            .and_then(|v| v.as_bool()),\n        Some(false),\n        \"restore backup should receive the updated effective config\"\n    );\n    assert_eq!(\n        backup_value\n            .get(\"env\")\n            .and_then(|v| v.get(\"ANTHROPIC_AUTH_TOKEN\"))\n            .and_then(|v| v.as_str()),\n        Some(\"real-token\"),\n        \"restore backup should preserve the provider token rather than proxy placeholder\"\n    );\n}\n\n#[test]\nfn explicitly_cleared_common_snippet_is_not_auto_extracted() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let _home = ensure_test_home();\n\n    let state = create_test_state().expect(\"create test state\");\n    state\n        .db\n        .set_config_snippet_cleared(AppType::Claude.as_str(), true)\n        .expect(\"mark snippet explicitly cleared\");\n\n    assert!(\n        !state\n            .db\n            .should_auto_extract_config_snippet(AppType::Claude.as_str())\n            .expect(\"check auto-extract eligibility\"),\n        \"explicitly cleared snippets should block auto-extraction\"\n    );\n\n    state\n        .db\n        .set_config_snippet(AppType::Claude.as_str(), Some(\"{}\".to_string()))\n        .expect(\"set snippet\");\n    state\n        .db\n        .set_config_snippet_cleared(AppType::Claude.as_str(), false)\n        .expect(\"clear explicit-empty marker\");\n\n    assert!(\n        !state\n            .db\n            .should_auto_extract_config_snippet(AppType::Claude.as_str())\n            .expect(\"check auto-extract after snippet saved\"),\n        \"existing snippets should also block auto-extraction\"\n    );\n}\n\n#[test]\nfn legacy_common_config_migration_flag_roundtrip() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let _home = ensure_test_home();\n\n    let state = create_test_state().expect(\"create test state\");\n\n    assert!(\n        !state\n            .db\n            .is_legacy_common_config_migrated()\n            .expect(\"initial migration flag\"),\n        \"migration flag should default to false\"\n    );\n\n    state\n        .db\n        .set_legacy_common_config_migrated(true)\n        .expect(\"set migration flag\");\n    assert!(\n        state\n            .db\n            .is_legacy_common_config_migrated()\n            .expect(\"read migration flag\"),\n        \"migration flag should persist once set\"\n    );\n\n    state\n        .db\n        .set_legacy_common_config_migrated(false)\n        .expect(\"clear migration flag\");\n    assert!(\n        !state\n            .db\n            .is_legacy_common_config_migrated()\n            .expect(\"read migration flag after clear\"),\n        \"migration flag should be removable for tests/debugging\"\n    );\n}\n\n#[test]\nfn switch_packycode_gemini_updates_security_selected_type() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let home = ensure_test_home();\n\n    let mut config = MultiAppConfig::default();\n    {\n        let manager = config\n            .get_manager_mut(&AppType::Gemini)\n            .expect(\"gemini manager\");\n        manager.current = \"packy-gemini\".to_string();\n        manager.providers.insert(\n            \"packy-gemini\".to_string(),\n            Provider::with_id(\n                \"packy-gemini\".to_string(),\n                \"PackyCode\".to_string(),\n                json!({\n                    \"env\": {\n                        \"GEMINI_API_KEY\": \"pk-key\",\n                        \"GOOGLE_GEMINI_BASE_URL\": \"https://www.packyapi.com\"\n                    }\n                }),\n                Some(\"https://www.packyapi.com\".to_string()),\n            ),\n        );\n    }\n\n    let state = create_test_state_with_config(&config).expect(\"create test state\");\n\n    ProviderService::switch(&state, AppType::Gemini, \"packy-gemini\")\n        .expect(\"switching to PackyCode Gemini should succeed\");\n\n    // Gemini security settings are written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json\n    let settings_path = home.join(\".gemini\").join(\"settings.json\");\n    assert!(\n        settings_path.exists(),\n        \"Gemini settings.json should exist at {}\",\n        settings_path.display()\n    );\n    let raw = std::fs::read_to_string(&settings_path).expect(\"read gemini settings.json\");\n    let value: serde_json::Value =\n        serde_json::from_str(&raw).expect(\"parse gemini settings.json after switch\");\n\n    assert_eq!(\n        value\n            .pointer(\"/security/auth/selectedType\")\n            .and_then(|v| v.as_str()),\n        Some(\"gemini-api-key\"),\n        \"PackyCode Gemini should set security.auth.selectedType\"\n    );\n}\n\n#[test]\nfn packycode_partner_meta_triggers_security_flag_even_without_keywords() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let home = ensure_test_home();\n\n    let mut config = MultiAppConfig::default();\n    {\n        let manager = config\n            .get_manager_mut(&AppType::Gemini)\n            .expect(\"gemini manager\");\n        manager.current = \"packy-meta\".to_string();\n        let mut provider = Provider::with_id(\n            \"packy-meta\".to_string(),\n            \"Generic Gemini\".to_string(),\n            json!({\n                \"env\": {\n                    \"GEMINI_API_KEY\": \"pk-meta\",\n                    \"GOOGLE_GEMINI_BASE_URL\": \"https://generativelanguage.googleapis.com\"\n                }\n            }),\n            Some(\"https://example.com\".to_string()),\n        );\n        provider.meta = Some(ProviderMeta {\n            partner_promotion_key: Some(\"packycode\".to_string()),\n            ..ProviderMeta::default()\n        });\n        manager.providers.insert(\"packy-meta\".to_string(), provider);\n    }\n\n    let state = create_test_state_with_config(&config).expect(\"create test state\");\n\n    ProviderService::switch(&state, AppType::Gemini, \"packy-meta\")\n        .expect(\"switching to partner meta provider should succeed\");\n\n    // Gemini security settings are written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json\n    let settings_path = home.join(\".gemini\").join(\"settings.json\");\n    assert!(\n        settings_path.exists(),\n        \"Gemini settings.json should exist at {}\",\n        settings_path.display()\n    );\n    let raw = std::fs::read_to_string(&settings_path).expect(\"read gemini settings.json\");\n    let value: serde_json::Value =\n        serde_json::from_str(&raw).expect(\"parse gemini settings.json after switch\");\n\n    assert_eq!(\n        value\n            .pointer(\"/security/auth/selectedType\")\n            .and_then(|v| v.as_str()),\n        Some(\"gemini-api-key\"),\n        \"Partner meta should set security.auth.selectedType even without packy keywords\"\n    );\n}\n\n#[test]\nfn switch_google_official_gemini_sets_oauth_security() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let home = ensure_test_home();\n\n    let mut config = MultiAppConfig::default();\n    {\n        let manager = config\n            .get_manager_mut(&AppType::Gemini)\n            .expect(\"gemini manager\");\n        manager.current = \"google-official\".to_string();\n        let mut provider = Provider::with_id(\n            \"google-official\".to_string(),\n            \"Google\".to_string(),\n            json!({\n                \"env\": {}\n            }),\n            Some(\"https://ai.google.dev\".to_string()),\n        );\n        provider.meta = Some(ProviderMeta {\n            partner_promotion_key: Some(\"google-official\".to_string()),\n            ..ProviderMeta::default()\n        });\n        manager\n            .providers\n            .insert(\"google-official\".to_string(), provider);\n    }\n\n    let state = create_test_state_with_config(&config).expect(\"create test state\");\n\n    ProviderService::switch(&state, AppType::Gemini, \"google-official\")\n        .expect(\"switching to Google official Gemini should succeed\");\n\n    // Gemini security settings are written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json\n    let gemini_settings = home.join(\".gemini\").join(\"settings.json\");\n    assert!(\n        gemini_settings.exists(),\n        \"Gemini settings.json should exist at {}\",\n        gemini_settings.display()\n    );\n    let gemini_raw = std::fs::read_to_string(&gemini_settings).expect(\"read gemini settings\");\n    let gemini_value: serde_json::Value =\n        serde_json::from_str(&gemini_raw).expect(\"parse gemini settings\");\n\n    assert_eq!(\n        gemini_value\n            .pointer(\"/security/auth/selectedType\")\n            .and_then(|v| v.as_str()),\n        Some(\"oauth-personal\"),\n        \"Gemini settings json should reflect oauth-personal for Google Official\"\n    );\n}\n\n#[test]\nfn provider_service_switch_claude_updates_live_and_state() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let _home = ensure_test_home();\n\n    let settings_path = get_claude_settings_path();\n    if let Some(parent) = settings_path.parent() {\n        std::fs::create_dir_all(parent).expect(\"create claude settings dir\");\n    }\n    let legacy_live = json!({\n        \"env\": {\n            \"ANTHROPIC_API_KEY\": \"legacy-key\"\n        },\n        \"workspace\": {\n            \"path\": \"/tmp/workspace\"\n        }\n    });\n    std::fs::write(\n        &settings_path,\n        serde_json::to_string_pretty(&legacy_live).expect(\"serialize legacy live\"),\n    )\n    .expect(\"seed claude live config\");\n\n    let mut config = MultiAppConfig::default();\n    {\n        let manager = config\n            .get_manager_mut(&AppType::Claude)\n            .expect(\"claude manager\");\n        manager.current = \"old-provider\".to_string();\n        manager.providers.insert(\n            \"old-provider\".to_string(),\n            Provider::with_id(\n                \"old-provider\".to_string(),\n                \"Legacy Claude\".to_string(),\n                json!({\n                    \"env\": { \"ANTHROPIC_API_KEY\": \"stale-key\" }\n                }),\n                None,\n            ),\n        );\n        manager.providers.insert(\n            \"new-provider\".to_string(),\n            Provider::with_id(\n                \"new-provider\".to_string(),\n                \"Fresh Claude\".to_string(),\n                json!({\n                    \"env\": { \"ANTHROPIC_API_KEY\": \"fresh-key\" },\n                    \"workspace\": { \"path\": \"/tmp/new-workspace\" }\n                }),\n                None,\n            ),\n        );\n    }\n\n    let state = create_test_state_with_config(&config).expect(\"create test state\");\n\n    ProviderService::switch(&state, AppType::Claude, \"new-provider\")\n        .expect(\"switch provider should succeed\");\n\n    let live_after: serde_json::Value =\n        read_json_file(&settings_path).expect(\"read claude live settings\");\n    assert_eq!(\n        live_after\n            .get(\"env\")\n            .and_then(|env| env.get(\"ANTHROPIC_API_KEY\"))\n            .and_then(|key| key.as_str()),\n        Some(\"fresh-key\"),\n        \"live settings.json should reflect new provider auth\"\n    );\n\n    let providers = state\n        .db\n        .get_all_providers(AppType::Claude.as_str())\n        .expect(\"get all providers\");\n    let current_id = state\n        .db\n        .get_current_provider(AppType::Claude.as_str())\n        .expect(\"get current provider\");\n    assert_eq!(\n        current_id.as_deref(),\n        Some(\"new-provider\"),\n        \"current provider updated\"\n    );\n\n    let legacy_provider = providers\n        .get(\"old-provider\")\n        .expect(\"legacy provider still exists\");\n    assert_eq!(\n        legacy_provider.settings_config, legacy_live,\n        \"previous provider should receive backfilled live config\"\n    );\n}\n\n#[test]\nfn provider_service_switch_missing_provider_returns_error() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let _home = ensure_test_home();\n\n    let state = create_test_state().expect(\"create test state\");\n\n    let err = ProviderService::switch(&state, AppType::Claude, \"missing\")\n        .expect_err(\"switching missing provider should fail\");\n    match err {\n        AppError::Message(msg) => {\n            assert!(\n                msg.contains(\"不存在\") || msg.contains(\"not found\"),\n                \"expected provider not found message, got {msg}\"\n            );\n        }\n        other => panic!(\"expected Message error for provider not found, got {other:?}\"),\n    }\n}\n\n#[test]\nfn provider_service_switch_codex_missing_auth_returns_error() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let _home = ensure_test_home();\n\n    let mut config = MultiAppConfig::default();\n    {\n        let manager = config\n            .get_manager_mut(&AppType::Codex)\n            .expect(\"codex manager\");\n        manager.providers.insert(\n            \"invalid\".to_string(),\n            Provider::with_id(\n                \"invalid\".to_string(),\n                \"Broken Codex\".to_string(),\n                json!({\n                    \"config\": \"[mcp_servers.test]\\ncommand = \\\"noop\\\"\"\n                }),\n                None,\n            ),\n        );\n    }\n\n    let state = create_test_state_with_config(&config).expect(\"create test state\");\n\n    let err = ProviderService::switch(&state, AppType::Codex, \"invalid\")\n        .expect_err(\"switching should fail without auth\");\n    match err {\n        AppError::Config(msg) => assert!(\n            msg.contains(\"auth\"),\n            \"expected auth related message, got {msg}\"\n        ),\n        other => panic!(\"expected config error, got {other:?}\"),\n    }\n}\n\n#[test]\nfn provider_service_delete_codex_removes_provider_and_files() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let home = ensure_test_home();\n\n    let mut config = MultiAppConfig::default();\n    {\n        let manager = config\n            .get_manager_mut(&AppType::Codex)\n            .expect(\"codex manager\");\n        manager.current = \"keep\".to_string();\n        manager.providers.insert(\n            \"keep\".to_string(),\n            Provider::with_id(\n                \"keep\".to_string(),\n                \"Keep\".to_string(),\n                json!({\n                    \"auth\": {\"OPENAI_API_KEY\": \"keep-key\"},\n                    \"config\": \"\"\n                }),\n                None,\n            ),\n        );\n        manager.providers.insert(\n            \"to-delete\".to_string(),\n            Provider::with_id(\n                \"to-delete\".to_string(),\n                \"DeleteCodex\".to_string(),\n                json!({\n                    \"auth\": {\"OPENAI_API_KEY\": \"delete-key\"},\n                    \"config\": \"\"\n                }),\n                None,\n            ),\n        );\n    }\n\n    let sanitized = sanitize_provider_name(\"DeleteCodex\");\n    let codex_dir = home.join(\".codex\");\n    std::fs::create_dir_all(&codex_dir).expect(\"create codex dir\");\n    let auth_path = codex_dir.join(format!(\"auth-{sanitized}.json\"));\n    let cfg_path = codex_dir.join(format!(\"config-{sanitized}.toml\"));\n    std::fs::write(&auth_path, \"{}\").expect(\"seed auth file\");\n    std::fs::write(&cfg_path, \"base_url = \\\"https://example\\\"\").expect(\"seed config file\");\n\n    let app_state = create_test_state_with_config(&config).expect(\"create test state\");\n\n    ProviderService::delete(&app_state, AppType::Codex, \"to-delete\")\n        .expect(\"delete provider should succeed\");\n\n    let providers = app_state\n        .db\n        .get_all_providers(AppType::Codex.as_str())\n        .expect(\"get all providers\");\n    assert!(\n        !providers.contains_key(\"to-delete\"),\n        \"provider entry should be removed\"\n    );\n    // v3.7.0+ 不再使用供应商特定文件（如 auth-*.json, config-*.toml）\n    // 删除供应商只影响数据库记录，不清理这些旧格式文件\n}\n\n#[test]\nfn provider_service_delete_claude_removes_provider_files() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let home = ensure_test_home();\n\n    let mut config = MultiAppConfig::default();\n    {\n        let manager = config\n            .get_manager_mut(&AppType::Claude)\n            .expect(\"claude manager\");\n        manager.current = \"keep\".to_string();\n        manager.providers.insert(\n            \"keep\".to_string(),\n            Provider::with_id(\n                \"keep\".to_string(),\n                \"Keep\".to_string(),\n                json!({\n                    \"env\": { \"ANTHROPIC_API_KEY\": \"keep-key\" }\n                }),\n                None,\n            ),\n        );\n        manager.providers.insert(\n            \"delete\".to_string(),\n            Provider::with_id(\n                \"delete\".to_string(),\n                \"DeleteClaude\".to_string(),\n                json!({\n                    \"env\": { \"ANTHROPIC_API_KEY\": \"delete-key\" }\n                }),\n                None,\n            ),\n        );\n    }\n\n    let sanitized = sanitize_provider_name(\"DeleteClaude\");\n    let claude_dir = home.join(\".claude\");\n    std::fs::create_dir_all(&claude_dir).expect(\"create claude dir\");\n    let by_name = claude_dir.join(format!(\"settings-{sanitized}.json\"));\n    let by_id = claude_dir.join(\"settings-delete.json\");\n    std::fs::write(&by_name, \"{}\").expect(\"seed settings by name\");\n    std::fs::write(&by_id, \"{}\").expect(\"seed settings by id\");\n\n    let app_state = create_test_state_with_config(&config).expect(\"create test state\");\n\n    ProviderService::delete(&app_state, AppType::Claude, \"delete\").expect(\"delete claude provider\");\n\n    let providers = app_state\n        .db\n        .get_all_providers(AppType::Claude.as_str())\n        .expect(\"get all providers\");\n    assert!(\n        !providers.contains_key(\"delete\"),\n        \"claude provider should be removed\"\n    );\n    // v3.7.0+ 不再使用供应商特定文件（如 settings-*.json）\n    // 删除供应商只影响数据库记录，不清理这些旧格式文件\n}\n\n#[test]\nfn provider_service_delete_current_provider_returns_error() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let _home = ensure_test_home();\n\n    let mut config = MultiAppConfig::default();\n    {\n        let manager = config\n            .get_manager_mut(&AppType::Claude)\n            .expect(\"claude manager\");\n        manager.current = \"keep\".to_string();\n        manager.providers.insert(\n            \"keep\".to_string(),\n            Provider::with_id(\n                \"keep\".to_string(),\n                \"Keep\".to_string(),\n                json!({\n                    \"env\": { \"ANTHROPIC_API_KEY\": \"keep-key\" }\n                }),\n                None,\n            ),\n        );\n    }\n\n    let app_state = create_test_state_with_config(&config).expect(\"create test state\");\n\n    let err = ProviderService::delete(&app_state, AppType::Claude, \"keep\")\n        .expect_err(\"deleting current provider should fail\");\n    match err {\n        AppError::Localized { zh, .. } => assert!(\n            zh.contains(\"不能删除当前正在使用的供应商\")\n                || zh.contains(\"无法删除当前正在使用的供应商\"),\n            \"unexpected message: {zh}\"\n        ),\n        AppError::Config(msg) => assert!(\n            msg.contains(\"不能删除当前正在使用的供应商\")\n                || msg.contains(\"无法删除当前正在使用的供应商\"),\n            \"unexpected message: {msg}\"\n        ),\n        AppError::Message(msg) => assert!(\n            msg.contains(\"不能删除当前正在使用的供应商\")\n                || msg.contains(\"无法删除当前正在使用的供应商\"),\n            \"unexpected message: {msg}\"\n        ),\n        other => panic!(\"expected Config/Message error, got {other:?}\"),\n    }\n}\n"
  },
  {
    "path": "src-tauri/tests/proxy_commands.rs",
    "content": "use cc_switch_lib::{\n    get_default_cost_multiplier_test_hook, get_pricing_model_source_test_hook,\n    set_default_cost_multiplier_test_hook, set_pricing_model_source_test_hook, AppError,\n};\n\n#[path = \"support.rs\"]\nmod support;\nuse support::{create_test_state, ensure_test_home, reset_test_fs, test_mutex};\n\n// 测试使用 Mutex 进行串行化，跨 await 持锁是预期行为\n#[allow(clippy::await_holding_lock)]\n#[tokio::test]\nasync fn default_cost_multiplier_commands_round_trip() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let _home = ensure_test_home();\n\n    let state = create_test_state().expect(\"create test state\");\n\n    let default = get_default_cost_multiplier_test_hook(&state, \"claude\")\n        .await\n        .expect(\"read default multiplier\");\n    assert_eq!(default, \"1\");\n\n    set_default_cost_multiplier_test_hook(&state, \"claude\", \"1.5\")\n        .await\n        .expect(\"set multiplier\");\n    let updated = get_default_cost_multiplier_test_hook(&state, \"claude\")\n        .await\n        .expect(\"read updated multiplier\");\n    assert_eq!(updated, \"1.5\");\n\n    let err = set_default_cost_multiplier_test_hook(&state, \"claude\", \"not-a-number\")\n        .await\n        .expect_err(\"invalid multiplier should error\");\n    // 错误已改为 Localized 类型（支持 i18n）\n    match err {\n        AppError::Localized { key, .. } => {\n            assert_eq!(key, \"error.invalidMultiplier\");\n        }\n        other => panic!(\"expected localized error, got {other:?}\"),\n    }\n}\n\n// 测试使用 Mutex 进行串行化，跨 await 持锁是预期行为\n#[allow(clippy::await_holding_lock)]\n#[tokio::test]\nasync fn pricing_model_source_commands_round_trip() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let _home = ensure_test_home();\n\n    let state = create_test_state().expect(\"create test state\");\n\n    let default = get_pricing_model_source_test_hook(&state, \"claude\")\n        .await\n        .expect(\"read default pricing model source\");\n    assert_eq!(default, \"response\");\n\n    set_pricing_model_source_test_hook(&state, \"claude\", \"request\")\n        .await\n        .expect(\"set pricing model source\");\n    let updated = get_pricing_model_source_test_hook(&state, \"claude\")\n        .await\n        .expect(\"read updated pricing model source\");\n    assert_eq!(updated, \"request\");\n\n    let err = set_pricing_model_source_test_hook(&state, \"claude\", \"invalid\")\n        .await\n        .expect_err(\"invalid pricing model source should error\");\n    // 错误已改为 Localized 类型（支持 i18n）\n    match err {\n        AppError::Localized { key, .. } => {\n            assert_eq!(key, \"error.invalidPricingMode\");\n        }\n        other => panic!(\"expected localized error, got {other:?}\"),\n    }\n}\n"
  },
  {
    "path": "src-tauri/tests/skill_sync.rs",
    "content": "use std::fs;\n\nuse cc_switch_lib::{\n    migrate_skills_to_ssot, AppType, ImportSkillSelection, InstalledSkill, SkillApps, SkillService,\n};\n\n#[path = \"support.rs\"]\nmod support;\nuse support::{create_test_state, ensure_test_home, reset_test_fs, test_mutex};\n\nfn write_skill(dir: &std::path::Path, name: &str) {\n    fs::create_dir_all(dir).expect(\"create skill dir\");\n    fs::write(\n        dir.join(\"SKILL.md\"),\n        format!(\"---\\nname: {name}\\ndescription: Test skill\\n---\\n\"),\n    )\n    .expect(\"write SKILL.md\");\n}\n\n#[cfg(unix)]\nfn symlink_dir(src: &std::path::Path, dest: &std::path::Path) {\n    std::os::unix::fs::symlink(src, dest).expect(\"create symlink\");\n}\n\n#[cfg(windows)]\nfn symlink_dir(src: &std::path::Path, dest: &std::path::Path) {\n    std::os::windows::fs::symlink_dir(src, dest).expect(\"create symlink\");\n}\n\n#[test]\nfn import_from_apps_respects_explicit_app_selection() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let home = ensure_test_home();\n\n    write_skill(\n        &home.join(\".claude\").join(\"skills\").join(\"shared-skill\"),\n        \"Shared\",\n    );\n    write_skill(\n        &home\n            .join(\".config\")\n            .join(\"opencode\")\n            .join(\"skills\")\n            .join(\"shared-skill\"),\n        \"Shared\",\n    );\n\n    let state = create_test_state().expect(\"create test state\");\n\n    let imported = SkillService::import_from_apps(\n        &state.db,\n        vec![ImportSkillSelection {\n            directory: \"shared-skill\".to_string(),\n            apps: SkillApps {\n                claude: false,\n                codex: false,\n                gemini: false,\n                opencode: true,\n            },\n        }],\n    )\n    .expect(\"import skills\");\n\n    assert_eq!(imported.len(), 1, \"expected exactly one imported skill\");\n    let skill = imported.first().expect(\"imported skill\");\n    assert!(\n        skill.apps.opencode,\n        \"explicitly selected OpenCode app should remain enabled\"\n    );\n    assert!(\n        !skill.apps.claude && !skill.apps.codex && !skill.apps.gemini,\n        \"import should no longer infer apps from every matching source path\"\n    );\n}\n\n#[test]\nfn sync_to_app_removes_disabled_and_orphaned_ssot_symlinks() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let home = ensure_test_home();\n\n    let ssot_dir = home.join(\".cc-switch\").join(\"skills\");\n    let disabled_skill = ssot_dir.join(\"disabled-skill\");\n    let orphan_skill = ssot_dir.join(\"orphan-skill\");\n    write_skill(&disabled_skill, \"Disabled\");\n    write_skill(&orphan_skill, \"Orphan\");\n\n    let opencode_skills_dir = home.join(\".config\").join(\"opencode\").join(\"skills\");\n    fs::create_dir_all(&opencode_skills_dir).expect(\"create opencode skills dir\");\n    symlink_dir(&disabled_skill, &opencode_skills_dir.join(\"disabled-skill\"));\n    symlink_dir(&orphan_skill, &opencode_skills_dir.join(\"orphan-skill\"));\n\n    let state = create_test_state().expect(\"create test state\");\n    state\n        .db\n        .save_skill(&InstalledSkill {\n            id: \"local:disabled-skill\".to_string(),\n            name: \"Disabled\".to_string(),\n            description: None,\n            directory: \"disabled-skill\".to_string(),\n            repo_owner: None,\n            repo_name: None,\n            repo_branch: None,\n            readme_url: None,\n            apps: SkillApps {\n                claude: false,\n                codex: false,\n                gemini: false,\n                opencode: false,\n            },\n            installed_at: 0,\n        })\n        .expect(\"save disabled skill\");\n\n    SkillService::sync_to_app(&state.db, &AppType::OpenCode).expect(\"reconcile skills\");\n\n    assert!(\n        !opencode_skills_dir.join(\"disabled-skill\").exists(),\n        \"DB-known disabled skill should be removed from OpenCode live dir\"\n    );\n    assert!(\n        !opencode_skills_dir.join(\"orphan-skill\").exists(),\n        \"orphaned symlink into SSOT should be cleaned up\"\n    );\n}\n\n#[test]\nfn uninstall_skill_creates_backup_before_removing_ssot() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let home = ensure_test_home();\n\n    let ssot_skill_dir = home.join(\".cc-switch\").join(\"skills\").join(\"backup-skill\");\n    write_skill(&ssot_skill_dir, \"Backup Skill\");\n    fs::write(ssot_skill_dir.join(\"prompt.md\"), \"backup me\").expect(\"write prompt.md\");\n\n    let state = create_test_state().expect(\"create test state\");\n    state\n        .db\n        .save_skill(&InstalledSkill {\n            id: \"local:backup-skill\".to_string(),\n            name: \"Backup Skill\".to_string(),\n            description: Some(\"Back me up before uninstall\".to_string()),\n            directory: \"backup-skill\".to_string(),\n            repo_owner: None,\n            repo_name: None,\n            repo_branch: None,\n            readme_url: None,\n            apps: SkillApps {\n                claude: true,\n                codex: false,\n                gemini: false,\n                opencode: false,\n            },\n            installed_at: 123,\n        })\n        .expect(\"save skill\");\n\n    let result = SkillService::uninstall(&state.db, \"local:backup-skill\").expect(\"uninstall skill\");\n    let backup_path = result.backup_path.expect(\"backup path should be returned\");\n    let backup_dir = std::path::PathBuf::from(&backup_path);\n\n    assert!(backup_dir.exists(), \"backup directory should exist\");\n    assert!(\n        backup_dir.join(\"skill\").join(\"SKILL.md\").exists(),\n        \"backup should include SKILL.md\"\n    );\n    assert_eq!(\n        fs::read_to_string(backup_dir.join(\"skill\").join(\"prompt.md\"))\n            .expect(\"read backed up prompt\"),\n        \"backup me\"\n    );\n\n    let metadata: serde_json::Value = serde_json::from_str(\n        &fs::read_to_string(backup_dir.join(\"meta.json\")).expect(\"read backup metadata\"),\n    )\n    .expect(\"parse backup metadata\");\n    assert_eq!(metadata[\"skill\"][\"directory\"], \"backup-skill\");\n    assert_eq!(metadata[\"skill\"][\"name\"], \"Backup Skill\");\n\n    assert!(\n        !ssot_skill_dir.exists(),\n        \"SSOT skill directory should be removed after uninstall\"\n    );\n    assert!(\n        state\n            .db\n            .get_installed_skill(\"local:backup-skill\")\n            .expect(\"query skill\")\n            .is_none(),\n        \"database row should be deleted after uninstall\"\n    );\n}\n\n#[test]\nfn restore_skill_backup_restores_files_to_ssot_and_current_app() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let home = ensure_test_home();\n\n    let ssot_skill_dir = home.join(\".cc-switch\").join(\"skills\").join(\"restore-skill\");\n    write_skill(&ssot_skill_dir, \"Restore Skill\");\n    fs::write(ssot_skill_dir.join(\"prompt.md\"), \"restore me\").expect(\"write prompt.md\");\n\n    let state = create_test_state().expect(\"create test state\");\n    state\n        .db\n        .save_skill(&InstalledSkill {\n            id: \"local:restore-skill\".to_string(),\n            name: \"Restore Skill\".to_string(),\n            description: Some(\"Bring the files back\".to_string()),\n            directory: \"restore-skill\".to_string(),\n            repo_owner: None,\n            repo_name: None,\n            repo_branch: None,\n            readme_url: None,\n            apps: SkillApps {\n                claude: true,\n                codex: false,\n                gemini: false,\n                opencode: false,\n            },\n            installed_at: 456,\n        })\n        .expect(\"save skill\");\n\n    let uninstall =\n        SkillService::uninstall(&state.db, \"local:restore-skill\").expect(\"uninstall skill\");\n    let backup_id = std::path::Path::new(\n        &uninstall\n            .backup_path\n            .expect(\"backup path should be returned on uninstall\"),\n    )\n    .file_name()\n    .expect(\"backup dir name\")\n    .to_string_lossy()\n    .to_string();\n\n    let restored = SkillService::restore_from_backup(&state.db, &backup_id, &AppType::Claude)\n        .expect(\"restore from backup\");\n\n    assert_eq!(restored.directory, \"restore-skill\");\n    assert!(restored.apps.claude, \"restored skill should enable Claude\");\n    assert!(\n        !restored.apps.codex && !restored.apps.gemini && !restored.apps.opencode,\n        \"restore should only enable the selected app\"\n    );\n    assert!(\n        home.join(\".cc-switch\")\n            .join(\"skills\")\n            .join(\"restore-skill\")\n            .join(\"prompt.md\")\n            .exists(),\n        \"restored skill should exist in SSOT\"\n    );\n    assert!(\n        home.join(\".claude\")\n            .join(\"skills\")\n            .join(\"restore-skill\")\n            .join(\"prompt.md\")\n            .exists(),\n        \"restored skill should sync to the selected app\"\n    );\n    assert!(\n        state\n            .db\n            .get_installed_skill(\"local:restore-skill\")\n            .expect(\"query restored skill\")\n            .is_some(),\n        \"restored skill should be written back to the database\"\n    );\n}\n\n#[test]\nfn delete_skill_backup_removes_backup_directory() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let home = ensure_test_home();\n\n    let ssot_skill_dir = home\n        .join(\".cc-switch\")\n        .join(\"skills\")\n        .join(\"delete-backup-skill\");\n    write_skill(&ssot_skill_dir, \"Delete Backup Skill\");\n\n    let state = create_test_state().expect(\"create test state\");\n    state\n        .db\n        .save_skill(&InstalledSkill {\n            id: \"local:delete-backup-skill\".to_string(),\n            name: \"Delete Backup Skill\".to_string(),\n            description: Some(\"Remove my backup\".to_string()),\n            directory: \"delete-backup-skill\".to_string(),\n            repo_owner: None,\n            repo_name: None,\n            repo_branch: None,\n            readme_url: None,\n            apps: SkillApps {\n                claude: true,\n                codex: false,\n                gemini: false,\n                opencode: false,\n            },\n            installed_at: 789,\n        })\n        .expect(\"save skill\");\n\n    let uninstall =\n        SkillService::uninstall(&state.db, \"local:delete-backup-skill\").expect(\"uninstall skill\");\n    let backup_path = uninstall\n        .backup_path\n        .expect(\"backup path should be returned on uninstall\");\n    let backup_id = std::path::Path::new(&backup_path)\n        .file_name()\n        .expect(\"backup dir name\")\n        .to_string_lossy()\n        .to_string();\n\n    assert!(\n        std::path::Path::new(&backup_path).exists(),\n        \"backup directory should exist before deletion\"\n    );\n\n    SkillService::delete_backup(&backup_id).expect(\"delete backup\");\n\n    assert!(\n        !std::path::Path::new(&backup_path).exists(),\n        \"backup directory should be removed\"\n    );\n    assert!(\n        SkillService::list_backups()\n            .expect(\"list backups\")\n            .into_iter()\n            .all(|entry| entry.backup_id != backup_id),\n        \"deleted backup should no longer appear in backup list\"\n    );\n}\n\n#[test]\nfn migration_snapshot_overrides_multi_source_directory_inference() {\n    let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n    reset_test_fs();\n    let home = ensure_test_home();\n\n    write_skill(\n        &home.join(\".claude\").join(\"skills\").join(\"demo-skill\"),\n        \"Demo\",\n    );\n    write_skill(\n        &home\n            .join(\".config\")\n            .join(\"opencode\")\n            .join(\"skills\")\n            .join(\"demo-skill\"),\n        \"Demo\",\n    );\n\n    let state = create_test_state().expect(\"create test state\");\n    state\n        .db\n        .set_setting(\n            \"skills_ssot_migration_snapshot\",\n            r#\"[{\"directory\":\"demo-skill\",\"app_type\":\"claude\"}]\"#,\n        )\n        .expect(\"seed migration snapshot\");\n\n    let count = migrate_skills_to_ssot(&state.db).expect(\"migrate skills to ssot\");\n    assert_eq!(count, 1, \"expected one migrated skill\");\n\n    let skills = state.db.get_all_installed_skills().expect(\"get skills\");\n    let migrated = skills\n        .values()\n        .find(|skill| skill.directory == \"demo-skill\")\n        .expect(\"migrated demo-skill\");\n\n    assert!(\n        migrated.apps.claude,\n        \"legacy snapshot should preserve Claude enablement\"\n    );\n    assert!(\n        !migrated.apps.opencode,\n        \"migration should no longer infer OpenCode enablement from a duplicate directory alone\"\n    );\n}\n"
  },
  {
    "path": "src-tauri/tests/support.rs",
    "content": "use std::path::{Path, PathBuf};\nuse std::sync::{Arc, Mutex, OnceLock};\n\nuse cc_switch_lib::{\n    update_settings, AppSettings, AppState, Database, MultiAppConfig, ProxyService,\n};\n\n/// 为测试设置隔离的 HOME 目录，避免污染真实用户数据。\npub fn ensure_test_home() -> &'static Path {\n    static HOME: OnceLock<PathBuf> = OnceLock::new();\n    HOME.get_or_init(|| {\n        let base = std::env::temp_dir().join(\"cc-switch-test-home\");\n        if base.exists() {\n            let _ = std::fs::remove_dir_all(&base);\n        }\n        std::fs::create_dir_all(&base).expect(\"create test home\");\n        // Windows 上 `dirs::home_dir()` 不受 HOME/USERPROFILE 影响（走 Known Folder API），\n        // 用 CC_SWITCH_TEST_HOME 显式覆盖，以确保测试不会污染真实用户目录。\n        std::env::set_var(\"CC_SWITCH_TEST_HOME\", &base);\n        std::env::set_var(\"HOME\", &base);\n        #[cfg(windows)]\n        std::env::set_var(\"USERPROFILE\", &base);\n        base\n    })\n    .as_path()\n}\n\n/// 清理测试目录中生成的配置文件与缓存。\npub fn reset_test_fs() {\n    let home = ensure_test_home();\n    for sub in [\n        \".claude\",\n        \".codex\",\n        \".cc-switch\",\n        \".gemini\",\n        \".config\",\n        \".openclaw\",\n    ] {\n        let path = home.join(sub);\n        if path.exists() {\n            if let Err(err) = std::fs::remove_dir_all(&path) {\n                eprintln!(\"failed to clean {}: {}\", path.display(), err);\n            }\n        }\n    }\n    let claude_json = home.join(\".claude.json\");\n    if claude_json.exists() {\n        let _ = std::fs::remove_file(&claude_json);\n    }\n\n    // 重置内存中的设置缓存，确保测试环境不受上一次调用影响\n    let _ = update_settings(AppSettings::default());\n}\n\n/// 全局互斥锁，避免多测试并发写入相同的 HOME 目录。\npub fn test_mutex() -> &'static Mutex<()> {\n    static MUTEX: OnceLock<Mutex<()>> = OnceLock::new();\n    MUTEX.get_or_init(|| Mutex::new(()))\n}\n\n/// 创建测试用的 AppState，包含一个空的数据库\n#[allow(dead_code)]\npub fn create_test_state() -> Result<AppState, Box<dyn std::error::Error>> {\n    let db = Arc::new(Database::init()?);\n    let proxy_service = ProxyService::new(db.clone());\n    Ok(AppState { db, proxy_service })\n}\n\n/// 创建测试用的 AppState，并从 MultiAppConfig 迁移数据\n#[allow(dead_code)]\npub fn create_test_state_with_config(\n    config: &MultiAppConfig,\n) -> Result<AppState, Box<dyn std::error::Error>> {\n    let db = Arc::new(Database::init()?);\n    db.migrate_from_json(config)?;\n    let proxy_service = ProxyService::new(db.clone());\n    Ok(AppState { db, proxy_service })\n}\n"
  },
  {
    "path": "src-tauri/wix/per-user-main.wxs",
    "content": "<?if $(sys.BUILDARCH)=\"x86\"?>\n    <?define Win64 = \"no\" ?>\n    <?define PlatformProgramFilesFolder = \"ProgramFilesFolder\" ?>\n<?elseif $(sys.BUILDARCH)=\"x64\"?>\n    <?define Win64 = \"yes\" ?>\n    <?define PlatformProgramFilesFolder = \"ProgramFiles64Folder\" ?>\n<?elseif $(sys.BUILDARCH)=\"arm64\"?>\n    <?define Win64 = \"yes\" ?>\n    <?define PlatformProgramFilesFolder = \"ProgramFiles64Folder\" ?>\n<?else?>\n    <?error Unsupported value of sys.BUILDARCH=$(sys.BUILDARCH)?>\n<?endif?>\n\n<Wix xmlns=\"http://schemas.microsoft.com/wix/2006/wi\">\n    <Product\n            Id=\"*\"\n            Name=\"{{product_name}}\"\n            UpgradeCode=\"{{upgrade_code}}\"\n            Language=\"!(loc.TauriLanguage)\"\n            Manufacturer=\"{{manufacturer}}\"\n            Version=\"{{version}}\">\n\n        <Package Id=\"*\"\n                 Keywords=\"Installer\"\n                 InstallerVersion=\"450\"\n                 Languages=\"0\"\n                 Compressed=\"yes\"\n                 InstallScope=\"perUser\"\n                 InstallPrivileges=\"limited\"\n                 SummaryCodepage=\"!(loc.TauriCodepage)\"/>\n\n        <!-- https://docs.microsoft.com/en-us/windows/win32/msi/reinstallmode -->\n        <!-- reinstall all files; rewrite all registry entries; reinstall all shortcuts -->\n        <Property Id=\"REINSTALLMODE\" Value=\"amus\" />\n\n        <!-- Auto launch app after installation, useful for passive mode which usually used in updates -->\n        <Property Id=\"AUTOLAUNCHAPP\" Secure=\"yes\" />\n        <!-- Property to forward cli args to the launched app to not lose those of the pre-update instance -->\n        <Property Id=\"LAUNCHAPPARGS\" Secure=\"yes\" />\n\n        {{#if allow_downgrades}}\n            <MajorUpgrade Schedule=\"afterInstallInitialize\" AllowDowngrades=\"yes\" />\n        {{else}}\n            <MajorUpgrade Schedule=\"afterInstallInitialize\" DowngradeErrorMessage=\"!(loc.DowngradeErrorMessage)\" AllowSameVersionUpgrades=\"yes\" />\n        {{/if}}\n\n        <InstallExecuteSequence>\n            <RemoveShortcuts>Installed AND NOT UPGRADINGPRODUCTCODE</RemoveShortcuts>\n        </InstallExecuteSequence>\n\n        <Media Id=\"1\" Cabinet=\"app.cab\" EmbedCab=\"yes\" />\n\n        {{#if banner_path}}\n        <WixVariable Id=\"WixUIBannerBmp\" Value=\"{{banner_path}}\" />\n        {{/if}}\n        {{#if dialog_image_path}}\n        <WixVariable Id=\"WixUIDialogBmp\" Value=\"{{dialog_image_path}}\" />\n        {{/if}}\n        {{#if license}}\n        <WixVariable Id=\"WixUILicenseRtf\" Value=\"{{license}}\" />\n        {{/if}}\n\n        <Icon Id=\"ProductIcon\" SourceFile=\"{{icon_path}}\"/>\n        <Property Id=\"ARPPRODUCTICON\" Value=\"ProductIcon\" />\n        <Property Id=\"ARPNOREPAIR\" Value=\"yes\" Secure=\"yes\" />      <!-- Remove repair -->\n        <SetProperty Id=\"ARPNOMODIFY\" Value=\"1\" After=\"InstallValidate\" Sequence=\"execute\"/>\n\n        {{#if homepage}}\n        <Property Id=\"ARPURLINFOABOUT\" Value=\"{{homepage}}\"/>\n        <Property Id=\"ARPHELPLINK\" Value=\"{{homepage}}\"/>\n        <Property Id=\"ARPURLUPDATEINFO\" Value=\"{{homepage}}\"/>\n        {{/if}}\n\n        <Property Id=\"INSTALLDIR\">\n          <!-- First attempt: Search for \"InstallDir\" -->\n          <RegistrySearch Id=\"PrevInstallDirWithName\" Root=\"HKCU\" Key=\"Software\\\\{{manufacturer}}\\\\{{product_name}}\" Name=\"InstallDir\" Type=\"raw\" />\n\n          <!-- Second attempt: If the first fails, search for the default key value (this is how the nsis installer currently stores the path) -->\n          <RegistrySearch Id=\"PrevInstallDirNoName\" Root=\"HKCU\" Key=\"Software\\\\{{manufacturer}}\\\\{{product_name}}\" Type=\"raw\" />\n        </Property>\n\n        <!-- launch app checkbox -->\n        <Property Id=\"WIXUI_EXITDIALOGOPTIONALCHECKBOXTEXT\" Value=\"!(loc.LaunchApp)\" />\n        <Property Id=\"WIXUI_EXITDIALOGOPTIONALCHECKBOX\" Value=\"1\"/>\n        <CustomAction Id=\"LaunchApplication\" Impersonate=\"yes\" FileKey=\"Path\" ExeCommand=\"[LAUNCHAPPARGS]\" Return=\"asyncNoWait\" />\n\n        <UI>\n            <!-- launch app checkbox -->\n            <Publish Dialog=\"ExitDialog\" Control=\"Finish\" Event=\"DoAction\" Value=\"LaunchApplication\">WIXUI_EXITDIALOGOPTIONALCHECKBOX = 1 and NOT Installed</Publish>\n\n            <Property Id=\"WIXUI_INSTALLDIR\" Value=\"INSTALLDIR\" />\n\n            {{#unless license}}\n            <!-- Skip license dialog -->\n            <Publish Dialog=\"WelcomeDlg\"\n                     Control=\"Next\"\n                     Event=\"NewDialog\"\n                     Value=\"InstallDirDlg\"\n                     Order=\"2\">1</Publish>\n            <Publish Dialog=\"InstallDirDlg\"\n                     Control=\"Back\"\n                     Event=\"NewDialog\"\n                     Value=\"WelcomeDlg\"\n                     Order=\"2\">1</Publish>\n            {{/unless}}\n        </UI>\n\n        <UIRef Id=\"WixUI_InstallDir\" />\n\n        <Directory Id=\"TARGETDIR\" Name=\"SourceDir\">\n            <Directory Id=\"DesktopFolder\" Name=\"Desktop\">\n                <Component Id=\"ApplicationShortcutDesktop\" Guid=\"*\">\n                    <Shortcut Id=\"ApplicationDesktopShortcut\" Name=\"{{product_name}}\" Description=\"Runs {{product_name}}\" Target=\"[!Path]\" WorkingDirectory=\"INSTALLDIR\" />\n                    <RemoveFolder Id=\"DesktopFolder\" On=\"uninstall\" />\n                    <RegistryValue Root=\"HKCU\" Key=\"Software\\\\{{manufacturer}}\\\\{{product_name}}\" Name=\"Desktop Shortcut\" Type=\"integer\" Value=\"1\" KeyPath=\"yes\" />\n                </Component>\n            </Directory>\n            <Directory Id=\"LocalAppDataFolder\">\n                <Directory Id=\"TauriLocalAppDataPrograms\" Name=\"Programs\">\n                    <Directory Id=\"INSTALLDIR\" Name=\"{{product_name}}\"/>\n                </Directory>\n            </Directory>\n            <Directory Id=\"ProgramMenuFolder\">\n                <Directory Id=\"ApplicationProgramsFolder\" Name=\"{{product_name}}\"/>\n            </Directory>\n        </Directory>\n\n        <DirectoryRef Id=\"INSTALLDIR\">\n            <Component Id=\"RegistryEntries\" Guid=\"*\">\n                <RegistryKey Root=\"HKCU\" Key=\"Software\\\\{{manufacturer}}\\\\{{product_name}}\">\n                    <RegistryValue Name=\"InstallDir\" Type=\"string\" Value=\"[INSTALLDIR]\" KeyPath=\"yes\" />\n                </RegistryKey>\n                <!-- Change the Root to HKCU for perUser installations -->\n                {{#each deep_link_protocols as |protocol| ~}}\n                <RegistryKey Root=\"HKCU\" Key=\"Software\\Classes\\\\{{protocol}}\">\n                    <RegistryValue Type=\"string\" Name=\"URL Protocol\" Value=\"\"/>\n                    <RegistryValue Type=\"string\" Value=\"URL:{{bundle_id}} protocol\"/>\n                    <RegistryKey Key=\"DefaultIcon\">\n                        <RegistryValue Type=\"string\" Value=\"&quot;[!Path]&quot;,0\" />\n                    </RegistryKey>\n                    <RegistryKey Key=\"shell\\open\\command\">\n                        <RegistryValue Type=\"string\" Value=\"&quot;[!Path]&quot; &quot;%1&quot;\" />\n                    </RegistryKey>\n                </RegistryKey>\n                {{/each~}}\n            </Component>\n            <Component Id=\"Path\" Guid=\"{{path_component_guid}}\" Win64=\"$(var.Win64)\">\n                <File Id=\"Path\" Source=\"{{main_binary_path}}\" KeyPath=\"no\" Checksum=\"yes\"/>\n                <RegistryValue Root=\"HKCU\" Key=\"Software\\{{manufacturer}}\\{{product_name}}\" Name=\"PathComponent\" Type=\"integer\" Value=\"1\" KeyPath=\"yes\" />\n                {{#each file_associations as |association| ~}}\n                {{#each association.ext as |ext| ~}}\n                <ProgId Id=\"{{../../product_name}}.{{ext}}\" Advertise=\"yes\" Description=\"{{association.description}}\">\n                    <Extension Id=\"{{ext}}\" Advertise=\"yes\">\n                        <Verb Id=\"open\" Command=\"Open with {{../../product_name}}\" Argument=\"&quot;%1&quot;\" />\n                    </Extension>\n                </ProgId>\n                {{/each~}}\n                {{/each~}}\n            </Component>\n            {{#each binaries as |bin| ~}}\n            <Component Id=\"{{ bin.id }}\" Guid=\"{{bin.guid}}\" Win64=\"$(var.Win64)\">\n                <File Id=\"Bin_{{ bin.id }}\" Source=\"{{bin.path}}\" KeyPath=\"yes\"/>\n            </Component>\n            {{/each~}}\n            {{#if enable_elevated_update_task}}\n            <Component Id=\"UpdateTask\" Guid=\"C492327D-9720-4CD5-8DB8-F09082AF44BE\" Win64=\"$(var.Win64)\">\n                <File Id=\"UpdateTask\" Source=\"update.xml\" KeyPath=\"yes\" Checksum=\"yes\"/>\n            </Component>\n            <Component Id=\"UpdateTaskInstaller\" Guid=\"011F25ED-9BE3-50A7-9E9B-3519ED2B9932\" Win64=\"$(var.Win64)\">\n                <File Id=\"UpdateTaskInstaller\" Source=\"install-task.ps1\" KeyPath=\"yes\" Checksum=\"yes\"/>\n            </Component>\n            <Component Id=\"UpdateTaskUninstaller\" Guid=\"D4F6CC3F-32DC-5FD0-95E8-782FFD7BBCE1\" Win64=\"$(var.Win64)\">\n                <File Id=\"UpdateTaskUninstaller\" Source=\"uninstall-task.ps1\" KeyPath=\"yes\" Checksum=\"yes\"/>\n            </Component>\n            {{/if}}\n            {{resources}}\n            <Component Id=\"CMP_UninstallShortcut\" Guid=\"*\">\n\n                <Shortcut Id=\"UninstallShortcut\"\n\t\t\t\t\t\t  Name=\"Uninstall {{product_name}}\"\n\t\t\t\t\t\t  Description=\"Uninstalls {{product_name}}\"\n\t\t\t\t\t\t  Target=\"[System64Folder]msiexec.exe\"\n\t\t\t\t\t\t  Arguments=\"/x [ProductCode]\" />\n\n                <RemoveFile Id=\"RemoveUserProgramsFiles\" Directory=\"TauriLocalAppDataPrograms\" Name=\"*\" On=\"uninstall\" />\n                <RemoveFolder Id=\"RemoveUserProgramsFolder\" Directory=\"TauriLocalAppDataPrograms\" On=\"uninstall\" />\n\n\t\t\t\t<RemoveFolder Id=\"INSTALLDIR\"\n\t\t\t\t\t\t\t  On=\"uninstall\" />\n\n\t\t\t\t<RegistryValue Root=\"HKCU\"\n\t\t\t\t\t\t\t   Key=\"Software\\\\{{manufacturer}}\\\\{{product_name}}\"\n\t\t\t\t\t\t\t   Name=\"Uninstaller Shortcut\"\n\t\t\t\t\t\t\t   Type=\"integer\"\n\t\t\t\t\t\t\t   Value=\"1\"\n\t\t\t\t\t\t\t   KeyPath=\"yes\" />\n            </Component>\n        </DirectoryRef>\n\n        <DirectoryRef Id=\"ApplicationProgramsFolder\">\n            <Component Id=\"ApplicationShortcut\" Guid=\"*\">\n                <Shortcut Id=\"ApplicationStartMenuShortcut\"\n                    Name=\"{{product_name}}\"\n                    Description=\"Runs {{product_name}}\"\n                    Target=\"[!Path]\"\n                    Icon=\"ProductIcon\"\n                    WorkingDirectory=\"INSTALLDIR\">\n                    <ShortcutProperty Key=\"System.AppUserModel.ID\" Value=\"{{bundle_id}}\"/>\n                </Shortcut>\n                <RemoveFolder Id=\"ApplicationProgramsFolder\" On=\"uninstall\"/>\n                <RegistryValue Root=\"HKCU\" Key=\"Software\\\\{{manufacturer}}\\\\{{product_name}}\" Name=\"Start Menu Shortcut\" Type=\"integer\" Value=\"1\" KeyPath=\"yes\"/>\n           </Component>\n        </DirectoryRef>\n\n        {{#each merge_modules as |msm| ~}}\n        <DirectoryRef Id=\"TARGETDIR\">\n            <Merge Id=\"{{ msm.name }}\" SourceFile=\"{{ msm.path }}\" DiskId=\"1\" Language=\"!(loc.TauriLanguage)\" />\n        </DirectoryRef>\n\n        <Feature Id=\"{{ msm.name }}\" Title=\"{{ msm.name }}\" AllowAdvertise=\"no\" Display=\"hidden\" Level=\"1\">\n            <MergeRef Id=\"{{ msm.name }}\"/>\n        </Feature>\n        {{/each~}}\n\n        <Feature\n                Id=\"MainProgram\"\n                Title=\"Application\"\n                Description=\"!(loc.InstallAppFeature)\"\n                Level=\"1\"\n                ConfigurableDirectory=\"INSTALLDIR\"\n                AllowAdvertise=\"no\"\n                Display=\"expand\"\n                Absent=\"disallow\">\n\n            <ComponentRef Id=\"RegistryEntries\"/>\n\n            {{#each resource_file_ids as |resource_file_id| ~}}\n                <ComponentRef Id=\"{{ resource_file_id }}\"/>\n            {{/each~}}\n\n            {{#if enable_elevated_update_task}}\n                <ComponentRef Id=\"UpdateTask\" />\n                <ComponentRef Id=\"UpdateTaskInstaller\" />\n                <ComponentRef Id=\"UpdateTaskUninstaller\" />\n            {{/if}}\n\n            <Feature Id=\"ShortcutsFeature\"\n                Title=\"Shortcuts\"\n                Level=\"1\">\n                <ComponentRef Id=\"Path\"/>\n                <ComponentRef Id=\"CMP_UninstallShortcut\" />\n                <ComponentRef Id=\"ApplicationShortcut\" />\n                <ComponentRef Id=\"ApplicationShortcutDesktop\" />\n            </Feature>\n\n            <Feature\n                Id=\"Environment\"\n                Title=\"PATH Environment Variable\"\n                Description=\"!(loc.PathEnvVarFeature)\"\n                Level=\"1\"\n                Absent=\"allow\">\n            <ComponentRef Id=\"Path\"/>\n            {{#each binaries as |bin| ~}}\n            <ComponentRef Id=\"{{ bin.id }}\"/>\n            {{/each~}}\n            </Feature>\n        </Feature>\n\n        <Feature Id=\"External\" AllowAdvertise=\"no\" Absent=\"disallow\">\n            {{#each component_group_refs as |id| ~}}\n            <ComponentGroupRef Id=\"{{ id }}\"/>\n            {{/each~}}\n            {{#each component_refs as |id| ~}}\n            <ComponentRef Id=\"{{ id }}\"/>\n            {{/each~}}\n            {{#each feature_group_refs as |id| ~}}\n            <FeatureGroupRef Id=\"{{ id }}\"/>\n            {{/each~}}\n            {{#each feature_refs as |id| ~}}\n            <FeatureRef Id=\"{{ id }}\"/>\n            {{/each~}}\n            {{#each merge_refs as |id| ~}}\n            <MergeRef Id=\"{{ id }}\"/>\n            {{/each~}}\n        </Feature>\n\n        {{#if install_webview}}\n        <!-- WebView2 -->\n        <Property Id=\"WVRTINSTALLED\">\n            <RegistrySearch Id=\"WVRTInstalledSystem\" Root=\"HKLM\" Key=\"SOFTWARE\\Microsoft\\EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}\" Name=\"pv\" Type=\"raw\" Win64=\"no\" />\n            <RegistrySearch Id=\"WVRTInstalledUser\" Root=\"HKCU\" Key=\"SOFTWARE\\Microsoft\\EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}\" Name=\"pv\" Type=\"raw\"/>\n        </Property>\n\n        {{#if download_bootstrapper}}\n        <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'/>\n        <InstallExecuteSequence>\n            <Custom Action='DownloadAndInvokeBootstrapper' Before='InstallFinalize'>\n                <![CDATA[NOT(REMOVE OR WVRTINSTALLED)]]>\n            </Custom>\n        </InstallExecuteSequence>\n        {{/if}}\n\n        <!-- Embedded webview bootstrapper mode -->\n        {{#if webview2_bootstrapper_path}}\n        <Binary Id=\"MicrosoftEdgeWebview2Setup.exe\" SourceFile=\"{{webview2_bootstrapper_path}}\"/>\n        <CustomAction Id='InvokeBootstrapper' BinaryKey='MicrosoftEdgeWebview2Setup.exe' Execute=\"deferred\" ExeCommand='{{webview_installer_args}} /install' Return='check' />\n        <InstallExecuteSequence>\n            <Custom Action='InvokeBootstrapper' Before='InstallFinalize'>\n                <![CDATA[NOT(REMOVE OR WVRTINSTALLED)]]>\n            </Custom>\n        </InstallExecuteSequence>\n        {{/if}}\n\n        <!-- Embedded offline installer -->\n        {{#if webview2_installer_path}}\n        <Binary Id=\"MicrosoftEdgeWebView2RuntimeInstaller.exe\" SourceFile=\"{{webview2_installer_path}}\"/>\n        <CustomAction Id='InvokeStandalone' BinaryKey='MicrosoftEdgeWebView2RuntimeInstaller.exe' Execute=\"deferred\" ExeCommand='{{webview_installer_args}} /install' Return='check' />\n        <InstallExecuteSequence>\n            <Custom Action='InvokeStandalone' Before='InstallFinalize'>\n                <![CDATA[NOT(REMOVE OR WVRTINSTALLED)]]>\n            </Custom>\n        </InstallExecuteSequence>\n        {{/if}}\n\n        {{/if}}\n\n        {{#if enable_elevated_update_task}}\n        <!-- Install an elevated update task within Windows Task Scheduler -->\n        <CustomAction\n            Id=\"CreateUpdateTask\"\n            Return=\"check\"\n            Directory=\"INSTALLDIR\"\n            Execute=\"commit\"\n            Impersonate=\"yes\"\n            ExeCommand=\"powershell.exe -WindowStyle hidden .\\install-task.ps1\" />\n        <InstallExecuteSequence>\n            <Custom Action='CreateUpdateTask' Before='InstallFinalize'>\n                NOT(REMOVE)\n            </Custom>\n        </InstallExecuteSequence>\n        <!-- Remove elevated update task during uninstall -->\n        <CustomAction\n            Id=\"DeleteUpdateTask\"\n            Return=\"check\"\n            Directory=\"INSTALLDIR\"\n            ExeCommand=\"powershell.exe -WindowStyle hidden .\\uninstall-task.ps1\" />\n        <InstallExecuteSequence>\n            <Custom Action=\"DeleteUpdateTask\" Before='InstallFinalize'>\n                (REMOVE = \"ALL\") AND NOT UPGRADINGPRODUCTCODE\n            </Custom>\n        </InstallExecuteSequence>\n        {{/if}}\n\n        <InstallExecuteSequence>\n          <Custom Action=\"LaunchApplication\" After=\"InstallFinalize\">AUTOLAUNCHAPP AND NOT Installed</Custom>\n        </InstallExecuteSequence>\n\n        <SetProperty Id=\"ARPINSTALLLOCATION\" Value=\"[INSTALLDIR]\" After=\"CostFinalize\"/>\n    </Product>\n</Wix>\n"
  },
  {
    "path": "tailwind.config.cjs",
    "content": "/** @type {import('tailwindcss').Config} */\nmodule.exports = {\n  content: [\"./src/index.html\", \"./src/**/*.{js,ts,jsx,tsx}\"],\n  darkMode: [\"selector\", \"class\"],\n  theme: {\n    extend: {\n      colors: {\n        background: \"hsl(var(--background))\",\n        foreground: \"hsl(var(--foreground))\",\n        card: {\n          DEFAULT: \"hsl(var(--card))\",\n          foreground: \"hsl(var(--card-foreground))\",\n        },\n        popover: {\n          DEFAULT: \"hsl(var(--popover))\",\n          foreground: \"hsl(var(--popover-foreground))\",\n        },\n        primary: {\n          DEFAULT: \"hsl(var(--primary))\",\n          foreground: \"hsl(var(--primary-foreground))\",\n        },\n        secondary: {\n          DEFAULT: \"hsl(var(--secondary))\",\n          foreground: \"hsl(var(--secondary-foreground))\",\n        },\n        muted: {\n          DEFAULT: \"hsl(var(--muted))\",\n          foreground: \"hsl(var(--muted-foreground))\",\n        },\n        accent: {\n          DEFAULT: \"hsl(var(--accent))\",\n          foreground: \"hsl(var(--accent-foreground))\",\n        },\n        destructive: {\n          DEFAULT: \"hsl(var(--destructive))\",\n          foreground: \"hsl(var(--destructive-foreground))\",\n        },\n        border: \"hsl(var(--border))\",\n        input: \"hsl(var(--input))\",\n        ring: \"hsl(var(--ring))\",\n        blue: {\n          400: \"#409CFF\",\n          500: \"#0A84FF\",\n          600: \"#0060DF\",\n        },\n        gray: {\n          50: \"#fafafa\",\n          100: \"#f4f4f5\",\n          200: \"#e4e4e7\",\n          300: \"#d4d4d8\",\n          400: \"#a1a1aa\",\n          500: \"#71717a\",\n          600: \"#636366\",\n          700: \"#48484A\",\n          800: \"#3A3A3C\",\n          900: \"#2C2C2E\",\n          950: \"#1C1C1E\",\n        },\n        green: {\n          100: \"#d1fae5\",\n          500: \"#10b981\",\n        },\n        red: {\n          100: \"#fee2e2\",\n          500: \"#ef4444\",\n        },\n        amber: {\n          100: \"#fef3c7\",\n          500: \"#f59e0b\",\n        },\n      },\n      boxShadow: {\n        sm: \"0 1px 2px 0 rgb(0 0 0 / 0.05)\",\n        md: \"0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)\",\n        lg: \"0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)\",\n      },\n      borderRadius: {\n        sm: \"0.375rem\",\n        md: \"0.5rem\",\n        lg: \"0.75rem\",\n        xl: \"0.875rem\",\n      },\n      fontFamily: {\n        // 使用与之前版本保持一致的系统字体栈\n        sans: [\n          \"-apple-system\",\n          \"BlinkMacSystemFont\",\n          '\"Segoe UI\"',\n          \"Roboto\",\n          '\"Helvetica Neue\"',\n          \"Arial\",\n          \"sans-serif\",\n        ],\n        mono: [\n          \"ui-monospace\",\n          \"SFMono-Regular\",\n          '\"SF Mono\"',\n          \"Consolas\",\n          '\"Liberation Mono\"',\n          \"Menlo\",\n          \"monospace\",\n        ],\n      },\n      animation: {\n        \"fade-in\": \"fadeIn 0.5s ease-out\",\n        \"slide-up\": \"slideUp 0.5s ease-out\",\n        \"slide-down\": \"slideDown 0.3s ease-out\",\n        \"slide-in-right\": \"slideInRight 0.3s ease-out\",\n        \"pulse-slow\": \"pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite\",\n        \"accordion-down\": \"accordion-down 0.2s ease-out\",\n        \"accordion-up\": \"accordion-up 0.2s ease-out\",\n      },\n      keyframes: {\n        fadeIn: {\n          \"0%\": {\n            opacity: \"0\",\n          },\n          \"100%\": {\n            opacity: \"1\",\n          },\n        },\n        slideUp: {\n          \"0%\": {\n            transform: \"translateY(20px)\",\n            opacity: \"0\",\n          },\n          \"100%\": {\n            transform: \"translateY(0)\",\n            opacity: \"1\",\n          },\n        },\n        slideDown: {\n          \"0%\": {\n            transform: \"translateY(-100%)\",\n            opacity: \"0\",\n          },\n          \"100%\": {\n            transform: \"translateY(0)\",\n            opacity: \"1\",\n          },\n        },\n        slideInRight: {\n          \"0%\": {\n            transform: \"translateX(100%)\",\n            opacity: \"0\",\n          },\n          \"100%\": {\n            transform: \"translateX(0)\",\n            opacity: \"1\",\n          },\n        },\n        \"accordion-down\": {\n          from: {\n            height: \"0\",\n          },\n          to: {\n            height: \"var(--radix-accordion-content-height)\",\n          },\n        },\n        \"accordion-up\": {\n          from: {\n            height: \"var(--radix-accordion-content-height)\",\n          },\n          to: {\n            height: \"0\",\n          },\n        },\n      },\n    },\n  },\n  plugins: [],\n};\n\n"
  },
  {
    "path": "tests/components/AddProviderDialog.test.tsx",
    "content": "import { fireEvent, render, screen, waitFor } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\nimport { AddProviderDialog } from \"@/components/providers/AddProviderDialog\";\nimport type { ProviderFormValues } from \"@/components/providers/forms/ProviderForm\";\n\nvi.mock(\"@/components/ui/dialog\", () => ({\n  Dialog: ({ children }: { children: React.ReactNode }) => (\n    <div>{children}</div>\n  ),\n  DialogContent: ({ children }: { children: React.ReactNode }) => (\n    <div>{children}</div>\n  ),\n  DialogHeader: ({ children }: { children: React.ReactNode }) => (\n    <div>{children}</div>\n  ),\n  DialogTitle: ({ children }: { children: React.ReactNode }) => (\n    <h1>{children}</h1>\n  ),\n  DialogDescription: ({ children }: { children: React.ReactNode }) => (\n    <p>{children}</p>\n  ),\n  DialogFooter: ({ children }: { children: React.ReactNode }) => (\n    <div>{children}</div>\n  ),\n}));\n\nlet mockFormValues: ProviderFormValues;\n\nvi.mock(\"@/components/providers/forms/ProviderForm\", () => ({\n  ProviderForm: ({\n    onSubmit,\n  }: {\n    onSubmit: (values: ProviderFormValues) => void;\n  }) => (\n    <form\n      id=\"provider-form\"\n      onSubmit={(event) => {\n        event.preventDefault();\n        onSubmit(mockFormValues);\n      }}\n    />\n  ),\n}));\n\ndescribe(\"AddProviderDialog\", () => {\n  beforeEach(() => {\n    mockFormValues = {\n      name: \"Test Provider\",\n      websiteUrl: \"https://provider.example.com\",\n      settingsConfig: JSON.stringify({ env: {}, config: {} }),\n      meta: {\n        custom_endpoints: {\n          \"https://api.new-endpoint.com\": {\n            url: \"https://api.new-endpoint.com\",\n            addedAt: 1,\n          },\n        },\n      },\n    };\n  });\n\n  it(\"使用 ProviderForm 返回的自定义端点\", async () => {\n    const handleSubmit = vi.fn().mockResolvedValue(undefined);\n    const handleOpenChange = vi.fn();\n\n    render(\n      <AddProviderDialog\n        open\n        onOpenChange={handleOpenChange}\n        appId=\"claude\"\n        onSubmit={handleSubmit}\n      />,\n    );\n\n    fireEvent.click(\n      screen.getByRole(\"button\", {\n        name: \"common.add\",\n      }),\n    );\n\n    await waitFor(() => expect(handleSubmit).toHaveBeenCalledTimes(1));\n\n    const submitted = handleSubmit.mock.calls[0][0];\n    expect(submitted.meta?.custom_endpoints).toEqual(\n      mockFormValues.meta?.custom_endpoints,\n    );\n    expect(handleOpenChange).toHaveBeenCalledWith(false);\n  });\n\n  it(\"在缺少自定义端点时回退到配置中的 baseUrl\", async () => {\n    const handleSubmit = vi.fn().mockResolvedValue(undefined);\n\n    mockFormValues = {\n      name: \"Base URL Provider\",\n      websiteUrl: \"\",\n      settingsConfig: JSON.stringify({\n        env: { ANTHROPIC_BASE_URL: \"https://claude.base\" },\n        config: {},\n      }),\n    };\n\n    render(\n      <AddProviderDialog\n        open\n        onOpenChange={vi.fn()}\n        appId=\"claude\"\n        onSubmit={handleSubmit}\n      />,\n    );\n\n    fireEvent.click(\n      screen.getByRole(\"button\", {\n        name: \"common.add\",\n      }),\n    );\n\n    await waitFor(() => expect(handleSubmit).toHaveBeenCalledTimes(1));\n\n    const submitted = handleSubmit.mock.calls[0][0];\n    expect(submitted.meta?.custom_endpoints).toEqual({\n      \"https://claude.base\": {\n        url: \"https://claude.base\",\n        addedAt: expect.any(Number),\n        lastUsed: undefined,\n      },\n    });\n  });\n});\n"
  },
  {
    "path": "tests/components/CommonConfigModalBehavior.test.tsx",
    "content": "import type { ReactNode } from \"react\";\nimport { fireEvent, render, screen, waitFor } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\nimport CodexConfigEditor from \"@/components/providers/forms/CodexConfigEditor\";\nimport GeminiConfigEditor from \"@/components/providers/forms/GeminiConfigEditor\";\n\nvi.mock(\"@/components/common/FullScreenPanel\", () => ({\n  FullScreenPanel: ({\n    isOpen,\n    title,\n    onClose,\n    children,\n    footer,\n  }: {\n    isOpen: boolean;\n    title: string;\n    onClose: () => void;\n    children: ReactNode;\n    footer?: ReactNode;\n  }) =>\n    isOpen ? (\n      <div data-testid=\"common-config-panel\">\n        <button type=\"button\" onClick={onClose}>\n          panel-close\n        </button>\n        <h2>{title}</h2>\n        <div>{children}</div>\n        <div>{footer}</div>\n      </div>\n    ) : null,\n}));\n\nvi.mock(\"@/components/JsonEditor\", () => ({\n  default: ({\n    value,\n    onChange,\n  }: {\n    value: string;\n    onChange: (value: string) => void;\n  }) => (\n    <textarea\n      value={value}\n      onChange={(event) => onChange(event.target.value)}\n      aria-label=\"mock-editor\"\n    />\n  ),\n}));\n\ndescribe(\"Common config modals\", () => {\n  it(\"keeps the Codex common config modal closed after user closes it with an error present\", async () => {\n    render(\n      <CodexConfigEditor\n        authValue=\"{}\"\n        configValue=\"\"\n        onAuthChange={() => {}}\n        onConfigChange={() => {}}\n        useCommonConfig={false}\n        onCommonConfigToggle={() => {}}\n        commonConfigSnippet={`base_url = \"https://example.com\"`}\n        onCommonConfigSnippetChange={() => false}\n        onCommonConfigErrorClear={() => {}}\n        commonConfigError=\"Invalid TOML\"\n        authError=\"\"\n        configError=\"\"\n      />,\n    );\n\n    expect(screen.queryByTestId(\"common-config-panel\")).not.toBeInTheDocument();\n\n    fireEvent.click(\n      screen.getByRole(\"button\", { name: /codexConfig.editCommonConfig|编辑通用配置/ }),\n    );\n\n    expect(screen.getByTestId(\"common-config-panel\")).toBeInTheDocument();\n\n    fireEvent.click(screen.getByRole(\"button\", { name: \"common.cancel\" }));\n\n    await waitFor(() =>\n      expect(\n        screen.queryByTestId(\"common-config-panel\"),\n      ).not.toBeInTheDocument(),\n    );\n  });\n\n  it(\"keeps the Gemini common config modal closed after user closes it with an error present\", async () => {\n    render(\n      <GeminiConfigEditor\n        envValue=\"{}\"\n        configValue=\"{}\"\n        onEnvChange={() => {}}\n        onConfigChange={() => {}}\n        useCommonConfig={false}\n        onCommonConfigToggle={() => {}}\n        commonConfigSnippet={`{\"GEMINI_MODEL\":\"gemini-2.5-pro\"}`}\n        onCommonConfigSnippetChange={() => false}\n        onCommonConfigErrorClear={() => {}}\n        commonConfigError=\"Invalid JSON\"\n        envError=\"\"\n        configError=\"\"\n      />,\n    );\n\n    expect(screen.queryByTestId(\"common-config-panel\")).not.toBeInTheDocument();\n\n    fireEvent.click(\n      screen.getByRole(\"button\", {\n        name: /geminiConfig.editCommonConfig|编辑通用配置/,\n      }),\n    );\n\n    expect(screen.getByTestId(\"common-config-panel\")).toBeInTheDocument();\n\n    fireEvent.click(screen.getByRole(\"button\", { name: \"common.cancel\" }));\n\n    await waitFor(() =>\n      expect(\n        screen.queryByTestId(\"common-config-panel\"),\n      ).not.toBeInTheDocument(),\n    );\n  });\n});\n"
  },
  {
    "path": "tests/components/GlobalProxySettings.test.tsx",
    "content": "import { render, screen, fireEvent, waitFor } from \"@testing-library/react\";\nimport { describe, it, expect, vi, beforeEach } from \"vitest\";\nimport { GlobalProxySettings } from \"@/components/settings/GlobalProxySettings\";\n\nvi.mock(\"react-i18next\", () => ({\n  useTranslation: () => ({ t: (key: string) => key }),\n}));\n\nconst mutateAsyncMock = vi.fn();\nconst testMutateAsyncMock = vi.fn();\nconst scanMutateAsyncMock = vi.fn();\n\nvi.mock(\"@/hooks/useGlobalProxy\", () => ({\n  useGlobalProxyUrl: () => ({ data: \"http://127.0.0.1:7890\", isLoading: false }),\n  useSetGlobalProxyUrl: () => ({\n    mutateAsync: mutateAsyncMock,\n    isPending: false,\n  }),\n  useTestProxy: () => ({\n    mutateAsync: testMutateAsyncMock,\n    isPending: false,\n  }),\n  useScanProxies: () => ({\n    mutateAsync: scanMutateAsyncMock,\n    isPending: false,\n  }),\n}));\n\ndescribe(\"GlobalProxySettings\", () => {\n  beforeEach(() => {\n    mutateAsyncMock.mockReset();\n    testMutateAsyncMock.mockReset();\n    scanMutateAsyncMock.mockReset();\n  });\n\n  it(\"renders proxy URL input with saved value\", async () => {\n    render(<GlobalProxySettings />);\n\n    const urlInput = screen.getByPlaceholderText(\n      \"http://127.0.0.1:7890 / socks5://127.0.0.1:1080\",\n    );\n    // URL 对象会在末尾添加斜杠\n    await waitFor(() =>\n      expect(urlInput).toHaveValue(\"http://127.0.0.1:7890/\"),\n    );\n  });\n\n  it(\"saves proxy URL when save button is clicked\", async () => {\n    render(<GlobalProxySettings />);\n\n    const urlInput = screen.getByPlaceholderText(\n      \"http://127.0.0.1:7890 / socks5://127.0.0.1:1080\",\n    );\n\n    fireEvent.change(urlInput, { target: { value: \"http://localhost:8080\" } });\n\n    const saveButton = screen.getByRole(\"button\", { name: \"common.save\" });\n    fireEvent.click(saveButton);\n\n    await waitFor(() => expect(mutateAsyncMock).toHaveBeenCalled());\n    // 没有用户名时，URL 不经过 URL 对象解析，所以没有尾部斜杠\n    expect(mutateAsyncMock).toHaveBeenCalledWith(\"http://localhost:8080\");\n  });\n\n  it(\"clears proxy URL when clear button is clicked\", async () => {\n    render(<GlobalProxySettings />);\n\n    const urlInput = screen.getByPlaceholderText(\n      \"http://127.0.0.1:7890 / socks5://127.0.0.1:1080\",\n    );\n\n    // Wait for initial value to load\n    await waitFor(() =>\n      expect(urlInput).toHaveValue(\"http://127.0.0.1:7890/\"),\n    );\n\n    // Click clear button\n    const clearButton = screen.getByTitle(\"settings.globalProxy.clear\");\n    fireEvent.click(clearButton);\n\n    expect(urlInput).toHaveValue(\"\");\n  });\n});\n"
  },
  {
    "path": "tests/components/ImportExportSection.test.tsx",
    "content": "import { render, screen, fireEvent } from \"@testing-library/react\";\nimport { describe, it, expect, vi, beforeEach } from \"vitest\";\nimport { ImportExportSection } from \"@/components/settings/ImportExportSection\";\n\nconst tMock = vi.fn((key: string) => key);\n\nvi.mock(\"react-i18next\", () => ({\n  useTranslation: () => ({ t: tMock }),\n}));\n\ndescribe(\"ImportExportSection Component\", () => {\n  const baseProps = {\n    status: \"idle\" as const,\n    selectedFile: \"\",\n    errorMessage: null,\n    backupId: null,\n    isImporting: false,\n    onSelectFile: vi.fn(),\n    onImport: vi.fn(),\n    onExport: vi.fn(),\n    onClear: vi.fn(),\n  };\n\n  beforeEach(() => {\n    tMock.mockImplementation((key: string) => key);\n    baseProps.onSelectFile.mockReset();\n    baseProps.onImport.mockReset();\n    baseProps.onExport.mockReset();\n    baseProps.onClear.mockReset();\n  });\n\n  it(\"should disable import button and show placeholder when no file selected\", () => {\n    render(<ImportExportSection {...baseProps} />);\n\n    // When no file selected, button shows \"selectConfigFile\" and clicking it opens file dialog\n    expect(\n      screen.getByRole(\"button\", { name: /settings\\.selectConfigFile/ }),\n    ).toBeInTheDocument();\n    fireEvent.click(\n      screen.getByRole(\"button\", { name: \"settings.exportConfig\" }),\n    );\n    expect(baseProps.onExport).toHaveBeenCalledTimes(1);\n\n    fireEvent.click(\n      screen.getByRole(\"button\", { name: /settings\\.selectConfigFile/ }),\n    );\n    expect(baseProps.onSelectFile).toHaveBeenCalledTimes(1);\n  });\n\n  it(\"should show filename and enable import/clear when file is selected\", () => {\n    render(\n      <ImportExportSection\n        {...baseProps}\n        selectedFile={\"/tmp/test/config.json\"}\n      />,\n    );\n\n    expect(screen.getByText(/config\\.json/)).toBeInTheDocument();\n    const importButton = screen.getByRole(\"button\", {\n      name: /settings\\.import/,\n    });\n    expect(importButton).toBeEnabled();\n    fireEvent.click(importButton);\n    expect(baseProps.onImport).toHaveBeenCalledTimes(1);\n\n    fireEvent.click(screen.getByRole(\"button\", { name: \"common.clear\" }));\n    expect(baseProps.onClear).toHaveBeenCalledTimes(1);\n  });\n\n  it(\"should show loading text and disable import button during import\", () => {\n    render(\n      <ImportExportSection\n        {...baseProps}\n        selectedFile={\"/tmp/test/config.json\"}\n        isImporting\n        status=\"importing\"\n      />,\n    );\n\n    const importingLabels = screen.getAllByText(\"settings.importing\");\n    expect(importingLabels.length).toBeGreaterThanOrEqual(2);\n    expect(\n      screen.getByRole(\"button\", { name: \"settings.importing\" }),\n    ).toBeDisabled();\n    expect(screen.getByText(\"common.loading\")).toBeInTheDocument();\n  });\n\n  it(\"should display backup information on successful import\", () => {\n    render(\n      <ImportExportSection\n        {...baseProps}\n        selectedFile={\"/tmp/test/config.json\"}\n        status=\"success\"\n        backupId=\"backup-001\"\n      />,\n    );\n\n    expect(screen.getByText(\"settings.importSuccess\")).toBeInTheDocument();\n    expect(screen.getByText(/backup-001/)).toBeInTheDocument();\n    expect(screen.getByText(\"settings.autoReload\")).toBeInTheDocument();\n  });\n\n  it(\"should display error message when import fails\", () => {\n    render(\n      <ImportExportSection\n        {...baseProps}\n        status=\"error\"\n        errorMessage=\"Parse failed\"\n      />,\n    );\n\n    expect(screen.getByText(\"settings.importFailed\")).toBeInTheDocument();\n    expect(screen.getByText(\"Parse failed\")).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "tests/components/McpFormModal.test.tsx",
    "content": "import React from \"react\";\nimport {\n  render,\n  screen,\n  fireEvent,\n  waitFor,\n  act,\n} from \"@testing-library/react\";\nimport type { McpServer } from \"@/types\";\nimport McpFormModal from \"@/components/mcp/McpFormModal\";\n\nconst toastErrorMock = vi.hoisted(() => vi.fn());\nconst toastSuccessMock = vi.hoisted(() => vi.fn());\nconst upsertMock = vi.hoisted(() => {\n  const fn = vi.fn();\n  fn.mockResolvedValue(undefined);\n  return fn;\n});\n\nvi.mock(\"sonner\", () => ({\n  toast: {\n    error: (...args: unknown[]) => toastErrorMock(...args),\n    success: (...args: unknown[]) => toastSuccessMock(...args),\n  },\n}));\n\nvi.mock(\"react-i18next\", () => ({\n  useTranslation: () => ({\n    t: (key: string, params?: Record<string, unknown>) =>\n      params ? `${key}:${JSON.stringify(params)}` : key,\n  }),\n  // 提供 initReactI18next 以兼容 i18n 初始化路径\n  initReactI18next: { type: \"3rdParty\", init: () => {} },\n}));\n\nvi.mock(\"@/config/mcpPresets\", () => ({\n  mcpPresets: [\n    {\n      id: \"preset-stdio\",\n      server: { type: \"stdio\", command: \"preset-cmd\" },\n    },\n  ],\n  getMcpPresetWithDescription: (preset: any) => ({\n    ...preset,\n    description: \"Preset description\",\n    tags: [\"preset\"],\n  }),\n}));\n\nvi.mock(\"@/components/ui/button\", () => ({\n  Button: ({ children, onClick, type = \"button\", ...rest }: any) => (\n    <button type={type} onClick={onClick} {...rest}>\n      {children}\n    </button>\n  ),\n}));\n\nvi.mock(\"@/components/ui/input\", () => ({\n  Input: ({ value, onChange, ...rest }: any) => (\n    <input\n      value={value}\n      onChange={(event) =>\n        onChange?.({ target: { value: event.target.value } })\n      }\n      {...rest}\n    />\n  ),\n}));\n\nvi.mock(\"@/components/ui/textarea\", () => ({\n  Textarea: ({ value, onChange, ...rest }: any) => (\n    <textarea\n      value={value}\n      onChange={(event) =>\n        onChange?.({ target: { value: event.target.value } })\n      }\n      {...rest}\n    />\n  ),\n}));\n\nvi.mock(\"@/components/JsonEditor\", () => ({\n  default: ({ value, onChange, placeholder, ...rest }: any) => (\n    <textarea\n      value={value}\n      placeholder={placeholder}\n      onChange={(event) => onChange?.(event.target.value)}\n      {...rest}\n    />\n  ),\n}));\n\nvi.mock(\"@/components/ui/checkbox\", () => ({\n  Checkbox: ({ id, checked, onCheckedChange, ...rest }: any) => (\n    <input\n      type=\"checkbox\"\n      id={id}\n      checked={checked ?? false}\n      onChange={(e) => onCheckedChange?.(e.target.checked)}\n      {...rest}\n    />\n  ),\n}));\n\nvi.mock(\"@/components/ui/dialog\", () => ({\n  Dialog: ({ children }: any) => <div>{children}</div>,\n  DialogContent: ({ children }: any) => <div>{children}</div>,\n  DialogHeader: ({ children }: any) => <div>{children}</div>,\n  DialogTitle: ({ children }: any) => <div>{children}</div>,\n  DialogFooter: ({ children }: any) => <div>{children}</div>,\n}));\n\nvi.mock(\"@/components/mcp/McpWizardModal\", () => ({\n  default: ({ isOpen, onApply }: any) =>\n    isOpen ? (\n      <button\n        type=\"button\"\n        data-testid=\"wizard-apply\"\n        onClick={() =>\n          onApply(\n            \"wizard-id\",\n            JSON.stringify({ type: \"stdio\", command: \"wizard-cmd\" }),\n          )\n        }\n      >\n        wizard-apply\n      </button>\n    ) : null,\n}));\n\nvi.mock(\"@/hooks/useMcp\", async () => {\n  const actual =\n    await vi.importActual<typeof import(\"@/hooks/useMcp\")>(\"@/hooks/useMcp\");\n  return {\n    ...actual,\n    useUpsertMcpServer: () => ({\n      mutateAsync: (...args: unknown[]) => upsertMock(...args),\n    }),\n  };\n});\n\ndescribe(\"McpFormModal\", () => {\n  beforeEach(() => {\n    toastErrorMock.mockClear();\n    toastSuccessMock.mockClear();\n    upsertMock.mockClear();\n  });\n\n  const renderForm = (\n    props?: Partial<React.ComponentProps<typeof McpFormModal>>,\n  ) => {\n    const {\n      onSave: overrideOnSave,\n      onClose: overrideOnClose,\n      ...rest\n    } = props ?? {};\n    const onSave = overrideOnSave ?? vi.fn().mockResolvedValue(undefined);\n    const onClose = overrideOnClose ?? vi.fn();\n    render(\n      <McpFormModal\n        onSave={onSave}\n        onClose={onClose}\n        existingIds={[]}\n        defaultFormat=\"json\"\n        {...rest}\n      />,\n    );\n    return { onSave, onClose };\n  };\n\n  it(\"应用预设后填充 ID 与配置内容\", async () => {\n    renderForm();\n    await waitFor(() =>\n      expect(\n        screen.getByPlaceholderText(\"mcp.form.titlePlaceholder\"),\n      ).toBeInTheDocument(),\n    );\n\n    fireEvent.click(screen.getByText(\"preset-stdio\"));\n\n    const idInput = screen.getByPlaceholderText(\n      \"mcp.form.titlePlaceholder\",\n    ) as HTMLInputElement;\n    expect(idInput.value).toBe(\"preset-stdio\");\n\n    const configTextarea = screen.getByPlaceholderText(\n      \"mcp.form.jsonPlaceholder\",\n    ) as HTMLTextAreaElement;\n    expect(configTextarea.value).toBe(\n      '{\\n  \"type\": \"stdio\",\\n  \"command\": \"preset-cmd\"\\n}',\n    );\n  });\n\n  it(\"提交时清洗字段并调用 upsert 与 onSave\", async () => {\n    const { onSave } = renderForm();\n\n    fireEvent.change(screen.getByPlaceholderText(\"mcp.form.titlePlaceholder\"), {\n      target: { value: \" my-server \" },\n    });\n    fireEvent.change(screen.getByPlaceholderText(\"mcp.form.namePlaceholder\"), {\n      target: { value: \"   Friendly \" },\n    });\n\n    fireEvent.click(screen.getByText(\"mcp.form.additionalInfo\"));\n\n    fireEvent.change(\n      screen.getByPlaceholderText(\"mcp.form.descriptionPlaceholder\"),\n      {\n        target: { value: \" Description \" },\n      },\n    );\n    fireEvent.change(screen.getByPlaceholderText(\"mcp.form.tagsPlaceholder\"), {\n      target: { value: \" tag1 , tag2 \" },\n    });\n    fireEvent.change(\n      screen.getByPlaceholderText(\"mcp.form.homepagePlaceholder\"),\n      {\n        target: { value: \" https://example.com \" },\n      },\n    );\n    fireEvent.change(screen.getByPlaceholderText(\"mcp.form.docsPlaceholder\"), {\n      target: { value: \" https://docs.example.com \" },\n    });\n\n    fireEvent.change(screen.getByPlaceholderText(\"mcp.form.jsonPlaceholder\"), {\n      target: { value: '{\"type\":\"stdio\",\"command\":\"run\"}' },\n    });\n\n    fireEvent.click(screen.getByText(\"common.add\"));\n\n    await waitFor(() => expect(upsertMock).toHaveBeenCalledTimes(1));\n    const [entry] = upsertMock.mock.calls.at(-1) ?? [];\n    expect(entry).toMatchObject({\n      id: \"my-server\",\n      name: \"Friendly\",\n      description: \"Description\",\n      homepage: \"https://example.com\",\n      docs: \"https://docs.example.com\",\n      tags: [\"tag1\", \"tag2\"],\n      server: {\n        type: \"stdio\",\n        command: \"run\",\n      },\n      apps: {\n        claude: true,\n        codex: true,\n        gemini: true,\n      },\n    });\n    expect(onSave).toHaveBeenCalledTimes(1);\n    expect(onSave).toHaveBeenCalledWith();\n    expect(toastErrorMock).not.toHaveBeenCalled();\n  });\n\n  it(\"缺少配置命令时阻止提交并提示错误\", async () => {\n    renderForm();\n\n    fireEvent.change(screen.getByPlaceholderText(\"mcp.form.titlePlaceholder\"), {\n      target: { value: \"no-command\" },\n    });\n    fireEvent.change(screen.getByPlaceholderText(\"mcp.form.jsonPlaceholder\"), {\n      target: { value: '{\"type\":\"stdio\"}' },\n    });\n\n    fireEvent.click(screen.getByText(\"common.add\"));\n\n    await waitFor(() => expect(toastErrorMock).toHaveBeenCalled());\n    expect(upsertMock).not.toHaveBeenCalled();\n    const [message] = toastErrorMock.mock.calls.at(-1) ?? [];\n    expect(message).toBe(\"mcp.error.commandRequired\");\n  });\n\n  it(\"支持向导生成配置并自动填充 ID\", async () => {\n    renderForm();\n    fireEvent.click(screen.getByText(\"mcp.form.useWizard\"));\n\n    const applyButton = await screen.findByTestId(\"wizard-apply\");\n    await act(async () => {\n      fireEvent.click(applyButton);\n    });\n\n    const idInput = screen.getByPlaceholderText(\n      \"mcp.form.titlePlaceholder\",\n    ) as HTMLInputElement;\n    expect(idInput.value).toBe(\"wizard-id\");\n\n    const configTextarea = screen.getByPlaceholderText(\n      \"mcp.form.jsonPlaceholder\",\n    ) as HTMLTextAreaElement;\n    expect(configTextarea.value).toBe(\n      '{\"type\":\"stdio\",\"command\":\"wizard-cmd\"}',\n    );\n  });\n\n  it(\"TOML 模式下自动提取 ID 并成功保存\", async () => {\n    const { onSave } = renderForm({ defaultFormat: \"toml\" });\n\n    const configTextarea = screen.getByPlaceholderText(\n      \"mcp.form.tomlPlaceholder\",\n    ) as HTMLTextAreaElement;\n\n    const toml = `[mcp.servers.demo]\ntype = \"stdio\"\ncommand = \"run\"\n`;\n    fireEvent.change(configTextarea, { target: { value: toml } });\n\n    const idInput = screen.getByPlaceholderText(\n      \"mcp.form.titlePlaceholder\",\n    ) as HTMLInputElement;\n\n    await waitFor(() => expect(idInput.value).toBe(\"demo\"));\n\n    fireEvent.click(screen.getByText(\"common.add\"));\n\n    await waitFor(() => expect(upsertMock).toHaveBeenCalledTimes(1));\n    const [entry] = upsertMock.mock.calls.at(-1) ?? [];\n    expect(entry.id).toBe(\"demo\");\n    expect(entry.server).toEqual({ type: \"stdio\", command: \"run\" });\n    expect(onSave).toHaveBeenCalledTimes(1);\n    expect(onSave).toHaveBeenCalledWith();\n    expect(toastErrorMock).not.toHaveBeenCalled();\n  });\n\n  it(\"TOML 模式下缺少命令时展示错误提示并阻止提交\", async () => {\n    renderForm({ defaultFormat: \"toml\" });\n\n    // 填写 ID 字段\n    fireEvent.change(screen.getByPlaceholderText(\"mcp.form.titlePlaceholder\"), {\n      target: { value: \"test-toml\" },\n    });\n\n    const configTextarea = screen.getByPlaceholderText(\n      \"mcp.form.tomlPlaceholder\",\n    ) as HTMLTextAreaElement;\n\n    const invalidToml = `[mcp.servers.demo]\ntype = \"stdio\"\n`;\n    fireEvent.change(configTextarea, { target: { value: invalidToml } });\n\n    fireEvent.click(screen.getByText(\"common.add\"));\n\n    await waitFor(() =>\n      expect(toastErrorMock).toHaveBeenCalledWith(\"mcp.error.tomlInvalid\", {\n        duration: 3000,\n      }),\n    );\n    expect(upsertMock).not.toHaveBeenCalled();\n  });\n\n  it(\"编辑模式下保持 ID 并更新配置\", async () => {\n    const initialData: McpServer = {\n      id: \"existing\",\n      name: \"Existing\",\n      enabled: true,\n      description: \"Old desc\",\n      server: { type: \"stdio\", command: \"old\" },\n      apps: { claude: true, codex: false, gemini: false },\n    } as McpServer;\n\n    const { onSave } = renderForm({\n      editingId: \"existing\",\n      initialData,\n    });\n\n    const idInput = screen.getByPlaceholderText(\n      \"mcp.form.titlePlaceholder\",\n    ) as HTMLInputElement;\n    expect(idInput.value).toBe(\"existing\");\n    expect(idInput).toHaveAttribute(\"disabled\");\n\n    const configTextarea = screen.getByPlaceholderText(\n      \"mcp.form.jsonPlaceholder\",\n    ) as HTMLTextAreaElement;\n    expect(configTextarea.value).toContain('\"command\": \"old\"');\n\n    fireEvent.change(configTextarea, {\n      target: { value: '{\"type\":\"stdio\",\"command\":\"updated\"}' },\n    });\n\n    fireEvent.click(screen.getByText(\"common.save\"));\n\n    await waitFor(() => expect(upsertMock).toHaveBeenCalledTimes(1));\n    const [entry] = upsertMock.mock.calls.at(-1) ?? [];\n    expect(entry.id).toBe(\"existing\");\n    expect(entry.server.command).toBe(\"updated\");\n    expect(entry.enabled).toBe(true);\n    expect(entry.apps).toEqual({\n      claude: true,\n      codex: false,\n      gemini: false,\n    });\n    expect(onSave).toHaveBeenCalledTimes(1);\n    expect(onSave).toHaveBeenCalledWith();\n  });\n\n  it(\"允许未选择任何应用保存配置，并保持 apps 全 false\", async () => {\n    const { onSave } = renderForm();\n\n    fireEvent.change(screen.getByPlaceholderText(\"mcp.form.titlePlaceholder\"), {\n      target: { value: \"no-apps\" },\n    });\n    fireEvent.change(screen.getByPlaceholderText(\"mcp.form.jsonPlaceholder\"), {\n      target: { value: '{\"type\":\"stdio\",\"command\":\"run\"}' },\n    });\n\n    const claudeCheckbox = screen.getByLabelText(\n      \"mcp.unifiedPanel.apps.claude\",\n    ) as HTMLInputElement;\n    expect(claudeCheckbox.checked).toBe(true);\n    fireEvent.click(claudeCheckbox);\n\n    const codexCheckbox = screen.getByLabelText(\n      \"mcp.unifiedPanel.apps.codex\",\n    ) as HTMLInputElement;\n    expect(codexCheckbox.checked).toBe(true);\n    fireEvent.click(codexCheckbox);\n\n    const geminiCheckbox = screen.getByLabelText(\n      \"mcp.unifiedPanel.apps.gemini\",\n    ) as HTMLInputElement;\n    expect(geminiCheckbox.checked).toBe(true);\n    fireEvent.click(geminiCheckbox);\n\n    fireEvent.click(screen.getByText(\"common.add\"));\n\n    await waitFor(() => expect(upsertMock).toHaveBeenCalledTimes(1));\n    const [entry] = upsertMock.mock.calls.at(-1) ?? [];\n    expect(entry.id).toBe(\"no-apps\");\n    expect(entry.apps).toEqual({\n      claude: false,\n      codex: false,\n      gemini: false,\n      opencode: false,\n      openclaw: false,\n    });\n    expect(onSave).toHaveBeenCalledTimes(1);\n    expect(toastErrorMock).not.toHaveBeenCalled();\n  });\n\n  it(\"保存失败时展示翻译后的错误并恢复按钮\", async () => {\n    const failingSave = vi.fn().mockRejectedValue(new Error(\"保存失败\"));\n    renderForm({ onSave: failingSave });\n\n    fireEvent.change(screen.getByPlaceholderText(\"mcp.form.titlePlaceholder\"), {\n      target: { value: \"will-fail\" },\n    });\n    fireEvent.change(screen.getByPlaceholderText(\"mcp.form.jsonPlaceholder\"), {\n      target: { value: '{\"type\":\"stdio\",\"command\":\"ok\"}' },\n    });\n\n    fireEvent.click(screen.getByText(\"common.add\"));\n\n    await waitFor(() => expect(failingSave).toHaveBeenCalled());\n    await waitFor(() => expect(toastErrorMock).toHaveBeenCalled());\n    const [message] = toastErrorMock.mock.calls.at(-1) ?? [];\n    expect(message).toBe(\"保存失败\");\n\n    const addButton = screen.getByText(\"common.add\") as HTMLButtonElement;\n    expect(addButton.disabled).toBe(false);\n  });\n});\n"
  },
  {
    "path": "tests/components/OmoFormFields.mergeCustomModelsIntoStore.test.ts",
    "content": "import { describe, expect, it } from \"vitest\";\nimport {\n  mergeCustomModelsIntoStore,\n  type CustomModelItem,\n} from \"@/components/providers/forms/OmoFormFields\";\n\ndescribe(\"mergeCustomModelsIntoStore\", () => {\n  it(\"保留自定义项高级字段，并在模型变更时仅按需清理非法 variant\", () => {\n    const store = {\n      sisyphus: { model: \"builtin-model\" },\n      \"custom-agent\": {\n        model: \"model-a\",\n        variant: \"fast\",\n        temperature: 0.2,\n        permission: { edit: \"allow\" },\n      },\n    };\n    const customs: CustomModelItem[] = [\n      { key: \"custom-agent\", model: \"model-b\", sourceKey: \"custom-agent\" },\n    ];\n\n    const merged = mergeCustomModelsIntoStore(\n      store,\n      new Set([\"sisyphus\"]),\n      customs,\n      { \"model-b\": [\"precise\"] },\n    );\n\n    expect(merged.sisyphus).toEqual({ model: \"builtin-model\" });\n    expect(merged[\"custom-agent\"]).toEqual({\n      model: \"model-b\",\n      temperature: 0.2,\n      permission: { edit: \"allow\" },\n    });\n  });\n\n  it(\"重命名自定义 key 时迁移原有 variant 和高级字段\", () => {\n    const store = {\n      sisyphus: { model: \"builtin-model\" },\n      \"custom-agent-old\": {\n        model: \"model-a\",\n        variant: \"fast\",\n        maxTokens: 8192,\n      },\n    };\n    const customs: CustomModelItem[] = [\n      {\n        key: \"custom-agent-new\",\n        sourceKey: \"custom-agent-old\",\n        model: \"model-a\",\n      },\n    ];\n\n    const merged = mergeCustomModelsIntoStore(\n      store,\n      new Set([\"sisyphus\"]),\n      customs,\n      { \"model-a\": [\"fast\", \"balanced\"] },\n    );\n\n    expect(merged[\"custom-agent-old\"]).toBeUndefined();\n    expect(merged[\"custom-agent-new\"]).toEqual({\n      model: \"model-a\",\n      variant: \"fast\",\n      maxTokens: 8192,\n    });\n  });\n\n  it(\"custom 列表为空时移除旧自定义项但保留内置项\", () => {\n    const store = {\n      sisyphus: { model: \"builtin-model\" },\n      hephaestus: { model: \"builtin-model-2\" },\n      \"custom-agent\": { model: \"model-a\", temperature: 0.3 },\n    };\n\n    const merged = mergeCustomModelsIntoStore(\n      store,\n      new Set([\"sisyphus\", \"hephaestus\"]),\n      [],\n      {},\n    );\n\n    expect(merged).toEqual({\n      sisyphus: { model: \"builtin-model\" },\n      hephaestus: { model: \"builtin-model-2\" },\n    });\n  });\n\n  it(\"清空 model 时保留高级字段并移除 model/variant\", () => {\n    const store = {\n      sisyphus: { model: \"builtin-model\" },\n      \"custom-agent\": {\n        model: \"model-a\",\n        variant: \"fast\",\n        temperature: 0.7,\n      },\n    };\n    const customs: CustomModelItem[] = [\n      { key: \"custom-agent\", model: \"\", sourceKey: \"custom-agent\" },\n    ];\n\n    const merged = mergeCustomModelsIntoStore(\n      store,\n      new Set([\"sisyphus\"]),\n      customs,\n      { \"model-a\": [\"fast\"] },\n    );\n\n    expect(merged[\"custom-agent\"]).toEqual({ temperature: 0.7 });\n  });\n});\n"
  },
  {
    "path": "tests/components/ProviderList.test.tsx",
    "content": "import { render, screen, fireEvent } from \"@testing-library/react\";\nimport { QueryClient, QueryClientProvider } from \"@tanstack/react-query\";\nimport { describe, it, expect, vi, beforeEach } from \"vitest\";\nimport type { ReactElement } from \"react\";\nimport type { Provider } from \"@/types\";\nimport { ProviderList } from \"@/components/providers/ProviderList\";\n\nconst useDragSortMock = vi.fn();\nconst useSortableMock = vi.fn();\nconst providerCardRenderSpy = vi.fn();\n\nvi.mock(\"@/hooks/useDragSort\", () => ({\n  useDragSort: (...args: unknown[]) => useDragSortMock(...args),\n}));\n\nvi.mock(\"@/components/providers/ProviderCard\", () => ({\n  ProviderCard: (props: any) => {\n    providerCardRenderSpy(props);\n    const {\n      provider,\n      onSwitch,\n      onEdit,\n      onDelete,\n      onDuplicate,\n      onConfigureUsage,\n    } = props;\n\n    return (\n      <div data-testid={`provider-card-${provider.id}`}>\n        <button\n          data-testid={`switch-${provider.id}`}\n          onClick={() => onSwitch(provider)}\n        >\n          switch\n        </button>\n        <button\n          data-testid={`edit-${provider.id}`}\n          onClick={() => onEdit(provider)}\n        >\n          edit\n        </button>\n        <button\n          data-testid={`duplicate-${provider.id}`}\n          onClick={() => onDuplicate(provider)}\n        >\n          duplicate\n        </button>\n        <button\n          data-testid={`usage-${provider.id}`}\n          onClick={() => onConfigureUsage(provider)}\n        >\n          usage\n        </button>\n        <button\n          data-testid={`delete-${provider.id}`}\n          onClick={() => onDelete(provider)}\n        >\n          delete\n        </button>\n        <span data-testid={`is-current-${provider.id}`}>\n          {props.isCurrent ? \"current\" : \"inactive\"}\n        </span>\n        <span data-testid={`drag-attr-${provider.id}`}>\n          {props.dragHandleProps?.attributes?.[\"data-dnd-id\"] ?? \"none\"}\n        </span>\n      </div>\n    );\n  },\n}));\n\nvi.mock(\"@/components/UsageFooter\", () => ({\n  default: () => <div data-testid=\"usage-footer\" />,\n}));\n\nvi.mock(\"@dnd-kit/sortable\", async () => {\n  const actual = await vi.importActual<any>(\"@dnd-kit/sortable\");\n\n  return {\n    ...actual,\n    useSortable: (...args: unknown[]) => useSortableMock(...args),\n  };\n});\n\n// Mock hooks that use QueryClient\nvi.mock(\"@/hooks/useStreamCheck\", () => ({\n  useStreamCheck: () => ({\n    checkProvider: vi.fn(),\n    isChecking: () => false,\n  }),\n}));\n\nvi.mock(\"@/lib/query/failover\", () => ({\n  useAutoFailoverEnabled: () => ({ data: false }),\n  useFailoverQueue: () => ({ data: [] }),\n  useAddToFailoverQueue: () => ({ mutate: vi.fn() }),\n  useRemoveFromFailoverQueue: () => ({ mutate: vi.fn() }),\n  useReorderFailoverQueue: () => ({ mutate: vi.fn() }),\n}));\n\nfunction createProvider(overrides: Partial<Provider> = {}): Provider {\n  return {\n    id: overrides.id ?? \"provider-1\",\n    name: overrides.name ?? \"Test Provider\",\n    settingsConfig: overrides.settingsConfig ?? {},\n    category: overrides.category,\n    createdAt: overrides.createdAt,\n    sortIndex: overrides.sortIndex,\n    meta: overrides.meta,\n    websiteUrl: overrides.websiteUrl,\n  };\n}\n\nfunction renderWithQueryClient(ui: ReactElement) {\n  const queryClient = new QueryClient({\n    defaultOptions: { queries: { retry: false } },\n  });\n\n  return render(\n    <QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,\n  );\n}\n\nbeforeEach(() => {\n  useDragSortMock.mockReset();\n  useSortableMock.mockReset();\n  providerCardRenderSpy.mockClear();\n\n  useSortableMock.mockImplementation(({ id }: { id: string }) => ({\n    setNodeRef: vi.fn(),\n    attributes: { \"data-dnd-id\": id },\n    listeners: { onPointerDown: vi.fn() },\n    transform: null,\n    transition: null,\n    isDragging: false,\n  }));\n\n  useDragSortMock.mockReturnValue({\n    sortedProviders: [],\n    sensors: [],\n    handleDragEnd: vi.fn(),\n  });\n});\n\ndescribe(\"ProviderList Component\", () => {\n  it(\"should render skeleton placeholders when loading\", () => {\n    const { container } = renderWithQueryClient(\n      <ProviderList\n        providers={{}}\n        currentProviderId=\"\"\n        appId=\"claude\"\n        onSwitch={vi.fn()}\n        onEdit={vi.fn()}\n        onDelete={vi.fn()}\n        onDuplicate={vi.fn()}\n        onOpenWebsite={vi.fn()}\n        isLoading\n      />,\n    );\n\n    const placeholders = container.querySelectorAll(\n      \".border-dashed.border-muted-foreground\\\\/40\",\n    );\n    expect(placeholders).toHaveLength(3);\n  });\n\n  it(\"should show empty state and trigger create callback when no providers exist\", () => {\n    const handleCreate = vi.fn();\n    useDragSortMock.mockReturnValueOnce({\n      sortedProviders: [],\n      sensors: [],\n      handleDragEnd: vi.fn(),\n    });\n\n    renderWithQueryClient(\n      <ProviderList\n        providers={{}}\n        currentProviderId=\"\"\n        appId=\"claude\"\n        onSwitch={vi.fn()}\n        onEdit={vi.fn()}\n        onDelete={vi.fn()}\n        onDuplicate={vi.fn()}\n        onOpenWebsite={vi.fn()}\n        onCreate={handleCreate}\n      />,\n    );\n\n    const addButton = screen.getByRole(\"button\", {\n      name: \"provider.addProvider\",\n    });\n    fireEvent.click(addButton);\n\n    expect(handleCreate).toHaveBeenCalledTimes(1);\n  });\n\n  it(\"should render in order returned by useDragSort and pass through action callbacks\", () => {\n    const providerA = createProvider({ id: \"a\", name: \"A\" });\n    const providerB = createProvider({ id: \"b\", name: \"B\" });\n\n    const handleSwitch = vi.fn();\n    const handleEdit = vi.fn();\n    const handleDelete = vi.fn();\n    const handleDuplicate = vi.fn();\n    const handleUsage = vi.fn();\n    const handleOpenWebsite = vi.fn();\n\n    useDragSortMock.mockReturnValue({\n      sortedProviders: [providerB, providerA],\n      sensors: [],\n      handleDragEnd: vi.fn(),\n    });\n\n    renderWithQueryClient(\n      <ProviderList\n        providers={{ a: providerA, b: providerB }}\n        currentProviderId=\"b\"\n        appId=\"claude\"\n        onSwitch={handleSwitch}\n        onEdit={handleEdit}\n        onDelete={handleDelete}\n        onDuplicate={handleDuplicate}\n        onConfigureUsage={handleUsage}\n        onOpenWebsite={handleOpenWebsite}\n      />,\n    );\n\n    // Verify sort order\n    expect(providerCardRenderSpy).toHaveBeenCalledTimes(2);\n    expect(providerCardRenderSpy.mock.calls[0][0].provider.id).toBe(\"b\");\n    expect(providerCardRenderSpy.mock.calls[1][0].provider.id).toBe(\"a\");\n\n    // Verify current provider marker\n    expect(providerCardRenderSpy.mock.calls[0][0].isCurrent).toBe(true);\n\n    // Drag attributes from useSortable\n    expect(\n      providerCardRenderSpy.mock.calls[0][0].dragHandleProps?.attributes[\n      \"data-dnd-id\"\n      ],\n    ).toBe(\"b\");\n    expect(\n      providerCardRenderSpy.mock.calls[1][0].dragHandleProps?.attributes[\n      \"data-dnd-id\"\n      ],\n    ).toBe(\"a\");\n\n    // Trigger action buttons\n    fireEvent.click(screen.getByTestId(\"switch-b\"));\n    fireEvent.click(screen.getByTestId(\"edit-b\"));\n    fireEvent.click(screen.getByTestId(\"duplicate-b\"));\n    fireEvent.click(screen.getByTestId(\"usage-b\"));\n    fireEvent.click(screen.getByTestId(\"delete-a\"));\n\n    expect(handleSwitch).toHaveBeenCalledWith(providerB);\n    expect(handleEdit).toHaveBeenCalledWith(providerB);\n    expect(handleDuplicate).toHaveBeenCalledWith(providerB);\n    expect(handleUsage).toHaveBeenCalledWith(providerB);\n    expect(handleDelete).toHaveBeenCalledWith(providerA);\n\n    // Verify useDragSort call parameters\n    expect(useDragSortMock).toHaveBeenCalledWith(\n      { a: providerA, b: providerB },\n      \"claude\",\n    );\n  });\n\n  it(\"filters providers with the search input\", () => {\n    const providerAlpha = createProvider({ id: \"alpha\", name: \"Alpha Labs\" });\n    const providerBeta = createProvider({ id: \"beta\", name: \"Beta Works\" });\n\n    useDragSortMock.mockReturnValue({\n      sortedProviders: [providerAlpha, providerBeta],\n      sensors: [],\n      handleDragEnd: vi.fn(),\n    });\n\n    renderWithQueryClient(\n      <ProviderList\n        providers={{ alpha: providerAlpha, beta: providerBeta }}\n        currentProviderId=\"\"\n        appId=\"claude\"\n        onSwitch={vi.fn()}\n        onEdit={vi.fn()}\n        onDelete={vi.fn()}\n        onDuplicate={vi.fn()}\n        onOpenWebsite={vi.fn()}\n      />,\n    );\n\n    fireEvent.keyDown(window, { key: \"f\", metaKey: true });\n    const searchInput = screen.getByPlaceholderText(\n      \"Search name, notes, or URL...\",\n    );\n    // Initially both providers are rendered\n    expect(screen.getByTestId(\"provider-card-alpha\")).toBeInTheDocument();\n    expect(screen.getByTestId(\"provider-card-beta\")).toBeInTheDocument();\n\n    fireEvent.change(searchInput, { target: { value: \"beta\" } });\n    expect(screen.queryByTestId(\"provider-card-alpha\")).not.toBeInTheDocument();\n    expect(screen.getByTestId(\"provider-card-beta\")).toBeInTheDocument();\n\n    fireEvent.change(searchInput, { target: { value: \"gamma\" } });\n    expect(screen.queryByTestId(\"provider-card-alpha\")).not.toBeInTheDocument();\n    expect(screen.queryByTestId(\"provider-card-beta\")).not.toBeInTheDocument();\n    expect(\n      screen.getByText(\"No providers match your search.\"),\n    ).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "tests/components/SessionManagerPage.test.tsx",
    "content": "import { QueryClient, QueryClientProvider } from \"@tanstack/react-query\";\nimport {\n  fireEvent,\n  render,\n  screen,\n  waitFor,\n  within,\n} from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\nimport { SessionManagerPage } from \"@/components/sessions/SessionManagerPage\";\nimport type { SessionMessage, SessionMeta } from \"@/types\";\nimport { setSessionFixtures } from \"../msw/state\";\n\nconst toastSuccessMock = vi.fn();\nconst toastErrorMock = vi.fn();\n\nvi.mock(\"sonner\", () => ({\n  toast: {\n    success: (...args: unknown[]) => toastSuccessMock(...args),\n    error: (...args: unknown[]) => toastErrorMock(...args),\n  },\n}));\n\nvi.mock(\"@/components/sessions/SessionToc\", () => ({\n  SessionTocSidebar: () => null,\n  SessionTocDialog: () => null,\n}));\n\nvi.mock(\"@/components/ConfirmDialog\", () => ({\n  ConfirmDialog: ({\n    isOpen,\n    title,\n    message,\n    confirmText,\n    cancelText,\n    onConfirm,\n    onCancel,\n  }: {\n    isOpen: boolean;\n    title: string;\n    message: string;\n    confirmText: string;\n    cancelText: string;\n    onConfirm: () => void;\n    onCancel: () => void;\n  }) =>\n    isOpen ? (\n      <div data-testid=\"confirm-dialog\">\n        <div>{title}</div>\n        <div>{message}</div>\n        <button onClick={onConfirm}>{confirmText}</button>\n        <button onClick={onCancel}>{cancelText}</button>\n      </div>\n    ) : null,\n}));\n\nconst renderPage = () => {\n  const client = new QueryClient({\n    defaultOptions: {\n      queries: { retry: false },\n      mutations: { retry: false },\n    },\n  });\n\n  return render(\n    <QueryClientProvider client={client}>\n      <SessionManagerPage appId=\"codex\" />\n    </QueryClientProvider>,\n  );\n};\n\nconst openSearch = () => {\n  const searchButton = Array.from(screen.getAllByRole(\"button\")).find((button) =>\n    button.querySelector(\".lucide-search\"),\n  );\n\n  if (!searchButton) {\n    throw new Error(\"Search button not found\");\n  }\n\n  fireEvent.click(searchButton);\n};\n\ndescribe(\"SessionManagerPage\", () => {\n  beforeEach(() => {\n    toastSuccessMock.mockReset();\n    toastErrorMock.mockReset();\n\n    const sessions: SessionMeta[] = [\n      {\n        providerId: \"codex\",\n        sessionId: \"codex-session-1\",\n        title: \"Alpha Session\",\n        summary: \"Alpha summary\",\n        projectDir: \"/mock/codex\",\n        createdAt: 2,\n        lastActiveAt: 20,\n        sourcePath: \"/mock/codex/session-1.jsonl\",\n        resumeCommand: \"codex resume codex-session-1\",\n      },\n      {\n        providerId: \"codex\",\n        sessionId: \"codex-session-2\",\n        title: \"Beta Session\",\n        summary: \"Beta summary\",\n        projectDir: \"/mock/codex\",\n        createdAt: 1,\n        lastActiveAt: 10,\n        sourcePath: \"/mock/codex/session-2.jsonl\",\n        resumeCommand: \"codex resume codex-session-2\",\n      },\n    ];\n    const messages: Record<string, SessionMessage[]> = {\n      \"codex:/mock/codex/session-1.jsonl\": [\n        { role: \"user\", content: \"alpha\", ts: 20 },\n      ],\n      \"codex:/mock/codex/session-2.jsonl\": [\n        { role: \"user\", content: \"beta\", ts: 10 },\n      ],\n    };\n\n    setSessionFixtures(sessions, messages);\n  });\n\n  it(\"deletes the selected session and selects the next visible session\", async () => {\n    renderPage();\n\n    await waitFor(() =>\n      expect(\n        screen.getByRole(\"heading\", { name: \"Alpha Session\" }),\n      ).toBeInTheDocument(),\n    );\n\n    fireEvent.click(screen.getByRole(\"button\", { name: /删除会话/i }));\n\n    const dialog = screen.getByTestId(\"confirm-dialog\");\n    expect(dialog).toBeInTheDocument();\n    expect(within(dialog).getByText(/Alpha Session/)).toBeInTheDocument();\n\n    fireEvent.click(within(dialog).getByRole(\"button\", { name: /删除会话/i }));\n\n    await waitFor(() =>\n      expect(\n        screen.getByRole(\"heading\", { name: \"Beta Session\" }),\n      ).toBeInTheDocument(),\n    );\n\n    expect(screen.queryByText(\"Alpha Session\")).not.toBeInTheDocument();\n    expect(toastErrorMock).not.toHaveBeenCalled();\n    expect(toastSuccessMock).toHaveBeenCalled();\n  });\n\n  it(\"removes a deleted session from filtered search results\", async () => {\n    renderPage();\n\n    await waitFor(() =>\n      expect(\n        screen.getByRole(\"heading\", { name: \"Alpha Session\" }),\n      ).toBeInTheDocument(),\n    );\n\n    openSearch();\n\n    fireEvent.change(screen.getByRole(\"textbox\"), {\n      target: { value: \"Alpha\" },\n    });\n\n    await waitFor(() =>\n      expect(screen.getAllByText(\"Alpha Session\")).toHaveLength(2),\n    );\n\n    fireEvent.click(screen.getByRole(\"button\", { name: /删除会话/i }));\n\n    const dialog = screen.getByTestId(\"confirm-dialog\");\n    fireEvent.click(within(dialog).getByRole(\"button\", { name: /删除会话/i }));\n\n    await waitFor(() =>\n      expect(screen.queryByText(\"Alpha Session\")).not.toBeInTheDocument(),\n    );\n\n    expect(screen.getByText(\"sessionManager.selectSession\")).toBeInTheDocument();\n    expect(\n      screen.queryByText(\"sessionManager.emptySession\"),\n    ).not.toBeInTheDocument();\n    expect(toastErrorMock).not.toHaveBeenCalled();\n    expect(toastSuccessMock).toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "tests/components/SettingsDialog.test.tsx",
    "content": "import { render, screen, fireEvent, waitFor } from \"@testing-library/react\";\nimport { describe, it, expect, vi, beforeEach, afterEach } from \"vitest\";\nimport \"@testing-library/jest-dom\";\nimport { createContext, useContext, type ComponentProps } from \"react\";\nimport { QueryClient, QueryClientProvider } from \"@tanstack/react-query\";\nimport { SettingsPage } from \"@/components/settings/SettingsPage\";\n\nconst toastSuccessMock = vi.fn();\nconst toastErrorMock = vi.fn();\n\nvi.mock(\"sonner\", () => ({\n  toast: {\n    success: (...args: unknown[]) => toastSuccessMock(...args),\n    error: (...args: unknown[]) => toastErrorMock(...args),\n  },\n}));\n\nconst tMock = vi.fn((key: string) => key);\nvi.mock(\"react-i18next\", () => ({\n  useTranslation: () => ({ t: tMock }),\n}));\n\nvi.mock(\"@/hooks/useProxyStatus\", () => ({\n  useProxyStatus: () => ({\n    status: null,\n    isLoading: false,\n    isRunning: false,\n    isTakeoverActive: false,\n    startWithTakeover: vi.fn(),\n    stopWithRestore: vi.fn(),\n    switchProxyProvider: vi.fn(),\n    checkRunning: vi.fn(),\n    checkTakeoverActive: vi.fn(),\n    isStarting: false,\n    isStopping: false,\n    isPending: false,\n  }),\n}));\n\ninterface SettingsMock {\n  settings: any;\n  isLoading: boolean;\n  isSaving: boolean;\n  isPortable: boolean;\n  appConfigDir?: string;\n  resolvedDirs: Record<string, string>;\n  requiresRestart: boolean;\n  updateSettings: ReturnType<typeof vi.fn>;\n  updateDirectory: ReturnType<typeof vi.fn>;\n  updateAppConfigDir: ReturnType<typeof vi.fn>;\n  browseDirectory: ReturnType<typeof vi.fn>;\n  browseAppConfigDir: ReturnType<typeof vi.fn>;\n  resetDirectory: ReturnType<typeof vi.fn>;\n  resetAppConfigDir: ReturnType<typeof vi.fn>;\n  saveSettings: ReturnType<typeof vi.fn>;\n  autoSaveSettings: ReturnType<typeof vi.fn>;\n  resetSettings: ReturnType<typeof vi.fn>;\n  acknowledgeRestart: ReturnType<typeof vi.fn>;\n}\n\nconst createSettingsMock = (overrides: Partial<SettingsMock> = {}) => {\n  const base: SettingsMock = {\n    settings: {\n      showInTray: true,\n      minimizeToTrayOnClose: true,\n      enableClaudePluginIntegration: false,\n      language: \"zh\",\n      claudeConfigDir: \"/claude\",\n      codexConfigDir: \"/codex\",\n    },\n    isLoading: false,\n    isSaving: false,\n    isPortable: false,\n    appConfigDir: \"/app-config\",\n    resolvedDirs: {\n      claude: \"/claude\",\n      codex: \"/codex\",\n    },\n    requiresRestart: false,\n    updateSettings: vi.fn(),\n    updateDirectory: vi.fn(),\n    updateAppConfigDir: vi.fn(),\n    browseDirectory: vi.fn(),\n    browseAppConfigDir: vi.fn(),\n    resetDirectory: vi.fn(),\n    resetAppConfigDir: vi.fn(),\n    saveSettings: vi.fn().mockResolvedValue({ requiresRestart: false }),\n    autoSaveSettings: vi.fn().mockResolvedValue({ requiresRestart: false }),\n    resetSettings: vi.fn(),\n    acknowledgeRestart: vi.fn(),\n  };\n\n  return { ...base, ...overrides };\n};\n\ninterface ImportExportMock {\n  selectedFile: string;\n  status: string;\n  errorMessage: string | null;\n  backupId: string | null;\n  isImporting: boolean;\n  selectImportFile: ReturnType<typeof vi.fn>;\n  importConfig: ReturnType<typeof vi.fn>;\n  exportConfig: ReturnType<typeof vi.fn>;\n  clearSelection: ReturnType<typeof vi.fn>;\n  resetStatus: ReturnType<typeof vi.fn>;\n}\n\nconst createImportExportMock = (overrides: Partial<ImportExportMock> = {}) => {\n  const base: ImportExportMock = {\n    selectedFile: \"\",\n    status: \"idle\",\n    errorMessage: null,\n    backupId: null,\n    isImporting: false,\n    selectImportFile: vi.fn(),\n    importConfig: vi.fn(),\n    exportConfig: vi.fn(),\n    clearSelection: vi.fn(),\n    resetStatus: vi.fn(),\n  };\n\n  return { ...base, ...overrides };\n};\n\nlet settingsMock = createSettingsMock();\nlet importExportMock = createImportExportMock();\nconst useImportExportSpy = vi.fn();\nlet lastUseImportExportOptions: Record<string, unknown> | undefined;\n\nvi.mock(\"@/hooks/useSettings\", () => ({\n  useSettings: () => settingsMock,\n}));\n\nvi.mock(\"@/hooks/useImportExport\", () => ({\n  useImportExport: (options?: Record<string, unknown>) =>\n    useImportExportSpy(options),\n}));\n\nvi.mock(\"@/lib/api\", () => ({\n  settingsApi: {\n    restart: vi.fn().mockResolvedValue(true),\n  },\n}));\n\nconst TabsContext = createContext<{\n  value: string;\n  onValueChange?: (value: string) => void;\n}>({\n  value: \"general\",\n});\n\nvi.mock(\"@/components/ui/dialog\", () => ({\n  Dialog: ({ open, children }: any) =>\n    open ? <div data-testid=\"dialog-root\">{children}</div> : null,\n  DialogContent: ({ children }: any) => <div>{children}</div>,\n  DialogHeader: ({ children }: any) => <div>{children}</div>,\n  DialogFooter: ({ children }: any) => <div>{children}</div>,\n  DialogTitle: ({ children }: any) => <h2>{children}</h2>,\n  DialogDescription: ({ children }: any) => <div>{children}</div>,\n}));\n\nvi.mock(\"@/components/ui/tabs\", () => {\n  return {\n    Tabs: ({ value, onValueChange, children }: any) => (\n      <TabsContext.Provider value={{ value, onValueChange }}>\n        <div data-testid=\"tabs\">{children}</div>\n      </TabsContext.Provider>\n    ),\n    TabsList: ({ children }: any) => <div>{children}</div>,\n    TabsTrigger: ({ value, children }: any) => {\n      const ctx = useContext(TabsContext);\n      return (\n        <button type=\"button\" onClick={() => ctx.onValueChange?.(value)}>\n          {children}\n        </button>\n      );\n    },\n    TabsContent: ({ value, children }: any) => {\n      const ctx = useContext(TabsContext);\n      if (ctx.value !== value) return null;\n      return <div data-testid={`tab-${value}`}>{children}</div>;\n    },\n  };\n});\n\nvi.mock(\"@/components/settings/LanguageSettings\", () => ({\n  LanguageSettings: ({ value, onChange }: any) => (\n    <div>\n      <span>language:{value}</span>\n      <button onClick={() => onChange(\"en\")}>change-language</button>\n    </div>\n  ),\n}));\n\nvi.mock(\"@/components/settings/ThemeSettings\", () => ({\n  ThemeSettings: () => <div>theme-settings</div>,\n}));\n\nvi.mock(\"@/components/settings/WindowSettings\", () => ({\n  WindowSettings: ({ onChange }: any) => (\n    <button onClick={() => onChange({ minimizeToTrayOnClose: false })}>\n      window-settings\n    </button>\n  ),\n}));\n\nvi.mock(\"@/components/settings/DirectorySettings\", () => ({\n  DirectorySettings: ({\n    onBrowseDirectory,\n    onResetDirectory,\n    onDirectoryChange,\n    onBrowseAppConfig,\n    onResetAppConfig,\n    onAppConfigChange,\n  }: any) => (\n    <div>\n      <button onClick={() => onBrowseDirectory(\"claude\")}>\n        browse-directory\n      </button>\n      <button onClick={() => onResetDirectory(\"claude\")}>\n        reset-directory\n      </button>\n      <button onClick={() => onDirectoryChange(\"codex\", \"/new/path\")}>\n        change-directory\n      </button>\n      <button onClick={() => onBrowseAppConfig()}>browse-app-config</button>\n      <button onClick={() => onResetAppConfig()}>reset-app-config</button>\n      <button onClick={() => onAppConfigChange(\"/app/new\")}>\n        change-app-config\n      </button>\n    </div>\n  ),\n}));\n\nvi.mock(\"@/components/settings/AboutSection\", () => ({\n  AboutSection: ({ isPortable }: any) => <div>about:{String(isPortable)}</div>,\n}));\n\nvi.mock(\"@/components/settings/WebdavSyncSection\", () => ({\n  WebdavSyncSection: ({ config }: any) => (\n    <div>webdav-sync-section:{config?.baseUrl ?? \"none\"}</div>\n  ),\n}));\n\nlet settingsApi: any;\n\nconst renderSettingsPage = (\n  props?: Partial<ComponentProps<typeof SettingsPage>>,\n) => {\n  const client = new QueryClient({\n    defaultOptions: {\n      queries: { retry: false },\n    },\n  });\n  return render(\n    <QueryClientProvider client={client}>\n      <SettingsPage open={true} onOpenChange={vi.fn()} {...props} />\n    </QueryClientProvider>,\n  );\n};\n\ndescribe(\"SettingsPage Component\", () => {\n  beforeEach(async () => {\n    tMock.mockImplementation((key: string) => key);\n    settingsMock = createSettingsMock();\n    importExportMock = createImportExportMock();\n    useImportExportSpy.mockReset();\n    useImportExportSpy.mockImplementation(\n      (options?: Record<string, unknown>) => {\n        lastUseImportExportOptions = options;\n        return importExportMock;\n      },\n    );\n    lastUseImportExportOptions = undefined;\n    toastSuccessMock.mockReset();\n    toastErrorMock.mockReset();\n    settingsApi = (await import(\"@/lib/api\")).settingsApi;\n    settingsApi.restart.mockClear();\n  });\n\n  afterEach(() => {\n    vi.unstubAllGlobals();\n  });\n\n  it(\"should not render form content when loading\", () => {\n    settingsMock = createSettingsMock({ settings: null, isLoading: true });\n\n    renderSettingsPage();\n\n    expect(screen.queryByText(\"language:zh\")).not.toBeInTheDocument();\n    // 加载状态下显示 spinner 而不是表单内容\n    expect(document.querySelector(\".animate-spin\")).toBeInTheDocument();\n  });\n\n  it(\"should reset import/export status when dialog transitions to open\", () => {\n    const client = new QueryClient({\n      defaultOptions: {\n        queries: { retry: false },\n      },\n    });\n    const { rerender } = render(\n      <QueryClientProvider client={client}>\n        <SettingsPage open={false} onOpenChange={vi.fn()} />\n      </QueryClientProvider>,\n    );\n\n    importExportMock.resetStatus.mockClear();\n\n    rerender(\n      <QueryClientProvider client={client}>\n        <SettingsPage open={true} onOpenChange={vi.fn()} />\n      </QueryClientProvider>,\n    );\n\n    expect(importExportMock.resetStatus).toHaveBeenCalledTimes(1);\n  });\n\n  it(\"should render general and advanced tabs and trigger child callbacks\", () => {\n    const onOpenChange = vi.fn();\n    // 设置 selectedFile 后，按钮显示 settings.import（可执行导入）\n    importExportMock = createImportExportMock({\n      selectedFile: \"/tmp/config.json\",\n    });\n\n    renderSettingsPage({ onOpenChange });\n\n    expect(screen.getByText(\"language:zh\")).toBeInTheDocument();\n    expect(screen.getByText(\"theme-settings\")).toBeInTheDocument();\n\n    fireEvent.click(screen.getByText(\"change-language\"));\n    expect(settingsMock.updateSettings).toHaveBeenCalledWith({\n      language: \"en\",\n    });\n\n    fireEvent.click(screen.getByText(\"window-settings\"));\n    expect(settingsMock.updateSettings).toHaveBeenCalledWith({\n      minimizeToTrayOnClose: false,\n    });\n\n    fireEvent.click(screen.getByText(\"settings.tabAdvanced\"));\n    fireEvent.click(screen.getByText(\"settings.advanced.cloudSync.title\"));\n    expect(screen.getByText(\"webdav-sync-section:none\")).toBeInTheDocument();\n    fireEvent.click(screen.getByText(\"settings.advanced.data.title\"));\n\n    // 有文件时，点击导入按钮执行 importConfig\n    fireEvent.click(\n      screen.getByRole(\"button\", { name: /settings\\.import/ }),\n    );\n    expect(importExportMock.importConfig).toHaveBeenCalled();\n\n    fireEvent.click(\n      screen.getByRole(\"button\", { name: \"settings.exportConfig\" }),\n    );\n    expect(importExportMock.exportConfig).toHaveBeenCalled();\n\n    // 清除选择按钮\n    fireEvent.click(screen.getByRole(\"button\", { name: \"common.clear\" }));\n    expect(importExportMock.clearSelection).toHaveBeenCalled();\n  });\n\n  it(\"should pass onImportSuccess callback to useImportExport hook\", async () => {\n    const onImportSuccess = vi.fn();\n\n    renderSettingsPage({ onImportSuccess });\n\n    expect(useImportExportSpy).toHaveBeenCalledWith(\n      expect.objectContaining({ onImportSuccess }),\n    );\n    expect(lastUseImportExportOptions?.onImportSuccess).toBe(onImportSuccess);\n\n    if (typeof lastUseImportExportOptions?.onImportSuccess === \"function\") {\n      await lastUseImportExportOptions.onImportSuccess();\n    }\n    expect(onImportSuccess).toHaveBeenCalledTimes(1);\n  });\n\n  it(\"should call saveSettings and close dialog when clicking save\", async () => {\n    const onOpenChange = vi.fn();\n    importExportMock = createImportExportMock();\n\n    renderSettingsPage({ onOpenChange });\n\n    // 保存按钮在 advanced tab 中\n    fireEvent.click(screen.getByText(\"settings.tabAdvanced\"));\n    fireEvent.click(screen.getByRole(\"button\", { name: /common\\.save/ }));\n\n    await waitFor(() => {\n      expect(settingsMock.saveSettings).toHaveBeenCalledTimes(1);\n      expect(importExportMock.clearSelection).toHaveBeenCalledTimes(1);\n      expect(importExportMock.resetStatus).toHaveBeenCalledTimes(2);\n      expect(settingsMock.acknowledgeRestart).toHaveBeenCalledTimes(1);\n      expect(onOpenChange).toHaveBeenCalledWith(false);\n    });\n  });\n\n  it(\"should show restart prompt and allow immediate restart after save\", async () => {\n    settingsMock = createSettingsMock({\n      requiresRestart: true,\n      saveSettings: vi.fn().mockResolvedValue({ requiresRestart: true }),\n    });\n\n    renderSettingsPage();\n\n    expect(\n      await screen.findByText(\"settings.restartRequired\"),\n    ).toBeInTheDocument();\n\n    fireEvent.click(screen.getByText(\"settings.restartNow\"));\n\n    await waitFor(() => {\n      expect(toastSuccessMock).toHaveBeenCalledWith(\n        \"settings.devModeRestartHint\",\n        expect.objectContaining({ closeButton: true }),\n      );\n    });\n  });\n\n  it(\"should allow postponing restart and close dialog without restarting\", async () => {\n    const onOpenChange = vi.fn();\n    settingsMock = createSettingsMock({ requiresRestart: true });\n\n    renderSettingsPage({ onOpenChange });\n\n    expect(\n      await screen.findByText(\"settings.restartRequired\"),\n    ).toBeInTheDocument();\n\n    fireEvent.click(screen.getByText(\"settings.restartLater\"));\n\n    await waitFor(() => {\n      expect(onOpenChange).toHaveBeenCalledWith(false);\n      expect(settingsMock.acknowledgeRestart).toHaveBeenCalledTimes(1);\n    });\n\n    expect(settingsApi.restart).not.toHaveBeenCalled();\n    expect(toastSuccessMock).not.toHaveBeenCalled();\n    expect(toastErrorMock).not.toHaveBeenCalled();\n  });\n\n  it(\"should trigger directory management callbacks inside advanced tab\", () => {\n    renderSettingsPage();\n\n    fireEvent.click(screen.getByText(\"settings.tabAdvanced\"));\n    fireEvent.click(screen.getByText(\"settings.advanced.configDir.title\"));\n\n    fireEvent.click(screen.getByText(\"browse-directory\"));\n    expect(settingsMock.browseDirectory).toHaveBeenCalledWith(\"claude\");\n\n    fireEvent.click(screen.getByText(\"reset-directory\"));\n    expect(settingsMock.resetDirectory).toHaveBeenCalledWith(\"claude\");\n\n    fireEvent.click(screen.getByText(\"change-directory\"));\n    expect(settingsMock.updateDirectory).toHaveBeenCalledWith(\n      \"codex\",\n      \"/new/path\",\n    );\n\n    fireEvent.click(screen.getByText(\"browse-app-config\"));\n    expect(settingsMock.browseAppConfigDir).toHaveBeenCalledTimes(1);\n\n    fireEvent.click(screen.getByText(\"reset-app-config\"));\n    expect(settingsMock.resetAppConfigDir).toHaveBeenCalledTimes(1);\n\n    fireEvent.click(screen.getByText(\"change-app-config\"));\n    expect(settingsMock.updateAppConfigDir).toHaveBeenCalledWith(\"/app/new\");\n  });\n});\n"
  },
  {
    "path": "tests/components/UnifiedSkillsPanel.test.tsx",
    "content": "import { createRef } from \"react\";\nimport { render, screen, waitFor, act } from \"@testing-library/react\";\nimport { describe, expect, it, vi, beforeEach } from \"vitest\";\n\nimport UnifiedSkillsPanel, {\n  type UnifiedSkillsPanelHandle,\n} from \"@/components/skills/UnifiedSkillsPanel\";\n\nconst scanUnmanagedMock = vi.fn();\nconst toggleSkillAppMock = vi.fn();\nconst uninstallSkillMock = vi.fn();\nconst importSkillsMock = vi.fn();\nconst installFromZipMock = vi.fn();\n\nvi.mock(\"sonner\", () => ({\n  toast: {\n    success: vi.fn(),\n    error: vi.fn(),\n    info: vi.fn(),\n  },\n}));\n\nvi.mock(\"@/hooks/useSkills\", () => ({\n  useInstalledSkills: () => ({\n    data: [],\n    isLoading: false,\n  }),\n  useToggleSkillApp: () => ({\n    mutateAsync: toggleSkillAppMock,\n  }),\n  useUninstallSkill: () => ({\n    mutateAsync: uninstallSkillMock,\n  }),\n  useScanUnmanagedSkills: () => ({\n    data: [\n      {\n        directory: \"shared-skill\",\n        name: \"Shared Skill\",\n        description: \"Imported from Claude\",\n        foundIn: [\"claude\"],\n        path: \"/tmp/shared-skill\",\n      },\n    ],\n    refetch: scanUnmanagedMock,\n  }),\n  useImportSkillsFromApps: () => ({\n    mutateAsync: importSkillsMock,\n  }),\n  useInstallSkillsFromZip: () => ({\n    mutateAsync: installFromZipMock,\n  }),\n}));\n\ndescribe(\"UnifiedSkillsPanel\", () => {\n  beforeEach(() => {\n    scanUnmanagedMock.mockResolvedValue({\n      data: [\n        {\n          directory: \"shared-skill\",\n          name: \"Shared Skill\",\n          description: \"Imported from Claude\",\n          foundIn: [\"claude\"],\n          path: \"/tmp/shared-skill\",\n        },\n      ],\n    });\n    toggleSkillAppMock.mockReset();\n    uninstallSkillMock.mockReset();\n    importSkillsMock.mockReset();\n    installFromZipMock.mockReset();\n  });\n\n  it(\"opens the import dialog without crashing when app toggles render\", async () => {\n    const ref = createRef<UnifiedSkillsPanelHandle>();\n\n    render(\n      <UnifiedSkillsPanel\n        ref={ref}\n        onOpenDiscovery={() => {}}\n        currentApp=\"claude\"\n      />,\n    );\n\n    await act(async () => {\n      await ref.current?.openImport();\n    });\n\n    await waitFor(() => {\n      expect(screen.getByText(\"skills.import\")).toBeInTheDocument();\n      expect(screen.getByText(\"Shared Skill\")).toBeInTheDocument();\n      expect(screen.getByText(\"/tmp/shared-skill\")).toBeInTheDocument();\n    });\n  });\n});\n"
  },
  {
    "path": "tests/components/WebdavSyncSection.test.tsx",
    "content": "import { render, screen, fireEvent, waitFor } from \"@testing-library/react\";\nimport { describe, it, expect, vi, beforeEach } from \"vitest\";\nimport \"@testing-library/jest-dom\";\nimport { QueryClient, QueryClientProvider } from \"@tanstack/react-query\";\n\nimport { WebdavSyncSection } from \"@/components/settings/WebdavSyncSection\";\nimport type { WebDavSyncSettings } from \"@/types\";\n\nconst toastSuccessMock = vi.fn();\nconst toastErrorMock = vi.fn();\nconst toastWarningMock = vi.fn();\nconst toastInfoMock = vi.fn();\n\nvi.mock(\"sonner\", () => ({\n  toast: {\n    success: (...args: unknown[]) => toastSuccessMock(...args),\n    error: (...args: unknown[]) => toastErrorMock(...args),\n    warning: (...args: unknown[]) => toastWarningMock(...args),\n    info: (...args: unknown[]) => toastInfoMock(...args),\n  },\n}));\n\nvi.mock(\"react-i18next\", () => ({\n  useTranslation: () => ({\n    t: (key: string) => key,\n  }),\n}));\n\nvi.mock(\"@/components/ui/button\", () => ({\n  Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,\n}));\n\nvi.mock(\"@/components/ui/input\", () => ({\n  Input: (props: any) => <input {...props} />,\n}));\n\nvi.mock(\"@/components/ui/switch\", () => ({\n  Switch: ({ checked, onCheckedChange, ...props }: any) => (\n    <button\n      role=\"switch\"\n      aria-checked={checked}\n      onClick={() => onCheckedChange?.(!checked)}\n      {...props}\n    />\n  ),\n}));\n\nvi.mock(\"@/components/ui/select\", () => ({\n  Select: ({ value, onValueChange, children }: any) => (\n    <select\n      data-testid=\"webdav-preset-select\"\n      value={value}\n      onChange={(event) => onValueChange?.(event.target.value)}\n    >\n      {children}\n    </select>\n  ),\n  SelectTrigger: ({ children }: any) => <>{children}</>,\n  SelectValue: () => null,\n  SelectContent: ({ children }: any) => <>{children}</>,\n  SelectItem: ({ value, children }: any) => (\n    <option value={value}>{children}</option>\n  ),\n}));\n\nvi.mock(\"@/components/ui/dialog\", () => ({\n  Dialog: ({ open, children }: any) => (open ? <div>{children}</div> : null),\n  DialogContent: ({ children }: any) => <div>{children}</div>,\n  DialogDescription: ({ children }: any) => <div>{children}</div>,\n  DialogFooter: ({ children }: any) => <div>{children}</div>,\n  DialogHeader: ({ children }: any) => <div>{children}</div>,\n  DialogTitle: ({ children }: any) => <h2>{children}</h2>,\n}));\n\nconst { settingsApiMock } = vi.hoisted(() => ({\n  settingsApiMock: {\n    webdavTestConnection: vi.fn(),\n    webdavSyncSaveSettings: vi.fn(),\n    webdavSyncFetchRemoteInfo: vi.fn(),\n    webdavSyncUpload: vi.fn(),\n    webdavSyncDownload: vi.fn(),\n  },\n}));\n\nvi.mock(\"@/lib/api\", () => ({\n  settingsApi: settingsApiMock,\n}));\n\nconst baseConfig: WebDavSyncSettings = {\n  enabled: true,\n  baseUrl: \"https://dav.example.com/dav/\",\n  username: \"alice\",\n  password: \"secret\",\n  remoteRoot: \"cc-switch-sync\",\n  profile: \"default\",\n  autoSync: false,\n  status: {},\n};\n\nfunction renderSection(config?: WebDavSyncSettings) {\n  const client = new QueryClient({\n    defaultOptions: {\n      queries: { retry: false },\n      mutations: { retry: false },\n    },\n  });\n  return render(\n    <QueryClientProvider client={client}>\n      <WebdavSyncSection config={config} />\n    </QueryClientProvider>,\n  );\n}\n\ndescribe(\"WebdavSyncSection\", () => {\n  beforeEach(() => {\n    toastSuccessMock.mockReset();\n    toastErrorMock.mockReset();\n    toastWarningMock.mockReset();\n    toastInfoMock.mockReset();\n    settingsApiMock.webdavTestConnection.mockReset();\n    settingsApiMock.webdavSyncSaveSettings.mockReset();\n    settingsApiMock.webdavSyncFetchRemoteInfo.mockReset();\n    settingsApiMock.webdavSyncUpload.mockReset();\n    settingsApiMock.webdavSyncDownload.mockReset();\n\n    settingsApiMock.webdavSyncSaveSettings.mockResolvedValue({ success: true });\n    settingsApiMock.webdavTestConnection.mockResolvedValue({\n      success: true,\n      message: \"ok\",\n    });\n    settingsApiMock.webdavSyncFetchRemoteInfo.mockResolvedValue({\n      deviceName: \"My MacBook\",\n      createdAt: \"2026-02-01T10:00:00Z\",\n      snapshotId: \"snapshot-1\",\n      version: 2,\n      compatible: true,\n      artifacts: [\"db.sql\", \"skills.zip\"],\n    });\n    settingsApiMock.webdavSyncUpload.mockResolvedValue({ status: \"uploaded\" });\n    settingsApiMock.webdavSyncDownload.mockResolvedValue({ status: \"downloaded\" });\n  });\n\n  it(\"shows auto sync error callout when last auto sync failed\", () => {\n    renderSection({\n      ...baseConfig,\n      status: {\n        lastError: \"network timeout\",\n        lastErrorSource: \"auto\",\n      },\n    });\n\n    expect(\n      screen.getByText(\"settings.webdavSync.autoSyncLastErrorTitle\"),\n    ).toBeInTheDocument();\n    expect(screen.getByText(\"network timeout\")).toBeInTheDocument();\n  });\n\n  it(\"does not show auto sync error callout for manual sync errors\", () => {\n    renderSection({\n      ...baseConfig,\n      status: {\n        lastError: \"manual upload failed\",\n        lastErrorSource: \"manual\",\n      },\n    });\n\n    expect(\n      screen.queryByText(\"settings.webdavSync.autoSyncLastErrorTitle\"),\n    ).not.toBeInTheDocument();\n  });\n\n  it(\"does not show auto sync error callout when source is missing\", () => {\n    renderSection({\n      ...baseConfig,\n      autoSync: true,\n      status: {\n        lastError: \"legacy error without source\",\n      },\n    });\n\n    expect(\n      screen.queryByText(\"settings.webdavSync.autoSyncLastErrorTitle\"),\n    ).not.toBeInTheDocument();\n  });\n\n  it(\"shows validation error when saving without base url\", async () => {\n    renderSection({ ...baseConfig, baseUrl: \"\" });\n\n    fireEvent.click(screen.getByRole(\"button\", { name: \"settings.webdavSync.save\" }));\n\n    expect(toastErrorMock).toHaveBeenCalledWith(\"settings.webdavSync.missingUrl\");\n    expect(settingsApiMock.webdavSyncSaveSettings).not.toHaveBeenCalled();\n  });\n\n  it(\"saves settings and auto tests connection\", async () => {\n    renderSection(baseConfig);\n\n    fireEvent.click(screen.getByRole(\"button\", { name: \"settings.webdavSync.save\" }));\n\n    await waitFor(() => {\n      expect(settingsApiMock.webdavSyncSaveSettings).toHaveBeenCalledTimes(1);\n    });\n    expect(settingsApiMock.webdavSyncSaveSettings).toHaveBeenCalledWith(\n      expect.objectContaining({\n        baseUrl: \"https://dav.example.com/dav/\",\n        username: \"alice\",\n        password: \"secret\",\n        autoSync: false,\n      }),\n      false,\n    );\n    await waitFor(() => {\n      expect(settingsApiMock.webdavTestConnection).toHaveBeenCalledWith(\n        expect.objectContaining({\n          baseUrl: \"https://dav.example.com/dav/\",\n        }),\n        true,\n      );\n    });\n    expect(toastSuccessMock).toHaveBeenCalledWith(\n      \"settings.webdavSync.saveAndTestSuccess\",\n    );\n  });\n\n  it(\"saves auto sync as true after toggle\", async () => {\n    renderSection(baseConfig);\n\n    fireEvent.click(\n      screen.getByRole(\"switch\", { name: \"settings.webdavSync.autoSync\" }),\n    );\n    fireEvent.click(\n      screen.getByRole(\"button\", { name: \"confirm.autoSync.confirm\" }),\n    );\n    await waitFor(() => {\n      expect(\n        screen.getByRole(\"switch\", { name: \"settings.webdavSync.autoSync\" }),\n      ).toHaveAttribute(\"aria-checked\", \"true\");\n    });\n    fireEvent.click(screen.getByRole(\"button\", { name: \"settings.webdavSync.save\" }));\n\n    await waitFor(() => {\n      expect(settingsApiMock.webdavSyncSaveSettings).toHaveBeenCalledWith(\n        expect.objectContaining({\n          autoSync: true,\n        }),\n        false,\n      );\n    });\n  });\n\n  it(\"blocks upload when there are unsaved changes\", async () => {\n    renderSection(baseConfig);\n\n    fireEvent.change(\n      screen.getByPlaceholderText(\"settings.webdavSync.usernamePlaceholder\"),\n      { target: { value: \"bob\" } },\n    );\n    fireEvent.click(\n      screen.getByRole(\"button\", { name: \"settings.webdavSync.upload\" }),\n    );\n\n    expect(toastErrorMock).toHaveBeenCalledWith(\n      \"settings.webdavSync.unsavedChanges\",\n    );\n    expect(settingsApiMock.webdavSyncFetchRemoteInfo).not.toHaveBeenCalled();\n  });\n\n  it(\"disables sync buttons until config is saved\", () => {\n    renderSection(undefined);\n\n    const uploadButton = screen.getByRole(\"button\", {\n      name: \"settings.webdavSync.upload\",\n    });\n    const downloadButton = screen.getByRole(\"button\", {\n      name: \"settings.webdavSync.download\",\n    });\n\n    expect(uploadButton).toBeDisabled();\n    expect(downloadButton).toBeDisabled();\n    expect(settingsApiMock.webdavSyncFetchRemoteInfo).not.toHaveBeenCalled();\n  });\n\n  it(\"fetches remote info and uploads after confirmation\", async () => {\n    renderSection(baseConfig);\n\n    fireEvent.click(\n      screen.getByRole(\"button\", { name: \"settings.webdavSync.upload\" }),\n    );\n\n    await waitFor(() => {\n      expect(settingsApiMock.webdavSyncFetchRemoteInfo).toHaveBeenCalledTimes(1);\n    });\n\n    fireEvent.click(\n      screen.getByRole(\"button\", {\n        name: \"settings.webdavSync.confirmUpload.confirm\",\n      }),\n    );\n\n    await waitFor(() => {\n      expect(settingsApiMock.webdavSyncUpload).toHaveBeenCalledTimes(1);\n    });\n    expect(toastSuccessMock).toHaveBeenCalledWith(\n      \"settings.webdavSync.uploadSuccess\",\n    );\n  });\n\n  it(\"blocks upload confirmation if form changes after dialog opens\", async () => {\n    renderSection(baseConfig);\n\n    fireEvent.click(\n      screen.getByRole(\"button\", { name: \"settings.webdavSync.upload\" }),\n    );\n    await waitFor(() => {\n      expect(settingsApiMock.webdavSyncFetchRemoteInfo).toHaveBeenCalledTimes(1);\n    });\n\n    fireEvent.change(screen.getByPlaceholderText(\"cc-switch-sync\"), {\n      target: { value: \"new-root\" },\n    });\n    fireEvent.click(\n      screen.getByRole(\"button\", {\n        name: \"settings.webdavSync.confirmUpload.confirm\",\n      }),\n    );\n\n    await waitFor(() => {\n      expect(toastErrorMock).toHaveBeenCalledWith(\n        \"settings.webdavSync.unsavedChanges\",\n      );\n    });\n    expect(settingsApiMock.webdavSyncUpload).not.toHaveBeenCalled();\n  });\n\n  it(\"fetches remote info and downloads after confirmation\", async () => {\n    renderSection(baseConfig);\n\n    fireEvent.click(\n      screen.getByRole(\"button\", { name: \"settings.webdavSync.download\" }),\n    );\n\n    await waitFor(() => {\n      expect(settingsApiMock.webdavSyncFetchRemoteInfo).toHaveBeenCalledTimes(1);\n    });\n\n    fireEvent.click(\n      screen.getByRole(\"button\", {\n        name: \"settings.webdavSync.confirmDownload.confirm\",\n      }),\n    );\n\n    await waitFor(() => {\n      expect(settingsApiMock.webdavSyncDownload).toHaveBeenCalledTimes(1);\n    });\n    expect(toastSuccessMock).toHaveBeenCalledWith(\n      \"settings.webdavSync.downloadSuccess\",\n    );\n  });\n\n  it(\"blocks download confirmation if form changes after dialog opens\", async () => {\n    renderSection(baseConfig);\n\n    fireEvent.click(\n      screen.getByRole(\"button\", { name: \"settings.webdavSync.download\" }),\n    );\n    await waitFor(() => {\n      expect(settingsApiMock.webdavSyncFetchRemoteInfo).toHaveBeenCalledTimes(1);\n    });\n\n    fireEvent.change(screen.getByPlaceholderText(\"default\"), {\n      target: { value: \"new-profile\" },\n    });\n    fireEvent.click(\n      screen.getByRole(\"button\", {\n        name: \"settings.webdavSync.confirmDownload.confirm\",\n      }),\n    );\n\n    await waitFor(() => {\n      expect(toastErrorMock).toHaveBeenCalledWith(\n        \"settings.webdavSync.unsavedChanges\",\n      );\n    });\n    expect(settingsApiMock.webdavSyncDownload).not.toHaveBeenCalled();\n  });\n\n  it(\"shows info when no remote snapshot is found for download\", async () => {\n    settingsApiMock.webdavSyncFetchRemoteInfo.mockResolvedValueOnce({ empty: true });\n    renderSection(baseConfig);\n\n    fireEvent.click(\n      screen.getByRole(\"button\", { name: \"settings.webdavSync.download\" }),\n    );\n\n    await waitFor(() => {\n      expect(toastInfoMock).toHaveBeenCalledWith(\"settings.webdavSync.noRemoteData\");\n    });\n    expect(settingsApiMock.webdavSyncDownload).not.toHaveBeenCalled();\n  });\n\n  it(\"blocks download when remote snapshot is incompatible\", async () => {\n    settingsApiMock.webdavSyncFetchRemoteInfo.mockResolvedValueOnce({\n      deviceName: \"Legacy Machine\",\n      createdAt: \"2025-01-01T00:00:00Z\",\n      snapshotId: \"legacy-snapshot\",\n      version: 1,\n      compatible: false,\n      artifacts: [\"db.sql\"],\n    });\n    renderSection(baseConfig);\n\n    fireEvent.click(\n      screen.getByRole(\"button\", { name: \"settings.webdavSync.download\" }),\n    );\n\n    await waitFor(() => {\n      expect(toastErrorMock).toHaveBeenCalledWith(\n        \"settings.webdavSync.incompatibleVersion\",\n      );\n    });\n    expect(settingsApiMock.webdavSyncDownload).not.toHaveBeenCalled();\n    expect(\n      screen.queryByRole(\"button\", {\n        name: \"settings.webdavSync.confirmDownload.confirm\",\n      }),\n    ).not.toBeInTheDocument();\n  });\n\n  it(\"shows error when download fails after confirmation\", async () => {\n    settingsApiMock.webdavSyncDownload.mockRejectedValueOnce(new Error(\"boom\"));\n    renderSection(baseConfig);\n\n    fireEvent.click(\n      screen.getByRole(\"button\", { name: \"settings.webdavSync.download\" }),\n    );\n    await waitFor(() => {\n      expect(settingsApiMock.webdavSyncFetchRemoteInfo).toHaveBeenCalledTimes(1);\n    });\n\n    fireEvent.click(\n      screen.getByRole(\"button\", {\n        name: \"settings.webdavSync.confirmDownload.confirm\",\n      }),\n    );\n\n    await waitFor(() => {\n      expect(toastErrorMock).toHaveBeenCalledWith(\n        \"settings.webdavSync.downloadFailed\",\n      );\n    });\n  });\n});\n"
  },
  {
    "path": "tests/components/openclaw.utils.test.ts",
    "content": "import { describe, expect, it } from \"vitest\";\nimport {\n  getOpenClawTimeoutInputValue,\n  getOpenClawToolsProfileSelectValue,\n  getOpenClawUnsupportedProfile,\n  OPENCLAW_UNSUPPORTED_PROFILE,\n  parseOpenClawEnvEditorValue,\n} from \"@/components/openclaw/utils\";\n\ndescribe(\"OpenClaw utils\", () => {\n  it(\"parses nested env objects without stringifying them\", () => {\n    const env = parseOpenClawEnvEditorValue(`{\n      \"API_KEY\": \"secret\",\n      \"vars\": { \"HTTP_PROXY\": \"http://127.0.0.1:8080\" },\n      \"shellEnv\": { \"NODE_OPTIONS\": \"--max-old-space-size=4096\" }\n    }`);\n\n    expect(env).toEqual({\n      API_KEY: \"secret\",\n      vars: { HTTP_PROXY: \"http://127.0.0.1:8080\" },\n      shellEnv: { NODE_OPTIONS: \"--max-old-space-size=4096\" },\n    });\n  });\n\n  it(\"rejects non-object env payloads\", () => {\n    expect(() => parseOpenClawEnvEditorValue(`[\"not\", \"an object\"]`)).toThrow(\n      \"OPENCLAW_ENV_OBJECT_REQUIRED\",\n    );\n  });\n\n  it(\"flags unsupported tools profiles without silently normalizing them\", () => {\n    expect(getOpenClawToolsProfileSelectValue(\"default\")).toBe(\n      OPENCLAW_UNSUPPORTED_PROFILE,\n    );\n    expect(getOpenClawUnsupportedProfile(\"default\")).toBe(\"default\");\n    expect(getOpenClawUnsupportedProfile(\"coding\")).toBeNull();\n  });\n\n  it(\"prefers timeoutSeconds and falls back to legacy timeout\", () => {\n    expect(\n      getOpenClawTimeoutInputValue({ timeoutSeconds: 120, timeout: 30 }),\n    ).toBe(\"120\");\n    expect(getOpenClawTimeoutInputValue({ timeout: 45 })).toBe(\"45\");\n    expect(getOpenClawTimeoutInputValue({})).toBe(\"\");\n  });\n});\n"
  },
  {
    "path": "tests/config/claudeProviderPresets.test.ts",
    "content": "import { describe, expect, it } from \"vitest\";\nimport { providerPresets } from \"@/config/claudeProviderPresets\";\n\ndescribe(\"AWS Bedrock Provider Presets\", () => {\n  const bedrockAksk = providerPresets.find(\n    (p) => p.name === \"AWS Bedrock (AKSK)\",\n  );\n\n  it(\"should include AWS Bedrock (AKSK) preset\", () => {\n    expect(bedrockAksk).toBeDefined();\n  });\n\n  it(\"AKSK preset should have required AWS env variables\", () => {\n    const env = (bedrockAksk!.settingsConfig as any).env;\n    expect(env).toHaveProperty(\"AWS_ACCESS_KEY_ID\");\n    expect(env).toHaveProperty(\"AWS_SECRET_ACCESS_KEY\");\n    expect(env).toHaveProperty(\"AWS_REGION\");\n    expect(env).toHaveProperty(\"CLAUDE_CODE_USE_BEDROCK\", \"1\");\n  });\n\n  it(\"AKSK preset should have template values for AWS credentials\", () => {\n    expect(bedrockAksk!.templateValues).toBeDefined();\n    expect(bedrockAksk!.templateValues!.AWS_ACCESS_KEY_ID).toBeDefined();\n    expect(bedrockAksk!.templateValues!.AWS_SECRET_ACCESS_KEY).toBeDefined();\n    expect(bedrockAksk!.templateValues!.AWS_REGION).toBeDefined();\n    expect(bedrockAksk!.templateValues!.AWS_REGION.editorValue).toBe(\n      \"us-west-2\",\n    );\n  });\n\n  it(\"AKSK preset should have correct base URL template\", () => {\n    const env = (bedrockAksk!.settingsConfig as any).env;\n    expect(env.ANTHROPIC_BASE_URL).toContain(\"bedrock-runtime\");\n    expect(env.ANTHROPIC_BASE_URL).toContain(\"${AWS_REGION}\");\n  });\n\n  it(\"AKSK preset should have cloud_provider category\", () => {\n    expect(bedrockAksk!.category).toBe(\"cloud_provider\");\n  });\n\n  it(\"AKSK preset should have Bedrock model as default\", () => {\n    const env = (bedrockAksk!.settingsConfig as any).env;\n    expect(env.ANTHROPIC_MODEL).toContain(\"anthropic.claude\");\n  });\n\n  const bedrockApiKey = providerPresets.find(\n    (p) => p.name === \"AWS Bedrock (API Key)\",\n  );\n\n  it(\"should include AWS Bedrock (API Key) preset\", () => {\n    expect(bedrockApiKey).toBeDefined();\n  });\n\n  it(\"API Key preset should have apiKey field and AWS env variables\", () => {\n    const config = bedrockApiKey!.settingsConfig as any;\n    expect(config).toHaveProperty(\"apiKey\", \"\");\n    expect(config.env).toHaveProperty(\"AWS_REGION\");\n    expect(config.env).toHaveProperty(\"CLAUDE_CODE_USE_BEDROCK\", \"1\");\n  });\n\n  it(\"API Key preset should NOT have AKSK env variables\", () => {\n    const env = (bedrockApiKey!.settingsConfig as any).env;\n    expect(env).not.toHaveProperty(\"AWS_ACCESS_KEY_ID\");\n    expect(env).not.toHaveProperty(\"AWS_SECRET_ACCESS_KEY\");\n  });\n\n  it(\"API Key preset should have template values for region only\", () => {\n    expect(bedrockApiKey!.templateValues).toBeDefined();\n    expect(bedrockApiKey!.templateValues!.AWS_REGION).toBeDefined();\n    expect(bedrockApiKey!.templateValues!.AWS_REGION.editorValue).toBe(\n      \"us-west-2\",\n    );\n  });\n\n  it(\"API Key preset should have cloud_provider category\", () => {\n    expect(bedrockApiKey!.category).toBe(\"cloud_provider\");\n  });\n});\n"
  },
  {
    "path": "tests/config/opencodeProviderPresets.test.ts",
    "content": "import { describe, expect, it } from \"vitest\";\nimport {\n  opencodeProviderPresets,\n  opencodeNpmPackages,\n  OPENCODE_PRESET_MODEL_VARIANTS,\n} from \"@/config/opencodeProviderPresets\";\n\ndescribe(\"AWS Bedrock OpenCode Provider Presets\", () => {\n  it(\"should include @ai-sdk/amazon-bedrock in npm packages\", () => {\n    const bedrockPkg = opencodeNpmPackages.find(\n      (p) => p.value === \"@ai-sdk/amazon-bedrock\",\n    );\n    expect(bedrockPkg).toBeDefined();\n    expect(bedrockPkg!.label).toBe(\"Amazon Bedrock\");\n  });\n\n  it(\"should include Bedrock model variants\", () => {\n    const variants = OPENCODE_PRESET_MODEL_VARIANTS[\"@ai-sdk/amazon-bedrock\"];\n    expect(variants).toBeDefined();\n    expect(variants.length).toBeGreaterThan(0);\n\n    const opusModel = variants.find((v) =>\n      v.id.includes(\"anthropic.claude-opus-4-6\"),\n    );\n    expect(opusModel).toBeDefined();\n  });\n\n  const bedrockPreset = opencodeProviderPresets.find(\n    (p) => p.name === \"AWS Bedrock\",\n  );\n\n  it(\"should include AWS Bedrock preset\", () => {\n    expect(bedrockPreset).toBeDefined();\n  });\n\n  it(\"Bedrock preset should use @ai-sdk/amazon-bedrock npm package\", () => {\n    expect(bedrockPreset!.settingsConfig.npm).toBe(\n      \"@ai-sdk/amazon-bedrock\",\n    );\n  });\n\n  it(\"Bedrock preset should have region in options\", () => {\n    expect(bedrockPreset!.settingsConfig.options).toHaveProperty(\"region\");\n  });\n\n  it(\"Bedrock preset should have cloud_provider category\", () => {\n    expect(bedrockPreset!.category).toBe(\"cloud_provider\");\n  });\n\n  it(\"Bedrock preset should have template values for AWS credentials\", () => {\n    expect(bedrockPreset!.templateValues).toBeDefined();\n    expect(bedrockPreset!.templateValues!.region).toBeDefined();\n    expect(bedrockPreset!.templateValues!.region.editorValue).toBe(\n      \"us-west-2\",\n    );\n    expect(bedrockPreset!.templateValues!.accessKeyId).toBeDefined();\n    expect(bedrockPreset!.templateValues!.secretAccessKey).toBeDefined();\n  });\n\n  it(\"Bedrock preset should include Claude models\", () => {\n    const models = bedrockPreset!.settingsConfig.models;\n    expect(models).toBeDefined();\n    const modelIds = Object.keys(models!);\n    expect(\n      modelIds.some((id) => id.includes(\"anthropic.claude\")),\n    ).toBe(true);\n  });\n});\n"
  },
  {
    "path": "tests/hooks/useCommonConfigSave.test.tsx",
    "content": "import { act, renderHook, waitFor } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\nimport { useCodexCommonConfig } from \"@/components/providers/forms/hooks/useCodexCommonConfig\";\nimport { useGeminiCommonConfig } from \"@/components/providers/forms/hooks/useGeminiCommonConfig\";\n\nconst getCommonConfigSnippetMock = vi.fn();\nconst setCommonConfigSnippetMock = vi.fn();\nconst extractCommonConfigSnippetMock = vi.fn();\n\nvi.mock(\"@/lib/api\", () => ({\n  configApi: {\n    getCommonConfigSnippet: (...args: unknown[]) =>\n      getCommonConfigSnippetMock(...args),\n    setCommonConfigSnippet: (...args: unknown[]) =>\n      setCommonConfigSnippetMock(...args),\n    extractCommonConfigSnippet: (...args: unknown[]) =>\n      extractCommonConfigSnippetMock(...args),\n  },\n}));\n\ndescribe(\"common config snippet saving\", () => {\n  beforeEach(() => {\n    getCommonConfigSnippetMock.mockResolvedValue(\"\");\n    setCommonConfigSnippetMock.mockResolvedValue(undefined);\n    extractCommonConfigSnippetMock.mockResolvedValue(\"\");\n  });\n\n  it(\"does not persist an invalid Codex common config snippet\", async () => {\n    const onConfigChange = vi.fn();\n    const { result } = renderHook(() =>\n      useCodexCommonConfig({\n        codexConfig: \"model = \\\"gpt-5\\\"\",\n        onConfigChange,\n      }),\n    );\n\n    await waitFor(() => expect(result.current.isLoading).toBe(false));\n\n    let saved = false;\n    act(() => {\n      saved = result.current.handleCommonConfigSnippetChange(\n        \"base_url = https://bad.example/v1\",\n      );\n    });\n\n    expect(saved).toBe(false);\n    expect(setCommonConfigSnippetMock).not.toHaveBeenCalled();\n    expect(onConfigChange).not.toHaveBeenCalled();\n    expect(result.current.commonConfigError).toContain(\"invalid value\");\n  });\n\n  it(\"does not persist an invalid Gemini common config snippet\", async () => {\n    const onEnvChange = vi.fn();\n    const { result } = renderHook(() =>\n      useGeminiCommonConfig({\n        envValue: \"\",\n        onEnvChange,\n        envStringToObj: () => ({}),\n        envObjToString: () => \"\",\n      }),\n    );\n\n    await waitFor(() => expect(result.current.isLoading).toBe(false));\n\n    let saved = false;\n    act(() => {\n      saved = result.current.handleCommonConfigSnippetChange(\n        JSON.stringify({ GEMINI_MODEL: 123 }),\n      );\n    });\n\n    expect(saved).toBe(false);\n    expect(setCommonConfigSnippetMock).not.toHaveBeenCalled();\n    expect(onEnvChange).not.toHaveBeenCalled();\n    expect(result.current.commonConfigError).toBe(\n      \"geminiConfig.commonConfigInvalidValues\",\n    );\n  });\n});\n"
  },
  {
    "path": "tests/hooks/useDirectorySettings.test.tsx",
    "content": "import { renderHook, act, waitFor } from \"@testing-library/react\";\nimport { describe, it, expect, beforeEach, vi } from \"vitest\";\nimport { useDirectorySettings } from \"@/hooks/useDirectorySettings\";\nimport type { SettingsFormState } from \"@/hooks/useSettingsForm\";\n\nconst getAppConfigDirOverrideMock = vi.hoisted(() => vi.fn());\nconst getConfigDirMock = vi.hoisted(() => vi.fn());\nconst selectConfigDirectoryMock = vi.hoisted(() => vi.fn());\nconst setAppConfigDirOverrideMock = vi.hoisted(() => vi.fn());\nconst homeDirMock = vi.hoisted(() => vi.fn<() => Promise<string>>());\nconst joinMock = vi.hoisted(() =>\n  vi.fn(async (...segments: string[]) => segments.join(\"/\")),\n);\nconst toastErrorMock = vi.hoisted(() => vi.fn());\n\nvi.mock(\"@/lib/api\", () => ({\n  settingsApi: {\n    getAppConfigDirOverride: getAppConfigDirOverrideMock,\n    getConfigDir: getConfigDirMock,\n    selectConfigDirectory: selectConfigDirectoryMock,\n    setAppConfigDirOverride: setAppConfigDirOverrideMock,\n  },\n}));\n\nvi.mock(\"@tauri-apps/api/path\", () => ({\n  homeDir: homeDirMock,\n  join: joinMock,\n}));\n\nvi.mock(\"sonner\", () => ({\n  toast: {\n    error: (...args: unknown[]) => toastErrorMock(...args),\n  },\n}));\n\nvi.mock(\"react-i18next\", () => ({\n  useTranslation: () => ({\n    t: (key: string, options?: Record<string, unknown>) =>\n      (options?.defaultValue as string) ?? key,\n  }),\n}));\n\nconst createSettings = (\n  overrides: Partial<SettingsFormState> = {},\n): SettingsFormState => ({\n  showInTray: true,\n  minimizeToTrayOnClose: true,\n  enableClaudePluginIntegration: false,\n  claudeConfigDir: \"/claude/custom\",\n  codexConfigDir: \"/codex/custom\",\n  language: \"zh\",\n  ...overrides,\n});\n\ndescribe(\"useDirectorySettings\", () => {\n  const onUpdateSettings = vi.fn();\n\n  beforeEach(() => {\n    vi.clearAllMocks();\n\n    homeDirMock.mockResolvedValue(\"/home/mock\");\n    joinMock.mockImplementation(async (...segments: string[]) =>\n      segments.join(\"/\"),\n    );\n\n    getAppConfigDirOverrideMock.mockResolvedValue(null);\n    getConfigDirMock.mockImplementation(async (app: string) => {\n      if (app === \"claude\") return \"/remote/claude\";\n      if (app === \"codex\") return \"/remote/codex\";\n      if (app === \"gemini\") return \"/remote/gemini\";\n      return \"/remote/opencode\";\n    });\n    selectConfigDirectoryMock.mockReset();\n  });\n\n  it(\"initializes directories using overrides and remote defaults\", async () => {\n    getAppConfigDirOverrideMock.mockResolvedValue(\"  /override/app  \");\n\n    const { result } = renderHook(() =>\n      useDirectorySettings({ settings: createSettings(), onUpdateSettings }),\n    );\n\n    await waitFor(() => expect(result.current.isLoading).toBe(false));\n\n    expect(result.current.appConfigDir).toBe(\"/override/app\");\n    expect(result.current.resolvedDirs).toEqual({\n      appConfig: \"/override/app\",\n      claude: \"/remote/claude\",\n      codex: \"/remote/codex\",\n      gemini: \"/remote/gemini\",\n      opencode: \"/remote/opencode\",\n    });\n  });\n\n  it(\"updates claude directory when browsing succeeds\", async () => {\n    selectConfigDirectoryMock.mockResolvedValue(\"/picked/claude\");\n\n    const { result } = renderHook(() =>\n      useDirectorySettings({\n        settings: createSettings({ claudeConfigDir: undefined }),\n        onUpdateSettings,\n      }),\n    );\n\n    await waitFor(() => expect(result.current.isLoading).toBe(false));\n\n    await act(async () => {\n      await result.current.browseDirectory(\"claude\");\n    });\n\n    expect(selectConfigDirectoryMock).toHaveBeenCalledWith(\"/remote/claude\");\n    expect(onUpdateSettings).toHaveBeenCalledWith({\n      claudeConfigDir: \"/picked/claude\",\n    });\n    expect(result.current.resolvedDirs.claude).toBe(\"/picked/claude\");\n  });\n\n  it(\"reports error when directory selection fails\", async () => {\n    selectConfigDirectoryMock.mockResolvedValue(null);\n\n    const { result } = renderHook(() =>\n      useDirectorySettings({ settings: createSettings(), onUpdateSettings }),\n    );\n    await waitFor(() => expect(result.current.isLoading).toBe(false));\n\n    await act(async () => {\n      await result.current.browseDirectory(\"codex\");\n    });\n\n    expect(result.current.resolvedDirs.codex).toBe(\"/remote/codex\");\n    expect(onUpdateSettings).not.toHaveBeenCalledWith({\n      codexConfigDir: expect.anything(),\n    });\n    expect(selectConfigDirectoryMock).toHaveBeenCalled();\n\n    selectConfigDirectoryMock.mockRejectedValue(new Error(\"dialog failed\"));\n    toastErrorMock.mockClear();\n\n    await act(async () => {\n      await result.current.browseDirectory(\"codex\");\n    });\n\n    expect(toastErrorMock).toHaveBeenCalled();\n  });\n\n  it(\"warns when directory selection promise rejects\", async () => {\n    selectConfigDirectoryMock.mockRejectedValue(new Error(\"dialog failed\"));\n\n    const { result } = renderHook(() =>\n      useDirectorySettings({ settings: createSettings(), onUpdateSettings }),\n    );\n    await waitFor(() => expect(result.current.isLoading).toBe(false));\n\n    await act(async () => {\n      await result.current.browseDirectory(\"codex\");\n    });\n\n    expect(toastErrorMock).toHaveBeenCalled();\n    expect(onUpdateSettings).not.toHaveBeenCalledWith({\n      codexConfigDir: expect.anything(),\n    });\n  });\n\n  it(\"updates app config directory via browseAppConfigDir\", async () => {\n    selectConfigDirectoryMock.mockResolvedValue(\"  /new/app  \");\n\n    const { result } = renderHook(() =>\n      useDirectorySettings({\n        settings: createSettings(),\n        onUpdateSettings,\n      }),\n    );\n    await waitFor(() => expect(result.current.isLoading).toBe(false));\n\n    await act(async () => {\n      await result.current.browseAppConfigDir();\n    });\n\n    expect(result.current.appConfigDir).toBe(\"/new/app\");\n    expect(selectConfigDirectoryMock).toHaveBeenCalledWith(\n      \"/home/mock/.cc-switch\",\n    );\n  });\n\n  it(\"resets directories to computed defaults\", async () => {\n    const { result } = renderHook(() =>\n      useDirectorySettings({\n        settings: createSettings({\n          claudeConfigDir: \"/custom/claude\",\n          codexConfigDir: \"/custom/codex\",\n        }),\n        onUpdateSettings,\n      }),\n    );\n    await waitFor(() => expect(result.current.isLoading).toBe(false));\n\n    await act(async () => {\n      await result.current.resetDirectory(\"claude\");\n      await result.current.resetDirectory(\"codex\");\n      await result.current.resetAppConfigDir();\n    });\n\n    expect(onUpdateSettings).toHaveBeenCalledWith({\n      claudeConfigDir: undefined,\n    });\n    expect(onUpdateSettings).toHaveBeenCalledWith({\n      codexConfigDir: undefined,\n    });\n    expect(result.current.resolvedDirs.claude).toBe(\"/home/mock/.claude\");\n    expect(result.current.resolvedDirs.codex).toBe(\"/home/mock/.codex\");\n    expect(result.current.resolvedDirs.appConfig).toBe(\"/home/mock/.cc-switch\");\n  });\n\n  it(\"resetAllDirectories applies provided resolved values\", async () => {\n    const { result } = renderHook(() =>\n      useDirectorySettings({ settings: createSettings(), onUpdateSettings }),\n    );\n    await waitFor(() => expect(result.current.isLoading).toBe(false));\n\n    act(() => {\n      result.current.resetAllDirectories(\n        \"/server/claude\",\n        \"/server/codex\",\n        \"/server/gemini\",\n        \"/server/opencode\",\n      );\n    });\n\n    expect(result.current.resolvedDirs.claude).toBe(\"/server/claude\");\n    expect(result.current.resolvedDirs.codex).toBe(\"/server/codex\");\n    expect(result.current.resolvedDirs.gemini).toBe(\"/server/gemini\");\n    expect(result.current.resolvedDirs.opencode).toBe(\"/server/opencode\");\n  });\n});\n"
  },
  {
    "path": "tests/hooks/useDragSort.test.tsx",
    "content": "import type { ReactNode } from \"react\";\nimport { renderHook, act } from \"@testing-library/react\";\nimport { QueryClient, QueryClientProvider } from \"@tanstack/react-query\";\nimport { describe, expect, it, vi, beforeEach, afterAll } from \"vitest\";\nimport type { Provider } from \"@/types\";\nimport { useDragSort } from \"@/hooks/useDragSort\";\n\nconst updateSortOrderMock = vi.fn();\nconst toastSuccessMock = vi.fn();\nconst toastErrorMock = vi.fn();\nconst consoleErrorSpy = vi.spyOn(console, \"error\").mockImplementation(() => {});\n\nvi.mock(\"sonner\", () => ({\n  toast: {\n    success: (...args: unknown[]) => toastSuccessMock(...args),\n    error: (...args: unknown[]) => toastErrorMock(...args),\n  },\n}));\n\nvi.mock(\"@/lib/api\", () => ({\n  providersApi: {\n    updateSortOrder: (...args: unknown[]) => updateSortOrderMock(...args),\n  },\n}));\n\ninterface WrapperProps {\n  children: ReactNode;\n}\n\nfunction createWrapper() {\n  const queryClient = new QueryClient();\n\n  const wrapper = ({ children }: WrapperProps) => (\n    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>\n  );\n\n  return { wrapper, queryClient };\n}\n\nconst mockProviders: Record<string, Provider> = {\n  a: {\n    id: \"a\",\n    name: \"AAA\",\n    settingsConfig: {},\n    sortIndex: 1,\n    createdAt: 5,\n  },\n  b: {\n    id: \"b\",\n    name: \"BBB\",\n    settingsConfig: {},\n    sortIndex: 0,\n    createdAt: 10,\n  },\n  c: {\n    id: \"c\",\n    name: \"CCC\",\n    settingsConfig: {},\n    createdAt: 1,\n  },\n};\n\ndescribe(\"useDragSort\", () => {\n  beforeEach(() => {\n    updateSortOrderMock.mockReset();\n    toastSuccessMock.mockReset();\n    toastErrorMock.mockReset();\n    consoleErrorSpy.mockClear();\n  });\n\n  afterAll(() => {\n    consoleErrorSpy.mockRestore();\n  });\n\n  it(\"should sort providers by sortIndex, createdAt, and name\", () => {\n    const { wrapper } = createWrapper();\n\n    const { result } = renderHook(() => useDragSort(mockProviders, \"claude\"), {\n      wrapper,\n    });\n\n    expect(result.current.sortedProviders.map((item) => item.id)).toEqual([\n      \"b\",\n      \"a\",\n      \"c\",\n    ]);\n  });\n\n  it(\"should call API and invalidate query cache after successful drag\", async () => {\n    updateSortOrderMock.mockResolvedValue(true);\n    const { wrapper, queryClient } = createWrapper();\n    const invalidateSpy = vi.spyOn(queryClient, \"invalidateQueries\");\n\n    const { result } = renderHook(() => useDragSort(mockProviders, \"claude\"), {\n      wrapper,\n    });\n\n    await act(async () => {\n      await result.current.handleDragEnd({\n        active: { id: \"b\" },\n        over: { id: \"a\" },\n      } as any);\n    });\n\n    expect(updateSortOrderMock).toHaveBeenCalledTimes(1);\n    expect(updateSortOrderMock).toHaveBeenCalledWith(\n      [\n        { id: \"a\", sortIndex: 0 },\n        { id: \"b\", sortIndex: 1 },\n        { id: \"c\", sortIndex: 2 },\n      ],\n      \"claude\",\n    );\n    expect(invalidateSpy).toHaveBeenCalledWith({\n      queryKey: [\"providers\", \"claude\"],\n    });\n    expect(toastSuccessMock).toHaveBeenCalledTimes(1);\n    expect(toastErrorMock).not.toHaveBeenCalled();\n  });\n\n  it(\"should show error toast when drag operation fails\", async () => {\n    updateSortOrderMock.mockRejectedValue(new Error(\"network\"));\n    const { wrapper } = createWrapper();\n\n    const { result } = renderHook(() => useDragSort(mockProviders, \"claude\"), {\n      wrapper,\n    });\n\n    await act(async () => {\n      await result.current.handleDragEnd({\n        active: { id: \"b\" },\n        over: { id: \"a\" },\n      } as any);\n    });\n\n    expect(toastErrorMock).toHaveBeenCalledTimes(1);\n    expect(toastSuccessMock).not.toHaveBeenCalled();\n    expect(consoleErrorSpy).toHaveBeenCalled();\n  });\n\n  it(\"should not trigger API call when there is no valid target\", async () => {\n    const { wrapper } = createWrapper();\n\n    const { result } = renderHook(() => useDragSort(mockProviders, \"claude\"), {\n      wrapper,\n    });\n\n    await act(async () => {\n      await result.current.handleDragEnd({\n        active: { id: \"b\" },\n        over: null,\n      } as any);\n    });\n\n    expect(updateSortOrderMock).not.toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "tests/hooks/useImportExport.extra.test.tsx",
    "content": "import { renderHook, act } from \"@testing-library/react\";\nimport { describe, it, expect, vi, beforeEach, afterEach } from \"vitest\";\nimport { useImportExport } from \"@/hooks/useImportExport\";\n\nconst toastSuccessMock = vi.fn();\nconst toastErrorMock = vi.fn();\nconst toastWarningMock = vi.fn();\n\nvi.mock(\"sonner\", () => ({\n  toast: {\n    success: (...args: unknown[]) => toastSuccessMock(...args),\n    error: (...args: unknown[]) => toastErrorMock(...args),\n    warning: (...args: unknown[]) => toastWarningMock(...args),\n  },\n}));\n\nconst openFileDialogMock = vi.fn();\nconst importConfigMock = vi.fn();\nconst saveFileDialogMock = vi.fn();\nconst exportConfigMock = vi.fn();\nconst syncCurrentProvidersLiveMock = vi.fn();\n\nvi.mock(\"@/lib/api\", () => ({\n  settingsApi: {\n    openFileDialog: (...args: unknown[]) => openFileDialogMock(...args),\n    importConfigFromFile: (...args: unknown[]) => importConfigMock(...args),\n    saveFileDialog: (...args: unknown[]) => saveFileDialogMock(...args),\n    exportConfigToFile: (...args: unknown[]) => exportConfigMock(...args),\n    syncCurrentProvidersLive: (...args: unknown[]) =>\n      syncCurrentProvidersLiveMock(...args),\n  },\n}));\n\ndescribe(\"useImportExport Hook (edge cases)\", () => {\n  beforeEach(() => {\n    openFileDialogMock.mockReset();\n    importConfigMock.mockReset();\n    saveFileDialogMock.mockReset();\n    exportConfigMock.mockReset();\n    toastSuccessMock.mockReset();\n    toastErrorMock.mockReset();\n    toastWarningMock.mockReset();\n    syncCurrentProvidersLiveMock.mockReset();\n    vi.useFakeTimers();\n  });\n\n  afterEach(() => {\n    vi.useRealTimers();\n  });\n\n  it(\"keeps state unchanged when file dialog resolves to null\", async () => {\n    openFileDialogMock.mockResolvedValue(null);\n    const { result } = renderHook(() => useImportExport());\n\n    await act(async () => {\n      await result.current.selectImportFile();\n    });\n\n    expect(result.current.selectedFile).toBe(\"\");\n    expect(result.current.status).toBe(\"idle\");\n    expect(toastErrorMock).not.toHaveBeenCalled();\n  });\n\n  it(\"resetStatus clears errors but preserves selected file\", async () => {\n    openFileDialogMock.mockResolvedValue(\"/config.json\");\n    importConfigMock.mockResolvedValue({ success: false, message: \"broken\" });\n    const { result } = renderHook(() => useImportExport());\n\n    await act(async () => {\n      await result.current.selectImportFile();\n    });\n\n    await act(async () => {\n      await result.current.importConfig();\n    });\n\n    act(() => {\n      result.current.resetStatus();\n    });\n\n    expect(result.current.selectedFile).toBe(\"/config.json\");\n    expect(result.current.status).toBe(\"idle\");\n    expect(result.current.errorMessage).toBeNull();\n    expect(result.current.backupId).toBeNull();\n  });\n\n  it(\"does not call onImportSuccess when import fails\", async () => {\n    openFileDialogMock.mockResolvedValue(\"/config.json\");\n    importConfigMock.mockResolvedValue({\n      success: false,\n      message: \"invalid\",\n    });\n    const onImportSuccess = vi.fn();\n    const { result } = renderHook(() => useImportExport({ onImportSuccess }));\n\n    await act(async () => {\n      await result.current.selectImportFile();\n    });\n\n    await act(async () => {\n      await result.current.importConfig();\n    });\n\n    expect(onImportSuccess).not.toHaveBeenCalled();\n    expect(result.current.status).toBe(\"error\");\n  });\n\n  it(\"propagates export success message to toast with saved path\", async () => {\n    saveFileDialogMock.mockResolvedValue(\"/exports/config.json\");\n    exportConfigMock.mockResolvedValue({\n      success: true,\n      filePath: \"/final/config.json\",\n    });\n    const { result } = renderHook(() => useImportExport());\n\n    await act(async () => {\n      await result.current.exportConfig();\n    });\n\n    expect(exportConfigMock).toHaveBeenCalledWith(\"/exports/config.json\");\n    expect(toastSuccessMock).toHaveBeenCalledWith(\n      expect.stringContaining(\"/final/config.json\"),\n      expect.objectContaining({ closeButton: true }),\n    );\n  });\n});\n"
  },
  {
    "path": "tests/hooks/useImportExport.test.tsx",
    "content": "import { renderHook, act } from \"@testing-library/react\";\nimport { describe, it, expect, vi, beforeEach, afterEach } from \"vitest\";\nimport { useImportExport } from \"@/hooks/useImportExport\";\n\nconst toastSuccessMock = vi.fn();\nconst toastErrorMock = vi.fn();\nconst toastWarningMock = vi.fn();\n\nvi.mock(\"sonner\", () => ({\n  toast: {\n    success: (...args: unknown[]) => toastSuccessMock(...args),\n    error: (...args: unknown[]) => toastErrorMock(...args),\n    warning: (...args: unknown[]) => toastWarningMock(...args),\n  },\n}));\n\nconst openFileDialogMock = vi.fn();\nconst importConfigMock = vi.fn();\nconst saveFileDialogMock = vi.fn();\nconst exportConfigMock = vi.fn();\nconst syncCurrentProvidersLiveMock = vi.fn();\n\nvi.mock(\"@/lib/api\", () => ({\n  settingsApi: {\n    openFileDialog: (...args: unknown[]) => openFileDialogMock(...args),\n    importConfigFromFile: (...args: unknown[]) => importConfigMock(...args),\n    saveFileDialog: (...args: unknown[]) => saveFileDialogMock(...args),\n    exportConfigToFile: (...args: unknown[]) => exportConfigMock(...args),\n    syncCurrentProvidersLive: (...args: unknown[]) =>\n      syncCurrentProvidersLiveMock(...args),\n  },\n}));\n\nbeforeEach(() => {\n  openFileDialogMock.mockReset();\n  importConfigMock.mockReset();\n  saveFileDialogMock.mockReset();\n  exportConfigMock.mockReset();\n  toastSuccessMock.mockReset();\n  toastErrorMock.mockReset();\n  toastWarningMock.mockReset();\n  syncCurrentProvidersLiveMock.mockReset();\n  vi.useFakeTimers();\n});\n\nafterEach(() => {\n  vi.useRealTimers();\n});\n\ndescribe(\"useImportExport Hook\", () => {\n  it(\"should update state after successfully selecting file\", async () => {\n    openFileDialogMock.mockResolvedValue(\"/path/config.json\");\n    const { result } = renderHook(() => useImportExport());\n\n    await act(async () => {\n      await result.current.selectImportFile();\n    });\n\n    expect(result.current.selectedFile).toBe(\"/path/config.json\");\n    expect(result.current.status).toBe(\"idle\");\n    expect(result.current.errorMessage).toBeNull();\n  });\n\n  it(\"should show error toast and keep initial state when file dialog fails\", async () => {\n    openFileDialogMock.mockRejectedValue(new Error(\"file dialog error\"));\n    const { result } = renderHook(() => useImportExport());\n\n    await act(async () => {\n      await result.current.selectImportFile();\n    });\n\n    expect(toastErrorMock).toHaveBeenCalledTimes(1);\n    expect(result.current.selectedFile).toBe(\"\");\n    expect(result.current.status).toBe(\"idle\");\n  });\n\n  it(\"should show error and return early when no file is selected for import\", async () => {\n    const { result } = renderHook(() =>\n      useImportExport({ onImportSuccess: vi.fn() }),\n    );\n\n    await act(async () => {\n      await result.current.importConfig();\n    });\n\n    expect(toastErrorMock).toHaveBeenCalledTimes(1);\n    expect(importConfigMock).not.toHaveBeenCalled();\n    expect(result.current.status).toBe(\"idle\");\n  });\n\n  it(\"should set success status, record backup ID, and call callback on successful import\", async () => {\n    openFileDialogMock.mockResolvedValue(\"/config.json\");\n    importConfigMock.mockResolvedValue({\n      success: true,\n      backupId: \"backup-123\",\n    });\n    const onImportSuccess = vi.fn();\n\n    const { result } = renderHook(() => useImportExport({ onImportSuccess }));\n\n    await act(async () => {\n      await result.current.selectImportFile();\n    });\n\n    await act(async () => {\n      await result.current.importConfig();\n    });\n\n    expect(importConfigMock).toHaveBeenCalledWith(\"/config.json\");\n    expect(result.current.status).toBe(\"success\");\n    expect(result.current.backupId).toBe(\"backup-123\");\n    expect(toastSuccessMock).toHaveBeenCalledTimes(1);\n    expect(onImportSuccess).toHaveBeenCalledTimes(1);\n  });\n\n  it(\"should show error message and keep selected file when import result fails\", async () => {\n    openFileDialogMock.mockResolvedValue(\"/config.json\");\n    importConfigMock.mockResolvedValue({\n      success: false,\n      message: \"Config corrupted\",\n    });\n\n    const { result } = renderHook(() => useImportExport());\n\n    await act(async () => {\n      await result.current.selectImportFile();\n    });\n\n    await act(async () => {\n      await result.current.importConfig();\n    });\n\n    expect(result.current.status).toBe(\"error\");\n    expect(result.current.errorMessage).toBe(\"Config corrupted\");\n    expect(result.current.selectedFile).toBe(\"/config.json\");\n    expect(toastErrorMock).toHaveBeenCalledWith(\"Config corrupted\");\n  });\n\n  it(\"should catch and display error when import process throws exception\", async () => {\n    openFileDialogMock.mockResolvedValue(\"/config.json\");\n    importConfigMock.mockRejectedValue(new Error(\"Import failed\"));\n\n    const { result } = renderHook(() => useImportExport());\n\n    await act(async () => {\n      await result.current.selectImportFile();\n    });\n\n    await act(async () => {\n      await result.current.importConfig();\n    });\n\n    expect(result.current.status).toBe(\"error\");\n    expect(result.current.errorMessage).toBe(\"Import failed\");\n    expect(toastErrorMock).toHaveBeenCalledWith(\n      expect.stringContaining(\"导入配置失败:\"),\n    );\n  });\n\n  it(\"should export successfully with default filename and show path in toast\", async () => {\n    saveFileDialogMock.mockResolvedValue(\"/export.json\");\n    exportConfigMock.mockResolvedValue({\n      success: true,\n      filePath: \"/backup/export.json\",\n    });\n\n    const { result } = renderHook(() => useImportExport());\n\n    await act(async () => {\n      await result.current.exportConfig();\n    });\n\n    expect(saveFileDialogMock).toHaveBeenCalledTimes(1);\n    expect(exportConfigMock).toHaveBeenCalledWith(\"/export.json\");\n    expect(toastSuccessMock).toHaveBeenCalledWith(\n      expect.stringContaining(\"/backup/export.json\"),\n      expect.objectContaining({ closeButton: true }),\n    );\n  });\n\n  it(\"should show error message when export fails\", async () => {\n    saveFileDialogMock.mockResolvedValue(\"/export.json\");\n    exportConfigMock.mockResolvedValue({\n      success: false,\n      message: \"Write failed\",\n    });\n\n    const { result } = renderHook(() => useImportExport());\n\n    await act(async () => {\n      await result.current.exportConfig();\n    });\n\n    expect(toastErrorMock).toHaveBeenCalledWith(\n      expect.stringContaining(\"Write failed\"),\n    );\n  });\n\n  it(\"should catch and show error when export throws exception\", async () => {\n    saveFileDialogMock.mockResolvedValue(\"/export.json\");\n    exportConfigMock.mockRejectedValue(new Error(\"Disk read-only\"));\n\n    const { result } = renderHook(() => useImportExport());\n\n    await act(async () => {\n      await result.current.exportConfig();\n    });\n\n    expect(toastErrorMock).toHaveBeenCalledWith(\n      expect.stringContaining(\"Disk read-only\"),\n    );\n  });\n\n  it(\"should show error and return when user cancels save dialog during export\", async () => {\n    saveFileDialogMock.mockResolvedValue(null);\n\n    const { result } = renderHook(() => useImportExport());\n\n    await act(async () => {\n      await result.current.exportConfig();\n    });\n\n    expect(exportConfigMock).not.toHaveBeenCalled();\n    expect(toastErrorMock).toHaveBeenCalledTimes(1);\n  });\n\n  it(\"should restore initial values when clearing selection and resetting status\", async () => {\n    openFileDialogMock.mockResolvedValue(\"/config.json\");\n    const { result } = renderHook(() => useImportExport());\n\n    await act(async () => {\n      await result.current.selectImportFile();\n    });\n\n    act(() => {\n      result.current.clearSelection();\n    });\n\n    expect(result.current.selectedFile).toBe(\"\");\n    expect(result.current.status).toBe(\"idle\");\n\n    act(() => {\n      result.current.resetStatus();\n    });\n\n    expect(result.current.errorMessage).toBeNull();\n    expect(result.current.backupId).toBeNull();\n  });\n});\n"
  },
  {
    "path": "tests/hooks/useMcpValidation.test.tsx",
    "content": "import { renderHook } from \"@testing-library/react\";\nimport { describe, it, expect, beforeEach, vi } from \"vitest\";\nimport { useMcpValidation } from \"@/components/mcp/useMcpValidation\";\n\nconst validateTomlMock = vi.hoisted(() => vi.fn());\nconst tomlToMcpServerMock = vi.hoisted(() => vi.fn());\n\nvi.mock(\"react-i18next\", () => ({\n  useTranslation: () => ({\n    t: (key: string) => key,\n  }),\n}));\n\nvi.mock(\"@/utils/tomlUtils\", () => ({\n  validateToml: (...args: unknown[]) => validateTomlMock(...args),\n  tomlToMcpServer: (...args: unknown[]) => tomlToMcpServerMock(...args),\n}));\n\ndescribe(\"useMcpValidation\", () => {\n  beforeEach(() => {\n    validateTomlMock.mockReset();\n    tomlToMcpServerMock.mockReset();\n    validateTomlMock.mockReturnValue(\"\");\n  });\n\n  const getHookResult = () =>\n    renderHook(() => useMcpValidation()).result.current;\n\n  describe(\"validateJson\", () => {\n    it(\"returns empty string for blank text\", () => {\n      const { validateJson } = getHookResult();\n      expect(validateJson(\"   \")).toBe(\"\");\n    });\n\n    it(\"returns error key when JSON parsing fails\", () => {\n      const { validateJson } = getHookResult();\n      expect(validateJson(\"{ invalid\")).toBe(\"mcp.error.jsonInvalid\");\n    });\n\n    it(\"returns error key when parsed value is not an object\", () => {\n      const { validateJson } = getHookResult();\n      expect(validateJson('\"string\"')).toBe(\"mcp.error.jsonInvalid\");\n      expect(validateJson(\"[]\")).toBe(\"mcp.error.jsonInvalid\");\n    });\n\n    it(\"accepts valid object payload\", () => {\n      const { validateJson } = getHookResult();\n      expect(validateJson('{\"id\":\"demo\"}')).toBe(\"\");\n    });\n  });\n\n  describe(\"formatTomlError\", () => {\n    it(\"maps mustBeObject and parseError to i18n key\", () => {\n      const { formatTomlError } = getHookResult();\n      expect(formatTomlError(\"mustBeObject\")).toBe(\"mcp.error.tomlInvalid\");\n      expect(formatTomlError(\"parseError\")).toBe(\"mcp.error.tomlInvalid\");\n    });\n\n    it(\"appends error message when details provided\", () => {\n      const { formatTomlError } = getHookResult();\n      expect(formatTomlError(\"unknown\")).toBe(\"mcp.error.tomlInvalid: unknown\");\n    });\n  });\n\n  describe(\"validateTomlConfig\", () => {\n    it(\"propagates errors returned by validateToml\", () => {\n      validateTomlMock.mockReturnValue(\"parse-error-detail\");\n      const { validateTomlConfig } = getHookResult();\n      expect(validateTomlConfig(\"foo\")).toBe(\n        \"mcp.error.tomlInvalid: parse-error-detail\",\n      );\n      expect(tomlToMcpServerMock).not.toHaveBeenCalled();\n    });\n\n    it(\"returns command required when stdio server missing command\", () => {\n      tomlToMcpServerMock.mockReturnValue({\n        type: \"stdio\",\n        command: \"   \",\n      });\n      const { validateTomlConfig } = getHookResult();\n      expect(validateTomlConfig(\"foo\")).toBe(\"mcp.error.commandRequired\");\n    });\n\n    it(\"returns url required when http server missing url\", () => {\n      tomlToMcpServerMock.mockReturnValue({\n        type: \"http\",\n        url: \"\",\n      });\n      const { validateTomlConfig } = getHookResult();\n      expect(validateTomlConfig(\"foo\")).toBe(\"mcp.wizard.urlRequired\");\n    });\n\n    it(\"returns url required when sse server missing url\", () => {\n      tomlToMcpServerMock.mockReturnValue({\n        type: \"sse\",\n        url: \"\",\n      });\n      const { validateTomlConfig } = getHookResult();\n      expect(validateTomlConfig(\"foo\")).toBe(\"mcp.wizard.urlRequired\");\n    });\n\n    it(\"surface tomlToMcpServer errors via formatter\", () => {\n      tomlToMcpServerMock.mockImplementation(() => {\n        throw new Error(\"normalize failed\");\n      });\n      const { validateTomlConfig } = getHookResult();\n      expect(validateTomlConfig(\"foo\")).toBe(\n        \"mcp.error.tomlInvalid: normalize failed\",\n      );\n    });\n\n    it(\"returns empty string when validation passes\", () => {\n      tomlToMcpServerMock.mockReturnValue({\n        type: \"stdio\",\n        command: \"run.sh\",\n      });\n      const { validateTomlConfig } = getHookResult();\n      expect(validateTomlConfig(\"foo\")).toBe(\"\");\n    });\n  });\n\n  describe(\"validateJsonConfig\", () => {\n    it(\"returns error when JSON invalid\", () => {\n      const { validateJsonConfig } = getHookResult();\n      expect(validateJsonConfig(\"invalid\")).toBe(\"mcp.error.jsonInvalid\");\n    });\n\n    it(\"rejects arrays of servers\", () => {\n      const { validateJsonConfig } = getHookResult();\n      expect(validateJsonConfig('{\"mcpServers\": {}}')).toBe(\n        \"mcp.error.singleServerObjectRequired\",\n      );\n    });\n\n    it(\"requires command for stdio type\", () => {\n      const { validateJsonConfig } = getHookResult();\n      expect(validateJsonConfig('{\"type\":\"stdio\"}')).toBe(\n        \"mcp.error.commandRequired\",\n      );\n    });\n\n    it(\"requires url for http type\", () => {\n      const { validateJsonConfig } = getHookResult();\n      expect(validateJsonConfig('{\"type\":\"http\",\"url\":\"\"}')).toBe(\n        \"mcp.wizard.urlRequired\",\n      );\n    });\n\n    it(\"requires url for sse type\", () => {\n      const { validateJsonConfig } = getHookResult();\n      expect(validateJsonConfig('{\"type\":\"sse\",\"url\":\"\"}')).toBe(\n        \"mcp.wizard.urlRequired\",\n      );\n    });\n\n    it(\"returns empty string when json config valid\", () => {\n      const { validateJsonConfig } = getHookResult();\n      expect(\n        validateJsonConfig(\n          JSON.stringify({\n            type: \"stdio\",\n            command: \"node\",\n            args: [\"index.js\"],\n          }),\n        ),\n      ).toBe(\"\");\n    });\n  });\n});\n"
  },
  {
    "path": "tests/hooks/useProviderActions.test.tsx",
    "content": "import type { ReactNode } from \"react\";\nimport { renderHook, act } from \"@testing-library/react\";\nimport { QueryClient, QueryClientProvider } from \"@tanstack/react-query\";\nimport { describe, it, expect, vi, beforeEach } from \"vitest\";\nimport { useProviderActions } from \"@/hooks/useProviderActions\";\nimport type { Provider, UsageScript } from \"@/types\";\n\nconst toastSuccessMock = vi.fn();\nconst toastErrorMock = vi.fn();\n\nvi.mock(\"sonner\", () => ({\n  toast: {\n    success: (...args: unknown[]) => toastSuccessMock(...args),\n    error: (...args: unknown[]) => toastErrorMock(...args),\n  },\n}));\n\nconst addProviderMutateAsync = vi.fn();\nconst updateProviderMutateAsync = vi.fn();\nconst deleteProviderMutateAsync = vi.fn();\nconst switchProviderMutateAsync = vi.fn();\n\nconst addProviderMutation = {\n  mutateAsync: addProviderMutateAsync,\n  isPending: false,\n};\nconst updateProviderMutation = {\n  mutateAsync: updateProviderMutateAsync,\n  isPending: false,\n};\nconst deleteProviderMutation = {\n  mutateAsync: deleteProviderMutateAsync,\n  isPending: false,\n};\nconst switchProviderMutation = {\n  mutateAsync: switchProviderMutateAsync,\n  isPending: false,\n};\n\nconst useAddProviderMutationMock = vi.fn(() => addProviderMutation);\nconst useUpdateProviderMutationMock = vi.fn(() => updateProviderMutation);\nconst useDeleteProviderMutationMock = vi.fn(() => deleteProviderMutation);\nconst useSwitchProviderMutationMock = vi.fn(() => switchProviderMutation);\n\nvi.mock(\"@/lib/query\", () => ({\n  useAddProviderMutation: () => useAddProviderMutationMock(),\n  useUpdateProviderMutation: () => useUpdateProviderMutationMock(),\n  useDeleteProviderMutation: () => useDeleteProviderMutationMock(),\n  useSwitchProviderMutation: () => useSwitchProviderMutationMock(),\n}));\n\nconst providersApiUpdateMock = vi.fn();\nconst providersApiUpdateTrayMenuMock = vi.fn();\nconst settingsApiGetMock = vi.fn();\nconst settingsApiApplyMock = vi.fn();\nconst openclawApiGetModelCatalogMock = vi.fn();\nconst openclawApiGetDefaultModelMock = vi.fn();\nconst openclawApiSetDefaultModelMock = vi.fn();\n\nvi.mock(\"@/lib/api\", () => ({\n  providersApi: {\n    update: (...args: unknown[]) => providersApiUpdateMock(...args),\n    updateTrayMenu: (...args: unknown[]) =>\n      providersApiUpdateTrayMenuMock(...args),\n  },\n  settingsApi: {\n    get: (...args: unknown[]) => settingsApiGetMock(...args),\n    applyClaudePluginConfig: (...args: unknown[]) =>\n      settingsApiApplyMock(...args),\n  },\n  openclawApi: {\n    getModelCatalog: (...args: unknown[]) =>\n      openclawApiGetModelCatalogMock(...args),\n    getDefaultModel: (...args: unknown[]) =>\n      openclawApiGetDefaultModelMock(...args),\n    setDefaultModel: (...args: unknown[]) =>\n      openclawApiSetDefaultModelMock(...args),\n  },\n}));\n\ninterface WrapperProps {\n  children: ReactNode;\n}\n\nfunction createWrapper() {\n  const queryClient = new QueryClient();\n\n  const wrapper = ({ children }: WrapperProps) => (\n    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>\n  );\n\n  return { wrapper, queryClient };\n}\n\nfunction createProvider(overrides: Partial<Provider> = {}): Provider {\n  return {\n    id: \"provider-1\",\n    name: \"Test Provider\",\n    settingsConfig: {},\n    category: \"official\",\n    ...overrides,\n  };\n}\n\nbeforeEach(() => {\n  addProviderMutateAsync.mockReset();\n  updateProviderMutateAsync.mockReset();\n  deleteProviderMutateAsync.mockReset();\n  switchProviderMutateAsync.mockReset();\n  providersApiUpdateMock.mockReset();\n  providersApiUpdateTrayMenuMock.mockReset();\n  settingsApiGetMock.mockReset();\n  settingsApiApplyMock.mockReset();\n  openclawApiGetModelCatalogMock.mockReset();\n  openclawApiGetDefaultModelMock.mockReset();\n  openclawApiSetDefaultModelMock.mockReset();\n  toastSuccessMock.mockReset();\n  toastErrorMock.mockReset();\n\n  addProviderMutation.isPending = false;\n  updateProviderMutation.isPending = false;\n  deleteProviderMutation.isPending = false;\n  switchProviderMutation.isPending = false;\n\n  useAddProviderMutationMock.mockClear();\n  useUpdateProviderMutationMock.mockClear();\n  useDeleteProviderMutationMock.mockClear();\n  useSwitchProviderMutationMock.mockClear();\n});\n\ndescribe(\"useProviderActions\", () => {\n  it(\"should trigger mutation when calling addProvider\", async () => {\n    addProviderMutateAsync.mockResolvedValueOnce(undefined);\n    const { wrapper } = createWrapper();\n    const providerInput = {\n      name: \"New Provider\",\n      settingsConfig: { token: \"abc\" },\n    } as Omit<Provider, \"id\">;\n\n    const { result } = renderHook(() => useProviderActions(\"claude\"), {\n      wrapper,\n    });\n\n    await act(async () => {\n      await result.current.addProvider(providerInput);\n    });\n\n    expect(addProviderMutateAsync).toHaveBeenCalledTimes(1);\n    expect(addProviderMutateAsync).toHaveBeenCalledWith(providerInput);\n  });\n\n  it(\"should update tray menu when calling updateProvider\", async () => {\n    updateProviderMutateAsync.mockResolvedValueOnce(undefined);\n    providersApiUpdateTrayMenuMock.mockResolvedValueOnce(true);\n    const { wrapper } = createWrapper();\n    const provider = createProvider();\n\n    const { result } = renderHook(() => useProviderActions(\"claude\"), {\n      wrapper,\n    });\n\n    await act(async () => {\n      await result.current.updateProvider(provider);\n    });\n\n    expect(updateProviderMutateAsync).toHaveBeenCalledWith(provider);\n    expect(providersApiUpdateTrayMenuMock).toHaveBeenCalledTimes(1);\n  });\n\n  it(\"should not request plugin sync when switching non-Claude provider\", async () => {\n    switchProviderMutateAsync.mockResolvedValueOnce(undefined);\n    const { wrapper } = createWrapper();\n    const provider = createProvider({ category: \"custom\" });\n\n    const { result } = renderHook(() => useProviderActions(\"codex\"), {\n      wrapper,\n    });\n\n    await act(async () => {\n      await result.current.switchProvider(provider);\n    });\n\n    expect(switchProviderMutateAsync).toHaveBeenCalledWith(provider.id);\n    expect(settingsApiGetMock).not.toHaveBeenCalled();\n    expect(settingsApiApplyMock).not.toHaveBeenCalled();\n  });\n\n  it(\"should sync plugin config when switching Claude provider with integration enabled\", async () => {\n    switchProviderMutateAsync.mockResolvedValueOnce(undefined);\n    settingsApiGetMock.mockResolvedValueOnce({\n      enableClaudePluginIntegration: true,\n    });\n    settingsApiApplyMock.mockResolvedValueOnce(true);\n    const { wrapper } = createWrapper();\n    const provider = createProvider({ category: \"official\" });\n\n    const { result } = renderHook(() => useProviderActions(\"claude\"), {\n      wrapper,\n    });\n\n    await act(async () => {\n      await result.current.switchProvider(provider);\n    });\n\n    expect(switchProviderMutateAsync).toHaveBeenCalledWith(provider.id);\n    expect(settingsApiGetMock).toHaveBeenCalledTimes(1);\n    expect(settingsApiApplyMock).toHaveBeenCalledWith({ official: true });\n  });\n\n  it(\"should not call applyClaudePluginConfig when integration is disabled\", async () => {\n    switchProviderMutateAsync.mockResolvedValueOnce(undefined);\n    settingsApiGetMock.mockResolvedValueOnce({\n      enableClaudePluginIntegration: false,\n    });\n    const { wrapper } = createWrapper();\n    const provider = createProvider();\n\n    const { result } = renderHook(() => useProviderActions(\"claude\"), {\n      wrapper,\n    });\n\n    await act(async () => {\n      await result.current.switchProvider(provider);\n    });\n\n    expect(settingsApiGetMock).toHaveBeenCalledTimes(1);\n    expect(settingsApiApplyMock).not.toHaveBeenCalled();\n  });\n\n  it(\"should show error toast when plugin sync fails with error message\", async () => {\n    switchProviderMutateAsync.mockResolvedValueOnce(undefined);\n    settingsApiGetMock.mockResolvedValueOnce({\n      enableClaudePluginIntegration: true,\n    });\n    settingsApiApplyMock.mockRejectedValueOnce(new Error(\"Sync failed\"));\n    const { wrapper } = createWrapper();\n    const provider = createProvider();\n\n    const { result } = renderHook(() => useProviderActions(\"claude\"), {\n      wrapper,\n    });\n\n    await act(async () => {\n      await result.current.switchProvider(provider);\n    });\n\n    expect(toastErrorMock).toHaveBeenCalledTimes(1);\n    expect(toastErrorMock.mock.calls[0]?.[0]).toBe(\"Sync failed\");\n  });\n\n  it(\"propagates updateProvider errors\", async () => {\n    updateProviderMutateAsync.mockRejectedValueOnce(new Error(\"update failed\"));\n    const { wrapper } = createWrapper();\n    const provider = createProvider();\n\n    const { result } = renderHook(() => useProviderActions(\"claude\"), {\n      wrapper,\n    });\n\n    await expect(\n      act(async () => {\n        await result.current.updateProvider(provider);\n      }),\n    ).rejects.toThrow(\"update failed\");\n  });\n\n  it(\"should use default error message when plugin sync fails without error message\", async () => {\n    switchProviderMutateAsync.mockResolvedValueOnce(undefined);\n    settingsApiGetMock.mockResolvedValueOnce({\n      enableClaudePluginIntegration: true,\n    });\n    settingsApiApplyMock.mockRejectedValueOnce(new Error(\"\"));\n    const { wrapper } = createWrapper();\n    const provider = createProvider();\n\n    const { result } = renderHook(() => useProviderActions(\"claude\"), {\n      wrapper,\n    });\n\n    await act(async () => {\n      await result.current.switchProvider(provider);\n    });\n\n    expect(toastErrorMock).toHaveBeenCalledTimes(1);\n    expect(toastErrorMock.mock.calls[0]?.[0]).toBe(\"同步 Claude 插件失败\");\n  });\n\n  it(\"handles mutation errors when plugin sync is skipped\", async () => {\n    switchProviderMutateAsync.mockRejectedValueOnce(new Error(\"switch failed\"));\n    const { wrapper } = createWrapper();\n    const provider = createProvider();\n\n    const { result } = renderHook(() => useProviderActions(\"codex\"), {\n      wrapper,\n    });\n\n    await expect(\n      result.current.switchProvider(provider),\n    ).resolves.toBeUndefined();\n    expect(settingsApiGetMock).not.toHaveBeenCalled();\n    expect(settingsApiApplyMock).not.toHaveBeenCalled();\n  });\n\n  it(\"should call delete mutation when calling deleteProvider\", async () => {\n    deleteProviderMutateAsync.mockResolvedValueOnce(undefined);\n    const { wrapper } = createWrapper();\n\n    const { result } = renderHook(() => useProviderActions(\"claude\"), {\n      wrapper,\n    });\n\n    await act(async () => {\n      await result.current.deleteProvider(\"provider-2\");\n    });\n\n    expect(deleteProviderMutateAsync).toHaveBeenCalledWith(\"provider-2\");\n  });\n\n  it(\"should update provider and refresh cache when saveUsageScript succeeds\", async () => {\n    providersApiUpdateMock.mockResolvedValueOnce(true);\n    const { wrapper, queryClient } = createWrapper();\n    const invalidateSpy = vi.spyOn(queryClient, \"invalidateQueries\");\n\n    const provider = createProvider({\n      meta: {\n        usage_script: {\n          enabled: false,\n          language: \"javascript\",\n          code: \"\",\n        },\n      },\n    });\n\n    const script: UsageScript = {\n      enabled: true,\n      language: \"javascript\",\n      code: \"return { success: true };\",\n      timeout: 5,\n    };\n\n    const { result } = renderHook(() => useProviderActions(\"claude\"), {\n      wrapper,\n    });\n\n    await act(async () => {\n      await result.current.saveUsageScript(provider, script);\n    });\n\n    expect(providersApiUpdateMock).toHaveBeenCalledWith(\n      {\n        ...provider,\n        meta: {\n          ...provider.meta,\n          usage_script: script,\n        },\n      },\n      \"claude\",\n    );\n    expect(invalidateSpy).toHaveBeenCalledWith({\n      queryKey: [\"providers\", \"claude\"],\n    });\n    expect(toastSuccessMock).toHaveBeenCalledTimes(1);\n  });\n\n  it(\"should show error toast when saveUsageScript fails with error message\", async () => {\n    providersApiUpdateMock.mockRejectedValueOnce(new Error(\"Save failed\"));\n    const { wrapper } = createWrapper();\n    const provider = createProvider();\n    const script: UsageScript = {\n      enabled: true,\n      language: \"javascript\",\n      code: \"return {}\",\n    };\n\n    const { result } = renderHook(() => useProviderActions(\"claude\"), {\n      wrapper,\n    });\n\n    await act(async () => {\n      await result.current.saveUsageScript(provider, script);\n    });\n\n    expect(toastErrorMock).toHaveBeenCalledTimes(1);\n    expect(toastErrorMock.mock.calls[0]?.[0]).toBe(\"Save failed\");\n  });\n\n  it(\"should use default error message when saveUsageScript fails without error message\", async () => {\n    providersApiUpdateMock.mockRejectedValueOnce(new Error(\"\"));\n    const { wrapper } = createWrapper();\n    const provider = createProvider();\n    const script: UsageScript = {\n      enabled: true,\n      language: \"javascript\",\n      code: \"return {}\",\n    };\n\n    const { result } = renderHook(() => useProviderActions(\"claude\"), {\n      wrapper,\n    });\n\n    await act(async () => {\n      await result.current.saveUsageScript(provider, script);\n    });\n\n    expect(toastErrorMock).toHaveBeenCalledTimes(1);\n    expect(toastErrorMock.mock.calls[0]?.[0]).toBe(\"用量查询配置保存失败\");\n  });\n\n  it(\"propagates addProvider errors to caller\", async () => {\n    addProviderMutateAsync.mockRejectedValueOnce(new Error(\"add failed\"));\n    const { wrapper } = createWrapper();\n\n    const { result } = renderHook(() => useProviderActions(\"claude\"), {\n      wrapper,\n    });\n\n    await expect(\n      act(async () => {\n        await result.current.addProvider({\n          name: \"temp\",\n          settingsConfig: {},\n        } as Omit<Provider, \"id\">);\n      }),\n    ).rejects.toThrow(\"add failed\");\n  });\n\n  it(\"propagates deleteProvider errors to caller\", async () => {\n    deleteProviderMutateAsync.mockRejectedValueOnce(new Error(\"delete failed\"));\n    const { wrapper } = createWrapper();\n\n    const { result } = renderHook(() => useProviderActions(\"claude\"), {\n      wrapper,\n    });\n\n    await expect(\n      act(async () => {\n        await result.current.deleteProvider(\"provider-2\");\n      }),\n    ).rejects.toThrow(\"delete failed\");\n  });\n\n  it(\"handles switch mutation errors silently\", async () => {\n    switchProviderMutateAsync.mockRejectedValueOnce(new Error(\"switch failed\"));\n    const { wrapper } = createWrapper();\n    const provider = createProvider();\n\n    const { result } = renderHook(() => useProviderActions(\"claude\"), {\n      wrapper,\n    });\n\n    await result.current.switchProvider(provider);\n\n    expect(settingsApiGetMock).not.toHaveBeenCalled();\n    expect(settingsApiApplyMock).not.toHaveBeenCalled();\n  });\n\n  it(\"should track pending state of all mutations in isLoading\", () => {\n    addProviderMutation.isPending = true;\n    const { wrapper } = createWrapper();\n\n    const { result } = renderHook(() => useProviderActions(\"claude\"), {\n      wrapper,\n    });\n\n    expect(result.current.isLoading).toBe(true);\n  });\n\n  it(\"does not show backup details when setting OpenClaw default model\", async () => {\n    openclawApiSetDefaultModelMock.mockResolvedValueOnce({\n      backupPath: \"/tmp/openclaw-backup.json5\",\n      warnings: [],\n    });\n\n    const { wrapper } = createWrapper();\n    const provider = createProvider({\n      settingsConfig: {\n        models: [{ id: \"gpt-4.1\" }, { id: \"gpt-4.1-mini\" }],\n      },\n    });\n\n    const { result } = renderHook(() => useProviderActions(\"openclaw\"), {\n      wrapper,\n    });\n\n    await act(async () => {\n      await result.current.setAsDefaultModel(provider);\n    });\n\n    expect(openclawApiSetDefaultModelMock).toHaveBeenCalledWith({\n      primary: \"provider-1/gpt-4.1\",\n      fallbacks: [\"provider-1/gpt-4.1-mini\"],\n    });\n    expect(toastSuccessMock).toHaveBeenCalledTimes(1);\n    expect(toastSuccessMock.mock.calls[0]?.[1]).toEqual({ closeButton: true });\n  });\n});\nit(\"clears loading flag when all mutations idle\", () => {\n  addProviderMutation.isPending = false;\n  updateProviderMutation.isPending = false;\n  deleteProviderMutation.isPending = false;\n  switchProviderMutation.isPending = false;\n\n  const { wrapper } = createWrapper();\n  const { result } = renderHook(() => useProviderActions(\"claude\"), {\n    wrapper,\n  });\n\n  expect(result.current.isLoading).toBe(false);\n});\n"
  },
  {
    "path": "tests/hooks/useProxyStatus.test.tsx",
    "content": "import type { ReactNode } from \"react\";\nimport { renderHook, act, waitFor } from \"@testing-library/react\";\nimport { QueryClientProvider } from \"@tanstack/react-query\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\nimport { useProxyStatus } from \"@/hooks/useProxyStatus\";\nimport { createTestQueryClient } from \"../utils/testQueryClient\";\n\nconst toastSuccessMock = vi.fn();\nconst toastErrorMock = vi.fn();\nconst invokeMock = vi.fn();\n\nvi.mock(\"sonner\", () => ({\n  toast: {\n    success: (...args: unknown[]) => toastSuccessMock(...args),\n    error: (...args: unknown[]) => toastErrorMock(...args),\n  },\n}));\n\nvi.mock(\"@tauri-apps/api/core\", () => ({\n  invoke: (...args: unknown[]) => invokeMock(...args),\n}));\n\nvi.mock(\"react-i18next\", () => ({\n  useTranslation: () => ({\n    t: (key: string, options?: Record<string, unknown>) => {\n      if (key === \"proxy.server.started\") {\n        return `代理服务已启动 - ${options?.address}:${options?.port}`;\n      }\n\n      if (typeof options?.defaultValue === \"string\") {\n        return options.defaultValue;\n      }\n\n      return key;\n    },\n  }),\n}));\n\ninterface WrapperProps {\n  children: ReactNode;\n}\n\nfunction createWrapper() {\n  const queryClient = createTestQueryClient();\n\n  const wrapper = ({ children }: WrapperProps) => (\n    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>\n  );\n\n  return { wrapper, queryClient };\n}\n\ndescribe(\"useProxyStatus\", () => {\n  beforeEach(() => {\n    invokeMock.mockReset();\n    toastSuccessMock.mockReset();\n    toastErrorMock.mockReset();\n\n    invokeMock.mockImplementation((command: string) => {\n      if (command === \"get_proxy_status\") {\n        return Promise.resolve({\n          running: false,\n          address: \"127.0.0.1\",\n          port: 15721,\n          active_connections: 0,\n          total_requests: 0,\n          success_requests: 0,\n          failed_requests: 0,\n          success_rate: 0,\n          uptime_seconds: 0,\n          current_provider: null,\n          current_provider_id: null,\n          last_request_at: null,\n          last_error: null,\n          failover_count: 0,\n        });\n      }\n\n      if (command === \"get_proxy_takeover_status\") {\n        return Promise.resolve({\n          claude: false,\n          codex: false,\n          gemini: false,\n          opencode: false,\n          openclaw: false,\n        });\n      }\n\n      if (command === \"start_proxy_server\") {\n        return Promise.resolve({\n          address: \"127.0.0.1\",\n          port: 15721,\n          started_at: \"2026-03-10T00:00:00Z\",\n        });\n      }\n\n      return Promise.resolve(null);\n    });\n  });\n\n  it(\"shows interpolated address and port after proxy server starts\", async () => {\n    const { wrapper } = createWrapper();\n    const { result } = renderHook(() => useProxyStatus(), { wrapper });\n\n    await waitFor(() => {\n      expect(result.current.isLoading).toBe(false);\n    });\n\n    await act(async () => {\n      await result.current.startProxyServer();\n    });\n\n    expect(toastSuccessMock).toHaveBeenCalledWith(\n      \"代理服务已启动 - 127.0.0.1:15721\",\n      { closeButton: true },\n    );\n  });\n});"
  },
  {
    "path": "tests/hooks/useSettings.test.tsx",
    "content": "import { renderHook, act } from \"@testing-library/react\";\nimport { describe, it, expect, beforeEach, vi } from \"vitest\";\nimport { useSettings } from \"@/hooks/useSettings\";\nimport type { Settings } from \"@/types\";\n\nconst mutateAsyncMock = vi.fn();\nconst useSettingsQueryMock = vi.fn();\nconst setAppConfigDirOverrideMock = vi.fn();\nconst applyClaudePluginConfigMock = vi.fn();\nconst applyClaudeOnboardingSkipMock = vi.fn();\nconst clearClaudeOnboardingSkipMock = vi.fn();\nconst syncCurrentProvidersLiveMock = vi.fn();\nconst updateTrayMenuMock = vi.fn();\nconst toastErrorMock = vi.fn();\nconst toastSuccessMock = vi.fn();\n\nlet settingsFormMock: any;\nlet directorySettingsMock: any;\nlet metadataMock: any;\nlet serverSettings: Settings;\n\nvi.mock(\"sonner\", () => ({\n  toast: {\n    error: (...args: unknown[]) => toastErrorMock(...args),\n    success: (...args: unknown[]) => toastSuccessMock(...args),\n  },\n}));\n\nvi.mock(\"@/hooks/useSettingsForm\", () => ({\n  useSettingsForm: () => settingsFormMock,\n}));\n\nvi.mock(\"@/hooks/useDirectorySettings\", () => ({\n  useDirectorySettings: () => directorySettingsMock,\n}));\n\nvi.mock(\"@/hooks/useSettingsMetadata\", () => ({\n  useSettingsMetadata: () => metadataMock,\n}));\n\nvi.mock(\"@/lib/query\", () => ({\n  useSettingsQuery: (...args: unknown[]) => useSettingsQueryMock(...args),\n  useSaveSettingsMutation: () => ({\n    mutateAsync: mutateAsyncMock,\n    isPending: false,\n  }),\n}));\n\nvi.mock(\"@/lib/api\", () => ({\n  settingsApi: {\n    setAppConfigDirOverride: (...args: unknown[]) =>\n      setAppConfigDirOverrideMock(...args),\n    applyClaudePluginConfig: (...args: unknown[]) =>\n      applyClaudePluginConfigMock(...args),\n    applyClaudeOnboardingSkip: (...args: unknown[]) =>\n      applyClaudeOnboardingSkipMock(...args),\n    clearClaudeOnboardingSkip: (...args: unknown[]) =>\n      clearClaudeOnboardingSkipMock(...args),\n    syncCurrentProvidersLive: (...args: unknown[]) =>\n      syncCurrentProvidersLiveMock(...args),\n  },\n  providersApi: {\n    updateTrayMenu: (...args: unknown[]) => updateTrayMenuMock(...args),\n  },\n}));\n\nconst createSettingsFormMock = (overrides: Record<string, unknown> = {}) => ({\n  settings: {\n    showInTray: true,\n    minimizeToTrayOnClose: true,\n    enableClaudePluginIntegration: false,\n    skipClaudeOnboarding: true,\n    claudeConfigDir: \"/claude\",\n    codexConfigDir: \"/codex\",\n    language: \"zh\",\n  },\n  isLoading: false,\n  initialLanguage: \"zh\",\n  updateSettings: vi.fn(),\n  resetSettings: vi.fn(),\n  syncLanguage: vi.fn(),\n  ...overrides,\n});\n\nconst createDirectorySettingsMock = (\n  overrides: Record<string, unknown> = {},\n) => ({\n  appConfigDir: undefined,\n  resolvedDirs: {\n    appConfig: \"/home/mock/.cc-switch\",\n    claude: \"/default/claude\",\n    codex: \"/default/codex\",\n  },\n  isLoading: false,\n  initialAppConfigDir: undefined,\n  updateDirectory: vi.fn(),\n  updateAppConfigDir: vi.fn(),\n  browseDirectory: vi.fn(),\n  browseAppConfigDir: vi.fn(),\n  resetDirectory: vi.fn(),\n  resetAppConfigDir: vi.fn(),\n  resetAllDirectories: vi.fn(),\n  ...overrides,\n});\n\nconst createMetadataMock = (overrides: Record<string, unknown> = {}) => ({\n  isPortable: false,\n  requiresRestart: false,\n  isLoading: false,\n  acknowledgeRestart: vi.fn(),\n  setRequiresRestart: vi.fn(),\n  ...overrides,\n});\n\ndescribe(\"useSettings hook\", () => {\n  beforeEach(() => {\n    mutateAsyncMock.mockReset();\n    useSettingsQueryMock.mockReset();\n    setAppConfigDirOverrideMock.mockReset();\n    applyClaudePluginConfigMock.mockReset();\n    applyClaudeOnboardingSkipMock.mockReset();\n    clearClaudeOnboardingSkipMock.mockReset();\n    syncCurrentProvidersLiveMock.mockReset();\n    toastErrorMock.mockReset();\n    toastSuccessMock.mockReset();\n    window.localStorage.clear();\n\n    serverSettings = {\n      showInTray: true,\n      minimizeToTrayOnClose: true,\n      enableClaudePluginIntegration: false,\n      skipClaudeOnboarding: true,\n      claudeConfigDir: \"/server/claude\",\n      codexConfigDir: \"/server/codex\",\n      language: \"zh\",\n    };\n\n    useSettingsQueryMock.mockReturnValue({\n      data: serverSettings,\n      isLoading: false,\n    });\n\n    settingsFormMock = createSettingsFormMock({\n      settings: {\n        ...serverSettings,\n        language: \"zh\",\n      },\n    });\n    directorySettingsMock = createDirectorySettingsMock();\n    metadataMock = createMetadataMock();\n\n    mutateAsyncMock.mockResolvedValue(true);\n    setAppConfigDirOverrideMock.mockResolvedValue(true);\n    applyClaudePluginConfigMock.mockResolvedValue(true);\n    applyClaudeOnboardingSkipMock.mockResolvedValue(true);\n    clearClaudeOnboardingSkipMock.mockResolvedValue(true);\n  });\n\n  it(\"auto-saves and applies Claude onboarding skip when toggled on\", async () => {\n    serverSettings = {\n      ...serverSettings,\n      skipClaudeOnboarding: false,\n    };\n    useSettingsQueryMock.mockReturnValue({\n      data: serverSettings,\n      isLoading: false,\n    });\n\n    settingsFormMock = createSettingsFormMock({\n      settings: {\n        ...serverSettings,\n        language: \"zh\",\n        skipClaudeOnboarding: false,\n      },\n    });\n\n    const { result } = renderHook(() => useSettings());\n\n    await act(async () => {\n      await result.current.autoSaveSettings({ skipClaudeOnboarding: true });\n    });\n\n    expect(applyClaudeOnboardingSkipMock).toHaveBeenCalledTimes(1);\n    expect(toastErrorMock).not.toHaveBeenCalled();\n  });\n\n  it(\"auto-saves and clears Claude onboarding skip when toggled off\", async () => {\n    serverSettings = {\n      ...serverSettings,\n      skipClaudeOnboarding: true,\n    };\n    useSettingsQueryMock.mockReturnValue({\n      data: serverSettings,\n      isLoading: false,\n    });\n\n    settingsFormMock = createSettingsFormMock({\n      settings: {\n        ...serverSettings,\n        language: \"zh\",\n        skipClaudeOnboarding: true,\n      },\n    });\n\n    const { result } = renderHook(() => useSettings());\n\n    await act(async () => {\n      await result.current.autoSaveSettings({ skipClaudeOnboarding: false });\n    });\n\n    expect(clearClaudeOnboardingSkipMock).toHaveBeenCalledTimes(1);\n    expect(toastErrorMock).not.toHaveBeenCalled();\n  });\n\n  it(\"saves settings and flags restart when app config directory changes\", async () => {\n    serverSettings = {\n      ...serverSettings,\n      enableClaudePluginIntegration: false,\n      claudeConfigDir: \"/server/claude\",\n      codexConfigDir: undefined,\n      language: \"en\",\n    };\n    useSettingsQueryMock.mockReturnValue({\n      data: serverSettings,\n      isLoading: false,\n    });\n\n    settingsFormMock = createSettingsFormMock({\n      settings: {\n        ...serverSettings,\n        claudeConfigDir: \"  /custom/claude  \",\n        codexConfigDir: \"   \",\n        language: \"en\",\n        enableClaudePluginIntegration: true, // 状态从 false 变为 true\n      },\n      initialLanguage: \"en\",\n    });\n\n    directorySettingsMock = createDirectorySettingsMock({\n      appConfigDir: \"  /override/app  \",\n      initialAppConfigDir: \"/previous/app\",\n    });\n\n    const { result } = renderHook(() => useSettings());\n\n    let saveResult: { requiresRestart: boolean } | null = null;\n    await act(async () => {\n      saveResult = await result.current.saveSettings();\n    });\n\n    expect(saveResult).toEqual({ requiresRestart: true });\n    expect(mutateAsyncMock).toHaveBeenCalledTimes(1);\n    const payload = mutateAsyncMock.mock.calls[0][0] as Settings;\n    expect(payload.claudeConfigDir).toBe(\"/custom/claude\");\n    expect(payload.codexConfigDir).toBeUndefined();\n    expect(payload.language).toBe(\"en\");\n    expect(setAppConfigDirOverrideMock).toHaveBeenCalledWith(\"/override/app\");\n    // 状态改变，应该调用 API\n    expect(applyClaudePluginConfigMock).toHaveBeenCalledWith({\n      official: false,\n    });\n    expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(true);\n    expect(window.localStorage.getItem(\"language\")).toBe(\"en\");\n    expect(toastErrorMock).not.toHaveBeenCalled();\n    // 目录有变化，应触发一次同步当前供应商到 live\n    expect(syncCurrentProvidersLiveMock).toHaveBeenCalledTimes(1);\n  });\n\n  it(\"saves settings without restart when directory unchanged\", async () => {\n    // 确保服务器和本地状态一致，不触发 API 调用\n    serverSettings = {\n      ...serverSettings,\n      enableClaudePluginIntegration: false,\n      launchOnStartup: false,\n    };\n    useSettingsQueryMock.mockReturnValue({\n      data: serverSettings,\n      isLoading: false,\n    });\n\n    settingsFormMock = createSettingsFormMock({\n      settings: {\n        ...serverSettings,\n        enableClaudePluginIntegration: false, // 状态未变\n        launchOnStartup: false, // 状态未变\n        language: \"zh\",\n      },\n      initialLanguage: \"zh\",\n    });\n\n    directorySettingsMock = createDirectorySettingsMock({\n      appConfigDir: undefined,\n      initialAppConfigDir: undefined,\n    });\n\n    const { result } = renderHook(() => useSettings());\n\n    let saveResult: { requiresRestart: boolean } | null = null;\n    await act(async () => {\n      saveResult = await result.current.saveSettings();\n    });\n\n    expect(saveResult).toEqual({ requiresRestart: false });\n    expect(setAppConfigDirOverrideMock).toHaveBeenCalledWith(null);\n    // 状态未改变，不应调用 API\n    expect(applyClaudePluginConfigMock).not.toHaveBeenCalled();\n    expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(false);\n    // 目录未变化，不应触发同步\n    expect(syncCurrentProvidersLiveMock).not.toHaveBeenCalled();\n  });\n\n  it(\"shows toast when Claude plugin sync fails but continues flow\", async () => {\n    // 设置服务器状态为 false,本地状态为 true,触发状态变化\n    serverSettings = {\n      ...serverSettings,\n      enableClaudePluginIntegration: false,\n    };\n    useSettingsQueryMock.mockReturnValue({\n      data: serverSettings,\n      isLoading: false,\n    });\n\n    settingsFormMock = createSettingsFormMock({\n      settings: {\n        ...serverSettings,\n        enableClaudePluginIntegration: true, // 状态改变\n        language: \"zh\",\n      },\n    });\n    directorySettingsMock = createDirectorySettingsMock({\n      appConfigDir: \"/override/app\",\n      initialAppConfigDir: \"/prior/app\",\n    });\n\n    applyClaudePluginConfigMock.mockRejectedValueOnce(new Error(\"sync failed\"));\n\n    const { result } = renderHook(() => useSettings());\n\n    await act(async () => {\n      await result.current.saveSettings();\n    });\n\n    expect(toastErrorMock).toHaveBeenCalled();\n    const message = toastErrorMock.mock.calls.at(-1)?.[0] as string;\n    expect(message).toContain(\"同步 Claude 插件失败\");\n    expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(true);\n  });\n\n  it(\"resets form, language and directories using server data\", () => {\n    serverSettings = {\n      ...serverSettings,\n      claudeConfigDir: \"  /server/claude  \",\n      codexConfigDir: \"   \",\n      language: \"zh\",\n    };\n    useSettingsQueryMock.mockReturnValue({\n      data: serverSettings,\n      isLoading: false,\n    });\n\n    settingsFormMock = createSettingsFormMock({\n      settings: {\n        ...serverSettings,\n        language: \"zh\",\n      },\n      initialLanguage: \"zh\",\n    });\n    directorySettingsMock = createDirectorySettingsMock();\n\n    const { result } = renderHook(() => useSettings());\n\n    act(() => {\n      result.current.resetSettings();\n    });\n\n    expect(settingsFormMock.resetSettings).toHaveBeenCalledWith(serverSettings);\n    expect(settingsFormMock.syncLanguage).toHaveBeenCalledWith(\n      settingsFormMock.initialLanguage,\n    );\n    expect(directorySettingsMock.resetAllDirectories).toHaveBeenCalledWith(\n      \"/server/claude\",\n      undefined,\n      undefined, // geminiConfigDir\n      undefined, // opencodeConfigDir\n    );\n    expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(false);\n  });\n\n  it(\"returns null immediately when settings state is missing\", async () => {\n    settingsFormMock = createSettingsFormMock({\n      settings: null,\n    });\n\n    const { result } = renderHook(() => useSettings());\n\n    let resultValue: { requiresRestart: boolean } | null = null;\n    await act(async () => {\n      resultValue = await result.current.saveSettings();\n    });\n\n    expect(resultValue).toBeNull();\n    expect(mutateAsyncMock).not.toHaveBeenCalled();\n    expect(setAppConfigDirOverrideMock).not.toHaveBeenCalled();\n  });\n\n  it(\"throws when save mutation rejects and keeps restart flag untouched\", async () => {\n    settingsFormMock = createSettingsFormMock();\n    directorySettingsMock = createDirectorySettingsMock({\n      appConfigDir: \"/override/app\",\n      initialAppConfigDir: \"/override/app\",\n    });\n    const rejection = new Error(\"save failed\");\n    mutateAsyncMock.mockRejectedValueOnce(rejection);\n\n    const { result } = renderHook(() => useSettings());\n\n    await expect(\n      act(async () => {\n        await result.current.saveSettings();\n      }),\n    ).rejects.toThrow(\"save failed\");\n\n    expect(setAppConfigDirOverrideMock).not.toHaveBeenCalled();\n    expect(metadataMock.setRequiresRestart).not.toHaveBeenCalledWith(true);\n  });\n});\n"
  },
  {
    "path": "tests/hooks/useSettingsForm.test.tsx",
    "content": "import { renderHook, act, waitFor } from \"@testing-library/react\";\nimport { describe, it, expect, vi, beforeEach, afterEach } from \"vitest\";\nimport i18n from \"i18next\";\nimport { useSettingsForm } from \"@/hooks/useSettingsForm\";\n\nconst useSettingsQueryMock = vi.fn();\n\nvi.mock(\"@/lib/query\", () => ({\n  useSettingsQuery: (...args: unknown[]) => useSettingsQueryMock(...args),\n}));\n\nlet changeLanguageSpy: ReturnType<typeof vi.spyOn<any, any>>;\n\nbeforeEach(() => {\n  useSettingsQueryMock.mockReset();\n  window.localStorage.clear();\n  (i18n as any).language = \"zh\";\n  changeLanguageSpy = vi\n    .spyOn(i18n, \"changeLanguage\")\n    .mockImplementation(async (lang?: string) => {\n      (i18n as any).language = lang;\n      return i18n.t;\n    });\n});\n\nafterEach(() => {\n  changeLanguageSpy.mockRestore();\n});\n\ndescribe(\"useSettingsForm Hook\", () => {\n  it(\"should normalize settings and sync language on initialization\", async () => {\n    useSettingsQueryMock.mockReturnValue({\n      data: {\n        showInTray: undefined,\n        minimizeToTrayOnClose: undefined,\n        enableClaudePluginIntegration: undefined,\n        claudeConfigDir: \"  /Users/demo  \",\n        codexConfigDir: \"   \",\n        language: \"en\",\n      },\n      isLoading: false,\n    });\n\n    const { result } = renderHook(() => useSettingsForm());\n\n    await waitFor(() => {\n      expect(result.current.settings).not.toBeNull();\n    });\n\n    const settings = result.current.settings!;\n    expect(settings.showInTray).toBe(true);\n    expect(settings.minimizeToTrayOnClose).toBe(true);\n    expect(settings.enableClaudePluginIntegration).toBe(false);\n    expect(settings.claudeConfigDir).toBe(\"/Users/demo\");\n    expect(settings.codexConfigDir).toBeUndefined();\n    expect(settings.language).toBe(\"en\");\n    expect(result.current.initialLanguage).toBe(\"en\");\n    expect(changeLanguageSpy).toHaveBeenCalledWith(\"en\");\n  });\n\n  it(\"should support japanese language preference from server data\", async () => {\n    useSettingsQueryMock.mockReturnValue({\n      data: {\n        showInTray: true,\n        minimizeToTrayOnClose: true,\n        enableClaudePluginIntegration: false,\n        claudeConfigDir: \"/Users/demo\",\n        codexConfigDir: null,\n        language: \"ja\",\n      },\n      isLoading: false,\n    });\n\n    const { result } = renderHook(() => useSettingsForm());\n\n    await waitFor(() => {\n      expect(result.current.settings?.language).toBe(\"ja\");\n    });\n\n    expect(result.current.initialLanguage).toBe(\"ja\");\n    expect(changeLanguageSpy).toHaveBeenCalledWith(\"ja\");\n  });\n\n  it(\"should prioritize reading language from local storage in readPersistedLanguage\", () => {\n    useSettingsQueryMock.mockReturnValue({\n      data: null,\n      isLoading: false,\n    });\n    window.localStorage.setItem(\"language\", \"en\");\n\n    const { result } = renderHook(() => useSettingsForm());\n\n    const lang = result.current.readPersistedLanguage();\n    expect(lang).toBe(\"en\");\n    expect(changeLanguageSpy).not.toHaveBeenCalled();\n  });\n\n  it(\"should update fields and sync language when language changes in updateSettings\", () => {\n    useSettingsQueryMock.mockReturnValue({\n      data: null,\n      isLoading: false,\n    });\n\n    const { result } = renderHook(() => useSettingsForm());\n\n    act(() => {\n      result.current.updateSettings({ showInTray: false });\n    });\n\n    expect(result.current.settings?.showInTray).toBe(false);\n\n    changeLanguageSpy.mockClear();\n    act(() => {\n      result.current.updateSettings({ language: \"en\" });\n    });\n\n    expect(result.current.settings?.language).toBe(\"en\");\n    expect(changeLanguageSpy).toHaveBeenCalledWith(\"en\");\n  });\n\n  it(\"should reset with server data and restore initial language in resetSettings\", async () => {\n    useSettingsQueryMock.mockReturnValue({\n      data: {\n        showInTray: true,\n        minimizeToTrayOnClose: true,\n        enableClaudePluginIntegration: false,\n        claudeConfigDir: \"/origin\",\n        codexConfigDir: null,\n        language: \"en\",\n      },\n      isLoading: false,\n    });\n\n    const { result } = renderHook(() => useSettingsForm());\n\n    await waitFor(() => {\n      expect(result.current.settings).not.toBeNull();\n    });\n\n    changeLanguageSpy.mockClear();\n    (i18n as any).language = \"zh\";\n\n    act(() => {\n      result.current.resetSettings({\n        showInTray: false,\n        minimizeToTrayOnClose: false,\n        enableClaudePluginIntegration: true,\n        claudeConfigDir: \"  /reset  \",\n        codexConfigDir: \"   \",\n        language: \"zh\",\n      });\n    });\n\n    const settings = result.current.settings!;\n    expect(settings.showInTray).toBe(false);\n    expect(settings.minimizeToTrayOnClose).toBe(false);\n    expect(settings.enableClaudePluginIntegration).toBe(true);\n    expect(settings.claudeConfigDir).toBe(\"/reset\");\n    expect(settings.codexConfigDir).toBeUndefined();\n    expect(settings.language).toBe(\"zh\");\n    expect(result.current.initialLanguage).toBe(\"en\");\n    expect(changeLanguageSpy).toHaveBeenCalledWith(\"en\");\n  });\n\n  it(\"should not call changeLanguage repeatedly when language is consistent in syncLanguage\", async () => {\n    useSettingsQueryMock.mockReturnValue({\n      data: {\n        showInTray: true,\n        minimizeToTrayOnClose: true,\n        enableClaudePluginIntegration: false,\n        claudeConfigDir: null,\n        codexConfigDir: null,\n        language: \"zh\",\n      },\n      isLoading: false,\n    });\n\n    const { result } = renderHook(() => useSettingsForm());\n\n    await waitFor(() => {\n      expect(result.current.settings).not.toBeNull();\n    });\n\n    changeLanguageSpy.mockClear();\n    (i18n as any).language = \"zh\";\n\n    act(() => {\n      result.current.syncLanguage(\"zh\");\n    });\n\n    expect(changeLanguageSpy).not.toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "tests/hooks/useSettingsMetadata.test.tsx",
    "content": "import { renderHook, act } from \"@testing-library/react\";\nimport { describe, it, expect, beforeEach, vi } from \"vitest\";\nimport { useSettingsMetadata } from \"@/hooks/useSettingsMetadata\";\n\nconst isPortableMock = vi.hoisted(() => vi.fn());\n\nvi.mock(\"@/lib/api\", () => ({\n  settingsApi: {\n    isPortable: (...args: unknown[]) => isPortableMock(...args),\n  },\n}));\n\ndescribe(\"useSettingsMetadata\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"loads portable flag and handles success path\", async () => {\n    isPortableMock.mockResolvedValue(true);\n\n    const { result } = renderHook(() => useSettingsMetadata());\n\n    expect(result.current.isLoading).toBe(true);\n    expect(result.current.isPortable).toBe(false);\n\n    await act(async () => {\n      await Promise.resolve();\n    });\n\n    expect(result.current.isPortable).toBe(true);\n    expect(result.current.isLoading).toBe(false);\n  });\n\n  it(\"handles errors from settingsApi and proceeds\", async () => {\n    isPortableMock.mockRejectedValue(new Error(\"network failure\"));\n\n    const { result } = renderHook(() => useSettingsMetadata());\n\n    await act(async () => {\n      await Promise.resolve();\n    });\n\n    expect(result.current.isPortable).toBe(false);\n    expect(result.current.isLoading).toBe(false);\n  });\n\n  it(\"allows updating restart flag via setters\", async () => {\n    isPortableMock.mockResolvedValue(false);\n\n    const { result } = renderHook(() => useSettingsMetadata());\n\n    await act(async () => {\n      await Promise.resolve();\n    });\n\n    await act(async () => {\n      result.current.setRequiresRestart(true);\n      await Promise.resolve();\n    });\n\n    expect(result.current.requiresRestart).toBe(true);\n\n    await act(async () => {\n      result.current.acknowledgeRestart();\n      await Promise.resolve();\n    });\n\n    expect(result.current.requiresRestart).toBe(false);\n  });\n});\n"
  },
  {
    "path": "tests/integration/App.test.tsx",
    "content": "import { Suspense, type ComponentType } from \"react\";\nimport { QueryClient, QueryClientProvider } from \"@tanstack/react-query\";\nimport { render, screen, waitFor, fireEvent } from \"@testing-library/react\";\nimport { describe, it, expect, beforeEach, vi } from \"vitest\";\nimport { resetProviderState } from \"../msw/state\";\nimport { emitTauriEvent } from \"../msw/tauriMocks\";\n\nconst toastSuccessMock = vi.fn();\nconst toastErrorMock = vi.fn();\n\nvi.mock(\"sonner\", () => ({\n  toast: {\n    success: (...args: unknown[]) => toastSuccessMock(...args),\n    error: (...args: unknown[]) => toastErrorMock(...args),\n  },\n}));\n\nvi.mock(\"@/components/providers/ProviderList\", () => ({\n  ProviderList: ({\n    providers,\n    currentProviderId,\n    onSwitch,\n    onEdit,\n    onDuplicate,\n    onConfigureUsage,\n    onOpenWebsite,\n    onCreate,\n  }: any) => (\n    <div>\n      <div data-testid=\"provider-list\">{JSON.stringify(providers)}</div>\n      <div data-testid=\"current-provider\">{currentProviderId}</div>\n      <button onClick={() => onSwitch(providers[currentProviderId])}>\n        switch\n      </button>\n      <button onClick={() => onEdit(providers[currentProviderId])}>edit</button>\n      <button onClick={() => onDuplicate(providers[currentProviderId])}>\n        duplicate\n      </button>\n      <button onClick={() => onConfigureUsage(providers[currentProviderId])}>\n        usage\n      </button>\n      <button onClick={() => onOpenWebsite(\"https://example.com\")}>\n        open-website\n      </button>\n      <button onClick={() => onCreate?.()}>create</button>\n    </div>\n  ),\n}));\n\nvi.mock(\"@/components/providers/AddProviderDialog\", () => ({\n  AddProviderDialog: ({ open, onOpenChange, onSubmit, appId }: any) =>\n    open ? (\n      <div data-testid=\"add-provider-dialog\">\n        <button\n          onClick={() =>\n            onSubmit({\n              name: `New ${appId} Provider`,\n              settingsConfig: {},\n              category: \"custom\",\n              sortIndex: 99,\n            })\n          }\n        >\n          confirm-add\n        </button>\n        <button onClick={() => onOpenChange(false)}>close-add</button>\n      </div>\n    ) : null,\n}));\n\nvi.mock(\"@/components/providers/EditProviderDialog\", () => ({\n  EditProviderDialog: ({ open, provider, onSubmit, onOpenChange }: any) =>\n    open ? (\n      <div data-testid=\"edit-provider-dialog\">\n        <button\n          onClick={() =>\n            onSubmit({\n              ...provider,\n              name: `${provider.name}-edited`,\n            })\n          }\n        >\n          confirm-edit\n        </button>\n        <button onClick={() => onOpenChange(false)}>close-edit</button>\n      </div>\n    ) : null,\n}));\n\nvi.mock(\"@/components/UsageScriptModal\", () => ({\n  default: ({ isOpen, provider, onSave, onClose }: any) =>\n    isOpen ? (\n      <div data-testid=\"usage-modal\">\n        <span data-testid=\"usage-provider\">{provider?.id}</span>\n        <button onClick={() => onSave(\"script-code\")}>save-script</button>\n        <button onClick={() => onClose()}>close-usage</button>\n      </div>\n    ) : null,\n}));\n\nvi.mock(\"@/components/ConfirmDialog\", () => ({\n  ConfirmDialog: ({ isOpen, onConfirm, onCancel }: any) =>\n    isOpen ? (\n      <div data-testid=\"confirm-dialog\">\n        <button onClick={() => onConfirm()}>confirm-delete</button>\n        <button onClick={() => onCancel()}>cancel-delete</button>\n      </div>\n    ) : null,\n}));\n\nvi.mock(\"@/components/AppSwitcher\", () => ({\n  AppSwitcher: ({ activeApp, onSwitch }: any) => (\n    <div data-testid=\"app-switcher\">\n      <span>{activeApp}</span>\n      <button onClick={() => onSwitch(\"claude\")}>switch-claude</button>\n      <button onClick={() => onSwitch(\"codex\")}>switch-codex</button>\n    </div>\n  ),\n}));\n\nvi.mock(\"@/components/UpdateBadge\", () => ({\n  UpdateBadge: ({ onClick }: any) => (\n    <button onClick={onClick}>update-badge</button>\n  ),\n}));\n\nvi.mock(\"@/components/mcp/McpPanel\", () => ({\n  default: ({ open, onOpenChange }: any) =>\n    open ? (\n      <div data-testid=\"mcp-panel\">\n        <button onClick={() => onOpenChange(false)}>close-mcp</button>\n      </div>\n    ) : (\n      <button onClick={() => onOpenChange(true)}>open-mcp</button>\n    ),\n}));\n\nconst renderApp = (AppComponent: ComponentType) => {\n  const client = new QueryClient();\n  return render(\n    <QueryClientProvider client={client}>\n      <Suspense fallback={<div data-testid=\"loading\">loading</div>}>\n        <AppComponent />\n      </Suspense>\n    </QueryClientProvider>,\n  );\n};\n\ndescribe(\"App integration with MSW\", () => {\n  beforeEach(() => {\n    resetProviderState();\n    toastSuccessMock.mockReset();\n    toastErrorMock.mockReset();\n  });\n\n  it(\"covers basic provider flows via real hooks\", async () => {\n    const { default: App } = await import(\"@/App\");\n    renderApp(App);\n\n    await waitFor(() =>\n      expect(screen.getByTestId(\"provider-list\").textContent).toContain(\n        \"claude-1\",\n      ),\n    );\n\n    fireEvent.click(screen.getByText(\"switch-codex\"));\n    await waitFor(() =>\n      expect(screen.getByTestId(\"provider-list\").textContent).toContain(\n        \"codex-1\",\n      ),\n    );\n\n    fireEvent.click(screen.getByText(\"usage\"));\n    expect(screen.getByTestId(\"usage-modal\")).toBeInTheDocument();\n    fireEvent.click(screen.getByText(\"save-script\"));\n    fireEvent.click(screen.getByText(\"close-usage\"));\n\n    fireEvent.click(screen.getByText(\"create\"));\n    expect(screen.getByTestId(\"add-provider-dialog\")).toBeInTheDocument();\n    fireEvent.click(screen.getByText(\"confirm-add\"));\n    await waitFor(() =>\n      expect(screen.getByTestId(\"provider-list\").textContent).toMatch(\n        /New codex Provider/,\n      ),\n    );\n\n    fireEvent.click(screen.getByText(\"edit\"));\n    expect(screen.getByTestId(\"edit-provider-dialog\")).toBeInTheDocument();\n    fireEvent.click(screen.getByText(\"confirm-edit\"));\n    await waitFor(() =>\n      expect(screen.getByTestId(\"provider-list\").textContent).toMatch(\n        /-edited/,\n      ),\n    );\n\n    fireEvent.click(screen.getByText(\"switch\"));\n    fireEvent.click(screen.getByText(\"duplicate\"));\n    await waitFor(() =>\n      expect(screen.getByTestId(\"provider-list\").textContent).toMatch(/copy/),\n    );\n\n    fireEvent.click(screen.getByText(\"open-website\"));\n\n    emitTauriEvent(\"provider-switched\", {\n      appType: \"codex\",\n      providerId: \"codex-2\",\n    });\n\n    expect(toastErrorMock).not.toHaveBeenCalled();\n    expect(toastSuccessMock).toHaveBeenCalled();\n  });\n\n  it(\"shows toast when auto sync fails in background\", async () => {\n    const { default: App } = await import(\"@/App\");\n    renderApp(App);\n\n    await waitFor(() =>\n      expect(screen.getByTestId(\"provider-list\").textContent).toContain(\n        \"claude-1\",\n      ),\n    );\n\n    emitTauriEvent(\"webdav-sync-status-updated\", {\n      source: \"auto\",\n      status: \"error\",\n      error: \"network timeout\",\n    });\n\n    await waitFor(() => {\n      expect(toastErrorMock).toHaveBeenCalled();\n    });\n  });\n});\n"
  },
  {
    "path": "tests/integration/SettingsDialog.test.tsx",
    "content": "import React, { Suspense } from \"react\";\nimport { QueryClient, QueryClientProvider } from \"@tanstack/react-query\";\nimport { render, screen, waitFor, fireEvent } from \"@testing-library/react\";\nimport { describe, it, expect, vi, beforeEach, afterEach } from \"vitest\";\nimport { http, HttpResponse } from \"msw\";\nimport { SettingsPage } from \"@/components/settings/SettingsPage\";\nimport {\n  resetProviderState,\n  getSettings,\n  getAppConfigDirOverride,\n} from \"../msw/state\";\nimport { server } from \"../msw/server\";\n\nconst toastSuccessMock = vi.fn();\nconst toastErrorMock = vi.fn();\n\nvi.mock(\"sonner\", () => ({\n  toast: {\n    success: (...args: unknown[]) => toastSuccessMock(...args),\n    error: (...args: unknown[]) => toastErrorMock(...args),\n  },\n}));\n\nvi.mock(\"@/components/ui/dialog\", () => ({\n  Dialog: ({ open, children }: any) =>\n    open ? <div data-testid=\"dialog-root\">{children}</div> : null,\n  DialogContent: ({ children }: any) => <div>{children}</div>,\n  DialogHeader: ({ children }: any) => <div>{children}</div>,\n  DialogFooter: ({ children }: any) => <div>{children}</div>,\n  DialogTitle: ({ children }: any) => <h2>{children}</h2>,\n  DialogDescription: ({ children }: any) => <div>{children}</div>,\n}));\n\nconst TabsContext = React.createContext<{\n  value: string;\n  onValueChange?: (value: string) => void;\n}>({\n  value: \"general\",\n});\n\nvi.mock(\"@/components/ui/tabs\", () => {\n  return {\n    Tabs: ({ value, onValueChange, children }: any) => (\n      <TabsContext.Provider value={{ value, onValueChange }}>\n        {children}\n      </TabsContext.Provider>\n    ),\n    TabsList: ({ children }: any) => <div>{children}</div>,\n    TabsTrigger: ({ value, children }: any) => {\n      const ctx = React.useContext(TabsContext);\n      return (\n        <button type=\"button\" onClick={() => ctx.onValueChange?.(value)}>\n          {children}\n        </button>\n      );\n    },\n    TabsContent: ({ value, children }: any) => {\n      const ctx = React.useContext(TabsContext);\n      return ctx.value === value ? (\n        <div data-testid={`tab-${value}`}>{children}</div>\n      ) : null;\n    },\n  };\n});\n\nvi.mock(\"@/components/settings/LanguageSettings\", () => ({\n  LanguageSettings: ({ value, onChange }: any) => (\n    <div>\n      <span>language:{value}</span>\n      <button onClick={() => onChange(\"en\")}>change-language</button>\n    </div>\n  ),\n}));\n\nvi.mock(\"@/components/settings/ThemeSettings\", () => ({\n  ThemeSettings: () => <div data-testid=\"theme-settings\">theme</div>,\n}));\n\nvi.mock(\"@/components/settings/WindowSettings\", () => ({\n  WindowSettings: ({ onChange }: any) => (\n    <button onClick={() => onChange({ minimizeToTrayOnClose: false })}>\n      window-settings\n    </button>\n  ),\n}));\n\nvi.mock(\"@/components/settings/DirectorySettings\", async () => {\n  const actual = await vi.importActual<\n    typeof import(\"@/components/settings/DirectorySettings\")\n  >(\"@/components/settings/DirectorySettings\");\n  return actual;\n});\n\nvi.mock(\"@/components/settings/ImportExportSection\", () => ({\n  ImportExportSection: ({\n    status,\n    selectedFile,\n    errorMessage,\n    isImporting,\n    onSelectFile,\n    onImport,\n    onExport,\n    onClear,\n  }: any) => (\n    <div>\n      <div data-testid=\"import-status\">{status}</div>\n      <div data-testid=\"selected-file\">{selectedFile || \"none\"}</div>\n      <button onClick={onSelectFile}>settings.selectConfigFile</button>\n      <button onClick={onImport} disabled={!selectedFile || isImporting}>\n        {isImporting ? \"settings.importing\" : \"settings.import\"}\n      </button>\n      <button onClick={onExport}>settings.exportConfig</button>\n      <button onClick={onClear}>common.clear</button>\n      {errorMessage ? <span>{errorMessage}</span> : null}\n    </div>\n  ),\n}));\n\nvi.mock(\"@/components/settings/AboutSection\", () => ({\n  AboutSection: ({ isPortable }: any) => <div>about:{String(isPortable)}</div>,\n}));\n\nconst renderDialog = (\n  props?: Partial<React.ComponentProps<typeof SettingsPage>>,\n) => {\n  const client = new QueryClient();\n  return render(\n    <QueryClientProvider client={client}>\n      <Suspense fallback={<div data-testid=\"loading\">loading</div>}>\n        <SettingsPage open onOpenChange={() => {}} {...props} />\n      </Suspense>\n    </QueryClientProvider>,\n  );\n};\n\nbeforeEach(() => {\n  resetProviderState();\n  toastSuccessMock.mockReset();\n  toastErrorMock.mockReset();\n});\n\nafterEach(() => {\n  vi.useRealTimers();\n});\n\ndescribe(\"SettingsPage integration\", () => {\n  it(\"loads default settings from MSW\", async () => {\n    renderDialog();\n\n    await waitFor(() =>\n      expect(screen.getByText(\"language:zh\")).toBeInTheDocument(),\n    );\n    fireEvent.click(screen.getByText(\"settings.tabAdvanced\"));\n    fireEvent.click(screen.getByText(\"settings.advanced.configDir.title\"));\n    const appInput = await screen.findByPlaceholderText(\n      \"settings.browsePlaceholderApp\",\n    );\n    expect((appInput as HTMLInputElement).value).toBe(\"/home/mock/.cc-switch\");\n  });\n\n  it(\"imports configuration and triggers success callback\", async () => {\n    const onImportSuccess = vi.fn();\n    renderDialog({ onImportSuccess });\n\n    await waitFor(() =>\n      expect(screen.getByText(\"language:zh\")).toBeInTheDocument(),\n    );\n\n    fireEvent.click(screen.getByText(\"settings.tabAdvanced\"));\n    fireEvent.click(screen.getByText(\"settings.advanced.data.title\"));\n    fireEvent.click(screen.getByText(\"settings.selectConfigFile\"));\n    await waitFor(() =>\n      expect(screen.getByTestId(\"selected-file\").textContent).toContain(\n        \"/mock/import-settings.json\",\n      ),\n    );\n\n    fireEvent.click(screen.getByText(\"settings.import\"));\n    await waitFor(() => expect(toastSuccessMock).toHaveBeenCalled());\n    await waitFor(() => expect(onImportSuccess).toHaveBeenCalled(), {\n      timeout: 4000,\n    });\n    expect(getSettings().language).toBe(\"en\");\n  });\n\n  it(\"saves settings and handles restart prompt\", async () => {\n    renderDialog();\n\n    await waitFor(() =>\n      expect(screen.getByText(\"language:zh\")).toBeInTheDocument(),\n    );\n\n    fireEvent.click(screen.getByText(\"settings.tabAdvanced\"));\n    fireEvent.click(screen.getByText(\"settings.advanced.configDir.title\"));\n    const appInput = await screen.findByPlaceholderText(\n      \"settings.browsePlaceholderApp\",\n    );\n    fireEvent.change(appInput, { target: { value: \"/custom/app\" } });\n    fireEvent.click(screen.getByText(\"common.save\"));\n\n    await waitFor(() => expect(toastSuccessMock).toHaveBeenCalled());\n    await screen.findByText(\"settings.restartRequired\");\n    fireEvent.click(screen.getByText(\"settings.restartLater\"));\n    await waitFor(() =>\n      expect(\n        screen.queryByText(\"settings.restartRequired\"),\n      ).not.toBeInTheDocument(),\n    );\n\n    expect(getAppConfigDirOverride()).toBe(\"/custom/app\");\n  });\n\n  it(\"allows browsing and resetting directories\", async () => {\n    renderDialog();\n\n    await waitFor(() =>\n      expect(screen.getByText(\"language:zh\")).toBeInTheDocument(),\n    );\n\n    fireEvent.click(screen.getByText(\"settings.tabAdvanced\"));\n    fireEvent.click(screen.getByText(\"settings.advanced.configDir.title\"));\n\n    const browseButtons = screen.getAllByTitle(\"settings.browseDirectory\");\n    const resetButtons = screen.getAllByTitle(\"settings.resetDefault\");\n\n    const appInput = (await screen.findByPlaceholderText(\n      \"settings.browsePlaceholderApp\",\n    )) as HTMLInputElement;\n    expect(appInput.value).toBe(\"/home/mock/.cc-switch\");\n\n    fireEvent.click(browseButtons[0]);\n    await waitFor(() =>\n      expect(appInput.value).toBe(\"/home/mock/.cc-switch/picked\"),\n    );\n\n    fireEvent.click(resetButtons[0]);\n    await waitFor(() => expect(appInput.value).toBe(\"/home/mock/.cc-switch\"));\n\n    const claudeInput = (await screen.findByPlaceholderText(\n      \"settings.browsePlaceholderClaude\",\n    )) as HTMLInputElement;\n    fireEvent.change(claudeInput, { target: { value: \"/custom/claude\" } });\n    await waitFor(() => expect(claudeInput.value).toBe(\"/custom/claude\"));\n\n    fireEvent.click(browseButtons[1]);\n    await waitFor(() =>\n      expect(claudeInput.value).toBe(\"/custom/claude/picked\"),\n    );\n\n    fireEvent.click(resetButtons[1]);\n    await waitFor(() => expect(claudeInput.value).toBe(\"/home/mock/.claude\"));\n  });\n\n  it(\"notifies when export fails\", async () => {\n    renderDialog();\n\n    await waitFor(() =>\n      expect(screen.getByText(\"language:zh\")).toBeInTheDocument(),\n    );\n    fireEvent.click(screen.getByText(\"settings.tabAdvanced\"));\n    fireEvent.click(screen.getByText(\"settings.advanced.data.title\"));\n\n    server.use(\n      http.post(\"http://tauri.local/save_file_dialog\", () =>\n        HttpResponse.json(null),\n      ),\n    );\n    fireEvent.click(screen.getByText(\"settings.exportConfig\"));\n\n    await waitFor(() => expect(toastErrorMock).toHaveBeenCalled());\n    const cancelMessage = toastErrorMock.mock.calls.at(-1)?.[0] as string;\n    expect(cancelMessage).toMatch(\n      /settings\\.selectFileFailed|请选择.*保存路径/,\n    );\n\n    toastErrorMock.mockClear();\n\n    server.use(\n      http.post(\"http://tauri.local/save_file_dialog\", () =>\n        HttpResponse.json(\"/mock/export-settings.json\"),\n      ),\n      http.post(\"http://tauri.local/export_config_to_file\", () =>\n        HttpResponse.json({ success: false, message: \"disk-full\" }),\n      ),\n    );\n\n    fireEvent.click(screen.getByText(\"settings.exportConfig\"));\n\n    await waitFor(() => expect(toastErrorMock).toHaveBeenCalled());\n    const exportMessage = toastErrorMock.mock.calls.at(-1)?.[0] as string;\n    expect(exportMessage).toContain(\"disk-full\");\n    expect(toastSuccessMock).not.toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "tests/msw/handlers.ts",
    "content": "import { http, HttpResponse } from \"msw\";\nimport type { AppId } from \"@/lib/api/types\";\nimport type { McpServer, Provider, Settings } from \"@/types\";\nimport {\n  addProvider,\n  deleteProvider,\n  deleteSession,\n  getCurrentProviderId,\n  getSessionMessages,\n  getProviders,\n  listProviders,\n  listSessions,\n  resetProviderState,\n  setCurrentProviderId,\n  updateProvider,\n  updateSortOrder,\n  getSettings,\n  setSettings,\n  getAppConfigDirOverride,\n  setAppConfigDirOverrideState,\n  getMcpConfig,\n  setMcpServerEnabled,\n  upsertMcpServer,\n  deleteMcpServer,\n} from \"./state\";\n\nconst TAURI_ENDPOINT = \"http://tauri.local\";\n\nconst withJson = async <T>(request: Request): Promise<T> => {\n  try {\n    const body = await request.text();\n    if (!body) return {} as T;\n    return JSON.parse(body) as T;\n  } catch {\n    return {} as T;\n  }\n};\n\nconst success = <T>(payload: T) => HttpResponse.json(payload as any);\n\nexport const handlers = [\n  http.post(`${TAURI_ENDPOINT}/get_migration_result`, () => success(false)),\n  http.post(`${TAURI_ENDPOINT}/get_skills_migration_result`, () =>\n    success(null),\n  ),\n  http.post(`${TAURI_ENDPOINT}/get_providers`, async ({ request }) => {\n    const { app } = await withJson<{ app: AppId }>(request);\n    return success(getProviders(app));\n  }),\n\n  http.post(`${TAURI_ENDPOINT}/get_current_provider`, async ({ request }) => {\n    const { app } = await withJson<{ app: AppId }>(request);\n    return success(getCurrentProviderId(app));\n  }),\n\n  http.post(\n    `${TAURI_ENDPOINT}/update_providers_sort_order`,\n    async ({ request }) => {\n      const { updates = [], app } = await withJson<{\n        updates: { id: string; sortIndex: number }[];\n        app: AppId;\n      }>(request);\n      updateSortOrder(app, updates);\n      return success(true);\n    },\n  ),\n\n  http.post(`${TAURI_ENDPOINT}/update_tray_menu`, () => success(true)),\n\n  http.post(`${TAURI_ENDPOINT}/switch_provider`, async ({ request }) => {\n    const { id, app } = await withJson<{ id: string; app: AppId }>(request);\n    const providers = listProviders(app);\n    if (!providers[id]) {\n      return HttpResponse.json(false, { status: 404 });\n    }\n    setCurrentProviderId(app, id);\n    return success(true);\n  }),\n\n  http.post(`${TAURI_ENDPOINT}/add_provider`, async ({ request }) => {\n    const { provider, app } = await withJson<{\n      provider: Provider & { id?: string };\n      app: AppId;\n    }>(request);\n\n    const newId = provider.id ?? `mock-${Date.now()}`;\n    addProvider(app, { ...provider, id: newId });\n    return success(true);\n  }),\n\n  http.post(`${TAURI_ENDPOINT}/update_provider`, async ({ request }) => {\n    const { provider, app } = await withJson<{\n      provider: Provider;\n      app: AppId;\n    }>(request);\n    updateProvider(app, provider);\n    return success(true);\n  }),\n\n  http.post(`${TAURI_ENDPOINT}/delete_provider`, async ({ request }) => {\n    const { id, app } = await withJson<{ id: string; app: AppId }>(request);\n    deleteProvider(app, id);\n    return success(true);\n  }),\n\n  http.post(`${TAURI_ENDPOINT}/import_default_config`, async () => {\n    resetProviderState();\n    return success(true);\n  }),\n\n  http.post(`${TAURI_ENDPOINT}/open_external`, () => success(true)),\n\n  http.post(`${TAURI_ENDPOINT}/list_sessions`, () => success(listSessions())),\n\n  http.post(`${TAURI_ENDPOINT}/get_session_messages`, async ({ request }) => {\n    const { providerId, sourcePath } = await withJson<{\n      providerId: string;\n      sourcePath: string;\n    }>(request);\n    return success(getSessionMessages(providerId, sourcePath));\n  }),\n\n  http.post(`${TAURI_ENDPOINT}/delete_session`, async ({ request }) => {\n    const { providerId, sessionId, sourcePath } = await withJson<{\n      providerId: string;\n      sessionId: string;\n      sourcePath: string;\n    }>(request);\n    return success(deleteSession(providerId, sessionId, sourcePath));\n  }),\n\n  // MCP APIs\n  http.post(`${TAURI_ENDPOINT}/get_mcp_config`, async ({ request }) => {\n    const { app } = await withJson<{ app: AppId }>(request);\n    return success(getMcpConfig(app));\n  }),\n\n  http.post(`${TAURI_ENDPOINT}/import_mcp_from_claude`, () => success(1)),\n  http.post(`${TAURI_ENDPOINT}/import_mcp_from_codex`, () => success(1)),\n\n  http.post(`${TAURI_ENDPOINT}/set_mcp_enabled`, async ({ request }) => {\n    const { app, id, enabled } = await withJson<{\n      app: AppId;\n      id: string;\n      enabled: boolean;\n    }>(request);\n    setMcpServerEnabled(app, id, enabled);\n    return success(true);\n  }),\n\n  http.post(\n    `${TAURI_ENDPOINT}/upsert_mcp_server_in_config`,\n    async ({ request }) => {\n      const { app, id, spec } = await withJson<{\n        app: AppId;\n        id: string;\n        spec: McpServer;\n      }>(request);\n      upsertMcpServer(app, id, spec);\n      return success(true);\n    },\n  ),\n\n  http.post(\n    `${TAURI_ENDPOINT}/delete_mcp_server_in_config`,\n    async ({ request }) => {\n      const { app, id } = await withJson<{ app: AppId; id: string }>(request);\n      deleteMcpServer(app, id);\n      return success(true);\n    },\n  ),\n\n  http.post(`${TAURI_ENDPOINT}/restart_app`, () => success(true)),\n\n  http.post(`${TAURI_ENDPOINT}/get_settings`, () => success(getSettings())),\n\n  http.post(`${TAURI_ENDPOINT}/save_settings`, async ({ request }) => {\n    const { settings } = await withJson<{ settings: Settings }>(request);\n    setSettings(settings);\n    return success(true);\n  }),\n\n  http.post(\n    `${TAURI_ENDPOINT}/set_app_config_dir_override`,\n    async ({ request }) => {\n      const { path } = await withJson<{ path: string | null }>(request);\n      setAppConfigDirOverrideState(path ?? null);\n      return success(true);\n    },\n  ),\n\n  http.post(`${TAURI_ENDPOINT}/get_app_config_dir_override`, () =>\n    success(getAppConfigDirOverride()),\n  ),\n\n  http.post(\n    `${TAURI_ENDPOINT}/apply_claude_plugin_config`,\n    async ({ request }) => {\n      const { official } = await withJson<{ official: boolean }>(request);\n      setSettings({ enableClaudePluginIntegration: !official });\n      return success(true);\n    },\n  ),\n\n  http.post(`${TAURI_ENDPOINT}/apply_claude_onboarding_skip`, () =>\n    success(true),\n  ),\n\n  http.post(`${TAURI_ENDPOINT}/clear_claude_onboarding_skip`, () =>\n    success(true),\n  ),\n\n  http.post(`${TAURI_ENDPOINT}/get_config_dir`, async ({ request }) => {\n    const { app } = await withJson<{ app: AppId }>(request);\n    return success(app === \"claude\" ? \"/default/claude\" : \"/default/codex\");\n  }),\n\n  http.post(`${TAURI_ENDPOINT}/is_portable_mode`, () => success(false)),\n\n  http.post(\n    `${TAURI_ENDPOINT}/select_config_directory`,\n    async ({ request }) => {\n      const { defaultPath, default_path } = await withJson<{\n        defaultPath?: string;\n        default_path?: string;\n      }>(request);\n      const initial = defaultPath ?? default_path;\n      return success(initial ? `${initial}/picked` : \"/mock/selected-dir\");\n    },\n  ),\n\n  http.post(`${TAURI_ENDPOINT}/pick_directory`, async ({ request }) => {\n    const { defaultPath, default_path } = await withJson<{\n      defaultPath?: string;\n      default_path?: string;\n    }>(request);\n    const initial = defaultPath ?? default_path;\n    return success(initial ? `${initial}/picked` : \"/mock/selected-dir\");\n  }),\n\n  http.post(`${TAURI_ENDPOINT}/open_file_dialog`, () =>\n    success(\"/mock/import-settings.json\"),\n  ),\n\n  http.post(\n    `${TAURI_ENDPOINT}/import_config_from_file`,\n    async ({ request }) => {\n      const { filePath } = await withJson<{ filePath: string }>(request);\n      if (!filePath) {\n        return success({ success: false, message: \"Missing file\" });\n      }\n      setSettings({ language: \"en\" });\n      return success({ success: true, backupId: \"backup-123\" });\n    },\n  ),\n\n  http.post(`${TAURI_ENDPOINT}/export_config_to_file`, async ({ request }) => {\n    const { filePath } = await withJson<{ filePath: string }>(request);\n    if (!filePath) {\n      return success({ success: false, message: \"Invalid destination\" });\n    }\n    return success({ success: true, filePath });\n  }),\n\n  http.post(`${TAURI_ENDPOINT}/save_file_dialog`, () =>\n    success(\"/mock/export-settings.json\"),\n  ),\n\n  // Sync current providers live (no-op success)\n  http.post(`${TAURI_ENDPOINT}/sync_current_providers_live`, () =>\n    success({ success: true }),\n  ),\n\n  // Proxy status (for SettingsPage / ProxyPanel hooks)\n  http.post(`${TAURI_ENDPOINT}/get_proxy_status`, () =>\n    success({\n      running: false,\n      address: \"127.0.0.1\",\n      port: 0,\n      active_connections: 0,\n      total_requests: 0,\n      success_requests: 0,\n      failed_requests: 0,\n      success_rate: 0,\n      uptime_seconds: 0,\n      current_provider: null,\n      current_provider_id: null,\n      last_request_at: null,\n      last_error: null,\n      failover_count: 0,\n      active_targets: [],\n    }),\n  ),\n\n  http.post(`${TAURI_ENDPOINT}/get_proxy_takeover_status`, () =>\n    success({\n      claude: false,\n      codex: false,\n      gemini: false,\n    }),\n  ),\n\n  http.post(`${TAURI_ENDPOINT}/is_live_takeover_active`, () => success(false)),\n\n  // Failover / circuit breaker defaults\n  http.post(`${TAURI_ENDPOINT}/get_failover_queue`, () => success([])),\n  http.post(`${TAURI_ENDPOINT}/get_available_providers_for_failover`, () =>\n    success([]),\n  ),\n  http.post(`${TAURI_ENDPOINT}/add_to_failover_queue`, () => success(true)),\n  http.post(`${TAURI_ENDPOINT}/remove_from_failover_queue`, () =>\n    success(true),\n  ),\n  http.post(`${TAURI_ENDPOINT}/reorder_failover_queue`, () => success(true)),\n  http.post(`${TAURI_ENDPOINT}/set_failover_item_enabled`, () => success(true)),\n\n  http.post(`${TAURI_ENDPOINT}/get_circuit_breaker_config`, () =>\n    success({\n      failureThreshold: 3,\n      successThreshold: 2,\n      timeoutSeconds: 60,\n      errorRateThreshold: 50,\n      minRequests: 5,\n    }),\n  ),\n  http.post(`${TAURI_ENDPOINT}/update_circuit_breaker_config`, () =>\n    success(true),\n  ),\n  http.post(`${TAURI_ENDPOINT}/get_provider_health`, () =>\n    success({\n      provider_id: \"mock-provider\",\n      app_type: \"claude\",\n      is_healthy: true,\n      consecutive_failures: 0,\n      last_success_at: null,\n      last_failure_at: null,\n      last_error: null,\n      updated_at: new Date().toISOString(),\n    }),\n  ),\n  http.post(`${TAURI_ENDPOINT}/reset_circuit_breaker`, () => success(true)),\n  http.post(`${TAURI_ENDPOINT}/get_circuit_breaker_stats`, () => success(null)),\n];\n"
  },
  {
    "path": "tests/msw/server.ts",
    "content": "import { setupServer } from \"msw/node\";\nimport { handlers } from \"./handlers\";\n\nexport const server = setupServer(...handlers);\n"
  },
  {
    "path": "tests/msw/state.ts",
    "content": "import type { AppId } from \"@/lib/api/types\";\nimport type {\n  McpServer,\n  Provider,\n  SessionMessage,\n  SessionMeta,\n  Settings,\n} from \"@/types\";\n\ntype ProvidersByApp = Record<AppId, Record<string, Provider>>;\ntype CurrentProviderState = Record<AppId, string>;\ntype McpConfigState = Record<AppId, Record<string, McpServer>>;\n\nconst createDefaultProviders = (): ProvidersByApp => ({\n  claude: {\n    \"claude-1\": {\n      id: \"claude-1\",\n      name: \"Claude Default\",\n      settingsConfig: {},\n      category: \"official\",\n      sortIndex: 0,\n      createdAt: Date.now(),\n    },\n    \"claude-2\": {\n      id: \"claude-2\",\n      name: \"Claude Custom\",\n      settingsConfig: {},\n      category: \"custom\",\n      sortIndex: 1,\n      createdAt: Date.now() + 1,\n    },\n  },\n  codex: {\n    \"codex-1\": {\n      id: \"codex-1\",\n      name: \"Codex Default\",\n      settingsConfig: {},\n      category: \"official\",\n      sortIndex: 0,\n      createdAt: Date.now(),\n    },\n    \"codex-2\": {\n      id: \"codex-2\",\n      name: \"Codex Secondary\",\n      settingsConfig: {},\n      category: \"custom\",\n      sortIndex: 1,\n      createdAt: Date.now() + 1,\n    },\n  },\n  gemini: {\n    \"gemini-1\": {\n      id: \"gemini-1\",\n      name: \"Gemini Default\",\n      settingsConfig: {\n        env: {\n          GEMINI_API_KEY: \"test-key\",\n          GOOGLE_GEMINI_BASE_URL: \"https://generativelanguage.googleapis.com\",\n        },\n      },\n      category: \"official\",\n      sortIndex: 0,\n      createdAt: Date.now(),\n    },\n  },\n  opencode: {},\n  openclaw: {},\n});\n\nconst createDefaultCurrent = (): CurrentProviderState => ({\n  claude: \"claude-1\",\n  codex: \"codex-1\",\n  gemini: \"gemini-1\",\n  opencode: \"\",\n  openclaw: \"\",\n});\n\nlet providers = createDefaultProviders();\nlet current = createDefaultCurrent();\nlet settingsState: Settings = {\n  showInTray: true,\n  minimizeToTrayOnClose: true,\n  enableClaudePluginIntegration: false,\n  claudeConfigDir: \"/default/claude\",\n  codexConfigDir: \"/default/codex\",\n  language: \"zh\",\n};\nlet appConfigDirOverride: string | null = null;\nconst sessionMessageKey = (providerId: string, sourcePath: string) =>\n  `${providerId}:${sourcePath}`;\n\nconst createDefaultSessions = (): SessionMeta[] => {\n  const now = Date.now();\n  return [\n    {\n      providerId: \"codex\",\n      sessionId: \"codex-session-1\",\n      title: \"Codex Session One\",\n      summary: \"Codex summary\",\n      projectDir: \"/mock/codex\",\n      createdAt: now - 2000,\n      lastActiveAt: now - 1000,\n      sourcePath: \"/mock/codex/session-1.jsonl\",\n      resumeCommand: \"codex resume codex-session-1\",\n    },\n    {\n      providerId: \"claude\",\n      sessionId: \"claude-session-1\",\n      title: \"Claude Session One\",\n      summary: \"Claude summary\",\n      projectDir: \"/mock/claude\",\n      createdAt: now - 4000,\n      lastActiveAt: now - 3000,\n      sourcePath: \"/mock/claude/session-1.jsonl\",\n      resumeCommand: \"claude --resume claude-session-1\",\n    },\n  ];\n};\n\nconst createDefaultSessionMessages = (): Record<string, SessionMessage[]> => ({\n  [sessionMessageKey(\"codex\", \"/mock/codex/session-1.jsonl\")]: [\n    {\n      role: \"user\",\n      content: \"First codex message\",\n      ts: Date.now() - 1000,\n    },\n  ],\n  [sessionMessageKey(\"claude\", \"/mock/claude/session-1.jsonl\")]: [\n    {\n      role: \"user\",\n      content: \"First claude message\",\n      ts: Date.now() - 3000,\n    },\n  ],\n});\n\nlet sessionsState = createDefaultSessions();\nlet sessionMessagesState = createDefaultSessionMessages();\nlet mcpConfigs: McpConfigState = {\n  claude: {\n    sample: {\n      id: \"sample\",\n      name: \"Sample Claude Server\",\n      enabled: true,\n      apps: {\n        claude: true,\n        codex: false,\n        gemini: false,\n        opencode: false,\n        openclaw: false,\n      },\n      server: {\n        type: \"stdio\",\n        command: \"claude-server\",\n      },\n    },\n  },\n  codex: {\n    httpServer: {\n      id: \"httpServer\",\n      name: \"HTTP Codex Server\",\n      enabled: false,\n      apps: {\n        claude: false,\n        codex: true,\n        gemini: false,\n        opencode: false,\n        openclaw: false,\n      },\n      server: {\n        type: \"http\",\n        url: \"http://localhost:3000\",\n      },\n    },\n  },\n  gemini: {},\n  opencode: {},\n  openclaw: {},\n};\n\nconst cloneProviders = (value: ProvidersByApp) =>\n  JSON.parse(JSON.stringify(value)) as ProvidersByApp;\n\nexport const resetProviderState = () => {\n  providers = createDefaultProviders();\n  current = createDefaultCurrent();\n  sessionsState = createDefaultSessions();\n  sessionMessagesState = createDefaultSessionMessages();\n  settingsState = {\n    showInTray: true,\n    minimizeToTrayOnClose: true,\n    enableClaudePluginIntegration: false,\n    claudeConfigDir: \"/default/claude\",\n    codexConfigDir: \"/default/codex\",\n    language: \"zh\",\n  };\n  appConfigDirOverride = null;\n  mcpConfigs = {\n    claude: {\n      sample: {\n        id: \"sample\",\n        name: \"Sample Claude Server\",\n        enabled: true,\n        apps: {\n          claude: true,\n          codex: false,\n          gemini: false,\n          opencode: false,\n          openclaw: false,\n        },\n        server: {\n          type: \"stdio\",\n          command: \"claude-server\",\n        },\n      },\n    },\n    codex: {\n      httpServer: {\n        id: \"httpServer\",\n        name: \"HTTP Codex Server\",\n        enabled: false,\n        apps: {\n          claude: false,\n          codex: true,\n          gemini: false,\n          opencode: false,\n          openclaw: false,\n        },\n        server: {\n          type: \"http\",\n          url: \"http://localhost:3000\",\n        },\n      },\n    },\n    gemini: {},\n    opencode: {},\n    openclaw: {},\n  };\n};\n\nexport const getProviders = (appType: AppId) =>\n  cloneProviders(providers)[appType] ?? {};\n\nexport const getCurrentProviderId = (appType: AppId) => current[appType] ?? \"\";\n\nexport const setCurrentProviderId = (appType: AppId, providerId: string) => {\n  current[appType] = providerId;\n};\n\nexport const updateProviders = (\n  appType: AppId,\n  data: Record<string, Provider>,\n) => {\n  providers[appType] = cloneProviders({ [appType]: data } as ProvidersByApp)[\n    appType\n  ];\n};\n\nexport const setProviders = (\n  appType: AppId,\n  data: Record<string, Provider>,\n) => {\n  providers[appType] = JSON.parse(JSON.stringify(data)) as Record<\n    string,\n    Provider\n  >;\n};\n\nexport const addProvider = (appType: AppId, provider: Provider) => {\n  providers[appType] = providers[appType] ?? {};\n  providers[appType][provider.id] = provider;\n};\n\nexport const updateProvider = (appType: AppId, provider: Provider) => {\n  if (!providers[appType]) return;\n  providers[appType][provider.id] = {\n    ...providers[appType][provider.id],\n    ...provider,\n  };\n};\n\nexport const deleteProvider = (appType: AppId, providerId: string) => {\n  if (!providers[appType]) return;\n  delete providers[appType][providerId];\n  if (current[appType] === providerId) {\n    const fallback = Object.keys(providers[appType])[0] ?? \"\";\n    current[appType] = fallback;\n  }\n};\n\nexport const updateSortOrder = (\n  appType: AppId,\n  updates: { id: string; sortIndex: number }[],\n) => {\n  if (!providers[appType]) return;\n  updates.forEach(({ id, sortIndex }) => {\n    const provider = providers[appType][id];\n    if (provider) {\n      providers[appType][id] = { ...provider, sortIndex };\n    }\n  });\n};\n\nexport const listProviders = (appType: AppId) =>\n  JSON.parse(JSON.stringify(providers[appType] ?? {})) as Record<\n    string,\n    Provider\n  >;\n\nexport const getSettings = () =>\n  JSON.parse(JSON.stringify(settingsState)) as Settings;\n\nexport const setSettings = (data: Partial<Settings>) => {\n  settingsState = { ...settingsState, ...data };\n};\n\nexport const getAppConfigDirOverride = () => appConfigDirOverride;\n\nexport const setAppConfigDirOverrideState = (value: string | null) => {\n  appConfigDirOverride = value;\n};\n\nexport const getMcpConfig = (appType: AppId) => {\n  const servers = JSON.parse(\n    JSON.stringify(mcpConfigs[appType] ?? {}),\n  ) as Record<string, McpServer>;\n  return {\n    configPath: `/mock/${appType}.mcp.json`,\n    servers,\n  };\n};\n\nexport const setMcpConfig = (\n  appType: AppId,\n  value: Record<string, McpServer>,\n) => {\n  mcpConfigs[appType] = JSON.parse(JSON.stringify(value)) as Record<\n    string,\n    McpServer\n  >;\n};\n\nexport const setMcpServerEnabled = (\n  appType: AppId,\n  id: string,\n  enabled: boolean,\n) => {\n  if (!mcpConfigs[appType]?.[id]) return;\n  mcpConfigs[appType][id] = {\n    ...mcpConfigs[appType][id],\n    enabled,\n  };\n};\n\nexport const upsertMcpServer = (\n  appType: AppId,\n  id: string,\n  server: McpServer,\n) => {\n  if (!mcpConfigs[appType]) {\n    mcpConfigs[appType] = {};\n  }\n  mcpConfigs[appType][id] = JSON.parse(JSON.stringify(server)) as McpServer;\n};\n\nexport const deleteMcpServer = (appType: AppId, id: string) => {\n  if (!mcpConfigs[appType]) return;\n  delete mcpConfigs[appType][id];\n};\n\nexport const listSessions = () =>\n  JSON.parse(JSON.stringify(sessionsState)) as SessionMeta[];\n\nexport const getSessionMessages = (providerId: string, sourcePath: string) =>\n  JSON.parse(\n    JSON.stringify(\n      sessionMessagesState[sessionMessageKey(providerId, sourcePath)] ?? [],\n    ),\n  ) as SessionMessage[];\n\nexport const deleteSession = (\n  providerId: string,\n  sessionId: string,\n  sourcePath: string,\n) => {\n  sessionsState = sessionsState.filter(\n    (session) =>\n      !(\n        session.providerId === providerId &&\n        session.sessionId === sessionId &&\n        session.sourcePath === sourcePath\n      ),\n  );\n  delete sessionMessagesState[sessionMessageKey(providerId, sourcePath)];\n  return true;\n};\n\nexport const setSessionFixtures = (\n  sessions: SessionMeta[],\n  messages: Record<string, SessionMessage[]>,\n) => {\n  sessionsState = JSON.parse(JSON.stringify(sessions)) as SessionMeta[];\n  sessionMessagesState = JSON.parse(JSON.stringify(messages)) as Record<\n    string,\n    SessionMessage[]\n  >;\n};\n"
  },
  {
    "path": "tests/msw/tauriMocks.ts",
    "content": "import \"cross-fetch/polyfill\";\nimport { vi } from \"vitest\";\nimport { server } from \"./server\";\n\nconst TAURI_ENDPOINT = \"http://tauri.local\";\n\nvi.mock(\"@tauri-apps/api/core\", () => ({\n  invoke: async (command: string, payload: Record<string, unknown> = {}) => {\n    const response = await fetch(`${TAURI_ENDPOINT}/${command}`, {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify(payload ?? {}),\n    });\n\n    if (!response.ok) {\n      const text = await response.text();\n      throw new Error(text || `Invoke failed for ${command}`);\n    }\n\n    const text = await response.text();\n    if (!text) return undefined;\n    try {\n      return JSON.parse(text);\n    } catch {\n      return text;\n    }\n  },\n}));\n\nconst listeners = new Map<string, Set<(event: { payload: unknown }) => void>>();\n\nconst ensureListenerSet = (event: string) => {\n  if (!listeners.has(event)) {\n    listeners.set(event, new Set());\n  }\n  return listeners.get(event)!;\n};\n\nexport const emitTauriEvent = (event: string, payload: unknown) => {\n  const handlers = listeners.get(event);\n  handlers?.forEach((handler) => handler({ payload }));\n};\n\nvi.mock(\"@tauri-apps/api/event\", () => ({\n  listen: async (\n    event: string,\n    handler: (event: { payload: unknown }) => void,\n  ) => {\n    const set = ensureListenerSet(event);\n    set.add(handler);\n    return () => {\n      set.delete(handler);\n    };\n  },\n}));\n\n// Ensure the MSW server is referenced so tree shaking doesn't remove imports\nvoid server;\n\nvi.mock(\"@tauri-apps/api/path\", () => ({\n  homeDir: async () => \"/home/mock\",\n  join: async (...segments: string[]) => segments.join(\"/\"),\n}));\n"
  },
  {
    "path": "tests/setupGlobals.ts",
    "content": "// Polyfill ResizeObserver for jsdom/happy-dom\nif (typeof globalThis.ResizeObserver === \"undefined\") {\n  globalThis.ResizeObserver = class ResizeObserver {\n    observe() {}\n    unobserve() {}\n    disconnect() {}\n  } as unknown as typeof globalThis.ResizeObserver;\n}\n\nconst storage = new Map<string, string>();\n\nif (\n  typeof globalThis.localStorage === \"undefined\" ||\n  typeof globalThis.localStorage?.getItem !== \"function\"\n) {\n  Object.defineProperty(globalThis, \"localStorage\", {\n    value: {\n      getItem: (key: string) => storage.get(key) ?? null,\n      setItem: (key: string, value: string) => {\n        storage.set(key, String(value));\n      },\n      removeItem: (key: string) => {\n        storage.delete(key);\n      },\n      clear: () => {\n        storage.clear();\n      },\n      key: (index: number) => Array.from(storage.keys())[index] ?? null,\n      get length() {\n        return storage.size;\n      },\n    },\n    configurable: true,\n  });\n}\n"
  },
  {
    "path": "tests/setupTests.ts",
    "content": "import \"@testing-library/jest-dom\";\nimport { afterAll, afterEach, beforeAll, vi } from \"vitest\";\nimport { cleanup } from \"@testing-library/react\";\nimport i18n from \"i18next\";\nimport { initReactI18next } from \"react-i18next\";\nimport { server } from \"./msw/server\";\nimport { resetProviderState } from \"./msw/state\";\nimport \"./msw/tauriMocks\";\n\nbeforeAll(async () => {\n  server.listen({ onUnhandledRequest: \"warn\" });\n  await i18n.use(initReactI18next).init({\n    lng: \"zh\",\n    fallbackLng: \"zh\",\n    resources: {\n      zh: { translation: {} },\n      en: { translation: {} },\n    },\n    interpolation: {\n      escapeValue: false,\n    },\n  });\n});\n\nafterEach(() => {\n  cleanup();\n  resetProviderState();\n  server.resetHandlers();\n  vi.clearAllMocks();\n});\n\nafterAll(() => {\n  server.close();\n});\n"
  },
  {
    "path": "tests/utils/omoConfig.test.ts",
    "content": "import { describe, expect, it } from \"vitest\";\nimport {\n  buildOmoProfilePreview,\n  parseOmoOtherFieldsObject,\n} from \"@/types/omo\";\n\ndescribe(\"parseOmoOtherFieldsObject\", () => {\n  it(\"解析对象 JSON\", () => {\n    expect(parseOmoOtherFieldsObject('{ \"foo\": 1 }')).toEqual({ foo: 1 });\n  });\n\n  it(\"数组/字符串返回 undefined\", () => {\n    expect(parseOmoOtherFieldsObject('[\"a\"]')).toBeUndefined();\n    expect(parseOmoOtherFieldsObject('\"hello\"')).toBeUndefined();\n  });\n\n  it(\"非法 JSON 抛出异常\", () => {\n    expect(() => parseOmoOtherFieldsObject(\"{\")).toThrow();\n  });\n});\n\ndescribe(\"buildOmoProfilePreview\", () => {\n  it(\"只合并 otherFields 的对象值，忽略数组\", () => {\n    const fromArray = buildOmoProfilePreview({}, {}, '[\"a\", \"b\"]');\n    expect(fromArray).toEqual({});\n\n    const fromObject = buildOmoProfilePreview({}, {}, '{ \"foo\": \"bar\" }');\n    expect(fromObject).toEqual({ foo: \"bar\" });\n  });\n});\n"
  },
  {
    "path": "tests/utils/providerConfigUtils.codex.test.ts",
    "content": "import { describe, expect, it } from \"vitest\";\nimport {\n  extractCodexBaseUrl,\n  extractCodexModelName,\n  setCodexBaseUrl,\n  setCodexModelName,\n} from \"@/utils/providerConfigUtils\";\n\ndescribe(\"Codex TOML utils\", () => {\n  it(\"removes base_url line when set to empty\", () => {\n    const input = [\n      'model_provider = \"openai\"',\n      'base_url = \"https://api.example.com/v1\"',\n      'model = \"gpt-5-codex\"',\n      \"\",\n    ].join(\"\\n\");\n\n    const output = setCodexBaseUrl(input, \"\");\n\n    expect(output).not.toMatch(/^\\s*base_url\\s*=/m);\n    expect(extractCodexBaseUrl(output)).toBeUndefined();\n    expect(extractCodexModelName(output)).toBe(\"gpt-5-codex\");\n  });\n\n  it(\"removes only the top-level model line when set to empty\", () => {\n    const input = [\n      'model_provider = \"openai\"',\n      'base_url = \"https://api.example.com/v1\"',\n      'model = \"gpt-5-codex\"',\n      \"\",\n      \"[profiles.default]\",\n      'model = \"profile-model\"',\n      \"\",\n    ].join(\"\\n\");\n\n    const output = setCodexModelName(input, \"\");\n\n    expect(output).not.toMatch(/^model\\s*=\\s*\"gpt-5-codex\"$/m);\n    expect(output).toMatch(/^\\[profiles\\.default\\]\\nmodel = \"profile-model\"$/m);\n    expect(extractCodexModelName(output)).toBeUndefined();\n    expect(extractCodexBaseUrl(output)).toBe(\"https://api.example.com/v1\");\n  });\n\n  it(\"updates existing values when non-empty\", () => {\n    const input = [\n      'model_provider = \"openai\"',\n      \"base_url = 'https://old.example/v1'\",\n      'model = \"old-model\"',\n      \"\",\n    ].join(\"\\n\");\n\n    const output1 = setCodexBaseUrl(input, \" https://new.example/v1 \\n\");\n    expect(extractCodexBaseUrl(output1)).toBe(\"https://new.example/v1\");\n\n    const output2 = setCodexModelName(output1, \" new-model \\n\");\n    expect(extractCodexModelName(output2)).toBe(\"new-model\");\n  });\n\n  it(\"reads and writes base_url in the active provider section\", () => {\n    const input = [\n      'model_provider = \"custom\"',\n      'model = \"gpt-5.4\"',\n      \"\",\n      \"[model_providers.custom]\",\n      'name = \"custom\"',\n      'wire_api = \"responses\"',\n      \"\",\n      \"[profiles.default]\",\n      'approval_policy = \"never\"',\n      \"\",\n    ].join(\"\\n\");\n\n    const output = setCodexBaseUrl(input, \"https://api.example.com/v1\");\n\n    expect(output).toContain(\n      '[model_providers.custom]\\nname = \"custom\"\\nwire_api = \"responses\"\\nbase_url = \"https://api.example.com/v1\"',\n    );\n    expect(extractCodexBaseUrl(output)).toBe(\"https://api.example.com/v1\");\n  });\n\n  it(\"recovers a single misplaced base_url from another section\", () => {\n    const input = [\n      'model_provider = \"custom\"',\n      'model = \"gpt-5.4\"',\n      \"\",\n      \"[model_providers.custom]\",\n      'name = \"custom\"',\n      'wire_api = \"responses\"',\n      \"\",\n      \"[profiles.default]\",\n      'approval_policy = \"never\"',\n      'base_url = \"https://wrong.example/v1\"',\n      \"\",\n    ].join(\"\\n\");\n\n    expect(extractCodexBaseUrl(input)).toBe(\"https://wrong.example/v1\");\n\n    const output = setCodexBaseUrl(input, \"https://fixed.example/v1\");\n\n    expect(output).toContain(\n      '[model_providers.custom]\\nname = \"custom\"\\nwire_api = \"responses\"\\nbase_url = \"https://fixed.example/v1\"',\n    );\n    expect(output).not.toContain(\"https://wrong.example/v1\");\n    expect(output.match(/base_url\\s*=/g)).toHaveLength(1);\n  });\n\n  it(\"does not treat mcp_servers base_url as provider base_url\", () => {\n    const input = [\n      'model_provider = \"azure\"',\n      'model = \"gpt-4\"',\n      \"\",\n      \"[model_providers.azure]\",\n      'name = \"Azure OpenAI\"',\n      'wire_api = \"responses\"',\n      \"\",\n      \"[mcp_servers.my_server]\",\n      'base_url = \"http://localhost:8080\"',\n      \"\",\n    ].join(\"\\n\");\n\n    expect(extractCodexBaseUrl(input)).toBeUndefined();\n\n    const output = setCodexBaseUrl(input, \"https://new.azure/v1\");\n\n    expect(output).toContain(\n      '[model_providers.azure]\\nname = \"Azure OpenAI\"\\nwire_api = \"responses\"\\nbase_url = \"https://new.azure/v1\"',\n    );\n    expect(output).toContain(\n      '[mcp_servers.my_server]\\nbase_url = \"http://localhost:8080\"',\n    );\n  });\n\n  it(\"reads model only from the top-level config\", () => {\n    const input = [\n      'model_provider = \"custom\"',\n      \"\",\n      \"[profiles.default]\",\n      'model = \"profile-model\"',\n      \"\",\n    ].join(\"\\n\");\n\n    expect(extractCodexModelName(input)).toBeUndefined();\n  });\n\n  it(\"handles single-quoted values\", () => {\n    const input = \"base_url = 'https://api.example.com/v1'\\nmodel = 'gpt-5'\\n\";\n\n    expect(extractCodexBaseUrl(input)).toBe(\"https://api.example.com/v1\");\n    expect(extractCodexModelName(input)).toBe(\"gpt-5\");\n  });\n});\n"
  },
  {
    "path": "tests/utils/providerMetaUtils.test.ts",
    "content": "import { describe, expect, it } from \"vitest\";\nimport type { ProviderMeta } from \"@/types\";\nimport { mergeProviderMeta } from \"@/utils/providerMetaUtils\";\n\nconst buildEndpoint = (url: string) => ({\n  url,\n  addedAt: 1,\n});\n\ndescribe(\"mergeProviderMeta\", () => {\n  it(\"returns undefined when no initial meta and no endpoints\", () => {\n    expect(mergeProviderMeta(undefined, null)).toBeUndefined();\n    expect(mergeProviderMeta(undefined, undefined)).toBeUndefined();\n  });\n\n  it(\"creates meta when endpoints are provided for new provider\", () => {\n    const result = mergeProviderMeta(undefined, {\n      \"https://example.com\": buildEndpoint(\"https://example.com\"),\n    });\n\n    expect(result).toEqual({\n      custom_endpoints: {\n        \"https://example.com\": buildEndpoint(\"https://example.com\"),\n      },\n    });\n  });\n\n  it(\"overrides custom endpoints but preserves other fields\", () => {\n    const initial: ProviderMeta = {\n      usage_script: {\n        enabled: true,\n        language: \"javascript\",\n        code: \"console.log(1);\",\n      },\n      custom_endpoints: {\n        \"https://old.com\": buildEndpoint(\"https://old.com\"),\n      },\n    };\n\n    const result = mergeProviderMeta(initial, {\n      \"https://new.com\": buildEndpoint(\"https://new.com\"),\n    });\n\n    expect(result).toEqual({\n      usage_script: initial.usage_script,\n      custom_endpoints: {\n        \"https://new.com\": buildEndpoint(\"https://new.com\"),\n      },\n    });\n  });\n\n  it(\"removes custom endpoints when result is empty but keeps other meta\", () => {\n    const initial: ProviderMeta = {\n      usage_script: {\n        enabled: true,\n        language: \"javascript\",\n        code: \"console.log(1);\",\n      },\n      custom_endpoints: {\n        \"https://example.com\": buildEndpoint(\"https://example.com\"),\n      },\n    };\n\n    const result = mergeProviderMeta(initial, null);\n\n    expect(result).toEqual({\n      usage_script: initial.usage_script,\n    });\n  });\n\n  it(\"returns undefined when removing last field\", () => {\n    const initial: ProviderMeta = {\n      custom_endpoints: {\n        \"https://example.com\": buildEndpoint(\"https://example.com\"),\n      },\n    };\n\n    expect(mergeProviderMeta(initial, null)).toBeUndefined();\n  });\n});\n"
  },
  {
    "path": "tests/utils/testQueryClient.ts",
    "content": "import { QueryClient } from \"@tanstack/react-query\";\n\nexport const createTestQueryClient = () =>\n  new QueryClient({\n    defaultOptions: {\n      queries: {\n        retry: false,\n      },\n    },\n  });\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2020\",\n    \"lib\": [\"ES2020\", \"DOM\", \"DOM.Iterable\"],\n    \"module\": \"ESNext\",\n    \"skipLibCheck\": true,\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"noEmit\": true,\n    \"jsx\": \"react-jsx\",\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\"src/*\"]\n    },\n    \"types\": [\"vitest/globals\"]\n  },\n  \"include\": [\"src/**/*\", \"tests/**/*\"],\n  \"references\": [{ \"path\": \"./tsconfig.node.json\" }]\n}\n"
  },
  {
    "path": "tsconfig.node.json",
    "content": "{\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"skipLibCheck\": true,\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"bundler\",\n    \"allowSyntheticDefaultImports\": true,\n    \"resolveJsonModule\": true,\n    \"esModuleInterop\": true,\n    \"target\": \"ES2020\",\n    \"strict\": true,\n    \"types\": [\n      \"node\"\n    ]\n  },\n  \"include\": [\n    \"vite.config.ts\",\n    \"vitest.config.ts\"\n  ]\n}\n"
  },
  {
    "path": "vite.config.ts",
    "content": "import path from \"node:path\";\nimport { defineConfig } from \"vite\";\nimport react from \"@vitejs/plugin-react\";\nimport { codeInspectorPlugin } from \"code-inspector-plugin\";\n\nexport default defineConfig(({ command }) => ({\n  root: \"src\",\n  plugins: [\n    command === \"serve\" &&\n      codeInspectorPlugin({\n        bundler: \"vite\",\n      }),\n    react(),\n  ].filter(Boolean),\n  base: \"./\",\n  build: {\n    outDir: \"../dist\",\n    emptyOutDir: true,\n  },\n  server: {\n    port: 3000,\n    strictPort: true,\n  },\n  resolve: {\n    alias: {\n      \"@\": path.resolve(__dirname, \"./src\"),\n    },\n  },\n  clearScreen: false,\n  envPrefix: [\"VITE_\", \"TAURI_\"],\n}));\n\n"
  },
  {
    "path": "vitest.config.ts",
    "content": "import path from \"node:path\";\nimport { defineConfig } from \"vitest/config\";\nimport react from \"@vitejs/plugin-react\";\n\nexport default defineConfig({\n  plugins: [react()],\n  resolve: {\n    alias: {\n      \"@\": path.resolve(__dirname, \"./src\"),\n    },\n  },\n  test: {\n    environment: \"jsdom\",\n    setupFiles: [\"./tests/setupGlobals.ts\", \"./tests/setupTests.ts\"],\n    globals: true,\n    coverage: {\n      reporter: [\"text\", \"lcov\"],\n    },\n  },\n});\n"
  }
]