Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 38bac36960 |
@@ -5,46 +5,6 @@ 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
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# All-in-One Assistant for Claude Code, Codex & Gemini CLI
|
# All-in-One Assistant for Claude Code, Codex & Gemini CLI
|
||||||
|
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://github.com/trending/typescript)
|
[](https://github.com/trending/typescript)
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://tauri.app/)
|
[](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) | [日本語](README_JA.md) | [Changelog](CHANGELOG.md)
|
English | [中文](README_ZH.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,42 +51,9 @@ Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJ
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Current Version: v3.8.0 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.8.0-en.md)
|
### Current Version: v3.7.0 | [Full Changelog](CHANGELOG.md) | [📋 Release Notes](docs/release-note-v3.7.0-en.md)
|
||||||
|
|
||||||
**v3.8.0 Major Update (2025-11-28)**
|
**v3.7.0 Major Update (2025-11-19)**
|
||||||
|
|
||||||
**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**
|
||||||
|
|
||||||
@@ -259,10 +226,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 (v3.8.0 New Architecture)**
|
**CC Switch Storage**
|
||||||
|
|
||||||
- Database (SSOT): `~/.cc-switch/cc-switch.db` (SQLite, stores providers, MCP, Prompts, Skills)
|
- Main config (SSOT): `~/.cc-switch/config.json` (includes providers, MCP, Prompts presets, etc.)
|
||||||
- Local settings: `~/.cc-switch/settings.json` (device-level settings)
|
- Settings: `~/.cc-switch/settings.json`
|
||||||
- Backups: `~/.cc-switch/backups/` (auto-rotate, keep 10)
|
- Backups: `~/.cc-switch/backups/` (auto-rotate, keep 10)
|
||||||
|
|
||||||
### Cloud Sync Setup
|
### Cloud Sync Setup
|
||||||
@@ -298,12 +265,11 @@ 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 data stored in `~/.cc-switch/cc-switch.db` (SQLite)
|
- **SSOT** (Single Source of Truth): All provider configs stored in `~/.cc-switch/config.json`
|
||||||
- **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**: Mutex-protected database connection avoids race conditions
|
- **Concurrency Safe**: RwLock with scoped guards avoids deadlocks
|
||||||
- **Layered Architecture**: Clear separation (Commands → Services → DAO → Database)
|
- **Layered Architecture**: Clear separation (Commands → Services → Models)
|
||||||
|
|
||||||
**Key Components**
|
**Key Components**
|
||||||
|
|
||||||
|
|||||||
@@ -1,476 +0,0 @@
|
|||||||
<div align="center">
|
|
||||||
|
|
||||||
# Claude Code / Codex / Gemini CLI オールインワン・アシスタント
|
|
||||||
|
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
|
||||||
[](https://github.com/trending/typescript)
|
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
|
||||||
[](https://tauri.app/)
|
|
||||||
[](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>
|
|
||||||
|
|
||||||
## ❤️スポンサー
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
本プロジェクトは 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>
|
|
||||||
|
|
||||||
## スクリーンショット
|
|
||||||
|
|
||||||
| メイン画面 | プロバイダ追加 |
|
|
||||||
| :-------------------------------------------: | :----------------------------------------------: |
|
|
||||||
|  |  |
|
|
||||||
|
|
||||||
## 特長
|
|
||||||
|
|
||||||
### 現在のバージョン: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
|
|
||||||
- プラットフォームネイティブ API(Registry/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 を管理
|
|
||||||
- 新たに SSE(Server-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
|
|
||||||
|
|
||||||
[](https://www.star-history.com/#farion1231/cc-switch&Date)
|
|
||||||
|
|
||||||
## ライセンス
|
|
||||||
|
|
||||||
MIT © Jason Young
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Claude Code / Codex / Gemini CLI 全方位辅助工具
|
# Claude Code / Codex / Gemini CLI 全方位辅助工具
|
||||||
|
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://github.com/trending/typescript)
|
[](https://github.com/trending/typescript)
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://tauri.app/)
|
[](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) | 中文 | [日本語](README_JA.md) | [更新日志](CHANGELOG.md) | [v3.8.0 发布说明](docs/release-note-v3.8.0-zh.md)
|
[English](README.md) | 中文 | [更新日志](CHANGELOG.md) | [📋 v3.7.0 发布说明](docs/release-note-v3.7.0-zh.md)
|
||||||
|
|
||||||
**从供应商切换器到 AI CLI 一体化管理平台**
|
**从供应商切换器到 AI CLI 一体化管理平台**
|
||||||
|
|
||||||
@@ -51,42 +51,9 @@ CC Switch 已经预设了智谱GLM,只需要填写 key 即可一键导入编
|
|||||||
|
|
||||||
## 功能特性
|
## 功能特性
|
||||||
|
|
||||||
### 当前版本:v3.8.0 | [完整更新日志](CHANGELOG.md)
|
### 当前版本:v3.7.0 | [完整更新日志](CHANGELOG.md)
|
||||||
|
|
||||||
**v3.8.0 重大更新(2025-11-28)**
|
**v3.7.0 重大更新(2025-11-19)**
|
||||||
|
|
||||||
**持久化架构升级 & 全新用户界面**
|
|
||||||
|
|
||||||
- **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+ 行新增代码**
|
||||||
|
|
||||||
@@ -259,10 +226,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 存储(v3.8.0 新架构)**
|
**CC Switch 存储**
|
||||||
|
|
||||||
- 数据库(SSOT):`~/.cc-switch/cc-switch.db`(SQLite,存储供应商、MCP、Prompts、Skills)
|
- 主配置(SSOT):`~/.cc-switch/config.json`(包含供应商、MCP、Prompts 预设等)
|
||||||
- 本地设置:`~/.cc-switch/settings.json`(设备级设置)
|
- 设置:`~/.cc-switch/settings.json`
|
||||||
- 备份:`~/.cc-switch/backups/`(自动轮换,保留 10 个)
|
- 备份:`~/.cc-switch/backups/`(自动轮换,保留 10 个)
|
||||||
|
|
||||||
### 云同步设置
|
### 云同步设置
|
||||||
@@ -298,12 +265,11 @@ paru -S cc-switch-bin
|
|||||||
|
|
||||||
**核心设计模式**
|
**核心设计模式**
|
||||||
|
|
||||||
- **SSOT**(单一事实源):所有数据存储在 `~/.cc-switch/cc-switch.db`(SQLite)
|
- **SSOT**(单一事实源):所有供应商配置存储在 `~/.cc-switch/config.json`
|
||||||
- **双层存储**:SQLite 存储可同步数据,JSON 存储设备级设置
|
|
||||||
- **双向同步**:切换时写入 live 文件,编辑当前供应商时从 live 回填
|
- **双向同步**:切换时写入 live 文件,编辑当前供应商时从 live 回填
|
||||||
- **原子写入**:临时文件 + 重命名模式防止配置损坏
|
- **原子写入**:临时文件 + 重命名模式防止配置损坏
|
||||||
- **并发安全**:Mutex 保护的数据库连接避免竞态条件
|
- **并发安全**:RwLock 与作用域守卫避免死锁
|
||||||
- **分层架构**:清晰分离(Commands → Services → DAO → Database)
|
- **分层架构**:清晰分离(Commands → Services → Models)
|
||||||
|
|
||||||
**核心组件**
|
**核心组件**
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# CC Switch 国际化功能说明
|
||||||
|
|
||||||
|
## 已完成的工作
|
||||||
|
|
||||||
|
1. **安装依赖**:添加了 `react-i18next` 和 `i18next` 包
|
||||||
|
2. **配置国际化**:在 `src/i18n/` 目录下创建了配置文件
|
||||||
|
3. **翻译文件**:创建了英文和中文翻译文件
|
||||||
|
4. **组件更新**:替换了主要组件中的硬编码文案
|
||||||
|
5. **语言切换器**:添加了语言切换按钮
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── i18n/
|
||||||
|
│ ├── index.ts # 国际化配置文件
|
||||||
|
│ └── locales/
|
||||||
|
│ ├── en.json # 英文翻译
|
||||||
|
│ └── zh.json # 中文翻译
|
||||||
|
├── components/
|
||||||
|
│ └── LanguageSwitcher.tsx # 语言切换组件
|
||||||
|
└── main.tsx # 导入国际化配置
|
||||||
|
```
|
||||||
|
|
||||||
|
## 默认语言设置
|
||||||
|
|
||||||
|
- **默认语言**:英文 (en)
|
||||||
|
- **回退语言**:英文 (en)
|
||||||
|
|
||||||
|
## 使用方式
|
||||||
|
|
||||||
|
1. 在组件中导入 `useTranslation`:
|
||||||
|
```tsx
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return <div>{t('common.save')}</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 切换语言:
|
||||||
|
```tsx
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
i18n.changeLanguage('zh'); // 切换到中文
|
||||||
|
```
|
||||||
|
|
||||||
|
## 翻译键结构
|
||||||
|
|
||||||
|
- `common.*` - 通用文案(保存、取消、设置等)
|
||||||
|
- `header.*` - 头部相关文案
|
||||||
|
- `provider.*` - 供应商相关文案
|
||||||
|
- `notifications.*` - 通知消息
|
||||||
|
- `settings.*` - 设置页面文案
|
||||||
|
- `apps.*` - 应用名称
|
||||||
|
- `console.*` - 控制台日志信息
|
||||||
|
|
||||||
|
## 测试功能
|
||||||
|
|
||||||
|
应用已添加了语言切换按钮(地球图标),点击可以在中英文之间切换,验证国际化功能是否正常工作。
|
||||||
|
|
||||||
|
## 已更新的组件
|
||||||
|
|
||||||
|
- ✅ App.tsx - 主应用组件
|
||||||
|
- ✅ ConfirmDialog.tsx - 确认对话框
|
||||||
|
- ✅ AddProviderModal.tsx - 添加供应商弹窗
|
||||||
|
- ✅ EditProviderModal.tsx - 编辑供应商弹窗
|
||||||
|
- ✅ ProviderList.tsx - 供应商列表
|
||||||
|
- ✅ LanguageSwitcher.tsx - 语言切换器
|
||||||
|
- ✅ settings/SettingsDialog.tsx - 设置对话框
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. 所有新的文案都应该添加到翻译文件中,而不是硬编码
|
||||||
|
2. 翻译键名应该有意义且结构化
|
||||||
|
3. 可以通过修改 `src/i18n/index.ts` 中的 `lng` 配置来更改默认语言
|
||||||
|
Before Width: | Height: | Size: 206 KiB After Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 216 KiB |
|
Before Width: | Height: | Size: 212 KiB After Width: | Height: | Size: 203 KiB |
|
Before Width: | Height: | Size: 227 KiB After Width: | Height: | Size: 227 KiB |
|
Before Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 225 KiB After Width: | Height: | Size: 227 KiB |
@@ -1,369 +0,0 @@
|
|||||||
# 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!**
|
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
# 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!**
|
|
||||||
@@ -1,369 +0,0 @@
|
|||||||
# 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 行
|
|
||||||
|
|
||||||
提交类型分布:
|
|
||||||
- fix:25 个(Bug 修复)
|
|
||||||
- refactor:11 个(代码重构)
|
|
||||||
- feat:9 个(新功能)
|
|
||||||
- test:1 个(测试)
|
|
||||||
- 其他: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.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) - 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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cc-switch",
|
"name": "cc-switch",
|
||||||
"version": "3.8.1",
|
"version": "3.7.1",
|
||||||
"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",
|
||||||
|
|||||||
@@ -618,7 +618,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc-switch"
|
name = "cc-switch"
|
||||||
version = "3.8.1"
|
version = "3.7.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"auto-launch",
|
"auto-launch",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cc-switch"
|
name = "cc-switch"
|
||||||
version = "3.8.1"
|
version = "3.7.1"
|
||||||
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"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use auto_launch::{AutoLaunch, AutoLaunchBuilder};
|
use auto_launch::AutoLaunch;
|
||||||
|
|
||||||
/// 初始化 AutoLaunch 实例
|
/// 初始化 AutoLaunch 实例
|
||||||
fn get_auto_launch() -> Result<AutoLaunch, AppError> {
|
fn get_auto_launch() -> Result<AutoLaunch, AppError> {
|
||||||
@@ -7,15 +7,13 @@ 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}")))?;
|
||||||
|
|
||||||
// 使用 AutoLaunchBuilder 消除平台差异
|
// Windows 平台的 AutoLaunch::new 只接受 3 个参数
|
||||||
// Windows/Linux: new() 接受 3 参数
|
// Linux/macOS 平台需要 4 个参数(包含 hidden 参数)
|
||||||
// macOS: new() 接受 4 参数(含 hidden 参数)
|
#[cfg(target_os = "windows")]
|
||||||
// Builder 模式自动处理这些差异
|
let auto_launch = AutoLaunch::new(app_name, &app_path.to_string_lossy(), &[] as &[&str]);
|
||||||
let auto_launch = AutoLaunchBuilder::new()
|
|
||||||
.set_app_name(app_name)
|
#[cfg(not(target_os = "windows"))]
|
||||||
.set_app_path(&app_path.to_string_lossy())
|
let auto_launch = AutoLaunch::new(app_name, &app_path.to_string_lossy(), false, &[] as &[&str]);
|
||||||
.build()
|
|
||||||
.map_err(|e| AppError::Message(format!("创建 AutoLaunch 失败: {e}")))?;
|
|
||||||
|
|
||||||
Ok(auto_launch)
|
Ok(auto_launch)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<bool, AppError> {
|
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<(), 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<bool, AppError> {
|
) -> Result<(), AppError> {
|
||||||
import_default_config_internal(state, app_type)
|
import_default_config_internal(state, app_type)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +102,9 @@ 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).map_err(Into::into)
|
import_default_config_internal(&state, app_type)
|
||||||
|
.map(|_| true)
|
||||||
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 查询供应商用量
|
/// 查询供应商用量
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ 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
|
||||||
|
|||||||
@@ -58,9 +58,7 @@ 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(
|
.prepare("SELECT owner, name, branch, enabled, skills_path FROM skill_repos ORDER BY owner ASC, name ASC")
|
||||||
"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
|
||||||
@@ -70,6 +68,7 @@ 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()))?;
|
||||||
@@ -85,8 +84,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) VALUES (?1, ?2, ?3, ?4)",
|
"INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled, skills_path) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||||
params![repo.owner, repo.name, repo.branch, repo.enabled],
|
params![repo.owner, repo.name, repo.branch, repo.enabled, repo.skills_path],
|
||||||
).map_err(|e| AppError::Database(e.to_string()))?;
|
).map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,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) VALUES (?1, ?2, ?3, ?4)",
|
"INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled, skills_path) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||||
params![repo.owner, repo.name, repo.branch, repo.enabled],
|
params![repo.owner, repo.name, repo.branch, repo.enabled, repo.skills_path],
|
||||||
).map_err(|e| AppError::Database(format!("Migrate skill repo failed: {e}")))?;
|
).map_err(|e| AppError::Database(format!("Migrate skill repo failed: {e}")))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -115,21 +115,28 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ 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)
|
||||||
)",
|
)",
|
||||||
[],
|
[],
|
||||||
@@ -232,7 +233,7 @@ impl Database {
|
|||||||
"TEXT NOT NULL DEFAULT 'main'",
|
"TEXT NOT NULL DEFAULT 'main'",
|
||||||
)?;
|
)?;
|
||||||
Self::add_column_if_missing(conn, "skill_repos", "enabled", "BOOLEAN NOT NULL DEFAULT 1")?;
|
Self::add_column_if_missing(conn, "skill_repos", "enabled", "BOOLEAN NOT NULL DEFAULT 1")?;
|
||||||
// 注意: skills_path 字段已被移除,因为现在支持全仓库递归扫描
|
Self::add_column_if_missing(conn, "skill_repos", "skills_path", "TEXT")?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,8 +100,12 @@ 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
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ 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,
|
||||||
@@ -203,6 +204,7 @@ 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,
|
||||||
@@ -260,6 +262,7 @@ 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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -284,6 +287,10 @@ 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,
|
||||||
@@ -291,6 +298,7 @@ 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,
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ 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
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ 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,
|
||||||
@@ -188,6 +189,7 @@ 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,
|
||||||
@@ -229,6 +231,7 @@ 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,
|
||||||
@@ -272,6 +275,7 @@ 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,
|
||||||
@@ -368,11 +372,12 @@ 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";
|
let url = "ccswitch://v1/import?resource=skill&repo=owner/repo&directory=skills&branch=dev&skills_path=src";
|
||||||
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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,16 @@ fn write_json_value(path: &Path, value: &Value) -> Result<(), AppError> {
|
|||||||
atomic_write(path, json.as_bytes())
|
atomic_write(path, json.as_bytes())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 读取 Gemini MCP 配置文件的完整 JSON 文本
|
||||||
|
pub fn read_mcp_json() -> Result<Option<String>, AppError> {
|
||||||
|
let path = user_config_path();
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
||||||
|
Ok(Some(content))
|
||||||
|
}
|
||||||
|
|
||||||
/// 读取 Gemini settings.json 中的 mcpServers 映射
|
/// 读取 Gemini settings.json 中的 mcpServers 映射
|
||||||
///
|
///
|
||||||
/// 执行反向格式转换以保持与统一 MCP 结构的兼容性:
|
/// 执行反向格式转换以保持与统一 MCP 结构的兼容性:
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ mod config;
|
|||||||
mod database;
|
mod database;
|
||||||
mod deeplink;
|
mod deeplink;
|
||||||
mod error;
|
mod error;
|
||||||
mod gemini_config;
|
mod gemini_config; // 新增
|
||||||
mod gemini_mcp;
|
mod gemini_mcp;
|
||||||
mod init_status;
|
mod init_status;
|
||||||
mod mcp;
|
mod mcp;
|
||||||
@@ -20,7 +20,6 @@ mod provider_defaults;
|
|||||||
mod services;
|
mod services;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod store;
|
mod store;
|
||||||
mod tray;
|
|
||||||
mod usage_script;
|
mod usage_script;
|
||||||
|
|
||||||
pub use app_config::{AppType, McpApps, McpServer, MultiAppConfig};
|
pub use app_config::{AppType, McpApps, McpServer, MultiAppConfig};
|
||||||
@@ -47,11 +46,257 @@ use tauri_plugin_deep_link::DeepLinkExt;
|
|||||||
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
|
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::tray::{TrayIconBuilder, TrayIconEvent};
|
use tauri::{
|
||||||
|
menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem},
|
||||||
|
tray::{TrayIconBuilder, TrayIconEvent},
|
||||||
|
};
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
use tauri::RunEvent;
|
use tauri::{ActivationPolicy, RunEvent};
|
||||||
use tauri::{Emitter, Manager};
|
use tauri::{Emitter, Manager};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct TrayTexts {
|
||||||
|
show_main: &'static str,
|
||||||
|
no_provider_hint: &'static str,
|
||||||
|
quit: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrayTexts {
|
||||||
|
fn from_language(language: &str) -> Self {
|
||||||
|
match language {
|
||||||
|
"en" => Self {
|
||||||
|
show_main: "Open main window",
|
||||||
|
no_provider_hint: " (No providers yet, please add them from the main window)",
|
||||||
|
quit: "Quit",
|
||||||
|
},
|
||||||
|
_ => Self {
|
||||||
|
show_main: "打开主界面",
|
||||||
|
no_provider_hint: " (无供应商,请在主界面添加)",
|
||||||
|
quit: "退出",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TrayAppSection {
|
||||||
|
app_type: AppType,
|
||||||
|
prefix: &'static str,
|
||||||
|
header_id: &'static str,
|
||||||
|
empty_id: &'static str,
|
||||||
|
header_label: &'static str,
|
||||||
|
log_name: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRAY_SECTIONS: [TrayAppSection; 3] = [
|
||||||
|
TrayAppSection {
|
||||||
|
app_type: AppType::Claude,
|
||||||
|
prefix: "claude_",
|
||||||
|
header_id: "claude_header",
|
||||||
|
empty_id: "claude_empty",
|
||||||
|
header_label: "─── Claude ───",
|
||||||
|
log_name: "Claude",
|
||||||
|
},
|
||||||
|
TrayAppSection {
|
||||||
|
app_type: AppType::Codex,
|
||||||
|
prefix: "codex_",
|
||||||
|
header_id: "codex_header",
|
||||||
|
empty_id: "codex_empty",
|
||||||
|
header_label: "─── Codex ───",
|
||||||
|
log_name: "Codex",
|
||||||
|
},
|
||||||
|
TrayAppSection {
|
||||||
|
app_type: AppType::Gemini,
|
||||||
|
prefix: "gemini_",
|
||||||
|
header_id: "gemini_header",
|
||||||
|
empty_id: "gemini_empty",
|
||||||
|
header_label: "─── Gemini ───",
|
||||||
|
log_name: "Gemini",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
fn append_provider_section<'a>(
|
||||||
|
app: &'a tauri::AppHandle,
|
||||||
|
mut menu_builder: MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>,
|
||||||
|
manager: Option<&crate::provider::ProviderManager>,
|
||||||
|
section: &TrayAppSection,
|
||||||
|
tray_texts: &TrayTexts,
|
||||||
|
) -> Result<MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>, AppError> {
|
||||||
|
let Some(manager) = manager else {
|
||||||
|
return Ok(menu_builder);
|
||||||
|
};
|
||||||
|
|
||||||
|
let header = MenuItem::with_id(
|
||||||
|
app,
|
||||||
|
section.header_id,
|
||||||
|
section.header_label,
|
||||||
|
false,
|
||||||
|
None::<&str>,
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Message(format!("创建{}标题失败: {e}", section.log_name)))?;
|
||||||
|
menu_builder = menu_builder.item(&header);
|
||||||
|
|
||||||
|
if manager.providers.is_empty() {
|
||||||
|
let empty_hint = MenuItem::with_id(
|
||||||
|
app,
|
||||||
|
section.empty_id,
|
||||||
|
tray_texts.no_provider_hint,
|
||||||
|
false,
|
||||||
|
None::<&str>,
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Message(format!("创建{}空提示失败: {e}", section.log_name)))?;
|
||||||
|
return Ok(menu_builder.item(&empty_hint));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut sorted_providers: Vec<_> = manager.providers.iter().collect();
|
||||||
|
sorted_providers.sort_by(|(_, a), (_, b)| {
|
||||||
|
match (a.sort_index, b.sort_index) {
|
||||||
|
(Some(idx_a), Some(idx_b)) => return idx_a.cmp(&idx_b),
|
||||||
|
(Some(_), None) => return std::cmp::Ordering::Less,
|
||||||
|
(None, Some(_)) => return std::cmp::Ordering::Greater,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
match (a.created_at, b.created_at) {
|
||||||
|
(Some(time_a), Some(time_b)) => return time_a.cmp(&time_b),
|
||||||
|
(Some(_), None) => return std::cmp::Ordering::Greater,
|
||||||
|
(None, Some(_)) => return std::cmp::Ordering::Less,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.name.cmp(&b.name)
|
||||||
|
});
|
||||||
|
|
||||||
|
for (id, provider) in sorted_providers {
|
||||||
|
let is_current = manager.current == *id;
|
||||||
|
let item = CheckMenuItem::with_id(
|
||||||
|
app,
|
||||||
|
format!("{}{}", section.prefix, id),
|
||||||
|
&provider.name,
|
||||||
|
true,
|
||||||
|
is_current,
|
||||||
|
None::<&str>,
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Message(format!("创建{}菜单项失败: {e}", section.log_name)))?;
|
||||||
|
menu_builder = menu_builder.item(&item);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(menu_builder)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_provider_tray_event(app: &tauri::AppHandle, event_id: &str) -> bool {
|
||||||
|
for section in TRAY_SECTIONS.iter() {
|
||||||
|
if let Some(provider_id) = event_id.strip_prefix(section.prefix) {
|
||||||
|
log::info!("切换到{}供应商: {provider_id}", section.log_name);
|
||||||
|
let app_handle = app.clone();
|
||||||
|
let provider_id = provider_id.to_string();
|
||||||
|
let app_type = section.app_type.clone();
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
if let Err(e) = switch_provider_internal(&app_handle, app_type, provider_id) {
|
||||||
|
log::error!("切换{}供应商失败: {e}", section.log_name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建动态托盘菜单
|
||||||
|
fn create_tray_menu(
|
||||||
|
app: &tauri::AppHandle,
|
||||||
|
app_state: &AppState,
|
||||||
|
) -> Result<Menu<tauri::Wry>, AppError> {
|
||||||
|
let app_settings = crate::settings::get_settings();
|
||||||
|
let tray_texts = TrayTexts::from_language(app_settings.language.as_deref().unwrap_or("zh"));
|
||||||
|
|
||||||
|
let mut menu_builder = MenuBuilder::new(app);
|
||||||
|
|
||||||
|
// 顶部:打开主界面
|
||||||
|
let show_main_item =
|
||||||
|
MenuItem::with_id(app, "show_main", tray_texts.show_main, true, None::<&str>)
|
||||||
|
.map_err(|e| AppError::Message(format!("创建打开主界面菜单失败: {e}")))?;
|
||||||
|
menu_builder = menu_builder.item(&show_main_item).separator();
|
||||||
|
|
||||||
|
// 直接添加所有供应商到主菜单(扁平化结构,更简单可靠)
|
||||||
|
for section in TRAY_SECTIONS.iter() {
|
||||||
|
let app_type_str = section.app_type.as_str();
|
||||||
|
let providers = app_state.db.get_all_providers(app_type_str)?;
|
||||||
|
|
||||||
|
// 使用有效的当前供应商 ID(验证存在性,自动清理失效 ID)
|
||||||
|
let current_id =
|
||||||
|
crate::settings::get_effective_current_provider(&app_state.db, §ion.app_type)?
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let manager = crate::provider::ProviderManager {
|
||||||
|
providers,
|
||||||
|
current: current_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
menu_builder =
|
||||||
|
append_provider_section(app, menu_builder, Some(&manager), section, &tray_texts)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分隔符和退出菜单
|
||||||
|
let quit_item = MenuItem::with_id(app, "quit", tray_texts.quit, true, None::<&str>)
|
||||||
|
.map_err(|e| AppError::Message(format!("创建退出菜单失败: {e}")))?;
|
||||||
|
|
||||||
|
menu_builder = menu_builder.separator().item(&quit_item);
|
||||||
|
|
||||||
|
menu_builder
|
||||||
|
.build()
|
||||||
|
.map_err(|e| AppError::Message(format!("构建菜单失败: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn apply_tray_policy(app: &tauri::AppHandle, dock_visible: bool) {
|
||||||
|
let desired_policy = if dock_visible {
|
||||||
|
ActivationPolicy::Regular
|
||||||
|
} else {
|
||||||
|
ActivationPolicy::Accessory
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = app.set_dock_visibility(dock_visible) {
|
||||||
|
log::warn!("设置 Dock 显示状态失败: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(err) = app.set_activation_policy(desired_policy) {
|
||||||
|
log::warn!("设置激活策略失败: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 处理托盘菜单事件
|
||||||
|
fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
|
||||||
|
log::info!("处理托盘菜单事件: {event_id}");
|
||||||
|
|
||||||
|
match event_id {
|
||||||
|
"show_main" => {
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
let _ = window.set_skip_taskbar(false);
|
||||||
|
}
|
||||||
|
let _ = window.unminimize();
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
apply_tray_policy(app, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"quit" => {
|
||||||
|
log::info!("退出应用");
|
||||||
|
app.exit(0);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
if handle_provider_tray_event(app, event_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log::warn!("未处理的菜单事件: {event_id}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 统一处理 ccswitch:// 深链接 URL
|
/// 统一处理 ccswitch:// 深链接 URL
|
||||||
///
|
///
|
||||||
/// - 解析 URL
|
/// - 解析 URL
|
||||||
@@ -111,13 +356,50 @@ fn handle_deeplink_url(
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
/// 内部切换供应商函数
|
||||||
|
fn switch_provider_internal(
|
||||||
|
app: &tauri::AppHandle,
|
||||||
|
app_type: crate::app_config::AppType,
|
||||||
|
provider_id: String,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
if let Some(app_state) = app.try_state::<AppState>() {
|
||||||
|
// 在使用前先保存需要的值
|
||||||
|
let app_type_str = app_type.as_str().to_string();
|
||||||
|
let provider_id_clone = provider_id.clone();
|
||||||
|
|
||||||
|
crate::commands::switch_provider(app_state.clone(), app_type_str.clone(), provider_id)
|
||||||
|
.map_err(AppError::Message)?;
|
||||||
|
|
||||||
|
// 切换成功后重新创建托盘菜单
|
||||||
|
if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) {
|
||||||
|
if let Some(tray) = app.tray_by_id("main") {
|
||||||
|
if let Err(e) = tray.set_menu(Some(new_menu)) {
|
||||||
|
log::error!("更新托盘菜单失败: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发射事件到前端,通知供应商已切换
|
||||||
|
let event_data = serde_json::json!({
|
||||||
|
"appType": app_type_str,
|
||||||
|
"providerId": provider_id_clone
|
||||||
|
});
|
||||||
|
if let Err(e) = app.emit("provider-switched", event_data) {
|
||||||
|
log::error!("发射供应商切换事件失败: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// 更新托盘菜单的Tauri命令
|
/// 更新托盘菜单的Tauri命令
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn update_tray_menu(
|
async fn update_tray_menu(
|
||||||
app: tauri::AppHandle,
|
app: tauri::AppHandle,
|
||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
match tray::create_tray_menu(&app, state.inner()) {
|
match create_tray_menu(&app, state.inner()) {
|
||||||
Ok(new_menu) => {
|
Ok(new_menu) => {
|
||||||
if let Some(tray) = app.tray_by_id("main") {
|
if let Some(tray) = app.tray_by_id("main") {
|
||||||
tray.set_menu(Some(new_menu))
|
tray.set_menu(Some(new_menu))
|
||||||
@@ -185,7 +467,7 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
tray::apply_tray_policy(window.app_handle(), false);
|
apply_tray_policy(window.app_handle(), false);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
window.app_handle().exit(0);
|
window.app_handle().exit(0);
|
||||||
@@ -234,8 +516,7 @@ pub fn run() {
|
|||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
use objc2::msg_send;
|
use objc2::msg_send;
|
||||||
let _: () =
|
let _: () = msg_send![&*ns_window, setBackgroundColor: &*bg_color];
|
||||||
msg_send![&*ns_window, setBackgroundColor: &*bg_color];
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log::warn!("Failed to retain NSWindow reference");
|
log::warn!("Failed to retain NSWindow reference");
|
||||||
@@ -331,47 +612,54 @@ 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
|
||||||
|
});
|
||||||
|
|
||||||
// 1. 初始化默认 Skills 仓库(已有内置检查:表非空则跳过)
|
if need_first_import {
|
||||||
match app_state.db.init_default_skill_repos() {
|
// 数据库为空,尝试从用户现有的配置文件导入数据并初始化默认配置
|
||||||
Ok(count) if count > 0 => {
|
log::info!(
|
||||||
log::info!("✓ Initialized {count} default skill repositories");
|
"Empty database detected, importing existing configurations and initializing defaults..."
|
||||||
}
|
);
|
||||||
Ok(_) => {} // 表非空,静默跳过
|
|
||||||
Err(e) => log::warn!("✗ Failed to initialize default skill repos: {e}"),
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 导入供应商配置(已有内置检查:该应用已有供应商则跳过)
|
// 1. 初始化默认 Skills 仓库(3个)
|
||||||
for app in [
|
match app_state.db.init_default_skill_repos() {
|
||||||
crate::app_config::AppType::Claude,
|
Ok(count) if count > 0 => {
|
||||||
crate::app_config::AppType::Codex,
|
log::info!("✓ Initialized {count} default skill repositories");
|
||||||
crate::app_config::AppType::Gemini,
|
|
||||||
] {
|
|
||||||
match crate::services::provider::ProviderService::import_default_config(
|
|
||||||
&app_state,
|
|
||||||
app.clone(),
|
|
||||||
) {
|
|
||||||
Ok(true) => {
|
|
||||||
log::info!("✓ Imported default provider for {}", app.as_str());
|
|
||||||
}
|
}
|
||||||
Ok(false) => {} // 已有供应商,静默跳过
|
Ok(_) => log::debug!("No default skill repositories to initialize"),
|
||||||
Err(e) => {
|
Err(e) => log::warn!("✗ Failed to initialize default skill repos: {e}"),
|
||||||
log::debug!(
|
}
|
||||||
"○ No default provider to import for {}: {}",
|
|
||||||
app.as_str(),
|
// 2. 导入供应商配置(从 live 配置文件)
|
||||||
e
|
for app in [
|
||||||
);
|
crate::app_config::AppType::Claude,
|
||||||
|
crate::app_config::AppType::Codex,
|
||||||
|
crate::app_config::AppType::Gemini,
|
||||||
|
] {
|
||||||
|
match crate::services::provider::ProviderService::import_default_config(
|
||||||
|
&app_state,
|
||||||
|
app.clone(),
|
||||||
|
) {
|
||||||
|
Ok(_) => {
|
||||||
|
log::info!("✓ Imported default provider for {}", app.as_str());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!(
|
||||||
|
"○ No default provider to import for {}: {}",
|
||||||
|
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");
|
||||||
@@ -395,28 +683,42 @@ 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. 导入提示词文件
|
||||||
if app_state.db.is_prompts_table_empty().unwrap_or(false) {
|
match crate::services::prompt::PromptService::import_from_file_on_first_launch(
|
||||||
log::info!("Prompts table empty, importing from live configurations...");
|
&app_state,
|
||||||
|
|
||||||
for app in [
|
|
||||||
crate::app_config::AppType::Claude,
|
crate::app_config::AppType::Claude,
|
||||||
crate::app_config::AppType::Codex,
|
) {
|
||||||
crate::app_config::AppType::Gemini,
|
Ok(count) if count > 0 => {
|
||||||
] {
|
log::info!("✓ Imported {count} prompt(s) from Claude");
|
||||||
match crate::services::prompt::PromptService::import_from_file_on_first_launch(
|
|
||||||
&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 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,
|
||||||
|
) {
|
||||||
|
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,
|
||||||
|
) {
|
||||||
|
Ok(count) if count > 0 => {
|
||||||
|
log::info!("✓ Imported {count} prompt(s) from Gemini");
|
||||||
|
}
|
||||||
|
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
|
||||||
@@ -485,7 +787,7 @@ pub fn run() {
|
|||||||
log::info!("✓ Deep-link URL handler registered");
|
log::info!("✓ Deep-link URL handler registered");
|
||||||
|
|
||||||
// 创建动态托盘菜单
|
// 创建动态托盘菜单
|
||||||
let menu = tray::create_tray_menu(app.handle(), &app_state)?;
|
let menu = create_tray_menu(app.handle(), &app_state)?;
|
||||||
|
|
||||||
// 构建托盘
|
// 构建托盘
|
||||||
let mut tray_builder = TrayIconBuilder::with_id("main")
|
let mut tray_builder = TrayIconBuilder::with_id("main")
|
||||||
@@ -496,7 +798,7 @@ pub fn run() {
|
|||||||
})
|
})
|
||||||
.menu(&menu)
|
.menu(&menu)
|
||||||
.on_menu_event(|app, event| {
|
.on_menu_event(|app, event| {
|
||||||
tray::handle_tray_menu_event(app, &event.id.0);
|
handle_tray_menu_event(app, &event.id.0);
|
||||||
})
|
})
|
||||||
.show_menu_on_left_click(true);
|
.show_menu_on_left_click(true);
|
||||||
|
|
||||||
@@ -639,7 +941,7 @@ pub fn run() {
|
|||||||
let _ = window.unminimize();
|
let _ = window.unminimize();
|
||||||
let _ = window.show();
|
let _ = window.show();
|
||||||
let _ = window.set_focus();
|
let _ = window.set_focus();
|
||||||
tray::apply_tray_policy(app_handle, true);
|
apply_tray_policy(app_handle, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 处理通过自定义 URL 协议触发的打开事件(例如 ccswitch://...)
|
// 处理通过自定义 URL 协议触发的打开事件(例如 ccswitch://...)
|
||||||
|
|||||||
@@ -1,17 +1,201 @@
|
|||||||
//! Codex MCP 同步和导入模块
|
|
||||||
//!
|
|
||||||
//! 包含 Codex 的 MCP 配置管理:
|
|
||||||
//! - 从 ~/.codex/config.toml 导入
|
|
||||||
//! - 同步到 ~/.codex/config.toml
|
|
||||||
//! - JSON 到 TOML 的转换逻辑
|
|
||||||
|
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::app_config::{McpApps, McpConfig, McpServer, MultiAppConfig};
|
use crate::app_config::{AppType, McpConfig, MultiAppConfig};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
|
||||||
use super::validation::{extract_server_spec, validate_server_spec};
|
/// 基础校验:允许 stdio/http/sse;或省略 type(视为 stdio)。对应必填字段存在
|
||||||
|
fn validate_server_spec(spec: &Value) -> Result<(), AppError> {
|
||||||
|
if !spec.is_object() {
|
||||||
|
return Err(AppError::McpValidation(
|
||||||
|
"MCP 服务器连接定义必须为 JSON 对象".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let t_opt = spec.get("type").and_then(|x| x.as_str());
|
||||||
|
// 支持三种:stdio/http/sse;若缺省 type 则按 stdio 处理(与社区常见 .mcp.json 一致)
|
||||||
|
let is_stdio = t_opt.map(|t| t == "stdio").unwrap_or(true);
|
||||||
|
let is_http = t_opt.map(|t| t == "http").unwrap_or(false);
|
||||||
|
let is_sse = t_opt.map(|t| t == "sse").unwrap_or(false);
|
||||||
|
|
||||||
|
if !(is_stdio || is_http || is_sse) {
|
||||||
|
return Err(AppError::McpValidation(
|
||||||
|
"MCP 服务器 type 必须是 'stdio'、'http' 或 'sse'(或省略表示 stdio)".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_stdio {
|
||||||
|
let cmd = spec.get("command").and_then(|x| x.as_str()).unwrap_or("");
|
||||||
|
if cmd.trim().is_empty() {
|
||||||
|
return Err(AppError::McpValidation(
|
||||||
|
"stdio 类型的 MCP 服务器缺少 command 字段".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if is_http {
|
||||||
|
let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
|
||||||
|
if url.trim().is_empty() {
|
||||||
|
return Err(AppError::McpValidation(
|
||||||
|
"http 类型的 MCP 服务器缺少 url 字段".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if is_sse {
|
||||||
|
let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
|
||||||
|
if url.trim().is_empty() {
|
||||||
|
return Err(AppError::McpValidation(
|
||||||
|
"sse 类型的 MCP 服务器缺少 url 字段".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)] // v3.7.0: 旧的验证逻辑,保留用于未来可能的迁移
|
||||||
|
fn validate_mcp_entry(entry: &Value) -> Result<(), AppError> {
|
||||||
|
let obj = entry
|
||||||
|
.as_object()
|
||||||
|
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目必须为 JSON 对象".into()))?;
|
||||||
|
|
||||||
|
let server = obj
|
||||||
|
.get("server")
|
||||||
|
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目缺少 server 字段".into()))?;
|
||||||
|
validate_server_spec(server)?;
|
||||||
|
|
||||||
|
for key in ["name", "description", "homepage", "docs"] {
|
||||||
|
if let Some(val) = obj.get(key) {
|
||||||
|
if !val.is_string() {
|
||||||
|
return Err(AppError::McpValidation(format!(
|
||||||
|
"MCP 服务器 {key} 必须为字符串"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(tags) = obj.get("tags") {
|
||||||
|
let arr = tags
|
||||||
|
.as_array()
|
||||||
|
.ok_or_else(|| AppError::McpValidation("MCP 服务器 tags 必须为字符串数组".into()))?;
|
||||||
|
if !arr.iter().all(|item| item.is_string()) {
|
||||||
|
return Err(AppError::McpValidation(
|
||||||
|
"MCP 服务器 tags 必须为字符串数组".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(enabled) = obj.get("enabled") {
|
||||||
|
if !enabled.is_boolean() {
|
||||||
|
return Err(AppError::McpValidation(
|
||||||
|
"MCP 服务器 enabled 必须为布尔值".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_server_keys(map: &mut HashMap<String, Value>) -> usize {
|
||||||
|
let mut change_count = 0usize;
|
||||||
|
let mut renames: Vec<(String, String)> = Vec::new();
|
||||||
|
|
||||||
|
for (key_ref, value) in map.iter_mut() {
|
||||||
|
let key = key_ref.clone();
|
||||||
|
let Some(obj) = value.as_object_mut() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let id_value = obj.get("id").cloned();
|
||||||
|
|
||||||
|
let target_id: String;
|
||||||
|
|
||||||
|
match id_value {
|
||||||
|
Some(id_val) => match id_val.as_str() {
|
||||||
|
Some(id_str) => {
|
||||||
|
let trimmed = id_str.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
obj.insert("id".into(), json!(key.clone()));
|
||||||
|
change_count += 1;
|
||||||
|
target_id = key.clone();
|
||||||
|
} else {
|
||||||
|
if trimmed != id_str {
|
||||||
|
obj.insert("id".into(), json!(trimmed));
|
||||||
|
change_count += 1;
|
||||||
|
}
|
||||||
|
target_id = trimmed.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
obj.insert("id".into(), json!(key.clone()));
|
||||||
|
change_count += 1;
|
||||||
|
target_id = key.clone();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
obj.insert("id".into(), json!(key.clone()));
|
||||||
|
change_count += 1;
|
||||||
|
target_id = key.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if target_id != key {
|
||||||
|
renames.push((key, target_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (old_key, new_key) in renames {
|
||||||
|
if old_key == new_key {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if map.contains_key(&new_key) {
|
||||||
|
log::warn!("MCP 条目 '{old_key}' 的内部 id '{new_key}' 与现有键冲突,回退为原键");
|
||||||
|
if let Some(value) = map.get_mut(&old_key) {
|
||||||
|
if let Some(obj) = value.as_object_mut() {
|
||||||
|
if obj
|
||||||
|
.get("id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s != old_key)
|
||||||
|
.unwrap_or(true)
|
||||||
|
{
|
||||||
|
obj.insert("id".into(), json!(old_key.clone()));
|
||||||
|
change_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(mut value) = map.remove(&old_key) {
|
||||||
|
if let Some(obj) = value.as_object_mut() {
|
||||||
|
obj.insert("id".into(), json!(new_key.clone()));
|
||||||
|
}
|
||||||
|
log::info!("MCP 条目键名已自动修复: '{old_key}' -> '{new_key}'");
|
||||||
|
map.insert(new_key, value);
|
||||||
|
change_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
change_count
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normalize_servers_for(config: &mut MultiAppConfig, app: &AppType) -> usize {
|
||||||
|
let servers = &mut config.mcp_for_mut(app).servers;
|
||||||
|
normalize_server_keys(servers)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_server_spec(entry: &Value) -> Result<Value, AppError> {
|
||||||
|
let obj = entry
|
||||||
|
.as_object()
|
||||||
|
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目必须为 JSON 对象".into()))?;
|
||||||
|
let server = obj
|
||||||
|
.get("server")
|
||||||
|
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目缺少 server 字段".into()))?;
|
||||||
|
|
||||||
|
if !server.is_object() {
|
||||||
|
return Err(AppError::McpValidation(
|
||||||
|
"MCP 服务器 server 字段必须为 JSON 对象".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(server.clone())
|
||||||
|
}
|
||||||
|
|
||||||
/// 返回已启用的 MCP 服务器(过滤 enabled==true)
|
/// 返回已启用的 MCP 服务器(过滤 enabled==true)
|
||||||
fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
|
fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
|
||||||
@@ -36,6 +220,185 @@ fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)] // v3.7.0: 旧的分应用 API,保留用于未来可能的迁移
|
||||||
|
pub fn get_servers_snapshot_for(
|
||||||
|
config: &mut MultiAppConfig,
|
||||||
|
app: &AppType,
|
||||||
|
) -> (HashMap<String, Value>, usize) {
|
||||||
|
let normalized = normalize_servers_for(config, app);
|
||||||
|
let mut snapshot = config.mcp_for(app).servers.clone();
|
||||||
|
snapshot.retain(|id, value| {
|
||||||
|
let Some(obj) = value.as_object_mut() else {
|
||||||
|
log::warn!("跳过无效的 MCP 条目 '{id}': 必须为 JSON 对象");
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
obj.entry(String::from("id")).or_insert(json!(id));
|
||||||
|
|
||||||
|
match validate_mcp_entry(value) {
|
||||||
|
Ok(()) => true,
|
||||||
|
Err(err) => {
|
||||||
|
log::error!("config.json 中存在无效的 MCP 条目 '{id}': {err}");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
(snapshot, normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)] // v3.7.0: 旧的分应用 API,保留用于未来可能的迁移
|
||||||
|
pub fn upsert_in_config_for(
|
||||||
|
config: &mut MultiAppConfig,
|
||||||
|
app: &AppType,
|
||||||
|
id: &str,
|
||||||
|
spec: Value,
|
||||||
|
) -> Result<bool, AppError> {
|
||||||
|
if id.trim().is_empty() {
|
||||||
|
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
|
||||||
|
}
|
||||||
|
normalize_servers_for(config, app);
|
||||||
|
validate_mcp_entry(&spec)?;
|
||||||
|
|
||||||
|
let mut entry_obj = spec
|
||||||
|
.as_object()
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目必须为 JSON 对象".into()))?;
|
||||||
|
if let Some(existing_id) = entry_obj.get("id") {
|
||||||
|
let Some(existing_id_str) = existing_id.as_str() else {
|
||||||
|
return Err(AppError::McpValidation("MCP 服务器 id 必须为字符串".into()));
|
||||||
|
};
|
||||||
|
if existing_id_str != id {
|
||||||
|
return Err(AppError::McpValidation(format!(
|
||||||
|
"MCP 服务器条目中的 id '{existing_id_str}' 与参数 id '{id}' 不一致"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
entry_obj.insert(String::from("id"), json!(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
let value = Value::Object(entry_obj);
|
||||||
|
|
||||||
|
let servers = &mut config.mcp_for_mut(app).servers;
|
||||||
|
let before = servers.get(id).cloned();
|
||||||
|
servers.insert(id.to_string(), value);
|
||||||
|
|
||||||
|
Ok(before.is_none())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)] // v3.7.0: 旧的分应用 API,保留用于未来可能的迁移
|
||||||
|
pub fn delete_in_config_for(
|
||||||
|
config: &mut MultiAppConfig,
|
||||||
|
app: &AppType,
|
||||||
|
id: &str,
|
||||||
|
) -> Result<bool, AppError> {
|
||||||
|
if id.trim().is_empty() {
|
||||||
|
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
|
||||||
|
}
|
||||||
|
normalize_servers_for(config, app);
|
||||||
|
let existed = config.mcp_for_mut(app).servers.remove(id).is_some();
|
||||||
|
Ok(existed)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)] // v3.7.0: 旧的分应用 API,保留用于未来可能的迁移
|
||||||
|
/// 设置启用状态(不执行落盘或文件同步)
|
||||||
|
pub fn set_enabled_flag_for(
|
||||||
|
config: &mut MultiAppConfig,
|
||||||
|
app: &AppType,
|
||||||
|
id: &str,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<bool, AppError> {
|
||||||
|
if id.trim().is_empty() {
|
||||||
|
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
|
||||||
|
}
|
||||||
|
normalize_servers_for(config, app);
|
||||||
|
if let Some(spec) = config.mcp_for_mut(app).servers.get_mut(id) {
|
||||||
|
// 写入 enabled 字段
|
||||||
|
let mut obj = spec
|
||||||
|
.as_object()
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| AppError::McpValidation("MCP 服务器定义必须为 JSON 对象".into()))?;
|
||||||
|
obj.insert("enabled".into(), json!(enabled));
|
||||||
|
*spec = Value::Object(obj);
|
||||||
|
} else {
|
||||||
|
// 若不存在则直接返回 false
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将 config.json 中 enabled==true 的项投影写入 ~/.claude.json
|
||||||
|
pub fn sync_enabled_to_claude(config: &MultiAppConfig) -> Result<(), AppError> {
|
||||||
|
let enabled = collect_enabled_servers(&config.mcp.claude);
|
||||||
|
crate::claude_mcp::set_mcp_servers_map(&enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 ~/.claude.json 导入 mcpServers 到统一结构(v3.7.0+)
|
||||||
|
/// 已存在的服务器将启用 Claude 应用,不覆盖其他字段和应用状态
|
||||||
|
pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError> {
|
||||||
|
use crate::app_config::{McpApps, McpServer};
|
||||||
|
|
||||||
|
let text_opt = crate::claude_mcp::read_mcp_json()?;
|
||||||
|
let Some(text) = text_opt else { return Ok(0) };
|
||||||
|
|
||||||
|
let v: Value = serde_json::from_str(&text)
|
||||||
|
.map_err(|e| AppError::McpValidation(format!("解析 ~/.claude.json 失败: {e}")))?;
|
||||||
|
let Some(map) = v.get("mcpServers").and_then(|x| x.as_object()) else {
|
||||||
|
return Ok(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 确保新结构存在
|
||||||
|
let servers = config.mcp.servers.get_or_insert_with(HashMap::new);
|
||||||
|
|
||||||
|
let mut changed = 0;
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
|
||||||
|
for (id, spec) in map.iter() {
|
||||||
|
// 校验:单项失败不中止,收集错误继续处理
|
||||||
|
if let Err(e) = validate_server_spec(spec) {
|
||||||
|
log::warn!("跳过无效 MCP 服务器 '{id}': {e}");
|
||||||
|
errors.push(format!("{id}: {e}"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(existing) = servers.get_mut(id) {
|
||||||
|
// 已存在:仅启用 Claude 应用
|
||||||
|
if !existing.apps.claude {
|
||||||
|
existing.apps.claude = true;
|
||||||
|
changed += 1;
|
||||||
|
log::info!("MCP 服务器 '{id}' 已启用 Claude 应用");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 新建服务器:默认仅启用 Claude
|
||||||
|
servers.insert(
|
||||||
|
id.clone(),
|
||||||
|
McpServer {
|
||||||
|
id: id.clone(),
|
||||||
|
name: id.clone(),
|
||||||
|
server: spec.clone(),
|
||||||
|
apps: McpApps {
|
||||||
|
claude: true,
|
||||||
|
codex: false,
|
||||||
|
gemini: false,
|
||||||
|
},
|
||||||
|
description: None,
|
||||||
|
homepage: None,
|
||||||
|
docs: None,
|
||||||
|
tags: Vec::new(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
changed += 1;
|
||||||
|
log::info!("导入新 MCP 服务器 '{id}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.is_empty() {
|
||||||
|
log::warn!("导入完成,但有 {} 项失败: {:?}", errors.len(), errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(changed)
|
||||||
|
}
|
||||||
|
|
||||||
/// 从 ~/.codex/config.toml 导入 MCP 到统一结构(v3.7.0+)
|
/// 从 ~/.codex/config.toml 导入 MCP 到统一结构(v3.7.0+)
|
||||||
///
|
///
|
||||||
/// 格式支持:
|
/// 格式支持:
|
||||||
@@ -44,6 +407,8 @@ fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
|
|||||||
///
|
///
|
||||||
/// 已存在的服务器将启用 Codex 应用,不覆盖其他字段和应用状态
|
/// 已存在的服务器将启用 Codex 应用,不覆盖其他字段和应用状态
|
||||||
pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError> {
|
pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError> {
|
||||||
|
use crate::app_config::{McpApps, McpServer};
|
||||||
|
|
||||||
let text = crate::codex_config::read_and_validate_codex_config_text()?;
|
let text = crate::codex_config::read_and_validate_codex_config_text()?;
|
||||||
if text.trim().is_empty() {
|
if text.trim().is_empty() {
|
||||||
return Ok(0);
|
return Ok(0);
|
||||||
@@ -332,95 +697,111 @@ pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), AppError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 将单个 MCP 服务器同步到 Codex live 配置
|
/// 将 config.json 中 enabled==true 的项投影写入 ~/.gemini/settings.json
|
||||||
/// 始终使用 Codex 官方格式 [mcp_servers],并清理可能存在的错误格式 [mcp.servers]
|
pub fn sync_enabled_to_gemini(config: &MultiAppConfig) -> Result<(), AppError> {
|
||||||
pub fn sync_single_server_to_codex(
|
let enabled = collect_enabled_servers(&config.mcp.gemini);
|
||||||
|
crate::gemini_mcp::set_mcp_servers_map(&enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 ~/.gemini/settings.json 导入 mcpServers 到统一结构(v3.7.0+)
|
||||||
|
/// 已存在的服务器将启用 Gemini 应用,不覆盖其他字段和应用状态
|
||||||
|
pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result<usize, AppError> {
|
||||||
|
use crate::app_config::{McpApps, McpServer};
|
||||||
|
|
||||||
|
let text_opt = crate::gemini_mcp::read_mcp_json()?;
|
||||||
|
let Some(text) = text_opt else { return Ok(0) };
|
||||||
|
|
||||||
|
let v: Value = serde_json::from_str(&text)
|
||||||
|
.map_err(|e| AppError::McpValidation(format!("解析 ~/.gemini/settings.json 失败: {e}")))?;
|
||||||
|
let Some(map) = v.get("mcpServers").and_then(|x| x.as_object()) else {
|
||||||
|
return Ok(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 确保新结构存在
|
||||||
|
let servers = config.mcp.servers.get_or_insert_with(HashMap::new);
|
||||||
|
|
||||||
|
let mut changed = 0;
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
|
||||||
|
for (id, spec) in map.iter() {
|
||||||
|
// 校验:单项失败不中止,收集错误继续处理
|
||||||
|
if let Err(e) = validate_server_spec(spec) {
|
||||||
|
log::warn!("跳过无效 MCP 服务器 '{id}': {e}");
|
||||||
|
errors.push(format!("{id}: {e}"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(existing) = servers.get_mut(id) {
|
||||||
|
// 已存在:仅启用 Gemini 应用
|
||||||
|
if !existing.apps.gemini {
|
||||||
|
existing.apps.gemini = true;
|
||||||
|
changed += 1;
|
||||||
|
log::info!("MCP 服务器 '{id}' 已启用 Gemini 应用");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 新建服务器:默认仅启用 Gemini
|
||||||
|
servers.insert(
|
||||||
|
id.clone(),
|
||||||
|
McpServer {
|
||||||
|
id: id.clone(),
|
||||||
|
name: id.clone(),
|
||||||
|
server: spec.clone(),
|
||||||
|
apps: McpApps {
|
||||||
|
claude: false,
|
||||||
|
codex: false,
|
||||||
|
gemini: true,
|
||||||
|
},
|
||||||
|
description: None,
|
||||||
|
homepage: None,
|
||||||
|
docs: None,
|
||||||
|
tags: Vec::new(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
changed += 1;
|
||||||
|
log::info!("导入新 MCP 服务器 '{id}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.is_empty() {
|
||||||
|
log::warn!("导入完成,但有 {} 项失败: {:?}", errors.len(), errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(changed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// v3.7.0 新增:单个服务器同步和删除函数
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// 将单个 MCP 服务器同步到 Claude live 配置
|
||||||
|
pub fn sync_single_server_to_claude(
|
||||||
_config: &MultiAppConfig,
|
_config: &MultiAppConfig,
|
||||||
id: &str,
|
id: &str,
|
||||||
server_spec: &Value,
|
server_spec: &Value,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
use toml_edit::Item;
|
// 读取现有的 MCP 配置
|
||||||
|
let current = crate::claude_mcp::read_mcp_servers_map()?;
|
||||||
|
|
||||||
// 读取现有的 config.toml
|
// 创建新的 HashMap,包含现有的所有服务器 + 当前要同步的服务器
|
||||||
let config_path = crate::codex_config::get_codex_config_path();
|
let mut updated = current;
|
||||||
|
updated.insert(id.to_string(), server_spec.clone());
|
||||||
|
|
||||||
let mut doc = if config_path.exists() {
|
// 写回
|
||||||
let content =
|
crate::claude_mcp::set_mcp_servers_map(&updated)
|
||||||
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
|
||||||
content
|
|
||||||
.parse::<toml_edit::DocumentMut>()
|
|
||||||
.map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?
|
|
||||||
} else {
|
|
||||||
toml_edit::DocumentMut::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
// 清理可能存在的错误格式 [mcp.servers]
|
|
||||||
if let Some(mcp_item) = doc.get_mut("mcp") {
|
|
||||||
if let Some(tbl) = mcp_item.as_table_like_mut() {
|
|
||||||
if tbl.contains_key("servers") {
|
|
||||||
log::warn!("检测到错误的 MCP 格式 [mcp.servers],正在清理并迁移到 [mcp_servers]");
|
|
||||||
tbl.remove("servers");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 确保 [mcp_servers] 表存在
|
|
||||||
if !doc.contains_key("mcp_servers") {
|
|
||||||
doc["mcp_servers"] = toml_edit::table();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将 JSON 服务器规范转换为 TOML 表
|
|
||||||
let toml_table = json_server_to_toml_table(server_spec)?;
|
|
||||||
|
|
||||||
// 使用唯一正确的格式:[mcp_servers]
|
|
||||||
doc["mcp_servers"][id] = Item::Table(toml_table);
|
|
||||||
|
|
||||||
// 写回文件
|
|
||||||
std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从 Codex live 配置中移除单个 MCP 服务器
|
/// 从 Claude live 配置中移除单个 MCP 服务器
|
||||||
/// 从正确的 [mcp_servers] 表中删除,同时清理可能存在于错误位置 [mcp.servers] 的数据
|
pub fn remove_server_from_claude(id: &str) -> Result<(), AppError> {
|
||||||
pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
|
// 读取现有的 MCP 配置
|
||||||
let config_path = crate::codex_config::get_codex_config_path();
|
let mut current = crate::claude_mcp::read_mcp_servers_map()?;
|
||||||
|
|
||||||
if !config_path.exists() {
|
// 移除指定服务器
|
||||||
return Ok(()); // 文件不存在,无需删除
|
current.remove(id);
|
||||||
}
|
|
||||||
|
|
||||||
let content =
|
// 写回
|
||||||
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
crate::claude_mcp::set_mcp_servers_map(¤t)
|
||||||
|
|
||||||
let mut doc = content
|
|
||||||
.parse::<toml_edit::DocumentMut>()
|
|
||||||
.map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?;
|
|
||||||
|
|
||||||
// 从正确的位置删除:[mcp_servers]
|
|
||||||
if let Some(mcp_servers) = doc.get_mut("mcp_servers").and_then(|s| s.as_table_mut()) {
|
|
||||||
mcp_servers.remove(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 同时清理可能存在于错误位置的数据:[mcp.servers](如果存在)
|
|
||||||
if let Some(mcp_table) = doc.get_mut("mcp").and_then(|t| t.as_table_mut()) {
|
|
||||||
if let Some(servers) = mcp_table.get_mut("servers").and_then(|s| s.as_table_mut()) {
|
|
||||||
if servers.remove(id).is_some() {
|
|
||||||
log::warn!("从错误的 MCP 格式 [mcp.servers] 中清理了服务器 '{id}'");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写回文件
|
|
||||||
std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// TOML 转换辅助函数
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// 通用 JSON 值到 TOML 值转换器(支持简单类型和浅层嵌套)
|
/// 通用 JSON 值到 TOML 值转换器(支持简单类型和浅层嵌套)
|
||||||
///
|
///
|
||||||
/// 支持的类型转换:
|
/// 支持的类型转换:
|
||||||
@@ -649,3 +1030,117 @@ fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table, AppError>
|
|||||||
|
|
||||||
Ok(t)
|
Ok(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 将单个 MCP 服务器同步到 Codex live 配置
|
||||||
|
/// 始终使用 Codex 官方格式 [mcp_servers],并清理可能存在的错误格式 [mcp.servers]
|
||||||
|
pub fn sync_single_server_to_codex(
|
||||||
|
_config: &MultiAppConfig,
|
||||||
|
id: &str,
|
||||||
|
server_spec: &Value,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
use toml_edit::Item;
|
||||||
|
|
||||||
|
// 读取现有的 config.toml
|
||||||
|
let config_path = crate::codex_config::get_codex_config_path();
|
||||||
|
|
||||||
|
let mut doc = if config_path.exists() {
|
||||||
|
let content =
|
||||||
|
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
||||||
|
content
|
||||||
|
.parse::<toml_edit::DocumentMut>()
|
||||||
|
.map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?
|
||||||
|
} else {
|
||||||
|
toml_edit::DocumentMut::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 清理可能存在的错误格式 [mcp.servers]
|
||||||
|
if let Some(mcp_item) = doc.get_mut("mcp") {
|
||||||
|
if let Some(tbl) = mcp_item.as_table_like_mut() {
|
||||||
|
if tbl.contains_key("servers") {
|
||||||
|
log::warn!("检测到错误的 MCP 格式 [mcp.servers],正在清理并迁移到 [mcp_servers]");
|
||||||
|
tbl.remove("servers");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保 [mcp_servers] 表存在
|
||||||
|
if !doc.contains_key("mcp_servers") {
|
||||||
|
doc["mcp_servers"] = toml_edit::table();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将 JSON 服务器规范转换为 TOML 表
|
||||||
|
let toml_table = json_server_to_toml_table(server_spec)?;
|
||||||
|
|
||||||
|
// 使用唯一正确的格式:[mcp_servers]
|
||||||
|
doc["mcp_servers"][id] = Item::Table(toml_table);
|
||||||
|
|
||||||
|
// 写回文件
|
||||||
|
std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 Codex live 配置中移除单个 MCP 服务器
|
||||||
|
/// 从正确的 [mcp_servers] 表中删除,同时清理可能存在于错误位置 [mcp.servers] 的数据
|
||||||
|
pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
|
||||||
|
let config_path = crate::codex_config::get_codex_config_path();
|
||||||
|
|
||||||
|
if !config_path.exists() {
|
||||||
|
return Ok(()); // 文件不存在,无需删除
|
||||||
|
}
|
||||||
|
|
||||||
|
let content =
|
||||||
|
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
||||||
|
|
||||||
|
let mut doc = content
|
||||||
|
.parse::<toml_edit::DocumentMut>()
|
||||||
|
.map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?;
|
||||||
|
|
||||||
|
// 从正确的位置删除:[mcp_servers]
|
||||||
|
if let Some(mcp_servers) = doc.get_mut("mcp_servers").and_then(|s| s.as_table_mut()) {
|
||||||
|
mcp_servers.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同时清理可能存在于错误位置的数据:[mcp.servers](如果存在)
|
||||||
|
if let Some(mcp_table) = doc.get_mut("mcp").and_then(|t| t.as_table_mut()) {
|
||||||
|
if let Some(servers) = mcp_table.get_mut("servers").and_then(|s| s.as_table_mut()) {
|
||||||
|
if servers.remove(id).is_some() {
|
||||||
|
log::warn!("从错误的 MCP 格式 [mcp.servers] 中清理了服务器 '{id}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写回文件
|
||||||
|
std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将单个 MCP 服务器同步到 Gemini live 配置
|
||||||
|
pub fn sync_single_server_to_gemini(
|
||||||
|
_config: &MultiAppConfig,
|
||||||
|
id: &str,
|
||||||
|
server_spec: &Value,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
// 读取现有的 MCP 配置
|
||||||
|
let current = crate::gemini_mcp::read_mcp_servers_map()?;
|
||||||
|
|
||||||
|
// 创建新的 HashMap,包含现有的所有服务器 + 当前要同步的服务器
|
||||||
|
let mut updated = current;
|
||||||
|
updated.insert(id.to_string(), server_spec.clone());
|
||||||
|
|
||||||
|
// 写回
|
||||||
|
crate::gemini_mcp::set_mcp_servers_map(&updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 Gemini live 配置中移除单个 MCP 服务器
|
||||||
|
pub fn remove_server_from_gemini(id: &str) -> Result<(), AppError> {
|
||||||
|
// 读取现有的 MCP 配置
|
||||||
|
let mut current = crate::gemini_mcp::read_mcp_servers_map()?;
|
||||||
|
|
||||||
|
// 移除指定服务器
|
||||||
|
current.remove(id);
|
||||||
|
|
||||||
|
// 写回
|
||||||
|
crate::gemini_mcp::set_mcp_servers_map(¤t)
|
||||||
|
}
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
//! Claude MCP 同步和导入模块
|
|
||||||
|
|
||||||
use serde_json::Value;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use crate::app_config::{McpApps, McpConfig, McpServer, MultiAppConfig};
|
|
||||||
use crate::error::AppError;
|
|
||||||
|
|
||||||
use super::validation::{extract_server_spec, validate_server_spec};
|
|
||||||
|
|
||||||
/// 返回已启用的 MCP 服务器(过滤 enabled==true)
|
|
||||||
fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
|
|
||||||
let mut out = HashMap::new();
|
|
||||||
for (id, entry) in cfg.servers.iter() {
|
|
||||||
let enabled = entry
|
|
||||||
.get("enabled")
|
|
||||||
.and_then(|v| v.as_bool())
|
|
||||||
.unwrap_or(false);
|
|
||||||
if !enabled {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
match extract_server_spec(entry) {
|
|
||||||
Ok(spec) => {
|
|
||||||
out.insert(id.clone(), spec);
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
log::warn!("跳过无效的 MCP 条目 '{id}': {err}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 将 config.json 中 enabled==true 的项投影写入 ~/.claude.json
|
|
||||||
pub fn sync_enabled_to_claude(config: &MultiAppConfig) -> Result<(), AppError> {
|
|
||||||
let enabled = collect_enabled_servers(&config.mcp.claude);
|
|
||||||
crate::claude_mcp::set_mcp_servers_map(&enabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从 ~/.claude.json 导入 mcpServers 到统一结构(v3.7.0+)
|
|
||||||
/// 已存在的服务器将启用 Claude 应用,不覆盖其他字段和应用状态
|
|
||||||
pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError> {
|
|
||||||
let text_opt = crate::claude_mcp::read_mcp_json()?;
|
|
||||||
let Some(text) = text_opt else { return Ok(0) };
|
|
||||||
|
|
||||||
let v: Value = serde_json::from_str(&text)
|
|
||||||
.map_err(|e| AppError::McpValidation(format!("解析 ~/.claude.json 失败: {e}")))?;
|
|
||||||
let Some(map) = v.get("mcpServers").and_then(|x| x.as_object()) else {
|
|
||||||
return Ok(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 确保新结构存在
|
|
||||||
let servers = config.mcp.servers.get_or_insert_with(HashMap::new);
|
|
||||||
|
|
||||||
let mut changed = 0;
|
|
||||||
let mut errors = Vec::new();
|
|
||||||
|
|
||||||
for (id, spec) in map.iter() {
|
|
||||||
// 校验:单项失败不中止,收集错误继续处理
|
|
||||||
if let Err(e) = validate_server_spec(spec) {
|
|
||||||
log::warn!("跳过无效 MCP 服务器 '{id}': {e}");
|
|
||||||
errors.push(format!("{id}: {e}"));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(existing) = servers.get_mut(id) {
|
|
||||||
// 已存在:仅启用 Claude 应用
|
|
||||||
if !existing.apps.claude {
|
|
||||||
existing.apps.claude = true;
|
|
||||||
changed += 1;
|
|
||||||
log::info!("MCP 服务器 '{id}' 已启用 Claude 应用");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 新建服务器:默认仅启用 Claude
|
|
||||||
servers.insert(
|
|
||||||
id.clone(),
|
|
||||||
McpServer {
|
|
||||||
id: id.clone(),
|
|
||||||
name: id.clone(),
|
|
||||||
server: spec.clone(),
|
|
||||||
apps: McpApps {
|
|
||||||
claude: true,
|
|
||||||
codex: false,
|
|
||||||
gemini: false,
|
|
||||||
},
|
|
||||||
description: None,
|
|
||||||
homepage: None,
|
|
||||||
docs: None,
|
|
||||||
tags: Vec::new(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
changed += 1;
|
|
||||||
log::info!("导入新 MCP 服务器 '{id}'");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !errors.is_empty() {
|
|
||||||
log::warn!("导入完成,但有 {} 项失败: {:?}", errors.len(), errors);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(changed)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 将单个 MCP 服务器同步到 Claude live 配置
|
|
||||||
pub fn sync_single_server_to_claude(
|
|
||||||
_config: &MultiAppConfig,
|
|
||||||
id: &str,
|
|
||||||
server_spec: &Value,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
// 读取现有的 MCP 配置
|
|
||||||
let current = crate::claude_mcp::read_mcp_servers_map()?;
|
|
||||||
|
|
||||||
// 创建新的 HashMap,包含现有的所有服务器 + 当前要同步的服务器
|
|
||||||
let mut updated = current;
|
|
||||||
updated.insert(id.to_string(), server_spec.clone());
|
|
||||||
|
|
||||||
// 写回
|
|
||||||
crate::claude_mcp::set_mcp_servers_map(&updated)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从 Claude live 配置中移除单个 MCP 服务器
|
|
||||||
pub fn remove_server_from_claude(id: &str) -> Result<(), AppError> {
|
|
||||||
// 读取现有的 MCP 配置
|
|
||||||
let mut current = crate::claude_mcp::read_mcp_servers_map()?;
|
|
||||||
|
|
||||||
// 移除指定服务器
|
|
||||||
current.remove(id);
|
|
||||||
|
|
||||||
// 写回
|
|
||||||
crate::claude_mcp::set_mcp_servers_map(¤t)
|
|
||||||
}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
//! Gemini MCP 同步和导入模块
|
|
||||||
|
|
||||||
use serde_json::Value;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use crate::app_config::{McpApps, McpConfig, McpServer, MultiAppConfig};
|
|
||||||
use crate::error::AppError;
|
|
||||||
|
|
||||||
use super::validation::{extract_server_spec, validate_server_spec};
|
|
||||||
|
|
||||||
/// 返回已启用的 MCP 服务器(过滤 enabled==true)
|
|
||||||
fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
|
|
||||||
let mut out = HashMap::new();
|
|
||||||
for (id, entry) in cfg.servers.iter() {
|
|
||||||
let enabled = entry
|
|
||||||
.get("enabled")
|
|
||||||
.and_then(|v| v.as_bool())
|
|
||||||
.unwrap_or(false);
|
|
||||||
if !enabled {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
match extract_server_spec(entry) {
|
|
||||||
Ok(spec) => {
|
|
||||||
out.insert(id.clone(), spec);
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
log::warn!("跳过无效的 MCP 条目 '{id}': {err}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 将 config.json 中 Gemini 的 enabled==true 项写入 Gemini MCP 配置
|
|
||||||
pub fn sync_enabled_to_gemini(config: &MultiAppConfig) -> Result<(), AppError> {
|
|
||||||
let enabled = collect_enabled_servers(&config.mcp.gemini);
|
|
||||||
crate::gemini_mcp::set_mcp_servers_map(&enabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从 Gemini MCP 配置导入到统一结构(v3.7.0+)
|
|
||||||
/// 已存在的服务器将启用 Gemini 应用,不覆盖其他字段和应用状态
|
|
||||||
pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result<usize, AppError> {
|
|
||||||
let map = crate::gemini_mcp::read_mcp_servers_map()?;
|
|
||||||
if map.is_empty() {
|
|
||||||
return Ok(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 确保新结构存在
|
|
||||||
let servers = config.mcp.servers.get_or_insert_with(HashMap::new);
|
|
||||||
|
|
||||||
let mut changed = 0;
|
|
||||||
let mut errors = Vec::new();
|
|
||||||
|
|
||||||
for (id, spec) in map.iter() {
|
|
||||||
// 校验:单项失败不中止,收集错误继续处理
|
|
||||||
if let Err(e) = validate_server_spec(spec) {
|
|
||||||
log::warn!("跳过无效 MCP 服务器 '{id}': {e}");
|
|
||||||
errors.push(format!("{id}: {e}"));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(existing) = servers.get_mut(id) {
|
|
||||||
// 已存在:仅启用 Gemini 应用
|
|
||||||
if !existing.apps.gemini {
|
|
||||||
existing.apps.gemini = true;
|
|
||||||
changed += 1;
|
|
||||||
log::info!("MCP 服务器 '{id}' 已启用 Gemini 应用");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 新建服务器:默认仅启用 Gemini
|
|
||||||
servers.insert(
|
|
||||||
id.clone(),
|
|
||||||
McpServer {
|
|
||||||
id: id.clone(),
|
|
||||||
name: id.clone(),
|
|
||||||
server: spec.clone(),
|
|
||||||
apps: McpApps {
|
|
||||||
claude: false,
|
|
||||||
codex: false,
|
|
||||||
gemini: true,
|
|
||||||
},
|
|
||||||
description: None,
|
|
||||||
homepage: None,
|
|
||||||
docs: None,
|
|
||||||
tags: Vec::new(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
changed += 1;
|
|
||||||
log::info!("导入新 MCP 服务器 '{id}'");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !errors.is_empty() {
|
|
||||||
log::warn!("导入完成,但有 {} 项失败: {:?}", errors.len(), errors);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(changed)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 将单个 MCP 服务器同步到 Gemini live 配置
|
|
||||||
pub fn sync_single_server_to_gemini(
|
|
||||||
_config: &MultiAppConfig,
|
|
||||||
id: &str,
|
|
||||||
server_spec: &Value,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
// 读取现有的 MCP 配置
|
|
||||||
let mut current = crate::gemini_mcp::read_mcp_servers_map()?;
|
|
||||||
|
|
||||||
// 添加/更新当前服务器
|
|
||||||
current.insert(id.to_string(), server_spec.clone());
|
|
||||||
|
|
||||||
// 写回
|
|
||||||
crate::gemini_mcp::set_mcp_servers_map(¤t)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从 Gemini live 配置中移除单个 MCP 服务器
|
|
||||||
pub fn remove_server_from_gemini(id: &str) -> Result<(), AppError> {
|
|
||||||
// 读取现有的 MCP 配置
|
|
||||||
let mut current = crate::gemini_mcp::read_mcp_servers_map()?;
|
|
||||||
|
|
||||||
// 移除指定服务器
|
|
||||||
current.remove(id);
|
|
||||||
|
|
||||||
// 写回
|
|
||||||
crate::gemini_mcp::set_mcp_servers_map(¤t)
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
//! MCP (Model Context Protocol) 服务器管理模块
|
|
||||||
//!
|
|
||||||
//! 本模块负责 MCP 服务器配置的验证、同步和导入导出。
|
|
||||||
//!
|
|
||||||
//! ## 模块结构
|
|
||||||
//!
|
|
||||||
//! - `validation` - 服务器配置验证
|
|
||||||
//! - `claude` - Claude MCP 同步和导入
|
|
||||||
//! - `codex` - Codex MCP 同步和导入(含 TOML 转换)
|
|
||||||
//! - `gemini` - Gemini MCP 同步和导入
|
|
||||||
|
|
||||||
mod claude;
|
|
||||||
mod codex;
|
|
||||||
mod gemini;
|
|
||||||
mod validation;
|
|
||||||
|
|
||||||
// 重新导出公共 API
|
|
||||||
pub use claude::{
|
|
||||||
import_from_claude, remove_server_from_claude, sync_enabled_to_claude,
|
|
||||||
sync_single_server_to_claude,
|
|
||||||
};
|
|
||||||
pub use codex::{
|
|
||||||
import_from_codex, remove_server_from_codex, sync_enabled_to_codex, sync_single_server_to_codex,
|
|
||||||
};
|
|
||||||
pub use gemini::{
|
|
||||||
import_from_gemini, remove_server_from_gemini, sync_enabled_to_gemini,
|
|
||||||
sync_single_server_to_gemini,
|
|
||||||
};
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
//! MCP 服务器配置验证模块
|
|
||||||
|
|
||||||
use serde_json::Value;
|
|
||||||
|
|
||||||
use crate::error::AppError;
|
|
||||||
|
|
||||||
/// 基础校验:允许 stdio/http/sse;或省略 type(视为 stdio)。对应必填字段存在
|
|
||||||
pub fn validate_server_spec(spec: &Value) -> Result<(), AppError> {
|
|
||||||
if !spec.is_object() {
|
|
||||||
return Err(AppError::McpValidation(
|
|
||||||
"MCP 服务器连接定义必须为 JSON 对象".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let t_opt = spec.get("type").and_then(|x| x.as_str());
|
|
||||||
// 支持三种:stdio/http/sse;若缺省 type 则按 stdio 处理(与社区常见 .mcp.json 一致)
|
|
||||||
let is_stdio = t_opt.map(|t| t == "stdio").unwrap_or(true);
|
|
||||||
let is_http = t_opt.map(|t| t == "http").unwrap_or(false);
|
|
||||||
let is_sse = t_opt.map(|t| t == "sse").unwrap_or(false);
|
|
||||||
|
|
||||||
if !(is_stdio || is_http || is_sse) {
|
|
||||||
return Err(AppError::McpValidation(
|
|
||||||
"MCP 服务器 type 必须是 'stdio'、'http' 或 'sse'(或省略表示 stdio)".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if is_stdio {
|
|
||||||
let cmd = spec.get("command").and_then(|x| x.as_str()).unwrap_or("");
|
|
||||||
if cmd.trim().is_empty() {
|
|
||||||
return Err(AppError::McpValidation(
|
|
||||||
"stdio 类型的 MCP 服务器缺少 command 字段".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if is_http {
|
|
||||||
let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
|
|
||||||
if url.trim().is_empty() {
|
|
||||||
return Err(AppError::McpValidation(
|
|
||||||
"http 类型的 MCP 服务器缺少 url 字段".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if is_sse {
|
|
||||||
let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
|
|
||||||
if url.trim().is_empty() {
|
|
||||||
return Err(AppError::McpValidation(
|
|
||||||
"sse 类型的 MCP 服务器缺少 url 字段".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从 MCP 条目中提取服务器规范
|
|
||||||
pub fn extract_server_spec(entry: &Value) -> Result<Value, AppError> {
|
|
||||||
let obj = entry
|
|
||||||
.as_object()
|
|
||||||
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目必须为 JSON 对象".into()))?;
|
|
||||||
let server = obj
|
|
||||||
.get("server")
|
|
||||||
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目缺少 server 字段".into()))?;
|
|
||||||
|
|
||||||
if !server.is_object() {
|
|
||||||
return Err(AppError::McpValidation(
|
|
||||||
"MCP 服务器 server 字段必须为 JSON 对象".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(server.clone())
|
|
||||||
}
|
|
||||||
@@ -176,12 +176,6 @@ 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)?;
|
||||||
|
|
||||||
// 检查文件是否存在
|
// 检查文件是否存在
|
||||||
|
|||||||
@@ -215,14 +215,11 @@ 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(false); // 已有供应商,跳过
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,7 +298,7 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
|
|||||||
.db
|
.db
|
||||||
.set_current_provider(app_type.as_str(), &provider.id)?;
|
.set_current_provider(app_type.as_str(), &provider.id)?;
|
||||||
|
|
||||||
Ok(true) // 真正导入了
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write Gemini live configuration with authentication handling
|
/// Write Gemini live configuration with authentication handling
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ use serde::Deserialize;
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::app_config::AppType;
|
use crate::app_config::AppType;
|
||||||
|
use crate::codex_config::write_codex_live_atomic;
|
||||||
|
use crate::config::{get_claude_settings_path, write_json_file};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::provider::{Provider, UsageResult};
|
use crate::provider::{Provider, UsageResult};
|
||||||
use crate::services::mcp::McpService;
|
use crate::services::mcp::McpService;
|
||||||
@@ -223,9 +225,7 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,6 +327,36 @@ impl ProviderService {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn write_codex_live(provider: &Provider) -> Result<(), AppError> {
|
||||||
|
let settings = provider
|
||||||
|
.settings_config
|
||||||
|
.as_object()
|
||||||
|
.ok_or_else(|| AppError::Config("Codex 配置必须是 JSON 对象".into()))?;
|
||||||
|
let auth = settings
|
||||||
|
.get("auth")
|
||||||
|
.ok_or_else(|| AppError::Config(format!("供应商 {} 缺少 auth 配置", provider.id)))?;
|
||||||
|
if !auth.is_object() {
|
||||||
|
return Err(AppError::Config(format!(
|
||||||
|
"供应商 {} 的 auth 必须是对象",
|
||||||
|
provider.id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let cfg_text = settings.get("config").and_then(Value::as_str);
|
||||||
|
|
||||||
|
write_codex_live_atomic(auth, cfg_text)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn write_claude_live(provider: &Provider) -> Result<(), AppError> {
|
||||||
|
let settings_path = get_claude_settings_path();
|
||||||
|
let mut content = provider.settings_config.clone();
|
||||||
|
let _ = normalize_claude_models_in_value(&mut content);
|
||||||
|
write_json_file(&settings_path, &content)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
|
pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
|
||||||
write_gemini_live(provider)
|
write_gemini_live(provider)
|
||||||
}
|
}
|
||||||
@@ -525,6 +555,15 @@ impl ProviderService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn app_not_found(app_type: &AppType) -> AppError {
|
||||||
|
AppError::localized(
|
||||||
|
"provider.app_not_found",
|
||||||
|
format!("应用类型不存在: {app_type:?}"),
|
||||||
|
format!("App type not found: {app_type:?}"),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Normalize Claude model keys in a JSON value
|
/// Normalize Claude model keys in a JSON value
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ 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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 仓库配置
|
/// 仓库配置
|
||||||
@@ -47,6 +50,9 @@ 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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 技能安装状态
|
/// 技能安装状态
|
||||||
@@ -78,18 +84,21 @@ 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 子目录
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -185,8 +194,25 @@ impl SkillService {
|
|||||||
})??;
|
})??;
|
||||||
let mut skills = Vec::new();
|
let mut skills = Vec::new();
|
||||||
|
|
||||||
// 扫描仓库根目录(支持全仓库递归扫描)
|
// 确定要扫描的目录路径
|
||||||
let scan_dir = temp_dir.clone();
|
let scan_dir = if let Some(ref skills_path) = repo.skills_path {
|
||||||
|
// 如果指定了 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()
|
||||||
|
};
|
||||||
|
|
||||||
// 递归扫描目录查找所有技能
|
// 递归扫描目录查找所有技能
|
||||||
self.scan_dir_recursive(&scan_dir, &scan_dir, repo, &mut skills)?;
|
self.scan_dir_recursive(&scan_dir, &scan_dir, repo, &mut skills)?;
|
||||||
@@ -258,7 +284,11 @@ impl SkillService {
|
|||||||
let meta = self.parse_skill_metadata(skill_md)?;
|
let meta = self.parse_skill_metadata(skill_md)?;
|
||||||
|
|
||||||
// 构建 README URL
|
// 构建 README URL
|
||||||
let readme_path = directory.to_string();
|
let readme_path = if let Some(ref skills_path) = repo.skills_path {
|
||||||
|
format!("{}/{}", skills_path.trim_matches('/'), directory)
|
||||||
|
} else {
|
||||||
|
directory.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
Ok(Skill {
|
Ok(Skill {
|
||||||
key: format!("{}/{}:{}", repo.owner, repo.name, directory),
|
key: format!("{}/{}:{}", repo.owner, repo.name, directory),
|
||||||
@@ -273,6 +303,7 @@ impl SkillService {
|
|||||||
repo_owner: Some(repo.owner.clone()),
|
repo_owner: Some(repo.owner.clone()),
|
||||||
repo_name: Some(repo.name.clone()),
|
repo_name: Some(repo.name.clone()),
|
||||||
repo_branch: Some(repo.branch.clone()),
|
repo_branch: Some(repo.branch.clone()),
|
||||||
|
skills_path: repo.skills_path.clone(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,6 +405,7 @@ impl SkillService {
|
|||||||
repo_owner: None,
|
repo_owner: None,
|
||||||
repo_name: None,
|
repo_name: None,
|
||||||
repo_branch: None,
|
repo_branch: None,
|
||||||
|
skills_path: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,8 +574,16 @@ impl SkillService {
|
|||||||
))
|
))
|
||||||
})??;
|
})??;
|
||||||
|
|
||||||
// 确定源目录路径(技能相对于仓库根目录的路径)
|
// 根据 skills_path 确定源目录路径
|
||||||
let source = temp_dir.join(&directory);
|
let source = if let Some(ref skills_path) = repo.skills_path {
|
||||||
|
// 如果指定了 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);
|
||||||
|
|||||||
@@ -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" | "ja"))
|
.filter(|s| matches!(*s, "en" | "zh"))
|
||||||
.map(|s| s.to_string());
|
.map(|s| s.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,300 +0,0 @@
|
|||||||
//! 托盘菜单管理模块
|
|
||||||
//!
|
|
||||||
//! 负责系统托盘图标和菜单的创建、更新和事件处理。
|
|
||||||
|
|
||||||
use tauri::menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem};
|
|
||||||
use tauri::{Emitter, Manager};
|
|
||||||
|
|
||||||
use crate::app_config::AppType;
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::store::AppState;
|
|
||||||
|
|
||||||
/// 托盘菜单文本(国际化)
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
pub struct TrayTexts {
|
|
||||||
pub show_main: &'static str,
|
|
||||||
pub no_provider_hint: &'static str,
|
|
||||||
pub quit: &'static str,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TrayTexts {
|
|
||||||
pub fn from_language(language: &str) -> Self {
|
|
||||||
match language {
|
|
||||||
"en" => Self {
|
|
||||||
show_main: "Open main window",
|
|
||||||
no_provider_hint: " (No providers yet, please add them from the main window)",
|
|
||||||
quit: "Quit",
|
|
||||||
},
|
|
||||||
"ja" => Self {
|
|
||||||
show_main: "メインウィンドウを開く",
|
|
||||||
no_provider_hint:
|
|
||||||
" (プロバイダーがまだありません。メイン画面から追加してください)",
|
|
||||||
quit: "終了",
|
|
||||||
},
|
|
||||||
_ => Self {
|
|
||||||
show_main: "打开主界面",
|
|
||||||
no_provider_hint: " (无供应商,请在主界面添加)",
|
|
||||||
quit: "退出",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 托盘应用分区配置
|
|
||||||
pub struct TrayAppSection {
|
|
||||||
pub app_type: AppType,
|
|
||||||
pub prefix: &'static str,
|
|
||||||
pub header_id: &'static str,
|
|
||||||
pub empty_id: &'static str,
|
|
||||||
pub header_label: &'static str,
|
|
||||||
pub log_name: &'static str,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const TRAY_SECTIONS: [TrayAppSection; 3] = [
|
|
||||||
TrayAppSection {
|
|
||||||
app_type: AppType::Claude,
|
|
||||||
prefix: "claude_",
|
|
||||||
header_id: "claude_header",
|
|
||||||
empty_id: "claude_empty",
|
|
||||||
header_label: "─── Claude ───",
|
|
||||||
log_name: "Claude",
|
|
||||||
},
|
|
||||||
TrayAppSection {
|
|
||||||
app_type: AppType::Codex,
|
|
||||||
prefix: "codex_",
|
|
||||||
header_id: "codex_header",
|
|
||||||
empty_id: "codex_empty",
|
|
||||||
header_label: "─── Codex ───",
|
|
||||||
log_name: "Codex",
|
|
||||||
},
|
|
||||||
TrayAppSection {
|
|
||||||
app_type: AppType::Gemini,
|
|
||||||
prefix: "gemini_",
|
|
||||||
header_id: "gemini_header",
|
|
||||||
empty_id: "gemini_empty",
|
|
||||||
header_label: "─── Gemini ───",
|
|
||||||
log_name: "Gemini",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/// 添加供应商分区到菜单
|
|
||||||
fn append_provider_section<'a>(
|
|
||||||
app: &'a tauri::AppHandle,
|
|
||||||
mut menu_builder: MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>,
|
|
||||||
manager: Option<&crate::provider::ProviderManager>,
|
|
||||||
section: &TrayAppSection,
|
|
||||||
tray_texts: &TrayTexts,
|
|
||||||
) -> Result<MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>, AppError> {
|
|
||||||
let Some(manager) = manager else {
|
|
||||||
return Ok(menu_builder);
|
|
||||||
};
|
|
||||||
|
|
||||||
let header = MenuItem::with_id(
|
|
||||||
app,
|
|
||||||
section.header_id,
|
|
||||||
section.header_label,
|
|
||||||
false,
|
|
||||||
None::<&str>,
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Message(format!("创建{}标题失败: {e}", section.log_name)))?;
|
|
||||||
menu_builder = menu_builder.item(&header);
|
|
||||||
|
|
||||||
if manager.providers.is_empty() {
|
|
||||||
let empty_hint = MenuItem::with_id(
|
|
||||||
app,
|
|
||||||
section.empty_id,
|
|
||||||
tray_texts.no_provider_hint,
|
|
||||||
false,
|
|
||||||
None::<&str>,
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Message(format!("创建{}空提示失败: {e}", section.log_name)))?;
|
|
||||||
return Ok(menu_builder.item(&empty_hint));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut sorted_providers: Vec<_> = manager.providers.iter().collect();
|
|
||||||
sorted_providers.sort_by(|(_, a), (_, b)| {
|
|
||||||
match (a.sort_index, b.sort_index) {
|
|
||||||
(Some(idx_a), Some(idx_b)) => return idx_a.cmp(&idx_b),
|
|
||||||
(Some(_), None) => return std::cmp::Ordering::Less,
|
|
||||||
(None, Some(_)) => return std::cmp::Ordering::Greater,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
match (a.created_at, b.created_at) {
|
|
||||||
(Some(time_a), Some(time_b)) => return time_a.cmp(&time_b),
|
|
||||||
(Some(_), None) => return std::cmp::Ordering::Greater,
|
|
||||||
(None, Some(_)) => return std::cmp::Ordering::Less,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
a.name.cmp(&b.name)
|
|
||||||
});
|
|
||||||
|
|
||||||
for (id, provider) in sorted_providers {
|
|
||||||
let is_current = manager.current == *id;
|
|
||||||
let item = CheckMenuItem::with_id(
|
|
||||||
app,
|
|
||||||
format!("{}{}", section.prefix, id),
|
|
||||||
&provider.name,
|
|
||||||
true,
|
|
||||||
is_current,
|
|
||||||
None::<&str>,
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Message(format!("创建{}菜单项失败: {e}", section.log_name)))?;
|
|
||||||
menu_builder = menu_builder.item(&item);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(menu_builder)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 处理供应商托盘事件
|
|
||||||
pub fn handle_provider_tray_event(app: &tauri::AppHandle, event_id: &str) -> bool {
|
|
||||||
for section in TRAY_SECTIONS.iter() {
|
|
||||||
if let Some(provider_id) = event_id.strip_prefix(section.prefix) {
|
|
||||||
log::info!("切换到{}供应商: {provider_id}", section.log_name);
|
|
||||||
let app_handle = app.clone();
|
|
||||||
let provider_id = provider_id.to_string();
|
|
||||||
let app_type = section.app_type.clone();
|
|
||||||
tauri::async_runtime::spawn_blocking(move || {
|
|
||||||
if let Err(e) = switch_provider_internal(&app_handle, app_type, provider_id) {
|
|
||||||
log::error!("切换{}供应商失败: {e}", section.log_name);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 创建动态托盘菜单
|
|
||||||
pub fn create_tray_menu(
|
|
||||||
app: &tauri::AppHandle,
|
|
||||||
app_state: &AppState,
|
|
||||||
) -> Result<Menu<tauri::Wry>, AppError> {
|
|
||||||
let app_settings = crate::settings::get_settings();
|
|
||||||
let tray_texts = TrayTexts::from_language(app_settings.language.as_deref().unwrap_or("zh"));
|
|
||||||
|
|
||||||
let mut menu_builder = MenuBuilder::new(app);
|
|
||||||
|
|
||||||
// 顶部:打开主界面
|
|
||||||
let show_main_item =
|
|
||||||
MenuItem::with_id(app, "show_main", tray_texts.show_main, true, None::<&str>)
|
|
||||||
.map_err(|e| AppError::Message(format!("创建打开主界面菜单失败: {e}")))?;
|
|
||||||
menu_builder = menu_builder.item(&show_main_item).separator();
|
|
||||||
|
|
||||||
// 直接添加所有供应商到主菜单(扁平化结构,更简单可靠)
|
|
||||||
for section in TRAY_SECTIONS.iter() {
|
|
||||||
let app_type_str = section.app_type.as_str();
|
|
||||||
let providers = app_state.db.get_all_providers(app_type_str)?;
|
|
||||||
|
|
||||||
// 使用有效的当前供应商 ID(验证存在性,自动清理失效 ID)
|
|
||||||
let current_id =
|
|
||||||
crate::settings::get_effective_current_provider(&app_state.db, §ion.app_type)?
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let manager = crate::provider::ProviderManager {
|
|
||||||
providers,
|
|
||||||
current: current_id,
|
|
||||||
};
|
|
||||||
|
|
||||||
menu_builder =
|
|
||||||
append_provider_section(app, menu_builder, Some(&manager), section, &tray_texts)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 分隔符和退出菜单
|
|
||||||
let quit_item = MenuItem::with_id(app, "quit", tray_texts.quit, true, None::<&str>)
|
|
||||||
.map_err(|e| AppError::Message(format!("创建退出菜单失败: {e}")))?;
|
|
||||||
|
|
||||||
menu_builder = menu_builder.separator().item(&quit_item);
|
|
||||||
|
|
||||||
menu_builder
|
|
||||||
.build()
|
|
||||||
.map_err(|e| AppError::Message(format!("构建菜单失败: {e}")))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
pub fn apply_tray_policy(app: &tauri::AppHandle, dock_visible: bool) {
|
|
||||||
use tauri::ActivationPolicy;
|
|
||||||
|
|
||||||
let desired_policy = if dock_visible {
|
|
||||||
ActivationPolicy::Regular
|
|
||||||
} else {
|
|
||||||
ActivationPolicy::Accessory
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(err) = app.set_dock_visibility(dock_visible) {
|
|
||||||
log::warn!("设置 Dock 显示状态失败: {err}");
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(err) = app.set_activation_policy(desired_policy) {
|
|
||||||
log::warn!("设置激活策略失败: {err}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 处理托盘菜单事件
|
|
||||||
pub fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
|
|
||||||
log::info!("处理托盘菜单事件: {event_id}");
|
|
||||||
|
|
||||||
match event_id {
|
|
||||||
"show_main" => {
|
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
let _ = window.set_skip_taskbar(false);
|
|
||||||
}
|
|
||||||
let _ = window.unminimize();
|
|
||||||
let _ = window.show();
|
|
||||||
let _ = window.set_focus();
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
apply_tray_policy(app, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"quit" => {
|
|
||||||
log::info!("退出应用");
|
|
||||||
app.exit(0);
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
if handle_provider_tray_event(app, event_id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
log::warn!("未处理的菜单事件: {event_id}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 内部切换供应商函数
|
|
||||||
pub fn switch_provider_internal(
|
|
||||||
app: &tauri::AppHandle,
|
|
||||||
app_type: AppType,
|
|
||||||
provider_id: String,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
if let Some(app_state) = app.try_state::<AppState>() {
|
|
||||||
// 在使用前先保存需要的值
|
|
||||||
let app_type_str = app_type.as_str().to_string();
|
|
||||||
let provider_id_clone = provider_id.clone();
|
|
||||||
|
|
||||||
crate::commands::switch_provider(app_state.clone(), app_type_str.clone(), provider_id)
|
|
||||||
.map_err(AppError::Message)?;
|
|
||||||
|
|
||||||
// 切换成功后重新创建托盘菜单
|
|
||||||
if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) {
|
|
||||||
if let Some(tray) = app.tray_by_id("main") {
|
|
||||||
if let Err(e) = tray.set_menu(Some(new_menu)) {
|
|
||||||
log::error!("更新托盘菜单失败: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 发射事件到前端,通知供应商已切换
|
|
||||||
let event_data = serde_json::json!({
|
|
||||||
"appType": app_type_str,
|
|
||||||
"providerId": provider_id_clone
|
|
||||||
});
|
|
||||||
if let Err(e) = app.emit("provider-switched", event_data) {
|
|
||||||
log::error!("发射供应商切换事件失败: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -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.8.1",
|
"version": "3.7.1",
|
||||||
"identifier": "com.ccswitch.desktop",
|
"identifier": "com.ccswitch.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
|
|||||||
@@ -303,25 +303,20 @@ function App() {
|
|||||||
return <AgentsPanel onOpenChange={() => setCurrentView("providers")} />;
|
return <AgentsPanel onOpenChange={() => setCurrentView("providers")} />;
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-[56rem] px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
<div className="mx-auto max-w-[56rem] px-6 space-y-4">
|
||||||
{/* 独立滚动容器 - 解决 Linux/Ubuntu 下 DndContext 与滚轮事件冲突 */}
|
<ProviderList
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-12">
|
providers={providers}
|
||||||
<div className="space-y-4">
|
currentProviderId={currentProviderId}
|
||||||
<ProviderList
|
appId={activeApp}
|
||||||
providers={providers}
|
isLoading={isLoading}
|
||||||
currentProviderId={currentProviderId}
|
onSwitch={switchProvider}
|
||||||
appId={activeApp}
|
onEdit={setEditingProvider}
|
||||||
isLoading={isLoading}
|
onDelete={setConfirmDelete}
|
||||||
onSwitch={switchProvider}
|
onDuplicate={handleDuplicateProvider}
|
||||||
onEdit={setEditingProvider}
|
onConfigureUsage={setUsageProvider}
|
||||||
onDelete={setConfirmDelete}
|
onOpenWebsite={handleOpenWebsite}
|
||||||
onDuplicate={handleDuplicateProvider}
|
onCreate={() => setIsAddOpen(true)}
|
||||||
onConfigureUsage={setUsageProvider}
|
/>
|
||||||
onOpenWebsite={handleOpenWebsite}
|
|
||||||
onCreate={() => setIsAddOpen(true)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -396,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" && t("agents.title")}
|
{currentView === "agents" && "Agents"}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -517,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={t("mcp.title")}
|
title="MCP"
|
||||||
>
|
>
|
||||||
<Server className="h-4 w-4" />
|
<Server className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -40,7 +40,11 @@ export function ConfirmDialog({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent className="max-w-sm" zIndex="alert">
|
<DialogContent
|
||||||
|
className="max-w-sm"
|
||||||
|
zIndex="alert"
|
||||||
|
overlayClassName="bg-background/80"
|
||||||
|
>
|
||||||
<DialogHeader className="space-y-3 border-b-0 bg-transparent pb-0">
|
<DialogHeader className="space-y-3 border-b-0 bg-transparent pb-0">
|
||||||
<DialogTitle className="flex items-center gap-2 text-lg font-semibold">
|
<DialogTitle className="flex items-center gap-2 text-lg font-semibold">
|
||||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||||
|
|||||||
@@ -176,16 +176,10 @@ 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;
|
||||||
}
|
}
|
||||||
// 检测 GENERAL 模板(有 apiKey 或 baseUrl)
|
return null;
|
||||||
if (existingScript?.apiKey || existingScript?.baseUrl) {
|
|
||||||
return TEMPLATE_KEYS.GENERAL;
|
|
||||||
}
|
|
||||||
// 新配置或无凭证:默认使用 GENERAL(与默认代码模板一致)
|
|
||||||
return TEMPLATE_KEYS.GENERAL;
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -355,9 +349,14 @@ 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">
|
||||||
<p className="text-base font-medium leading-none text-foreground">
|
<div className="space-y-1">
|
||||||
{t("usageScript.enableUsageQuery")}
|
<p className="text-sm font-medium leading-none text-foreground">
|
||||||
</p>
|
{t("usageScript.enableUsageQuery")}
|
||||||
|
</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) =>
|
||||||
@@ -371,9 +370,14 @@ 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">
|
||||||
<Label className="text-base font-medium">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
{t("usageScript.presetTemplate")}
|
<Label className="text-base font-medium">
|
||||||
</Label>
|
{t("usageScript.presetTemplate")}
|
||||||
|
</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;
|
||||||
@@ -443,9 +447,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="usage-base-url">
|
<Label htmlFor="usage-base-url">Base URL</Label>
|
||||||
{t("usageScript.baseUrl")}
|
|
||||||
</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="usage-base-url"
|
id="usage-base-url"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -464,9 +466,7 @@ 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">
|
<Label htmlFor="usage-newapi-base-url">Base URL</Label>
|
||||||
{t("usageScript.baseUrl")}
|
|
||||||
</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="usage-newapi-base-url"
|
id="usage-newapi-base-url"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -545,36 +545,141 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 通用配置(始终显示) */}
|
{/* 脚本配置 */}
|
||||||
<div className="grid gap-4 md:grid-cols-2 pt-4 border-t border-white/10">
|
<div className="space-y-4 glass rounded-xl border border-white/10 p-6">
|
||||||
{/* 超时时间 */}
|
<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-timeout">
|
<Label htmlFor="usage-request-url">
|
||||||
{t("usageScript.timeoutSeconds")}
|
{t("usageScript.requestUrl")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="usage-timeout"
|
id="usage-request-url"
|
||||||
type="number"
|
type="text"
|
||||||
min={0}
|
value={script.request?.url || ""}
|
||||||
value={script.timeout ?? 10}
|
onChange={(e) => {
|
||||||
onChange={(e) =>
|
|
||||||
setScript({
|
setScript({
|
||||||
...script,
|
...script,
|
||||||
timeout: validateTimeout(e.target.value),
|
request: { ...script.request, url: e.target.value },
|
||||||
})
|
});
|
||||||
}
|
}}
|
||||||
onBlur={(e) =>
|
placeholder={t("usageScript.requestUrlPlaceholder")}
|
||||||
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")}
|
||||||
@@ -603,6 +708,9 @@ 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>
|
||||||
|
|||||||
@@ -30,11 +30,22 @@ export function SkillConfirmation({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<label className="block text-sm font-medium text-muted-foreground">
|
<div>
|
||||||
{t("deeplink.skill.branch")}
|
<label className="block text-sm font-medium text-muted-foreground">
|
||||||
</label>
|
{t("deeplink.skill.branch")}
|
||||||
<div className="mt-1 text-sm">{request.branch || "main"}</div>
|
</label>
|
||||||
|
<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">
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Save } from "lucide-react";
|
import { Save } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -225,21 +224,16 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||||
<DialogContent
|
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
|
||||||
className="max-w-2xl max-h-[90vh] flex flex-col"
|
<DialogHeader>
|
||||||
zIndex="alert"
|
<DialogTitle>{t("mcp.wizard.title")}</DialogTitle>
|
||||||
>
|
|
||||||
<DialogHeader className="space-y-3 border-b-0 bg-transparent pb-0">
|
|
||||||
<DialogTitle className="text-lg font-semibold">
|
|
||||||
{t("mcp.wizard.title")}
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||||
{/* Hint */}
|
{/* Hint */}
|
||||||
<div className="rounded-lg border border-border-default bg-gray-100/50 dark:bg-gray-800/50 p-3">
|
<div className="rounded-lg border border-border-active bg-blue-50 p-3 dark:bg-blue-900/20">
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||||
{t("mcp.wizard.hint")}
|
{t("mcp.wizard.hint")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -260,7 +254,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setWizardType(e.target.value as "stdio" | "http" | "sse")
|
setWizardType(e.target.value as "stdio" | "http" | "sse")
|
||||||
}
|
}
|
||||||
className="w-4 h-4 accent-blue-500"
|
className="w-4 h-4 text-emerald-500 bg-white dark:bg-gray-800 border-border-default focus:ring-emerald-500 dark:focus:ring-emerald-400 focus:ring-2"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-gray-900 dark:text-gray-100">
|
<span className="text-sm text-gray-900 dark:text-gray-100">
|
||||||
{t("mcp.wizard.typeStdio")}
|
{t("mcp.wizard.typeStdio")}
|
||||||
@@ -274,7 +268,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setWizardType(e.target.value as "stdio" | "http" | "sse")
|
setWizardType(e.target.value as "stdio" | "http" | "sse")
|
||||||
}
|
}
|
||||||
className="w-4 h-4 accent-blue-500"
|
className="w-4 h-4 text-emerald-500 bg-white dark:bg-gray-800 border-border-default focus:ring-emerald-500 dark:focus:ring-emerald-400 focus:ring-2"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-gray-900 dark:text-gray-100">
|
<span className="text-sm text-gray-900 dark:text-gray-100">
|
||||||
{t("mcp.wizard.typeHttp")}
|
{t("mcp.wizard.typeHttp")}
|
||||||
@@ -288,7 +282,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setWizardType(e.target.value as "stdio" | "http" | "sse")
|
setWizardType(e.target.value as "stdio" | "http" | "sse")
|
||||||
}
|
}
|
||||||
className="w-4 h-4 accent-blue-500"
|
className="w-4 h-4 text-emerald-500 bg-white dark:bg-gray-800 border-border-default focus:ring-emerald-500 dark:focus:ring-emerald-400 focus:ring-2"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-gray-900 dark:text-gray-100">
|
<span className="text-sm text-gray-900 dark:text-gray-100">
|
||||||
{t("mcp.wizard.typeSse")}
|
{t("mcp.wizard.typeSse")}
|
||||||
@@ -302,13 +296,13 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
{t("mcp.form.title")} <span className="text-red-500">*</span>
|
{t("mcp.form.title")} <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={wizardTitle}
|
value={wizardTitle}
|
||||||
onChange={(e) => setWizardTitle(e.target.value)}
|
onChange={(e) => setWizardTitle(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={t("mcp.form.titlePlaceholder")}
|
placeholder={t("mcp.form.titlePlaceholder")}
|
||||||
className="font-mono"
|
className="w-full rounded-lg border border-border-default px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:bg-gray-800 dark:text-gray-100"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -321,13 +315,13 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
{t("mcp.wizard.command")}{" "}
|
{t("mcp.wizard.command")}{" "}
|
||||||
<span className="text-red-500">*</span>
|
<span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={wizardCommand}
|
value={wizardCommand}
|
||||||
onChange={(e) => setWizardCommand(e.target.value)}
|
onChange={(e) => setWizardCommand(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={t("mcp.wizard.commandPlaceholder")}
|
placeholder={t("mcp.wizard.commandPlaceholder")}
|
||||||
className="font-mono"
|
className="w-full rounded-lg border border-border-default px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:bg-gray-800 dark:text-gray-100"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -341,7 +335,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
onChange={(e) => setWizardArgs(e.target.value)}
|
onChange={(e) => setWizardArgs(e.target.value)}
|
||||||
placeholder={t("mcp.wizard.argsPlaceholder")}
|
placeholder={t("mcp.wizard.argsPlaceholder")}
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full rounded-md border border-border-default bg-white dark:bg-gray-800 px-3 py-2 text-sm font-mono text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 resize-y"
|
className="w-full rounded-lg border border-border-default px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:bg-gray-800 dark:text-gray-100 resize-y"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -355,7 +349,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
onChange={(e) => setWizardEnv(e.target.value)}
|
onChange={(e) => setWizardEnv(e.target.value)}
|
||||||
placeholder={t("mcp.wizard.envPlaceholder")}
|
placeholder={t("mcp.wizard.envPlaceholder")}
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full rounded-md border border-border-default bg-white dark:bg-gray-800 px-3 py-2 text-sm font-mono text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 resize-y"
|
className="w-full rounded-lg border border-border-default px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:bg-gray-800 dark:text-gray-100 resize-y"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -370,13 +364,13 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
{t("mcp.wizard.url")}{" "}
|
{t("mcp.wizard.url")}{" "}
|
||||||
<span className="text-red-500">*</span>
|
<span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={wizardUrl}
|
value={wizardUrl}
|
||||||
onChange={(e) => setWizardUrl(e.target.value)}
|
onChange={(e) => setWizardUrl(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={t("mcp.wizard.urlPlaceholder")}
|
placeholder={t("mcp.wizard.urlPlaceholder")}
|
||||||
className="font-mono"
|
className="w-full rounded-lg border border-border-default px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:bg-gray-800 dark:text-gray-100"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -390,7 +384,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
onChange={(e) => setWizardHeaders(e.target.value)}
|
onChange={(e) => setWizardHeaders(e.target.value)}
|
||||||
placeholder={t("mcp.wizard.headersPlaceholder")}
|
placeholder={t("mcp.wizard.headersPlaceholder")}
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full rounded-md border border-border-default bg-white dark:bg-gray-800 px-3 py-2 text-sm font-mono text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 resize-y"
|
className="w-full rounded-lg border border-border-default px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:bg-gray-800 dark:text-gray-100 resize-y"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -403,11 +397,11 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
wizardEnv ||
|
wizardEnv ||
|
||||||
wizardUrl ||
|
wizardUrl ||
|
||||||
wizardHeaders) && (
|
wizardHeaders) && (
|
||||||
<div className="space-y-2 border-t border-border-default pt-4">
|
<div className="space-y-2 border-t border-border-default pt-4 ">
|
||||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
{t("mcp.wizard.preview")}
|
{t("mcp.wizard.preview")}
|
||||||
</h3>
|
</h3>
|
||||||
<pre className="overflow-x-auto rounded-lg bg-gray-100 dark:bg-gray-800 p-3 text-xs font-mono text-gray-700 dark:text-gray-300">
|
<pre className="overflow-x-auto rounded-lg bg-gray-50 p-3 text-xs font-mono text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||||
{preview}
|
{preview}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -415,11 +409,11 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<DialogFooter className="flex gap-2 border-t-0 bg-transparent pt-2 sm:justify-end">
|
<DialogFooter className="gap-3 pt-4">
|
||||||
<Button variant="outline" onClick={handleClose}>
|
<Button type="button" variant="ghost" onClick={handleClose}>
|
||||||
{t("common.cancel")}
|
{t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="mcp" onClick={handleApply}>
|
<Button type="button" variant="mcp" onClick={handleApply}>
|
||||||
<Save className="h-4 w-4" />
|
<Save className="h-4 w-4" />
|
||||||
{t("mcp.wizard.apply")}
|
{t("mcp.wizard.apply")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -116,7 +116,9 @@ export function ProviderCard({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"glass-card relative overflow-hidden rounded-xl p-4 transition-all duration-300",
|
"glass-card relative overflow-hidden rounded-xl p-4 transition-all duration-300",
|
||||||
"group hover:bg-black/[0.02] dark:hover:bg-white/[0.02] hover:border-primary/50",
|
"group hover:bg-black/[0.02] dark:hover:bg-white/[0.02] hover:border-primary/50",
|
||||||
isCurrent ? "glass-card-active" : "hover:scale-[1.01]",
|
isCurrent
|
||||||
|
? "border-primary/50 bg-primary/5 shadow-[0_0_20px_rgba(59,130,246,0.15)]"
|
||||||
|
: "hover:scale-[1.01]",
|
||||||
dragHandleProps?.isDragging &&
|
dragHandleProps?.isDragging &&
|
||||||
"cursor-grabbing border-primary shadow-lg scale-105 z-10",
|
"cursor-grabbing border-primary shadow-lg scale-105 z-10",
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function useBaseUrlState({
|
|||||||
try {
|
try {
|
||||||
const config = JSON.parse(settingsConfig || "{}");
|
const config = JSON.parse(settingsConfig || "{}");
|
||||||
const envUrl: unknown = config?.env?.ANTHROPIC_BASE_URL;
|
const envUrl: unknown = config?.env?.ANTHROPIC_BASE_URL;
|
||||||
if (typeof envUrl === "string" && envUrl && envUrl.trim() !== baseUrl) {
|
if (typeof envUrl === "string" && envUrl && envUrl !== baseUrl) {
|
||||||
setBaseUrl(envUrl.trim());
|
setBaseUrl(envUrl.trim());
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -73,7 +73,8 @@ export function useBaseUrlState({
|
|||||||
try {
|
try {
|
||||||
const config = JSON.parse(settingsConfig || "{}");
|
const config = JSON.parse(settingsConfig || "{}");
|
||||||
const envUrl: unknown = config?.env?.GOOGLE_GEMINI_BASE_URL;
|
const envUrl: unknown = config?.env?.GOOGLE_GEMINI_BASE_URL;
|
||||||
const nextUrl = typeof envUrl === "string" ? envUrl.trim() : "";
|
const nextUrl =
|
||||||
|
typeof envUrl === "string" ? envUrl.trim().replace(/\/+$/, "") : "";
|
||||||
if (nextUrl !== geminiBaseUrl) {
|
if (nextUrl !== geminiBaseUrl) {
|
||||||
setGeminiBaseUrl(nextUrl);
|
setGeminiBaseUrl(nextUrl);
|
||||||
setBaseUrl(nextUrl); // 也更新 baseUrl 用于 UI
|
setBaseUrl(nextUrl); // 也更新 baseUrl 用于 UI
|
||||||
@@ -86,7 +87,7 @@ export function useBaseUrlState({
|
|||||||
// 处理 Claude Base URL 变化
|
// 处理 Claude Base URL 变化
|
||||||
const handleClaudeBaseUrlChange = useCallback(
|
const handleClaudeBaseUrlChange = useCallback(
|
||||||
(url: string) => {
|
(url: string) => {
|
||||||
const sanitized = url.trim();
|
const sanitized = url.trim().replace(/\/+$/, "");
|
||||||
setBaseUrl(sanitized);
|
setBaseUrl(sanitized);
|
||||||
isUpdatingRef.current = true;
|
isUpdatingRef.current = true;
|
||||||
|
|
||||||
@@ -111,7 +112,7 @@ export function useBaseUrlState({
|
|||||||
// 处理 Codex Base URL 变化
|
// 处理 Codex Base URL 变化
|
||||||
const handleCodexBaseUrlChange = useCallback(
|
const handleCodexBaseUrlChange = useCallback(
|
||||||
(url: string) => {
|
(url: string) => {
|
||||||
const sanitized = url.trim();
|
const sanitized = url.trim().replace(/\/+$/, "");
|
||||||
setCodexBaseUrl(sanitized);
|
setCodexBaseUrl(sanitized);
|
||||||
|
|
||||||
if (!sanitized || !onCodexConfigChange) {
|
if (!sanitized || !onCodexConfigChange) {
|
||||||
@@ -135,7 +136,7 @@ export function useBaseUrlState({
|
|||||||
// 处理 Gemini Base URL 变化
|
// 处理 Gemini Base URL 变化
|
||||||
const handleGeminiBaseUrlChange = useCallback(
|
const handleGeminiBaseUrlChange = useCallback(
|
||||||
(url: string) => {
|
(url: string) => {
|
||||||
const sanitized = url.trim();
|
const sanitized = url.trim().replace(/\/+$/, "");
|
||||||
setGeminiBaseUrl(sanitized);
|
setGeminiBaseUrl(sanitized);
|
||||||
setBaseUrl(sanitized); // 也更新 baseUrl 用于 UI
|
setBaseUrl(sanitized); // 也更新 baseUrl 用于 UI
|
||||||
isUpdatingRef.current = true;
|
isUpdatingRef.current = true;
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
|||||||
// 处理 Codex Base URL 变化
|
// 处理 Codex Base URL 变化
|
||||||
const handleCodexBaseUrlChange = useCallback(
|
const handleCodexBaseUrlChange = useCallback(
|
||||||
(url: string) => {
|
(url: string) => {
|
||||||
const sanitized = url.trim();
|
const sanitized = url.trim().replace(/\/+$/, "");
|
||||||
setCodexBaseUrl(sanitized);
|
setCodexBaseUrl(sanitized);
|
||||||
|
|
||||||
if (!sanitized) {
|
if (!sanitized) {
|
||||||
|
|||||||
@@ -2,11 +2,9 @@ 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: LanguageOption;
|
value: "zh" | "en";
|
||||||
onChange: (value: LanguageOption) => void;
|
onChange: (value: "zh" | "en") => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LanguageSettings({ value, onChange }: LanguageSettingsProps) {
|
export function LanguageSettings({ value, onChange }: LanguageSettingsProps) {
|
||||||
@@ -27,9 +25,6 @@ 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ export function SettingsPage({
|
|||||||
const isBusy = useMemo(() => isLoading && !settings, [isLoading, settings]);
|
const isBusy = useMemo(() => isLoading && !settings, [isLoading, settings]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-[56rem] flex flex-col h-[calc(100vh-8rem)] overflow-hidden px-6">
|
<div className="mx-auto max-w-[56rem] flex flex-col h-[calc(100vh-8rem)] px-6">
|
||||||
{isBusy ? (
|
{isBusy ? (
|
||||||
<div className="flex flex-1 items-center justify-center">
|
<div className="flex flex-1 items-center justify-center">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
@@ -172,7 +172,7 @@ export function SettingsPage({
|
|||||||
<TabsTrigger value="about">{t("common.about")}</TabsTrigger>
|
<TabsTrigger value="about">{t("common.about")}</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pr-2">
|
<div className="flex-1 overflow-y-auto pr-2">
|
||||||
<TabsContent value="general" className="space-y-6 mt-0">
|
<TabsContent value="general" className="space-y-6 mt-0">
|
||||||
{settings ? (
|
{settings ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ 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) =>
|
||||||
@@ -79,10 +80,12 @@ 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"));
|
||||||
}
|
}
|
||||||
@@ -127,6 +130,13 @@ 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"
|
||||||
@@ -161,6 +171,12 @@ 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),
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ 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) =>
|
||||||
@@ -66,10 +67,12 @@ 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"));
|
||||||
}
|
}
|
||||||
@@ -90,7 +93,7 @@ export function RepoManagerPanel({
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
>
|
>
|
||||||
{/* 添加仓库表单 */}
|
{/* 添加仓库表单 */}
|
||||||
<div className="space-y-4 glass-card rounded-xl p-6">
|
<div className="space-y-4 glass rounded-xl p-6 border border-white/10">
|
||||||
<h3 className="text-base font-semibold text-foreground">
|
<h3 className="text-base font-semibold text-foreground">
|
||||||
添加技能仓库
|
添加技能仓库
|
||||||
</h3>
|
</h3>
|
||||||
@@ -107,17 +110,31 @@ export function RepoManagerPanel({
|
|||||||
className="mt-2"
|
className="mt-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<Label htmlFor="branch" className="text-foreground">
|
<div>
|
||||||
{t("skills.repo.branch")}
|
<Label htmlFor="branch" className="text-foreground">
|
||||||
</Label>
|
{t("skills.repo.branch")}
|
||||||
<Input
|
</Label>
|
||||||
id="branch"
|
<Input
|
||||||
placeholder={t("skills.repo.branchPlaceholder")}
|
id="branch"
|
||||||
value={branch}
|
placeholder={t("skills.repo.branchPlaceholder")}
|
||||||
onChange={(e) => setBranch(e.target.value)}
|
value={branch}
|
||||||
className="mt-2"
|
onChange={(e) => setBranch(e.target.value)}
|
||||||
/>
|
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>
|
||||||
@@ -139,7 +156,7 @@ export function RepoManagerPanel({
|
|||||||
{t("skills.repo.list")}
|
{t("skills.repo.list")}
|
||||||
</h3>
|
</h3>
|
||||||
{repos.length === 0 ? (
|
{repos.length === 0 ? (
|
||||||
<div className="text-center py-12 glass-card rounded-xl">
|
<div className="text-center py-12 glass rounded-xl border border-white/10">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t("skills.repo.empty")}
|
{t("skills.repo.empty")}
|
||||||
</p>
|
</p>
|
||||||
@@ -149,7 +166,7 @@ export function RepoManagerPanel({
|
|||||||
{repos.map((repo) => (
|
{repos.map((repo) => (
|
||||||
<div
|
<div
|
||||||
key={`${repo.owner}/${repo.name}`}
|
key={`${repo.owner}/${repo.name}`}
|
||||||
className="flex items-center justify-between glass-card rounded-xl px-4 py-3"
|
className="flex items-center justify-between rounded-xl border border-white/10 bg-gray-900/40 px-4 py-3"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-foreground">
|
<div className="text-sm font-medium text-foreground">
|
||||||
@@ -157,6 +174,12 @@ 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),
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function SkillCard({ skill, onInstall, onUninstall }: SkillCardProps) {
|
|||||||
skill.directory.trim().toLowerCase() !== skill.name.trim().toLowerCase();
|
skill.directory.trim().toLowerCase() !== skill.name.trim().toLowerCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="glass-card flex flex-col h-full transition-all duration-300 hover:scale-[1.01] hover:shadow-lg group relative overflow-hidden">
|
<Card className="glass flex flex-col h-full border border-white/10 bg-gray-900/40 transition-all duration-300 hover:bg-gray-900/60 hover:border-white/20 hover:shadow-lg group relative overflow-hidden">
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" />
|
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" />
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
@@ -96,7 +96,7 @@ export function SkillCard({ skill, onInstall, onUninstall }: SkillCardProps) {
|
|||||||
{skill.description || t("skills.noDescription")}
|
{skill.description || t("skills.noDescription")}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="flex gap-2 pt-3 border-t border-border/50 relative z-10">
|
<CardFooter className="flex gap-2 pt-3 border-t border-white/5 relative z-10">
|
||||||
{skill.readmeUrl && (
|
{skill.readmeUrl && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -205,10 +205,12 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
|||||||
}, [skills, searchQuery, filterStatus]);
|
}, [skills, searchQuery, filterStatus]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-[56rem] px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden bg-background/50">
|
<div className="flex flex-col h-full min-h-0 bg-background/50">
|
||||||
|
{/* 顶部操作栏(固定区域)已移除,由 App.tsx 接管 */}
|
||||||
|
|
||||||
{/* 技能网格(可滚动详情区域) */}
|
{/* 技能网格(可滚动详情区域) */}
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden animate-fade-in">
|
<div className="flex-1 min-h-0 overflow-y-auto animate-fade-in">
|
||||||
<div className="py-4">
|
<div className="mx-auto max-w-[56rem] px-6 py-4">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
|
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
@@ -261,19 +263,19 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
|||||||
<SelectContent className="bg-card text-foreground shadow-lg">
|
<SelectContent className="bg-card text-foreground shadow-lg">
|
||||||
<SelectItem
|
<SelectItem
|
||||||
value="all"
|
value="all"
|
||||||
className="text-left pr-3 [&[data-state=checked]>span:first-child]:hidden"
|
className="text-left pr-3 [&[data-state=checked]>span]:hidden"
|
||||||
>
|
>
|
||||||
{t("skills.filter.all")}
|
{t("skills.filter.all")}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem
|
<SelectItem
|
||||||
value="installed"
|
value="installed"
|
||||||
className="text-left pr-3 [&[data-state=checked]>span:first-child]:hidden"
|
className="text-left pr-3 [&[data-state=checked]>span]:hidden"
|
||||||
>
|
>
|
||||||
{t("skills.filter.installed")}
|
{t("skills.filter.installed")}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem
|
<SelectItem
|
||||||
value="uninstalled"
|
value="uninstalled"
|
||||||
className="text-left pr-3 [&[data-state=checked]>span:first-child]:hidden"
|
className="text-left pr-3 [&[data-state=checked]>span]:hidden"
|
||||||
>
|
>
|
||||||
{t("skills.filter.uninstalled")}
|
{t("skills.filter.uninstalled")}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|||||||
@@ -185,6 +185,8 @@ export const providerPresets: ProviderPreset[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
category: "aggregator",
|
category: "aggregator",
|
||||||
|
icon: "alibaba",
|
||||||
|
iconColor: "#FF6A00",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "KAT-Coder",
|
name: "KAT-Coder",
|
||||||
@@ -232,7 +234,7 @@ export const providerPresets: ProviderPreset[] = [
|
|||||||
{
|
{
|
||||||
name: "MiniMax",
|
name: "MiniMax",
|
||||||
websiteUrl: "https://platform.minimaxi.com",
|
websiteUrl: "https://platform.minimaxi.com",
|
||||||
apiKeyUrl: "https://platform.minimaxi.com/subscribe/coding-plan",
|
apiKeyUrl: "https://platform.minimax.io/subscribe/coding-plan",
|
||||||
settingsConfig: {
|
settingsConfig: {
|
||||||
env: {
|
env: {
|
||||||
ANTHROPIC_BASE_URL: "https://api.minimaxi.com/anthropic",
|
ANTHROPIC_BASE_URL: "https://api.minimaxi.com/anthropic",
|
||||||
@@ -247,33 +249,7 @@ export const providerPresets: ProviderPreset[] = [
|
|||||||
},
|
},
|
||||||
category: "cn_official",
|
category: "cn_official",
|
||||||
isPartner: true,
|
isPartner: true,
|
||||||
partnerPromotionKey: "minimax_cn",
|
partnerPromotionKey: "minimax",
|
||||||
theme: {
|
|
||||||
backgroundColor: "#f64551",
|
|
||||||
textColor: "#FFFFFF",
|
|
||||||
},
|
|
||||||
icon: "minimax",
|
|
||||||
iconColor: "#FF6B6B",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "MiniMax en",
|
|
||||||
websiteUrl: "https://platform.minimax.io",
|
|
||||||
apiKeyUrl: "https://platform.minimax.io/subscribe/coding-plan",
|
|
||||||
settingsConfig: {
|
|
||||||
env: {
|
|
||||||
ANTHROPIC_BASE_URL: "https://api.minimax.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",
|
||||||
@@ -314,6 +290,8 @@ export const providerPresets: ProviderPreset[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
category: "cn_official",
|
category: "cn_official",
|
||||||
|
icon: "alibaba",
|
||||||
|
iconColor: "#FF6A00",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "AiHubMix",
|
name: "AiHubMix",
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function generateThirdPartyAuth(apiKey: string): Record<string, any> {
|
|||||||
export function generateThirdPartyConfig(
|
export function generateThirdPartyConfig(
|
||||||
providerName: string,
|
providerName: string,
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
modelName = "gpt-5.1-codex",
|
modelName = "gpt-5-codex",
|
||||||
): string {
|
): string {
|
||||||
// 清理供应商名称,确保符合TOML键名规范
|
// 清理供应商名称,确保符合TOML键名规范
|
||||||
const cleanProviderName =
|
const cleanProviderName =
|
||||||
@@ -85,7 +85,7 @@ export const codexProviderPresets: CodexProviderPreset[] = [
|
|||||||
isOfficial: true,
|
isOfficial: true,
|
||||||
auth: generateThirdPartyAuth(""),
|
auth: generateThirdPartyAuth(""),
|
||||||
config: `model_provider = "azure"
|
config: `model_provider = "azure"
|
||||||
model = "gpt-5.1-codex"
|
model = "gpt-5-codex"
|
||||||
model_reasoning_effort = "high"
|
model_reasoning_effort = "high"
|
||||||
disable_response_storage = true
|
disable_response_storage = true
|
||||||
|
|
||||||
@@ -113,7 +113,7 @@ requires_openai_auth = true`,
|
|||||||
config: generateThirdPartyConfig(
|
config: generateThirdPartyConfig(
|
||||||
"aihubmix",
|
"aihubmix",
|
||||||
"https://aihubmix.com/v1",
|
"https://aihubmix.com/v1",
|
||||||
"gpt-5.1-codex",
|
"gpt-5-codex",
|
||||||
),
|
),
|
||||||
endpointCandidates: [
|
endpointCandidates: [
|
||||||
"https://aihubmix.com/v1",
|
"https://aihubmix.com/v1",
|
||||||
@@ -128,7 +128,7 @@ requires_openai_auth = true`,
|
|||||||
config: generateThirdPartyConfig(
|
config: generateThirdPartyConfig(
|
||||||
"dmxapi",
|
"dmxapi",
|
||||||
"https://www.dmxapi.cn/v1",
|
"https://www.dmxapi.cn/v1",
|
||||||
"gpt-5.1-codex",
|
"gpt-5-codex",
|
||||||
),
|
),
|
||||||
endpointCandidates: ["https://www.dmxapi.cn/v1"],
|
endpointCandidates: ["https://www.dmxapi.cn/v1"],
|
||||||
},
|
},
|
||||||
@@ -141,7 +141,7 @@ requires_openai_auth = true`,
|
|||||||
config: generateThirdPartyConfig(
|
config: generateThirdPartyConfig(
|
||||||
"packycode",
|
"packycode",
|
||||||
"https://www.packyapi.com/v1",
|
"https://www.packyapi.com/v1",
|
||||||
"gpt-5.1-codex",
|
"gpt-5-codex",
|
||||||
),
|
),
|
||||||
endpointCandidates: [
|
endpointCandidates: [
|
||||||
"https://www.packyapi.com/v1",
|
"https://www.packyapi.com/v1",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from "./useDirectorySettings";
|
} from "./useDirectorySettings";
|
||||||
import { useSettingsMetadata } from "./useSettingsMetadata";
|
import { useSettingsMetadata } from "./useSettingsMetadata";
|
||||||
|
|
||||||
type Language = "zh" | "en" | "ja";
|
type Language = "zh" | "en";
|
||||||
|
|
||||||
interface SaveResult {
|
interface SaveResult {
|
||||||
requiresRestart: boolean;
|
requiresRestart: boolean;
|
||||||
|
|||||||
@@ -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" | "ja";
|
type Language = "zh" | "en";
|
||||||
|
|
||||||
export type SettingsFormState = Omit<Settings, "language"> & {
|
export type SettingsFormState = Omit<Settings, "language"> & {
|
||||||
language: Language;
|
language: Language;
|
||||||
@@ -11,8 +11,7 @@ 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";
|
||||||
const normalized = lang.toLowerCase();
|
return lang === "en" ? "en" : "zh";
|
||||||
return normalized === "en" || normalized === "ja" ? normalized : "zh";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const sanitizeDir = (value?: string | null): string | undefined => {
|
const sanitizeDir = (value?: string | null): string | undefined => {
|
||||||
@@ -52,8 +51,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" || stored === "ja") {
|
if (stored === "en" || stored === "zh") {
|
||||||
return stored as Language;
|
return stored;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return normalizeLanguage(i18n.language);
|
return normalizeLanguage(i18n.language);
|
||||||
|
|||||||
@@ -2,18 +2,15 @@ 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";
|
||||||
|
|
||||||
type Language = "zh" | "en" | "ja";
|
const DEFAULT_LANGUAGE: "zh" | "en" = "zh";
|
||||||
|
|
||||||
const DEFAULT_LANGUAGE: Language = "zh";
|
const getInitialLanguage = (): "zh" | "en" => {
|
||||||
|
|
||||||
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" || stored === "ja") {
|
if (stored === "zh" || stored === "en") {
|
||||||
return stored;
|
return stored;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -31,10 +28,6 @@ const getInitialLanguage = (): Language => {
|
|||||||
return "zh";
|
return "zh";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (navigatorLang?.startsWith("ja")) {
|
|
||||||
return "ja";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (navigatorLang?.startsWith("en")) {
|
if (navigatorLang?.startsWith("en")) {
|
||||||
return "en";
|
return "en";
|
||||||
}
|
}
|
||||||
@@ -46,9 +39,6 @@ const resources = {
|
|||||||
en: {
|
en: {
|
||||||
translation: en,
|
translation: en,
|
||||||
},
|
},
|
||||||
ja: {
|
|
||||||
translation: ja,
|
|
||||||
},
|
|
||||||
zh: {
|
zh: {
|
||||||
translation: zh,
|
translation: zh,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -165,7 +165,6 @@
|
|||||||
"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",
|
||||||
@@ -263,8 +262,7 @@
|
|||||||
"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_cn": "MiniMax Coding Plan Special Offer, Starter from ¥9.9",
|
"minimax": "MiniMax Coding Plan Black Friday, Starter is now $2/mo (80% OFF!)"
|
||||||
"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)",
|
||||||
@@ -272,7 +270,7 @@
|
|||||||
"fastModel": "Fast Model (optional)",
|
"fastModel": "Fast Model (optional)",
|
||||||
"fastModelPlaceholder": "e.g., GLM-4.5-Air",
|
"fastModelPlaceholder": "e.g., GLM-4.5-Air",
|
||||||
"modelHint": "💡 Leave blank to use provider's default model",
|
"modelHint": "💡 Leave blank to use provider's default model",
|
||||||
"apiHint": "💡 Fill in Claude API compatible service endpoint, avoid trailing slash",
|
"apiHint": "💡 Fill in Claude API compatible service endpoint",
|
||||||
"codexApiHint": "💡 Fill in service endpoint compatible with OpenAI Response format",
|
"codexApiHint": "💡 Fill in service endpoint compatible with OpenAI Response format",
|
||||||
"fillSupplierName": "Please fill in provider name",
|
"fillSupplierName": "Please fill in provider name",
|
||||||
"fillConfigContent": "Please fill in configuration content",
|
"fillConfigContent": "Please fill in configuration content",
|
||||||
@@ -378,7 +376,6 @@
|
|||||||
"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",
|
||||||
@@ -392,7 +389,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, 0 to disable)",
|
"autoIntervalMinutes": "Auto query interval (minutes)",
|
||||||
"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",
|
||||||
@@ -834,11 +831,5 @@
|
|||||||
"colorLabel": "Icon Color",
|
"colorLabel": "Icon Color",
|
||||||
"selectIcon": "Select Icon",
|
"selectIcon": "Select Icon",
|
||||||
"preview": "Preview"
|
"preview": "Preview"
|
||||||
},
|
|
||||||
"migration": {
|
|
||||||
"success": "Configuration migrated successfully"
|
|
||||||
},
|
|
||||||
"agents": {
|
|
||||||
"title": "Agents"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,844 +0,0 @@
|
|||||||
{
|
|
||||||
"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 が月額 $2(80% 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": "エージェント"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -165,7 +165,6 @@
|
|||||||
"autoReload": "数据将在2秒后自动刷新...",
|
"autoReload": "数据将在2秒后自动刷新...",
|
||||||
"languageOptionChinese": "中文",
|
"languageOptionChinese": "中文",
|
||||||
"languageOptionEnglish": "English",
|
"languageOptionEnglish": "English",
|
||||||
"languageOptionJapanese": "日本語",
|
|
||||||
"windowBehavior": "窗口行为",
|
"windowBehavior": "窗口行为",
|
||||||
"windowBehaviorHint": "配置窗口最小化与 Claude 插件联动策略。",
|
"windowBehaviorHint": "配置窗口最小化与 Claude 插件联动策略。",
|
||||||
"launchOnStartup": "开机自启",
|
"launchOnStartup": "开机自启",
|
||||||
@@ -263,8 +262,7 @@
|
|||||||
"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_cn": "MiniMax Coding Plan 特惠,Starter 套餐 9.9 元起",
|
"minimax": "MiniMax Coding Plan 黑五特惠,Starter 套餐现仅 $2/月(2折优惠!)"
|
||||||
"minimax_en": "MiniMax Coding Plan 黑五特惠,Starter 套餐现仅 $2/月(2折优惠!)"
|
|
||||||
},
|
},
|
||||||
"parameterConfig": "参数配置 - {{name}} *",
|
"parameterConfig": "参数配置 - {{name}} *",
|
||||||
"mainModel": "主模型 (可选)",
|
"mainModel": "主模型 (可选)",
|
||||||
@@ -272,7 +270,7 @@
|
|||||||
"fastModel": "快速模型 (可选)",
|
"fastModel": "快速模型 (可选)",
|
||||||
"fastModelPlaceholder": "例如: GLM-4.5-Air",
|
"fastModelPlaceholder": "例如: GLM-4.5-Air",
|
||||||
"modelHint": "💡 留空将使用供应商的默认模型",
|
"modelHint": "💡 留空将使用供应商的默认模型",
|
||||||
"apiHint": "💡 填写兼容 Claude API 的服务端点地址,不要以斜杠结尾",
|
"apiHint": "💡 填写兼容 Claude API 的服务端点地址",
|
||||||
"codexApiHint": "💡 填写兼容 OpenAI Response 格式的服务端点地址",
|
"codexApiHint": "💡 填写兼容 OpenAI Response 格式的服务端点地址",
|
||||||
"fillSupplierName": "请填写供应商名称",
|
"fillSupplierName": "请填写供应商名称",
|
||||||
"fillConfigContent": "请填写配置内容",
|
"fillConfigContent": "请填写配置内容",
|
||||||
@@ -378,8 +376,7 @@
|
|||||||
"templateGeneral": "通用模板",
|
"templateGeneral": "通用模板",
|
||||||
"templateNewAPI": "NewAPI",
|
"templateNewAPI": "NewAPI",
|
||||||
"credentialsConfig": "凭证配置",
|
"credentialsConfig": "凭证配置",
|
||||||
"baseUrl": "请求地址",
|
"accessToken": "访问令牌",
|
||||||
"accessToken": "访问令牌(在个人安全设置里获取)",
|
|
||||||
"accessTokenPlaceholder": "在'安全设置'里生成",
|
"accessTokenPlaceholder": "在'安全设置'里生成",
|
||||||
"userId": "用户 ID",
|
"userId": "用户 ID",
|
||||||
"userIdPlaceholder": "例如:114514",
|
"userIdPlaceholder": "例如:114514",
|
||||||
@@ -392,7 +389,7 @@
|
|||||||
"timeoutHint": "范围: 2-30 秒",
|
"timeoutHint": "范围: 2-30 秒",
|
||||||
"timeoutMustBeInteger": "超时时间必须为整数,小数部分已忽略",
|
"timeoutMustBeInteger": "超时时间必须为整数,小数部分已忽略",
|
||||||
"timeoutCannotBeNegative": "超时时间不能为负数",
|
"timeoutCannotBeNegative": "超时时间不能为负数",
|
||||||
"autoIntervalMinutes": "自动查询间隔(分钟,0 表示不自动查询)",
|
"autoIntervalMinutes": "自动查询间隔(分钟)",
|
||||||
"autoQueryInterval": "自动查询间隔(分钟)",
|
"autoQueryInterval": "自动查询间隔(分钟)",
|
||||||
"autoQueryIntervalHint": "0 表示不自动查询,建议 5-60 分钟",
|
"autoQueryIntervalHint": "0 表示不自动查询,建议 5-60 分钟",
|
||||||
"intervalMustBeInteger": "自动查询间隔必须为整数,小数部分已忽略",
|
"intervalMustBeInteger": "自动查询间隔必须为整数,小数部分已忽略",
|
||||||
@@ -834,11 +831,5 @@
|
|||||||
"colorLabel": "图标颜色",
|
"colorLabel": "图标颜色",
|
||||||
"selectIcon": "选择图标",
|
"selectIcon": "选择图标",
|
||||||
"preview": "预览"
|
"preview": "预览"
|
||||||
},
|
|
||||||
"migration": {
|
|
||||||
"success": "配置迁移成功"
|
|
||||||
},
|
|
||||||
"agents": {
|
|
||||||
"title": "智能体"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Glassmorphism Utilities */
|
||||||
/* Glassmorphism Utilities */
|
/* Glassmorphism Utilities */
|
||||||
.glass {
|
.glass {
|
||||||
background: rgba(255, 255, 255, 0.7);
|
background: rgba(255, 255, 255, 0.7);
|
||||||
@@ -103,19 +104,6 @@
|
|||||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 供应商卡片选中状态 */
|
|
||||||
.glass-card-active {
|
|
||||||
background: rgba(59, 130, 246, 0.08);
|
|
||||||
border: 1px solid rgba(59, 130, 246, 0.4);
|
|
||||||
box-shadow: 0 0 20px rgba(59, 130, 246, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .glass-card-active {
|
|
||||||
background: rgba(59, 130, 246, 0.12);
|
|
||||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
|
||||||
box-shadow: 0 0 24px rgba(59, 130, 246, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.glass-header {
|
.glass-header {
|
||||||
background: hsl(var(--background));
|
background: hsl(var(--background));
|
||||||
backdrop-filter: none;
|
backdrop-filter: none;
|
||||||
@@ -143,8 +131,6 @@
|
|||||||
/* 全局基础样式 */
|
/* 全局基础样式 */
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
scrollbar-width: none; /* Firefox */
|
|
||||||
-ms-overflow-style: none; /* IE 10+ */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
@@ -172,6 +158,16 @@ html.dark {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Firefox */
|
||||||
|
* {
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* IE 10+ */
|
||||||
|
* {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* 焦点样式 */
|
/* 焦点样式 */
|
||||||
*:focus-visible {
|
*:focus-visible {
|
||||||
@apply outline-2 outline-blue-500 outline-offset-2;
|
@apply outline-2 outline-blue-500 outline-offset-2;
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ 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;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface Skill {
|
|||||||
repoOwner?: string;
|
repoOwner?: string;
|
||||||
repoName?: string;
|
repoName?: string;
|
||||||
repoBranch?: string;
|
repoBranch?: string;
|
||||||
|
skillsPath?: string; // 技能所在的子目录路径,如 "skills"
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SkillRepo {
|
export interface SkillRepo {
|
||||||
@@ -17,6 +18,7 @@ export interface SkillRepo {
|
|||||||
name: string;
|
name: string;
|
||||||
branch: string;
|
branch: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
skillsPath?: string; // 可选:技能所在的子目录路径,如 "skills"
|
||||||
}
|
}
|
||||||
|
|
||||||
export const skillsApi = {
|
export const skillsApi = {
|
||||||
|
|||||||
@@ -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", "ja"]).optional(),
|
language: z.enum(["en", "zh"]).optional(),
|
||||||
|
|
||||||
// 设备级目录覆盖
|
// 设备级目录覆盖
|
||||||
claudeConfigDir: directorySchema.nullable().optional(),
|
claudeConfigDir: directorySchema.nullable().optional(),
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export interface Settings {
|
|||||||
// 是否开机自启
|
// 是否开机自启
|
||||||
launchOnStartup?: boolean;
|
launchOnStartup?: boolean;
|
||||||
// 首选语言(可选,默认中文)
|
// 首选语言(可选,默认中文)
|
||||||
language?: "en" | "zh" | "ja";
|
language?: "en" | "zh";
|
||||||
|
|
||||||
// ===== 设备级目录覆盖 =====
|
// ===== 设备级目录覆盖 =====
|
||||||
// 覆盖 Claude Code 配置目录(可选)
|
// 覆盖 Claude Code 配置目录(可选)
|
||||||
|
|||||||
@@ -453,7 +453,7 @@ export const setCodexBaseUrl = (
|
|||||||
// 归一化原文本中的引号(既能匹配,也能输出稳定格式)
|
// 归一化原文本中的引号(既能匹配,也能输出稳定格式)
|
||||||
const normalizedText = normalizeQuotes(configText);
|
const normalizedText = normalizeQuotes(configText);
|
||||||
|
|
||||||
const normalizedUrl = trimmed.replace(/\s+/g, "");
|
const normalizedUrl = trimmed.replace(/\s+/g, "").replace(/\/+$/, "");
|
||||||
const replacementLine = `base_url = "${normalizedUrl}"`;
|
const replacementLine = `base_url = "${normalizedUrl}"`;
|
||||||
const pattern = /base_url\s*=\s*(["'])([^"']+)\1/;
|
const pattern = /base_url\s*=\s*(["'])([^"']+)\1/;
|
||||||
|
|
||||||
|
|||||||
@@ -8,40 +8,6 @@ export default {
|
|||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
// shadcn/ui CSS 变量映射
|
|
||||||
background: "hsl(var(--background))",
|
|
||||||
foreground: "hsl(var(--foreground))",
|
|
||||||
card: {
|
|
||||||
DEFAULT: "hsl(var(--card))",
|
|
||||||
foreground: "hsl(var(--card-foreground))",
|
|
||||||
},
|
|
||||||
popover: {
|
|
||||||
DEFAULT: "hsl(var(--popover))",
|
|
||||||
foreground: "hsl(var(--popover-foreground))",
|
|
||||||
},
|
|
||||||
primary: {
|
|
||||||
DEFAULT: "hsl(var(--primary))",
|
|
||||||
foreground: "hsl(var(--primary-foreground))",
|
|
||||||
},
|
|
||||||
secondary: {
|
|
||||||
DEFAULT: "hsl(var(--secondary))",
|
|
||||||
foreground: "hsl(var(--secondary-foreground))",
|
|
||||||
},
|
|
||||||
muted: {
|
|
||||||
DEFAULT: "hsl(var(--muted))",
|
|
||||||
foreground: "hsl(var(--muted-foreground))",
|
|
||||||
},
|
|
||||||
accent: {
|
|
||||||
DEFAULT: "hsl(var(--accent))",
|
|
||||||
foreground: "hsl(var(--accent-foreground))",
|
|
||||||
},
|
|
||||||
destructive: {
|
|
||||||
DEFAULT: "hsl(var(--destructive))",
|
|
||||||
foreground: "hsl(var(--destructive-foreground))",
|
|
||||||
},
|
|
||||||
border: "hsl(var(--border))",
|
|
||||||
input: "hsl(var(--input))",
|
|
||||||
ring: "hsl(var(--ring))",
|
|
||||||
// macOS 风格系统蓝
|
// macOS 风格系统蓝
|
||||||
blue: {
|
blue: {
|
||||||
400: '#409CFF',
|
400: '#409CFF',
|
||||||
|
|||||||
@@ -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} />);
|
||||||
|
|
||||||
// When no file selected, button shows "selectConfigFile" and clicking it opens file dialog
|
expect(screen.getByText("settings.noFileSelected")).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: /settings\.selectConfigFile/ }),
|
screen.getByRole("button", { name: "settings.import" }),
|
||||||
).toBeInTheDocument();
|
).toBeDisabled();
|
||||||
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: "Clear selection" }));
|
fireEvent.click(screen.getByRole("button", { name: "common.clear" }));
|
||||||
expect(baseProps.onClear).toHaveBeenCalledTimes(1);
|
expect(baseProps.onClear).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -79,17 +79,6 @@ 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
|
||||||
@@ -267,7 +256,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.commandRequired");
|
expect(message).toBe("mcp.error.jsonInvalid");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("支持向导生成配置并自动填充 ID", async () => {
|
it("支持向导生成配置并自动填充 ID", async () => {
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ 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>;
|
||||||
}
|
}
|
||||||
@@ -67,7 +66,6 @@ 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(),
|
||||||
};
|
};
|
||||||
@@ -249,8 +247,7 @@ 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();
|
||||||
// 加载状态下显示 spinner 而不是表单内容
|
expect(screen.getByText("settings.title")).toBeInTheDocument();
|
||||||
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", () => {
|
||||||
@@ -267,7 +264,6 @@ 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",
|
||||||
});
|
});
|
||||||
@@ -288,20 +284,21 @@ describe("SettingsPage Component", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("settings.tabAdvanced"));
|
fireEvent.click(screen.getByText("settings.tabAdvanced"));
|
||||||
|
|
||||||
// 有文件时,点击导入按钮执行 importConfig
|
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole("button", { name: /settings\.import/ }),
|
screen.getByRole("button", { name: "settings.selectConfigFile" }),
|
||||||
);
|
);
|
||||||
expect(importExportMock.importConfig).toHaveBeenCalled();
|
|
||||||
|
expect(importExportMock.selectImportFile).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" }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Clear selection" }));
|
expect(importExportMock.importConfig).toHaveBeenCalled();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "common.clear" }));
|
||||||
expect(importExportMock.clearSelection).toHaveBeenCalled();
|
expect(importExportMock.clearSelection).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -333,9 +330,7 @@ describe("SettingsPage Component", () => {
|
|||||||
|
|
||||||
render(<SettingsPage open={true} onOpenChange={onOpenChange} />);
|
render(<SettingsPage open={true} onOpenChange={onOpenChange} />);
|
||||||
|
|
||||||
// 保存按钮在 advanced tab 中
|
fireEvent.click(screen.getByText("common.save"));
|
||||||
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);
|
||||||
@@ -346,6 +341,20 @@ 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,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ 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();
|
||||||
|
|
||||||
@@ -53,9 +52,6 @@ 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> = {}) => ({
|
||||||
|
|||||||
@@ -58,29 +58,6 @@ 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,
|
||||||
|
|||||||
@@ -109,8 +109,8 @@ vi.mock("@/components/ConfirmDialog", () => ({
|
|||||||
) : null,
|
) : null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/components/settings/SettingsPage", () => ({
|
vi.mock("@/components/settings/SettingsDialog", () => ({
|
||||||
SettingsPage: ({ open, onOpenChange, onImportSuccess }: any) =>
|
SettingsDialog: ({ open, onOpenChange, onImportSuccess }: any) =>
|
||||||
open ? (
|
open ? (
|
||||||
<div data-testid="settings-dialog">
|
<div data-testid="settings-dialog">
|
||||||
<button onClick={() => onImportSuccess?.()}>
|
<button onClick={() => onImportSuccess?.()}>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||