mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 13:45:03 +08:00
Compare commits
91 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de0758eba4 | |||
| 22552f7a7f | |||
| 1c6689a0bc | |||
| 9404341f14 | |||
| 53dd0a90f3 | |||
| 779fefd86d | |||
| 096c1d57c4 | |||
| adb868d0cf | |||
| a6ad896db0 | |||
| d6cf4390ac | |||
| 0ef670325b | |||
| 07fc6b175e | |||
| 79d3ecc1b8 | |||
| 9c249d9486 | |||
| 15f60e8ce2 | |||
| 3d733a3b80 | |||
| cfb113ac8d | |||
| 62a4ab2ad3 | |||
| aa0f191420 | |||
| b8305f281b | |||
| b3af191fab | |||
| e6b6bc18a9 | |||
| b1f7ff4768 | |||
| 09d8c27972 | |||
| 9736861ace | |||
| fef750bb4c | |||
| 3677bb61f5 | |||
| 2d17bde790 | |||
| dc865fbbbf | |||
| a187380d6f | |||
| b993b1f664 | |||
| 00168877d9 | |||
| 4496110dd8 | |||
| c9e85e8cac | |||
| eab1d08527 | |||
| 30009ad5f1 | |||
| e7badb1a24 | |||
| 7bb458eecb | |||
| 76897e2b97 | |||
| fb9e7dee50 | |||
| e1d4dd7f55 | |||
| c847fff768 | |||
| 1eb0a0d7ac | |||
| 3bd3845ec0 | |||
| b0d0a2c466 | |||
| 73013c10af | |||
| 1a0872c153 | |||
| 255a7f570a | |||
| fb44fb136f | |||
| 58d3bb89d2 | |||
| 403227c901 | |||
| 5bcf5bf382 | |||
| 6ce6f16a99 | |||
| ad6f5b388b | |||
| 2844f7c557 | |||
| 882c73234f | |||
| b70de25de4 | |||
| 88dbeb5335 | |||
| 966d7b5782 | |||
| 42a92c712a | |||
| 2f0998c6c8 | |||
| 938e2eb563 | |||
| 2cc36b3950 | |||
| e06c6176d9 | |||
| 9b4485e111 | |||
| f349d85e85 | |||
| 58a13cc69a | |||
| d765364a18 | |||
| 5c6956b6e2 | |||
| cb1b45ae4e | |||
| e4df1a32a5 | |||
| 2494eaaa32 | |||
| 45b9cf1df0 | |||
| de3a22535d | |||
| 36d6d48002 | |||
| 864884926a | |||
| 093ff0ba29 | |||
| 21754a7349 | |||
| 7997b2c7b3 | |||
| b8538b211d | |||
| 7ea2c3452b | |||
| a30d72bb68 | |||
| 58ecc44ee6 | |||
| 5658d93924 | |||
| e4d24f2df9 | |||
| 07d022ba9f | |||
| f3343992f2 | |||
| 53f40b2d7a | |||
| 1393f89797 | |||
| f9d80b8dc3 | |||
| 8b92982112 |
@@ -9,6 +9,97 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [3.10.2] - 2026-01-24
|
||||||
|
|
||||||
|
### Patch Release
|
||||||
|
|
||||||
|
This maintenance release adds skill sync options and includes important bug fixes.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Skills**: Add skill sync method setting with symlink/copy options
|
||||||
|
- **Partners**: Add RightCode as official partner
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Prompts**: Clear prompt file when all prompts are disabled
|
||||||
|
- **OpenCode**: Preserve extra model fields during serialization
|
||||||
|
- **Provider Form**: Backfill model fields when editing Claude provider
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.10.1] - 2026-01-23
|
||||||
|
|
||||||
|
### Patch Release
|
||||||
|
|
||||||
|
This maintenance release includes important bug fixes for Windows platform, UI improvements, and code quality enhancements.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Provider Icons**: Updated RightCode provider icon with improved visual design
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Proxy Rectifier**: Changed rectifier default state to disabled for better stability
|
||||||
|
- **Window Settings**: Reordered window settings and updated default values for improved UX
|
||||||
|
- **UI Layout**: Increased app icon collapse threshold from 3 to 4 icons
|
||||||
|
- **Code Quality**: Simplified `RectifierConfig` implementation using `#[derive(Default)]`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Windows Platform**:
|
||||||
|
- Fixed terminal window closing immediately after execution on Windows
|
||||||
|
- Corrected OpenCode config path resolution on Windows
|
||||||
|
- **UI Improvements**:
|
||||||
|
- Fixed ProviderIcon color validation to prevent black icons from appearing
|
||||||
|
- Unified layout padding across all panels for consistent spacing
|
||||||
|
- Fixed panel content alignment with header constraints
|
||||||
|
- **Code Quality**: Resolved Rust Clippy warnings and applied consistent formatting
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.10.0] - 2026-01-21
|
||||||
|
|
||||||
|
### Feature Release
|
||||||
|
|
||||||
|
This release introduces OpenCode support and brings improvements across proxy, usage tracking, and overall UX.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **OpenCode Support** - Manage OpenCode providers, MCP servers, and Skills, with first-launch import and full internationalization (#695)
|
||||||
|
- **Global Proxy** - Add global proxy settings for outbound network requests (#596)
|
||||||
|
- **Claude Rectifier** - Add thinking signature rectifier for Claude API (#595)
|
||||||
|
- **Health Check Enhancements** - Configurable prompt and CLI-compatible requests for stream health check (#623)
|
||||||
|
- **Per-Provider Config** - Support provider-specific configuration and persistence (#663)
|
||||||
|
- **App Visibility Controls** - Show/hide apps and keep tray menu in sync (Gemini hidden by default)
|
||||||
|
- **Takeover Compact Mode** - Use a compact AppSwitcher layout when showing 3+ visible apps
|
||||||
|
- **Keyboard Shortcut** - Press `ESC` to quickly go back/close panels (#670)
|
||||||
|
- **Terminal Improvements** - Provider-specific terminal button, `fnm` path support, and safer cross-platform launching (#564)
|
||||||
|
- **WSL Tool Detection** - Detect tool versions in WSL with additional security hardening (#627)
|
||||||
|
- **Skills Presets** - Add `baoyu-skills` preset repo and auto-supplement missing default repos
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Proxy Logging** - Simplify proxy log output (#585)
|
||||||
|
- **Pricing Editor UX** - Unify pricing edit modal with `FullScreenPanel`
|
||||||
|
- **Advanced Settings Layout** - Move rectifier section below failover for better flow
|
||||||
|
- **OpenRouter Compat Mode** - Disable OpenRouter compatibility mode by default and hide UI toggle
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Auto Failover** - Switch to P1 immediately when enabling auto failover
|
||||||
|
- **Provider Edit Dialog** - Fix stale data when reopening provider editor after save (#654)
|
||||||
|
- **Deeplink** - Support multiple endpoints and prioritize `GOOGLE_GEMINI_BASE_URL` over `GEMINI_BASE_URL` (#597)
|
||||||
|
- **MCP (WSL)** - Skip `cmd /c` wrapper for WSL target paths (#592)
|
||||||
|
- **Usage Templates** - Add variable hints and validation fixes; prevent config leaking between providers (#628)
|
||||||
|
- **Gemini Timeout Format** - Convert timeout params to Gemini CLI format (#580)
|
||||||
|
- **UI** - Fix Select dropdown rendering in `FullScreenPanel`; auto-apply default icon color when unset
|
||||||
|
- **Usage UI** - Auto-adapt usage block offset based on action buttons width (#613)
|
||||||
|
- **Provider Endpoint** - Persist endpoint auto-select state (#611)
|
||||||
|
- **Provider Form** - Reset baseUrl and apiKey states when switching presets
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [3.9.1] - 2026-01-09
|
## [3.9.1] - 2026-01-09
|
||||||
|
|
||||||
### Bug Fix Release
|
### Bug Fix Release
|
||||||
|
|||||||
@@ -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/farion1231/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://tauri.app/)
|
[](https://tauri.app/)
|
||||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||||
@@ -52,7 +52,7 @@ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Current Version: v3.9.1 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.9.0-en.md)
|
### Current Version: v3.10.2 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.9.0-en.md)
|
||||||
|
|
||||||
**v3.8.0 Major Update (2025-11-28)**
|
**v3.8.0 Major Update (2025-11-28)**
|
||||||
|
|
||||||
@@ -181,7 +181,7 @@ Download `CC-Switch-v{version}-macOS.zip` from the [Releases](../../releases) pa
|
|||||||
|
|
||||||
> **Note**: Since the author doesn't have an Apple Developer account, you may see an "unidentified developer" warning on first launch. Please close it first, then go to "System Settings" → "Privacy & Security" → click "Open Anyway", and you'll be able to open it normally afterwards.
|
> **Note**: Since the author doesn't have an Apple Developer account, you may see an "unidentified developer" warning on first launch. Please close it first, then go to "System Settings" → "Privacy & Security" → click "Open Anyway", and you'll be able to open it normally afterwards.
|
||||||
|
|
||||||
### ArchLinux 用户
|
### Arch Linux Users
|
||||||
|
|
||||||
**Install via paru (Recommended)**
|
**Install via paru (Recommended)**
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -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/farion1231/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://tauri.app/)
|
[](https://tauri.app/)
|
||||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
|
|
||||||
## 特長
|
## 特長
|
||||||
|
|
||||||
### 現在のバージョン:v3.9.1 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.9.0-ja.md)
|
### 現在のバージョン:v3.10.2 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.9.0-ja.md)
|
||||||
|
|
||||||
**v3.8.0 メジャーアップデート (2025-11-28)**
|
**v3.8.0 メジャーアップデート (2025-11-28)**
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -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/farion1231/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://tauri.app/)
|
[](https://tauri.app/)
|
||||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
|
|
||||||
## 功能特性
|
## 功能特性
|
||||||
|
|
||||||
### 当前版本:v3.9.1 | [完整更新日志](CHANGELOG.md) | [发布说明](docs/release-note-v3.9.0-zh.md)
|
### 当前版本:v3.10.2 | [完整更新日志](CHANGELOG.md) | [发布说明](docs/release-note-v3.9.0-zh.md)
|
||||||
|
|
||||||
**v3.8.0 重大更新(2025-11-28)**
|
**v3.8.0 重大更新(2025-11-28)**
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,485 @@
|
|||||||
|
# OpenCode 第四应用支持实现计划
|
||||||
|
|
||||||
|
> **范围说明**:本计划暂不包含统一供应商(UniversalProvider)对 OpenCode 的支持,以降低初期实现复杂度。
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
为 CC Switch 添加 OpenCode 支持,这是第四个受管理的 CLI 应用。OpenCode 的核心差异在于采用**累加式**供应商管理(多供应商共存,应用内热切换),而非现有三应用的**替换式**管理。
|
||||||
|
|
||||||
|
## 关键设计决策
|
||||||
|
|
||||||
|
| 特性 | Claude/Codex/Gemini | OpenCode |
|
||||||
|
|------|---------------------|----------|
|
||||||
|
| 供应商模式 | 替换式(单一活跃) | 累加式(多供应商共存) |
|
||||||
|
| UI 按钮 | 启用/切换 | 添加/删除 |
|
||||||
|
| is_current | 需要 | 不需要 |
|
||||||
|
| 代理/故障转移 | 支持 | 不支持 |
|
||||||
|
| API 格式字段 | 无 | 需要(npm 包名) |
|
||||||
|
| 配置文件 | 各自独立 | `~/.config/opencode/opencode.json` |
|
||||||
|
|
||||||
|
## 配置文件格式
|
||||||
|
|
||||||
|
### 供应商配置
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"provider": {
|
||||||
|
"provider-id": {
|
||||||
|
"npm": "@ai-sdk/openai-compatible",
|
||||||
|
"name": "Provider Name",
|
||||||
|
"options": {
|
||||||
|
"baseURL": "https://api.example.com/v1",
|
||||||
|
"apiKey": "{env:API_KEY}"
|
||||||
|
},
|
||||||
|
"models": {
|
||||||
|
"model-id": { "name": "Model Name" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### MCP 配置
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcp": {
|
||||||
|
"remote-server": {
|
||||||
|
"type": "remote",
|
||||||
|
"url": "https://example.com/mcp",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"local-server": {
|
||||||
|
"type": "local",
|
||||||
|
"command": ["npx", "-y", "my-mcp-command"],
|
||||||
|
"enabled": true,
|
||||||
|
"environment": { "KEY": "value" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 实现步骤
|
||||||
|
|
||||||
|
### Phase 1: 后端数据结构扩展
|
||||||
|
|
||||||
|
#### 1.1 AppType 枚举扩展
|
||||||
|
**文件**: `src-tauri/src/app_config.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum AppType {
|
||||||
|
Claude,
|
||||||
|
Codex,
|
||||||
|
Gemini,
|
||||||
|
OpenCode, // 新增
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.2 McpApps / SkillApps 扩展
|
||||||
|
**文件**: `src-tauri/src/app_config.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct McpApps {
|
||||||
|
pub claude: bool,
|
||||||
|
pub codex: bool,
|
||||||
|
pub gemini: bool,
|
||||||
|
pub opencode: bool, // 新增
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SkillApps {
|
||||||
|
pub claude: bool,
|
||||||
|
pub codex: bool,
|
||||||
|
pub gemini: bool,
|
||||||
|
pub opencode: bool, // 新增
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.3 数据库 Schema 迁移
|
||||||
|
**文件**: `src-tauri/src/database/schema.rs`
|
||||||
|
|
||||||
|
- `SCHEMA_VERSION` 递增
|
||||||
|
- 添加迁移:
|
||||||
|
```sql
|
||||||
|
ALTER TABLE mcp_servers ADD COLUMN enabled_opencode BOOLEAN NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE skills ADD COLUMN enabled_opencode BOOLEAN NOT NULL DEFAULT 0;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 2: OpenCode 供应商数据结构
|
||||||
|
|
||||||
|
#### 2.1 OpenCode 专属配置结构
|
||||||
|
**文件**: `src-tauri/src/provider.rs`(或新建 `opencode_provider.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// OpenCode 供应商的 settings_config 结构
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OpenCodeProviderConfig {
|
||||||
|
/// AI SDK 包名,如 "@ai-sdk/openai-compatible"
|
||||||
|
pub npm: String,
|
||||||
|
/// 供应商选项
|
||||||
|
pub options: OpenCodeProviderOptions,
|
||||||
|
/// 模型定义
|
||||||
|
pub models: HashMap<String, OpenCodeModel>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OpenCodeProviderOptions {
|
||||||
|
#[serde(rename = "baseURL", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub base_url: Option<String>,
|
||||||
|
#[serde(rename = "apiKey", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub api_key: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub headers: Option<HashMap<String, String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OpenCodeModel {
|
||||||
|
pub name: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub limit: Option<OpenCodeModelLimit>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OpenCodeModelLimit {
|
||||||
|
pub context: Option<u64>,
|
||||||
|
pub output: Option<u64>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 3: OpenCode Live 配置读写
|
||||||
|
|
||||||
|
#### 3.1 新建 OpenCode 配置模块
|
||||||
|
**文件**: `src-tauri/src/opencode_config.rs`
|
||||||
|
|
||||||
|
核心功能:
|
||||||
|
- `get_opencode_config_path()` → `~/.config/opencode/opencode.json`
|
||||||
|
- `read_opencode_config()` → 读取整个配置文件
|
||||||
|
- `write_opencode_config()` → 原子写入配置文件
|
||||||
|
- `get_providers()` → 获取 `provider` 对象
|
||||||
|
- `set_provider(id, config)` → 添加/更新供应商
|
||||||
|
- `remove_provider(id)` → 删除供应商
|
||||||
|
- `get_mcp_servers()` → 获取 `mcp` 对象
|
||||||
|
- `set_mcp_server(id, config)` → 添加/更新 MCP 服务器
|
||||||
|
- `remove_mcp_server(id)` → 删除 MCP 服务器
|
||||||
|
|
||||||
|
### Phase 4: MCP 同步模块
|
||||||
|
|
||||||
|
#### 4.1 新建 OpenCode MCP 同步
|
||||||
|
**文件**: `src-tauri/src/mcp/opencode.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// 同步所有 enabled_opencode=true 的服务器到 OpenCode 配置
|
||||||
|
pub fn sync_enabled_to_opencode(config: &MultiAppConfig) -> Result<(), AppError>
|
||||||
|
|
||||||
|
/// 同步单个服务器
|
||||||
|
pub fn sync_single_server_to_opencode(
|
||||||
|
config: &MultiAppConfig,
|
||||||
|
id: &str,
|
||||||
|
server_spec: &Value
|
||||||
|
) -> Result<(), AppError>
|
||||||
|
|
||||||
|
/// 从 OpenCode 配置移除服务器
|
||||||
|
pub fn remove_server_from_opencode(id: &str) -> Result<(), AppError>
|
||||||
|
|
||||||
|
/// 从 OpenCode 配置导入服务器
|
||||||
|
pub fn import_from_opencode(config: &mut MultiAppConfig) -> Result<usize, AppError>
|
||||||
|
```
|
||||||
|
|
||||||
|
**格式转换**:
|
||||||
|
| CC Switch 统一格式 | OpenCode 格式 |
|
||||||
|
|-------------------|---------------|
|
||||||
|
| `type: "stdio"` | `type: "local"` |
|
||||||
|
| `command` + `args` | `command: [cmd, ...args]` |
|
||||||
|
| `env` | `environment` |
|
||||||
|
| `type: "sse"/"http"` | `type: "remote"` |
|
||||||
|
| `url` | `url` |
|
||||||
|
|
||||||
|
### Phase 5: 供应商服务层
|
||||||
|
|
||||||
|
#### 5.1 OpenCode 供应商服务
|
||||||
|
**文件**: `src-tauri/src/services/provider/opencode.rs`
|
||||||
|
|
||||||
|
核心方法:
|
||||||
|
```rust
|
||||||
|
/// 获取所有 OpenCode 供应商
|
||||||
|
pub fn list(state: &AppState) -> Result<IndexMap<String, Provider>, AppError>
|
||||||
|
|
||||||
|
/// 添加供应商(同时写入 live 配置)
|
||||||
|
pub fn add(state: &AppState, provider: Provider) -> Result<bool, AppError>
|
||||||
|
|
||||||
|
/// 更新供应商
|
||||||
|
pub fn update(state: &AppState, provider: Provider) -> Result<bool, AppError>
|
||||||
|
|
||||||
|
/// 删除供应商(同时从 live 配置移除)
|
||||||
|
pub fn delete(state: &AppState, id: &str) -> Result<(), AppError>
|
||||||
|
|
||||||
|
/// 从 live 配置导入供应商到数据库
|
||||||
|
pub fn import_from_live(state: &AppState) -> Result<usize, AppError>
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键差异**:
|
||||||
|
- 不需要 `switch()` 方法
|
||||||
|
- 不需要 `is_current` 管理
|
||||||
|
- `add()` 自动写入 live
|
||||||
|
- `delete()` 自动从 live 移除
|
||||||
|
|
||||||
|
### Phase 6: Tauri 命令扩展
|
||||||
|
|
||||||
|
#### 6.1 更新现有命令
|
||||||
|
**文件**: `src-tauri/src/commands/providers.rs`
|
||||||
|
|
||||||
|
- 所有命令支持 `app_type = "opencode"`
|
||||||
|
- OpenCode 特定逻辑分支
|
||||||
|
|
||||||
|
#### 6.2 新增 OpenCode 专属命令(如需要)
|
||||||
|
```rust
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn opencode_sync_all_providers(state: State<'_, AppState>) -> Result<(), AppError>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 7: 前端类型定义
|
||||||
|
|
||||||
|
#### 7.1 TypeScript 类型扩展
|
||||||
|
**文件**: `src/types.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// AppId 扩展
|
||||||
|
type AppId = "claude" | "codex" | "gemini" | "opencode";
|
||||||
|
|
||||||
|
// OpenCode 专属配置
|
||||||
|
interface OpenCodeProviderConfig {
|
||||||
|
npm: string; // AI SDK 包名
|
||||||
|
options: {
|
||||||
|
baseURL?: string;
|
||||||
|
apiKey?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
};
|
||||||
|
models: Record<string, OpenCodeModel>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenCodeModel {
|
||||||
|
name: string;
|
||||||
|
limit?: {
|
||||||
|
context?: number;
|
||||||
|
output?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 7.2 MCP 应用状态扩展
|
||||||
|
**文件**: `src/types.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface McpApps {
|
||||||
|
claude: boolean;
|
||||||
|
codex: boolean;
|
||||||
|
gemini: boolean;
|
||||||
|
opencode: boolean; // 新增
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 8: 前端预设配置
|
||||||
|
|
||||||
|
#### 8.1 新建 OpenCode 供应商预设
|
||||||
|
**文件**: `src/config/opencodeProviderPresets.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const opencodeProviderPresets: ProviderPreset[] = [
|
||||||
|
{
|
||||||
|
name: "OpenAI",
|
||||||
|
npmPackage: "@ai-sdk/openai",
|
||||||
|
settingsConfig: {
|
||||||
|
npm: "@ai-sdk/openai",
|
||||||
|
options: { apiKey: "{env:OPENAI_API_KEY}" },
|
||||||
|
models: {
|
||||||
|
"gpt-4o": { name: "GPT-4o" },
|
||||||
|
"gpt-4o-mini": { name: "GPT-4o Mini" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
theme: { icon: "openai", iconColor: "#00A67E" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Anthropic",
|
||||||
|
npmPackage: "@ai-sdk/anthropic",
|
||||||
|
settingsConfig: {
|
||||||
|
npm: "@ai-sdk/anthropic",
|
||||||
|
options: { apiKey: "{env:ANTHROPIC_API_KEY}" },
|
||||||
|
models: {
|
||||||
|
"claude-sonnet-4-20250514": { name: "Claude Sonnet 4" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "OpenAI Compatible",
|
||||||
|
npmPackage: "@ai-sdk/openai-compatible",
|
||||||
|
settingsConfig: {
|
||||||
|
npm: "@ai-sdk/openai-compatible",
|
||||||
|
options: {
|
||||||
|
baseURL: "",
|
||||||
|
apiKey: "{env:API_KEY}",
|
||||||
|
},
|
||||||
|
models: {},
|
||||||
|
},
|
||||||
|
isCustomTemplate: true,
|
||||||
|
},
|
||||||
|
// ... 更多预设
|
||||||
|
];
|
||||||
|
|
||||||
|
// npm 包选项
|
||||||
|
export const opencodeNpmPackages = [
|
||||||
|
{ value: "@ai-sdk/openai", label: "OpenAI" },
|
||||||
|
{ value: "@ai-sdk/anthropic", label: "Anthropic" },
|
||||||
|
{ value: "@ai-sdk/openai-compatible", label: "OpenAI Compatible" },
|
||||||
|
{ value: "@ai-sdk/google", label: "Google" },
|
||||||
|
{ value: "@ai-sdk/azure", label: "Azure OpenAI" },
|
||||||
|
{ value: "@ai-sdk/amazon-bedrock", label: "Amazon Bedrock" },
|
||||||
|
// ... 更多选项
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 9: 前端 UI 组件
|
||||||
|
|
||||||
|
#### 9.1 OpenCode 供应商表单
|
||||||
|
**文件**: `src/components/providers/forms/OpenCodeFormFields.tsx`
|
||||||
|
|
||||||
|
新增字段:
|
||||||
|
- npm 包选择器(下拉框 + 自定义输入)
|
||||||
|
- options 编辑器(baseURL, apiKey, headers)
|
||||||
|
- models 编辑器(动态添加/删除模型)
|
||||||
|
|
||||||
|
#### 9.2 供应商卡片按钮适配
|
||||||
|
**文件**: `src/components/providers/ProviderActions.tsx`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// OpenCode 使用不同的主按钮
|
||||||
|
if (appId === "opencode") {
|
||||||
|
return (
|
||||||
|
<Button onClick={onAdd}>
|
||||||
|
{isInConfig ? t("provider.removeFromConfig") : t("provider.addToConfig")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 9.3 隐藏 OpenCode 不需要的功能
|
||||||
|
|
||||||
|
在以下组件中检查 `appId !== "opencode"`:
|
||||||
|
- 代理设置面板
|
||||||
|
- 故障转移队列
|
||||||
|
- 供应商切换逻辑
|
||||||
|
|
||||||
|
### Phase 10: 国际化
|
||||||
|
|
||||||
|
#### 10.1 新增翻译 Key
|
||||||
|
**文件**: `src/locales/zh/translation.json` & `en/translation.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"app.opencode": "OpenCode",
|
||||||
|
"provider.addToConfig": "添加到配置",
|
||||||
|
"provider.removeFromConfig": "从配置移除",
|
||||||
|
"provider.inConfig": "已添加",
|
||||||
|
"provider.npmPackage": "AI SDK 包",
|
||||||
|
"provider.models": "模型配置",
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 关键文件清单
|
||||||
|
|
||||||
|
### 后端(Rust)
|
||||||
|
| 操作 | 文件路径 |
|
||||||
|
|------|---------|
|
||||||
|
| 修改 | `src-tauri/src/app_config.rs` |
|
||||||
|
| 修改 | `src-tauri/src/database/schema.rs` |
|
||||||
|
| 修改 | `src-tauri/src/database/dao/mcp.rs` |
|
||||||
|
| 修改 | `src-tauri/src/database/dao/providers.rs` |
|
||||||
|
| 修改 | `src-tauri/src/services/provider/mod.rs` |
|
||||||
|
| 修改 | `src-tauri/src/services/mcp.rs` |
|
||||||
|
| 修改 | `src-tauri/src/commands/providers.rs` |
|
||||||
|
| 修改 | `src-tauri/src/commands/mcp.rs` |
|
||||||
|
| 修改 | `src-tauri/src/mcp/mod.rs` |
|
||||||
|
| 新建 | `src-tauri/src/opencode_config.rs` |
|
||||||
|
| 新建 | `src-tauri/src/mcp/opencode.rs` |
|
||||||
|
| 新建 | `src-tauri/src/services/provider/opencode.rs` |
|
||||||
|
|
||||||
|
### 前端(TypeScript/React)
|
||||||
|
| 操作 | 文件路径 |
|
||||||
|
|------|---------|
|
||||||
|
| 修改 | `src/types.ts` |
|
||||||
|
| 修改 | `src/lib/api/types.ts` |
|
||||||
|
| 修改 | `src/lib/api/providers.ts` |
|
||||||
|
| 修改 | `src/components/providers/ProviderActions.tsx` |
|
||||||
|
| 修改 | `src/components/providers/ProviderCard.tsx` |
|
||||||
|
| 修改 | `src/components/providers/AddProviderDialog.tsx` |
|
||||||
|
| 修改 | `src/components/providers/forms/ProviderForm.tsx` |
|
||||||
|
| 修改 | `src/App.tsx` |
|
||||||
|
| 新建 | `src/config/opencodeProviderPresets.ts` |
|
||||||
|
| 新建 | `src/components/providers/forms/OpenCodeFormFields.tsx` |
|
||||||
|
|
||||||
|
### 国际化
|
||||||
|
| 操作 | 文件路径 |
|
||||||
|
|------|---------|
|
||||||
|
| 修改 | `src/locales/zh/translation.json` |
|
||||||
|
| 修改 | `src/locales/en/translation.json` |
|
||||||
|
| 修改 | `src/locales/ja/translation.json` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验证计划
|
||||||
|
|
||||||
|
### 单元测试
|
||||||
|
1. OpenCode 配置读写测试
|
||||||
|
2. MCP 格式转换测试(stdio ↔ local, sse ↔ remote)
|
||||||
|
3. 供应商 CRUD 操作测试
|
||||||
|
|
||||||
|
### 集成测试
|
||||||
|
1. 添加 OpenCode 供应商 → 验证写入 `~/.config/opencode/opencode.json`
|
||||||
|
2. 删除供应商 → 验证从配置文件移除
|
||||||
|
3. MCP 同步测试 → 验证格式正确转换
|
||||||
|
4. 从 live 配置导入 → 验证正确解析
|
||||||
|
|
||||||
|
### 手动测试
|
||||||
|
1. UI 流程:添加预设 → 编辑 → 删除
|
||||||
|
2. 切换应用 Tab → OpenCode 显示正确的 UI(无代理/故障转移)
|
||||||
|
3. 托盘菜单正确显示 OpenCode 供应商
|
||||||
|
4. 深链接导入 OpenCode 供应商
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 风险评估
|
||||||
|
|
||||||
|
1. **数据库迁移**:需要在升级时自动执行 `ALTER TABLE` 语句
|
||||||
|
2. **配置文件冲突**:OpenCode 可能有自己的配置,需要合并而非覆盖
|
||||||
|
3. **MCP 格式差异**:`stdio` → `local` 转换需要处理边界情况
|
||||||
|
4. **UI 一致性**:OpenCode 的"添加/删除"模式需要与其他应用的"启用/切换"清晰区分
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充说明
|
||||||
|
|
||||||
|
### 托盘菜单特殊处理
|
||||||
|
|
||||||
|
由于 OpenCode 采用累加式管理,托盘菜单行为需要调整:
|
||||||
|
|
||||||
|
- **现有三应用**:托盘菜单显示 `CheckMenuItem`(单选,切换当前供应商)
|
||||||
|
- **OpenCode**:显示当前所有启用的供应商(普通 MenuItem,无勾选逻辑),点击打开主界面
|
||||||
|
|
||||||
|
**修改文件**:`src-tauri/src/tray.rs`(`TRAY_SECTIONS` 常量)
|
||||||
|
|
||||||
|
### 数据库约束更新
|
||||||
|
|
||||||
|
`proxy_config` 表的 CHECK 约束需要扩展:
|
||||||
|
```sql
|
||||||
|
CHECK (app_type IN ('claude','codex','gemini','opencode'))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Settings 结构体扩展
|
||||||
|
|
||||||
|
**文件**:`src-tauri/src/settings.rs`
|
||||||
|
|
||||||
|
需要添加:
|
||||||
|
- `current_provider_opencode: Option<String>` - 对 OpenCode 可能无意义,但保持结构一致
|
||||||
|
- `opencode_config_dir: Option<String>` - 自定义配置目录
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
# CC Switch v3.10.0
|
||||||
|
|
||||||
|
> OpenCode Support, Global Proxy, Claude Rectifier & Multi-App Experience Enhancements
|
||||||
|
|
||||||
|
**[中文版 →](release-note-v3.10.0-zh.md) | [日本語版 →](release-note-v3.10.0-ja.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
CC Switch v3.10.0 introduces OpenCode support, becoming the fourth managed CLI application.
|
||||||
|
This release also brings global proxy settings, Claude Rectifier (thinking signature fixer), enhanced health checks, per-provider configuration, and many other important features, along with comprehensive improvements to multi-app workflows and terminal experience.
|
||||||
|
|
||||||
|
**Release Date**: 2026-01-21
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Highlights
|
||||||
|
|
||||||
|
- OpenCode Support: Full management of providers, MCP servers, and Skills with auto-import on first launch
|
||||||
|
- Global Proxy: Configure a unified proxy for all outbound network requests
|
||||||
|
- Claude Rectifier: Thinking signature fixer for better compatibility with third-party APIs
|
||||||
|
- Enhanced Health Checks: Configurable prompts and CLI-compatible request format
|
||||||
|
- Per-Provider Config: Persistent provider-specific configuration support
|
||||||
|
- App Visibility Control: Freely show/hide apps with synchronized tray menu updates
|
||||||
|
- Terminal Improvements: Provider-specific terminal buttons, fnm path support, cross-platform safe launch
|
||||||
|
- WSL Tool Detection: Detect tool versions in WSL environment with security hardening
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Main Features
|
||||||
|
|
||||||
|
### OpenCode Support (New Fourth App)
|
||||||
|
|
||||||
|
- Complete OpenCode provider management: add, edit, switch, delete
|
||||||
|
- MCP server management: unified architecture with Claude/Codex/Gemini
|
||||||
|
- Skills support: OpenCode can also use Skills functionality
|
||||||
|
- Auto-import on first launch: automatically imports existing OpenCode configuration when detected
|
||||||
|
- Full internationalization: Chinese/English/Japanese support (#695)
|
||||||
|
|
||||||
|
### Global Proxy
|
||||||
|
|
||||||
|
- Configure a unified proxy for all outbound network requests (#596, thanks @yovinchen)
|
||||||
|
- Supports HTTP/HTTPS proxy protocols
|
||||||
|
- Suitable for network environments requiring proxy access to external APIs
|
||||||
|
|
||||||
|
### Claude Rectifier (Thinking Signature Fixer)
|
||||||
|
|
||||||
|
- Automatically fixes Claude API thinking signatures (#595, thanks @yovinchen)
|
||||||
|
- Resolves incompatible thinking block formats returned by some third-party API gateways
|
||||||
|
- Can be enabled/disabled in Advanced Settings
|
||||||
|
|
||||||
|
### Enhanced Health Checks
|
||||||
|
|
||||||
|
- Configurable custom prompts for streaming health checks (#623, thanks @yovinchen)
|
||||||
|
- Supports CLI-compatible request format for better simulation of real usage scenarios
|
||||||
|
- Improves fault detection accuracy
|
||||||
|
|
||||||
|
### Per-Provider Config
|
||||||
|
|
||||||
|
- Support for saving configuration separately for each provider (#663, thanks @yovinchen)
|
||||||
|
- Persistent configuration: provider-specific settings retained after restart
|
||||||
|
- Suitable for scenarios where different providers require different configurations
|
||||||
|
|
||||||
|
### App Visibility Control
|
||||||
|
|
||||||
|
- Freely show/hide any app (Gemini hidden by default)
|
||||||
|
- Tray menu automatically syncs visibility settings
|
||||||
|
- Hidden apps won't appear in the main interface or tray menu
|
||||||
|
|
||||||
|
### Takeover Compact Mode
|
||||||
|
|
||||||
|
- Automatically uses compact layout when 3 or more visible apps are displayed
|
||||||
|
- Optimizes space utilization in multi-app scenarios
|
||||||
|
|
||||||
|
### Terminal Improvements
|
||||||
|
|
||||||
|
- Provider-specific terminal button: one-click to use current provider in terminal (#564, thanks @kkkman22)
|
||||||
|
- `fnm` path support: automatically recognizes Node.js paths managed by fnm
|
||||||
|
- Cross-platform safe launch: improved terminal launch logic for Windows/macOS/Linux
|
||||||
|
|
||||||
|
### WSL Tool Detection
|
||||||
|
|
||||||
|
- Detect tool versions in WSL environment (#627, thanks @yovinchen)
|
||||||
|
- Added security hardening to prevent command injection risks
|
||||||
|
|
||||||
|
### Skills Preset Enhancements
|
||||||
|
|
||||||
|
- Added `baoyu-skills` preset repository
|
||||||
|
- Automatically supplements missing default repositories for out-of-the-box experience
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Experience Improvements
|
||||||
|
|
||||||
|
- Keyboard shortcuts: Press `ESC` to quickly return/close panels (#670, thanks @xxk8)
|
||||||
|
- Simplified proxy logs: cleaner and more readable output (#585, thanks @yovinchen)
|
||||||
|
- Pricing editor UX: unified `FullScreenPanel` style
|
||||||
|
- Advanced settings layout: Rectifier section moved below Failover for better logical flow
|
||||||
|
- OpenRouter compatibility mode: disabled by default, UI toggle hidden (reduces clutter)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bug Fixes
|
||||||
|
|
||||||
|
### Proxy & Failover
|
||||||
|
|
||||||
|
- Immediately switch to P1 when auto-failover is enabled (instead of waiting for next request)
|
||||||
|
|
||||||
|
### Provider Management
|
||||||
|
|
||||||
|
- Fixed stale data when reopening provider edit dialog after save (#654, thanks @YangYongAn)
|
||||||
|
- Fixed baseUrl and apiKey state not resetting when switching presets
|
||||||
|
- Fixed endpoint auto-selection state not persisting (#611, thanks @yovinchen)
|
||||||
|
- Automatically apply default color when icon color is not set
|
||||||
|
|
||||||
|
### Deep Links
|
||||||
|
|
||||||
|
- Support multi-endpoint import (#597, thanks @yovinchen)
|
||||||
|
- Prefer `GOOGLE_GEMINI_BASE_URL` over `GEMINI_BASE_URL`
|
||||||
|
|
||||||
|
### MCP
|
||||||
|
|
||||||
|
- Skip `cmd /c` wrapper for WSL target paths (#592, thanks @cxyfer)
|
||||||
|
|
||||||
|
### Usage Templates
|
||||||
|
|
||||||
|
- Added variable hints, fixed validation issues (#628, thanks @YangYongAn)
|
||||||
|
- Prevent configuration leakage between providers
|
||||||
|
- Usage block offset automatically adapts to action button width (#613, thanks @yovinchen)
|
||||||
|
|
||||||
|
### Gemini
|
||||||
|
|
||||||
|
- Convert timeout parameters to Gemini CLI format (#580, thanks @cxyfer)
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
- Fixed Select dropdown rendering issues in `FullScreenPanel`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes & Considerations
|
||||||
|
|
||||||
|
- **OpenCode is a newly supported app**: OpenCode CLI must be installed first to use related features.
|
||||||
|
- **Global proxy affects all outbound requests**: including usage queries, health checks, and other network operations.
|
||||||
|
- **Rectifier is experimental**: can be disabled in Advanced Settings if issues occur.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Special Thanks
|
||||||
|
|
||||||
|
Thanks to @yovinchen @YangYongAn @cxyfer @xxk8 @kkkman22 @Shuimo03 for their contributions to this release!
|
||||||
|
Thanks to @libukai for designing the elegant failover-related UI!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Download & Installation
|
||||||
|
|
||||||
|
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download the appropriate version.
|
||||||
|
|
||||||
|
### System Requirements
|
||||||
|
|
||||||
|
| System | Minimum Version | Architecture |
|
||||||
|
| ------- | ------------------------------ | ----------------------------------- |
|
||||||
|
| Windows | Windows 10 or later | x64 |
|
||||||
|
| macOS | macOS 10.15 (Catalina) or later | Intel (x64) / Apple Silicon (arm64) |
|
||||||
|
| Linux | See table below | x64 |
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
| ---------------------------------------- | ---------------------------------------------------- |
|
||||||
|
| `CC-Switch-v3.10.0-Windows.msi` | **Recommended** - MSI installer with auto-update |
|
||||||
|
| `CC-Switch-v3.10.0-Windows-Portable.zip` | Portable version, extract and run, no registry write |
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
| -------------------------------- | ------------------------------------------------------------------ |
|
||||||
|
| `CC-Switch-v3.10.0-macOS.zip` | **Recommended** - Extract and drag to Applications, Universal Binary |
|
||||||
|
| `CC-Switch-v3.10.0-macOS.tar.gz` | For Homebrew installation and auto-update |
|
||||||
|
|
||||||
|
> **Note**: Since the author doesn't have an Apple Developer account, you may see an "unidentified developer" warning on first launch. Please close it, then go to "System Settings" → "Privacy & Security" → click "Open Anyway", and it will open normally afterwards.
|
||||||
|
|
||||||
|
### Homebrew (macOS)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew tap farion1231/ccswitch
|
||||||
|
brew install --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
Update:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew upgrade --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
|
||||||
|
| Distribution | Recommended Format | Installation Method |
|
||||||
|
| --------------------------------------- | ------------------ | ---------------------------------------------------------------------- |
|
||||||
|
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` or `sudo apt install ./CC-Switch-*.deb` |
|
||||||
|
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` or `sudo dnf install ./CC-Switch-*.rpm` |
|
||||||
|
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||||
|
| Arch Linux / Manjaro | `.AppImage` | Add execute permission and run directly, or use AUR |
|
||||||
|
| Other distributions / Unsure | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
# CC Switch v3.10.0
|
||||||
|
|
||||||
|
> OpenCode サポート、グローバルプロキシ、Claude Rectifier とマルチアプリ体験の強化
|
||||||
|
|
||||||
|
**[中文版 →](release-note-v3.10.0-zh.md) | [English →](release-note-v3.10.0-en.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 概要
|
||||||
|
|
||||||
|
CC Switch v3.10.0 では OpenCode サポートが追加され、4番目の管理対象 CLI アプリケーションとなりました。
|
||||||
|
また、グローバルプロキシ設定、Claude Rectifier(thinking 署名修正機能)、ヘルスチェックの強化、プロバイダー別設定など、多くの重要な機能が追加され、マルチアプリワークフローとターミナル体験が全面的に改善されました。
|
||||||
|
|
||||||
|
**リリース日**: 2026-01-21
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ハイライト
|
||||||
|
|
||||||
|
- OpenCode サポート:プロバイダー、MCP サーバー、Skills の完全管理、初回起動時の自動インポート
|
||||||
|
- グローバルプロキシ:すべての送信ネットワークリクエストに統一プロキシを設定
|
||||||
|
- Claude Rectifier:thinking 署名修正機能、サードパーティ API との互換性向上
|
||||||
|
- ヘルスチェック強化:カスタムプロンプト設定、CLI 互換リクエスト形式
|
||||||
|
- プロバイダー別設定:プロバイダー固有の設定の永続化をサポート
|
||||||
|
- アプリ表示制御:アプリの表示/非表示を自由に設定、トレイメニューと同期
|
||||||
|
- ターミナル改善:プロバイダー専用ターミナルボタン、fnm パスサポート、クロスプラットフォーム安全起動
|
||||||
|
- WSL ツール検出:WSL 環境でのツールバージョン検出とセキュリティ強化
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 主な機能
|
||||||
|
|
||||||
|
### OpenCode サポート(新しい4番目のアプリ)
|
||||||
|
|
||||||
|
- 完全な OpenCode プロバイダー管理:追加、編集、切り替え、削除
|
||||||
|
- MCP サーバー管理:Claude/Codex/Gemini と統一されたアーキテクチャ
|
||||||
|
- Skills サポート:OpenCode でも Skills 機能を使用可能
|
||||||
|
- 初回起動時の自動インポート:既存の OpenCode 設定を検出すると自動的にインポート
|
||||||
|
- 完全な国際化:中国語/英語/日本語サポート (#695)
|
||||||
|
|
||||||
|
### グローバルプロキシ
|
||||||
|
|
||||||
|
- すべての送信ネットワークリクエストに統一プロキシを設定 (#596、@yovinchen に感謝)
|
||||||
|
- HTTP/HTTPS プロキシプロトコルをサポート
|
||||||
|
- 外部 API へのプロキシアクセスが必要なネットワーク環境に適用
|
||||||
|
|
||||||
|
### Claude Rectifier(Thinking 署名修正機能)
|
||||||
|
|
||||||
|
- Claude API の thinking 署名を自動修正 (#595、@yovinchen に感謝)
|
||||||
|
- 一部のサードパーティ API ゲートウェイが返す互換性のない thinking ブロック形式を解決
|
||||||
|
- 詳細設定で有効/無効を切り替え可能
|
||||||
|
|
||||||
|
### ヘルスチェック強化
|
||||||
|
|
||||||
|
- ストリーミングヘルスチェック用のカスタムプロンプトを設定可能 (#623、@yovinchen に感謝)
|
||||||
|
- CLI 互換リクエスト形式をサポートし、実際の使用シナリオをより良くシミュレート
|
||||||
|
- 障害検出の精度を向上
|
||||||
|
|
||||||
|
### プロバイダー別設定
|
||||||
|
|
||||||
|
- 各プロバイダーごとに設定を個別に保存可能 (#663、@yovinchen に感謝)
|
||||||
|
- 設定の永続化:再起動後もプロバイダー固有の設定を保持
|
||||||
|
- 異なるプロバイダーに異なる設定が必要なシナリオに適用
|
||||||
|
|
||||||
|
### アプリ表示制御
|
||||||
|
|
||||||
|
- 任意のアプリを自由に表示/非表示(Gemini はデフォルトで非表示)
|
||||||
|
- トレイメニューは表示設定と自動的に同期
|
||||||
|
- 非表示のアプリはメインインターフェースとトレイメニューに表示されない
|
||||||
|
|
||||||
|
### Takeover コンパクトモード
|
||||||
|
|
||||||
|
- 3つ以上の表示アプリがある場合、自動的にコンパクトレイアウトを使用
|
||||||
|
- マルチアプリシナリオでのスペース利用を最適化
|
||||||
|
|
||||||
|
### ターミナル改善
|
||||||
|
|
||||||
|
- プロバイダー専用ターミナルボタン:ワンクリックでターミナルで現在のプロバイダーを使用 (#564、@kkkman22 に感謝)
|
||||||
|
- `fnm` パスサポート:fnm で管理された Node.js パスを自動認識
|
||||||
|
- クロスプラットフォーム安全起動:Windows/macOS/Linux のターミナル起動ロジックを改善
|
||||||
|
|
||||||
|
### WSL ツール検出
|
||||||
|
|
||||||
|
- WSL 環境でツールバージョンを検出 (#627、@yovinchen に感謝)
|
||||||
|
- コマンドインジェクションリスクを防ぐためのセキュリティ強化を追加
|
||||||
|
|
||||||
|
### Skills プリセット強化
|
||||||
|
|
||||||
|
- `baoyu-skills` プリセットリポジトリを追加
|
||||||
|
- 不足しているデフォルトリポジトリを自動補完し、すぐに使える状態を確保
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 体験の改善
|
||||||
|
|
||||||
|
- キーボードショートカット:`ESC` を押してパネルをすばやく戻る/閉じる (#670、@xxk8 に感謝)
|
||||||
|
- プロキシログの簡素化:より明確で読みやすい出力 (#585、@yovinchen に感謝)
|
||||||
|
- 価格エディター UX:統一された `FullScreenPanel` スタイル
|
||||||
|
- 詳細設定レイアウト:Rectifier セクションを Failover の下に移動し、論理的な流れを改善
|
||||||
|
- OpenRouter 互換モード:デフォルトで無効、UI トグルを非表示(煩雑さを軽減)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## バグ修正
|
||||||
|
|
||||||
|
### プロキシとフェイルオーバー
|
||||||
|
|
||||||
|
- 自動フェイルオーバーが有効な場合、すぐに P1 に切り替え(次のリクエストを待たずに)
|
||||||
|
|
||||||
|
### プロバイダー管理
|
||||||
|
|
||||||
|
- 保存後にプロバイダー編集ダイアログを再度開いたときにデータが古い問題を修正 (#654、@YangYongAn に感謝)
|
||||||
|
- プリセット切り替え時に baseUrl と apiKey の状態がリセットされない問題を修正
|
||||||
|
- エンドポイント自動選択状態が永続化されない問題を修正 (#611、@yovinchen に感謝)
|
||||||
|
- アイコンカラーが設定されていない場合、デフォルトカラーを自動適用
|
||||||
|
|
||||||
|
### ディープリンク
|
||||||
|
|
||||||
|
- マルチエンドポイントインポートをサポート (#597、@yovinchen に感謝)
|
||||||
|
- `GEMINI_BASE_URL` より `GOOGLE_GEMINI_BASE_URL` を優先
|
||||||
|
|
||||||
|
### MCP
|
||||||
|
|
||||||
|
- WSL ターゲットパスの `cmd /c` ラッパーをスキップ (#592、@cxyfer に感謝)
|
||||||
|
|
||||||
|
### 使用量テンプレート
|
||||||
|
|
||||||
|
- 変数ヒントを追加、検証の問題を修正 (#628、@YangYongAn に感謝)
|
||||||
|
- プロバイダー間での設定漏洩を防止
|
||||||
|
- 使用量ブロックのオフセットがアクションボタンの幅に自動適応 (#613、@yovinchen に感謝)
|
||||||
|
|
||||||
|
### Gemini
|
||||||
|
|
||||||
|
- タイムアウトパラメータを Gemini CLI 形式に変換 (#580、@cxyfer に感謝)
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
- `FullScreenPanel` での Select ドロップダウンのレンダリング問題を修正
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 注意事項
|
||||||
|
|
||||||
|
- **OpenCode は新しくサポートされたアプリです**:関連機能を使用するには、まず OpenCode CLI をインストールする必要があります。
|
||||||
|
- **グローバルプロキシはすべての送信リクエストに影響します**:使用量クエリ、ヘルスチェックなどのネットワーク操作を含みます。
|
||||||
|
- **Rectifier は実験的機能です**:問題が発生した場合は、詳細設定で無効にできます。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 特別な感謝
|
||||||
|
|
||||||
|
@yovinchen @YangYongAn @cxyfer @xxk8 @kkkman22 @Shuimo03 の皆様、このリリースへの貢献に感謝します!
|
||||||
|
@libukai 様、エレガントなフェイルオーバー関連 UI のデザインに感謝します!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ダウンロードとインストール
|
||||||
|
|
||||||
|
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から適切なバージョンをダウンロードしてください。
|
||||||
|
|
||||||
|
### システム要件
|
||||||
|
|
||||||
|
| システム | 最小バージョン | アーキテクチャ |
|
||||||
|
| -------- | -------------------------------- | ----------------------------------- |
|
||||||
|
| Windows | Windows 10 以降 | x64 |
|
||||||
|
| macOS | macOS 10.15 (Catalina) 以降 | Intel (x64) / Apple Silicon (arm64) |
|
||||||
|
| Linux | 下表参照 | x64 |
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
| ファイル | 説明 |
|
||||||
|
| ---------------------------------------- | ---------------------------------------------------- |
|
||||||
|
| `CC-Switch-v3.10.0-Windows.msi` | **推奨** - MSI インストーラー、自動更新対応 |
|
||||||
|
| `CC-Switch-v3.10.0-Windows-Portable.zip` | ポータブル版、解凍して実行、レジストリ書き込みなし |
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
| ファイル | 説明 |
|
||||||
|
| -------------------------------- | ----------------------------------------------------------------- |
|
||||||
|
| `CC-Switch-v3.10.0-macOS.zip` | **推奨** - 解凍して Applications にドラッグ、Universal Binary |
|
||||||
|
| `CC-Switch-v3.10.0-macOS.tar.gz` | Homebrew インストールと自動更新用 |
|
||||||
|
|
||||||
|
> **注意**:作者が Apple Developer アカウントを持っていないため、初回起動時に「開発元を確認できません」という警告が表示される場合があります。一度閉じてから、「システム設定」→「プライバシーとセキュリティ」→「このまま開く」をクリックすると、その後は正常に開けます。
|
||||||
|
|
||||||
|
### Homebrew (macOS)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew tap farion1231/ccswitch
|
||||||
|
brew install --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
更新:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew upgrade --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
|
||||||
|
| ディストリビューション | 推奨形式 | インストール方法 |
|
||||||
|
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
|
||||||
|
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` または `sudo apt install ./CC-Switch-*.deb` |
|
||||||
|
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` または `sudo dnf install ./CC-Switch-*.rpm` |
|
||||||
|
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||||
|
| Arch Linux / Manjaro | `.AppImage` | 実行権限を追加して直接実行、または AUR を使用 |
|
||||||
|
| その他のディストリビューション / 不明 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
# CC Switch v3.10.0
|
||||||
|
|
||||||
|
> OpenCode 支持、全局代理、Claude Rectifier 与多应用体验增强
|
||||||
|
|
||||||
|
**[English →](release-note-v3.10.0-en.md) | [日本語版 →](release-note-v3.10.0-ja.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 概览
|
||||||
|
|
||||||
|
CC Switch v3.10.0 新增 OpenCode 支持,成为第四个受管理的 CLI 应用。
|
||||||
|
同时带来全局代理设置、Claude Rectifier(thinking 签名修正器)、健康检查增强、按供应商配置等多项重要功能,并对多应用工作流与终端体验做了全面改进。
|
||||||
|
|
||||||
|
**发布日期**:2026-01-21
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 重点内容
|
||||||
|
|
||||||
|
- OpenCode 支持:供应商、MCP 服务器、Skills 全面管理,首次启动自动导入
|
||||||
|
- 全局代理:为出站网络请求统一配置代理
|
||||||
|
- Claude Rectifier:thinking 签名修正器,兼容更多第三方 API
|
||||||
|
- 健康检查增强:可配置提示词、CLI 兼容请求
|
||||||
|
- 按供应商配置:支持供应商特定配置的持久化
|
||||||
|
- 应用可见性控制:自由显示/隐藏应用,托盘菜单同步更新
|
||||||
|
- 终端改进:供应商专属终端按钮、fnm 路径支持、跨平台安全启动
|
||||||
|
- WSL 工具检测:在 WSL 环境检测工具版本,并增加安全加固
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 主要功能
|
||||||
|
|
||||||
|
### OpenCode 支持(新增第四应用)
|
||||||
|
|
||||||
|
- 完整的 OpenCode 供应商管理:新增、编辑、切换、删除
|
||||||
|
- MCP 服务器管理:与 Claude/Codex/Gemini 统一架构
|
||||||
|
- Skills 支持:OpenCode 也可使用 Skills 功能
|
||||||
|
- 首次启动自动导入:检测到已有 OpenCode 配置时自动导入
|
||||||
|
- 完整国际化:中/英/日三语支持(#695)
|
||||||
|
|
||||||
|
### 全局代理(Global Proxy)
|
||||||
|
|
||||||
|
- 为所有出站网络请求配置统一代理(#596,感谢 @yovinchen)
|
||||||
|
- 支持 HTTP/HTTPS 代理协议
|
||||||
|
- 适用于需要代理访问外部 API 的网络环境
|
||||||
|
|
||||||
|
### Claude Rectifier(Thinking 签名修正器)
|
||||||
|
|
||||||
|
- 自动修正 Claude API 的 thinking 签名(#595,感谢 @yovinchen)
|
||||||
|
- 解决部分第三方 API 网关返回的 thinking 块格式不兼容问题
|
||||||
|
- 在高级设置中可开启/关闭
|
||||||
|
|
||||||
|
### 健康检查增强
|
||||||
|
|
||||||
|
- 可配置自定义提示词(prompt)用于流式健康检查(#623,感谢 @yovinchen)
|
||||||
|
- 支持 CLI 兼容请求格式,更好地模拟真实使用场景
|
||||||
|
- 提升故障检测的准确性
|
||||||
|
|
||||||
|
### 按供应商配置(Per-Provider Config)
|
||||||
|
|
||||||
|
- 支持为每个供应商单独保存配置(#663,感谢 @yovinchen)
|
||||||
|
- 配置持久化:重启后保留供应商专属设置
|
||||||
|
- 适用于不同供应商需要不同配置的场景
|
||||||
|
|
||||||
|
### 应用可见性控制
|
||||||
|
|
||||||
|
- 自由显示/隐藏任意应用(Gemini 默认隐藏)
|
||||||
|
- 托盘菜单自动同步可见性设置
|
||||||
|
- 隐藏的应用不会出现在主界面和托盘菜单中
|
||||||
|
|
||||||
|
### Takeover Compact Mode
|
||||||
|
|
||||||
|
- 当显示 3 个及以上可见应用时,自动使用紧凑布局
|
||||||
|
- 优化多应用场景下的空间利用
|
||||||
|
|
||||||
|
### 终端改进
|
||||||
|
|
||||||
|
- 供应商专属终端按钮:一键在终端中使用当前供应商(#564,感谢 @kkkman22)
|
||||||
|
- `fnm` 路径支持:自动识别 fnm 管理的 Node.js 路径
|
||||||
|
- 跨平台安全启动:改进 Windows/macOS/Linux 的终端启动逻辑
|
||||||
|
|
||||||
|
### WSL 工具检测
|
||||||
|
|
||||||
|
- 在 WSL 环境中检测工具版本(#627,感谢 @yovinchen)
|
||||||
|
- 增加安全加固,防止命令注入风险
|
||||||
|
|
||||||
|
### Skills 预设增强
|
||||||
|
|
||||||
|
- 新增 `baoyu-skills` 预设仓库
|
||||||
|
- 自动补充缺失的默认仓库,确保开箱即用
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 体验优化
|
||||||
|
|
||||||
|
- 键盘快捷键:按 `ESC` 快速返回/关闭面板(#670,感谢 @xxk8)
|
||||||
|
- 代理日志简化:输出更清晰易读(#585,感谢 @yovinchen)
|
||||||
|
- 定价编辑器 UX:统一使用 `FullScreenPanel` 风格
|
||||||
|
- 高级设置布局:Rectifier 区块移至 Failover 下方,逻辑更顺畅
|
||||||
|
- OpenRouter 兼容模式:默认禁用,UI 开关隐藏(减少干扰)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bug 修复
|
||||||
|
|
||||||
|
### 代理与故障切换
|
||||||
|
|
||||||
|
- 启用自动故障切换时立即切换到 P1(而非等待下次请求)
|
||||||
|
|
||||||
|
### 供应商管理
|
||||||
|
|
||||||
|
- 修复供应商编辑对话框保存后重新打开时数据过时的问题(#654,感谢 @YangYongAn)
|
||||||
|
- 修复切换预设时 baseUrl 和 apiKey 状态未重置的问题
|
||||||
|
- 修复端点自动选择状态未持久化的问题(#611,感谢 @yovinchen)
|
||||||
|
- 未设置图标颜色时自动应用默认颜色
|
||||||
|
|
||||||
|
### 深链接
|
||||||
|
|
||||||
|
- 支持多端点导入(#597,感谢 @yovinchen)
|
||||||
|
- 优先使用 `GOOGLE_GEMINI_BASE_URL` 而非 `GEMINI_BASE_URL`
|
||||||
|
|
||||||
|
### MCP
|
||||||
|
|
||||||
|
- WSL 目标路径跳过 `cmd /c` 包裹(#592,感谢 @cxyfer)
|
||||||
|
|
||||||
|
### 用量模板
|
||||||
|
|
||||||
|
- 新增变量提示,修复验证问题(#628,感谢 @YangYongAn)
|
||||||
|
- 防止配置在供应商之间泄漏
|
||||||
|
- 用量区块偏移量根据操作按钮宽度自动适应(#613,感谢 @yovinchen)
|
||||||
|
|
||||||
|
### Gemini
|
||||||
|
|
||||||
|
- 超时参数转换为 Gemini CLI 格式(#580,感谢 @cxyfer)
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
- 修复 `FullScreenPanel` 中 Select 下拉框渲染问题
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 说明与注意事项
|
||||||
|
|
||||||
|
- **OpenCode 为新支持的应用**:需要先安装 OpenCode CLI 才能使用相关功能。
|
||||||
|
- **全局代理会影响所有出站请求**:包括用量查询、健康检查等网络操作。
|
||||||
|
- **Rectifier 功能为实验性**:如遇问题可在高级设置中关闭。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 特别感谢
|
||||||
|
|
||||||
|
感谢 @yovinchen @YangYongAn @cxyfer @xxk8 @kkkman22 @Shuimo03 为本版本做出的贡献!
|
||||||
|
感谢 @libukai 设计的故障转移相关 UI,非常优雅!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 下载与安装
|
||||||
|
|
||||||
|
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。
|
||||||
|
|
||||||
|
### 系统要求
|
||||||
|
|
||||||
|
| 系统 | 最低版本 | 架构 |
|
||||||
|
| ------- | ----------------------------- | ----------------------------------- |
|
||||||
|
| Windows | Windows 10 及以上 | x64 |
|
||||||
|
| macOS | macOS 10.15 (Catalina) 及以上 | Intel (x64) / Apple Silicon (arm64) |
|
||||||
|
| Linux | 见下表 | x64 |
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
| ---------------------------------------- | ----------------------------------- |
|
||||||
|
| `CC-Switch-v3.10.0-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
|
||||||
|
| `CC-Switch-v3.10.0-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
| -------------------------------- | --------------------------------------------------------- |
|
||||||
|
| `CC-Switch-v3.10.0-macOS.zip` | **推荐** - 解压后拖入 Applications 即可,Universal Binary |
|
||||||
|
| `CC-Switch-v3.10.0-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
|
||||||
|
|
||||||
|
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开
|
||||||
|
|
||||||
|
### Homebrew(macOS)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew tap farion1231/ccswitch
|
||||||
|
brew install --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
更新:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew upgrade --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
|
||||||
|
| 发行版 | 推荐格式 | 安装方式 |
|
||||||
|
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
|
||||||
|
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` 或 `sudo apt install ./CC-Switch-*.deb` |
|
||||||
|
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` 或 `sudo dnf install ./CC-Switch-*.rpm` |
|
||||||
|
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||||
|
| Arch Linux / Manjaro | `.AppImage` | 添加执行权限后直接运行,或使用 AUR |
|
||||||
|
| 其他发行版 / 不确定 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cc-switch",
|
"name": "cc-switch",
|
||||||
"version": "3.9.1",
|
"version": "3.10.2",
|
||||||
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Generated
+1
-1
@@ -701,7 +701,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc-switch"
|
name = "cc-switch"
|
||||||
version = "3.9.1"
|
version = "3.10.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cc-switch"
|
name = "cc-switch"
|
||||||
version = "3.9.1"
|
version = "3.10.2"
|
||||||
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"
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ pub struct McpApps {
|
|||||||
pub codex: bool,
|
pub codex: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub gemini: bool,
|
pub gemini: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub opencode: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl McpApps {
|
impl McpApps {
|
||||||
@@ -22,6 +24,7 @@ impl McpApps {
|
|||||||
AppType::Claude => self.claude,
|
AppType::Claude => self.claude,
|
||||||
AppType::Codex => self.codex,
|
AppType::Codex => self.codex,
|
||||||
AppType::Gemini => self.gemini,
|
AppType::Gemini => self.gemini,
|
||||||
|
AppType::OpenCode => self.opencode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +34,7 @@ impl McpApps {
|
|||||||
AppType::Claude => self.claude = enabled,
|
AppType::Claude => self.claude = enabled,
|
||||||
AppType::Codex => self.codex = enabled,
|
AppType::Codex => self.codex = enabled,
|
||||||
AppType::Gemini => self.gemini = enabled,
|
AppType::Gemini => self.gemini = enabled,
|
||||||
|
AppType::OpenCode => self.opencode = enabled,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,12 +50,15 @@ impl McpApps {
|
|||||||
if self.gemini {
|
if self.gemini {
|
||||||
apps.push(AppType::Gemini);
|
apps.push(AppType::Gemini);
|
||||||
}
|
}
|
||||||
|
if self.opencode {
|
||||||
|
apps.push(AppType::OpenCode);
|
||||||
|
}
|
||||||
apps
|
apps
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 检查是否所有应用都未启用
|
/// 检查是否所有应用都未启用
|
||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
!self.claude && !self.codex && !self.gemini
|
!self.claude && !self.codex && !self.gemini && !self.opencode
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +71,8 @@ pub struct SkillApps {
|
|||||||
pub codex: bool,
|
pub codex: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub gemini: bool,
|
pub gemini: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub opencode: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SkillApps {
|
impl SkillApps {
|
||||||
@@ -73,6 +82,7 @@ impl SkillApps {
|
|||||||
AppType::Claude => self.claude,
|
AppType::Claude => self.claude,
|
||||||
AppType::Codex => self.codex,
|
AppType::Codex => self.codex,
|
||||||
AppType::Gemini => self.gemini,
|
AppType::Gemini => self.gemini,
|
||||||
|
AppType::OpenCode => self.opencode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +92,7 @@ impl SkillApps {
|
|||||||
AppType::Claude => self.claude = enabled,
|
AppType::Claude => self.claude = enabled,
|
||||||
AppType::Codex => self.codex = enabled,
|
AppType::Codex => self.codex = enabled,
|
||||||
AppType::Gemini => self.gemini = enabled,
|
AppType::Gemini => self.gemini = enabled,
|
||||||
|
AppType::OpenCode => self.opencode = enabled,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,12 +108,15 @@ impl SkillApps {
|
|||||||
if self.gemini {
|
if self.gemini {
|
||||||
apps.push(AppType::Gemini);
|
apps.push(AppType::Gemini);
|
||||||
}
|
}
|
||||||
|
if self.opencode {
|
||||||
|
apps.push(AppType::OpenCode);
|
||||||
|
}
|
||||||
apps
|
apps
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 检查是否所有应用都未启用
|
/// 检查是否所有应用都未启用
|
||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
!self.claude && !self.codex && !self.gemini
|
!self.claude && !self.codex && !self.gemini && !self.opencode
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 仅启用指定应用(其他应用设为禁用)
|
/// 仅启用指定应用(其他应用设为禁用)
|
||||||
@@ -205,6 +219,9 @@ pub struct McpRoot {
|
|||||||
pub codex: McpConfig,
|
pub codex: McpConfig,
|
||||||
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||||
pub gemini: McpConfig,
|
pub gemini: McpConfig,
|
||||||
|
/// OpenCode MCP 配置(v4.0.0+,实际使用 opencode.json)
|
||||||
|
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||||
|
pub opencode: McpConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for McpRoot {
|
impl Default for McpRoot {
|
||||||
@@ -216,6 +233,7 @@ impl Default for McpRoot {
|
|||||||
claude: McpConfig::default(),
|
claude: McpConfig::default(),
|
||||||
codex: McpConfig::default(),
|
codex: McpConfig::default(),
|
||||||
gemini: McpConfig::default(),
|
gemini: McpConfig::default(),
|
||||||
|
opencode: McpConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,6 +254,8 @@ pub struct PromptRoot {
|
|||||||
pub codex: PromptConfig,
|
pub codex: PromptConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub gemini: PromptConfig,
|
pub gemini: PromptConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub opencode: PromptConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
|
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
|
||||||
@@ -249,7 +269,8 @@ use crate::provider::ProviderManager;
|
|||||||
pub enum AppType {
|
pub enum AppType {
|
||||||
Claude,
|
Claude,
|
||||||
Codex,
|
Codex,
|
||||||
Gemini, // 新增
|
Gemini,
|
||||||
|
OpenCode,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppType {
|
impl AppType {
|
||||||
@@ -257,7 +278,8 @@ impl AppType {
|
|||||||
match self {
|
match self {
|
||||||
AppType::Claude => "claude",
|
AppType::Claude => "claude",
|
||||||
AppType::Codex => "codex",
|
AppType::Codex => "codex",
|
||||||
AppType::Gemini => "gemini", // 新增
|
AppType::Gemini => "gemini",
|
||||||
|
AppType::OpenCode => "opencode",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -270,11 +292,12 @@ impl FromStr for AppType {
|
|||||||
match normalized.as_str() {
|
match normalized.as_str() {
|
||||||
"claude" => Ok(AppType::Claude),
|
"claude" => Ok(AppType::Claude),
|
||||||
"codex" => Ok(AppType::Codex),
|
"codex" => Ok(AppType::Codex),
|
||||||
"gemini" => Ok(AppType::Gemini), // 新增
|
"gemini" => Ok(AppType::Gemini),
|
||||||
|
"opencode" => Ok(AppType::OpenCode),
|
||||||
other => Err(AppError::localized(
|
other => Err(AppError::localized(
|
||||||
"unsupported_app",
|
"unsupported_app",
|
||||||
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini。"),
|
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode。"),
|
||||||
format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini."),
|
format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode."),
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -291,6 +314,9 @@ pub struct CommonConfigSnippets {
|
|||||||
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub gemini: Option<String>,
|
pub gemini: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub opencode: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommonConfigSnippets {
|
impl CommonConfigSnippets {
|
||||||
@@ -300,6 +326,7 @@ impl CommonConfigSnippets {
|
|||||||
AppType::Claude => self.claude.as_ref(),
|
AppType::Claude => self.claude.as_ref(),
|
||||||
AppType::Codex => self.codex.as_ref(),
|
AppType::Codex => self.codex.as_ref(),
|
||||||
AppType::Gemini => self.gemini.as_ref(),
|
AppType::Gemini => self.gemini.as_ref(),
|
||||||
|
AppType::OpenCode => self.opencode.as_ref(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,6 +336,7 @@ impl CommonConfigSnippets {
|
|||||||
AppType::Claude => self.claude = snippet,
|
AppType::Claude => self.claude = snippet,
|
||||||
AppType::Codex => self.codex = snippet,
|
AppType::Codex => self.codex = snippet,
|
||||||
AppType::Gemini => self.gemini = snippet,
|
AppType::Gemini => self.gemini = snippet,
|
||||||
|
AppType::OpenCode => self.opencode = snippet,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -347,7 +375,8 @@ impl Default for MultiAppConfig {
|
|||||||
let mut apps = HashMap::new();
|
let mut apps = HashMap::new();
|
||||||
apps.insert("claude".to_string(), ProviderManager::default());
|
apps.insert("claude".to_string(), ProviderManager::default());
|
||||||
apps.insert("codex".to_string(), ProviderManager::default());
|
apps.insert("codex".to_string(), ProviderManager::default());
|
||||||
apps.insert("gemini".to_string(), ProviderManager::default()); // 新增
|
apps.insert("gemini".to_string(), ProviderManager::default());
|
||||||
|
apps.insert("opencode".to_string(), ProviderManager::default());
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
version: 2,
|
version: 2,
|
||||||
@@ -506,6 +535,7 @@ impl MultiAppConfig {
|
|||||||
AppType::Claude => &self.mcp.claude,
|
AppType::Claude => &self.mcp.claude,
|
||||||
AppType::Codex => &self.mcp.codex,
|
AppType::Codex => &self.mcp.codex,
|
||||||
AppType::Gemini => &self.mcp.gemini,
|
AppType::Gemini => &self.mcp.gemini,
|
||||||
|
AppType::OpenCode => &self.mcp.opencode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,6 +545,7 @@ impl MultiAppConfig {
|
|||||||
AppType::Claude => &mut self.mcp.claude,
|
AppType::Claude => &mut self.mcp.claude,
|
||||||
AppType::Codex => &mut self.mcp.codex,
|
AppType::Codex => &mut self.mcp.codex,
|
||||||
AppType::Gemini => &mut self.mcp.gemini,
|
AppType::Gemini => &mut self.mcp.gemini,
|
||||||
|
AppType::OpenCode => &mut self.mcp.opencode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,6 +559,7 @@ impl MultiAppConfig {
|
|||||||
Self::auto_import_prompt_if_exists(&mut config, AppType::Claude)?;
|
Self::auto_import_prompt_if_exists(&mut config, AppType::Claude)?;
|
||||||
Self::auto_import_prompt_if_exists(&mut config, AppType::Codex)?;
|
Self::auto_import_prompt_if_exists(&mut config, AppType::Codex)?;
|
||||||
Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?;
|
Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?;
|
||||||
|
Self::auto_import_prompt_if_exists(&mut config, AppType::OpenCode)?;
|
||||||
|
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
@@ -547,6 +579,7 @@ impl MultiAppConfig {
|
|||||||
if !self.prompts.claude.prompts.is_empty()
|
if !self.prompts.claude.prompts.is_empty()
|
||||||
|| !self.prompts.codex.prompts.is_empty()
|
|| !self.prompts.codex.prompts.is_empty()
|
||||||
|| !self.prompts.gemini.prompts.is_empty()
|
|| !self.prompts.gemini.prompts.is_empty()
|
||||||
|
|| !self.prompts.opencode.prompts.is_empty()
|
||||||
{
|
{
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
@@ -554,7 +587,12 @@ impl MultiAppConfig {
|
|||||||
log::info!("检测到已存在配置文件且 Prompt 列表为空,将尝试从现有提示词文件自动导入");
|
log::info!("检测到已存在配置文件且 Prompt 列表为空,将尝试从现有提示词文件自动导入");
|
||||||
|
|
||||||
let mut imported = false;
|
let mut imported = false;
|
||||||
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
for app in [
|
||||||
|
AppType::Claude,
|
||||||
|
AppType::Codex,
|
||||||
|
AppType::Gemini,
|
||||||
|
AppType::OpenCode,
|
||||||
|
] {
|
||||||
// 复用已有的单应用导入逻辑
|
// 复用已有的单应用导入逻辑
|
||||||
if Self::auto_import_prompt_if_exists(self, app)? {
|
if Self::auto_import_prompt_if_exists(self, app)? {
|
||||||
imported = true;
|
imported = true;
|
||||||
@@ -623,6 +661,7 @@ impl MultiAppConfig {
|
|||||||
AppType::Claude => &mut config.prompts.claude.prompts,
|
AppType::Claude => &mut config.prompts.claude.prompts,
|
||||||
AppType::Codex => &mut config.prompts.codex.prompts,
|
AppType::Codex => &mut config.prompts.codex.prompts,
|
||||||
AppType::Gemini => &mut config.prompts.gemini.prompts,
|
AppType::Gemini => &mut config.prompts.gemini.prompts,
|
||||||
|
AppType::OpenCode => &mut config.prompts.opencode.prompts,
|
||||||
};
|
};
|
||||||
|
|
||||||
prompts.insert(id, prompt);
|
prompts.insert(id, prompt);
|
||||||
@@ -656,6 +695,7 @@ impl MultiAppConfig {
|
|||||||
AppType::Claude => &self.mcp.claude.servers,
|
AppType::Claude => &self.mcp.claude.servers,
|
||||||
AppType::Codex => &self.mcp.codex.servers,
|
AppType::Codex => &self.mcp.codex.servers,
|
||||||
AppType::Gemini => &self.mcp.gemini.servers,
|
AppType::Gemini => &self.mcp.gemini.servers,
|
||||||
|
AppType::OpenCode => &self.mcp.opencode.servers,
|
||||||
};
|
};
|
||||||
|
|
||||||
for (id, entry) in old_servers {
|
for (id, entry) in old_servers {
|
||||||
|
|||||||
@@ -51,6 +51,15 @@ pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
|
|||||||
|
|
||||||
Ok(ConfigStatus { exists, path })
|
Ok(ConfigStatus { exists, path })
|
||||||
}
|
}
|
||||||
|
AppType::OpenCode => {
|
||||||
|
let config_path = crate::opencode_config::get_opencode_config_path();
|
||||||
|
let exists = config_path.exists();
|
||||||
|
let path = crate::opencode_config::get_opencode_dir()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
Ok(ConfigStatus { exists, path })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +76,7 @@ pub async fn get_config_dir(app: String) -> Result<String, String> {
|
|||||||
AppType::Claude => config::get_claude_config_dir(),
|
AppType::Claude => config::get_claude_config_dir(),
|
||||||
AppType::Codex => codex_config::get_codex_config_dir(),
|
AppType::Codex => codex_config::get_codex_config_dir(),
|
||||||
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
||||||
|
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(dir.to_string_lossy().to_string())
|
Ok(dir.to_string_lossy().to_string())
|
||||||
@@ -79,6 +89,7 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool,
|
|||||||
AppType::Claude => config::get_claude_config_dir(),
|
AppType::Claude => config::get_claude_config_dir(),
|
||||||
AppType::Codex => codex_config::get_codex_config_dir(),
|
AppType::Codex => codex_config::get_codex_config_dir(),
|
||||||
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
||||||
|
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if !config_dir.exists() {
|
if !config_dir.exists() {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
use crate::database::FailoverQueueItem;
|
use crate::database::FailoverQueueItem;
|
||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
use crate::store::AppState;
|
use crate::store::AppState;
|
||||||
|
use std::str::FromStr;
|
||||||
|
use tauri::Emitter;
|
||||||
|
|
||||||
/// 获取故障转移队列
|
/// 获取故障转移队列
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -75,6 +77,7 @@ pub async fn get_auto_failover_enabled(
|
|||||||
/// 注意:关闭故障转移时不会清除队列,队列内容会保留供下次开启时使用
|
/// 注意:关闭故障转移时不会清除队列,队列内容会保留供下次开启时使用
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn set_auto_failover_enabled(
|
pub async fn set_auto_failover_enabled(
|
||||||
|
app: tauri::AppHandle,
|
||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
app_type: String,
|
app_type: String,
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
@@ -83,6 +86,47 @@ pub async fn set_auto_failover_enabled(
|
|||||||
"[Failover] Setting auto_failover_enabled: app_type='{app_type}', enabled={enabled}"
|
"[Failover] Setting auto_failover_enabled: app_type='{app_type}', enabled={enabled}"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 强一致语义:开启故障转移后立即切到队列 P1(并确保队列非空)
|
||||||
|
//
|
||||||
|
// 说明:
|
||||||
|
// - 仅在 enabled=true 时执行“切到 P1”
|
||||||
|
// - 若队列为空,则尝试把“当前供应商”自动加入队列作为 P1,避免用户在 UI 上陷入死锁(无法先加队列再开启)
|
||||||
|
let p1_provider_id = if enabled {
|
||||||
|
let mut queue = state
|
||||||
|
.db
|
||||||
|
.get_failover_queue(&app_type)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if queue.is_empty() {
|
||||||
|
let app_enum = crate::app_config::AppType::from_str(&app_type)
|
||||||
|
.map_err(|_| format!("无效的应用类型: {app_type}"))?;
|
||||||
|
|
||||||
|
let current_id = crate::settings::get_effective_current_provider(&state.db, &app_enum)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let Some(current_id) = current_id else {
|
||||||
|
return Err("故障转移队列为空,且未设置当前供应商,无法开启故障转移".to_string());
|
||||||
|
};
|
||||||
|
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.add_to_failover_queue(&app_type, ¤t_id)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
queue = state
|
||||||
|
.db
|
||||||
|
.get_failover_queue(&app_type)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
queue
|
||||||
|
.first()
|
||||||
|
.map(|item| item.provider_id.clone())
|
||||||
|
.ok_or_else(|| "故障转移队列为空,无法开启故障转移".to_string())?
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
// 读取当前配置
|
// 读取当前配置
|
||||||
let mut config = state
|
let mut config = state
|
||||||
.db
|
.db
|
||||||
@@ -98,5 +142,30 @@ pub async fn set_auto_failover_enabled(
|
|||||||
.db
|
.db
|
||||||
.update_proxy_config_for_app(config)
|
.update_proxy_config_for_app(config)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// 开启后立即切到 P1:更新 is_current + 本地 settings + Live 备份(接管模式下)
|
||||||
|
if enabled {
|
||||||
|
state
|
||||||
|
.proxy_service
|
||||||
|
.switch_proxy_target(&app_type, &p1_provider_id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 发射 provider-switched 事件(让前端刷新当前供应商)
|
||||||
|
let event_data = serde_json::json!({
|
||||||
|
"appType": app_type,
|
||||||
|
"providerId": p1_provider_id,
|
||||||
|
"source": "failoverEnabled"
|
||||||
|
});
|
||||||
|
let _ = app.emit("provider-switched", event_data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新托盘菜单,确保状态同步
|
||||||
|
if let Ok(new_menu) = crate::tray::create_tray_menu(&app, &state) {
|
||||||
|
if let Some(tray) = app.tray_by_id("main") {
|
||||||
|
let _ = tray.set_menu(Some(new_menu));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
//! 全局出站代理相关命令
|
||||||
|
//!
|
||||||
|
//! 提供获取、设置和测试全局代理的 Tauri 命令。
|
||||||
|
|
||||||
|
use crate::proxy::http_client;
|
||||||
|
use crate::store::AppState;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::net::{Ipv4Addr, SocketAddrV4, TcpStream};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// 获取全局代理 URL
|
||||||
|
///
|
||||||
|
/// 返回当前配置的代理 URL,null 表示直连。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_global_proxy_url(state: tauri::State<'_, AppState>) -> Result<Option<String>, String> {
|
||||||
|
let result = state.db.get_global_proxy_url().map_err(|e| e.to_string())?;
|
||||||
|
log::debug!(
|
||||||
|
"[GlobalProxy] [GP-010] Read from database: {}",
|
||||||
|
result
|
||||||
|
.as_ref()
|
||||||
|
.map(|u| http_client::mask_url(u))
|
||||||
|
.unwrap_or_else(|| "None".to_string())
|
||||||
|
);
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置全局代理 URL
|
||||||
|
///
|
||||||
|
/// - 传入非空字符串:启用代理
|
||||||
|
/// - 传入空字符串:清除代理(直连)
|
||||||
|
///
|
||||||
|
/// 执行顺序:先验证 → 写 DB → 再应用
|
||||||
|
/// 这样确保 DB 写失败时不会出现运行态与持久化不一致的问题
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn set_global_proxy_url(state: tauri::State<'_, AppState>, url: String) -> Result<(), String> {
|
||||||
|
// 调试:显示接收到的 URL 信息(不包含敏感内容)
|
||||||
|
let has_auth = url.contains('@') && (url.starts_with("http://") || url.starts_with("socks"));
|
||||||
|
log::debug!(
|
||||||
|
"[GlobalProxy] [GP-011] Received URL: length={}, has_auth={}",
|
||||||
|
url.len(),
|
||||||
|
has_auth
|
||||||
|
);
|
||||||
|
|
||||||
|
let url_opt = if url.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(url.as_str())
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. 先验证代理配置是否有效(不应用)
|
||||||
|
http_client::validate_proxy(url_opt)?;
|
||||||
|
|
||||||
|
// 2. 验证成功后保存到数据库
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.set_global_proxy_url(url_opt)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// 3. DB 写入成功后再应用到运行态
|
||||||
|
http_client::apply_proxy(url_opt)?;
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"[GlobalProxy] [GP-009] Configuration updated: {}",
|
||||||
|
url_opt
|
||||||
|
.map(http_client::mask_url)
|
||||||
|
.unwrap_or_else(|| "direct connection".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 代理测试结果
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ProxyTestResult {
|
||||||
|
/// 是否连接成功
|
||||||
|
pub success: bool,
|
||||||
|
/// 延迟(毫秒)
|
||||||
|
pub latency_ms: u64,
|
||||||
|
/// 错误信息
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试代理连接
|
||||||
|
///
|
||||||
|
/// 通过指定的代理 URL 发送测试请求,返回连接结果和延迟。
|
||||||
|
/// 使用多个测试目标,任一成功即认为代理可用。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn test_proxy_url(url: String) -> Result<ProxyTestResult, String> {
|
||||||
|
if url.trim().is_empty() {
|
||||||
|
return Err("Proxy URL is empty".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = Instant::now();
|
||||||
|
|
||||||
|
// 构建带代理的临时客户端
|
||||||
|
let proxy = reqwest::Proxy::all(&url).map_err(|e| format!("Invalid proxy URL: {e}"))?;
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.proxy(proxy)
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.connect_timeout(std::time::Duration::from_secs(10))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("Failed to build client: {e}"))?;
|
||||||
|
|
||||||
|
// 使用多个测试目标,提高兼容性
|
||||||
|
// 优先使用 httpbin(专门用于 HTTP 测试),回退到其他公共端点
|
||||||
|
let test_urls = [
|
||||||
|
"https://httpbin.org/get",
|
||||||
|
"https://www.google.com",
|
||||||
|
"https://api.anthropic.com",
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut last_error = None;
|
||||||
|
|
||||||
|
for test_url in test_urls {
|
||||||
|
match client.head(test_url).send().await {
|
||||||
|
Ok(resp) => {
|
||||||
|
let latency = start.elapsed().as_millis() as u64;
|
||||||
|
log::debug!(
|
||||||
|
"[GlobalProxy] Test successful: {} -> {} via {} ({}ms)",
|
||||||
|
http_client::mask_url(&url),
|
||||||
|
test_url,
|
||||||
|
resp.status(),
|
||||||
|
latency
|
||||||
|
);
|
||||||
|
return Ok(ProxyTestResult {
|
||||||
|
success: true,
|
||||||
|
latency_ms: latency,
|
||||||
|
error: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!("[GlobalProxy] Test to {test_url} failed: {e}");
|
||||||
|
last_error = Some(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 所有测试目标都失败
|
||||||
|
let latency = start.elapsed().as_millis() as u64;
|
||||||
|
let error_msg = last_error
|
||||||
|
.map(|e| e.to_string())
|
||||||
|
.unwrap_or_else(|| "All test targets failed".to_string());
|
||||||
|
|
||||||
|
log::debug!(
|
||||||
|
"[GlobalProxy] Test failed: {} -> {} ({}ms)",
|
||||||
|
http_client::mask_url(&url),
|
||||||
|
error_msg,
|
||||||
|
latency
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(ProxyTestResult {
|
||||||
|
success: false,
|
||||||
|
latency_ms: latency,
|
||||||
|
error: Some(error_msg),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取当前出站代理状态
|
||||||
|
///
|
||||||
|
/// 返回当前是否启用了出站代理以及代理 URL。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_upstream_proxy_status() -> UpstreamProxyStatus {
|
||||||
|
let url = http_client::get_current_proxy_url();
|
||||||
|
UpstreamProxyStatus {
|
||||||
|
enabled: url.is_some(),
|
||||||
|
proxy_url: url,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 出站代理状态信息
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UpstreamProxyStatus {
|
||||||
|
/// 是否启用代理
|
||||||
|
pub enabled: bool,
|
||||||
|
/// 代理 URL
|
||||||
|
pub proxy_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检测到的代理信息
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DetectedProxy {
|
||||||
|
/// 代理 URL
|
||||||
|
pub url: String,
|
||||||
|
/// 代理类型 (http/socks5)
|
||||||
|
pub proxy_type: String,
|
||||||
|
/// 端口
|
||||||
|
pub port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 常见代理端口配置
|
||||||
|
/// 格式:(端口, 主要类型, 是否同时支持 http 和 socks5)
|
||||||
|
/// 对于 mixed 端口,会同时返回两种协议供用户选择
|
||||||
|
const PROXY_PORTS: &[(u16, &str, bool)] = &[
|
||||||
|
(7890, "http", true), // Clash (mixed mode)
|
||||||
|
(7891, "socks5", false), // Clash SOCKS only
|
||||||
|
(1080, "socks5", false), // 通用 SOCKS5
|
||||||
|
(8080, "http", false), // 通用 HTTP
|
||||||
|
(8888, "http", false), // Charles/Fiddler
|
||||||
|
(3128, "http", false), // Squid
|
||||||
|
(10808, "socks5", false), // V2Ray SOCKS
|
||||||
|
(10809, "http", false), // V2Ray HTTP
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 扫描本地代理
|
||||||
|
///
|
||||||
|
/// 检测常见端口是否有代理服务在运行。
|
||||||
|
/// 使用异步任务避免阻塞 UI 线程。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn scan_local_proxies() -> Vec<DetectedProxy> {
|
||||||
|
// 使用 spawn_blocking 避免阻塞主线程
|
||||||
|
tokio::task::spawn_blocking(|| {
|
||||||
|
let mut found = Vec::new();
|
||||||
|
|
||||||
|
for &(port, primary_type, is_mixed) in PROXY_PORTS {
|
||||||
|
let addr = SocketAddrV4::new(Ipv4Addr::LOCALHOST, port);
|
||||||
|
if TcpStream::connect_timeout(&addr.into(), Duration::from_millis(100)).is_ok() {
|
||||||
|
// 添加主要类型
|
||||||
|
found.push(DetectedProxy {
|
||||||
|
url: format!("{primary_type}://127.0.0.1:{port}"),
|
||||||
|
proxy_type: primary_type.to_string(),
|
||||||
|
port,
|
||||||
|
});
|
||||||
|
// 对于 mixed 端口,同时添加另一种协议
|
||||||
|
if is_mixed {
|
||||||
|
let alt_type = if primary_type == "http" {
|
||||||
|
"socks5"
|
||||||
|
} else {
|
||||||
|
"http"
|
||||||
|
};
|
||||||
|
found.push(DetectedProxy {
|
||||||
|
url: format!("{alt_type}://127.0.0.1:{port}"),
|
||||||
|
proxy_type: alt_type.to_string(),
|
||||||
|
port,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
found
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
@@ -122,6 +122,7 @@ pub async fn upsert_mcp_server_in_config(
|
|||||||
new_server.apps.claude = true;
|
new_server.apps.claude = true;
|
||||||
new_server.apps.codex = true;
|
new_server.apps.codex = true;
|
||||||
new_server.apps.gemini = true;
|
new_server.apps.gemini = true;
|
||||||
|
new_server.apps.opencode = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
McpService::upsert_server(&state, new_server)
|
McpService::upsert_server(&state, new_server)
|
||||||
@@ -200,5 +201,6 @@ pub async fn import_mcp_from_apps(state: State<'_, AppState>) -> Result<usize, S
|
|||||||
total += McpService::import_from_claude(&state).unwrap_or(0);
|
total += McpService::import_from_claude(&state).unwrap_or(0);
|
||||||
total += McpService::import_from_codex(&state).unwrap_or(0);
|
total += McpService::import_from_codex(&state).unwrap_or(0);
|
||||||
total += McpService::import_from_gemini(&state).unwrap_or(0);
|
total += McpService::import_from_gemini(&state).unwrap_or(0);
|
||||||
|
total += McpService::import_from_opencode(&state).unwrap_or(0);
|
||||||
Ok(total)
|
Ok(total)
|
||||||
}
|
}
|
||||||
|
|||||||
+260
-89
@@ -5,6 +5,7 @@ use crate::init_status::{InitErrorPayload, SkillsMigrationPayload};
|
|||||||
use crate::services::ProviderService;
|
use crate::services::ProviderService;
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
|
use std::path::Path;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
@@ -91,15 +92,14 @@ pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
|
|||||||
let tools = vec!["claude", "codex", "gemini"];
|
let tools = vec!["claude", "codex", "gemini"];
|
||||||
let mut results = Vec::new();
|
let mut results = Vec::new();
|
||||||
|
|
||||||
// 用于获取远程版本的 client
|
// 使用全局 HTTP 客户端(已包含代理配置)
|
||||||
let client = reqwest::Client::builder()
|
let client = crate::proxy::http_client::get();
|
||||||
.user_agent("cc-switch/1.0")
|
|
||||||
.build()
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
for tool in tools {
|
for tool in tools {
|
||||||
// 1. 获取本地版本 - 先尝试直接执行,失败则扫描常见路径
|
// 1. 获取本地版本 - 先尝试直接执行,失败则扫描常见路径
|
||||||
let (local_version, local_error) = {
|
let (local_version, local_error) = if let Some(distro) = wsl_distro_for_tool(tool) {
|
||||||
|
try_get_version_wsl(tool, &distro)
|
||||||
|
} else {
|
||||||
// 先尝试直接执行
|
// 先尝试直接执行
|
||||||
let direct_result = try_get_version(tool);
|
let direct_result = try_get_version(tool);
|
||||||
|
|
||||||
@@ -187,7 +187,7 @@ fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
|
|||||||
if out.status.success() {
|
if out.status.success() {
|
||||||
let raw = if stdout.is_empty() { &stderr } else { &stdout };
|
let raw = if stdout.is_empty() { &stderr } else { &stdout };
|
||||||
if raw.is_empty() {
|
if raw.is_empty() {
|
||||||
(None, Some("未安装或无法执行".to_string()))
|
(None, Some("not installed or not executable".to_string()))
|
||||||
} else {
|
} else {
|
||||||
(Some(extract_version(raw)), None)
|
(Some(extract_version(raw)), None)
|
||||||
}
|
}
|
||||||
@@ -196,7 +196,7 @@ fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
|
|||||||
(
|
(
|
||||||
None,
|
None,
|
||||||
Some(if err.is_empty() {
|
Some(if err.is_empty() {
|
||||||
"未安装或无法执行".to_string()
|
"not installed or not executable".to_string()
|
||||||
} else {
|
} else {
|
||||||
err
|
err
|
||||||
}),
|
}),
|
||||||
@@ -207,6 +207,88 @@ fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 校验 WSL 发行版名称是否合法
|
||||||
|
/// WSL 发行版名称只允许字母、数字、连字符和下划线
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn is_valid_wsl_distro_name(name: &str) -> bool {
|
||||||
|
!name.is_empty()
|
||||||
|
&& name.len() <= 64
|
||||||
|
&& name
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn try_get_version_wsl(tool: &str, distro: &str) -> (Option<String>, Option<String>) {
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
// 防御性断言:tool 只能是预定义的值
|
||||||
|
debug_assert!(
|
||||||
|
["claude", "codex", "gemini"].contains(&tool),
|
||||||
|
"unexpected tool name: {tool}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 校验 distro 名称,防止命令注入
|
||||||
|
if !is_valid_wsl_distro_name(distro) {
|
||||||
|
return (None, Some(format!("[WSL:{distro}] invalid distro name")));
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = Command::new("wsl.exe")
|
||||||
|
.args([
|
||||||
|
"-d",
|
||||||
|
distro,
|
||||||
|
"--",
|
||||||
|
"sh",
|
||||||
|
"-lc",
|
||||||
|
&format!("{tool} --version"),
|
||||||
|
])
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.output();
|
||||||
|
|
||||||
|
match output {
|
||||||
|
Ok(out) => {
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||||
|
if out.status.success() {
|
||||||
|
let raw = if stdout.is_empty() { &stderr } else { &stdout };
|
||||||
|
if raw.is_empty() {
|
||||||
|
(
|
||||||
|
None,
|
||||||
|
Some(format!("[WSL:{distro}] not installed or not executable")),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(Some(extract_version(raw)), None)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let err = if stderr.is_empty() { stdout } else { stderr };
|
||||||
|
(
|
||||||
|
None,
|
||||||
|
Some(format!(
|
||||||
|
"[WSL:{distro}] {}",
|
||||||
|
if err.is_empty() {
|
||||||
|
"not installed or not executable".to_string()
|
||||||
|
} else {
|
||||||
|
err
|
||||||
|
}
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => (None, Some(format!("[WSL:{distro}] exec failed: {e}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 非 Windows 平台的 WSL 版本检测存根
|
||||||
|
/// 注意:此函数实际上不会被调用,因为 `wsl_distro_from_path` 在非 Windows 平台总是返回 None。
|
||||||
|
/// 保留此函数是为了保持 API 一致性,防止未来重构时遗漏。
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
fn try_get_version_wsl(_tool: &str, _distro: &str) -> (Option<String>, Option<String>) {
|
||||||
|
(
|
||||||
|
None,
|
||||||
|
Some("WSL check not supported on this platform".to_string()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// 扫描常见路径查找 CLI
|
/// 扫描常见路径查找 CLI
|
||||||
fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
@@ -240,6 +322,19 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
|||||||
search_paths.push(std::path::PathBuf::from("C:\\Program Files\\nodejs"));
|
search_paths.push(std::path::PathBuf::from("C:\\Program Files\\nodejs"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 添加 fnm 路径支持
|
||||||
|
let fnm_base = home.join(".local/state/fnm_multishells");
|
||||||
|
if fnm_base.exists() {
|
||||||
|
if let Ok(entries) = std::fs::read_dir(&fnm_base) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let bin_path = entry.path().join("bin");
|
||||||
|
if bin_path.exists() {
|
||||||
|
search_paths.push(bin_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 扫描 nvm 目录下的所有 node 版本
|
// 扫描 nvm 目录下的所有 node 版本
|
||||||
let nvm_base = home.join(".nvm/versions/node");
|
let nvm_base = home.join(".nvm/versions/node");
|
||||||
if nvm_base.exists() {
|
if nvm_base.exists() {
|
||||||
@@ -302,7 +397,49 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(None, Some("未安装或无法执行".to_string()))
|
(None, Some("not installed or not executable".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wsl_distro_for_tool(tool: &str) -> Option<String> {
|
||||||
|
let override_dir = match tool {
|
||||||
|
"claude" => crate::settings::get_claude_override_dir(),
|
||||||
|
"codex" => crate::settings::get_codex_override_dir(),
|
||||||
|
"gemini" => crate::settings::get_gemini_override_dir(),
|
||||||
|
_ => None,
|
||||||
|
}?;
|
||||||
|
|
||||||
|
wsl_distro_from_path(&override_dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 UNC 路径中提取 WSL 发行版名称
|
||||||
|
/// 支持 `\\wsl$\Ubuntu\...` 和 `\\wsl.localhost\Ubuntu\...` 两种格式
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn wsl_distro_from_path(path: &Path) -> Option<String> {
|
||||||
|
use std::path::{Component, Prefix};
|
||||||
|
let Some(Component::Prefix(prefix)) = path.components().next() else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
match prefix.kind() {
|
||||||
|
Prefix::UNC(server, share) | Prefix::VerbatimUNC(server, share) => {
|
||||||
|
let server_name = server.to_string_lossy();
|
||||||
|
if server_name.eq_ignore_ascii_case("wsl$")
|
||||||
|
|| server_name.eq_ignore_ascii_case("wsl.localhost")
|
||||||
|
{
|
||||||
|
let distro = share.to_string_lossy().to_string();
|
||||||
|
if !distro.is_empty() {
|
||||||
|
return Some(distro);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 非 Windows 平台不支持 WSL 路径解析
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
fn wsl_distro_from_path(_path: &Path) -> Option<String> {
|
||||||
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 打开指定提供商的终端
|
/// 打开指定提供商的终端
|
||||||
@@ -402,18 +539,15 @@ fn launch_terminal_with_env(
|
|||||||
// 创建并写入配置文件
|
// 创建并写入配置文件
|
||||||
write_claude_config(&config_file, &env_vars)?;
|
write_claude_config(&config_file, &env_vars)?;
|
||||||
|
|
||||||
// 转义配置文件路径用于 shell
|
|
||||||
let config_path_escaped = escape_shell_path(&config_file);
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
launch_macos_terminal(&config_file, &config_path_escaped)?;
|
launch_macos_terminal(&config_file)?;
|
||||||
return Ok(());
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
{
|
{
|
||||||
launch_linux_terminal(&config_file, &config_path_escaped)?;
|
launch_linux_terminal(&config_file)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,105 +581,132 @@ fn write_claude_config(
|
|||||||
std::fs::write(config_file, config_json).map_err(|e| format!("写入配置文件失败: {e}"))
|
std::fs::write(config_file, config_json).map_err(|e| format!("写入配置文件失败: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 转义 shell 路径
|
|
||||||
fn escape_shell_path(path: &std::path::Path) -> String {
|
|
||||||
path.to_string_lossy()
|
|
||||||
.replace('\\', "\\\\")
|
|
||||||
.replace('"', "\\\"")
|
|
||||||
.replace('$', "\\$")
|
|
||||||
.replace(' ', "\\ ")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 生成 bash 包装脚本,用于清理临时文件
|
|
||||||
fn generate_wrapper_script(config_path: &str, escaped_path: &str) -> String {
|
|
||||||
format!(
|
|
||||||
"bash -c 'trap \"rm -f \\\"{}\\\"\" EXIT; echo \"Using provider-specific claude config:\"; echo \"{}\"; claude --settings \"{}\"; exec bash --norc --noprofile'",
|
|
||||||
config_path, escaped_path, escaped_path
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// macOS: 使用 Terminal.app 启动
|
/// macOS: 使用 Terminal.app 启动
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
fn launch_macos_terminal(
|
fn launch_macos_terminal(config_file: &std::path::Path) -> Result<(), String> {
|
||||||
config_file: &std::path::Path,
|
use std::os::unix::fs::PermissionsExt;
|
||||||
config_path_escaped: &str,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
let config_path_for_script = config_file.to_string_lossy().replace('\"', "\\\"");
|
let temp_dir = std::env::temp_dir();
|
||||||
|
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
|
||||||
|
|
||||||
let shell_script = generate_wrapper_script(&config_path_for_script, config_path_escaped);
|
let config_path = config_file.to_string_lossy();
|
||||||
|
|
||||||
let script = format!(
|
// Write the shell script to a temp file (no escaping needed!)
|
||||||
r#"tell application "Terminal"
|
let script_content = format!(
|
||||||
activate
|
r#"#!/bin/bash
|
||||||
do script "{}"
|
trap 'rm -f "{config_path}" "{script_file}"' EXIT
|
||||||
end tell"#,
|
echo "Using provider-specific claude config:"
|
||||||
shell_script.replace('\"', "\\\"")
|
echo "{config_path}"
|
||||||
|
claude --settings "{config_path}"
|
||||||
|
exec bash --norc --noprofile
|
||||||
|
"#,
|
||||||
|
config_path = config_path,
|
||||||
|
script_file = script_file.display()
|
||||||
);
|
);
|
||||||
|
|
||||||
Command::new("osascript")
|
std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?;
|
||||||
|
|
||||||
|
// Make script executable
|
||||||
|
std::fs::set_permissions(&script_file, std::fs::Permissions::from_mode(0o755))
|
||||||
|
.map_err(|e| format!("设置脚本权限失败: {e}"))?;
|
||||||
|
|
||||||
|
// Simple AppleScript - just execute the script file
|
||||||
|
let applescript = format!(
|
||||||
|
r#"tell application "Terminal"
|
||||||
|
activate
|
||||||
|
do script "bash '{}'"
|
||||||
|
end tell"#,
|
||||||
|
script_file.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
let output = Command::new("osascript")
|
||||||
.arg("-e")
|
.arg("-e")
|
||||||
.arg(&script)
|
.arg(&applescript)
|
||||||
.spawn()
|
.output()
|
||||||
.map_err(|e| format!("启动 macOS 终端失败: {e}"))?;
|
.map_err(|e| format!("执行 osascript 失败: {e}"))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
// Clean up on failure
|
||||||
|
let _ = std::fs::remove_file(&script_file);
|
||||||
|
let _ = std::fs::remove_file(config_file);
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
return Err(format!(
|
||||||
|
"AppleScript 执行失败 (exit code: {:?}): {}",
|
||||||
|
output.status.code(),
|
||||||
|
stderr
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Linux: 尝试使用常见终端启动
|
/// Linux: 尝试使用常见终端启动
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn launch_linux_terminal(
|
fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> {
|
||||||
config_file: &std::path::Path,
|
use std::os::unix::fs::PermissionsExt;
|
||||||
config_path_escaped: &str,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
let terminals = [
|
let terminals = [
|
||||||
"gnome-terminal",
|
("gnome-terminal", vec!["--"]),
|
||||||
"konsole",
|
("konsole", vec!["-e"]),
|
||||||
"xfce4-terminal",
|
("xfce4-terminal", vec!["-e"]),
|
||||||
"mate-terminal",
|
("mate-terminal", vec!["--"]),
|
||||||
"lxterminal",
|
("lxterminal", vec!["-e"]),
|
||||||
"alacritty",
|
("alacritty", vec!["-e"]),
|
||||||
"kitty",
|
("kitty", vec!["-e"]),
|
||||||
];
|
];
|
||||||
|
|
||||||
let config_path_for_bash = config_file.to_string_lossy();
|
// Create temp script file (same approach as macOS)
|
||||||
let shell_cmd = generate_wrapper_script(&config_path_for_bash, config_path_escaped);
|
let temp_dir = std::env::temp_dir();
|
||||||
|
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
|
||||||
|
let config_path = config_file.to_string_lossy();
|
||||||
|
|
||||||
|
let script_content = format!(
|
||||||
|
r#"#!/bin/bash
|
||||||
|
trap 'rm -f "{config_path}" "{script_file}"' EXIT
|
||||||
|
echo "Using provider-specific claude config:"
|
||||||
|
echo "{config_path}"
|
||||||
|
claude --settings "{config_path}"
|
||||||
|
exec bash --norc --noprofile
|
||||||
|
"#,
|
||||||
|
config_path = config_path,
|
||||||
|
script_file = script_file.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?;
|
||||||
|
|
||||||
|
std::fs::set_permissions(&script_file, std::fs::Permissions::from_mode(0o755))
|
||||||
|
.map_err(|e| format!("设置脚本权限失败: {e}"))?;
|
||||||
|
|
||||||
let mut last_error = String::from("未找到可用的终端");
|
let mut last_error = String::from("未找到可用的终端");
|
||||||
|
|
||||||
for terminal in terminals {
|
for (terminal, args) in terminals {
|
||||||
// 检查终端是否存在
|
// Check if terminal exists
|
||||||
if std::path::Path::new(&format!("/usr/bin/{}", terminal)).exists()
|
if std::path::Path::new(&format!("/usr/bin/{}", terminal)).exists()
|
||||||
|| std::path::Path::new(&format!("/bin/{}", terminal)).exists()
|
|| std::path::Path::new(&format!("/bin/{}", terminal)).exists()
|
||||||
{
|
{
|
||||||
let result = match terminal {
|
let result = Command::new(terminal)
|
||||||
"gnome-terminal" | "mate-terminal" => Command::new(terminal)
|
.args(&args)
|
||||||
.arg("--")
|
.arg("bash")
|
||||||
.arg("bash")
|
.arg(script_file.to_string_lossy().as_ref())
|
||||||
.arg("-c")
|
.output();
|
||||||
.arg(&shell_cmd)
|
|
||||||
.spawn(),
|
|
||||||
_ => Command::new(terminal)
|
|
||||||
.arg("-e")
|
|
||||||
.arg("bash")
|
|
||||||
.arg("-c")
|
|
||||||
.arg(&shell_cmd)
|
|
||||||
.spawn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(_) => return Ok(()),
|
Ok(output) if output.status.success() => return Ok(()),
|
||||||
|
Ok(output) => {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
last_error = format!("启动 {} 失败: {}", terminal, stderr);
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
last_error = format!("启动 {} 失败: {}", terminal, e);
|
last_error = format!("执行 {} 失败: {}", terminal, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清理配置文件
|
// Clean up on failure
|
||||||
|
let _ = std::fs::remove_file(&script_file);
|
||||||
let _ = std::fs::remove_file(config_file);
|
let _ = std::fs::remove_file(config_file);
|
||||||
Err(last_error)
|
Err(last_error)
|
||||||
}
|
}
|
||||||
@@ -568,21 +729,31 @@ echo {}
|
|||||||
claude --settings \"{}\"
|
claude --settings \"{}\"
|
||||||
del \"{}\" >nul 2>&1
|
del \"{}\" >nul 2>&1
|
||||||
del \"%~f0\" >nul 2>&1
|
del \"%~f0\" >nul 2>&1
|
||||||
if errorlevel 1 (
|
",
|
||||||
echo.
|
|
||||||
echo Press any key to close...
|
|
||||||
pause >nul
|
|
||||||
)",
|
|
||||||
config_path_for_batch, config_path_for_batch, config_path_for_batch
|
config_path_for_batch, config_path_for_batch, config_path_for_batch
|
||||||
);
|
);
|
||||||
|
|
||||||
std::fs::write(&bat_file, content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
|
std::fs::write(&bat_file, content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
|
||||||
|
|
||||||
Command::new("cmd")
|
// Use output() to capture errors from the start command
|
||||||
.args(["/C", "start", "cmd", "/C", &bat_file.to_string_lossy()])
|
// Use /K instead of /C to keep the window open after execution
|
||||||
|
let output = Command::new("cmd")
|
||||||
|
.args(["/C", "start", "cmd", "/K", &bat_file.to_string_lossy()])
|
||||||
.creation_flags(CREATE_NO_WINDOW)
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
.spawn()
|
.output()
|
||||||
.map_err(|e| format!("启动 Windows 终端失败: {e}"))?;
|
.map_err(|e| format!("执行 cmd 失败: {e}"))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
// Clean up on failure
|
||||||
|
let _ = std::fs::remove_file(&bat_file);
|
||||||
|
let _ = std::fs::remove_file(config_file);
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
return Err(format!(
|
||||||
|
"启动 Windows 终端失败 (exit code: {:?}): {}",
|
||||||
|
output.status.code(),
|
||||||
|
stderr
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ mod config;
|
|||||||
mod deeplink;
|
mod deeplink;
|
||||||
mod env;
|
mod env;
|
||||||
mod failover;
|
mod failover;
|
||||||
|
mod global_proxy;
|
||||||
mod import_export;
|
mod import_export;
|
||||||
mod mcp;
|
mod mcp;
|
||||||
mod misc;
|
mod misc;
|
||||||
@@ -20,6 +21,7 @@ pub use config::*;
|
|||||||
pub use deeplink::*;
|
pub use deeplink::*;
|
||||||
pub use env::*;
|
pub use env::*;
|
||||||
pub use failover::*;
|
pub use failover::*;
|
||||||
|
pub use global_proxy::*;
|
||||||
pub use import_export::*;
|
pub use import_export::*;
|
||||||
pub use mcp::*;
|
pub use mcp::*;
|
||||||
pub use misc::*;
|
pub use misc::*;
|
||||||
|
|||||||
@@ -60,6 +60,16 @@ pub fn delete_provider(
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove provider from live config only (for additive mode apps like OpenCode)
|
||||||
|
/// Does NOT delete from database - provider remains in the list
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn remove_provider_from_live_config(app: String, id: String) -> Result<bool, String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
ProviderService::remove_from_live_config(app_type, &id)
|
||||||
|
.map(|_| true)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
/// 切换供应商
|
/// 切换供应商
|
||||||
fn switch_provider_internal(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
fn switch_provider_internal(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
||||||
ProviderService::switch(state, app_type, id)
|
ProviderService::switch(state, app_type, id)
|
||||||
@@ -133,6 +143,7 @@ pub async fn testUsageScript(
|
|||||||
#[allow(non_snake_case)] baseUrl: Option<String>,
|
#[allow(non_snake_case)] baseUrl: Option<String>,
|
||||||
#[allow(non_snake_case)] accessToken: Option<String>,
|
#[allow(non_snake_case)] accessToken: Option<String>,
|
||||||
#[allow(non_snake_case)] userId: Option<String>,
|
#[allow(non_snake_case)] userId: Option<String>,
|
||||||
|
#[allow(non_snake_case)] templateType: Option<String>,
|
||||||
) -> Result<crate::provider::UsageResult, String> {
|
) -> Result<crate::provider::UsageResult, 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())?;
|
||||||
ProviderService::test_usage_script(
|
ProviderService::test_usage_script(
|
||||||
@@ -145,6 +156,7 @@ pub async fn testUsageScript(
|
|||||||
baseUrl.as_deref(),
|
baseUrl.as_deref(),
|
||||||
accessToken.as_deref(),
|
accessToken.as_deref(),
|
||||||
userId.as_deref(),
|
userId.as_deref(),
|
||||||
|
templateType.as_deref(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
@@ -323,3 +335,27 @@ pub fn sync_universal_provider(
|
|||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// OpenCode 专属命令
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// 从 OpenCode live 配置导入供应商到数据库
|
||||||
|
///
|
||||||
|
/// 这是 OpenCode 特有的功能,因为 OpenCode 使用累加模式,
|
||||||
|
/// 用户可能已经在 opencode.json 中配置了供应商。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn import_opencode_providers_from_live(state: State<'_, AppState>) -> Result<usize, String> {
|
||||||
|
crate::services::provider::import_opencode_providers_from_live(state.inner())
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 OpenCode live 配置中的供应商 ID 列表
|
||||||
|
///
|
||||||
|
/// 用于前端判断供应商是否已添加到 opencode.json
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_opencode_live_provider_ids() -> Result<Vec<String>, String> {
|
||||||
|
crate::opencode_config::get_providers()
|
||||||
|
.map(|providers| providers.keys().cloned().collect())
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,3 +59,51 @@ pub async fn set_auto_launch(enabled: bool) -> Result<bool, String> {
|
|||||||
pub async fn get_auto_launch_status() -> Result<bool, String> {
|
pub async fn get_auto_launch_status() -> Result<bool, String> {
|
||||||
crate::auto_launch::is_auto_launch_enabled().map_err(|e| format!("获取开机自启状态失败: {e}"))
|
crate::auto_launch::is_auto_launch_enabled().map_err(|e| format!("获取开机自启状态失败: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取整流器配置
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_rectifier_config(
|
||||||
|
state: tauri::State<'_, crate::AppState>,
|
||||||
|
) -> Result<crate::proxy::types::RectifierConfig, String> {
|
||||||
|
state.db.get_rectifier_config().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置整流器配置
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_rectifier_config(
|
||||||
|
state: tauri::State<'_, crate::AppState>,
|
||||||
|
config: crate::proxy::types::RectifierConfig,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.set_rectifier_config(&config)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取日志配置
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_log_config(
|
||||||
|
state: tauri::State<'_, crate::AppState>,
|
||||||
|
) -> Result<crate::proxy::types::LogConfig, String> {
|
||||||
|
state.db.get_log_config().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置日志配置
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_log_config(
|
||||||
|
state: tauri::State<'_, crate::AppState>,
|
||||||
|
config: crate::proxy::types::LogConfig,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.set_log_config(&config)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
log::set_max_level(config.to_level_filter());
|
||||||
|
log::info!(
|
||||||
|
"日志配置已更新: enabled={}, level={}",
|
||||||
|
config.enabled,
|
||||||
|
config.level
|
||||||
|
);
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ fn parse_app_type(app: &str) -> Result<AppType, String> {
|
|||||||
"claude" => Ok(AppType::Claude),
|
"claude" => Ok(AppType::Claude),
|
||||||
"codex" => Ok(AppType::Codex),
|
"codex" => Ok(AppType::Codex),
|
||||||
"gemini" => Ok(AppType::Gemini),
|
"gemini" => Ok(AppType::Gemini),
|
||||||
|
"opencode" => Ok(AppType::OpenCode),
|
||||||
_ => Err(format!("不支持的 app 类型: {app}")),
|
_ => Err(format!("不支持的 app 类型: {app}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ impl Database {
|
|||||||
pub fn get_all_mcp_servers(&self) -> Result<IndexMap<String, McpServer>, AppError> {
|
pub fn get_all_mcp_servers(&self) -> Result<IndexMap<String, McpServer>, AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini
|
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode
|
||||||
FROM mcp_servers
|
FROM mcp_servers
|
||||||
ORDER BY name ASC, id ASC"
|
ORDER BY name ASC, id ASC"
|
||||||
).map_err(|e| AppError::Database(e.to_string()))?;
|
).map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
@@ -30,6 +30,7 @@ impl Database {
|
|||||||
let enabled_claude: bool = row.get(7)?;
|
let enabled_claude: bool = row.get(7)?;
|
||||||
let enabled_codex: bool = row.get(8)?;
|
let enabled_codex: bool = row.get(8)?;
|
||||||
let enabled_gemini: bool = row.get(9)?;
|
let enabled_gemini: bool = row.get(9)?;
|
||||||
|
let enabled_opencode: bool = row.get(10)?;
|
||||||
|
|
||||||
let server = serde_json::from_str(&server_config_str).unwrap_or_default();
|
let server = serde_json::from_str(&server_config_str).unwrap_or_default();
|
||||||
let tags = serde_json::from_str(&tags_str).unwrap_or_default();
|
let tags = serde_json::from_str(&tags_str).unwrap_or_default();
|
||||||
@@ -44,6 +45,7 @@ impl Database {
|
|||||||
claude: enabled_claude,
|
claude: enabled_claude,
|
||||||
codex: enabled_codex,
|
codex: enabled_codex,
|
||||||
gemini: enabled_gemini,
|
gemini: enabled_gemini,
|
||||||
|
opencode: enabled_opencode,
|
||||||
},
|
},
|
||||||
description,
|
description,
|
||||||
homepage,
|
homepage,
|
||||||
@@ -68,8 +70,8 @@ impl Database {
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO mcp_servers (
|
"INSERT OR REPLACE INTO mcp_servers (
|
||||||
id, name, server_config, description, homepage, docs, tags,
|
id, name, server_config, description, homepage, docs, tags,
|
||||||
enabled_claude, enabled_codex, enabled_gemini
|
enabled_claude, enabled_codex, enabled_gemini, enabled_opencode
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||||
params![
|
params![
|
||||||
server.id,
|
server.id,
|
||||||
server.name,
|
server.name,
|
||||||
@@ -84,6 +86,7 @@ impl Database {
|
|||||||
server.apps.claude,
|
server.apps.claude,
|
||||||
server.apps.codex,
|
server.apps.codex,
|
||||||
server.apps.gemini,
|
server.apps.gemini,
|
||||||
|
server.apps.opencode,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|||||||
@@ -614,4 +614,51 @@ impl Database {
|
|||||||
log::info!("已删除所有 Live 配置备份");
|
log::info!("已删除所有 Live 配置备份");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Sync Methods for Tray Menu ====================
|
||||||
|
|
||||||
|
/// 同步获取应用的 proxy 启用状态和自动故障转移状态
|
||||||
|
///
|
||||||
|
/// 用于托盘菜单构建等同步场景
|
||||||
|
/// 返回 (enabled, auto_failover_enabled)
|
||||||
|
pub fn get_proxy_flags_sync(&self, app_type: &str) -> (bool, bool) {
|
||||||
|
let conn = match self.conn.lock() {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => return (false, false),
|
||||||
|
};
|
||||||
|
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT enabled, auto_failover_enabled FROM proxy_config WHERE app_type = ?1",
|
||||||
|
[app_type],
|
||||||
|
|row| Ok((row.get::<_, i32>(0)? != 0, row.get::<_, i32>(1)? != 0)),
|
||||||
|
)
|
||||||
|
.unwrap_or((false, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同步设置应用的 proxy 启用状态和自动故障转移状态
|
||||||
|
///
|
||||||
|
/// 用于托盘菜单点击等同步场景
|
||||||
|
pub fn set_proxy_flags_sync(
|
||||||
|
&self,
|
||||||
|
app_type: &str,
|
||||||
|
enabled: bool,
|
||||||
|
auto_failover_enabled: bool,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let conn = self
|
||||||
|
.conn
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| AppError::Database(format!("Mutex lock failed: {e}")))?;
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE proxy_config SET enabled = ?2, auto_failover_enabled = ?3, updated_at = datetime('now') WHERE app_type = ?1",
|
||||||
|
rusqlite::params![
|
||||||
|
app_type,
|
||||||
|
if enabled { 1 } else { 0 },
|
||||||
|
if auto_failover_enabled { 1 } else { 0 },
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,41 @@ impl Database {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 全局出站代理 ---
|
||||||
|
|
||||||
|
/// 全局代理 URL 的存储键名
|
||||||
|
const GLOBAL_PROXY_URL_KEY: &'static str = "global_proxy_url";
|
||||||
|
|
||||||
|
/// 获取全局出站代理 URL
|
||||||
|
///
|
||||||
|
/// 返回 None 表示未配置或已清除代理(直连)
|
||||||
|
/// 返回 Some(url) 表示已配置代理
|
||||||
|
pub fn get_global_proxy_url(&self) -> Result<Option<String>, AppError> {
|
||||||
|
self.get_setting(Self::GLOBAL_PROXY_URL_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置全局出站代理 URL
|
||||||
|
///
|
||||||
|
/// - 传入非空字符串:启用代理
|
||||||
|
/// - 传入空字符串或 None:清除代理设置(直连)
|
||||||
|
pub fn set_global_proxy_url(&self, url: Option<&str>) -> Result<(), AppError> {
|
||||||
|
match url {
|
||||||
|
Some(u) if !u.trim().is_empty() => {
|
||||||
|
self.set_setting(Self::GLOBAL_PROXY_URL_KEY, u.trim())
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// 清除代理设置
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM settings WHERE key = ?1",
|
||||||
|
params![Self::GLOBAL_PROXY_URL_KEY],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- 代理接管状态管理(已废弃,使用 proxy_config.enabled 替代)---
|
// --- 代理接管状态管理(已废弃,使用 proxy_config.enabled 替代)---
|
||||||
|
|
||||||
/// 获取指定应用的代理接管状态
|
/// 获取指定应用的代理接管状态
|
||||||
@@ -128,4 +163,45 @@ impl Database {
|
|||||||
log::info!("已清除所有代理接管状态");
|
log::info!("已清除所有代理接管状态");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 整流器配置 ---
|
||||||
|
|
||||||
|
/// 获取整流器配置
|
||||||
|
///
|
||||||
|
/// 返回整流器配置,如果不存在则返回默认值(全部启用)
|
||||||
|
pub fn get_rectifier_config(&self) -> Result<crate::proxy::types::RectifierConfig, AppError> {
|
||||||
|
match self.get_setting("rectifier_config")? {
|
||||||
|
Some(json) => serde_json::from_str(&json)
|
||||||
|
.map_err(|e| AppError::Database(format!("解析整流器配置失败: {e}"))),
|
||||||
|
None => Ok(crate::proxy::types::RectifierConfig::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新整流器配置
|
||||||
|
pub fn set_rectifier_config(
|
||||||
|
&self,
|
||||||
|
config: &crate::proxy::types::RectifierConfig,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let json = serde_json::to_string(config)
|
||||||
|
.map_err(|e| AppError::Database(format!("序列化整流器配置失败: {e}")))?;
|
||||||
|
self.set_setting("rectifier_config", &json)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 日志配置 ---
|
||||||
|
|
||||||
|
/// 获取日志配置
|
||||||
|
pub fn get_log_config(&self) -> Result<crate::proxy::types::LogConfig, AppError> {
|
||||||
|
match self.get_setting("log_config")? {
|
||||||
|
Some(json) => serde_json::from_str(&json)
|
||||||
|
.map_err(|e| AppError::Database(format!("解析日志配置失败: {e}"))),
|
||||||
|
None => Ok(crate::proxy::types::LogConfig::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新日志配置
|
||||||
|
pub fn set_log_config(&self, config: &crate::proxy::types::LogConfig) -> Result<(), AppError> {
|
||||||
|
let json = serde_json::to_string(config)
|
||||||
|
.map_err(|e| AppError::Database(format!("序列化日志配置失败: {e}")))?;
|
||||||
|
self.set_setting("log_config", &json)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
//! 提供 Skills 和 Skill Repos 的 CRUD 操作。
|
//! 提供 Skills 和 Skill Repos 的 CRUD 操作。
|
||||||
//!
|
//!
|
||||||
//! v3.10.0+ 统一管理架构:
|
//! v3.10.0+ 统一管理架构:
|
||||||
//! - Skills 使用统一的 id 主键,支持三应用启用标志
|
//! - Skills 使用统一的 id 主键,支持四应用启用标志
|
||||||
//! - 实际文件存储在 ~/.cc-switch/skills/,同步到各应用目录
|
//! - 实际文件存储在 ~/.cc-switch/skills/,同步到各应用目录
|
||||||
|
|
||||||
use crate::app_config::{InstalledSkill, SkillApps};
|
use crate::app_config::{InstalledSkill, SkillApps};
|
||||||
@@ -22,7 +22,7 @@ impl Database {
|
|||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
|
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
|
||||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at
|
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at
|
||||||
FROM skills ORDER BY name ASC",
|
FROM skills ORDER BY name ASC",
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
@@ -42,8 +42,9 @@ impl Database {
|
|||||||
claude: row.get(8)?,
|
claude: row.get(8)?,
|
||||||
codex: row.get(9)?,
|
codex: row.get(9)?,
|
||||||
gemini: row.get(10)?,
|
gemini: row.get(10)?,
|
||||||
|
opencode: row.get(11)?,
|
||||||
},
|
},
|
||||||
installed_at: row.get(11)?,
|
installed_at: row.get(12)?,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
@@ -62,7 +63,7 @@ impl Database {
|
|||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
|
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
|
||||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at
|
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at
|
||||||
FROM skills WHERE id = ?1",
|
FROM skills WHERE id = ?1",
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
@@ -81,8 +82,9 @@ impl Database {
|
|||||||
claude: row.get(8)?,
|
claude: row.get(8)?,
|
||||||
codex: row.get(9)?,
|
codex: row.get(9)?,
|
||||||
gemini: row.get(10)?,
|
gemini: row.get(10)?,
|
||||||
|
opencode: row.get(11)?,
|
||||||
},
|
},
|
||||||
installed_at: row.get(11)?,
|
installed_at: row.get(12)?,
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -99,8 +101,8 @@ impl Database {
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO skills
|
"INSERT OR REPLACE INTO skills
|
||||||
(id, name, description, directory, repo_owner, repo_name, repo_branch,
|
(id, name, description, directory, repo_owner, repo_name, repo_branch,
|
||||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at)
|
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||||
params![
|
params![
|
||||||
skill.id,
|
skill.id,
|
||||||
skill.name,
|
skill.name,
|
||||||
@@ -113,6 +115,7 @@ impl Database {
|
|||||||
skill.apps.claude,
|
skill.apps.claude,
|
||||||
skill.apps.codex,
|
skill.apps.codex,
|
||||||
skill.apps.gemini,
|
skill.apps.gemini,
|
||||||
|
skill.apps.opencode,
|
||||||
skill.installed_at,
|
skill.installed_at,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -142,8 +145,8 @@ impl Database {
|
|||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
let affected = conn
|
let affected = conn
|
||||||
.execute(
|
.execute(
|
||||||
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3 WHERE id = ?4",
|
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_opencode = ?4 WHERE id = ?5",
|
||||||
params![apps.claude, apps.codex, apps.gemini, id],
|
params![apps.claude, apps.codex, apps.gemini, apps.opencode, id],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
Ok(affected > 0)
|
Ok(affected > 0)
|
||||||
@@ -200,24 +203,32 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 初始化默认的 Skill 仓库(首次启动时调用)
|
/// 初始化默认的 Skill 仓库(启动时调用,补充缺失的默认仓库)
|
||||||
pub fn init_default_skill_repos(&self) -> Result<usize, AppError> {
|
pub fn init_default_skill_repos(&self) -> Result<usize, AppError> {
|
||||||
// 检查是否已有仓库
|
// 获取已有仓库列表
|
||||||
let existing = self.get_skill_repos()?;
|
let existing = self.get_skill_repos()?;
|
||||||
if !existing.is_empty() {
|
let existing_keys: std::collections::HashSet<(String, String)> = existing
|
||||||
return Ok(0);
|
.iter()
|
||||||
}
|
.map(|r| (r.owner.clone(), r.name.clone()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
// 获取默认仓库列表
|
// 获取默认仓库列表
|
||||||
let default_store = crate::services::skill::SkillStore::default();
|
let default_store = crate::services::skill::SkillStore::default();
|
||||||
let mut count = 0;
|
let mut count = 0;
|
||||||
|
|
||||||
|
// 仅插入缺失的默认仓库
|
||||||
for repo in &default_store.repos {
|
for repo in &default_store.repos {
|
||||||
self.save_skill_repo(repo)?;
|
let key = (repo.owner.clone(), repo.name.clone());
|
||||||
count += 1;
|
if !existing_keys.contains(&key) {
|
||||||
|
self.save_skill_repo(repo)?;
|
||||||
|
count += 1;
|
||||||
|
log::info!("补充默认 Skill 仓库: {}/{}", repo.owner, repo.name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log::info!("初始化默认 Skill 仓库完成,共 {count} 个");
|
if count > 0 {
|
||||||
|
log::info!("补充默认 Skill 仓库完成,新增 {count} 个");
|
||||||
|
}
|
||||||
Ok(count)
|
Ok(count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ const DB_BACKUP_RETAIN: usize = 10;
|
|||||||
|
|
||||||
/// 当前 Schema 版本号
|
/// 当前 Schema 版本号
|
||||||
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
||||||
pub(crate) const SCHEMA_VERSION: i32 = 3;
|
pub(crate) const SCHEMA_VERSION: i32 = 4;
|
||||||
|
|
||||||
/// 安全地序列化 JSON,避免 unwrap panic
|
/// 安全地序列化 JSON,避免 unwrap panic
|
||||||
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
|
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ impl Database {
|
|||||||
id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL,
|
id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL,
|
||||||
description TEXT, homepage TEXT, docs TEXT, tags TEXT NOT NULL DEFAULT '[]',
|
description TEXT, homepage TEXT, docs TEXT, tags TEXT NOT NULL DEFAULT '[]',
|
||||||
enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0,
|
enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0,
|
||||||
enabled_gemini BOOLEAN NOT NULL DEFAULT 0
|
enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0
|
||||||
)",
|
)",
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
@@ -85,6 +85,7 @@ impl Database {
|
|||||||
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
|
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
|
||||||
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
|
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
|
||||||
enabled_gemini BOOLEAN NOT NULL DEFAULT 0,
|
enabled_gemini BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
|
||||||
installed_at INTEGER NOT NULL DEFAULT 0
|
installed_at INTEGER NOT NULL DEFAULT 0
|
||||||
)",
|
)",
|
||||||
[],
|
[],
|
||||||
@@ -346,6 +347,11 @@ impl Database {
|
|||||||
Self::migrate_v2_to_v3(conn)?;
|
Self::migrate_v2_to_v3(conn)?;
|
||||||
Self::set_user_version(conn, 3)?;
|
Self::set_user_version(conn, 3)?;
|
||||||
}
|
}
|
||||||
|
3 => {
|
||||||
|
log::info!("迁移数据库从 v3 到 v4(OpenCode 支持)");
|
||||||
|
Self::migrate_v3_to_v4(conn)?;
|
||||||
|
Self::set_user_version(conn, 4)?;
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
return Err(AppError::Database(format!(
|
return Err(AppError::Database(format!(
|
||||||
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
|
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
|
||||||
@@ -849,6 +855,30 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// v3 -> v4 迁移:添加 OpenCode 支持
|
||||||
|
///
|
||||||
|
/// 为 mcp_servers 和 skills 表添加 enabled_opencode 列。
|
||||||
|
fn migrate_v3_to_v4(conn: &Connection) -> Result<(), AppError> {
|
||||||
|
// 为 mcp_servers 表添加 enabled_opencode 列
|
||||||
|
Self::add_column_if_missing(
|
||||||
|
conn,
|
||||||
|
"mcp_servers",
|
||||||
|
"enabled_opencode",
|
||||||
|
"BOOLEAN NOT NULL DEFAULT 0",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// 为 skills 表添加 enabled_opencode 列
|
||||||
|
Self::add_column_if_missing(
|
||||||
|
conn,
|
||||||
|
"skills",
|
||||||
|
"enabled_opencode",
|
||||||
|
"BOOLEAN NOT NULL DEFAULT 0",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
log::info!("v3 -> v4 迁移完成:已添加 OpenCode 支持");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// 插入默认模型定价数据
|
/// 插入默认模型定价数据
|
||||||
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
|
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
|
||||||
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
|
|||||||
claude: false,
|
claude: false,
|
||||||
codex: false,
|
codex: false,
|
||||||
gemini: false,
|
gemini: false,
|
||||||
|
opencode: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
for app in apps_str.split(',') {
|
for app in apps_str.split(',') {
|
||||||
@@ -173,6 +174,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
|
|||||||
"claude" => apps.claude = true,
|
"claude" => apps.claude = true,
|
||||||
"codex" => apps.codex = true,
|
"codex" => apps.codex = true,
|
||||||
"gemini" => apps.gemini = true,
|
"gemini" => apps.gemini = true,
|
||||||
|
"opencode" => apps.opencode = true,
|
||||||
other => {
|
other => {
|
||||||
return Err(AppError::InvalidInput(format!(
|
return Err(AppError::InvalidInput(format!(
|
||||||
"Invalid app in 'apps': {other}"
|
"Invalid app in 'apps': {other}"
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ pub(crate) fn build_provider_from_request(
|
|||||||
AppType::Claude => build_claude_settings(request),
|
AppType::Claude => build_claude_settings(request),
|
||||||
AppType::Codex => build_codex_settings(request),
|
AppType::Codex => build_codex_settings(request),
|
||||||
AppType::Gemini => build_gemini_settings(request),
|
AppType::Gemini => build_gemini_settings(request),
|
||||||
|
AppType::OpenCode => build_opencode_settings(request),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build usage script configuration if provided
|
// Build usage script configuration if provided
|
||||||
@@ -225,6 +226,7 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
|
|||||||
}),
|
}),
|
||||||
access_token: request.usage_access_token.clone(),
|
access_token: request.usage_access_token.clone(),
|
||||||
user_id: request.usage_user_id.clone(),
|
user_id: request.usage_user_id.clone(),
|
||||||
|
template_type: None, // Deeplink providers don't specify template type (will use backward compatibility logic)
|
||||||
auto_query_interval: request.usage_auto_interval,
|
auto_query_interval: request.usage_auto_interval,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -362,6 +364,33 @@ fn build_gemini_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
|||||||
json!({ "env": env })
|
json!({ "env": env })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build OpenCode settings configuration
|
||||||
|
fn build_opencode_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||||
|
let endpoint = get_primary_endpoint(request);
|
||||||
|
|
||||||
|
// Build options object
|
||||||
|
let mut options = serde_json::Map::new();
|
||||||
|
if !endpoint.is_empty() {
|
||||||
|
options.insert("baseURL".to_string(), json!(endpoint));
|
||||||
|
}
|
||||||
|
if let Some(api_key) = &request.api_key {
|
||||||
|
options.insert("apiKey".to_string(), json!(api_key));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build models object
|
||||||
|
let mut models = serde_json::Map::new();
|
||||||
|
if let Some(model) = &request.model {
|
||||||
|
models.insert(model.clone(), json!({ "name": model }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to openai-compatible npm package
|
||||||
|
json!({
|
||||||
|
"npm": "@ai-sdk/openai-compatible",
|
||||||
|
"options": options,
|
||||||
|
"models": models
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Config Merge Logic
|
// Config Merge Logic
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
+96
-10
@@ -13,6 +13,7 @@ mod gemini_config;
|
|||||||
mod gemini_mcp;
|
mod gemini_mcp;
|
||||||
mod init_status;
|
mod init_status;
|
||||||
mod mcp;
|
mod mcp;
|
||||||
|
mod opencode_config;
|
||||||
mod panic_hook;
|
mod panic_hook;
|
||||||
mod prompt;
|
mod prompt;
|
||||||
mod prompt_files;
|
mod prompt_files;
|
||||||
@@ -265,33 +266,41 @@ pub fn run() {
|
|||||||
log::warn!("初始化 Updater 插件失败,已跳过:{e}");
|
log::warn!("初始化 Updater 插件失败,已跳过:{e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 初始化日志(Debug 和 Release 模式都启用 Info 级别)
|
// 初始化日志(单文件输出到 <app_config_dir>/logs/cc-switch.log)
|
||||||
// 日志同时输出到控制台和文件(<app_config_dir>/logs/;若设置了覆盖则使用覆盖目录)
|
|
||||||
{
|
{
|
||||||
use tauri_plugin_log::{RotationStrategy, Target, TargetKind, TimezoneStrategy};
|
use tauri_plugin_log::{RotationStrategy, Target, TargetKind, TimezoneStrategy};
|
||||||
|
|
||||||
let log_dir = panic_hook::get_log_dir();
|
let log_dir = panic_hook::get_log_dir();
|
||||||
|
|
||||||
|
// 确保日志目录存在
|
||||||
|
if let Err(e) = std::fs::create_dir_all(&log_dir) {
|
||||||
|
eprintln!("创建日志目录失败: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动时删除旧日志文件,实现单文件覆盖效果
|
||||||
|
let log_file_path = log_dir.join("cc-switch.log");
|
||||||
|
let _ = std::fs::remove_file(&log_file_path);
|
||||||
|
|
||||||
app.handle().plugin(
|
app.handle().plugin(
|
||||||
tauri_plugin_log::Builder::default()
|
tauri_plugin_log::Builder::default()
|
||||||
.level(log::LevelFilter::Info)
|
// 初始化为 Trace,允许后续通过 log::set_max_level() 动态调整级别
|
||||||
|
.level(log::LevelFilter::Trace)
|
||||||
.targets([
|
.targets([
|
||||||
// 输出到控制台
|
|
||||||
Target::new(TargetKind::Stdout),
|
Target::new(TargetKind::Stdout),
|
||||||
// 输出到日志文件
|
|
||||||
Target::new(TargetKind::Folder {
|
Target::new(TargetKind::Folder {
|
||||||
path: log_dir,
|
path: log_dir,
|
||||||
file_name: Some("cc-switch".into()),
|
file_name: Some("cc-switch".into()),
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
.rotation_strategy(RotationStrategy::KeepAll)
|
// 单文件模式:启动时删除旧文件,达到大小时轮转
|
||||||
.max_file_size(5_000_000) // 5MB 单文件上限
|
// 注意:KeepSome(n) 内部会做 n-2 运算,n=1 会导致 usize 下溢
|
||||||
|
// KeepSome(2) 是最小安全值,表示不保留轮转文件
|
||||||
|
.rotation_strategy(RotationStrategy::KeepSome(2))
|
||||||
|
// 单文件大小限制 1GB
|
||||||
|
.max_file_size(1024 * 1024 * 1024)
|
||||||
.timezone_strategy(TimezoneStrategy::UseLocal)
|
.timezone_strategy(TimezoneStrategy::UseLocal)
|
||||||
.build(),
|
.build(),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// 清理旧日志文件,只保留最近 2 个
|
|
||||||
panic_hook::cleanup_old_logs();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化数据库
|
// 初始化数据库
|
||||||
@@ -482,6 +491,17 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2.1 OpenCode 供应商导入(累加式模式,需特殊处理)
|
||||||
|
// OpenCode 与其他应用不同:配置文件中可同时存在多个供应商
|
||||||
|
// 需要遍历 provider 字段下的每个供应商并导入
|
||||||
|
match crate::services::provider::import_opencode_providers_from_live(&app_state) {
|
||||||
|
Ok(count) if count > 0 => {
|
||||||
|
log::info!("✓ Imported {count} OpenCode provider(s) from live config");
|
||||||
|
}
|
||||||
|
Ok(_) => log::debug!("○ No OpenCode providers found to import"),
|
||||||
|
Err(e) => log::debug!("○ Failed to import OpenCode providers: {e}"),
|
||||||
|
}
|
||||||
|
|
||||||
// 3. 导入 MCP 服务器配置(表空时触发)
|
// 3. 导入 MCP 服务器配置(表空时触发)
|
||||||
if app_state.db.is_mcp_table_empty().unwrap_or(false) {
|
if app_state.db.is_mcp_table_empty().unwrap_or(false) {
|
||||||
log::info!("MCP table empty, importing from live configurations...");
|
log::info!("MCP table empty, importing from live configurations...");
|
||||||
@@ -509,6 +529,14 @@ 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}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
match crate::services::mcp::McpService::import_from_opencode(&app_state) {
|
||||||
|
Ok(count) if count > 0 => {
|
||||||
|
log::info!("✓ Imported {count} MCP server(s) from OpenCode");
|
||||||
|
}
|
||||||
|
Ok(_) => log::debug!("○ No OpenCode MCP servers found to import"),
|
||||||
|
Err(e) => log::warn!("✗ Failed to import OpenCode MCP: {e}"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 导入提示词文件(表空时触发)
|
// 4. 导入提示词文件(表空时触发)
|
||||||
@@ -640,10 +668,54 @@ pub fn run() {
|
|||||||
// 将同一个实例注入到全局状态,避免重复创建导致的不一致
|
// 将同一个实例注入到全局状态,避免重复创建导致的不一致
|
||||||
app.manage(app_state);
|
app.manage(app_state);
|
||||||
|
|
||||||
|
// 从数据库加载日志配置并应用
|
||||||
|
{
|
||||||
|
let db = &app.state::<AppState>().db;
|
||||||
|
if let Ok(log_config) = db.get_log_config() {
|
||||||
|
log::set_max_level(log_config.to_level_filter());
|
||||||
|
log::info!(
|
||||||
|
"已加载日志配置: enabled={}, level={}",
|
||||||
|
log_config.enabled,
|
||||||
|
log_config.level
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 初始化 SkillService
|
// 初始化 SkillService
|
||||||
let skill_service = SkillService::new();
|
let skill_service = SkillService::new();
|
||||||
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
|
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
|
||||||
|
|
||||||
|
// 初始化全局出站代理 HTTP 客户端
|
||||||
|
{
|
||||||
|
let db = &app.state::<AppState>().db;
|
||||||
|
let proxy_url = db.get_global_proxy_url().ok().flatten();
|
||||||
|
|
||||||
|
if let Err(e) = crate::proxy::http_client::init(proxy_url.as_deref()) {
|
||||||
|
log::error!(
|
||||||
|
"[GlobalProxy] [GP-005] Failed to initialize with saved config: {e}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 清除无效的代理配置
|
||||||
|
if proxy_url.is_some() {
|
||||||
|
log::warn!(
|
||||||
|
"[GlobalProxy] [GP-006] Clearing invalid proxy config from database"
|
||||||
|
);
|
||||||
|
if let Err(clear_err) = db.set_global_proxy_url(None) {
|
||||||
|
log::error!(
|
||||||
|
"[GlobalProxy] [GP-007] Failed to clear invalid config: {clear_err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用直连模式重新初始化
|
||||||
|
if let Err(fallback_err) = crate::proxy::http_client::init(None) {
|
||||||
|
log::error!(
|
||||||
|
"[GlobalProxy] [GP-008] Failed to initialize direct connection: {fallback_err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 异常退出恢复 + 代理状态自动恢复
|
// 异常退出恢复 + 代理状态自动恢复
|
||||||
let app_handle = app.handle().clone();
|
let app_handle = app.handle().clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
@@ -681,6 +753,7 @@ pub fn run() {
|
|||||||
commands::add_provider,
|
commands::add_provider,
|
||||||
commands::update_provider,
|
commands::update_provider,
|
||||||
commands::delete_provider,
|
commands::delete_provider,
|
||||||
|
commands::remove_provider_from_live_config,
|
||||||
commands::switch_provider,
|
commands::switch_provider,
|
||||||
commands::import_default_config,
|
commands::import_default_config,
|
||||||
commands::get_claude_config_status,
|
commands::get_claude_config_status,
|
||||||
@@ -703,6 +776,10 @@ pub fn run() {
|
|||||||
commands::read_live_provider_settings,
|
commands::read_live_provider_settings,
|
||||||
commands::get_settings,
|
commands::get_settings,
|
||||||
commands::save_settings,
|
commands::save_settings,
|
||||||
|
commands::get_rectifier_config,
|
||||||
|
commands::set_rectifier_config,
|
||||||
|
commands::get_log_config,
|
||||||
|
commands::set_log_config,
|
||||||
commands::restart_app,
|
commands::restart_app,
|
||||||
commands::check_for_updates,
|
commands::check_for_updates,
|
||||||
commands::is_portable_mode,
|
commands::is_portable_mode,
|
||||||
@@ -841,6 +918,15 @@ pub fn run() {
|
|||||||
commands::upsert_universal_provider,
|
commands::upsert_universal_provider,
|
||||||
commands::delete_universal_provider,
|
commands::delete_universal_provider,
|
||||||
commands::sync_universal_provider,
|
commands::sync_universal_provider,
|
||||||
|
// OpenCode specific
|
||||||
|
commands::import_opencode_providers_from_live,
|
||||||
|
commands::get_opencode_live_provider_ids,
|
||||||
|
// Global upstream proxy
|
||||||
|
commands::get_global_proxy_url,
|
||||||
|
commands::set_global_proxy_url,
|
||||||
|
commands::test_proxy_url,
|
||||||
|
commands::get_upstream_proxy_status,
|
||||||
|
commands::scan_local_proxies,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let app = builder
|
let app = builder
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError
|
|||||||
claude: true,
|
claude: true,
|
||||||
codex: false,
|
codex: false,
|
||||||
gemini: false,
|
gemini: false,
|
||||||
|
opencode: false,
|
||||||
},
|
},
|
||||||
description: None,
|
description: None,
|
||||||
homepage: None,
|
homepage: None,
|
||||||
|
|||||||
@@ -235,6 +235,7 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
|
|||||||
claude: false,
|
claude: false,
|
||||||
codex: true,
|
codex: true,
|
||||||
gemini: false,
|
gemini: false,
|
||||||
|
opencode: false,
|
||||||
},
|
},
|
||||||
description: None,
|
description: None,
|
||||||
homepage: None,
|
homepage: None,
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result<usize, AppError
|
|||||||
claude: false,
|
claude: false,
|
||||||
codex: false,
|
codex: false,
|
||||||
gemini: true,
|
gemini: true,
|
||||||
|
opencode: false,
|
||||||
},
|
},
|
||||||
description: None,
|
description: None,
|
||||||
homepage: None,
|
homepage: None,
|
||||||
|
|||||||
@@ -8,10 +8,12 @@
|
|||||||
//! - `claude` - Claude MCP 同步和导入
|
//! - `claude` - Claude MCP 同步和导入
|
||||||
//! - `codex` - Codex MCP 同步和导入(含 TOML 转换)
|
//! - `codex` - Codex MCP 同步和导入(含 TOML 转换)
|
||||||
//! - `gemini` - Gemini MCP 同步和导入
|
//! - `gemini` - Gemini MCP 同步和导入
|
||||||
|
//! - `opencode` - OpenCode MCP 同步和导入(含 local/remote 格式转换)
|
||||||
|
|
||||||
mod claude;
|
mod claude;
|
||||||
mod codex;
|
mod codex;
|
||||||
mod gemini;
|
mod gemini;
|
||||||
|
mod opencode;
|
||||||
mod validation;
|
mod validation;
|
||||||
|
|
||||||
// 重新导出公共 API
|
// 重新导出公共 API
|
||||||
@@ -26,3 +28,6 @@ pub use gemini::{
|
|||||||
import_from_gemini, remove_server_from_gemini, sync_enabled_to_gemini,
|
import_from_gemini, remove_server_from_gemini, sync_enabled_to_gemini,
|
||||||
sync_single_server_to_gemini,
|
sync_single_server_to_gemini,
|
||||||
};
|
};
|
||||||
|
pub use opencode::{
|
||||||
|
import_from_opencode, remove_server_from_opencode, sync_single_server_to_opencode,
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
//! OpenCode MCP 同步和导入模块
|
||||||
|
//!
|
||||||
|
//! 本模块处理 CC Switch 统一 MCP 格式与 OpenCode 格式之间的转换。
|
||||||
|
//!
|
||||||
|
//! ## 格式差异
|
||||||
|
//!
|
||||||
|
//! | CC Switch 统一格式 | OpenCode 格式 |
|
||||||
|
//! |----------------------|---------------------|
|
||||||
|
//! | `type: "stdio"` | `type: "local"` |
|
||||||
|
//! | `command` + `args` | `command: [cmd, ...args]` |
|
||||||
|
//! | `env` | `environment` |
|
||||||
|
//! | `type: "sse"/"http"` | `type: "remote"` |
|
||||||
|
//! | `url` | `url` |
|
||||||
|
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::app_config::{McpApps, McpServer, MultiAppConfig};
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::opencode_config;
|
||||||
|
|
||||||
|
use super::validation::validate_server_spec;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Check if OpenCode MCP sync should proceed
|
||||||
|
fn should_sync_opencode_mcp() -> bool {
|
||||||
|
// Skip if OpenCode config directory doesn't exist
|
||||||
|
opencode_config::get_opencode_dir().exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Format Conversion: CC Switch → OpenCode
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Convert CC Switch unified format to OpenCode format
|
||||||
|
///
|
||||||
|
/// Conversion rules:
|
||||||
|
/// - `stdio` → `local`, command+args → command array, env → environment
|
||||||
|
/// - `sse`/`http` → `remote`, url preserved
|
||||||
|
pub fn convert_to_opencode_format(spec: &Value) -> Result<Value, AppError> {
|
||||||
|
let obj = spec
|
||||||
|
.as_object()
|
||||||
|
.ok_or_else(|| AppError::McpValidation("MCP spec must be a JSON object".into()))?;
|
||||||
|
|
||||||
|
let typ = obj.get("type").and_then(|v| v.as_str()).unwrap_or("stdio");
|
||||||
|
|
||||||
|
let mut result = serde_json::Map::new();
|
||||||
|
|
||||||
|
match typ {
|
||||||
|
"stdio" => {
|
||||||
|
// Convert to "local" type
|
||||||
|
result.insert("type".into(), json!("local"));
|
||||||
|
|
||||||
|
// Merge command and args into a single array
|
||||||
|
let cmd = obj.get("command").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let mut command_arr = vec![json!(cmd)];
|
||||||
|
|
||||||
|
if let Some(args) = obj.get("args").and_then(|v| v.as_array()) {
|
||||||
|
for arg in args {
|
||||||
|
command_arr.push(arg.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.insert("command".into(), Value::Array(command_arr));
|
||||||
|
|
||||||
|
// Convert env → environment
|
||||||
|
if let Some(env) = obj.get("env") {
|
||||||
|
if env.is_object() && !env.as_object().map(|o| o.is_empty()).unwrap_or(true) {
|
||||||
|
result.insert("environment".into(), env.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add enabled flag (OpenCode expects this)
|
||||||
|
result.insert("enabled".into(), json!(true));
|
||||||
|
}
|
||||||
|
"sse" | "http" => {
|
||||||
|
// Convert to "remote" type
|
||||||
|
result.insert("type".into(), json!("remote"));
|
||||||
|
|
||||||
|
// Preserve url
|
||||||
|
if let Some(url) = obj.get("url") {
|
||||||
|
result.insert("url".into(), url.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert headers if present
|
||||||
|
if let Some(headers) = obj.get("headers") {
|
||||||
|
if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true)
|
||||||
|
{
|
||||||
|
result.insert("headers".into(), headers.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add enabled flag
|
||||||
|
result.insert("enabled".into(), json!(true));
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(AppError::McpValidation(format!("Unknown MCP type: {typ}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Value::Object(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Format Conversion: OpenCode → CC Switch
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Convert OpenCode format to CC Switch unified format
|
||||||
|
///
|
||||||
|
/// Conversion rules:
|
||||||
|
/// - `local` → `stdio`, command array → command+args, environment → env
|
||||||
|
/// - `remote` → `sse`, url preserved
|
||||||
|
pub fn convert_from_opencode_format(spec: &Value) -> Result<Value, AppError> {
|
||||||
|
let obj = spec
|
||||||
|
.as_object()
|
||||||
|
.ok_or_else(|| AppError::McpValidation("OpenCode MCP spec must be a JSON object".into()))?;
|
||||||
|
|
||||||
|
let typ = obj.get("type").and_then(|v| v.as_str()).unwrap_or("local");
|
||||||
|
|
||||||
|
let mut result = serde_json::Map::new();
|
||||||
|
|
||||||
|
match typ {
|
||||||
|
"local" => {
|
||||||
|
// Convert to "stdio" type
|
||||||
|
result.insert("type".into(), json!("stdio"));
|
||||||
|
|
||||||
|
// Split command array into command and args
|
||||||
|
if let Some(cmd_arr) = obj.get("command").and_then(|v| v.as_array()) {
|
||||||
|
if !cmd_arr.is_empty() {
|
||||||
|
// First element is the command
|
||||||
|
if let Some(cmd) = cmd_arr.first().and_then(|v| v.as_str()) {
|
||||||
|
result.insert("command".into(), json!(cmd));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rest are args
|
||||||
|
if cmd_arr.len() > 1 {
|
||||||
|
let args: Vec<Value> = cmd_arr[1..].to_vec();
|
||||||
|
result.insert("args".into(), Value::Array(args));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert environment → env
|
||||||
|
if let Some(env) = obj.get("environment") {
|
||||||
|
if env.is_object() && !env.as_object().map(|o| o.is_empty()).unwrap_or(true) {
|
||||||
|
result.insert("env".into(), env.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"remote" => {
|
||||||
|
// Convert to "sse" type (default remote protocol)
|
||||||
|
result.insert("type".into(), json!("sse"));
|
||||||
|
|
||||||
|
// Preserve url
|
||||||
|
if let Some(url) = obj.get("url") {
|
||||||
|
result.insert("url".into(), url.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve headers
|
||||||
|
if let Some(headers) = obj.get("headers") {
|
||||||
|
if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true)
|
||||||
|
{
|
||||||
|
result.insert("headers".into(), headers.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(AppError::McpValidation(format!(
|
||||||
|
"Unknown OpenCode MCP type: {typ}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Value::Object(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Public API: Sync Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Sync a single MCP server to OpenCode live config
|
||||||
|
pub fn sync_single_server_to_opencode(
|
||||||
|
_config: &MultiAppConfig,
|
||||||
|
id: &str,
|
||||||
|
server_spec: &Value,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
if !should_sync_opencode_mcp() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to OpenCode format
|
||||||
|
let opencode_spec = convert_to_opencode_format(server_spec)?;
|
||||||
|
|
||||||
|
// Set in OpenCode config
|
||||||
|
opencode_config::set_mcp_server(id, opencode_spec)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a single MCP server from OpenCode live config
|
||||||
|
pub fn remove_server_from_opencode(id: &str) -> Result<(), AppError> {
|
||||||
|
if !should_sync_opencode_mcp() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
opencode_config::remove_mcp_server(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import MCP servers from OpenCode config to unified structure
|
||||||
|
///
|
||||||
|
/// Existing servers will have OpenCode app enabled without overwriting other fields.
|
||||||
|
pub fn import_from_opencode(config: &mut MultiAppConfig) -> Result<usize, AppError> {
|
||||||
|
let mcp_map = opencode_config::get_mcp_servers()?;
|
||||||
|
if mcp_map.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure servers map exists
|
||||||
|
let servers = config.mcp.servers.get_or_insert_with(HashMap::new);
|
||||||
|
|
||||||
|
let mut changed = 0;
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
|
||||||
|
for (id, spec) in mcp_map {
|
||||||
|
// Convert from OpenCode format to unified format
|
||||||
|
let unified_spec = match convert_from_opencode_format(&spec) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Skip invalid OpenCode MCP server '{id}': {e}");
|
||||||
|
errors.push(format!("{id}: {e}"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate the converted spec
|
||||||
|
if let Err(e) = validate_server_spec(&unified_spec) {
|
||||||
|
log::warn!("Skip invalid MCP server '{id}' after conversion: {e}");
|
||||||
|
errors.push(format!("{id}: {e}"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(existing) = servers.get_mut(&id) {
|
||||||
|
// Existing server: just enable OpenCode app
|
||||||
|
if !existing.apps.opencode {
|
||||||
|
existing.apps.opencode = true;
|
||||||
|
changed += 1;
|
||||||
|
log::info!("MCP server '{id}' enabled for OpenCode");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// New server: default to only OpenCode enabled
|
||||||
|
servers.insert(
|
||||||
|
id.clone(),
|
||||||
|
McpServer {
|
||||||
|
id: id.clone(),
|
||||||
|
name: id.clone(),
|
||||||
|
server: unified_spec,
|
||||||
|
apps: McpApps {
|
||||||
|
claude: false,
|
||||||
|
codex: false,
|
||||||
|
gemini: false,
|
||||||
|
opencode: true,
|
||||||
|
},
|
||||||
|
description: None,
|
||||||
|
homepage: None,
|
||||||
|
docs: None,
|
||||||
|
tags: Vec::new(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
changed += 1;
|
||||||
|
log::info!("Imported new MCP server '{id}' from OpenCode");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.is_empty() {
|
||||||
|
log::warn!(
|
||||||
|
"Import completed with {} failures: {:?}",
|
||||||
|
errors.len(),
|
||||||
|
errors
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(changed)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_convert_stdio_to_local() {
|
||||||
|
let spec = json!({
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@modelcontextprotocol/server-filesystem"],
|
||||||
|
"env": { "HOME": "/Users/test" }
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = convert_to_opencode_format(&spec).unwrap();
|
||||||
|
assert_eq!(result["type"], "local");
|
||||||
|
assert_eq!(result["command"][0], "npx");
|
||||||
|
assert_eq!(result["command"][1], "-y");
|
||||||
|
assert_eq!(
|
||||||
|
result["command"][2],
|
||||||
|
"@modelcontextprotocol/server-filesystem"
|
||||||
|
);
|
||||||
|
assert_eq!(result["environment"]["HOME"], "/Users/test");
|
||||||
|
assert_eq!(result["enabled"], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_convert_sse_to_remote() {
|
||||||
|
let spec = json!({
|
||||||
|
"type": "sse",
|
||||||
|
"url": "https://example.com/mcp",
|
||||||
|
"headers": { "Authorization": "Bearer xxx" }
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = convert_to_opencode_format(&spec).unwrap();
|
||||||
|
assert_eq!(result["type"], "remote");
|
||||||
|
assert_eq!(result["url"], "https://example.com/mcp");
|
||||||
|
assert_eq!(result["headers"]["Authorization"], "Bearer xxx");
|
||||||
|
assert_eq!(result["enabled"], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_convert_local_to_stdio() {
|
||||||
|
let spec = json!({
|
||||||
|
"type": "local",
|
||||||
|
"command": ["npx", "-y", "@modelcontextprotocol/server-filesystem"],
|
||||||
|
"environment": { "HOME": "/Users/test" }
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = convert_from_opencode_format(&spec).unwrap();
|
||||||
|
assert_eq!(result["type"], "stdio");
|
||||||
|
assert_eq!(result["command"], "npx");
|
||||||
|
assert_eq!(result["args"][0], "-y");
|
||||||
|
assert_eq!(result["args"][1], "@modelcontextprotocol/server-filesystem");
|
||||||
|
assert_eq!(result["env"]["HOME"], "/Users/test");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_convert_remote_to_sse() {
|
||||||
|
let spec = json!({
|
||||||
|
"type": "remote",
|
||||||
|
"url": "https://example.com/mcp",
|
||||||
|
"headers": { "Authorization": "Bearer xxx" }
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = convert_from_opencode_format(&spec).unwrap();
|
||||||
|
assert_eq!(result["type"], "sse");
|
||||||
|
assert_eq!(result["url"], "https://example.com/mcp");
|
||||||
|
assert_eq!(result["headers"]["Authorization"], "Bearer xxx");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
//! OpenCode 配置文件读写模块
|
||||||
|
//!
|
||||||
|
//! 处理 `~/.config/opencode/opencode.json` 配置文件的读写操作。
|
||||||
|
//! OpenCode 使用累加式供应商管理,所有供应商配置共存于同一配置文件中。
|
||||||
|
//!
|
||||||
|
//! ## 配置文件格式
|
||||||
|
//!
|
||||||
|
//! ```json
|
||||||
|
//! {
|
||||||
|
//! "$schema": "https://opencode.ai/config.json",
|
||||||
|
//! "provider": {
|
||||||
|
//! "my-provider": {
|
||||||
|
//! "npm": "@ai-sdk/openai-compatible",
|
||||||
|
//! "options": { "baseURL": "...", "apiKey": "{env:API_KEY}" },
|
||||||
|
//! "models": { "gpt-4o": { "name": "GPT-4o" } }
|
||||||
|
//! }
|
||||||
|
//! },
|
||||||
|
//! "mcp": {
|
||||||
|
//! "my-server": { "type": "local", "command": ["..."] }
|
||||||
|
//! }
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use crate::config::write_json_file;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::provider::OpenCodeProviderConfig;
|
||||||
|
use crate::settings::get_opencode_override_dir;
|
||||||
|
use indexmap::IndexMap;
|
||||||
|
use serde_json::{json, Map, Value};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Path Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// 获取 OpenCode 配置目录
|
||||||
|
///
|
||||||
|
/// 默认路径: `~/.config/opencode/`
|
||||||
|
/// 可通过 settings.opencode_config_dir 覆盖
|
||||||
|
pub fn get_opencode_dir() -> PathBuf {
|
||||||
|
if let Some(override_dir) = get_opencode_override_dir() {
|
||||||
|
return override_dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 所有平台统一使用 ~/.config/opencode
|
||||||
|
dirs::home_dir()
|
||||||
|
.map(|h| h.join(".config").join("opencode"))
|
||||||
|
.unwrap_or_else(|| PathBuf::from(".config").join("opencode"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 OpenCode 配置文件路径
|
||||||
|
///
|
||||||
|
/// 返回 `~/.config/opencode/opencode.json`
|
||||||
|
pub fn get_opencode_config_path() -> PathBuf {
|
||||||
|
get_opencode_dir().join("opencode.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 OpenCode 环境变量文件路径(如果存在)
|
||||||
|
///
|
||||||
|
/// 返回 `~/.config/opencode/.env`
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn get_opencode_env_path() -> PathBuf {
|
||||||
|
get_opencode_dir().join(".env")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Core Read/Write Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// 读取 OpenCode 配置文件
|
||||||
|
///
|
||||||
|
/// 返回完整的配置 JSON 对象
|
||||||
|
pub fn read_opencode_config() -> Result<Value, AppError> {
|
||||||
|
let path = get_opencode_config_path();
|
||||||
|
|
||||||
|
if !path.exists() {
|
||||||
|
// Return empty config with schema
|
||||||
|
return Ok(json!({
|
||||||
|
"$schema": "https://opencode.ai/config.json"
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
||||||
|
serde_json::from_str(&content).map_err(|e| AppError::json(&path, e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 写入 OpenCode 配置文件(原子写入)
|
||||||
|
///
|
||||||
|
/// 使用临时文件 + 重命名确保原子性
|
||||||
|
pub fn write_opencode_config(config: &Value) -> Result<(), AppError> {
|
||||||
|
let path = get_opencode_config_path();
|
||||||
|
// 复用统一的原子写入逻辑(兼容 Windows 上目标文件已存在的情况)
|
||||||
|
write_json_file(&path, config)?;
|
||||||
|
|
||||||
|
log::debug!("OpenCode config written to {path:?}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Provider Functions (Untyped - for raw JSON operations)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// 获取所有供应商配置(原始 JSON)
|
||||||
|
pub fn get_providers() -> Result<Map<String, Value>, AppError> {
|
||||||
|
let config = read_opencode_config()?;
|
||||||
|
Ok(config
|
||||||
|
.get("provider")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置供应商配置(原始 JSON)
|
||||||
|
pub fn set_provider(id: &str, config: Value) -> Result<(), AppError> {
|
||||||
|
let mut full_config = read_opencode_config()?;
|
||||||
|
|
||||||
|
if full_config.get("provider").is_none() {
|
||||||
|
full_config["provider"] = json!({});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(providers) = full_config
|
||||||
|
.get_mut("provider")
|
||||||
|
.and_then(|v| v.as_object_mut())
|
||||||
|
{
|
||||||
|
providers.insert(id.to_string(), config);
|
||||||
|
}
|
||||||
|
|
||||||
|
write_opencode_config(&full_config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除供应商配置
|
||||||
|
pub fn remove_provider(id: &str) -> Result<(), AppError> {
|
||||||
|
let mut config = read_opencode_config()?;
|
||||||
|
|
||||||
|
if let Some(providers) = config.get_mut("provider").and_then(|v| v.as_object_mut()) {
|
||||||
|
providers.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
write_opencode_config(&config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Provider Functions (Typed - using OpenCodeProviderConfig)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// 获取所有供应商配置(类型化)
|
||||||
|
pub fn get_typed_providers() -> Result<IndexMap<String, OpenCodeProviderConfig>, AppError> {
|
||||||
|
let providers = get_providers()?;
|
||||||
|
let mut result = IndexMap::new();
|
||||||
|
|
||||||
|
for (id, value) in providers {
|
||||||
|
match serde_json::from_value::<OpenCodeProviderConfig>(value.clone()) {
|
||||||
|
Ok(config) => {
|
||||||
|
result.insert(id, config);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to parse provider '{id}': {e}");
|
||||||
|
// Skip invalid providers but continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置供应商配置(类型化)
|
||||||
|
pub fn set_typed_provider(id: &str, config: &OpenCodeProviderConfig) -> Result<(), AppError> {
|
||||||
|
let value = serde_json::to_value(config).map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||||
|
set_provider(id, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MCP Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// 获取所有 MCP 服务器配置
|
||||||
|
pub fn get_mcp_servers() -> Result<Map<String, Value>, AppError> {
|
||||||
|
let config = read_opencode_config()?;
|
||||||
|
Ok(config
|
||||||
|
.get("mcp")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置 MCP 服务器配置
|
||||||
|
pub fn set_mcp_server(id: &str, config: Value) -> Result<(), AppError> {
|
||||||
|
let mut full_config = read_opencode_config()?;
|
||||||
|
|
||||||
|
if full_config.get("mcp").is_none() {
|
||||||
|
full_config["mcp"] = json!({});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(mcp) = full_config.get_mut("mcp").and_then(|v| v.as_object_mut()) {
|
||||||
|
mcp.insert(id.to_string(), config);
|
||||||
|
}
|
||||||
|
|
||||||
|
write_opencode_config(&full_config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除 MCP 服务器配置
|
||||||
|
pub fn remove_mcp_server(id: &str) -> Result<(), AppError> {
|
||||||
|
let mut config = read_opencode_config()?;
|
||||||
|
|
||||||
|
if let Some(mcp) = config.get_mut("mcp").and_then(|v| v.as_object_mut()) {
|
||||||
|
mcp.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
write_opencode_config(&config)
|
||||||
|
}
|
||||||
@@ -12,9 +12,6 @@ use std::sync::OnceLock;
|
|||||||
/// 应用版本号(从 Cargo.toml 读取)
|
/// 应用版本号(从 Cargo.toml 读取)
|
||||||
const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
|
const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
/// 日志文件保留数量
|
|
||||||
const LOG_FILES_TO_KEEP: usize = 2;
|
|
||||||
|
|
||||||
static APP_CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
|
static APP_CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||||
|
|
||||||
pub fn init_app_config_dir(dir: PathBuf) {
|
pub fn init_app_config_dir(dir: PathBuf) {
|
||||||
@@ -46,48 +43,6 @@ pub fn get_log_dir() -> PathBuf {
|
|||||||
get_app_config_dir().join("logs")
|
get_app_config_dir().join("logs")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 清理旧日志文件,只保留最近 N 个
|
|
||||||
///
|
|
||||||
/// 在应用启动时调用,确保日志文件不会无限增长。
|
|
||||||
pub fn cleanup_old_logs() {
|
|
||||||
let log_dir = get_log_dir();
|
|
||||||
|
|
||||||
if !log_dir.exists() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 读取目录中的所有 .log 文件
|
|
||||||
let mut log_files: Vec<_> = match std::fs::read_dir(&log_dir) {
|
|
||||||
Ok(entries) => entries
|
|
||||||
.filter_map(|e| e.ok())
|
|
||||||
.map(|e| e.path())
|
|
||||||
.filter(|p| p.extension().map(|ext| ext == "log").unwrap_or(false))
|
|
||||||
.collect(),
|
|
||||||
Err(_) => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 如果文件数量不超过保留数量,无需清理
|
|
||||||
if log_files.len() <= LOG_FILES_TO_KEEP {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 按修改时间排序(最新的在前)
|
|
||||||
log_files.sort_by(|a, b| {
|
|
||||||
let time_a = a.metadata().and_then(|m| m.modified()).ok();
|
|
||||||
let time_b = b.metadata().and_then(|m| m.modified()).ok();
|
|
||||||
time_b.cmp(&time_a) // 降序
|
|
||||||
});
|
|
||||||
|
|
||||||
// 删除多余的旧文件
|
|
||||||
for old_file in log_files.into_iter().skip(LOG_FILES_TO_KEEP) {
|
|
||||||
if let Err(e) = std::fs::remove_file(&old_file) {
|
|
||||||
log::warn!("清理旧日志文件失败 {}: {e}", old_file.display());
|
|
||||||
} else {
|
|
||||||
log::info!("已清理旧日志文件: {}", old_file.display());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 安全获取环境信息(不会 panic)
|
/// 安全获取环境信息(不会 panic)
|
||||||
fn get_system_info() -> String {
|
fn get_system_info() -> String {
|
||||||
let os = std::env::consts::OS;
|
let os = std::env::consts::OS;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use crate::codex_config::get_codex_auth_path;
|
|||||||
use crate::config::get_claude_settings_path;
|
use crate::config::get_claude_settings_path;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::gemini_config::get_gemini_dir;
|
use crate::gemini_config::get_gemini_dir;
|
||||||
|
use crate::opencode_config::get_opencode_dir;
|
||||||
|
|
||||||
/// 返回指定应用所使用的提示词文件路径。
|
/// 返回指定应用所使用的提示词文件路径。
|
||||||
pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
||||||
@@ -12,12 +13,14 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
|||||||
AppType::Claude => get_base_dir_with_fallback(get_claude_settings_path(), ".claude")?,
|
AppType::Claude => get_base_dir_with_fallback(get_claude_settings_path(), ".claude")?,
|
||||||
AppType::Codex => get_base_dir_with_fallback(get_codex_auth_path(), ".codex")?,
|
AppType::Codex => get_base_dir_with_fallback(get_codex_auth_path(), ".codex")?,
|
||||||
AppType::Gemini => get_gemini_dir(),
|
AppType::Gemini => get_gemini_dir(),
|
||||||
|
AppType::OpenCode => get_opencode_dir(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let filename = match app {
|
let filename = match app {
|
||||||
AppType::Claude => "CLAUDE.md",
|
AppType::Claude => "CLAUDE.md",
|
||||||
AppType::Codex => "AGENTS.md",
|
AppType::Codex => "AGENTS.md",
|
||||||
AppType::Gemini => "GEMINI.md",
|
AppType::Gemini => "GEMINI.md",
|
||||||
|
AppType::OpenCode => "AGENTS.md",
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(base_dir.join(filename))
|
Ok(base_dir.join(filename))
|
||||||
|
|||||||
@@ -98,6 +98,10 @@ pub struct UsageScript {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
#[serde(rename = "userId")]
|
#[serde(rename = "userId")]
|
||||||
pub user_id: Option<String>,
|
pub user_id: Option<String>,
|
||||||
|
/// 模板类型(用于后端判断验证规则)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[serde(rename = "templateType")]
|
||||||
|
pub template_type: Option<String>,
|
||||||
/// 自动查询间隔(单位:分钟,0 表示禁用自动查询)
|
/// 自动查询间隔(单位:分钟,0 表示禁用自动查询)
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
#[serde(rename = "autoQueryInterval")]
|
#[serde(rename = "autoQueryInterval")]
|
||||||
@@ -138,6 +142,55 @@ pub struct UsageResult {
|
|||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 供应商单独的模型测试配置
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct ProviderTestConfig {
|
||||||
|
/// 是否启用单独配置(false 时使用全局配置)
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
|
/// 测试用的模型名称(覆盖全局配置)
|
||||||
|
#[serde(rename = "testModel", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub test_model: Option<String>,
|
||||||
|
/// 超时时间(秒)
|
||||||
|
#[serde(rename = "timeoutSecs", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub timeout_secs: Option<u64>,
|
||||||
|
/// 测试提示词
|
||||||
|
#[serde(rename = "testPrompt", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub test_prompt: Option<String>,
|
||||||
|
/// 降级阈值(毫秒)
|
||||||
|
#[serde(
|
||||||
|
rename = "degradedThresholdMs",
|
||||||
|
skip_serializing_if = "Option::is_none"
|
||||||
|
)]
|
||||||
|
pub degraded_threshold_ms: Option<u64>,
|
||||||
|
/// 最大重试次数
|
||||||
|
#[serde(rename = "maxRetries", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub max_retries: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 供应商单独的代理配置
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct ProviderProxyConfig {
|
||||||
|
/// 是否启用单独配置(false 时使用全局/系统代理)
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
|
/// 代理类型:http, https, socks5
|
||||||
|
#[serde(rename = "proxyType", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub proxy_type: Option<String>,
|
||||||
|
/// 代理主机
|
||||||
|
#[serde(rename = "proxyHost", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub proxy_host: Option<String>,
|
||||||
|
/// 代理端口
|
||||||
|
#[serde(rename = "proxyPort", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub proxy_port: Option<u16>,
|
||||||
|
/// 代理用户名(可选)
|
||||||
|
#[serde(rename = "proxyUsername", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub proxy_username: Option<String>,
|
||||||
|
/// 代理密码(可选)
|
||||||
|
#[serde(rename = "proxyPassword", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub proxy_password: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// 供应商元数据
|
/// 供应商元数据
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct ProviderMeta {
|
pub struct ProviderMeta {
|
||||||
@@ -168,6 +221,12 @@ pub struct ProviderMeta {
|
|||||||
/// 每月消费限额(USD)
|
/// 每月消费限额(USD)
|
||||||
#[serde(rename = "limitMonthlyUsd", skip_serializing_if = "Option::is_none")]
|
#[serde(rename = "limitMonthlyUsd", skip_serializing_if = "Option::is_none")]
|
||||||
pub limit_monthly_usd: Option<String>,
|
pub limit_monthly_usd: Option<String>,
|
||||||
|
/// 供应商单独的模型测试配置
|
||||||
|
#[serde(rename = "testConfig", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub test_config: Option<ProviderTestConfig>,
|
||||||
|
/// 供应商单独的代理配置
|
||||||
|
#[serde(rename = "proxyConfig", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub proxy_config: Option<ProviderProxyConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProviderManager {
|
impl ProviderManager {
|
||||||
@@ -458,3 +517,100 @@ requires_openai_auth = true"#
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// OpenCode 供应商配置结构
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// OpenCode 供应商的 settings_config 结构
|
||||||
|
///
|
||||||
|
/// OpenCode 使用 AI SDK 包名来指定供应商类型,与其他应用的配置格式不同。
|
||||||
|
/// 配置示例:
|
||||||
|
/// ```json
|
||||||
|
/// {
|
||||||
|
/// "npm": "@ai-sdk/openai-compatible",
|
||||||
|
/// "options": { "baseURL": "https://api.example.com/v1", "apiKey": "sk-xxx" },
|
||||||
|
/// "models": { "gpt-4o": { "name": "GPT-4o" } }
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OpenCodeProviderConfig {
|
||||||
|
/// AI SDK 包名,如 "@ai-sdk/openai-compatible", "@ai-sdk/anthropic"
|
||||||
|
pub npm: String,
|
||||||
|
|
||||||
|
/// 供应商名称(可选,用于显示)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub name: Option<String>,
|
||||||
|
|
||||||
|
/// 供应商选项(API 密钥、基础 URL 等)
|
||||||
|
#[serde(default)]
|
||||||
|
pub options: OpenCodeProviderOptions,
|
||||||
|
|
||||||
|
/// 模型定义映射
|
||||||
|
#[serde(default)]
|
||||||
|
pub models: HashMap<String, OpenCodeModel>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for OpenCodeProviderConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
npm: "@ai-sdk/openai-compatible".to_string(),
|
||||||
|
name: None,
|
||||||
|
options: OpenCodeProviderOptions::default(),
|
||||||
|
models: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OpenCode 供应商选项
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct OpenCodeProviderOptions {
|
||||||
|
/// API 基础 URL
|
||||||
|
#[serde(rename = "baseURL", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub base_url: Option<String>,
|
||||||
|
|
||||||
|
/// API 密钥(支持环境变量引用,如 "{env:API_KEY}")
|
||||||
|
#[serde(rename = "apiKey", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub api_key: Option<String>,
|
||||||
|
|
||||||
|
/// 自定义请求头
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub headers: Option<HashMap<String, String>>,
|
||||||
|
|
||||||
|
/// 额外选项(timeout, setCacheKey 等)
|
||||||
|
/// 使用 flatten 捕获所有未明确定义的字段
|
||||||
|
#[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
|
||||||
|
pub extra: HashMap<String, Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OpenCode 模型定义
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OpenCodeModel {
|
||||||
|
/// 模型显示名称
|
||||||
|
pub name: String,
|
||||||
|
|
||||||
|
/// 模型限制(上下文和输出 token 数)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub limit: Option<OpenCodeModelLimit>,
|
||||||
|
|
||||||
|
/// 模型额外选项(provider 路由等)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub options: Option<HashMap<String, Value>>,
|
||||||
|
|
||||||
|
/// 额外字段(cost、modalities、thinking、variants 等)
|
||||||
|
/// 使用 flatten 捕获所有未明确定义的字段
|
||||||
|
#[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
|
||||||
|
pub extra: HashMap<String, Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OpenCode 模型限制
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct OpenCodeModelLimit {
|
||||||
|
/// 上下文 token 限制
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub context: Option<u64>,
|
||||||
|
|
||||||
|
/// 输出 token 限制
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub output: Option<u64>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -319,7 +319,11 @@ impl CircuitBreaker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn release_half_open_permit(&self) {
|
/// 仅释放 HalfOpen permit,不影响健康统计
|
||||||
|
///
|
||||||
|
/// 用于整流器等场景:请求结果不应计入 Provider 健康度,
|
||||||
|
/// 但仍需释放占用的探测名额,避免 HalfOpen 状态卡死
|
||||||
|
pub fn release_half_open_permit(&self) {
|
||||||
let mut current = self.half_open_requests.load(Ordering::SeqCst);
|
let mut current = self.half_open_requests.load(Ordering::SeqCst);
|
||||||
loop {
|
loop {
|
||||||
if current == 0 {
|
if current == 0 {
|
||||||
|
|||||||
@@ -7,15 +7,15 @@ use super::{
|
|||||||
error::*,
|
error::*,
|
||||||
failover_switch::FailoverSwitchManager,
|
failover_switch::FailoverSwitchManager,
|
||||||
provider_router::ProviderRouter,
|
provider_router::ProviderRouter,
|
||||||
providers::{get_adapter, ProviderAdapter},
|
providers::{get_adapter, ProviderAdapter, ProviderType},
|
||||||
types::ProxyStatus,
|
thinking_rectifier::{rectify_anthropic_request, should_rectify_thinking_signature},
|
||||||
|
types::{ProxyStatus, RectifierConfig},
|
||||||
ProxyError,
|
ProxyError,
|
||||||
};
|
};
|
||||||
use crate::{app_config::AppType, provider::Provider};
|
use crate::{app_config::AppType, provider::Provider};
|
||||||
use reqwest::{Client, Response};
|
use reqwest::Response;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
/// Headers 黑名单 - 不透传到上游的 Headers
|
/// Headers 黑名单 - 不透传到上游的 Headers
|
||||||
@@ -28,6 +28,7 @@ const HEADER_BLACKLIST: &[&str] = &[
|
|||||||
// 认证类(会被覆盖)
|
// 认证类(会被覆盖)
|
||||||
"authorization",
|
"authorization",
|
||||||
"x-api-key",
|
"x-api-key",
|
||||||
|
"x-goog-api-key",
|
||||||
// 连接类(由 HTTP 客户端管理)
|
// 连接类(由 HTTP 客户端管理)
|
||||||
"host",
|
"host",
|
||||||
"content-length",
|
"content-length",
|
||||||
@@ -81,8 +82,6 @@ pub struct ForwardError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct RequestForwarder {
|
pub struct RequestForwarder {
|
||||||
client: Option<Client>,
|
|
||||||
client_init_error: Option<String>,
|
|
||||||
/// 共享的 ProviderRouter(持有熔断器状态)
|
/// 共享的 ProviderRouter(持有熔断器状态)
|
||||||
router: Arc<ProviderRouter>,
|
router: Arc<ProviderRouter>,
|
||||||
status: Arc<RwLock<ProxyStatus>>,
|
status: Arc<RwLock<ProxyStatus>>,
|
||||||
@@ -93,6 +92,10 @@ pub struct RequestForwarder {
|
|||||||
app_handle: Option<tauri::AppHandle>,
|
app_handle: Option<tauri::AppHandle>,
|
||||||
/// 请求开始时的"当前供应商 ID"(用于判断是否需要同步 UI/托盘)
|
/// 请求开始时的"当前供应商 ID"(用于判断是否需要同步 UI/托盘)
|
||||||
current_provider_id_at_start: String,
|
current_provider_id_at_start: String,
|
||||||
|
/// 整流器配置
|
||||||
|
rectifier_config: RectifierConfig,
|
||||||
|
/// 非流式请求超时(秒)
|
||||||
|
non_streaming_timeout: std::time::Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RequestForwarder {
|
impl RequestForwarder {
|
||||||
@@ -107,52 +110,17 @@ impl RequestForwarder {
|
|||||||
current_provider_id_at_start: String,
|
current_provider_id_at_start: String,
|
||||||
_streaming_first_byte_timeout: u64,
|
_streaming_first_byte_timeout: u64,
|
||||||
_streaming_idle_timeout: u64,
|
_streaming_idle_timeout: u64,
|
||||||
|
rectifier_config: RectifierConfig,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
// 全局超时设置为 1800 秒(30 分钟),确保业务层超时配置能正常工作
|
|
||||||
// 参考 Claude Code Hub 的 undici 全局超时设计
|
|
||||||
const GLOBAL_TIMEOUT_SECS: u64 = 1800;
|
|
||||||
|
|
||||||
let timeout_secs = if non_streaming_timeout > 0 {
|
|
||||||
non_streaming_timeout
|
|
||||||
} else {
|
|
||||||
GLOBAL_TIMEOUT_SECS
|
|
||||||
};
|
|
||||||
|
|
||||||
// 注意:这里不能用 expect/unwrap。
|
|
||||||
// release 配置为 panic=abort,一旦 build 失败会导致整个应用闪退。
|
|
||||||
// 常见原因:用户环境变量里存在不合法/不支持的代理(HTTP(S)_PROXY/ALL_PROXY 等)。
|
|
||||||
let (client, client_init_error) = match Client::builder()
|
|
||||||
.timeout(Duration::from_secs(timeout_secs))
|
|
||||||
.build()
|
|
||||||
{
|
|
||||||
Ok(client) => (Some(client), None),
|
|
||||||
Err(e) => {
|
|
||||||
// 降级:忽略系统/环境代理,避免因代理配置问题导致整个应用崩溃
|
|
||||||
match Client::builder()
|
|
||||||
.timeout(Duration::from_secs(timeout_secs))
|
|
||||||
.no_proxy()
|
|
||||||
.build()
|
|
||||||
{
|
|
||||||
Ok(client) => (Some(client), Some(e.to_string())),
|
|
||||||
Err(fallback_err) => (
|
|
||||||
None,
|
|
||||||
Some(format!(
|
|
||||||
"Failed to create HTTP client: {e}; no_proxy fallback failed: {fallback_err}"
|
|
||||||
)),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
client,
|
|
||||||
client_init_error,
|
|
||||||
router,
|
router,
|
||||||
status,
|
status,
|
||||||
current_providers,
|
current_providers,
|
||||||
failover_manager,
|
failover_manager,
|
||||||
app_handle,
|
app_handle,
|
||||||
current_provider_id_at_start,
|
current_provider_id_at_start,
|
||||||
|
rectifier_config,
|
||||||
|
non_streaming_timeout: std::time::Duration::from_secs(non_streaming_timeout),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +136,7 @@ impl RequestForwarder {
|
|||||||
&self,
|
&self,
|
||||||
app_type: &AppType,
|
app_type: &AppType,
|
||||||
endpoint: &str,
|
endpoint: &str,
|
||||||
body: Value,
|
mut body: Value,
|
||||||
headers: axum::http::HeaderMap,
|
headers: axum::http::HeaderMap,
|
||||||
providers: Vec<Provider>,
|
providers: Vec<Provider>,
|
||||||
) -> Result<ForwardResult, ForwardError> {
|
) -> Result<ForwardResult, ForwardError> {
|
||||||
@@ -187,6 +155,9 @@ impl RequestForwarder {
|
|||||||
let mut last_provider = None;
|
let mut last_provider = None;
|
||||||
let mut attempted_providers = 0usize;
|
let mut attempted_providers = 0usize;
|
||||||
|
|
||||||
|
// 整流器重试标记:确保整流最多触发一次
|
||||||
|
let mut rectifier_retried = false;
|
||||||
|
|
||||||
// 单 Provider 场景下跳过熔断器检查(故障转移关闭时)
|
// 单 Provider 场景下跳过熔断器检查(故障转移关闭时)
|
||||||
let bypass_circuit_breaker = providers.len() == 1;
|
let bypass_circuit_breaker = providers.len() == 1;
|
||||||
|
|
||||||
@@ -281,6 +252,205 @@ impl RequestForwarder {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
// 检测是否需要触发整流器(仅 Claude/ClaudeAuth 供应商)
|
||||||
|
let provider_type = ProviderType::from_app_type_and_config(app_type, provider);
|
||||||
|
let is_anthropic_provider = matches!(
|
||||||
|
provider_type,
|
||||||
|
ProviderType::Claude | ProviderType::ClaudeAuth
|
||||||
|
);
|
||||||
|
|
||||||
|
if is_anthropic_provider {
|
||||||
|
let error_message = extract_error_message(&e);
|
||||||
|
if should_rectify_thinking_signature(
|
||||||
|
error_message.as_deref(),
|
||||||
|
&self.rectifier_config,
|
||||||
|
) {
|
||||||
|
// 已经重试过:直接返回错误(不可重试客户端错误)
|
||||||
|
if rectifier_retried {
|
||||||
|
log::warn!("[{app_type_str}] [RECT-005] 整流器已触发过,不再重试");
|
||||||
|
// 释放 HalfOpen permit(不记录熔断器,这是客户端兼容性问题)
|
||||||
|
self.router
|
||||||
|
.release_permit_neutral(
|
||||||
|
&provider.id,
|
||||||
|
app_type_str,
|
||||||
|
used_half_open_permit,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let mut status = self.status.write().await;
|
||||||
|
status.failed_requests += 1;
|
||||||
|
status.last_error = Some(e.to_string());
|
||||||
|
if status.total_requests > 0 {
|
||||||
|
status.success_rate = (status.success_requests as f32
|
||||||
|
/ status.total_requests as f32)
|
||||||
|
* 100.0;
|
||||||
|
}
|
||||||
|
return Err(ForwardError {
|
||||||
|
error: e,
|
||||||
|
provider: Some(provider.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 首次触发:整流请求体
|
||||||
|
let rectified = rectify_anthropic_request(&mut body);
|
||||||
|
|
||||||
|
// 整流未生效:直接返回错误(不可重试客户端错误)
|
||||||
|
if !rectified.applied {
|
||||||
|
log::warn!(
|
||||||
|
"[{app_type_str}] [RECT-006] 整流器触发但无可整流内容,不做无意义重试"
|
||||||
|
);
|
||||||
|
// 释放 HalfOpen permit(不记录熔断器,这是客户端兼容性问题)
|
||||||
|
self.router
|
||||||
|
.release_permit_neutral(
|
||||||
|
&provider.id,
|
||||||
|
app_type_str,
|
||||||
|
used_half_open_permit,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let mut status = self.status.write().await;
|
||||||
|
status.failed_requests += 1;
|
||||||
|
status.last_error = Some(e.to_string());
|
||||||
|
if status.total_requests > 0 {
|
||||||
|
status.success_rate = (status.success_requests as f32
|
||||||
|
/ status.total_requests as f32)
|
||||||
|
* 100.0;
|
||||||
|
}
|
||||||
|
return Err(ForwardError {
|
||||||
|
error: e,
|
||||||
|
provider: Some(provider.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"[{}] [RECT-001] thinking 签名整流器触发, 移除 {} thinking blocks, {} redacted_thinking blocks, {} signature fields",
|
||||||
|
app_type_str,
|
||||||
|
rectified.removed_thinking_blocks,
|
||||||
|
rectified.removed_redacted_thinking_blocks,
|
||||||
|
rectified.removed_signature_fields
|
||||||
|
);
|
||||||
|
|
||||||
|
// 标记已重试(当前逻辑下重试后必定 return,保留标记以备将来扩展)
|
||||||
|
let _ = std::mem::replace(&mut rectifier_retried, true);
|
||||||
|
|
||||||
|
// 使用同一供应商重试(不计入熔断器)
|
||||||
|
match self
|
||||||
|
.forward(provider, endpoint, &body, &headers, adapter.as_ref())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(response) => {
|
||||||
|
log::info!("[{app_type_str}] [RECT-002] 整流重试成功");
|
||||||
|
// 记录成功
|
||||||
|
let _ = self
|
||||||
|
.router
|
||||||
|
.record_result(
|
||||||
|
&provider.id,
|
||||||
|
app_type_str,
|
||||||
|
used_half_open_permit,
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// 更新当前应用类型使用的 provider
|
||||||
|
{
|
||||||
|
let mut current_providers =
|
||||||
|
self.current_providers.write().await;
|
||||||
|
current_providers.insert(
|
||||||
|
app_type_str.to_string(),
|
||||||
|
(provider.id.clone(), provider.name.clone()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新成功统计
|
||||||
|
{
|
||||||
|
let mut status = self.status.write().await;
|
||||||
|
status.success_requests += 1;
|
||||||
|
status.last_error = None;
|
||||||
|
let should_switch =
|
||||||
|
self.current_provider_id_at_start.as_str()
|
||||||
|
!= provider.id.as_str();
|
||||||
|
if should_switch {
|
||||||
|
status.failover_count += 1;
|
||||||
|
|
||||||
|
// 异步触发供应商切换,更新 UI/托盘
|
||||||
|
let fm = self.failover_manager.clone();
|
||||||
|
let ah = self.app_handle.clone();
|
||||||
|
let pid = provider.id.clone();
|
||||||
|
let pname = provider.name.clone();
|
||||||
|
let at = app_type_str.to_string();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = fm
|
||||||
|
.try_switch(ah.as_ref(), &at, &pid, &pname)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if status.total_requests > 0 {
|
||||||
|
status.success_rate = (status.success_requests as f32
|
||||||
|
/ status.total_requests as f32)
|
||||||
|
* 100.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(ForwardResult {
|
||||||
|
response,
|
||||||
|
provider: provider.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(retry_err) => {
|
||||||
|
// 整流重试仍失败:区分错误类型决定是否记录熔断器
|
||||||
|
log::warn!(
|
||||||
|
"[{app_type_str}] [RECT-003] 整流重试仍失败: {retry_err}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 区分错误类型:Provider 问题记录失败,客户端问题仅释放 permit
|
||||||
|
let is_provider_error = match &retry_err {
|
||||||
|
ProxyError::Timeout(_) | ProxyError::ForwardFailed(_) => {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
ProxyError::UpstreamError { status, .. } => *status >= 500,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if is_provider_error {
|
||||||
|
// Provider 问题:记录失败到熔断器
|
||||||
|
let _ = self
|
||||||
|
.router
|
||||||
|
.record_result(
|
||||||
|
&provider.id,
|
||||||
|
app_type_str,
|
||||||
|
used_half_open_permit,
|
||||||
|
false,
|
||||||
|
Some(retry_err.to_string()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
} else {
|
||||||
|
// 客户端问题:仅释放 permit,不记录熔断器
|
||||||
|
self.router
|
||||||
|
.release_permit_neutral(
|
||||||
|
&provider.id,
|
||||||
|
app_type_str,
|
||||||
|
used_half_open_permit,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut status = self.status.write().await;
|
||||||
|
status.failed_requests += 1;
|
||||||
|
status.last_error = Some(retry_err.to_string());
|
||||||
|
if status.total_requests > 0 {
|
||||||
|
status.success_rate = (status.success_requests as f32
|
||||||
|
/ status.total_requests as f32)
|
||||||
|
* 100.0;
|
||||||
|
}
|
||||||
|
return Err(ForwardError {
|
||||||
|
error: retry_err,
|
||||||
|
provider: Some(provider.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 失败:记录失败并更新熔断器
|
// 失败:记录失败并更新熔断器
|
||||||
let _ = self
|
let _ = self
|
||||||
.router
|
.router
|
||||||
@@ -416,16 +586,18 @@ impl RequestForwarder {
|
|||||||
// 默认使用空白名单,过滤所有 _ 前缀字段
|
// 默认使用空白名单,过滤所有 _ 前缀字段
|
||||||
let filtered_body = filter_private_params_with_whitelist(request_body, &[]);
|
let filtered_body = filter_private_params_with_whitelist(request_body, &[]);
|
||||||
|
|
||||||
// 构建请求
|
// 获取 HTTP 客户端:优先使用供应商单独代理配置,否则使用全局客户端
|
||||||
let client = self.client.as_ref().ok_or_else(|| {
|
let proxy_config = provider.meta.as_ref().and_then(|m| m.proxy_config.as_ref());
|
||||||
ProxyError::ForwardFailed(
|
let client = super::http_client::get_for_provider(proxy_config);
|
||||||
self.client_init_error
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| "HTTP client is not initialized".to_string()),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let mut request = client.post(&url);
|
let mut request = client.post(&url);
|
||||||
|
|
||||||
|
// 只有当 timeout > 0 时才设置请求超时
|
||||||
|
// Duration::ZERO 在 reqwest 中表示"立刻超时"而不是"禁用超时"
|
||||||
|
// 故障转移关闭时会传入 0,此时应该使用 client 的默认超时(600秒)
|
||||||
|
if !self.non_streaming_timeout.is_zero() {
|
||||||
|
request = request.timeout(self.non_streaming_timeout);
|
||||||
|
}
|
||||||
|
|
||||||
// 过滤黑名单 Headers,保护隐私并避免冲突
|
// 过滤黑名单 Headers,保护隐私并避免冲突
|
||||||
for (key, value) in headers {
|
for (key, value) in headers {
|
||||||
if HEADER_BLACKLIST
|
if HEADER_BLACKLIST
|
||||||
@@ -492,6 +664,17 @@ impl RequestForwarder {
|
|||||||
request = request.header("anthropic-version", version_str);
|
request = request.header("anthropic-version", version_str);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 输出请求信息日志
|
||||||
|
let tag = adapter.name();
|
||||||
|
log::debug!("[{tag}] >>> 请求 URL: {url}");
|
||||||
|
if let Ok(body_str) = serde_json::to_string(&filtered_body) {
|
||||||
|
log::debug!(
|
||||||
|
"[{tag}] >>> 请求体内容 ({}字节): {}",
|
||||||
|
body_str.len(),
|
||||||
|
body_str
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 发送请求
|
// 发送请求
|
||||||
let response = request.json(&filtered_body).send().await.map_err(|e| {
|
let response = request.json(&filtered_body).send().await.map_err(|e| {
|
||||||
if e.is_timeout() {
|
if e.is_timeout() {
|
||||||
@@ -541,3 +724,11 @@ impl RequestForwarder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 从 ProxyError 中提取错误消息
|
||||||
|
fn extract_error_message(error: &ProxyError) -> Option<String> {
|
||||||
|
match error {
|
||||||
|
ProxyError::UpstreamError { body, .. } => body.clone(),
|
||||||
|
_ => Some(error.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,10 @@
|
|||||||
use crate::app_config::AppType;
|
use crate::app_config::AppType;
|
||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
use crate::proxy::{
|
use crate::proxy::{
|
||||||
extract_session_id, forwarder::RequestForwarder, server::ProxyState, types::AppProxyConfig,
|
extract_session_id,
|
||||||
|
forwarder::RequestForwarder,
|
||||||
|
server::ProxyState,
|
||||||
|
types::{AppProxyConfig, RectifierConfig},
|
||||||
ProxyError,
|
ProxyError,
|
||||||
};
|
};
|
||||||
use axum::http::HeaderMap;
|
use axum::http::HeaderMap;
|
||||||
@@ -54,6 +57,8 @@ pub struct RequestContext {
|
|||||||
pub app_type: AppType,
|
pub app_type: AppType,
|
||||||
/// Session ID(从客户端请求提取或新生成)
|
/// Session ID(从客户端请求提取或新生成)
|
||||||
pub session_id: String,
|
pub session_id: String,
|
||||||
|
/// 整流器配置
|
||||||
|
pub rectifier_config: RectifierConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RequestContext {
|
impl RequestContext {
|
||||||
@@ -86,6 +91,9 @@ impl RequestContext {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
|
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
|
||||||
|
|
||||||
|
// 从数据库读取整流器配置
|
||||||
|
let rectifier_config = state.db.get_rectifier_config().unwrap_or_default();
|
||||||
|
|
||||||
let current_provider_id =
|
let current_provider_id =
|
||||||
crate::settings::get_current_provider(&app_type).unwrap_or_default();
|
crate::settings::get_current_provider(&app_type).unwrap_or_default();
|
||||||
|
|
||||||
@@ -147,6 +155,7 @@ impl RequestContext {
|
|||||||
app_type_str,
|
app_type_str,
|
||||||
app_type,
|
app_type,
|
||||||
session_id,
|
session_id,
|
||||||
|
rectifier_config,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,6 +215,7 @@ impl RequestContext {
|
|||||||
self.current_provider_id.clone(),
|
self.current_provider_id.clone(),
|
||||||
first_byte_timeout,
|
first_byte_timeout,
|
||||||
idle_timeout,
|
idle_timeout,
|
||||||
|
self.rectifier_config.clone(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,484 @@
|
|||||||
|
//! 全局 HTTP 客户端模块
|
||||||
|
//!
|
||||||
|
//! 提供支持全局代理配置的 HTTP 客户端。
|
||||||
|
//! 所有需要发送 HTTP 请求的模块都应使用此模块提供的客户端。
|
||||||
|
|
||||||
|
use crate::provider::ProviderProxyConfig;
|
||||||
|
use once_cell::sync::OnceCell;
|
||||||
|
use reqwest::Client;
|
||||||
|
use std::env;
|
||||||
|
use std::net::IpAddr;
|
||||||
|
use std::sync::RwLock;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// 全局 HTTP 客户端实例
|
||||||
|
static GLOBAL_CLIENT: OnceCell<RwLock<Client>> = OnceCell::new();
|
||||||
|
|
||||||
|
/// 当前代理 URL(用于日志和状态查询)
|
||||||
|
static CURRENT_PROXY_URL: OnceCell<RwLock<Option<String>>> = OnceCell::new();
|
||||||
|
|
||||||
|
/// 初始化全局 HTTP 客户端
|
||||||
|
///
|
||||||
|
/// 应在应用启动时调用一次。
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `proxy_url` - 代理 URL,如 `http://127.0.0.1:7890` 或 `socks5://127.0.0.1:1080`
|
||||||
|
/// 传入 None 或空字符串表示直连
|
||||||
|
pub fn init(proxy_url: Option<&str>) -> Result<(), String> {
|
||||||
|
let effective_url = proxy_url.filter(|s| !s.trim().is_empty());
|
||||||
|
let client = build_client(effective_url)?;
|
||||||
|
|
||||||
|
// 尝试初始化全局客户端,如果已存在则记录警告并使用 apply_proxy 更新
|
||||||
|
if GLOBAL_CLIENT.set(RwLock::new(client.clone())).is_err() {
|
||||||
|
log::warn!(
|
||||||
|
"[GlobalProxy] [GP-003] Already initialized, updating instead: {}",
|
||||||
|
effective_url
|
||||||
|
.map(mask_url)
|
||||||
|
.unwrap_or_else(|| "direct connection".to_string())
|
||||||
|
);
|
||||||
|
// 已初始化,改用 apply_proxy 更新
|
||||||
|
return apply_proxy(proxy_url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化代理 URL 记录
|
||||||
|
let _ = CURRENT_PROXY_URL.set(RwLock::new(effective_url.map(|s| s.to_string())));
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"[GlobalProxy] Initialized: {}",
|
||||||
|
effective_url
|
||||||
|
.map(mask_url)
|
||||||
|
.unwrap_or_else(|| "direct connection".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 验证代理配置(不应用)
|
||||||
|
///
|
||||||
|
/// 只验证代理 URL 是否有效,不实际更新全局客户端。
|
||||||
|
/// 用于在持久化之前验证配置的有效性。
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `proxy_url` - 代理 URL,None 或空字符串表示直连
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// 验证成功返回 Ok(()),失败返回错误信息
|
||||||
|
pub fn validate_proxy(proxy_url: Option<&str>) -> Result<(), String> {
|
||||||
|
let effective_url = proxy_url.filter(|s| !s.trim().is_empty());
|
||||||
|
// 只调用 build_client 来验证,但不应用
|
||||||
|
build_client(effective_url)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 应用代理配置(假设已验证)
|
||||||
|
///
|
||||||
|
/// 直接应用代理配置到全局客户端,不做额外验证。
|
||||||
|
/// 应在 validate_proxy 成功后调用。
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `proxy_url` - 代理 URL,None 或空字符串表示直连
|
||||||
|
pub fn apply_proxy(proxy_url: Option<&str>) -> Result<(), String> {
|
||||||
|
let effective_url = proxy_url.filter(|s| !s.trim().is_empty());
|
||||||
|
let new_client = build_client(effective_url)?;
|
||||||
|
|
||||||
|
// 更新客户端
|
||||||
|
if let Some(lock) = GLOBAL_CLIENT.get() {
|
||||||
|
let mut client = lock.write().map_err(|e| {
|
||||||
|
log::error!("[GlobalProxy] [GP-001] Failed to acquire write lock: {e}");
|
||||||
|
"Failed to update proxy: lock poisoned".to_string()
|
||||||
|
})?;
|
||||||
|
*client = new_client;
|
||||||
|
} else {
|
||||||
|
// 如果还没初始化,则初始化
|
||||||
|
return init(proxy_url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新代理 URL 记录
|
||||||
|
if let Some(lock) = CURRENT_PROXY_URL.get() {
|
||||||
|
let mut url = lock.write().map_err(|e| {
|
||||||
|
log::error!("[GlobalProxy] [GP-002] Failed to acquire URL write lock: {e}");
|
||||||
|
"Failed to update proxy URL record: lock poisoned".to_string()
|
||||||
|
})?;
|
||||||
|
*url = effective_url.map(|s| s.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"[GlobalProxy] Applied: {}",
|
||||||
|
effective_url
|
||||||
|
.map(mask_url)
|
||||||
|
.unwrap_or_else(|| "direct connection".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新代理配置(热更新)
|
||||||
|
///
|
||||||
|
/// 可在运行时调用以更改代理设置,无需重启应用。
|
||||||
|
/// 注意:此函数同时验证和应用,如果需要先验证后持久化再应用,
|
||||||
|
/// 请使用 validate_proxy + apply_proxy 组合。
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `proxy_url` - 新的代理 URL,None 或空字符串表示直连
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn update_proxy(proxy_url: Option<&str>) -> Result<(), String> {
|
||||||
|
let effective_url = proxy_url.filter(|s| !s.trim().is_empty());
|
||||||
|
let new_client = build_client(effective_url)?;
|
||||||
|
|
||||||
|
// 更新客户端
|
||||||
|
if let Some(lock) = GLOBAL_CLIENT.get() {
|
||||||
|
let mut client = lock.write().map_err(|e| {
|
||||||
|
log::error!("[GlobalProxy] [GP-001] Failed to acquire write lock: {e}");
|
||||||
|
"Failed to update proxy: lock poisoned".to_string()
|
||||||
|
})?;
|
||||||
|
*client = new_client;
|
||||||
|
} else {
|
||||||
|
// 如果还没初始化,则初始化
|
||||||
|
return init(proxy_url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新代理 URL 记录
|
||||||
|
if let Some(lock) = CURRENT_PROXY_URL.get() {
|
||||||
|
let mut url = lock.write().map_err(|e| {
|
||||||
|
log::error!("[GlobalProxy] [GP-002] Failed to acquire URL write lock: {e}");
|
||||||
|
"Failed to update proxy URL record: lock poisoned".to_string()
|
||||||
|
})?;
|
||||||
|
*url = effective_url.map(|s| s.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"[GlobalProxy] Updated: {}",
|
||||||
|
effective_url
|
||||||
|
.map(mask_url)
|
||||||
|
.unwrap_or_else(|| "direct connection".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取全局 HTTP 客户端
|
||||||
|
///
|
||||||
|
/// 返回配置了代理的客户端(如果已配置代理),否则返回跟随系统代理的客户端。
|
||||||
|
pub fn get() -> Client {
|
||||||
|
GLOBAL_CLIENT
|
||||||
|
.get()
|
||||||
|
.and_then(|lock| lock.read().ok())
|
||||||
|
.map(|c| c.clone())
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
log::warn!("[GlobalProxy] [GP-004] Client not initialized, using fallback");
|
||||||
|
build_client(None).unwrap_or_default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取当前代理 URL
|
||||||
|
///
|
||||||
|
/// 返回当前配置的代理 URL,None 表示直连。
|
||||||
|
pub fn get_current_proxy_url() -> Option<String> {
|
||||||
|
CURRENT_PROXY_URL
|
||||||
|
.get()
|
||||||
|
.and_then(|lock| lock.read().ok())
|
||||||
|
.and_then(|url| url.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查是否正在使用代理
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn is_proxy_enabled() -> bool {
|
||||||
|
get_current_proxy_url().is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 构建 HTTP 客户端
|
||||||
|
fn build_client(proxy_url: Option<&str>) -> Result<Client, String> {
|
||||||
|
let mut builder = Client::builder()
|
||||||
|
.timeout(Duration::from_secs(600))
|
||||||
|
.connect_timeout(Duration::from_secs(30))
|
||||||
|
.pool_max_idle_per_host(10)
|
||||||
|
.tcp_keepalive(Duration::from_secs(60));
|
||||||
|
|
||||||
|
// 有代理地址则使用代理,否则跟随系统代理
|
||||||
|
if let Some(url) = proxy_url {
|
||||||
|
// 先验证 URL 格式和 scheme
|
||||||
|
let parsed = url::Url::parse(url)
|
||||||
|
.map_err(|e| format!("Invalid proxy URL '{}': {}", mask_url(url), e))?;
|
||||||
|
|
||||||
|
let scheme = parsed.scheme();
|
||||||
|
if !["http", "https", "socks5", "socks5h"].contains(&scheme) {
|
||||||
|
return Err(format!(
|
||||||
|
"Invalid proxy scheme '{}' in URL '{}'. Supported: http, https, socks5, socks5h",
|
||||||
|
scheme,
|
||||||
|
mask_url(url)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let proxy = reqwest::Proxy::all(url)
|
||||||
|
.map_err(|e| format!("Invalid proxy URL '{}': {}", mask_url(url), e))?;
|
||||||
|
builder = builder.proxy(proxy);
|
||||||
|
log::debug!("[GlobalProxy] Proxy configured: {}", mask_url(url));
|
||||||
|
} else {
|
||||||
|
// 未设置全局代理时,让 reqwest 自动检测系统代理(环境变量)
|
||||||
|
// 若系统代理指向本机,禁用系统代理避免自环
|
||||||
|
if system_proxy_points_to_loopback() {
|
||||||
|
builder = builder.no_proxy();
|
||||||
|
log::warn!(
|
||||||
|
"[GlobalProxy] System proxy points to localhost, bypassing to avoid recursion"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log::debug!("[GlobalProxy] Following system proxy (no explicit proxy configured)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
builder
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("Failed to build HTTP client: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn system_proxy_points_to_loopback() -> bool {
|
||||||
|
const KEYS: [&str; 6] = [
|
||||||
|
"HTTP_PROXY",
|
||||||
|
"http_proxy",
|
||||||
|
"HTTPS_PROXY",
|
||||||
|
"https_proxy",
|
||||||
|
"ALL_PROXY",
|
||||||
|
"all_proxy",
|
||||||
|
];
|
||||||
|
|
||||||
|
KEYS.iter()
|
||||||
|
.filter_map(|key| env::var(key).ok())
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.any(|value| proxy_points_to_loopback(&value))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn proxy_points_to_loopback(value: &str) -> bool {
|
||||||
|
fn host_is_loopback(host: &str) -> bool {
|
||||||
|
if host.eq_ignore_ascii_case("localhost") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
host.parse::<IpAddr>()
|
||||||
|
.map(|ip| ip.is_loopback())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(parsed) = url::Url::parse(value) {
|
||||||
|
if let Some(host) = parsed.host_str() {
|
||||||
|
return host_is_loopback(host);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let with_scheme = format!("http://{value}");
|
||||||
|
if let Ok(parsed) = url::Url::parse(&with_scheme) {
|
||||||
|
if let Some(host) = parsed.host_str() {
|
||||||
|
return host_is_loopback(host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 隐藏 URL 中的敏感信息(用于日志)
|
||||||
|
pub fn mask_url(url: &str) -> String {
|
||||||
|
if let Ok(parsed) = url::Url::parse(url) {
|
||||||
|
// 隐藏用户名和密码,保留 scheme、host 和端口
|
||||||
|
let host = parsed.host_str().unwrap_or("?");
|
||||||
|
match parsed.port() {
|
||||||
|
Some(port) => format!("{}://{}:{}", parsed.scheme(), host, port),
|
||||||
|
None => format!("{}://{}", parsed.scheme(), host),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// URL 解析失败,返回部分内容
|
||||||
|
if url.len() > 20 {
|
||||||
|
format!("{}...", &url[..20])
|
||||||
|
} else {
|
||||||
|
url.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据供应商单独代理配置构建代理 URL
|
||||||
|
///
|
||||||
|
/// 将 ProviderProxyConfig 转换为代理 URL 字符串
|
||||||
|
fn build_proxy_url_from_config(config: &ProviderProxyConfig) -> Option<String> {
|
||||||
|
let proxy_type = config.proxy_type.as_deref().unwrap_or("http");
|
||||||
|
let host = config.proxy_host.as_deref()?;
|
||||||
|
let port = config.proxy_port?;
|
||||||
|
|
||||||
|
// 构建带认证的代理 URL
|
||||||
|
if let (Some(username), Some(password)) = (&config.proxy_username, &config.proxy_password) {
|
||||||
|
if !username.is_empty() && !password.is_empty() {
|
||||||
|
return Some(format!(
|
||||||
|
"{proxy_type}://{username}:{password}@{host}:{port}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(format!("{proxy_type}://{host}:{port}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据供应商单独代理配置构建 HTTP 客户端
|
||||||
|
///
|
||||||
|
/// 如果供应商配置了单独代理(enabled = true),则使用该代理构建客户端;
|
||||||
|
/// 否则返回 None,调用方应使用全局客户端。
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `proxy_config` - 供应商的代理配置
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// 如果配置有效则返回 Some(Client),否则返回 None
|
||||||
|
pub fn build_client_for_provider(proxy_config: Option<&ProviderProxyConfig>) -> Option<Client> {
|
||||||
|
let config = proxy_config.filter(|c| c.enabled)?;
|
||||||
|
|
||||||
|
let proxy_url = build_proxy_url_from_config(config)?;
|
||||||
|
|
||||||
|
log::debug!(
|
||||||
|
"[ProviderProxy] Building client with proxy: {}",
|
||||||
|
mask_url(&proxy_url)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 构建带代理的客户端
|
||||||
|
let proxy = match reqwest::Proxy::all(&proxy_url) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!(
|
||||||
|
"[ProviderProxy] Failed to create proxy from '{}': {}",
|
||||||
|
mask_url(&proxy_url),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match Client::builder()
|
||||||
|
.timeout(Duration::from_secs(600))
|
||||||
|
.connect_timeout(Duration::from_secs(30))
|
||||||
|
.pool_max_idle_per_host(10)
|
||||||
|
.tcp_keepalive(Duration::from_secs(60))
|
||||||
|
.proxy(proxy)
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(client) => {
|
||||||
|
log::info!(
|
||||||
|
"[ProviderProxy] Client built with proxy: {}",
|
||||||
|
mask_url(&proxy_url)
|
||||||
|
);
|
||||||
|
Some(client)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("[ProviderProxy] Failed to build client: {e}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取供应商专用的 HTTP 客户端
|
||||||
|
///
|
||||||
|
/// 优先使用供应商单独代理配置,如果未启用则返回全局客户端。
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `proxy_config` - 供应商的代理配置
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// 返回适合该供应商的 HTTP 客户端
|
||||||
|
pub fn get_for_provider(proxy_config: Option<&ProviderProxyConfig>) -> Client {
|
||||||
|
// 优先使用供应商单独代理
|
||||||
|
if let Some(client) = build_client_for_provider(proxy_config) {
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退到全局客户端
|
||||||
|
get()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
|
fn env_lock() -> &'static Mutex<()> {
|
||||||
|
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||||
|
LOCK.get_or_init(|| Mutex::new(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mask_url() {
|
||||||
|
assert_eq!(mask_url("http://127.0.0.1:7890"), "http://127.0.0.1:7890");
|
||||||
|
assert_eq!(
|
||||||
|
mask_url("http://user:pass@127.0.0.1:7890"),
|
||||||
|
"http://127.0.0.1:7890"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
mask_url("socks5://admin:secret@proxy.example.com:1080"),
|
||||||
|
"socks5://proxy.example.com:1080"
|
||||||
|
);
|
||||||
|
// 无端口的 URL 不应显示 ":?"
|
||||||
|
assert_eq!(
|
||||||
|
mask_url("http://proxy.example.com"),
|
||||||
|
"http://proxy.example.com"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
mask_url("https://user:pass@proxy.example.com"),
|
||||||
|
"https://proxy.example.com"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_client_direct() {
|
||||||
|
let result = build_client(None);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_client_with_http_proxy() {
|
||||||
|
let result = build_client(Some("http://127.0.0.1:7890"));
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_client_with_socks5_proxy() {
|
||||||
|
let result = build_client(Some("socks5://127.0.0.1:1080"));
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_client_invalid_url() {
|
||||||
|
// reqwest::Proxy::all 对某些无效 URL 不会立即报错
|
||||||
|
// 使用明确无效的 scheme 来触发错误
|
||||||
|
let result = build_client(Some("invalid-scheme://127.0.0.1:7890"));
|
||||||
|
assert!(result.is_err(), "Should reject invalid proxy scheme");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_proxy_points_to_loopback() {
|
||||||
|
assert!(proxy_points_to_loopback("http://127.0.0.1:7890"));
|
||||||
|
assert!(proxy_points_to_loopback("socks5://localhost:1080"));
|
||||||
|
assert!(proxy_points_to_loopback("127.0.0.1:7890"));
|
||||||
|
assert!(!proxy_points_to_loopback("http://192.168.1.10:7890"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_system_proxy_points_to_loopback() {
|
||||||
|
let _guard = env_lock().lock().unwrap();
|
||||||
|
|
||||||
|
let keys = [
|
||||||
|
"HTTP_PROXY",
|
||||||
|
"http_proxy",
|
||||||
|
"HTTPS_PROXY",
|
||||||
|
"https_proxy",
|
||||||
|
"ALL_PROXY",
|
||||||
|
"all_proxy",
|
||||||
|
];
|
||||||
|
|
||||||
|
for key in &keys {
|
||||||
|
std::env::remove_var(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::env::set_var("HTTP_PROXY", "http://127.0.0.1:7890");
|
||||||
|
assert!(system_proxy_points_to_loopback());
|
||||||
|
|
||||||
|
std::env::set_var("HTTP_PROXY", "http://10.0.0.2:7890");
|
||||||
|
assert!(!system_proxy_points_to_loopback());
|
||||||
|
|
||||||
|
for key in &keys {
|
||||||
|
std::env::remove_var(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ pub mod handler_config;
|
|||||||
pub mod handler_context;
|
pub mod handler_context;
|
||||||
mod handlers;
|
mod handlers;
|
||||||
mod health;
|
mod health;
|
||||||
|
pub mod http_client;
|
||||||
pub mod log_codes;
|
pub mod log_codes;
|
||||||
pub mod model_mapper;
|
pub mod model_mapper;
|
||||||
pub mod provider_router;
|
pub mod provider_router;
|
||||||
@@ -20,6 +21,7 @@ pub mod response_handler;
|
|||||||
pub mod response_processor;
|
pub mod response_processor;
|
||||||
pub(crate) mod server;
|
pub(crate) mod server;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
|
pub mod thinking_rectifier;
|
||||||
pub(crate) mod types;
|
pub(crate) mod types;
|
||||||
pub mod usage;
|
pub mod usage;
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
//!
|
//!
|
||||||
//! 负责选择和管理代理目标供应商,实现智能故障转移
|
//! 负责选择和管理代理目标供应商,实现智能故障转移
|
||||||
|
|
||||||
|
use crate::app_config::AppType;
|
||||||
use crate::database::Database;
|
use crate::database::Database;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
use crate::proxy::circuit_breaker::{AllowResult, CircuitBreaker, CircuitBreakerConfig};
|
use crate::proxy::circuit_breaker::{AllowResult, CircuitBreaker, CircuitBreakerConfig};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::str::FromStr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
@@ -31,7 +33,7 @@ impl ProviderRouter {
|
|||||||
///
|
///
|
||||||
/// 返回按优先级排序的可用供应商列表:
|
/// 返回按优先级排序的可用供应商列表:
|
||||||
/// - 故障转移关闭时:仅返回当前供应商
|
/// - 故障转移关闭时:仅返回当前供应商
|
||||||
/// - 故障转移开启时:完全按照故障转移队列顺序返回,忽略当前供应商设置
|
/// - 故障转移开启时:仅使用故障转移队列,按队列顺序依次尝试(P1 → P2 → ...)
|
||||||
pub async fn select_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> {
|
pub async fn select_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> {
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
let mut total_providers = 0usize;
|
let mut total_providers = 0usize;
|
||||||
@@ -47,12 +49,25 @@ impl ProviderRouter {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if auto_failover_enabled {
|
if auto_failover_enabled {
|
||||||
// 故障转移开启:使用 in_failover_queue 标记的供应商,按 sort_index 排序
|
// 故障转移开启:仅按队列顺序依次尝试(P1 → P2 → ...)
|
||||||
let failover_providers = self.db.get_failover_providers(app_type)?;
|
let all_providers = self.db.get_all_providers(app_type)?;
|
||||||
total_providers = failover_providers.len();
|
|
||||||
|
|
||||||
for provider in failover_providers {
|
// 使用 DAO 返回的排序结果,确保和前端展示一致
|
||||||
let circuit_key = format!("{}:{}", app_type, provider.id);
|
let ordered_ids: Vec<String> = self
|
||||||
|
.db
|
||||||
|
.get_failover_queue(app_type)?
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| item.provider_id)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
total_providers = ordered_ids.len();
|
||||||
|
|
||||||
|
for provider_id in ordered_ids {
|
||||||
|
let Some(provider) = all_providers.get(&provider_id).cloned() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let circuit_key = format!("{app_type}:{}", provider.id);
|
||||||
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
|
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
|
||||||
|
|
||||||
if breaker.is_available().await {
|
if breaker.is_available().await {
|
||||||
@@ -63,7 +78,16 @@ impl ProviderRouter {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 故障转移关闭:仅使用当前供应商,跳过熔断器检查
|
// 故障转移关闭:仅使用当前供应商,跳过熔断器检查
|
||||||
if let Some(current_id) = self.db.get_current_provider(app_type)? {
|
let current_id = AppType::from_str(app_type)
|
||||||
|
.ok()
|
||||||
|
.and_then(|app_enum| {
|
||||||
|
crate::settings::get_effective_current_provider(&self.db, &app_enum)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
})
|
||||||
|
.or_else(|| self.db.get_current_provider(app_type).ok().flatten());
|
||||||
|
|
||||||
|
if let Some(current_id) = current_id {
|
||||||
if let Some(current) = self.db.get_provider_by_id(¤t_id, app_type)? {
|
if let Some(current) = self.db.get_provider_by_id(¤t_id, app_type)? {
|
||||||
total_providers = 1;
|
total_providers = 1;
|
||||||
result.push(current);
|
result.push(current);
|
||||||
@@ -151,6 +175,24 @@ impl ProviderRouter {
|
|||||||
self.reset_circuit_breaker(&circuit_key).await;
|
self.reset_circuit_breaker(&circuit_key).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 仅释放 HalfOpen permit,不影响健康统计(neutral 接口)
|
||||||
|
///
|
||||||
|
/// 用于整流器等场景:请求结果不应计入 Provider 健康度,
|
||||||
|
/// 但仍需释放占用的探测名额,避免 HalfOpen 状态卡死
|
||||||
|
pub async fn release_permit_neutral(
|
||||||
|
&self,
|
||||||
|
provider_id: &str,
|
||||||
|
app_type: &str,
|
||||||
|
used_half_open_permit: bool,
|
||||||
|
) {
|
||||||
|
if !used_half_open_permit {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let circuit_key = format!("{app_type}:{provider_id}");
|
||||||
|
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
|
||||||
|
breaker.release_half_open_permit();
|
||||||
|
}
|
||||||
|
|
||||||
/// 更新所有熔断器的配置(热更新)
|
/// 更新所有熔断器的配置(热更新)
|
||||||
pub async fn update_all_configs(&self, config: CircuitBreakerConfig) {
|
pub async fn update_all_configs(&self, config: CircuitBreakerConfig) {
|
||||||
let breakers = self.circuit_breakers.read().await;
|
let breakers = self.circuit_breakers.read().await;
|
||||||
@@ -221,9 +263,53 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::database::Database;
|
use crate::database::Database;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use serial_test::serial;
|
||||||
|
use std::env;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
struct TempHome {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
dir: TempDir,
|
||||||
|
original_home: Option<String>,
|
||||||
|
original_userprofile: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TempHome {
|
||||||
|
fn new() -> Self {
|
||||||
|
let dir = TempDir::new().expect("failed to create temp home");
|
||||||
|
let original_home = env::var("HOME").ok();
|
||||||
|
let original_userprofile = env::var("USERPROFILE").ok();
|
||||||
|
|
||||||
|
env::set_var("HOME", dir.path());
|
||||||
|
env::set_var("USERPROFILE", dir.path());
|
||||||
|
crate::settings::reload_settings().expect("reload settings");
|
||||||
|
|
||||||
|
Self {
|
||||||
|
dir,
|
||||||
|
original_home,
|
||||||
|
original_userprofile,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TempHome {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
match &self.original_home {
|
||||||
|
Some(value) => env::set_var("HOME", value),
|
||||||
|
None => env::remove_var("HOME"),
|
||||||
|
}
|
||||||
|
|
||||||
|
match &self.original_userprofile {
|
||||||
|
Some(value) => env::set_var("USERPROFILE", value),
|
||||||
|
None => env::remove_var("USERPROFILE"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_provider_router_creation() {
|
async fn test_provider_router_creation() {
|
||||||
|
let _home = TempHome::new();
|
||||||
let db = Arc::new(Database::memory().unwrap());
|
let db = Arc::new(Database::memory().unwrap());
|
||||||
let router = ProviderRouter::new(db);
|
let router = ProviderRouter::new(db);
|
||||||
|
|
||||||
@@ -232,7 +318,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_failover_disabled_uses_current_provider() {
|
async fn test_failover_disabled_uses_current_provider() {
|
||||||
|
let _home = TempHome::new();
|
||||||
let db = Arc::new(Database::memory().unwrap());
|
let db = Arc::new(Database::memory().unwrap());
|
||||||
|
|
||||||
let provider_a =
|
let provider_a =
|
||||||
@@ -253,7 +341,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_failover_enabled_uses_queue_order() {
|
#[serial]
|
||||||
|
async fn test_failover_enabled_uses_queue_order_ignoring_current() {
|
||||||
|
let _home = TempHome::new();
|
||||||
let db = Arc::new(Database::memory().unwrap());
|
let db = Arc::new(Database::memory().unwrap());
|
||||||
|
|
||||||
// 设置 sort_index 来控制顺序:b=1, a=2
|
// 设置 sort_index 来控制顺序:b=1, a=2
|
||||||
@@ -280,13 +370,45 @@ mod tests {
|
|||||||
let providers = router.select_providers("claude").await.unwrap();
|
let providers = router.select_providers("claude").await.unwrap();
|
||||||
|
|
||||||
assert_eq!(providers.len(), 2);
|
assert_eq!(providers.len(), 2);
|
||||||
// 按 sort_index 排序:b(1) 在前,a(2) 在后
|
// 故障转移开启时:仅按队列顺序选择(忽略当前供应商)
|
||||||
assert_eq!(providers[0].id, "b");
|
assert_eq!(providers[0].id, "b");
|
||||||
assert_eq!(providers[1].id, "a");
|
assert_eq!(providers[1].id, "a");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_failover_enabled_uses_queue_only_even_if_current_not_in_queue() {
|
||||||
|
let _home = TempHome::new();
|
||||||
|
let db = Arc::new(Database::memory().unwrap());
|
||||||
|
|
||||||
|
let provider_a =
|
||||||
|
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
|
||||||
|
let mut provider_b =
|
||||||
|
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
|
||||||
|
provider_b.sort_index = Some(1);
|
||||||
|
|
||||||
|
db.save_provider("claude", &provider_a).unwrap();
|
||||||
|
db.save_provider("claude", &provider_b).unwrap();
|
||||||
|
db.set_current_provider("claude", "a").unwrap();
|
||||||
|
|
||||||
|
// 只把 b 加入故障转移队列(模拟“当前供应商不在队列里”的常见配置)
|
||||||
|
db.add_to_failover_queue("claude", "b").unwrap();
|
||||||
|
|
||||||
|
let mut config = db.get_proxy_config_for_app("claude").await.unwrap();
|
||||||
|
config.auto_failover_enabled = true;
|
||||||
|
db.update_proxy_config_for_app(config).await.unwrap();
|
||||||
|
|
||||||
|
let router = ProviderRouter::new(db.clone());
|
||||||
|
let providers = router.select_providers("claude").await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(providers.len(), 1);
|
||||||
|
assert_eq!(providers[0].id, "b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_select_providers_does_not_consume_half_open_permit() {
|
async fn test_select_providers_does_not_consume_half_open_permit() {
|
||||||
|
let _home = TempHome::new();
|
||||||
let db = Arc::new(Database::memory().unwrap());
|
let db = Arc::new(Database::memory().unwrap());
|
||||||
|
|
||||||
db.update_circuit_breaker_config(&CircuitBreakerConfig {
|
db.update_circuit_breaker_config(&CircuitBreakerConfig {
|
||||||
@@ -325,4 +447,57 @@ mod tests {
|
|||||||
|
|
||||||
assert!(router.allow_provider_request("b", "claude").await.allowed);
|
assert!(router.allow_provider_request("b", "claude").await.allowed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_release_permit_neutral_frees_half_open_slot() {
|
||||||
|
let _home = TempHome::new();
|
||||||
|
let db = Arc::new(Database::memory().unwrap());
|
||||||
|
|
||||||
|
// 配置熔断器:1 次失败即熔断,0 秒超时立即进入 HalfOpen
|
||||||
|
db.update_circuit_breaker_config(&CircuitBreakerConfig {
|
||||||
|
failure_threshold: 1,
|
||||||
|
timeout_seconds: 0,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let provider_a =
|
||||||
|
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
|
||||||
|
db.save_provider("claude", &provider_a).unwrap();
|
||||||
|
db.add_to_failover_queue("claude", "a").unwrap();
|
||||||
|
|
||||||
|
// 启用自动故障转移
|
||||||
|
let mut config = db.get_proxy_config_for_app("claude").await.unwrap();
|
||||||
|
config.auto_failover_enabled = true;
|
||||||
|
db.update_proxy_config_for_app(config).await.unwrap();
|
||||||
|
|
||||||
|
let router = ProviderRouter::new(db.clone());
|
||||||
|
|
||||||
|
// 触发熔断:1 次失败
|
||||||
|
router
|
||||||
|
.record_result("a", "claude", false, false, Some("fail".to_string()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// 第一次请求:获取 HalfOpen 探测名额
|
||||||
|
let first = router.allow_provider_request("a", "claude").await;
|
||||||
|
assert!(first.allowed);
|
||||||
|
assert!(first.used_half_open_permit);
|
||||||
|
|
||||||
|
// 第二次请求应被拒绝(名额已被占用)
|
||||||
|
let second = router.allow_provider_request("a", "claude").await;
|
||||||
|
assert!(!second.allowed);
|
||||||
|
|
||||||
|
// 使用 release_permit_neutral 释放名额(不影响健康统计)
|
||||||
|
router
|
||||||
|
.release_permit_neutral("a", "claude", first.used_half_open_permit)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// 第三次请求应被允许(名额已释放)
|
||||||
|
let third = router.allow_provider_request("a", "claude").await;
|
||||||
|
assert!(third.allowed);
|
||||||
|
assert!(third.used_half_open_permit);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,6 +132,10 @@ impl ProviderType {
|
|||||||
}
|
}
|
||||||
ProviderType::Gemini
|
ProviderType::Gemini
|
||||||
}
|
}
|
||||||
|
AppType::OpenCode => {
|
||||||
|
// OpenCode doesn't support proxy, but return a default type for completeness
|
||||||
|
ProviderType::Codex // Fallback to Codex-like type
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,6 +180,10 @@ pub fn get_adapter(app_type: &AppType) -> Box<dyn ProviderAdapter> {
|
|||||||
AppType::Claude => Box::new(ClaudeAdapter::new()),
|
AppType::Claude => Box::new(ClaudeAdapter::new()),
|
||||||
AppType::Codex => Box::new(CodexAdapter::new()),
|
AppType::Codex => Box::new(CodexAdapter::new()),
|
||||||
AppType::Gemini => Box::new(GeminiAdapter::new()),
|
AppType::Gemini => Box::new(GeminiAdapter::new()),
|
||||||
|
AppType::OpenCode => {
|
||||||
|
// OpenCode doesn't support proxy, fallback to Codex adapter
|
||||||
|
Box::new(CodexAdapter::new())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,6 @@ pub fn create_anthropic_sse_stream(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(chunk) = serde_json::from_str::<OpenAIStreamChunk>(data) {
|
if let Ok(chunk) = serde_json::from_str::<OpenAIStreamChunk>(data) {
|
||||||
// 仅在 DEBUG 级别简短记录 SSE 事件
|
|
||||||
log::debug!("[Claude/OpenRouter] <<< SSE chunk received");
|
log::debug!("[Claude/OpenRouter] <<< SSE chunk received");
|
||||||
|
|
||||||
if message_id.is_none() {
|
if message_id.is_none() {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use super::{
|
|||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use futures::stream::{Stream, StreamExt};
|
use futures::stream::{Stream, StreamExt};
|
||||||
|
use reqwest::header::HeaderMap;
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::{
|
use std::{
|
||||||
@@ -47,6 +48,12 @@ pub async fn handle_streaming(
|
|||||||
parser_config: &UsageParserConfig,
|
parser_config: &UsageParserConfig,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
|
log::debug!(
|
||||||
|
"[{}] 已接收上游流式响应: status={}, headers={}",
|
||||||
|
ctx.tag,
|
||||||
|
status.as_u16(),
|
||||||
|
format_headers(response.headers())
|
||||||
|
);
|
||||||
let mut builder = axum::response::Response::builder().status(status);
|
let mut builder = axum::response::Response::builder().status(status);
|
||||||
|
|
||||||
// 复制响应头
|
// 复制响应头
|
||||||
@@ -94,6 +101,19 @@ pub async fn handle_non_streaming(
|
|||||||
log::error!("[{}] 读取响应失败: {e}", ctx.tag);
|
log::error!("[{}] 读取响应失败: {e}", ctx.tag);
|
||||||
ProxyError::ForwardFailed(format!("Failed to read response body: {e}"))
|
ProxyError::ForwardFailed(format!("Failed to read response body: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
log::debug!(
|
||||||
|
"[{}] 已接收上游响应体: status={}, bytes={}, headers={}",
|
||||||
|
ctx.tag,
|
||||||
|
status.as_u16(),
|
||||||
|
body_bytes.len(),
|
||||||
|
format_headers(&response_headers)
|
||||||
|
);
|
||||||
|
|
||||||
|
log::debug!(
|
||||||
|
"[{}] 上游响应体内容: {}",
|
||||||
|
ctx.tag,
|
||||||
|
String::from_utf8_lossy(&body_bytes)
|
||||||
|
);
|
||||||
|
|
||||||
// 解析并记录使用量
|
// 解析并记录使用量
|
||||||
if let Ok(json_value) = serde_json::from_slice::<Value>(&body_bytes) {
|
if let Ok(json_value) = serde_json::from_slice::<Value>(&body_bytes) {
|
||||||
@@ -470,6 +490,12 @@ pub fn create_logged_passthrough_stream(
|
|||||||
|
|
||||||
match chunk_result {
|
match chunk_result {
|
||||||
Some(Ok(bytes)) => {
|
Some(Ok(bytes)) => {
|
||||||
|
if is_first_chunk {
|
||||||
|
log::debug!(
|
||||||
|
"[{tag}] 已接收上游流式首包: bytes={}",
|
||||||
|
bytes.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
is_first_chunk = false;
|
is_first_chunk = false;
|
||||||
let text = String::from_utf8_lossy(&bytes);
|
let text = String::from_utf8_lossy(&bytes);
|
||||||
buffer.push_str(&text);
|
buffer.push_str(&text);
|
||||||
@@ -488,13 +514,9 @@ pub fn create_logged_passthrough_stream(
|
|||||||
if let Some(c) = &collector {
|
if let Some(c) = &collector {
|
||||||
c.push(json_value.clone()).await;
|
c.push(json_value.clone()).await;
|
||||||
}
|
}
|
||||||
log::debug!(
|
log::debug!("[{tag}] <<< SSE 事件: {data}");
|
||||||
"[{}] <<< SSE 事件: {}",
|
|
||||||
tag,
|
|
||||||
data.chars().take(100).collect::<String>()
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
log::debug!("[{tag}] <<< SSE 数据: {}", data.chars().take(100).collect::<String>());
|
log::debug!("[{tag}] <<< SSE 数据: {data}");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log::debug!("[{tag}] <<< SSE: [DONE]");
|
log::debug!("[{tag}] <<< SSE: [DONE]");
|
||||||
@@ -523,3 +545,14 @@ pub fn create_logged_passthrough_stream(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn format_headers(headers: &HeaderMap) -> String {
|
||||||
|
headers
|
||||||
|
.iter()
|
||||||
|
.map(|(key, value)| {
|
||||||
|
let value_str = value.to_str().unwrap_or("<non-utf8>");
|
||||||
|
format!("{key}={value_str}")
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use super::{
|
|||||||
};
|
};
|
||||||
use crate::database::Database;
|
use crate::database::Database;
|
||||||
use axum::{
|
use axum::{
|
||||||
|
extract::DefaultBodyLimit,
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
@@ -189,6 +190,18 @@ impl ProxyServer {
|
|||||||
status
|
status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 更新某个应用类型当前“目标供应商”(用于 UI 展示 active_targets)
|
||||||
|
///
|
||||||
|
/// 注意:这不代表该供应商一定已经处理过请求,而是用于“热切换/启用故障转移立即切 P1”
|
||||||
|
/// 等场景下,让 UI 能立刻反映最新目标。
|
||||||
|
pub async fn set_active_target(&self, app_type: &str, provider_id: &str, provider_name: &str) {
|
||||||
|
let mut current_providers = self.state.current_providers.write().await;
|
||||||
|
current_providers.insert(
|
||||||
|
app_type.to_string(),
|
||||||
|
(provider_id.to_string(), provider_name.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn build_router(&self) -> Router {
|
fn build_router(&self) -> Router {
|
||||||
let cors = CorsLayer::new()
|
let cors = CorsLayer::new()
|
||||||
.allow_origin(Any)
|
.allow_origin(Any)
|
||||||
@@ -224,6 +237,8 @@ impl ProxyServer {
|
|||||||
// Gemini API (支持带前缀和不带前缀)
|
// Gemini API (支持带前缀和不带前缀)
|
||||||
.route("/v1beta/*path", post(handlers::handle_gemini))
|
.route("/v1beta/*path", post(handlers::handle_gemini))
|
||||||
.route("/gemini/v1beta/*path", post(handlers::handle_gemini))
|
.route("/gemini/v1beta/*path", post(handlers::handle_gemini))
|
||||||
|
// 提高默认请求体大小限制(避免 413 Payload Too Large)
|
||||||
|
.layer(DefaultBodyLimit::max(200 * 1024 * 1024))
|
||||||
.layer(cors)
|
.layer(cors)
|
||||||
.with_state(self.state.clone())
|
.with_state(self.state.clone())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,421 @@
|
|||||||
|
//! Thinking Signature 整流器
|
||||||
|
//!
|
||||||
|
//! 用于自动修复 Anthropic API 中因签名校验失败导致的请求错误。
|
||||||
|
//! 当上游 API 返回签名相关错误时,系统会自动移除有问题的签名字段并重试请求。
|
||||||
|
|
||||||
|
use super::types::RectifierConfig;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
/// 整流结果
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct RectifyResult {
|
||||||
|
/// 是否应用了整流
|
||||||
|
pub applied: bool,
|
||||||
|
/// 移除的 thinking block 数量
|
||||||
|
pub removed_thinking_blocks: usize,
|
||||||
|
/// 移除的 redacted_thinking block 数量
|
||||||
|
pub removed_redacted_thinking_blocks: usize,
|
||||||
|
/// 移除的 signature 字段数量
|
||||||
|
pub removed_signature_fields: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检测是否需要触发 thinking 签名整流器
|
||||||
|
///
|
||||||
|
/// 返回 `true` 表示需要触发整流器,`false` 表示不需要。
|
||||||
|
/// 会检查配置开关。
|
||||||
|
pub fn should_rectify_thinking_signature(
|
||||||
|
error_message: Option<&str>,
|
||||||
|
config: &RectifierConfig,
|
||||||
|
) -> bool {
|
||||||
|
// 检查总开关
|
||||||
|
if !config.enabled {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 检查子开关
|
||||||
|
if !config.request_thinking_signature {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测错误类型
|
||||||
|
let Some(msg) = error_message else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let lower = msg.to_lowercase();
|
||||||
|
|
||||||
|
// 场景1: thinking block 中的签名无效
|
||||||
|
// 错误示例: "Invalid 'signature' in 'thinking' block"
|
||||||
|
if lower.contains("invalid")
|
||||||
|
&& lower.contains("signature")
|
||||||
|
&& lower.contains("thinking")
|
||||||
|
&& lower.contains("block")
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 场景2: assistant 消息必须以 thinking block 开头
|
||||||
|
// 错误示例: "must start with a thinking block"
|
||||||
|
if lower.contains("must start with a thinking block") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 场景3: expected thinking or redacted_thinking, found tool_use
|
||||||
|
// 错误示例: "Expected `thinking` or `redacted_thinking`, but found `tool_use`"
|
||||||
|
if lower.contains("expected")
|
||||||
|
&& (lower.contains("thinking") || lower.contains("redacted_thinking"))
|
||||||
|
&& lower.contains("found")
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 场景4: signature 字段必需但缺失
|
||||||
|
// 错误示例: "signature: Field required"
|
||||||
|
if lower.contains("signature") && lower.contains("field required") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 对 Anthropic 请求体做最小侵入整流
|
||||||
|
///
|
||||||
|
/// - 移除 messages[*].content 中的 thinking/redacted_thinking block
|
||||||
|
/// - 移除非 thinking block 上遗留的 signature 字段
|
||||||
|
/// - 特定条件下删除顶层 thinking 字段
|
||||||
|
///
|
||||||
|
/// 注意:该函数会原地修改 body 对象
|
||||||
|
pub fn rectify_anthropic_request(body: &mut Value) -> RectifyResult {
|
||||||
|
let mut result = RectifyResult::default();
|
||||||
|
|
||||||
|
let messages = match body.get_mut("messages").and_then(|m| m.as_array_mut()) {
|
||||||
|
Some(m) => m,
|
||||||
|
None => return result,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 遍历所有消息
|
||||||
|
for msg in messages.iter_mut() {
|
||||||
|
let content = match msg.get_mut("content").and_then(|c| c.as_array_mut()) {
|
||||||
|
Some(c) => c,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut new_content = Vec::with_capacity(content.len());
|
||||||
|
let mut content_modified = false;
|
||||||
|
|
||||||
|
for block in content.iter() {
|
||||||
|
let block_type = block.get("type").and_then(|t| t.as_str());
|
||||||
|
|
||||||
|
match block_type {
|
||||||
|
Some("thinking") => {
|
||||||
|
result.removed_thinking_blocks += 1;
|
||||||
|
content_modified = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Some("redacted_thinking") => {
|
||||||
|
result.removed_redacted_thinking_blocks += 1;
|
||||||
|
content_modified = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除非 thinking block 上的 signature 字段
|
||||||
|
if block.get("signature").is_some() {
|
||||||
|
let mut block_clone = block.clone();
|
||||||
|
if let Some(obj) = block_clone.as_object_mut() {
|
||||||
|
obj.remove("signature");
|
||||||
|
result.removed_signature_fields += 1;
|
||||||
|
content_modified = true;
|
||||||
|
new_content.push(Value::Object(obj.clone()));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
new_content.push(block.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
if content_modified {
|
||||||
|
result.applied = true;
|
||||||
|
*content = new_content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底处理:thinking 启用 + 工具调用链路中最后一条 assistant 消息未以 thinking 开头
|
||||||
|
let messages_snapshot: Vec<Value> = body
|
||||||
|
.get("messages")
|
||||||
|
.and_then(|m| m.as_array())
|
||||||
|
.map(|a| a.to_vec())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if should_remove_top_level_thinking(body, &messages_snapshot) {
|
||||||
|
if let Some(obj) = body.as_object_mut() {
|
||||||
|
obj.remove("thinking");
|
||||||
|
result.applied = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判断是否需要删除顶层 thinking 字段
|
||||||
|
fn should_remove_top_level_thinking(body: &Value, messages: &[Value]) -> bool {
|
||||||
|
// 检查 thinking 是否启用
|
||||||
|
let thinking_enabled = body
|
||||||
|
.get("thinking")
|
||||||
|
.and_then(|t| t.get("type"))
|
||||||
|
.and_then(|t| t.as_str())
|
||||||
|
== Some("enabled");
|
||||||
|
|
||||||
|
if !thinking_enabled {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 找到最后一条 assistant 消息
|
||||||
|
let last_assistant = messages
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|m| m.get("role").and_then(|r| r.as_str()) == Some("assistant"));
|
||||||
|
|
||||||
|
let last_assistant_content = match last_assistant
|
||||||
|
.and_then(|m| m.get("content"))
|
||||||
|
.and_then(|c| c.as_array())
|
||||||
|
{
|
||||||
|
Some(c) if !c.is_empty() => c,
|
||||||
|
_ => return false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查首块是否为 thinking/redacted_thinking
|
||||||
|
let first_block_type = last_assistant_content
|
||||||
|
.first()
|
||||||
|
.and_then(|b| b.get("type"))
|
||||||
|
.and_then(|t| t.as_str());
|
||||||
|
|
||||||
|
let missing_thinking_prefix =
|
||||||
|
first_block_type != Some("thinking") && first_block_type != Some("redacted_thinking");
|
||||||
|
|
||||||
|
if !missing_thinking_prefix {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否存在 tool_use
|
||||||
|
last_assistant_content
|
||||||
|
.iter()
|
||||||
|
.any(|b| b.get("type").and_then(|t| t.as_str()) == Some("tool_use"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn enabled_config() -> RectifierConfig {
|
||||||
|
RectifierConfig {
|
||||||
|
enabled: true,
|
||||||
|
request_thinking_signature: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disabled_config() -> RectifierConfig {
|
||||||
|
RectifierConfig {
|
||||||
|
enabled: true,
|
||||||
|
request_thinking_signature: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn master_disabled_config() -> RectifierConfig {
|
||||||
|
RectifierConfig {
|
||||||
|
enabled: false,
|
||||||
|
request_thinking_signature: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== should_rectify_thinking_signature 测试 ====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_detect_invalid_signature() {
|
||||||
|
assert!(should_rectify_thinking_signature(
|
||||||
|
Some("messages.1.content.0: Invalid `signature` in `thinking` block"),
|
||||||
|
&enabled_config()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_detect_invalid_signature_no_backticks() {
|
||||||
|
assert!(should_rectify_thinking_signature(
|
||||||
|
Some("Messages.1.Content.0: invalid signature in thinking block"),
|
||||||
|
&enabled_config()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_detect_invalid_signature_nested_json() {
|
||||||
|
// 测试嵌套 JSON 格式的错误消息(第三方渠道常见格式)
|
||||||
|
let nested_error = r#"{"error":{"message":"{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"***.content.0: Invalid `signature` in `thinking` block\"},\"request_id\":\"req_xxx\"}"}}"#;
|
||||||
|
assert!(should_rectify_thinking_signature(
|
||||||
|
Some(nested_error),
|
||||||
|
&enabled_config()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_detect_thinking_expected() {
|
||||||
|
assert!(should_rectify_thinking_signature(
|
||||||
|
Some("messages.69.content.0.type: Expected `thinking` or `redacted_thinking`, but found `tool_use`."),
|
||||||
|
&enabled_config()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_detect_must_start_with_thinking() {
|
||||||
|
assert!(should_rectify_thinking_signature(
|
||||||
|
Some("a final `assistant` message must start with a thinking block"),
|
||||||
|
&enabled_config()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_trigger_for_unrelated_error() {
|
||||||
|
assert!(!should_rectify_thinking_signature(
|
||||||
|
Some("Request timeout"),
|
||||||
|
&enabled_config()
|
||||||
|
));
|
||||||
|
assert!(!should_rectify_thinking_signature(
|
||||||
|
Some("Connection refused"),
|
||||||
|
&enabled_config()
|
||||||
|
));
|
||||||
|
assert!(!should_rectify_thinking_signature(None, &enabled_config()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_detect_signature_field_required() {
|
||||||
|
// 场景4: signature 字段缺失
|
||||||
|
assert!(should_rectify_thinking_signature(
|
||||||
|
Some("***.***.***.***.***.signature: Field required"),
|
||||||
|
&enabled_config()
|
||||||
|
));
|
||||||
|
// 嵌套 JSON 格式
|
||||||
|
let nested_error = r#"{"error":{"type":"<nil>","message":"{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"***.***.***.***.***.signature: Field required\"},\"request_id\":\"req_xxx\"}"}}"#;
|
||||||
|
assert!(should_rectify_thinking_signature(
|
||||||
|
Some(nested_error),
|
||||||
|
&enabled_config()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_disabled_config() {
|
||||||
|
// 即使错误匹配,配置关闭时也不触发
|
||||||
|
assert!(!should_rectify_thinking_signature(
|
||||||
|
Some("Invalid `signature` in `thinking` block"),
|
||||||
|
&disabled_config()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_master_disabled() {
|
||||||
|
// 总开关关闭时,即使子开关开启也不触发
|
||||||
|
assert!(!should_rectify_thinking_signature(
|
||||||
|
Some("Invalid `signature` in `thinking` block"),
|
||||||
|
&master_disabled_config()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== rectify_anthropic_request 测试 ====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rectify_removes_thinking_blocks() {
|
||||||
|
let mut body = json!({
|
||||||
|
"model": "claude-test",
|
||||||
|
"messages": [{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{ "type": "thinking", "thinking": "t", "signature": "sig" },
|
||||||
|
{ "type": "text", "text": "hello", "signature": "sig_text" },
|
||||||
|
{ "type": "tool_use", "id": "toolu_1", "name": "WebSearch", "input": {}, "signature": "sig_tool" },
|
||||||
|
{ "type": "redacted_thinking", "data": "r", "signature": "sig_redacted" }
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = rectify_anthropic_request(&mut body);
|
||||||
|
|
||||||
|
assert!(result.applied);
|
||||||
|
assert_eq!(result.removed_thinking_blocks, 1);
|
||||||
|
assert_eq!(result.removed_redacted_thinking_blocks, 1);
|
||||||
|
assert_eq!(result.removed_signature_fields, 2);
|
||||||
|
|
||||||
|
let content = body["messages"][0]["content"].as_array().unwrap();
|
||||||
|
assert_eq!(content.len(), 2);
|
||||||
|
assert_eq!(content[0]["type"], "text");
|
||||||
|
assert!(content[0].get("signature").is_none());
|
||||||
|
assert_eq!(content[1]["type"], "tool_use");
|
||||||
|
assert!(content[1].get("signature").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rectify_removes_top_level_thinking() {
|
||||||
|
let mut body = json!({
|
||||||
|
"model": "claude-test",
|
||||||
|
"thinking": { "type": "enabled", "budget_tokens": 1024 },
|
||||||
|
"messages": [{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{ "type": "tool_use", "id": "toolu_1", "name": "WebSearch", "input": {} }
|
||||||
|
]
|
||||||
|
}, {
|
||||||
|
"role": "user",
|
||||||
|
"content": [{ "type": "tool_result", "tool_use_id": "toolu_1", "content": "ok" }]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = rectify_anthropic_request(&mut body);
|
||||||
|
|
||||||
|
assert!(result.applied);
|
||||||
|
assert!(body.get("thinking").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rectify_no_change_when_no_issues() {
|
||||||
|
let mut body = json!({
|
||||||
|
"model": "claude-test",
|
||||||
|
"messages": [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [{ "type": "text", "text": "hello" }]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = rectify_anthropic_request(&mut body);
|
||||||
|
|
||||||
|
assert!(!result.applied);
|
||||||
|
assert_eq!(result.removed_thinking_blocks, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rectify_no_messages() {
|
||||||
|
let mut body = json!({ "model": "claude-test" });
|
||||||
|
let result = rectify_anthropic_request(&mut body);
|
||||||
|
assert!(!result.applied);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rectify_preserves_thinking_when_prefix_exists() {
|
||||||
|
let mut body = json!({
|
||||||
|
"model": "claude-test",
|
||||||
|
"thinking": { "type": "enabled" },
|
||||||
|
"messages": [{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{ "type": "thinking", "thinking": "some thought" },
|
||||||
|
{ "type": "tool_use", "id": "toolu_1", "name": "Test", "input": {} }
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = rectify_anthropic_request(&mut body);
|
||||||
|
|
||||||
|
// thinking block 被移除,但顶层 thinking 不应被移除(因为原本有 thinking 前缀)
|
||||||
|
assert!(result.applied);
|
||||||
|
assert_eq!(result.removed_thinking_blocks, 1);
|
||||||
|
// 注意:由于 thinking block 被移除后,首块变成了 tool_use,
|
||||||
|
// 此时会触发删除顶层 thinking 的逻辑
|
||||||
|
// 这是预期行为:整流后如果仍然不符合要求,就删除顶层 thinking
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,13 +16,13 @@ pub struct ProxyConfig {
|
|||||||
/// 是否正在接管 Live 配置
|
/// 是否正在接管 Live 配置
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub live_takeover_active: bool,
|
pub live_takeover_active: bool,
|
||||||
/// 流式首字超时(秒)- 等待首个数据块的最大时间
|
/// 流式首字超时(秒)- 等待首个数据块的最大时间,范围 1-120 秒,默认 60 秒
|
||||||
#[serde(default = "default_streaming_first_byte_timeout")]
|
#[serde(default = "default_streaming_first_byte_timeout")]
|
||||||
pub streaming_first_byte_timeout: u64,
|
pub streaming_first_byte_timeout: u64,
|
||||||
/// 流式静默超时(秒)- 两个数据块之间的最大间隔
|
/// 流式静默超时(秒)- 两个数据块之间的最大间隔,范围 60-600 秒,填 0 禁用(防止中途卡住)
|
||||||
#[serde(default = "default_streaming_idle_timeout")]
|
#[serde(default = "default_streaming_idle_timeout")]
|
||||||
pub streaming_idle_timeout: u64,
|
pub streaming_idle_timeout: u64,
|
||||||
/// 非流式总超时(秒)- 非流式请求的总超时时间
|
/// 非流式总超时(秒)- 非流式请求的总超时时间,范围 60-1200 秒,默认 600 秒(10 分钟)
|
||||||
#[serde(default = "default_non_streaming_timeout")]
|
#[serde(default = "default_non_streaming_timeout")]
|
||||||
pub non_streaming_timeout: u64,
|
pub non_streaming_timeout: u64,
|
||||||
}
|
}
|
||||||
@@ -191,3 +191,175 @@ pub struct AppProxyConfig {
|
|||||||
/// 计算错误率的最小请求数
|
/// 计算错误率的最小请求数
|
||||||
pub circuit_min_requests: u32,
|
pub circuit_min_requests: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 整流器配置
|
||||||
|
///
|
||||||
|
/// 存储在 settings 表中
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RectifierConfig {
|
||||||
|
/// 总开关:是否启用整流器
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
|
/// 请求整流:启用 thinking 签名整流器
|
||||||
|
///
|
||||||
|
/// 处理错误:Invalid 'signature' in 'thinking' block
|
||||||
|
#[serde(default)]
|
||||||
|
pub request_thinking_signature: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_log_level() -> String {
|
||||||
|
"info".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 日志配置
|
||||||
|
///
|
||||||
|
/// 存储在 settings 表的 log_config 字段中(JSON 格式)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct LogConfig {
|
||||||
|
/// 总开关:是否启用日志
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub enabled: bool,
|
||||||
|
/// 日志级别: error, warn, info, debug, trace
|
||||||
|
#[serde(default = "default_log_level")]
|
||||||
|
pub level: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LogConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: true,
|
||||||
|
level: "info".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogConfig {
|
||||||
|
/// 将配置转换为 log::LevelFilter
|
||||||
|
pub fn to_level_filter(&self) -> log::LevelFilter {
|
||||||
|
if !self.enabled {
|
||||||
|
return log::LevelFilter::Off;
|
||||||
|
}
|
||||||
|
match self.level.to_lowercase().as_str() {
|
||||||
|
"error" => log::LevelFilter::Error,
|
||||||
|
"warn" => log::LevelFilter::Warn,
|
||||||
|
"info" => log::LevelFilter::Info,
|
||||||
|
"debug" => log::LevelFilter::Debug,
|
||||||
|
"trace" => log::LevelFilter::Trace,
|
||||||
|
_ => log::LevelFilter::Info,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rectifier_config_default_disabled() {
|
||||||
|
// 验证 RectifierConfig::default() 返回全禁用状态
|
||||||
|
let config = RectifierConfig::default();
|
||||||
|
assert!(!config.enabled, "整流器总开关默认应为 false");
|
||||||
|
assert!(
|
||||||
|
!config.request_thinking_signature,
|
||||||
|
"thinking 签名整流器默认应为 false"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rectifier_config_serde_default() {
|
||||||
|
// 验证反序列化缺字段时使用默认值 false
|
||||||
|
let json = "{}";
|
||||||
|
let config: RectifierConfig = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(!config.enabled);
|
||||||
|
assert!(!config.request_thinking_signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rectifier_config_serde_explicit_true() {
|
||||||
|
// 验证显式设置 true 时正确反序列化
|
||||||
|
let json = r#"{"enabled": true, "requestThinkingSignature": true}"#;
|
||||||
|
let config: RectifierConfig = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(config.enabled);
|
||||||
|
assert!(config.request_thinking_signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_log_config_default() {
|
||||||
|
let config = LogConfig::default();
|
||||||
|
assert!(config.enabled);
|
||||||
|
assert_eq!(config.level, "info");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_log_config_serde_default() {
|
||||||
|
let json = "{}";
|
||||||
|
let config: LogConfig = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(config.enabled);
|
||||||
|
assert_eq!(config.level, "info");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_log_config_to_level_filter() {
|
||||||
|
let config = LogConfig {
|
||||||
|
level: "error".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(config.to_level_filter(), log::LevelFilter::Error);
|
||||||
|
|
||||||
|
let config = LogConfig {
|
||||||
|
level: "warn".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(config.to_level_filter(), log::LevelFilter::Warn);
|
||||||
|
|
||||||
|
let config = LogConfig {
|
||||||
|
level: "info".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(config.to_level_filter(), log::LevelFilter::Info);
|
||||||
|
|
||||||
|
let config = LogConfig {
|
||||||
|
level: "debug".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(config.to_level_filter(), log::LevelFilter::Debug);
|
||||||
|
|
||||||
|
let config = LogConfig {
|
||||||
|
level: "trace".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(config.to_level_filter(), log::LevelFilter::Trace);
|
||||||
|
|
||||||
|
// 无效级别回退到 info
|
||||||
|
let config = LogConfig {
|
||||||
|
level: "invalid".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(config.to_level_filter(), log::LevelFilter::Info);
|
||||||
|
|
||||||
|
// 禁用时返回 Off
|
||||||
|
let config = LogConfig {
|
||||||
|
enabled: false,
|
||||||
|
level: "debug".to_string(),
|
||||||
|
};
|
||||||
|
assert_eq!(config.to_level_filter(), log::LevelFilter::Off);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_log_config_serde_roundtrip() {
|
||||||
|
let config = LogConfig {
|
||||||
|
enabled: true,
|
||||||
|
level: "debug".to_string(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
|
let parsed: LogConfig = serde_json::from_str(&json).unwrap();
|
||||||
|
assert!(parsed.enabled);
|
||||||
|
assert_eq!(parsed.level, "debug");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -122,6 +122,10 @@ impl ConfigService {
|
|||||||
AppType::Codex => Self::sync_codex_live(config, ¤t_id, &provider)?,
|
AppType::Codex => Self::sync_codex_live(config, ¤t_id, &provider)?,
|
||||||
AppType::Claude => Self::sync_claude_live(config, ¤t_id, &provider)?,
|
AppType::Claude => Self::sync_claude_live(config, ¤t_id, &provider)?,
|
||||||
AppType::Gemini => Self::sync_gemini_live(config, ¤t_id, &provider)?,
|
AppType::Gemini => Self::sync_gemini_live(config, ¤t_id, &provider)?,
|
||||||
|
AppType::OpenCode => {
|
||||||
|
// OpenCode uses additive mode, no live sync needed
|
||||||
|
// OpenCode providers are managed directly in the config file
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ impl McpService {
|
|||||||
if prev_apps.gemini && !server.apps.gemini {
|
if prev_apps.gemini && !server.apps.gemini {
|
||||||
Self::remove_server_from_app(state, &server.id, &AppType::Gemini)?;
|
Self::remove_server_from_app(state, &server.id, &AppType::Gemini)?;
|
||||||
}
|
}
|
||||||
|
if prev_apps.opencode && !server.apps.opencode {
|
||||||
|
Self::remove_server_from_app(state, &server.id, &AppType::OpenCode)?;
|
||||||
|
}
|
||||||
|
|
||||||
// 同步到各个启用的应用
|
// 同步到各个启用的应用
|
||||||
Self::sync_server_to_apps(state, &server)?;
|
Self::sync_server_to_apps(state, &server)?;
|
||||||
@@ -113,6 +116,13 @@ impl McpService {
|
|||||||
AppType::Gemini => {
|
AppType::Gemini => {
|
||||||
mcp::sync_single_server_to_gemini(&Default::default(), &server.id, &server.server)?;
|
mcp::sync_single_server_to_gemini(&Default::default(), &server.id, &server.server)?;
|
||||||
}
|
}
|
||||||
|
AppType::OpenCode => {
|
||||||
|
mcp::sync_single_server_to_opencode(
|
||||||
|
&Default::default(),
|
||||||
|
&server.id,
|
||||||
|
&server.server,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -135,6 +145,9 @@ impl McpService {
|
|||||||
AppType::Claude => mcp::remove_server_from_claude(id)?,
|
AppType::Claude => mcp::remove_server_from_claude(id)?,
|
||||||
AppType::Codex => mcp::remove_server_from_codex(id)?,
|
AppType::Codex => mcp::remove_server_from_codex(id)?,
|
||||||
AppType::Gemini => mcp::remove_server_from_gemini(id)?,
|
AppType::Gemini => mcp::remove_server_from_gemini(id)?,
|
||||||
|
AppType::OpenCode => {
|
||||||
|
mcp::remove_server_from_opencode(id)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -311,4 +324,42 @@ impl McpService {
|
|||||||
|
|
||||||
Ok(new_count)
|
Ok(new_count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 从 OpenCode 导入 MCP(v3.9.2+ 新增)
|
||||||
|
pub fn import_from_opencode(state: &AppState) -> Result<usize, AppError> {
|
||||||
|
// 创建临时 MultiAppConfig 用于导入
|
||||||
|
let mut temp_config = crate::app_config::MultiAppConfig::default();
|
||||||
|
|
||||||
|
// 调用原有的导入逻辑(从 mcp/opencode.rs)
|
||||||
|
let count = crate::mcp::import_from_opencode(&mut temp_config)?;
|
||||||
|
|
||||||
|
let mut new_count = 0;
|
||||||
|
|
||||||
|
// 如果有导入的服务器,保存到数据库
|
||||||
|
if count > 0 {
|
||||||
|
if let Some(servers) = &temp_config.mcp.servers {
|
||||||
|
let mut existing = state.db.get_all_mcp_servers()?;
|
||||||
|
for server in servers.values() {
|
||||||
|
// 已存在:仅启用 OpenCode,不覆盖其他字段(与导入模块语义保持一致)
|
||||||
|
let to_save = if let Some(existing_server) = existing.get(&server.id) {
|
||||||
|
let mut merged = existing_server.clone();
|
||||||
|
merged.apps.opencode = true;
|
||||||
|
merged
|
||||||
|
} else {
|
||||||
|
// 真正的新服务器
|
||||||
|
new_count += 1;
|
||||||
|
server.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
state.db.save_mcp_server(&to_save)?;
|
||||||
|
existing.insert(to_save.id.clone(), to_save.clone());
|
||||||
|
|
||||||
|
// 同步到对应应用 live 配置
|
||||||
|
Self::sync_server_to_apps(state, &to_save)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(new_count)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,10 +36,22 @@ impl PromptService {
|
|||||||
|
|
||||||
state.db.save_prompt(app.as_str(), &prompt)?;
|
state.db.save_prompt(app.as_str(), &prompt)?;
|
||||||
|
|
||||||
// 如果是已启用的提示词,同步更新到对应的文件
|
|
||||||
if is_enabled {
|
if is_enabled {
|
||||||
|
// 启用提示词:写入内容到文件
|
||||||
let target_path = prompt_file_path(&app)?;
|
let target_path = prompt_file_path(&app)?;
|
||||||
write_text_file(&target_path, &prompt.content)?;
|
write_text_file(&target_path, &prompt.content)?;
|
||||||
|
} else {
|
||||||
|
// 禁用提示词:检查是否还有其他已启用的提示词
|
||||||
|
let prompts = state.db.get_prompts(app.as_str())?;
|
||||||
|
let any_enabled = prompts.values().any(|p| p.enabled);
|
||||||
|
|
||||||
|
if !any_enabled {
|
||||||
|
// 所有提示词都已禁用,清空文件
|
||||||
|
let target_path = prompt_file_path(&app)?;
|
||||||
|
if target_path.exists() {
|
||||||
|
write_text_file(&target_path, "")?;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -120,6 +120,64 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
|||||||
// Delegate to write_gemini_live which handles env file writing correctly
|
// Delegate to write_gemini_live which handles env file writing correctly
|
||||||
write_gemini_live(provider)?;
|
write_gemini_live(provider)?;
|
||||||
}
|
}
|
||||||
|
AppType::OpenCode => {
|
||||||
|
// OpenCode uses additive mode - write provider to config
|
||||||
|
use crate::opencode_config;
|
||||||
|
use crate::provider::OpenCodeProviderConfig;
|
||||||
|
|
||||||
|
// Defensive check: if settings_config is a full config structure, extract provider fragment
|
||||||
|
let config_to_write = if let Some(obj) = provider.settings_config.as_object() {
|
||||||
|
// Detect full config structure (has $schema or top-level provider field)
|
||||||
|
if obj.contains_key("$schema") || obj.contains_key("provider") {
|
||||||
|
log::warn!(
|
||||||
|
"OpenCode provider '{}' has full config structure in settings_config, attempting to extract fragment",
|
||||||
|
provider.id
|
||||||
|
);
|
||||||
|
// Try to extract from provider.{id}
|
||||||
|
obj.get("provider")
|
||||||
|
.and_then(|p| p.get(&provider.id))
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| provider.settings_config.clone())
|
||||||
|
} else {
|
||||||
|
provider.settings_config.clone()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
provider.settings_config.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert settings_config to OpenCodeProviderConfig
|
||||||
|
let opencode_config_result =
|
||||||
|
serde_json::from_value::<OpenCodeProviderConfig>(config_to_write.clone());
|
||||||
|
|
||||||
|
match opencode_config_result {
|
||||||
|
Ok(config) => {
|
||||||
|
opencode_config::set_typed_provider(&provider.id, &config)?;
|
||||||
|
log::info!("OpenCode provider '{}' written to live config", provider.id);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"Failed to parse OpenCode provider config for '{}': {}",
|
||||||
|
provider.id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
// Only write if config looks like a valid provider fragment
|
||||||
|
if config_to_write.get("npm").is_some()
|
||||||
|
|| config_to_write.get("options").is_some()
|
||||||
|
{
|
||||||
|
opencode_config::set_provider(&provider.id, config_to_write)?;
|
||||||
|
log::info!(
|
||||||
|
"OpenCode provider '{}' written as raw JSON to live config",
|
||||||
|
provider.id
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log::error!(
|
||||||
|
"OpenCode provider '{}' has invalid config structure, skipping write",
|
||||||
|
provider.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -220,6 +278,21 @@ pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
|
|||||||
"config": config_obj
|
"config": config_obj
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
AppType::OpenCode => {
|
||||||
|
use crate::opencode_config::{get_opencode_config_path, read_opencode_config};
|
||||||
|
|
||||||
|
let config_path = get_opencode_config_path();
|
||||||
|
if !config_path.exists() {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"opencode.config.missing",
|
||||||
|
"OpenCode 配置文件不存在",
|
||||||
|
"OpenCode configuration file not found",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = read_opencode_config()?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,6 +368,24 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
|
|||||||
"config": config_obj
|
"config": config_obj
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
AppType::OpenCode => {
|
||||||
|
// OpenCode uses additive mode - import from live is not the same pattern
|
||||||
|
// For now, return an empty config structure
|
||||||
|
use crate::opencode_config::{get_opencode_config_path, read_opencode_config};
|
||||||
|
|
||||||
|
let config_path = get_opencode_config_path();
|
||||||
|
if !config_path.exists() {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"opencode.live.missing",
|
||||||
|
"OpenCode 配置文件不存在",
|
||||||
|
"OpenCode configuration file is missing",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// For OpenCode, we return the full config - but note that OpenCode
|
||||||
|
// uses additive mode, so importing defaults works differently
|
||||||
|
read_opencode_config()?
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut provider = Provider::with_id(
|
let mut provider = Provider::with_id(
|
||||||
@@ -399,3 +490,75 @@ pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove an OpenCode provider from the live configuration
|
||||||
|
///
|
||||||
|
/// This is specific to OpenCode's additive mode - removing a provider
|
||||||
|
/// from the opencode.json file.
|
||||||
|
pub(crate) fn remove_opencode_provider_from_live(provider_id: &str) -> Result<(), AppError> {
|
||||||
|
use crate::opencode_config;
|
||||||
|
|
||||||
|
// Check if OpenCode config directory exists
|
||||||
|
if !opencode_config::get_opencode_dir().exists() {
|
||||||
|
log::debug!("OpenCode config directory doesn't exist, skipping removal of '{provider_id}'");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
opencode_config::remove_provider(provider_id)?;
|
||||||
|
log::info!("OpenCode provider '{provider_id}' removed from live config");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import all providers from OpenCode live config to database
|
||||||
|
///
|
||||||
|
/// This imports existing providers from ~/.config/opencode/opencode.json
|
||||||
|
/// into the CC Switch database. Each provider found will be added to the
|
||||||
|
/// database with is_current set to false.
|
||||||
|
pub fn import_opencode_providers_from_live(state: &AppState) -> Result<usize, AppError> {
|
||||||
|
use crate::opencode_config;
|
||||||
|
|
||||||
|
let providers = opencode_config::get_typed_providers()?;
|
||||||
|
if providers.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut imported = 0;
|
||||||
|
let existing = state.db.get_all_providers("opencode")?;
|
||||||
|
|
||||||
|
for (id, config) in providers {
|
||||||
|
// Skip if already exists in database
|
||||||
|
if existing.contains_key(&id) {
|
||||||
|
log::debug!("OpenCode provider '{id}' already exists in database, skipping");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to Value for settings_config
|
||||||
|
let settings_config = match serde_json::to_value(&config) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to serialize OpenCode provider '{id}': {e}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create provider
|
||||||
|
let provider = Provider::with_id(
|
||||||
|
id.clone(),
|
||||||
|
config.name.clone().unwrap_or_else(|| id.clone()),
|
||||||
|
settings_config,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save to database
|
||||||
|
if let Err(e) = state.db.save_provider("opencode", &provider) {
|
||||||
|
log::warn!("Failed to import OpenCode provider '{id}': {e}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
imported += 1;
|
||||||
|
log::info!("Imported OpenCode provider '{id}' from live config");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(imported)
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,13 +20,16 @@ use crate::settings::CustomEndpoint;
|
|||||||
use crate::store::AppState;
|
use crate::store::AppState;
|
||||||
|
|
||||||
// Re-export sub-module functions for external access
|
// Re-export sub-module functions for external access
|
||||||
pub use live::{import_default_config, read_live_settings, sync_current_to_live};
|
pub use live::{
|
||||||
|
import_default_config, import_opencode_providers_from_live, read_live_settings,
|
||||||
|
sync_current_to_live,
|
||||||
|
};
|
||||||
|
|
||||||
// Internal re-exports (pub(crate))
|
// Internal re-exports (pub(crate))
|
||||||
pub(crate) use live::write_live_snapshot;
|
pub(crate) use live::write_live_snapshot;
|
||||||
|
|
||||||
// Internal re-exports
|
// Internal re-exports
|
||||||
use live::write_gemini_live;
|
use live::{remove_opencode_provider_from_live, write_gemini_live};
|
||||||
use usage::validate_usage_script;
|
use usage::validate_usage_script;
|
||||||
|
|
||||||
/// Provider business logic service
|
/// Provider business logic service
|
||||||
@@ -137,7 +140,13 @@ impl ProviderService {
|
|||||||
/// 使用有效的当前供应商 ID(验证过存在性)。
|
/// 使用有效的当前供应商 ID(验证过存在性)。
|
||||||
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
|
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
|
||||||
/// 这确保了云同步场景下多设备可以独立选择供应商,且返回的 ID 一定有效。
|
/// 这确保了云同步场景下多设备可以独立选择供应商,且返回的 ID 一定有效。
|
||||||
|
///
|
||||||
|
/// 对于 OpenCode(累加模式),不存在"当前供应商"概念,直接返回空字符串。
|
||||||
pub fn current(state: &AppState, app_type: AppType) -> Result<String, AppError> {
|
pub fn current(state: &AppState, app_type: AppType) -> Result<String, AppError> {
|
||||||
|
// OpenCode uses additive mode - no "current" provider concept
|
||||||
|
if matches!(app_type, AppType::OpenCode) {
|
||||||
|
return Ok(String::new());
|
||||||
|
}
|
||||||
crate::settings::get_effective_current_provider(&state.db, &app_type)
|
crate::settings::get_effective_current_provider(&state.db, &app_type)
|
||||||
.map(|opt| opt.unwrap_or_default())
|
.map(|opt| opt.unwrap_or_default())
|
||||||
}
|
}
|
||||||
@@ -152,7 +161,13 @@ impl ProviderService {
|
|||||||
// Save to database
|
// Save to database
|
||||||
state.db.save_provider(app_type.as_str(), &provider)?;
|
state.db.save_provider(app_type.as_str(), &provider)?;
|
||||||
|
|
||||||
// Check if sync is needed (if this is current provider, or no current provider)
|
// OpenCode uses additive mode - always write to live config
|
||||||
|
if matches!(app_type, AppType::OpenCode) {
|
||||||
|
write_live_snapshot(&app_type, &provider)?;
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other apps: Check if sync is needed (if this is current provider, or no current provider)
|
||||||
let current = state.db.get_current_provider(app_type.as_str())?;
|
let current = state.db.get_current_provider(app_type.as_str())?;
|
||||||
if current.is_none() {
|
if current.is_none() {
|
||||||
// No current provider, set as current and sync
|
// No current provider, set as current and sync
|
||||||
@@ -176,14 +191,20 @@ impl ProviderService {
|
|||||||
Self::normalize_provider_if_claude(&app_type, &mut provider);
|
Self::normalize_provider_if_claude(&app_type, &mut provider);
|
||||||
Self::validate_provider_settings(&app_type, &provider)?;
|
Self::validate_provider_settings(&app_type, &provider)?;
|
||||||
|
|
||||||
// Check if this is current provider (use effective current, not just DB)
|
// Save to database
|
||||||
|
state.db.save_provider(app_type.as_str(), &provider)?;
|
||||||
|
|
||||||
|
// OpenCode uses additive mode - always update in live config
|
||||||
|
if matches!(app_type, AppType::OpenCode) {
|
||||||
|
write_live_snapshot(&app_type, &provider)?;
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other apps: Check if this is current provider (use effective current, not just DB)
|
||||||
let effective_current =
|
let effective_current =
|
||||||
crate::settings::get_effective_current_provider(&state.db, &app_type)?;
|
crate::settings::get_effective_current_provider(&state.db, &app_type)?;
|
||||||
let is_current = effective_current.as_deref() == Some(provider.id.as_str());
|
let is_current = effective_current.as_deref() == Some(provider.id.as_str());
|
||||||
|
|
||||||
// Save to database
|
|
||||||
state.db.save_provider(app_type.as_str(), &provider)?;
|
|
||||||
|
|
||||||
if is_current {
|
if is_current {
|
||||||
// 如果代理接管模式处于激活状态,并且代理服务正在运行:
|
// 如果代理接管模式处于激活状态,并且代理服务正在运行:
|
||||||
// - 不写 Live 配置(否则会破坏接管)
|
// - 不写 Live 配置(否则会破坏接管)
|
||||||
@@ -216,8 +237,18 @@ impl ProviderService {
|
|||||||
/// Delete a provider
|
/// Delete a provider
|
||||||
///
|
///
|
||||||
/// 同时检查本地 settings 和数据库的当前供应商,防止删除任一端正在使用的供应商。
|
/// 同时检查本地 settings 和数据库的当前供应商,防止删除任一端正在使用的供应商。
|
||||||
|
/// 对于 OpenCode(累加模式),可以随时删除任意供应商,同时从 live 配置中移除。
|
||||||
pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
||||||
// Check both local settings and database
|
// OpenCode uses additive mode - no current provider concept
|
||||||
|
if matches!(app_type, AppType::OpenCode) {
|
||||||
|
// Remove from database
|
||||||
|
state.db.delete_provider(app_type.as_str(), id)?;
|
||||||
|
// Also remove from live config
|
||||||
|
remove_opencode_provider_from_live(id)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other apps: Check both local settings and database
|
||||||
let local_current = crate::settings::get_current_provider(&app_type);
|
let local_current = crate::settings::get_current_provider(&app_type);
|
||||||
let db_current = state.db.get_current_provider(app_type.as_str())?;
|
let db_current = state.db.get_current_provider(app_type.as_str())?;
|
||||||
|
|
||||||
@@ -230,6 +261,27 @@ impl ProviderService {
|
|||||||
state.db.delete_provider(app_type.as_str(), id)
|
state.db.delete_provider(app_type.as_str(), id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove provider from live config only (for additive mode apps like OpenCode)
|
||||||
|
///
|
||||||
|
/// Does NOT delete from database - provider remains in the list.
|
||||||
|
/// This is used when user wants to "remove" a provider from active config
|
||||||
|
/// but keep it available for future use.
|
||||||
|
pub fn remove_from_live_config(app_type: AppType, id: &str) -> Result<(), AppError> {
|
||||||
|
match app_type {
|
||||||
|
AppType::OpenCode => {
|
||||||
|
remove_opencode_provider_from_live(id)?;
|
||||||
|
}
|
||||||
|
// Future: add other additive mode apps here
|
||||||
|
_ => {
|
||||||
|
return Err(AppError::Message(format!(
|
||||||
|
"App {} does not support remove from live config",
|
||||||
|
app_type.as_str()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Switch to a provider
|
/// Switch to a provider
|
||||||
///
|
///
|
||||||
/// Switch flow:
|
/// Switch flow:
|
||||||
@@ -326,22 +378,29 @@ impl ProviderService {
|
|||||||
|
|
||||||
if let Some(current_id) = current_id {
|
if let Some(current_id) = current_id {
|
||||||
if current_id != id {
|
if current_id != id {
|
||||||
// Only backfill when switching to a different provider
|
// OpenCode uses additive mode - all providers coexist in the same file,
|
||||||
if let Ok(live_config) = read_live_settings(app_type.clone()) {
|
// no backfill needed (backfill is for exclusive mode apps like Claude/Codex/Gemini)
|
||||||
if let Some(mut current_provider) = providers.get(¤t_id).cloned() {
|
if !matches!(app_type, AppType::OpenCode) {
|
||||||
current_provider.settings_config = live_config;
|
// Only backfill when switching to a different provider
|
||||||
// Ignore backfill failure, don't affect switch flow
|
if let Ok(live_config) = read_live_settings(app_type.clone()) {
|
||||||
let _ = state.db.save_provider(app_type.as_str(), ¤t_provider);
|
if let Some(mut current_provider) = providers.get(¤t_id).cloned() {
|
||||||
|
current_provider.settings_config = live_config;
|
||||||
|
// Ignore backfill failure, don't affect switch flow
|
||||||
|
let _ = state.db.save_provider(app_type.as_str(), ¤t_provider);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update local settings (device-level, takes priority)
|
// OpenCode uses additive mode - skip setting is_current (no such concept)
|
||||||
crate::settings::set_current_provider(&app_type, Some(id))?;
|
if !matches!(app_type, AppType::OpenCode) {
|
||||||
|
// Update local settings (device-level, takes priority)
|
||||||
|
crate::settings::set_current_provider(&app_type, Some(id))?;
|
||||||
|
|
||||||
// Update database is_current (as default for new devices)
|
// Update database is_current (as default for new devices)
|
||||||
state.db.set_current_provider(app_type.as_str(), id)?;
|
state.db.set_current_provider(app_type.as_str(), id)?;
|
||||||
|
}
|
||||||
|
|
||||||
// Sync to live (write_gemini_live handles security flag internally for Gemini)
|
// Sync to live (write_gemini_live handles security flag internally for Gemini)
|
||||||
write_live_snapshot(&app_type, provider)?;
|
write_live_snapshot(&app_type, provider)?;
|
||||||
@@ -380,6 +439,7 @@ impl ProviderService {
|
|||||||
AppType::Claude => Self::extract_claude_common_config(&provider.settings_config),
|
AppType::Claude => Self::extract_claude_common_config(&provider.settings_config),
|
||||||
AppType::Codex => Self::extract_codex_common_config(&provider.settings_config),
|
AppType::Codex => Self::extract_codex_common_config(&provider.settings_config),
|
||||||
AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config),
|
AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config),
|
||||||
|
AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,6 +452,7 @@ impl ProviderService {
|
|||||||
AppType::Claude => Self::extract_claude_common_config(settings_config),
|
AppType::Claude => Self::extract_claude_common_config(settings_config),
|
||||||
AppType::Codex => Self::extract_codex_common_config(settings_config),
|
AppType::Codex => Self::extract_codex_common_config(settings_config),
|
||||||
AppType::Gemini => Self::extract_gemini_common_config(settings_config),
|
AppType::Gemini => Self::extract_gemini_common_config(settings_config),
|
||||||
|
AppType::OpenCode => Self::extract_opencode_common_config(settings_config),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,6 +586,29 @@ impl ProviderService {
|
|||||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract common config for OpenCode (JSON format)
|
||||||
|
fn extract_opencode_common_config(settings: &Value) -> Result<String, AppError> {
|
||||||
|
// OpenCode uses a different config structure with npm, options, models
|
||||||
|
// For common config, we exclude provider-specific fields like apiKey
|
||||||
|
let mut config = settings.clone();
|
||||||
|
|
||||||
|
// Remove provider-specific fields
|
||||||
|
if let Some(obj) = config.as_object_mut() {
|
||||||
|
if let Some(options) = obj.get_mut("options").and_then(|v| v.as_object_mut()) {
|
||||||
|
options.remove("apiKey");
|
||||||
|
options.remove("baseURL");
|
||||||
|
}
|
||||||
|
// Keep npm and models as they might be common
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) {
|
||||||
|
return Ok("{}".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
serde_json::to_string_pretty(&config)
|
||||||
|
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
/// Import default configuration from live files (re-export)
|
/// Import default configuration from live files (re-export)
|
||||||
///
|
///
|
||||||
/// Returns `Ok(true)` if imported, `Ok(false)` if skipped.
|
/// Returns `Ok(true)` if imported, `Ok(false)` if skipped.
|
||||||
@@ -615,6 +699,7 @@ impl ProviderService {
|
|||||||
base_url: Option<&str>,
|
base_url: Option<&str>,
|
||||||
access_token: Option<&str>,
|
access_token: Option<&str>,
|
||||||
user_id: Option<&str>,
|
user_id: Option<&str>,
|
||||||
|
template_type: Option<&str>,
|
||||||
) -> Result<UsageResult, AppError> {
|
) -> Result<UsageResult, AppError> {
|
||||||
usage::test_usage_script(
|
usage::test_usage_script(
|
||||||
state,
|
state,
|
||||||
@@ -626,6 +711,7 @@ impl ProviderService {
|
|||||||
base_url,
|
base_url,
|
||||||
access_token,
|
access_token,
|
||||||
user_id,
|
user_id,
|
||||||
|
template_type,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -689,6 +775,17 @@ impl ProviderService {
|
|||||||
use crate::gemini_config::validate_gemini_settings;
|
use crate::gemini_config::validate_gemini_settings;
|
||||||
validate_gemini_settings(&provider.settings_config)?
|
validate_gemini_settings(&provider.settings_config)?
|
||||||
}
|
}
|
||||||
|
AppType::OpenCode => {
|
||||||
|
// OpenCode uses a different config structure: { npm, options, models }
|
||||||
|
// Basic validation - must be an object
|
||||||
|
if !provider.settings_config.is_object() {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"provider.opencode.settings.not_object",
|
||||||
|
"OpenCode 配置必须是 JSON 对象",
|
||||||
|
"OpenCode configuration must be a JSON object",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate and clean UsageScript configuration (common for all app types)
|
// Validate and clean UsageScript configuration (common for all app types)
|
||||||
@@ -826,6 +923,40 @@ impl ProviderService {
|
|||||||
|
|
||||||
Ok((api_key, base_url))
|
Ok((api_key, base_url))
|
||||||
}
|
}
|
||||||
|
AppType::OpenCode => {
|
||||||
|
// OpenCode uses options.apiKey and options.baseURL
|
||||||
|
let options = provider
|
||||||
|
.settings_config
|
||||||
|
.get("options")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AppError::localized(
|
||||||
|
"provider.opencode.options.missing",
|
||||||
|
"配置格式错误: 缺少 options",
|
||||||
|
"Invalid configuration: missing options section",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let api_key = options
|
||||||
|
.get("apiKey")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AppError::localized(
|
||||||
|
"provider.opencode.api_key.missing",
|
||||||
|
"缺少 API Key",
|
||||||
|
"API key is missing",
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let base_url = options
|
||||||
|
.get("baseURL")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
Ok((api_key, base_url))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ pub(crate) async fn execute_and_format_usage_result(
|
|||||||
timeout: u64,
|
timeout: u64,
|
||||||
access_token: Option<&str>,
|
access_token: Option<&str>,
|
||||||
user_id: Option<&str>,
|
user_id: Option<&str>,
|
||||||
|
template_type: Option<&str>,
|
||||||
) -> Result<UsageResult, AppError> {
|
) -> Result<UsageResult, AppError> {
|
||||||
match usage_script::execute_usage_script(
|
match usage_script::execute_usage_script(
|
||||||
script_code,
|
script_code,
|
||||||
@@ -25,6 +26,7 @@ pub(crate) async fn execute_and_format_usage_result(
|
|||||||
timeout,
|
timeout,
|
||||||
access_token,
|
access_token,
|
||||||
user_id,
|
user_id,
|
||||||
|
template_type,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -113,7 +115,7 @@ pub async fn query_usage(
|
|||||||
app_type: AppType,
|
app_type: AppType,
|
||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
) -> Result<UsageResult, AppError> {
|
) -> Result<UsageResult, AppError> {
|
||||||
let (script_code, timeout, api_key, base_url, access_token, user_id) = {
|
let (script_code, timeout, api_key, base_url, access_token, user_id, template_type) = {
|
||||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||||
let provider = providers.get(provider_id).ok_or_else(|| {
|
let provider = providers.get(provider_id).ok_or_else(|| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
@@ -164,6 +166,7 @@ pub async fn query_usage(
|
|||||||
base_url,
|
base_url,
|
||||||
usage_script.access_token.clone(),
|
usage_script.access_token.clone(),
|
||||||
usage_script.user_id.clone(),
|
usage_script.user_id.clone(),
|
||||||
|
usage_script.template_type.clone(),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -174,6 +177,7 @@ pub async fn query_usage(
|
|||||||
timeout,
|
timeout,
|
||||||
access_token.as_deref(),
|
access_token.as_deref(),
|
||||||
user_id.as_deref(),
|
user_id.as_deref(),
|
||||||
|
template_type.as_deref(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -190,6 +194,7 @@ pub async fn test_usage_script(
|
|||||||
base_url: Option<&str>,
|
base_url: Option<&str>,
|
||||||
access_token: Option<&str>,
|
access_token: Option<&str>,
|
||||||
user_id: Option<&str>,
|
user_id: Option<&str>,
|
||||||
|
template_type: Option<&str>,
|
||||||
) -> Result<UsageResult, AppError> {
|
) -> Result<UsageResult, AppError> {
|
||||||
// Use provided credential parameters directly for testing
|
// Use provided credential parameters directly for testing
|
||||||
execute_and_format_usage_result(
|
execute_and_format_usage_result(
|
||||||
@@ -199,6 +204,7 @@ pub async fn test_usage_script(
|
|||||||
timeout,
|
timeout,
|
||||||
access_token,
|
access_token,
|
||||||
user_id,
|
user_id,
|
||||||
|
template_type,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -368,6 +368,10 @@ impl ProxyService {
|
|||||||
AppType::Claude => self.read_claude_live()?,
|
AppType::Claude => self.read_claude_live()?,
|
||||||
AppType::Codex => self.read_codex_live()?,
|
AppType::Codex => self.read_codex_live()?,
|
||||||
AppType::Gemini => self.read_gemini_live()?,
|
AppType::Gemini => self.read_gemini_live()?,
|
||||||
|
AppType::OpenCode => {
|
||||||
|
// OpenCode doesn't support proxy features
|
||||||
|
return Err("OpenCode 不支持代理功能".to_string());
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
self.sync_live_config_to_provider(app_type, &live_config)
|
self.sync_live_config_to_provider(app_type, &live_config)
|
||||||
@@ -581,6 +585,9 @@ impl ProxyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AppType::OpenCode => {
|
||||||
|
// OpenCode doesn't support proxy features, skip silently
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -759,6 +766,10 @@ impl ProxyService {
|
|||||||
AppType::Claude => ("claude", self.read_claude_live()?),
|
AppType::Claude => ("claude", self.read_claude_live()?),
|
||||||
AppType::Codex => ("codex", self.read_codex_live()?),
|
AppType::Codex => ("codex", self.read_codex_live()?),
|
||||||
AppType::Gemini => ("gemini", self.read_gemini_live()?),
|
AppType::Gemini => ("gemini", self.read_gemini_live()?),
|
||||||
|
AppType::OpenCode => {
|
||||||
|
// OpenCode doesn't support proxy features
|
||||||
|
return Err("OpenCode 不支持代理功能".to_string());
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let json_str = serde_json::to_string(&config)
|
let json_str = serde_json::to_string(&config)
|
||||||
@@ -967,6 +978,10 @@ impl ProxyService {
|
|||||||
self.write_gemini_live(&live_config)?;
|
self.write_gemini_live(&live_config)?;
|
||||||
log::info!("Gemini Live 配置已接管,代理地址: {proxy_url}");
|
log::info!("Gemini Live 配置已接管,代理地址: {proxy_url}");
|
||||||
}
|
}
|
||||||
|
AppType::OpenCode => {
|
||||||
|
// OpenCode doesn't support proxy features
|
||||||
|
return Err("OpenCode 不支持代理功能".to_string());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1050,6 +1065,9 @@ impl ProxyService {
|
|||||||
let _ = self.write_gemini_live(&live_config);
|
let _ = self.write_gemini_live(&live_config);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AppType::OpenCode => {
|
||||||
|
// OpenCode doesn't support proxy features, skip silently
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1082,6 +1100,9 @@ impl ProxyService {
|
|||||||
log::info!("Gemini Live 配置已恢复");
|
log::info!("Gemini Live 配置已恢复");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AppType::OpenCode => {
|
||||||
|
// OpenCode doesn't support proxy features, skip silently
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1161,6 +1182,10 @@ impl ProxyService {
|
|||||||
AppType::Claude => self.write_claude_live(config),
|
AppType::Claude => self.write_claude_live(config),
|
||||||
AppType::Codex => self.write_codex_live(config),
|
AppType::Codex => self.write_codex_live(config),
|
||||||
AppType::Gemini => self.write_gemini_live(config),
|
AppType::Gemini => self.write_gemini_live(config),
|
||||||
|
AppType::OpenCode => {
|
||||||
|
// OpenCode doesn't support proxy features
|
||||||
|
Err("OpenCode 不支持代理功能".to_string())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1178,6 +1203,10 @@ impl ProxyService {
|
|||||||
Ok(config) => Self::is_gemini_live_taken_over(&config),
|
Ok(config) => Self::is_gemini_live_taken_over(&config),
|
||||||
Err(_) => false,
|
Err(_) => false,
|
||||||
},
|
},
|
||||||
|
AppType::OpenCode => {
|
||||||
|
// OpenCode doesn't support proxy takeover
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1217,6 +1246,10 @@ impl ProxyService {
|
|||||||
AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),
|
AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),
|
||||||
AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),
|
AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),
|
||||||
AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),
|
AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),
|
||||||
|
AppType::OpenCode => {
|
||||||
|
// OpenCode doesn't support proxy features
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1497,7 +1530,10 @@ impl ProxyService {
|
|||||||
app_type: &str,
|
app_type: &str,
|
||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// 更新数据库中的 is_current 标记
|
// 代理模式切换供应商(热切换):
|
||||||
|
// - 更新 SSOT(数据库 is_current)
|
||||||
|
// - 同步本地 settings(设备级 current_provider_*)
|
||||||
|
// - 若该应用正处于接管模式,则同步更新 Live 备份(用于停止代理时恢复)
|
||||||
let app_type_enum =
|
let app_type_enum =
|
||||||
AppType::from_str(app_type).map_err(|_| format!("无效的应用类型: {app_type}"))?;
|
AppType::from_str(app_type).map_err(|_| format!("无效的应用类型: {app_type}"))?;
|
||||||
|
|
||||||
@@ -1505,6 +1541,35 @@ impl ProxyService {
|
|||||||
.set_current_provider(app_type_enum.as_str(), provider_id)
|
.set_current_provider(app_type_enum.as_str(), provider_id)
|
||||||
.map_err(|e| format!("更新当前供应商失败: {e}"))?;
|
.map_err(|e| format!("更新当前供应商失败: {e}"))?;
|
||||||
|
|
||||||
|
// 同步本地 settings(设备级优先)
|
||||||
|
crate::settings::set_current_provider(&app_type_enum, Some(provider_id))
|
||||||
|
.map_err(|e| format!("更新本地当前供应商失败: {e}"))?;
|
||||||
|
|
||||||
|
// 仅在确实处于接管状态时才更新 Live 备份,避免无接管时误写覆盖 Live
|
||||||
|
let has_backup = self
|
||||||
|
.db
|
||||||
|
.get_live_backup(app_type_enum.as_str())
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.is_some();
|
||||||
|
let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);
|
||||||
|
|
||||||
|
if let Ok(Some(provider)) = self.db.get_provider_by_id(provider_id, app_type) {
|
||||||
|
// 同步更新 Live 备份(用于 stop_with_restore 恢复)
|
||||||
|
if has_backup || live_taken_over {
|
||||||
|
self.update_live_backup_from_provider(app_type, &provider)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同步更新 ProxyStatus.active_targets(用于 UI 立即反映切换目标)
|
||||||
|
if let Some(server) = self.server.read().await.as_ref() {
|
||||||
|
server
|
||||||
|
.set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
log::info!("代理模式:已切换 {app_type} 的目标供应商为 {provider_id}");
|
log::info!("代理模式:已切换 {app_type} 的目标供应商为 {provider_id}");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -2025,4 +2090,66 @@ model = "gpt-5.1-codex"
|
|||||||
"should not add ANTHROPIC_AUTH_TOKEN when absent"
|
"should not add ANTHROPIC_AUTH_TOKEN when absent"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn switch_proxy_target_updates_live_backup_when_taken_over() {
|
||||||
|
let _home = TempHome::new();
|
||||||
|
crate::settings::reload_settings().expect("reload settings");
|
||||||
|
|
||||||
|
let db = Arc::new(Database::memory().expect("init db"));
|
||||||
|
let service = ProxyService::new(db.clone());
|
||||||
|
|
||||||
|
let provider_a = Provider::with_id(
|
||||||
|
"a".to_string(),
|
||||||
|
"A".to_string(),
|
||||||
|
json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_API_KEY": "a-key"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let provider_b = Provider::with_id(
|
||||||
|
"b".to_string(),
|
||||||
|
"B".to_string(),
|
||||||
|
json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_API_KEY": "b-key"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
db.save_provider("claude", &provider_a)
|
||||||
|
.expect("save provider a");
|
||||||
|
db.save_provider("claude", &provider_b)
|
||||||
|
.expect("save provider b");
|
||||||
|
db.set_current_provider("claude", "a")
|
||||||
|
.expect("set current provider");
|
||||||
|
|
||||||
|
// 模拟“已接管”状态:存在 Live 备份(内容不重要,会被热切换更新)
|
||||||
|
db.save_live_backup("claude", "{\"env\":{}}")
|
||||||
|
.await
|
||||||
|
.expect("seed live backup");
|
||||||
|
|
||||||
|
service
|
||||||
|
.switch_proxy_target("claude", "b")
|
||||||
|
.await
|
||||||
|
.expect("switch proxy target");
|
||||||
|
|
||||||
|
// 断言:本地 settings 的 current provider 已同步
|
||||||
|
assert_eq!(
|
||||||
|
crate::settings::get_current_provider(&AppType::Claude).as_deref(),
|
||||||
|
Some("b")
|
||||||
|
);
|
||||||
|
|
||||||
|
// 断言:Live 备份已更新为目标供应商配置(用于 stop_with_restore 恢复)
|
||||||
|
let backup = db
|
||||||
|
.get_live_backup("claude")
|
||||||
|
.await
|
||||||
|
.expect("get live backup")
|
||||||
|
.expect("backup exists");
|
||||||
|
let expected = serde_json::to_string(&provider_b.settings_config).expect("serialize");
|
||||||
|
assert_eq!(backup.original_config, expected);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+201
-30
@@ -7,7 +7,6 @@
|
|||||||
|
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use reqwest::Client;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -22,6 +21,19 @@ use crate::error::format_skill_error;
|
|||||||
|
|
||||||
// ========== 数据结构 ==========
|
// ========== 数据结构 ==========
|
||||||
|
|
||||||
|
/// Skill 同步方式
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum SyncMethod {
|
||||||
|
/// 自动选择:优先 symlink,失败时回退到 copy
|
||||||
|
#[default]
|
||||||
|
Auto,
|
||||||
|
/// 符号链接(推荐,节省磁盘空间)
|
||||||
|
Symlink,
|
||||||
|
/// 文件复制(兼容模式)
|
||||||
|
Copy,
|
||||||
|
}
|
||||||
|
|
||||||
/// 可发现的技能(来自仓库)
|
/// 可发现的技能(来自仓库)
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct DiscoverableSkill {
|
pub struct DiscoverableSkill {
|
||||||
@@ -129,6 +141,12 @@ impl Default for SkillStore {
|
|||||||
branch: "master".to_string(),
|
branch: "master".to_string(),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
|
SkillRepo {
|
||||||
|
owner: "JimLiu".to_string(),
|
||||||
|
name: "baoyu-skills".to_string(),
|
||||||
|
branch: "main".to_string(),
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,9 +161,7 @@ pub struct SkillMetadata {
|
|||||||
|
|
||||||
// ========== SkillService ==========
|
// ========== SkillService ==========
|
||||||
|
|
||||||
pub struct SkillService {
|
pub struct SkillService;
|
||||||
http_client: Client,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for SkillService {
|
impl Default for SkillService {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
@@ -155,13 +171,7 @@ impl Default for SkillService {
|
|||||||
|
|
||||||
impl SkillService {
|
impl SkillService {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self
|
||||||
http_client: Client::builder()
|
|
||||||
.user_agent("cc-switch")
|
|
||||||
.timeout(std::time::Duration::from_secs(10))
|
|
||||||
.build()
|
|
||||||
.unwrap_or_else(|_| Client::new()),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 路径管理 ==========
|
// ========== 路径管理 ==========
|
||||||
@@ -192,6 +202,11 @@ impl SkillService {
|
|||||||
return Ok(custom.join("skills"));
|
return Ok(custom.join("skills"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AppType::OpenCode => {
|
||||||
|
if let Some(custom) = crate::settings::get_opencode_override_dir() {
|
||||||
|
return Ok(custom.join("skills"));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 默认路径:回退到用户主目录下的标准位置
|
// 默认路径:回退到用户主目录下的标准位置
|
||||||
@@ -205,6 +220,7 @@ impl SkillService {
|
|||||||
AppType::Claude => home.join(".claude").join("skills"),
|
AppType::Claude => home.join(".claude").join("skills"),
|
||||||
AppType::Codex => home.join(".codex").join("skills"),
|
AppType::Codex => home.join(".codex").join("skills"),
|
||||||
AppType::Gemini => home.join(".gemini").join("skills"),
|
AppType::Gemini => home.join(".gemini").join("skills"),
|
||||||
|
AppType::OpenCode => home.join(".config").join("opencode").join("skills"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,6 +252,50 @@ impl SkillService {
|
|||||||
.map(|s| s.to_string_lossy().to_string())
|
.map(|s| s.to_string_lossy().to_string())
|
||||||
.unwrap_or_else(|| skill.directory.clone());
|
.unwrap_or_else(|| skill.directory.clone());
|
||||||
|
|
||||||
|
// 检查数据库中是否已有同名 directory 的 skill(来自其他仓库)
|
||||||
|
let existing_skills = db.get_all_installed_skills()?;
|
||||||
|
for existing in existing_skills.values() {
|
||||||
|
if existing.directory.eq_ignore_ascii_case(&install_name) {
|
||||||
|
// 检查是否来自同一仓库
|
||||||
|
let same_repo = existing.repo_owner.as_deref() == Some(&skill.repo_owner)
|
||||||
|
&& existing.repo_name.as_deref() == Some(&skill.repo_name);
|
||||||
|
if same_repo {
|
||||||
|
// 同一仓库的同名 skill,返回现有记录(可能需要更新启用状态)
|
||||||
|
let mut updated = existing.clone();
|
||||||
|
updated.apps.set_enabled_for(current_app, true);
|
||||||
|
db.save_skill(&updated)?;
|
||||||
|
Self::sync_to_app_dir(&updated.directory, current_app)?;
|
||||||
|
log::info!(
|
||||||
|
"Skill {} 已存在,更新 {:?} 启用状态",
|
||||||
|
updated.name,
|
||||||
|
current_app
|
||||||
|
);
|
||||||
|
return Ok(updated);
|
||||||
|
} else {
|
||||||
|
// 不同仓库的同名 skill,报错
|
||||||
|
return Err(anyhow!(format_skill_error(
|
||||||
|
"SKILL_DIRECTORY_CONFLICT",
|
||||||
|
&[
|
||||||
|
("directory", &install_name),
|
||||||
|
(
|
||||||
|
"existing_repo",
|
||||||
|
&format!(
|
||||||
|
"{}/{}",
|
||||||
|
existing.repo_owner.as_deref().unwrap_or("unknown"),
|
||||||
|
existing.repo_name.as_deref().unwrap_or("unknown")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"new_repo",
|
||||||
|
&format!("{}/{}", skill.repo_owner, skill.repo_name)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
Some("uninstallFirst"),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let dest = ssot_dir.join(&install_name);
|
let dest = ssot_dir.join(&install_name);
|
||||||
|
|
||||||
// 如果已存在则跳过下载
|
// 如果已存在则跳过下载
|
||||||
@@ -302,7 +362,7 @@ impl SkillService {
|
|||||||
db.save_skill(&installed_skill)?;
|
db.save_skill(&installed_skill)?;
|
||||||
|
|
||||||
// 同步到当前应用目录
|
// 同步到当前应用目录
|
||||||
Self::copy_to_app(&install_name, current_app)?;
|
Self::sync_to_app_dir(&install_name, current_app)?;
|
||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
"Skill {} 安装成功,已启用 {:?}",
|
"Skill {} 安装成功,已启用 {:?}",
|
||||||
@@ -326,7 +386,12 @@ impl SkillService {
|
|||||||
.ok_or_else(|| anyhow!("Skill not found: {id}"))?;
|
.ok_or_else(|| anyhow!("Skill not found: {id}"))?;
|
||||||
|
|
||||||
// 从所有应用目录删除
|
// 从所有应用目录删除
|
||||||
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
for app in [
|
||||||
|
AppType::Claude,
|
||||||
|
AppType::Codex,
|
||||||
|
AppType::Gemini,
|
||||||
|
AppType::OpenCode,
|
||||||
|
] {
|
||||||
let _ = Self::remove_from_app(&skill.directory, &app);
|
let _ = Self::remove_from_app(&skill.directory, &app);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,7 +425,7 @@ impl SkillService {
|
|||||||
|
|
||||||
// 同步文件
|
// 同步文件
|
||||||
if enabled {
|
if enabled {
|
||||||
Self::copy_to_app(&skill.directory, app)?;
|
Self::sync_to_app_dir(&skill.directory, app)?;
|
||||||
} else {
|
} else {
|
||||||
Self::remove_from_app(&skill.directory, app)?;
|
Self::remove_from_app(&skill.directory, app)?;
|
||||||
}
|
}
|
||||||
@@ -385,7 +450,12 @@ impl SkillService {
|
|||||||
|
|
||||||
let mut unmanaged: HashMap<String, UnmanagedSkill> = HashMap::new();
|
let mut unmanaged: HashMap<String, UnmanagedSkill> = HashMap::new();
|
||||||
|
|
||||||
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
for app in [
|
||||||
|
AppType::Claude,
|
||||||
|
AppType::Codex,
|
||||||
|
AppType::Gemini,
|
||||||
|
AppType::OpenCode,
|
||||||
|
] {
|
||||||
let app_dir = match Self::get_app_skills_dir(&app) {
|
let app_dir = match Self::get_app_skills_dir(&app) {
|
||||||
Ok(d) => d,
|
Ok(d) => d,
|
||||||
Err(_) => continue,
|
Err(_) => continue,
|
||||||
@@ -434,6 +504,7 @@ impl SkillService {
|
|||||||
AppType::Claude => "claude",
|
AppType::Claude => "claude",
|
||||||
AppType::Codex => "codex",
|
AppType::Codex => "codex",
|
||||||
AppType::Gemini => "gemini",
|
AppType::Gemini => "gemini",
|
||||||
|
AppType::OpenCode => "opencode",
|
||||||
};
|
};
|
||||||
|
|
||||||
unmanaged
|
unmanaged
|
||||||
@@ -466,7 +537,12 @@ impl SkillService {
|
|||||||
let mut source_path: Option<PathBuf> = None;
|
let mut source_path: Option<PathBuf> = None;
|
||||||
let mut found_in: Vec<String> = Vec::new();
|
let mut found_in: Vec<String> = Vec::new();
|
||||||
|
|
||||||
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
for app in [
|
||||||
|
AppType::Claude,
|
||||||
|
AppType::Codex,
|
||||||
|
AppType::Gemini,
|
||||||
|
AppType::OpenCode,
|
||||||
|
] {
|
||||||
if let Ok(app_dir) = Self::get_app_skills_dir(&app) {
|
if let Ok(app_dir) = Self::get_app_skills_dir(&app) {
|
||||||
let skill_path = app_dir.join(&dir_name);
|
let skill_path = app_dir.join(&dir_name);
|
||||||
if skill_path.exists() {
|
if skill_path.exists() {
|
||||||
@@ -477,6 +553,7 @@ impl SkillService {
|
|||||||
AppType::Claude => "claude",
|
AppType::Claude => "claude",
|
||||||
AppType::Codex => "codex",
|
AppType::Codex => "codex",
|
||||||
AppType::Gemini => "gemini",
|
AppType::Gemini => "gemini",
|
||||||
|
AppType::OpenCode => "opencode",
|
||||||
};
|
};
|
||||||
found_in.push(app_str.to_string());
|
found_in.push(app_str.to_string());
|
||||||
}
|
}
|
||||||
@@ -515,6 +592,7 @@ impl SkillService {
|
|||||||
"claude" => apps.claude = true,
|
"claude" => apps.claude = true,
|
||||||
"codex" => apps.codex = true,
|
"codex" => apps.codex = true,
|
||||||
"gemini" => apps.gemini = true,
|
"gemini" => apps.gemini = true,
|
||||||
|
"opencode" => apps.opencode = true,
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -545,8 +623,41 @@ impl SkillService {
|
|||||||
|
|
||||||
// ========== 文件同步方法 ==========
|
// ========== 文件同步方法 ==========
|
||||||
|
|
||||||
/// 复制 Skill 到应用目录
|
/// 创建符号链接(跨平台)
|
||||||
pub fn copy_to_app(directory: &str, app: &AppType) -> Result<()> {
|
///
|
||||||
|
/// - Unix: 使用 std::os::unix::fs::symlink
|
||||||
|
/// - Windows: 使用 std::os::windows::fs::symlink_dir
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn create_symlink(src: &Path, dest: &Path) -> Result<()> {
|
||||||
|
std::os::unix::fs::symlink(src, dest)
|
||||||
|
.with_context(|| format!("创建符号链接失败: {} -> {}", src.display(), dest.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn create_symlink(src: &Path, dest: &Path) -> Result<()> {
|
||||||
|
std::os::windows::fs::symlink_dir(src, dest)
|
||||||
|
.with_context(|| format!("创建符号链接失败: {} -> {}", src.display(), dest.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查路径是否为符号链接
|
||||||
|
fn is_symlink(path: &Path) -> bool {
|
||||||
|
path.symlink_metadata()
|
||||||
|
.map(|m| m.file_type().is_symlink())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取当前同步方式配置
|
||||||
|
fn get_sync_method() -> SyncMethod {
|
||||||
|
crate::settings::get_skill_sync_method()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同步 Skill 到应用目录(使用 symlink 或 copy)
|
||||||
|
///
|
||||||
|
/// 根据配置和平台选择最佳同步方式:
|
||||||
|
/// - Auto: 优先尝试 symlink,失败时回退到 copy
|
||||||
|
/// - Symlink: 仅使用 symlink
|
||||||
|
/// - Copy: 仅使用文件复制
|
||||||
|
pub fn sync_to_app_dir(directory: &str, app: &AppType) -> Result<()> {
|
||||||
let ssot_dir = Self::get_ssot_dir()?;
|
let ssot_dir = Self::get_ssot_dir()?;
|
||||||
let source = ssot_dir.join(directory);
|
let source = ssot_dir.join(directory);
|
||||||
|
|
||||||
@@ -559,25 +670,77 @@ impl SkillService {
|
|||||||
|
|
||||||
let dest = app_dir.join(directory);
|
let dest = app_dir.join(directory);
|
||||||
|
|
||||||
// 如果已存在则先删除
|
// 如果已存在则先删除(无论是 symlink 还是真实目录)
|
||||||
if dest.exists() {
|
if dest.exists() || Self::is_symlink(&dest) {
|
||||||
fs::remove_dir_all(&dest)?;
|
Self::remove_path(&dest)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Self::copy_dir_recursive(&source, &dest)?;
|
let sync_method = Self::get_sync_method();
|
||||||
|
|
||||||
log::debug!("Skill {directory} 已复制到 {app:?}");
|
match sync_method {
|
||||||
|
SyncMethod::Auto => {
|
||||||
|
// 优先尝试 symlink
|
||||||
|
match Self::create_symlink(&source, &dest) {
|
||||||
|
Ok(()) => {
|
||||||
|
log::debug!("Skill {directory} 已通过 symlink 同步到 {app:?}");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!(
|
||||||
|
"Symlink 创建失败,将回退到文件复制: {} -> {}. 错误: {err:#}",
|
||||||
|
source.display(),
|
||||||
|
dest.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback 到 copy
|
||||||
|
Self::copy_dir_recursive(&source, &dest)?;
|
||||||
|
log::debug!("Skill {directory} 已通过复制同步到 {app:?}");
|
||||||
|
}
|
||||||
|
SyncMethod::Symlink => {
|
||||||
|
Self::create_symlink(&source, &dest)?;
|
||||||
|
log::debug!("Skill {directory} 已通过 symlink 同步到 {app:?}");
|
||||||
|
}
|
||||||
|
SyncMethod::Copy => {
|
||||||
|
Self::copy_dir_recursive(&source, &dest)?;
|
||||||
|
log::debug!("Skill {directory} 已通过复制同步到 {app:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从应用目录删除 Skill
|
/// 复制 Skill 到应用目录(保留用于向后兼容)
|
||||||
|
#[deprecated(note = "请使用 sync_to_app_dir() 代替")]
|
||||||
|
pub fn copy_to_app(directory: &str, app: &AppType) -> Result<()> {
|
||||||
|
Self::sync_to_app_dir(directory, app)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除路径(支持 symlink 和真实目录)
|
||||||
|
fn remove_path(path: &Path) -> Result<()> {
|
||||||
|
if Self::is_symlink(path) {
|
||||||
|
// 符号链接:仅删除链接本身,不影响源文件
|
||||||
|
#[cfg(unix)]
|
||||||
|
fs::remove_file(path)?;
|
||||||
|
#[cfg(windows)]
|
||||||
|
fs::remove_dir(path)?; // Windows 的目录 symlink 需要用 remove_dir
|
||||||
|
} else if path.is_dir() {
|
||||||
|
// 真实目录:递归删除
|
||||||
|
fs::remove_dir_all(path)?;
|
||||||
|
} else if path.exists() {
|
||||||
|
// 普通文件
|
||||||
|
fs::remove_file(path)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从应用目录删除 Skill(支持 symlink 和真实目录)
|
||||||
pub fn remove_from_app(directory: &str, app: &AppType) -> Result<()> {
|
pub fn remove_from_app(directory: &str, app: &AppType) -> Result<()> {
|
||||||
let app_dir = Self::get_app_skills_dir(app)?;
|
let app_dir = Self::get_app_skills_dir(app)?;
|
||||||
let skill_path = app_dir.join(directory);
|
let skill_path = app_dir.join(directory);
|
||||||
|
|
||||||
if skill_path.exists() {
|
if skill_path.exists() || Self::is_symlink(&skill_path) {
|
||||||
fs::remove_dir_all(&skill_path)?;
|
Self::remove_path(&skill_path)?;
|
||||||
log::debug!("Skill {directory} 已从 {app:?} 删除");
|
log::debug!("Skill {directory} 已从 {app:?} 删除");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -590,7 +753,7 @@ impl SkillService {
|
|||||||
|
|
||||||
for skill in skills.values() {
|
for skill in skills.values() {
|
||||||
if skill.apps.is_enabled_for(app) {
|
if skill.apps.is_enabled_for(app) {
|
||||||
Self::copy_to_app(&skill.directory, app)?;
|
Self::sync_to_app_dir(&skill.directory, app)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -814,10 +977,12 @@ impl SkillService {
|
|||||||
Ok(meta)
|
Ok(meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 去重技能列表
|
/// 去重技能列表(基于完整 key,不同仓库的同名 skill 分开显示)
|
||||||
fn deduplicate_discoverable_skills(skills: &mut Vec<DiscoverableSkill>) {
|
fn deduplicate_discoverable_skills(skills: &mut Vec<DiscoverableSkill>) {
|
||||||
let mut seen = HashMap::new();
|
let mut seen = HashMap::new();
|
||||||
skills.retain(|skill| {
|
skills.retain(|skill| {
|
||||||
|
// 使用完整 key(owner/repo:directory)作为唯一标识
|
||||||
|
// 这样不同仓库的同名 skill 会分开显示
|
||||||
let unique_key = skill.key.to_lowercase();
|
let unique_key = skill.key.to_lowercase();
|
||||||
if let std::collections::hash_map::Entry::Vacant(e) = seen.entry(unique_key) {
|
if let std::collections::hash_map::Entry::Vacant(e) = seen.entry(unique_key) {
|
||||||
e.insert(true);
|
e.insert(true);
|
||||||
@@ -863,7 +1028,8 @@ impl SkillService {
|
|||||||
|
|
||||||
/// 下载并解压 ZIP
|
/// 下载并解压 ZIP
|
||||||
async fn download_and_extract(&self, url: &str, dest: &Path) -> Result<()> {
|
async fn download_and_extract(&self, url: &str, dest: &Path) -> Result<()> {
|
||||||
let response = self.http_client.get(url).send().await?;
|
let client = crate::proxy::http_client::get();
|
||||||
|
let response = client.get(url).send().await?;
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
let status = response.status().as_u16().to_string();
|
let status = response.status().as_u16().to_string();
|
||||||
return Err(anyhow::anyhow!(format_skill_error(
|
return Err(anyhow::anyhow!(format_skill_error(
|
||||||
@@ -984,7 +1150,12 @@ pub fn migrate_skills_to_ssot(db: &Arc<Database>) -> Result<usize> {
|
|||||||
let mut discovered: HashMap<String, SkillApps> = HashMap::new();
|
let mut discovered: HashMap<String, SkillApps> = HashMap::new();
|
||||||
|
|
||||||
// 扫描各应用目录
|
// 扫描各应用目录
|
||||||
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
for app in [
|
||||||
|
AppType::Claude,
|
||||||
|
AppType::Codex,
|
||||||
|
AppType::Gemini,
|
||||||
|
AppType::OpenCode,
|
||||||
|
] {
|
||||||
let app_dir = match SkillService::get_app_skills_dir(&app) {
|
let app_dir = match SkillService::get_app_skills_dir(&app) {
|
||||||
Ok(d) => d,
|
Ok(d) => d,
|
||||||
Err(_) => continue,
|
Err(_) => continue,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use futures::future::join_all;
|
use futures::future::join_all;
|
||||||
use reqwest::{Client, Url};
|
use reqwest::{Client, Url};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::Instant;
|
||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
|
||||||
@@ -65,17 +65,21 @@ impl SpeedtestService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let timeout = Self::sanitize_timeout(timeout_secs);
|
let timeout = Self::sanitize_timeout(timeout_secs);
|
||||||
let client = Self::build_client(timeout)?;
|
let (client, request_timeout) = Self::build_client(timeout)?;
|
||||||
|
|
||||||
let tasks = valid_targets.into_iter().map(|(idx, trimmed, parsed_url)| {
|
let tasks = valid_targets.into_iter().map(|(idx, trimmed, parsed_url)| {
|
||||||
let client = client.clone();
|
let client = client.clone();
|
||||||
async move {
|
async move {
|
||||||
// 先进行一次热身请求,忽略结果,仅用于复用连接/绕过首包惩罚。
|
// 先进行一次热身请求,忽略结果,仅用于复用连接/绕过首包惩罚。
|
||||||
let _ = client.get(parsed_url.clone()).send().await;
|
let _ = client
|
||||||
|
.get(parsed_url.clone())
|
||||||
|
.timeout(request_timeout)
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
// 第二次请求开始计时,并将其作为结果返回。
|
// 第二次请求开始计时,并将其作为结果返回。
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let latency = match client.get(parsed_url).send().await {
|
let latency = match client.get(parsed_url).timeout(request_timeout).send().await {
|
||||||
Ok(resp) => EndpointLatency {
|
Ok(resp) => EndpointLatency {
|
||||||
url: trimmed,
|
url: trimmed,
|
||||||
latency: Some(start.elapsed().as_millis()),
|
latency: Some(start.elapsed().as_millis()),
|
||||||
@@ -112,19 +116,11 @@ impl SpeedtestService {
|
|||||||
Ok(results.into_iter().flatten().collect::<Vec<_>>())
|
Ok(results.into_iter().flatten().collect::<Vec<_>>())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_client(timeout_secs: u64) -> Result<Client, AppError> {
|
fn build_client(timeout_secs: u64) -> Result<(Client, std::time::Duration), AppError> {
|
||||||
Client::builder()
|
// 使用全局 HTTP 客户端(已包含代理配置)
|
||||||
.timeout(Duration::from_secs(timeout_secs))
|
// 返回 timeout Duration 供请求级别使用
|
||||||
.redirect(reqwest::redirect::Policy::limited(5))
|
let timeout = std::time::Duration::from_secs(timeout_secs);
|
||||||
.user_agent("cc-switch-speedtest/1.0")
|
Ok((crate::proxy::http_client::get(), timeout))
|
||||||
.build()
|
|
||||||
.map_err(|e| {
|
|
||||||
AppError::localized(
|
|
||||||
"speedtest.client_create_failed",
|
|
||||||
format!("创建 HTTP 客户端失败: {e}"),
|
|
||||||
format!("Failed to create HTTP client: {e}"),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sanitize_timeout(timeout_secs: Option<u64>) -> u64 {
|
fn sanitize_timeout(timeout_secs: Option<u64>) -> u64 {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use regex::Regex;
|
|||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::Instant;
|
||||||
|
|
||||||
use crate::app_config::AppType;
|
use crate::app_config::AppType;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
@@ -36,6 +36,13 @@ pub struct StreamCheckConfig {
|
|||||||
pub codex_model: String,
|
pub codex_model: String,
|
||||||
/// Gemini 测试模型
|
/// Gemini 测试模型
|
||||||
pub gemini_model: String,
|
pub gemini_model: String,
|
||||||
|
/// 检查提示词
|
||||||
|
#[serde(default = "default_test_prompt")]
|
||||||
|
pub test_prompt: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_test_prompt() -> String {
|
||||||
|
"Who are you?".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for StreamCheckConfig {
|
impl Default for StreamCheckConfig {
|
||||||
@@ -47,6 +54,7 @@ impl Default for StreamCheckConfig {
|
|||||||
claude_model: "claude-haiku-4-5-20251001".to_string(),
|
claude_model: "claude-haiku-4-5-20251001".to_string(),
|
||||||
codex_model: "gpt-5.1-codex@low".to_string(),
|
codex_model: "gpt-5.1-codex@low".to_string(),
|
||||||
gemini_model: "gemini-3-pro-preview".to_string(),
|
gemini_model: "gemini-3-pro-preview".to_string(),
|
||||||
|
test_prompt: default_test_prompt(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,15 +78,19 @@ pub struct StreamCheckService;
|
|||||||
|
|
||||||
impl StreamCheckService {
|
impl StreamCheckService {
|
||||||
/// 执行流式健康检查(带重试)
|
/// 执行流式健康检查(带重试)
|
||||||
|
///
|
||||||
|
/// 如果 Provider 配置了单独的测试配置(meta.testConfig),则使用该配置覆盖全局配置
|
||||||
pub async fn check_with_retry(
|
pub async fn check_with_retry(
|
||||||
app_type: &AppType,
|
app_type: &AppType,
|
||||||
provider: &Provider,
|
provider: &Provider,
|
||||||
config: &StreamCheckConfig,
|
config: &StreamCheckConfig,
|
||||||
) -> Result<StreamCheckResult, AppError> {
|
) -> Result<StreamCheckResult, AppError> {
|
||||||
|
// 合并供应商单独配置和全局配置
|
||||||
|
let effective_config = Self::merge_provider_config(provider, config);
|
||||||
let mut last_result = None;
|
let mut last_result = None;
|
||||||
|
|
||||||
for attempt in 0..=config.max_retries {
|
for attempt in 0..=effective_config.max_retries {
|
||||||
let result = Self::check_once(app_type, provider, config).await;
|
let result = Self::check_once(app_type, provider, &effective_config).await;
|
||||||
|
|
||||||
match &result {
|
match &result {
|
||||||
Ok(r) if r.success => {
|
Ok(r) if r.success => {
|
||||||
@@ -89,7 +101,7 @@ impl StreamCheckService {
|
|||||||
}
|
}
|
||||||
Ok(r) => {
|
Ok(r) => {
|
||||||
// 失败但非异常,判断是否重试
|
// 失败但非异常,判断是否重试
|
||||||
if Self::should_retry(&r.message) && attempt < config.max_retries {
|
if Self::should_retry(&r.message) && attempt < effective_config.max_retries {
|
||||||
last_result = Some(r.clone());
|
last_result = Some(r.clone());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -99,7 +111,8 @@ impl StreamCheckService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if Self::should_retry(&e.to_string()) && attempt < config.max_retries {
|
if Self::should_retry(&e.to_string()) && attempt < effective_config.max_retries
|
||||||
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
return Err(AppError::Message(e.to_string()));
|
return Err(AppError::Message(e.to_string()));
|
||||||
@@ -110,15 +123,56 @@ impl StreamCheckService {
|
|||||||
Ok(last_result.unwrap_or_else(|| StreamCheckResult {
|
Ok(last_result.unwrap_or_else(|| StreamCheckResult {
|
||||||
status: HealthStatus::Failed,
|
status: HealthStatus::Failed,
|
||||||
success: false,
|
success: false,
|
||||||
message: "检查失败".to_string(),
|
message: "Check failed".to_string(),
|
||||||
response_time_ms: None,
|
response_time_ms: None,
|
||||||
http_status: None,
|
http_status: None,
|
||||||
model_used: String::new(),
|
model_used: String::new(),
|
||||||
tested_at: chrono::Utc::now().timestamp(),
|
tested_at: chrono::Utc::now().timestamp(),
|
||||||
retry_count: config.max_retries,
|
retry_count: effective_config.max_retries,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 合并供应商单独配置和全局配置
|
||||||
|
///
|
||||||
|
/// 如果供应商配置了 meta.testConfig 且 enabled 为 true,则使用供应商配置覆盖全局配置
|
||||||
|
fn merge_provider_config(
|
||||||
|
provider: &Provider,
|
||||||
|
global_config: &StreamCheckConfig,
|
||||||
|
) -> StreamCheckConfig {
|
||||||
|
let test_config = provider
|
||||||
|
.meta
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|m| m.test_config.as_ref())
|
||||||
|
.filter(|tc| tc.enabled);
|
||||||
|
|
||||||
|
match test_config {
|
||||||
|
Some(tc) => StreamCheckConfig {
|
||||||
|
timeout_secs: tc.timeout_secs.unwrap_or(global_config.timeout_secs),
|
||||||
|
max_retries: tc.max_retries.unwrap_or(global_config.max_retries),
|
||||||
|
degraded_threshold_ms: tc
|
||||||
|
.degraded_threshold_ms
|
||||||
|
.unwrap_or(global_config.degraded_threshold_ms),
|
||||||
|
claude_model: tc
|
||||||
|
.test_model
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| global_config.claude_model.clone()),
|
||||||
|
codex_model: tc
|
||||||
|
.test_model
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| global_config.codex_model.clone()),
|
||||||
|
gemini_model: tc
|
||||||
|
.test_model
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| global_config.gemini_model.clone()),
|
||||||
|
test_prompt: tc
|
||||||
|
.test_prompt
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| global_config.test_prompt.clone()),
|
||||||
|
},
|
||||||
|
None => global_config.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 单次流式检查
|
/// 单次流式检查
|
||||||
async fn check_once(
|
async fn check_once(
|
||||||
app_type: &AppType,
|
app_type: &AppType,
|
||||||
@@ -130,29 +184,61 @@ impl StreamCheckService {
|
|||||||
|
|
||||||
let base_url = adapter
|
let base_url = adapter
|
||||||
.extract_base_url(provider)
|
.extract_base_url(provider)
|
||||||
.map_err(|e| AppError::Message(format!("提取 base_url 失败: {e}")))?;
|
.map_err(|e| AppError::Message(format!("Failed to extract base_url: {e}")))?;
|
||||||
|
|
||||||
let auth = adapter
|
let auth = adapter
|
||||||
.extract_auth(provider)
|
.extract_auth(provider)
|
||||||
.ok_or_else(|| AppError::Message("未找到 API Key".to_string()))?;
|
.ok_or_else(|| AppError::Message("API Key not found".to_string()))?;
|
||||||
|
|
||||||
let client = Client::builder()
|
// 获取 HTTP 客户端:优先使用供应商单独代理配置,否则使用全局客户端
|
||||||
.timeout(Duration::from_secs(config.timeout_secs))
|
let proxy_config = provider.meta.as_ref().and_then(|m| m.proxy_config.as_ref());
|
||||||
.user_agent("cc-switch/1.0")
|
let client = crate::proxy::http_client::get_for_provider(proxy_config);
|
||||||
.build()
|
let request_timeout = std::time::Duration::from_secs(config.timeout_secs);
|
||||||
.map_err(|e| AppError::Message(format!("创建客户端失败: {e}")))?;
|
|
||||||
|
|
||||||
let model_to_test = Self::resolve_test_model(app_type, provider, config);
|
let model_to_test = Self::resolve_test_model(app_type, provider, config);
|
||||||
|
let test_prompt = &config.test_prompt;
|
||||||
|
|
||||||
let result = match app_type {
|
let result = match app_type {
|
||||||
AppType::Claude => {
|
AppType::Claude => {
|
||||||
Self::check_claude_stream(&client, &base_url, &auth, &model_to_test).await
|
Self::check_claude_stream(
|
||||||
|
&client,
|
||||||
|
&base_url,
|
||||||
|
&auth,
|
||||||
|
&model_to_test,
|
||||||
|
test_prompt,
|
||||||
|
request_timeout,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
AppType::Codex => {
|
AppType::Codex => {
|
||||||
Self::check_codex_stream(&client, &base_url, &auth, &model_to_test).await
|
Self::check_codex_stream(
|
||||||
|
&client,
|
||||||
|
&base_url,
|
||||||
|
&auth,
|
||||||
|
&model_to_test,
|
||||||
|
test_prompt,
|
||||||
|
request_timeout,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
AppType::Gemini => {
|
AppType::Gemini => {
|
||||||
Self::check_gemini_stream(&client, &base_url, &auth, &model_to_test).await
|
Self::check_gemini_stream(
|
||||||
|
&client,
|
||||||
|
&base_url,
|
||||||
|
&auth,
|
||||||
|
&model_to_test,
|
||||||
|
test_prompt,
|
||||||
|
request_timeout,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
AppType::OpenCode => {
|
||||||
|
// OpenCode doesn't support stream check yet
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"opencode_no_stream_check",
|
||||||
|
"OpenCode 暂不支持健康检查",
|
||||||
|
"OpenCode does not support health check yet",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -166,7 +252,7 @@ impl StreamCheckService {
|
|||||||
Ok(StreamCheckResult {
|
Ok(StreamCheckResult {
|
||||||
status: health_status,
|
status: health_status,
|
||||||
success: true,
|
success: true,
|
||||||
message: "检查成功".to_string(),
|
message: "Check succeeded".to_string(),
|
||||||
response_time_ms: Some(response_time),
|
response_time_ms: Some(response_time),
|
||||||
http_status: Some(status_code),
|
http_status: Some(status_code),
|
||||||
model_used: model,
|
model_used: model,
|
||||||
@@ -188,31 +274,69 @@ impl StreamCheckService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Claude 流式检查
|
/// Claude 流式检查
|
||||||
|
///
|
||||||
|
/// 严格按照 Claude CLI 真实请求格式构建请求
|
||||||
async fn check_claude_stream(
|
async fn check_claude_stream(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
auth: &AuthInfo,
|
auth: &AuthInfo,
|
||||||
model: &str,
|
model: &str,
|
||||||
|
test_prompt: &str,
|
||||||
|
timeout: std::time::Duration,
|
||||||
) -> Result<(u16, String), AppError> {
|
) -> Result<(u16, String), AppError> {
|
||||||
let base = base_url.trim_end_matches('/');
|
let base = base_url.trim_end_matches('/');
|
||||||
|
// URL 必须包含 ?beta=true 参数(某些中转服务依赖此参数验证请求来源)
|
||||||
let url = if base.ends_with("/v1") {
|
let url = if base.ends_with("/v1") {
|
||||||
format!("{base}/messages")
|
format!("{base}/messages?beta=true")
|
||||||
} else {
|
} else {
|
||||||
format!("{base}/v1/messages")
|
format!("{base}/v1/messages?beta=true")
|
||||||
};
|
};
|
||||||
|
|
||||||
let body = json!({
|
let body = json!({
|
||||||
"model": model,
|
"model": model,
|
||||||
"max_tokens": 1,
|
"max_tokens": 1,
|
||||||
"messages": [{ "role": "user", "content": "hi" }],
|
"messages": [{ "role": "user", "content": test_prompt }],
|
||||||
"stream": true
|
"stream": true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 获取本地系统信息
|
||||||
|
let os_name = Self::get_os_name();
|
||||||
|
let arch_name = Self::get_arch_name();
|
||||||
|
|
||||||
|
// 严格按照 Claude CLI 请求格式设置 headers
|
||||||
let response = client
|
let response = client
|
||||||
.post(&url)
|
.post(&url)
|
||||||
|
// 认证 headers(双重认证)
|
||||||
|
.header("authorization", format!("Bearer {}", auth.api_key))
|
||||||
.header("x-api-key", &auth.api_key)
|
.header("x-api-key", &auth.api_key)
|
||||||
|
// Anthropic 必需 headers
|
||||||
.header("anthropic-version", "2023-06-01")
|
.header("anthropic-version", "2023-06-01")
|
||||||
.header("Content-Type", "application/json")
|
.header(
|
||||||
|
"anthropic-beta",
|
||||||
|
"claude-code-20250219,interleaved-thinking-2025-05-14",
|
||||||
|
)
|
||||||
|
.header("anthropic-dangerous-direct-browser-access", "true")
|
||||||
|
// 内容类型 headers
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("accept", "application/json")
|
||||||
|
.header("accept-encoding", "identity")
|
||||||
|
.header("accept-language", "*")
|
||||||
|
// 客户端标识 headers
|
||||||
|
.header("user-agent", "claude-cli/2.1.2 (external, cli)")
|
||||||
|
.header("x-app", "cli")
|
||||||
|
// x-stainless SDK headers(动态获取本地系统信息)
|
||||||
|
.header("x-stainless-lang", "js")
|
||||||
|
.header("x-stainless-package-version", "0.70.0")
|
||||||
|
.header("x-stainless-os", os_name)
|
||||||
|
.header("x-stainless-arch", arch_name)
|
||||||
|
.header("x-stainless-runtime", "node")
|
||||||
|
.header("x-stainless-runtime-version", "v22.20.0")
|
||||||
|
.header("x-stainless-retry-count", "0")
|
||||||
|
.header("x-stainless-timeout", "600")
|
||||||
|
// 其他 headers
|
||||||
|
.header("sec-fetch-mode", "cors")
|
||||||
|
.header("connection", "keep-alive")
|
||||||
|
.timeout(timeout)
|
||||||
.json(&body)
|
.json(&body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -230,51 +354,64 @@ impl StreamCheckService {
|
|||||||
if let Some(chunk) = stream.next().await {
|
if let Some(chunk) = stream.next().await {
|
||||||
match chunk {
|
match chunk {
|
||||||
Ok(_) => Ok((status, model.to_string())),
|
Ok(_) => Ok((status, model.to_string())),
|
||||||
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))),
|
Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Err(AppError::Message("未收到响应数据".to_string()))
|
Err(AppError::Message("No response data received".to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Codex 流式检查
|
/// Codex 流式检查
|
||||||
|
///
|
||||||
|
/// 严格按照 Codex CLI 真实请求格式构建请求 (Responses API)
|
||||||
async fn check_codex_stream(
|
async fn check_codex_stream(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
auth: &AuthInfo,
|
auth: &AuthInfo,
|
||||||
model: &str,
|
model: &str,
|
||||||
|
test_prompt: &str,
|
||||||
|
timeout: std::time::Duration,
|
||||||
) -> Result<(u16, String), AppError> {
|
) -> Result<(u16, String), AppError> {
|
||||||
let base = base_url.trim_end_matches('/');
|
let base = base_url.trim_end_matches('/');
|
||||||
|
// Codex CLI 使用 /v1/responses 端点 (OpenAI Responses API)
|
||||||
let url = if base.ends_with("/v1") {
|
let url = if base.ends_with("/v1") {
|
||||||
format!("{base}/chat/completions")
|
format!("{base}/responses")
|
||||||
} else {
|
} else {
|
||||||
format!("{base}/v1/chat/completions")
|
format!("{base}/v1/responses")
|
||||||
};
|
};
|
||||||
|
|
||||||
// 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
|
// 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
|
||||||
let (actual_model, reasoning_effort) = Self::parse_model_with_effort(model);
|
let (actual_model, reasoning_effort) = Self::parse_model_with_effort(model);
|
||||||
|
|
||||||
|
// 获取本地系统信息
|
||||||
|
let os_name = Self::get_os_name();
|
||||||
|
let arch_name = Self::get_arch_name();
|
||||||
|
|
||||||
|
// Responses API 请求体格式 (input 必须是数组)
|
||||||
let mut body = json!({
|
let mut body = json!({
|
||||||
"model": actual_model,
|
"model": actual_model,
|
||||||
"messages": [
|
"input": [{ "role": "user", "content": test_prompt }],
|
||||||
{ "role": "system", "content": "" },
|
|
||||||
{ "role": "assistant", "content": "" },
|
|
||||||
{ "role": "user", "content": "hi" }
|
|
||||||
],
|
|
||||||
"max_tokens": 1,
|
|
||||||
"temperature": 0,
|
|
||||||
"stream": true
|
"stream": true
|
||||||
});
|
});
|
||||||
|
|
||||||
// 如果是推理模型,添加 reasoning_effort
|
// 如果是推理模型,添加 reasoning_effort
|
||||||
if let Some(effort) = reasoning_effort {
|
if let Some(effort) = reasoning_effort {
|
||||||
body["reasoning_effort"] = json!(effort);
|
body["reasoning"] = json!({ "effort": effort });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 严格按照 Codex CLI 请求格式设置 headers
|
||||||
let response = client
|
let response = client
|
||||||
.post(&url)
|
.post(&url)
|
||||||
.header("Authorization", format!("Bearer {}", auth.api_key))
|
.header("authorization", format!("Bearer {}", auth.api_key))
|
||||||
.header("Content-Type", "application/json")
|
.header("content-type", "application/json")
|
||||||
|
.header("accept", "text/event-stream")
|
||||||
|
.header("accept-encoding", "identity")
|
||||||
|
.header(
|
||||||
|
"user-agent",
|
||||||
|
format!("codex_cli_rs/0.80.0 ({os_name} 15.7.2; {arch_name}) Terminal"),
|
||||||
|
)
|
||||||
|
.header("originator", "codex_cli_rs")
|
||||||
|
.timeout(timeout)
|
||||||
.json(&body)
|
.json(&body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -291,35 +428,48 @@ impl StreamCheckService {
|
|||||||
if let Some(chunk) = stream.next().await {
|
if let Some(chunk) = stream.next().await {
|
||||||
match chunk {
|
match chunk {
|
||||||
Ok(_) => Ok((status, model.to_string())),
|
Ok(_) => Ok((status, model.to_string())),
|
||||||
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))),
|
Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Err(AppError::Message("未收到响应数据".to_string()))
|
Err(AppError::Message("No response data received".to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gemini 流式检查
|
/// Gemini 流式检查
|
||||||
|
///
|
||||||
|
/// 使用 Gemini 原生 API 格式 (streamGenerateContent)
|
||||||
async fn check_gemini_stream(
|
async fn check_gemini_stream(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
auth: &AuthInfo,
|
auth: &AuthInfo,
|
||||||
model: &str,
|
model: &str,
|
||||||
|
test_prompt: &str,
|
||||||
|
timeout: std::time::Duration,
|
||||||
) -> Result<(u16, String), AppError> {
|
) -> Result<(u16, String), AppError> {
|
||||||
let base = base_url.trim_end_matches('/');
|
let base = base_url.trim_end_matches('/');
|
||||||
let url = format!("{base}/v1/chat/completions");
|
// Gemini 原生 API: /v1beta/models/{model}:streamGenerateContent?alt=sse
|
||||||
|
// 智能处理 /v1beta 路径:如果 base_url 不包含版本路径,则添加 /v1beta
|
||||||
|
// alt=sse 参数使 API 返回 SSE 格式(text/event-stream)而非 JSON 数组
|
||||||
|
let url = if base.contains("/v1beta") || base.contains("/v1/") {
|
||||||
|
format!("{base}/models/{model}:streamGenerateContent?alt=sse")
|
||||||
|
} else {
|
||||||
|
format!("{base}/v1beta/models/{model}:streamGenerateContent?alt=sse")
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gemini 原生请求体格式
|
||||||
let body = json!({
|
let body = json!({
|
||||||
"model": model,
|
"contents": [{
|
||||||
"messages": [{ "role": "user", "content": "hi" }],
|
"role": "user",
|
||||||
"max_tokens": 1,
|
"parts": [{ "text": test_prompt }]
|
||||||
"temperature": 0,
|
}]
|
||||||
"stream": true
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = client
|
let response = client
|
||||||
.post(&url)
|
.post(&url)
|
||||||
.header("Authorization", format!("Bearer {}", auth.api_key))
|
.header("x-goog-api-key", &auth.api_key)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Accept", "text/event-stream")
|
||||||
|
.timeout(timeout)
|
||||||
.json(&body)
|
.json(&body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -336,10 +486,10 @@ impl StreamCheckService {
|
|||||||
if let Some(chunk) = stream.next().await {
|
if let Some(chunk) = stream.next().await {
|
||||||
match chunk {
|
match chunk {
|
||||||
Ok(_) => Ok((status, model.to_string())),
|
Ok(_) => Ok((status, model.to_string())),
|
||||||
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))),
|
Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Err(AppError::Message("未收到响应数据".to_string()))
|
Err(AppError::Message("No response data received".to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,7 +504,6 @@ impl StreamCheckService {
|
|||||||
/// 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
|
/// 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
|
||||||
/// 返回 (实际模型名, Option<推理等级>)
|
/// 返回 (实际模型名, Option<推理等级>)
|
||||||
fn parse_model_with_effort(model: &str) -> (String, Option<String>) {
|
fn parse_model_with_effort(model: &str) -> (String, Option<String>) {
|
||||||
// 查找 @ 或 # 分隔符
|
|
||||||
if let Some(pos) = model.find('@').or_else(|| model.find('#')) {
|
if let Some(pos) = model.find('@').or_else(|| model.find('#')) {
|
||||||
let actual_model = model[..pos].to_string();
|
let actual_model = model[..pos].to_string();
|
||||||
let effort = model[pos + 1..].to_string();
|
let effort = model[pos + 1..].to_string();
|
||||||
@@ -367,17 +516,14 @@ impl StreamCheckService {
|
|||||||
|
|
||||||
fn should_retry(msg: &str) -> bool {
|
fn should_retry(msg: &str) -> bool {
|
||||||
let lower = msg.to_lowercase();
|
let lower = msg.to_lowercase();
|
||||||
lower.contains("timeout")
|
lower.contains("timeout") || lower.contains("abort") || lower.contains("timed out")
|
||||||
|| lower.contains("abort")
|
|
||||||
|| lower.contains("中断")
|
|
||||||
|| lower.contains("超时")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn map_request_error(e: reqwest::Error) -> AppError {
|
fn map_request_error(e: reqwest::Error) -> AppError {
|
||||||
if e.is_timeout() {
|
if e.is_timeout() {
|
||||||
AppError::Message("请求超时".to_string())
|
AppError::Message("Request timeout".to_string())
|
||||||
} else if e.is_connect() {
|
} else if e.is_connect() {
|
||||||
AppError::Message(format!("连接失败: {e}"))
|
AppError::Message(format!("Connection failed: {e}"))
|
||||||
} else {
|
} else {
|
||||||
AppError::Message(e.to_string())
|
AppError::Message(e.to_string())
|
||||||
}
|
}
|
||||||
@@ -396,9 +542,24 @@ impl StreamCheckService {
|
|||||||
}
|
}
|
||||||
AppType::Gemini => Self::extract_env_model(provider, "GEMINI_MODEL")
|
AppType::Gemini => Self::extract_env_model(provider, "GEMINI_MODEL")
|
||||||
.unwrap_or_else(|| config.gemini_model.clone()),
|
.unwrap_or_else(|| config.gemini_model.clone()),
|
||||||
|
AppType::OpenCode => {
|
||||||
|
// OpenCode uses models map in settings_config
|
||||||
|
// Try to extract first model from the models object
|
||||||
|
Self::extract_opencode_model(provider).unwrap_or_else(|| "gpt-4o".to_string())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn extract_opencode_model(provider: &Provider) -> Option<String> {
|
||||||
|
let models = provider
|
||||||
|
.settings_config
|
||||||
|
.get("models")
|
||||||
|
.and_then(|m| m.as_object())?;
|
||||||
|
|
||||||
|
// Return the first model ID from the models map
|
||||||
|
models.keys().next().map(|s| s.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
fn extract_env_model(provider: &Provider, key: &str) -> Option<String> {
|
fn extract_env_model(provider: &Provider, key: &str) -> Option<String> {
|
||||||
provider
|
provider
|
||||||
.settings_config
|
.settings_config
|
||||||
@@ -424,6 +585,26 @@ impl StreamCheckService {
|
|||||||
.map(|m| m.as_str().trim().to_string())
|
.map(|m| m.as_str().trim().to_string())
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取操作系统名称(映射为 Claude CLI 使用的格式)
|
||||||
|
fn get_os_name() -> &'static str {
|
||||||
|
match std::env::consts::OS {
|
||||||
|
"macos" => "MacOS",
|
||||||
|
"linux" => "Linux",
|
||||||
|
"windows" => "Windows",
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 CPU 架构名称(映射为 Claude CLI 使用的格式)
|
||||||
|
fn get_arch_name() -> &'static str {
|
||||||
|
match std::env::consts::ARCH {
|
||||||
|
"aarch64" => "arm64",
|
||||||
|
"x86_64" => "x86_64",
|
||||||
|
"x86" => "x86",
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -448,9 +629,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_should_retry() {
|
fn test_should_retry() {
|
||||||
assert!(StreamCheckService::should_retry("请求超时"));
|
assert!(StreamCheckService::should_retry("Request timeout"));
|
||||||
assert!(StreamCheckService::should_retry("request timeout"));
|
assert!(StreamCheckService::should_retry("request timed out"));
|
||||||
assert!(!StreamCheckService::should_retry("API Key 无效"));
|
assert!(StreamCheckService::should_retry("connection abort"));
|
||||||
|
assert!(!StreamCheckService::should_retry("API Key invalid"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -478,4 +660,33 @@ mod tests {
|
|||||||
assert_eq!(model, "gpt-4o-mini");
|
assert_eq!(model, "gpt-4o-mini");
|
||||||
assert_eq!(effort, None);
|
assert_eq!(effort, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_os_name() {
|
||||||
|
let os_name = StreamCheckService::get_os_name();
|
||||||
|
// 确保返回非空字符串
|
||||||
|
assert!(!os_name.is_empty());
|
||||||
|
// 在 macOS 上应该返回 "MacOS"
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
assert_eq!(os_name, "MacOS");
|
||||||
|
// 在 Linux 上应该返回 "Linux"
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
assert_eq!(os_name, "Linux");
|
||||||
|
// 在 Windows 上应该返回 "Windows"
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
assert_eq!(os_name, "Windows");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_arch_name() {
|
||||||
|
let arch_name = StreamCheckService::get_arch_name();
|
||||||
|
// 确保返回非空字符串
|
||||||
|
assert!(!arch_name.is_empty());
|
||||||
|
// 在 ARM64 上应该返回 "arm64"
|
||||||
|
#[cfg(target_arch = "aarch64")]
|
||||||
|
assert_eq!(arch_name, "arm64");
|
||||||
|
// 在 x86_64 上应该返回 "x86_64"
|
||||||
|
#[cfg(target_arch = "x86_64")]
|
||||||
|
assert_eq!(arch_name, "x86_64");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use std::sync::{OnceLock, RwLock};
|
|||||||
|
|
||||||
use crate::app_config::AppType;
|
use crate::app_config::AppType;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
use crate::services::skill::SyncMethod;
|
||||||
|
|
||||||
/// 自定义端点配置(历史兼容,实际存储在 provider.meta.custom_endpoints)
|
/// 自定义端点配置(历史兼容,实际存储在 provider.meta.custom_endpoints)
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -16,6 +17,47 @@ pub struct CustomEndpoint {
|
|||||||
pub last_used: Option<i64>,
|
pub last_used: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 主页面显示的应用配置
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct VisibleApps {
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub claude: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub codex: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub gemini: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub opencode: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for VisibleApps {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
claude: true,
|
||||||
|
codex: true,
|
||||||
|
gemini: true,
|
||||||
|
opencode: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VisibleApps {
|
||||||
|
/// Check if the specified app is visible
|
||||||
|
pub fn is_visible(&self, app: &AppType) -> bool {
|
||||||
|
match app {
|
||||||
|
AppType::Claude => self.claude,
|
||||||
|
AppType::Codex => self.codex,
|
||||||
|
AppType::Gemini => self.gemini,
|
||||||
|
AppType::OpenCode => self.opencode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 应用设置结构
|
/// 应用设置结构
|
||||||
///
|
///
|
||||||
/// 存储设备级别设置,保存在本地 `~/.cc-switch/settings.json`,不随数据库同步。
|
/// 存储设备级别设置,保存在本地 `~/.cc-switch/settings.json`,不随数据库同步。
|
||||||
@@ -32,7 +74,7 @@ pub struct AppSettings {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub enable_claude_plugin_integration: bool,
|
pub enable_claude_plugin_integration: bool,
|
||||||
/// 是否跳过 Claude Code 初次安装确认
|
/// 是否跳过 Claude Code 初次安装确认
|
||||||
#[serde(default = "default_true")]
|
#[serde(default)]
|
||||||
pub skip_claude_onboarding: bool,
|
pub skip_claude_onboarding: bool,
|
||||||
/// 是否开机自启
|
/// 是否开机自启
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -40,6 +82,10 @@ pub struct AppSettings {
|
|||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub language: Option<String>,
|
pub language: Option<String>,
|
||||||
|
|
||||||
|
// ===== 主页面显示的应用 =====
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub visible_apps: Option<VisibleApps>,
|
||||||
|
|
||||||
// ===== 设备级目录覆盖 =====
|
// ===== 设备级目录覆盖 =====
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub claude_config_dir: Option<String>,
|
pub claude_config_dir: Option<String>,
|
||||||
@@ -47,6 +93,8 @@ pub struct AppSettings {
|
|||||||
pub codex_config_dir: Option<String>,
|
pub codex_config_dir: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub gemini_config_dir: Option<String>,
|
pub gemini_config_dir: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub opencode_config_dir: Option<String>,
|
||||||
|
|
||||||
// ===== 当前供应商 ID(设备级)=====
|
// ===== 当前供应商 ID(设备级)=====
|
||||||
/// 当前 Claude 供应商 ID(本地存储,优先于数据库 is_current)
|
/// 当前 Claude 供应商 ID(本地存储,优先于数据库 is_current)
|
||||||
@@ -58,6 +106,14 @@ pub struct AppSettings {
|
|||||||
/// 当前 Gemini 供应商 ID(本地存储,优先于数据库 is_current)
|
/// 当前 Gemini 供应商 ID(本地存储,优先于数据库 is_current)
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub current_provider_gemini: Option<String>,
|
pub current_provider_gemini: Option<String>,
|
||||||
|
/// 当前 OpenCode 供应商 ID(本地存储,对 OpenCode 可能无意义,但保持结构一致)
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub current_provider_opencode: Option<String>,
|
||||||
|
|
||||||
|
// ===== Skill 同步设置 =====
|
||||||
|
/// Skill 同步方式:auto(默认,优先 symlink)、symlink、copy
|
||||||
|
#[serde(default)]
|
||||||
|
pub skill_sync_method: SyncMethod,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_show_in_tray() -> bool {
|
fn default_show_in_tray() -> bool {
|
||||||
@@ -68,25 +124,25 @@ fn default_minimize_to_tray_on_close() -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_true() -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for AppSettings {
|
impl Default for AppSettings {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
show_in_tray: true,
|
show_in_tray: true,
|
||||||
minimize_to_tray_on_close: true,
|
minimize_to_tray_on_close: true,
|
||||||
enable_claude_plugin_integration: false,
|
enable_claude_plugin_integration: false,
|
||||||
skip_claude_onboarding: true,
|
skip_claude_onboarding: false,
|
||||||
launch_on_startup: false,
|
launch_on_startup: false,
|
||||||
language: None,
|
language: None,
|
||||||
|
visible_apps: None,
|
||||||
claude_config_dir: None,
|
claude_config_dir: None,
|
||||||
codex_config_dir: None,
|
codex_config_dir: None,
|
||||||
gemini_config_dir: None,
|
gemini_config_dir: None,
|
||||||
|
opencode_config_dir: None,
|
||||||
current_provider_claude: None,
|
current_provider_claude: None,
|
||||||
current_provider_codex: None,
|
current_provider_codex: None,
|
||||||
current_provider_gemini: None,
|
current_provider_gemini: None,
|
||||||
|
current_provider_opencode: None,
|
||||||
|
skill_sync_method: SyncMethod::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,6 +175,13 @@ impl AppSettings {
|
|||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.map(|s| s.to_string());
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
self.opencode_config_dir = self
|
||||||
|
.opencode_config_dir
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.trim())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
self.language = self
|
self.language = self
|
||||||
.language
|
.language
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -251,6 +314,14 @@ pub fn get_gemini_override_dir() -> Option<PathBuf> {
|
|||||||
.map(|p| resolve_override_path(p))
|
.map(|p| resolve_override_path(p))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_opencode_override_dir() -> Option<PathBuf> {
|
||||||
|
let settings = settings_store().read().ok()?;
|
||||||
|
settings
|
||||||
|
.opencode_config_dir
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| resolve_override_path(p))
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 当前供应商管理函数 =====
|
// ===== 当前供应商管理函数 =====
|
||||||
|
|
||||||
/// 获取指定应用类型的当前供应商 ID(从本地 settings 读取)
|
/// 获取指定应用类型的当前供应商 ID(从本地 settings 读取)
|
||||||
@@ -263,6 +334,7 @@ pub fn get_current_provider(app_type: &AppType) -> Option<String> {
|
|||||||
AppType::Claude => settings.current_provider_claude.clone(),
|
AppType::Claude => settings.current_provider_claude.clone(),
|
||||||
AppType::Codex => settings.current_provider_codex.clone(),
|
AppType::Codex => settings.current_provider_codex.clone(),
|
||||||
AppType::Gemini => settings.current_provider_gemini.clone(),
|
AppType::Gemini => settings.current_provider_gemini.clone(),
|
||||||
|
AppType::OpenCode => settings.current_provider_opencode.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,6 +349,7 @@ pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(),
|
|||||||
AppType::Claude => settings.current_provider_claude = id.map(|s| s.to_string()),
|
AppType::Claude => settings.current_provider_claude = id.map(|s| s.to_string()),
|
||||||
AppType::Codex => settings.current_provider_codex = id.map(|s| s.to_string()),
|
AppType::Codex => settings.current_provider_codex = id.map(|s| s.to_string()),
|
||||||
AppType::Gemini => settings.current_provider_gemini = id.map(|s| s.to_string()),
|
AppType::Gemini => settings.current_provider_gemini = id.map(|s| s.to_string()),
|
||||||
|
AppType::OpenCode => settings.current_provider_opencode = id.map(|s| s.to_string()),
|
||||||
}
|
}
|
||||||
|
|
||||||
update_settings(settings)
|
update_settings(settings)
|
||||||
@@ -316,3 +389,16 @@ pub fn get_effective_current_provider(
|
|||||||
// Fallback 到数据库的 is_current
|
// Fallback 到数据库的 is_current
|
||||||
db.get_current_provider(app_type.as_str())
|
db.get_current_provider(app_type.as_str())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Skill 同步方式管理函数 =====
|
||||||
|
|
||||||
|
/// 获取 Skill 同步方式配置
|
||||||
|
pub fn get_skill_sync_method() -> SyncMethod {
|
||||||
|
settings_store()
|
||||||
|
.read()
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
log::warn!("设置锁已毒化,使用恢复值: {e}");
|
||||||
|
e.into_inner()
|
||||||
|
})
|
||||||
|
.skill_sync_method
|
||||||
|
}
|
||||||
|
|||||||
+199
-42
@@ -15,6 +15,7 @@ pub struct TrayTexts {
|
|||||||
pub show_main: &'static str,
|
pub show_main: &'static str,
|
||||||
pub no_provider_hint: &'static str,
|
pub no_provider_hint: &'static str,
|
||||||
pub quit: &'static str,
|
pub quit: &'static str,
|
||||||
|
pub auto_label: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TrayTexts {
|
impl TrayTexts {
|
||||||
@@ -24,17 +25,20 @@ impl TrayTexts {
|
|||||||
show_main: "Open main window",
|
show_main: "Open main window",
|
||||||
no_provider_hint: " (No providers yet, please add them from the main window)",
|
no_provider_hint: " (No providers yet, please add them from the main window)",
|
||||||
quit: "Quit",
|
quit: "Quit",
|
||||||
|
auto_label: "Auto (Failover)",
|
||||||
},
|
},
|
||||||
"ja" => Self {
|
"ja" => Self {
|
||||||
show_main: "メインウィンドウを開く",
|
show_main: "メインウィンドウを開く",
|
||||||
no_provider_hint:
|
no_provider_hint:
|
||||||
" (プロバイダーがまだありません。メイン画面から追加してください)",
|
" (プロバイダーがまだありません。メイン画面から追加してください)",
|
||||||
quit: "終了",
|
quit: "終了",
|
||||||
|
auto_label: "自動 (フェイルオーバー)",
|
||||||
},
|
},
|
||||||
_ => Self {
|
_ => Self {
|
||||||
show_main: "打开主界面",
|
show_main: "打开主界面",
|
||||||
no_provider_hint: " (无供应商,请在主界面添加)",
|
no_provider_hint: " (无供应商,请在主界面添加)",
|
||||||
quit: "退出",
|
quit: "退出",
|
||||||
|
auto_label: "自动 (故障转移)",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,6 +54,9 @@ pub struct TrayAppSection {
|
|||||||
pub log_name: &'static str,
|
pub log_name: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Auto 菜单项后缀
|
||||||
|
pub const AUTO_SUFFIX: &str = "auto";
|
||||||
|
|
||||||
pub const TRAY_SECTIONS: [TrayAppSection; 3] = [
|
pub const TRAY_SECTIONS: [TrayAppSection; 3] = [
|
||||||
TrayAppSection {
|
TrayAppSection {
|
||||||
app_type: AppType::Claude,
|
app_type: AppType::Claude,
|
||||||
@@ -84,6 +91,7 @@ fn append_provider_section<'a>(
|
|||||||
manager: Option<&crate::provider::ProviderManager>,
|
manager: Option<&crate::provider::ProviderManager>,
|
||||||
section: &TrayAppSection,
|
section: &TrayAppSection,
|
||||||
tray_texts: &TrayTexts,
|
tray_texts: &TrayTexts,
|
||||||
|
app_state: &AppState,
|
||||||
) -> Result<MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>, AppError> {
|
) -> Result<MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>, AppError> {
|
||||||
let Some(manager) = manager else {
|
let Some(manager) = manager else {
|
||||||
return Ok(menu_builder);
|
return Ok(menu_builder);
|
||||||
@@ -111,6 +119,23 @@ fn append_provider_section<'a>(
|
|||||||
return Ok(menu_builder.item(&empty_hint));
|
return Ok(menu_builder.item(&empty_hint));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取 proxy 状态,决定 Auto 是否选中
|
||||||
|
let (proxy_enabled, auto_failover) =
|
||||||
|
app_state.db.get_proxy_flags_sync(section.app_type.as_str());
|
||||||
|
let auto_mode = proxy_enabled && auto_failover;
|
||||||
|
|
||||||
|
// 添加 Auto 菜单项(始终显示在供应商列表前)
|
||||||
|
let auto_item = CheckMenuItem::with_id(
|
||||||
|
app,
|
||||||
|
format!("{}{}", section.prefix, AUTO_SUFFIX),
|
||||||
|
tray_texts.auto_label,
|
||||||
|
true,
|
||||||
|
auto_mode,
|
||||||
|
None::<&str>,
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Message(format!("创建{}Auto菜单项失败: {e}", section.log_name)))?;
|
||||||
|
menu_builder = menu_builder.item(&auto_item);
|
||||||
|
|
||||||
let mut sorted_providers: Vec<_> = manager.providers.iter().collect();
|
let mut sorted_providers: Vec<_> = manager.providers.iter().collect();
|
||||||
sorted_providers.sort_by(|(_, a), (_, b)| {
|
sorted_providers.sort_by(|(_, a), (_, b)| {
|
||||||
match (a.sort_index, b.sort_index) {
|
match (a.sort_index, b.sort_index) {
|
||||||
@@ -131,7 +156,8 @@ fn append_provider_section<'a>(
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (id, provider) in sorted_providers {
|
for (id, provider) in sorted_providers {
|
||||||
let is_current = manager.current == *id;
|
// Auto 模式下所有供应商都不选中
|
||||||
|
let is_current = !auto_mode && manager.current == *id;
|
||||||
let item = CheckMenuItem::with_id(
|
let item = CheckMenuItem::with_id(
|
||||||
app,
|
app,
|
||||||
format!("{}{}", section.prefix, id),
|
format!("{}{}", section.prefix, id),
|
||||||
@@ -150,13 +176,27 @@ fn append_provider_section<'a>(
|
|||||||
/// 处理供应商托盘事件
|
/// 处理供应商托盘事件
|
||||||
pub fn handle_provider_tray_event(app: &tauri::AppHandle, event_id: &str) -> bool {
|
pub fn handle_provider_tray_event(app: &tauri::AppHandle, event_id: &str) -> bool {
|
||||||
for section in TRAY_SECTIONS.iter() {
|
for section in TRAY_SECTIONS.iter() {
|
||||||
if let Some(provider_id) = event_id.strip_prefix(section.prefix) {
|
if let Some(suffix) = event_id.strip_prefix(section.prefix) {
|
||||||
log::info!("切换到{}供应商: {provider_id}", section.log_name);
|
// 处理 Auto 点击
|
||||||
|
if suffix == AUTO_SUFFIX {
|
||||||
|
log::info!("切换到{} Auto模式", section.log_name);
|
||||||
|
let app_handle = app.clone();
|
||||||
|
let app_type = section.app_type.clone();
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
if let Err(e) = handle_auto_click(&app_handle, &app_type) {
|
||||||
|
log::error!("切换{}Auto模式失败: {e}", section.log_name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理供应商点击
|
||||||
|
log::info!("切换到{}供应商: {suffix}", section.log_name);
|
||||||
let app_handle = app.clone();
|
let app_handle = app.clone();
|
||||||
let provider_id = provider_id.to_string();
|
let provider_id = suffix.to_string();
|
||||||
let app_type = section.app_type.clone();
|
let app_type = section.app_type.clone();
|
||||||
tauri::async_runtime::spawn_blocking(move || {
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
if let Err(e) = switch_provider_internal(&app_handle, app_type, provider_id) {
|
if let Err(e) = handle_provider_click(&app_handle, &app_type, &provider_id) {
|
||||||
log::error!("切换{}供应商失败: {e}", section.log_name);
|
log::error!("切换{}供应商失败: {e}", section.log_name);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -166,6 +206,143 @@ pub fn handle_provider_tray_event(app: &tauri::AppHandle, event_id: &str) -> boo
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 处理 Auto 点击:启用 proxy 和 auto_failover
|
||||||
|
fn handle_auto_click(app: &tauri::AppHandle, app_type: &AppType) -> Result<(), AppError> {
|
||||||
|
if let Some(app_state) = app.try_state::<AppState>() {
|
||||||
|
let app_type_str = app_type.as_str();
|
||||||
|
|
||||||
|
// 强一致语义:Auto 模式开启后立即切到队列 P1(P1→P2→...)
|
||||||
|
// 若队列为空,则尝试把“当前供应商”自动加入队列作为 P1,避免用户陷入无法开启的死锁。
|
||||||
|
let mut queue = app_state.db.get_failover_queue(app_type_str)?;
|
||||||
|
if queue.is_empty() {
|
||||||
|
let current_id =
|
||||||
|
crate::settings::get_effective_current_provider(&app_state.db, app_type)?;
|
||||||
|
let Some(current_id) = current_id else {
|
||||||
|
return Err(AppError::Message(
|
||||||
|
"故障转移队列为空,且未设置当前供应商,无法启用 Auto 模式".to_string(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
app_state
|
||||||
|
.db
|
||||||
|
.add_to_failover_queue(app_type_str, ¤t_id)?;
|
||||||
|
queue = app_state.db.get_failover_queue(app_type_str)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let p1_provider_id = queue
|
||||||
|
.first()
|
||||||
|
.map(|item| item.provider_id.clone())
|
||||||
|
.ok_or_else(|| AppError::Message("故障转移队列为空,无法启用 Auto 模式".to_string()))?;
|
||||||
|
|
||||||
|
// 真正启用 failover:启动代理服务 + 执行接管 + 开启 auto_failover
|
||||||
|
let proxy_service = &app_state.proxy_service;
|
||||||
|
|
||||||
|
// 1) 确保代理服务运行(会自动设置 proxy_enabled = true)
|
||||||
|
let is_running = futures::executor::block_on(proxy_service.is_running());
|
||||||
|
if !is_running {
|
||||||
|
log::info!("[Tray] Auto 模式:启动代理服务");
|
||||||
|
if let Err(e) = futures::executor::block_on(proxy_service.start()) {
|
||||||
|
log::error!("[Tray] 启动代理服务失败: {e}");
|
||||||
|
return Err(AppError::Message(format!("启动代理服务失败: {e}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 执行 Live 配置接管(确保该 app 被代理接管)
|
||||||
|
log::info!("[Tray] Auto 模式:对 {app_type_str} 执行接管");
|
||||||
|
if let Err(e) =
|
||||||
|
futures::executor::block_on(proxy_service.set_takeover_for_app(app_type_str, true))
|
||||||
|
{
|
||||||
|
log::error!("[Tray] 执行接管失败: {e}");
|
||||||
|
return Err(AppError::Message(format!("执行接管失败: {e}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 设置 auto_failover_enabled = true
|
||||||
|
app_state
|
||||||
|
.db
|
||||||
|
.set_proxy_flags_sync(app_type_str, true, true)?;
|
||||||
|
|
||||||
|
// 3.1) 立即切到队列 P1(热切换:不写 Live,仅更新 DB/settings/备份)
|
||||||
|
if let Err(e) = futures::executor::block_on(
|
||||||
|
proxy_service.switch_proxy_target(app_type_str, &p1_provider_id),
|
||||||
|
) {
|
||||||
|
log::error!("[Tray] Auto 模式切换到队列 P1 失败: {e}");
|
||||||
|
return Err(AppError::Message(format!(
|
||||||
|
"Auto 模式切换到队列 P1 失败: {e}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) 更新托盘菜单
|
||||||
|
if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) {
|
||||||
|
if let Some(tray) = app.tray_by_id("main") {
|
||||||
|
let _ = tray.set_menu(Some(new_menu));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) 发射事件到前端
|
||||||
|
let event_data = serde_json::json!({
|
||||||
|
"appType": app_type_str,
|
||||||
|
"proxyEnabled": true,
|
||||||
|
"autoFailoverEnabled": true,
|
||||||
|
"providerId": p1_provider_id
|
||||||
|
});
|
||||||
|
if let Err(e) = app.emit("proxy-flags-changed", event_data.clone()) {
|
||||||
|
log::error!("发射 proxy-flags-changed 事件失败: {e}");
|
||||||
|
}
|
||||||
|
// 发射 provider-switched 事件(保持向后兼容,Auto 切换也算一种切换)
|
||||||
|
if let Err(e) = app.emit("provider-switched", event_data) {
|
||||||
|
log::error!("发射 provider-switched 事件失败: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 处理供应商点击:关闭 auto_failover + 切换供应商
|
||||||
|
fn handle_provider_click(
|
||||||
|
app: &tauri::AppHandle,
|
||||||
|
app_type: &AppType,
|
||||||
|
provider_id: &str,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
if let Some(app_state) = app.try_state::<AppState>() {
|
||||||
|
let app_type_str = app_type.as_str();
|
||||||
|
|
||||||
|
// 获取当前 proxy 状态,保持 enabled 不变,只关闭 auto_failover
|
||||||
|
let (proxy_enabled, _) = app_state.db.get_proxy_flags_sync(app_type_str);
|
||||||
|
app_state
|
||||||
|
.db
|
||||||
|
.set_proxy_flags_sync(app_type_str, proxy_enabled, false)?;
|
||||||
|
|
||||||
|
// 切换供应商
|
||||||
|
crate::commands::switch_provider(
|
||||||
|
app_state.clone(),
|
||||||
|
app_type_str.to_string(),
|
||||||
|
provider_id.to_string(),
|
||||||
|
)
|
||||||
|
.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") {
|
||||||
|
let _ = tray.set_menu(Some(new_menu));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发射事件到前端
|
||||||
|
let event_data = serde_json::json!({
|
||||||
|
"appType": app_type_str,
|
||||||
|
"proxyEnabled": proxy_enabled,
|
||||||
|
"autoFailoverEnabled": false,
|
||||||
|
"providerId": provider_id
|
||||||
|
});
|
||||||
|
if let Err(e) = app.emit("proxy-flags-changed", event_data.clone()) {
|
||||||
|
log::error!("发射 proxy-flags-changed 事件失败: {e}");
|
||||||
|
}
|
||||||
|
// 发射 provider-switched 事件(保持向后兼容)
|
||||||
|
if let Err(e) = app.emit("provider-switched", event_data) {
|
||||||
|
log::error!("发射 provider-switched 事件失败: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// 创建动态托盘菜单
|
/// 创建动态托盘菜单
|
||||||
pub fn create_tray_menu(
|
pub fn create_tray_menu(
|
||||||
app: &tauri::AppHandle,
|
app: &tauri::AppHandle,
|
||||||
@@ -174,6 +351,9 @@ pub fn create_tray_menu(
|
|||||||
let app_settings = crate::settings::get_settings();
|
let app_settings = crate::settings::get_settings();
|
||||||
let tray_texts = TrayTexts::from_language(app_settings.language.as_deref().unwrap_or("zh"));
|
let tray_texts = TrayTexts::from_language(app_settings.language.as_deref().unwrap_or("zh"));
|
||||||
|
|
||||||
|
// Get visible apps setting, default to all visible
|
||||||
|
let visible_apps = app_settings.visible_apps.unwrap_or_default();
|
||||||
|
|
||||||
let mut menu_builder = MenuBuilder::new(app);
|
let mut menu_builder = MenuBuilder::new(app);
|
||||||
|
|
||||||
// 顶部:打开主界面
|
// 顶部:打开主界面
|
||||||
@@ -183,7 +363,13 @@ pub fn create_tray_menu(
|
|||||||
menu_builder = menu_builder.item(&show_main_item).separator();
|
menu_builder = menu_builder.item(&show_main_item).separator();
|
||||||
|
|
||||||
// 直接添加所有供应商到主菜单(扁平化结构,更简单可靠)
|
// 直接添加所有供应商到主菜单(扁平化结构,更简单可靠)
|
||||||
|
// Only add visible app sections
|
||||||
for section in TRAY_SECTIONS.iter() {
|
for section in TRAY_SECTIONS.iter() {
|
||||||
|
// Skip hidden apps
|
||||||
|
if !visible_apps.is_visible(§ion.app_type) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let app_type_str = section.app_type.as_str();
|
let app_type_str = section.app_type.as_str();
|
||||||
let providers = app_state.db.get_all_providers(app_type_str)?;
|
let providers = app_state.db.get_all_providers(app_type_str)?;
|
||||||
|
|
||||||
@@ -197,8 +383,14 @@ pub fn create_tray_menu(
|
|||||||
current: current_id,
|
current: current_id,
|
||||||
};
|
};
|
||||||
|
|
||||||
menu_builder =
|
menu_builder = append_provider_section(
|
||||||
append_provider_section(app, menu_builder, Some(&manager), section, &tray_texts)?;
|
app,
|
||||||
|
menu_builder,
|
||||||
|
Some(&manager),
|
||||||
|
section,
|
||||||
|
&tray_texts,
|
||||||
|
app_state,
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分隔符和退出菜单
|
// 分隔符和退出菜单
|
||||||
@@ -263,38 +455,3 @@ pub fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 内部切换供应商函数
|
|
||||||
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,8 +1,6 @@
|
|||||||
use reqwest::Client;
|
|
||||||
use rquickjs::{Context, Function, Runtime};
|
use rquickjs::{Context, Function, Runtime};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::time::Duration;
|
|
||||||
use url::{Host, Url};
|
use url::{Host, Url};
|
||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
@@ -15,13 +13,21 @@ pub async fn execute_usage_script(
|
|||||||
timeout_secs: u64,
|
timeout_secs: u64,
|
||||||
access_token: Option<&str>,
|
access_token: Option<&str>,
|
||||||
user_id: Option<&str>,
|
user_id: Option<&str>,
|
||||||
|
template_type: Option<&str>,
|
||||||
) -> Result<Value, AppError> {
|
) -> Result<Value, AppError> {
|
||||||
|
// 检测是否为自定义模板模式
|
||||||
|
// 优先使用前端传递的 template_type
|
||||||
|
let is_custom_template = template_type.map(|t| t == "custom").unwrap_or(false);
|
||||||
|
|
||||||
// 1. 替换模板变量,避免泄露敏感信息
|
// 1. 替换模板变量,避免泄露敏感信息
|
||||||
let script_with_vars =
|
let script_with_vars =
|
||||||
build_script_with_vars(script_code, api_key, base_url, access_token, user_id);
|
build_script_with_vars(script_code, api_key, base_url, access_token, user_id);
|
||||||
|
|
||||||
// 2. 验证 base_url 的安全性
|
// 2. 验证 base_url 的安全性(仅当提供了 base_url 时)
|
||||||
validate_base_url(base_url)?;
|
// 自定义模板模式下,用户可能不使用模板变量,而是直接在脚本中写完整 URL
|
||||||
|
if !base_url.is_empty() {
|
||||||
|
validate_base_url(base_url)?;
|
||||||
|
}
|
||||||
|
|
||||||
// 3. 在独立作用域中提取 request 配置(确保 Runtime/Context 在 await 前释放)
|
// 3. 在独立作用域中提取 request 配置(确保 Runtime/Context 在 await 前释放)
|
||||||
let request_config = {
|
let request_config = {
|
||||||
@@ -99,7 +105,8 @@ pub async fn execute_usage_script(
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
// 5. 验证请求 URL 是否安全(防止 SSRF)
|
// 5. 验证请求 URL 是否安全(防止 SSRF)
|
||||||
validate_request_url(&request.url, base_url)?;
|
// 如果提供了 base_url,则验证同源;否则只做基本安全检查
|
||||||
|
validate_request_url(&request.url, base_url, is_custom_template)?;
|
||||||
|
|
||||||
// 6. 发送 HTTP 请求
|
// 6. 发送 HTTP 请求
|
||||||
let response_data = send_http_request(&request, timeout_secs).await?;
|
let response_data = send_http_request(&request, timeout_secs).await?;
|
||||||
@@ -215,18 +222,10 @@ struct RequestConfig {
|
|||||||
|
|
||||||
/// 发送 HTTP 请求
|
/// 发送 HTTP 请求
|
||||||
async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<String, AppError> {
|
async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<String, AppError> {
|
||||||
// 约束超时范围,防止异常配置导致长时间阻塞
|
// 使用全局 HTTP 客户端(已包含代理配置)
|
||||||
let timeout = timeout_secs.clamp(2, 30);
|
let client = crate::proxy::http_client::get();
|
||||||
let client = Client::builder()
|
// 约束超时范围,防止异常配置导致长时间阻塞(最小 2 秒,最大 30 秒)
|
||||||
.timeout(Duration::from_secs(timeout))
|
let request_timeout = std::time::Duration::from_secs(timeout_secs.clamp(2, 30));
|
||||||
.build()
|
|
||||||
.map_err(|e| {
|
|
||||||
AppError::localized(
|
|
||||||
"usage_script.client_create_failed",
|
|
||||||
format!("创建客户端失败: {e}"),
|
|
||||||
format!("Failed to create client: {e}"),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// 严格校验 HTTP 方法,非法值不回退为 GET
|
// 严格校验 HTTP 方法,非法值不回退为 GET
|
||||||
let method: reqwest::Method = config.method.parse().map_err(|_| {
|
let method: reqwest::Method = config.method.parse().map_err(|_| {
|
||||||
@@ -237,7 +236,9 @@ async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let mut req = client.request(method.clone(), &config.url);
|
let mut req = client
|
||||||
|
.request(method.clone(), &config.url)
|
||||||
|
.timeout(request_timeout);
|
||||||
|
|
||||||
// 添加请求头
|
// 添加请求头
|
||||||
for (k, v) in &config.headers {
|
for (k, v) in &config.headers {
|
||||||
@@ -480,7 +481,11 @@ fn validate_base_url(base_url: &str) -> Result<(), AppError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 验证请求 URL 是否安全(防止 SSRF)
|
/// 验证请求 URL 是否安全(防止 SSRF)
|
||||||
fn validate_request_url(request_url: &str, base_url: &str) -> Result<(), AppError> {
|
fn validate_request_url(
|
||||||
|
request_url: &str,
|
||||||
|
base_url: &str,
|
||||||
|
is_custom_template: bool,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
// 解析请求 URL
|
// 解析请求 URL
|
||||||
let parsed_request = Url::parse(request_url).map_err(|e| {
|
let parsed_request = Url::parse(request_url).map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
@@ -490,19 +495,11 @@ fn validate_request_url(request_url: &str, base_url: &str) -> Result<(), AppErro
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// 解析 base URL
|
|
||||||
let parsed_base = Url::parse(base_url).map_err(|e| {
|
|
||||||
AppError::localized(
|
|
||||||
"usage_script.base_url_invalid",
|
|
||||||
format!("无效的 base_url: {e}"),
|
|
||||||
format!("Invalid base_url: {e}"),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let is_request_loopback = is_loopback_host(&parsed_request);
|
let is_request_loopback = is_loopback_host(&parsed_request);
|
||||||
|
|
||||||
// 必须使用 HTTPS(允许 localhost 用于开发)
|
// 必须使用 HTTPS(允许 localhost 用于开发)
|
||||||
if parsed_request.scheme() != "https" && !is_request_loopback {
|
// 自定义模板模式下,允许用户自行决定是否使用 HTTP(用户需自行承担安全风险)
|
||||||
|
if !is_custom_template && parsed_request.scheme() != "https" && !is_request_loopback {
|
||||||
return Err(AppError::localized(
|
return Err(AppError::localized(
|
||||||
"usage_script.request_https_required",
|
"usage_script.request_https_required",
|
||||||
"请求 URL 必须使用 HTTPS 协议(localhost 除外)",
|
"请求 URL 必须使用 HTTPS 协议(localhost 除外)",
|
||||||
@@ -510,60 +507,85 @@ fn validate_request_url(request_url: &str, base_url: &str) -> Result<(), AppErro
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 核心安全检查:必须与 base_url 同源(相同域名和端口)
|
// 如果提供了 base_url(非空),则进行同源检查
|
||||||
if parsed_request.host_str() != parsed_base.host_str() {
|
// 🔧 自定义模板模式下,用户可以自由访问任意 HTTPS 域名,跳过同源检查
|
||||||
return Err(AppError::localized(
|
if !base_url.is_empty() && !is_custom_template {
|
||||||
"usage_script.request_host_mismatch",
|
// 解析 base URL
|
||||||
format!(
|
let parsed_base = Url::parse(base_url).map_err(|e| {
|
||||||
"请求域名 {} 与 base_url 域名 {} 不匹配(必须是同源请求)",
|
AppError::localized(
|
||||||
parsed_request.host_str().unwrap_or("unknown"),
|
"usage_script.base_url_invalid",
|
||||||
parsed_base.host_str().unwrap_or("unknown")
|
format!("无效的 base_url: {e}"),
|
||||||
),
|
format!("Invalid base_url: {e}"),
|
||||||
format!(
|
)
|
||||||
"Request host {} must match base_url host {} (same-origin required)",
|
})?;
|
||||||
parsed_request.host_str().unwrap_or("unknown"),
|
|
||||||
parsed_base.host_str().unwrap_or("unknown")
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查端口是否匹配(考虑默认端口)
|
// 核心安全检查:必须与 base_url 同源(相同域名和端口)
|
||||||
// 使用 port_or_known_default() 会自动处理默认端口(http->80, https->443)
|
if parsed_request.host_str() != parsed_base.host_str() {
|
||||||
match (
|
|
||||||
parsed_request.port_or_known_default(),
|
|
||||||
parsed_base.port_or_known_default(),
|
|
||||||
) {
|
|
||||||
(Some(request_port), Some(base_port)) if request_port == base_port => {
|
|
||||||
// 端口匹配,继续执行
|
|
||||||
}
|
|
||||||
(Some(request_port), Some(base_port)) => {
|
|
||||||
return Err(AppError::localized(
|
return Err(AppError::localized(
|
||||||
"usage_script.request_port_mismatch",
|
"usage_script.request_host_mismatch",
|
||||||
format!("请求端口 {request_port} 必须与 base_url 端口 {base_port} 匹配"),
|
format!(
|
||||||
format!("Request port {request_port} must match base_url port {base_port}"),
|
"请求域名 {} 与 base_url 域名 {} 不匹配(必须是同源请求)",
|
||||||
|
parsed_request.host_str().unwrap_or("unknown"),
|
||||||
|
parsed_base.host_str().unwrap_or("unknown")
|
||||||
|
),
|
||||||
|
format!(
|
||||||
|
"Request host {} must match base_url host {} (same-origin required)",
|
||||||
|
parsed_request.host_str().unwrap_or("unknown"),
|
||||||
|
parsed_base.host_str().unwrap_or("unknown")
|
||||||
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
_ => {
|
|
||||||
// 理论上不会发生,因为 port_or_known_default() 应该总是返回 Some
|
// 检查端口是否匹配(考虑默认端口)
|
||||||
return Err(AppError::localized(
|
// 使用 port_or_known_default() 会自动处理默认端口(http->80, https->443)
|
||||||
"usage_script.request_port_unknown",
|
match (
|
||||||
"无法确定端口号",
|
parsed_request.port_or_known_default(),
|
||||||
"Unable to determine port number",
|
parsed_base.port_or_known_default(),
|
||||||
));
|
) {
|
||||||
|
(Some(request_port), Some(base_port)) if request_port == base_port => {
|
||||||
|
// 端口匹配,继续执行
|
||||||
|
}
|
||||||
|
(Some(request_port), Some(base_port)) => {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"usage_script.request_port_mismatch",
|
||||||
|
format!("请求端口 {request_port} 必须与 base_url 端口 {base_port} 匹配"),
|
||||||
|
format!("Request port {request_port} must match base_url port {base_port}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// 理论上不会发生,因为 port_or_known_default() 应该总是返回 Some
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"usage_script.request_port_unknown",
|
||||||
|
"无法确定端口号",
|
||||||
|
"Unable to determine port number",
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 禁止私有 IP 地址访问(除非 base_url 本身就是私有地址,用于开发环境)
|
// 禁止私有 IP 地址访问(除非 base_url 本身就是私有地址,用于开发环境)
|
||||||
if let Some(host) = parsed_request.host_str() {
|
if let Some(host) = parsed_request.host_str() {
|
||||||
let base_host = parsed_base.host_str().unwrap_or("");
|
let base_host = parsed_base.host_str().unwrap_or("");
|
||||||
|
|
||||||
// 如果 base_url 不是私有地址,则禁止访问私有IP
|
// 如果 base_url 不是私有地址,则禁止访问私有IP
|
||||||
if !is_private_ip(base_host) && is_private_ip(host) {
|
if !is_private_ip(base_host) && is_private_ip(host) {
|
||||||
return Err(AppError::localized(
|
return Err(AppError::localized(
|
||||||
"usage_script.private_ip_blocked",
|
"usage_script.private_ip_blocked",
|
||||||
"禁止访问私有 IP 地址",
|
"禁止访问私有 IP 地址",
|
||||||
"Access to private IP addresses is blocked",
|
"Access to private IP addresses is blocked",
|
||||||
));
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 自定义模板模式:没有 base_url,需要额外的安全检查
|
||||||
|
// 禁止访问私有 IP 地址(SSRF 防护)
|
||||||
|
if let Some(host) = parsed_request.host_str() {
|
||||||
|
if is_private_ip(host) && !is_request_loopback {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"usage_script.private_ip_blocked",
|
||||||
|
"禁止访问私有 IP 地址(localhost 除外)",
|
||||||
|
"Access to private IP addresses is blocked (localhost allowed)",
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -851,7 +873,7 @@ mod tests {
|
|||||||
];
|
];
|
||||||
|
|
||||||
for (base_url, request_url, should_match) in test_cases {
|
for (base_url, request_url, should_match) in test_cases {
|
||||||
let result = validate_request_url(request_url, base_url);
|
let result = validate_request_url(request_url, base_url, false);
|
||||||
|
|
||||||
if should_match {
|
if should_match {
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -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.9.1",
|
"version": "3.10.2",
|
||||||
"identifier": "com.ccswitch.desktop",
|
"identifier": "com.ccswitch.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
|
|||||||
@@ -553,6 +553,7 @@ command = "echo"
|
|||||||
claude: false,
|
claude: false,
|
||||||
codex: false, // 初始未启用
|
codex: false, // 初始未启用
|
||||||
gemini: false,
|
gemini: false,
|
||||||
|
opencode: false,
|
||||||
},
|
},
|
||||||
description: None,
|
description: None,
|
||||||
homepage: None,
|
homepage: None,
|
||||||
@@ -680,6 +681,7 @@ fn import_from_claude_merges_into_config() {
|
|||||||
claude: false, // 初始未启用
|
claude: false, // 初始未启用
|
||||||
codex: false,
|
codex: false,
|
||||||
gemini: false,
|
gemini: false,
|
||||||
|
opencode: false,
|
||||||
},
|
},
|
||||||
description: None,
|
description: None,
|
||||||
homepage: None,
|
homepage: None,
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ fn set_mcp_enabled_for_codex_writes_live_config() {
|
|||||||
claude: false,
|
claude: false,
|
||||||
codex: false, // 初始未启用
|
codex: false, // 初始未启用
|
||||||
gemini: false,
|
gemini: false,
|
||||||
|
opencode: false,
|
||||||
},
|
},
|
||||||
description: None,
|
description: None,
|
||||||
homepage: None,
|
homepage: None,
|
||||||
@@ -277,6 +278,7 @@ fn enabling_codex_mcp_skips_when_codex_dir_missing() {
|
|||||||
claude: false,
|
claude: false,
|
||||||
codex: false,
|
codex: false,
|
||||||
gemini: false,
|
gemini: false,
|
||||||
|
opencode: false,
|
||||||
},
|
},
|
||||||
description: None,
|
description: None,
|
||||||
homepage: None,
|
homepage: None,
|
||||||
@@ -320,6 +322,7 @@ fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() {
|
|||||||
claude: true,
|
claude: true,
|
||||||
codex: false,
|
codex: false,
|
||||||
gemini: false,
|
gemini: false,
|
||||||
|
opencode: false,
|
||||||
},
|
},
|
||||||
description: None,
|
description: None,
|
||||||
homepage: None,
|
homepage: None,
|
||||||
@@ -352,6 +355,7 @@ fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() {
|
|||||||
claude: false,
|
claude: false,
|
||||||
codex: false,
|
codex: false,
|
||||||
gemini: false,
|
gemini: false,
|
||||||
|
opencode: false,
|
||||||
},
|
},
|
||||||
description: None,
|
description: None,
|
||||||
homepage: None,
|
homepage: None,
|
||||||
@@ -483,6 +487,7 @@ fn enabling_gemini_mcp_skips_when_gemini_dir_missing() {
|
|||||||
claude: false,
|
claude: false,
|
||||||
codex: false,
|
codex: false,
|
||||||
gemini: false,
|
gemini: false,
|
||||||
|
opencode: false,
|
||||||
},
|
},
|
||||||
description: None,
|
description: None,
|
||||||
homepage: None,
|
homepage: None,
|
||||||
@@ -536,6 +541,7 @@ fn enabling_claude_mcp_skips_when_claude_config_absent() {
|
|||||||
claude: false,
|
claude: false,
|
||||||
codex: false,
|
codex: false,
|
||||||
gemini: false,
|
gemini: false,
|
||||||
|
opencode: false,
|
||||||
},
|
},
|
||||||
description: None,
|
description: None,
|
||||||
homepage: None,
|
homepage: None,
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ command = "say"
|
|||||||
claude: false,
|
claude: false,
|
||||||
codex: true, // 启用 Codex
|
codex: true, // 启用 Codex
|
||||||
gemini: false,
|
gemini: false,
|
||||||
|
opencode: false,
|
||||||
},
|
},
|
||||||
description: None,
|
description: None,
|
||||||
homepage: None,
|
homepage: None,
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ command = "say"
|
|||||||
claude: false,
|
claude: false,
|
||||||
codex: true,
|
codex: true,
|
||||||
gemini: false,
|
gemini: false,
|
||||||
|
opencode: false,
|
||||||
},
|
},
|
||||||
description: None,
|
description: None,
|
||||||
homepage: None,
|
homepage: None,
|
||||||
|
|||||||
+212
-48
@@ -11,14 +11,14 @@ import {
|
|||||||
// Bot, // TODO: Agents 功能开发中,暂时不需要
|
// Bot, // TODO: Agents 功能开发中,暂时不需要
|
||||||
Book,
|
Book,
|
||||||
Wrench,
|
Wrench,
|
||||||
Server,
|
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
Download,
|
Download,
|
||||||
|
BarChart2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Provider } from "@/types";
|
import type { Provider, VisibleApps } from "@/types";
|
||||||
import type { EnvConflict } from "@/types/env";
|
import type { EnvConflict } from "@/types/env";
|
||||||
import { useProvidersQuery } from "@/lib/query";
|
import { useProvidersQuery, useSettingsQuery } from "@/lib/query";
|
||||||
import {
|
import {
|
||||||
providersApi,
|
providersApi,
|
||||||
settingsApi,
|
settingsApi,
|
||||||
@@ -30,6 +30,7 @@ import { useProviderActions } from "@/hooks/useProviderActions";
|
|||||||
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
||||||
import { useLastValidValue } from "@/hooks/useLastValidValue";
|
import { useLastValidValue } from "@/hooks/useLastValidValue";
|
||||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||||
|
import { isTextEditableTarget } from "@/utils/domUtils";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { isWindows, isLinux } from "@/lib/platform";
|
import { isWindows, isLinux } from "@/lib/platform";
|
||||||
import { AppSwitcher } from "@/components/AppSwitcher";
|
import { AppSwitcher } from "@/components/AppSwitcher";
|
||||||
@@ -41,6 +42,7 @@ import { SettingsPage } from "@/components/settings/SettingsPage";
|
|||||||
import { UpdateBadge } from "@/components/UpdateBadge";
|
import { UpdateBadge } from "@/components/UpdateBadge";
|
||||||
import { EnvWarningBanner } from "@/components/env/EnvWarningBanner";
|
import { EnvWarningBanner } from "@/components/env/EnvWarningBanner";
|
||||||
import { ProxyToggle } from "@/components/proxy/ProxyToggle";
|
import { ProxyToggle } from "@/components/proxy/ProxyToggle";
|
||||||
|
import { FailoverToggle } from "@/components/proxy/FailoverToggle";
|
||||||
import UsageScriptModal from "@/components/UsageScriptModal";
|
import UsageScriptModal from "@/components/UsageScriptModal";
|
||||||
import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel";
|
import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel";
|
||||||
import PromptPanel from "@/components/prompts/PromptPanel";
|
import PromptPanel from "@/components/prompts/PromptPanel";
|
||||||
@@ -49,6 +51,7 @@ import UnifiedSkillsPanel from "@/components/skills/UnifiedSkillsPanel";
|
|||||||
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
|
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
|
||||||
import { AgentsPanel } from "@/components/agents/AgentsPanel";
|
import { AgentsPanel } from "@/components/agents/AgentsPanel";
|
||||||
import { UniversalProviderPanel } from "@/components/universal";
|
import { UniversalProviderPanel } from "@/components/universal";
|
||||||
|
import { McpIcon } from "@/components/BrandIcons";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
type View =
|
type View =
|
||||||
@@ -75,9 +78,38 @@ function App() {
|
|||||||
const [settingsDefaultTab, setSettingsDefaultTab] = useState("general");
|
const [settingsDefaultTab, setSettingsDefaultTab] = useState("general");
|
||||||
const [isAddOpen, setIsAddOpen] = useState(false);
|
const [isAddOpen, setIsAddOpen] = useState(false);
|
||||||
|
|
||||||
|
// Get settings for visibleApps
|
||||||
|
const { data: settingsData } = useSettingsQuery();
|
||||||
|
const visibleApps: VisibleApps = settingsData?.visibleApps ?? {
|
||||||
|
claude: true,
|
||||||
|
codex: true,
|
||||||
|
gemini: true,
|
||||||
|
opencode: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get first visible app for fallback
|
||||||
|
const getFirstVisibleApp = (): AppId => {
|
||||||
|
if (visibleApps.claude) return "claude";
|
||||||
|
if (visibleApps.codex) return "codex";
|
||||||
|
if (visibleApps.gemini) return "gemini";
|
||||||
|
if (visibleApps.opencode) return "opencode";
|
||||||
|
return "claude"; // fallback
|
||||||
|
};
|
||||||
|
|
||||||
|
// If current active app is hidden, switch to first visible app
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visibleApps[activeApp]) {
|
||||||
|
setActiveApp(getFirstVisibleApp());
|
||||||
|
}
|
||||||
|
}, [visibleApps, activeApp]);
|
||||||
|
|
||||||
const [editingProvider, setEditingProvider] = useState<Provider | null>(null);
|
const [editingProvider, setEditingProvider] = useState<Provider | null>(null);
|
||||||
const [usageProvider, setUsageProvider] = useState<Provider | null>(null);
|
const [usageProvider, setUsageProvider] = useState<Provider | null>(null);
|
||||||
const [confirmDelete, setConfirmDelete] = useState<Provider | null>(null);
|
// Confirm action state: 'remove' = remove from live config, 'delete' = delete from database
|
||||||
|
const [confirmAction, setConfirmAction] = useState<{
|
||||||
|
provider: Provider;
|
||||||
|
action: "remove" | "delete";
|
||||||
|
} | null>(null);
|
||||||
const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]);
|
const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]);
|
||||||
const [showEnvBanner, setShowEnvBanner] = useState(false);
|
const [showEnvBanner, setShowEnvBanner] = useState(false);
|
||||||
|
|
||||||
@@ -287,18 +319,40 @@ function App() {
|
|||||||
checkEnvOnSwitch();
|
checkEnvOnSwitch();
|
||||||
}, [activeApp]);
|
}, [activeApp]);
|
||||||
|
|
||||||
|
// 全局键盘快捷键
|
||||||
|
const currentViewRef = useRef(currentView);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleGlobalShortcut = (event: KeyboardEvent) => {
|
currentViewRef.current = currentView;
|
||||||
if (event.key !== "," || !(event.metaKey || event.ctrlKey)) {
|
}, [currentView]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
// Cmd/Ctrl + , 打开设置
|
||||||
|
if (event.key === "," && (event.metaKey || event.ctrlKey)) {
|
||||||
|
event.preventDefault();
|
||||||
|
setCurrentView("settings");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ESC 键返回
|
||||||
|
if (event.key !== "Escape" || event.defaultPrevented) return;
|
||||||
|
|
||||||
|
// 如果有模态框打开(通过 overflow hidden 判断),则不处理全局 ESC,交给模态框处理
|
||||||
|
if (document.body.style.overflow === "hidden") return;
|
||||||
|
|
||||||
|
const view = currentViewRef.current;
|
||||||
|
if (view === "providers") return;
|
||||||
|
|
||||||
|
if (isTextEditableTarget(event.target)) return;
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setCurrentView("settings");
|
setCurrentView(view === "skillsDiscovery" ? "skills" : "providers");
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("keydown", handleGlobalShortcut);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("keydown", handleGlobalShortcut);
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -322,11 +376,49 @@ function App() {
|
|||||||
setEditingProvider(null);
|
setEditingProvider(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 确认删除供应商
|
// 确认删除/移除供应商
|
||||||
const handleConfirmDelete = async () => {
|
const handleConfirmAction = async () => {
|
||||||
if (!confirmDelete) return;
|
if (!confirmAction) return;
|
||||||
await deleteProvider(confirmDelete.id);
|
const { provider, action } = confirmAction;
|
||||||
setConfirmDelete(null);
|
|
||||||
|
if (action === "remove") {
|
||||||
|
// Remove from live config only (for additive mode apps like OpenCode)
|
||||||
|
// Does NOT delete from database - provider remains in the list
|
||||||
|
await providersApi.removeFromLiveConfig(provider.id, activeApp);
|
||||||
|
// Invalidate queries to refresh the isInConfig state
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: ["opencodeLiveProviderIds"],
|
||||||
|
});
|
||||||
|
toast.success(
|
||||||
|
t("notifications.removeFromConfigSuccess", {
|
||||||
|
defaultValue: "已从配置移除",
|
||||||
|
}),
|
||||||
|
{ closeButton: true },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Delete from database
|
||||||
|
await deleteProvider(provider.id);
|
||||||
|
}
|
||||||
|
setConfirmAction(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate a unique provider key for OpenCode duplication
|
||||||
|
const generateUniqueOpencodeKey = (
|
||||||
|
originalKey: string,
|
||||||
|
existingKeys: string[],
|
||||||
|
): string => {
|
||||||
|
const baseKey = `${originalKey}-copy`;
|
||||||
|
|
||||||
|
if (!existingKeys.includes(baseKey)) {
|
||||||
|
return baseKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If -copy already exists, try -copy-2, -copy-3, ...
|
||||||
|
let counter = 2;
|
||||||
|
while (existingKeys.includes(`${baseKey}-${counter}`)) {
|
||||||
|
counter++;
|
||||||
|
}
|
||||||
|
return `${baseKey}-${counter}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 复制供应商
|
// 复制供应商
|
||||||
@@ -335,7 +427,9 @@ function App() {
|
|||||||
const newSortIndex =
|
const newSortIndex =
|
||||||
provider.sortIndex !== undefined ? provider.sortIndex + 1 : undefined;
|
provider.sortIndex !== undefined ? provider.sortIndex + 1 : undefined;
|
||||||
|
|
||||||
const duplicatedProvider: Omit<Provider, "id" | "createdAt"> = {
|
const duplicatedProvider: Omit<Provider, "id" | "createdAt"> & {
|
||||||
|
providerKey?: string;
|
||||||
|
} = {
|
||||||
name: `${provider.name} copy`,
|
name: `${provider.name} copy`,
|
||||||
settingsConfig: JSON.parse(JSON.stringify(provider.settingsConfig)), // 深拷贝
|
settingsConfig: JSON.parse(JSON.stringify(provider.settingsConfig)), // 深拷贝
|
||||||
websiteUrl: provider.websiteUrl,
|
websiteUrl: provider.websiteUrl,
|
||||||
@@ -348,6 +442,15 @@ function App() {
|
|||||||
iconColor: provider.iconColor,
|
iconColor: provider.iconColor,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// OpenCode: generate unique provider key (used as ID)
|
||||||
|
if (activeApp === "opencode") {
|
||||||
|
const existingKeys = Object.keys(providers);
|
||||||
|
duplicatedProvider.providerKey = generateUniqueOpencodeKey(
|
||||||
|
provider.id,
|
||||||
|
existingKeys,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 2️⃣ 如果原供应商有 sortIndex,需要将后续所有供应商的 sortIndex +1
|
// 2️⃣ 如果原供应商有 sortIndex,需要将后续所有供应商的 sortIndex +1
|
||||||
if (provider.sortIndex !== undefined) {
|
if (provider.sortIndex !== undefined) {
|
||||||
const updates = Object.values(providers)
|
const updates = Object.values(providers)
|
||||||
@@ -454,7 +557,12 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "skillsDiscovery":
|
case "skillsDiscovery":
|
||||||
return <SkillsPage ref={skillsPageRef} initialApp={activeApp} />;
|
return (
|
||||||
|
<SkillsPage
|
||||||
|
ref={skillsPageRef}
|
||||||
|
initialApp={activeApp === "opencode" ? "claude" : activeApp}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case "mcp":
|
case "mcp":
|
||||||
return (
|
return (
|
||||||
<UnifiedMcpPanel
|
<UnifiedMcpPanel
|
||||||
@@ -468,13 +576,13 @@ function App() {
|
|||||||
);
|
);
|
||||||
case "universal":
|
case "universal":
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-[56rem] px-5 pt-4">
|
<div className="px-6 pt-4">
|
||||||
<UniversalProviderPanel />
|
<UniversalProviderPanel />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-[56rem] px-5 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
<div className="px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
||||||
{/* 独立滚动容器 - 解决 Linux/Ubuntu 下 DndContext 与滚轮事件冲突 */}
|
{/* 独立滚动容器 - 解决 Linux/Ubuntu 下 DndContext 与滚轮事件冲突 */}
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-12 px-1">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-12 px-1">
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
@@ -498,11 +606,21 @@ function App() {
|
|||||||
activeProviderId={activeProviderId}
|
activeProviderId={activeProviderId}
|
||||||
onSwitch={switchProvider}
|
onSwitch={switchProvider}
|
||||||
onEdit={setEditingProvider}
|
onEdit={setEditingProvider}
|
||||||
onDelete={setConfirmDelete}
|
onDelete={(provider) =>
|
||||||
|
setConfirmAction({ provider, action: "delete" })
|
||||||
|
}
|
||||||
|
onRemoveFromConfig={
|
||||||
|
activeApp === "opencode"
|
||||||
|
? (provider) =>
|
||||||
|
setConfirmAction({ provider, action: "remove" })
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onDuplicate={handleDuplicateProvider}
|
onDuplicate={handleDuplicateProvider}
|
||||||
onConfigureUsage={setUsageProvider}
|
onConfigureUsage={setUsageProvider}
|
||||||
onOpenWebsite={handleOpenWebsite}
|
onOpenWebsite={handleOpenWebsite}
|
||||||
onOpenTerminal={activeApp === "claude" ? handleOpenTerminal : undefined}
|
onOpenTerminal={
|
||||||
|
activeApp === "claude" ? handleOpenTerminal : undefined
|
||||||
|
}
|
||||||
onCreate={() => setIsAddOpen(true)}
|
onCreate={() => setIsAddOpen(true)}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -578,7 +696,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="mx-auto flex h-full max-w-[56rem] flex-wrap items-center justify-between gap-2 px-6"
|
className="flex h-full items-center justify-between gap-2 px-6"
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
style={{ WebkitAppRegion: "drag" } as any}
|
style={{ WebkitAppRegion: "drag" } as any}
|
||||||
>
|
>
|
||||||
@@ -617,8 +735,8 @@ function App() {
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="relative inline-flex items-center">
|
||||||
<a
|
<a
|
||||||
href="https://github.com/farion1231/cc-switch"
|
href="https://github.com/farion1231/cc-switch"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -632,31 +750,47 @@ function App() {
|
|||||||
>
|
>
|
||||||
CC Switch
|
CC Switch
|
||||||
</a>
|
</a>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => {
|
|
||||||
setSettingsDefaultTab("general");
|
|
||||||
setCurrentView("settings");
|
|
||||||
}}
|
|
||||||
title={t("common.settings")}
|
|
||||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
|
||||||
>
|
|
||||||
<Settings className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => {
|
||||||
|
setSettingsDefaultTab("general");
|
||||||
|
setCurrentView("settings");
|
||||||
|
}}
|
||||||
|
title={t("common.settings")}
|
||||||
|
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
<UpdateBadge
|
<UpdateBadge
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSettingsDefaultTab("about");
|
setSettingsDefaultTab("about");
|
||||||
setCurrentView("settings");
|
setCurrentView("settings");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
{isCurrentAppTakeoverActive && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => {
|
||||||
|
setSettingsDefaultTab("usage");
|
||||||
|
setCurrentView("settings");
|
||||||
|
}}
|
||||||
|
title={t("settings.usage.title", {
|
||||||
|
defaultValue: "使用统计",
|
||||||
|
})}
|
||||||
|
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<BarChart2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-2 h-[32px]"
|
className="flex items-center gap-1.5 h-[32px]"
|
||||||
style={{ WebkitAppRegion: "no-drag" } as any}
|
style={{ WebkitAppRegion: "no-drag" } as any}
|
||||||
>
|
>
|
||||||
{currentView === "prompts" && (
|
{currentView === "prompts" && (
|
||||||
@@ -738,9 +872,31 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
{currentView === "providers" && (
|
{currentView === "providers" && (
|
||||||
<>
|
<>
|
||||||
<ProxyToggle activeApp={activeApp} />
|
{activeApp !== "opencode" && (
|
||||||
|
<>
|
||||||
|
<ProxyToggle activeApp={activeApp} />
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"transition-all duration-300 ease-in-out overflow-hidden",
|
||||||
|
isCurrentAppTakeoverActive
|
||||||
|
? "opacity-100 max-w-[100px] scale-100"
|
||||||
|
: "opacity-0 max-w-0 scale-75 pointer-events-none",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FailoverToggle activeApp={activeApp} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
|
<AppSwitcher
|
||||||
|
activeApp={activeApp}
|
||||||
|
onSwitch={setActiveApp}
|
||||||
|
visibleApps={visibleApps}
|
||||||
|
compact={
|
||||||
|
isCurrentAppTakeoverActive &&
|
||||||
|
Object.values(visibleApps).filter(Boolean).length >= 4
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex items-center gap-1 p-1 bg-muted rounded-xl">
|
<div className="flex items-center gap-1 p-1 bg-muted rounded-xl">
|
||||||
<Button
|
<Button
|
||||||
@@ -786,7 +942,7 @@ function App() {
|
|||||||
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={t("mcp.title")}
|
||||||
>
|
>
|
||||||
<Server className="w-4 h-4" />
|
<McpIcon size={16} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -843,17 +999,25 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
isOpen={Boolean(confirmDelete)}
|
isOpen={Boolean(confirmAction)}
|
||||||
title={t("confirm.deleteProvider")}
|
title={
|
||||||
|
confirmAction?.action === "remove"
|
||||||
|
? t("confirm.removeProvider")
|
||||||
|
: t("confirm.deleteProvider")
|
||||||
|
}
|
||||||
message={
|
message={
|
||||||
confirmDelete
|
confirmAction
|
||||||
? t("confirm.deleteProviderMessage", {
|
? confirmAction.action === "remove"
|
||||||
name: confirmDelete.name,
|
? t("confirm.removeProviderMessage", {
|
||||||
})
|
name: confirmAction.provider.name,
|
||||||
|
})
|
||||||
|
: t("confirm.deleteProviderMessage", {
|
||||||
|
name: confirmAction.provider.name,
|
||||||
|
})
|
||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
onConfirm={() => void handleConfirmDelete()}
|
onConfirm={() => void handleConfirmAction()}
|
||||||
onCancel={() => setConfirmDelete(null)}
|
onCancel={() => setConfirmAction(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DeepLinkImportDialog />
|
<DeepLinkImportDialog />
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
@@ -1,12 +1,22 @@
|
|||||||
import type { AppId } from "@/lib/api";
|
import type { AppId } from "@/lib/api";
|
||||||
|
import type { VisibleApps } from "@/types";
|
||||||
import { ProviderIcon } from "@/components/ProviderIcon";
|
import { ProviderIcon } from "@/components/ProviderIcon";
|
||||||
|
|
||||||
interface AppSwitcherProps {
|
interface AppSwitcherProps {
|
||||||
activeApp: AppId;
|
activeApp: AppId;
|
||||||
onSwitch: (app: AppId) => void;
|
onSwitch: (app: AppId) => void;
|
||||||
|
visibleApps?: VisibleApps;
|
||||||
|
compact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
|
const ALL_APPS: AppId[] = ["claude", "codex", "gemini", "opencode"];
|
||||||
|
|
||||||
|
export function AppSwitcher({
|
||||||
|
activeApp,
|
||||||
|
onSwitch,
|
||||||
|
visibleApps,
|
||||||
|
compact,
|
||||||
|
}: AppSwitcherProps) {
|
||||||
const handleSwitch = (app: AppId) => {
|
const handleSwitch = (app: AppId) => {
|
||||||
if (app === activeApp) return;
|
if (app === activeApp) return;
|
||||||
onSwitch(app);
|
onSwitch(app);
|
||||||
@@ -16,80 +26,42 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
|
|||||||
claude: "claude",
|
claude: "claude",
|
||||||
codex: "openai",
|
codex: "openai",
|
||||||
gemini: "gemini",
|
gemini: "gemini",
|
||||||
|
opencode: "opencode",
|
||||||
};
|
};
|
||||||
const appDisplayName: Record<AppId, string> = {
|
const appDisplayName: Record<AppId, string> = {
|
||||||
claude: "Claude",
|
claude: "Claude",
|
||||||
codex: "Codex",
|
codex: "Codex",
|
||||||
gemini: "Gemini",
|
gemini: "Gemini",
|
||||||
|
opencode: "OpenCode",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Filter apps based on visibility settings (default all visible)
|
||||||
|
const appsToShow = ALL_APPS.filter((app) => {
|
||||||
|
if (!visibleApps) return true;
|
||||||
|
return visibleApps[app];
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex bg-muted rounded-xl p-1 gap-1">
|
<div className="inline-flex bg-muted rounded-xl p-1 gap-1">
|
||||||
<button
|
{appsToShow.map((app) => (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => handleSwitch("claude")}
|
key={app}
|
||||||
className={`group inline-flex items-center gap-2 px-3 h-8 rounded-md text-sm font-medium transition-all duration-200 ${
|
type="button"
|
||||||
activeApp === "claude"
|
onClick={() => handleSwitch(app)}
|
||||||
? "bg-background text-foreground shadow-sm"
|
className={`group inline-flex items-center gap-2 px-3 h-8 rounded-md text-sm font-medium transition-all duration-200 ${
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
|
activeApp === app
|
||||||
}`}
|
? "bg-background text-foreground shadow-sm"
|
||||||
>
|
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
|
||||||
<ProviderIcon
|
}`}
|
||||||
icon={appIconName.claude}
|
>
|
||||||
name={appDisplayName.claude}
|
<ProviderIcon
|
||||||
size={iconSize}
|
icon={appIconName[app]}
|
||||||
className={
|
name={appDisplayName[app]}
|
||||||
activeApp === "claude"
|
size={iconSize}
|
||||||
? "text-foreground"
|
/>
|
||||||
: "text-muted-foreground group-hover:text-foreground transition-colors"
|
{!compact && <span>{appDisplayName[app]}</span>}
|
||||||
}
|
</button>
|
||||||
/>
|
))}
|
||||||
<span>{appDisplayName.claude}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleSwitch("codex")}
|
|
||||||
className={`group inline-flex items-center gap-2 px-3 h-8 rounded-md text-sm font-medium transition-all duration-200 ${
|
|
||||||
activeApp === "codex"
|
|
||||||
? "bg-background text-foreground shadow-sm"
|
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<ProviderIcon
|
|
||||||
icon={appIconName.codex}
|
|
||||||
name={appDisplayName.codex}
|
|
||||||
size={iconSize}
|
|
||||||
className={
|
|
||||||
activeApp === "codex"
|
|
||||||
? "text-foreground"
|
|
||||||
: "text-muted-foreground group-hover:text-foreground transition-colors"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span>{appDisplayName.codex}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleSwitch("gemini")}
|
|
||||||
className={`group inline-flex items-center gap-2 px-3 h-8 rounded-md text-sm font-medium transition-all duration-200 ${
|
|
||||||
activeApp === "gemini"
|
|
||||||
? "bg-background text-foreground shadow-sm"
|
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<ProviderIcon
|
|
||||||
icon={appIconName.gemini}
|
|
||||||
name={appDisplayName.gemini}
|
|
||||||
size={iconSize}
|
|
||||||
className={
|
|
||||||
activeApp === "gemini"
|
|
||||||
? "text-foreground"
|
|
||||||
: "text-muted-foreground group-hover:text-foreground transition-colors"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span>{appDisplayName.gemini}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,3 +46,21 @@ export function GeminiIcon({ size = 16, className = "" }: IconProps) {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MCP icon uses inline SVG to support currentColor for hover effects
|
||||||
|
export function McpIcon({ size = 16, className = "" }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
fill="currentColor"
|
||||||
|
fillRule="evenodd"
|
||||||
|
height={size}
|
||||||
|
width={size}
|
||||||
|
className={className}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z" />
|
||||||
|
<path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useMemo } from "react";
|
import React, { useMemo } from "react";
|
||||||
import { getIcon, hasIcon } from "@/icons/extracted";
|
import { getIcon, hasIcon, getIconMetadata } from "@/icons/extracted";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface ProviderIconProps {
|
interface ProviderIconProps {
|
||||||
@@ -39,6 +39,23 @@ export const ProviderIcon: React.FC<ProviderIconProps> = ({
|
|||||||
};
|
};
|
||||||
}, [size]);
|
}, [size]);
|
||||||
|
|
||||||
|
// 获取有效颜色:优先使用传入的有效 color,否则从元数据获取 defaultColor
|
||||||
|
const effectiveColor = useMemo(() => {
|
||||||
|
// 只有当 color 是有效的非空字符串时才使用
|
||||||
|
if (color && typeof color === "string" && color.trim() !== "") {
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
// 否则从元数据获取 defaultColor
|
||||||
|
if (icon) {
|
||||||
|
const metadata = getIconMetadata(icon);
|
||||||
|
// 只有当 defaultColor 不是 currentColor 时才使用
|
||||||
|
if (metadata?.defaultColor && metadata.defaultColor !== "currentColor") {
|
||||||
|
return metadata.defaultColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [color, icon]);
|
||||||
|
|
||||||
// 如果有图标,显示图标
|
// 如果有图标,显示图标
|
||||||
if (iconSvg) {
|
if (iconSvg) {
|
||||||
return (
|
return (
|
||||||
@@ -47,7 +64,7 @@ export const ProviderIcon: React.FC<ProviderIconProps> = ({
|
|||||||
"inline-flex items-center justify-center flex-shrink-0",
|
"inline-flex items-center justify-center flex-shrink-0",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
style={{ ...sizeStyle, color }}
|
style={{ ...sizeStyle, color: effectiveColor }}
|
||||||
dangerouslySetInnerHTML={{ __html: iconSvg }}
|
dangerouslySetInnerHTML={{ __html: iconSvg }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { X, Download } from "lucide-react";
|
|
||||||
import { useUpdate } from "@/contexts/UpdateContext";
|
import { useUpdate } from "@/contexts/UpdateContext";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ArrowUpCircle } from "lucide-react";
|
||||||
|
|
||||||
interface UpdateBadgeProps {
|
interface UpdateBadgeProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -8,56 +9,34 @@ interface UpdateBadgeProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function UpdateBadge({ className = "", onClick }: UpdateBadgeProps) {
|
export function UpdateBadge({ className = "", onClick }: UpdateBadgeProps) {
|
||||||
const { hasUpdate, updateInfo, isDismissed, dismissUpdate } = useUpdate();
|
const { hasUpdate, updateInfo } = useUpdate();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const isActive = hasUpdate && updateInfo;
|
||||||
|
const title = isActive
|
||||||
|
? t("settings.updateAvailable", {
|
||||||
|
version: updateInfo?.availableVersion ?? "",
|
||||||
|
})
|
||||||
|
: t("settings.checkForUpdates");
|
||||||
|
|
||||||
// 如果没有更新或已关闭,不显示
|
if (!isActive) {
|
||||||
if (!hasUpdate || isDismissed || !updateInfo) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
title={title}
|
||||||
|
aria-label={title}
|
||||||
|
onClick={onClick}
|
||||||
className={`
|
className={`
|
||||||
flex items-center gap-1.5 px-2.5 py-1
|
relative h-8 w-8 rounded-full
|
||||||
bg-white dark:bg-gray-800
|
${isActive ? "text-green-600 dark:text-green-400 hover:bg-green-50 dark:hover:bg-green-500/10" : "text-muted-foreground hover:bg-muted/60"}
|
||||||
border border-border-default
|
|
||||||
rounded-lg text-xs
|
|
||||||
shadow-sm
|
|
||||||
transition-all duration-200
|
|
||||||
${onClick ? "cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-750" : ""}
|
|
||||||
${className}
|
${className}
|
||||||
`}
|
`}
|
||||||
role={onClick ? "button" : undefined}
|
|
||||||
tabIndex={onClick ? 0 : -1}
|
|
||||||
onClick={onClick}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (!onClick) return;
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
onClick();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Download className="w-3 h-3 text-blue-500 dark:text-blue-400" />
|
<ArrowUpCircle className="h-5 w-5" />
|
||||||
<span className="text-gray-700 dark:text-gray-300 font-medium">
|
</Button>
|
||||||
{t("settings.updateBadge")}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
dismissUpdate();
|
|
||||||
}}
|
|
||||||
className="
|
|
||||||
ml-1 -mr-0.5 p-0.5 rounded
|
|
||||||
hover:bg-gray-100 dark:hover:bg-gray-700
|
|
||||||
transition-colors
|
|
||||||
focus:outline-none focus:ring-2 focus:ring-blue-500/20
|
|
||||||
"
|
|
||||||
aria-label={t("common.close")}
|
|
||||||
>
|
|
||||||
<X className="w-3 h-3 text-muted-foreground" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface UsageFooterProps {
|
|||||||
appId: AppId;
|
appId: AppId;
|
||||||
usageEnabled: boolean; // 是否启用了用量查询
|
usageEnabled: boolean; // 是否启用了用量查询
|
||||||
isCurrent: boolean; // 是否为当前激活的供应商
|
isCurrent: boolean; // 是否为当前激活的供应商
|
||||||
|
isInConfig?: boolean; // OpenCode: 是否已添加到配置
|
||||||
inline?: boolean; // 是否内联显示(在按钮左侧)
|
inline?: boolean; // 是否内联显示(在按钮左侧)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,12 +21,15 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
|
|||||||
appId,
|
appId,
|
||||||
usageEnabled,
|
usageEnabled,
|
||||||
isCurrent,
|
isCurrent,
|
||||||
|
isInConfig = false,
|
||||||
inline = false,
|
inline = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// 统一的用量查询(自动查询仅对当前激活的供应商启用)
|
// 统一的用量查询(自动查询仅对当前激活的供应商启用)
|
||||||
const autoQueryInterval = isCurrent
|
// OpenCode(累加模式):使用 isInConfig 代替 isCurrent
|
||||||
|
const shouldAutoQuery = appId === "opencode" ? isInConfig : isCurrent;
|
||||||
|
const autoQueryInterval = shouldAutoQuery
|
||||||
? provider.meta?.usage_script?.autoQueryInterval || 0
|
? provider.meta?.usage_script?.autoQueryInterval || 0
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import React, { useState } from "react";
|
|||||||
import { Play, Wand2, Eye, EyeOff, Save } from "lucide-react";
|
import { Play, Wand2, Eye, EyeOff, Save } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Provider, UsageScript, UsageData } from "@/types";
|
import { Provider, UsageScript, UsageData } from "@/types";
|
||||||
import { usageApi, type AppId } from "@/lib/api";
|
import { usageApi, type AppId } from "@/lib/api";
|
||||||
|
import { extractCodexBaseUrl } from "@/utils/providerConfigUtils";
|
||||||
import JsonEditor from "./JsonEditor";
|
import JsonEditor from "./JsonEditor";
|
||||||
import * as prettier from "prettier/standalone";
|
import * as prettier from "prettier/standalone";
|
||||||
import * as parserBabel from "prettier/parser-babel";
|
import * as parserBabel from "prettier/parser-babel";
|
||||||
@@ -109,19 +111,67 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
onSave,
|
onSave,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// 生成带国际化的预设模板
|
// 生成带国际化的预设模板
|
||||||
const PRESET_TEMPLATES = generatePresetTemplates(t);
|
const PRESET_TEMPLATES = generatePresetTemplates(t);
|
||||||
|
|
||||||
const [script, setScript] = useState<UsageScript>(() => {
|
// 从 provider 的 settingsConfig 中提取 API Key 和 Base URL
|
||||||
return (
|
const getProviderCredentials = (): {
|
||||||
provider.meta?.usage_script || {
|
apiKey: string | undefined;
|
||||||
enabled: false,
|
baseUrl: string | undefined;
|
||||||
language: "javascript",
|
} => {
|
||||||
code: PRESET_TEMPLATES[TEMPLATE_KEYS.GENERAL],
|
try {
|
||||||
timeout: 10,
|
const config = provider.settingsConfig;
|
||||||
|
if (!config) return { apiKey: undefined, baseUrl: undefined };
|
||||||
|
|
||||||
|
// 处理不同应用的配置格式
|
||||||
|
if (appId === "claude") {
|
||||||
|
// Claude: { env: { ANTHROPIC_AUTH_TOKEN | ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL } }
|
||||||
|
const env = (config as any).env || {};
|
||||||
|
return {
|
||||||
|
apiKey: env.ANTHROPIC_AUTH_TOKEN || env.ANTHROPIC_API_KEY,
|
||||||
|
baseUrl: env.ANTHROPIC_BASE_URL,
|
||||||
|
};
|
||||||
|
} else if (appId === "codex") {
|
||||||
|
// Codex: { auth: { OPENAI_API_KEY }, config: TOML string with base_url }
|
||||||
|
const auth = (config as any).auth || {};
|
||||||
|
const configToml = (config as any).config || "";
|
||||||
|
return {
|
||||||
|
apiKey: auth.OPENAI_API_KEY,
|
||||||
|
baseUrl: extractCodexBaseUrl(configToml),
|
||||||
|
};
|
||||||
|
} else if (appId === "gemini") {
|
||||||
|
// Gemini: { env: { GEMINI_API_KEY, GOOGLE_GEMINI_BASE_URL } }
|
||||||
|
const env = (config as any).env || {};
|
||||||
|
return {
|
||||||
|
apiKey: env.GEMINI_API_KEY,
|
||||||
|
baseUrl: env.GOOGLE_GEMINI_BASE_URL,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
);
|
return { apiKey: undefined, baseUrl: undefined };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to extract provider credentials:", error);
|
||||||
|
return { apiKey: undefined, baseUrl: undefined };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const providerCredentials = getProviderCredentials();
|
||||||
|
|
||||||
|
const [script, setScript] = useState<UsageScript>(() => {
|
||||||
|
const savedScript = provider.meta?.usage_script;
|
||||||
|
const defaultScript = {
|
||||||
|
enabled: false,
|
||||||
|
language: "javascript" as const,
|
||||||
|
code: PRESET_TEMPLATES[TEMPLATE_KEYS.GENERAL],
|
||||||
|
timeout: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!savedScript) {
|
||||||
|
return defaultScript;
|
||||||
|
}
|
||||||
|
|
||||||
|
return savedScript;
|
||||||
});
|
});
|
||||||
|
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
@@ -176,6 +226,11 @@ 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;
|
||||||
|
// 优先使用保存的 templateType
|
||||||
|
if (existingScript?.templateType) {
|
||||||
|
return existingScript.templateType;
|
||||||
|
}
|
||||||
|
// 向后兼容:根据字段推断模板类型
|
||||||
// 检测 NEW_API 模板(有 accessToken 或 userId)
|
// 检测 NEW_API 模板(有 accessToken 或 userId)
|
||||||
if (existingScript?.accessToken || existingScript?.userId) {
|
if (existingScript?.accessToken || existingScript?.userId) {
|
||||||
return TEMPLATE_KEYS.NEW_API;
|
return TEMPLATE_KEYS.NEW_API;
|
||||||
@@ -201,7 +256,16 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
toast.error(t("usageScript.mustHaveReturn"), { duration: 5000 });
|
toast.error(t("usageScript.mustHaveReturn"), { duration: 5000 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onSave(script);
|
// 保存时记录当前选择的模板类型
|
||||||
|
const scriptWithTemplate = {
|
||||||
|
...script,
|
||||||
|
templateType: selectedTemplate as
|
||||||
|
| "custom"
|
||||||
|
| "general"
|
||||||
|
| "newapi"
|
||||||
|
| undefined,
|
||||||
|
};
|
||||||
|
onSave(scriptWithTemplate);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -217,6 +281,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
script.baseUrl,
|
script.baseUrl,
|
||||||
script.accessToken,
|
script.accessToken,
|
||||||
script.userId,
|
script.userId,
|
||||||
|
selectedTemplate as "custom" | "general" | "newapi" | undefined,
|
||||||
);
|
);
|
||||||
if (result.success && result.data && result.data.length > 0) {
|
if (result.success && result.data && result.data.length > 0) {
|
||||||
const summary = result.data
|
const summary = result.data
|
||||||
@@ -229,6 +294,9 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
duration: 3000,
|
duration: 3000,
|
||||||
closeButton: true,
|
closeButton: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🔧 测试成功后,更新主界面列表的用量查询缓存
|
||||||
|
queryClient.setQueryData(["usage", provider.id, appId], result);
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
toast.error(
|
||||||
`${t("usageScript.testFailed")}: ${result.error || t("endpointTest.noResult")}`,
|
`${t("usageScript.testFailed")}: ${result.error || t("endpointTest.noResult")}`,
|
||||||
@@ -278,9 +346,13 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
const preset = PRESET_TEMPLATES[presetName];
|
const preset = PRESET_TEMPLATES[presetName];
|
||||||
if (preset) {
|
if (preset) {
|
||||||
if (presetName === TEMPLATE_KEYS.CUSTOM) {
|
if (presetName === TEMPLATE_KEYS.CUSTOM) {
|
||||||
|
// 🔧 自定义模式:用户应该在脚本中直接写完整 URL 和凭证,而不是依赖变量替换
|
||||||
|
// 这样可以避免同源检查导致的问题
|
||||||
|
// 如果用户想使用变量,需要手动在配置中设置 baseUrl/apiKey
|
||||||
setScript({
|
setScript({
|
||||||
...script,
|
...script,
|
||||||
code: preset,
|
code: preset,
|
||||||
|
// 清除凭证,用户可选择手动输入或保持空
|
||||||
apiKey: undefined,
|
apiKey: undefined,
|
||||||
baseUrl: undefined,
|
baseUrl: undefined,
|
||||||
accessToken: undefined,
|
accessToken: undefined,
|
||||||
@@ -401,6 +473,74 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 自定义模式:变量提示和具体值 */}
|
||||||
|
{selectedTemplate === TEMPLATE_KEYS.CUSTOM && (
|
||||||
|
<div className="space-y-2 border-t border-white/10 pt-3">
|
||||||
|
<h4 className="text-sm font-medium text-foreground">
|
||||||
|
{t("usageScript.supportedVariables")}
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-1 text-xs">
|
||||||
|
{/* baseUrl */}
|
||||||
|
<div className="flex items-center gap-2 py-1">
|
||||||
|
<code className="text-emerald-500 dark:text-emerald-400 font-mono shrink-0">
|
||||||
|
{"{{baseUrl}}"}
|
||||||
|
</code>
|
||||||
|
<span className="text-muted-foreground/50">=</span>
|
||||||
|
{providerCredentials.baseUrl ? (
|
||||||
|
<code className="text-foreground/70 break-all font-mono">
|
||||||
|
{providerCredentials.baseUrl}
|
||||||
|
</code>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground/50 italic">
|
||||||
|
{t("common.notSet") || "未设置"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* apiKey */}
|
||||||
|
<div className="flex items-center gap-2 py-1">
|
||||||
|
<code className="text-emerald-500 dark:text-emerald-400 font-mono shrink-0">
|
||||||
|
{"{{apiKey}}"}
|
||||||
|
</code>
|
||||||
|
<span className="text-muted-foreground/50">=</span>
|
||||||
|
{providerCredentials.apiKey ? (
|
||||||
|
<>
|
||||||
|
{showApiKey ? (
|
||||||
|
<code className="text-foreground/70 break-all font-mono">
|
||||||
|
{providerCredentials.apiKey}
|
||||||
|
</code>
|
||||||
|
) : (
|
||||||
|
<code className="text-foreground/70 font-mono">
|
||||||
|
••••••••
|
||||||
|
</code>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowApiKey(!showApiKey)}
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors ml-1"
|
||||||
|
aria-label={
|
||||||
|
showApiKey
|
||||||
|
? t("apiKeyInput.hide")
|
||||||
|
: t("apiKeyInput.show")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{showApiKey ? (
|
||||||
|
<EyeOff size={12} />
|
||||||
|
) : (
|
||||||
|
<Eye size={12} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground/50 italic">
|
||||||
|
{t("common.notSet") || "未设置"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 凭证配置 */}
|
{/* 凭证配置 */}
|
||||||
{shouldShowCredentialsConfig && (
|
{shouldShowCredentialsConfig && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -601,11 +741,13 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
max={1440}
|
max={1440}
|
||||||
value={script.autoIntervalMinutes ?? 0}
|
value={
|
||||||
|
script.autoQueryInterval ?? script.autoIntervalMinutes ?? 0
|
||||||
|
}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setScript({
|
setScript({
|
||||||
...script,
|
...script,
|
||||||
autoIntervalMinutes: validateAndClampInterval(
|
autoQueryInterval: validateAndClampInterval(
|
||||||
e.target.value,
|
e.target.value,
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
@@ -613,7 +755,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
onBlur={(e) =>
|
onBlur={(e) =>
|
||||||
setScript({
|
setScript({
|
||||||
...script,
|
...script,
|
||||||
autoIntervalMinutes: validateAndClampInterval(
|
autoQueryInterval: validateAndClampInterval(
|
||||||
e.target.value,
|
e.target.value,
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ interface AgentsPanelProps {
|
|||||||
|
|
||||||
export function AgentsPanel({}: AgentsPanelProps) {
|
export function AgentsPanel({}: AgentsPanelProps) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-5xl flex flex-col h-[calc(100vh-8rem)]">
|
<div className="px-6 flex flex-col h-[calc(100vh-8rem)]">
|
||||||
<div className="flex-1 glass-card rounded-xl p-8 flex flex-col items-center justify-center text-center space-y-4">
|
<div className="flex-1 glass-card rounded-xl p-8 flex flex-col items-center justify-center text-center space-y-4">
|
||||||
<div className="w-20 h-20 rounded-full bg-white/5 flex items-center justify-center mb-4 animate-pulse-slow">
|
<div className="w-20 h-20 rounded-full bg-white/5 flex items-center justify-center mb-4 animate-pulse-slow">
|
||||||
<Bot className="w-10 h-10 text-muted-foreground" />
|
<Bot className="w-10 h-10 text-muted-foreground" />
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { motion, AnimatePresence } from "framer-motion";
|
|||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { isWindows, isLinux } from "@/lib/platform";
|
import { isWindows, isLinux } from "@/lib/platform";
|
||||||
|
import { isTextEditableTarget } from "@/utils/domUtils";
|
||||||
|
|
||||||
interface FullScreenPanelProps {
|
interface FullScreenPanelProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -37,6 +38,39 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
|
|||||||
};
|
};
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// ESC 键关闭面板
|
||||||
|
const onCloseRef = React.useRef(onClose);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
onCloseRef.current = onClose;
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
// 子组件(例如 Radix 的 Select/Dialog/Dropdown)如果已经消费了 ESC,就不要再关闭整个面板
|
||||||
|
if (event.defaultPrevented) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTextEditableTarget(event.target)) {
|
||||||
|
return; // 让输入框自己处理 ESC(比如清空、失焦等)
|
||||||
|
}
|
||||||
|
|
||||||
|
event.stopPropagation(); // 阻止事件继续冒泡到 window,避免触发 App.tsx 的全局监听
|
||||||
|
onCloseRef.current();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 使用冒泡阶段监听,让子组件(如 Radix UI)优先处理 ESC
|
||||||
|
window.addEventListener("keydown", handleKeyDown, false);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown, false);
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
@@ -72,7 +106,7 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="mx-auto max-w-[56rem] px-6 w-full flex items-center gap-4"
|
className="px-6 w-full flex items-center gap-4"
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
||||||
>
|
>
|
||||||
@@ -94,9 +128,7 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto scroll-overlay">
|
<div className="flex-1 overflow-y-auto scroll-overlay">
|
||||||
<div className="mx-auto max-w-[56rem] px-6 py-6 space-y-6 w-full">
|
<div className="px-6 py-6 space-y-6 w-full">{children}</div>
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
@@ -105,7 +137,7 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
|
|||||||
className="flex-shrink-0 py-4 border-t border-border-default"
|
className="flex-shrink-0 py-4 border-t border-border-default"
|
||||||
style={{ backgroundColor: "hsl(var(--background))" }}
|
style={{ backgroundColor: "hsl(var(--background))" }}
|
||||||
>
|
>
|
||||||
<div className="mx-auto max-w-[56rem] px-6 flex items-center justify-end gap-3">
|
<div className="px-6 flex items-center justify-end gap-3">
|
||||||
{footer}
|
{footer}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
claude: boolean;
|
claude: boolean;
|
||||||
codex: boolean;
|
codex: boolean;
|
||||||
gemini: boolean;
|
gemini: boolean;
|
||||||
|
opencode: boolean;
|
||||||
}>(() => {
|
}>(() => {
|
||||||
if (initialData?.apps) {
|
if (initialData?.apps) {
|
||||||
return { ...initialData.apps };
|
return { ...initialData.apps };
|
||||||
@@ -73,6 +74,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
claude: defaultEnabledApps.includes("claude"),
|
claude: defaultEnabledApps.includes("claude"),
|
||||||
codex: defaultEnabledApps.includes("codex"),
|
codex: defaultEnabledApps.includes("codex"),
|
||||||
gemini: defaultEnabledApps.includes("gemini"),
|
gemini: defaultEnabledApps.includes("gemini"),
|
||||||
|
opencode: defaultEnabledApps.includes("opencode"),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -59,11 +59,12 @@ const UnifiedMcpPanel = React.forwardRef<
|
|||||||
|
|
||||||
// Count enabled servers per app
|
// Count enabled servers per app
|
||||||
const enabledCounts = useMemo(() => {
|
const enabledCounts = useMemo(() => {
|
||||||
const counts = { claude: 0, codex: 0, gemini: 0 };
|
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0 };
|
||||||
serverEntries.forEach(([_, server]) => {
|
serverEntries.forEach(([_, server]) => {
|
||||||
if (server.apps.claude) counts.claude++;
|
if (server.apps.claude) counts.claude++;
|
||||||
if (server.apps.codex) counts.codex++;
|
if (server.apps.codex) counts.codex++;
|
||||||
if (server.apps.gemini) counts.gemini++;
|
if (server.apps.gemini) counts.gemini++;
|
||||||
|
if (server.apps.opencode) counts.opencode++;
|
||||||
});
|
});
|
||||||
return counts;
|
return counts;
|
||||||
}, [serverEntries]);
|
}, [serverEntries]);
|
||||||
@@ -141,14 +142,15 @@ const UnifiedMcpPanel = React.forwardRef<
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-[56rem] px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
<div className="px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
||||||
{/* Info Section */}
|
{/* Info Section */}
|
||||||
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6">
|
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{t("mcp.serverCount", { count: serverEntries.length })} ·{" "}
|
{t("mcp.serverCount", { count: serverEntries.length })} ·{" "}
|
||||||
{t("mcp.unifiedPanel.apps.claude")}: {enabledCounts.claude} ·{" "}
|
{t("mcp.unifiedPanel.apps.claude")}: {enabledCounts.claude} ·{" "}
|
||||||
{t("mcp.unifiedPanel.apps.codex")}: {enabledCounts.codex} ·{" "}
|
{t("mcp.unifiedPanel.apps.codex")}: {enabledCounts.codex} ·{" "}
|
||||||
{t("mcp.unifiedPanel.apps.gemini")}: {enabledCounts.gemini}
|
{t("mcp.unifiedPanel.apps.gemini")}: {enabledCounts.gemini} ·{" "}
|
||||||
|
{t("mcp.unifiedPanel.apps.opencode")}: {enabledCounts.opencode}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -337,6 +339,22 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<label
|
||||||
|
htmlFor={`${id}-opencode`}
|
||||||
|
className="text-sm text-foreground/80 cursor-pointer"
|
||||||
|
>
|
||||||
|
{t("mcp.unifiedPanel.apps.opencode")}
|
||||||
|
</label>
|
||||||
|
<Switch
|
||||||
|
id={`${id}-opencode`}
|
||||||
|
checked={server.apps.opencode}
|
||||||
|
onCheckedChange={(checked: boolean) =>
|
||||||
|
onToggleApp(id, "opencode", checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 右侧:操作按钮 */}
|
{/* 右侧:操作按钮 */}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const PromptFormModal: React.FC<PromptFormModalProps> = ({
|
|||||||
claude: "CLAUDE.md",
|
claude: "CLAUDE.md",
|
||||||
codex: "AGENTS.md",
|
codex: "AGENTS.md",
|
||||||
gemini: "GEMINI.md",
|
gemini: "GEMINI.md",
|
||||||
|
opencode: "AGENTS.md",
|
||||||
};
|
};
|
||||||
const filename = filenameMap[appId];
|
const filename = filenameMap[appId];
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const PromptFormPanel: React.FC<PromptFormPanelProps> = ({
|
|||||||
claude: "CLAUDE.md",
|
claude: "CLAUDE.md",
|
||||||
codex: "AGENTS.md",
|
codex: "AGENTS.md",
|
||||||
gemini: "GEMINI.md",
|
gemini: "GEMINI.md",
|
||||||
|
opencode: "AGENTS.md",
|
||||||
};
|
};
|
||||||
const filename = filenameMap[appId];
|
const filename = filenameMap[appId];
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ const PromptPanel = React.forwardRef<PromptPanelHandle, PromptPanelProps>(
|
|||||||
const enabledPrompt = promptEntries.find(([_, p]) => p.enabled);
|
const enabledPrompt = promptEntries.find(([_, p]) => p.enabled);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-[56rem] flex flex-col h-[calc(100vh-8rem)] px-6">
|
<div className="flex flex-col h-[calc(100vh-8rem)] px-6">
|
||||||
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6">
|
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{t("prompts.count", { count: promptEntries.length })} ·{" "}
|
{t("prompts.count", { count: promptEntries.length })} ·{" "}
|
||||||
|
|||||||
@@ -17,13 +17,16 @@ import { UniversalProviderPanel } from "@/components/universal";
|
|||||||
import { providerPresets } from "@/config/claudeProviderPresets";
|
import { providerPresets } from "@/config/claudeProviderPresets";
|
||||||
import { codexProviderPresets } from "@/config/codexProviderPresets";
|
import { codexProviderPresets } from "@/config/codexProviderPresets";
|
||||||
import { geminiProviderPresets } from "@/config/geminiProviderPresets";
|
import { geminiProviderPresets } from "@/config/geminiProviderPresets";
|
||||||
|
// Note: opencodeProviderPresets is loaded via ProviderForm, not needed here
|
||||||
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
|
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
|
||||||
|
|
||||||
interface AddProviderDialogProps {
|
interface AddProviderDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
appId: AppId;
|
appId: AppId;
|
||||||
onSubmit: (provider: Omit<Provider, "id">) => Promise<void> | void;
|
onSubmit: (
|
||||||
|
provider: Omit<Provider, "id"> & { providerKey?: string },
|
||||||
|
) => Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddProviderDialog({
|
export function AddProviderDialog({
|
||||||
@@ -33,6 +36,8 @@ export function AddProviderDialog({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
}: AddProviderDialogProps) {
|
}: AddProviderDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
// OpenCode doesn't support universal providers
|
||||||
|
const showUniversalTab = appId !== "opencode";
|
||||||
const [activeTab, setActiveTab] = useState<"app-specific" | "universal">(
|
const [activeTab, setActiveTab] = useState<"app-specific" | "universal">(
|
||||||
"app-specific",
|
"app-specific",
|
||||||
);
|
);
|
||||||
@@ -82,7 +87,7 @@ export function AddProviderDialog({
|
|||||||
>;
|
>;
|
||||||
|
|
||||||
// 构造基础提交数据
|
// 构造基础提交数据
|
||||||
const providerData: Omit<Provider, "id"> = {
|
const providerData: Omit<Provider, "id"> & { providerKey?: string } = {
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
notes: values.notes?.trim() || undefined,
|
notes: values.notes?.trim() || undefined,
|
||||||
websiteUrl: values.websiteUrl?.trim() || undefined,
|
websiteUrl: values.websiteUrl?.trim() || undefined,
|
||||||
@@ -93,6 +98,11 @@ export function AddProviderDialog({
|
|||||||
...(values.meta ? { meta: values.meta } : {}),
|
...(values.meta ? { meta: values.meta } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// OpenCode: pass providerKey for ID generation
|
||||||
|
if (appId === "opencode" && values.providerKey) {
|
||||||
|
providerData.providerKey = values.providerKey;
|
||||||
|
}
|
||||||
|
|
||||||
const hasCustomEndpoints =
|
const hasCustomEndpoints =
|
||||||
providerData.meta?.custom_endpoints &&
|
providerData.meta?.custom_endpoints &&
|
||||||
Object.keys(providerData.meta.custom_endpoints).length > 0;
|
Object.keys(providerData.meta.custom_endpoints).length > 0;
|
||||||
@@ -153,6 +163,7 @@ export function AddProviderDialog({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Note: OpenCode doesn't use endpointCandidates - it handles endpoints internally
|
||||||
}
|
}
|
||||||
|
|
||||||
if (appId === "claude") {
|
if (appId === "claude") {
|
||||||
@@ -175,6 +186,14 @@ export function AddProviderDialog({
|
|||||||
if (env?.GOOGLE_GEMINI_BASE_URL) {
|
if (env?.GOOGLE_GEMINI_BASE_URL) {
|
||||||
addUrl(env.GOOGLE_GEMINI_BASE_URL);
|
addUrl(env.GOOGLE_GEMINI_BASE_URL);
|
||||||
}
|
}
|
||||||
|
} else if (appId === "opencode") {
|
||||||
|
// OpenCode uses options.baseURL
|
||||||
|
const options = parsedConfig.options as
|
||||||
|
| Record<string, any>
|
||||||
|
| undefined;
|
||||||
|
if (options?.baseURL) {
|
||||||
|
addUrl(options.baseURL);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const urls = Array.from(urlSet);
|
const urls = Array.from(urlSet);
|
||||||
@@ -204,7 +223,7 @@ export function AddProviderDialog({
|
|||||||
|
|
||||||
// 动态 footer:根据当前 Tab 显示不同按钮
|
// 动态 footer:根据当前 Tab 显示不同按钮
|
||||||
const footer =
|
const footer =
|
||||||
activeTab === "app-specific" ? (
|
!showUniversalTab || activeTab === "app-specific" ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -248,41 +267,54 @@ export function AddProviderDialog({
|
|||||||
onClose={() => onOpenChange(false)}
|
onClose={() => onOpenChange(false)}
|
||||||
footer={footer}
|
footer={footer}
|
||||||
>
|
>
|
||||||
<Tabs
|
{showUniversalTab ? (
|
||||||
value={activeTab}
|
<Tabs
|
||||||
onValueChange={(v) => setActiveTab(v as "app-specific" | "universal")}
|
value={activeTab}
|
||||||
>
|
onValueChange={(v) => setActiveTab(v as "app-specific" | "universal")}
|
||||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
>
|
||||||
<TabsTrigger value="app-specific">
|
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||||
{t(`apps.${appId}`)} {t("provider.tabProvider")}
|
<TabsTrigger value="app-specific">
|
||||||
</TabsTrigger>
|
{t(`apps.${appId}`)} {t("provider.tabProvider")}
|
||||||
<TabsTrigger value="universal">
|
</TabsTrigger>
|
||||||
{t("provider.tabUniversal")}
|
<TabsTrigger value="universal">
|
||||||
</TabsTrigger>
|
{t("provider.tabUniversal")}
|
||||||
</TabsList>
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="app-specific" className="mt-0">
|
<TabsContent value="app-specific" className="mt-0">
|
||||||
<ProviderForm
|
<ProviderForm
|
||||||
appId={appId}
|
appId={appId}
|
||||||
submitLabel={t("common.add")}
|
submitLabel={t("common.add")}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onCancel={() => onOpenChange(false)}
|
onCancel={() => onOpenChange(false)}
|
||||||
showButtons={false}
|
showButtons={false}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="universal" className="mt-0">
|
<TabsContent value="universal" className="mt-0">
|
||||||
<UniversalProviderPanel />
|
<UniversalProviderPanel />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
) : (
|
||||||
|
// OpenCode: directly show form without tabs
|
||||||
|
<ProviderForm
|
||||||
|
appId={appId}
|
||||||
|
submitLabel={t("common.add")}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onCancel={() => onOpenChange(false)}
|
||||||
|
showButtons={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Universal Provider Form Modal */}
|
{/* Universal Provider Form Modal */}
|
||||||
<UniversalProviderFormModal
|
{showUniversalTab && (
|
||||||
isOpen={universalFormOpen}
|
<UniversalProviderFormModal
|
||||||
onClose={handleUniversalFormClose}
|
isOpen={universalFormOpen}
|
||||||
onSave={handleUniversalProviderSave}
|
onClose={handleUniversalFormClose}
|
||||||
initialPreset={selectedUniversalPreset}
|
onSave={handleUniversalProviderSave}
|
||||||
/>
|
initialPreset={selectedUniversalPreset}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</FullScreenPanel>
|
</FullScreenPanel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,17 @@ export function EditProviderDialog({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OpenCode uses additive mode - each provider's config is stored independently in DB
|
||||||
|
// Reading live config would return the full opencode.json (with $schema, provider, mcp etc.)
|
||||||
|
// instead of just the provider fragment, causing incorrect nested structure on save
|
||||||
|
if (appId === "opencode") {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLiveSettings(null);
|
||||||
|
setHasLoadedLive(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const currentId = await providersApi.getCurrent(appId);
|
const currentId = await providersApi.getCurrent(appId);
|
||||||
if (currentId && provider.id === currentId) {
|
if (currentId && provider.id === currentId) {
|
||||||
@@ -117,9 +128,10 @@ export function EditProviderDialog({
|
|||||||
iconColor: provider.iconColor,
|
iconColor: provider.iconColor,
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
|
open, // 修复:编辑保存后再次打开显示旧数据,依赖 open 确保每次打开时重新读取最新 provider 数据
|
||||||
provider?.id, // 只依赖 ID,provider 对象更新不会触发重新计算
|
provider?.id, // 只依赖 ID,provider 对象更新不会触发重新计算
|
||||||
|
provider?.meta, // 需要依赖 meta 以便正确初始化 testConfig 和 proxyConfig
|
||||||
initialSettingsConfig,
|
initialSettingsConfig,
|
||||||
// 注意:不依赖 provider 的其他字段,防止表单重置
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
Edit,
|
Edit,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Minus,
|
||||||
Play,
|
Play,
|
||||||
Plus,
|
Plus,
|
||||||
Terminal,
|
Terminal,
|
||||||
@@ -13,9 +14,13 @@ import {
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { AppId } from "@/lib/api";
|
||||||
|
|
||||||
interface ProviderActionsProps {
|
interface ProviderActionsProps {
|
||||||
|
appId?: AppId;
|
||||||
isCurrent: boolean;
|
isCurrent: boolean;
|
||||||
|
/** OpenCode: 是否已添加到配置 */
|
||||||
|
isInConfig?: boolean;
|
||||||
isTesting?: boolean;
|
isTesting?: boolean;
|
||||||
isProxyTakeover?: boolean;
|
isProxyTakeover?: boolean;
|
||||||
onSwitch: () => void;
|
onSwitch: () => void;
|
||||||
@@ -24,6 +29,8 @@ interface ProviderActionsProps {
|
|||||||
onTest?: () => void;
|
onTest?: () => void;
|
||||||
onConfigureUsage: () => void;
|
onConfigureUsage: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
/** OpenCode: remove from live config (not delete from database) */
|
||||||
|
onRemoveFromConfig?: () => void;
|
||||||
onOpenTerminal?: () => void;
|
onOpenTerminal?: () => void;
|
||||||
// 故障转移相关
|
// 故障转移相关
|
||||||
isAutoFailoverEnabled?: boolean;
|
isAutoFailoverEnabled?: boolean;
|
||||||
@@ -32,7 +39,9 @@ interface ProviderActionsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ProviderActions({
|
export function ProviderActions({
|
||||||
|
appId,
|
||||||
isCurrent,
|
isCurrent,
|
||||||
|
isInConfig = false,
|
||||||
isTesting,
|
isTesting,
|
||||||
isProxyTakeover = false,
|
isProxyTakeover = false,
|
||||||
onSwitch,
|
onSwitch,
|
||||||
@@ -41,6 +50,7 @@ export function ProviderActions({
|
|||||||
onTest,
|
onTest,
|
||||||
onConfigureUsage,
|
onConfigureUsage,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onRemoveFromConfig,
|
||||||
onOpenTerminal,
|
onOpenTerminal,
|
||||||
// 故障转移相关
|
// 故障转移相关
|
||||||
isAutoFailoverEnabled = false,
|
isAutoFailoverEnabled = false,
|
||||||
@@ -50,12 +60,28 @@ export function ProviderActions({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const iconButtonClass = "h-8 w-8 p-1";
|
const iconButtonClass = "h-8 w-8 p-1";
|
||||||
|
|
||||||
// 故障转移模式下的按钮逻辑
|
// OpenCode 使用累加模式
|
||||||
const isFailoverMode = isAutoFailoverEnabled && onToggleFailover;
|
const isOpenCodeMode = appId === "opencode";
|
||||||
|
|
||||||
|
// 故障转移模式下的按钮逻辑(OpenCode 不支持故障转移)
|
||||||
|
const isFailoverMode =
|
||||||
|
!isOpenCodeMode && isAutoFailoverEnabled && onToggleFailover;
|
||||||
|
|
||||||
// 处理主按钮点击
|
// 处理主按钮点击
|
||||||
const handleMainButtonClick = () => {
|
const handleMainButtonClick = () => {
|
||||||
if (isFailoverMode) {
|
if (isOpenCodeMode) {
|
||||||
|
// OpenCode 模式:切换配置状态(添加/移除)
|
||||||
|
if (isInConfig) {
|
||||||
|
// Use onRemoveFromConfig if available, otherwise fall back to onDelete
|
||||||
|
if (onRemoveFromConfig) {
|
||||||
|
onRemoveFromConfig();
|
||||||
|
} else {
|
||||||
|
onDelete();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onSwitch(); // 添加到配置
|
||||||
|
}
|
||||||
|
} else if (isFailoverMode) {
|
||||||
// 故障转移模式:切换队列状态
|
// 故障转移模式:切换队列状态
|
||||||
onToggleFailover(!isInFailoverQueue);
|
onToggleFailover(!isInFailoverQueue);
|
||||||
} else {
|
} else {
|
||||||
@@ -66,8 +92,30 @@ export function ProviderActions({
|
|||||||
|
|
||||||
// 主按钮的状态和样式
|
// 主按钮的状态和样式
|
||||||
const getMainButtonState = () => {
|
const getMainButtonState = () => {
|
||||||
|
// OpenCode 累加模式
|
||||||
|
if (isOpenCodeMode) {
|
||||||
|
if (isInConfig) {
|
||||||
|
return {
|
||||||
|
disabled: false,
|
||||||
|
variant: "secondary" as const,
|
||||||
|
className:
|
||||||
|
"bg-orange-100 text-orange-600 hover:bg-orange-200 dark:bg-orange-900/50 dark:text-orange-400 dark:hover:bg-orange-900/70",
|
||||||
|
icon: <Minus className="h-4 w-4" />,
|
||||||
|
text: t("provider.removeFromConfig", { defaultValue: "移除" }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
disabled: false,
|
||||||
|
variant: "default" as const,
|
||||||
|
className:
|
||||||
|
"bg-emerald-500 hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700",
|
||||||
|
icon: <Plus className="h-4 w-4" />,
|
||||||
|
text: t("provider.addToConfig", { defaultValue: "添加" }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 故障转移模式
|
||||||
if (isFailoverMode) {
|
if (isFailoverMode) {
|
||||||
// 故障转移模式
|
|
||||||
if (isInFailoverQueue) {
|
if (isInFailoverQueue) {
|
||||||
return {
|
return {
|
||||||
disabled: false,
|
disabled: false,
|
||||||
@@ -113,6 +161,9 @@ export function ProviderActions({
|
|||||||
|
|
||||||
const buttonState = getMainButtonState();
|
const buttonState = getMainButtonState();
|
||||||
|
|
||||||
|
// OpenCode 模式下删除按钮始终可用(主按钮"移除"是从 live 配置移除,删除是从数据库删除)
|
||||||
|
const canDelete = isOpenCodeMode ? true : !isCurrent;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Button
|
<Button
|
||||||
@@ -192,12 +243,12 @@ export function ProviderActions({
|
|||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={isCurrent ? undefined : onDelete}
|
onClick={canDelete ? onDelete : undefined}
|
||||||
title={t("common.delete")}
|
title={t("common.delete")}
|
||||||
className={cn(
|
className={cn(
|
||||||
iconButtonClass,
|
iconButtonClass,
|
||||||
!isCurrent && "hover:text-red-500 dark:hover:text-red-400",
|
canDelete && "hover:text-red-500 dark:hover:text-red-400",
|
||||||
isCurrent && "opacity-40 cursor-not-allowed text-muted-foreground",
|
!canDelete && "opacity-40 cursor-not-allowed text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
|
|||||||
@@ -26,9 +26,12 @@ interface ProviderCardProps {
|
|||||||
provider: Provider;
|
provider: Provider;
|
||||||
isCurrent: boolean;
|
isCurrent: boolean;
|
||||||
appId: AppId;
|
appId: AppId;
|
||||||
|
isInConfig?: boolean; // OpenCode: 是否已添加到 opencode.json
|
||||||
onSwitch: (provider: Provider) => void;
|
onSwitch: (provider: Provider) => void;
|
||||||
onEdit: (provider: Provider) => void;
|
onEdit: (provider: Provider) => void;
|
||||||
onDelete: (provider: Provider) => void;
|
onDelete: (provider: Provider) => void;
|
||||||
|
/** OpenCode: remove from live config (not delete from database) */
|
||||||
|
onRemoveFromConfig?: (provider: Provider) => void;
|
||||||
onConfigureUsage: (provider: Provider) => void;
|
onConfigureUsage: (provider: Provider) => void;
|
||||||
onOpenWebsite: (url: string) => void;
|
onOpenWebsite: (url: string) => void;
|
||||||
onDuplicate: (provider: Provider) => void;
|
onDuplicate: (provider: Provider) => void;
|
||||||
@@ -85,9 +88,11 @@ export function ProviderCard({
|
|||||||
provider,
|
provider,
|
||||||
isCurrent,
|
isCurrent,
|
||||||
appId,
|
appId,
|
||||||
|
isInConfig = true,
|
||||||
onSwitch,
|
onSwitch,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onRemoveFromConfig,
|
||||||
onConfigureUsage,
|
onConfigureUsage,
|
||||||
onOpenWebsite,
|
onOpenWebsite,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
@@ -134,7 +139,9 @@ export function ProviderCard({
|
|||||||
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
|
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
|
||||||
|
|
||||||
// 获取用量数据以判断是否有多套餐
|
// 获取用量数据以判断是否有多套餐
|
||||||
const autoQueryInterval = isCurrent
|
// OpenCode(累加模式):使用 isInConfig 代替 isCurrent
|
||||||
|
const shouldAutoQuery = appId === "opencode" ? isInConfig : isCurrent;
|
||||||
|
const autoQueryInterval = shouldAutoQuery
|
||||||
? provider.meta?.usage_script?.autoQueryInterval || 0
|
? provider.meta?.usage_script?.autoQueryInterval || 0
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
@@ -182,12 +189,16 @@ export function ProviderCard({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 判断是否是"当前使用中"的供应商
|
// 判断是否是"当前使用中"的供应商
|
||||||
|
// - OpenCode(累加模式):不存在"当前"概念,始终返回 false
|
||||||
// - 故障转移模式:代理实际使用的供应商(activeProviderId)
|
// - 故障转移模式:代理实际使用的供应商(activeProviderId)
|
||||||
// - 代理接管模式(非故障转移):isCurrent
|
// - 代理接管模式(非故障转移):isCurrent
|
||||||
// - 普通模式:isCurrent
|
// - 普通模式:isCurrent
|
||||||
const isActiveProvider = isAutoFailoverEnabled
|
const isActiveProvider =
|
||||||
? activeProviderId === provider.id
|
appId === "opencode"
|
||||||
: isCurrent;
|
? false
|
||||||
|
: isAutoFailoverEnabled
|
||||||
|
? activeProviderId === provider.id
|
||||||
|
: isCurrent;
|
||||||
|
|
||||||
// 判断是否使用绿色(代理接管模式)还是蓝色(普通模式)
|
// 判断是否使用绿色(代理接管模式)还是蓝色(普通模式)
|
||||||
const shouldUseGreen = isProxyTakeover && isActiveProvider;
|
const shouldUseGreen = isProxyTakeover && isActiveProvider;
|
||||||
@@ -301,7 +312,11 @@ export function ProviderCard({
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className="relative flex items-center ml-auto min-w-0 gap-3"
|
className="relative flex items-center ml-auto min-w-0 gap-3"
|
||||||
style={{ "--actions-width": `${actionsWidth || 320}px` } as React.CSSProperties}
|
style={
|
||||||
|
{
|
||||||
|
"--actions-width": `${actionsWidth || 320}px`,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{/* 用量信息区域 - hover 时向左移动,为操作按钮腾出空间 */}
|
{/* 用量信息区域 - hover 时向左移动,为操作按钮腾出空间 */}
|
||||||
<div className="ml-auto">
|
<div className="ml-auto">
|
||||||
@@ -323,6 +338,7 @@ export function ProviderCard({
|
|||||||
appId={appId}
|
appId={appId}
|
||||||
usageEnabled={usageEnabled}
|
usageEnabled={usageEnabled}
|
||||||
isCurrent={isCurrent}
|
isCurrent={isCurrent}
|
||||||
|
isInConfig={isInConfig}
|
||||||
inline={true}
|
inline={true}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -356,7 +372,9 @@ export function ProviderCard({
|
|||||||
className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1.5 pl-3 opacity-0 pointer-events-none group-hover:opacity-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-all duration-200 translate-x-2 group-hover:translate-x-0 group-focus-within:translate-x-0"
|
className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1.5 pl-3 opacity-0 pointer-events-none group-hover:opacity-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-all duration-200 translate-x-2 group-hover:translate-x-0 group-focus-within:translate-x-0"
|
||||||
>
|
>
|
||||||
<ProviderActions
|
<ProviderActions
|
||||||
|
appId={appId}
|
||||||
isCurrent={isCurrent}
|
isCurrent={isCurrent}
|
||||||
|
isInConfig={isInConfig}
|
||||||
isTesting={isTesting}
|
isTesting={isTesting}
|
||||||
isProxyTakeover={isProxyTakeover}
|
isProxyTakeover={isProxyTakeover}
|
||||||
onSwitch={() => onSwitch(provider)}
|
onSwitch={() => onSwitch(provider)}
|
||||||
@@ -365,7 +383,14 @@ export function ProviderCard({
|
|||||||
onTest={onTest ? () => onTest(provider) : undefined}
|
onTest={onTest ? () => onTest(provider) : undefined}
|
||||||
onConfigureUsage={() => onConfigureUsage(provider)}
|
onConfigureUsage={() => onConfigureUsage(provider)}
|
||||||
onDelete={() => onDelete(provider)}
|
onDelete={() => onDelete(provider)}
|
||||||
onOpenTerminal={onOpenTerminal ? () => onOpenTerminal(provider) : undefined}
|
onRemoveFromConfig={
|
||||||
|
onRemoveFromConfig
|
||||||
|
? () => onRemoveFromConfig(provider)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onOpenTerminal={
|
||||||
|
onOpenTerminal ? () => onOpenTerminal(provider) : undefined
|
||||||
|
}
|
||||||
// 故障转移相关
|
// 故障转移相关
|
||||||
isAutoFailoverEnabled={isAutoFailoverEnabled}
|
isAutoFailoverEnabled={isAutoFailoverEnabled}
|
||||||
isInFailoverQueue={isInFailoverQueue}
|
isInFailoverQueue={isInFailoverQueue}
|
||||||
@@ -384,6 +409,7 @@ export function ProviderCard({
|
|||||||
appId={appId}
|
appId={appId}
|
||||||
usageEnabled={usageEnabled}
|
usageEnabled={usageEnabled}
|
||||||
isCurrent={isCurrent}
|
isCurrent={isCurrent}
|
||||||
|
isInConfig={isInConfig}
|
||||||
inline={false}
|
inline={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ import {
|
|||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { Search, X } from "lucide-react";
|
import { Search, X } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import type { Provider } from "@/types";
|
import type { Provider } from "@/types";
|
||||||
import type { AppId } from "@/lib/api";
|
import type { AppId } from "@/lib/api";
|
||||||
|
import { providersApi } from "@/lib/api/providers";
|
||||||
import { useDragSort } from "@/hooks/useDragSort";
|
import { useDragSort } from "@/hooks/useDragSort";
|
||||||
import { useStreamCheck } from "@/hooks/useStreamCheck";
|
import { useStreamCheck } from "@/hooks/useStreamCheck";
|
||||||
import { ProviderCard } from "@/components/providers/ProviderCard";
|
import { ProviderCard } from "@/components/providers/ProviderCard";
|
||||||
@@ -38,6 +40,8 @@ interface ProviderListProps {
|
|||||||
onSwitch: (provider: Provider) => void;
|
onSwitch: (provider: Provider) => void;
|
||||||
onEdit: (provider: Provider) => void;
|
onEdit: (provider: Provider) => void;
|
||||||
onDelete: (provider: Provider) => void;
|
onDelete: (provider: Provider) => void;
|
||||||
|
/** OpenCode: remove from live config (not delete from database) */
|
||||||
|
onRemoveFromConfig?: (provider: Provider) => void;
|
||||||
onDuplicate: (provider: Provider) => void;
|
onDuplicate: (provider: Provider) => void;
|
||||||
onConfigureUsage?: (provider: Provider) => void;
|
onConfigureUsage?: (provider: Provider) => void;
|
||||||
onOpenWebsite: (url: string) => void;
|
onOpenWebsite: (url: string) => void;
|
||||||
@@ -56,6 +60,7 @@ export function ProviderList({
|
|||||||
onSwitch,
|
onSwitch,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onRemoveFromConfig,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
onConfigureUsage,
|
onConfigureUsage,
|
||||||
onOpenWebsite,
|
onOpenWebsite,
|
||||||
@@ -72,6 +77,22 @@ export function ProviderList({
|
|||||||
appId,
|
appId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// OpenCode: 查询 live 配置中的供应商 ID 列表,用于判断 isInConfig
|
||||||
|
const { data: opencodeLiveIds } = useQuery({
|
||||||
|
queryKey: ["opencodeLiveProviderIds"],
|
||||||
|
queryFn: () => providersApi.getOpenCodeLiveProviderIds(),
|
||||||
|
enabled: appId === "opencode",
|
||||||
|
});
|
||||||
|
|
||||||
|
// OpenCode: 判断供应商是否已添加到 opencode.json
|
||||||
|
const isProviderInConfig = useCallback(
|
||||||
|
(providerId: string): boolean => {
|
||||||
|
if (appId !== "opencode") return true; // 非 OpenCode 应用始终返回 true
|
||||||
|
return opencodeLiveIds?.includes(providerId) ?? false;
|
||||||
|
},
|
||||||
|
[appId, opencodeLiveIds],
|
||||||
|
);
|
||||||
|
|
||||||
// 流式健康检查
|
// 流式健康检查
|
||||||
const { checkProvider, isChecking } = useStreamCheck(appId);
|
const { checkProvider, isChecking } = useStreamCheck(appId);
|
||||||
|
|
||||||
@@ -199,14 +220,16 @@ export function ProviderList({
|
|||||||
provider={provider}
|
provider={provider}
|
||||||
isCurrent={provider.id === currentProviderId}
|
isCurrent={provider.id === currentProviderId}
|
||||||
appId={appId}
|
appId={appId}
|
||||||
|
isInConfig={isProviderInConfig(provider.id)}
|
||||||
onSwitch={onSwitch}
|
onSwitch={onSwitch}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
|
onRemoveFromConfig={onRemoveFromConfig}
|
||||||
onDuplicate={onDuplicate}
|
onDuplicate={onDuplicate}
|
||||||
onConfigureUsage={onConfigureUsage}
|
onConfigureUsage={onConfigureUsage}
|
||||||
onOpenWebsite={onOpenWebsite}
|
onOpenWebsite={onOpenWebsite}
|
||||||
onOpenTerminal={onOpenTerminal}
|
onOpenTerminal={onOpenTerminal}
|
||||||
onTest={handleTest}
|
onTest={appId !== "opencode" ? handleTest : undefined}
|
||||||
isTesting={isChecking(provider.id)}
|
isTesting={isChecking(provider.id)}
|
||||||
isProxyRunning={isProxyRunning}
|
isProxyRunning={isProxyRunning}
|
||||||
isProxyTakeover={isProxyTakeover}
|
isProxyTakeover={isProxyTakeover}
|
||||||
@@ -308,14 +331,17 @@ interface SortableProviderCardProps {
|
|||||||
provider: Provider;
|
provider: Provider;
|
||||||
isCurrent: boolean;
|
isCurrent: boolean;
|
||||||
appId: AppId;
|
appId: AppId;
|
||||||
|
isInConfig: boolean;
|
||||||
onSwitch: (provider: Provider) => void;
|
onSwitch: (provider: Provider) => void;
|
||||||
onEdit: (provider: Provider) => void;
|
onEdit: (provider: Provider) => void;
|
||||||
onDelete: (provider: Provider) => void;
|
onDelete: (provider: Provider) => void;
|
||||||
|
/** OpenCode: remove from live config (not delete from database) */
|
||||||
|
onRemoveFromConfig?: (provider: Provider) => void;
|
||||||
onDuplicate: (provider: Provider) => void;
|
onDuplicate: (provider: Provider) => void;
|
||||||
onConfigureUsage?: (provider: Provider) => void;
|
onConfigureUsage?: (provider: Provider) => void;
|
||||||
onOpenWebsite: (url: string) => void;
|
onOpenWebsite: (url: string) => void;
|
||||||
onOpenTerminal?: (provider: Provider) => void;
|
onOpenTerminal?: (provider: Provider) => void;
|
||||||
onTest: (provider: Provider) => void;
|
onTest?: (provider: Provider) => void;
|
||||||
isTesting: boolean;
|
isTesting: boolean;
|
||||||
isProxyRunning: boolean;
|
isProxyRunning: boolean;
|
||||||
isProxyTakeover: boolean;
|
isProxyTakeover: boolean;
|
||||||
@@ -331,9 +357,11 @@ function SortableProviderCard({
|
|||||||
provider,
|
provider,
|
||||||
isCurrent,
|
isCurrent,
|
||||||
appId,
|
appId,
|
||||||
|
isInConfig,
|
||||||
onSwitch,
|
onSwitch,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onRemoveFromConfig,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
onConfigureUsage,
|
onConfigureUsage,
|
||||||
onOpenWebsite,
|
onOpenWebsite,
|
||||||
@@ -368,9 +396,11 @@ function SortableProviderCard({
|
|||||||
provider={provider}
|
provider={provider}
|
||||||
isCurrent={isCurrent}
|
isCurrent={isCurrent}
|
||||||
appId={appId}
|
appId={appId}
|
||||||
|
isInConfig={isInConfig}
|
||||||
onSwitch={onSwitch}
|
onSwitch={onSwitch}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
|
onRemoveFromConfig={onRemoveFromConfig}
|
||||||
onDuplicate={onDuplicate}
|
onDuplicate={onDuplicate}
|
||||||
onConfigureUsage={
|
onConfigureUsage={
|
||||||
onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined
|
onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
FormField,
|
FormField,
|
||||||
@@ -24,9 +25,14 @@ import type { ProviderFormData } from "@/lib/schemas/provider";
|
|||||||
|
|
||||||
interface BasicFormFieldsProps {
|
interface BasicFormFieldsProps {
|
||||||
form: UseFormReturn<ProviderFormData>;
|
form: UseFormReturn<ProviderFormData>;
|
||||||
|
/** Slot to render content between icon and name fields */
|
||||||
|
beforeNameSlot?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BasicFormFields({ form }: BasicFormFieldsProps) {
|
export function BasicFormFields({
|
||||||
|
form,
|
||||||
|
beforeNameSlot,
|
||||||
|
}: BasicFormFieldsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [iconDialogOpen, setIconDialogOpen] = useState(false);
|
const [iconDialogOpen, setIconDialogOpen] = useState(false);
|
||||||
|
|
||||||
@@ -78,7 +84,7 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) {
|
|||||||
>
|
>
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="flex-shrink-0 py-4 border-b border-border-default bg-muted/40">
|
<div className="flex-shrink-0 py-4 border-b border-border-default bg-muted/40">
|
||||||
<div className="mx-auto max-w-[56rem] px-6 flex items-center gap-4">
|
<div className="px-6 flex items-center gap-4">
|
||||||
<DialogClose asChild>
|
<DialogClose asChild>
|
||||||
<Button type="button" variant="outline" size="icon">
|
<Button type="button" variant="outline" size="icon">
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
@@ -92,7 +98,7 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="space-y-2 mx-auto max-w-[56rem] px-6 py-6 w-full">
|
<div className="space-y-2 px-6 py-6 w-full">
|
||||||
<IconPicker
|
<IconPicker
|
||||||
value={currentIcon}
|
value={currentIcon}
|
||||||
onValueChange={handleIconSelect}
|
onValueChange={handleIconSelect}
|
||||||
@@ -112,6 +118,9 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Slot for additional fields between icon and name */}
|
||||||
|
{beforeNameSlot}
|
||||||
|
|
||||||
{/* 基础信息 - 网格布局 */}
|
{/* 基础信息 - 网格布局 */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<FormField
|
<FormField
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
|||||||
import type { CustomEndpoint, EndpointCandidate } from "@/types";
|
import type { CustomEndpoint, EndpointCandidate } from "@/types";
|
||||||
|
|
||||||
// 端点测速超时配置(秒)
|
// 端点测速超时配置(秒)
|
||||||
const ENDPOINT_TIMEOUT_SECS = {
|
const ENDPOINT_TIMEOUT_SECS: Record<AppId, number> = {
|
||||||
codex: 12,
|
codex: 12,
|
||||||
claude: 8,
|
claude: 8,
|
||||||
gemini: 8, // 新增 gemini
|
gemini: 8,
|
||||||
} as const;
|
opencode: 8,
|
||||||
|
};
|
||||||
|
|
||||||
interface TestResult {
|
interface TestResult {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -524,7 +525,7 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
type="url"
|
type="text"
|
||||||
value={customUrl}
|
value={customUrl}
|
||||||
placeholder={t("endpointTest.addEndpointPlaceholder")}
|
placeholder={t("endpointTest.addEndpointPlaceholder")}
|
||||||
onChange={(event) => setCustomUrl(event.target.value)}
|
onChange={(event) => setCustomUrl(event.target.value)}
|
||||||
|
|||||||
@@ -0,0 +1,664 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { FormLabel } from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Plus, Trash2, ChevronRight } from "lucide-react";
|
||||||
|
import { ApiKeySection } from "./shared";
|
||||||
|
import { opencodeNpmPackages } from "@/config/opencodeProviderPresets";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { ProviderCategory, OpenCodeModel } from "@/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model ID input with local state to prevent focus loss.
|
||||||
|
* The key prop issue: when Model ID changes, React sees it as a new element
|
||||||
|
* and unmounts/remounts the input, losing focus. Using local state + onBlur
|
||||||
|
* keeps the key stable during editing.
|
||||||
|
*/
|
||||||
|
function ModelIdInput({
|
||||||
|
modelId,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
}: {
|
||||||
|
modelId: string;
|
||||||
|
onChange: (newId: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}) {
|
||||||
|
const [localValue, setLocalValue] = useState(modelId);
|
||||||
|
|
||||||
|
// Sync when external modelId changes (e.g., undo operation)
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalValue(modelId);
|
||||||
|
}, [modelId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
value={localValue}
|
||||||
|
onChange={(e) => setLocalValue(e.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
if (localValue !== modelId && localValue.trim()) {
|
||||||
|
onChange(localValue);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extra option key input with local state to prevent focus loss.
|
||||||
|
* Same pattern as ModelIdInput - use local state during editing,
|
||||||
|
* only commit changes on blur.
|
||||||
|
*/
|
||||||
|
function ExtraOptionKeyInput({
|
||||||
|
optionKey,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
}: {
|
||||||
|
optionKey: string;
|
||||||
|
onChange: (newKey: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}) {
|
||||||
|
// For new options with placeholder keys like "option-123", show empty string
|
||||||
|
const displayValue = optionKey.startsWith("option-") ? "" : optionKey;
|
||||||
|
const [localValue, setLocalValue] = useState(displayValue);
|
||||||
|
|
||||||
|
// Sync when external key changes
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalValue(optionKey.startsWith("option-") ? "" : optionKey);
|
||||||
|
}, [optionKey]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
value={localValue}
|
||||||
|
onChange={(e) => setLocalValue(e.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
const trimmed = localValue.trim();
|
||||||
|
if (trimmed && trimmed !== optionKey) {
|
||||||
|
onChange(trimmed);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model option key input with local state to prevent focus loss.
|
||||||
|
* Reuses the same pattern as ExtraOptionKeyInput.
|
||||||
|
*/
|
||||||
|
function ModelOptionKeyInput({
|
||||||
|
optionKey,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
}: {
|
||||||
|
optionKey: string;
|
||||||
|
onChange: (newKey: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}) {
|
||||||
|
const displayValue = optionKey.startsWith("option-") ? "" : optionKey;
|
||||||
|
const [localValue, setLocalValue] = useState(displayValue);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalValue(optionKey.startsWith("option-") ? "" : optionKey);
|
||||||
|
}, [optionKey]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
value={localValue}
|
||||||
|
onChange={(e) => setLocalValue(e.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
const trimmed = localValue.trim();
|
||||||
|
if (trimmed && trimmed !== optionKey) {
|
||||||
|
onChange(trimmed);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenCodeFormFieldsProps {
|
||||||
|
// NPM Package
|
||||||
|
npm: string;
|
||||||
|
onNpmChange: (value: string) => void;
|
||||||
|
|
||||||
|
// API Key
|
||||||
|
apiKey: string;
|
||||||
|
onApiKeyChange: (value: string) => void;
|
||||||
|
category?: ProviderCategory;
|
||||||
|
shouldShowApiKeyLink: boolean;
|
||||||
|
websiteUrl: string;
|
||||||
|
|
||||||
|
// Base URL
|
||||||
|
baseUrl: string;
|
||||||
|
onBaseUrlChange: (value: string) => void;
|
||||||
|
|
||||||
|
// Models
|
||||||
|
models: Record<string, OpenCodeModel>;
|
||||||
|
onModelsChange: (models: Record<string, OpenCodeModel>) => void;
|
||||||
|
|
||||||
|
// Extra Options
|
||||||
|
extraOptions: Record<string, string>;
|
||||||
|
onExtraOptionsChange: (options: Record<string, string>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpenCodeFormFields({
|
||||||
|
npm,
|
||||||
|
onNpmChange,
|
||||||
|
apiKey,
|
||||||
|
onApiKeyChange,
|
||||||
|
category,
|
||||||
|
shouldShowApiKeyLink,
|
||||||
|
websiteUrl,
|
||||||
|
baseUrl,
|
||||||
|
onBaseUrlChange,
|
||||||
|
models,
|
||||||
|
onModelsChange,
|
||||||
|
extraOptions,
|
||||||
|
onExtraOptionsChange,
|
||||||
|
}: OpenCodeFormFieldsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Track which models have expanded options panel
|
||||||
|
const [expandedModels, setExpandedModels] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Toggle model expand state
|
||||||
|
const toggleModelExpand = (key: string) => {
|
||||||
|
setExpandedModels((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) next.delete(key);
|
||||||
|
else next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add a new model entry
|
||||||
|
const handleAddModel = () => {
|
||||||
|
const newKey = `model-${Date.now()}`;
|
||||||
|
onModelsChange({
|
||||||
|
...models,
|
||||||
|
[newKey]: { name: "" },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove a model entry
|
||||||
|
const handleRemoveModel = (key: string) => {
|
||||||
|
const newModels = { ...models };
|
||||||
|
delete newModels[key];
|
||||||
|
onModelsChange(newModels);
|
||||||
|
// Also remove from expanded set
|
||||||
|
setExpandedModels((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update model ID (key)
|
||||||
|
const handleModelIdChange = (oldKey: string, newKey: string) => {
|
||||||
|
if (oldKey === newKey || !newKey.trim()) return;
|
||||||
|
const newModels: Record<string, OpenCodeModel> = {};
|
||||||
|
for (const [k, v] of Object.entries(models)) {
|
||||||
|
if (k === oldKey) {
|
||||||
|
newModels[newKey] = v;
|
||||||
|
} else {
|
||||||
|
newModels[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onModelsChange(newModels);
|
||||||
|
// Update expanded set if this model was expanded
|
||||||
|
if (expandedModels.has(oldKey)) {
|
||||||
|
setExpandedModels((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(oldKey);
|
||||||
|
next.add(newKey);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update model name
|
||||||
|
const handleModelNameChange = (key: string, name: string) => {
|
||||||
|
onModelsChange({
|
||||||
|
...models,
|
||||||
|
[key]: { ...models[key], name },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Model options handlers
|
||||||
|
const handleAddModelOption = (modelKey: string) => {
|
||||||
|
const model = models[modelKey];
|
||||||
|
const newOptionKey = `option-${Date.now()}`;
|
||||||
|
onModelsChange({
|
||||||
|
...models,
|
||||||
|
[modelKey]: {
|
||||||
|
...model,
|
||||||
|
options: { ...model.options, [newOptionKey]: "" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveModelOption = (modelKey: string, optionKey: string) => {
|
||||||
|
const model = models[modelKey];
|
||||||
|
const newOptions = { ...model.options };
|
||||||
|
delete newOptions[optionKey];
|
||||||
|
onModelsChange({
|
||||||
|
...models,
|
||||||
|
[modelKey]: {
|
||||||
|
...model,
|
||||||
|
options: Object.keys(newOptions).length > 0 ? newOptions : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModelOptionKeyChange = (
|
||||||
|
modelKey: string,
|
||||||
|
oldKey: string,
|
||||||
|
newKey: string,
|
||||||
|
) => {
|
||||||
|
if (!newKey.trim() || oldKey === newKey) return;
|
||||||
|
const model = models[modelKey];
|
||||||
|
const newOptions: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(model.options || {})) {
|
||||||
|
if (k === oldKey) newOptions[newKey] = v;
|
||||||
|
else newOptions[k] = v;
|
||||||
|
}
|
||||||
|
onModelsChange({
|
||||||
|
...models,
|
||||||
|
[modelKey]: { ...model, options: newOptions },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModelOptionValueChange = (
|
||||||
|
modelKey: string,
|
||||||
|
optionKey: string,
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
|
const model = models[modelKey];
|
||||||
|
let parsedValue: unknown;
|
||||||
|
try {
|
||||||
|
parsedValue = JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
parsedValue = value;
|
||||||
|
}
|
||||||
|
onModelsChange({
|
||||||
|
...models,
|
||||||
|
[modelKey]: {
|
||||||
|
...model,
|
||||||
|
options: { ...model.options, [optionKey]: parsedValue },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extra Options handlers
|
||||||
|
const handleAddExtraOption = () => {
|
||||||
|
const newKey = `option-${Date.now()}`;
|
||||||
|
onExtraOptionsChange({
|
||||||
|
...extraOptions,
|
||||||
|
[newKey]: "",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveExtraOption = (key: string) => {
|
||||||
|
const newOptions = { ...extraOptions };
|
||||||
|
delete newOptions[key];
|
||||||
|
onExtraOptionsChange(newOptions);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExtraOptionKeyChange = (oldKey: string, newKey: string) => {
|
||||||
|
if (oldKey === newKey) return;
|
||||||
|
const newOptions: Record<string, string> = {};
|
||||||
|
for (const [k, v] of Object.entries(extraOptions)) {
|
||||||
|
if (k === oldKey) {
|
||||||
|
newOptions[newKey.trim() || oldKey] = v;
|
||||||
|
} else {
|
||||||
|
newOptions[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onExtraOptionsChange(newOptions);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExtraOptionValueChange = (key: string, value: string) => {
|
||||||
|
onExtraOptionsChange({
|
||||||
|
...extraOptions,
|
||||||
|
[key]: value,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* NPM Package Selector */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<FormLabel htmlFor="opencode-npm">
|
||||||
|
{t("opencode.npmPackage", {
|
||||||
|
defaultValue: "接口格式",
|
||||||
|
})}
|
||||||
|
</FormLabel>
|
||||||
|
<Select value={npm} onValueChange={onNpmChange}>
|
||||||
|
<SelectTrigger id="opencode-npm">
|
||||||
|
<SelectValue
|
||||||
|
placeholder={t("opencode.selectPackage", {
|
||||||
|
defaultValue: "Select a package",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{opencodeNpmPackages.map((pkg) => (
|
||||||
|
<SelectItem key={pkg.value} value={pkg.value}>
|
||||||
|
{pkg.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("opencode.npmPackageHint", {
|
||||||
|
defaultValue:
|
||||||
|
"Select the AI SDK package that matches your provider.",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API Key */}
|
||||||
|
<ApiKeySection
|
||||||
|
value={apiKey}
|
||||||
|
onChange={onApiKeyChange}
|
||||||
|
category={category}
|
||||||
|
shouldShowLink={shouldShowApiKeyLink}
|
||||||
|
websiteUrl={websiteUrl}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Base URL */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<FormLabel htmlFor="opencode-baseurl">
|
||||||
|
{t("opencode.baseUrl", { defaultValue: "Base URL" })}
|
||||||
|
</FormLabel>
|
||||||
|
<Input
|
||||||
|
id="opencode-baseurl"
|
||||||
|
value={baseUrl}
|
||||||
|
onChange={(e) => onBaseUrlChange(e.target.value)}
|
||||||
|
placeholder="https://api.example.com/v1"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("opencode.baseUrlHint", {
|
||||||
|
defaultValue:
|
||||||
|
"The base URL for the API endpoint. Leave empty to use the default endpoint for official SDKs.",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Extra Options Editor */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<FormLabel>
|
||||||
|
{t("opencode.extraOptions", { defaultValue: "额外选项" })}
|
||||||
|
</FormLabel>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAddExtraOption}
|
||||||
|
className="h-7 gap-1"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
{t("opencode.addExtraOption", { defaultValue: "添加" })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{Object.keys(extraOptions).length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground py-2">
|
||||||
|
{t("opencode.noExtraOptions", {
|
||||||
|
defaultValue: "暂无额外选项",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground px-1 mb-1">
|
||||||
|
<span className="flex-1">
|
||||||
|
{t("opencode.extraOptionKey", { defaultValue: "键名" })}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1">
|
||||||
|
{t("opencode.extraOptionValue", { defaultValue: "值" })}
|
||||||
|
</span>
|
||||||
|
<span className="w-9" />
|
||||||
|
</div>
|
||||||
|
{Object.entries(extraOptions).map(([key, value]) => (
|
||||||
|
<div key={key} className="flex items-center gap-2">
|
||||||
|
<ExtraOptionKeyInput
|
||||||
|
optionKey={key}
|
||||||
|
onChange={(newKey) => handleExtraOptionKeyChange(key, newKey)}
|
||||||
|
placeholder={t("opencode.extraOptionKeyPlaceholder", {
|
||||||
|
defaultValue: "timeout",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleExtraOptionValueChange(key, e.target.value)
|
||||||
|
}
|
||||||
|
placeholder={t("opencode.extraOptionValuePlaceholder", {
|
||||||
|
defaultValue: "600000",
|
||||||
|
})}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleRemoveExtraOption(key)}
|
||||||
|
className="h-9 w-9 text-muted-foreground hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("opencode.extraOptionsHint", {
|
||||||
|
defaultValue:
|
||||||
|
"配置额外的 SDK 选项,如 timeout、setCacheKey 等。值会自动解析类型(数字、布尔值等)。",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Models Editor */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<FormLabel>
|
||||||
|
{t("opencode.models", { defaultValue: "Models" })}
|
||||||
|
</FormLabel>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAddModel}
|
||||||
|
className="h-7 gap-1"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
{t("opencode.addModel", { defaultValue: "Add" })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{Object.keys(models).length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground py-2">
|
||||||
|
{t("opencode.noModels", {
|
||||||
|
defaultValue: "No models configured. Click Add to add a model.",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground px-1 mb-1">
|
||||||
|
<span className="w-9" />
|
||||||
|
<span className="flex-1">
|
||||||
|
{t("opencode.modelId", { defaultValue: "模型 ID" })}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1">
|
||||||
|
{t("opencode.modelName", { defaultValue: "显示名称" })}
|
||||||
|
</span>
|
||||||
|
<span className="w-9" />
|
||||||
|
</div>
|
||||||
|
{Object.entries(models).map(([key, model]) => (
|
||||||
|
<div key={key} className="space-y-2">
|
||||||
|
{/* Model row */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => toggleModelExpand(key)}
|
||||||
|
className="h-9 w-9 shrink-0"
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
className={cn(
|
||||||
|
"h-4 w-4 transition-transform",
|
||||||
|
expandedModels.has(key) && "rotate-90",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
<ModelIdInput
|
||||||
|
modelId={key}
|
||||||
|
onChange={(newId) => handleModelIdChange(key, newId)}
|
||||||
|
placeholder={t("opencode.modelId", {
|
||||||
|
defaultValue: "Model ID",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={model.name}
|
||||||
|
onChange={(e) => handleModelNameChange(key, e.target.value)}
|
||||||
|
placeholder={t("opencode.modelName", {
|
||||||
|
defaultValue: "Display Name",
|
||||||
|
})}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleRemoveModel(key)}
|
||||||
|
className="h-9 w-9 text-muted-foreground hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded model options */}
|
||||||
|
{expandedModels.has(key) && (
|
||||||
|
<div className="ml-9 pl-4 border-l-2 border-muted space-y-2">
|
||||||
|
{Object.keys(model.options || {}).length === 0 ? (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-muted-foreground py-1">
|
||||||
|
{t("opencode.noModelOptions", {
|
||||||
|
defaultValue: "模型选项,点击 + 添加",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleAddModelOption(key)}
|
||||||
|
className="h-6 px-2 gap-1"
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{Object.entries(model.options || {}).map(
|
||||||
|
([optKey, optValue]) => (
|
||||||
|
<div
|
||||||
|
key={optKey}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<ModelOptionKeyInput
|
||||||
|
optionKey={optKey}
|
||||||
|
onChange={(newKey) =>
|
||||||
|
handleModelOptionKeyChange(
|
||||||
|
key,
|
||||||
|
optKey,
|
||||||
|
newKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder={t(
|
||||||
|
"opencode.modelOptionKeyPlaceholder",
|
||||||
|
{
|
||||||
|
defaultValue: "provider",
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={
|
||||||
|
typeof optValue === "string"
|
||||||
|
? optValue
|
||||||
|
: JSON.stringify(optValue)
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleModelOptionValueChange(
|
||||||
|
key,
|
||||||
|
optKey,
|
||||||
|
e.target.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder={t(
|
||||||
|
"opencode.modelOptionValuePlaceholder",
|
||||||
|
{
|
||||||
|
defaultValue: '{"order": ["baseten"]}',
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() =>
|
||||||
|
handleRemoveModelOption(key, optKey)
|
||||||
|
}
|
||||||
|
className="h-9 w-9 text-muted-foreground hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleAddModelOption(key)}
|
||||||
|
className="h-6 px-2 gap-1"
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("opencode.modelsHint", {
|
||||||
|
defaultValue:
|
||||||
|
"Configure available models. Model ID is the API identifier, Display Name is shown in the UI.",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
FlaskConical,
|
||||||
|
Globe,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { ProviderTestConfig, ProviderProxyConfig } from "@/types";
|
||||||
|
|
||||||
|
interface ProviderAdvancedConfigProps {
|
||||||
|
testConfig: ProviderTestConfig;
|
||||||
|
proxyConfig: ProviderProxyConfig;
|
||||||
|
onTestConfigChange: (config: ProviderTestConfig) => void;
|
||||||
|
onProxyConfigChange: (config: ProviderProxyConfig) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 ProviderProxyConfig 构建完整 URL */
|
||||||
|
function buildProxyUrl(config: ProviderProxyConfig): string {
|
||||||
|
if (!config.proxyHost) return "";
|
||||||
|
|
||||||
|
const protocol = config.proxyType || "http";
|
||||||
|
const host = config.proxyHost;
|
||||||
|
const port = config.proxyPort || (protocol === "socks5" ? 1080 : 7890);
|
||||||
|
|
||||||
|
return `${protocol}://${host}:${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从完整 URL 解析为 ProviderProxyConfig */
|
||||||
|
function parseProxyUrl(url: string): Partial<ProviderProxyConfig> {
|
||||||
|
if (!url.trim()) {
|
||||||
|
return { proxyHost: undefined, proxyPort: undefined, proxyType: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const protocol = parsed.protocol.replace(":", "") as
|
||||||
|
| "http"
|
||||||
|
| "https"
|
||||||
|
| "socks5";
|
||||||
|
const host = parsed.hostname;
|
||||||
|
const port = parsed.port ? parseInt(parsed.port, 10) : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
proxyType: protocol,
|
||||||
|
proxyHost: host || undefined,
|
||||||
|
proxyPort: port,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// 尝试简单解析(不是标准 URL 格式)
|
||||||
|
const match = url.match(/^(?:(\w+):\/\/)?([^:]+)(?::(\d+))?$/);
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
proxyType: (match[1] as "http" | "https" | "socks5") || "http",
|
||||||
|
proxyHost: match[2] || undefined,
|
||||||
|
proxyPort: match[3] ? parseInt(match[3], 10) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProviderAdvancedConfig({
|
||||||
|
testConfig,
|
||||||
|
proxyConfig,
|
||||||
|
onTestConfigChange,
|
||||||
|
onProxyConfigChange,
|
||||||
|
}: ProviderAdvancedConfigProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isTestConfigOpen, setIsTestConfigOpen] = useState(testConfig.enabled);
|
||||||
|
const [isProxyConfigOpen, setIsProxyConfigOpen] = useState(
|
||||||
|
proxyConfig.enabled,
|
||||||
|
);
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
|
// 代理 URL 输入状态(仅在初始化时从 proxyConfig 构建)
|
||||||
|
const [proxyUrl, setProxyUrl] = useState(() => buildProxyUrl(proxyConfig));
|
||||||
|
|
||||||
|
// 标记是否为用户主动输入(用于区分外部更新和用户输入)
|
||||||
|
const [isUserTyping, setIsUserTyping] = useState(false);
|
||||||
|
|
||||||
|
// 同步外部 testConfig.enabled 变化到展开状态
|
||||||
|
useEffect(() => {
|
||||||
|
setIsTestConfigOpen(testConfig.enabled);
|
||||||
|
}, [testConfig.enabled]);
|
||||||
|
|
||||||
|
// 同步外部 proxyConfig.enabled 变化到展开状态
|
||||||
|
useEffect(() => {
|
||||||
|
setIsProxyConfigOpen(proxyConfig.enabled);
|
||||||
|
}, [proxyConfig.enabled]);
|
||||||
|
|
||||||
|
// 仅在外部 proxyConfig 变化且非用户输入时同步(如:重置表单、加载数据)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isUserTyping) {
|
||||||
|
const newUrl = buildProxyUrl(proxyConfig);
|
||||||
|
if (newUrl !== proxyUrl) {
|
||||||
|
setProxyUrl(newUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [proxyConfig.proxyType, proxyConfig.proxyHost, proxyConfig.proxyPort]);
|
||||||
|
|
||||||
|
// 处理代理 URL 变化(用户输入时不触发 URL 重建)
|
||||||
|
const handleProxyUrlChange = (value: string) => {
|
||||||
|
setIsUserTyping(true);
|
||||||
|
setProxyUrl(value);
|
||||||
|
const parsed = parseProxyUrl(value);
|
||||||
|
onProxyConfigChange({
|
||||||
|
...proxyConfig,
|
||||||
|
...parsed,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 输入框失焦时结束用户输入状态
|
||||||
|
const handleProxyUrlBlur = () => {
|
||||||
|
setIsUserTyping(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 清除代理配置
|
||||||
|
const handleClearProxy = () => {
|
||||||
|
setProxyUrl("");
|
||||||
|
onProxyConfigChange({
|
||||||
|
...proxyConfig,
|
||||||
|
proxyType: undefined,
|
||||||
|
proxyHost: undefined,
|
||||||
|
proxyPort: undefined,
|
||||||
|
proxyUsername: undefined,
|
||||||
|
proxyPassword: undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 模型测试配置 */}
|
||||||
|
<div className="rounded-lg border border-border/50 bg-muted/20">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center justify-between p-4 hover:bg-muted/30 transition-colors"
|
||||||
|
onClick={() => setIsTestConfigOpen(!isTestConfigOpen)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FlaskConical className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="font-medium">
|
||||||
|
{t("providerAdvanced.testConfig", {
|
||||||
|
defaultValue: "模型测试配置",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Label
|
||||||
|
htmlFor="test-config-enabled"
|
||||||
|
className="text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
{t("providerAdvanced.useCustomConfig", {
|
||||||
|
defaultValue: "使用单独配置",
|
||||||
|
})}
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id="test-config-enabled"
|
||||||
|
checked={testConfig.enabled}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
onTestConfigChange({ ...testConfig, enabled: checked });
|
||||||
|
if (checked) setIsTestConfigOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{isTestConfigOpen ? (
|
||||||
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden transition-all duration-200",
|
||||||
|
isTestConfigOpen
|
||||||
|
? "max-h-[500px] opacity-100"
|
||||||
|
: "max-h-0 opacity-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="border-t border-border/50 p-4 space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("providerAdvanced.testConfigDesc", {
|
||||||
|
defaultValue:
|
||||||
|
"为此供应商配置单独的模型测试参数,不启用时使用全局配置。",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="test-model">
|
||||||
|
{t("providerAdvanced.testModel", {
|
||||||
|
defaultValue: "测试模型",
|
||||||
|
})}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="test-model"
|
||||||
|
value={testConfig.testModel || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onTestConfigChange({
|
||||||
|
...testConfig,
|
||||||
|
testModel: e.target.value || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder={t("providerAdvanced.testModelPlaceholder", {
|
||||||
|
defaultValue: "留空使用全局配置",
|
||||||
|
})}
|
||||||
|
disabled={!testConfig.enabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="test-timeout">
|
||||||
|
{t("providerAdvanced.timeoutSecs", {
|
||||||
|
defaultValue: "超时时间(秒)",
|
||||||
|
})}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="test-timeout"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={300}
|
||||||
|
value={testConfig.timeoutSecs || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onTestConfigChange({
|
||||||
|
...testConfig,
|
||||||
|
timeoutSecs: e.target.value
|
||||||
|
? parseInt(e.target.value, 10)
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="45"
|
||||||
|
disabled={!testConfig.enabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="test-prompt">
|
||||||
|
{t("providerAdvanced.testPrompt", {
|
||||||
|
defaultValue: "测试提示词",
|
||||||
|
})}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="test-prompt"
|
||||||
|
value={testConfig.testPrompt || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onTestConfigChange({
|
||||||
|
...testConfig,
|
||||||
|
testPrompt: e.target.value || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="Who are you?"
|
||||||
|
disabled={!testConfig.enabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="degraded-threshold">
|
||||||
|
{t("providerAdvanced.degradedThreshold", {
|
||||||
|
defaultValue: "降级阈值(毫秒)",
|
||||||
|
})}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="degraded-threshold"
|
||||||
|
type="number"
|
||||||
|
min={100}
|
||||||
|
max={60000}
|
||||||
|
value={testConfig.degradedThresholdMs || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onTestConfigChange({
|
||||||
|
...testConfig,
|
||||||
|
degradedThresholdMs: e.target.value
|
||||||
|
? parseInt(e.target.value, 10)
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="6000"
|
||||||
|
disabled={!testConfig.enabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="max-retries">
|
||||||
|
{t("providerAdvanced.maxRetries", {
|
||||||
|
defaultValue: "最大重试次数",
|
||||||
|
})}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="max-retries"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={10}
|
||||||
|
value={testConfig.maxRetries ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onTestConfigChange({
|
||||||
|
...testConfig,
|
||||||
|
maxRetries: e.target.value
|
||||||
|
? parseInt(e.target.value, 10)
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="2"
|
||||||
|
disabled={!testConfig.enabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 代理配置 */}
|
||||||
|
<div className="rounded-lg border border-border/50 bg-muted/20">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center justify-between p-4 hover:bg-muted/30 transition-colors"
|
||||||
|
onClick={() => setIsProxyConfigOpen(!isProxyConfigOpen)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="font-medium">
|
||||||
|
{t("providerAdvanced.proxyConfig", {
|
||||||
|
defaultValue: "代理配置",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Label
|
||||||
|
htmlFor="proxy-config-enabled"
|
||||||
|
className="text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
{t("providerAdvanced.useCustomProxy", {
|
||||||
|
defaultValue: "使用单独代理",
|
||||||
|
})}
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id="proxy-config-enabled"
|
||||||
|
checked={proxyConfig.enabled}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
onProxyConfigChange({ ...proxyConfig, enabled: checked });
|
||||||
|
if (checked) setIsProxyConfigOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{isProxyConfigOpen ? (
|
||||||
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden transition-all duration-200",
|
||||||
|
isProxyConfigOpen
|
||||||
|
? "max-h-[500px] opacity-100"
|
||||||
|
: "max-h-0 opacity-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="border-t border-border/50 p-4 space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("providerAdvanced.proxyConfigDesc", {
|
||||||
|
defaultValue:
|
||||||
|
"为此供应商配置单独的网络代理,不启用时使用系统代理或全局设置。",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 代理地址输入框(仿照全局代理样式) */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="http://127.0.0.1:7890 / socks5://127.0.0.1:1080"
|
||||||
|
value={proxyUrl}
|
||||||
|
onChange={(e) => handleProxyUrlChange(e.target.value)}
|
||||||
|
onBlur={handleProxyUrlBlur}
|
||||||
|
className="font-mono text-sm flex-1"
|
||||||
|
disabled={!proxyConfig.enabled}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
disabled={!proxyConfig.enabled || !proxyUrl}
|
||||||
|
onClick={handleClearProxy}
|
||||||
|
title={t("common.clear", { defaultValue: "清除" })}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 认证信息:用户名 + 密码(可选) */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder={t("providerAdvanced.proxyUsername", {
|
||||||
|
defaultValue: "用户名(可选)",
|
||||||
|
})}
|
||||||
|
value={proxyConfig.proxyUsername || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onProxyConfigChange({
|
||||||
|
...proxyConfig,
|
||||||
|
proxyUsername: e.target.value || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="font-mono text-sm flex-1"
|
||||||
|
disabled={!proxyConfig.enabled}
|
||||||
|
/>
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Input
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
placeholder={t("providerAdvanced.proxyPassword", {
|
||||||
|
defaultValue: "密码(可选)",
|
||||||
|
})}
|
||||||
|
value={proxyConfig.proxyPassword || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onProxyConfigChange({
|
||||||
|
...proxyConfig,
|
||||||
|
proxyPassword: e.target.value || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="font-mono text-sm pr-10"
|
||||||
|
disabled={!proxyConfig.enabled}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
tabIndex={-1}
|
||||||
|
disabled={!proxyConfig.enabled}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,9 +5,15 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form";
|
import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
|
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
|
||||||
import type { AppId } from "@/lib/api";
|
import type { AppId } from "@/lib/api";
|
||||||
import type { ProviderCategory, ProviderMeta } from "@/types";
|
import type {
|
||||||
|
ProviderCategory,
|
||||||
|
ProviderMeta,
|
||||||
|
ProviderTestConfig,
|
||||||
|
ProviderProxyConfig,
|
||||||
|
} from "@/types";
|
||||||
import {
|
import {
|
||||||
providerPresets,
|
providerPresets,
|
||||||
type ProviderPreset,
|
type ProviderPreset,
|
||||||
@@ -20,6 +26,12 @@ import {
|
|||||||
geminiProviderPresets,
|
geminiProviderPresets,
|
||||||
type GeminiProviderPreset,
|
type GeminiProviderPreset,
|
||||||
} from "@/config/geminiProviderPresets";
|
} from "@/config/geminiProviderPresets";
|
||||||
|
import {
|
||||||
|
opencodeProviderPresets,
|
||||||
|
type OpenCodeProviderPreset,
|
||||||
|
} from "@/config/opencodeProviderPresets";
|
||||||
|
import { OpenCodeFormFields } from "./OpenCodeFormFields";
|
||||||
|
import type { OpenCodeModel } from "@/types";
|
||||||
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
|
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
|
||||||
import { applyTemplateValues } from "@/utils/providerConfigUtils";
|
import { applyTemplateValues } from "@/utils/providerConfigUtils";
|
||||||
import { mergeProviderMeta } from "@/utils/providerMetaUtils";
|
import { mergeProviderMeta } from "@/utils/providerMetaUtils";
|
||||||
@@ -27,11 +39,14 @@ import { getCodexCustomTemplate } from "@/config/codexTemplates";
|
|||||||
import CodexConfigEditor from "./CodexConfigEditor";
|
import CodexConfigEditor from "./CodexConfigEditor";
|
||||||
import { CommonConfigEditor } from "./CommonConfigEditor";
|
import { CommonConfigEditor } from "./CommonConfigEditor";
|
||||||
import GeminiConfigEditor from "./GeminiConfigEditor";
|
import GeminiConfigEditor from "./GeminiConfigEditor";
|
||||||
|
import JsonEditor from "@/components/JsonEditor";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
import { ProviderPresetSelector } from "./ProviderPresetSelector";
|
import { ProviderPresetSelector } from "./ProviderPresetSelector";
|
||||||
import { BasicFormFields } from "./BasicFormFields";
|
import { BasicFormFields } from "./BasicFormFields";
|
||||||
import { ClaudeFormFields } from "./ClaudeFormFields";
|
import { ClaudeFormFields } from "./ClaudeFormFields";
|
||||||
import { CodexFormFields } from "./CodexFormFields";
|
import { CodexFormFields } from "./CodexFormFields";
|
||||||
import { GeminiFormFields } from "./GeminiFormFields";
|
import { GeminiFormFields } from "./GeminiFormFields";
|
||||||
|
import { ProviderAdvancedConfig } from "./ProviderAdvancedConfig";
|
||||||
import {
|
import {
|
||||||
useProviderCategory,
|
useProviderCategory,
|
||||||
useApiKeyState,
|
useApiKeyState,
|
||||||
@@ -47,6 +62,7 @@ import {
|
|||||||
useGeminiConfigState,
|
useGeminiConfigState,
|
||||||
useGeminiCommonConfig,
|
useGeminiCommonConfig,
|
||||||
} from "./hooks";
|
} from "./hooks";
|
||||||
|
import { useProvidersQuery } from "@/lib/query/queries";
|
||||||
|
|
||||||
const CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {} }, null, 2);
|
const CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {} }, null, 2);
|
||||||
const CODEX_DEFAULT_CONFIG = JSON.stringify({ auth: {}, config: "" }, null, 2);
|
const CODEX_DEFAULT_CONFIG = JSON.stringify({ auth: {}, config: "" }, null, 2);
|
||||||
@@ -62,9 +78,26 @@ const GEMINI_DEFAULT_CONFIG = JSON.stringify(
|
|||||||
2,
|
2,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const OPENCODE_DEFAULT_CONFIG = JSON.stringify(
|
||||||
|
{
|
||||||
|
npm: "@ai-sdk/openai-compatible",
|
||||||
|
options: {
|
||||||
|
baseURL: "",
|
||||||
|
apiKey: "",
|
||||||
|
},
|
||||||
|
models: {},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
|
||||||
type PresetEntry = {
|
type PresetEntry = {
|
||||||
id: string;
|
id: string;
|
||||||
preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset;
|
preset:
|
||||||
|
| ProviderPreset
|
||||||
|
| CodexProviderPreset
|
||||||
|
| GeminiProviderPreset
|
||||||
|
| OpenCodeProviderPreset;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ProviderFormProps {
|
interface ProviderFormProps {
|
||||||
@@ -128,6 +161,14 @@ export function ProviderForm({
|
|||||||
() => initialData?.meta?.endpointAutoSelect ?? true,
|
() => initialData?.meta?.endpointAutoSelect ?? true,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 高级配置:模型测试和代理配置
|
||||||
|
const [testConfig, setTestConfig] = useState<ProviderTestConfig>(
|
||||||
|
() => initialData?.meta?.testConfig ?? { enabled: false },
|
||||||
|
);
|
||||||
|
const [proxyConfig, setProxyConfig] = useState<ProviderProxyConfig>(
|
||||||
|
() => initialData?.meta?.proxyConfig ?? { enabled: false },
|
||||||
|
);
|
||||||
|
|
||||||
// 使用 category hook
|
// 使用 category hook
|
||||||
const { category } = useProviderCategory({
|
const { category } = useProviderCategory({
|
||||||
appId,
|
appId,
|
||||||
@@ -145,6 +186,8 @@ export function ProviderForm({
|
|||||||
setDraftCustomEndpoints([]);
|
setDraftCustomEndpoints([]);
|
||||||
}
|
}
|
||||||
setEndpointAutoSelect(initialData?.meta?.endpointAutoSelect ?? true);
|
setEndpointAutoSelect(initialData?.meta?.endpointAutoSelect ?? true);
|
||||||
|
setTestConfig(initialData?.meta?.testConfig ?? { enabled: false });
|
||||||
|
setProxyConfig(initialData?.meta?.proxyConfig ?? { enabled: false });
|
||||||
}, [appId, initialData]);
|
}, [appId, initialData]);
|
||||||
|
|
||||||
const defaultValues: ProviderFormData = useMemo(
|
const defaultValues: ProviderFormData = useMemo(
|
||||||
@@ -158,7 +201,9 @@ export function ProviderForm({
|
|||||||
? CODEX_DEFAULT_CONFIG
|
? CODEX_DEFAULT_CONFIG
|
||||||
: appId === "gemini"
|
: appId === "gemini"
|
||||||
? GEMINI_DEFAULT_CONFIG
|
? GEMINI_DEFAULT_CONFIG
|
||||||
: CLAUDE_DEFAULT_CONFIG,
|
: appId === "opencode"
|
||||||
|
? OPENCODE_DEFAULT_CONFIG
|
||||||
|
: CLAUDE_DEFAULT_CONFIG,
|
||||||
icon: initialData?.icon ?? "",
|
icon: initialData?.icon ?? "",
|
||||||
iconColor: initialData?.iconColor ?? "",
|
iconColor: initialData?.iconColor ?? "",
|
||||||
}),
|
}),
|
||||||
@@ -171,7 +216,7 @@ export function ProviderForm({
|
|||||||
mode: "onSubmit",
|
mode: "onSubmit",
|
||||||
});
|
});
|
||||||
|
|
||||||
const settingsConfigValue = form.watch("settingsConfig");
|
const settingsConfigValue = form.getValues("settingsConfig");
|
||||||
|
|
||||||
// 使用 API Key hook
|
// 使用 API Key hook
|
||||||
const {
|
const {
|
||||||
@@ -179,7 +224,7 @@ export function ProviderForm({
|
|||||||
handleApiKeyChange,
|
handleApiKeyChange,
|
||||||
showApiKey: shouldShowApiKey,
|
showApiKey: shouldShowApiKey,
|
||||||
} = useApiKeyState({
|
} = useApiKeyState({
|
||||||
initialConfig: form.watch("settingsConfig"),
|
initialConfig: form.getValues("settingsConfig"),
|
||||||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||||
selectedPresetId,
|
selectedPresetId,
|
||||||
category,
|
category,
|
||||||
@@ -190,7 +235,7 @@ export function ProviderForm({
|
|||||||
const { baseUrl, handleClaudeBaseUrlChange } = useBaseUrlState({
|
const { baseUrl, handleClaudeBaseUrlChange } = useBaseUrlState({
|
||||||
appType: appId,
|
appType: appId,
|
||||||
category,
|
category,
|
||||||
settingsConfig: form.watch("settingsConfig"),
|
settingsConfig: form.getValues("settingsConfig"),
|
||||||
codexConfig: "",
|
codexConfig: "",
|
||||||
onSettingsConfigChange: (config) => form.setValue("settingsConfig", config),
|
onSettingsConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||||
onCodexConfigChange: () => {
|
onCodexConfigChange: () => {
|
||||||
@@ -207,7 +252,7 @@ export function ProviderForm({
|
|||||||
defaultOpusModel,
|
defaultOpusModel,
|
||||||
handleModelChange,
|
handleModelChange,
|
||||||
} = useModelState({
|
} = useModelState({
|
||||||
settingsConfig: form.watch("settingsConfig"),
|
settingsConfig: form.getValues("settingsConfig"),
|
||||||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -328,6 +373,11 @@ export function ProviderForm({
|
|||||||
id: `gemini-${index}`,
|
id: `gemini-${index}`,
|
||||||
preset,
|
preset,
|
||||||
}));
|
}));
|
||||||
|
} else if (appId === "opencode") {
|
||||||
|
return opencodeProviderPresets.map<PresetEntry>((preset, index) => ({
|
||||||
|
id: `opencode-${index}`,
|
||||||
|
preset,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
return providerPresets.map<PresetEntry>((preset, index) => ({
|
return providerPresets.map<PresetEntry>((preset, index) => ({
|
||||||
id: `claude-${index}`,
|
id: `claude-${index}`,
|
||||||
@@ -345,7 +395,7 @@ export function ProviderForm({
|
|||||||
} = useTemplateValues({
|
} = useTemplateValues({
|
||||||
selectedPresetId: appId === "claude" ? selectedPresetId : null,
|
selectedPresetId: appId === "claude" ? selectedPresetId : null,
|
||||||
presetEntries: appId === "claude" ? presetEntries : [],
|
presetEntries: appId === "claude" ? presetEntries : [],
|
||||||
settingsConfig: form.watch("settingsConfig"),
|
settingsConfig: form.getValues("settingsConfig"),
|
||||||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -359,10 +409,11 @@ export function ProviderForm({
|
|||||||
isExtracting: isClaudeExtracting,
|
isExtracting: isClaudeExtracting,
|
||||||
handleExtract: handleClaudeExtract,
|
handleExtract: handleClaudeExtract,
|
||||||
} = useCommonConfigSnippet({
|
} = useCommonConfigSnippet({
|
||||||
settingsConfig: form.watch("settingsConfig"),
|
settingsConfig: form.getValues("settingsConfig"),
|
||||||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||||
initialData: appId === "claude" ? initialData : undefined,
|
initialData: appId === "claude" ? initialData : undefined,
|
||||||
selectedPresetId: selectedPresetId ?? undefined,
|
selectedPresetId: selectedPresetId ?? undefined,
|
||||||
|
enabled: appId === "claude",
|
||||||
});
|
});
|
||||||
|
|
||||||
// 使用 Codex 通用配置片段 hook (仅 Codex 模式)
|
// 使用 Codex 通用配置片段 hook (仅 Codex 模式)
|
||||||
@@ -408,7 +459,7 @@ export function ProviderForm({
|
|||||||
originalHandleGeminiApiKeyChange(key);
|
originalHandleGeminiApiKeyChange(key);
|
||||||
// 同步更新 settingsConfig
|
// 同步更新 settingsConfig
|
||||||
try {
|
try {
|
||||||
const config = JSON.parse(form.watch("settingsConfig") || "{}");
|
const config = JSON.parse(form.getValues("settingsConfig") || "{}");
|
||||||
if (!config.env) config.env = {};
|
if (!config.env) config.env = {};
|
||||||
config.env.GEMINI_API_KEY = key.trim();
|
config.env.GEMINI_API_KEY = key.trim();
|
||||||
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
|
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
|
||||||
@@ -424,7 +475,7 @@ export function ProviderForm({
|
|||||||
originalHandleGeminiBaseUrlChange(url);
|
originalHandleGeminiBaseUrlChange(url);
|
||||||
// 同步更新 settingsConfig
|
// 同步更新 settingsConfig
|
||||||
try {
|
try {
|
||||||
const config = JSON.parse(form.watch("settingsConfig") || "{}");
|
const config = JSON.parse(form.getValues("settingsConfig") || "{}");
|
||||||
if (!config.env) config.env = {};
|
if (!config.env) config.env = {};
|
||||||
config.env.GOOGLE_GEMINI_BASE_URL = url.trim().replace(/\/+$/, "");
|
config.env.GOOGLE_GEMINI_BASE_URL = url.trim().replace(/\/+$/, "");
|
||||||
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
|
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
|
||||||
@@ -440,7 +491,7 @@ export function ProviderForm({
|
|||||||
originalHandleGeminiModelChange(model);
|
originalHandleGeminiModelChange(model);
|
||||||
// 同步更新 settingsConfig
|
// 同步更新 settingsConfig
|
||||||
try {
|
try {
|
||||||
const config = JSON.parse(form.watch("settingsConfig") || "{}");
|
const config = JSON.parse(form.getValues("settingsConfig") || "{}");
|
||||||
if (!config.env) config.env = {};
|
if (!config.env) config.env = {};
|
||||||
config.env.GEMINI_MODEL = model.trim();
|
config.env.GEMINI_MODEL = model.trim();
|
||||||
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
|
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
|
||||||
@@ -469,6 +520,214 @@ export function ProviderForm({
|
|||||||
selectedPresetId: selectedPresetId ?? undefined,
|
selectedPresetId: selectedPresetId ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// OpenCode: query existing providers for duplicate key checking
|
||||||
|
const { data: opencodeProvidersData } = useProvidersQuery("opencode");
|
||||||
|
const existingOpencodeKeys = useMemo(() => {
|
||||||
|
if (!opencodeProvidersData?.providers) return [];
|
||||||
|
// Exclude current provider ID when in edit mode
|
||||||
|
return Object.keys(opencodeProvidersData.providers).filter(
|
||||||
|
(k) => k !== providerId,
|
||||||
|
);
|
||||||
|
}, [opencodeProvidersData?.providers, providerId]);
|
||||||
|
|
||||||
|
// OpenCode Provider Key state
|
||||||
|
const [opencodeProviderKey, setOpencodeProviderKey] = useState<string>(() => {
|
||||||
|
if (appId !== "opencode") return "";
|
||||||
|
// In edit mode, use the existing provider ID as the key
|
||||||
|
return providerId || "";
|
||||||
|
});
|
||||||
|
|
||||||
|
// OpenCode 配置状态
|
||||||
|
const [opencodeNpm, setOpencodeNpm] = useState<string>(() => {
|
||||||
|
if (appId !== "opencode") return "@ai-sdk/openai-compatible";
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(
|
||||||
|
initialData?.settingsConfig
|
||||||
|
? JSON.stringify(initialData.settingsConfig)
|
||||||
|
: OPENCODE_DEFAULT_CONFIG,
|
||||||
|
);
|
||||||
|
return config.npm || "@ai-sdk/openai-compatible";
|
||||||
|
} catch {
|
||||||
|
return "@ai-sdk/openai-compatible";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const [opencodeApiKey, setOpencodeApiKey] = useState<string>(() => {
|
||||||
|
if (appId !== "opencode") return "";
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(
|
||||||
|
initialData?.settingsConfig
|
||||||
|
? JSON.stringify(initialData.settingsConfig)
|
||||||
|
: OPENCODE_DEFAULT_CONFIG,
|
||||||
|
);
|
||||||
|
return config.options?.apiKey || "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const [opencodeBaseUrl, setOpencodeBaseUrl] = useState<string>(() => {
|
||||||
|
if (appId !== "opencode") return "";
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(
|
||||||
|
initialData?.settingsConfig
|
||||||
|
? JSON.stringify(initialData.settingsConfig)
|
||||||
|
: OPENCODE_DEFAULT_CONFIG,
|
||||||
|
);
|
||||||
|
return config.options?.baseURL || "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const [opencodeModels, setOpencodeModels] = useState<
|
||||||
|
Record<string, OpenCodeModel>
|
||||||
|
>(() => {
|
||||||
|
if (appId !== "opencode") return {};
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(
|
||||||
|
initialData?.settingsConfig
|
||||||
|
? JSON.stringify(initialData.settingsConfig)
|
||||||
|
: OPENCODE_DEFAULT_CONFIG,
|
||||||
|
);
|
||||||
|
return config.models || {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// OpenCode extra options state (e.g., timeout, setCacheKey)
|
||||||
|
const [opencodeExtraOptions, setOpencodeExtraOptions] = useState<
|
||||||
|
Record<string, string>
|
||||||
|
>(() => {
|
||||||
|
if (appId !== "opencode") return {};
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(
|
||||||
|
initialData?.settingsConfig
|
||||||
|
? JSON.stringify(initialData.settingsConfig)
|
||||||
|
: OPENCODE_DEFAULT_CONFIG,
|
||||||
|
);
|
||||||
|
const options = config.options || {};
|
||||||
|
const extra: Record<string, string> = {};
|
||||||
|
const knownKeys = ["baseURL", "apiKey", "headers"];
|
||||||
|
for (const [k, v] of Object.entries(options)) {
|
||||||
|
if (!knownKeys.includes(k)) {
|
||||||
|
// Convert value to string for display
|
||||||
|
extra[k] = typeof v === "string" ? v : JSON.stringify(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return extra;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// OpenCode handlers - sync state to form
|
||||||
|
const handleOpencodeNpmChange = useCallback(
|
||||||
|
(npm: string) => {
|
||||||
|
setOpencodeNpm(npm);
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(
|
||||||
|
form.getValues("settingsConfig") || OPENCODE_DEFAULT_CONFIG,
|
||||||
|
);
|
||||||
|
config.npm = npm;
|
||||||
|
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[form],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpencodeApiKeyChange = useCallback(
|
||||||
|
(apiKey: string) => {
|
||||||
|
setOpencodeApiKey(apiKey);
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(
|
||||||
|
form.getValues("settingsConfig") || OPENCODE_DEFAULT_CONFIG,
|
||||||
|
);
|
||||||
|
if (!config.options) config.options = {};
|
||||||
|
config.options.apiKey = apiKey;
|
||||||
|
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[form],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpencodeBaseUrlChange = useCallback(
|
||||||
|
(baseUrl: string) => {
|
||||||
|
setOpencodeBaseUrl(baseUrl);
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(
|
||||||
|
form.getValues("settingsConfig") || OPENCODE_DEFAULT_CONFIG,
|
||||||
|
);
|
||||||
|
if (!config.options) config.options = {};
|
||||||
|
config.options.baseURL = baseUrl.trim().replace(/\/+$/, "");
|
||||||
|
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[form],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpencodeModelsChange = useCallback(
|
||||||
|
(models: Record<string, OpenCodeModel>) => {
|
||||||
|
setOpencodeModels(models);
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(
|
||||||
|
form.getValues("settingsConfig") || OPENCODE_DEFAULT_CONFIG,
|
||||||
|
);
|
||||||
|
config.models = models;
|
||||||
|
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[form],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpencodeExtraOptionsChange = useCallback(
|
||||||
|
(options: Record<string, string>) => {
|
||||||
|
setOpencodeExtraOptions(options);
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(
|
||||||
|
form.getValues("settingsConfig") || OPENCODE_DEFAULT_CONFIG,
|
||||||
|
);
|
||||||
|
if (!config.options) config.options = {};
|
||||||
|
|
||||||
|
// Remove old extra options (keep only known keys)
|
||||||
|
const knownKeys = ["baseURL", "apiKey", "headers"];
|
||||||
|
for (const k of Object.keys(config.options)) {
|
||||||
|
if (!knownKeys.includes(k)) {
|
||||||
|
delete config.options[k];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new extra options (auto-parse value types)
|
||||||
|
for (const [k, v] of Object.entries(options)) {
|
||||||
|
const trimmedKey = k.trim();
|
||||||
|
if (trimmedKey && !trimmedKey.startsWith("option-")) {
|
||||||
|
try {
|
||||||
|
// Try to parse as JSON (number, boolean, object, array)
|
||||||
|
config.options[trimmedKey] = JSON.parse(v);
|
||||||
|
} catch {
|
||||||
|
// If parsing fails, keep as string
|
||||||
|
config.options[trimmedKey] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[form],
|
||||||
|
);
|
||||||
|
|
||||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
||||||
|
|
||||||
const handleSubmit = (values: ProviderFormData) => {
|
const handleSubmit = (values: ProviderFormData) => {
|
||||||
@@ -496,6 +755,23 @@ export function ProviderForm({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OpenCode: validate provider key
|
||||||
|
if (appId === "opencode") {
|
||||||
|
const keyPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
||||||
|
if (!opencodeProviderKey.trim()) {
|
||||||
|
toast.error(t("opencode.providerKeyRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!keyPattern.test(opencodeProviderKey)) {
|
||||||
|
toast.error(t("opencode.providerKeyInvalid"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isEditMode && existingOpencodeKeys.includes(opencodeProviderKey)) {
|
||||||
|
toast.error(t("opencode.providerKeyDuplicate"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 非官方供应商必填校验:端点和 API Key
|
// 非官方供应商必填校验:端点和 API Key
|
||||||
if (category !== "official") {
|
if (category !== "official") {
|
||||||
if (appId === "claude") {
|
if (appId === "claude") {
|
||||||
@@ -593,6 +869,11 @@ export function ProviderForm({
|
|||||||
settingsConfig,
|
settingsConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// OpenCode: pass provider key for ID generation
|
||||||
|
if (appId === "opencode") {
|
||||||
|
payload.providerKey = opencodeProviderKey;
|
||||||
|
}
|
||||||
|
|
||||||
if (activePreset) {
|
if (activePreset) {
|
||||||
payload.presetId = activePreset.id;
|
payload.presetId = activePreset.id;
|
||||||
if (activePreset.category) {
|
if (activePreset.category) {
|
||||||
@@ -656,6 +937,9 @@ export function ProviderForm({
|
|||||||
payload.meta = {
|
payload.meta = {
|
||||||
...(baseMeta ?? {}),
|
...(baseMeta ?? {}),
|
||||||
endpointAutoSelect,
|
endpointAutoSelect,
|
||||||
|
// 添加高级配置
|
||||||
|
testConfig: testConfig.enabled ? testConfig : undefined,
|
||||||
|
proxyConfig: proxyConfig.enabled ? proxyConfig : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
onSubmit(payload);
|
onSubmit(payload);
|
||||||
@@ -748,6 +1032,15 @@ export function ProviderForm({
|
|||||||
if (appId === "gemini") {
|
if (appId === "gemini") {
|
||||||
resetGeminiConfig({}, {});
|
resetGeminiConfig({}, {});
|
||||||
}
|
}
|
||||||
|
// OpenCode 自定义模式:重置为空配置
|
||||||
|
if (appId === "opencode") {
|
||||||
|
setOpencodeProviderKey("");
|
||||||
|
setOpencodeNpm("@ai-sdk/openai-compatible");
|
||||||
|
setOpencodeBaseUrl("");
|
||||||
|
setOpencodeApiKey("");
|
||||||
|
setOpencodeModels({});
|
||||||
|
setOpencodeExtraOptions({});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -801,6 +1094,42 @@ export function ProviderForm({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OpenCode preset handling
|
||||||
|
if (appId === "opencode") {
|
||||||
|
const preset = entry.preset as OpenCodeProviderPreset;
|
||||||
|
const config = preset.settingsConfig;
|
||||||
|
|
||||||
|
// Clear provider key (user must enter their own unique key)
|
||||||
|
setOpencodeProviderKey("");
|
||||||
|
|
||||||
|
// Update OpenCode-specific states
|
||||||
|
setOpencodeNpm(config.npm || "@ai-sdk/openai-compatible");
|
||||||
|
setOpencodeBaseUrl(config.options?.baseURL || "");
|
||||||
|
setOpencodeApiKey(config.options?.apiKey || "");
|
||||||
|
setOpencodeModels(config.models || {});
|
||||||
|
|
||||||
|
// Extract extra options from preset
|
||||||
|
const options = config.options || {};
|
||||||
|
const extra: Record<string, string> = {};
|
||||||
|
const knownKeys = ["baseURL", "apiKey", "headers"];
|
||||||
|
for (const [k, v] of Object.entries(options)) {
|
||||||
|
if (!knownKeys.includes(k)) {
|
||||||
|
extra[k] = typeof v === "string" ? v : JSON.stringify(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setOpencodeExtraOptions(extra);
|
||||||
|
|
||||||
|
// Update form fields
|
||||||
|
form.reset({
|
||||||
|
name: preset.name,
|
||||||
|
websiteUrl: preset.websiteUrl ?? "",
|
||||||
|
settingsConfig: JSON.stringify(config, null, 2),
|
||||||
|
icon: preset.icon ?? "",
|
||||||
|
iconColor: preset.iconColor ?? "",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const preset = entry.preset as ProviderPreset;
|
const preset = entry.preset as ProviderPreset;
|
||||||
const config = applyTemplateValues(
|
const config = applyTemplateValues(
|
||||||
preset.settingsConfig,
|
preset.settingsConfig,
|
||||||
@@ -838,14 +1167,67 @@ export function ProviderForm({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 基础字段 */}
|
{/* 基础字段 */}
|
||||||
<BasicFormFields form={form} />
|
<BasicFormFields
|
||||||
|
form={form}
|
||||||
|
beforeNameSlot={
|
||||||
|
appId === "opencode" ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="opencode-key">
|
||||||
|
{t("opencode.providerKey")}
|
||||||
|
<span className="text-destructive ml-1">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="opencode-key"
|
||||||
|
value={opencodeProviderKey}
|
||||||
|
onChange={(e) =>
|
||||||
|
setOpencodeProviderKey(
|
||||||
|
e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder={t("opencode.providerKeyPlaceholder")}
|
||||||
|
disabled={isEditMode}
|
||||||
|
className={
|
||||||
|
(existingOpencodeKeys.includes(opencodeProviderKey) &&
|
||||||
|
!isEditMode) ||
|
||||||
|
(opencodeProviderKey.trim() !== "" &&
|
||||||
|
!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(opencodeProviderKey))
|
||||||
|
? "border-destructive"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{existingOpencodeKeys.includes(opencodeProviderKey) &&
|
||||||
|
!isEditMode && (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{t("opencode.providerKeyDuplicate")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{opencodeProviderKey.trim() !== "" &&
|
||||||
|
!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(opencodeProviderKey) && (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{t("opencode.providerKeyInvalid")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!(
|
||||||
|
existingOpencodeKeys.includes(opencodeProviderKey) &&
|
||||||
|
!isEditMode
|
||||||
|
) &&
|
||||||
|
(opencodeProviderKey.trim() === "" ||
|
||||||
|
/^[a-z0-9]+(-[a-z0-9]+)*$/.test(opencodeProviderKey)) && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("opencode.providerKeyHint")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Claude 专属字段 */}
|
{/* Claude 专属字段 */}
|
||||||
{appId === "claude" && (
|
{appId === "claude" && (
|
||||||
<ClaudeFormFields
|
<ClaudeFormFields
|
||||||
providerId={providerId}
|
providerId={providerId}
|
||||||
shouldShowApiKey={shouldShowApiKey(
|
shouldShowApiKey={shouldShowApiKey(
|
||||||
form.watch("settingsConfig"),
|
form.getValues("settingsConfig"),
|
||||||
isEditMode,
|
isEditMode,
|
||||||
)}
|
)}
|
||||||
apiKey={apiKey}
|
apiKey={apiKey}
|
||||||
@@ -916,7 +1298,7 @@ export function ProviderForm({
|
|||||||
<GeminiFormFields
|
<GeminiFormFields
|
||||||
providerId={providerId}
|
providerId={providerId}
|
||||||
shouldShowApiKey={shouldShowApiKey(
|
shouldShowApiKey={shouldShowApiKey(
|
||||||
form.watch("settingsConfig"),
|
form.getValues("settingsConfig"),
|
||||||
isEditMode,
|
isEditMode,
|
||||||
)}
|
)}
|
||||||
apiKey={geminiApiKey}
|
apiKey={geminiApiKey}
|
||||||
@@ -941,6 +1323,25 @@ export function ProviderForm({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* OpenCode 专属字段 */}
|
||||||
|
{appId === "opencode" && (
|
||||||
|
<OpenCodeFormFields
|
||||||
|
npm={opencodeNpm}
|
||||||
|
onNpmChange={handleOpencodeNpmChange}
|
||||||
|
apiKey={opencodeApiKey}
|
||||||
|
onApiKeyChange={handleOpencodeApiKeyChange}
|
||||||
|
category={category}
|
||||||
|
shouldShowApiKeyLink={false}
|
||||||
|
websiteUrl=""
|
||||||
|
baseUrl={opencodeBaseUrl}
|
||||||
|
onBaseUrlChange={handleOpencodeBaseUrlChange}
|
||||||
|
models={opencodeModels}
|
||||||
|
onModelsChange={handleOpencodeModelsChange}
|
||||||
|
extraOptions={opencodeExtraOptions}
|
||||||
|
onExtraOptionsChange={handleOpencodeExtraOptionsChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 配置编辑器:Codex、Claude、Gemini 分别使用不同的编辑器 */}
|
{/* 配置编辑器:Codex、Claude、Gemini 分别使用不同的编辑器 */}
|
||||||
{appId === "codex" ? (
|
{appId === "codex" ? (
|
||||||
<>
|
<>
|
||||||
@@ -1000,10 +1401,40 @@ export function ProviderForm({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
) : appId === "opencode" ? (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="settingsConfig">{t("provider.configJson")}</Label>
|
||||||
|
<JsonEditor
|
||||||
|
value={form.getValues("settingsConfig")}
|
||||||
|
onChange={(config) => form.setValue("settingsConfig", config)}
|
||||||
|
placeholder={`{
|
||||||
|
"npm": "@ai-sdk/openai-compatible",
|
||||||
|
"options": {
|
||||||
|
"baseURL": "https://your-api-endpoint.com",
|
||||||
|
"apiKey": "your-api-key-here"
|
||||||
|
},
|
||||||
|
"models": {}
|
||||||
|
}`}
|
||||||
|
rows={14}
|
||||||
|
showValidation={true}
|
||||||
|
language="json"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="settingsConfig"
|
||||||
|
render={() => (
|
||||||
|
<FormItem className="space-y-0">
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<CommonConfigEditor
|
<CommonConfigEditor
|
||||||
value={form.watch("settingsConfig")}
|
value={form.getValues("settingsConfig")}
|
||||||
onChange={(value) => form.setValue("settingsConfig", value)}
|
onChange={(value) => form.setValue("settingsConfig", value)}
|
||||||
useCommonConfig={useCommonConfig}
|
useCommonConfig={useCommonConfig}
|
||||||
onCommonConfigToggle={handleCommonConfigToggle}
|
onCommonConfigToggle={handleCommonConfigToggle}
|
||||||
@@ -1029,6 +1460,14 @@ export function ProviderForm({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 高级配置:模型测试和代理配置 */}
|
||||||
|
<ProviderAdvancedConfig
|
||||||
|
testConfig={testConfig}
|
||||||
|
proxyConfig={proxyConfig}
|
||||||
|
onTestConfigChange={setTestConfig}
|
||||||
|
onProxyConfigChange={setProxyConfig}
|
||||||
|
/>
|
||||||
|
|
||||||
{showButtons && (
|
{showButtons && (
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button variant="outline" type="button" onClick={onCancel}>
|
<Button variant="outline" type="button" onClick={onCancel}>
|
||||||
@@ -1047,4 +1486,5 @@ export type ProviderFormValues = ProviderFormData & {
|
|||||||
presetCategory?: ProviderCategory;
|
presetCategory?: ProviderCategory;
|
||||||
isPartner?: boolean;
|
isPartner?: boolean;
|
||||||
meta?: ProviderMeta;
|
meta?: ProviderMeta;
|
||||||
|
providerKey?: string; // OpenCode: user-defined provider key
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
import type { ProviderCategory } from "@/types";
|
import type { ProviderCategory } from "@/types";
|
||||||
|
|
||||||
interface UseBaseUrlStateProps {
|
interface UseBaseUrlStateProps {
|
||||||
appType: "claude" | "codex" | "gemini";
|
appType: "claude" | "codex" | "gemini" | "opencode";
|
||||||
category: ProviderCategory | undefined;
|
category: ProviderCategory | undefined;
|
||||||
settingsConfig: string;
|
settingsConfig: string;
|
||||||
codexConfig?: string;
|
codexConfig?: string;
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ interface UseCommonConfigSnippetProps {
|
|||||||
settingsConfig?: Record<string, unknown>;
|
settingsConfig?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
selectedPresetId?: string;
|
selectedPresetId?: string;
|
||||||
|
/** When false, the hook skips all logic and returns disabled state. Default: true */
|
||||||
|
enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,6 +32,7 @@ export function useCommonConfigSnippet({
|
|||||||
onConfigChange,
|
onConfigChange,
|
||||||
initialData,
|
initialData,
|
||||||
selectedPresetId,
|
selectedPresetId,
|
||||||
|
enabled = true,
|
||||||
}: UseCommonConfigSnippetProps) {
|
}: UseCommonConfigSnippetProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [useCommonConfig, setUseCommonConfig] = useState(false);
|
const [useCommonConfig, setUseCommonConfig] = useState(false);
|
||||||
@@ -47,11 +50,16 @@ export function useCommonConfigSnippet({
|
|||||||
|
|
||||||
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
|
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
hasInitializedNewMode.current = false;
|
hasInitializedNewMode.current = false;
|
||||||
}, [selectedPresetId]);
|
}, [selectedPresetId, enabled]);
|
||||||
|
|
||||||
// 初始化:从 config.json 加载,支持从 localStorage 迁移
|
// 初始化:从 config.json 加载,支持从 localStorage 迁移
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
|
|
||||||
const loadSnippet = async () => {
|
const loadSnippet = async () => {
|
||||||
@@ -100,10 +108,11 @@ export function useCommonConfigSnippet({
|
|||||||
return () => {
|
return () => {
|
||||||
mounted = false;
|
mounted = false;
|
||||||
};
|
};
|
||||||
}, []);
|
}, [enabled]);
|
||||||
|
|
||||||
// 初始化时检查通用配置片段(编辑模式)
|
// 初始化时检查通用配置片段(编辑模式)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
if (initialData && !isLoading) {
|
if (initialData && !isLoading) {
|
||||||
const configString = JSON.stringify(initialData.settingsConfig, null, 2);
|
const configString = JSON.stringify(initialData.settingsConfig, null, 2);
|
||||||
const hasCommon = hasCommonConfigSnippet(
|
const hasCommon = hasCommonConfigSnippet(
|
||||||
@@ -112,10 +121,11 @@ export function useCommonConfigSnippet({
|
|||||||
);
|
);
|
||||||
setUseCommonConfig(hasCommon);
|
setUseCommonConfig(hasCommon);
|
||||||
}
|
}
|
||||||
}, [initialData, commonConfigSnippet, isLoading]);
|
}, [enabled, initialData, commonConfigSnippet, isLoading]);
|
||||||
|
|
||||||
// 新建模式:如果通用配置片段存在且有效,默认启用
|
// 新建模式:如果通用配置片段存在且有效,默认启用
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
// 仅新建模式、加载完成、尚未初始化过
|
// 仅新建模式、加载完成、尚未初始化过
|
||||||
if (!initialData && !isLoading && !hasInitializedNewMode.current) {
|
if (!initialData && !isLoading && !hasInitializedNewMode.current) {
|
||||||
hasInitializedNewMode.current = true;
|
hasInitializedNewMode.current = true;
|
||||||
@@ -145,6 +155,7 @@ export function useCommonConfigSnippet({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
enabled,
|
||||||
initialData,
|
initialData,
|
||||||
commonConfigSnippet,
|
commonConfigSnippet,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -259,6 +270,7 @@ export function useCommonConfigSnippet({
|
|||||||
|
|
||||||
// 当配置变化时检查是否包含通用配置(但避免在通过通用配置更新时检查)
|
// 当配置变化时检查是否包含通用配置(但避免在通过通用配置更新时检查)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
if (isUpdatingFromCommonConfig.current || isLoading) {
|
if (isUpdatingFromCommonConfig.current || isLoading) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -267,7 +279,7 @@ export function useCommonConfigSnippet({
|
|||||||
commonConfigSnippet,
|
commonConfigSnippet,
|
||||||
);
|
);
|
||||||
setUseCommonConfig(hasCommon);
|
setUseCommonConfig(hasCommon);
|
||||||
}, [settingsConfig, commonConfigSnippet, isLoading]);
|
}, [enabled, settingsConfig, commonConfigSnippet, isLoading]);
|
||||||
|
|
||||||
// 从编辑器当前内容提取通用配置片段
|
// 从编辑器当前内容提取通用配置片段
|
||||||
const handleExtract = useCallback(async () => {
|
const handleExtract = useCallback(async () => {
|
||||||
|
|||||||
@@ -1,10 +1,46 @@
|
|||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
|
|
||||||
interface UseModelStateProps {
|
interface UseModelStateProps {
|
||||||
settingsConfig: string;
|
settingsConfig: string;
|
||||||
onConfigChange: (config: string) => void;
|
onConfigChange: (config: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse model values from settings config JSON
|
||||||
|
*/
|
||||||
|
function parseModelsFromConfig(settingsConfig: string) {
|
||||||
|
try {
|
||||||
|
const cfg = settingsConfig ? JSON.parse(settingsConfig) : {};
|
||||||
|
const env = cfg?.env || {};
|
||||||
|
const model =
|
||||||
|
typeof env.ANTHROPIC_MODEL === "string" ? env.ANTHROPIC_MODEL : "";
|
||||||
|
const reasoning =
|
||||||
|
typeof env.ANTHROPIC_REASONING_MODEL === "string"
|
||||||
|
? env.ANTHROPIC_REASONING_MODEL
|
||||||
|
: "";
|
||||||
|
const small =
|
||||||
|
typeof env.ANTHROPIC_SMALL_FAST_MODEL === "string"
|
||||||
|
? env.ANTHROPIC_SMALL_FAST_MODEL
|
||||||
|
: "";
|
||||||
|
const haiku =
|
||||||
|
typeof env.ANTHROPIC_DEFAULT_HAIKU_MODEL === "string"
|
||||||
|
? env.ANTHROPIC_DEFAULT_HAIKU_MODEL
|
||||||
|
: small || model;
|
||||||
|
const sonnet =
|
||||||
|
typeof env.ANTHROPIC_DEFAULT_SONNET_MODEL === "string"
|
||||||
|
? env.ANTHROPIC_DEFAULT_SONNET_MODEL
|
||||||
|
: model || small;
|
||||||
|
const opus =
|
||||||
|
typeof env.ANTHROPIC_DEFAULT_OPUS_MODEL === "string"
|
||||||
|
? env.ANTHROPIC_DEFAULT_OPUS_MODEL
|
||||||
|
: model || small;
|
||||||
|
|
||||||
|
return { model, reasoning, haiku, sonnet, opus };
|
||||||
|
} catch {
|
||||||
|
return { model: "", reasoning: "", haiku: "", sonnet: "", opus: "" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 管理模型选择状态
|
* 管理模型选择状态
|
||||||
* 支持 ANTHROPIC_MODEL, ANTHROPIC_REASONING_MODEL 和各类型默认模型
|
* 支持 ANTHROPIC_MODEL, ANTHROPIC_REASONING_MODEL 和各类型默认模型
|
||||||
@@ -13,11 +49,25 @@ export function useModelState({
|
|||||||
settingsConfig,
|
settingsConfig,
|
||||||
onConfigChange,
|
onConfigChange,
|
||||||
}: UseModelStateProps) {
|
}: UseModelStateProps) {
|
||||||
const [claudeModel, setClaudeModel] = useState("");
|
// Initialize state by parsing config directly (fixes edit mode backfill)
|
||||||
const [reasoningModel, setReasoningModel] = useState("");
|
const [claudeModel, setClaudeModel] = useState(
|
||||||
const [defaultHaikuModel, setDefaultHaikuModel] = useState("");
|
() => parseModelsFromConfig(settingsConfig).model,
|
||||||
const [defaultSonnetModel, setDefaultSonnetModel] = useState("");
|
);
|
||||||
const [defaultOpusModel, setDefaultOpusModel] = useState("");
|
const [reasoningModel, setReasoningModel] = useState(
|
||||||
|
() => parseModelsFromConfig(settingsConfig).reasoning,
|
||||||
|
);
|
||||||
|
const [defaultHaikuModel, setDefaultHaikuModel] = useState(
|
||||||
|
() => parseModelsFromConfig(settingsConfig).haiku,
|
||||||
|
);
|
||||||
|
const [defaultSonnetModel, setDefaultSonnetModel] = useState(
|
||||||
|
() => parseModelsFromConfig(settingsConfig).sonnet,
|
||||||
|
);
|
||||||
|
const [defaultOpusModel, setDefaultOpusModel] = useState(
|
||||||
|
() => parseModelsFromConfig(settingsConfig).opus,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isUserEditingRef = useRef(false);
|
||||||
|
const lastConfigRef = useRef(settingsConfig);
|
||||||
|
|
||||||
// 初始化读取:读新键;若缺失,按兼容优先级回退
|
// 初始化读取:读新键;若缺失,按兼容优先级回退
|
||||||
// Haiku: DEFAULT_HAIKU || SMALL_FAST || MODEL
|
// Haiku: DEFAULT_HAIKU || SMALL_FAST || MODEL
|
||||||
@@ -25,6 +75,18 @@ export function useModelState({
|
|||||||
// Opus: DEFAULT_OPUS || MODEL || SMALL_FAST
|
// Opus: DEFAULT_OPUS || MODEL || SMALL_FAST
|
||||||
// 仅在 settingsConfig 变化时同步一次(表单加载/切换预设时)
|
// 仅在 settingsConfig 变化时同步一次(表单加载/切换预设时)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (lastConfigRef.current === settingsConfig) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUserEditingRef.current) {
|
||||||
|
isUserEditingRef.current = false;
|
||||||
|
lastConfigRef.current = settingsConfig;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastConfigRef.current = settingsConfig;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cfg = settingsConfig ? JSON.parse(settingsConfig) : {};
|
const cfg = settingsConfig ? JSON.parse(settingsConfig) : {};
|
||||||
const env = cfg?.env || {};
|
const env = cfg?.env || {};
|
||||||
@@ -71,6 +133,8 @@ export function useModelState({
|
|||||||
| "ANTHROPIC_DEFAULT_OPUS_MODEL",
|
| "ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||||
value: string,
|
value: string,
|
||||||
) => {
|
) => {
|
||||||
|
isUserEditingRef.current = true;
|
||||||
|
|
||||||
if (field === "ANTHROPIC_MODEL") setClaudeModel(value);
|
if (field === "ANTHROPIC_MODEL") setClaudeModel(value);
|
||||||
if (field === "ANTHROPIC_REASONING_MODEL") setReasoningModel(value);
|
if (field === "ANTHROPIC_REASONING_MODEL") setReasoningModel(value);
|
||||||
if (field === "ANTHROPIC_DEFAULT_HAIKU_MODEL")
|
if (field === "ANTHROPIC_DEFAULT_HAIKU_MODEL")
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function EndpointField({
|
|||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
id={id}
|
id={id}
|
||||||
type="url"
|
type="text"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ export function AutoFailoverConfigPanel({
|
|||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
autoFailoverEnabled: false,
|
autoFailoverEnabled: false,
|
||||||
maxRetries: "3",
|
maxRetries: "3",
|
||||||
streamingFirstByteTimeout: "30",
|
streamingFirstByteTimeout: "60",
|
||||||
streamingIdleTimeout: "60",
|
streamingIdleTimeout: "120",
|
||||||
nonStreamingTimeout: "300",
|
nonStreamingTimeout: "600",
|
||||||
circuitFailureThreshold: "5",
|
circuitFailureThreshold: "5",
|
||||||
circuitSuccessThreshold: "2",
|
circuitSuccessThreshold: "2",
|
||||||
circuitTimeoutSeconds: "60",
|
circuitTimeoutSeconds: "60",
|
||||||
@@ -67,9 +67,9 @@ export function AutoFailoverConfigPanel({
|
|||||||
// 定义各字段的有效范围
|
// 定义各字段的有效范围
|
||||||
const ranges = {
|
const ranges = {
|
||||||
maxRetries: { min: 0, max: 10 },
|
maxRetries: { min: 0, max: 10 },
|
||||||
streamingFirstByteTimeout: { min: 0, max: 180 },
|
streamingFirstByteTimeout: { min: 1, max: 120 },
|
||||||
streamingIdleTimeout: { min: 0, max: 600 },
|
streamingIdleTimeout: { min: 0, max: 600 },
|
||||||
nonStreamingTimeout: { min: 0, max: 1800 },
|
nonStreamingTimeout: { min: 60, max: 1200 },
|
||||||
circuitFailureThreshold: { min: 1, max: 20 },
|
circuitFailureThreshold: { min: 1, max: 20 },
|
||||||
circuitSuccessThreshold: { min: 1, max: 10 },
|
circuitSuccessThreshold: { min: 1, max: 10 },
|
||||||
circuitTimeoutSeconds: { min: 0, max: 300 },
|
circuitTimeoutSeconds: { min: 0, max: 300 },
|
||||||
@@ -307,8 +307,8 @@ export function AutoFailoverConfigPanel({
|
|||||||
<Input
|
<Input
|
||||||
id={`streamingFirstByte-${appType}`}
|
id={`streamingFirstByte-${appType}`}
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="1"
|
||||||
max="180"
|
max="120"
|
||||||
value={formData.streamingFirstByteTimeout}
|
value={formData.streamingFirstByteTimeout}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({
|
setFormData({
|
||||||
@@ -321,7 +321,7 @@ export function AutoFailoverConfigPanel({
|
|||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
"proxy.autoFailover.streamingFirstByteHint",
|
"proxy.autoFailover.streamingFirstByteHint",
|
||||||
"等待首个数据块的最大时间",
|
"等待首个数据块的最大时间,范围 1-120 秒,默认 60 秒",
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -347,7 +347,7 @@ export function AutoFailoverConfigPanel({
|
|||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
"proxy.autoFailover.streamingIdleHint",
|
"proxy.autoFailover.streamingIdleHint",
|
||||||
"数据块之间的最大间隔",
|
"数据块之间的最大间隔,范围 60-600 秒,填 0 禁用(防止中途卡住)",
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -359,8 +359,8 @@ export function AutoFailoverConfigPanel({
|
|||||||
<Input
|
<Input
|
||||||
id={`nonStreaming-${appType}`}
|
id={`nonStreaming-${appType}`}
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="60"
|
||||||
max="1800"
|
max="1200"
|
||||||
value={formData.nonStreamingTimeout}
|
value={formData.nonStreamingTimeout}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({
|
setFormData({
|
||||||
@@ -373,7 +373,7 @@ export function AutoFailoverConfigPanel({
|
|||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
"proxy.autoFailover.nonStreamingHint",
|
"proxy.autoFailover.nonStreamingHint",
|
||||||
"非流式请求的总超时时间",
|
"非流式请求的总超时时间,范围 60-1200 秒,默认 600 秒(10 分钟)",
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -140,7 +140,8 @@ export function FailoverQueueManager({
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t("proxy.failover.autoSwitchDescription", {
|
{t("proxy.failover.autoSwitchDescription", {
|
||||||
defaultValue: "开启后,请求失败时自动切换到队列中的下一个供应商",
|
defaultValue:
|
||||||
|
"开启后将立即切换到队列 P1,并在请求失败时自动切换到队列中的下一个供应商",
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user