Compare commits

...

12 Commits

Author SHA1 Message Date
Jason c229c47c00 fix(auto-launch): use AutoLaunchBuilder for cross-platform compatibility
The auto-launch crate has different API signatures on each platform:
- Windows/Linux: AutoLaunch::new() takes 3 arguments
- macOS: AutoLaunch::new() takes 4 arguments (includes hidden param)

The previous code used #[cfg(not(target_os = "windows"))] which incorrectly
applied macOS's 4-argument signature to Linux, causing build failures.

Switch to AutoLaunchBuilder which handles platform differences internally.
2025-11-28 22:59:09 +08:00
Jason 6c477a60f9 chore: bump version to v3.8.0 2025-11-28 22:37:32 +08:00
Jason 7eac809689 feat(preset): add MiniMax international version and split promotions
- Add MiniMax en preset with international API endpoint (minimaxi.io)
- Split MiniMax partner promotion into CN and EN versions
- Remove unnecessary icon config from Aliyun and Alibaba Lingma presets
2025-11-28 22:27:23 +08:00
Jason dafa77897b docs: add v3.8.0 release documentation and Japanese README
- Add CHANGELOG entry for v3.8.0
- Update README.md and README_ZH.md with v3.8.0 features
- Add Japanese README (README_JA.md)
- Add release notes in English, Chinese, and Japanese
2025-11-28 21:47:05 +08:00
YoVinchen 0f959112b1 refactor(skill): remove skillsPath configuration (#310)
Remove the skillsPath field from SkillRepo and Skill structs since
recursive scanning now automatically discovers skills in all directories.
Simplify the UI by removing the path input field.
2025-11-28 16:26:28 +08:00
Jason f4c284f86c fix(test): update component tests to match current implementation
- Add JsonEditor mock to McpFormModal tests (component uses CodeMirror
  instead of Textarea)
- Fix assertion for missing command error message
- Update ImportExportSection tests for new button behavior and file
  display format
2025-11-28 16:20:18 +08:00
Jason 3878a16c4f fix: pre-release fixes for code formatting, i18n, and test suite
- Run cargo fmt to fix Rust code formatting (lib.rs)
- Add missing i18n keys: migration.success, agents.title (zh/en/ja)
- Replace hardcoded strings "Agents" and "MCP" with t() calls in App.tsx
- Fix test mocks and assertions:
  - Add providersApi.updateTrayMenu to useSettings.test.tsx mock
  - Update SettingsPage mock path in App.test.tsx
  - Fix toast message assertion in integration/SettingsDialog.test.tsx
  - Add autoSaveSettings to SettingsDialog component test mock
  - Fix loading state test to check spinner instead of title
  - Update import button name matching for selected file state
  - Fix save button test to switch to advanced tab first
  - Remove obsolete cancel button test (button no longer exists)

Test results improved from 99 passed / 17 failed to 104 passed / 11 failed
2025-11-28 15:53:25 +08:00
Jason 00f78e4546 feat(i18n): add Japanese language support
- Add complete Japanese translation file (ja.json)
- Update frontend types and hooks to support "ja" language option
- Add Japanese tray menu texts in Rust backend
- Add Japanese option to language settings component
- Update Zod schema to include Japanese language validation
- Add test case for Japanese language preference
- Update i18n documentation to reflect three-language support
2025-11-28 15:14:39 +08:00
Jason 1ee1e9cb2e refactor(startup): improve first-launch import logic with per-table detection
- Replace global `is_empty_for_first_import()` with independent checks:
  - `is_mcp_table_empty()` for MCP server imports
  - `is_prompts_table_empty()` for prompt imports
  - Skills and providers already have built-in idempotency checks

- Fix misleading logs in provider import:
  - Change `import_default_config` return type from `Result<()>` to `Result<bool>`
  - Return `true` when actually imported, `false` when skipped
  - Only log success message when import actually occurred

- Add idempotency protection to `import_from_file_on_first_launch`

This allows each data type to be independently recovered if deleted,
rather than requiring all tables to be empty for any import to trigger.
2025-11-28 12:05:29 +08:00
YoVinchen 7db4b8d976 feat(skill): implement recursive scanning for skill repositories (#309)
Add recursive directory scanning to discover SKILL.md files in nested
directories. When a SKILL.md is found, treat sibling directories as
functional folders rather than separate skills.
2025-11-28 12:01:20 +08:00
Jason 924ad44e6c fix(usage): correct selectedTemplate initialization logic
Previously, selectedTemplate was initialized to null when no NEW_API
credentials were detected, which caused the credentials config section
to be hidden even for new configurations or GENERAL template users.

Now properly detects:
- NEW_API template (has accessToken or userId)
- GENERAL template (has apiKey or baseUrl)
- Default to GENERAL for new configurations (matches default code template)
2025-11-28 11:10:22 +08:00
Jason eecd6a3a2b refactor(usage): simplify usage script modal UI
- Remove redundant "Request Configuration" section (URL, method, headers, body editors)
- Move timeout and auto query interval fields to preset template section
- Simplify "Enable usage query" toggle styling
- Remove redundant hints and variables description
- Add i18n support for Base URL label
- Include "0 to disable" hint directly in auto interval label
2025-11-28 11:09:34 +08:00
59 changed files with 3115 additions and 668 deletions
+40
View File
@@ -5,6 +5,46 @@ All notable changes to CC Switch will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.8.0] - 2025-11-28
### Major Updates
- **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.
- **Brand new UI** - Full layout redesign, unified component/ConfirmDialog styles, smoother animations, overscroll disabled; Tailwind CSS downgraded to v3.4 for compatibility.
- **Japanese language support** - UI now localized in Chinese/English/Japanese.
### Added
- **Skills recursive scanning** - Discovers nested `SKILL.md` files across multi-level directories; same-name skills allowed by full-path dedup.
- **Provider icons** - Presets ship with default icons; custom icon colors; icons retained when duplicating providers.
- **Auto launch on startup** - One-click enable/disable using Registry/LaunchAgent/XDG autostart.
- **Provider preset** - Added MiniMax partner preset.
- **Form validation** - Required fields get real-time validation and unified toast messaging.
### Fixed
- **Custom endpoints loss** - Switched provider updates to `UPDATE` to avoid cascade deletes from `INSERT OR REPLACE`.
- **Gemini config writing** - Correctly writes custom env vars to `.env` and keeps auth configs isolated.
- **Provider validation** - Handles missing current provider IDs and preserves icon fields on duplicate.
- **Linux rendering** - Fixed WebKitGTK DMA-BUF rendering and preserved user `.desktop` customizations.
- **Misc** - Removed redundant usage queries; corrected DMXAPI auth token field; restored missing deeplink translations; fixed usage script template init.
### Technical
- **Database modules** - Added `schema`, `backup`, `migration`, and DAO layers for providers/MCP/prompts/skills/settings.
- **Service modularization** - Split provider service into live/auth/endpoints/usage modules; deeplink parsing/import logic modularized.
- **Code cleanup** - Removed legacy JSON-era import/export, unused MCP types; unified error handling; tests migrated to SQLite backend and MSW handlers updated.
### Migration Notes
- 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.
### Stats
- 51 commits since v3.7.1; 207 files changed; +17,297 / -6,870 lines. See [release-note-v3.8.0](docs/release-note-v3.8.0-en.md) for details.
---
## [3.7.1] - 2025-11-22 ## [3.7.1] - 2025-11-22
### Fixed ### Fixed
+44 -10
View File
@@ -2,7 +2,7 @@
# All-in-One Assistant for Claude Code, Codex & Gemini CLI # All-in-One Assistant for Claude Code, Codex & Gemini CLI
[![Version](https://img.shields.io/badge/version-3.7.1-blue.svg)](https://github.com/farion1231/cc-switch/releases) [![Version](https://img.shields.io/badge/version-3.8.0-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Trending](https://img.shields.io/badge/🔥_TypeScript_Trending-Daily%20%7C%20Weekly%20%7C%20Monthly-ff6b6b.svg)](https://github.com/trending/typescript) [![Trending](https://img.shields.io/badge/🔥_TypeScript_Trending-Daily%20%7C%20Weekly%20%7C%20Monthly-ff6b6b.svg)](https://github.com/trending/typescript)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/) [![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
@@ -10,7 +10,7 @@
<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> <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>
English | [中文](README_ZH.md) | [Changelog](CHANGELOG.md) English | [中文](README_ZH.md) | [日本語](README_JA.md) | [Changelog](CHANGELOG.md)
**From Provider Switcher to All-in-One AI CLI Management Platform** **From Provider Switcher to All-in-One AI CLI Management Platform**
@@ -51,9 +51,42 @@ Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJ
## Features ## Features
### Current Version: v3.7.0 | [Full Changelog](CHANGELOG.md) | [📋 Release Notes](docs/release-note-v3.7.0-en.md) ### Current Version: v3.8.0 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.8.0-en.md)
**v3.7.0 Major Update (2025-11-19)** **v3.8.0 Major Update (2025-11-28)**
**Persistence Architecture Upgrade & Brand New UI**
- **SQLite + JSON Dual-layer Architecture**
- Migrated from JSON file storage to SQLite + JSON dual-layer structure
- Syncable data (providers, MCP, Prompts, Skills) stored in SQLite
- Device-level data (window state, local paths) stored in JSON
- Lays the foundation for future cloud sync functionality
- Schema version management for database migrations
- **Brand New User Interface**
- Completely redesigned interface layout
- Unified component styles and smoother animations
- Optimized visual hierarchy
- Tailwind CSS downgraded from v4 to v3.4 for better browser compatibility
- **Japanese Language Support**
- Added Japanese interface support (now supports Chinese/English/Japanese)
- **Auto Launch on Startup**
- One-click enable/disable in settings
- Platform-native APIs (Registry/LaunchAgent/XDG autostart)
- **Skills Recursive Scanning**
- Support for multi-level directory structures
- Allow same-named skills from different repositories
- **Critical Bug Fixes**
- Fixed custom endpoints lost when updating providers
- Fixed Gemini configuration write issues
- Fixed Linux WebKitGTK rendering issues
**v3.7.0 Highlights**
**Six Core Features, 18,000+ Lines of New Code** **Six Core Features, 18,000+ Lines of New Code**
@@ -226,10 +259,10 @@ Download the latest `CC-Switch-v{version}-Linux.deb` package or `CC-Switch-v{ver
- MCP servers: `~/.gemini/settings.json``mcpServers` - MCP servers: `~/.gemini/settings.json``mcpServers`
- Tray quick switch: Each provider switch rewrites `~/.gemini/.env`, no need to restart Gemini CLI - Tray quick switch: Each provider switch rewrites `~/.gemini/.env`, no need to restart Gemini CLI
**CC Switch Storage** **CC Switch Storage (v3.8.0 New Architecture)**
- Main config (SSOT): `~/.cc-switch/config.json` (includes providers, MCP, Prompts presets, etc.) - Database (SSOT): `~/.cc-switch/cc-switch.db` (SQLite, stores providers, MCP, Prompts, Skills)
- Settings: `~/.cc-switch/settings.json` - Local settings: `~/.cc-switch/settings.json` (device-level settings)
- Backups: `~/.cc-switch/backups/` (auto-rotate, keep 10) - Backups: `~/.cc-switch/backups/` (auto-rotate, keep 10)
### Cloud Sync Setup ### Cloud Sync Setup
@@ -265,11 +298,12 @@ Download the latest `CC-Switch-v{version}-Linux.deb` package or `CC-Switch-v{ver
**Core Design Patterns** **Core Design Patterns**
- **SSOT** (Single Source of Truth): All provider configs stored in `~/.cc-switch/config.json` - **SSOT** (Single Source of Truth): All data stored in `~/.cc-switch/cc-switch.db` (SQLite)
- **Dual-layer Storage**: SQLite for syncable data, JSON for device-level settings
- **Dual-way Sync**: Write to live files on switch, backfill from live when editing active provider - **Dual-way Sync**: Write to live files on switch, backfill from live when editing active provider
- **Atomic Writes**: Temp file + rename pattern prevents config corruption - **Atomic Writes**: Temp file + rename pattern prevents config corruption
- **Concurrency Safe**: RwLock with scoped guards avoids deadlocks - **Concurrency Safe**: Mutex-protected database connection avoids race conditions
- **Layered Architecture**: Clear separation (Commands → Services → Models) - **Layered Architecture**: Clear separation (Commands → Services → DAO → Database)
**Key Components** **Key Components**
+476
View File
@@ -0,0 +1,476 @@
<div align="center">
# Claude Code / Codex / Gemini CLI オールインワン・アシスタント
[![Version](https://img.shields.io/badge/version-3.8.0-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Trending](https://img.shields.io/badge/🔥_TypeScript_Trending-Daily%20%7C%20Weekly%20%7C%20Monthly-ff6b6b.svg)](https://github.com/trending/typescript)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![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)
<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>
[English](README.md) | [中文](README_ZH.md) | 日本語 | [Changelog](CHANGELOG.md) | [v3.8.0 リリースノート](docs/release-note-v3.8.0-en.md)
**プロバイダスイッチャーから AI CLI 一体型管理プラットフォームへ**
Claude Code・Codex・Gemini CLI のプロバイダ設定、MCP サーバー、Skills 拡張、システムプロンプトを統合管理。
</div>
## ❤️スポンサー
![Zhipu GLM](assets/partners/banners/glm-en.jpg)
本プロジェクトは Z.ai の GLM CODING PLAN による支援を受けています。
GLM CODING PLAN は AI コーディング向けのサブスクリプションで、月額わずか 3 ドルから。Claude Code、Cline、Roo Code など 10 以上の人気 AI コーディングツールでフラッグシップモデル GLM-4.6 を利用でき、速く安定した開発体験を提供します。
[このリンク](https://z.ai/subscribe?ic=8JVLJQFSKB) から申し込むと 10% オフになります!
---
<table>
<tr>
<td width="180"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></td>
<td>PackyCode のご支援に感謝します!PackyCode は Claude Code、Codex、Gemini などのリレーサービスを提供する信頼性の高い API 中継プラットフォームです。本ソフト利用者向けに特別割引があります:<a href="https://www.packyapi.com/register?aff=cc-switch">このリンク</a>で登録し、チャージ時に「cc-switch」クーポンを入力すると 10% オフになります。</td>
</tr>
<tr>
<td width="180"><img src="assets/partners/logos/sds-en.png" alt="ShanDianShuo" width="150"></td>
<td>ShanDianShuo のご支援に感謝します!ShanDianShuo はローカルファーストの音声入力ツールで、ミリ秒遅延・データは端末から外に出ず・キーボード入力の 4 倍の速度・AI 自動補正・プライバシー優先で完全無料。Claude Code と組み合わせればコーディング効率が倍増します。<a href="https://www.shandianshuo.cn">Mac/Win 版を無料ダウンロード</a></td>
</tr>
</table>
## スクリーンショット
| メイン画面 | プロバイダ追加 |
| :-------------------------------------------: | :---------------------------------------------: |
| ![メイン画面](assets/screenshots/main-en.png) | ![プロバイダ追加](assets/screenshots/add-en.png) |
## 特長
### 現在のバージョン:v3.8.0 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.8.0-en.md)
**v3.8.0 メジャーアップデート (2025-11-28)**
**永続化アーキテクチャ刷新 & 新 UI**
- **SQLite + JSON 二層構造**
- JSON 単独保存から SQLite + JSON の二層構造へ移行
- 同期対象データ(プロバイダ、MCP、Prompts、Skills)は SQLite に保存
- デバイス固有データ(ウィンドウ状態、ローカルパス)は JSON に保存
- 将来のクラウド同期の土台を用意
- DB マイグレーション用にスキーマバージョンを管理
- **新しいユーザーインターフェース**
- レイアウトを全面再設計
- コンポーネントスタイルとアニメーションを統一
- 視覚的な階層を最適化
- ブラウザ互換性向上のため Tailwind CSS を v4 から v3.4 にダウングレード
- **日本語対応**
- UI が中国語/英語/日本語の 3 言語対応に
- **自動起動**
- 設定画面でワンクリック ON/OFF
- プラットフォームネイティブ APIRegistry/LaunchAgent/XDG autostart)を使用
- **Skills 再帰スキャン**
- 多階層ディレクトリをサポート
- リポジトリが異なる同名スキルを許可
- **重要なバグ修正**
- プロバイダ更新時にカスタムエンドポイントが失われる問題を修正
- Gemini 設定の書き込み問題を修正
- Linux WebKitGTK の描画問題を修正
**v3.7.0 ハイライト**
**6 つのコア機能、18,000 行超の新コード**
- **Gemini CLI 統合**
- Claude Code / Codex / Gemini の 3 番目のサポート AI CLI
- 2 つの設定ファイル(`.env` + `settings.json`)に対応
- MCP サーバー管理を完備
- プリセット:Google 公式(OAuth/ PackyCode / カスタム
- **Claude Skills 管理システム**
- GitHub リポジトリを自動スキャン(3 つのキュレーション済みリポジトリを同梱)
- `~/.claude/skills/` へワンクリックでインストール/アンインストール
- カスタムリポジトリ + サブディレクトリスキャンをサポート
- ライフサイクル管理(検出/インストール/更新)を完備
- **Prompts 管理システム**
- 無制限のシステムプロンプトプリセットを作成
- Markdown エディタ(CodeMirror 6 + リアルタイムプレビュー)付き
- スマートなバックフィル保護で手動変更を保持
- 複数アプリに同時対応(Claude: `CLAUDE.md` / Codex: `AGENTS.md` / Gemini: `GEMINI.md`
- **MCP v3.7.0 統合アーキテクチャ**
- 1 つのパネルで 3 アプリの MCP を管理
- 新たに SSEServer-Sent Events)トランスポートを追加
- スマート JSON パーサー + Codex TOML 自動修正
- 双方向のインポート/エクスポート + 双方向同期
- **ディープリンクプロトコル**
- `ccswitch://` を全プラットフォームで登録
- 共有リンクからプロバイダ設定をワンクリックでインポート
- セキュリティ検証 + ライフサイクル統合
- **環境変数の競合検知**
- Claude/Codex/Gemini/MCP 間の設定競合を自動検出
- 競合表示 + 解決ガイド
- 上書き前の警告 + バックアップ
**コア機能**
- **プロバイダ管理**Claude Code、Codex、Gemini の API 設定をワンクリックで切り替え
- **速度テスト**:エンドポイント遅延を計測し、品質を可視化
- **インポート/エクスポート**:設定をバックアップ・復元(最新 10 件を自動ローテーション)
- **多言語対応**:UI/エラー/トレイを含む中国語・英語・日本語ローカライズ
- **Claude プラグイン同期**:Claude プラグイン設定をワンクリックで適用/復元
**v3.6 ハイライト**
- プロバイダの複製とドラッグ&ドロップ並び替え
- 複数エンドポイント管理とカスタム設定ディレクトリ(クラウド同期準備済み)
- 4 階層のモデル設定(Haiku/Sonnet/Opus/Custom
- WSL 環境をサポートし、ディレクトリ変更時に自動同期
- Hooks テスト 100% カバレッジ + アーキテクチャ全面リファクタ
**システム機能**
- クイックスイッチ付きシステムトレイ
- シングルインスタンス常駐
- ビルトイン自動アップデータ
- ロールバック保護付きのアトミック書き込み
## ダウンロード & インストール
### システム要件
- **Windows**: Windows 10 以上
- **macOS**: macOS 10.15 (Catalina) 以上
- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+ など主要ディストリビューション
### Windows ユーザー
[Releases](../../releases) ページから最新版の `CC-Switch-v{version}-Windows.msi` インストーラー、またはポータブル版 `CC-Switch-v{version}-Windows-Portable.zip` をダウンロード。
### macOS ユーザー
**方法 1: Homebrew でインストール(推奨)**
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
アップデート:
```bash
brew upgrade --cask cc-switch
```
**方法 2: 手動ダウンロード**
[Releases](../../releases) から `CC-Switch-v{version}-macOS.zip` をダウンロードして展開。
> **注意**: 開発者アカウント未登録のため、初回起動時に「開発元を確認できません」と表示される場合があります。一度閉じてから「システム設定」→「プライバシーとセキュリティ」→「このまま開く」をクリックしてください。以降は通常通り起動できます。
### ArchLinux ユーザー
**paru でインストール(推奨)**
```bash
paru -S cc-switch-bin
```
### Linux ユーザー
[Releases](../../releases) から最新版の `CC-Switch-v{version}-Linux.deb` または `CC-Switch-v{version}-Linux.AppImage` をダウンロード。
## クイックスタート
### 基本的な使い方
1. **プロバイダ追加**:「Add Provider」をクリック → プリセットを選ぶかカスタム設定を作成
2. **プロバイダ切り替え**:
- メイン UI: プロバイダを選択 → 「Enable」をクリック
- システムトレイ: プロバイダ名をクリック(即時反映)
3. **反映**: ターミナルや Claude Code / Codex / Gemini クライアントを再起動して適用
4. **公式設定に戻す**: 「Official Login」プリセット(Claude/Codex)または「Google Official」プリセット(Gemini)を選び、対応クライアントを再起動してログイン/OAuth を実行
### MCP 管理
- **入口**: 右上の「MCP」ボタンをクリック
- **サーバー追加**:
- 組み込みテンプレート(mcp-fetch、mcp-filesystem など)を使用
- stdio / http / sse の各トランスポートをサポート
- アプリごとに独立した MCP を設定可能
- **有効/無効**: トグルでライブ設定への同期を切り替え
- **同期**: 有効なサーバーは各アプリのライブファイルへ自動同期
- **インポート/エクスポート**: Claude/Codex/Gemini の設定ファイルから既存 MCP を取り込み
### Skills 管理 (v3.7.0 新機能)
- **入口**: 右上の「Skills」ボタンをクリック
- **スキル探索**:
- 事前設定済みの GitHub リポジトリを自動スキャン(Anthropic 公式、ComposioHQ、コミュニティなど)
- カスタムリポジトリを追加(サブディレクトリスキャン対応)
- **インストール**: 「Install」を押すだけで `~/.claude/skills/` に配置
- **アンインストール**: 「Uninstall」で安全に削除と状態クリーンアップ
- **リポジトリ管理**: カスタム GitHub リポジトリを追加/削除
### Prompts 管理 (v3.7.0 新機能)
- **入口**: 右上の「Prompts」ボタンをクリック
- **プリセット作成**:
- 無制限のシステムプロンプトプリセットを作成
- Markdown エディタで記述(シンタックスハイライト + リアルタイムプレビュー)
- **プリセット切り替え**: プリセットを選択 → 「Activate」で即適用
- **同期先**:
- Claude: `~/.claude/CLAUDE.md`
- Codex: `~/.codex/AGENTS.md`
- Gemini: `~/.gemini/GEMINI.md`
- **保護機構**: 切り替え前に現在の内容を自動保存し、手動変更を保持
### 設定ファイルパス
**Claude Code**
- ライブ設定: `~/.claude/settings.json`(または `claude.json`
- API キーフィールド: `env.ANTHROPIC_AUTH_TOKEN` または `env.ANTHROPIC_API_KEY`
- MCP サーバー: `~/.claude.json``mcpServers`
**Codex**
- ライブ設定: `~/.codex/auth.json`(必須)+ `config.toml`(任意)
- API キーフィールド: `auth.json` 内の `OPENAI_API_KEY`
- MCP サーバー: `~/.codex/config.toml``[mcp_servers]` テーブル
**Gemini**
- ライブ設定: `~/.gemini/.env`API キー)+ `~/.gemini/settings.json`(認証モード)
- API キーフィールド: `.env` 内の `GEMINI_API_KEY` または `GOOGLE_GEMINI_API_KEY`
- 環境変数: `GOOGLE_GEMINI_BASE_URL``GEMINI_MODEL` などをサポート
- MCP サーバー: `~/.gemini/settings.json``mcpServers`
- トレイでのクイックスイッチ: プロバイダ切り替えごとに `~/.gemini/.env` を書き換えるため Gemini CLI の再起動は不要
**CC Switch 保存先 (v3.8.0 新アーキテクチャ)**
- データベース (SSOT): `~/.cc-switch/cc-switch.db`SQLite。プロバイダ、MCP、Prompts、Skills を保存)
- ローカル設定: `~/.cc-switch/settings.json`(デバイスレベル設定)
- バックアップ: `~/.cc-switch/backups/`(自動ローテーション、最新 10 件を保持)
### クラウド同期の設定
1. 設定 → 「Custom Configuration Directory」へ進む
2. クラウド同期フォルダ(Dropbox、OneDrive、iCloud など)を選択
3. アプリを再起動して反映
4. 他のデバイスでも同じフォルダを指定すればクロスデバイス同期が有効に
> **補足**: 初回起動時に既存の Claude/Codex 設定をデフォルトプロバイダとして自動インポートします。
## アーキテクチャ概要
### 設計原則
```
┌─────────────────────────────────────────────────────────────┐
│ Frontend (React + TS) │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Components │ │ Hooks │ │ TanStack Query │ │
│ │ (UI) │──│ (Bus. Logic) │──│ (Cache/Sync) │ │
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
└────────────────────────┬────────────────────────────────────┘
│ Tauri IPC
┌────────────────────────▼────────────────────────────────────┐
│ Backend (Tauri + Rust) │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Commands │ │ Services │ │ Models/Config │ │
│ │ (API Layer) │──│ (Bus. Layer) │──│ (Data) │ │
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
**コア設計パターン**
- **SSOT** (Single Source of Truth): すべてのデータを `~/.cc-switch/cc-switch.db`SQLite)に集約
- **二層ストレージ**: 同期データは SQLite、デバイスデータは JSON
- **双方向同期**: 切り替え時はライブファイルへ書き込み、編集時はアクティブプロバイダから逆同期
- **アトミック書き込み**: 一時ファイル + rename パターンで設定破損を防止
- **並行安全**: Mutex で保護された DB 接続でレースを防ぐ
- **レイヤードアーキテクチャ**: Commands → Services → DAO → Database を明確に分離
**主要コンポーネント**
- **ProviderService**: プロバイダの CRUD、切り替え、バックフィル、ソート
- **McpService**: MCP サーバー管理、インポート/エクスポート、ライブファイル同期
- **ConfigService**: 設定のインポート/エクスポート、バックアップローテーション
- **SpeedtestService**: API エンドポイントの遅延計測
**v3.6 リファクタリング**
- バックエンド: エラーハンドリング → コマンド分割 → テスト → サービス層 → 並行性の 5 フェーズ
- フロントエンド: テスト基盤 → hooks → コンポーネント → クリーンアップの 4 ステージ
- テスト: hooks 100% カバレッジ + 統合テスト(vitest + MSW
## 開発
### 開発環境
- Node.js 18+
- pnpm 8+
- Rust 1.85+
- Tauri CLI 2.8+
### 開発コマンド
```bash
# 依存関係をインストール
pnpm install
# ホットリロード付き開発モード
pnpm dev
# 型チェック
pnpm typecheck
# コード整形
pnpm format
# フォーマット検証
pnpm format:check
# フロントエンド単体テスト
pnpm test:unit
# ウォッチモード(開発に推奨)
pnpm test:unit:watch
# アプリをビルド
pnpm build
# デバッグビルド
pnpm tauri build --debug
```
### Rust バックエンド開発
```bash
cd src-tauri
# Rust コード整形
cargo fmt
# clippy チェック
cargo clippy
# バックエンドテスト
cargo test
# 特定テストのみ実行
cargo test test_name
# test-hooks フィーチャー付きでテスト
cargo test --features test-hooks
```
### テストガイド (v3.6)
**フロントエンドテスト**:
- テストフレームワークに **vitest** を使用
- **MSW (Mock Service Worker)** で Tauri API 呼び出しをモック
- コンポーネントテストに **@testing-library/react** を採用
**テストカバレッジ**:
- Hooks の単体テスト(100% カバレッジ)
- `useProviderActions` - プロバイダ操作
- `useMcpActions` - MCP 管理
- `useSettings` 系 - 設定管理
- `useImportExport` - インポート/エクスポート
- 統合テスト
- アプリのメインフロー
- SettingsDialog の一連操作
- MCP パネルの機能
**テスト実行**:
```bash
# 全テストを実行
pnpm test:unit
# ウォッチモード(自動再実行)
pnpm test:unit:watch
# カバレッジレポート付き
pnpm test:unit --coverage
```
## 技術スタック
**フロントエンド**: React 18 · TypeScript · Vite · TailwindCSS 4 · TanStack Query v5 · react-i18next · react-hook-form · zod · shadcn/ui · @dnd-kit
**バックエンド**: Tauri 2.8 · Rust · serde · tokio · thiserror · tauri-plugin-updater/process/dialog/store/log
**テスト**: vitest · MSW · @testing-library/react
## プロジェクト構成
```
├── src/ # フロントエンド (React + TypeScript)
│ ├── components/ # UI コンポーネント (providers/settings/mcp/ui)
│ ├── hooks/ # ビジネスロジック用カスタムフック
│ ├── lib/
│ │ ├── api/ # Tauri API ラッパー (型安全)
│ │ └── query/ # TanStack Query 設定
│ ├── i18n/locales/ # 翻訳 (zh/en)
│ ├── config/ # プリセット (providers/mcp)
│ └── types/ # TypeScript 型定義
├── src-tauri/ # バックエンド (Rust)
│ └── src/
│ ├── commands/ # Tauri コマンド層 (ドメイン別)
│ ├── services/ # ビジネスロジック層
│ ├── app_config.rs # 設定モデル
│ ├── provider.rs # プロバイダドメインモデル
│ ├── mcp.rs # MCP 同期 & 検証
│ └── lib.rs # アプリエントリ & トレイメニュー
├── tests/ # フロントエンドテスト
│ ├── hooks/ # 単体テスト
│ └── components/ # 統合テスト
└── assets/ # スクリーンショット & スポンサーリソース
```
## 更新履歴
詳細は [CHANGELOG.md](CHANGELOG.md) をご覧ください。
## 旧 Electron 版
[Releases](../../releases) に v2.0.3 の Electron 旧版を残しています。
旧版コードが必要な場合は `electron-legacy` ブランチを取得してください。
## 貢献
Issue や提案を歓迎します!
PR を送る前に以下をご確認ください:
- 型チェック: `pnpm typecheck`
- フォーマットチェック: `pnpm format:check`
- 単体テスト: `pnpm test:unit`
- 💡 新機能の場合は、事前に Issue でディスカッションしていただけると助かります
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=farion1231/cc-switch&type=Date)](https://www.star-history.com/#farion1231/cc-switch&Date)
## ライセンス
MIT © Jason Young
+44 -10
View File
@@ -2,7 +2,7 @@
# Claude Code / Codex / Gemini CLI 全方位辅助工具 # Claude Code / Codex / Gemini CLI 全方位辅助工具
[![Version](https://img.shields.io/badge/version-3.7.1-blue.svg)](https://github.com/farion1231/cc-switch/releases) [![Version](https://img.shields.io/badge/version-3.8.0-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Trending](https://img.shields.io/badge/🔥_TypeScript_Trending-Daily%20%7C%20Weekly%20%7C%20Monthly-ff6b6b.svg)](https://github.com/trending/typescript) [![Trending](https://img.shields.io/badge/🔥_TypeScript_Trending-Daily%20%7C%20Weekly%20%7C%20Monthly-ff6b6b.svg)](https://github.com/trending/typescript)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/) [![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
@@ -10,7 +10,7 @@
<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> <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>
[English](README.md) | 中文 | [更新日志](CHANGELOG.md) | [📋 v3.7.0 发布说明](docs/release-note-v3.7.0-zh.md) [English](README.md) | 中文 | [日本語](README_JA.md) | [更新日志](CHANGELOG.md) | [v3.8.0 发布说明](docs/release-note-v3.8.0-zh.md)
**从供应商切换器到 AI CLI 一体化管理平台** **从供应商切换器到 AI CLI 一体化管理平台**
@@ -51,9 +51,42 @@ CC Switch 已经预设了智谱GLM,只需要填写 key 即可一键导入编
## 功能特性 ## 功能特性
### 当前版本:v3.7.0 | [完整更新日志](CHANGELOG.md) ### 当前版本:v3.8.0 | [完整更新日志](CHANGELOG.md)
**v3.7.0 重大更新(2025-11-19** **v3.8.0 重大更新(2025-11-28**
**持久化架构升级 & 全新用户界面**
- **SQLite + JSON 双层架构**
- 从 JSON 文件存储迁移到 SQLite + JSON 双层结构
- 可同步数据(供应商、MCP、Prompts、Skills)存入 SQLite
- 设备级数据(窗口状态、本地路径)保留在 JSON
- 为未来云同步功能奠定基础
- Schema 版本管理支持数据库迁移
- **全新用户界面**
- 完全重新设计的界面布局
- 统一的组件样式和更流畅的动画
- 优化的视觉层次
- Tailwind CSS 从 v4 降级到 v3.4 以提升浏览器兼容性
- **日语支持**
- 新增日语界面支持(现支持中文/英文/日语)
- **开机自启**
- 在设置中一键开启/关闭
- 使用平台原生 API(注册表/LaunchAgent/XDG autostart
- **Skills 递归扫描**
- 支持多层目录结构
- 允许不同仓库的同名技能
- **关键 Bug 修复**
- 修复更新供应商时自定义端点丢失问题
- 修复 Gemini 配置写入问题
- 修复 Linux WebKitGTK 渲染问题
**v3.7.0 亮点**
**六大核心功能,18,000+ 行新增代码** **六大核心功能,18,000+ 行新增代码**
@@ -226,10 +259,10 @@ paru -S cc-switch-bin
- MCP 服务器:`~/.gemini/settings.json``mcpServers` - MCP 服务器:`~/.gemini/settings.json``mcpServers`
- 托盘快速切换:每次切换供应商都会重写 `~/.gemini/.env`,无需重启 Gemini CLI 即可生效 - 托盘快速切换:每次切换供应商都会重写 `~/.gemini/.env`,无需重启 Gemini CLI 即可生效
**CC Switch 存储** **CC Switch 存储v3.8.0 新架构)**
- 主配置SSOT):`~/.cc-switch/config.json`(包含供应商、MCP、Prompts 预设等 - 数据库SSOT):`~/.cc-switch/cc-switch.db`SQLite,存储供应商、MCP、Prompts、Skills
- 设置:`~/.cc-switch/settings.json` - 本地设置:`~/.cc-switch/settings.json`(设备级设置)
- 备份:`~/.cc-switch/backups/`(自动轮换,保留 10 个) - 备份:`~/.cc-switch/backups/`(自动轮换,保留 10 个)
### 云同步设置 ### 云同步设置
@@ -265,11 +298,12 @@ paru -S cc-switch-bin
**核心设计模式** **核心设计模式**
- **SSOT**(单一事实源):所有供应商配置存储在 `~/.cc-switch/config.json` - **SSOT**(单一事实源):所有数据存储在 `~/.cc-switch/cc-switch.db`SQLite
- **双层存储**SQLite 存储可同步数据,JSON 存储设备级设置
- **双向同步**:切换时写入 live 文件,编辑当前供应商时从 live 回填 - **双向同步**:切换时写入 live 文件,编辑当前供应商时从 live 回填
- **原子写入**:临时文件 + 重命名模式防止配置损坏 - **原子写入**:临时文件 + 重命名模式防止配置损坏
- **并发安全**RwLock 与作用域守卫避免死锁 - **并发安全**Mutex 保护的数据库连接避免竞态条件
- **分层架构**:清晰分离(Commands → Services → Models - **分层架构**:清晰分离(Commands → Services → DAO → Database
**核心组件** **核心组件**
+4 -3
View File
@@ -4,7 +4,7 @@
1. **安装依赖**:添加了 `react-i18next``i18next` 1. **安装依赖**:添加了 `react-i18next``i18next`
2. **配置国际化**:在 `src/i18n/` 目录下创建了配置文件 2. **配置国际化**:在 `src/i18n/` 目录下创建了配置文件
3. **翻译文件**:创建了英文和中文翻译文件 3. **翻译文件**:创建了英文、中文、日文翻译文件
4. **组件更新**:替换了主要组件中的硬编码文案 4. **组件更新**:替换了主要组件中的硬编码文案
5. **语言切换器**:添加了语言切换按钮 5. **语言切换器**:添加了语言切换按钮
@@ -16,6 +16,7 @@ src/
│ ├── index.ts # 国际化配置文件 │ ├── index.ts # 国际化配置文件
│ └── locales/ │ └── locales/
│ ├── en.json # 英文翻译 │ ├── en.json # 英文翻译
│ ├── ja.json # 日文翻译
│ └── zh.json # 中文翻译 │ └── zh.json # 中文翻译
├── components/ ├── components/
│ └── LanguageSwitcher.tsx # 语言切换组件 │ └── LanguageSwitcher.tsx # 语言切换组件
@@ -24,7 +25,7 @@ src/
## 默认语言设置 ## 默认语言设置
- **默认语言**文 (en) - **默认语言**文 (zh)(无首选时根据浏览器/系统语言选择 zh/en/ja)
- **回退语言**:英文 (en) - **回退语言**:英文 (en)
## 使用方式 ## 使用方式
@@ -57,7 +58,7 @@ src/
## 测试功能 ## 测试功能
应用已添加了语言切换按钮(地球图标),点击可以在中英文之间切换,验证国际化功能是否正常工作。 应用已添加了语言切换按钮,支持中文、英文、日文三种语言切换,验证国际化功能是否正常工作。
## 已更新的组件 ## 已更新的组件
+369
View File
@@ -0,0 +1,369 @@
# CC Switch v3.8.0
> Persistence Architecture Upgrade, Laying the Foundation for Cloud Sync
**[中文版 →](release-note-v3.8.0-zh.md) | [日本語版 →](release-note-v3.8.0-ja.md)**
---
## Overview
CC 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.
**Release Date**: 2025-11-28
**Commits**: 51 commits since v3.7.1
**Code Changes**: 207 files, +17,297 / -6,870 lines
---
## Major Updates
### Persistence Architecture Upgrade
Migrated from single JSON file storage to SQLite + JSON dual-layer architecture for hierarchical data management.
**Architecture Changes**:
```
v3.7.x (Old) v3.8.0 (New)
┌─────────────────┐ ┌─────────────────────────────────┐
│ config.json │ │ SQLite (Syncable Data) │
│ ┌───────────┐ │ │ ├─ providers Provider cfg │
│ │ providers │ │ │ ├─ mcp_servers MCP servers │
│ │ mcp │ │ ──> │ ├─ prompts Prompts │
│ │ prompts │ │ │ ├─ skills Skills │
│ │ settings │ │ │ └─ settings General cfg │
│ └───────────┘ │ ├─────────────────────────────────┤
└─────────────────┘ │ JSON (Device-level Data) │
│ └─ settings.json Local settings│
│ ├─ Window position │
│ ├─ Path overrides │
│ └─ Current provider ID │
└─────────────────────────────────┘
```
**Dual-layer Structure Design**:
| Layer | Storage | Data Types | Sync Strategy |
| ---------- | ------- | ------------------------------- | --------------- |
| Cloud Sync | SQLite | Providers, MCP, Prompts, Skills | Future syncable |
| Device | JSON | Window state, local paths | Stays local |
**Technical Implementation**:
- **Schema Version Management** - Supports database structure upgrade migrations
- **SQL Import/Export** - `backup.rs` supports SQL dump for cloud storage
- **Transaction Support** - SQLite native transactions ensure data consistency
- **Auto Migration** - Automatically migrates from `config.json` on first launch
**Modular Refactoring**:
```
database/
├── mod.rs Core Database struct and initialization
├── schema.rs Table definitions, schema version migrations
├── backup.rs SQL import/export, binary snapshot backup
├── migration.rs JSON → SQLite data migration engine
└── dao/ Data Access Object layer
├── providers.rs Provider CRUD
├── mcp.rs MCP server CRUD
├── prompts.rs Prompts CRUD
├── skills.rs Skills CRUD
└── settings.rs Key-value settings storage
```
---
### Brand New User Interface
Completely redesigned UI providing a more modern visual experience.
**Visual Improvements**:
- Redesigned interface layout
- Unified component styles
- Smoother transition animations
- Optimized visual hierarchy
**Interaction Enhancements**:
- Redesigned header toolbar
- Unified ConfirmDialog styling
- Disabled overscroll bounce effect on main view
- Improved form validation feedback
**Compatibility Adjustments**:
- Downgraded Tailwind CSS from v4 to v3.4 for better browser compatibility
---
### Japanese Language Support
Added Japanese interface support, expanding internationalization to three languages.
**Supported Languages**:
- Simplified Chinese
- English
- Japanese (New)
---
## New Features
### Skills Recursive Scanning
Skills management system now supports recursive scanning of repository directories, automatically discovering nested skill files.
**Improvements**:
- Support for multi-level directory structures
- Automatic discovery of all `SKILL.md` files
- Allow same-named skills from different repositories (using full path for deduplication)
---
### Provider Icon Configuration
Provider presets now support custom icon configuration.
**Features**:
- Preset providers include default icons
- Icon settings preserved when duplicating providers
- Custom icon colors
---
### Enhanced Form Validation
Provider forms now include required field validation with friendlier error messages.
**Improvements**:
- Real-time validation for required fields
- Unified Toast notifications for validation errors
- Clearer error messages
---
### Auto Launch on Startup
Added auto-launch functionality supporting Windows, macOS, and Linux platforms.
**Features**:
- One-click enable/disable in settings
- Implemented using platform-native APIs
- Windows uses Registry, macOS uses LaunchAgent, Linux uses XDG autostart
---
### New Provider Presets
- **MiniMax** - Official partner
---
## Bug Fixes
### Critical Fixes
**Custom Endpoints Lost Issue**
Fixed an issue where custom request URLs were unexpectedly lost when updating providers.
- Root Cause: `INSERT OR REPLACE` executes `DELETE + INSERT` under the hood in SQLite, triggering foreign key cascade deletion
- Fix: Changed to use `UPDATE` statement for existing providers
**Gemini Configuration Issues**
- Fixed custom provider environment variables not correctly written to `.env` file
- Fixed security auth config incorrectly written to other config files
**Provider Validation Issues**
- Fixed validation error when current provider ID doesn't exist
- Fixed icon fields lost when duplicating providers
### Platform Compatibility
**Linux**
- Resolved WebKitGTK DMA-BUF rendering issue
- Preserve user `.desktop` file customizations
### Other Fixes
- Fixed redundant usage queries when switching apps
- Fixed DMXAPI preset using wrong auth token field
- Fixed missing translation keys in deeplink components
- Fixed usage script template initialization logic
---
## Technical Improvements
### Architecture Refactoring
**Provider Service Modularization**:
```
services/provider/
├── mod.rs Core service - add/update/delete/switch/validate
├── live.rs Live config file operations
├── gemini_auth.rs Gemini auth type detection
├── endpoints.rs Custom endpoint management
└── usage.rs Usage script execution
```
**Deeplink Modularization**:
```
deeplink/
├── mod.rs Module exports
├── parser.rs URL parsing
├── provider.rs Provider import logic
├── mcp.rs MCP import logic
├── prompt.rs Prompt import
├── skill.rs Skills import
└── utils.rs Utility functions
```
### Code Quality
**Cleanup**:
- Removed legacy JSON-era import/export dead code
- Removed unused MCP type exports
- Unified error handling approach
**Test Updates**:
- Migrated tests to SQLite database architecture
- Updated component tests to match current implementation
- Fixed MSW handlers to adapt to new API
---
## Technical Statistics
```
Overall Changes:
- Commits: 51
- Files: 207 files changed
- Additions: +17,297 lines
- Deletions: -6,870 lines
- Net: +10,427 lines
Commit Type Distribution:
- fix: 25 (Bug fixes)
- refactor: 11 (Code refactoring)
- feat: 9 (New features)
- test: 1 (Testing)
- other: 5
Change Area Distribution:
- Frontend source: 112 files
- Rust backend: 63 files
- Test files: 20 files
- i18n files: 3 files
```
---
## Migration Guide
### Upgrading from v3.7.x
**Auto Migration** - Executes automatically on first launch:
1. Detects if `config.json` exists
2. Migrates all data to SQLite within a transaction
3. Migrates device-level settings to `settings.json`
4. Shows migration success notification
**Data Safety**:
- Original `config.json` file is preserved (not deleted)
- Error dialog displayed on migration failure, `config.json` preserved
- Supports Dry-run mode to verify migration logic
---
## Download & Installation
### System Requirements
- **Windows**: Windows 10+
- **macOS**: macOS 10.15 (Catalina)+
- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+
### Download Links
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download:
- **Windows**: `CC-Switch-v3.8.0-Windows.msi` or `-Portable.zip`
- **macOS**: `CC-Switch-v3.8.0-macOS.tar.gz` or `.zip`
- **Linux**: `CC-Switch-v3.8.0-Linux.AppImage` or `.deb`
### Homebrew (macOS)
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
Update:
```bash
brew upgrade --cask cc-switch
```
## Acknowledgments
### Contributors
Thanks to all contributors who made this release possible:
- [@YoVinchen](https://github.com/YoVinchen) - UI and database refactoring
- [@farion1231](https://github.com/farion1231) - Bug fixes and feature enhancements
- Community members for testing and feedback
### Sponsors
**Zhipu AI** - GLM CODING PLAN Sponsor
[Get 10% off with this link](https://z.ai/subscribe?ic=8JVLJQFSKB)
**PackyCode** - API Relay Service Partner
[Use code "cc-switch" for 10% off registration](https://www.packyapi.com/register?aff=cc-switch)
**ShandianShuo** - Local-first AI Voice Input
[Free download](https://shandianshuo.cn) for Mac/Windows
**MiniMax** - MiniMax M2 CODING PLAN Sponsor
[Black Friday sale, plans starting at $2](https://platform.minimax.io/subscribe/coding-plan)
---
## Feedback & Support
- **Issue Reports**: [GitHub Issues](https://github.com/farion1231/cc-switch/issues)
- **Discussions**: [GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)
- **Documentation**: [README](../README.md)
- **Changelog**: [CHANGELOG.md](../CHANGELOG.md)
---
## Future Roadmap
**v3.9.0 Preview** (Tentative):
- Local proxy feature
Stay tuned for more updates!
---
**Happy Coding!**
+315
View File
@@ -0,0 +1,315 @@
# CC Switch v3.8.0
> 永続化アーキテクチャを刷新し、クラウド同期の土台を構築
**[English →](release-note-v3.8.0-en.md) | [中文版 →](release-note-v3.8.0-zh.md)**
---
## 概要
CC Switch v3.8.0 はデータ永続化レイヤーと UI を大幅に作り替え、今後のクラウド同期やローカルプロキシ機能に向けた基盤を整えたメジャーアップデートです。
**リリース日**: 2025-11-28
**コミット数**: v3.7.1 以降 51 commits
**変更量**: 207 files, +17,297 / -6,870 lines
---
## 主要アップデート
### 永続化アーキテクチャの刷新
単一の JSON 保存から、階層化された SQLite + JSON の二層構造へ移行。
**アーキテクチャ変更**:
```
v3.7.x (旧) v3.8.0 (新)
┌─────────────────┐ ┌─────────────────────────────────┐
│ config.json │ │ SQLite (同期対象データ) │
│ ┌───────────┐ │ │ ├─ providers プロバイダ設定 │
│ │ providers │ │ │ ├─ mcp_servers MCP サーバー │
│ │ mcp │ │ ──> │ ├─ prompts プロンプト │
│ │ prompts │ │ │ ├─ skills Skills │
│ │ settings │ │ │ └─ settings 汎用設定 │
│ └───────────┘ │ ├─────────────────────────────────┤
└─────────────────┘ │ JSON (デバイス固有データ) │
│ └─ settings.json ローカル設定 │
│ ├─ ウィンドウ位置 │
│ ├─ パスの上書き │
│ └─ 現在のプロバイダ ID │
└─────────────────────────────────┘
```
**二層構造の設計**:
| レイヤー | ストレージ | データ種別 | 同期戦略 |
| -------- | ---------- | ----------------------------------- | ---------------- |
| クラウド | SQLite | Providers, MCP, Prompts, Skills | 将来同期対象 |
| デバイス | JSON | ウィンドウ状態、ローカルパス | ローカル保持 |
**実装ポイント**:
- **スキーマバージョン管理**: DB 構造のマイグレーションに対応
- **SQL インポート/エクスポート**: `backup.rs` が SQL ダンプをサポート
- **トランザクション対応**: SQLite ネイティブで整合性を確保
- **自動マイグレーション**: 初回起動で `config.json` から自動移行
**モジュール分割**:
```
database/
├── mod.rs Database 構造体と初期化
├── schema.rs テーブル定義とスキーマ移行
├── backup.rs SQL インポート/エクスポートとスナップショット
├── migration.rs JSON → SQLite 変換エンジン
└── dao/ DAO レイヤー
├── providers.rs プロバイダ CRUD
├── mcp.rs MCP CRUD
├── prompts.rs プロンプト CRUD
├── skills.rs Skills CRUD
└── settings.rs 設定 Key-Value 保存
```
---
### 新しいユーザーインターフェース
よりモダンな見た目と操作感に再設計。
- レイアウト全面刷新、コンポーネントスタイルを統一
- トランジションを滑らかにし、視覚的階層を最適化
- メインビューのオーバースクロールバウンスを無効化
- ブラウザ互換性向上のため Tailwind CSS を v4→v3.4 にダウングレード
---
### 日語化
UI が日本語に対応し、国際化が 3 言語(中/英/日)へ拡大。
---
## 新機能
### Skills 递帰スキャン
Skills 管理がリポジトリを再帰的に走査し、ネストされた `SKILL.md` を自動検出。
- 複数階層のディレクトリに対応
- すべての `SKILL.md` を自動発見
- パスをキーにした重複排除で同名スキルを許容
### プロバイダアイコン設定
プリセットがデフォルトアイコンを持ち、複製してもアイコンを保持。カスタム色も設定可能。
### フォームバリデーション強化
必須項目にリアルタイム検証と分かりやすいエラーメッセージを追加し、トースト通知を統一。
### 自動起動
Windows/macOS/Linux で自動起動をサポート。
- 設定画面からワンクリックで ON/OFF
- Registry / LaunchAgent / XDG autostart を使用
### 新プロバイダプリセット
- **MiniMax** - 公式パートナー
---
## バグ修正
### 重要修正
**カスタムエンドポイント消失**
- 原因: SQLite の `INSERT OR REPLACE` が内部で `DELETE + INSERT` を実行し、外部キーのカスケード削除が発生
- 対応: 既存プロバイダ更新を `UPDATE` に変更
**Gemini 設定**
- カスタム環境変数が `.env` に正しく書き込まれない問題を修正
- 認証設定が他ファイルに誤って書き込まれる問題を修正
**プロバイダ検証**
- 現在プロバイダ ID が欠落している場合のバリデーションエラーを修正
- 複製時にアイコンフィールドが失われる問題を修正
### プラットフォーム互換性
**Linux**
- WebKitGTK の DMA-BUF 描画問題を解消
- ユーザーの `.desktop` カスタマイズを保持
### その他修正
- アプリ切り替え時の不要な使用量クエリを削減
- DMXAPI プリセットの誤ったトークンフィールドを修正
- Deeplink コンポーネントの欠損翻訳キーを補完
- 使用量スクリプトテンプレート初期化を修正
---
## 技術的改善
### アーキテクチャ再編
**Provider Service のモジュール化**:
```
services/provider/
├── mod.rs 追加/更新/削除/切替/検証の中核
├── live.rs ライブ設定ファイル操作
├── gemini_auth.rs Gemini 認証タイプ検出
├── endpoints.rs カスタムエンドポイント管理
└── usage.rs 使用量スクリプト実行
```
**Deeplink のモジュール化**:
```
deeplink/
├── mod.rs エクスポート
├── parser.rs URL パース
├── provider.rs プロバイダ取り込み
├── mcp.rs MCP 取り込み
├── prompt.rs プロンプト取り込み
├── skill.rs Skills 取り込み
└── utils.rs ユーティリティ
```
### コード品質
- レガシーな JSON 時代のインポート/エクスポート死蔵コードを削除
- 使われていない MCP 型を削除し、エラーハンドリングを統一
- テストを SQLite バックエンドに移行し、MSW ハンドラを最新 API に合わせて更新
---
## 技術統計
```
全体変更:
- コミット: 51
- 変更ファイル: 207
- 追加: +17,297 行
- 削除: -6,870 行
- 純増: +10,427 行
コミット種別:
- fix: 25
- refactor: 11
- feat: 9
- test: 1
- other: 5
変更箇所:
- フロントエンド: 112 files
- Rust バックエンド: 63 files
- テスト: 20 files
- i18n: 3 files
```
---
## マイグレーションガイド
### v3.7.x からのアップグレード
**自動マイグレーション**(初回起動時):
1. `config.json` の存在を検出
2. 全データをトランザクションで SQLite に移行
3. デバイス設定を `settings.json` へ移行
4. 移行成功の通知を表示
**データ保護**:
- 元の `config.json` は保持(削除しない)
- 失敗時はエラーダイアログを表示し、`config.json` を温存
- Dry-run モードで検証可能
---
## ダウンロード & インストール
### システム要件
- **Windows**: Windows 10+
- **macOS**: macOS 10.15 (Catalina)+
- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+
### ダウンロード
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から入手:
- **Windows**: `CC-Switch-v3.8.0-Windows.msi` または `-Portable.zip`
- **macOS**: `CC-Switch-v3.8.0-macOS.tar.gz` または `.zip`
- **Linux**: `CC-Switch-v3.8.0-Linux.AppImage` または `.deb`
### Homebrew (macOS)
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
アップデート:
```bash
brew upgrade --cask cc-switch
```
---
## 謝辞
### コントリビューター
- [@YoVinchen](https://github.com/YoVinchen) - UI とデータベースリファクタ
- [@farion1231](https://github.com/farion1231) - バグ修正と機能拡張
- コミュニティの皆さん - テストとフィードバック
### スポンサー
**Zhipu AI** - GLM CODING PLAN スポンサー
[10% オフリンク](https://z.ai/subscribe?ic=8JVLJQFSKB)
**PackyCode** - API リレーサービスパートナー
[登録時に「cc-switch」で 10% オフ](https://www.packyapi.com/register?aff=cc-switch)
**ShandianShuo** - ローカルファースト音声入力
[Mac/Windows 無料ダウンロード](https://shandianshuo.cn)
**MiniMax** - MiniMax M2 CODING PLAN スポンサー
[ブラックフライデーセール中、$2 から](https://platform.minimax.io/subscribe/coding-plan)
---
## フィードバック & サポート
- **Issue**: [GitHub Issues](https://github.com/farion1231/cc-switch/issues)
- **Discussions**: [GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)
- **ドキュメント**: [README](../README.md)
- **更新履歴**: [CHANGELOG.md](../CHANGELOG.md)
---
## 今後のロードマップ
**v3.9.0 予告(予定)**:
- ローカルプロキシ機能
続報にご期待ください!
---
**Happy Coding!**
+369
View File
@@ -0,0 +1,369 @@
# CC Switch v3.8.0
> 持久化架构升级,为云同步奠定基础
**[English Version →](release-note-v3.8.0-en.md)**
---
## 概览
CC Switch v3.8.0 是一次重大的架构升级版本,重构了数据持久化层和用户界面,为未来的云同步和本地代理功能奠定基础。
**发布日期**2025-11-28
**提交数量**:从 v3.7.1 开始 51 个提交
**代码变更**207 个文件,+17,297 / -6,870 行
---
## 重大更新
### 持久化架构升级
从单一 JSON 文件存储迁移到 SQLite + JSON 双层架构,实现数据分层管理。
**架构变更**
```
v3.7.x (旧) v3.8.0 (新)
┌─────────────────┐ ┌─────────────────────────────────┐
│ config.json │ │ SQLite (可同步数据) │
│ ┌───────────┐ │ │ ├─ providers 供应商配置 │
│ │ providers │ │ │ ├─ mcp_servers MCP 服务器 │
│ │ mcp │ │ ──> │ ├─ prompts 提示词 │
│ │ prompts │ │ │ ├─ skills 技能 │
│ │ settings │ │ │ └─ settings 通用设置 │
│ └───────────┘ │ ├─────────────────────────────────┤
└─────────────────┘ │ JSON (设备级数据) │
│ └─ settings.json 本地设置 │
│ ├─ 窗口位置 │
│ ├─ 路径覆盖 │
│ └─ 当前选中供应商 ID │
└─────────────────────────────────┘
```
**双层结构设计**
| 层级 | 存储方式 | 数据类型 | 同步策略 |
| -------- | -------- | ---------------------------- | ---------- |
| 云同步层 | SQLite | 供应商、MCP、Prompts、Skills | 未来可同步 |
| 设备层 | JSON | 窗口状态、本地路径、当前选择 | 保持本地 |
**技术实现**
- **Schema 版本管理** - 支持数据库结构升级迁移
- **SQL 导入导出** - `backup.rs` 支持 SQL dump,便于云端存储
- **事务支持** - SQLite 原生事务保证数据一致性
- **自动迁移** - 首次启动自动从 `config.json` 迁移数据
**模块化重构**
```
database/
├── mod.rs 核心 Database 结构体和初始化
├── schema.rs 表结构定义、Schema 版本迁移
├── backup.rs SQL 导入导出、二进制快照备份
├── migration.rs JSON → SQLite 数据迁移引擎
└── dao/ 数据访问对象层
├── providers.rs 供应商 CRUD
├── mcp.rs MCP 服务器 CRUD
├── prompts.rs 提示词 CRUD
├── skills.rs Skills CRUD
└── settings.rs 键值对设置存储
```
---
### 全新用户界面
完整重构的 UI 设计,提供更现代化的视觉体验。
**视觉改进**
- 重新设计的界面布局
- 统一的组件样式
- 更流畅的过渡动画
- 优化的视觉层次
**交互优化**
- Header toolbar 重新设计
- ConfirmDialog 样式统一
- 禁用主视图 overscroll 弹跳效果
- 改进的表单验证反馈
**兼容性调整**
- Tailwind CSS 从 v4 降级到 v3.4,提升浏览器兼容性
---
### 日语支持
新增日语(日本語)界面支持,国际化语言扩展到三种。
**支持语言**
- 简体中文
- English
- 日本語(新增)
---
## 新增功能
### Skills 递归扫描
Skills 管理系统支持递归扫描仓库目录,自动发现嵌套的技能文件。
**改进内容**
- 支持多层目录结构
- 自动发现所有 `SKILL.md` 文件
- 允许不同仓库的同名技能(使用完整路径去重)
---
### 供应商图标配置
供应商预设支持自定义图标配置。
**功能特性**
- 预设供应商包含默认图标
- 复制供应商时保留图标设置
- 图标颜色自定义
---
### 表单验证增强
供应商表单新增必填字段验证,提供更友好的错误提示。
**改进内容**
- 必填字段实时校验
- 统一使用 Toast 通知显示验证错误
- 更清晰的错误信息
---
### 开机自启
新增开机自动启动功能,支持 Windows、macOS 和 Linux 三个平台。
**功能特性**
- 在设置中一键开启/关闭
- 使用平台原生 API 实现
- Windows 使用注册表、macOS 使用 LaunchAgent、Linux 使用 XDG autostart
---
### 新增供应商预设
- **MiniMax** - 官方合作伙伴
---
## Bug 修复
### 关键修复
**自定义端点丢失问题**
修复更新供应商时自定义请求地址意外丢失的问题。
- 根因:`INSERT OR REPLACE` 在 SQLite 底层执行 `DELETE + INSERT`,触发外键级联删除
- 修复:改用 `UPDATE` 语句更新已存在的供应商
**Gemini 配置问题**
- 修复自定义供应商环境变量未正确写入 `.env` 文件
- 修复安全认证配置错误写入到其他配置文件
**供应商验证问题**
- 修复当前供应商 ID 不存在时的验证错误
- 修复供应商复制时图标字段丢失
### 平台兼容性
**Linux**
- 解决 WebKitGTK DMA-BUF 渲染问题
- 保留用户 `.desktop` 文件自定义
### 其他修复
- 修复切换应用时的冗余用量查询
- 修复 DMXAPI 预设使用错误的认证令牌字段
- 修复深链接组件缺少翻译键
- 修复用量脚本模板初始化逻辑
---
## 技术改进
### 架构重构
**供应商服务模块化**
```
services/provider/
├── mod.rs 核心服务 - add/update/delete/switch/validate
├── live.rs Live 配置文件操作
├── gemini_auth.rs Gemini 认证类型检测
├── endpoints.rs 自定义端点管理
└── usage.rs 用量脚本执行
```
**深链接模块化**
```
deeplink/
├── mod.rs 模块导出
├── parser.rs URL 解析
├── provider.rs 供应商导入逻辑
├── mcp.rs MCP 导入逻辑
├── prompt.rs 提示词导入
├── skill.rs Skills 导入
└── utils.rs 工具函数
```
### 代码质量
**清理工作**
- 移除 JSON 时代遗留的导入导出死代码
- 移除未使用的 MCP 类型导出
- 统一错误处理方式
**测试更新**
- 迁移测试到 SQLite 数据库架构
- 更新组件测试匹配当前实现
- 修复 MSW handlers 适配新 API
---
## 技术统计
```
总体变更:
- 提交数:51
- 文件数:207 个文件变更
- 新增:+17,297 行
- 删除:-6,870 行
- 净增:+10,427 行
提交类型分布:
- fix25 个(Bug 修复)
- refactor11 个(代码重构)
- feat9 个(新功能)
- test1 个(测试)
- 其他:5 个
改动区域分布:
- 前端源码:112 个文件
- Rust 后端:63 个文件
- 测试文件:20 个文件
- 国际化文件:3 个文件
```
---
## 迁移说明
### 从 v3.7.x 升级
**自动迁移** - 首次启动时自动执行:
1. 检测 `config.json` 是否存在
2. 在事务中迁移所有数据到 SQLite
3. 设备级设置迁移到 `settings.json`
4. 显示迁移成功通知
**数据安全**
-`config.json` 文件保留不删除
- 迁移失败时显示错误对话框,保留`config.json`
- 支持 Dry-run 模式验证迁移逻辑
---
## 下载与安装
### 系统要求
- **Windows**Windows 10+
- **macOS**macOS 10.15Catalina+
- **Linux**Ubuntu 22.04+ / Debian 11+ / Fedora 34+
### 下载链接
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载:
- **Windows**`CC-Switch-v3.8.0-Windows.msi``-Portable.zip`
- **macOS**`CC-Switch-v3.8.0-macOS.tar.gz``.zip`
- **Linux**`CC-Switch-v3.8.0-Linux.AppImage``.deb`
### HomebrewmacOS
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
更新:
```bash
brew upgrade --cask cc-switch
```
## 致谢
### 贡献者
感谢所有让这个版本成为可能的贡献者:
- [@YoVinchen](https://github.com/YoVinchen) - UI 和数据库重构
- [@farion1231](https://github.com/farion1231) - BUG 修复和功能增强
- 社区成员的测试和反馈
### 赞助商
**智谱AI** - GLM CODING PLAN 赞助商
[使用此链接购买可享九折优惠](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)
**PackyCode** - API 中转服务合作伙伴
[使用 "cc-switch" 优惠码注册享 9 折优惠](https://www.packyapi.com/register?aff=cc-switch)
**闪电说** - 本地优先的 AI 语音输入法
[免费下载](https://shandianshuo.cn) Mac/Win 双平台
**MiniMax** - MiniMax M2 CODING PLAN 赞助商
[黑五优惠进行中,套餐9.9元起](https://platform.minimaxi.com/subscribe/coding-plan)
---
## 反馈与支持
- **问题反馈**[GitHub Issues](https://github.com/farion1231/cc-switch/issues)
- **讨论**[GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)
- **文档**[README](../README_ZH.md)
- **更新日志**[CHANGELOG.md](../CHANGELOG.md)
---
## 未来展望
**v3.9.0 预览**(暂定):
- 本地代理功能
敬请期待更多更新!
---
**Happy Coding!**
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "cc-switch", "name": "cc-switch",
"version": "3.7.1", "version": "3.8.0",
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI", "description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
"scripts": { "scripts": {
"dev": "pnpm tauri dev", "dev": "pnpm tauri dev",
+1 -1
View File
@@ -618,7 +618,7 @@ dependencies = [
[[package]] [[package]]
name = "cc-switch" name = "cc-switch"
version = "3.7.1" version = "3.8.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"auto-launch", "auto-launch",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "cc-switch" name = "cc-switch"
version = "3.7.1" version = "3.8.0"
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI" description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
authors = ["Jason Young"] authors = ["Jason Young"]
license = "MIT" license = "MIT"
+10 -8
View File
@@ -1,5 +1,5 @@
use crate::error::AppError; use crate::error::AppError;
use auto_launch::AutoLaunch; use auto_launch::{AutoLaunch, AutoLaunchBuilder};
/// 初始化 AutoLaunch 实例 /// 初始化 AutoLaunch 实例
fn get_auto_launch() -> Result<AutoLaunch, AppError> { fn get_auto_launch() -> Result<AutoLaunch, AppError> {
@@ -7,13 +7,15 @@ fn get_auto_launch() -> Result<AutoLaunch, AppError> {
let app_path = let app_path =
std::env::current_exe().map_err(|e| AppError::Message(format!("无法获取应用路径: {e}")))?; std::env::current_exe().map_err(|e| AppError::Message(format!("无法获取应用路径: {e}")))?;
// Windows 平台的 AutoLaunch::new 只接受 3 个参数 // 使用 AutoLaunchBuilder 消除平台差异
// Linux/macOS 平台需要 4 个参数(包含 hidden 参数 // Windows/Linux: new() 接受 3 参数
#[cfg(target_os = "windows")] // macOS: new() 接受 4 参数(含 hidden 参数)
let auto_launch = AutoLaunch::new(app_name, &app_path.to_string_lossy(), &[] as &[&str]); // Builder 模式自动处理这些差异
let auto_launch = AutoLaunchBuilder::new()
#[cfg(not(target_os = "windows"))] .set_app_name(app_name)
let auto_launch = AutoLaunch::new(app_name, &app_path.to_string_lossy(), false, &[] as &[&str]); .set_app_path(&app_path.to_string_lossy())
.build()
.map_err(|e| AppError::Message(format!("创建 AutoLaunch 失败: {e}")))?;
Ok(auto_launch) Ok(auto_launch)
} }
+3 -5
View File
@@ -86,7 +86,7 @@ pub fn switch_provider(
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<(), AppError> { fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
ProviderService::import_default_config(state, app_type) ProviderService::import_default_config(state, app_type)
} }
@@ -94,7 +94,7 @@ fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result
pub fn import_default_config_test_hook( pub fn import_default_config_test_hook(
state: &AppState, state: &AppState,
app_type: AppType, app_type: AppType,
) -> Result<(), AppError> { ) -> Result<bool, AppError> {
import_default_config_internal(state, app_type) import_default_config_internal(state, app_type)
} }
@@ -102,9 +102,7 @@ pub fn import_default_config_test_hook(
#[tauri::command] #[tauri::command]
pub fn import_default_config(state: State<'_, AppState>, app: String) -> Result<bool, String> { pub fn import_default_config(state: State<'_, AppState>, app: String) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
import_default_config_internal(&state, app_type) import_default_config_internal(&state, app_type).map_err(Into::into)
.map(|_| true)
.map_err(Into::into)
} }
/// 查询供应商用量 /// 查询供应商用量
-1
View File
@@ -90,7 +90,6 @@ pub async fn install_skill(
.clone() .clone()
.unwrap_or_else(|| "main".to_string()), .unwrap_or_else(|| "main".to_string()),
enabled: true, enabled: true,
skills_path: skill.skills_path.clone(), // 使用技能记录的 skills_path
}; };
service service
+5 -4
View File
@@ -58,7 +58,9 @@ impl Database {
pub fn get_skill_repos(&self) -> Result<Vec<SkillRepo>, AppError> { pub fn get_skill_repos(&self) -> Result<Vec<SkillRepo>, AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
let mut stmt = conn let mut stmt = conn
.prepare("SELECT owner, name, branch, enabled, skills_path FROM skill_repos ORDER BY owner ASC, name ASC") .prepare(
"SELECT owner, name, branch, enabled FROM skill_repos ORDER BY owner ASC, name ASC",
)
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
let repo_iter = stmt let repo_iter = stmt
@@ -68,7 +70,6 @@ impl Database {
name: row.get(1)?, name: row.get(1)?,
branch: row.get(2)?, branch: row.get(2)?,
enabled: row.get(3)?, enabled: row.get(3)?,
skills_path: row.get(4)?,
}) })
}) })
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
@@ -84,8 +85,8 @@ impl Database {
pub fn save_skill_repo(&self, repo: &SkillRepo) -> Result<(), AppError> { pub fn save_skill_repo(&self, repo: &SkillRepo) -> Result<(), AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
conn.execute( conn.execute(
"INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled, skills_path) VALUES (?1, ?2, ?3, ?4, ?5)", "INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled) VALUES (?1, ?2, ?3, ?4)",
params![repo.owner, repo.name, repo.branch, repo.enabled, repo.skills_path], params![repo.owner, repo.name, repo.branch, repo.enabled],
).map_err(|e| AppError::Database(e.to_string()))?; ).map_err(|e| AppError::Database(e.to_string()))?;
Ok(()) Ok(())
} }
+13 -11
View File
@@ -153,12 +153,14 @@ impl Database {
tx: &rusqlite::Transaction<'_>, tx: &rusqlite::Transaction<'_>,
config: &MultiAppConfig, config: &MultiAppConfig,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
let migrate_app_prompts = let migrate_app_prompts = |prompts_map: &std::collections::HashMap<
|prompts_map: &std::collections::HashMap<String, crate::prompt::Prompt>, String,
app_type: &str| crate::prompt::Prompt,
-> Result<(), AppError> { >,
for (id, prompt) in prompts_map { app_type: &str|
tx.execute( -> Result<(), AppError> {
for (id, prompt) in prompts_map {
tx.execute(
"INSERT OR REPLACE INTO prompts ( "INSERT OR REPLACE INTO prompts (
id, app_type, name, content, description, enabled, created_at, updated_at id, app_type, name, content, description, enabled, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
@@ -174,9 +176,9 @@ impl Database {
], ],
) )
.map_err(|e| AppError::Database(format!("Migrate prompt failed: {e}")))?; .map_err(|e| AppError::Database(format!("Migrate prompt failed: {e}")))?;
} }
Ok(()) Ok(())
}; };
migrate_app_prompts(&config.prompts.claude.prompts, "claude")?; migrate_app_prompts(&config.prompts.claude.prompts, "claude")?;
migrate_app_prompts(&config.prompts.codex.prompts, "codex")?; migrate_app_prompts(&config.prompts.codex.prompts, "codex")?;
@@ -200,8 +202,8 @@ impl Database {
for repo in &config.skills.repos { for repo in &config.skills.repos {
tx.execute( tx.execute(
"INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled, skills_path) VALUES (?1, ?2, ?3, ?4, ?5)", "INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled) VALUES (?1, ?2, ?3, ?4)",
params![repo.owner, repo.name, repo.branch, repo.enabled, repo.skills_path], params![repo.owner, repo.name, repo.branch, repo.enabled],
).map_err(|e| AppError::Database(format!("Migrate skill repo failed: {e}")))?; ).map_err(|e| AppError::Database(format!("Migrate skill repo failed: {e}")))?;
} }
+10 -17
View File
@@ -115,28 +115,21 @@ impl Database {
Ok(db) Ok(db)
} }
/// 检查数据库是否为空(需要首次导入) /// 检查 MCP 服务器表是否为空
/// pub fn is_mcp_table_empty(&self) -> Result<bool, AppError> {
/// 通过检查是否有任何 MCP 服务器、提示词、Skills 仓库或供应商来判断
pub fn is_empty_for_first_import(&self) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
let count: i64 = conn
let mcp_count: i64 = conn
.query_row("SELECT COUNT(*) FROM mcp_servers", [], |row| row.get(0)) .query_row("SELECT COUNT(*) FROM mcp_servers", [], |row| row.get(0))
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
Ok(count == 0)
}
let prompt_count: i64 = conn /// 检查提示词表是否为空
pub fn is_prompts_table_empty(&self) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM prompts", [], |row| row.get(0)) .query_row("SELECT COUNT(*) FROM prompts", [], |row| row.get(0))
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
Ok(count == 0)
let skill_repo_count: i64 = conn
.query_row("SELECT COUNT(*) FROM skill_repos", [], |row| row.get(0))
.map_err(|e| AppError::Database(e.to_string()))?;
let provider_count: i64 = conn
.query_row("SELECT COUNT(*) FROM providers", [], |row| row.get(0))
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(mcp_count == 0 && prompt_count == 0 && skill_repo_count == 0 && provider_count == 0)
} }
} }
+11 -13
View File
@@ -104,7 +104,6 @@ impl Database {
name TEXT NOT NULL, name TEXT NOT NULL,
branch TEXT NOT NULL DEFAULT 'main', branch TEXT NOT NULL DEFAULT 'main',
enabled BOOLEAN NOT NULL DEFAULT 1, enabled BOOLEAN NOT NULL DEFAULT 1,
skills_path TEXT,
PRIMARY KEY (owner, name) PRIMARY KEY (owner, name)
)", )",
[], [],
@@ -226,14 +225,14 @@ impl Database {
Self::add_column_if_missing(conn, "skills", "installed_at", "INTEGER NOT NULL DEFAULT 0")?; Self::add_column_if_missing(conn, "skills", "installed_at", "INTEGER NOT NULL DEFAULT 0")?;
// skill_repos 表 // skill_repos 表
Self::add_column_if_missing(conn, "skill_repos", "branch", "TEXT NOT NULL DEFAULT 'main'")?;
Self::add_column_if_missing( Self::add_column_if_missing(
conn, conn,
"skill_repos", "skill_repos",
"enabled", "branch",
"BOOLEAN NOT NULL DEFAULT 1", "TEXT NOT NULL DEFAULT 'main'",
)?; )?;
Self::add_column_if_missing(conn, "skill_repos", "skills_path", "TEXT")?; Self::add_column_if_missing(conn, "skill_repos", "enabled", "BOOLEAN NOT NULL DEFAULT 1")?;
// 注意: skills_path 字段已被移除,因为现在支持全仓库递归扫描
Ok(()) Ok(())
} }
@@ -247,9 +246,7 @@ impl Database {
pub(crate) fn set_user_version(conn: &Connection, version: i32) -> Result<(), AppError> { pub(crate) fn set_user_version(conn: &Connection, version: i32) -> Result<(), AppError> {
if version < 0 { if version < 0 {
return Err(AppError::Database( return Err(AppError::Database("user_version 不能为负数".to_string()));
"user_version 不能为负数".to_string(),
));
} }
let sql = format!("PRAGMA user_version = {version};"); let sql = format!("PRAGMA user_version = {version};");
conn.execute(&sql, []) conn.execute(&sql, [])
@@ -261,10 +258,7 @@ impl Database {
if s.is_empty() { if s.is_empty() {
return Err(AppError::Database(format!("{kind} 不能为空"))); return Err(AppError::Database(format!("{kind} 不能为空")));
} }
if !s if !s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_')
{
return Err(AppError::Database(format!( return Err(AppError::Database(format!(
"非法{kind}: {s},仅允许字母、数字和下划线" "非法{kind}: {s},仅允许字母、数字和下划线"
))); )));
@@ -292,7 +286,11 @@ impl Database {
Ok(false) Ok(false)
} }
pub(crate) fn has_column(conn: &Connection, table: &str, column: &str) -> Result<bool, AppError> { pub(crate) fn has_column(
conn: &Connection,
table: &str,
column: &str,
) -> Result<bool, AppError> {
Self::validate_identifier(table, "表名")?; Self::validate_identifier(table, "表名")?;
Self::validate_identifier(column, "列名")?; Self::validate_identifier(column, "列名")?;
+4 -10
View File
@@ -108,8 +108,8 @@ fn migration_rejects_future_version() {
Database::create_tables_on_conn(&conn).expect("create tables"); Database::create_tables_on_conn(&conn).expect("create tables");
Database::set_user_version(&conn, SCHEMA_VERSION + 1).expect("set future version"); Database::set_user_version(&conn, SCHEMA_VERSION + 1).expect("set future version");
let err = Database::apply_schema_migrations_on_conn(&conn) let err =
.expect_err("should reject higher version"); Database::apply_schema_migrations_on_conn(&conn).expect_err("should reject higher version");
assert!( assert!(
err.to_string().contains("数据库版本过新"), err.to_string().contains("数据库版本过新"),
"unexpected error: {err}" "unexpected error: {err}"
@@ -168,10 +168,7 @@ fn migration_aligns_column_defaults_and_types() {
let is_current = get_column_info(&conn, "providers", "is_current"); let is_current = get_column_info(&conn, "providers", "is_current");
assert_eq!(is_current.r#type, "BOOLEAN"); assert_eq!(is_current.r#type, "BOOLEAN");
assert_eq!(is_current.notnull, 1); assert_eq!(is_current.notnull, 1);
assert_eq!( assert_eq!(normalize_default(&is_current.default).as_deref(), Some("0"));
normalize_default(&is_current.default).as_deref(),
Some("0")
);
let tags = get_column_info(&conn, "mcp_servers", "tags"); let tags = get_column_info(&conn, "mcp_servers", "tags");
assert_eq!(tags.r#type, "TEXT"); assert_eq!(tags.r#type, "TEXT");
@@ -181,10 +178,7 @@ fn migration_aligns_column_defaults_and_types() {
let enabled = get_column_info(&conn, "prompts", "enabled"); let enabled = get_column_info(&conn, "prompts", "enabled");
assert_eq!(enabled.r#type, "BOOLEAN"); assert_eq!(enabled.r#type, "BOOLEAN");
assert_eq!(enabled.notnull, 1); assert_eq!(enabled.notnull, 1);
assert_eq!( assert_eq!(normalize_default(&enabled.default).as_deref(), Some("1"));
normalize_default(&enabled.default).as_deref(),
Some("1")
);
let installed_at = get_column_info(&conn, "skills", "installed_at"); let installed_at = get_column_info(&conn, "skills", "installed_at");
assert_eq!(installed_at.r#type, "INTEGER"); assert_eq!(installed_at.r#type, "INTEGER");
-4
View File
@@ -100,12 +100,8 @@ pub struct DeepLinkImportRequest {
/// Skill directory name /// Skill directory name
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub directory: Option<String>, pub directory: Option<String>,
/// Repository branch (default: "main")
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub branch: Option<String>, pub branch: Option<String>,
/// Skills subdirectory path (e.g., "skills")
#[serde(skip_serializing_if = "Option::is_none")]
pub skills_path: Option<String>,
// ============ Config file fields (v3.8+) ============ // ============ Config file fields (v3.8+) ============
/// Base64 encoded config content /// Base64 encoded config content
-8
View File
@@ -143,7 +143,6 @@ fn parse_provider_deeplink(
repo: None, repo: None,
directory: None, directory: None,
branch: None, branch: None,
skills_path: None,
config, config,
config_format, config_format,
config_url, config_url,
@@ -204,7 +203,6 @@ fn parse_prompt_deeplink(
repo: None, repo: None,
directory: None, directory: None,
branch: None, branch: None,
skills_path: None,
config: None, config: None,
config_format: None, config_format: None,
config_url: None, config_url: None,
@@ -262,7 +260,6 @@ fn parse_mcp_deeplink(
repo: None, repo: None,
directory: None, directory: None,
branch: None, branch: None,
skills_path: None,
config_url: None, config_url: None,
}) })
} }
@@ -287,10 +284,6 @@ fn parse_skill_deeplink(
let directory = params.get("directory").cloned(); let directory = params.get("directory").cloned();
let branch = params.get("branch").cloned(); let branch = params.get("branch").cloned();
let skills_path = params
.get("skills_path")
.or_else(|| params.get("skillsPath"))
.cloned();
Ok(DeepLinkImportRequest { Ok(DeepLinkImportRequest {
version, version,
@@ -298,7 +291,6 @@ fn parse_skill_deeplink(
repo: Some(repo), repo: Some(repo),
directory, directory,
branch, branch,
skills_path,
icon: None, icon: None,
app: Some("claude".to_string()), // Skills are Claude-only app: Some("claude".to_string()), // Skills are Claude-only
name: None, name: None,
-1
View File
@@ -40,7 +40,6 @@ pub fn import_skill_from_deeplink(
name: name.clone(), name: name.clone(),
branch: request.branch.unwrap_or_else(|| "main".to_string()), branch: request.branch.unwrap_or_else(|| "main".to_string()),
enabled: request.enabled.unwrap_or(true), enabled: request.enabled.unwrap_or(true),
skills_path: request.skills_path,
}; };
// Save using Database // Save using Database
+2 -8
View File
@@ -142,7 +142,6 @@ fn test_build_gemini_provider_with_model() {
repo: None, repo: None,
directory: None, directory: None,
branch: None, branch: None,
skills_path: None,
content: None, content: None,
description: None, description: None,
enabled: None, enabled: None,
@@ -189,7 +188,6 @@ fn test_build_gemini_provider_without_model() {
repo: None, repo: None,
directory: None, directory: None,
branch: None, branch: None,
skills_path: None,
content: None, content: None,
description: None, description: None,
enabled: None, enabled: None,
@@ -231,7 +229,6 @@ fn test_parse_and_merge_config_claude() {
repo: None, repo: None,
directory: None, directory: None,
branch: None, branch: None,
skills_path: None,
content: None, content: None,
description: None, description: None,
enabled: None, enabled: None,
@@ -275,7 +272,6 @@ fn test_parse_and_merge_config_url_override() {
repo: None, repo: None,
directory: None, directory: None,
branch: None, branch: None,
skills_path: None,
content: None, content: None,
description: None, description: None,
enabled: None, enabled: None,
@@ -307,8 +303,7 @@ fn test_import_prompt_allows_space_in_base64_content() {
let db = Arc::new(Database::memory().expect("create memory db")); let db = Arc::new(Database::memory().expect("create memory db"));
let state = AppState::new(db.clone()); let state = AppState::new(db.clone());
let prompt_id = let prompt_id = import_prompt_from_deeplink(&state, request.clone()).expect("import prompt");
import_prompt_from_deeplink(&state, request.clone()).expect("import prompt");
let prompts = state.db.get_prompts("codex").expect("get prompts"); let prompts = state.db.get_prompts("codex").expect("get prompts");
let prompt = prompts.get(&prompt_id).expect("prompt saved"); let prompt = prompts.get(&prompt_id).expect("prompt saved");
@@ -373,12 +368,11 @@ fn test_parse_mcp_deeplink() {
#[test] #[test]
fn test_parse_skill_deeplink() { fn test_parse_skill_deeplink() {
let url = "ccswitch://v1/import?resource=skill&repo=owner/repo&directory=skills&branch=dev&skills_path=src"; let url = "ccswitch://v1/import?resource=skill&repo=owner/repo&directory=skills&branch=dev";
let request = parse_deeplink_url(&url).unwrap(); let request = parse_deeplink_url(&url).unwrap();
assert_eq!(request.resource, "skill"); assert_eq!(request.resource, "skill");
assert_eq!(request.repo.unwrap(), "owner/repo"); assert_eq!(request.repo.unwrap(), "owner/repo");
assert_eq!(request.directory.unwrap(), "skills"); assert_eq!(request.directory.unwrap(), "skills");
assert_eq!(request.branch.unwrap(), "dev"); assert_eq!(request.branch.unwrap(), "dev");
assert_eq!(request.skills_path.unwrap(), "src");
} }
+66 -84
View File
@@ -69,6 +69,12 @@ impl TrayTexts {
no_provider_hint: " (No providers yet, please add them from the main window)", no_provider_hint: " (No providers yet, please add them from the main window)",
quit: "Quit", quit: "Quit",
}, },
"ja" => Self {
show_main: "メインウィンドウを開く",
no_provider_hint:
" (プロバイダーがまだありません。メイン画面から追加してください)",
quit: "終了",
},
_ => Self { _ => Self {
show_main: "打开主界面", show_main: "打开主界面",
no_provider_hint: " (无供应商,请在主界面添加)", no_provider_hint: " (无供应商,请在主界面添加)",
@@ -223,11 +229,9 @@ fn create_tray_menu(
let providers = app_state.db.get_all_providers(app_type_str)?; let providers = app_state.db.get_all_providers(app_type_str)?;
// 使用有效的当前供应商 ID(验证存在性,自动清理失效 ID) // 使用有效的当前供应商 ID(验证存在性,自动清理失效 ID)
let current_id = crate::settings::get_effective_current_provider( let current_id =
&app_state.db, crate::settings::get_effective_current_provider(&app_state.db, &section.app_type)?
&section.app_type, .unwrap_or_default();
)?
.unwrap_or_default();
let manager = crate::provider::ProviderManager { let manager = crate::provider::ProviderManager {
providers, providers,
@@ -518,7 +522,8 @@ pub fn run() {
unsafe { unsafe {
use objc2::msg_send; use objc2::msg_send;
let _: () = msg_send![&*ns_window, setBackgroundColor: &*bg_color]; let _: () =
msg_send![&*ns_window, setBackgroundColor: &*bg_color];
} }
} else { } else {
log::warn!("Failed to retain NSWindow reference"); log::warn!("Failed to retain NSWindow reference");
@@ -614,54 +619,47 @@ pub fn run() {
let app_state = AppState::new(db); let app_state = AppState::new(db);
// 检查是否需要首次导入(数据库为空) // ============================================================
let need_first_import = app_state // 按表独立判断的导入逻辑(各类数据独立检查,互不影响)
.db // ============================================================
.is_empty_for_first_import()
.unwrap_or_else(|e| {
log::warn!("Failed to check if database is empty: {e}");
false
});
if need_first_import { // 1. 初始化默认 Skills 仓库(已有内置检查:表非空则跳过)
// 数据库为空,尝试从用户现有的配置文件导入数据并初始化默认配置 match app_state.db.init_default_skill_repos() {
log::info!( Ok(count) if count > 0 => {
"Empty database detected, importing existing configurations and initializing defaults..." log::info!("✓ Initialized {count} default skill repositories");
);
// 1. 初始化默认 Skills 仓库(3个)
match app_state.db.init_default_skill_repos() {
Ok(count) if count > 0 => {
log::info!("✓ Initialized {count} default skill repositories");
}
Ok(_) => log::debug!("No default skill repositories to initialize"),
Err(e) => log::warn!("✗ Failed to initialize default skill repos: {e}"),
} }
Ok(_) => {} // 表非空,静默跳过
Err(e) => log::warn!("✗ Failed to initialize default skill repos: {e}"),
}
// 2. 导入供应商配置(从 live 配置文件 // 2. 导入供应商配置(已有内置检查:该应用已有供应商则跳过
for app in [ for app in [
crate::app_config::AppType::Claude, crate::app_config::AppType::Claude,
crate::app_config::AppType::Codex, crate::app_config::AppType::Codex,
crate::app_config::AppType::Gemini, crate::app_config::AppType::Gemini,
] { ] {
match crate::services::provider::ProviderService::import_default_config( match crate::services::provider::ProviderService::import_default_config(
&app_state, &app_state,
app.clone(), app.clone(),
) { ) {
Ok(_) => { Ok(true) => {
log::info!("✓ Imported default provider for {}", app.as_str()); log::info!("✓ Imported default provider for {}", app.as_str());
} }
Err(e) => { Ok(false) => {} // 已有供应商,静默跳过
log::debug!( Err(e) => {
"○ No default provider to import for {}: {}", log::debug!(
app.as_str(), "○ No default provider to import for {}: {}",
e app.as_str(),
); e
} );
} }
} }
}
// 3. 导入 MCP 服务器配置(表空时触发)
if app_state.db.is_mcp_table_empty().unwrap_or(false) {
log::info!("MCP table empty, importing from live configurations...");
// 3. 导入 MCP 服务器配置
match crate::services::mcp::McpService::import_from_claude(&app_state) { match crate::services::mcp::McpService::import_from_claude(&app_state) {
Ok(count) if count > 0 => { Ok(count) if count > 0 => {
log::info!("✓ Imported {count} MCP server(s) from Claude"); log::info!("✓ Imported {count} MCP server(s) from Claude");
@@ -685,42 +683,28 @@ pub fn run() {
Ok(_) => log::debug!("○ No Gemini MCP servers found to import"), Ok(_) => log::debug!("○ No Gemini MCP servers found to import"),
Err(e) => log::warn!("✗ Failed to import Gemini MCP: {e}"), Err(e) => log::warn!("✗ Failed to import Gemini MCP: {e}"),
} }
}
// 4. 导入提示词文件 // 4. 导入提示词文件(表空时触发)
match crate::services::prompt::PromptService::import_from_file_on_first_launch( if app_state.db.is_prompts_table_empty().unwrap_or(false) {
&app_state, log::info!("Prompts table empty, importing from live configurations...");
for app in [
crate::app_config::AppType::Claude, crate::app_config::AppType::Claude,
) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} prompt(s) from Claude");
}
Ok(_) => log::debug!("○ No Claude prompt file found to import"),
Err(e) => log::warn!("✗ Failed to import Claude prompt: {e}"),
}
match crate::services::prompt::PromptService::import_from_file_on_first_launch(
&app_state,
crate::app_config::AppType::Codex, crate::app_config::AppType::Codex,
) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} prompt(s) from Codex");
}
Ok(_) => log::debug!("○ No Codex prompt file found to import"),
Err(e) => log::warn!("✗ Failed to import Codex prompt: {e}"),
}
match crate::services::prompt::PromptService::import_from_file_on_first_launch(
&app_state,
crate::app_config::AppType::Gemini, crate::app_config::AppType::Gemini,
) { ] {
Ok(count) if count > 0 => { match crate::services::prompt::PromptService::import_from_file_on_first_launch(
log::info!("✓ Imported {count} prompt(s) from Gemini"); &app_state,
app.clone(),
) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} prompt(s) for {}", app.as_str());
}
Ok(_) => log::debug!("○ No prompt file found for {}", app.as_str()),
Err(e) => log::warn!("✗ Failed to import prompt for {}: {e}", app.as_str()),
} }
Ok(_) => log::debug!("○ No Gemini prompt file found to import"),
Err(e) => log::warn!("✗ Failed to import Gemini prompt: {e}"),
} }
log::info!("First-time import completed");
} }
// 迁移旧的 app_config_dir 配置到 Store // 迁移旧的 app_config_dir 配置到 Store
@@ -1033,21 +1017,19 @@ fn show_migration_error_dialog(app: &tauri::AppHandle, error: &str) -> bool {
let message = if is_chinese_locale() { let message = if is_chinese_locale() {
format!( format!(
"从旧版本迁移配置时发生错误:\n\n{}\n\n\ "从旧版本迁移配置时发生错误:\n\n{error}\n\n\
您的数据尚未丢失,旧配置文件仍然保留。\n\ 您的数据尚未丢失,旧配置文件仍然保留。\n\
建议回退到旧版本 CC Switch 以保护数据。\n\n\ 建议回退到旧版本 CC Switch 以保护数据。\n\n\
点击「重试」重新尝试迁移\n\ 点击「重试」重新尝试迁移\n\
点击「退出」关闭程序(可回退版本后重新打开)", 点击「退出」关闭程序(可回退版本后重新打开)"
error
) )
} else { } else {
format!( format!(
"An error occurred while migrating configuration:\n\n{}\n\n\ "An error occurred while migrating configuration:\n\n{error}\n\n\
Your data is NOT lost - the old config file is still preserved.\n\ Your data is NOT lost - the old config file is still preserved.\n\
Consider rolling back to an older CC Switch version.\n\n\ Consider rolling back to an older CC Switch version.\n\n\
Click 'Retry' to attempt migration again\n\ Click 'Retry' to attempt migration again\n\
Click 'Exit' to close the program", Click 'Exit' to close the program"
error
) )
}; };
+6
View File
@@ -176,6 +176,12 @@ impl PromptService {
state: &AppState, state: &AppState,
app: AppType, app: AppType,
) -> Result<usize, AppError> { ) -> Result<usize, AppError> {
// 幂等性保护:该应用已有提示词则跳过
let existing = state.db.get_prompts(app.as_str())?;
if !existing.is_empty() {
return Ok(0);
}
let file_path = prompt_file_path(&app)?; let file_path = prompt_file_path(&app)?;
// 检查文件是否存在 // 检查文件是否存在
+19 -16
View File
@@ -100,12 +100,13 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
write_json_file(&path, &provider.settings_config)?; write_json_file(&path, &provider.settings_config)?;
} }
AppType::Codex => { AppType::Codex => {
let obj = provider.settings_config.as_object().ok_or_else(|| { let obj = provider
AppError::Config("Codex 供应商配置必须是 JSON 对象".to_string()) .settings_config
})?; .as_object()
let auth = obj.get("auth").ok_or_else(|| { .ok_or_else(|| AppError::Config("Codex 供应商配置必须是 JSON 对象".to_string()))?;
AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string()) let auth = obj
})?; .get("auth")
.ok_or_else(|| AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string()))?;
let config_str = obj.get("config").and_then(|v| v.as_str()).ok_or_else(|| { let config_str = obj.get("config").and_then(|v| v.as_str()).ok_or_else(|| {
AppError::Config("Codex 供应商配置缺少 'config' 字段或不是字符串".to_string()) AppError::Config("Codex 供应商配置缺少 'config' 字段或不是字符串".to_string())
})?; })?;
@@ -113,8 +114,7 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
let auth_path = get_codex_auth_path(); let auth_path = get_codex_auth_path();
write_json_file(&auth_path, auth)?; write_json_file(&auth_path, auth)?;
let config_path = get_codex_config_path(); let config_path = get_codex_config_path();
std::fs::write(&config_path, config_str) std::fs::write(&config_path, config_str).map_err(|e| AppError::io(&config_path, e))?;
.map_err(|e| AppError::io(&config_path, e))?;
} }
AppType::Gemini => { AppType::Gemini => {
// Delegate to write_gemini_live which handles env file writing correctly // Delegate to write_gemini_live which handles env file writing correctly
@@ -132,11 +132,11 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> { pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] { for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
// Use validated effective current provider // Use validated effective current provider
let current_id = match crate::settings::get_effective_current_provider(&state.db, &app_type)? let current_id =
{ match crate::settings::get_effective_current_provider(&state.db, &app_type)? {
Some(id) => id, Some(id) => id,
None => continue, None => continue,
}; };
let providers = state.db.get_all_providers(app_type.as_str())?; let providers = state.db.get_all_providers(app_type.as_str())?;
if let Some(provider) = providers.get(&current_id) { if let Some(provider) = providers.get(&current_id) {
@@ -215,11 +215,14 @@ pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
} }
/// Import default configuration from live files /// Import default configuration from live files
pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<(), AppError> { ///
/// Returns `Ok(true)` if a provider was actually imported,
/// `Ok(false)` if skipped (providers already exist for this app).
pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
{ {
let providers = state.db.get_all_providers(app_type.as_str())?; let providers = state.db.get_all_providers(app_type.as_str())?;
if !providers.is_empty() { if !providers.is_empty() {
return Ok(()); return Ok(false); // 已有供应商,跳过
} }
} }
@@ -298,7 +301,7 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<(),
.db .db
.set_current_provider(app_type.as_str(), &provider.id)?; .set_current_provider(app_type.as_str(), &provider.id)?;
Ok(()) Ok(true) // 真正导入了
} }
/// Write Gemini live configuration with authentication handling /// Write Gemini live configuration with authentication handling
+3 -1
View File
@@ -225,7 +225,9 @@ impl ProviderService {
} }
/// Import default configuration from live files (re-export) /// Import default configuration from live files (re-export)
pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<(), AppError> { ///
/// Returns `Ok(true)` if imported, `Ok(false)` if skipped.
pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
import_default_config(state, app_type) import_default_config(state, app_type)
} }
+1 -3
View File
@@ -168,9 +168,7 @@ pub(crate) fn validate_usage_script(script: &UsageScript) -> Result<(), AppError
if interval > 1440 { if interval > 1440 {
return Err(AppError::localized( return Err(AppError::localized(
"usage_script.interval_too_large", "usage_script.interval_too_large",
format!( format!("自动查询间隔不能超过 1440 分钟(24小时),当前值: {interval}"),
"自动查询间隔不能超过 1440 分钟(24小时),当前值: {interval}"
),
format!( format!(
"Auto query interval cannot exceed 1440 minutes (24 hours), current: {interval}" "Auto query interval cannot exceed 1440 minutes (24 hours), current: {interval}"
), ),
+155 -120
View File
@@ -34,9 +34,6 @@ pub struct Skill {
/// 分支名称 /// 分支名称
#[serde(rename = "repoBranch")] #[serde(rename = "repoBranch")]
pub repo_branch: Option<String>, pub repo_branch: Option<String>,
/// 技能所在的子目录路径 (可选, 如 "skills")
#[serde(rename = "skillsPath")]
pub skills_path: Option<String>,
} }
/// 仓库配置 /// 仓库配置
@@ -50,9 +47,6 @@ pub struct SkillRepo {
pub branch: String, pub branch: String,
/// 是否启用 /// 是否启用
pub enabled: bool, pub enabled: bool,
/// 技能所在的子目录路径 (可选, 如 "skills", "my-skills/subdir")
#[serde(rename = "skillsPath")]
pub skills_path: Option<String>,
} }
/// 技能安装状态 /// 技能安装状态
@@ -84,21 +78,18 @@ impl Default for SkillStore {
name: "awesome-claude-skills".to_string(), name: "awesome-claude-skills".to_string(),
branch: "main".to_string(), branch: "main".to_string(),
enabled: true, enabled: true,
skills_path: None, // 扫描根目录
}, },
SkillRepo { SkillRepo {
owner: "anthropics".to_string(), owner: "anthropics".to_string(),
name: "skills".to_string(), name: "skills".to_string(),
branch: "main".to_string(), branch: "main".to_string(),
enabled: true, enabled: true,
skills_path: None, // 扫描根目录
}, },
SkillRepo { SkillRepo {
owner: "cexll".to_string(), owner: "cexll".to_string(),
name: "myclaude".to_string(), name: "myclaude".to_string(),
branch: "master".to_string(), branch: "master".to_string(),
enabled: true, enabled: true,
skills_path: Some("skills".to_string()), // 扫描 skills 子目录
}, },
], ],
} }
@@ -194,76 +185,11 @@ impl SkillService {
})??; })??;
let mut skills = Vec::new(); let mut skills = Vec::new();
// 确定要扫描的目录路径 // 扫描仓库根目录(支持全仓库递归扫描)
let scan_dir = if let Some(ref skills_path) = repo.skills_path { let scan_dir = temp_dir.clone();
// 如果指定了 skillsPath,则扫描该子目录
let subdir = temp_dir.join(skills_path.trim_matches('/'));
if !subdir.exists() {
log::warn!(
"仓库 {}/{} 中指定的技能路径 '{}' 不存在",
repo.owner,
repo.name,
skills_path
);
let _ = fs::remove_dir_all(&temp_dir);
return Ok(skills);
}
subdir
} else {
// 否则扫描仓库根目录
temp_dir.clone()
};
// 遍历目标目录 // 递归扫描目录查找所有技能
for entry in fs::read_dir(&scan_dir)? { self.scan_dir_recursive(&scan_dir, &scan_dir, repo, &mut skills)?;
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let skill_md = path.join("SKILL.md");
if !skill_md.exists() {
continue;
}
// 解析技能元数据
match self.parse_skill_metadata(&skill_md) {
Ok(meta) => {
// 安全地获取目录名
let Some(dir_name) = path.file_name() else {
log::warn!("Failed to get directory name from path: {path:?}");
continue;
};
let directory = dir_name.to_string_lossy().to_string();
// 构建 README URL(考虑 skillsPath
let readme_path = if let Some(ref skills_path) = repo.skills_path {
format!("{}/{}", skills_path.trim_matches('/'), directory)
} else {
directory.clone()
};
skills.push(Skill {
key: format!("{}/{}:{}", repo.owner, repo.name, directory),
name: meta.name.unwrap_or_else(|| directory.clone()),
description: meta.description.unwrap_or_default(),
directory,
readme_url: Some(format!(
"https://github.com/{}/{}/tree/{}/{}",
repo.owner, repo.name, repo.branch, readme_path
)),
installed: false,
repo_owner: Some(repo.owner.clone()),
repo_name: Some(repo.name.clone()),
repo_branch: Some(repo.branch.clone()),
skills_path: repo.skills_path.clone(),
});
}
Err(e) => log::warn!("解析 {} 元数据失败: {}", skill_md.display(), e),
}
}
// 清理临时目录 // 清理临时目录
let _ = fs::remove_dir_all(&temp_dir); let _ = fs::remove_dir_all(&temp_dir);
@@ -271,6 +197,85 @@ impl SkillService {
Ok(skills) Ok(skills)
} }
/// 递归扫描目录查找 SKILL.md
///
/// 规则:
/// 1. 如果当前目录存在 SKILL.md,则识别为技能,停止扫描其子目录(子目录视为功能文件夹)
/// 2. 如果当前目录不存在 SKILL.md,则递归扫描所有子目录
fn scan_dir_recursive(
&self,
current_dir: &Path,
base_dir: &Path,
repo: &SkillRepo,
skills: &mut Vec<Skill>,
) -> Result<()> {
// 检查当前目录是否包含 SKILL.md
let skill_md = current_dir.join("SKILL.md");
if skill_md.exists() {
// 发现技能!获取相对路径作为目录名
let directory = if current_dir == base_dir {
// 根目录的 SKILL.md,使用仓库名
repo.name.clone()
} else {
// 子目录的 SKILL.md,使用相对路径
current_dir
.strip_prefix(base_dir)
.unwrap_or(current_dir)
.to_string_lossy()
.to_string()
};
if let Ok(skill) = self.build_skill_from_metadata(&skill_md, &directory, repo) {
skills.push(skill);
}
// 停止扫描此目录的子目录(同级目录都是功能文件夹)
return Ok(());
}
// 未发现 SKILL.md,继续递归扫描所有子目录
for entry in fs::read_dir(current_dir)? {
let entry = entry?;
let path = entry.path();
// 只处理目录
if path.is_dir() {
self.scan_dir_recursive(&path, base_dir, repo, skills)?;
}
}
Ok(())
}
/// 从 SKILL.md 构建技能对象
fn build_skill_from_metadata(
&self,
skill_md: &Path,
directory: &str,
repo: &SkillRepo,
) -> Result<Skill> {
let meta = self.parse_skill_metadata(skill_md)?;
// 构建 README URL
let readme_path = directory.to_string();
Ok(Skill {
key: format!("{}/{}:{}", repo.owner, repo.name, directory),
name: meta.name.unwrap_or_else(|| directory.to_string()),
description: meta.description.unwrap_or_default(),
directory: directory.to_string(),
readme_url: Some(format!(
"https://github.com/{}/{}/tree/{}/{}",
repo.owner, repo.name, repo.branch, readme_path
)),
installed: false,
repo_owner: Some(repo.owner.clone()),
repo_name: Some(repo.name.clone()),
repo_branch: Some(repo.branch.clone()),
})
}
/// 解析技能元数据 /// 解析技能元数据
fn parse_skill_metadata(&self, path: &Path) -> Result<SkillMetadata> { fn parse_skill_metadata(&self, path: &Path) -> Result<SkillMetadata> {
let content = fs::read_to_string(path)?; let content = fs::read_to_string(path)?;
@@ -302,25 +307,18 @@ impl SkillService {
return Ok(()); return Ok(());
} }
for entry in fs::read_dir(&self.install_dir)? { // 收集所有本地技能
let entry = entry?; let mut local_skills = Vec::new();
let path = entry.path(); self.scan_local_dir_recursive(&self.install_dir, &self.install_dir, &mut local_skills)?;
if !path.is_dir() { // 处理找到的本地技能
continue; for local_skill in local_skills {
} let directory = &local_skill.directory;
// 安全地获取目录名 // 更新已安装状态(匹配远程技能)
let Some(dir_name) = path.file_name() else {
log::warn!("Failed to get directory name from path: {path:?}");
continue;
};
let directory = dir_name.to_string_lossy().to_string();
// 更新已安装状态
let mut found = false; let mut found = false;
for skill in skills.iter_mut() { for skill in skills.iter_mut() {
if skill.directory.eq_ignore_ascii_case(&directory) { if skill.directory.eq_ignore_ascii_case(directory) {
skill.installed = true; skill.installed = true;
found = true; found = true;
break; break;
@@ -329,23 +327,68 @@ impl SkillService {
// 添加本地独有的技能(仅当在仓库中未找到时) // 添加本地独有的技能(仅当在仓库中未找到时)
if !found { if !found {
let skill_md = path.join("SKILL.md"); skills.push(local_skill);
if skill_md.exists() { }
if let Ok(meta) = self.parse_skill_metadata(&skill_md) { }
skills.push(Skill {
key: format!("local:{directory}"), Ok(())
name: meta.name.unwrap_or_else(|| directory.clone()), }
description: meta.description.unwrap_or_default(),
directory: directory.clone(), /// 递归扫描本地目录查找 SKILL.md
readme_url: None, fn scan_local_dir_recursive(
installed: true, &self,
repo_owner: None, current_dir: &Path,
repo_name: None, base_dir: &Path,
repo_branch: None, skills: &mut Vec<Skill>,
skills_path: None, ) -> Result<()> {
}); // 检查当前目录是否包含 SKILL.md
} let skill_md = current_dir.join("SKILL.md");
}
if skill_md.exists() {
// 发现技能!获取相对路径作为目录名
let directory = if current_dir == base_dir {
// 如果是 install_dir 本身,使用最后一段路径名
current_dir
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string()
} else {
// 使用相对于 install_dir 的路径
current_dir
.strip_prefix(base_dir)
.unwrap_or(current_dir)
.to_string_lossy()
.to_string()
};
// 解析元数据并创建本地技能对象
if let Ok(meta) = self.parse_skill_metadata(&skill_md) {
skills.push(Skill {
key: format!("local:{directory}"),
name: meta.name.unwrap_or_else(|| directory.clone()),
description: meta.description.unwrap_or_default(),
directory: directory.clone(),
readme_url: None,
installed: true,
repo_owner: None,
repo_name: None,
repo_branch: None,
});
}
// 停止扫描此目录的子目录(同级目录都是功能文件夹)
return Ok(());
}
// 未发现 SKILL.md,继续递归扫描所有子目录
for entry in fs::read_dir(current_dir)? {
let entry = entry?;
let path = entry.path();
// 只处理目录
if path.is_dir() {
self.scan_local_dir_recursive(&path, base_dir, skills)?;
} }
} }
@@ -499,16 +542,8 @@ impl SkillService {
)) ))
})??; })??;
// 根据 skills_path 确定源目录路径 // 确定源目录路径(技能相对于仓库根目录的路径)
let source = if let Some(ref skills_path) = repo.skills_path { let source = temp_dir.join(&directory);
// 如果指定了 skills_path,源路径为: temp_dir/skills_path/directory
temp_dir
.join(skills_path.trim_matches('/'))
.join(&directory)
} else {
// 否则源路径为: temp_dir/directory
temp_dir.join(&directory)
};
if !source.exists() { if !source.exists() {
let _ = fs::remove_dir_all(&temp_dir); let _ = fs::remove_dir_all(&temp_dir);
+1 -1
View File
@@ -118,7 +118,7 @@ impl AppSettings {
.language .language
.as_ref() .as_ref()
.map(|s| s.trim()) .map(|s| s.trim())
.filter(|s| matches!(*s, "en" | "zh")) .filter(|s| matches!(*s, "en" | "zh" | "ja"))
.map(|s| s.to_string()); .map(|s| s.to_string());
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch", "productName": "CC Switch",
"version": "3.7.1", "version": "3.8.0",
"identifier": "com.ccswitch.desktop", "identifier": "com.ccswitch.desktop",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
+5 -3
View File
@@ -3,13 +3,15 @@ use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use cc_switch_lib::{ use cc_switch_lib::{
get_claude_settings_path, read_json_file, AppError, AppType, ConfigService, get_claude_settings_path, read_json_file, AppError, AppType, ConfigService, MultiAppConfig,
MultiAppConfig, Provider, ProviderMeta, Provider, ProviderMeta,
}; };
#[path = "support.rs"] #[path = "support.rs"]
mod support; mod support;
use support::{create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex}; use support::{
create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex,
};
#[test] #[test]
fn sync_claude_provider_writes_live_settings() { fn sync_claude_provider_writes_live_settings() {
+13 -12
View File
@@ -42,9 +42,13 @@ fn import_default_config_claude_persists_provider() {
.expect("import default config succeeds"); .expect("import default config succeeds");
// 验证内存状态 // 验证内存状态
let providers = state.db.get_all_providers(AppType::Claude.as_str()) let providers = state
.db
.get_all_providers(AppType::Claude.as_str())
.expect("get all providers"); .expect("get all providers");
let current_id = state.db.get_current_provider(AppType::Claude.as_str()) let current_id = state
.db
.get_current_provider(AppType::Claude.as_str())
.expect("get current provider"); .expect("get current provider");
assert_eq!(current_id.as_deref(), Some("default")); assert_eq!(current_id.as_deref(), Some("default"));
let default_provider = providers.get("default").expect("default provider"); let default_provider = providers.get("default").expect("default provider");
@@ -87,7 +91,9 @@ fn import_default_config_without_live_file_returns_error() {
// 使用数据库架构,不再检查 config.json // 使用数据库架构,不再检查 config.json
// 失败的导入不应该向数据库写入任何供应商 // 失败的导入不应该向数据库写入任何供应商
let providers = state.db.get_all_providers(AppType::Claude.as_str()) let providers = state
.db
.get_all_providers(AppType::Claude.as_str())
.expect("get all providers"); .expect("get all providers");
assert!( assert!(
providers.is_empty(), providers.is_empty(),
@@ -125,8 +131,7 @@ fn import_mcp_from_claude_creates_config_and_enables_servers() {
"import should report inserted or normalized entries" "import should report inserted or normalized entries"
); );
let servers = state.db.get_all_mcp_servers() let servers = state.db.get_all_mcp_servers().expect("get all mcp servers");
.expect("get all mcp servers");
let entry = servers let entry = servers
.get("echo") .get("echo")
.expect("server imported into unified structure"); .expect("server imported into unified structure");
@@ -168,8 +173,7 @@ fn import_mcp_from_claude_invalid_json_preserves_state() {
} }
// 使用数据库架构,检查 MCP 服务器未被写入 // 使用数据库架构,检查 MCP 服务器未被写入
let servers = state.db.get_all_mcp_servers() let servers = state.db.get_all_mcp_servers().expect("get all mcp servers");
.expect("get all mcp servers");
assert!( assert!(
servers.is_empty(), servers.is_empty(),
"failed import should not persist any MCP servers to database" "failed import should not persist any MCP servers to database"
@@ -224,11 +228,8 @@ fn set_mcp_enabled_for_codex_writes_live_config() {
McpService::toggle_app(&state, "codex-server", AppType::Codex, true) McpService::toggle_app(&state, "codex-server", AppType::Codex, true)
.expect("toggle_app should succeed"); .expect("toggle_app should succeed");
let servers = state.db.get_all_mcp_servers() let servers = state.db.get_all_mcp_servers().expect("get all mcp servers");
.expect("get all mcp servers"); let entry = servers.get("codex-server").expect("codex server exists");
let entry = servers
.get("codex-server")
.expect("codex server exists");
assert!( assert!(
entry.apps.codex, entry.apps.codex,
"server should have Codex app enabled after toggle" "server should have Codex app enabled after toggle"
+34 -16
View File
@@ -7,8 +7,8 @@ use cc_switch_lib::{
#[path = "support.rs"] #[path = "support.rs"]
mod support; mod support;
use support::{create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex};
use std::collections::HashMap; use std::collections::HashMap;
use support::{create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex};
#[test] #[test]
fn switch_provider_updates_codex_live_and_state() { fn switch_provider_updates_codex_live_and_state() {
@@ -104,16 +104,22 @@ command = "say"
"config.toml should contain synced MCP servers" "config.toml should contain synced MCP servers"
); );
let current_id = app_state.db.get_current_provider(AppType::Codex.as_str()) let current_id = app_state
.db
.get_current_provider(AppType::Codex.as_str())
.expect("get current provider"); .expect("get current provider");
assert_eq!(current_id.as_deref(), Some("new-provider"), "current provider updated"); assert_eq!(
current_id.as_deref(),
Some("new-provider"),
"current provider updated"
);
let providers = app_state.db.get_all_providers(AppType::Codex.as_str()) let providers = app_state
.db
.get_all_providers(AppType::Codex.as_str())
.expect("get all providers"); .expect("get all providers");
let new_provider = providers let new_provider = providers.get("new-provider").expect("new provider exists");
.get("new-provider")
.expect("new provider exists");
let new_config_text = new_provider let new_config_text = new_provider
.settings_config .settings_config
.get("config") .get("config")
@@ -165,7 +171,9 @@ fn switch_provider_missing_provider_returns_error() {
let err_str = err.to_string(); let err_str = err.to_string();
assert!( assert!(
err_str.contains("供应商不存在") || err_str.contains("Provider not found") || err_str.contains("missing-provider"), err_str.contains("供应商不存在")
|| err_str.contains("Provider not found")
|| err_str.contains("missing-provider"),
"error message should mention missing provider, got: {err_str}" "error message should mention missing provider, got: {err_str}"
); );
} }
@@ -241,11 +249,19 @@ fn switch_provider_updates_claude_live_and_state() {
"live settings.json should reflect new provider auth" "live settings.json should reflect new provider auth"
); );
let current_id = app_state.db.get_current_provider(AppType::Claude.as_str()) let current_id = app_state
.db
.get_current_provider(AppType::Claude.as_str())
.expect("get current provider"); .expect("get current provider");
assert_eq!(current_id.as_deref(), Some("new-provider"), "current provider updated"); assert_eq!(
current_id.as_deref(),
Some("new-provider"),
"current provider updated"
);
let providers = app_state.db.get_all_providers(AppType::Claude.as_str()) let providers = app_state
.db
.get_all_providers(AppType::Claude.as_str())
.expect("get all providers"); .expect("get all providers");
let legacy_provider = providers let legacy_provider = providers
@@ -258,9 +274,7 @@ fn switch_provider_updates_claude_live_and_state() {
"previous provider should be backfilled with live config" "previous provider should be backfilled with live config"
); );
let new_provider = providers let new_provider = providers.get("new-provider").expect("new provider exists");
.get("new-provider")
.expect("new provider exists");
assert_eq!( assert_eq!(
new_provider new_provider
.settings_config .settings_config
@@ -283,7 +297,9 @@ fn switch_provider_updates_claude_live_and_state() {
); );
// 验证当前供应商已更新 // 验证当前供应商已更新
let current_id = app_state.db.get_current_provider(AppType::Claude.as_str()) let current_id = app_state
.db
.get_current_provider(AppType::Claude.as_str())
.expect("get current provider"); .expect("get current provider");
assert_eq!( assert_eq!(
current_id.as_deref(), current_id.as_deref(),
@@ -328,7 +344,9 @@ fn switch_provider_codex_missing_auth_returns_error_and_keeps_state() {
other => panic!("expected config error, got {other:?}"), other => panic!("expected config error, got {other:?}"),
} }
let current_id = app_state.db.get_current_provider(AppType::Codex.as_str()) let current_id = app_state
.db
.get_current_provider(AppType::Codex.as_str())
.expect("get current provider"); .expect("get current provider");
// 切换失败后,由于数据库操作是先设置再验证,current 可能已被设为 "invalid" // 切换失败后,由于数据库操作是先设置再验证,current 可能已被设为 "invalid"
// 但由于 live 配置写入失败,状态应该回滚 // 但由于 live 配置写入失败,状态应该回滚
+44 -18
View File
@@ -1,13 +1,15 @@
use serde_json::json; use serde_json::json;
use cc_switch_lib::{ use cc_switch_lib::{
get_claude_settings_path, read_json_file, write_codex_live_atomic, AppError, AppType, get_claude_settings_path, read_json_file, write_codex_live_atomic, AppError, AppType, McpApps,
McpApps, McpServer, MultiAppConfig, Provider, ProviderMeta, ProviderService, McpServer, MultiAppConfig, Provider, ProviderMeta, ProviderService,
}; };
#[path = "support.rs"] #[path = "support.rs"]
mod support; mod support;
use support::{create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex}; use support::{
create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex,
};
fn sanitize_provider_name(name: &str) -> String { fn sanitize_provider_name(name: &str) -> String {
name.chars() name.chars()
@@ -69,7 +71,10 @@ command = "say"
} }
// 使用新的统一 MCP 结构(v3.7.0+ // 使用新的统一 MCP 结构(v3.7.0+
let servers = initial_config.mcp.servers.get_or_insert_with(Default::default); let servers = initial_config
.mcp
.servers
.get_or_insert_with(Default::default);
servers.insert( servers.insert(
"echo-server".into(), "echo-server".into(),
McpServer { McpServer {
@@ -111,16 +116,22 @@ command = "say"
"config.toml should contain synced MCP servers" "config.toml should contain synced MCP servers"
); );
let current_id = state.db.get_current_provider(AppType::Codex.as_str()) let current_id = state
.db
.get_current_provider(AppType::Codex.as_str())
.expect("read current provider after switch"); .expect("read current provider after switch");
assert_eq!(current_id.as_deref(), Some("new-provider"), "current provider updated"); assert_eq!(
current_id.as_deref(),
Some("new-provider"),
"current provider updated"
);
let providers = state.db.get_all_providers(AppType::Codex.as_str()) let providers = state
.db
.get_all_providers(AppType::Codex.as_str())
.expect("read providers after switch"); .expect("read providers after switch");
let new_provider = providers let new_provider = providers.get("new-provider").expect("new provider exists");
.get("new-provider")
.expect("new provider exists");
let new_config_text = new_provider let new_config_text = new_provider
.settings_config .settings_config
.get("config") .get("config")
@@ -385,11 +396,19 @@ fn provider_service_switch_claude_updates_live_and_state() {
"live settings.json should reflect new provider auth" "live settings.json should reflect new provider auth"
); );
let providers = state.db.get_all_providers(AppType::Claude.as_str()) let providers = state
.db
.get_all_providers(AppType::Claude.as_str())
.expect("get all providers"); .expect("get all providers");
let current_id = state.db.get_current_provider(AppType::Claude.as_str()) let current_id = state
.db
.get_current_provider(AppType::Claude.as_str())
.expect("get current provider"); .expect("get current provider");
assert_eq!(current_id.as_deref(), Some("new-provider"), "current provider updated"); assert_eq!(
current_id.as_deref(),
Some("new-provider"),
"current provider updated"
);
let legacy_provider = providers let legacy_provider = providers
.get("old-provider") .get("old-provider")
@@ -509,7 +528,9 @@ fn provider_service_delete_codex_removes_provider_and_files() {
ProviderService::delete(&app_state, AppType::Codex, "to-delete") ProviderService::delete(&app_state, AppType::Codex, "to-delete")
.expect("delete provider should succeed"); .expect("delete provider should succeed");
let providers = app_state.db.get_all_providers(AppType::Codex.as_str()) let providers = app_state
.db
.get_all_providers(AppType::Codex.as_str())
.expect("get all providers"); .expect("get all providers");
assert!( assert!(
!providers.contains_key("to-delete"), !providers.contains_key("to-delete"),
@@ -567,7 +588,9 @@ fn provider_service_delete_claude_removes_provider_files() {
ProviderService::delete(&app_state, AppType::Claude, "delete").expect("delete claude provider"); ProviderService::delete(&app_state, AppType::Claude, "delete").expect("delete claude provider");
let providers = app_state.db.get_all_providers(AppType::Claude.as_str()) let providers = app_state
.db
.get_all_providers(AppType::Claude.as_str())
.expect("get all providers"); .expect("get all providers");
assert!( assert!(
!providers.contains_key("delete"), !providers.contains_key("delete"),
@@ -608,15 +631,18 @@ fn provider_service_delete_current_provider_returns_error() {
.expect_err("deleting current provider should fail"); .expect_err("deleting current provider should fail");
match err { match err {
AppError::Localized { zh, .. } => assert!( AppError::Localized { zh, .. } => assert!(
zh.contains("不能删除当前正在使用的供应商") || zh.contains("无法删除当前正在使用的供应商"), zh.contains("不能删除当前正在使用的供应商")
|| zh.contains("无法删除当前正在使用的供应商"),
"unexpected message: {zh}" "unexpected message: {zh}"
), ),
AppError::Config(msg) => assert!( AppError::Config(msg) => assert!(
msg.contains("不能删除当前正在使用的供应商") || msg.contains("无法删除当前正在使用的供应商"), msg.contains("不能删除当前正在使用的供应商")
|| msg.contains("无法删除当前正在使用的供应商"),
"unexpected message: {msg}" "unexpected message: {msg}"
), ),
AppError::Message(msg) => assert!( AppError::Message(msg) => assert!(
msg.contains("不能删除当前正在使用的供应商") || msg.contains("无法删除当前正在使用的供应商"), msg.contains("不能删除当前正在使用的供应商")
|| msg.contains("无法删除当前正在使用的供应商"),
"unexpected message: {msg}" "unexpected message: {msg}"
), ),
other => panic!("expected Config/Message error, got {other:?}"), other => panic!("expected Config/Message error, got {other:?}"),
+2 -2
View File
@@ -391,7 +391,7 @@ function App() {
t("prompts.title", { appName: t(`apps.${activeApp}`) })} t("prompts.title", { appName: t(`apps.${activeApp}`) })}
{currentView === "skills" && t("skills.title")} {currentView === "skills" && t("skills.title")}
{currentView === "mcp" && t("mcp.unifiedPanel.title")} {currentView === "mcp" && t("mcp.unifiedPanel.title")}
{currentView === "agents" && "Agents"} {currentView === "agents" && t("agents.title")}
</h1> </h1>
</div> </div>
) : ( ) : (
@@ -512,7 +512,7 @@ function App() {
size="sm" size="sm"
onClick={() => setCurrentView("mcp")} onClick={() => setCurrentView("mcp")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5" className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title="MCP" title={t("mcp.title")}
> >
<Server className="h-4 w-4" /> <Server className="h-4 w-4" />
</Button> </Button>
+39 -147
View File
@@ -176,10 +176,16 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
const [selectedTemplate, setSelectedTemplate] = useState<string | null>( const [selectedTemplate, setSelectedTemplate] = useState<string | null>(
() => { () => {
const existingScript = provider.meta?.usage_script; const existingScript = provider.meta?.usage_script;
// 检测 NEW_API 模板(有 accessToken 或 userId
if (existingScript?.accessToken || existingScript?.userId) { if (existingScript?.accessToken || existingScript?.userId) {
return TEMPLATE_KEYS.NEW_API; return TEMPLATE_KEYS.NEW_API;
} }
return null; // 检测 GENERAL 模板(有 apiKey 或 baseUrl
if (existingScript?.apiKey || existingScript?.baseUrl) {
return TEMPLATE_KEYS.GENERAL;
}
// 新配置或无凭证:默认使用 GENERAL(与默认代码模板一致)
return TEMPLATE_KEYS.GENERAL;
}, },
); );
@@ -349,14 +355,9 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
footer={footer} footer={footer}
> >
<div className="glass rounded-xl border border-white/10 px-6 py-4 flex items-center justify-between gap-4"> <div className="glass rounded-xl border border-white/10 px-6 py-4 flex items-center justify-between gap-4">
<div className="space-y-1"> <p className="text-base font-medium leading-none text-foreground">
<p className="text-sm font-medium leading-none text-foreground"> {t("usageScript.enableUsageQuery")}
{t("usageScript.enableUsageQuery")} </p>
</p>
<p className="text-xs text-muted-foreground">
{t("usageScript.autoQueryIntervalHint")}
</p>
</div>
<Switch <Switch
checked={script.enabled} checked={script.enabled}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
@@ -370,14 +371,9 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
<div className="space-y-6"> <div className="space-y-6">
{/* 预设模板选择 */} {/* 预设模板选择 */}
<div className="space-y-4 glass rounded-xl border border-white/10 p-6"> <div className="space-y-4 glass rounded-xl border border-white/10 p-6">
<div className="flex flex-wrap items-center justify-between gap-2"> <Label className="text-base font-medium">
<Label className="text-base font-medium"> {t("usageScript.presetTemplate")}
{t("usageScript.presetTemplate")} </Label>
</Label>
<span className="text-xs text-muted-foreground">
{t("usageScript.variablesHint")}
</span>
</div>
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
{Object.keys(PRESET_TEMPLATES).map((name) => { {Object.keys(PRESET_TEMPLATES).map((name) => {
const isSelected = selectedTemplate === name; const isSelected = selectedTemplate === name;
@@ -447,7 +443,9 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="usage-base-url">Base URL</Label> <Label htmlFor="usage-base-url">
{t("usageScript.baseUrl")}
</Label>
<Input <Input
id="usage-base-url" id="usage-base-url"
type="text" type="text"
@@ -466,7 +464,9 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
{selectedTemplate === TEMPLATE_KEYS.NEW_API && ( {selectedTemplate === TEMPLATE_KEYS.NEW_API && (
<> <>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="usage-newapi-base-url">Base URL</Label> <Label htmlFor="usage-newapi-base-url">
{t("usageScript.baseUrl")}
</Label>
<Input <Input
id="usage-newapi-base-url" id="usage-newapi-base-url"
type="text" type="text"
@@ -545,141 +545,36 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
</div> </div>
</div> </div>
)} )}
</div>
{/* 脚本配置 */} {/* 通用配置(始终显示) */}
<div className="space-y-4 glass rounded-xl border border-white/10 p-6"> <div className="grid gap-4 md:grid-cols-2 pt-4 border-t border-white/10">
<div className="flex items-center justify-between"> {/* 超时时间 */}
<h4 className="text-base font-medium text-foreground">
{t("usageScript.scriptConfig")}
</h4>
<p className="text-xs text-muted-foreground">
{t("usageScript.variablesHint")}
</p>
</div>
<div className="grid gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="usage-request-url"> <Label htmlFor="usage-timeout">
{t("usageScript.requestUrl")} {t("usageScript.timeoutSeconds")}
</Label> </Label>
<Input <Input
id="usage-request-url" id="usage-timeout"
type="text" type="number"
value={script.request?.url || ""} min={0}
onChange={(e) => { value={script.timeout ?? 10}
onChange={(e) =>
setScript({ setScript({
...script, ...script,
request: { ...script.request, url: e.target.value }, timeout: validateTimeout(e.target.value),
}); })
}} }
placeholder={t("usageScript.requestUrlPlaceholder")} onBlur={(e) =>
setScript({
...script,
timeout: validateTimeout(e.target.value),
})
}
className="border-white/10" className="border-white/10"
/> />
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> {/* 自动查询间隔 */}
<div className="space-y-2">
<Label htmlFor="usage-method">
{t("usageScript.method")}
</Label>
<Input
id="usage-method"
type="text"
value={script.request?.method || "GET"}
onChange={(e) => {
setScript({
...script,
request: {
...script.request,
method: e.target.value.toUpperCase(),
},
});
}}
placeholder="GET / POST"
className="border-white/10"
/>
</div>
<div className="space-y-2">
<Label htmlFor="usage-timeout">
{t("usageScript.timeoutSeconds")}
</Label>
<Input
id="usage-timeout"
type="number"
min={0}
value={script.timeout ?? 10}
onChange={(e) =>
setScript({
...script,
timeout: validateTimeout(e.target.value),
})
}
onBlur={(e) =>
setScript({
...script,
timeout: validateTimeout(e.target.value),
})
}
className="border-white/10"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="usage-headers">
{t("usageScript.headers")}
</Label>
<JsonEditor
id="usage-headers"
value={
script.request?.headers
? JSON.stringify(script.request.headers, null, 2)
: "{}"
}
onChange={(value) => {
try {
const parsed = JSON.parse(value || "{}");
setScript({
...script,
request: { ...script.request, headers: parsed },
});
} catch (error) {
console.error("Invalid headers JSON", error);
}
}}
height={180}
/>
</div>
<div className="space-y-2">
<Label htmlFor="usage-body">{t("usageScript.body")}</Label>
<JsonEditor
id="usage-body"
value={
script.request?.body
? JSON.stringify(script.request.body, null, 2)
: "{}"
}
onChange={(value) => {
try {
const parsed =
value?.trim() === "" ? undefined : JSON.parse(value);
setScript({
...script,
request: { ...script.request, body: parsed },
});
} catch (error) {
toast.error(
t("usageScript.invalidJson") || "Body 必须是合法 JSON",
);
}
}}
height={220}
/>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="usage-interval"> <Label htmlFor="usage-interval">
{t("usageScript.autoIntervalMinutes")} {t("usageScript.autoIntervalMinutes")}
@@ -708,9 +603,6 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
} }
className="border-white/10" className="border-white/10"
/> />
<p className="text-xs text-muted-foreground">
{t("usageScript.autoQueryIntervalHint")}
</p>
</div> </div>
</div> </div>
</div> </div>
+5 -16
View File
@@ -30,22 +30,11 @@ export function SkillConfirmation({
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div>
<div> <label className="block text-sm font-medium text-muted-foreground">
<label className="block text-sm font-medium text-muted-foreground"> {t("deeplink.skill.branch")}
{t("deeplink.skill.branch")} </label>
</label> <div className="mt-1 text-sm">{request.branch || "main"}</div>
<div className="mt-1 text-sm">{request.branch || "main"}</div>
</div>
{request.skillsPath && (
<div>
<label className="block text-sm font-medium text-muted-foreground">
{t("deeplink.skill.skillsPath")}
</label>
<div className="mt-1 text-sm">{request.skillsPath}</div>
</div>
)}
</div> </div>
<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"> <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">
+7 -2
View File
@@ -2,9 +2,11 @@ import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
type LanguageOption = "zh" | "en" | "ja";
interface LanguageSettingsProps { interface LanguageSettingsProps {
value: "zh" | "en"; value: LanguageOption;
onChange: (value: "zh" | "en") => void; onChange: (value: LanguageOption) => void;
} }
export function LanguageSettings({ value, onChange }: LanguageSettingsProps) { export function LanguageSettings({ value, onChange }: LanguageSettingsProps) {
@@ -25,6 +27,9 @@ export function LanguageSettings({ value, onChange }: LanguageSettingsProps) {
<LanguageButton active={value === "en"} onClick={() => onChange("en")}> <LanguageButton active={value === "en"} onClick={() => onChange("en")}>
{t("settings.languageOptionEnglish")} {t("settings.languageOptionEnglish")}
</LanguageButton> </LanguageButton>
<LanguageButton active={value === "ja"} onClick={() => onChange("ja")}>
{t("settings.languageOptionJapanese")}
</LanguageButton>
</div> </div>
</section> </section>
); );
-16
View File
@@ -34,7 +34,6 @@ export function RepoManager({
const { t } = useTranslation(); const { t } = useTranslation();
const [repoUrl, setRepoUrl] = useState(""); const [repoUrl, setRepoUrl] = useState("");
const [branch, setBranch] = useState(""); const [branch, setBranch] = useState("");
const [skillsPath, setSkillsPath] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const getSkillCount = (repo: SkillRepo) => const getSkillCount = (repo: SkillRepo) =>
@@ -80,12 +79,10 @@ export function RepoManager({
name: parsed.name, name: parsed.name,
branch: branch || "main", branch: branch || "main",
enabled: true, enabled: true,
skillsPath: skillsPath.trim() || undefined, // 仅在有值时传递
}); });
setRepoUrl(""); setRepoUrl("");
setBranch(""); setBranch("");
setSkillsPath("");
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : t("skills.repo.addFailed")); setError(e instanceof Error ? e.message : t("skills.repo.addFailed"));
} }
@@ -130,13 +127,6 @@ export function RepoManager({
onChange={(e) => setBranch(e.target.value)} onChange={(e) => setBranch(e.target.value)}
className="flex-1" className="flex-1"
/> />
<Input
id="skills-path"
placeholder={t("skills.repo.pathPlaceholder")}
value={skillsPath}
onChange={(e) => setSkillsPath(e.target.value)}
className="flex-1"
/>
<Button <Button
onClick={handleAdd} onClick={handleAdd}
className="w-full sm:w-auto sm:px-4" className="w-full sm:w-auto sm:px-4"
@@ -171,12 +161,6 @@ export function RepoManager({
</div> </div>
<div className="mt-1 text-xs text-muted-foreground"> <div className="mt-1 text-xs text-muted-foreground">
{t("skills.repo.branch")}: {repo.branch || "main"} {t("skills.repo.branch")}: {repo.branch || "main"}
{repo.skillsPath && (
<>
<span className="mx-2"></span>
{t("skills.repo.path")}: {repo.skillsPath}
</>
)}
<span className="ml-3 inline-flex items-center rounded-full border border-border-default px-2 py-0.5 text-[11px]"> <span className="ml-3 inline-flex items-center rounded-full border border-border-default px-2 py-0.5 text-[11px]">
{t("skills.repo.skillCount", { {t("skills.repo.skillCount", {
count: getSkillCount(repo), count: getSkillCount(repo),
+11 -34
View File
@@ -26,7 +26,6 @@ export function RepoManagerPanel({
const { t } = useTranslation(); const { t } = useTranslation();
const [repoUrl, setRepoUrl] = useState(""); const [repoUrl, setRepoUrl] = useState("");
const [branch, setBranch] = useState(""); const [branch, setBranch] = useState("");
const [skillsPath, setSkillsPath] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const getSkillCount = (repo: SkillRepo) => const getSkillCount = (repo: SkillRepo) =>
@@ -67,12 +66,10 @@ export function RepoManagerPanel({
name: parsed.name, name: parsed.name,
branch: branch || "main", branch: branch || "main",
enabled: true, enabled: true,
skillsPath: skillsPath.trim() || undefined,
}); });
setRepoUrl(""); setRepoUrl("");
setBranch(""); setBranch("");
setSkillsPath("");
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : t("skills.repo.addFailed")); setError(e instanceof Error ? e.message : t("skills.repo.addFailed"));
} }
@@ -110,31 +107,17 @@ export function RepoManagerPanel({
className="mt-2" className="mt-2"
/> />
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div>
<div> <Label htmlFor="branch" className="text-foreground">
<Label htmlFor="branch" className="text-foreground"> {t("skills.repo.branch")}
{t("skills.repo.branch")} </Label>
</Label> <Input
<Input id="branch"
id="branch" placeholder={t("skills.repo.branchPlaceholder")}
placeholder={t("skills.repo.branchPlaceholder")} value={branch}
value={branch} onChange={(e) => setBranch(e.target.value)}
onChange={(e) => setBranch(e.target.value)} className="mt-2"
className="mt-2" />
/>
</div>
<div>
<Label htmlFor="skills-path" className="text-foreground">
{t("skills.repo.path")}
</Label>
<Input
id="skills-path"
placeholder={t("skills.repo.pathPlaceholder")}
value={skillsPath}
onChange={(e) => setSkillsPath(e.target.value)}
className="mt-2"
/>
</div>
</div> </div>
{error && ( {error && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p> <p className="text-sm text-red-600 dark:text-red-400">{error}</p>
@@ -174,12 +157,6 @@ export function RepoManagerPanel({
</div> </div>
<div className="mt-1 text-xs text-muted-foreground"> <div className="mt-1 text-xs text-muted-foreground">
{t("skills.repo.branch")}: {repo.branch || "main"} {t("skills.repo.branch")}: {repo.branch || "main"}
{repo.skillsPath && (
<>
<span className="mx-2"></span>
{t("skills.repo.path")}: {repo.skillsPath}
</>
)}
<span className="ml-3 inline-flex items-center rounded-full border border-border-default px-2 py-0.5 text-[11px]"> <span className="ml-3 inline-flex items-center rounded-full border border-border-default px-2 py-0.5 text-[11px]">
{t("skills.repo.skillCount", { {t("skills.repo.skillCount", {
count: getSkillCount(repo), count: getSkillCount(repo),
+29 -7
View File
@@ -184,9 +184,7 @@ export const providerPresets: ProviderPreset[] = [
ANTHROPIC_DEFAULT_OPUS_MODEL: "ZhipuAI/GLM-4.6", ANTHROPIC_DEFAULT_OPUS_MODEL: "ZhipuAI/GLM-4.6",
}, },
}, },
category: "aggregator", category: "aggregator"
icon: "alibaba",
iconColor: "#FF6A00",
}, },
{ {
name: "KAT-Coder", name: "KAT-Coder",
@@ -249,7 +247,33 @@ export const providerPresets: ProviderPreset[] = [
}, },
category: "cn_official", category: "cn_official",
isPartner: true, isPartner: true,
partnerPromotionKey: "minimax", partnerPromotionKey: "minimax_cn",
theme: {
backgroundColor: "#f64551",
textColor: "#FFFFFF",
},
icon: "minimax",
iconColor: "#FF6B6B",
},
{
name: "MiniMax en",
websiteUrl: "https://platform.minimaxi.io",
apiKeyUrl: "https://platform.minimax.io/subscribe/coding-plan",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://api.minimaxi.io/anthropic",
ANTHROPIC_AUTH_TOKEN: "",
API_TIMEOUT_MS: "3000000",
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: 1,
ANTHROPIC_MODEL: "MiniMax-M2",
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMax-M2",
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMax-M2",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMax-M2",
},
},
category: "cn_official",
isPartner: true,
partnerPromotionKey: "minimax_en",
theme: { theme: {
backgroundColor: "#f64551", backgroundColor: "#f64551",
textColor: "#FFFFFF", textColor: "#FFFFFF",
@@ -289,9 +313,7 @@ export const providerPresets: ProviderPreset[] = [
ANTHROPIC_DEFAULT_OPUS_MODEL: "Ling-1T", ANTHROPIC_DEFAULT_OPUS_MODEL: "Ling-1T",
}, },
}, },
category: "cn_official", category: "cn_official"
icon: "alibaba",
iconColor: "#FF6A00",
}, },
{ {
name: "AiHubMix", name: "AiHubMix",
+1 -1
View File
@@ -12,7 +12,7 @@ import {
} from "./useDirectorySettings"; } from "./useDirectorySettings";
import { useSettingsMetadata } from "./useSettingsMetadata"; import { useSettingsMetadata } from "./useSettingsMetadata";
type Language = "zh" | "en"; type Language = "zh" | "en" | "ja";
interface SaveResult { interface SaveResult {
requiresRestart: boolean; requiresRestart: boolean;
+5 -4
View File
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { useSettingsQuery } from "@/lib/query"; import { useSettingsQuery } from "@/lib/query";
import type { Settings } from "@/types"; import type { Settings } from "@/types";
type Language = "zh" | "en"; type Language = "zh" | "en" | "ja";
export type SettingsFormState = Omit<Settings, "language"> & { export type SettingsFormState = Omit<Settings, "language"> & {
language: Language; language: Language;
@@ -11,7 +11,8 @@ export type SettingsFormState = Omit<Settings, "language"> & {
const normalizeLanguage = (lang?: string | null): Language => { const normalizeLanguage = (lang?: string | null): Language => {
if (!lang) return "zh"; if (!lang) return "zh";
return lang === "en" ? "en" : "zh"; const normalized = lang.toLowerCase();
return normalized === "en" || normalized === "ja" ? normalized : "zh";
}; };
const sanitizeDir = (value?: string | null): string | undefined => { const sanitizeDir = (value?: string | null): string | undefined => {
@@ -51,8 +52,8 @@ export function useSettingsForm(): UseSettingsFormResult {
const readPersistedLanguage = useCallback((): Language => { const readPersistedLanguage = useCallback((): Language => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const stored = window.localStorage.getItem("language"); const stored = window.localStorage.getItem("language");
if (stored === "en" || stored === "zh") { if (stored === "en" || stored === "zh" || stored === "ja") {
return stored; return stored as Language;
} }
} }
return normalizeLanguage(i18n.language); return normalizeLanguage(i18n.language);
+13 -3
View File
@@ -2,15 +2,18 @@ import i18n from "i18next";
import { initReactI18next } from "react-i18next"; import { initReactI18next } from "react-i18next";
import en from "./locales/en.json"; import en from "./locales/en.json";
import ja from "./locales/ja.json";
import zh from "./locales/zh.json"; import zh from "./locales/zh.json";
const DEFAULT_LANGUAGE: "zh" | "en" = "zh"; type Language = "zh" | "en" | "ja";
const getInitialLanguage = (): "zh" | "en" => { const DEFAULT_LANGUAGE: Language = "zh";
const getInitialLanguage = (): Language => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
try { try {
const stored = window.localStorage.getItem("language"); const stored = window.localStorage.getItem("language");
if (stored === "zh" || stored === "en") { if (stored === "zh" || stored === "en" || stored === "ja") {
return stored; return stored;
} }
} catch (error) { } catch (error) {
@@ -28,6 +31,10 @@ const getInitialLanguage = (): "zh" | "en" => {
return "zh"; return "zh";
} }
if (navigatorLang?.startsWith("ja")) {
return "ja";
}
if (navigatorLang?.startsWith("en")) { if (navigatorLang?.startsWith("en")) {
return "en"; return "en";
} }
@@ -39,6 +46,9 @@ const resources = {
en: { en: {
translation: en, translation: en,
}, },
ja: {
translation: ja,
},
zh: { zh: {
translation: zh, translation: zh,
}, },
+11 -2
View File
@@ -165,6 +165,7 @@
"autoReload": "Data will refresh automatically in 2 seconds...", "autoReload": "Data will refresh automatically in 2 seconds...",
"languageOptionChinese": "中文", "languageOptionChinese": "中文",
"languageOptionEnglish": "English", "languageOptionEnglish": "English",
"languageOptionJapanese": "日本語",
"windowBehavior": "Window Behavior", "windowBehavior": "Window Behavior",
"windowBehaviorHint": "Configure window minimize and Claude plugin integration policies.", "windowBehaviorHint": "Configure window minimize and Claude plugin integration policies.",
"launchOnStartup": "Launch on Startup", "launchOnStartup": "Launch on Startup",
@@ -262,7 +263,8 @@
"partnerPromotion": { "partnerPromotion": {
"zhipu": "Zhipu GLM is an official partner of CC Switch. Use this link to top up and get a 10% discount", "zhipu": "Zhipu GLM is an official partner of CC Switch. Use this link to top up and get a 10% discount",
"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", "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",
"minimax": "MiniMax Coding Plan Black Friday, Starter is now $2/mo (80% OFF!)" "minimax_cn": "MiniMax Coding Plan Special Offer, Starter from ¥9.9",
"minimax_en": "MiniMax Coding Plan Black Friday, Starter is now $2/mo (80% OFF!)"
}, },
"parameterConfig": "Parameter Config - {{name}} *", "parameterConfig": "Parameter Config - {{name}} *",
"mainModel": "Main Model (optional)", "mainModel": "Main Model (optional)",
@@ -376,6 +378,7 @@
"templateGeneral": "General", "templateGeneral": "General",
"templateNewAPI": "NewAPI", "templateNewAPI": "NewAPI",
"credentialsConfig": "Credentials", "credentialsConfig": "Credentials",
"baseUrl": "Base URL",
"accessToken": "Access Token", "accessToken": "Access Token",
"accessTokenPlaceholder": "Generate in 'Security Settings'", "accessTokenPlaceholder": "Generate in 'Security Settings'",
"userId": "User ID", "userId": "User ID",
@@ -389,7 +392,7 @@
"timeoutHint": "Range: 2-30 seconds", "timeoutHint": "Range: 2-30 seconds",
"timeoutMustBeInteger": "Timeout must be an integer, decimal part ignored", "timeoutMustBeInteger": "Timeout must be an integer, decimal part ignored",
"timeoutCannotBeNegative": "Timeout cannot be negative", "timeoutCannotBeNegative": "Timeout cannot be negative",
"autoIntervalMinutes": "Auto query interval (minutes)", "autoIntervalMinutes": "Auto query interval (minutes, 0 to disable)",
"autoQueryInterval": "Auto Query Interval (minutes)", "autoQueryInterval": "Auto Query Interval (minutes)",
"autoQueryIntervalHint": "0 to disable; recommend 5-60 minutes", "autoQueryIntervalHint": "0 to disable; recommend 5-60 minutes",
"intervalMustBeInteger": "Interval must be an integer, decimal part ignored", "intervalMustBeInteger": "Interval must be an integer, decimal part ignored",
@@ -831,5 +834,11 @@
"colorLabel": "Icon Color", "colorLabel": "Icon Color",
"selectIcon": "Select Icon", "selectIcon": "Select Icon",
"preview": "Preview" "preview": "Preview"
},
"migration": {
"success": "Configuration migrated successfully"
},
"agents": {
"title": "Agents"
} }
} }
+844
View File
@@ -0,0 +1,844 @@
{
"app": {
"title": "CC Switch",
"description": "Claude Code・Codex・Gemini CLI のためのオールインワンアシスタント"
},
"common": {
"add": "追加",
"edit": "編集",
"delete": "削除",
"save": "保存",
"saving": "保存中...",
"cancel": "キャンセル",
"confirm": "確認",
"close": "閉じる",
"done": "完了",
"settings": "設定",
"about": "バージョン情報",
"version": "バージョン",
"loading": "読み込み中...",
"success": "成功",
"error": "エラー",
"unknown": "不明",
"enterValidValue": "有効な値を入力してください",
"clear": "クリア",
"toggleTheme": "テーマを切り替え",
"format": "フォーマット",
"formatSuccess": "整形しました",
"formatError": "整形に失敗しました: {{error}}",
"copy": "コピー",
"view": "表示",
"back": "戻る"
},
"apiKeyInput": {
"placeholder": "API Key を入力",
"show": "API Key を表示",
"hide": "API Key を隠す"
},
"jsonEditor": {
"mustBeObject": "設定はオブジェクト形式の JSON で入力してください(配列や他の型は不可)",
"invalidJson": "JSON 形式が正しくありません"
},
"claudeConfig": {
"configLabel": "Claude Code settings.json (JSON) *",
"writeCommonConfig": "共通設定を書き込む",
"editCommonConfig": "共通設定を編集",
"editCommonConfigTitle": "共通設定スニペットを編集",
"commonConfigHint": "「共通設定を書き込む」がオンのとき settings.json にマージされます",
"fullSettingsHint": "Claude Code の settings.json 全文"
},
"header": {
"viewOnGithub": "GitHub で見る",
"toggleDarkMode": "ダークモードに切り替え",
"toggleLightMode": "ライトモードに切り替え",
"addProvider": "プロバイダーを追加",
"switchToChinese": "中国語に切り替え",
"switchToEnglish": "英語に切り替え",
"enterEditMode": "編集モードに入る",
"exitEditMode": "編集モードを終了"
},
"provider": {
"noProviders": "まだプロバイダーがありません",
"noProvidersDescription": "右上の「プロバイダーを追加」を押して最初の API プロバイダーを登録してください",
"currentlyUsing": "現在使用中",
"enable": "有効化",
"inUse": "使用中",
"editProvider": "プロバイダーを編集",
"editProviderHint": "保存すると現在のプロバイダーにすぐ反映されます。",
"deleteProvider": "プロバイダーを削除",
"addNewProvider": "新しいプロバイダーを追加",
"addClaudeProvider": "Claude Code プロバイダーを追加",
"addCodexProvider": "Codex プロバイダーを追加",
"addGeminiProvider": "Gemini プロバイダーを追加",
"addProviderHint": "一覧にすばやく切り替えられるよう、ここに情報を入力してください。",
"editClaudeProvider": "Claude Code プロバイダーを編集",
"editCodexProvider": "Codex プロバイダーを編集",
"configError": "設定エラー",
"notConfigured": "公式サイト用に未設定",
"applyToClaudePlugin": "Claude プラグインに適用",
"removeFromClaudePlugin": "Claude プラグインから解除",
"dragToReorder": "ドラッグで並べ替え",
"dragHandle": "ドラッグで並べ替え",
"duplicate": "複製",
"sortUpdateFailed": "並び順の更新に失敗しました",
"configureUsage": "利用状況を設定",
"name": "プロバイダー名",
"namePlaceholder": "例: Claude Official",
"websiteUrl": "Web サイト URL",
"notes": "メモ",
"notesPlaceholder": "例: 会社用アカウント",
"configJson": "Config JSON",
"writeCommonConfig": "共通設定を書き込む",
"editCommonConfigButton": "共通設定を編集",
"configJsonHint": "Claude Code の設定をすべて入力してください",
"editCommonConfigTitle": "共通設定スニペットを編集",
"editCommonConfigHint": "共通設定スニペットは、この機能をオンにしたすべてのプロバイダーへマージされます",
"addProvider": "プロバイダーを追加",
"sortUpdated": "並び順を更新しました",
"usageSaved": "利用状況の設定を保存しました",
"usageSaveFailed": "利用状況設定の保存に失敗しました",
"geminiConfig": "Gemini 設定",
"geminiConfigHint": ".env 形式で Gemini を設定してください",
"form": {
"gemini": {
"model": "モデル",
"oauthTitle": "OAuth 認証モード",
"oauthHint": "Google 公式は OAuth 個人認証を使用するため API Key は不要です。初回利用時にブラウザが開きます。",
"apiKeyPlaceholder": "Gemini API Key を入力"
}
}
},
"notifications": {
"providerAdded": "プロバイダーを追加しました",
"providerSaved": "プロバイダー設定を保存しました",
"providerDeleted": "プロバイダーを削除しました",
"switchSuccess": "切り替え成功! {{appName}} ターミナルを再起動すると反映されます",
"switchFailedTitle": "切り替えに失敗しました",
"switchFailed": "切り替えに失敗しました: {{error}}",
"autoImported": "既存設定からデフォルトプロバイダーを自動作成しました",
"addFailed": "プロバイダーの追加に失敗しました: {{error}}",
"saveFailed": "保存に失敗しました: {{error}}",
"saveFailedGeneric": "保存に失敗しました。もう一度お試しください",
"appliedToClaudePlugin": "Claude プラグインに適用しました",
"removedFromClaudePlugin": "Claude プラグインから削除しました",
"syncClaudePluginFailed": "Claude プラグインとの同期に失敗しました",
"updateSuccess": "プロバイダーを更新しました",
"updateFailed": "プロバイダーの更新に失敗しました: {{error}}",
"deleteSuccess": "プロバイダーを削除しました",
"deleteFailed": "プロバイダーの削除に失敗しました: {{error}}",
"settingsSaved": "設定を保存しました",
"settingsSaveFailed": "設定の保存に失敗しました: {{error}}"
},
"confirm": {
"deleteProvider": "プロバイダーを削除",
"deleteProviderMessage": "プロバイダー「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。"
},
"settings": {
"title": "設定",
"general": "一般",
"tabGeneral": "一般",
"tabAdvanced": "詳細",
"language": "言語",
"languageHint": "切り替えるとすぐにプレビューされ、保存後に永続化されます。",
"theme": "テーマ",
"themeHint": "アプリのテーマを選択します。すぐに反映されます。",
"themeLight": "ライト",
"themeDark": "ダーク",
"themeSystem": "システム",
"importExport": "SQL インポート/エクスポート",
"importExportHint": "移行や復元用にデータベースの SQL バックアップをインポート/エクスポートします。",
"exportConfig": "SQL バックアップをエクスポート",
"selectConfigFile": "SQL ファイルを選択",
"noFileSelected": "ファイルが選択されていません。",
"import": "インポート",
"importing": "インポート中...",
"importSuccess": "インポート成功!",
"importFailed": "インポート失敗",
"syncLiveFailed": "インポートしましたが、現在のプロバイダーへの同期に失敗しました。手動で再選択してください。",
"importPartialSuccess": "設定はインポートされましたが、現在のプロバイダーへの同期に失敗しました。",
"importPartialHint": "ライブ設定を更新するため、もう一度プロバイダーを選択してください。",
"configExported": "設定をエクスポートしました:",
"exportFailed": "エクスポートに失敗しました",
"selectFileFailed": "有効な SQL バックアップファイルを選択してください",
"configCorrupted": "SQL ファイルが壊れているか形式が無効な可能性があります",
"backupId": "バックアップ ID",
"autoReload": "2 秒後に自動で再読み込みします...",
"languageOptionChinese": "中文",
"languageOptionEnglish": "English",
"languageOptionJapanese": "日本語",
"windowBehavior": "ウィンドウ動作",
"windowBehaviorHint": "最小化動作や Claude プラグイン連携を設定します。",
"launchOnStartup": "起動時に自動実行",
"launchOnStartupDescription": "システム起動時に CC Switch を自動起動します",
"autoLaunchFailed": "自動起動の設定に失敗しました",
"minimizeToTray": "閉じるときトレイへ最小化",
"minimizeToTrayDescription": "チェックすると閉じるボタンでトレイに隠し、オフならアプリを終了します。",
"enableClaudePluginIntegration": "Claude Code 拡張に適用",
"enableClaudePluginIntegrationDescription": "オンにすると VS Code の Claude Code 拡張のプロバイダーも同期します",
"configDirectoryOverride": "設定ディレクトリの上書き(詳細)",
"configDirectoryDescription": "WSL などで Claude Code や Codex を使う場合、ここで設定ディレクトリを WSL 側に合わせるとデータを揃えられます。",
"appConfigDir": "CC Switch 設定ディレクトリ",
"appConfigDirDescription": "CC Switch の保存場所をカスタマイズします(クラウド同期フォルダを指定すると設定を同期できます)",
"browsePlaceholderApp": "例: C:\\\\Users\\\\Administrator\\\\.cc-switch",
"claudeConfigDir": "Claude Code 設定ディレクトリ",
"claudeConfigDirDescription": "Claude の設定ディレクトリ(settings.json)を上書きし、claude.json(MCP)も同じ場所に置きます。",
"codexConfigDir": "Codex 設定ディレクトリ",
"codexConfigDirDescription": "Codex の設定ディレクトリを上書きします。",
"geminiConfigDir": "Gemini 設定ディレクトリ",
"geminiConfigDirDescription": "Gemini の設定ディレクトリ(.env)を上書きします。",
"browsePlaceholderClaude": "例: /home/<your-username>/.claude",
"browsePlaceholderCodex": "例: /home/<your-username>/.codex",
"browsePlaceholderGemini": "例: /home/<your-username>/.gemini",
"browseDirectory": "ディレクトリを選択",
"resetDefault": "デフォルトに戻す(保存後に反映)",
"checkForUpdates": "アップデートを確認",
"updateTo": "v{{version}} に更新",
"updating": "更新中...",
"checking": "確認中...",
"upToDate": "最新バージョンです",
"aboutHint": "バージョン情報と更新状況を表示します。",
"portableMode": "ポータブルモード: 更新は手動ダウンロードが必要です。",
"updateAvailable": "新しいバージョンがあります: {{version}}",
"updateFailed": "更新のインストールに失敗しました。ダウンロードページを開こうとしました。",
"checkUpdateFailed": "更新の確認に失敗しました。時間をおいて再試行してください。",
"openReleaseNotesFailed": "リリースノートの表示に失敗しました",
"releaseNotes": "リリースノート",
"viewReleaseNotes": "このバージョンのリリースノートを見る",
"viewCurrentReleaseNotes": "現在のバージョンのリリースノートを見る",
"importFailedError": "設定のインポートに失敗しました: {{message}}",
"exportFailedError": "設定のエクスポートに失敗しました:",
"restartRequired": "再起動が必要です",
"restartRequiredMessage": "CC Switch の設定ディレクトリを変更すると再起動が必要です。今すぐ再起動しますか?",
"restartNow": "今すぐ再起動",
"restartLater": "後で再起動",
"restartFailed": "アプリの再起動に失敗しました。手動で閉じて再度開いてください。",
"devModeRestartHint": "開発モードでは自動再起動をサポートしていません。手動で再起動してください。",
"saving": "保存中..."
},
"apps": {
"claude": "Claude Code",
"codex": "Codex",
"gemini": "Gemini"
},
"console": {
"providerSwitchReceived": "プロバイダー切り替えイベントを受信:",
"setupListenerFailed": "プロバイダー切り替えリスナーの設定に失敗:",
"updateProviderFailed": "プロバイダー更新に失敗:",
"autoImportFailed": "デフォルト設定の自動インポートに失敗:",
"openLinkFailed": "リンクを開けませんでした:",
"getVersionFailed": "バージョン情報の取得に失敗:",
"loadSettingsFailed": "設定の読み込みに失敗:",
"getConfigPathFailed": "設定パスの取得に失敗:",
"getConfigDirFailed": "設定ディレクトリの取得に失敗:",
"detectPortableFailed": "ポータブルモードの検出に失敗:",
"saveSettingsFailed": "設定の保存に失敗:",
"updateFailed": "更新に失敗:",
"checkUpdateFailed": "更新確認に失敗:",
"openConfigFolderFailed": "設定フォルダを開けませんでした:",
"selectConfigDirFailed": "設定ディレクトリの選択に失敗:",
"getDefaultConfigDirFailed": "デフォルト設定ディレクトリの取得に失敗:",
"openReleaseNotesFailed": "リリースノートを開けませんでした:"
},
"providerForm": {
"supplierName": "プロバイダー名",
"supplierNameRequired": "プロバイダー名 *",
"supplierNamePlaceholder": "例: Anthropic Official",
"websiteUrl": "Web サイト URL",
"websiteUrlPlaceholder": "https://example.com(任意)",
"apiEndpoint": "API エンドポイント",
"apiEndpointPlaceholder": "https://your-api-endpoint.com",
"codexApiEndpointPlaceholder": "https://your-api-endpoint.com/v1",
"manageAndTest": "管理・テスト",
"configContent": "設定内容",
"officialNoApiKey": "公式ログインは API Key 不要です。そのまま保存できます",
"codexOfficialNoApiKey": "公式は API Key 不要です。そのまま保存してください",
"codexApiKeyAutoFill": "ここに入力すれば auth.json も自動で埋まります",
"apiKeyAutoFill": "ここに入力すれば下の設定も自動で埋まります",
"cnOfficialApiKeyHint": "💡 API Key のみ入力すれば OK。エンドポイントはプリセット済みです",
"aggregatorApiKeyHint": "💡 API Key のみ入力すれば OK。エンドポイントはプリセット済みです",
"thirdPartyApiKeyHint": "💡 API Key のみ入力すれば OK。エンドポイントはプリセット済みです",
"customApiKeyHint": "💡 カスタム設定では必要な項目をすべて手動で入力してください",
"officialHint": "💡 公式プロバイダーはブラウザログインで、API Key は不要です",
"getApiKey": "API Key を取得",
"partnerPromotion": {
"zhipu": "Zhipu GLM は CC Switch の公式パートナーです。リンク経由でチャージすると 10% 割引",
"packycode": "PackyCode は CC Switch の公式パートナーです。登録後チャージ時に \"cc-switch\" を入力すると 10% オフ",
"minimax_cn": "MiniMax Coding Plan 特別価格、Starter ¥9.9 から",
"minimax_en": "MiniMax Coding Plan Black Friday、Starter が月額 $280% OFF"
},
"parameterConfig": "パラメーター設定 - {{name}} *",
"mainModel": "メインモデル(任意)",
"mainModelPlaceholder": "例: GLM-4.6",
"fastModel": "高速モデル(任意)",
"fastModelPlaceholder": "例: GLM-4.5-Air",
"modelHint": "💡 空欄ならプロバイダーのデフォルトモデルを使用します",
"apiHint": "💡 Claude API 互換サービスのエンドポイントを入力してください",
"codexApiHint": "💡 OpenAI Response 互換のサービスエンドポイントを入力してください",
"fillSupplierName": "プロバイダー名を入力してください",
"fillConfigContent": "設定内容を入力してください",
"fillParameter": "{{label}} を入力してください",
"fillTemplateValue": "{{label}} を入力してください",
"endpointRequired": "公式以外は API エンドポイントが必須です",
"apiKeyRequired": "公式以外は API Key が必須です",
"configJsonError": "Config JSON の形式が正しくありません。構文を確認してください",
"authJsonRequired": "auth.json は JSON オブジェクトで入力してください",
"authJsonError": "auth.json の形式が正しくありません。JSON を確認してください",
"fillAuthJson": "auth.json の設定を入力してください",
"fillApiKey": "OPENAI_API_KEY を入力してください",
"visitWebsite": "{{url}} を開く",
"anthropicModel": "メインモデル",
"anthropicSmallFastModel": "高速モデル",
"anthropicDefaultHaikuModel": "既定 Haiku モデル",
"anthropicDefaultSonnetModel": "既定 Sonnet モデル",
"anthropicDefaultOpusModel": "既定 Opus モデル",
"modelPlaceholder": "",
"smallModelPlaceholder": "",
"haikuModelPlaceholder": "",
"modelHelper": "任意: 既定で使いたい Claude モデルを指定。空欄ならシステム既定を使用します。",
"categoryOfficial": "公式",
"categoryCnOfficial": "オープンソース公式",
"categoryAggregation": "アグリゲーター",
"categoryThirdParty": "サードパーティ"
},
"endpointTest": {
"title": "API エンドポイント管理",
"endpoints": "エンドポイント",
"autoSelect": "自動選択",
"testSpeed": "テスト",
"testing": "テスト中",
"addEndpointPlaceholder": "https://api.example.com",
"done": "完了",
"noEndpoints": "エンドポイントがありません",
"failed": "失敗",
"enterValidUrl": "有効な URL を入力してください",
"invalidUrlFormat": "URL 形式が正しくありません",
"onlyHttps": "HTTP/HTTPS のみサポートします",
"urlExists": "この URL はすでに存在します",
"saveFailed": "保存に失敗しました。もう一度お試しください",
"loadEndpointsFailed": "カスタムエンドポイントの読み込みに失敗:",
"addEndpointFailed": "カスタムエンドポイントの追加に失敗:",
"removeEndpointFailed": "カスタムエンドポイントの削除に失敗:",
"removeFailed": "削除に失敗しました: {{error}}",
"updateLastUsedFailed": "エンドポイントの最終使用時間の更新に失敗しました",
"pleaseAddEndpoint": "まずエンドポイントを追加してください",
"testUnavailable": "速度テストを実行できません",
"noResult": "結果がありません",
"testFailed": "速度テストに失敗しました: {{error}}",
"status": "ステータス: {{code}}"
},
"codexConfig": {
"authJson": "auth.json (JSON) *",
"authJsonPlaceholder": "{\n \"OPENAI_API_KEY\": \"sk-your-api-key-here\"\n}",
"authJsonHint": "Codex の auth.json 設定内容",
"configToml": "config.toml (TOML)",
"configTomlHint": "Codex の config.toml 設定内容",
"writeCommonConfig": "共通設定を書き込む",
"editCommonConfig": "共通設定を編集",
"editCommonConfigTitle": "Codex 共通設定スニペットを編集",
"commonConfigHint": "「共通設定を書き込む」がオンの場合、config.toml の末尾に追記されます",
"apiUrlLabel": "API リクエスト URL"
},
"geminiConfig": {
"envFile": "環境変数 (.env)",
"envFileHint": ".env 形式で Gemini の環境変数を設定",
"configJson": "設定ファイル (config.json)",
"configJsonHint": "Gemini 拡張パラメーターを JSON 形式で設定(任意)",
"writeCommonConfig": "共通設定を書き込む",
"editCommonConfig": "共通設定を編集",
"editCommonConfigTitle": "Gemini 共通設定スニペットを編集",
"commonConfigHint": "共通設定スニペットは、この機能をオンにしたすべての Gemini プロバイダーへマージされます"
},
"providerPreset": {
"label": "プロバイダータイプ",
"custom": "カスタム設定",
"other": "その他",
"hint": "プリセットを選んだ後でも、下のフィールドで調整できます。"
},
"usage": {
"queryFailed": "照会に失敗しました",
"refreshUsage": "利用状況を更新",
"planUsage": "プラン利用状況",
"invalid": "期限切れ",
"total": "合計:",
"used": "使用:",
"remaining": "残り:",
"justNow": "たった今",
"minutesAgo": "{{count}} 分前",
"hoursAgo": "{{count}} 時間前",
"daysAgo": "{{count}} 日前"
},
"usageScript": {
"title": "利用状況を設定",
"enableUsageQuery": "利用状況照会を有効にする",
"presetTemplate": "プリセットテンプレート",
"requestUrl": "リクエスト URL",
"requestUrlPlaceholder": "例: https://api.example.com",
"method": "HTTP メソッド",
"templateCustom": "カスタム",
"templateGeneral": "General",
"templateNewAPI": "NewAPI",
"credentialsConfig": "認証情報",
"baseUrl": "Base URL",
"accessToken": "Access Token",
"accessTokenPlaceholder": "「Security Settings」で生成",
"userId": "ユーザー ID",
"userIdPlaceholder": "例: 114514",
"defaultPlan": "デフォルトプラン",
"queryFailedMessage": "照会に失敗しました",
"queryScript": "照会スクリプト (JavaScript)",
"timeoutSeconds": "タイムアウト(秒)",
"headers": "ヘッダー",
"body": "ボディ",
"timeoutHint": "範囲: 2〜30 秒",
"timeoutMustBeInteger": "タイムアウトは整数で入力してください(小数は切り捨て)",
"timeoutCannotBeNegative": "タイムアウトは負の値にできません",
"autoIntervalMinutes": "自動照会間隔(分、0 で無効)",
"autoQueryInterval": "自動照会間隔(分)",
"autoQueryIntervalHint": "0 で無効。推奨 5〜60 分",
"intervalMustBeInteger": "間隔は整数で入力してください(小数は切り捨て)",
"intervalCannotBeNegative": "間隔は負の値にできません",
"intervalAdjusted": "間隔を {{value}} 分に調整しました",
"scriptHelp": "スクリプトの書き方:",
"configFormat": "設定の形式:",
"commentOptional": "任意",
"commentResponseIsJson": "response は API から返る JSON データです",
"extractorFormat": "抽出関数の返却形式(すべて任意):",
"tips": "💡 ヒント:",
"testing": "テスト中...",
"testScript": "スクリプトをテスト",
"format": "整形",
"saveConfig": "設定を保存",
"scriptEmpty": "スクリプト設定は空にできません",
"mustHaveReturn": "スクリプトには return 文が必要です",
"testSuccess": "テスト成功!",
"testFailed": "テストに失敗しました",
"formatSuccess": "整形に成功しました",
"formatFailed": "整形に失敗しました",
"variablesHint": "使用可能な変数: {{apiKey}}, {{baseUrl}} | extractor 関数には API 応答の JSON オブジェクトが渡されます",
"scriptConfig": "リクエスト設定",
"extractorCode": "抽出コード",
"extractorHint": "戻り値のオブジェクトに残り枠の項目を含めてください",
"fieldIsValid": "• isValid: Boolean。プランが有効かどうか",
"fieldInvalidMessage": "• invalidMessage: String。無効時の理由(isValid が false のとき表示)",
"fieldRemaining": "• remaining: Number。残り枠",
"fieldUnit": "• unit: String。単位(例: \"USD\"",
"fieldPlanName": "• planName: String。プラン名",
"fieldTotal": "• total: Number。総枠",
"fieldUsed": "• used: Number。使用量",
"fieldExtra": "• extra: String。自由記述の追加テキスト",
"tip1": "• 変数 {{apiKey}} と {{baseUrl}} は自動で置換されます",
"tip2": "• 抽出関数はサンドボックスで実行され、ES2020+ の構文を使えます",
"tip3": "• 全体を () で囲み、オブジェクトリテラル式にしてください"
},
"errors": {
"usage_query_failed": "利用状況の取得に失敗しました"
},
"presetSelector": {
"title": "設定タイプを選択",
"custom": "カスタム",
"customDescription": "手動で設定。完全な構成が必要",
"officialDescription": "公式ログイン。API Key 不要",
"presetDescription": "プリセットを使用。API Key だけ入力すれば OK"
},
"mcp": {
"title": "MCP 管理",
"claudeTitle": "Claude Code MCP 管理",
"codexTitle": "Codex MCP 管理",
"geminiTitle": "Gemini MCP 管理",
"unifiedPanel": {
"title": "MCP サーバー管理",
"addServer": "サーバーを追加",
"editServer": "サーバーを編集",
"deleteServer": "サーバーを削除",
"deleteConfirm": "サーバー「{{id}}」を削除しますか?この操作は元に戻せません。",
"noServers": "まだサーバーがありません",
"enabledApps": "有効なアプリ",
"apps": {
"claude": "Claude",
"codex": "Codex",
"gemini": "Gemini"
}
},
"userLevelPath": "ユーザーレベルの MCP パス",
"serverList": "サーバー一覧",
"loading": "読み込み中...",
"empty": "MCP サーバーがありません",
"emptyDescription": "右上のボタンから最初の MCP サーバーを追加してください",
"add": "MCP を追加",
"addServer": "MCP を追加",
"editServer": "MCP を編集",
"addClaudeServer": "Claude Code MCP を追加",
"editClaudeServer": "Claude Code MCP を編集",
"addCodexServer": "Codex MCP を追加",
"editCodexServer": "Codex MCP を編集",
"configPath": "設定パス",
"serverCount": "MCP サーバー: {{count}} 件",
"enabledCount": "{{count}} 件が有効",
"template": {
"fetch": "クイックテンプレート: mcp-fetch"
},
"form": {
"title": "MCP ID(ユニーク)",
"titlePlaceholder": "my-mcp-server",
"name": "表示名",
"namePlaceholder": "例: @modelcontextprotocol/server-time",
"enabledApps": "適用するアプリ",
"noAppsWarning": "少なくとも 1 つ選択してください",
"description": "説明",
"descriptionPlaceholder": "任意の説明",
"tags": "タグ(カンマ区切り)",
"tagsPlaceholder": "stdio, time, utility",
"homepage": "ホームページ",
"homepagePlaceholder": "https://example.com",
"docs": "ドキュメント",
"docsPlaceholder": "https://example.com/docs",
"additionalInfo": "追加情報",
"jsonConfig": "JSON 全設定",
"jsonConfigOrPrefix": "JSON 全設定、または",
"tomlConfigOrPrefix": "TOML 全設定、または",
"jsonPlaceholder": "{\n \"type\": \"stdio\",\n \"command\": \"uvx\",\n \"args\": [\"mcp-server-fetch\"]\n}",
"tomlConfig": "TOML 全設定",
"tomlPlaceholder": "type = \"stdio\"\ncommand = \"uvx\"\nargs = [\"mcp-server-fetch\"]",
"useWizard": "設定ウィザード",
"syncOtherSide": "{{target}} にも反映",
"syncOtherSideHint": "{{target}} に同じ設定を書き込みます。既存の同一 ID は上書きされます。",
"willOverwriteWarning": "{{target}} の既存設定を上書きします"
},
"wizard": {
"title": "MCP 設定ウィザード",
"hint": "MCP サーバーを素早く設定し JSON を自動生成します",
"type": "タイプ",
"typeStdio": "stdio",
"typeHttp": "http",
"typeSse": "sse",
"command": "コマンド",
"commandPlaceholder": "npx または uvx",
"args": "引数",
"argsPlaceholder": "arg1\narg2",
"env": "環境変数",
"envPlaceholder": "KEY1=value1\nKEY2=value2",
"url": "URL",
"urlPlaceholder": "https://api.example.com/mcp",
"urlRequired": "URL を入力してください",
"headers": "ヘッダー(任意)",
"headersPlaceholder": "Authorization: Bearer your_token_here\nContent-Type: application/json",
"preview": "設定プレビュー",
"apply": "設定を反映"
},
"id": "識別子(ユニーク)",
"type": "タイプ",
"command": "コマンド",
"validateCommand": "コマンドを検証",
"args": "引数",
"argsPlaceholder": "例: mcp-server-fetch --help",
"env": "環境変数(1 行に 1 件、KEY=VALUE",
"envPlaceholder": "FOO=bar\nHELLO=world",
"reset": "リセット",
"notice": {
"restartClaude": "書き込みました。Claude を再起動すると反映されます。"
},
"msg": {
"saved": "保存しました",
"deleted": "削除しました",
"enabled": "有効化しました",
"disabled": "無効化しました",
"templateAdded": "テンプレートを追加しました"
},
"error": {
"idRequired": "識別子を入力してください",
"idExists": "この識別子は既に存在します。別のものを選んでください。",
"jsonInvalid": "JSON 形式が無効です",
"tomlInvalid": "TOML 形式が無効です",
"commandRequired": "コマンドを入力してください",
"singleServerObjectRequired": "単一の MCP サーバーオブジェクトを貼り付けてください(トップレベルの mcpServers は不要)",
"saveFailed": "保存に失敗しました",
"deleteFailed": "削除に失敗しました"
},
"validation": {
"ok": "コマンドが見つかりました",
"fail": "コマンドが見つかりません"
},
"confirm": {
"deleteTitle": "MCP サーバーを削除",
"deleteMessage": "MCP サーバー「{{id}}」を削除してもよろしいですか?この操作は元に戻せません。"
},
"presets": {
"title": "MCP タイプを選択",
"enable": "有効化",
"enabled": "有効",
"installed": "インストール済み",
"docs": "ドキュメント",
"requiresEnv": "環境変数が必要",
"fetch": {
"name": "mcp-server-fetch",
"description": "汎用 HTTP リクエストツール。GET/POST などに対応し、API テストや Web データ取得に最適です"
},
"time": {
"name": "@modelcontextprotocol/server-time",
"description": "現在時刻、タイムゾーン変換、日付計算を提供する時間クエリツール"
},
"memory": {
"name": "@modelcontextprotocol/server-memory",
"description": "エンティティ・関係・観測を扱うナレッジグラフ型メモリ。会話の重要情報を AI に記憶させます"
},
"sequential-thinking": {
"name": "@modelcontextprotocol/server-sequential-thinking",
"description": "複雑な問題をステップに分解して深く考えるためのシーケンシャル思考ツール"
},
"context7": {
"name": "@upstash/context7-mcp",
"description": "最新のライブラリドキュメントとコード例を提供する Context7 ドキュメント検索ツール。キー設定で上限が拡張されます"
}
}
},
"prompts": {
"manage": "プロンプト",
"title": "{{appName}} プロンプト管理",
"claudeTitle": "Claude プロンプト管理",
"codexTitle": "Codex プロンプト管理",
"add": "プロンプトを追加",
"edit": "プロンプトを編集",
"addTitle": "{{appName}} プロンプトを追加",
"editTitle": "{{appName}} プロンプトを編集",
"import": "既存をインポート",
"count": "{{count}} 件のプロンプト",
"enabled": "有効",
"enable": "有効化",
"enabledName": "有効: {{name}}",
"noneEnabled": "有効なプロンプトがありません",
"currentFile": "現在の {{filename}} の内容",
"empty": "まだプロンプトがありません",
"emptyDescription": "上のボタンからプロンプトを追加またはインポートしてください",
"loading": "読み込み中...",
"name": "名前",
"namePlaceholder": "例: デフォルトプロジェクトプロンプト",
"description": "説明",
"descriptionPlaceholder": "任意の説明",
"content": "内容",
"contentPlaceholder": "# {{filename}}\n\nここにプロンプト内容を入力...",
"loadFailed": "プロンプトの読み込みに失敗しました",
"saveSuccess": "保存しました",
"saveFailed": "保存に失敗しました",
"deleteSuccess": "削除しました",
"deleteFailed": "削除に失敗しました",
"enableSuccess": "有効化しました",
"enableFailed": "有効化に失敗しました",
"disableSuccess": "無効化しました",
"disableFailed": "無効化に失敗しました",
"importSuccess": "インポートしました",
"importFailed": "インポートに失敗しました",
"confirm": {
"deleteTitle": "削除の確認",
"deleteMessage": "プロンプト「{{name}}」を削除してもよろしいですか?"
}
},
"env": {
"warning": {
"title": "競合する環境変数を検出しました",
"description": "設定を上書きする可能性のある環境変数を {{count}} 件見つけました"
},
"actions": {
"expand": "詳細を表示",
"collapse": "折りたたむ",
"selectAll": "すべて選択",
"clearSelection": "選択を解除",
"deleteSelected": "選択 {{count}} 件を削除",
"deleting": "削除中..."
},
"field": {
"value": "値",
"source": "ソース"
},
"source": {
"userRegistry": "ユーザー環境変数(レジストリ)",
"systemRegistry": "システム環境変数(レジストリ)",
"systemEnv": "システム環境変数"
},
"delete": {
"success": "環境変数を削除しました",
"error": "環境変数の削除に失敗しました"
},
"backup": {
"location": "バックアップ場所: {{path}}"
},
"confirm": {
"title": "環境変数を削除しますか?",
"message": "{{count}} 件の環境変数を削除してもよろしいですか?",
"backupNotice": "削除前に自動バックアップを作成します。後で復元できます。再起動またはターミナル再起動後に反映されます。",
"confirm": "削除を確認"
},
"error": {
"noSelection": "削除する環境変数を選択してください"
}
},
"skills": {
"manage": "Skills",
"title": "Claude スキル管理",
"description": "人気リポジトリから Claude Skills を探してインストールし、Claude Code/Codex を拡張",
"refresh": "更新",
"refreshing": "更新中...",
"repoManager": "リポジトリ管理",
"count": "{{count}} 個のスキル",
"empty": "スキルがありません",
"emptyDescription": "スキルリポジトリを追加して探索してください",
"addRepo": "スキルリポジトリを追加",
"loading": "読み込み中...",
"installed": "インストール済み",
"install": "インストール",
"installing": "インストール中...",
"uninstall": "アンインストール",
"uninstalling": "アンインストール中...",
"view": "表示",
"noDescription": "説明なし",
"loadFailed": "読み込みに失敗しました",
"installSuccess": "スキル {{name}} をインストールしました",
"installFailed": "インストールに失敗しました",
"uninstallSuccess": "スキル {{name}} をアンインストールしました",
"uninstallFailed": "アンインストールに失敗しました",
"error": {
"skillNotFound": "スキルが見つかりません: {{directory}}",
"missingRepoInfo": "リポジトリ情報(owner または name)が不足しています",
"downloadTimeout": "リポジトリ {{owner}}/{{name}} のダウンロードがタイムアウトしました({{timeout}} 秒)",
"downloadTimeoutHint": "ネットワークを確認するか、時間をおいて再試行してください",
"skillPathNotFound": "リポジトリ {{owner}}/{{name}} にスキルパス '{{path}}' がありません",
"skillDirNotFound": "スキルディレクトリが見つかりません: {{path}}",
"emptyArchive": "ダウンロードしたアーカイブが空です",
"downloadFailed": "ダウンロードに失敗しました: HTTP {{status}}",
"allBranchesFailed": "すべてのブランチで失敗しました。試行: {{branches}}",
"httpError": "HTTP エラー {{status}}",
"http403": "GitHub へのアクセスが制限されています(レート制限の可能性)",
"http404": "リポジトリまたはブランチが見つかりません。URL を確認してください",
"http429": "リクエストが多すぎます。時間をおいて再試行してください",
"parseMetadataFailed": "スキルメタデータの解析に失敗しました",
"getHomeDirFailed": "ユーザーのホームディレクトリを取得できません",
"networkError": "ネットワークエラー",
"fsError": "ファイルシステムエラー",
"unknownError": "不明なエラー",
"suggestion": {
"checkNetwork": "ネットワーク接続を確認してください",
"checkProxy": "HTTP プロキシの設定を検討してください",
"retryLater": "時間をおいて再試行してください",
"checkRepoUrl": "リポジトリ URL とブランチ名を確認してください",
"checkDiskSpace": "ディスク容量を確認してください",
"checkPermission": "ディレクトリの権限を確認してください"
}
},
"repo": {
"title": "スキルリポジトリを管理",
"description": "GitHub のスキルリポジトリソースを追加または削除します",
"url": "リポジトリ URL",
"urlPlaceholder": "owner/name または https://github.com/owner/name",
"branch": "ブランチ",
"branchPlaceholder": "main",
"path": "スキルパス",
"pathPlaceholder": "skills(任意。空欄はルート)",
"add": "リポジトリを追加",
"list": "追加済みリポジトリ",
"empty": "リポジトリがありません",
"invalidUrl": "リポジトリ URL の形式が無効です",
"addSuccess": "リポジトリ {{owner}}/{{name}} を追加しました。検出スキル: {{count}} 件",
"addFailed": "追加に失敗しました",
"removeSuccess": "リポジトリ {{owner}}/{{name}} を削除しました",
"removeFailed": "削除に失敗しました",
"skillCount": "{{count}} 件のスキルを検出"
},
"search": "スキルを検索",
"searchPlaceholder": "スキル名または説明で検索...",
"filter": {
"placeholder": "状態で絞り込み",
"all": "すべて",
"installed": "インストール済み",
"uninstalled": "未インストール"
},
"noResults": "一致するスキルが見つかりませんでした"
},
"deeplink": {
"confirmImport": "プロバイダーのインポートを確認",
"confirmImportDescription": "次の設定をディープリンクから CC Switch へインポートします",
"importPrompt": "プロンプトをインポート",
"importPromptDescription": "このシステムプロンプトをインポートするか確認してください",
"importMcp": "MCP サーバーをインポート",
"importMcpDescription": "これらの MCP サーバーをインポートするか確認してください",
"importSkill": "スキルリポジトリを追加",
"importSkillDescription": "このスキルリポジトリを追加するか確認してください",
"promptImportSuccess": "プロンプトをインポートしました",
"promptImportSuccessDescription": "インポートされたプロンプト: {{name}}",
"mcpImportSuccess": "MCP サーバーをインポートしました",
"mcpImportSuccessDescription": "{{count}} 件のサーバーをインポートしました",
"mcpPartialSuccess": "一部のみインポート成功",
"mcpPartialSuccessDescription": "成功: {{success}}、失敗: {{failed}}",
"skillImportSuccess": "スキルリポジトリを追加しました",
"skillImportSuccessDescription": "追加したリポジトリ: {{repo}}",
"app": "アプリ種別",
"providerName": "プロバイダー名",
"homepage": "ホームページ",
"endpoint": "API エンドポイント",
"apiKey": "API Key",
"icon": "アイコン",
"model": "モデル",
"haikuModel": "Haiku モデル",
"sonnetModel": "Sonnet モデル",
"opusModel": "Opus モデル",
"multiModel": "マルチモーダルモデル",
"notes": "メモ",
"import": "インポート",
"importing": "インポート中...",
"warning": "インポート前に内容を確認してください。後から一覧で編集・削除できます。",
"parseError": "ディープリンクの解析に失敗しました",
"importSuccess": "インポート成功",
"importSuccessDescription": "プロバイダー「{{name}}」をインポートしました",
"importError": "インポートに失敗しました",
"configSource": "設定ソース",
"configEmbedded": "埋め込み設定",
"configRemote": "リモート設定",
"configDetails": "設定の詳細",
"configUrl": "設定ファイル URL",
"configMergeError": "設定ファイルのマージに失敗しました",
"mcp": {
"title": "MCP サーバーを一括インポート",
"targetApps": "ターゲットアプリ",
"serverCount": "MCP サーバー({{count}} 件)",
"enabledWarning": "インポート後、指定したすべてのアプリに即座に書き込まれます"
},
"prompt": {
"title": "システムプロンプトをインポート",
"app": "アプリ",
"name": "名前",
"description": "説明",
"contentPreview": "内容プレビュー",
"enabledWarning": "インポート後すぐにこのプロンプトが有効になり、他は無効になります"
},
"skill": {
"title": "Claude スキルリポジトリを追加",
"repo": "GitHub リポジトリ",
"directory": "対象ディレクトリ",
"branch": "ブランチ",
"skillsPath": "スキルパス",
"hint": "この操作でスキルリポジトリが一覧に追加されます。",
"hintDetail": "追加後、スキル管理ページから個別のスキルをインストールできます。"
}
},
"iconPicker": {
"search": "アイコンを検索",
"searchPlaceholder": "アイコン名を入力...",
"noResults": "一致するアイコンが見つかりません",
"category": {
"aiProvider": "AI プロバイダー",
"cloud": "クラウドプラットフォーム",
"tool": "開発ツール",
"other": "その他"
}
},
"providerIcon": {
"label": "アイコン",
"colorLabel": "アイコンカラー",
"selectIcon": "アイコンを選択",
"preview": "プレビュー"
},
"migration": {
"success": "設定の移行が完了しました"
},
"agents": {
"title": "エージェント"
}
}
+12 -3
View File
@@ -165,6 +165,7 @@
"autoReload": "数据将在2秒后自动刷新...", "autoReload": "数据将在2秒后自动刷新...",
"languageOptionChinese": "中文", "languageOptionChinese": "中文",
"languageOptionEnglish": "English", "languageOptionEnglish": "English",
"languageOptionJapanese": "日本語",
"windowBehavior": "窗口行为", "windowBehavior": "窗口行为",
"windowBehaviorHint": "配置窗口最小化与 Claude 插件联动策略。", "windowBehaviorHint": "配置窗口最小化与 Claude 插件联动策略。",
"launchOnStartup": "开机自启", "launchOnStartup": "开机自启",
@@ -262,7 +263,8 @@
"partnerPromotion": { "partnerPromotion": {
"zhipu": "智谱 GLM 是 CC Switch 的官方合作伙伴,使用此链接充值可以获得9折优惠", "zhipu": "智谱 GLM 是 CC Switch 的官方合作伙伴,使用此链接充值可以获得9折优惠",
"packycode": "PackyCode 是 CC Switch 的官方合作伙伴,使用此链接注册并在充值时填写 \"cc-switch\" 优惠码,可以享受9折优惠", "packycode": "PackyCode 是 CC Switch 的官方合作伙伴,使用此链接注册并在充值时填写 \"cc-switch\" 优惠码,可以享受9折优惠",
"minimax": "MiniMax Coding Plan 黑五特惠,Starter 套餐现仅 $2/月(2折优惠!)" "minimax_cn": "MiniMax Coding Plan 特惠,Starter 套餐 9.9 元起",
"minimax_en": "MiniMax Coding Plan 黑五特惠,Starter 套餐现仅 $2/月(2折优惠!)"
}, },
"parameterConfig": "参数配置 - {{name}} *", "parameterConfig": "参数配置 - {{name}} *",
"mainModel": "主模型 (可选)", "mainModel": "主模型 (可选)",
@@ -376,7 +378,8 @@
"templateGeneral": "通用模板", "templateGeneral": "通用模板",
"templateNewAPI": "NewAPI", "templateNewAPI": "NewAPI",
"credentialsConfig": "凭证配置", "credentialsConfig": "凭证配置",
"accessToken": "访问令牌", "baseUrl": "请求地址",
"accessToken": "访问令牌(在个人安全设置里获取)",
"accessTokenPlaceholder": "在'安全设置'里生成", "accessTokenPlaceholder": "在'安全设置'里生成",
"userId": "用户 ID", "userId": "用户 ID",
"userIdPlaceholder": "例如:114514", "userIdPlaceholder": "例如:114514",
@@ -389,7 +392,7 @@
"timeoutHint": "范围: 2-30 秒", "timeoutHint": "范围: 2-30 秒",
"timeoutMustBeInteger": "超时时间必须为整数,小数部分已忽略", "timeoutMustBeInteger": "超时时间必须为整数,小数部分已忽略",
"timeoutCannotBeNegative": "超时时间不能为负数", "timeoutCannotBeNegative": "超时时间不能为负数",
"autoIntervalMinutes": "自动查询间隔(分钟)", "autoIntervalMinutes": "自动查询间隔(分钟0 表示不自动查询",
"autoQueryInterval": "自动查询间隔(分钟)", "autoQueryInterval": "自动查询间隔(分钟)",
"autoQueryIntervalHint": "0 表示不自动查询,建议 5-60 分钟", "autoQueryIntervalHint": "0 表示不自动查询,建议 5-60 分钟",
"intervalMustBeInteger": "自动查询间隔必须为整数,小数部分已忽略", "intervalMustBeInteger": "自动查询间隔必须为整数,小数部分已忽略",
@@ -831,5 +834,11 @@
"colorLabel": "图标颜色", "colorLabel": "图标颜色",
"selectIcon": "选择图标", "selectIcon": "选择图标",
"preview": "预览" "preview": "预览"
},
"migration": {
"success": "配置迁移成功"
},
"agents": {
"title": "智能体"
} }
} }
-1
View File
@@ -33,7 +33,6 @@ export interface DeepLinkImportRequest {
repo?: string; repo?: string;
directory?: string; directory?: string;
branch?: string; branch?: string;
skillsPath?: string;
// Config file fields // Config file fields
config?: string; config?: string;
-2
View File
@@ -10,7 +10,6 @@ export interface Skill {
repoOwner?: string; repoOwner?: string;
repoName?: string; repoName?: string;
repoBranch?: string; repoBranch?: string;
skillsPath?: string; // 技能所在的子目录路径,如 "skills"
} }
export interface SkillRepo { export interface SkillRepo {
@@ -18,7 +17,6 @@ export interface SkillRepo {
name: string; name: string;
branch: string; branch: string;
enabled: boolean; enabled: boolean;
skillsPath?: string; // 可选:技能所在的子目录路径,如 "skills"
} }
export const skillsApi = { export const skillsApi = {
+1 -1
View File
@@ -13,7 +13,7 @@ export const settingsSchema = z.object({
minimizeToTrayOnClose: z.boolean(), minimizeToTrayOnClose: z.boolean(),
enableClaudePluginIntegration: z.boolean().optional(), enableClaudePluginIntegration: z.boolean().optional(),
launchOnStartup: z.boolean().optional(), launchOnStartup: z.boolean().optional(),
language: z.enum(["en", "zh"]).optional(), language: z.enum(["en", "zh", "ja"]).optional(),
// 设备级目录覆盖 // 设备级目录覆盖
claudeConfigDir: directorySchema.nullable().optional(), claudeConfigDir: directorySchema.nullable().optional(),
+1 -1
View File
@@ -109,7 +109,7 @@ export interface Settings {
// 是否开机自启 // 是否开机自启
launchOnStartup?: boolean; launchOnStartup?: boolean;
// 首选语言(可选,默认中文) // 首选语言(可选,默认中文)
language?: "en" | "zh"; language?: "en" | "zh" | "ja";
// ===== 设备级目录覆盖 ===== // ===== 设备级目录覆盖 =====
// 覆盖 Claude Code 配置目录(可选) // 覆盖 Claude Code 配置目录(可选)
@@ -32,17 +32,17 @@ describe("ImportExportSection Component", () => {
it("should disable import button and show placeholder when no file selected", () => { it("should disable import button and show placeholder when no file selected", () => {
render(<ImportExportSection {...baseProps} />); render(<ImportExportSection {...baseProps} />);
expect(screen.getByText("settings.noFileSelected")).toBeInTheDocument(); // When no file selected, button shows "selectConfigFile" and clicking it opens file dialog
expect( expect(
screen.getByRole("button", { name: "settings.import" }), screen.getByRole("button", { name: /settings\.selectConfigFile/ }),
).toBeDisabled(); ).toBeInTheDocument();
fireEvent.click( fireEvent.click(
screen.getByRole("button", { name: "settings.exportConfig" }), screen.getByRole("button", { name: "settings.exportConfig" }),
); );
expect(baseProps.onExport).toHaveBeenCalledTimes(1); expect(baseProps.onExport).toHaveBeenCalledTimes(1);
fireEvent.click( fireEvent.click(
screen.getByRole("button", { name: "settings.selectConfigFile" }), screen.getByRole("button", { name: /settings\.selectConfigFile/ }),
); );
expect(baseProps.onSelectFile).toHaveBeenCalledTimes(1); expect(baseProps.onSelectFile).toHaveBeenCalledTimes(1);
}); });
@@ -55,15 +55,15 @@ describe("ImportExportSection Component", () => {
/>, />,
); );
expect(screen.getByText("config.json")).toBeInTheDocument(); expect(screen.getByText(/config\.json/)).toBeInTheDocument();
const importButton = screen.getByRole("button", { const importButton = screen.getByRole("button", {
name: "settings.import", name: /settings\.import/,
}); });
expect(importButton).toBeEnabled(); expect(importButton).toBeEnabled();
fireEvent.click(importButton); fireEvent.click(importButton);
expect(baseProps.onImport).toHaveBeenCalledTimes(1); expect(baseProps.onImport).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: "common.clear" })); fireEvent.click(screen.getByRole("button", { name: "Clear selection" }));
expect(baseProps.onClear).toHaveBeenCalledTimes(1); expect(baseProps.onClear).toHaveBeenCalledTimes(1);
}); });
+12 -1
View File
@@ -79,6 +79,17 @@ vi.mock("@/components/ui/textarea", () => ({
), ),
})); }));
vi.mock("@/components/JsonEditor", () => ({
default: ({ value, onChange, placeholder, ...rest }: any) => (
<textarea
value={value}
placeholder={placeholder}
onChange={(event) => onChange?.(event.target.value)}
{...rest}
/>
),
}));
vi.mock("@/components/ui/checkbox", () => ({ vi.mock("@/components/ui/checkbox", () => ({
Checkbox: ({ id, checked, onCheckedChange, ...rest }: any) => ( Checkbox: ({ id, checked, onCheckedChange, ...rest }: any) => (
<input <input
@@ -256,7 +267,7 @@ describe("McpFormModal", () => {
await waitFor(() => expect(toastErrorMock).toHaveBeenCalled()); await waitFor(() => expect(toastErrorMock).toHaveBeenCalled());
expect(upsertMock).not.toHaveBeenCalled(); expect(upsertMock).not.toHaveBeenCalled();
const [message] = toastErrorMock.mock.calls.at(-1) ?? []; const [message] = toastErrorMock.mock.calls.at(-1) ?? [];
expect(message).toBe("mcp.error.jsonInvalid"); expect(message).toBe("mcp.error.commandRequired");
}); });
it("支持向导生成配置并自动填充 ID", async () => { it("支持向导生成配置并自动填充 ID", async () => {
+15 -24
View File
@@ -35,6 +35,7 @@ interface SettingsMock {
resetDirectory: ReturnType<typeof vi.fn>; resetDirectory: ReturnType<typeof vi.fn>;
resetAppConfigDir: ReturnType<typeof vi.fn>; resetAppConfigDir: ReturnType<typeof vi.fn>;
saveSettings: ReturnType<typeof vi.fn>; saveSettings: ReturnType<typeof vi.fn>;
autoSaveSettings: ReturnType<typeof vi.fn>;
resetSettings: ReturnType<typeof vi.fn>; resetSettings: ReturnType<typeof vi.fn>;
acknowledgeRestart: ReturnType<typeof vi.fn>; acknowledgeRestart: ReturnType<typeof vi.fn>;
} }
@@ -66,6 +67,7 @@ const createSettingsMock = (overrides: Partial<SettingsMock> = {}) => {
resetDirectory: vi.fn(), resetDirectory: vi.fn(),
resetAppConfigDir: vi.fn(), resetAppConfigDir: vi.fn(),
saveSettings: vi.fn().mockResolvedValue({ requiresRestart: false }), saveSettings: vi.fn().mockResolvedValue({ requiresRestart: false }),
autoSaveSettings: vi.fn().mockResolvedValue({ requiresRestart: false }),
resetSettings: vi.fn(), resetSettings: vi.fn(),
acknowledgeRestart: vi.fn(), acknowledgeRestart: vi.fn(),
}; };
@@ -247,7 +249,8 @@ describe("SettingsPage Component", () => {
render(<SettingsPage open={true} onOpenChange={vi.fn()} />); render(<SettingsPage open={true} onOpenChange={vi.fn()} />);
expect(screen.queryByText("language:zh")).not.toBeInTheDocument(); expect(screen.queryByText("language:zh")).not.toBeInTheDocument();
expect(screen.getByText("settings.title")).toBeInTheDocument(); // 加载状态下显示 spinner 而不是表单内容
expect(document.querySelector(".animate-spin")).toBeInTheDocument();
}); });
it("should reset import/export status when dialog transitions to open", () => { it("should reset import/export status when dialog transitions to open", () => {
@@ -264,6 +267,7 @@ describe("SettingsPage Component", () => {
it("should render general and advanced tabs and trigger child callbacks", () => { it("should render general and advanced tabs and trigger child callbacks", () => {
const onOpenChange = vi.fn(); const onOpenChange = vi.fn();
// 设置 selectedFile 后,按钮显示 settings.import(可执行导入)
importExportMock = createImportExportMock({ importExportMock = createImportExportMock({
selectedFile: "/tmp/config.json", selectedFile: "/tmp/config.json",
}); });
@@ -284,21 +288,20 @@ describe("SettingsPage Component", () => {
}); });
fireEvent.click(screen.getByText("settings.tabAdvanced")); fireEvent.click(screen.getByText("settings.tabAdvanced"));
fireEvent.click(
screen.getByRole("button", { name: "settings.selectConfigFile" }),
);
expect(importExportMock.selectImportFile).toHaveBeenCalled(); // 有文件时,点击导入按钮执行 importConfig
fireEvent.click(
screen.getByRole("button", { name: /settings\.import/ }),
);
expect(importExportMock.importConfig).toHaveBeenCalled();
fireEvent.click( fireEvent.click(
screen.getByRole("button", { name: "settings.exportConfig" }), screen.getByRole("button", { name: "settings.exportConfig" }),
); );
expect(importExportMock.exportConfig).toHaveBeenCalled(); expect(importExportMock.exportConfig).toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "settings.import" })); // 清除选择按钮
expect(importExportMock.importConfig).toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "Clear selection" }));
fireEvent.click(screen.getByRole("button", { name: "common.clear" }));
expect(importExportMock.clearSelection).toHaveBeenCalled(); expect(importExportMock.clearSelection).toHaveBeenCalled();
}); });
@@ -330,7 +333,9 @@ describe("SettingsPage Component", () => {
render(<SettingsPage open={true} onOpenChange={onOpenChange} />); render(<SettingsPage open={true} onOpenChange={onOpenChange} />);
fireEvent.click(screen.getByText("common.save")); // 保存按钮在 advanced tab 中
fireEvent.click(screen.getByText("settings.tabAdvanced"));
fireEvent.click(screen.getByRole("button", { name: /common\.save/ }));
await waitFor(() => { await waitFor(() => {
expect(settingsMock.saveSettings).toHaveBeenCalledTimes(1); expect(settingsMock.saveSettings).toHaveBeenCalledTimes(1);
@@ -341,20 +346,6 @@ describe("SettingsPage Component", () => {
}); });
}); });
it("should reset settings and close dialog when clicking cancel", () => {
const onOpenChange = vi.fn();
render(<SettingsPage open={true} onOpenChange={onOpenChange} />);
fireEvent.click(screen.getByText("common.cancel"));
expect(settingsMock.resetSettings).toHaveBeenCalledTimes(1);
expect(settingsMock.acknowledgeRestart).toHaveBeenCalledTimes(1);
expect(importExportMock.clearSelection).toHaveBeenCalledTimes(1);
expect(importExportMock.resetStatus).toHaveBeenCalledTimes(2);
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("should show restart prompt and allow immediate restart after save", async () => { it("should show restart prompt and allow immediate restart after save", async () => {
settingsMock = createSettingsMock({ settingsMock = createSettingsMock({
requiresRestart: true, requiresRestart: true,
+4
View File
@@ -8,6 +8,7 @@ const useSettingsQueryMock = vi.fn();
const setAppConfigDirOverrideMock = vi.fn(); const setAppConfigDirOverrideMock = vi.fn();
const applyClaudePluginConfigMock = vi.fn(); const applyClaudePluginConfigMock = vi.fn();
const syncCurrentProvidersLiveMock = vi.fn(); const syncCurrentProvidersLiveMock = vi.fn();
const updateTrayMenuMock = vi.fn();
const toastErrorMock = vi.fn(); const toastErrorMock = vi.fn();
const toastSuccessMock = vi.fn(); const toastSuccessMock = vi.fn();
@@ -52,6 +53,9 @@ vi.mock("@/lib/api", () => ({
syncCurrentProvidersLive: (...args: unknown[]) => syncCurrentProvidersLive: (...args: unknown[]) =>
syncCurrentProvidersLiveMock(...args), syncCurrentProvidersLiveMock(...args),
}, },
providersApi: {
updateTrayMenu: (...args: unknown[]) => updateTrayMenuMock(...args),
},
})); }));
const createSettingsFormMock = (overrides: Record<string, unknown> = {}) => ({ const createSettingsFormMock = (overrides: Record<string, unknown> = {}) => ({
+23
View File
@@ -58,6 +58,29 @@ describe("useSettingsForm Hook", () => {
expect(changeLanguageSpy).toHaveBeenCalledWith("en"); expect(changeLanguageSpy).toHaveBeenCalledWith("en");
}); });
it("should support japanese language preference from server data", async () => {
useSettingsQueryMock.mockReturnValue({
data: {
showInTray: true,
minimizeToTrayOnClose: true,
enableClaudePluginIntegration: false,
claudeConfigDir: "/Users/demo",
codexConfigDir: null,
language: "ja",
},
isLoading: false,
});
const { result } = renderHook(() => useSettingsForm());
await waitFor(() => {
expect(result.current.settings?.language).toBe("ja");
});
expect(result.current.initialLanguage).toBe("ja");
expect(changeLanguageSpy).toHaveBeenCalledWith("ja");
});
it("should prioritize reading language from local storage in readPersistedLanguage", () => { it("should prioritize reading language from local storage in readPersistedLanguage", () => {
useSettingsQueryMock.mockReturnValue({ useSettingsQueryMock.mockReturnValue({
data: null, data: null,
+2 -2
View File
@@ -109,8 +109,8 @@ vi.mock("@/components/ConfirmDialog", () => ({
) : null, ) : null,
})); }));
vi.mock("@/components/settings/SettingsDialog", () => ({ vi.mock("@/components/settings/SettingsPage", () => ({
SettingsDialog: ({ open, onOpenChange, onImportSuccess }: any) => SettingsPage: ({ open, onOpenChange, onImportSuccess }: any) =>
open ? ( open ? (
<div data-testid="settings-dialog"> <div data-testid="settings-dialog">
<button onClick={() => onImportSuccess?.()}> <button onClick={() => onImportSuccess?.()}>
+1 -1
View File
@@ -264,7 +264,7 @@ describe("SettingsPage integration", () => {
await waitFor(() => expect(toastErrorMock).toHaveBeenCalled()); await waitFor(() => expect(toastErrorMock).toHaveBeenCalled());
const cancelMessage = toastErrorMock.mock.calls.at(-1)?.[0] as string; const cancelMessage = toastErrorMock.mock.calls.at(-1)?.[0] as string;
expect(cancelMessage).toMatch( expect(cancelMessage).toMatch(
/settings\.selectFileFailed|选择保存位置失败/, /settings\.selectFileFailed|选择.*保存路径/,
); );
toastErrorMock.mockClear(); toastErrorMock.mockClear();