Compare commits

..

1 Commits

Author SHA1 Message Date
YoVinchen 5b5b3efad4 refactor(ui): simplify UpdateBadge to minimal dot indicator 2026-01-15 01:08:42 +08:00
123 changed files with 621 additions and 7225 deletions
-42
View File
@@ -9,48 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
## [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
### Bug Fix Release
+3 -3
View File
@@ -2,7 +2,7 @@
# All-in-One Assistant for Claude Code, Codex & Gemini CLI
[![Version](https://img.shields.io/badge/version-3.10.0-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.9.1-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
@@ -52,7 +52,7 @@ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM
## Features
### Current Version: v3.10.0 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.9.0-en.md)
### Current Version: v3.9.1 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.9.0-en.md)
**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.
### Arch Linux Users
### ArchLinux 用户
**Install via paru (Recommended)**
+2 -2
View File
@@ -2,7 +2,7 @@
# Claude Code / Codex / Gemini CLI オールインワン・アシスタント
[![Version](https://img.shields.io/badge/version-3.10.0-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.9.1-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
@@ -52,7 +52,7 @@
## 特長
### 現在のバージョン:v3.10.0 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.9.0-ja.md)
### 現在のバージョン:v3.9.1 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.9.0-ja.md)
**v3.8.0 メジャーアップデート (2025-11-28)**
+2 -2
View File
@@ -2,7 +2,7 @@
# Claude Code / Codex / Gemini CLI 全方位辅助工具
[![Version](https://img.shields.io/badge/version-3.10.0-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.9.1-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
@@ -52,7 +52,7 @@
## 功能特性
### 当前版本:v3.10.0 | [完整更新日志](CHANGELOG.md) | [发布说明](docs/release-note-v3.9.0-zh.md)
### 当前版本:v3.9.1 | [完整更新日志](CHANGELOG.md) | [发布说明](docs/release-note-v3.9.0-zh.md)
**v3.8.0 重大更新(2025-11-28**
-485
View File
@@ -1,485 +0,0 @@
# 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>` - 自定义配置目录
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cc-switch",
"version": "3.10.0",
"version": "3.9.1",
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
"type": "module",
"scripts": {
+1 -1
View File
@@ -701,7 +701,7 @@ dependencies = [
[[package]]
name = "cc-switch"
version = "3.10.0"
version = "3.9.1"
dependencies = [
"anyhow",
"async-stream",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cc-switch"
version = "3.10.0"
version = "3.9.1"
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
authors = ["Jason Young"]
license = "MIT"
+9 -49
View File
@@ -13,8 +13,6 @@ pub struct McpApps {
pub codex: bool,
#[serde(default)]
pub gemini: bool,
#[serde(default)]
pub opencode: bool,
}
impl McpApps {
@@ -24,7 +22,6 @@ impl McpApps {
AppType::Claude => self.claude,
AppType::Codex => self.codex,
AppType::Gemini => self.gemini,
AppType::OpenCode => self.opencode,
}
}
@@ -34,7 +31,6 @@ impl McpApps {
AppType::Claude => self.claude = enabled,
AppType::Codex => self.codex = enabled,
AppType::Gemini => self.gemini = enabled,
AppType::OpenCode => self.opencode = enabled,
}
}
@@ -50,15 +46,12 @@ impl McpApps {
if self.gemini {
apps.push(AppType::Gemini);
}
if self.opencode {
apps.push(AppType::OpenCode);
}
apps
}
/// 检查是否所有应用都未启用
pub fn is_empty(&self) -> bool {
!self.claude && !self.codex && !self.gemini && !self.opencode
!self.claude && !self.codex && !self.gemini
}
}
@@ -71,8 +64,6 @@ pub struct SkillApps {
pub codex: bool,
#[serde(default)]
pub gemini: bool,
#[serde(default)]
pub opencode: bool,
}
impl SkillApps {
@@ -82,7 +73,6 @@ impl SkillApps {
AppType::Claude => self.claude,
AppType::Codex => self.codex,
AppType::Gemini => self.gemini,
AppType::OpenCode => self.opencode,
}
}
@@ -92,7 +82,6 @@ impl SkillApps {
AppType::Claude => self.claude = enabled,
AppType::Codex => self.codex = enabled,
AppType::Gemini => self.gemini = enabled,
AppType::OpenCode => self.opencode = enabled,
}
}
@@ -108,15 +97,12 @@ impl SkillApps {
if self.gemini {
apps.push(AppType::Gemini);
}
if self.opencode {
apps.push(AppType::OpenCode);
}
apps
}
/// 检查是否所有应用都未启用
pub fn is_empty(&self) -> bool {
!self.claude && !self.codex && !self.gemini && !self.opencode
!self.claude && !self.codex && !self.gemini
}
/// 仅启用指定应用(其他应用设为禁用)
@@ -219,9 +205,6 @@ pub struct McpRoot {
pub codex: McpConfig,
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
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 {
@@ -233,7 +216,6 @@ impl Default for McpRoot {
claude: McpConfig::default(),
codex: McpConfig::default(),
gemini: McpConfig::default(),
opencode: McpConfig::default(),
}
}
}
@@ -254,8 +236,6 @@ pub struct PromptRoot {
pub codex: PromptConfig,
#[serde(default)]
pub gemini: PromptConfig,
#[serde(default)]
pub opencode: PromptConfig,
}
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
@@ -269,8 +249,7 @@ use crate::provider::ProviderManager;
pub enum AppType {
Claude,
Codex,
Gemini,
OpenCode,
Gemini, // 新增
}
impl AppType {
@@ -278,8 +257,7 @@ impl AppType {
match self {
AppType::Claude => "claude",
AppType::Codex => "codex",
AppType::Gemini => "gemini",
AppType::OpenCode => "opencode",
AppType::Gemini => "gemini", // 新增
}
}
}
@@ -292,12 +270,11 @@ impl FromStr for AppType {
match normalized.as_str() {
"claude" => Ok(AppType::Claude),
"codex" => Ok(AppType::Codex),
"gemini" => Ok(AppType::Gemini),
"opencode" => Ok(AppType::OpenCode),
"gemini" => Ok(AppType::Gemini), // 新增
other => Err(AppError::localized(
"unsupported_app",
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode"),
format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode."),
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini。"),
format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini."),
)),
}
}
@@ -314,9 +291,6 @@ pub struct CommonConfigSnippets {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gemini: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opencode: Option<String>,
}
impl CommonConfigSnippets {
@@ -326,7 +300,6 @@ impl CommonConfigSnippets {
AppType::Claude => self.claude.as_ref(),
AppType::Codex => self.codex.as_ref(),
AppType::Gemini => self.gemini.as_ref(),
AppType::OpenCode => self.opencode.as_ref(),
}
}
@@ -336,7 +309,6 @@ impl CommonConfigSnippets {
AppType::Claude => self.claude = snippet,
AppType::Codex => self.codex = snippet,
AppType::Gemini => self.gemini = snippet,
AppType::OpenCode => self.opencode = snippet,
}
}
}
@@ -375,8 +347,7 @@ impl Default for MultiAppConfig {
let mut apps = HashMap::new();
apps.insert("claude".to_string(), ProviderManager::default());
apps.insert("codex".to_string(), ProviderManager::default());
apps.insert("gemini".to_string(), ProviderManager::default());
apps.insert("opencode".to_string(), ProviderManager::default());
apps.insert("gemini".to_string(), ProviderManager::default()); // 新增
Self {
version: 2,
@@ -535,7 +506,6 @@ impl MultiAppConfig {
AppType::Claude => &self.mcp.claude,
AppType::Codex => &self.mcp.codex,
AppType::Gemini => &self.mcp.gemini,
AppType::OpenCode => &self.mcp.opencode,
}
}
@@ -545,7 +515,6 @@ impl MultiAppConfig {
AppType::Claude => &mut self.mcp.claude,
AppType::Codex => &mut self.mcp.codex,
AppType::Gemini => &mut self.mcp.gemini,
AppType::OpenCode => &mut self.mcp.opencode,
}
}
@@ -559,7 +528,6 @@ impl MultiAppConfig {
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::Gemini)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::OpenCode)?;
Ok(config)
}
@@ -579,7 +547,6 @@ impl MultiAppConfig {
if !self.prompts.claude.prompts.is_empty()
|| !self.prompts.codex.prompts.is_empty()
|| !self.prompts.gemini.prompts.is_empty()
|| !self.prompts.opencode.prompts.is_empty()
{
return Ok(false);
}
@@ -587,12 +554,7 @@ impl MultiAppConfig {
log::info!("检测到已存在配置文件且 Prompt 列表为空,将尝试从现有提示词文件自动导入");
let mut imported = false;
for app in [
AppType::Claude,
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
] {
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
// 复用已有的单应用导入逻辑
if Self::auto_import_prompt_if_exists(self, app)? {
imported = true;
@@ -661,7 +623,6 @@ impl MultiAppConfig {
AppType::Claude => &mut config.prompts.claude.prompts,
AppType::Codex => &mut config.prompts.codex.prompts,
AppType::Gemini => &mut config.prompts.gemini.prompts,
AppType::OpenCode => &mut config.prompts.opencode.prompts,
};
prompts.insert(id, prompt);
@@ -695,7 +656,6 @@ impl MultiAppConfig {
AppType::Claude => &self.mcp.claude.servers,
AppType::Codex => &self.mcp.codex.servers,
AppType::Gemini => &self.mcp.gemini.servers,
AppType::OpenCode => &self.mcp.opencode.servers,
};
for (id, entry) in old_servers {
-11
View File
@@ -51,15 +51,6 @@ pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
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 })
}
}
}
@@ -76,7 +67,6 @@ pub async fn get_config_dir(app: String) -> Result<String, String> {
AppType::Claude => config::get_claude_config_dir(),
AppType::Codex => codex_config::get_codex_config_dir(),
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
};
Ok(dir.to_string_lossy().to_string())
@@ -89,7 +79,6 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool,
AppType::Claude => config::get_claude_config_dir(),
AppType::Codex => codex_config::get_codex_config_dir(),
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
};
if !config_dir.exists() {
+1 -70
View File
@@ -5,8 +5,6 @@
use crate::database::FailoverQueueItem;
use crate::provider::Provider;
use crate::store::AppState;
use std::str::FromStr;
use tauri::Emitter;
/// 获取故障转移队列
#[tauri::command]
@@ -77,7 +75,6 @@ pub async fn get_auto_failover_enabled(
/// 注意:关闭故障转移时不会清除队列,队列内容会保留供下次开启时使用
#[tauri::command]
pub async fn set_auto_failover_enabled(
app: tauri::AppHandle,
state: tauri::State<'_, AppState>,
app_type: String,
enabled: bool,
@@ -86,47 +83,6 @@ pub async fn set_auto_failover_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, &current_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
.db
@@ -142,30 +98,5 @@ pub async fn set_auto_failover_enabled(
.db
.update_proxy_config_for_app(config)
.await
.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(())
.map_err(|e| e.to_string())
}
-2
View File
@@ -122,7 +122,6 @@ pub async fn upsert_mcp_server_in_config(
new_server.apps.claude = true;
new_server.apps.codex = true;
new_server.apps.gemini = true;
new_server.apps.opencode = true;
}
McpService::upsert_server(&state, new_server)
@@ -201,6 +200,5 @@ 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_codex(&state).unwrap_or(0);
total += McpService::import_from_gemini(&state).unwrap_or(0);
total += McpService::import_from_opencode(&state).unwrap_or(0);
Ok(total)
}
+71 -122
View File
@@ -322,19 +322,6 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
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 版本
let nvm_base = home.join(".nvm/versions/node");
if nvm_base.exists() {
@@ -539,15 +526,18 @@ fn launch_terminal_with_env(
// 创建并写入配置文件
write_claude_config(&config_file, &env_vars)?;
// 转义配置文件路径用于 shell
let config_path_escaped = escape_shell_path(&config_file);
#[cfg(target_os = "macos")]
{
launch_macos_terminal(&config_file)?;
launch_macos_terminal(&config_file, &config_path_escaped)?;
Ok(())
}
#[cfg(target_os = "linux")]
{
launch_linux_terminal(&config_file)?;
launch_linux_terminal(&config_file, &config_path_escaped)?;
Ok(())
}
@@ -581,132 +571,104 @@ fn write_claude_config(
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 \\\"{config_path}\\\"\" EXIT; echo \"Using provider-specific claude config:\"; echo \"{escaped_path}\"; claude --settings \"{escaped_path}\"; exec bash --norc --noprofile'"
)
}
/// macOS: 使用 Terminal.app 启动
#[cfg(target_os = "macos")]
fn launch_macos_terminal(config_file: &std::path::Path) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
fn launch_macos_terminal(
config_file: &std::path::Path,
config_path_escaped: &str,
) -> Result<(), String> {
use std::process::Command;
let temp_dir = std::env::temp_dir();
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
let config_path_for_script = config_file.to_string_lossy().replace('\"', "\\\"");
let config_path = config_file.to_string_lossy();
let shell_script = generate_wrapper_script(&config_path_for_script, config_path_escaped);
// Write the shell script to a temp file (no escaping needed!)
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}"))?;
// 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!(
let script = format!(
r#"tell application "Terminal"
activate
do script "bash '{}'"
end tell"#,
script_file.display()
activate
do script "{}"
end tell"#,
shell_script.replace('\"', "\\\"")
);
let output = Command::new("osascript")
Command::new("osascript")
.arg("-e")
.arg(&applescript)
.output()
.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
));
}
.arg(&script)
.spawn()
.map_err(|e| format!("启动 macOS 终端失败: {e}"))?;
Ok(())
}
/// Linux: 尝试使用常见终端启动
#[cfg(target_os = "linux")]
fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
fn launch_linux_terminal(
config_file: &std::path::Path,
config_path_escaped: &str,
) -> Result<(), String> {
use std::process::Command;
let terminals = [
("gnome-terminal", vec!["--"]),
("konsole", vec!["-e"]),
("xfce4-terminal", vec!["-e"]),
("mate-terminal", vec!["--"]),
("lxterminal", vec!["-e"]),
("alacritty", vec!["-e"]),
("kitty", vec!["-e"]),
"gnome-terminal",
"konsole",
"xfce4-terminal",
"mate-terminal",
"lxterminal",
"alacritty",
"kitty",
];
// Create temp script file (same approach as macOS)
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 config_path_for_bash = config_file.to_string_lossy();
let shell_cmd = generate_wrapper_script(&config_path_for_bash, config_path_escaped);
let mut last_error = String::from("未找到可用的终端");
for (terminal, args) in terminals {
// Check if terminal exists
for terminal in terminals {
// 检查终端是否存在
if std::path::Path::new(&format!("/usr/bin/{}", terminal)).exists()
|| std::path::Path::new(&format!("/bin/{}", terminal)).exists()
{
let result = Command::new(terminal)
.args(&args)
.arg("bash")
.arg(script_file.to_string_lossy().as_ref())
.output();
let result = match terminal {
"gnome-terminal" | "mate-terminal" => Command::new(terminal)
.arg("--")
.arg("bash")
.arg("-c")
.arg(&shell_cmd)
.spawn(),
_ => Command::new(terminal)
.arg("-e")
.arg("bash")
.arg("-c")
.arg(&shell_cmd)
.spawn(),
};
match result {
Ok(output) if output.status.success() => return Ok(()),
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
last_error = format!("启动 {} 失败: {}", terminal, stderr);
}
Ok(_) => return Ok(()),
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);
Err(last_error)
}
@@ -739,24 +701,11 @@ if errorlevel 1 (
std::fs::write(&bat_file, content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
// Use output() to capture errors from the start command
let output = Command::new("cmd")
Command::new("cmd")
.args(["/C", "start", "cmd", "/C", &bat_file.to_string_lossy()])
.creation_flags(CREATE_NO_WINDOW)
.output()
.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
));
}
.spawn()
.map_err(|e| format!("启动 Windows 终端失败: {e}"))?;
Ok(())
}
-34
View File
@@ -60,16 +60,6 @@ pub fn delete_provider(
.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> {
ProviderService::switch(state, app_type, id)
@@ -335,27 +325,3 @@ pub fn sync_universal_provider(
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())
}
-27
View File
@@ -80,30 +80,3 @@ pub async fn set_rectifier_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)
}
-1
View File
@@ -20,7 +20,6 @@ fn parse_app_type(app: &str) -> Result<AppType, String> {
"claude" => Ok(AppType::Claude),
"codex" => Ok(AppType::Codex),
"gemini" => Ok(AppType::Gemini),
"opencode" => Ok(AppType::OpenCode),
_ => Err(format!("不支持的 app 类型: {app}")),
}
}
+3 -6
View File
@@ -13,7 +13,7 @@ impl Database {
pub fn get_all_mcp_servers(&self) -> Result<IndexMap<String, McpServer>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn.prepare(
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini
FROM mcp_servers
ORDER BY name ASC, id ASC"
).map_err(|e| AppError::Database(e.to_string()))?;
@@ -30,7 +30,6 @@ impl Database {
let enabled_claude: bool = row.get(7)?;
let enabled_codex: bool = row.get(8)?;
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 tags = serde_json::from_str(&tags_str).unwrap_or_default();
@@ -45,7 +44,6 @@ impl Database {
claude: enabled_claude,
codex: enabled_codex,
gemini: enabled_gemini,
opencode: enabled_opencode,
},
description,
homepage,
@@ -70,8 +68,8 @@ impl Database {
conn.execute(
"INSERT OR REPLACE INTO mcp_servers (
id, name, server_config, description, homepage, docs, tags,
enabled_claude, enabled_codex, enabled_gemini, enabled_opencode
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
enabled_claude, enabled_codex, enabled_gemini
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
params![
server.id,
server.name,
@@ -86,7 +84,6 @@ impl Database {
server.apps.claude,
server.apps.codex,
server.apps.gemini,
server.apps.opencode,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
-47
View File
@@ -614,51 +614,4 @@ impl Database {
log::info!("已删除所有 Live 配置备份");
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(())
}
}
-18
View File
@@ -186,22 +186,4 @@ impl Database {
.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)
}
}
+17 -28
View File
@@ -3,7 +3,7 @@
//! 提供 Skills 和 Skill Repos 的 CRUD 操作。
//!
//! v3.10.0+ 统一管理架构:
//! - Skills 使用统一的 id 主键,支持应用启用标志
//! - Skills 使用统一的 id 主键,支持应用启用标志
//! - 实际文件存储在 ~/.cc-switch/skills/,同步到各应用目录
use crate::app_config::{InstalledSkill, SkillApps};
@@ -22,7 +22,7 @@ impl Database {
let mut stmt = conn
.prepare(
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at
readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at
FROM skills ORDER BY name ASC",
)
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -42,9 +42,8 @@ impl Database {
claude: row.get(8)?,
codex: row.get(9)?,
gemini: row.get(10)?,
opencode: row.get(11)?,
},
installed_at: row.get(12)?,
installed_at: row.get(11)?,
})
})
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -63,7 +62,7 @@ impl Database {
let mut stmt = conn
.prepare(
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at
readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at
FROM skills WHERE id = ?1",
)
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -82,9 +81,8 @@ impl Database {
claude: row.get(8)?,
codex: row.get(9)?,
gemini: row.get(10)?,
opencode: row.get(11)?,
},
installed_at: row.get(12)?,
installed_at: row.get(11)?,
})
});
@@ -101,8 +99,8 @@ impl Database {
conn.execute(
"INSERT OR REPLACE INTO skills
(id, name, description, directory, repo_owner, repo_name, repo_branch,
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, ?13)",
readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
params![
skill.id,
skill.name,
@@ -115,7 +113,6 @@ impl Database {
skill.apps.claude,
skill.apps.codex,
skill.apps.gemini,
skill.apps.opencode,
skill.installed_at,
],
)
@@ -145,8 +142,8 @@ impl Database {
let conn = lock_conn!(self.conn);
let affected = conn
.execute(
"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, apps.opencode, id],
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3 WHERE id = ?4",
params![apps.claude, apps.codex, apps.gemini, id],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(affected > 0)
@@ -203,32 +200,24 @@ impl Database {
Ok(())
}
/// 初始化默认的 Skill 仓库(启动时调用,补充缺失的默认仓库
/// 初始化默认的 Skill 仓库(首次启动时调用)
pub fn init_default_skill_repos(&self) -> Result<usize, AppError> {
// 获取已有仓库列表
// 检查是否已有仓库
let existing = self.get_skill_repos()?;
let existing_keys: std::collections::HashSet<(String, String)> = existing
.iter()
.map(|r| (r.owner.clone(), r.name.clone()))
.collect();
if !existing.is_empty() {
return Ok(0);
}
// 获取默认仓库列表
let default_store = crate::services::skill::SkillStore::default();
let mut count = 0;
// 仅插入缺失的默认仓库
for repo in &default_store.repos {
let key = (repo.owner.clone(), repo.name.clone());
if !existing_keys.contains(&key) {
self.save_skill_repo(repo)?;
count += 1;
log::info!("补充默认 Skill 仓库: {}/{}", repo.owner, repo.name);
}
self.save_skill_repo(repo)?;
count += 1;
}
if count > 0 {
log::info!("补充默认 Skill 仓库完成,新增 {count} 个");
}
log::info!("初始化默认 Skill 仓库完成,共 {count} 个");
Ok(count)
}
}
+1 -1
View File
@@ -47,7 +47,7 @@ const DB_BACKUP_RETAIN: usize = 10;
/// 当前 Schema 版本号
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
pub(crate) const SCHEMA_VERSION: i32 = 4;
pub(crate) const SCHEMA_VERSION: i32 = 3;
/// 安全地序列化 JSON,避免 unwrap panic
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
+1 -31
View File
@@ -58,7 +58,7 @@ impl Database {
id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL,
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_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0
enabled_gemini BOOLEAN NOT NULL DEFAULT 0
)",
[],
)
@@ -85,7 +85,6 @@ impl Database {
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
enabled_codex 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
)",
[],
@@ -347,11 +346,6 @@ impl Database {
Self::migrate_v2_to_v3(conn)?;
Self::set_user_version(conn, 3)?;
}
3 => {
log::info!("迁移数据库从 v3 到 v4OpenCode 支持)");
Self::migrate_v3_to_v4(conn)?;
Self::set_user_version(conn, 4)?;
}
_ => {
return Err(AppError::Database(format!(
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
@@ -855,30 +849,6 @@ impl Database {
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 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
-2
View File
@@ -166,7 +166,6 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
claude: false,
codex: false,
gemini: false,
opencode: false,
};
for app in apps_str.split(',') {
@@ -174,7 +173,6 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
"claude" => apps.claude = true,
"codex" => apps.codex = true,
"gemini" => apps.gemini = true,
"opencode" => apps.opencode = true,
other => {
return Err(AppError::InvalidInput(format!(
"Invalid app in 'apps': {other}"
-28
View File
@@ -145,7 +145,6 @@ pub(crate) fn build_provider_from_request(
AppType::Claude => build_claude_settings(request),
AppType::Codex => build_codex_settings(request),
AppType::Gemini => build_gemini_settings(request),
AppType::OpenCode => build_opencode_settings(request),
};
// Build usage script configuration if provided
@@ -364,33 +363,6 @@ fn build_gemini_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
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
// =============================================================================
+10 -57
View File
@@ -13,7 +13,6 @@ mod gemini_config;
mod gemini_mcp;
mod init_status;
mod mcp;
mod opencode_config;
mod panic_hook;
mod prompt;
mod prompt_files;
@@ -266,41 +265,33 @@ pub fn run() {
log::warn!("初始化 Updater 插件失败,已跳过:{e}");
}
}
// 初始化日志(单文件输出到 <app_config_dir>/logs/cc-switch.log
// 初始化日志(Debug 和 Release 模式都启用 Info 级别
// 日志同时输出到控制台和文件(<app_config_dir>/logs/;若设置了覆盖则使用覆盖目录)
{
use tauri_plugin_log::{RotationStrategy, Target, TargetKind, TimezoneStrategy};
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(
tauri_plugin_log::Builder::default()
// 初始化为 Trace,允许后续通过 log::set_max_level() 动态调整级别
.level(log::LevelFilter::Trace)
.level(log::LevelFilter::Info)
.targets([
// 输出到控制台
Target::new(TargetKind::Stdout),
// 输出到日志文件
Target::new(TargetKind::Folder {
path: log_dir,
file_name: Some("cc-switch".into()),
}),
])
// 单文件模式:启动时删除旧文件,达到大小时轮转
// 注意:KeepSome(n) 内部会做 n-2 运算,n=1 会导致 usize 下溢
// KeepSome(2) 是最小安全值,表示不保留轮转文件
.rotation_strategy(RotationStrategy::KeepSome(2))
// 单文件大小限制 1GB
.max_file_size(1024 * 1024 * 1024)
.rotation_strategy(RotationStrategy::KeepAll)
.max_file_size(5_000_000) // 5MB 单文件上限
.timezone_strategy(TimezoneStrategy::UseLocal)
.build(),
)?;
// 清理旧日志文件,只保留最近 2 个
panic_hook::cleanup_old_logs();
}
// 初始化数据库
@@ -491,17 +482,6 @@ 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 服务器配置(表空时触发)
if app_state.db.is_mcp_table_empty().unwrap_or(false) {
log::info!("MCP table empty, importing from live configurations...");
@@ -529,14 +509,6 @@ pub fn run() {
Ok(_) => log::debug!("○ No Gemini MCP servers found to import"),
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. 导入提示词文件(表空时触发)
@@ -668,19 +640,6 @@ pub fn run() {
// 将同一个实例注入到全局状态,避免重复创建导致的不一致
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
let skill_service = SkillService::new();
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
@@ -753,7 +712,6 @@ pub fn run() {
commands::add_provider,
commands::update_provider,
commands::delete_provider,
commands::remove_provider_from_live_config,
commands::switch_provider,
commands::import_default_config,
commands::get_claude_config_status,
@@ -778,8 +736,6 @@ pub fn run() {
commands::save_settings,
commands::get_rectifier_config,
commands::set_rectifier_config,
commands::get_log_config,
commands::set_log_config,
commands::restart_app,
commands::check_for_updates,
commands::is_portable_mode,
@@ -918,9 +874,6 @@ pub fn run() {
commands::upsert_universal_provider,
commands::delete_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,
-1
View File
@@ -91,7 +91,6 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError
claude: true,
codex: false,
gemini: false,
opencode: false,
},
description: None,
homepage: None,
-1
View File
@@ -235,7 +235,6 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
claude: false,
codex: true,
gemini: false,
opencode: false,
},
description: None,
homepage: None,
-1
View File
@@ -87,7 +87,6 @@ pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result<usize, AppError
claude: false,
codex: false,
gemini: true,
opencode: false,
},
description: None,
homepage: None,
-5
View File
@@ -8,12 +8,10 @@
//! - `claude` - Claude MCP 同步和导入
//! - `codex` - Codex MCP 同步和导入(含 TOML 转换)
//! - `gemini` - Gemini MCP 同步和导入
//! - `opencode` - OpenCode MCP 同步和导入(含 local/remote 格式转换)
mod claude;
mod codex;
mod gemini;
mod opencode;
mod validation;
// 重新导出公共 API
@@ -28,6 +26,3 @@ pub use gemini::{
import_from_gemini, remove_server_from_gemini, sync_enabled_to_gemini,
sync_single_server_to_gemini,
};
pub use opencode::{
import_from_opencode, remove_server_from_opencode, sync_single_server_to_opencode,
};
-354
View File
@@ -1,354 +0,0 @@
//! 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");
}
}
-221
View File
@@ -1,221 +0,0 @@
//! 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;
}
#[cfg(target_os = "windows")]
{
// Windows: %APPDATA%\opencode
dirs::data_dir()
.map(|d| d.join("opencode"))
.unwrap_or_else(|| PathBuf::from(".config").join("opencode"))
}
#[cfg(not(target_os = "windows"))]
{
// Unix: ~/.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)
}
+45
View File
@@ -12,6 +12,9 @@ use std::sync::OnceLock;
/// 应用版本号(从 Cargo.toml 读取)
const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
/// 日志文件保留数量
const LOG_FILES_TO_KEEP: usize = 2;
static APP_CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
pub fn init_app_config_dir(dir: PathBuf) {
@@ -43,6 +46,48 @@ pub fn get_log_dir() -> PathBuf {
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)
fn get_system_info() -> String {
let os = std::env::consts::OS;
-3
View File
@@ -5,7 +5,6 @@ use crate::codex_config::get_codex_auth_path;
use crate::config::get_claude_settings_path;
use crate::error::AppError;
use crate::gemini_config::get_gemini_dir;
use crate::opencode_config::get_opencode_dir;
/// 返回指定应用所使用的提示词文件路径。
pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
@@ -13,14 +12,12 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
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::Gemini => get_gemini_dir(),
AppType::OpenCode => get_opencode_dir(),
};
let filename = match app {
AppType::Claude => "CLAUDE.md",
AppType::Codex => "AGENTS.md",
AppType::Gemini => "GEMINI.md",
AppType::OpenCode => "AGENTS.md",
};
Ok(base_dir.join(filename))
-147
View File
@@ -142,55 +142,6 @@ pub struct UsageResult {
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)]
pub struct ProviderMeta {
@@ -221,12 +172,6 @@ pub struct ProviderMeta {
/// 每月消费限额(USD
#[serde(rename = "limitMonthlyUsd", skip_serializing_if = "Option::is_none")]
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 {
@@ -517,95 +462,3 @@ 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>>,
}
/// 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>,
}
+2 -15
View File
@@ -28,7 +28,6 @@ const HEADER_BLACKLIST: &[&str] = &[
// 认证类(会被覆盖)
"authorization",
"x-api-key",
"x-goog-api-key",
// 连接类(由 HTTP 客户端管理)
"host",
"content-length",
@@ -586,9 +585,8 @@ impl RequestForwarder {
// 默认使用空白名单,过滤所有 _ 前缀字段
let filtered_body = filter_private_params_with_whitelist(request_body, &[]);
// 获取 HTTP 客户端:优先使用供应商单独代理配置,否则使用全局客户端
let proxy_config = provider.meta.as_ref().and_then(|m| m.proxy_config.as_ref());
let client = super::http_client::get_for_provider(proxy_config);
// 每次请求时获取最新的全局 HTTP 客户端(支持热更新代理配置)
let client = super::http_client::get();
let mut request = client.post(&url);
// 只有当 timeout > 0 时才设置请求超时
@@ -664,17 +662,6 @@ impl RequestForwarder {
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| {
if e.is_timeout() {
+13 -196
View File
@@ -3,11 +3,8 @@
//! 提供支持全局代理配置的 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;
@@ -158,15 +155,23 @@ pub fn update_proxy(proxy_url: Option<&str>) -> Result<(), String> {
/// 获取全局 HTTP 客户端
///
/// 返回配置了代理的客户端(如果已配置代理),否则返回跟随系统代理的客户端。
/// 返回配置了代理的客户端(如果已配置代理),否则返回直连客户端。
pub fn get() -> Client {
GLOBAL_CLIENT
.get()
.and_then(|lock| lock.read().ok())
.map(|c| c.clone())
.unwrap_or_else(|| {
// 如果还没初始化,创建一个默认客户端(配置与 build_client 一致)
log::warn!("[GlobalProxy] [GP-004] Client not initialized, using fallback");
build_client(None).unwrap_or_default()
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))
.no_proxy()
.build()
.unwrap_or_default()
})
}
@@ -194,7 +199,7 @@ fn build_client(proxy_url: Option<&str>) -> Result<Client, String> {
.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)
@@ -214,16 +219,8 @@ fn build_client(proxy_url: Option<&str>) -> Result<Client, String> {
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 = builder.no_proxy();
log::debug!("[GlobalProxy] Direct connection (no proxy)");
}
builder
@@ -231,50 +228,6 @@ fn build_client(proxy_url: Option<&str>) -> Result<Client, String> {
.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) {
@@ -294,109 +247,9 @@ pub fn mask_url(url: &str) -> 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() {
@@ -445,40 +298,4 @@ mod tests {
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);
}
}
}
+9 -115
View File
@@ -2,13 +2,11 @@
//!
//! 负责选择和管理代理目标供应商,实现智能故障转移
use crate::app_config::AppType;
use crate::database::Database;
use crate::error::AppError;
use crate::provider::Provider;
use crate::proxy::circuit_breaker::{AllowResult, CircuitBreaker, CircuitBreakerConfig};
use std::collections::HashMap;
use std::str::FromStr;
use std::sync::Arc;
use tokio::sync::RwLock;
@@ -33,7 +31,7 @@ impl ProviderRouter {
///
/// 返回按优先级排序的可用供应商列表:
/// - 故障转移关闭时:仅返回当前供应商
/// - 故障转移开启时:仅使用故障转移队列,按队列顺序依次尝试(P1 → P2 → ...)
/// - 故障转移开启时:完全按照故障转移队列顺序返回,忽略当前供应商设置
pub async fn select_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> {
let mut result = Vec::new();
let mut total_providers = 0usize;
@@ -49,25 +47,12 @@ impl ProviderRouter {
};
if auto_failover_enabled {
// 故障转移开启:仅按队列顺序依次尝试(P1 → P2 → ...)
let all_providers = self.db.get_all_providers(app_type)?;
// 故障转移开启:使用 in_failover_queue 标记的供应商,按 sort_index 排序
let failover_providers = self.db.get_failover_providers(app_type)?;
total_providers = failover_providers.len();
// 使用 DAO 返回的排序结果,确保和前端展示一致
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);
for provider in failover_providers {
let circuit_key = format!("{}:{}", app_type, provider.id);
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
if breaker.is_available().await {
@@ -78,16 +63,7 @@ impl ProviderRouter {
}
} else {
// 故障转移关闭:仅使用当前供应商,跳过熔断器检查
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_id) = self.db.get_current_provider(app_type)? {
if let Some(current) = self.db.get_provider_by_id(&current_id, app_type)? {
total_providers = 1;
result.push(current);
@@ -263,53 +239,9 @@ mod tests {
use super::*;
use crate::database::Database;
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]
#[serial]
async fn test_provider_router_creation() {
let _home = TempHome::new();
let db = Arc::new(Database::memory().unwrap());
let router = ProviderRouter::new(db);
@@ -318,9 +250,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_failover_disabled_uses_current_provider() {
let _home = TempHome::new();
let db = Arc::new(Database::memory().unwrap());
let provider_a =
@@ -341,9 +271,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_failover_enabled_uses_queue_order_ignoring_current() {
let _home = TempHome::new();
async fn test_failover_enabled_uses_queue_order() {
let db = Arc::new(Database::memory().unwrap());
// 设置 sort_index 来控制顺序:b=1, a=2
@@ -370,45 +298,13 @@ mod tests {
let providers = router.select_providers("claude").await.unwrap();
assert_eq!(providers.len(), 2);
// 故障转移开启时:仅按队列顺序选择(忽略当前供应商)
// 按 sort_index 排序:b(1) 在前,a(2) 在后
assert_eq!(providers[0].id, "b");
assert_eq!(providers[1].id, "a");
}
#[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() {
let _home = TempHome::new();
let db = Arc::new(Database::memory().unwrap());
db.update_circuit_breaker_config(&CircuitBreakerConfig {
@@ -449,9 +345,7 @@ mod tests {
}
#[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
-8
View File
@@ -132,10 +132,6 @@ impl ProviderType {
}
ProviderType::Gemini
}
AppType::OpenCode => {
// OpenCode doesn't support proxy, but return a default type for completeness
ProviderType::Codex // Fallback to Codex-like type
}
}
}
@@ -180,10 +176,6 @@ pub fn get_adapter(app_type: &AppType) -> Box<dyn ProviderAdapter> {
AppType::Claude => Box::new(ClaudeAdapter::new()),
AppType::Codex => Box::new(CodexAdapter::new()),
AppType::Gemini => Box::new(GeminiAdapter::new()),
AppType::OpenCode => {
// OpenCode doesn't support proxy, fallback to Codex adapter
Box::new(CodexAdapter::new())
}
}
}
@@ -104,6 +104,7 @@ pub fn create_anthropic_sse_stream(
}
if let Ok(chunk) = serde_json::from_str::<OpenAIStreamChunk>(data) {
// 仅在 DEBUG 级别简短记录 SSE 事件
log::debug!("[Claude/OpenRouter] <<< SSE chunk received");
if message_id.is_none() {
+6 -39
View File
@@ -12,7 +12,6 @@ use super::{
use axum::response::{IntoResponse, Response};
use bytes::Bytes;
use futures::stream::{Stream, StreamExt};
use reqwest::header::HeaderMap;
use rust_decimal::Decimal;
use serde_json::Value;
use std::{
@@ -48,12 +47,6 @@ pub async fn handle_streaming(
parser_config: &UsageParserConfig,
) -> Response {
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);
// 复制响应头
@@ -101,19 +94,6 @@ pub async fn handle_non_streaming(
log::error!("[{}] 读取响应失败: {e}", ctx.tag);
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) {
@@ -490,12 +470,6 @@ pub fn create_logged_passthrough_stream(
match chunk_result {
Some(Ok(bytes)) => {
if is_first_chunk {
log::debug!(
"[{tag}] 已接收上游流式首包: bytes={}",
bytes.len()
);
}
is_first_chunk = false;
let text = String::from_utf8_lossy(&bytes);
buffer.push_str(&text);
@@ -514,9 +488,13 @@ pub fn create_logged_passthrough_stream(
if let Some(c) = &collector {
c.push(json_value.clone()).await;
}
log::debug!("[{tag}] <<< SSE 事件: {data}");
log::debug!(
"[{}] <<< SSE 事件: {}",
tag,
data.chars().take(100).collect::<String>()
);
} else {
log::debug!("[{tag}] <<< SSE 数据: {data}");
log::debug!("[{tag}] <<< SSE 数据: {}", data.chars().take(100).collect::<String>());
}
} else {
log::debug!("[{tag}] <<< SSE: [DONE]");
@@ -545,14 +523,3 @@ 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(", ")
}
-15
View File
@@ -8,7 +8,6 @@ use super::{
};
use crate::database::Database;
use axum::{
extract::DefaultBodyLimit,
routing::{get, post},
Router,
};
@@ -190,18 +189,6 @@ impl ProxyServer {
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 {
let cors = CorsLayer::new()
.allow_origin(Any)
@@ -237,8 +224,6 @@ impl ProxyServer {
// Gemini API (支持带前缀和不带前缀)
.route("/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)
.with_state(self.state.clone())
}
+3 -121
View File
@@ -16,13 +16,13 @@ pub struct ProxyConfig {
/// 是否正在接管 Live 配置
#[serde(default)]
pub live_takeover_active: bool,
/// 流式首字超时(秒)- 等待首个数据块的最大时间,范围 1-120 秒,默认 60 秒
/// 流式首字超时(秒)- 等待首个数据块的最大时间
#[serde(default = "default_streaming_first_byte_timeout")]
pub streaming_first_byte_timeout: u64,
/// 流式静默超时(秒)- 两个数据块之间的最大间隔,范围 60-600 秒,填 0 禁用(防止中途卡住)
/// 流式静默超时(秒)- 两个数据块之间的最大间隔
#[serde(default = "default_streaming_idle_timeout")]
pub streaming_idle_timeout: u64,
/// 非流式总超时(秒)- 非流式请求的总超时时间,范围 60-1200 秒,默认 600 秒(10 分钟)
/// 非流式总超时(秒)- 非流式请求的总超时时间
#[serde(default = "default_non_streaming_timeout")]
pub non_streaming_timeout: u64,
}
@@ -221,50 +221,6 @@ 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::*;
@@ -298,78 +254,4 @@ mod tests {
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");
}
}
-4
View File
@@ -122,10 +122,6 @@ impl ConfigService {
AppType::Codex => Self::sync_codex_live(config, &current_id, &provider)?,
AppType::Claude => Self::sync_claude_live(config, &current_id, &provider)?,
AppType::Gemini => Self::sync_gemini_live(config, &current_id, &provider)?,
AppType::OpenCode => {
// OpenCode uses additive mode, no live sync needed
// OpenCode providers are managed directly in the config file
}
}
Ok(())
-51
View File
@@ -37,9 +37,6 @@ impl McpService {
if prev_apps.gemini && !server.apps.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)?;
@@ -116,13 +113,6 @@ impl McpService {
AppType::Gemini => {
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(())
}
@@ -145,9 +135,6 @@ impl McpService {
AppType::Claude => mcp::remove_server_from_claude(id)?,
AppType::Codex => mcp::remove_server_from_codex(id)?,
AppType::Gemini => mcp::remove_server_from_gemini(id)?,
AppType::OpenCode => {
mcp::remove_server_from_opencode(id)?;
}
}
Ok(())
}
@@ -324,42 +311,4 @@ impl McpService {
Ok(new_count)
}
/// 从 OpenCode 导入 MCPv3.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)
}
}
-163
View File
@@ -120,64 +120,6 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
// Delegate to write_gemini_live which handles env file writing correctly
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(())
}
@@ -278,21 +220,6 @@ pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
"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)
}
}
}
@@ -368,24 +295,6 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
"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(
@@ -490,75 +399,3 @@ pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
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)
}
+18 -147
View File
@@ -20,16 +20,13 @@ use crate::settings::CustomEndpoint;
use crate::store::AppState;
// Re-export sub-module functions for external access
pub use live::{
import_default_config, import_opencode_providers_from_live, read_live_settings,
sync_current_to_live,
};
pub use live::{import_default_config, read_live_settings, sync_current_to_live};
// Internal re-exports (pub(crate))
pub(crate) use live::write_live_snapshot;
// Internal re-exports
use live::{remove_opencode_provider_from_live, write_gemini_live};
use live::write_gemini_live;
use usage::validate_usage_script;
/// Provider business logic service
@@ -140,13 +137,7 @@ impl ProviderService {
/// 使用有效的当前供应商 ID(验证过存在性)。
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
/// 这确保了云同步场景下多设备可以独立选择供应商,且返回的 ID 一定有效。
///
/// 对于 OpenCode(累加模式),不存在"当前供应商"概念,直接返回空字符串。
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)
.map(|opt| opt.unwrap_or_default())
}
@@ -161,13 +152,7 @@ impl ProviderService {
// Save to database
state.db.save_provider(app_type.as_str(), &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)
// 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())?;
if current.is_none() {
// No current provider, set as current and sync
@@ -191,20 +176,14 @@ impl ProviderService {
Self::normalize_provider_if_claude(&app_type, &mut provider);
Self::validate_provider_settings(&app_type, &provider)?;
// 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)
// Check if this is current provider (use effective current, not just DB)
let effective_current =
crate::settings::get_effective_current_provider(&state.db, &app_type)?;
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 {
// 如果代理接管模式处于激活状态,并且代理服务正在运行:
// - 不写 Live 配置(否则会破坏接管)
@@ -237,18 +216,8 @@ impl ProviderService {
/// Delete a provider
///
/// 同时检查本地 settings 和数据库的当前供应商,防止删除任一端正在使用的供应商。
/// 对于 OpenCode(累加模式),可以随时删除任意供应商,同时从 live 配置中移除。
pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
// 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
// Check both local settings and database
let local_current = crate::settings::get_current_provider(&app_type);
let db_current = state.db.get_current_provider(app_type.as_str())?;
@@ -261,27 +230,6 @@ impl ProviderService {
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 flow:
@@ -378,29 +326,22 @@ impl ProviderService {
if let Some(current_id) = current_id {
if current_id != id {
// OpenCode uses additive mode - all providers coexist in the same file,
// no backfill needed (backfill is for exclusive mode apps like Claude/Codex/Gemini)
if !matches!(app_type, AppType::OpenCode) {
// Only backfill when switching to a different provider
if let Ok(live_config) = read_live_settings(app_type.clone()) {
if let Some(mut current_provider) = providers.get(&current_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(), &current_provider);
}
// Only backfill when switching to a different provider
if let Ok(live_config) = read_live_settings(app_type.clone()) {
if let Some(mut current_provider) = providers.get(&current_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(), &current_provider);
}
}
}
}
// OpenCode uses additive mode - skip setting is_current (no such concept)
if !matches!(app_type, AppType::OpenCode) {
// Update local settings (device-level, takes priority)
crate::settings::set_current_provider(&app_type, Some(id))?;
// 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)
state.db.set_current_provider(app_type.as_str(), id)?;
}
// Update database is_current (as default for new devices)
state.db.set_current_provider(app_type.as_str(), id)?;
// Sync to live (write_gemini_live handles security flag internally for Gemini)
write_live_snapshot(&app_type, provider)?;
@@ -439,7 +380,6 @@ impl ProviderService {
AppType::Claude => Self::extract_claude_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::OpenCode => Self::extract_opencode_common_config(&provider.settings_config),
}
}
@@ -452,7 +392,6 @@ impl ProviderService {
AppType::Claude => Self::extract_claude_common_config(settings_config),
AppType::Codex => Self::extract_codex_common_config(settings_config),
AppType::Gemini => Self::extract_gemini_common_config(settings_config),
AppType::OpenCode => Self::extract_opencode_common_config(settings_config),
}
}
@@ -586,29 +525,6 @@ impl ProviderService {
.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)
///
/// Returns `Ok(true)` if imported, `Ok(false)` if skipped.
@@ -775,17 +691,6 @@ impl ProviderService {
use crate::gemini_config::validate_gemini_settings;
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)
@@ -923,40 +828,6 @@ impl ProviderService {
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))
}
}
}
}
+1 -128
View File
@@ -368,10 +368,6 @@ impl ProxyService {
AppType::Claude => self.read_claude_live()?,
AppType::Codex => self.read_codex_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)
@@ -585,9 +581,6 @@ impl ProxyService {
}
}
}
AppType::OpenCode => {
// OpenCode doesn't support proxy features, skip silently
}
}
Ok(())
@@ -766,10 +759,6 @@ impl ProxyService {
AppType::Claude => ("claude", self.read_claude_live()?),
AppType::Codex => ("codex", self.read_codex_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)
@@ -978,10 +967,6 @@ impl ProxyService {
self.write_gemini_live(&live_config)?;
log::info!("Gemini Live 配置已接管,代理地址: {proxy_url}");
}
AppType::OpenCode => {
// OpenCode doesn't support proxy features
return Err("OpenCode 不支持代理功能".to_string());
}
}
Ok(())
@@ -1065,9 +1050,6 @@ impl ProxyService {
let _ = self.write_gemini_live(&live_config);
}
}
AppType::OpenCode => {
// OpenCode doesn't support proxy features, skip silently
}
}
Ok(())
@@ -1100,9 +1082,6 @@ impl ProxyService {
log::info!("Gemini Live 配置已恢复");
}
}
AppType::OpenCode => {
// OpenCode doesn't support proxy features, skip silently
}
}
Ok(())
@@ -1182,10 +1161,6 @@ impl ProxyService {
AppType::Claude => self.write_claude_live(config),
AppType::Codex => self.write_codex_live(config),
AppType::Gemini => self.write_gemini_live(config),
AppType::OpenCode => {
// OpenCode doesn't support proxy features
Err("OpenCode 不支持代理功能".to_string())
}
}
}
@@ -1203,10 +1178,6 @@ impl ProxyService {
Ok(config) => Self::is_gemini_live_taken_over(&config),
Err(_) => false,
},
AppType::OpenCode => {
// OpenCode doesn't support proxy takeover
false
}
}
}
@@ -1246,10 +1217,6 @@ impl ProxyService {
AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),
AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),
AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),
AppType::OpenCode => {
// OpenCode doesn't support proxy features
Ok(())
}
}
}
@@ -1530,10 +1497,7 @@ impl ProxyService {
app_type: &str,
provider_id: &str,
) -> Result<(), String> {
// 代理模式切换供应商(热切换):
// - 更新 SSOT(数据库 is_current
// - 同步本地 settings(设备级 current_provider_*
// - 若该应用正处于接管模式,则同步更新 Live 备份(用于停止代理时恢复)
// 更新数据库中的 is_current 标记
let app_type_enum =
AppType::from_str(app_type).map_err(|_| format!("无效的应用类型: {app_type}"))?;
@@ -1541,35 +1505,6 @@ impl ProxyService {
.set_current_provider(app_type_enum.as_str(), provider_id)
.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}");
Ok(())
}
@@ -2090,66 +2025,4 @@ model = "gpt-5.1-codex"
"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);
}
}
+4 -39
View File
@@ -128,12 +128,6 @@ impl Default for SkillStore {
branch: "master".to_string(),
enabled: true,
},
SkillRepo {
owner: "JimLiu".to_string(),
name: "baoyu-skills".to_string(),
branch: "main".to_string(),
enabled: true,
},
],
}
}
@@ -189,11 +183,6 @@ impl SkillService {
return Ok(custom.join("skills"));
}
}
AppType::OpenCode => {
if let Some(custom) = crate::settings::get_opencode_override_dir() {
return Ok(custom.join("skills"));
}
}
}
// 默认路径:回退到用户主目录下的标准位置
@@ -207,7 +196,6 @@ impl SkillService {
AppType::Claude => home.join(".claude").join("skills"),
AppType::Codex => home.join(".codex").join("skills"),
AppType::Gemini => home.join(".gemini").join("skills"),
AppType::OpenCode => home.join(".config").join("opencode").join("skills"),
})
}
@@ -329,12 +317,7 @@ impl SkillService {
.ok_or_else(|| anyhow!("Skill not found: {id}"))?;
// 从所有应用目录删除
for app in [
AppType::Claude,
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
] {
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
let _ = Self::remove_from_app(&skill.directory, &app);
}
@@ -393,12 +376,7 @@ impl SkillService {
let mut unmanaged: HashMap<String, UnmanagedSkill> = HashMap::new();
for app in [
AppType::Claude,
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
] {
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
let app_dir = match Self::get_app_skills_dir(&app) {
Ok(d) => d,
Err(_) => continue,
@@ -447,7 +425,6 @@ impl SkillService {
AppType::Claude => "claude",
AppType::Codex => "codex",
AppType::Gemini => "gemini",
AppType::OpenCode => "opencode",
};
unmanaged
@@ -480,12 +457,7 @@ impl SkillService {
let mut source_path: Option<PathBuf> = None;
let mut found_in: Vec<String> = Vec::new();
for app in [
AppType::Claude,
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
] {
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
if let Ok(app_dir) = Self::get_app_skills_dir(&app) {
let skill_path = app_dir.join(&dir_name);
if skill_path.exists() {
@@ -496,7 +468,6 @@ impl SkillService {
AppType::Claude => "claude",
AppType::Codex => "codex",
AppType::Gemini => "gemini",
AppType::OpenCode => "opencode",
};
found_in.push(app_str.to_string());
}
@@ -535,7 +506,6 @@ impl SkillService {
"claude" => apps.claude = true,
"codex" => apps.codex = true,
"gemini" => apps.gemini = true,
"opencode" => apps.opencode = true,
_ => {}
}
}
@@ -1006,12 +976,7 @@ pub fn migrate_skills_to_ssot(db: &Arc<Database>) -> Result<usize> {
let mut discovered: HashMap<String, SkillApps> = HashMap::new();
// 扫描各应用目录
for app in [
AppType::Claude,
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
] {
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
let app_dir = match SkillService::get_app_skills_dir(&app) {
Ok(d) => d,
Err(_) => continue,
+14 -94
View File
@@ -78,19 +78,15 @@ pub struct StreamCheckService;
impl StreamCheckService {
/// 执行流式健康检查(带重试)
///
/// 如果 Provider 配置了单独的测试配置(meta.testConfig),则使用该配置覆盖全局配置
pub async fn check_with_retry(
app_type: &AppType,
provider: &Provider,
config: &StreamCheckConfig,
) -> Result<StreamCheckResult, AppError> {
// 合并供应商单独配置和全局配置
let effective_config = Self::merge_provider_config(provider, config);
let mut last_result = None;
for attempt in 0..=effective_config.max_retries {
let result = Self::check_once(app_type, provider, &effective_config).await;
for attempt in 0..=config.max_retries {
let result = Self::check_once(app_type, provider, config).await;
match &result {
Ok(r) if r.success => {
@@ -101,7 +97,7 @@ impl StreamCheckService {
}
Ok(r) => {
// 失败但非异常,判断是否重试
if Self::should_retry(&r.message) && attempt < effective_config.max_retries {
if Self::should_retry(&r.message) && attempt < config.max_retries {
last_result = Some(r.clone());
continue;
}
@@ -111,8 +107,7 @@ impl StreamCheckService {
});
}
Err(e) => {
if Self::should_retry(&e.to_string()) && attempt < effective_config.max_retries
{
if Self::should_retry(&e.to_string()) && attempt < config.max_retries {
continue;
}
return Err(AppError::Message(e.to_string()));
@@ -128,51 +123,10 @@ impl StreamCheckService {
http_status: None,
model_used: String::new(),
tested_at: chrono::Utc::now().timestamp(),
retry_count: effective_config.max_retries,
retry_count: 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(
app_type: &AppType,
@@ -190,9 +144,8 @@ impl StreamCheckService {
.extract_auth(provider)
.ok_or_else(|| AppError::Message("API Key not found".to_string()))?;
// 获取 HTTP 客户端:优先使用供应商单独代理配置,否则使用全局客户端
let proxy_config = provider.meta.as_ref().and_then(|m| m.proxy_config.as_ref());
let client = crate::proxy::http_client::get_for_provider(proxy_config);
// 使用全局 HTTP 客户端(已包含代理配置)
let client = crate::proxy::http_client::get();
let request_timeout = std::time::Duration::from_secs(config.timeout_secs);
let model_to_test = Self::resolve_test_model(app_type, provider, config);
@@ -232,14 +185,6 @@ impl StreamCheckService {
)
.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",
));
}
};
let response_time = start.elapsed().as_millis() as u64;
@@ -436,8 +381,6 @@ impl StreamCheckService {
}
/// Gemini 流式检查
///
/// 使用 Gemini 原生 API 格式 (streamGenerateContent)
async fn check_gemini_stream(
client: &Client,
base_url: &str,
@@ -447,28 +390,20 @@ impl StreamCheckService {
timeout: std::time::Duration,
) -> Result<(u16, String), AppError> {
let base = base_url.trim_end_matches('/');
// 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")
};
let url = format!("{base}/v1/chat/completions");
// Gemini 原生请求体格式
let body = json!({
"contents": [{
"role": "user",
"parts": [{ "text": test_prompt }]
}]
"model": model,
"messages": [{ "role": "user", "content": test_prompt }],
"max_tokens": 1,
"temperature": 0,
"stream": true
});
let response = client
.post(&url)
.header("x-goog-api-key", &auth.api_key)
.header("Authorization", format!("Bearer {}", auth.api_key))
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream")
.timeout(timeout)
.json(&body)
.send()
@@ -542,24 +477,9 @@ impl StreamCheckService {
}
AppType::Gemini => Self::extract_env_model(provider, "GEMINI_MODEL")
.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> {
provider
.settings_config
+4 -70
View File
@@ -16,47 +16,6 @@ pub struct CustomEndpoint {
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`,不随数据库同步。
@@ -81,10 +40,6 @@ pub struct AppSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
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")]
pub claude_config_dir: Option<String>,
@@ -92,8 +47,6 @@ pub struct AppSettings {
pub codex_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gemini_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opencode_config_dir: Option<String>,
// ===== 当前供应商 ID(设备级)=====
/// 当前 Claude 供应商 ID(本地存储,优先于数据库 is_current
@@ -105,9 +58,6 @@ pub struct AppSettings {
/// 当前 Gemini 供应商 ID(本地存储,优先于数据库 is_current
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_provider_gemini: Option<String>,
/// 当前 OpenCode 供应商 ID(本地存储,对 OpenCode 可能无意义,但保持结构一致)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_provider_opencode: Option<String>,
}
fn default_show_in_tray() -> bool {
@@ -118,6 +68,10 @@ fn default_minimize_to_tray_on_close() -> bool {
true
}
fn default_true() -> bool {
true
}
impl Default for AppSettings {
fn default() -> Self {
Self {
@@ -127,15 +81,12 @@ impl Default for AppSettings {
skip_claude_onboarding: true,
launch_on_startup: false,
language: None,
visible_apps: None,
claude_config_dir: None,
codex_config_dir: None,
gemini_config_dir: None,
opencode_config_dir: None,
current_provider_claude: None,
current_provider_codex: None,
current_provider_gemini: None,
current_provider_opencode: None,
}
}
}
@@ -168,13 +119,6 @@ impl AppSettings {
.filter(|s| !s.is_empty())
.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
.language
.as_ref()
@@ -307,14 +251,6 @@ pub fn get_gemini_override_dir() -> Option<PathBuf> {
.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 读取)
@@ -327,7 +263,6 @@ pub fn get_current_provider(app_type: &AppType) -> Option<String> {
AppType::Claude => settings.current_provider_claude.clone(),
AppType::Codex => settings.current_provider_codex.clone(),
AppType::Gemini => settings.current_provider_gemini.clone(),
AppType::OpenCode => settings.current_provider_opencode.clone(),
}
}
@@ -342,7 +277,6 @@ 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::Codex => settings.current_provider_codex = 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)
+42 -199
View File
@@ -15,7 +15,6 @@ pub struct TrayTexts {
pub show_main: &'static str,
pub no_provider_hint: &'static str,
pub quit: &'static str,
pub auto_label: &'static str,
}
impl TrayTexts {
@@ -25,20 +24,17 @@ impl TrayTexts {
show_main: "Open main window",
no_provider_hint: " (No providers yet, please add them from the main window)",
quit: "Quit",
auto_label: "Auto (Failover)",
},
"ja" => Self {
show_main: "メインウィンドウを開く",
no_provider_hint:
" (プロバイダーがまだありません。メイン画面から追加してください)",
quit: "終了",
auto_label: "自動 (フェイルオーバー)",
},
_ => Self {
show_main: "打开主界面",
no_provider_hint: " (无供应商,请在主界面添加)",
quit: "退出",
auto_label: "自动 (故障转移)",
},
}
}
@@ -54,9 +50,6 @@ pub struct TrayAppSection {
pub log_name: &'static str,
}
/// Auto 菜单项后缀
pub const AUTO_SUFFIX: &str = "auto";
pub const TRAY_SECTIONS: [TrayAppSection; 3] = [
TrayAppSection {
app_type: AppType::Claude,
@@ -91,7 +84,6 @@ fn append_provider_section<'a>(
manager: Option<&crate::provider::ProviderManager>,
section: &TrayAppSection,
tray_texts: &TrayTexts,
app_state: &AppState,
) -> Result<MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>, AppError> {
let Some(manager) = manager else {
return Ok(menu_builder);
@@ -119,23 +111,6 @@ fn append_provider_section<'a>(
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();
sorted_providers.sort_by(|(_, a), (_, b)| {
match (a.sort_index, b.sort_index) {
@@ -156,8 +131,7 @@ fn append_provider_section<'a>(
});
for (id, provider) in sorted_providers {
// Auto 模式下所有供应商都不选中
let is_current = !auto_mode && manager.current == *id;
let is_current = manager.current == *id;
let item = CheckMenuItem::with_id(
app,
format!("{}{}", section.prefix, id),
@@ -176,27 +150,13 @@ fn append_provider_section<'a>(
/// 处理供应商托盘事件
pub fn handle_provider_tray_event(app: &tauri::AppHandle, event_id: &str) -> bool {
for section in TRAY_SECTIONS.iter() {
if let Some(suffix) = event_id.strip_prefix(section.prefix) {
// 处理 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);
if let Some(provider_id) = event_id.strip_prefix(section.prefix) {
log::info!("切换到{}供应商: {provider_id}", section.log_name);
let app_handle = app.clone();
let provider_id = suffix.to_string();
let provider_id = provider_id.to_string();
let app_type = section.app_type.clone();
tauri::async_runtime::spawn_blocking(move || {
if let Err(e) = handle_provider_click(&app_handle, &app_type, &provider_id) {
if let Err(e) = switch_provider_internal(&app_handle, app_type, provider_id) {
log::error!("切换{}供应商失败: {e}", section.log_name);
}
});
@@ -206,143 +166,6 @@ pub fn handle_provider_tray_event(app: &tauri::AppHandle, event_id: &str) -> boo
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, &current_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(
app: &tauri::AppHandle,
@@ -351,9 +174,6 @@ pub fn create_tray_menu(
let app_settings = crate::settings::get_settings();
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);
// 顶部:打开主界面
@@ -363,13 +183,7 @@ pub fn create_tray_menu(
menu_builder = menu_builder.item(&show_main_item).separator();
// 直接添加所有供应商到主菜单(扁平化结构,更简单可靠)
// Only add visible app sections
for section in TRAY_SECTIONS.iter() {
// Skip hidden apps
if !visible_apps.is_visible(&section.app_type) {
continue;
}
let app_type_str = section.app_type.as_str();
let providers = app_state.db.get_all_providers(app_type_str)?;
@@ -383,14 +197,8 @@ pub fn create_tray_menu(
current: current_id,
};
menu_builder = append_provider_section(
app,
menu_builder,
Some(&manager),
section,
&tray_texts,
app_state,
)?;
menu_builder =
append_provider_section(app, menu_builder, Some(&manager), section, &tray_texts)?;
}
// 分隔符和退出菜单
@@ -455,3 +263,38 @@ 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 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch",
"version": "3.10.0",
"version": "3.9.1",
"identifier": "com.ccswitch.desktop",
"build": {
"frontendDist": "../dist",
-2
View File
@@ -553,7 +553,6 @@ command = "echo"
claude: false,
codex: false, // 初始未启用
gemini: false,
opencode: false,
},
description: None,
homepage: None,
@@ -681,7 +680,6 @@ fn import_from_claude_merges_into_config() {
claude: false, // 初始未启用
codex: false,
gemini: false,
opencode: false,
},
description: None,
homepage: None,
-6
View File
@@ -214,7 +214,6 @@ fn set_mcp_enabled_for_codex_writes_live_config() {
claude: false,
codex: false, // 初始未启用
gemini: false,
opencode: false,
},
description: None,
homepage: None,
@@ -278,7 +277,6 @@ fn enabling_codex_mcp_skips_when_codex_dir_missing() {
claude: false,
codex: false,
gemini: false,
opencode: false,
},
description: None,
homepage: None,
@@ -322,7 +320,6 @@ fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() {
claude: true,
codex: false,
gemini: false,
opencode: false,
},
description: None,
homepage: None,
@@ -355,7 +352,6 @@ fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() {
claude: false,
codex: false,
gemini: false,
opencode: false,
},
description: None,
homepage: None,
@@ -487,7 +483,6 @@ fn enabling_gemini_mcp_skips_when_gemini_dir_missing() {
claude: false,
codex: false,
gemini: false,
opencode: false,
},
description: None,
homepage: None,
@@ -541,7 +536,6 @@ fn enabling_claude_mcp_skips_when_claude_config_absent() {
claude: false,
codex: false,
gemini: false,
opencode: false,
},
description: None,
homepage: None,
-1
View File
@@ -74,7 +74,6 @@ command = "say"
claude: false,
codex: true, // 启用 Codex
gemini: false,
opencode: false,
},
description: None,
homepage: None,
-1
View File
@@ -88,7 +88,6 @@ command = "say"
claude: false,
codex: true,
gemini: false,
opencode: false,
},
description: None,
homepage: None,
+32 -194
View File
@@ -11,14 +11,14 @@ import {
// Bot, // TODO: Agents 功能开发中,暂时不需要
Book,
Wrench,
Server,
RefreshCw,
Search,
Download,
BarChart2,
} from "lucide-react";
import type { Provider, VisibleApps } from "@/types";
import type { Provider } from "@/types";
import type { EnvConflict } from "@/types/env";
import { useProvidersQuery, useSettingsQuery } from "@/lib/query";
import { useProvidersQuery } from "@/lib/query";
import {
providersApi,
settingsApi,
@@ -30,7 +30,6 @@ import { useProviderActions } from "@/hooks/useProviderActions";
import { useProxyStatus } from "@/hooks/useProxyStatus";
import { useLastValidValue } from "@/hooks/useLastValidValue";
import { extractErrorMessage } from "@/utils/errorUtils";
import { isTextEditableTarget } from "@/utils/domUtils";
import { cn } from "@/lib/utils";
import { isWindows, isLinux } from "@/lib/platform";
import { AppSwitcher } from "@/components/AppSwitcher";
@@ -42,7 +41,6 @@ import { SettingsPage } from "@/components/settings/SettingsPage";
import { UpdateBadge } from "@/components/UpdateBadge";
import { EnvWarningBanner } from "@/components/env/EnvWarningBanner";
import { ProxyToggle } from "@/components/proxy/ProxyToggle";
import { FailoverToggle } from "@/components/proxy/FailoverToggle";
import UsageScriptModal from "@/components/UsageScriptModal";
import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel";
import PromptPanel from "@/components/prompts/PromptPanel";
@@ -51,7 +49,6 @@ import UnifiedSkillsPanel from "@/components/skills/UnifiedSkillsPanel";
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
import { AgentsPanel } from "@/components/agents/AgentsPanel";
import { UniversalProviderPanel } from "@/components/universal";
import { McpIcon } from "@/components/BrandIcons";
import { Button } from "@/components/ui/button";
type View =
@@ -78,38 +75,9 @@ function App() {
const [settingsDefaultTab, setSettingsDefaultTab] = useState("general");
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 [usageProvider, setUsageProvider] = 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 [confirmDelete, setConfirmDelete] = useState<Provider | null>(null);
const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]);
const [showEnvBanner, setShowEnvBanner] = useState(false);
@@ -319,40 +287,18 @@ function App() {
checkEnvOnSwitch();
}, [activeApp]);
// 全局键盘快捷键
const currentViewRef = useRef(currentView);
useEffect(() => {
currentViewRef.current = currentView;
}, [currentView]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Cmd/Ctrl + , 打开设置
if (event.key === "," && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
setCurrentView("settings");
const handleGlobalShortcut = (event: KeyboardEvent) => {
if (event.key !== "," || !(event.metaKey || event.ctrlKey)) {
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();
setCurrentView(view === "skillsDiscovery" ? "skills" : "providers");
setCurrentView("settings");
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keydown", handleGlobalShortcut);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keydown", handleGlobalShortcut);
};
}, []);
@@ -376,49 +322,11 @@ function App() {
setEditingProvider(null);
};
// 确认删除/移除供应商
const handleConfirmAction = async () => {
if (!confirmAction) return;
const { provider, action } = confirmAction;
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}`;
// 确认删除供应商
const handleConfirmDelete = async () => {
if (!confirmDelete) return;
await deleteProvider(confirmDelete.id);
setConfirmDelete(null);
};
// 复制供应商
@@ -427,9 +335,7 @@ function App() {
const newSortIndex =
provider.sortIndex !== undefined ? provider.sortIndex + 1 : undefined;
const duplicatedProvider: Omit<Provider, "id" | "createdAt"> & {
providerKey?: string;
} = {
const duplicatedProvider: Omit<Provider, "id" | "createdAt"> = {
name: `${provider.name} copy`,
settingsConfig: JSON.parse(JSON.stringify(provider.settingsConfig)), // 深拷贝
websiteUrl: provider.websiteUrl,
@@ -442,15 +348,6 @@ function App() {
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
if (provider.sortIndex !== undefined) {
const updates = Object.values(providers)
@@ -557,12 +454,7 @@ function App() {
/>
);
case "skillsDiscovery":
return (
<SkillsPage
ref={skillsPageRef}
initialApp={activeApp === "opencode" ? "claude" : activeApp}
/>
);
return <SkillsPage ref={skillsPageRef} initialApp={activeApp} />;
case "mcp":
return (
<UnifiedMcpPanel
@@ -576,13 +468,13 @@ function App() {
);
case "universal":
return (
<div className="px-6 pt-4">
<div className="mx-auto max-w-[56rem] px-5 pt-4">
<UniversalProviderPanel />
</div>
);
default:
return (
<div className="px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
<div className="mx-auto max-w-[56rem] px-5 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
{/* 独立滚动容器 - 解决 Linux/Ubuntu 下 DndContext 与滚轮事件冲突 */}
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-12 px-1">
<AnimatePresence mode="wait">
@@ -606,15 +498,7 @@ function App() {
activeProviderId={activeProviderId}
onSwitch={switchProvider}
onEdit={setEditingProvider}
onDelete={(provider) =>
setConfirmAction({ provider, action: "delete" })
}
onRemoveFromConfig={
activeApp === "opencode"
? (provider) =>
setConfirmAction({ provider, action: "remove" })
: undefined
}
onDelete={setConfirmDelete}
onDuplicate={handleDuplicateProvider}
onConfigureUsage={setUsageProvider}
onOpenWebsite={handleOpenWebsite}
@@ -696,7 +580,7 @@ function App() {
}
>
<div
className="mx-auto flex h-full max-w-[56rem] items-center justify-between gap-2 px-4"
className="mx-auto flex h-full max-w-[56rem] flex-wrap items-center justify-between gap-2 px-6"
data-tauri-drag-region
style={{ WebkitAppRegion: "drag" } as any}
>
@@ -770,28 +654,12 @@ function App() {
>
<Settings className="w-4 h-4" />
</Button>
{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
className="flex items-center gap-1.5 h-[32px]"
className="flex items-center gap-2 h-[32px]"
style={{ WebkitAppRegion: "no-drag" } as any}
>
{currentView === "prompts" && (
@@ -873,31 +741,9 @@ function App() {
)}
{currentView === "providers" && (
<>
{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>
</>
)}
<ProxyToggle activeApp={activeApp} />
<AppSwitcher
activeApp={activeApp}
onSwitch={setActiveApp}
visibleApps={visibleApps}
compact={
isCurrentAppTakeoverActive &&
Object.values(visibleApps).filter(Boolean).length >= 3
}
/>
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
<div className="flex items-center gap-1 p-1 bg-muted rounded-xl">
<Button
@@ -943,7 +789,7 @@ function App() {
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("mcp.title")}
>
<McpIcon size={16} />
<Server className="w-4 h-4" />
</Button>
</div>
@@ -1000,25 +846,17 @@ function App() {
)}
<ConfirmDialog
isOpen={Boolean(confirmAction)}
title={
confirmAction?.action === "remove"
? t("confirm.removeProvider")
: t("confirm.deleteProvider")
}
isOpen={Boolean(confirmDelete)}
title={t("confirm.deleteProvider")}
message={
confirmAction
? confirmAction.action === "remove"
? t("confirm.removeProviderMessage", {
name: confirmAction.provider.name,
})
: t("confirm.deleteProviderMessage", {
name: confirmAction.provider.name,
})
confirmDelete
? t("confirm.deleteProviderMessage", {
name: confirmDelete.name,
})
: ""
}
onConfirm={() => void handleConfirmAction()}
onCancel={() => setConfirmAction(null)}
onConfirm={() => void handleConfirmDelete()}
onCancel={() => setConfirmDelete(null)}
/>
<DeepLinkImportDialog />
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

+66 -38
View File
@@ -1,22 +1,12 @@
import type { AppId } from "@/lib/api";
import type { VisibleApps } from "@/types";
import { ProviderIcon } from "@/components/ProviderIcon";
interface AppSwitcherProps {
activeApp: AppId;
onSwitch: (app: AppId) => void;
visibleApps?: VisibleApps;
compact?: boolean;
}
const ALL_APPS: AppId[] = ["claude", "codex", "gemini", "opencode"];
export function AppSwitcher({
activeApp,
onSwitch,
visibleApps,
compact,
}: AppSwitcherProps) {
export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
const handleSwitch = (app: AppId) => {
if (app === activeApp) return;
onSwitch(app);
@@ -26,42 +16,80 @@ export function AppSwitcher({
claude: "claude",
codex: "openai",
gemini: "gemini",
opencode: "opencode",
};
const appDisplayName: Record<AppId, string> = {
claude: "Claude",
codex: "Codex",
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 (
<div className="inline-flex bg-muted rounded-xl p-1 gap-1">
{appsToShow.map((app) => (
<button
key={app}
type="button"
onClick={() => handleSwitch(app)}
className={`group inline-flex items-center gap-2 px-3 h-8 rounded-md text-sm font-medium transition-all duration-200 ${
activeApp === app
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
}`}
>
<ProviderIcon
icon={appIconName[app]}
name={appDisplayName[app]}
size={iconSize}
/>
{!compact && <span>{appDisplayName[app]}</span>}
</button>
))}
<button
type="button"
onClick={() => handleSwitch("claude")}
className={`group inline-flex items-center gap-2 px-3 h-8 rounded-md text-sm font-medium transition-all duration-200 ${
activeApp === "claude"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
}`}
>
<ProviderIcon
icon={appIconName.claude}
name={appDisplayName.claude}
size={iconSize}
className={
activeApp === "claude"
? "text-foreground"
: "text-muted-foreground group-hover:text-foreground transition-colors"
}
/>
<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>
);
}
-18
View File
@@ -46,21 +46,3 @@ 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>
);
}
+2 -15
View File
@@ -1,5 +1,5 @@
import React, { useMemo } from "react";
import { getIcon, hasIcon, getIconMetadata } from "@/icons/extracted";
import { getIcon, hasIcon } from "@/icons/extracted";
import { cn } from "@/lib/utils";
interface ProviderIconProps {
@@ -39,19 +39,6 @@ export const ProviderIcon: React.FC<ProviderIconProps> = ({
};
}, [size]);
// 获取有效颜色:优先使用传入的 color,否则从元数据获取 defaultColor
const effectiveColor = useMemo(() => {
if (color) return color;
if (icon) {
const metadata = getIconMetadata(icon);
// 只有当 defaultColor 不是 currentColor 时才使用
if (metadata?.defaultColor && metadata.defaultColor !== "currentColor") {
return metadata.defaultColor;
}
}
return undefined;
}, [color, icon]);
// 如果有图标,显示图标
if (iconSvg) {
return (
@@ -60,7 +47,7 @@ export const ProviderIcon: React.FC<ProviderIconProps> = ({
"inline-flex items-center justify-center flex-shrink-0",
className,
)}
style={{ ...sizeStyle, color: effectiveColor }}
style={{ ...sizeStyle, color }}
dangerouslySetInnerHTML={{ __html: iconSvg }}
/>
);
+1 -5
View File
@@ -11,7 +11,6 @@ interface UsageFooterProps {
appId: AppId;
usageEnabled: boolean; // 是否启用了用量查询
isCurrent: boolean; // 是否为当前激活的供应商
isInConfig?: boolean; // OpenCode: 是否已添加到配置
inline?: boolean; // 是否内联显示(在按钮左侧)
}
@@ -21,15 +20,12 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
appId,
usageEnabled,
isCurrent,
isInConfig = false,
inline = false,
}) => {
const { t } = useTranslation();
// 统一的用量查询(自动查询仅对当前激活的供应商启用)
// OpenCode(累加模式):使用 isInConfig 代替 isCurrent
const shouldAutoQuery = appId === "opencode" ? isInConfig : isCurrent;
const autoQueryInterval = shouldAutoQuery
const autoQueryInterval = isCurrent
? provider.meta?.usage_script?.autoQueryInterval || 0
: 0;
+5 -37
View File
@@ -4,7 +4,6 @@ import { motion, AnimatePresence } from "framer-motion";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { isWindows, isLinux } from "@/lib/platform";
import { isTextEditableTarget } from "@/utils/domUtils";
interface FullScreenPanelProps {
isOpen: boolean;
@@ -38,39 +37,6 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
};
}, [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(
<AnimatePresence>
{isOpen && (
@@ -106,7 +72,7 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
}
>
<div
className="px-6 w-full flex items-center gap-4"
className="mx-auto max-w-[56rem] px-6 w-full flex items-center gap-4"
data-tauri-drag-region
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
>
@@ -128,7 +94,9 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
{/* Content */}
<div className="flex-1 overflow-y-auto scroll-overlay">
<div className="px-6 py-6 space-y-6 w-full">{children}</div>
<div className="mx-auto max-w-[56rem] px-6 py-6 space-y-6 w-full">
{children}
</div>
</div>
{/* Footer */}
@@ -137,7 +105,7 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
className="flex-shrink-0 py-4 border-t border-border-default"
style={{ backgroundColor: "hsl(var(--background))" }}
>
<div className="px-6 flex items-center justify-end gap-3">
<div className="mx-auto max-w-[56rem] px-6 flex items-center justify-end gap-3">
{footer}
</div>
</div>
-2
View File
@@ -65,7 +65,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
claude: boolean;
codex: boolean;
gemini: boolean;
opencode: boolean;
}>(() => {
if (initialData?.apps) {
return { ...initialData.apps };
@@ -74,7 +73,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
claude: defaultEnabledApps.includes("claude"),
codex: defaultEnabledApps.includes("codex"),
gemini: defaultEnabledApps.includes("gemini"),
opencode: defaultEnabledApps.includes("opencode"),
};
});
+3 -21
View File
@@ -59,12 +59,11 @@ const UnifiedMcpPanel = React.forwardRef<
// Count enabled servers per app
const enabledCounts = useMemo(() => {
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0 };
const counts = { claude: 0, codex: 0, gemini: 0 };
serverEntries.forEach(([_, server]) => {
if (server.apps.claude) counts.claude++;
if (server.apps.codex) counts.codex++;
if (server.apps.gemini) counts.gemini++;
if (server.apps.opencode) counts.opencode++;
});
return counts;
}, [serverEntries]);
@@ -142,15 +141,14 @@ const UnifiedMcpPanel = React.forwardRef<
};
return (
<div className="px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
<div className="mx-auto max-w-[56rem] px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
{/* Info Section */}
<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">
{t("mcp.serverCount", { count: serverEntries.length })} ·{" "}
{t("mcp.unifiedPanel.apps.claude")}: {enabledCounts.claude} ·{" "}
{t("mcp.unifiedPanel.apps.codex")}: {enabledCounts.codex} ·{" "}
{t("mcp.unifiedPanel.apps.gemini")}: {enabledCounts.gemini} ·{" "}
{t("mcp.unifiedPanel.apps.opencode")}: {enabledCounts.opencode}
{t("mcp.unifiedPanel.apps.gemini")}: {enabledCounts.gemini}
</div>
</div>
@@ -339,22 +337,6 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
}
/>
</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>
{/* 右侧:操作按钮 */}
@@ -34,7 +34,6 @@ const PromptFormModal: React.FC<PromptFormModalProps> = ({
claude: "CLAUDE.md",
codex: "AGENTS.md",
gemini: "GEMINI.md",
opencode: "AGENTS.md",
};
const filename = filenameMap[appId];
const [name, setName] = useState("");
@@ -28,7 +28,6 @@ const PromptFormPanel: React.FC<PromptFormPanelProps> = ({
claude: "CLAUDE.md",
codex: "AGENTS.md",
gemini: "GEMINI.md",
opencode: "AGENTS.md",
};
const filename = filenameMap[appId];
const [name, setName] = useState("");
+1 -1
View File
@@ -96,7 +96,7 @@ const PromptPanel = React.forwardRef<PromptPanelHandle, PromptPanelProps>(
const enabledPrompt = promptEntries.find(([_, p]) => p.enabled);
return (
<div className="flex flex-col h-[calc(100vh-8rem)] px-6">
<div className="mx-auto max-w-[56rem] 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="text-sm text-muted-foreground">
{t("prompts.count", { count: promptEntries.length })} ·{" "}
+34 -66
View File
@@ -17,16 +17,13 @@ import { UniversalProviderPanel } from "@/components/universal";
import { providerPresets } from "@/config/claudeProviderPresets";
import { codexProviderPresets } from "@/config/codexProviderPresets";
import { geminiProviderPresets } from "@/config/geminiProviderPresets";
// Note: opencodeProviderPresets is loaded via ProviderForm, not needed here
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
interface AddProviderDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
appId: AppId;
onSubmit: (
provider: Omit<Provider, "id"> & { providerKey?: string },
) => Promise<void> | void;
onSubmit: (provider: Omit<Provider, "id">) => Promise<void> | void;
}
export function AddProviderDialog({
@@ -36,8 +33,6 @@ export function AddProviderDialog({
onSubmit,
}: AddProviderDialogProps) {
const { t } = useTranslation();
// OpenCode doesn't support universal providers
const showUniversalTab = appId !== "opencode";
const [activeTab, setActiveTab] = useState<"app-specific" | "universal">(
"app-specific",
);
@@ -87,7 +82,7 @@ export function AddProviderDialog({
>;
// 构造基础提交数据
const providerData: Omit<Provider, "id"> & { providerKey?: string } = {
const providerData: Omit<Provider, "id"> = {
name: values.name.trim(),
notes: values.notes?.trim() || undefined,
websiteUrl: values.websiteUrl?.trim() || undefined,
@@ -98,11 +93,6 @@ export function AddProviderDialog({
...(values.meta ? { meta: values.meta } : {}),
};
// OpenCode: pass providerKey for ID generation
if (appId === "opencode" && values.providerKey) {
providerData.providerKey = values.providerKey;
}
const hasCustomEndpoints =
providerData.meta?.custom_endpoints &&
Object.keys(providerData.meta.custom_endpoints).length > 0;
@@ -163,7 +153,6 @@ export function AddProviderDialog({
}
}
}
// Note: OpenCode doesn't use endpointCandidates - it handles endpoints internally
}
if (appId === "claude") {
@@ -186,14 +175,6 @@ export function AddProviderDialog({
if (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);
@@ -223,7 +204,7 @@ export function AddProviderDialog({
// 动态 footer:根据当前 Tab 显示不同按钮
const footer =
!showUniversalTab || activeTab === "app-specific" ? (
activeTab === "app-specific" ? (
<>
<Button
variant="outline"
@@ -267,54 +248,41 @@ export function AddProviderDialog({
onClose={() => onOpenChange(false)}
footer={footer}
>
{showUniversalTab ? (
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as "app-specific" | "universal")}
>
<TabsList className="grid w-full grid-cols-2 mb-6">
<TabsTrigger value="app-specific">
{t(`apps.${appId}`)} {t("provider.tabProvider")}
</TabsTrigger>
<TabsTrigger value="universal">
{t("provider.tabUniversal")}
</TabsTrigger>
</TabsList>
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as "app-specific" | "universal")}
>
<TabsList className="grid w-full grid-cols-2 mb-6">
<TabsTrigger value="app-specific">
{t(`apps.${appId}`)} {t("provider.tabProvider")}
</TabsTrigger>
<TabsTrigger value="universal">
{t("provider.tabUniversal")}
</TabsTrigger>
</TabsList>
<TabsContent value="app-specific" className="mt-0">
<ProviderForm
appId={appId}
submitLabel={t("common.add")}
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
showButtons={false}
/>
</TabsContent>
<TabsContent value="app-specific" className="mt-0">
<ProviderForm
appId={appId}
submitLabel={t("common.add")}
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
showButtons={false}
/>
</TabsContent>
<TabsContent value="universal" className="mt-0">
<UniversalProviderPanel />
</TabsContent>
</Tabs>
) : (
// OpenCode: directly show form without tabs
<ProviderForm
appId={appId}
submitLabel={t("common.add")}
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
showButtons={false}
/>
)}
<TabsContent value="universal" className="mt-0">
<UniversalProviderPanel />
</TabsContent>
</Tabs>
{/* Universal Provider Form Modal */}
{showUniversalTab && (
<UniversalProviderFormModal
isOpen={universalFormOpen}
onClose={handleUniversalFormClose}
onSave={handleUniversalProviderSave}
initialPreset={selectedUniversalPreset}
/>
)}
<UniversalProviderFormModal
isOpen={universalFormOpen}
onClose={handleUniversalFormClose}
onSave={handleUniversalProviderSave}
initialPreset={selectedUniversalPreset}
/>
</FullScreenPanel>
);
}
@@ -62,17 +62,6 @@ export function EditProviderDialog({
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 {
const currentId = await providersApi.getCurrent(appId);
if (currentId && provider.id === currentId) {
@@ -128,10 +117,9 @@ export function EditProviderDialog({
iconColor: provider.iconColor,
};
}, [
open, // 修复:编辑保存后再次打开显示旧数据,依赖 open 确保每次打开时重新读取最新 provider 数据
provider?.id, // 只依赖 ID,provider 对象更新不会触发重新计算
provider?.meta, // 需要依赖 meta 以便正确初始化 testConfig 和 proxyConfig
initialSettingsConfig,
// 注意:不依赖 provider 的其他字段,防止表单重置
]);
const handleSubmit = useCallback(
+7 -58
View File
@@ -4,7 +4,6 @@ import {
Copy,
Edit,
Loader2,
Minus,
Play,
Plus,
Terminal,
@@ -14,13 +13,9 @@ import {
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { AppId } from "@/lib/api";
interface ProviderActionsProps {
appId?: AppId;
isCurrent: boolean;
/** OpenCode: 是否已添加到配置 */
isInConfig?: boolean;
isTesting?: boolean;
isProxyTakeover?: boolean;
onSwitch: () => void;
@@ -29,8 +24,6 @@ interface ProviderActionsProps {
onTest?: () => void;
onConfigureUsage: () => void;
onDelete: () => void;
/** OpenCode: remove from live config (not delete from database) */
onRemoveFromConfig?: () => void;
onOpenTerminal?: () => void;
// 故障转移相关
isAutoFailoverEnabled?: boolean;
@@ -39,9 +32,7 @@ interface ProviderActionsProps {
}
export function ProviderActions({
appId,
isCurrent,
isInConfig = false,
isTesting,
isProxyTakeover = false,
onSwitch,
@@ -50,7 +41,6 @@ export function ProviderActions({
onTest,
onConfigureUsage,
onDelete,
onRemoveFromConfig,
onOpenTerminal,
// 故障转移相关
isAutoFailoverEnabled = false,
@@ -60,28 +50,12 @@ export function ProviderActions({
const { t } = useTranslation();
const iconButtonClass = "h-8 w-8 p-1";
// OpenCode 使用累加模式
const isOpenCodeMode = appId === "opencode";
// 故障转移模式下的按钮逻辑(OpenCode 不支持故障转移)
const isFailoverMode =
!isOpenCodeMode && isAutoFailoverEnabled && onToggleFailover;
// 故障转移模式下的按钮逻辑
const isFailoverMode = isAutoFailoverEnabled && onToggleFailover;
// 处理主按钮点击
const handleMainButtonClick = () => {
if (isOpenCodeMode) {
// OpenCode 模式:切换配置状态(添加/移除)
if (isInConfig) {
// Use onRemoveFromConfig if available, otherwise fall back to onDelete
if (onRemoveFromConfig) {
onRemoveFromConfig();
} else {
onDelete();
}
} else {
onSwitch(); // 添加到配置
}
} else if (isFailoverMode) {
if (isFailoverMode) {
// 故障转移模式:切换队列状态
onToggleFailover(!isInFailoverQueue);
} else {
@@ -92,30 +66,8 @@ export function ProviderActions({
// 主按钮的状态和样式
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 (isInFailoverQueue) {
return {
disabled: false,
@@ -161,9 +113,6 @@ export function ProviderActions({
const buttonState = getMainButtonState();
// OpenCode 模式下删除按钮始终可用(主按钮"移除"是从 live 配置移除,删除是从数据库删除)
const canDelete = isOpenCodeMode ? true : !isCurrent;
return (
<div className="flex items-center gap-1.5">
<Button
@@ -243,12 +192,12 @@ export function ProviderActions({
<Button
size="icon"
variant="ghost"
onClick={canDelete ? onDelete : undefined}
onClick={isCurrent ? undefined : onDelete}
title={t("common.delete")}
className={cn(
iconButtonClass,
canDelete && "hover:text-red-500 dark:hover:text-red-400",
!canDelete && "opacity-40 cursor-not-allowed text-muted-foreground",
!isCurrent && "hover:text-red-500 dark:hover:text-red-400",
isCurrent && "opacity-40 cursor-not-allowed text-muted-foreground",
)}
>
<Trash2 className="h-4 w-4" />
+4 -24
View File
@@ -26,12 +26,9 @@ interface ProviderCardProps {
provider: Provider;
isCurrent: boolean;
appId: AppId;
isInConfig?: boolean; // OpenCode: 是否已添加到 opencode.json
onSwitch: (provider: Provider) => void;
onEdit: (provider: Provider) => void;
onDelete: (provider: Provider) => void;
/** OpenCode: remove from live config (not delete from database) */
onRemoveFromConfig?: (provider: Provider) => void;
onConfigureUsage: (provider: Provider) => void;
onOpenWebsite: (url: string) => void;
onDuplicate: (provider: Provider) => void;
@@ -88,11 +85,9 @@ export function ProviderCard({
provider,
isCurrent,
appId,
isInConfig = true,
onSwitch,
onEdit,
onDelete,
onRemoveFromConfig,
onConfigureUsage,
onOpenWebsite,
onDuplicate,
@@ -139,9 +134,7 @@ export function ProviderCard({
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
// 获取用量数据以判断是否有多套餐
// OpenCode(累加模式):使用 isInConfig 代替 isCurrent
const shouldAutoQuery = appId === "opencode" ? isInConfig : isCurrent;
const autoQueryInterval = shouldAutoQuery
const autoQueryInterval = isCurrent
? provider.meta?.usage_script?.autoQueryInterval || 0
: 0;
@@ -189,16 +182,12 @@ export function ProviderCard({
};
// 判断是否是"当前使用中"的供应商
// - OpenCode(累加模式):不存在"当前"概念,始终返回 false
// - 故障转移模式:代理实际使用的供应商(activeProviderId
// - 代理接管模式(非故障转移):isCurrent
// - 普通模式:isCurrent
const isActiveProvider =
appId === "opencode"
? false
: isAutoFailoverEnabled
? activeProviderId === provider.id
: isCurrent;
const isActiveProvider = isAutoFailoverEnabled
? activeProviderId === provider.id
: isCurrent;
// 判断是否使用绿色(代理接管模式)还是蓝色(普通模式)
const shouldUseGreen = isProxyTakeover && isActiveProvider;
@@ -338,7 +327,6 @@ export function ProviderCard({
appId={appId}
usageEnabled={usageEnabled}
isCurrent={isCurrent}
isInConfig={isInConfig}
inline={true}
/>
)}
@@ -372,9 +360,7 @@ 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"
>
<ProviderActions
appId={appId}
isCurrent={isCurrent}
isInConfig={isInConfig}
isTesting={isTesting}
isProxyTakeover={isProxyTakeover}
onSwitch={() => onSwitch(provider)}
@@ -383,11 +369,6 @@ export function ProviderCard({
onTest={onTest ? () => onTest(provider) : undefined}
onConfigureUsage={() => onConfigureUsage(provider)}
onDelete={() => onDelete(provider)}
onRemoveFromConfig={
onRemoveFromConfig
? () => onRemoveFromConfig(provider)
: undefined
}
onOpenTerminal={
onOpenTerminal ? () => onOpenTerminal(provider) : undefined
}
@@ -409,7 +390,6 @@ export function ProviderCard({
appId={appId}
usageEnabled={usageEnabled}
isCurrent={isCurrent}
isInConfig={isInConfig}
inline={false}
/>
</div>
+2 -32
View File
@@ -15,10 +15,8 @@ import {
import { AnimatePresence, motion } from "framer-motion";
import { Search, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import type { Provider } from "@/types";
import type { AppId } from "@/lib/api";
import { providersApi } from "@/lib/api/providers";
import { useDragSort } from "@/hooks/useDragSort";
import { useStreamCheck } from "@/hooks/useStreamCheck";
import { ProviderCard } from "@/components/providers/ProviderCard";
@@ -40,8 +38,6 @@ interface ProviderListProps {
onSwitch: (provider: Provider) => void;
onEdit: (provider: Provider) => void;
onDelete: (provider: Provider) => void;
/** OpenCode: remove from live config (not delete from database) */
onRemoveFromConfig?: (provider: Provider) => void;
onDuplicate: (provider: Provider) => void;
onConfigureUsage?: (provider: Provider) => void;
onOpenWebsite: (url: string) => void;
@@ -60,7 +56,6 @@ export function ProviderList({
onSwitch,
onEdit,
onDelete,
onRemoveFromConfig,
onDuplicate,
onConfigureUsage,
onOpenWebsite,
@@ -77,22 +72,6 @@ export function ProviderList({
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);
@@ -220,16 +199,14 @@ export function ProviderList({
provider={provider}
isCurrent={provider.id === currentProviderId}
appId={appId}
isInConfig={isProviderInConfig(provider.id)}
onSwitch={onSwitch}
onEdit={onEdit}
onDelete={onDelete}
onRemoveFromConfig={onRemoveFromConfig}
onDuplicate={onDuplicate}
onConfigureUsage={onConfigureUsage}
onOpenWebsite={onOpenWebsite}
onOpenTerminal={onOpenTerminal}
onTest={appId !== "opencode" ? handleTest : undefined}
onTest={handleTest}
isTesting={isChecking(provider.id)}
isProxyRunning={isProxyRunning}
isProxyTakeover={isProxyTakeover}
@@ -331,17 +308,14 @@ interface SortableProviderCardProps {
provider: Provider;
isCurrent: boolean;
appId: AppId;
isInConfig: boolean;
onSwitch: (provider: Provider) => void;
onEdit: (provider: Provider) => void;
onDelete: (provider: Provider) => void;
/** OpenCode: remove from live config (not delete from database) */
onRemoveFromConfig?: (provider: Provider) => void;
onDuplicate: (provider: Provider) => void;
onConfigureUsage?: (provider: Provider) => void;
onOpenWebsite: (url: string) => void;
onOpenTerminal?: (provider: Provider) => void;
onTest?: (provider: Provider) => void;
onTest: (provider: Provider) => void;
isTesting: boolean;
isProxyRunning: boolean;
isProxyTakeover: boolean;
@@ -357,11 +331,9 @@ function SortableProviderCard({
provider,
isCurrent,
appId,
isInConfig,
onSwitch,
onEdit,
onDelete,
onRemoveFromConfig,
onDuplicate,
onConfigureUsage,
onOpenWebsite,
@@ -396,11 +368,9 @@ function SortableProviderCard({
provider={provider}
isCurrent={isCurrent}
appId={appId}
isInConfig={isInConfig}
onSwitch={onSwitch}
onEdit={onEdit}
onDelete={onDelete}
onRemoveFromConfig={onRemoveFromConfig}
onDuplicate={onDuplicate}
onConfigureUsage={
onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined
@@ -1,6 +1,5 @@
import { useTranslation } from "react-i18next";
import { useState } from "react";
import type { ReactNode } from "react";
import {
FormControl,
FormField,
@@ -25,14 +24,9 @@ import type { ProviderFormData } from "@/lib/schemas/provider";
interface BasicFormFieldsProps {
form: UseFormReturn<ProviderFormData>;
/** Slot to render content between icon and name fields */
beforeNameSlot?: ReactNode;
}
export function BasicFormFields({
form,
beforeNameSlot,
}: BasicFormFieldsProps) {
export function BasicFormFields({ form }: BasicFormFieldsProps) {
const { t } = useTranslation();
const [iconDialogOpen, setIconDialogOpen] = useState(false);
@@ -84,7 +78,7 @@ export function BasicFormFields({
>
<div className="flex h-full flex-col">
<div className="flex-shrink-0 py-4 border-b border-border-default bg-muted/40">
<div className="px-6 flex items-center gap-4">
<div className="mx-auto max-w-[56rem] px-6 flex items-center gap-4">
<DialogClose asChild>
<Button type="button" variant="outline" size="icon">
<ArrowLeft className="h-4 w-4" />
@@ -98,7 +92,7 @@ export function BasicFormFields({
</div>
</div>
<div className="flex-1 overflow-y-auto">
<div className="space-y-2 px-6 py-6 w-full">
<div className="space-y-2 mx-auto max-w-[56rem] px-6 py-6 w-full">
<IconPicker
value={currentIcon}
onValueChange={handleIconSelect}
@@ -118,9 +112,6 @@ export function BasicFormFields({
</Dialog>
</div>
{/* Slot for additional fields between icon and name */}
{beforeNameSlot}
{/* 基础信息 - 网格布局 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
@@ -9,12 +9,11 @@ import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import type { CustomEndpoint, EndpointCandidate } from "@/types";
// 端点测速超时配置(秒)
const ENDPOINT_TIMEOUT_SECS: Record<AppId, number> = {
const ENDPOINT_TIMEOUT_SECS = {
codex: 12,
claude: 8,
gemini: 8,
opencode: 8,
};
gemini: 8, // 新增 gemini
} as const;
interface TestResult {
url: string;
@@ -525,7 +524,7 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
<div className="space-y-1.5">
<div className="flex gap-2">
<Input
type="text"
type="url"
value={customUrl}
placeholder={t("endpointTest.addEndpointPlaceholder")}
onChange={(event) => setCustomUrl(event.target.value)}
@@ -1,664 +0,0 @@
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>
</>
);
}
@@ -1,455 +0,0 @@
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>
);
}
+16 -456
View File
@@ -5,15 +5,9 @@ import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
import type { AppId } from "@/lib/api";
import type {
ProviderCategory,
ProviderMeta,
ProviderTestConfig,
ProviderProxyConfig,
} from "@/types";
import type { ProviderCategory, ProviderMeta } from "@/types";
import {
providerPresets,
type ProviderPreset,
@@ -26,12 +20,6 @@ import {
geminiProviderPresets,
type GeminiProviderPreset,
} 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 { applyTemplateValues } from "@/utils/providerConfigUtils";
import { mergeProviderMeta } from "@/utils/providerMetaUtils";
@@ -39,14 +27,11 @@ import { getCodexCustomTemplate } from "@/config/codexTemplates";
import CodexConfigEditor from "./CodexConfigEditor";
import { CommonConfigEditor } from "./CommonConfigEditor";
import GeminiConfigEditor from "./GeminiConfigEditor";
import JsonEditor from "@/components/JsonEditor";
import { Label } from "@/components/ui/label";
import { ProviderPresetSelector } from "./ProviderPresetSelector";
import { BasicFormFields } from "./BasicFormFields";
import { ClaudeFormFields } from "./ClaudeFormFields";
import { CodexFormFields } from "./CodexFormFields";
import { GeminiFormFields } from "./GeminiFormFields";
import { ProviderAdvancedConfig } from "./ProviderAdvancedConfig";
import {
useProviderCategory,
useApiKeyState,
@@ -62,7 +47,6 @@ import {
useGeminiConfigState,
useGeminiCommonConfig,
} from "./hooks";
import { useProvidersQuery } from "@/lib/query/queries";
const CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {} }, null, 2);
const CODEX_DEFAULT_CONFIG = JSON.stringify({ auth: {}, config: "" }, null, 2);
@@ -78,26 +62,9 @@ const GEMINI_DEFAULT_CONFIG = JSON.stringify(
2,
);
const OPENCODE_DEFAULT_CONFIG = JSON.stringify(
{
npm: "@ai-sdk/openai-compatible",
options: {
baseURL: "",
apiKey: "",
},
models: {},
},
null,
2,
);
type PresetEntry = {
id: string;
preset:
| ProviderPreset
| CodexProviderPreset
| GeminiProviderPreset
| OpenCodeProviderPreset;
preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset;
};
interface ProviderFormProps {
@@ -161,14 +128,6 @@ export function ProviderForm({
() => 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
const { category } = useProviderCategory({
appId,
@@ -186,8 +145,6 @@ export function ProviderForm({
setDraftCustomEndpoints([]);
}
setEndpointAutoSelect(initialData?.meta?.endpointAutoSelect ?? true);
setTestConfig(initialData?.meta?.testConfig ?? { enabled: false });
setProxyConfig(initialData?.meta?.proxyConfig ?? { enabled: false });
}, [appId, initialData]);
const defaultValues: ProviderFormData = useMemo(
@@ -201,9 +158,7 @@ export function ProviderForm({
? CODEX_DEFAULT_CONFIG
: appId === "gemini"
? GEMINI_DEFAULT_CONFIG
: appId === "opencode"
? OPENCODE_DEFAULT_CONFIG
: CLAUDE_DEFAULT_CONFIG,
: CLAUDE_DEFAULT_CONFIG,
icon: initialData?.icon ?? "",
iconColor: initialData?.iconColor ?? "",
}),
@@ -216,7 +171,7 @@ export function ProviderForm({
mode: "onSubmit",
});
const settingsConfigValue = form.getValues("settingsConfig");
const settingsConfigValue = form.watch("settingsConfig");
// 使用 API Key hook
const {
@@ -224,7 +179,7 @@ export function ProviderForm({
handleApiKeyChange,
showApiKey: shouldShowApiKey,
} = useApiKeyState({
initialConfig: form.getValues("settingsConfig"),
initialConfig: form.watch("settingsConfig"),
onConfigChange: (config) => form.setValue("settingsConfig", config),
selectedPresetId,
category,
@@ -235,7 +190,7 @@ export function ProviderForm({
const { baseUrl, handleClaudeBaseUrlChange } = useBaseUrlState({
appType: appId,
category,
settingsConfig: form.getValues("settingsConfig"),
settingsConfig: form.watch("settingsConfig"),
codexConfig: "",
onSettingsConfigChange: (config) => form.setValue("settingsConfig", config),
onCodexConfigChange: () => {
@@ -252,7 +207,7 @@ export function ProviderForm({
defaultOpusModel,
handleModelChange,
} = useModelState({
settingsConfig: form.getValues("settingsConfig"),
settingsConfig: form.watch("settingsConfig"),
onConfigChange: (config) => form.setValue("settingsConfig", config),
});
@@ -373,11 +328,6 @@ export function ProviderForm({
id: `gemini-${index}`,
preset,
}));
} else if (appId === "opencode") {
return opencodeProviderPresets.map<PresetEntry>((preset, index) => ({
id: `opencode-${index}`,
preset,
}));
}
return providerPresets.map<PresetEntry>((preset, index) => ({
id: `claude-${index}`,
@@ -395,7 +345,7 @@ export function ProviderForm({
} = useTemplateValues({
selectedPresetId: appId === "claude" ? selectedPresetId : null,
presetEntries: appId === "claude" ? presetEntries : [],
settingsConfig: form.getValues("settingsConfig"),
settingsConfig: form.watch("settingsConfig"),
onConfigChange: (config) => form.setValue("settingsConfig", config),
});
@@ -409,11 +359,10 @@ export function ProviderForm({
isExtracting: isClaudeExtracting,
handleExtract: handleClaudeExtract,
} = useCommonConfigSnippet({
settingsConfig: form.getValues("settingsConfig"),
settingsConfig: form.watch("settingsConfig"),
onConfigChange: (config) => form.setValue("settingsConfig", config),
initialData: appId === "claude" ? initialData : undefined,
selectedPresetId: selectedPresetId ?? undefined,
enabled: appId === "claude",
});
// 使用 Codex 通用配置片段 hook (仅 Codex 模式)
@@ -459,7 +408,7 @@ export function ProviderForm({
originalHandleGeminiApiKeyChange(key);
// 同步更新 settingsConfig
try {
const config = JSON.parse(form.getValues("settingsConfig") || "{}");
const config = JSON.parse(form.watch("settingsConfig") || "{}");
if (!config.env) config.env = {};
config.env.GEMINI_API_KEY = key.trim();
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
@@ -475,7 +424,7 @@ export function ProviderForm({
originalHandleGeminiBaseUrlChange(url);
// 同步更新 settingsConfig
try {
const config = JSON.parse(form.getValues("settingsConfig") || "{}");
const config = JSON.parse(form.watch("settingsConfig") || "{}");
if (!config.env) config.env = {};
config.env.GOOGLE_GEMINI_BASE_URL = url.trim().replace(/\/+$/, "");
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
@@ -491,7 +440,7 @@ export function ProviderForm({
originalHandleGeminiModelChange(model);
// 同步更新 settingsConfig
try {
const config = JSON.parse(form.getValues("settingsConfig") || "{}");
const config = JSON.parse(form.watch("settingsConfig") || "{}");
if (!config.env) config.env = {};
config.env.GEMINI_MODEL = model.trim();
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
@@ -520,214 +469,6 @@ export function ProviderForm({
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 handleSubmit = (values: ProviderFormData) => {
@@ -755,23 +496,6 @@ export function ProviderForm({
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
if (category !== "official") {
if (appId === "claude") {
@@ -869,11 +593,6 @@ export function ProviderForm({
settingsConfig,
};
// OpenCode: pass provider key for ID generation
if (appId === "opencode") {
payload.providerKey = opencodeProviderKey;
}
if (activePreset) {
payload.presetId = activePreset.id;
if (activePreset.category) {
@@ -937,9 +656,6 @@ export function ProviderForm({
payload.meta = {
...(baseMeta ?? {}),
endpointAutoSelect,
// 添加高级配置
testConfig: testConfig.enabled ? testConfig : undefined,
proxyConfig: proxyConfig.enabled ? proxyConfig : undefined,
};
onSubmit(payload);
@@ -1032,15 +748,6 @@ export function ProviderForm({
if (appId === "gemini") {
resetGeminiConfig({}, {});
}
// OpenCode 自定义模式:重置为空配置
if (appId === "opencode") {
setOpencodeProviderKey("");
setOpencodeNpm("@ai-sdk/openai-compatible");
setOpencodeBaseUrl("");
setOpencodeApiKey("");
setOpencodeModels({});
setOpencodeExtraOptions({});
}
return;
}
@@ -1094,42 +801,6 @@ export function ProviderForm({
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 config = applyTemplateValues(
preset.settingsConfig,
@@ -1167,67 +838,14 @@ export function ProviderForm({
)}
{/* 基础字段 */}
<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
}
/>
<BasicFormFields form={form} />
{/* Claude 专属字段 */}
{appId === "claude" && (
<ClaudeFormFields
providerId={providerId}
shouldShowApiKey={shouldShowApiKey(
form.getValues("settingsConfig"),
form.watch("settingsConfig"),
isEditMode,
)}
apiKey={apiKey}
@@ -1298,7 +916,7 @@ export function ProviderForm({
<GeminiFormFields
providerId={providerId}
shouldShowApiKey={shouldShowApiKey(
form.getValues("settingsConfig"),
form.watch("settingsConfig"),
isEditMode,
)}
apiKey={geminiApiKey}
@@ -1323,25 +941,6 @@ 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 分别使用不同的编辑器 */}
{appId === "codex" ? (
<>
@@ -1401,40 +1000,10 @@ 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
value={form.getValues("settingsConfig")}
value={form.watch("settingsConfig")}
onChange={(value) => form.setValue("settingsConfig", value)}
useCommonConfig={useCommonConfig}
onCommonConfigToggle={handleCommonConfigToggle}
@@ -1460,14 +1029,6 @@ export function ProviderForm({
</>
)}
{/* 高级配置:模型测试和代理配置 */}
<ProviderAdvancedConfig
testConfig={testConfig}
proxyConfig={proxyConfig}
onTestConfigChange={setTestConfig}
onProxyConfigChange={setProxyConfig}
/>
{showButtons && (
<div className="flex justify-end gap-2">
<Button variant="outline" type="button" onClick={onCancel}>
@@ -1486,5 +1047,4 @@ export type ProviderFormValues = ProviderFormData & {
presetCategory?: ProviderCategory;
isPartner?: boolean;
meta?: ProviderMeta;
providerKey?: string; // OpenCode: user-defined provider key
};
@@ -6,7 +6,7 @@ import {
import type { ProviderCategory } from "@/types";
interface UseBaseUrlStateProps {
appType: "claude" | "codex" | "gemini" | "opencode";
appType: "claude" | "codex" | "gemini";
category: ProviderCategory | undefined;
settingsConfig: string;
codexConfig?: string;
@@ -19,8 +19,6 @@ interface UseCommonConfigSnippetProps {
settingsConfig?: Record<string, unknown>;
};
selectedPresetId?: string;
/** When false, the hook skips all logic and returns disabled state. Default: true */
enabled?: boolean;
}
/**
@@ -32,7 +30,6 @@ export function useCommonConfigSnippet({
onConfigChange,
initialData,
selectedPresetId,
enabled = true,
}: UseCommonConfigSnippetProps) {
const { t } = useTranslation();
const [useCommonConfig, setUseCommonConfig] = useState(false);
@@ -50,16 +47,11 @@ export function useCommonConfigSnippet({
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
useEffect(() => {
if (!enabled) return;
hasInitializedNewMode.current = false;
}, [selectedPresetId, enabled]);
}, [selectedPresetId]);
// 初始化:从 config.json 加载,支持从 localStorage 迁移
useEffect(() => {
if (!enabled) {
setIsLoading(false);
return;
}
let mounted = true;
const loadSnippet = async () => {
@@ -108,11 +100,10 @@ export function useCommonConfigSnippet({
return () => {
mounted = false;
};
}, [enabled]);
}, []);
// 初始化时检查通用配置片段(编辑模式)
useEffect(() => {
if (!enabled) return;
if (initialData && !isLoading) {
const configString = JSON.stringify(initialData.settingsConfig, null, 2);
const hasCommon = hasCommonConfigSnippet(
@@ -121,11 +112,10 @@ export function useCommonConfigSnippet({
);
setUseCommonConfig(hasCommon);
}
}, [enabled, initialData, commonConfigSnippet, isLoading]);
}, [initialData, commonConfigSnippet, isLoading]);
// 新建模式:如果通用配置片段存在且有效,默认启用
useEffect(() => {
if (!enabled) return;
// 仅新建模式、加载完成、尚未初始化过
if (!initialData && !isLoading && !hasInitializedNewMode.current) {
hasInitializedNewMode.current = true;
@@ -155,7 +145,6 @@ export function useCommonConfigSnippet({
}
}
}, [
enabled,
initialData,
commonConfigSnippet,
isLoading,
@@ -270,7 +259,6 @@ export function useCommonConfigSnippet({
// 当配置变化时检查是否包含通用配置(但避免在通过通用配置更新时检查)
useEffect(() => {
if (!enabled) return;
if (isUpdatingFromCommonConfig.current || isLoading) {
return;
}
@@ -279,7 +267,7 @@ export function useCommonConfigSnippet({
commonConfigSnippet,
);
setUseCommonConfig(hasCommon);
}, [enabled, settingsConfig, commonConfigSnippet, isLoading]);
}, [settingsConfig, commonConfigSnippet, isLoading]);
// 从编辑器当前内容提取通用配置片段
const handleExtract = useCallback(async () => {
@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { useState, useCallback, useEffect } from "react";
interface UseModelStateProps {
settingsConfig: string;
@@ -19,27 +19,12 @@ export function useModelState({
const [defaultSonnetModel, setDefaultSonnetModel] = useState("");
const [defaultOpusModel, setDefaultOpusModel] = useState("");
const isUserEditingRef = useRef(false);
const lastConfigRef = useRef(settingsConfig);
// 初始化读取:读新键;若缺失,按兼容优先级回退
// Haiku: DEFAULT_HAIKU || SMALL_FAST || MODEL
// Sonnet: DEFAULT_SONNET || MODEL || SMALL_FAST
// Opus: DEFAULT_OPUS || MODEL || SMALL_FAST
// 仅在 settingsConfig 变化时同步一次(表单加载/切换预设时)
useEffect(() => {
if (lastConfigRef.current === settingsConfig) {
return;
}
if (isUserEditingRef.current) {
isUserEditingRef.current = false;
lastConfigRef.current = settingsConfig;
return;
}
lastConfigRef.current = settingsConfig;
try {
const cfg = settingsConfig ? JSON.parse(settingsConfig) : {};
const env = cfg?.env || {};
@@ -86,8 +71,6 @@ export function useModelState({
| "ANTHROPIC_DEFAULT_OPUS_MODEL",
value: string,
) => {
isUserEditingRef.current = true;
if (field === "ANTHROPIC_MODEL") setClaudeModel(value);
if (field === "ANTHROPIC_REASONING_MODEL") setReasoningModel(value);
if (field === "ANTHROPIC_DEFAULT_HAIKU_MODEL")
@@ -49,7 +49,7 @@ export function EndpointField({
</div>
<Input
id={id}
type="text"
type="url"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
@@ -25,9 +25,9 @@ export function AutoFailoverConfigPanel({
const [formData, setFormData] = useState({
autoFailoverEnabled: false,
maxRetries: "3",
streamingFirstByteTimeout: "60",
streamingIdleTimeout: "120",
nonStreamingTimeout: "600",
streamingFirstByteTimeout: "30",
streamingIdleTimeout: "60",
nonStreamingTimeout: "300",
circuitFailureThreshold: "5",
circuitSuccessThreshold: "2",
circuitTimeoutSeconds: "60",
@@ -67,9 +67,9 @@ export function AutoFailoverConfigPanel({
// 定义各字段的有效范围
const ranges = {
maxRetries: { min: 0, max: 10 },
streamingFirstByteTimeout: { min: 1, max: 120 },
streamingFirstByteTimeout: { min: 0, max: 180 },
streamingIdleTimeout: { min: 0, max: 600 },
nonStreamingTimeout: { min: 60, max: 1200 },
nonStreamingTimeout: { min: 0, max: 1800 },
circuitFailureThreshold: { min: 1, max: 20 },
circuitSuccessThreshold: { min: 1, max: 10 },
circuitTimeoutSeconds: { min: 0, max: 300 },
@@ -307,8 +307,8 @@ export function AutoFailoverConfigPanel({
<Input
id={`streamingFirstByte-${appType}`}
type="number"
min="1"
max="120"
min="0"
max="180"
value={formData.streamingFirstByteTimeout}
onChange={(e) =>
setFormData({
@@ -321,7 +321,7 @@ export function AutoFailoverConfigPanel({
<p className="text-xs text-muted-foreground">
{t(
"proxy.autoFailover.streamingFirstByteHint",
"等待首个数据块的最大时间,范围 1-120 秒,默认 60 秒",
"等待首个数据块的最大时间",
)}
</p>
</div>
@@ -347,7 +347,7 @@ export function AutoFailoverConfigPanel({
<p className="text-xs text-muted-foreground">
{t(
"proxy.autoFailover.streamingIdleHint",
"数据块之间的最大间隔,范围 60-600 秒,填 0 禁用(防止中途卡住)",
"数据块之间的最大间隔",
)}
</p>
</div>
@@ -359,8 +359,8 @@ export function AutoFailoverConfigPanel({
<Input
id={`nonStreaming-${appType}`}
type="number"
min="60"
max="1200"
min="0"
max="1800"
value={formData.nonStreamingTimeout}
onChange={(e) =>
setFormData({
@@ -373,7 +373,7 @@ export function AutoFailoverConfigPanel({
<p className="text-xs text-muted-foreground">
{t(
"proxy.autoFailover.nonStreamingHint",
"非流式请求的总超时时间,范围 60-1200 秒,默认 600 秒(10 分钟)",
"非流式请求的总超时时间",
)}
</p>
</div>
@@ -140,8 +140,7 @@ export function FailoverQueueManager({
</div>
<p className="text-xs text-muted-foreground">
{t("proxy.failover.autoSwitchDescription", {
defaultValue:
"开启后将立即切换到队列 P1,并在请求失败时自动切换到队列中的下一个供应商",
defaultValue: "开启后,请求失败时自动切换到队列中的下一个供应商",
})}
</p>
</div>
-76
View File
@@ -1,76 +0,0 @@
/**
*
*
* /
*/
import { Shuffle, Loader2 } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import {
useAutoFailoverEnabled,
useSetAutoFailoverEnabled,
} from "@/lib/query/failover";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import type { AppId } from "@/lib/api";
interface FailoverToggleProps {
className?: string;
activeApp: AppId;
}
export function FailoverToggle({ className, activeApp }: FailoverToggleProps) {
const { t } = useTranslation();
const { data: isEnabled = false, isLoading } =
useAutoFailoverEnabled(activeApp);
const setEnabled = useSetAutoFailoverEnabled();
const handleToggle = (checked: boolean) => {
setEnabled.mutate({ appType: activeApp, enabled: checked });
};
const appLabel =
activeApp === "claude"
? "Claude"
: activeApp === "codex"
? "Codex"
: "Gemini";
const tooltipText = isEnabled
? t("failover.tooltip.enabled", {
app: appLabel,
defaultValue: `${appLabel} 故障转移已启用\n按队列优先级(P1→P2→...)选择供应商`,
})
: t("failover.tooltip.disabled", {
app: appLabel,
defaultValue: `启用 ${appLabel} 故障转移\n将立即切换到队列 P1,并在失败时自动切换到下一个`,
});
return (
<div
className={cn(
"flex items-center gap-1 px-1.5 h-8 rounded-lg bg-muted/50 transition-all",
className,
)}
title={tooltipText}
>
{setEnabled.isPending || isLoading ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<Shuffle
className={cn(
"h-4 w-4 transition-colors",
isEnabled
? "text-emerald-500 animate-pulse"
: "text-muted-foreground",
)}
/>
)}
<Switch
checked={isEnabled}
onCheckedChange={handleToggle}
disabled={setEnabled.isPending || isLoading}
/>
</div>
);
}
+2 -2
View File
@@ -50,8 +50,8 @@ export function ProxyPanel() {
}
}, [globalConfig]);
// 获取所有三个应用类型的故障转移队列
// 启用自动故障转移后,将按队列优先级(P1→P2→...)选择供应商
// 获取所有三个应用类型的故障转移队列(不包含当前供应商)
// 当前供应商始终优先,队列仅用于失败后的备用顺序
const { data: claudeQueue = [] } = useFailoverQueue("claude");
const { data: codexQueue = [] } = useFailoverQueue("codex");
const { data: geminiQueue = [] } = useFailoverQueue("gemini");
+27 -19
View File
@@ -37,9 +37,7 @@ export function ProxyToggle({ className, activeApp }: ProxyToggleProps) {
? "Claude"
: activeApp === "codex"
? "Codex"
: activeApp === "gemini"
? "Gemini"
: "OpenCode";
: "Gemini";
const tooltipText = takeoverEnabled
? isRunning
@@ -55,29 +53,39 @@ export function ProxyToggle({ className, activeApp }: ProxyToggleProps) {
return (
<div
className={cn(
"flex items-center gap-1 px-1.5 h-8 rounded-lg bg-muted/50 transition-all",
className,
)}
className={cn("p-1 rounded-xl transition-all", className)}
title={tooltipText}
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<Radio
<div className="flex items-center gap-2 px-2 h-8 rounded-md cursor-default">
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<Radio
className={cn(
"h-4 w-4 transition-colors",
takeoverEnabled
? "text-emerald-500 animate-pulse"
: "text-muted-foreground",
)}
/>
)}
<span
className={cn(
"h-4 w-4 transition-colors",
"text-sm font-medium transition-colors select-none",
takeoverEnabled
? "text-emerald-500 animate-pulse"
? "text-emerald-600 dark:text-emerald-400"
: "text-muted-foreground",
)}
>
Proxy
</span>
<Switch
checked={takeoverEnabled}
onCheckedChange={handleToggle}
disabled={isPending}
className="ml-1"
/>
)}
<Switch
checked={takeoverEnabled}
onCheckedChange={handleToggle}
disabled={isPending}
/>
</div>
</div>
);
}
+2 -2
View File
@@ -9,6 +9,7 @@ import {
Terminal,
CheckCircle2,
AlertCircle,
Sparkles,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next";
@@ -19,7 +20,6 @@ import { useUpdate } from "@/contexts/UpdateContext";
import { relaunchApp } from "@/lib/updater";
import { Badge } from "@/components/ui/badge";
import { motion } from "framer-motion";
import appIcon from "@/assets/icons/app-icon.png";
interface AboutSectionProps {
isPortable: boolean;
@@ -204,7 +204,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-2">
<div className="flex items-center gap-2">
<img src={appIcon} alt="CC Switch" className="h-5 w-5" />
<Sparkles className="h-5 w-5 text-primary" />
<h4 className="text-lg font-semibold text-foreground">
CC Switch
</h4>
@@ -1,123 +0,0 @@
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { ProviderIcon } from "@/components/ProviderIcon";
import type { SettingsFormState } from "@/hooks/useSettings";
import type { VisibleApps } from "@/types";
import type { AppId } from "@/lib/api";
interface AppVisibilitySettingsProps {
settings: SettingsFormState;
onChange: (updates: Partial<SettingsFormState>) => void;
}
const APP_CONFIG: Array<{
id: AppId;
icon: string;
nameKey: string;
}> = [
{ id: "claude", icon: "claude", nameKey: "apps.claude" },
{ id: "codex", icon: "openai", nameKey: "apps.codex" },
{ id: "gemini", icon: "gemini", nameKey: "apps.gemini" },
{ id: "opencode", icon: "opencode", nameKey: "apps.opencode" },
];
export function AppVisibilitySettings({
settings,
onChange,
}: AppVisibilitySettingsProps) {
const { t } = useTranslation();
const visibleApps: VisibleApps = settings.visibleApps ?? {
claude: true,
codex: true,
gemini: false,
opencode: true,
};
// Count how many apps are currently visible
const visibleCount = Object.values(visibleApps).filter(Boolean).length;
const handleToggle = (appId: AppId) => {
const isCurrentlyVisible = visibleApps[appId];
// Prevent disabling the last visible app
if (isCurrentlyVisible && visibleCount <= 1) return;
onChange({
visibleApps: {
...visibleApps,
[appId]: !isCurrentlyVisible,
},
});
};
return (
<section className="space-y-2">
<header className="space-y-1">
<h3 className="text-sm font-medium">
{t("settings.appVisibility.title")}
</h3>
<p className="text-xs text-muted-foreground">
{t("settings.appVisibility.description")}
</p>
</header>
<div className="inline-flex gap-1 rounded-md border border-border-default bg-background p-1">
{APP_CONFIG.map((app) => {
const isVisible = visibleApps[app.id];
// Disable button if this is the last visible app
const isDisabled = isVisible && visibleCount <= 1;
return (
<AppButton
key={app.id}
active={isVisible}
disabled={isDisabled}
onClick={() => handleToggle(app.id)}
icon={app.icon}
name={t(app.nameKey)}
>
{t(app.nameKey)}
</AppButton>
);
})}
</div>
</section>
);
}
interface AppButtonProps {
active: boolean;
disabled?: boolean;
onClick: () => void;
icon: string;
name: string;
children: React.ReactNode;
}
function AppButton({
active,
disabled,
onClick,
icon,
name,
children,
}: AppButtonProps) {
return (
<Button
type="button"
onClick={onClick}
disabled={disabled}
size="sm"
variant={active ? "default" : "ghost"}
className={cn(
"w-[90px] gap-1.5",
active
? "shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
>
<ProviderIcon icon={icon} name={name} size={14} />
{children}
</Button>
);
}
-119
View File
@@ -1,119 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { settingsApi, type LogConfig } from "@/lib/api/settings";
const LOG_LEVELS = ["error", "warn", "info", "debug", "trace"] as const;
export function LogConfigPanel() {
const { t } = useTranslation();
const [config, setConfig] = useState<LogConfig>({
enabled: true,
level: "info",
});
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
settingsApi
.getLogConfig()
.then(setConfig)
.catch((e) => console.error("Failed to load log config:", e))
.finally(() => setIsLoading(false));
}, []);
const handleChange = async (updates: Partial<LogConfig>) => {
const newConfig = { ...config, ...updates };
setConfig(newConfig);
try {
await settingsApi.setLogConfig(newConfig);
} catch (e) {
console.error("Failed to save log config:", e);
toast.error(String(e));
setConfig(config);
}
};
if (isLoading) return null;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>{t("settings.advanced.logConfig.enabled")}</Label>
<p className="text-xs text-muted-foreground">
{t("settings.advanced.logConfig.enabledDescription")}
</p>
</div>
<Switch
checked={config.enabled}
onCheckedChange={(checked) => handleChange({ enabled: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>{t("settings.advanced.logConfig.level")}</Label>
<p className="text-xs text-muted-foreground">
{t("settings.advanced.logConfig.levelDescription")}
</p>
</div>
<Select
value={config.level}
disabled={!config.enabled}
onValueChange={(value) =>
handleChange({ level: value as LogConfig["level"] })
}
>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LOG_LEVELS.map((level) => (
<SelectItem key={level} value={level}>
{t(`settings.advanced.logConfig.levels.${level}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 日志级别说明 */}
<div className="rounded-lg bg-muted/50 p-4 text-xs space-y-1.5">
<p className="font-medium text-muted-foreground mb-2">
{t("settings.advanced.logConfig.levelHint")}
</p>
<div className="grid gap-1 text-muted-foreground">
<p>
<span className="font-mono text-red-500">error</span> -{" "}
{t("settings.advanced.logConfig.levelDesc.error")}
</p>
<p>
<span className="font-mono text-orange-500">warn</span> -{" "}
{t("settings.advanced.logConfig.levelDesc.warn")}
</p>
<p>
<span className="font-mono text-blue-500">info</span> -{" "}
{t("settings.advanced.logConfig.levelDesc.info")}
</p>
<p>
<span className="font-mono text-green-500">debug</span> -{" "}
{t("settings.advanced.logConfig.levelDesc.debug")}
</p>
<p>
<span className="font-mono text-gray-500">trace</span> -{" "}
{t("settings.advanced.logConfig.levelDesc.trace")}
</p>
</div>
</div>
</div>
);
}
+6 -35
View File
@@ -11,7 +11,6 @@ import {
ChevronDown,
Zap,
Globe,
ScrollText,
} from "lucide-react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { toast } from "sonner";
@@ -34,7 +33,6 @@ import { settingsApi } from "@/lib/api";
import { LanguageSettings } from "@/components/settings/LanguageSettings";
import { ThemeSettings } from "@/components/settings/ThemeSettings";
import { WindowSettings } from "@/components/settings/WindowSettings";
import { AppVisibilitySettings } from "@/components/settings/AppVisibilitySettings";
import { DirectorySettings } from "@/components/settings/DirectorySettings";
import { ImportExportSection } from "@/components/settings/ImportExportSection";
import { AboutSection } from "@/components/settings/AboutSection";
@@ -46,7 +44,6 @@ import { AutoFailoverConfigPanel } from "@/components/proxy/AutoFailoverConfigPa
import { FailoverQueueManager } from "@/components/proxy/FailoverQueueManager";
import { UsageDashboard } from "@/components/usage/UsageDashboard";
import { RectifierConfigPanel } from "@/components/settings/RectifierConfigPanel";
import { LogConfigPanel } from "@/components/settings/LogConfigPanel";
import { useSettings } from "@/hooks/useSettings";
import { useImportExport } from "@/hooks/useImportExport";
import { useTranslation } from "react-i18next";
@@ -205,7 +202,7 @@ export function SettingsPage({
};
return (
<div className="flex flex-col h-[calc(100vh-8rem)] overflow-hidden px-6">
<div className="mx-auto max-w-[56rem] flex flex-col h-[calc(100vh-8rem)] overflow-hidden px-6">
{isBusy ? (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
@@ -241,10 +238,6 @@ export function SettingsPage({
onChange={(lang) => handleAutoSave({ language: lang })}
/>
<ThemeSettings />
<AppVisibilitySettings
settings={settings}
onChange={handleAutoSave}
/>
<WindowSettings
settings={settings}
onChange={handleAutoSave}
@@ -462,28 +455,6 @@ export function SettingsPage({
</AccordionContent>
</AccordionItem>
<AccordionItem
value="rectifier"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<Zap className="h-5 w-5 text-purple-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("settings.advanced.rectifier.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.rectifier.description")}
</p>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<RectifierConfigPanel />
</AccordionContent>
</AccordionItem>
<AccordionItem
value="test"
className="rounded-xl glass-card overflow-hidden"
@@ -583,24 +554,24 @@ export function SettingsPage({
</AccordionItem>
<AccordionItem
value="logConfig"
value="rectifier"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<ScrollText className="h-5 w-5 text-cyan-500" />
<Zap className="h-5 w-5 text-purple-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("settings.advanced.logConfig.title")}
{t("settings.advanced.rectifier.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.logConfig.description")}
{t("settings.advanced.rectifier.description")}
</p>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<LogConfigPanel />
<RectifierConfigPanel />
</AccordionContent>
</AccordionItem>
</Accordion>
+38 -1
View File
@@ -1,7 +1,7 @@
import { Switch } from "@/components/ui/switch";
import { useTranslation } from "react-i18next";
import type { SettingsFormState } from "@/hooks/useSettings";
import { AppWindow, MonitorUp, Power } from "lucide-react";
import { ToggleRow } from "@/components/ui/toggle-row";
interface WindowSettingsProps {
settings: SettingsFormState;
@@ -58,3 +58,40 @@ export function WindowSettings({ settings, onChange }: WindowSettingsProps) {
</section>
);
}
interface ToggleRowProps {
icon: React.ReactNode;
title: string;
description?: string;
checked: boolean;
onCheckedChange: (value: boolean) => void;
}
function ToggleRow({
icon,
title,
description,
checked,
onCheckedChange,
}: ToggleRowProps) {
return (
<div className="flex items-center justify-between gap-4 rounded-xl border border-border bg-card/50 p-4 transition-colors hover:bg-muted/50">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-background ring-1 ring-border">
{icon}
</div>
<div className="space-y-1">
<p className="text-sm font-medium leading-none">{title}</p>
{description ? (
<p className="text-xs text-muted-foreground">{description}</p>
) : null}
</div>
</div>
<Switch
checked={checked}
onCheckedChange={onCheckedChange}
aria-label={title}
/>
</div>
);
}
+1 -1
View File
@@ -193,7 +193,7 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
}, [skills, searchQuery, filterStatus]);
return (
<div className="px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden bg-background/50">
<div className="mx-auto max-w-[56rem] px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden bg-background/50">
{/* 技能网格(可滚动详情区域) */}
<div className="flex-1 overflow-y-auto overflow-x-hidden animate-fade-in">
<div className="py-4">
+3 -21
View File
@@ -52,13 +52,12 @@ const UnifiedSkillsPanel = React.forwardRef<
// Count enabled skills per app
const enabledCounts = useMemo(() => {
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0 };
const counts = { claude: 0, codex: 0, gemini: 0 };
if (!skills) return counts;
skills.forEach((skill) => {
if (skill.apps.claude) counts.claude++;
if (skill.apps.codex) counts.codex++;
if (skill.apps.gemini) counts.gemini++;
if (skill.apps.opencode) counts.opencode++;
});
return counts;
}, [skills]);
@@ -133,15 +132,14 @@ const UnifiedSkillsPanel = React.forwardRef<
}));
return (
<div className="px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
<div className="mx-auto max-w-[56rem] px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
{/* Info Section */}
<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">
{t("skills.installed", { count: skills?.length || 0 })} ·{" "}
{t("skills.apps.claude")}: {enabledCounts.claude} ·{" "}
{t("skills.apps.codex")}: {enabledCounts.codex} ·{" "}
{t("skills.apps.gemini")}: {enabledCounts.gemini} ·{" "}
{t("skills.apps.opencode")}: {enabledCounts.opencode}
{t("skills.apps.gemini")}: {enabledCounts.gemini}
</div>
</div>
@@ -310,22 +308,6 @@ const InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({
}
/>
</div>
<div className="flex items-center justify-between gap-3">
<label
htmlFor={`${skill.id}-opencode`}
className="text-sm text-foreground/80 cursor-pointer"
>
{t("skills.apps.opencode")}
</label>
<Switch
id={`${skill.id}-opencode`}
checked={skill.apps.opencode}
onCheckedChange={(checked: boolean) =>
onToggleApp(skill.id, "opencode", checked)
}
/>
</div>
</div>
{/* 右侧:删除按钮 */}
+8 -2
View File
@@ -1,6 +1,6 @@
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { ChevronDown, ChevronUp } from "lucide-react";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
@@ -37,7 +37,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-[100] min-w-[8rem] overflow-hidden rounded-md border border-border-default bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border border-border-default bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
position={position}
@@ -87,6 +87,12 @@ const SelectItem = React.forwardRef<
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
+1 -8
View File
@@ -1,18 +1,11 @@
import { Toaster as SonnerToaster } from "sonner";
import { useTheme } from "@/components/theme-provider";
export function Toaster() {
const { theme } = useTheme();
// 将应用主题映射到 Sonner 的主题
// 如果是 "system"Sonner 会自己处理
const sonnerTheme = theme === "system" ? "system" : theme;
return (
<SonnerToaster
position="top-center"
richColors
theme={sonnerTheme}
theme="system"
toastOptions={{
duration: 2000,
classNames: {
-41
View File
@@ -1,41 +0,0 @@
import { Switch } from "@/components/ui/switch";
export interface ToggleRowProps {
icon: React.ReactNode;
title: string;
description?: string;
checked: boolean;
onCheckedChange: (value: boolean) => void;
disabled?: boolean;
}
export function ToggleRow({
icon,
title,
description,
checked,
onCheckedChange,
disabled,
}: ToggleRowProps) {
return (
<div className="flex items-center justify-between gap-4 rounded-xl border border-border bg-card/50 p-4 transition-colors hover:bg-muted/50">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-background ring-1 ring-border">
{icon}
</div>
<div className="space-y-1">
<p className="text-sm font-medium leading-none">{title}</p>
{description ? (
<p className="text-xs text-muted-foreground">{description}</p>
) : null}
</div>
</div>
<Switch
checked={checked}
onCheckedChange={onCheckedChange}
disabled={disabled}
aria-label={title}
/>
</div>
);
}
+1 -50
View File
@@ -100,7 +100,7 @@ export const providerPresets: ProviderPreset[] = [
iconColor: "#0F62FE",
},
{
name: "Zhipu GLM en",
name: "Z.ai GLM",
websiteUrl: "https://z.ai",
apiKeyUrl: "https://z.ai/subscribe?ic=8JVLJQFSKB",
settingsConfig: {
@@ -211,7 +211,6 @@ export const providerPresets: ProviderPreset[] = [
editorValue: "",
},
},
icon: "catcoder",
},
{
name: "Longcat",
@@ -337,42 +336,6 @@ export const providerPresets: ProviderPreset[] = [
icon: "aihubmix",
iconColor: "#006FFB",
},
{
name: "SiliconFlow",
websiteUrl: "https://siliconflow.cn",
apiKeyUrl: "https://cloud.siliconflow.cn/me/account/ak",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://api.siliconflow.cn",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "Pro/MiniMaxAI/MiniMax-M2.1",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "Pro/MiniMaxAI/MiniMax-M2.1",
ANTHROPIC_DEFAULT_SONNET_MODEL: "Pro/MiniMaxAI/MiniMax-M2.1",
ANTHROPIC_DEFAULT_OPUS_MODEL: "Pro/MiniMaxAI/MiniMax-M2.1",
},
},
category: "aggregator",
icon: "siliconflow",
iconColor: "#6E29F6",
},
{
name: "SiliconFlow en",
websiteUrl: "https://siliconflow.com",
apiKeyUrl: "https://cloud.siliconflow.com/account/ak",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://api.siliconflow.com",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "MiniMaxAI/MiniMax-M2.1",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMaxAI/MiniMax-M2.1",
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMaxAI/MiniMax-M2.1",
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMaxAI/MiniMax-M2.1",
},
},
category: "aggregator",
icon: "siliconflow",
iconColor: "#000000",
},
{
name: "DMXAPI",
websiteUrl: "https://www.dmxapi.cn",
@@ -449,18 +412,6 @@ export const providerPresets: ProviderPreset[] = [
icon: "aigocode",
iconColor: "#5B7FFF",
},
{
name: "RightCode",
websiteUrl: "https://www.right.codes",
apiKeyUrl: "https://www.right.codes/register?aff=0bdf9bfa",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://www.right.codes/claude",
ANTHROPIC_AUTH_TOKEN: "",
},
},
category: "third_party"
},
{
name: "OpenRouter",
websiteUrl: "https://openrouter.ai",
-14
View File
@@ -192,20 +192,6 @@ requires_openai_auth = true`,
icon: "aigocode",
iconColor: "#5B7FFF",
},
{
name: "RightCode",
websiteUrl: "https://www.right.codes",
apiKeyUrl: "https://www.right.codes/register?aff=0bdf9bfa",
auth: generateThirdPartyAuth(""),
config: generateThirdPartyConfig(
"rightcode",
"https://right.codes/codex/v1",
"gpt-5.2",
),
category: "third_party",
icon: "rc",
iconColor: "#E96B2C",
},
{
name: "OpenRouter",
websiteUrl: "https://openrouter.ai",
-706
View File
@@ -1,706 +0,0 @@
/**
* OpenCode
* OpenCode 使 AI SDK npm
*/
import type { ProviderCategory, OpenCodeProviderConfig } from "../types";
import type { PresetTheme, TemplateValueConfig } from "./claudeProviderPresets";
export interface OpenCodeProviderPreset {
name: string;
websiteUrl: string;
apiKeyUrl?: string;
/** OpenCode settings_config 结构 */
settingsConfig: OpenCodeProviderConfig;
isOfficial?: boolean;
isPartner?: boolean;
partnerPromotionKey?: string;
category?: ProviderCategory;
/** 模板变量定义 */
templateValues?: Record<string, TemplateValueConfig>;
/** 视觉主题配置 */
theme?: PresetTheme;
/** 图标名称 */
icon?: string;
/** 图标颜色 */
iconColor?: string;
/** 标记为自定义模板(用于 UI 区分) */
isCustomTemplate?: boolean;
}
/**
* OpenCode npm AI SDK
*/
export const opencodeNpmPackages = [
{ value: "@ai-sdk/openai", label: "OpenAI" },
{ value: "@ai-sdk/openai-compatible", label: "OpenAI Compatible" },
{ value: "@ai-sdk/anthropic", label: "Anthropic" },
{ value: "@ai-sdk/google", label: "Google (Gemini)" },
] as const;
/**
* OpenCode
*/
export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
// ========== 国产官方 ==========
{
name: "DeepSeek",
websiteUrl: "https://platform.deepseek.com",
apiKeyUrl: "https://platform.deepseek.com/api_keys",
settingsConfig: {
npm: "@ai-sdk/openai-compatible",
options: {
baseURL: "https://api.deepseek.com/v1",
apiKey: "",
},
models: {
"deepseek-chat": { name: "DeepSeek V3.2" },
"deepseek-reasoner": { name: "DeepSeek R1" },
},
},
category: "cn_official",
icon: "deepseek",
iconColor: "#1E88E5",
templateValues: {
apiKey: {
label: "API Key",
placeholder: "sk-...",
editorValue: "",
},
},
},
{
name: "Zhipu GLM",
websiteUrl: "https://open.bigmodel.cn",
apiKeyUrl: "https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII",
settingsConfig: {
npm: "@ai-sdk/openai-compatible",
name: "Zhipu GLM",
options: {
baseURL: "https://open.bigmodel.cn/api/paas/v4",
apiKey: "",
},
models: {
"glm-4.7": { name: "GLM-4.7" },
},
},
category: "cn_official",
isPartner: true,
partnerPromotionKey: "zhipu",
icon: "zhipu",
iconColor: "#0F62FE",
templateValues: {
baseURL: {
label: "Base URL",
placeholder: "https://open.bigmodel.cn/api/paas/v4",
defaultValue: "https://open.bigmodel.cn/api/paas/v4",
editorValue: "",
},
apiKey: {
label: "API Key",
placeholder: "",
editorValue: "",
},
},
},
{
name: "Zhipu GLM en",
websiteUrl: "https://z.ai",
apiKeyUrl: "https://z.ai/subscribe?ic=8JVLJQFSKB",
settingsConfig: {
npm: "@ai-sdk/openai-compatible",
name: "Zhipu GLM en",
options: {
baseURL: "https://api.z.ai/v1",
apiKey: "",
},
models: {
"glm-4.7": { name: "GLM-4.7" },
},
},
category: "cn_official",
isPartner: true,
partnerPromotionKey: "zhipu",
icon: "zhipu",
iconColor: "#0F62FE",
templateValues: {
baseURL: {
label: "Base URL",
placeholder: "https://api.z.ai/v1",
defaultValue: "https://api.z.ai/v1",
editorValue: "",
},
apiKey: {
label: "API Key",
placeholder: "",
editorValue: "",
},
},
},
{
name: "Qwen Coder",
websiteUrl: "https://bailian.console.aliyun.com",
apiKeyUrl: "https://bailian.console.aliyun.com/#/api-key",
settingsConfig: {
npm: "@ai-sdk/openai-compatible",
name: "Qwen Coder",
options: {
baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiKey: "",
},
models: {
"qwen3-max": { name: "Qwen3 Max" },
},
},
category: "cn_official",
icon: "qwen",
iconColor: "#FF6A00",
templateValues: {
baseURL: {
label: "Base URL",
placeholder: "https://dashscope.aliyuncs.com/compatible-mode/v1",
defaultValue: "https://dashscope.aliyuncs.com/compatible-mode/v1",
editorValue: "",
},
apiKey: {
label: "API Key",
placeholder: "sk-...",
editorValue: "",
},
},
},
{
name: "Kimi k2",
websiteUrl: "https://platform.moonshot.cn/console",
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys",
settingsConfig: {
npm: "@ai-sdk/openai-compatible",
name: "Kimi k2",
options: {
baseURL: "https://api.moonshot.cn/v1",
apiKey: "",
},
models: {
"kimi-k2-thinking": { name: "Kimi K2 Thinking" },
},
},
category: "cn_official",
icon: "kimi",
iconColor: "#6366F1",
templateValues: {
baseURL: {
label: "Base URL",
placeholder: "https://api.moonshot.cn/v1",
defaultValue: "https://api.moonshot.cn/v1",
editorValue: "",
},
apiKey: {
label: "API Key",
placeholder: "sk-...",
editorValue: "",
},
},
},
{
name: "Kimi For Coding",
websiteUrl: "https://www.kimi.com/coding/docs/",
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys",
settingsConfig: {
npm: "@ai-sdk/openai-compatible",
name: "Kimi For Coding",
options: {
baseURL: "https://api.kimi.com/v1",
apiKey: "",
},
models: {
"kimi-for-coding": { name: "Kimi For Coding" },
},
},
category: "cn_official",
icon: "kimi",
iconColor: "#6366F1",
templateValues: {
baseURL: {
label: "Base URL",
placeholder: "https://api.kimi.com/v1",
defaultValue: "https://api.kimi.com/v1",
editorValue: "",
},
apiKey: {
label: "API Key",
placeholder: "sk-...",
editorValue: "",
},
},
},
{
name: "ModelScope",
websiteUrl: "https://modelscope.cn",
apiKeyUrl: "https://modelscope.cn/my/myaccesstoken",
settingsConfig: {
npm: "@ai-sdk/openai-compatible",
name: "ModelScope",
options: {
baseURL: "https://api-inference.modelscope.cn/v1",
apiKey: "",
},
models: {
"ZhipuAI/GLM-4.7": { name: "GLM-4.7" },
},
},
category: "aggregator",
icon: "modelscope",
iconColor: "#624AFF",
templateValues: {
baseURL: {
label: "Base URL",
placeholder: "https://api-inference.modelscope.cn/v1",
defaultValue: "https://api-inference.modelscope.cn/v1",
editorValue: "",
},
apiKey: {
label: "API Key",
placeholder: "",
editorValue: "",
},
},
},
{
name: "KAT-Coder",
websiteUrl: "https://console.streamlake.ai",
apiKeyUrl: "https://console.streamlake.ai/console/api-key",
settingsConfig: {
npm: "@ai-sdk/openai-compatible",
name: "KAT-Coder",
options: {
baseURL:
"https://vanchin.streamlake.ai/api/gateway/v1/endpoints/${ENDPOINT_ID}/openai",
apiKey: "",
},
models: {
"KAT-Coder-Pro": { name: "KAT-Coder Pro" },
},
},
category: "cn_official",
templateValues: {
baseURL: {
label: "Base URL",
placeholder:
"https://vanchin.streamlake.ai/api/gateway/v1/endpoints/${ENDPOINT_ID}/openai",
defaultValue:
"https://vanchin.streamlake.ai/api/gateway/v1/endpoints/${ENDPOINT_ID}/openai",
editorValue: "",
},
ENDPOINT_ID: {
label: "Vanchin Endpoint ID",
placeholder: "ep-xxx-xxx",
defaultValue: "",
editorValue: "",
},
apiKey: {
label: "API Key",
placeholder: "",
editorValue: "",
},
},
icon: "catcoder",
},
{
name: "Longcat",
websiteUrl: "https://longcat.chat/platform",
apiKeyUrl: "https://longcat.chat/platform/api_keys",
settingsConfig: {
npm: "@ai-sdk/openai-compatible",
name: "Longcat",
options: {
baseURL: "https://api.longcat.chat/v1",
apiKey: "",
},
models: {
"LongCat-Flash-Chat": { name: "LongCat Flash Chat" },
},
},
category: "cn_official",
icon: "longcat",
iconColor: "#29E154",
templateValues: {
baseURL: {
label: "Base URL",
placeholder: "https://api.longcat.chat/v1",
defaultValue: "https://api.longcat.chat/v1",
editorValue: "",
},
apiKey: {
label: "API Key",
placeholder: "",
editorValue: "",
},
},
},
{
name: "MiniMax",
websiteUrl: "https://platform.minimaxi.com",
apiKeyUrl: "https://platform.minimaxi.com/subscribe/coding-plan",
settingsConfig: {
npm: "@ai-sdk/openai-compatible",
name: "MiniMax",
options: {
baseURL: "https://api.minimaxi.com/v1",
apiKey: "",
},
models: {
"MiniMax-M2.1": { name: "MiniMax M2.1" },
},
},
category: "cn_official",
isPartner: true,
partnerPromotionKey: "minimax_cn",
theme: {
backgroundColor: "#f64551",
textColor: "#FFFFFF",
},
icon: "minimax",
iconColor: "#FF6B6B",
templateValues: {
apiKey: {
label: "API Key",
placeholder: "",
editorValue: "",
},
},
},
{
name: "MiniMax en",
websiteUrl: "https://platform.minimax.io",
apiKeyUrl: "https://platform.minimax.io/subscribe/coding-plan",
settingsConfig: {
npm: "@ai-sdk/openai-compatible",
name: "MiniMax en",
options: {
baseURL: "https://api.minimax.io/v1",
apiKey: "",
},
models: {
"MiniMax-M2.1": { name: "MiniMax M2.1" },
},
},
category: "cn_official",
isPartner: true,
partnerPromotionKey: "minimax_en",
theme: {
backgroundColor: "#f64551",
textColor: "#FFFFFF",
},
icon: "minimax",
iconColor: "#FF6B6B",
templateValues: {
apiKey: {
label: "API Key",
placeholder: "",
editorValue: "",
},
},
},
{
name: "DouBaoSeed",
websiteUrl: "https://www.volcengine.com/product/doubao",
apiKeyUrl: "https://www.volcengine.com/product/doubao",
settingsConfig: {
npm: "@ai-sdk/openai-compatible",
name: "DouBaoSeed",
options: {
baseURL: "https://ark.cn-beijing.volces.com/api/v3",
apiKey: "",
},
models: {
"doubao-seed-code-preview-latest": { name: "Doubao Seed Code Preview" },
},
},
category: "cn_official",
icon: "doubao",
iconColor: "#3370FF",
templateValues: {
apiKey: {
label: "API Key",
placeholder: "",
editorValue: "",
},
},
},
{
name: "BaiLing",
websiteUrl: "https://alipaytbox.yuque.com/sxs0ba/ling/get_started",
settingsConfig: {
npm: "@ai-sdk/openai-compatible",
name: "BaiLing",
options: {
baseURL: "https://api.tbox.cn/v1",
apiKey: "",
},
models: {
"Ling-1T": { name: "Ling 1T" },
},
},
category: "cn_official",
templateValues: {
apiKey: {
label: "API Key",
placeholder: "",
editorValue: "",
},
},
},
{
name: "Xiaomi MiMo",
websiteUrl: "https://platform.xiaomimimo.com",
apiKeyUrl: "https://platform.xiaomimimo.com/#/console/api-keys",
settingsConfig: {
npm: "@ai-sdk/openai-compatible",
name: "Xiaomi MiMo",
options: {
baseURL: "https://api.xiaomimimo.com/v1",
apiKey: "",
},
models: {
"mimo-v2-flash": { name: "MiMo V2 Flash" },
},
},
category: "cn_official",
icon: "xiaomimimo",
iconColor: "#000000",
templateValues: {
apiKey: {
label: "API Key",
placeholder: "",
editorValue: "",
},
},
},
// ========== 聚合网站 ==========
{
name: "AiHubMix",
websiteUrl: "https://aihubmix.com",
apiKeyUrl: "https://aihubmix.com",
settingsConfig: {
npm: "@ai-sdk/anthropic",
name: "AiHubMix",
options: {
baseURL: "https://aihubmix.com/v1",
apiKey: "",
},
models: {
"claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" },
"claude-opus-4-5-20251101": { name: "Claude Opus 4.5" },
},
},
category: "aggregator",
icon: "aihubmix",
iconColor: "#006FFB",
templateValues: {
apiKey: {
label: "API Key",
placeholder: "",
editorValue: "",
},
},
},
{
name: "DMXAPI",
websiteUrl: "https://www.dmxapi.cn",
apiKeyUrl: "https://www.dmxapi.cn",
settingsConfig: {
npm: "@ai-sdk/anthropic",
name: "DMXAPI",
options: {
baseURL: "https://www.dmxapi.cn/v1",
apiKey: "",
},
models: {
"claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" },
"claude-opus-4-5-20251101": { name: "Claude Opus 4.5" },
},
},
category: "aggregator",
isPartner: true,
partnerPromotionKey: "dmxapi",
templateValues: {
apiKey: {
label: "API Key",
placeholder: "",
editorValue: "",
},
},
},
{
name: "OpenRouter",
websiteUrl: "https://openrouter.ai",
apiKeyUrl: "https://openrouter.ai/keys",
settingsConfig: {
npm: "@ai-sdk/anthropic",
name: "OpenRouter",
options: {
baseURL: "https://openrouter.ai/api/v1",
apiKey: "",
},
models: {
"anthropic/claude-sonnet-4.5": { name: "Claude Sonnet 4.5" },
"anthropic/claude-opus-4.5": { name: "Claude Opus 4.5" },
},
},
category: "aggregator",
icon: "openrouter",
iconColor: "#6566F1",
templateValues: {
apiKey: {
label: "API Key",
placeholder: "sk-or-...",
editorValue: "",
},
},
},
// ========== 第三方合作伙伴 ==========
{
name: "PackyCode",
websiteUrl: "https://www.packyapi.com",
apiKeyUrl: "https://www.packyapi.com/register?aff=cc-switch",
settingsConfig: {
npm: "@ai-sdk/anthropic",
name: "PackyCode",
options: {
baseURL: "https://www.packyapi.com/v1",
apiKey: "",
},
models: {
"claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" },
"claude-opus-4-5-20251101": { name: "Claude Opus 4.5" },
},
},
category: "third_party",
isPartner: true,
partnerPromotionKey: "packycode",
icon: "packycode",
templateValues: {
apiKey: {
label: "API Key",
placeholder: "",
editorValue: "",
},
},
},
{
name: "Cubence",
websiteUrl: "https://cubence.com",
apiKeyUrl: "https://cubence.com/signup?code=CCSWITCH&source=ccs",
settingsConfig: {
npm: "@ai-sdk/anthropic",
name: "Cubence",
options: {
baseURL: "https://api.cubence.com/v1",
apiKey: "",
},
models: {
"claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" },
"claude-opus-4-5-20251101": { name: "Claude Opus 4.5" },
},
},
category: "third_party",
isPartner: true,
partnerPromotionKey: "cubence",
icon: "cubence",
iconColor: "#000000",
templateValues: {
apiKey: {
label: "API Key",
placeholder: "",
editorValue: "",
},
},
},
{
name: "AIGoCode",
websiteUrl: "https://aigocode.com",
apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH",
settingsConfig: {
npm: "@ai-sdk/anthropic",
name: "AIGoCode",
options: {
baseURL: "https://api.aigocode.com/v1",
apiKey: "",
},
models: {
"claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" },
"claude-opus-4-5-20251101": { name: "Claude Opus 4.5" },
},
},
category: "third_party",
isPartner: true,
partnerPromotionKey: "aigocode",
icon: "aigocode",
iconColor: "#5B7FFF",
templateValues: {
apiKey: {
label: "API Key",
placeholder: "",
editorValue: "",
},
},
},
{
name: "RightCode",
websiteUrl: "https://www.right.codes",
apiKeyUrl: "https://www.right.codes/register?aff=0bdf9bfa",
settingsConfig: {
npm: "@ai-sdk/openai",
name: "RightCode",
options: {
baseURL: "https://right.codes/codex/v1",
apiKey: "",
},
models: {
"gpt-5.2": { name: "GPT-5.2" },
"gpt-5.2-codex": { name: "GPT-5.2 Codex" },
},
},
category: "third_party",
icon: "rc",
iconColor: "#E96B2C",
templateValues: {
apiKey: {
label: "API Key",
placeholder: "",
editorValue: "",
},
},
},
// ========== 自定义模板 ==========
{
name: "OpenAI Compatible",
websiteUrl: "",
settingsConfig: {
npm: "@ai-sdk/openai-compatible",
options: {
baseURL: "",
apiKey: "",
},
models: {},
},
category: "custom",
isCustomTemplate: true,
icon: "generic",
iconColor: "#6B7280",
templateValues: {
baseURL: {
label: "Base URL",
placeholder: "https://api.example.com/v1",
editorValue: "",
},
apiKey: {
label: "API Key",
placeholder: "",
editorValue: "",
},
},
},
];

Some files were not shown because too many files have changed in this diff Show More