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
76 changed files with 233 additions and 4272 deletions
-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>` - 自定义配置目录
+9 -49
View File
@@ -13,8 +13,6 @@ pub struct McpApps {
pub codex: bool, pub codex: bool,
#[serde(default)] #[serde(default)]
pub gemini: bool, pub gemini: bool,
#[serde(default)]
pub opencode: bool,
} }
impl McpApps { impl McpApps {
@@ -24,7 +22,6 @@ impl McpApps {
AppType::Claude => self.claude, AppType::Claude => self.claude,
AppType::Codex => self.codex, AppType::Codex => self.codex,
AppType::Gemini => self.gemini, AppType::Gemini => self.gemini,
AppType::OpenCode => self.opencode,
} }
} }
@@ -34,7 +31,6 @@ impl McpApps {
AppType::Claude => self.claude = enabled, AppType::Claude => self.claude = enabled,
AppType::Codex => self.codex = enabled, AppType::Codex => self.codex = enabled,
AppType::Gemini => self.gemini = enabled, AppType::Gemini => self.gemini = enabled,
AppType::OpenCode => self.opencode = enabled,
} }
} }
@@ -50,15 +46,12 @@ impl McpApps {
if self.gemini { if self.gemini {
apps.push(AppType::Gemini); apps.push(AppType::Gemini);
} }
if self.opencode {
apps.push(AppType::OpenCode);
}
apps apps
} }
/// 检查是否所有应用都未启用 /// 检查是否所有应用都未启用
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
!self.claude && !self.codex && !self.gemini && !self.opencode !self.claude && !self.codex && !self.gemini
} }
} }
@@ -71,8 +64,6 @@ pub struct SkillApps {
pub codex: bool, pub codex: bool,
#[serde(default)] #[serde(default)]
pub gemini: bool, pub gemini: bool,
#[serde(default)]
pub opencode: bool,
} }
impl SkillApps { impl SkillApps {
@@ -82,7 +73,6 @@ impl SkillApps {
AppType::Claude => self.claude, AppType::Claude => self.claude,
AppType::Codex => self.codex, AppType::Codex => self.codex,
AppType::Gemini => self.gemini, AppType::Gemini => self.gemini,
AppType::OpenCode => self.opencode,
} }
} }
@@ -92,7 +82,6 @@ impl SkillApps {
AppType::Claude => self.claude = enabled, AppType::Claude => self.claude = enabled,
AppType::Codex => self.codex = enabled, AppType::Codex => self.codex = enabled,
AppType::Gemini => self.gemini = enabled, AppType::Gemini => self.gemini = enabled,
AppType::OpenCode => self.opencode = enabled,
} }
} }
@@ -108,15 +97,12 @@ impl SkillApps {
if self.gemini { if self.gemini {
apps.push(AppType::Gemini); apps.push(AppType::Gemini);
} }
if self.opencode {
apps.push(AppType::OpenCode);
}
apps apps
} }
/// 检查是否所有应用都未启用 /// 检查是否所有应用都未启用
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
!self.claude && !self.codex && !self.gemini && !self.opencode !self.claude && !self.codex && !self.gemini
} }
/// 仅启用指定应用(其他应用设为禁用) /// 仅启用指定应用(其他应用设为禁用)
@@ -219,9 +205,6 @@ pub struct McpRoot {
pub codex: McpConfig, pub codex: McpConfig,
#[serde(default, skip_serializing_if = "McpConfig::is_empty")] #[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub gemini: McpConfig, pub gemini: McpConfig,
/// OpenCode MCP 配置(v4.0.0+,实际使用 opencode.json
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub opencode: McpConfig,
} }
impl Default for McpRoot { impl Default for McpRoot {
@@ -233,7 +216,6 @@ impl Default for McpRoot {
claude: McpConfig::default(), claude: McpConfig::default(),
codex: McpConfig::default(), codex: McpConfig::default(),
gemini: McpConfig::default(), gemini: McpConfig::default(),
opencode: McpConfig::default(),
} }
} }
} }
@@ -254,8 +236,6 @@ pub struct PromptRoot {
pub codex: PromptConfig, pub codex: PromptConfig,
#[serde(default)] #[serde(default)]
pub gemini: PromptConfig, pub gemini: PromptConfig,
#[serde(default)]
pub opencode: PromptConfig,
} }
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file}; use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
@@ -269,8 +249,7 @@ use crate::provider::ProviderManager;
pub enum AppType { pub enum AppType {
Claude, Claude,
Codex, Codex,
Gemini, Gemini, // 新增
OpenCode,
} }
impl AppType { impl AppType {
@@ -278,8 +257,7 @@ impl AppType {
match self { match self {
AppType::Claude => "claude", AppType::Claude => "claude",
AppType::Codex => "codex", AppType::Codex => "codex",
AppType::Gemini => "gemini", AppType::Gemini => "gemini", // 新增
AppType::OpenCode => "opencode",
} }
} }
} }
@@ -292,12 +270,11 @@ impl FromStr for AppType {
match normalized.as_str() { match normalized.as_str() {
"claude" => Ok(AppType::Claude), "claude" => Ok(AppType::Claude),
"codex" => Ok(AppType::Codex), "codex" => Ok(AppType::Codex),
"gemini" => Ok(AppType::Gemini), "gemini" => Ok(AppType::Gemini), // 新增
"opencode" => Ok(AppType::OpenCode),
other => Err(AppError::localized( other => Err(AppError::localized(
"unsupported_app", "unsupported_app",
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode"), format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini。"),
format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode."), format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini."),
)), )),
} }
} }
@@ -314,9 +291,6 @@ pub struct CommonConfigSnippets {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub gemini: Option<String>, pub gemini: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opencode: Option<String>,
} }
impl CommonConfigSnippets { impl CommonConfigSnippets {
@@ -326,7 +300,6 @@ impl CommonConfigSnippets {
AppType::Claude => self.claude.as_ref(), AppType::Claude => self.claude.as_ref(),
AppType::Codex => self.codex.as_ref(), AppType::Codex => self.codex.as_ref(),
AppType::Gemini => self.gemini.as_ref(), AppType::Gemini => self.gemini.as_ref(),
AppType::OpenCode => self.opencode.as_ref(),
} }
} }
@@ -336,7 +309,6 @@ impl CommonConfigSnippets {
AppType::Claude => self.claude = snippet, AppType::Claude => self.claude = snippet,
AppType::Codex => self.codex = snippet, AppType::Codex => self.codex = snippet,
AppType::Gemini => self.gemini = snippet, AppType::Gemini => self.gemini = snippet,
AppType::OpenCode => self.opencode = snippet,
} }
} }
} }
@@ -375,8 +347,7 @@ impl Default for MultiAppConfig {
let mut apps = HashMap::new(); let mut apps = HashMap::new();
apps.insert("claude".to_string(), ProviderManager::default()); apps.insert("claude".to_string(), ProviderManager::default());
apps.insert("codex".to_string(), ProviderManager::default()); apps.insert("codex".to_string(), ProviderManager::default());
apps.insert("gemini".to_string(), ProviderManager::default()); apps.insert("gemini".to_string(), ProviderManager::default()); // 新增
apps.insert("opencode".to_string(), ProviderManager::default());
Self { Self {
version: 2, version: 2,
@@ -535,7 +506,6 @@ impl MultiAppConfig {
AppType::Claude => &self.mcp.claude, AppType::Claude => &self.mcp.claude,
AppType::Codex => &self.mcp.codex, AppType::Codex => &self.mcp.codex,
AppType::Gemini => &self.mcp.gemini, AppType::Gemini => &self.mcp.gemini,
AppType::OpenCode => &self.mcp.opencode,
} }
} }
@@ -545,7 +515,6 @@ impl MultiAppConfig {
AppType::Claude => &mut self.mcp.claude, AppType::Claude => &mut self.mcp.claude,
AppType::Codex => &mut self.mcp.codex, AppType::Codex => &mut self.mcp.codex,
AppType::Gemini => &mut self.mcp.gemini, AppType::Gemini => &mut self.mcp.gemini,
AppType::OpenCode => &mut self.mcp.opencode,
} }
} }
@@ -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::Claude)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::Codex)?; Self::auto_import_prompt_if_exists(&mut config, AppType::Codex)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?; Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::OpenCode)?;
Ok(config) Ok(config)
} }
@@ -579,7 +547,6 @@ impl MultiAppConfig {
if !self.prompts.claude.prompts.is_empty() if !self.prompts.claude.prompts.is_empty()
|| !self.prompts.codex.prompts.is_empty() || !self.prompts.codex.prompts.is_empty()
|| !self.prompts.gemini.prompts.is_empty() || !self.prompts.gemini.prompts.is_empty()
|| !self.prompts.opencode.prompts.is_empty()
{ {
return Ok(false); return Ok(false);
} }
@@ -587,12 +554,7 @@ impl MultiAppConfig {
log::info!("检测到已存在配置文件且 Prompt 列表为空,将尝试从现有提示词文件自动导入"); log::info!("检测到已存在配置文件且 Prompt 列表为空,将尝试从现有提示词文件自动导入");
let mut imported = false; let mut imported = false;
for app in [ for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
AppType::Claude,
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
] {
// 复用已有的单应用导入逻辑 // 复用已有的单应用导入逻辑
if Self::auto_import_prompt_if_exists(self, app)? { if Self::auto_import_prompt_if_exists(self, app)? {
imported = true; imported = true;
@@ -661,7 +623,6 @@ impl MultiAppConfig {
AppType::Claude => &mut config.prompts.claude.prompts, AppType::Claude => &mut config.prompts.claude.prompts,
AppType::Codex => &mut config.prompts.codex.prompts, AppType::Codex => &mut config.prompts.codex.prompts,
AppType::Gemini => &mut config.prompts.gemini.prompts, AppType::Gemini => &mut config.prompts.gemini.prompts,
AppType::OpenCode => &mut config.prompts.opencode.prompts,
}; };
prompts.insert(id, prompt); prompts.insert(id, prompt);
@@ -695,7 +656,6 @@ impl MultiAppConfig {
AppType::Claude => &self.mcp.claude.servers, AppType::Claude => &self.mcp.claude.servers,
AppType::Codex => &self.mcp.codex.servers, AppType::Codex => &self.mcp.codex.servers,
AppType::Gemini => &self.mcp.gemini.servers, AppType::Gemini => &self.mcp.gemini.servers,
AppType::OpenCode => &self.mcp.opencode.servers,
}; };
for (id, entry) in old_servers { for (id, entry) in old_servers {
-11
View File
@@ -51,15 +51,6 @@ pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
Ok(ConfigStatus { exists, path }) Ok(ConfigStatus { exists, path })
} }
AppType::OpenCode => {
let config_path = crate::opencode_config::get_opencode_config_path();
let exists = config_path.exists();
let path = crate::opencode_config::get_opencode_dir()
.to_string_lossy()
.to_string();
Ok(ConfigStatus { exists, path })
}
} }
} }
@@ -76,7 +67,6 @@ pub async fn get_config_dir(app: String) -> Result<String, String> {
AppType::Claude => config::get_claude_config_dir(), AppType::Claude => config::get_claude_config_dir(),
AppType::Codex => codex_config::get_codex_config_dir(), AppType::Codex => codex_config::get_codex_config_dir(),
AppType::Gemini => crate::gemini_config::get_gemini_dir(), AppType::Gemini => crate::gemini_config::get_gemini_dir(),
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
}; };
Ok(dir.to_string_lossy().to_string()) Ok(dir.to_string_lossy().to_string())
@@ -89,7 +79,6 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool,
AppType::Claude => config::get_claude_config_dir(), AppType::Claude => config::get_claude_config_dir(),
AppType::Codex => codex_config::get_codex_config_dir(), AppType::Codex => codex_config::get_codex_config_dir(),
AppType::Gemini => crate::gemini_config::get_gemini_dir(), AppType::Gemini => crate::gemini_config::get_gemini_dir(),
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
}; };
if !config_dir.exists() { if !config_dir.exists() {
-2
View File
@@ -122,7 +122,6 @@ pub async fn upsert_mcp_server_in_config(
new_server.apps.claude = true; new_server.apps.claude = true;
new_server.apps.codex = true; new_server.apps.codex = true;
new_server.apps.gemini = true; new_server.apps.gemini = true;
new_server.apps.opencode = true;
} }
McpService::upsert_server(&state, new_server) McpService::upsert_server(&state, new_server)
@@ -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_claude(&state).unwrap_or(0);
total += McpService::import_from_codex(&state).unwrap_or(0); total += McpService::import_from_codex(&state).unwrap_or(0);
total += McpService::import_from_gemini(&state).unwrap_or(0); total += McpService::import_from_gemini(&state).unwrap_or(0);
total += McpService::import_from_opencode(&state).unwrap_or(0);
Ok(total) Ok(total)
} }
-34
View File
@@ -60,16 +60,6 @@ pub fn delete_provider(
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
/// Remove provider from live config only (for additive mode apps like OpenCode)
/// Does NOT delete from database - provider remains in the list
#[tauri::command]
pub fn remove_provider_from_live_config(app: String, id: String) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::remove_from_live_config(app_type, &id)
.map(|_| true)
.map_err(|e| e.to_string())
}
/// 切换供应商 /// 切换供应商
fn switch_provider_internal(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> { fn switch_provider_internal(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
ProviderService::switch(state, app_type, id) ProviderService::switch(state, app_type, id)
@@ -335,27 +325,3 @@ pub fn sync_universal_provider(
Ok(result) Ok(result)
} }
// ============================================================================
// OpenCode 专属命令
// ============================================================================
/// 从 OpenCode live 配置导入供应商到数据库
///
/// 这是 OpenCode 特有的功能,因为 OpenCode 使用累加模式,
/// 用户可能已经在 opencode.json 中配置了供应商。
#[tauri::command]
pub fn import_opencode_providers_from_live(state: State<'_, AppState>) -> Result<usize, String> {
crate::services::provider::import_opencode_providers_from_live(state.inner())
.map_err(|e| e.to_string())
}
/// 获取 OpenCode live 配置中的供应商 ID 列表
///
/// 用于前端判断供应商是否已添加到 opencode.json
#[tauri::command]
pub fn get_opencode_live_provider_ids() -> Result<Vec<String>, String> {
crate::opencode_config::get_providers()
.map(|providers| providers.keys().cloned().collect())
.map_err(|e| e.to_string())
}
-1
View File
@@ -20,7 +20,6 @@ fn parse_app_type(app: &str) -> Result<AppType, String> {
"claude" => Ok(AppType::Claude), "claude" => Ok(AppType::Claude),
"codex" => Ok(AppType::Codex), "codex" => Ok(AppType::Codex),
"gemini" => Ok(AppType::Gemini), "gemini" => Ok(AppType::Gemini),
"opencode" => Ok(AppType::OpenCode),
_ => Err(format!("不支持的 app 类型: {app}")), _ => Err(format!("不支持的 app 类型: {app}")),
} }
} }
+3 -6
View File
@@ -13,7 +13,7 @@ impl Database {
pub fn get_all_mcp_servers(&self) -> Result<IndexMap<String, McpServer>, AppError> { pub fn get_all_mcp_servers(&self) -> Result<IndexMap<String, McpServer>, AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode "SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini
FROM mcp_servers FROM mcp_servers
ORDER BY name ASC, id ASC" ORDER BY name ASC, id ASC"
).map_err(|e| AppError::Database(e.to_string()))?; ).map_err(|e| AppError::Database(e.to_string()))?;
@@ -30,7 +30,6 @@ impl Database {
let enabled_claude: bool = row.get(7)?; let enabled_claude: bool = row.get(7)?;
let enabled_codex: bool = row.get(8)?; let enabled_codex: bool = row.get(8)?;
let enabled_gemini: bool = row.get(9)?; let enabled_gemini: bool = row.get(9)?;
let enabled_opencode: bool = row.get(10)?;
let server = serde_json::from_str(&server_config_str).unwrap_or_default(); let server = serde_json::from_str(&server_config_str).unwrap_or_default();
let tags = serde_json::from_str(&tags_str).unwrap_or_default(); let tags = serde_json::from_str(&tags_str).unwrap_or_default();
@@ -45,7 +44,6 @@ impl Database {
claude: enabled_claude, claude: enabled_claude,
codex: enabled_codex, codex: enabled_codex,
gemini: enabled_gemini, gemini: enabled_gemini,
opencode: enabled_opencode,
}, },
description, description,
homepage, homepage,
@@ -70,8 +68,8 @@ impl Database {
conn.execute( conn.execute(
"INSERT OR REPLACE INTO mcp_servers ( "INSERT OR REPLACE INTO mcp_servers (
id, name, server_config, description, homepage, docs, tags, id, name, server_config, description, homepage, docs, tags,
enabled_claude, enabled_codex, enabled_gemini, enabled_opencode enabled_claude, enabled_codex, enabled_gemini
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
params![ params![
server.id, server.id,
server.name, server.name,
@@ -86,7 +84,6 @@ impl Database {
server.apps.claude, server.apps.claude,
server.apps.codex, server.apps.codex,
server.apps.gemini, server.apps.gemini,
server.apps.opencode,
], ],
) )
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
+9 -12
View File
@@ -3,7 +3,7 @@
//! 提供 Skills 和 Skill Repos 的 CRUD 操作。 //! 提供 Skills 和 Skill Repos 的 CRUD 操作。
//! //!
//! v3.10.0+ 统一管理架构: //! v3.10.0+ 统一管理架构:
//! - Skills 使用统一的 id 主键,支持应用启用标志 //! - Skills 使用统一的 id 主键,支持应用启用标志
//! - 实际文件存储在 ~/.cc-switch/skills/,同步到各应用目录 //! - 实际文件存储在 ~/.cc-switch/skills/,同步到各应用目录
use crate::app_config::{InstalledSkill, SkillApps}; use crate::app_config::{InstalledSkill, SkillApps};
@@ -22,7 +22,7 @@ impl Database {
let mut stmt = conn let mut stmt = conn
.prepare( .prepare(
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch, "SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at
FROM skills ORDER BY name ASC", FROM skills ORDER BY name ASC",
) )
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
@@ -42,9 +42,8 @@ impl Database {
claude: row.get(8)?, claude: row.get(8)?,
codex: row.get(9)?, codex: row.get(9)?,
gemini: row.get(10)?, gemini: row.get(10)?,
opencode: row.get(11)?,
}, },
installed_at: row.get(12)?, installed_at: row.get(11)?,
}) })
}) })
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
@@ -63,7 +62,7 @@ impl Database {
let mut stmt = conn let mut stmt = conn
.prepare( .prepare(
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch, "SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at
FROM skills WHERE id = ?1", FROM skills WHERE id = ?1",
) )
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
@@ -82,9 +81,8 @@ impl Database {
claude: row.get(8)?, claude: row.get(8)?,
codex: row.get(9)?, codex: row.get(9)?,
gemini: row.get(10)?, gemini: row.get(10)?,
opencode: row.get(11)?,
}, },
installed_at: row.get(12)?, installed_at: row.get(11)?,
}) })
}); });
@@ -101,8 +99,8 @@ impl Database {
conn.execute( conn.execute(
"INSERT OR REPLACE INTO skills "INSERT OR REPLACE INTO skills
(id, name, description, directory, repo_owner, repo_name, repo_branch, (id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at) readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
params![ params![
skill.id, skill.id,
skill.name, skill.name,
@@ -115,7 +113,6 @@ impl Database {
skill.apps.claude, skill.apps.claude,
skill.apps.codex, skill.apps.codex,
skill.apps.gemini, skill.apps.gemini,
skill.apps.opencode,
skill.installed_at, skill.installed_at,
], ],
) )
@@ -145,8 +142,8 @@ impl Database {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
let affected = conn let affected = conn
.execute( .execute(
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_opencode = ?4 WHERE id = ?5", "UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3 WHERE id = ?4",
params![apps.claude, apps.codex, apps.gemini, apps.opencode, id], params![apps.claude, apps.codex, apps.gemini, id],
) )
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
Ok(affected > 0) Ok(affected > 0)
+1 -1
View File
@@ -47,7 +47,7 @@ const DB_BACKUP_RETAIN: usize = 10;
/// 当前 Schema 版本号 /// 当前 Schema 版本号
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑 /// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
pub(crate) const SCHEMA_VERSION: i32 = 4; pub(crate) const SCHEMA_VERSION: i32 = 3;
/// 安全地序列化 JSON,避免 unwrap panic /// 安全地序列化 JSON,避免 unwrap panic
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> { pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
+1 -31
View File
@@ -58,7 +58,7 @@ impl Database {
id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL, id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL,
description TEXT, homepage TEXT, docs TEXT, tags TEXT NOT NULL DEFAULT '[]', description TEXT, homepage TEXT, docs TEXT, tags TEXT NOT NULL DEFAULT '[]',
enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0, enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0,
enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_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_claude BOOLEAN NOT NULL DEFAULT 0,
enabled_codex BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0,
enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_gemini BOOLEAN NOT NULL DEFAULT 0,
enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
installed_at INTEGER NOT NULL DEFAULT 0 installed_at INTEGER NOT NULL DEFAULT 0
)", )",
[], [],
@@ -347,11 +346,6 @@ impl Database {
Self::migrate_v2_to_v3(conn)?; Self::migrate_v2_to_v3(conn)?;
Self::set_user_version(conn, 3)?; Self::set_user_version(conn, 3)?;
} }
3 => {
log::info!("迁移数据库从 v3 到 v4OpenCode 支持)");
Self::migrate_v3_to_v4(conn)?;
Self::set_user_version(conn, 4)?;
}
_ => { _ => {
return Err(AppError::Database(format!( return Err(AppError::Database(format!(
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}" "未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
@@ -855,30 +849,6 @@ impl Database {
Ok(()) Ok(())
} }
/// v3 -> v4 迁移:添加 OpenCode 支持
///
/// 为 mcp_servers 和 skills 表添加 enabled_opencode 列。
fn migrate_v3_to_v4(conn: &Connection) -> Result<(), AppError> {
// 为 mcp_servers 表添加 enabled_opencode 列
Self::add_column_if_missing(
conn,
"mcp_servers",
"enabled_opencode",
"BOOLEAN NOT NULL DEFAULT 0",
)?;
// 为 skills 表添加 enabled_opencode 列
Self::add_column_if_missing(
conn,
"skills",
"enabled_opencode",
"BOOLEAN NOT NULL DEFAULT 0",
)?;
log::info!("v3 -> v4 迁移完成:已添加 OpenCode 支持");
Ok(())
}
/// 插入默认模型定价数据 /// 插入默认模型定价数据
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation) /// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致 /// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
-2
View File
@@ -166,7 +166,6 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
claude: false, claude: false,
codex: false, codex: false,
gemini: false, gemini: false,
opencode: false,
}; };
for app in apps_str.split(',') { for app in apps_str.split(',') {
@@ -174,7 +173,6 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
"claude" => apps.claude = true, "claude" => apps.claude = true,
"codex" => apps.codex = true, "codex" => apps.codex = true,
"gemini" => apps.gemini = true, "gemini" => apps.gemini = true,
"opencode" => apps.opencode = true,
other => { other => {
return Err(AppError::InvalidInput(format!( return Err(AppError::InvalidInput(format!(
"Invalid app in 'apps': {other}" "Invalid app in 'apps': {other}"
-28
View File
@@ -145,7 +145,6 @@ pub(crate) fn build_provider_from_request(
AppType::Claude => build_claude_settings(request), AppType::Claude => build_claude_settings(request),
AppType::Codex => build_codex_settings(request), AppType::Codex => build_codex_settings(request),
AppType::Gemini => build_gemini_settings(request), AppType::Gemini => build_gemini_settings(request),
AppType::OpenCode => build_opencode_settings(request),
}; };
// Build usage script configuration if provided // Build usage script configuration if provided
@@ -364,33 +363,6 @@ fn build_gemini_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
json!({ "env": env }) json!({ "env": env })
} }
/// Build OpenCode settings configuration
fn build_opencode_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
let endpoint = get_primary_endpoint(request);
// Build options object
let mut options = serde_json::Map::new();
if !endpoint.is_empty() {
options.insert("baseURL".to_string(), json!(endpoint));
}
if let Some(api_key) = &request.api_key {
options.insert("apiKey".to_string(), json!(api_key));
}
// Build models object
let mut models = serde_json::Map::new();
if let Some(model) = &request.model {
models.insert(model.clone(), json!({ "name": model }));
}
// Default to openai-compatible npm package
json!({
"npm": "@ai-sdk/openai-compatible",
"options": options,
"models": models
})
}
// ============================================================================= // =============================================================================
// Config Merge Logic // Config Merge Logic
// ============================================================================= // =============================================================================
-24
View File
@@ -13,7 +13,6 @@ mod gemini_config;
mod gemini_mcp; mod gemini_mcp;
mod init_status; mod init_status;
mod mcp; mod mcp;
mod opencode_config;
mod panic_hook; mod panic_hook;
mod prompt; mod prompt;
mod prompt_files; mod prompt_files;
@@ -483,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 服务器配置(表空时触发) // 3. 导入 MCP 服务器配置(表空时触发)
if app_state.db.is_mcp_table_empty().unwrap_or(false) { if app_state.db.is_mcp_table_empty().unwrap_or(false) {
log::info!("MCP table empty, importing from live configurations..."); log::info!("MCP table empty, importing from live configurations...");
@@ -521,14 +509,6 @@ pub fn run() {
Ok(_) => log::debug!("○ No Gemini MCP servers found to import"), Ok(_) => log::debug!("○ No Gemini MCP servers found to import"),
Err(e) => log::warn!("✗ Failed to import Gemini MCP: {e}"), Err(e) => log::warn!("✗ Failed to import Gemini MCP: {e}"),
} }
match crate::services::mcp::McpService::import_from_opencode(&app_state) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} MCP server(s) from OpenCode");
}
Ok(_) => log::debug!("○ No OpenCode MCP servers found to import"),
Err(e) => log::warn!("✗ Failed to import OpenCode MCP: {e}"),
}
} }
// 4. 导入提示词文件(表空时触发) // 4. 导入提示词文件(表空时触发)
@@ -732,7 +712,6 @@ pub fn run() {
commands::add_provider, commands::add_provider,
commands::update_provider, commands::update_provider,
commands::delete_provider, commands::delete_provider,
commands::remove_provider_from_live_config,
commands::switch_provider, commands::switch_provider,
commands::import_default_config, commands::import_default_config,
commands::get_claude_config_status, commands::get_claude_config_status,
@@ -895,9 +874,6 @@ pub fn run() {
commands::upsert_universal_provider, commands::upsert_universal_provider,
commands::delete_universal_provider, commands::delete_universal_provider,
commands::sync_universal_provider, commands::sync_universal_provider,
// OpenCode specific
commands::import_opencode_providers_from_live,
commands::get_opencode_live_provider_ids,
// Global upstream proxy // Global upstream proxy
commands::get_global_proxy_url, commands::get_global_proxy_url,
commands::set_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, claude: true,
codex: false, codex: false,
gemini: false, gemini: false,
opencode: false,
}, },
description: None, description: None,
homepage: None, homepage: None,
-1
View File
@@ -235,7 +235,6 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
claude: false, claude: false,
codex: true, codex: true,
gemini: false, gemini: false,
opencode: false,
}, },
description: None, description: None,
homepage: None, homepage: None,
-1
View File
@@ -87,7 +87,6 @@ pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result<usize, AppError
claude: false, claude: false,
codex: false, codex: false,
gemini: true, gemini: true,
opencode: false,
}, },
description: None, description: None,
homepage: None, homepage: None,
-5
View File
@@ -8,12 +8,10 @@
//! - `claude` - Claude MCP 同步和导入 //! - `claude` - Claude MCP 同步和导入
//! - `codex` - Codex MCP 同步和导入(含 TOML 转换) //! - `codex` - Codex MCP 同步和导入(含 TOML 转换)
//! - `gemini` - Gemini MCP 同步和导入 //! - `gemini` - Gemini MCP 同步和导入
//! - `opencode` - OpenCode MCP 同步和导入(含 local/remote 格式转换)
mod claude; mod claude;
mod codex; mod codex;
mod gemini; mod gemini;
mod opencode;
mod validation; mod validation;
// 重新导出公共 API // 重新导出公共 API
@@ -28,6 +26,3 @@ pub use gemini::{
import_from_gemini, remove_server_from_gemini, sync_enabled_to_gemini, import_from_gemini, remove_server_from_gemini, sync_enabled_to_gemini,
sync_single_server_to_gemini, sync_single_server_to_gemini,
}; };
pub use opencode::{
import_from_opencode, remove_server_from_opencode, sync_single_server_to_opencode,
};
-358
View File
@@ -1,358 +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 '{}' after conversion: {}", id, 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 '{}' enabled for OpenCode", id);
}
} 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 '{}' from OpenCode", id);
}
}
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");
}
}
-222
View File
@@ -1,222 +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)
}
-3
View File
@@ -5,7 +5,6 @@ use crate::codex_config::get_codex_auth_path;
use crate::config::get_claude_settings_path; use crate::config::get_claude_settings_path;
use crate::error::AppError; use crate::error::AppError;
use crate::gemini_config::get_gemini_dir; use crate::gemini_config::get_gemini_dir;
use crate::opencode_config::get_opencode_dir;
/// 返回指定应用所使用的提示词文件路径。 /// 返回指定应用所使用的提示词文件路径。
pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> { pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
@@ -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::Claude => get_base_dir_with_fallback(get_claude_settings_path(), ".claude")?,
AppType::Codex => get_base_dir_with_fallback(get_codex_auth_path(), ".codex")?, AppType::Codex => get_base_dir_with_fallback(get_codex_auth_path(), ".codex")?,
AppType::Gemini => get_gemini_dir(), AppType::Gemini => get_gemini_dir(),
AppType::OpenCode => get_opencode_dir(),
}; };
let filename = match app { let filename = match app {
AppType::Claude => "CLAUDE.md", AppType::Claude => "CLAUDE.md",
AppType::Codex => "AGENTS.md", AppType::Codex => "AGENTS.md",
AppType::Gemini => "GEMINI.md", AppType::Gemini => "GEMINI.md",
AppType::OpenCode => "AGENTS.md",
}; };
Ok(base_dir.join(filename)) Ok(base_dir.join(filename))
-92
View File
@@ -462,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>,
}
-8
View File
@@ -132,10 +132,6 @@ impl ProviderType {
} }
ProviderType::Gemini ProviderType::Gemini
} }
AppType::OpenCode => {
// OpenCode doesn't support proxy, but return a default type for completeness
ProviderType::Codex // Fallback to Codex-like type
}
} }
} }
@@ -180,10 +176,6 @@ pub fn get_adapter(app_type: &AppType) -> Box<dyn ProviderAdapter> {
AppType::Claude => Box::new(ClaudeAdapter::new()), AppType::Claude => Box::new(ClaudeAdapter::new()),
AppType::Codex => Box::new(CodexAdapter::new()), AppType::Codex => Box::new(CodexAdapter::new()),
AppType::Gemini => Box::new(GeminiAdapter::new()), AppType::Gemini => Box::new(GeminiAdapter::new()),
AppType::OpenCode => {
// OpenCode doesn't support proxy, fallback to Codex adapter
Box::new(CodexAdapter::new())
}
} }
} }
-4
View File
@@ -122,10 +122,6 @@ impl ConfigService {
AppType::Codex => Self::sync_codex_live(config, &current_id, &provider)?, AppType::Codex => Self::sync_codex_live(config, &current_id, &provider)?,
AppType::Claude => Self::sync_claude_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::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(()) Ok(())
-51
View File
@@ -37,9 +37,6 @@ impl McpService {
if prev_apps.gemini && !server.apps.gemini { if prev_apps.gemini && !server.apps.gemini {
Self::remove_server_from_app(state, &server.id, &AppType::Gemini)?; Self::remove_server_from_app(state, &server.id, &AppType::Gemini)?;
} }
if prev_apps.opencode && !server.apps.opencode {
Self::remove_server_from_app(state, &server.id, &AppType::OpenCode)?;
}
// 同步到各个启用的应用 // 同步到各个启用的应用
Self::sync_server_to_apps(state, &server)?; Self::sync_server_to_apps(state, &server)?;
@@ -116,13 +113,6 @@ impl McpService {
AppType::Gemini => { AppType::Gemini => {
mcp::sync_single_server_to_gemini(&Default::default(), &server.id, &server.server)?; mcp::sync_single_server_to_gemini(&Default::default(), &server.id, &server.server)?;
} }
AppType::OpenCode => {
mcp::sync_single_server_to_opencode(
&Default::default(),
&server.id,
&server.server,
)?;
}
} }
Ok(()) Ok(())
} }
@@ -145,9 +135,6 @@ impl McpService {
AppType::Claude => mcp::remove_server_from_claude(id)?, AppType::Claude => mcp::remove_server_from_claude(id)?,
AppType::Codex => mcp::remove_server_from_codex(id)?, AppType::Codex => mcp::remove_server_from_codex(id)?,
AppType::Gemini => mcp::remove_server_from_gemini(id)?, AppType::Gemini => mcp::remove_server_from_gemini(id)?,
AppType::OpenCode => {
mcp::remove_server_from_opencode(id)?;
}
} }
Ok(()) Ok(())
} }
@@ -324,42 +311,4 @@ impl McpService {
Ok(new_count) 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)
}
} }
-172
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 // Delegate to write_gemini_live which handles env file writing correctly
write_gemini_live(provider)?; write_gemini_live(provider)?;
} }
AppType::OpenCode => {
// OpenCode uses additive mode - write provider to config
use crate::opencode_config;
use crate::provider::OpenCodeProviderConfig;
// Defensive check: if settings_config is a full config structure, extract provider fragment
let config_to_write = if let Some(obj) = provider.settings_config.as_object() {
// Detect full config structure (has $schema or top-level provider field)
if obj.contains_key("$schema") || obj.contains_key("provider") {
log::warn!(
"OpenCode provider '{}' has full config structure in settings_config, attempting to extract fragment",
provider.id
);
// Try to extract from provider.{id}
obj.get("provider")
.and_then(|p| p.get(&provider.id))
.cloned()
.unwrap_or_else(|| provider.settings_config.clone())
} else {
provider.settings_config.clone()
}
} else {
provider.settings_config.clone()
};
// Convert settings_config to OpenCodeProviderConfig
let opencode_config_result =
serde_json::from_value::<OpenCodeProviderConfig>(config_to_write.clone());
match opencode_config_result {
Ok(config) => {
opencode_config::set_typed_provider(&provider.id, &config)?;
log::info!("OpenCode provider '{}' written to live config", provider.id);
}
Err(e) => {
log::warn!(
"Failed to parse OpenCode provider config for '{}': {}",
provider.id,
e
);
// Only write if config looks like a valid provider fragment
if config_to_write.get("npm").is_some()
|| config_to_write.get("options").is_some()
{
opencode_config::set_provider(&provider.id, config_to_write)?;
log::info!(
"OpenCode provider '{}' written as raw JSON to live config",
provider.id
);
} else {
log::error!(
"OpenCode provider '{}' has invalid config structure, skipping write",
provider.id
);
}
}
}
}
} }
Ok(()) Ok(())
} }
@@ -278,21 +220,6 @@ pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
"config": config_obj "config": config_obj
})) }))
} }
AppType::OpenCode => {
use crate::opencode_config::{get_opencode_config_path, read_opencode_config};
let config_path = get_opencode_config_path();
if !config_path.exists() {
return Err(AppError::localized(
"opencode.config.missing",
"OpenCode 配置文件不存在",
"OpenCode configuration file not found",
));
}
let config = read_opencode_config()?;
Ok(config)
}
} }
} }
@@ -368,24 +295,6 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
"config": config_obj "config": config_obj
}) })
} }
AppType::OpenCode => {
// OpenCode uses additive mode - import from live is not the same pattern
// For now, return an empty config structure
use crate::opencode_config::{get_opencode_config_path, read_opencode_config};
let config_path = get_opencode_config_path();
if !config_path.exists() {
return Err(AppError::localized(
"opencode.live.missing",
"OpenCode 配置文件不存在",
"OpenCode configuration file is missing",
));
}
// For OpenCode, we return the full config - but note that OpenCode
// uses additive mode, so importing defaults works differently
read_opencode_config()?
}
}; };
let mut provider = Provider::with_id( let mut provider = Provider::with_id(
@@ -490,84 +399,3 @@ pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
Ok(()) Ok(())
} }
/// Remove an OpenCode provider from the live configuration
///
/// This is specific to OpenCode's additive mode - removing a provider
/// from the opencode.json file.
pub(crate) fn remove_opencode_provider_from_live(provider_id: &str) -> Result<(), AppError> {
use crate::opencode_config;
// Check if OpenCode config directory exists
if !opencode_config::get_opencode_dir().exists() {
log::debug!(
"OpenCode config directory doesn't exist, skipping removal of '{}'",
provider_id
);
return Ok(());
}
opencode_config::remove_provider(provider_id)?;
log::info!(
"OpenCode provider '{}' removed from live config",
provider_id
);
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 '{}' already exists in database, skipping",
id
);
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 '{}' from live config", id);
}
Ok(imported)
}
+18 -147
View File
@@ -20,16 +20,13 @@ use crate::settings::CustomEndpoint;
use crate::store::AppState; use crate::store::AppState;
// Re-export sub-module functions for external access // Re-export sub-module functions for external access
pub use live::{ pub use live::{import_default_config, read_live_settings, sync_current_to_live};
import_default_config, import_opencode_providers_from_live, read_live_settings,
sync_current_to_live,
};
// Internal re-exports (pub(crate)) // Internal re-exports (pub(crate))
pub(crate) use live::write_live_snapshot; pub(crate) use live::write_live_snapshot;
// Internal re-exports // Internal re-exports
use live::{remove_opencode_provider_from_live, write_gemini_live}; use live::write_gemini_live;
use usage::validate_usage_script; use usage::validate_usage_script;
/// Provider business logic service /// Provider business logic service
@@ -140,13 +137,7 @@ impl ProviderService {
/// 使用有效的当前供应商 ID(验证过存在性)。 /// 使用有效的当前供应商 ID(验证过存在性)。
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。 /// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
/// 这确保了云同步场景下多设备可以独立选择供应商,且返回的 ID 一定有效。 /// 这确保了云同步场景下多设备可以独立选择供应商,且返回的 ID 一定有效。
///
/// 对于 OpenCode(累加模式),不存在"当前供应商"概念,直接返回空字符串。
pub fn current(state: &AppState, app_type: AppType) -> Result<String, AppError> { pub fn current(state: &AppState, app_type: AppType) -> Result<String, AppError> {
// OpenCode uses additive mode - no "current" provider concept
if matches!(app_type, AppType::OpenCode) {
return Ok(String::new());
}
crate::settings::get_effective_current_provider(&state.db, &app_type) crate::settings::get_effective_current_provider(&state.db, &app_type)
.map(|opt| opt.unwrap_or_default()) .map(|opt| opt.unwrap_or_default())
} }
@@ -161,13 +152,7 @@ impl ProviderService {
// Save to database // Save to database
state.db.save_provider(app_type.as_str(), &provider)?; state.db.save_provider(app_type.as_str(), &provider)?;
// OpenCode uses additive mode - always write to live config // Check if sync is needed (if this is current provider, or no current provider)
if matches!(app_type, AppType::OpenCode) {
write_live_snapshot(&app_type, &provider)?;
return Ok(true);
}
// For other apps: Check if sync is needed (if this is current provider, or no current provider)
let current = state.db.get_current_provider(app_type.as_str())?; let current = state.db.get_current_provider(app_type.as_str())?;
if current.is_none() { if current.is_none() {
// No current provider, set as current and sync // No current provider, set as current and sync
@@ -191,20 +176,14 @@ impl ProviderService {
Self::normalize_provider_if_claude(&app_type, &mut provider); Self::normalize_provider_if_claude(&app_type, &mut provider);
Self::validate_provider_settings(&app_type, &provider)?; Self::validate_provider_settings(&app_type, &provider)?;
// Save to database // Check if this is current provider (use effective current, not just DB)
state.db.save_provider(app_type.as_str(), &provider)?;
// OpenCode uses additive mode - always update in live config
if matches!(app_type, AppType::OpenCode) {
write_live_snapshot(&app_type, &provider)?;
return Ok(true);
}
// For other apps: Check if this is current provider (use effective current, not just DB)
let effective_current = let effective_current =
crate::settings::get_effective_current_provider(&state.db, &app_type)?; crate::settings::get_effective_current_provider(&state.db, &app_type)?;
let is_current = effective_current.as_deref() == Some(provider.id.as_str()); let is_current = effective_current.as_deref() == Some(provider.id.as_str());
// Save to database
state.db.save_provider(app_type.as_str(), &provider)?;
if is_current { if is_current {
// 如果代理接管模式处于激活状态,并且代理服务正在运行: // 如果代理接管模式处于激活状态,并且代理服务正在运行:
// - 不写 Live 配置(否则会破坏接管) // - 不写 Live 配置(否则会破坏接管)
@@ -237,18 +216,8 @@ impl ProviderService {
/// Delete a provider /// Delete a provider
/// ///
/// 同时检查本地 settings 和数据库的当前供应商,防止删除任一端正在使用的供应商。 /// 同时检查本地 settings 和数据库的当前供应商,防止删除任一端正在使用的供应商。
/// 对于 OpenCode(累加模式),可以随时删除任意供应商,同时从 live 配置中移除。
pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> { pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
// OpenCode uses additive mode - no current provider concept // Check both local settings and database
if matches!(app_type, AppType::OpenCode) {
// Remove from database
state.db.delete_provider(app_type.as_str(), id)?;
// Also remove from live config
remove_opencode_provider_from_live(id)?;
return Ok(());
}
// For other apps: Check both local settings and database
let local_current = crate::settings::get_current_provider(&app_type); let local_current = crate::settings::get_current_provider(&app_type);
let db_current = state.db.get_current_provider(app_type.as_str())?; let db_current = state.db.get_current_provider(app_type.as_str())?;
@@ -261,27 +230,6 @@ impl ProviderService {
state.db.delete_provider(app_type.as_str(), id) state.db.delete_provider(app_type.as_str(), id)
} }
/// Remove provider from live config only (for additive mode apps like OpenCode)
///
/// Does NOT delete from database - provider remains in the list.
/// This is used when user wants to "remove" a provider from active config
/// but keep it available for future use.
pub fn remove_from_live_config(app_type: AppType, id: &str) -> Result<(), AppError> {
match app_type {
AppType::OpenCode => {
remove_opencode_provider_from_live(id)?;
}
// Future: add other additive mode apps here
_ => {
return Err(AppError::Message(format!(
"App {} does not support remove from live config",
app_type.as_str()
)));
}
}
Ok(())
}
/// Switch to a provider /// Switch to a provider
/// ///
/// Switch flow: /// Switch flow:
@@ -378,29 +326,22 @@ impl ProviderService {
if let Some(current_id) = current_id { if let Some(current_id) = current_id {
if current_id != id { if current_id != id {
// OpenCode uses additive mode - all providers coexist in the same file, // Only backfill when switching to a different provider
// no backfill needed (backfill is for exclusive mode apps like Claude/Codex/Gemini) if let Ok(live_config) = read_live_settings(app_type.clone()) {
if !matches!(app_type, AppType::OpenCode) { if let Some(mut current_provider) = providers.get(&current_id).cloned() {
// Only backfill when switching to a different provider current_provider.settings_config = live_config;
if let Ok(live_config) = read_live_settings(app_type.clone()) { // Ignore backfill failure, don't affect switch flow
if let Some(mut current_provider) = providers.get(&current_id).cloned() { let _ = state.db.save_provider(app_type.as_str(), &current_provider);
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) // Update local settings (device-level, takes priority)
if !matches!(app_type, AppType::OpenCode) { 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) // Update database is_current (as default for new devices)
state.db.set_current_provider(app_type.as_str(), id)?; state.db.set_current_provider(app_type.as_str(), id)?;
}
// Sync to live (write_gemini_live handles security flag internally for Gemini) // Sync to live (write_gemini_live handles security flag internally for Gemini)
write_live_snapshot(&app_type, provider)?; write_live_snapshot(&app_type, provider)?;
@@ -439,7 +380,6 @@ impl ProviderService {
AppType::Claude => Self::extract_claude_common_config(&provider.settings_config), AppType::Claude => Self::extract_claude_common_config(&provider.settings_config),
AppType::Codex => Self::extract_codex_common_config(&provider.settings_config), AppType::Codex => Self::extract_codex_common_config(&provider.settings_config),
AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config), AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config),
AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config),
} }
} }
@@ -452,7 +392,6 @@ impl ProviderService {
AppType::Claude => Self::extract_claude_common_config(settings_config), AppType::Claude => Self::extract_claude_common_config(settings_config),
AppType::Codex => Self::extract_codex_common_config(settings_config), AppType::Codex => Self::extract_codex_common_config(settings_config),
AppType::Gemini => Self::extract_gemini_common_config(settings_config), AppType::Gemini => Self::extract_gemini_common_config(settings_config),
AppType::OpenCode => Self::extract_opencode_common_config(settings_config),
} }
} }
@@ -586,29 +525,6 @@ impl ProviderService {
.map_err(|e| AppError::Message(format!("Serialization failed: {e}"))) .map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
} }
/// Extract common config for OpenCode (JSON format)
fn extract_opencode_common_config(settings: &Value) -> Result<String, AppError> {
// OpenCode uses a different config structure with npm, options, models
// For common config, we exclude provider-specific fields like apiKey
let mut config = settings.clone();
// Remove provider-specific fields
if let Some(obj) = config.as_object_mut() {
if let Some(options) = obj.get_mut("options").and_then(|v| v.as_object_mut()) {
options.remove("apiKey");
options.remove("baseURL");
}
// Keep npm and models as they might be common
}
if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) {
return Ok("{}".to_string());
}
serde_json::to_string_pretty(&config)
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
}
/// Import default configuration from live files (re-export) /// Import default configuration from live files (re-export)
/// ///
/// Returns `Ok(true)` if imported, `Ok(false)` if skipped. /// Returns `Ok(true)` if imported, `Ok(false)` if skipped.
@@ -775,17 +691,6 @@ impl ProviderService {
use crate::gemini_config::validate_gemini_settings; use crate::gemini_config::validate_gemini_settings;
validate_gemini_settings(&provider.settings_config)? validate_gemini_settings(&provider.settings_config)?
} }
AppType::OpenCode => {
// OpenCode uses a different config structure: { npm, options, models }
// Basic validation - must be an object
if !provider.settings_config.is_object() {
return Err(AppError::localized(
"provider.opencode.settings.not_object",
"OpenCode 配置必须是 JSON 对象",
"OpenCode configuration must be a JSON object",
));
}
}
} }
// Validate and clean UsageScript configuration (common for all app types) // Validate and clean UsageScript configuration (common for all app types)
@@ -923,40 +828,6 @@ impl ProviderService {
Ok((api_key, base_url)) Ok((api_key, base_url))
} }
AppType::OpenCode => {
// OpenCode uses options.apiKey and options.baseURL
let options = provider
.settings_config
.get("options")
.and_then(|v| v.as_object())
.ok_or_else(|| {
AppError::localized(
"provider.opencode.options.missing",
"配置格式错误: 缺少 options",
"Invalid configuration: missing options section",
)
})?;
let api_key = options
.get("apiKey")
.and_then(|v| v.as_str())
.ok_or_else(|| {
AppError::localized(
"provider.opencode.api_key.missing",
"缺少 API Key",
"API key is missing",
)
})?
.to_string();
let base_url = options
.get("baseURL")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Ok((api_key, base_url))
}
} }
} }
} }
-33
View File
@@ -368,10 +368,6 @@ impl ProxyService {
AppType::Claude => self.read_claude_live()?, AppType::Claude => self.read_claude_live()?,
AppType::Codex => self.read_codex_live()?, AppType::Codex => self.read_codex_live()?,
AppType::Gemini => self.read_gemini_live()?, AppType::Gemini => self.read_gemini_live()?,
AppType::OpenCode => {
// OpenCode doesn't support proxy features
return Err("OpenCode 不支持代理功能".to_string());
}
}; };
self.sync_live_config_to_provider(app_type, &live_config) self.sync_live_config_to_provider(app_type, &live_config)
@@ -585,9 +581,6 @@ impl ProxyService {
} }
} }
} }
AppType::OpenCode => {
// OpenCode doesn't support proxy features, skip silently
}
} }
Ok(()) Ok(())
@@ -766,10 +759,6 @@ impl ProxyService {
AppType::Claude => ("claude", self.read_claude_live()?), AppType::Claude => ("claude", self.read_claude_live()?),
AppType::Codex => ("codex", self.read_codex_live()?), AppType::Codex => ("codex", self.read_codex_live()?),
AppType::Gemini => ("gemini", self.read_gemini_live()?), AppType::Gemini => ("gemini", self.read_gemini_live()?),
AppType::OpenCode => {
// OpenCode doesn't support proxy features
return Err("OpenCode 不支持代理功能".to_string());
}
}; };
let json_str = serde_json::to_string(&config) let json_str = serde_json::to_string(&config)
@@ -978,10 +967,6 @@ impl ProxyService {
self.write_gemini_live(&live_config)?; self.write_gemini_live(&live_config)?;
log::info!("Gemini Live 配置已接管,代理地址: {proxy_url}"); log::info!("Gemini Live 配置已接管,代理地址: {proxy_url}");
} }
AppType::OpenCode => {
// OpenCode doesn't support proxy features
return Err("OpenCode 不支持代理功能".to_string());
}
} }
Ok(()) Ok(())
@@ -1065,9 +1050,6 @@ impl ProxyService {
let _ = self.write_gemini_live(&live_config); let _ = self.write_gemini_live(&live_config);
} }
} }
AppType::OpenCode => {
// OpenCode doesn't support proxy features, skip silently
}
} }
Ok(()) Ok(())
@@ -1100,9 +1082,6 @@ impl ProxyService {
log::info!("Gemini Live 配置已恢复"); log::info!("Gemini Live 配置已恢复");
} }
} }
AppType::OpenCode => {
// OpenCode doesn't support proxy features, skip silently
}
} }
Ok(()) Ok(())
@@ -1182,10 +1161,6 @@ impl ProxyService {
AppType::Claude => self.write_claude_live(config), AppType::Claude => self.write_claude_live(config),
AppType::Codex => self.write_codex_live(config), AppType::Codex => self.write_codex_live(config),
AppType::Gemini => self.write_gemini_live(config), AppType::Gemini => self.write_gemini_live(config),
AppType::OpenCode => {
// OpenCode doesn't support proxy features
Err("OpenCode 不支持代理功能".to_string())
}
} }
} }
@@ -1203,10 +1178,6 @@ impl ProxyService {
Ok(config) => Self::is_gemini_live_taken_over(&config), Ok(config) => Self::is_gemini_live_taken_over(&config),
Err(_) => false, Err(_) => false,
}, },
AppType::OpenCode => {
// OpenCode doesn't support proxy takeover
false
}
} }
} }
@@ -1246,10 +1217,6 @@ impl ProxyService {
AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(), AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),
AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(), AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),
AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(), AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),
AppType::OpenCode => {
// OpenCode doesn't support proxy features
Ok(())
}
} }
} }
+4 -13
View File
@@ -183,11 +183,6 @@ impl SkillService {
return Ok(custom.join("skills")); return Ok(custom.join("skills"));
} }
} }
AppType::OpenCode => {
if let Some(custom) = crate::settings::get_opencode_override_dir() {
return Ok(custom.join("skills"));
}
}
} }
// 默认路径:回退到用户主目录下的标准位置 // 默认路径:回退到用户主目录下的标准位置
@@ -201,7 +196,6 @@ impl SkillService {
AppType::Claude => home.join(".claude").join("skills"), AppType::Claude => home.join(".claude").join("skills"),
AppType::Codex => home.join(".codex").join("skills"), AppType::Codex => home.join(".codex").join("skills"),
AppType::Gemini => home.join(".gemini").join("skills"), AppType::Gemini => home.join(".gemini").join("skills"),
AppType::OpenCode => home.join(".config").join("opencode").join("skills"),
}) })
} }
@@ -323,7 +317,7 @@ impl SkillService {
.ok_or_else(|| anyhow!("Skill not found: {id}"))?; .ok_or_else(|| anyhow!("Skill not found: {id}"))?;
// 从所有应用目录删除 // 从所有应用目录删除
for app in [AppType::Claude, AppType::Codex, AppType::Gemini, AppType::OpenCode] { for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
let _ = Self::remove_from_app(&skill.directory, &app); let _ = Self::remove_from_app(&skill.directory, &app);
} }
@@ -382,7 +376,7 @@ impl SkillService {
let mut unmanaged: HashMap<String, UnmanagedSkill> = HashMap::new(); let mut unmanaged: HashMap<String, UnmanagedSkill> = HashMap::new();
for app in [AppType::Claude, AppType::Codex, AppType::Gemini, AppType::OpenCode] { for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
let app_dir = match Self::get_app_skills_dir(&app) { let app_dir = match Self::get_app_skills_dir(&app) {
Ok(d) => d, Ok(d) => d,
Err(_) => continue, Err(_) => continue,
@@ -431,7 +425,6 @@ impl SkillService {
AppType::Claude => "claude", AppType::Claude => "claude",
AppType::Codex => "codex", AppType::Codex => "codex",
AppType::Gemini => "gemini", AppType::Gemini => "gemini",
AppType::OpenCode => "opencode",
}; };
unmanaged unmanaged
@@ -464,7 +457,7 @@ impl SkillService {
let mut source_path: Option<PathBuf> = None; let mut source_path: Option<PathBuf> = None;
let mut found_in: Vec<String> = Vec::new(); let mut found_in: Vec<String> = Vec::new();
for app in [AppType::Claude, AppType::Codex, AppType::Gemini, AppType::OpenCode] { for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
if let Ok(app_dir) = Self::get_app_skills_dir(&app) { if let Ok(app_dir) = Self::get_app_skills_dir(&app) {
let skill_path = app_dir.join(&dir_name); let skill_path = app_dir.join(&dir_name);
if skill_path.exists() { if skill_path.exists() {
@@ -475,7 +468,6 @@ impl SkillService {
AppType::Claude => "claude", AppType::Claude => "claude",
AppType::Codex => "codex", AppType::Codex => "codex",
AppType::Gemini => "gemini", AppType::Gemini => "gemini",
AppType::OpenCode => "opencode",
}; };
found_in.push(app_str.to_string()); found_in.push(app_str.to_string());
} }
@@ -514,7 +506,6 @@ impl SkillService {
"claude" => apps.claude = true, "claude" => apps.claude = true,
"codex" => apps.codex = true, "codex" => apps.codex = true,
"gemini" => apps.gemini = true, "gemini" => apps.gemini = true,
"opencode" => apps.opencode = true,
_ => {} _ => {}
} }
} }
@@ -985,7 +976,7 @@ pub fn migrate_skills_to_ssot(db: &Arc<Database>) -> Result<usize> {
let mut discovered: HashMap<String, SkillApps> = HashMap::new(); let mut discovered: HashMap<String, SkillApps> = HashMap::new();
// 扫描各应用目录 // 扫描各应用目录
for app in [AppType::Claude, AppType::Codex, AppType::Gemini, AppType::OpenCode] { for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
let app_dir = match SkillService::get_app_skills_dir(&app) { let app_dir = match SkillService::get_app_skills_dir(&app) {
Ok(d) => d, Ok(d) => d,
Err(_) => continue, Err(_) => continue,
-23
View File
@@ -185,14 +185,6 @@ impl StreamCheckService {
) )
.await .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; let response_time = start.elapsed().as_millis() as u64;
@@ -485,24 +477,9 @@ impl StreamCheckService {
} }
AppType::Gemini => Self::extract_env_model(provider, "GEMINI_MODEL") AppType::Gemini => Self::extract_env_model(provider, "GEMINI_MODEL")
.unwrap_or_else(|| config.gemini_model.clone()), .unwrap_or_else(|| config.gemini_model.clone()),
AppType::OpenCode => {
// OpenCode uses models map in settings_config
// Try to extract first model from the models object
Self::extract_opencode_model(provider).unwrap_or_else(|| "gpt-4o".to_string())
}
} }
} }
fn extract_opencode_model(provider: &Provider) -> Option<String> {
let models = provider
.settings_config
.get("models")
.and_then(|m| m.as_object())?;
// Return the first model ID from the models map
models.keys().next().map(|s| s.to_string())
}
fn extract_env_model(provider: &Provider, key: &str) -> Option<String> { fn extract_env_model(provider: &Provider, key: &str) -> Option<String> {
provider provider
.settings_config .settings_config
-24
View File
@@ -47,8 +47,6 @@ pub struct AppSettings {
pub codex_config_dir: Option<String>, pub codex_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub gemini_config_dir: Option<String>, pub gemini_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opencode_config_dir: Option<String>,
// ===== 当前供应商 ID(设备级)===== // ===== 当前供应商 ID(设备级)=====
/// 当前 Claude 供应商 ID(本地存储,优先于数据库 is_current /// 当前 Claude 供应商 ID(本地存储,优先于数据库 is_current
@@ -60,9 +58,6 @@ pub struct AppSettings {
/// 当前 Gemini 供应商 ID(本地存储,优先于数据库 is_current /// 当前 Gemini 供应商 ID(本地存储,优先于数据库 is_current
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub current_provider_gemini: Option<String>, pub current_provider_gemini: Option<String>,
/// 当前 OpenCode 供应商 ID(本地存储,对 OpenCode 可能无意义,但保持结构一致)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_provider_opencode: Option<String>,
} }
fn default_show_in_tray() -> bool { fn default_show_in_tray() -> bool {
@@ -89,11 +84,9 @@ impl Default for AppSettings {
claude_config_dir: None, claude_config_dir: None,
codex_config_dir: None, codex_config_dir: None,
gemini_config_dir: None, gemini_config_dir: None,
opencode_config_dir: None,
current_provider_claude: None, current_provider_claude: None,
current_provider_codex: None, current_provider_codex: None,
current_provider_gemini: None, current_provider_gemini: None,
current_provider_opencode: None,
} }
} }
} }
@@ -126,13 +119,6 @@ impl AppSettings {
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.map(|s| s.to_string()); .map(|s| s.to_string());
self.opencode_config_dir = self
.opencode_config_dir
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
self.language = self self.language = self
.language .language
.as_ref() .as_ref()
@@ -265,14 +251,6 @@ pub fn get_gemini_override_dir() -> Option<PathBuf> {
.map(|p| resolve_override_path(p)) .map(|p| resolve_override_path(p))
} }
pub fn get_opencode_override_dir() -> Option<PathBuf> {
let settings = settings_store().read().ok()?;
settings
.opencode_config_dir
.as_ref()
.map(|p| resolve_override_path(p))
}
// ===== 当前供应商管理函数 ===== // ===== 当前供应商管理函数 =====
/// 获取指定应用类型的当前供应商 ID(从本地 settings 读取) /// 获取指定应用类型的当前供应商 ID(从本地 settings 读取)
@@ -285,7 +263,6 @@ pub fn get_current_provider(app_type: &AppType) -> Option<String> {
AppType::Claude => settings.current_provider_claude.clone(), AppType::Claude => settings.current_provider_claude.clone(),
AppType::Codex => settings.current_provider_codex.clone(), AppType::Codex => settings.current_provider_codex.clone(),
AppType::Gemini => settings.current_provider_gemini.clone(), AppType::Gemini => settings.current_provider_gemini.clone(),
AppType::OpenCode => settings.current_provider_opencode.clone(),
} }
} }
@@ -300,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::Claude => settings.current_provider_claude = id.map(|s| s.to_string()),
AppType::Codex => settings.current_provider_codex = id.map(|s| s.to_string()), AppType::Codex => settings.current_provider_codex = id.map(|s| s.to_string()),
AppType::Gemini => settings.current_provider_gemini = id.map(|s| s.to_string()), AppType::Gemini => settings.current_provider_gemini = id.map(|s| s.to_string()),
AppType::OpenCode => settings.current_provider_opencode = id.map(|s| s.to_string()),
} }
update_settings(settings) update_settings(settings)
-2
View File
@@ -553,7 +553,6 @@ command = "echo"
claude: false, claude: false,
codex: false, // 初始未启用 codex: false, // 初始未启用
gemini: false, gemini: false,
opencode: false,
}, },
description: None, description: None,
homepage: None, homepage: None,
@@ -681,7 +680,6 @@ fn import_from_claude_merges_into_config() {
claude: false, // 初始未启用 claude: false, // 初始未启用
codex: false, codex: false,
gemini: false, gemini: false,
opencode: false,
}, },
description: None, description: None,
homepage: None, homepage: None,
-6
View File
@@ -214,7 +214,6 @@ fn set_mcp_enabled_for_codex_writes_live_config() {
claude: false, claude: false,
codex: false, // 初始未启用 codex: false, // 初始未启用
gemini: false, gemini: false,
opencode: false,
}, },
description: None, description: None,
homepage: None, homepage: None,
@@ -278,7 +277,6 @@ fn enabling_codex_mcp_skips_when_codex_dir_missing() {
claude: false, claude: false,
codex: false, codex: false,
gemini: false, gemini: false,
opencode: false,
}, },
description: None, description: None,
homepage: None, homepage: None,
@@ -322,7 +320,6 @@ fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() {
claude: true, claude: true,
codex: false, codex: false,
gemini: false, gemini: false,
opencode: false,
}, },
description: None, description: None,
homepage: None, homepage: None,
@@ -355,7 +352,6 @@ fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() {
claude: false, claude: false,
codex: false, codex: false,
gemini: false, gemini: false,
opencode: false,
}, },
description: None, description: None,
homepage: None, homepage: None,
@@ -487,7 +483,6 @@ fn enabling_gemini_mcp_skips_when_gemini_dir_missing() {
claude: false, claude: false,
codex: false, codex: false,
gemini: false, gemini: false,
opencode: false,
}, },
description: None, description: None,
homepage: None, homepage: None,
@@ -541,7 +536,6 @@ fn enabling_claude_mcp_skips_when_claude_config_absent() {
claude: false, claude: false,
codex: false, codex: false,
gemini: false, gemini: false,
opencode: false,
}, },
description: None, description: None,
homepage: None, homepage: None,
-1
View File
@@ -74,7 +74,6 @@ command = "say"
claude: false, claude: false,
codex: true, // 启用 Codex codex: true, // 启用 Codex
gemini: false, gemini: false,
opencode: false,
}, },
description: None, description: None,
homepage: None, homepage: None,
-1
View File
@@ -88,7 +88,6 @@ command = "say"
claude: false, claude: false,
codex: true, codex: true,
gemini: false, gemini: false,
opencode: false,
}, },
description: None, description: None,
homepage: None, homepage: None,
+37 -99
View File
@@ -77,11 +77,7 @@ function App() {
const [editingProvider, setEditingProvider] = useState<Provider | null>(null); const [editingProvider, setEditingProvider] = useState<Provider | null>(null);
const [usageProvider, setUsageProvider] = useState<Provider | null>(null); const [usageProvider, setUsageProvider] = useState<Provider | null>(null);
// Confirm action state: 'remove' = remove from live config, 'delete' = delete from database const [confirmDelete, setConfirmDelete] = useState<Provider | null>(null);
const [confirmAction, setConfirmAction] = useState<{
provider: Provider;
action: "remove" | "delete";
} | null>(null);
const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]); const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]);
const [showEnvBanner, setShowEnvBanner] = useState(false); const [showEnvBanner, setShowEnvBanner] = useState(false);
@@ -326,46 +322,11 @@ function App() {
setEditingProvider(null); setEditingProvider(null);
}; };
// 确认删除/移除供应商 // 确认删除供应商
const handleConfirmAction = async () => { const handleConfirmDelete = async () => {
if (!confirmAction) return; if (!confirmDelete) return;
const { provider, action } = confirmAction; await deleteProvider(confirmDelete.id);
setConfirmDelete(null);
if (action === "remove") {
// Remove from live config only (for additive mode apps like OpenCode)
// Does NOT delete from database - provider remains in the list
await providersApi.removeFromLiveConfig(provider.id, activeApp);
// Invalidate queries to refresh the isInConfig state
await queryClient.invalidateQueries({
queryKey: ["opencodeLiveProviderIds"],
});
toast.success(
t("notifications.removeFromConfigSuccess", {
defaultValue: "已从配置移除",
}),
{ closeButton: true },
);
} else {
// Delete from database
await deleteProvider(provider.id);
}
setConfirmAction(null);
};
// Generate a unique provider key for OpenCode duplication
const generateUniqueOpencodeKey = (originalKey: string, existingKeys: string[]): string => {
const baseKey = `${originalKey}-copy`;
if (!existingKeys.includes(baseKey)) {
return baseKey;
}
// If -copy already exists, try -copy-2, -copy-3, ...
let counter = 2;
while (existingKeys.includes(`${baseKey}-${counter}`)) {
counter++;
}
return `${baseKey}-${counter}`;
}; };
// 复制供应商 // 复制供应商
@@ -374,7 +335,7 @@ function App() {
const newSortIndex = const newSortIndex =
provider.sortIndex !== undefined ? provider.sortIndex + 1 : undefined; provider.sortIndex !== undefined ? provider.sortIndex + 1 : undefined;
const duplicatedProvider: Omit<Provider, "id" | "createdAt"> & { providerKey?: string } = { const duplicatedProvider: Omit<Provider, "id" | "createdAt"> = {
name: `${provider.name} copy`, name: `${provider.name} copy`,
settingsConfig: JSON.parse(JSON.stringify(provider.settingsConfig)), // 深拷贝 settingsConfig: JSON.parse(JSON.stringify(provider.settingsConfig)), // 深拷贝
websiteUrl: provider.websiteUrl, websiteUrl: provider.websiteUrl,
@@ -387,12 +348,6 @@ function App() {
iconColor: provider.iconColor, iconColor: provider.iconColor,
}; };
// OpenCode: generate unique provider key (used as ID)
if (activeApp === "opencode") {
const existingKeys = Object.keys(providers);
duplicatedProvider.providerKey = generateUniqueOpencodeKey(provider.id, existingKeys);
}
// 2️⃣ 如果原供应商有 sortIndex,需要将后续所有供应商的 sortIndex +1 // 2️⃣ 如果原供应商有 sortIndex,需要将后续所有供应商的 sortIndex +1
if (provider.sortIndex !== undefined) { if (provider.sortIndex !== undefined) {
const updates = Object.values(providers) const updates = Object.values(providers)
@@ -499,7 +454,7 @@ function App() {
/> />
); );
case "skillsDiscovery": case "skillsDiscovery":
return <SkillsPage ref={skillsPageRef} initialApp={activeApp === "opencode" ? "claude" : activeApp} />; return <SkillsPage ref={skillsPageRef} initialApp={activeApp} />;
case "mcp": case "mcp":
return ( return (
<UnifiedMcpPanel <UnifiedMcpPanel
@@ -513,13 +468,13 @@ function App() {
); );
case "universal": case "universal":
return ( return (
<div className="px-6 pt-4"> <div className="mx-auto max-w-[56rem] px-5 pt-4">
<UniversalProviderPanel /> <UniversalProviderPanel />
</div> </div>
); );
default: default:
return ( 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 与滚轮事件冲突 */} {/* 独立滚动容器 - 解决 Linux/Ubuntu 下 DndContext 与滚轮事件冲突 */}
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-12 px-1"> <div className="flex-1 overflow-y-auto overflow-x-hidden pb-12 px-1">
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
@@ -543,15 +498,7 @@ function App() {
activeProviderId={activeProviderId} activeProviderId={activeProviderId}
onSwitch={switchProvider} onSwitch={switchProvider}
onEdit={setEditingProvider} onEdit={setEditingProvider}
onDelete={(provider) => onDelete={setConfirmDelete}
setConfirmAction({ provider, action: "delete" })
}
onRemoveFromConfig={
activeApp === "opencode"
? (provider) =>
setConfirmAction({ provider, action: "remove" })
: undefined
}
onDuplicate={handleDuplicateProvider} onDuplicate={handleDuplicateProvider}
onConfigureUsage={setUsageProvider} onConfigureUsage={setUsageProvider}
onOpenWebsite={handleOpenWebsite} onOpenWebsite={handleOpenWebsite}
@@ -633,7 +580,7 @@ function App() {
} }
> >
<div <div
className="flex h-full items-center justify-between gap-2 px-6" className="mx-auto flex h-full max-w-[56rem] flex-wrap items-center justify-between gap-2 px-6"
data-tauri-drag-region data-tauri-drag-region
style={{ WebkitAppRegion: "drag" } as any} style={{ WebkitAppRegion: "drag" } as any}
> >
@@ -672,8 +619,8 @@ function App() {
</h1> </h1>
</div> </div>
) : ( ) : (
<> <div className="flex items-center gap-2">
<div className="flex items-center gap-2"> <div className="relative inline-flex items-center">
<a <a
href="https://github.com/farion1231/cc-switch" href="https://github.com/farion1231/cc-switch"
target="_blank" target="_blank"
@@ -687,26 +634,27 @@ function App() {
> >
CC Switch CC Switch
</a> </a>
<Button <UpdateBadge
variant="ghost"
size="icon"
onClick={() => { onClick={() => {
setSettingsDefaultTab("general"); setSettingsDefaultTab("about");
setCurrentView("settings"); setCurrentView("settings");
}} }}
title={t("common.settings")} className="absolute -top-4 -right-4"
className="hover:bg-black/5 dark:hover:bg-white/5" />
>
<Settings className="w-4 h-4" />
</Button>
</div> </div>
<UpdateBadge <Button
variant="ghost"
size="icon"
onClick={() => { onClick={() => {
setSettingsDefaultTab("about"); setSettingsDefaultTab("general");
setCurrentView("settings"); setCurrentView("settings");
}} }}
/> title={t("common.settings")}
</> className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Settings className="w-4 h-4" />
</Button>
</div>
)} )}
</div> </div>
@@ -793,9 +741,7 @@ function App() {
)} )}
{currentView === "providers" && ( {currentView === "providers" && (
<> <>
{activeApp !== "opencode" && ( <ProxyToggle activeApp={activeApp} />
<ProxyToggle activeApp={activeApp} />
)}
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} /> <AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
@@ -900,25 +846,17 @@ function App() {
)} )}
<ConfirmDialog <ConfirmDialog
isOpen={Boolean(confirmAction)} isOpen={Boolean(confirmDelete)}
title={ title={t("confirm.deleteProvider")}
confirmAction?.action === "remove"
? t("confirm.removeProvider")
: t("confirm.deleteProvider")
}
message={ message={
confirmAction confirmDelete
? confirmAction.action === "remove" ? t("confirm.deleteProviderMessage", {
? t("confirm.removeProviderMessage", { name: confirmDelete.name,
name: confirmAction.provider.name, })
})
: t("confirm.deleteProviderMessage", {
name: confirmAction.provider.name,
})
: "" : ""
} }
onConfirm={() => void handleConfirmAction()} onConfirm={() => void handleConfirmDelete()}
onCancel={() => setConfirmAction(null)} onCancel={() => setConfirmDelete(null)}
/> />
<DeepLinkImportDialog /> <DeepLinkImportDialog />
-24
View File
@@ -16,13 +16,11 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
claude: "claude", claude: "claude",
codex: "openai", codex: "openai",
gemini: "gemini", gemini: "gemini",
opencode: "opencode",
}; };
const appDisplayName: Record<AppId, string> = { const appDisplayName: Record<AppId, string> = {
claude: "Claude", claude: "Claude",
codex: "Codex", codex: "Codex",
gemini: "Gemini", gemini: "Gemini",
opencode: "OpenCode",
}; };
return ( return (
@@ -92,28 +90,6 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
/> />
<span>{appDisplayName.gemini}</span> <span>{appDisplayName.gemini}</span>
</button> </button>
<button
type="button"
onClick={() => handleSwitch("opencode")}
className={`group inline-flex items-center gap-2 px-3 h-8 rounded-md text-sm font-medium transition-all duration-200 ${
activeApp === "opencode"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
}`}
>
<ProviderIcon
icon={appIconName.opencode}
name={appDisplayName.opencode}
size={iconSize}
className={
activeApp === "opencode"
? "text-foreground"
: "text-muted-foreground group-hover:text-foreground transition-colors"
}
/>
<span>{appDisplayName.opencode}</span>
</button>
</div> </div>
); );
} }
+25 -42
View File
@@ -1,6 +1,6 @@
import { X, Download } from "lucide-react";
import { useUpdate } from "@/contexts/UpdateContext"; import { useUpdate } from "@/contexts/UpdateContext";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
interface UpdateBadgeProps { interface UpdateBadgeProps {
className?: string; className?: string;
@@ -8,56 +8,39 @@ interface UpdateBadgeProps {
} }
export function UpdateBadge({ className = "", onClick }: UpdateBadgeProps) { export function UpdateBadge({ className = "", onClick }: UpdateBadgeProps) {
const { hasUpdate, updateInfo, isDismissed, dismissUpdate } = useUpdate(); const { hasUpdate, updateInfo } = useUpdate();
const { t } = useTranslation(); const { t } = useTranslation();
const isActive = hasUpdate && updateInfo;
const title = isActive
? t("settings.updateAvailable", {
version: updateInfo?.availableVersion ?? "",
})
: t("settings.checkForUpdates");
// 如果没有更新或已关闭,不显示 if (!isActive) {
if (!hasUpdate || isDismissed || !updateInfo) {
return null; return null;
} }
return ( return (
<div <Button
type="button"
variant="ghost"
size="icon"
title={title}
aria-label={title}
onClick={onClick}
className={` className={`
flex items-center gap-1.5 px-2.5 py-1 relative h-6 w-6 rounded-full
bg-white dark:bg-gray-800 ${isActive ? "text-blue-600 dark:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-500/10" : "text-muted-foreground hover:bg-muted/60"}
border border-border-default
rounded-lg text-xs
shadow-sm
transition-all duration-200
${onClick ? "cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-750" : ""}
${className} ${className}
`} `}
role={onClick ? "button" : undefined}
tabIndex={onClick ? 0 : -1}
onClick={onClick}
onKeyDown={(e) => {
if (!onClick) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
}
}}
> >
<Download className="w-3 h-3 text-blue-500 dark:text-blue-400" /> <span
<span className="text-gray-700 dark:text-gray-300 font-medium"> className={`
{t("settings.updateBadge")} absolute inset-0 m-auto h-2 w-2 rounded-full ring-1 ring-background
</span> ${isActive ? "bg-blue-500 dark:bg-blue-400" : "bg-blue-300/70 dark:bg-blue-300/60"}
<button `}
onClick={(e) => { />
e.stopPropagation(); </Button>
dismissUpdate();
}}
className="
ml-1 -mr-0.5 p-0.5 rounded
hover:bg-gray-100 dark:hover:bg-gray-700
transition-colors
focus:outline-none focus:ring-2 focus:ring-blue-500/20
"
aria-label={t("common.close")}
>
<X className="w-3 h-3 text-muted-foreground" />
</button>
</div>
); );
} }
+1 -5
View File
@@ -11,7 +11,6 @@ interface UsageFooterProps {
appId: AppId; appId: AppId;
usageEnabled: boolean; // 是否启用了用量查询 usageEnabled: boolean; // 是否启用了用量查询
isCurrent: boolean; // 是否为当前激活的供应商 isCurrent: boolean; // 是否为当前激活的供应商
isInConfig?: boolean; // OpenCode: 是否已添加到配置
inline?: boolean; // 是否内联显示(在按钮左侧) inline?: boolean; // 是否内联显示(在按钮左侧)
} }
@@ -21,15 +20,12 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
appId, appId,
usageEnabled, usageEnabled,
isCurrent, isCurrent,
isInConfig = false,
inline = false, inline = false,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
// 统一的用量查询(自动查询仅对当前激活的供应商启用) // 统一的用量查询(自动查询仅对当前激活的供应商启用)
// OpenCode(累加模式):使用 isInConfig 代替 isCurrent const autoQueryInterval = isCurrent
const shouldAutoQuery = appId === "opencode" ? isInConfig : isCurrent;
const autoQueryInterval = shouldAutoQuery
? provider.meta?.usage_script?.autoQueryInterval || 0 ? provider.meta?.usage_script?.autoQueryInterval || 0
: 0; : 0;
+3 -3
View File
@@ -72,7 +72,7 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
} }
> >
<div <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 data-tauri-drag-region
style={{ WebkitAppRegion: "drag" } as React.CSSProperties} style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
> >
@@ -94,7 +94,7 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto scroll-overlay"> <div className="flex-1 overflow-y-auto scroll-overlay">
<div className="px-6 py-6 space-y-6 w-full"> <div className="mx-auto max-w-[56rem] px-6 py-6 space-y-6 w-full">
{children} {children}
</div> </div>
</div> </div>
@@ -105,7 +105,7 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
className="flex-shrink-0 py-4 border-t border-border-default" className="flex-shrink-0 py-4 border-t border-border-default"
style={{ backgroundColor: "hsl(var(--background))" }} style={{ backgroundColor: "hsl(var(--background))" }}
> >
<div className="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} {footer}
</div> </div>
</div> </div>
-2
View File
@@ -65,7 +65,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
claude: boolean; claude: boolean;
codex: boolean; codex: boolean;
gemini: boolean; gemini: boolean;
opencode: boolean;
}>(() => { }>(() => {
if (initialData?.apps) { if (initialData?.apps) {
return { ...initialData.apps }; return { ...initialData.apps };
@@ -74,7 +73,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
claude: defaultEnabledApps.includes("claude"), claude: defaultEnabledApps.includes("claude"),
codex: defaultEnabledApps.includes("codex"), codex: defaultEnabledApps.includes("codex"),
gemini: defaultEnabledApps.includes("gemini"), gemini: defaultEnabledApps.includes("gemini"),
opencode: defaultEnabledApps.includes("opencode"),
}; };
}); });
+3 -21
View File
@@ -59,12 +59,11 @@ const UnifiedMcpPanel = React.forwardRef<
// Count enabled servers per app // Count enabled servers per app
const enabledCounts = useMemo(() => { const enabledCounts = useMemo(() => {
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0 }; const counts = { claude: 0, codex: 0, gemini: 0 };
serverEntries.forEach(([_, server]) => { serverEntries.forEach(([_, server]) => {
if (server.apps.claude) counts.claude++; if (server.apps.claude) counts.claude++;
if (server.apps.codex) counts.codex++; if (server.apps.codex) counts.codex++;
if (server.apps.gemini) counts.gemini++; if (server.apps.gemini) counts.gemini++;
if (server.apps.opencode) counts.opencode++;
}); });
return counts; return counts;
}, [serverEntries]); }, [serverEntries]);
@@ -142,15 +141,14 @@ const UnifiedMcpPanel = React.forwardRef<
}; };
return ( 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 */} {/* Info Section */}
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6"> <div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6">
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{t("mcp.serverCount", { count: serverEntries.length })} ·{" "} {t("mcp.serverCount", { count: serverEntries.length })} ·{" "}
{t("mcp.unifiedPanel.apps.claude")}: {enabledCounts.claude} ·{" "} {t("mcp.unifiedPanel.apps.claude")}: {enabledCounts.claude} ·{" "}
{t("mcp.unifiedPanel.apps.codex")}: {enabledCounts.codex} ·{" "} {t("mcp.unifiedPanel.apps.codex")}: {enabledCounts.codex} ·{" "}
{t("mcp.unifiedPanel.apps.gemini")}: {enabledCounts.gemini} ·{" "} {t("mcp.unifiedPanel.apps.gemini")}: {enabledCounts.gemini}
{t("mcp.unifiedPanel.apps.opencode")}: {enabledCounts.opencode}
</div> </div>
</div> </div>
@@ -339,22 +337,6 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
} }
/> />
</div> </div>
<div className="flex items-center justify-between gap-3">
<label
htmlFor={`${id}-opencode`}
className="text-sm text-foreground/80 cursor-pointer"
>
{t("mcp.unifiedPanel.apps.opencode")}
</label>
<Switch
id={`${id}-opencode`}
checked={server.apps.opencode}
onCheckedChange={(checked: boolean) =>
onToggleApp(id, "opencode", checked)
}
/>
</div>
</div> </div>
{/* 右侧:操作按钮 */} {/* 右侧:操作按钮 */}
@@ -34,7 +34,6 @@ const PromptFormModal: React.FC<PromptFormModalProps> = ({
claude: "CLAUDE.md", claude: "CLAUDE.md",
codex: "AGENTS.md", codex: "AGENTS.md",
gemini: "GEMINI.md", gemini: "GEMINI.md",
opencode: "AGENTS.md",
}; };
const filename = filenameMap[appId]; const filename = filenameMap[appId];
const [name, setName] = useState(""); const [name, setName] = useState("");
@@ -28,7 +28,6 @@ const PromptFormPanel: React.FC<PromptFormPanelProps> = ({
claude: "CLAUDE.md", claude: "CLAUDE.md",
codex: "AGENTS.md", codex: "AGENTS.md",
gemini: "GEMINI.md", gemini: "GEMINI.md",
opencode: "AGENTS.md",
}; };
const filename = filenameMap[appId]; const filename = filenameMap[appId];
const [name, setName] = useState(""); const [name, setName] = useState("");
+1 -1
View File
@@ -96,7 +96,7 @@ const PromptPanel = React.forwardRef<PromptPanelHandle, PromptPanelProps>(
const enabledPrompt = promptEntries.find(([_, p]) => p.enabled); const enabledPrompt = promptEntries.find(([_, p]) => p.enabled);
return ( return (
<div className="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="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6">
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{t("prompts.count", { count: promptEntries.length })} ·{" "} {t("prompts.count", { count: promptEntries.length })} ·{" "}
+34 -62
View File
@@ -17,14 +17,13 @@ import { UniversalProviderPanel } from "@/components/universal";
import { providerPresets } from "@/config/claudeProviderPresets"; import { providerPresets } from "@/config/claudeProviderPresets";
import { codexProviderPresets } from "@/config/codexProviderPresets"; import { codexProviderPresets } from "@/config/codexProviderPresets";
import { geminiProviderPresets } from "@/config/geminiProviderPresets"; import { geminiProviderPresets } from "@/config/geminiProviderPresets";
// Note: opencodeProviderPresets is loaded via ProviderForm, not needed here
import type { UniversalProviderPreset } from "@/config/universalProviderPresets"; import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
interface AddProviderDialogProps { interface AddProviderDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
appId: AppId; appId: AppId;
onSubmit: (provider: Omit<Provider, "id"> & { providerKey?: string }) => Promise<void> | void; onSubmit: (provider: Omit<Provider, "id">) => Promise<void> | void;
} }
export function AddProviderDialog({ export function AddProviderDialog({
@@ -34,8 +33,6 @@ export function AddProviderDialog({
onSubmit, onSubmit,
}: AddProviderDialogProps) { }: AddProviderDialogProps) {
const { t } = useTranslation(); const { t } = useTranslation();
// OpenCode doesn't support universal providers
const showUniversalTab = appId !== "opencode";
const [activeTab, setActiveTab] = useState<"app-specific" | "universal">( const [activeTab, setActiveTab] = useState<"app-specific" | "universal">(
"app-specific", "app-specific",
); );
@@ -85,7 +82,7 @@ export function AddProviderDialog({
>; >;
// 构造基础提交数据 // 构造基础提交数据
const providerData: Omit<Provider, "id"> & { providerKey?: string } = { const providerData: Omit<Provider, "id"> = {
name: values.name.trim(), name: values.name.trim(),
notes: values.notes?.trim() || undefined, notes: values.notes?.trim() || undefined,
websiteUrl: values.websiteUrl?.trim() || undefined, websiteUrl: values.websiteUrl?.trim() || undefined,
@@ -96,11 +93,6 @@ export function AddProviderDialog({
...(values.meta ? { meta: values.meta } : {}), ...(values.meta ? { meta: values.meta } : {}),
}; };
// OpenCode: pass providerKey for ID generation
if (appId === "opencode" && values.providerKey) {
providerData.providerKey = values.providerKey;
}
const hasCustomEndpoints = const hasCustomEndpoints =
providerData.meta?.custom_endpoints && providerData.meta?.custom_endpoints &&
Object.keys(providerData.meta.custom_endpoints).length > 0; Object.keys(providerData.meta.custom_endpoints).length > 0;
@@ -161,7 +153,6 @@ export function AddProviderDialog({
} }
} }
} }
// Note: OpenCode doesn't use endpointCandidates - it handles endpoints internally
} }
if (appId === "claude") { if (appId === "claude") {
@@ -184,12 +175,6 @@ export function AddProviderDialog({
if (env?.GOOGLE_GEMINI_BASE_URL) { if (env?.GOOGLE_GEMINI_BASE_URL) {
addUrl(env.GOOGLE_GEMINI_BASE_URL); addUrl(env.GOOGLE_GEMINI_BASE_URL);
} }
} else if (appId === "opencode") {
// OpenCode uses options.baseURL
const options = parsedConfig.options as Record<string, any> | undefined;
if (options?.baseURL) {
addUrl(options.baseURL);
}
} }
const urls = Array.from(urlSet); const urls = Array.from(urlSet);
@@ -219,7 +204,7 @@ export function AddProviderDialog({
// 动态 footer:根据当前 Tab 显示不同按钮 // 动态 footer:根据当前 Tab 显示不同按钮
const footer = const footer =
!showUniversalTab || activeTab === "app-specific" ? ( activeTab === "app-specific" ? (
<> <>
<Button <Button
variant="outline" variant="outline"
@@ -263,54 +248,41 @@ export function AddProviderDialog({
onClose={() => onOpenChange(false)} onClose={() => onOpenChange(false)}
footer={footer} footer={footer}
> >
{showUniversalTab ? ( <Tabs
<Tabs value={activeTab}
value={activeTab} onValueChange={(v) => setActiveTab(v as "app-specific" | "universal")}
onValueChange={(v) => setActiveTab(v as "app-specific" | "universal")} >
> <TabsList className="grid w-full grid-cols-2 mb-6">
<TabsList className="grid w-full grid-cols-2 mb-6"> <TabsTrigger value="app-specific">
<TabsTrigger value="app-specific"> {t(`apps.${appId}`)} {t("provider.tabProvider")}
{t(`apps.${appId}`)} {t("provider.tabProvider")} </TabsTrigger>
</TabsTrigger> <TabsTrigger value="universal">
<TabsTrigger value="universal"> {t("provider.tabUniversal")}
{t("provider.tabUniversal")} </TabsTrigger>
</TabsTrigger> </TabsList>
</TabsList>
<TabsContent value="app-specific" className="mt-0"> <TabsContent value="app-specific" className="mt-0">
<ProviderForm <ProviderForm
appId={appId} appId={appId}
submitLabel={t("common.add")} submitLabel={t("common.add")}
onSubmit={handleSubmit} onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)} onCancel={() => onOpenChange(false)}
showButtons={false} showButtons={false}
/> />
</TabsContent> </TabsContent>
<TabsContent value="universal" className="mt-0"> <TabsContent value="universal" className="mt-0">
<UniversalProviderPanel /> <UniversalProviderPanel />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
) : (
// OpenCode: directly show form without tabs
<ProviderForm
appId={appId}
submitLabel={t("common.add")}
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
showButtons={false}
/>
)}
{/* Universal Provider Form Modal */} {/* Universal Provider Form Modal */}
{showUniversalTab && ( <UniversalProviderFormModal
<UniversalProviderFormModal isOpen={universalFormOpen}
isOpen={universalFormOpen} onClose={handleUniversalFormClose}
onClose={handleUniversalFormClose} onSave={handleUniversalProviderSave}
onSave={handleUniversalProviderSave} initialPreset={selectedUniversalPreset}
initialPreset={selectedUniversalPreset} />
/>
)}
</FullScreenPanel> </FullScreenPanel>
); );
} }
@@ -62,17 +62,6 @@ export function EditProviderDialog({
return; return;
} }
// OpenCode uses additive mode - each provider's config is stored independently in DB
// Reading live config would return the full opencode.json (with $schema, provider, mcp etc.)
// instead of just the provider fragment, causing incorrect nested structure on save
if (appId === "opencode") {
if (!cancelled) {
setLiveSettings(null);
setHasLoadedLive(true);
}
return;
}
try { try {
const currentId = await providersApi.getCurrent(appId); const currentId = await providersApi.getCurrent(appId);
if (currentId && provider.id === currentId) { if (currentId && provider.id === currentId) {
+7 -57
View File
@@ -4,7 +4,6 @@ import {
Copy, Copy,
Edit, Edit,
Loader2, Loader2,
Minus,
Play, Play,
Plus, Plus,
Terminal, Terminal,
@@ -14,13 +13,9 @@ import {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { AppId } from "@/lib/api";
interface ProviderActionsProps { interface ProviderActionsProps {
appId?: AppId;
isCurrent: boolean; isCurrent: boolean;
/** OpenCode: 是否已添加到配置 */
isInConfig?: boolean;
isTesting?: boolean; isTesting?: boolean;
isProxyTakeover?: boolean; isProxyTakeover?: boolean;
onSwitch: () => void; onSwitch: () => void;
@@ -29,8 +24,6 @@ interface ProviderActionsProps {
onTest?: () => void; onTest?: () => void;
onConfigureUsage: () => void; onConfigureUsage: () => void;
onDelete: () => void; onDelete: () => void;
/** OpenCode: remove from live config (not delete from database) */
onRemoveFromConfig?: () => void;
onOpenTerminal?: () => void; onOpenTerminal?: () => void;
// 故障转移相关 // 故障转移相关
isAutoFailoverEnabled?: boolean; isAutoFailoverEnabled?: boolean;
@@ -39,9 +32,7 @@ interface ProviderActionsProps {
} }
export function ProviderActions({ export function ProviderActions({
appId,
isCurrent, isCurrent,
isInConfig = false,
isTesting, isTesting,
isProxyTakeover = false, isProxyTakeover = false,
onSwitch, onSwitch,
@@ -50,7 +41,6 @@ export function ProviderActions({
onTest, onTest,
onConfigureUsage, onConfigureUsage,
onDelete, onDelete,
onRemoveFromConfig,
onOpenTerminal, onOpenTerminal,
// 故障转移相关 // 故障转移相关
isAutoFailoverEnabled = false, isAutoFailoverEnabled = false,
@@ -60,27 +50,12 @@ export function ProviderActions({
const { t } = useTranslation(); const { t } = useTranslation();
const iconButtonClass = "h-8 w-8 p-1"; const iconButtonClass = "h-8 w-8 p-1";
// OpenCode 使用累加模式 // 故障转移模式下的按钮逻辑
const isOpenCodeMode = appId === "opencode"; const isFailoverMode = isAutoFailoverEnabled && onToggleFailover;
// 故障转移模式下的按钮逻辑(OpenCode 不支持故障转移)
const isFailoverMode = !isOpenCodeMode && isAutoFailoverEnabled && onToggleFailover;
// 处理主按钮点击 // 处理主按钮点击
const handleMainButtonClick = () => { const handleMainButtonClick = () => {
if (isOpenCodeMode) { if (isFailoverMode) {
// OpenCode 模式:切换配置状态(添加/移除)
if (isInConfig) {
// Use onRemoveFromConfig if available, otherwise fall back to onDelete
if (onRemoveFromConfig) {
onRemoveFromConfig();
} else {
onDelete();
}
} else {
onSwitch(); // 添加到配置
}
} else if (isFailoverMode) {
// 故障转移模式:切换队列状态 // 故障转移模式:切换队列状态
onToggleFailover(!isInFailoverQueue); onToggleFailover(!isInFailoverQueue);
} else { } else {
@@ -91,30 +66,8 @@ export function ProviderActions({
// 主按钮的状态和样式 // 主按钮的状态和样式
const getMainButtonState = () => { const getMainButtonState = () => {
// OpenCode 累加模式
if (isOpenCodeMode) {
if (isInConfig) {
return {
disabled: false,
variant: "secondary" as const,
className:
"bg-orange-100 text-orange-600 hover:bg-orange-200 dark:bg-orange-900/50 dark:text-orange-400 dark:hover:bg-orange-900/70",
icon: <Minus className="h-4 w-4" />,
text: t("provider.removeFromConfig", { defaultValue: "移除" }),
};
}
return {
disabled: false,
variant: "default" as const,
className:
"bg-emerald-500 hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700",
icon: <Plus className="h-4 w-4" />,
text: t("provider.addToConfig", { defaultValue: "添加" }),
};
}
// 故障转移模式
if (isFailoverMode) { if (isFailoverMode) {
// 故障转移模式
if (isInFailoverQueue) { if (isInFailoverQueue) {
return { return {
disabled: false, disabled: false,
@@ -160,9 +113,6 @@ export function ProviderActions({
const buttonState = getMainButtonState(); const buttonState = getMainButtonState();
// OpenCode 模式下删除按钮始终可用(主按钮"移除"是从 live 配置移除,删除是从数据库删除)
const canDelete = isOpenCodeMode ? true : !isCurrent;
return ( return (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Button <Button
@@ -242,12 +192,12 @@ export function ProviderActions({
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
onClick={canDelete ? onDelete : undefined} onClick={isCurrent ? undefined : onDelete}
title={t("common.delete")} title={t("common.delete")}
className={cn( className={cn(
iconButtonClass, iconButtonClass,
canDelete && "hover:text-red-500 dark:hover:text-red-400", !isCurrent && "hover:text-red-500 dark:hover:text-red-400",
!canDelete && "opacity-40 cursor-not-allowed text-muted-foreground", isCurrent && "opacity-40 cursor-not-allowed text-muted-foreground",
)} )}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
+4 -24
View File
@@ -26,12 +26,9 @@ interface ProviderCardProps {
provider: Provider; provider: Provider;
isCurrent: boolean; isCurrent: boolean;
appId: AppId; appId: AppId;
isInConfig?: boolean; // OpenCode: 是否已添加到 opencode.json
onSwitch: (provider: Provider) => void; onSwitch: (provider: Provider) => void;
onEdit: (provider: Provider) => void; onEdit: (provider: Provider) => void;
onDelete: (provider: Provider) => void; onDelete: (provider: Provider) => void;
/** OpenCode: remove from live config (not delete from database) */
onRemoveFromConfig?: (provider: Provider) => void;
onConfigureUsage: (provider: Provider) => void; onConfigureUsage: (provider: Provider) => void;
onOpenWebsite: (url: string) => void; onOpenWebsite: (url: string) => void;
onDuplicate: (provider: Provider) => void; onDuplicate: (provider: Provider) => void;
@@ -88,11 +85,9 @@ export function ProviderCard({
provider, provider,
isCurrent, isCurrent,
appId, appId,
isInConfig = true,
onSwitch, onSwitch,
onEdit, onEdit,
onDelete, onDelete,
onRemoveFromConfig,
onConfigureUsage, onConfigureUsage,
onOpenWebsite, onOpenWebsite,
onDuplicate, onDuplicate,
@@ -139,9 +134,7 @@ export function ProviderCard({
const usageEnabled = provider.meta?.usage_script?.enabled ?? false; const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
// 获取用量数据以判断是否有多套餐 // 获取用量数据以判断是否有多套餐
// OpenCode(累加模式):使用 isInConfig 代替 isCurrent const autoQueryInterval = isCurrent
const shouldAutoQuery = appId === "opencode" ? isInConfig : isCurrent;
const autoQueryInterval = shouldAutoQuery
? provider.meta?.usage_script?.autoQueryInterval || 0 ? provider.meta?.usage_script?.autoQueryInterval || 0
: 0; : 0;
@@ -189,16 +182,12 @@ export function ProviderCard({
}; };
// 判断是否是"当前使用中"的供应商 // 判断是否是"当前使用中"的供应商
// - OpenCode(累加模式):不存在"当前"概念,始终返回 false
// - 故障转移模式:代理实际使用的供应商(activeProviderId // - 故障转移模式:代理实际使用的供应商(activeProviderId
// - 代理接管模式(非故障转移):isCurrent // - 代理接管模式(非故障转移):isCurrent
// - 普通模式:isCurrent // - 普通模式:isCurrent
const isActiveProvider = const isActiveProvider = isAutoFailoverEnabled
appId === "opencode" ? activeProviderId === provider.id
? false : isCurrent;
: isAutoFailoverEnabled
? activeProviderId === provider.id
: isCurrent;
// 判断是否使用绿色(代理接管模式)还是蓝色(普通模式) // 判断是否使用绿色(代理接管模式)还是蓝色(普通模式)
const shouldUseGreen = isProxyTakeover && isActiveProvider; const shouldUseGreen = isProxyTakeover && isActiveProvider;
@@ -338,7 +327,6 @@ export function ProviderCard({
appId={appId} appId={appId}
usageEnabled={usageEnabled} usageEnabled={usageEnabled}
isCurrent={isCurrent} isCurrent={isCurrent}
isInConfig={isInConfig}
inline={true} 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" className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1.5 pl-3 opacity-0 pointer-events-none group-hover:opacity-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-all duration-200 translate-x-2 group-hover:translate-x-0 group-focus-within:translate-x-0"
> >
<ProviderActions <ProviderActions
appId={appId}
isCurrent={isCurrent} isCurrent={isCurrent}
isInConfig={isInConfig}
isTesting={isTesting} isTesting={isTesting}
isProxyTakeover={isProxyTakeover} isProxyTakeover={isProxyTakeover}
onSwitch={() => onSwitch(provider)} onSwitch={() => onSwitch(provider)}
@@ -383,11 +369,6 @@ export function ProviderCard({
onTest={onTest ? () => onTest(provider) : undefined} onTest={onTest ? () => onTest(provider) : undefined}
onConfigureUsage={() => onConfigureUsage(provider)} onConfigureUsage={() => onConfigureUsage(provider)}
onDelete={() => onDelete(provider)} onDelete={() => onDelete(provider)}
onRemoveFromConfig={
onRemoveFromConfig
? () => onRemoveFromConfig(provider)
: undefined
}
onOpenTerminal={ onOpenTerminal={
onOpenTerminal ? () => onOpenTerminal(provider) : undefined onOpenTerminal ? () => onOpenTerminal(provider) : undefined
} }
@@ -409,7 +390,6 @@ export function ProviderCard({
appId={appId} appId={appId}
usageEnabled={usageEnabled} usageEnabled={usageEnabled}
isCurrent={isCurrent} isCurrent={isCurrent}
isInConfig={isInConfig}
inline={false} inline={false}
/> />
</div> </div>
+2 -32
View File
@@ -15,10 +15,8 @@ import {
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { Search, X } from "lucide-react"; import { Search, X } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import type { Provider } from "@/types"; import type { Provider } from "@/types";
import type { AppId } from "@/lib/api"; import type { AppId } from "@/lib/api";
import { providersApi } from "@/lib/api/providers";
import { useDragSort } from "@/hooks/useDragSort"; import { useDragSort } from "@/hooks/useDragSort";
import { useStreamCheck } from "@/hooks/useStreamCheck"; import { useStreamCheck } from "@/hooks/useStreamCheck";
import { ProviderCard } from "@/components/providers/ProviderCard"; import { ProviderCard } from "@/components/providers/ProviderCard";
@@ -40,8 +38,6 @@ interface ProviderListProps {
onSwitch: (provider: Provider) => void; onSwitch: (provider: Provider) => void;
onEdit: (provider: Provider) => void; onEdit: (provider: Provider) => void;
onDelete: (provider: Provider) => void; onDelete: (provider: Provider) => void;
/** OpenCode: remove from live config (not delete from database) */
onRemoveFromConfig?: (provider: Provider) => void;
onDuplicate: (provider: Provider) => void; onDuplicate: (provider: Provider) => void;
onConfigureUsage?: (provider: Provider) => void; onConfigureUsage?: (provider: Provider) => void;
onOpenWebsite: (url: string) => void; onOpenWebsite: (url: string) => void;
@@ -60,7 +56,6 @@ export function ProviderList({
onSwitch, onSwitch,
onEdit, onEdit,
onDelete, onDelete,
onRemoveFromConfig,
onDuplicate, onDuplicate,
onConfigureUsage, onConfigureUsage,
onOpenWebsite, onOpenWebsite,
@@ -77,22 +72,6 @@ export function ProviderList({
appId, appId,
); );
// OpenCode: 查询 live 配置中的供应商 ID 列表,用于判断 isInConfig
const { data: opencodeLiveIds } = useQuery({
queryKey: ["opencodeLiveProviderIds"],
queryFn: () => providersApi.getOpenCodeLiveProviderIds(),
enabled: appId === "opencode",
});
// OpenCode: 判断供应商是否已添加到 opencode.json
const isProviderInConfig = useCallback(
(providerId: string): boolean => {
if (appId !== "opencode") return true; // 非 OpenCode 应用始终返回 true
return opencodeLiveIds?.includes(providerId) ?? false;
},
[appId, opencodeLiveIds],
);
// 流式健康检查 // 流式健康检查
const { checkProvider, isChecking } = useStreamCheck(appId); const { checkProvider, isChecking } = useStreamCheck(appId);
@@ -220,16 +199,14 @@ export function ProviderList({
provider={provider} provider={provider}
isCurrent={provider.id === currentProviderId} isCurrent={provider.id === currentProviderId}
appId={appId} appId={appId}
isInConfig={isProviderInConfig(provider.id)}
onSwitch={onSwitch} onSwitch={onSwitch}
onEdit={onEdit} onEdit={onEdit}
onDelete={onDelete} onDelete={onDelete}
onRemoveFromConfig={onRemoveFromConfig}
onDuplicate={onDuplicate} onDuplicate={onDuplicate}
onConfigureUsage={onConfigureUsage} onConfigureUsage={onConfigureUsage}
onOpenWebsite={onOpenWebsite} onOpenWebsite={onOpenWebsite}
onOpenTerminal={onOpenTerminal} onOpenTerminal={onOpenTerminal}
onTest={appId !== "opencode" ? handleTest : undefined} onTest={handleTest}
isTesting={isChecking(provider.id)} isTesting={isChecking(provider.id)}
isProxyRunning={isProxyRunning} isProxyRunning={isProxyRunning}
isProxyTakeover={isProxyTakeover} isProxyTakeover={isProxyTakeover}
@@ -331,17 +308,14 @@ interface SortableProviderCardProps {
provider: Provider; provider: Provider;
isCurrent: boolean; isCurrent: boolean;
appId: AppId; appId: AppId;
isInConfig: boolean;
onSwitch: (provider: Provider) => void; onSwitch: (provider: Provider) => void;
onEdit: (provider: Provider) => void; onEdit: (provider: Provider) => void;
onDelete: (provider: Provider) => void; onDelete: (provider: Provider) => void;
/** OpenCode: remove from live config (not delete from database) */
onRemoveFromConfig?: (provider: Provider) => void;
onDuplicate: (provider: Provider) => void; onDuplicate: (provider: Provider) => void;
onConfigureUsage?: (provider: Provider) => void; onConfigureUsage?: (provider: Provider) => void;
onOpenWebsite: (url: string) => void; onOpenWebsite: (url: string) => void;
onOpenTerminal?: (provider: Provider) => void; onOpenTerminal?: (provider: Provider) => void;
onTest?: (provider: Provider) => void; onTest: (provider: Provider) => void;
isTesting: boolean; isTesting: boolean;
isProxyRunning: boolean; isProxyRunning: boolean;
isProxyTakeover: boolean; isProxyTakeover: boolean;
@@ -357,11 +331,9 @@ function SortableProviderCard({
provider, provider,
isCurrent, isCurrent,
appId, appId,
isInConfig,
onSwitch, onSwitch,
onEdit, onEdit,
onDelete, onDelete,
onRemoveFromConfig,
onDuplicate, onDuplicate,
onConfigureUsage, onConfigureUsage,
onOpenWebsite, onOpenWebsite,
@@ -396,11 +368,9 @@ function SortableProviderCard({
provider={provider} provider={provider}
isCurrent={isCurrent} isCurrent={isCurrent}
appId={appId} appId={appId}
isInConfig={isInConfig}
onSwitch={onSwitch} onSwitch={onSwitch}
onEdit={onEdit} onEdit={onEdit}
onDelete={onDelete} onDelete={onDelete}
onRemoveFromConfig={onRemoveFromConfig}
onDuplicate={onDuplicate} onDuplicate={onDuplicate}
onConfigureUsage={ onConfigureUsage={
onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined
@@ -1,6 +1,5 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useState } from "react"; import { useState } from "react";
import type { ReactNode } from "react";
import { import {
FormControl, FormControl,
FormField, FormField,
@@ -25,11 +24,9 @@ import type { ProviderFormData } from "@/lib/schemas/provider";
interface BasicFormFieldsProps { interface BasicFormFieldsProps {
form: UseFormReturn<ProviderFormData>; form: UseFormReturn<ProviderFormData>;
/** Slot to render content between icon and name fields */
beforeNameSlot?: ReactNode;
} }
export function BasicFormFields({ form, beforeNameSlot }: BasicFormFieldsProps) { export function BasicFormFields({ form }: BasicFormFieldsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [iconDialogOpen, setIconDialogOpen] = useState(false); const [iconDialogOpen, setIconDialogOpen] = useState(false);
@@ -81,7 +78,7 @@ export function BasicFormFields({ form, beforeNameSlot }: BasicFormFieldsProps)
> >
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="flex-shrink-0 py-4 border-b border-border-default bg-muted/40"> <div className="flex-shrink-0 py-4 border-b border-border-default bg-muted/40">
<div className="px-6 flex items-center gap-4"> <div className="mx-auto max-w-[56rem] px-6 flex items-center gap-4">
<DialogClose asChild> <DialogClose asChild>
<Button type="button" variant="outline" size="icon"> <Button type="button" variant="outline" size="icon">
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
@@ -95,7 +92,7 @@ export function BasicFormFields({ form, beforeNameSlot }: BasicFormFieldsProps)
</div> </div>
</div> </div>
<div className="flex-1 overflow-y-auto"> <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 <IconPicker
value={currentIcon} value={currentIcon}
onValueChange={handleIconSelect} onValueChange={handleIconSelect}
@@ -115,9 +112,6 @@ export function BasicFormFields({ form, beforeNameSlot }: BasicFormFieldsProps)
</Dialog> </Dialog>
</div> </div>
{/* Slot for additional fields between icon and name */}
{beforeNameSlot}
{/* 基础信息 - 网格布局 */} {/* 基础信息 - 网格布局 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField <FormField
@@ -9,12 +9,11 @@ import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import type { CustomEndpoint, EndpointCandidate } from "@/types"; import type { CustomEndpoint, EndpointCandidate } from "@/types";
// 端点测速超时配置(秒) // 端点测速超时配置(秒)
const ENDPOINT_TIMEOUT_SECS: Record<AppId, number> = { const ENDPOINT_TIMEOUT_SECS = {
codex: 12, codex: 12,
claude: 8, claude: 8,
gemini: 8, gemini: 8, // 新增 gemini
opencode: 8, } as const;
};
interface TestResult { interface TestResult {
url: string; url: string;
@@ -1,655 +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>
</>
);
}
+15 -378
View File
@@ -5,7 +5,6 @@ import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form"; import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider"; import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
import type { AppId } from "@/lib/api"; import type { AppId } from "@/lib/api";
import type { ProviderCategory, ProviderMeta } from "@/types"; import type { ProviderCategory, ProviderMeta } from "@/types";
@@ -21,12 +20,6 @@ import {
geminiProviderPresets, geminiProviderPresets,
type GeminiProviderPreset, type GeminiProviderPreset,
} from "@/config/geminiProviderPresets"; } from "@/config/geminiProviderPresets";
import {
opencodeProviderPresets,
type OpenCodeProviderPreset,
} from "@/config/opencodeProviderPresets";
import { OpenCodeFormFields } from "./OpenCodeFormFields";
import type { OpenCodeModel } from "@/types";
import type { UniversalProviderPreset } from "@/config/universalProviderPresets"; import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
import { applyTemplateValues } from "@/utils/providerConfigUtils"; import { applyTemplateValues } from "@/utils/providerConfigUtils";
import { mergeProviderMeta } from "@/utils/providerMetaUtils"; import { mergeProviderMeta } from "@/utils/providerMetaUtils";
@@ -34,8 +27,6 @@ import { getCodexCustomTemplate } from "@/config/codexTemplates";
import CodexConfigEditor from "./CodexConfigEditor"; import CodexConfigEditor from "./CodexConfigEditor";
import { CommonConfigEditor } from "./CommonConfigEditor"; import { CommonConfigEditor } from "./CommonConfigEditor";
import GeminiConfigEditor from "./GeminiConfigEditor"; import GeminiConfigEditor from "./GeminiConfigEditor";
import JsonEditor from "@/components/JsonEditor";
import { Label } from "@/components/ui/label";
import { ProviderPresetSelector } from "./ProviderPresetSelector"; import { ProviderPresetSelector } from "./ProviderPresetSelector";
import { BasicFormFields } from "./BasicFormFields"; import { BasicFormFields } from "./BasicFormFields";
import { ClaudeFormFields } from "./ClaudeFormFields"; import { ClaudeFormFields } from "./ClaudeFormFields";
@@ -56,7 +47,6 @@ import {
useGeminiConfigState, useGeminiConfigState,
useGeminiCommonConfig, useGeminiCommonConfig,
} from "./hooks"; } from "./hooks";
import { useProvidersQuery } from "@/lib/query/queries";
const CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {} }, null, 2); const CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {} }, null, 2);
const CODEX_DEFAULT_CONFIG = JSON.stringify({ auth: {}, config: "" }, null, 2); const CODEX_DEFAULT_CONFIG = JSON.stringify({ auth: {}, config: "" }, null, 2);
@@ -72,22 +62,9 @@ const GEMINI_DEFAULT_CONFIG = JSON.stringify(
2, 2,
); );
const OPENCODE_DEFAULT_CONFIG = JSON.stringify(
{
npm: "@ai-sdk/openai-compatible",
options: {
baseURL: "",
apiKey: "",
},
models: {},
},
null,
2,
);
type PresetEntry = { type PresetEntry = {
id: string; id: string;
preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset | OpenCodeProviderPreset; preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset;
}; };
interface ProviderFormProps { interface ProviderFormProps {
@@ -181,9 +158,7 @@ export function ProviderForm({
? CODEX_DEFAULT_CONFIG ? CODEX_DEFAULT_CONFIG
: appId === "gemini" : appId === "gemini"
? GEMINI_DEFAULT_CONFIG ? GEMINI_DEFAULT_CONFIG
: appId === "opencode" : CLAUDE_DEFAULT_CONFIG,
? OPENCODE_DEFAULT_CONFIG
: CLAUDE_DEFAULT_CONFIG,
icon: initialData?.icon ?? "", icon: initialData?.icon ?? "",
iconColor: initialData?.iconColor ?? "", iconColor: initialData?.iconColor ?? "",
}), }),
@@ -196,7 +171,7 @@ export function ProviderForm({
mode: "onSubmit", mode: "onSubmit",
}); });
const settingsConfigValue = form.getValues("settingsConfig"); const settingsConfigValue = form.watch("settingsConfig");
// 使用 API Key hook // 使用 API Key hook
const { const {
@@ -204,7 +179,7 @@ export function ProviderForm({
handleApiKeyChange, handleApiKeyChange,
showApiKey: shouldShowApiKey, showApiKey: shouldShowApiKey,
} = useApiKeyState({ } = useApiKeyState({
initialConfig: form.getValues("settingsConfig"), initialConfig: form.watch("settingsConfig"),
onConfigChange: (config) => form.setValue("settingsConfig", config), onConfigChange: (config) => form.setValue("settingsConfig", config),
selectedPresetId, selectedPresetId,
category, category,
@@ -215,7 +190,7 @@ export function ProviderForm({
const { baseUrl, handleClaudeBaseUrlChange } = useBaseUrlState({ const { baseUrl, handleClaudeBaseUrlChange } = useBaseUrlState({
appType: appId, appType: appId,
category, category,
settingsConfig: form.getValues("settingsConfig"), settingsConfig: form.watch("settingsConfig"),
codexConfig: "", codexConfig: "",
onSettingsConfigChange: (config) => form.setValue("settingsConfig", config), onSettingsConfigChange: (config) => form.setValue("settingsConfig", config),
onCodexConfigChange: () => { onCodexConfigChange: () => {
@@ -232,7 +207,7 @@ export function ProviderForm({
defaultOpusModel, defaultOpusModel,
handleModelChange, handleModelChange,
} = useModelState({ } = useModelState({
settingsConfig: form.getValues("settingsConfig"), settingsConfig: form.watch("settingsConfig"),
onConfigChange: (config) => form.setValue("settingsConfig", config), onConfigChange: (config) => form.setValue("settingsConfig", config),
}); });
@@ -353,11 +328,6 @@ export function ProviderForm({
id: `gemini-${index}`, id: `gemini-${index}`,
preset, preset,
})); }));
} else if (appId === "opencode") {
return opencodeProviderPresets.map<PresetEntry>((preset, index) => ({
id: `opencode-${index}`,
preset,
}));
} }
return providerPresets.map<PresetEntry>((preset, index) => ({ return providerPresets.map<PresetEntry>((preset, index) => ({
id: `claude-${index}`, id: `claude-${index}`,
@@ -375,7 +345,7 @@ export function ProviderForm({
} = useTemplateValues({ } = useTemplateValues({
selectedPresetId: appId === "claude" ? selectedPresetId : null, selectedPresetId: appId === "claude" ? selectedPresetId : null,
presetEntries: appId === "claude" ? presetEntries : [], presetEntries: appId === "claude" ? presetEntries : [],
settingsConfig: form.getValues("settingsConfig"), settingsConfig: form.watch("settingsConfig"),
onConfigChange: (config) => form.setValue("settingsConfig", config), onConfigChange: (config) => form.setValue("settingsConfig", config),
}); });
@@ -389,11 +359,10 @@ export function ProviderForm({
isExtracting: isClaudeExtracting, isExtracting: isClaudeExtracting,
handleExtract: handleClaudeExtract, handleExtract: handleClaudeExtract,
} = useCommonConfigSnippet({ } = useCommonConfigSnippet({
settingsConfig: form.getValues("settingsConfig"), settingsConfig: form.watch("settingsConfig"),
onConfigChange: (config) => form.setValue("settingsConfig", config), onConfigChange: (config) => form.setValue("settingsConfig", config),
initialData: appId === "claude" ? initialData : undefined, initialData: appId === "claude" ? initialData : undefined,
selectedPresetId: selectedPresetId ?? undefined, selectedPresetId: selectedPresetId ?? undefined,
enabled: appId === "claude",
}); });
// 使用 Codex 通用配置片段 hook (仅 Codex 模式) // 使用 Codex 通用配置片段 hook (仅 Codex 模式)
@@ -439,7 +408,7 @@ export function ProviderForm({
originalHandleGeminiApiKeyChange(key); originalHandleGeminiApiKeyChange(key);
// 同步更新 settingsConfig // 同步更新 settingsConfig
try { try {
const config = JSON.parse(form.getValues("settingsConfig") || "{}"); const config = JSON.parse(form.watch("settingsConfig") || "{}");
if (!config.env) config.env = {}; if (!config.env) config.env = {};
config.env.GEMINI_API_KEY = key.trim(); config.env.GEMINI_API_KEY = key.trim();
form.setValue("settingsConfig", JSON.stringify(config, null, 2)); form.setValue("settingsConfig", JSON.stringify(config, null, 2));
@@ -455,7 +424,7 @@ export function ProviderForm({
originalHandleGeminiBaseUrlChange(url); originalHandleGeminiBaseUrlChange(url);
// 同步更新 settingsConfig // 同步更新 settingsConfig
try { try {
const config = JSON.parse(form.getValues("settingsConfig") || "{}"); const config = JSON.parse(form.watch("settingsConfig") || "{}");
if (!config.env) config.env = {}; if (!config.env) config.env = {};
config.env.GOOGLE_GEMINI_BASE_URL = url.trim().replace(/\/+$/, ""); config.env.GOOGLE_GEMINI_BASE_URL = url.trim().replace(/\/+$/, "");
form.setValue("settingsConfig", JSON.stringify(config, null, 2)); form.setValue("settingsConfig", JSON.stringify(config, null, 2));
@@ -471,7 +440,7 @@ export function ProviderForm({
originalHandleGeminiModelChange(model); originalHandleGeminiModelChange(model);
// 同步更新 settingsConfig // 同步更新 settingsConfig
try { try {
const config = JSON.parse(form.getValues("settingsConfig") || "{}"); const config = JSON.parse(form.watch("settingsConfig") || "{}");
if (!config.env) config.env = {}; if (!config.env) config.env = {};
config.env.GEMINI_MODEL = model.trim(); config.env.GEMINI_MODEL = model.trim();
form.setValue("settingsConfig", JSON.stringify(config, null, 2)); form.setValue("settingsConfig", JSON.stringify(config, null, 2));
@@ -500,180 +469,6 @@ export function ProviderForm({
selectedPresetId: selectedPresetId ?? undefined, selectedPresetId: selectedPresetId ?? undefined,
}); });
// OpenCode: query existing providers for duplicate key checking
const { data: opencodeProvidersData } = useProvidersQuery("opencode");
const existingOpencodeKeys = useMemo(() => {
if (!opencodeProvidersData?.providers) return [];
// Exclude current provider ID when in edit mode
return Object.keys(opencodeProvidersData.providers).filter(
(k) => k !== providerId
);
}, [opencodeProvidersData?.providers, providerId]);
// OpenCode Provider Key state
const [opencodeProviderKey, setOpencodeProviderKey] = useState<string>(() => {
if (appId !== "opencode") return "";
// In edit mode, use the existing provider ID as the key
return providerId || "";
});
// OpenCode 配置状态
const [opencodeNpm, setOpencodeNpm] = useState<string>(() => {
if (appId !== "opencode") return "@ai-sdk/openai-compatible";
try {
const config = JSON.parse(initialData?.settingsConfig ? JSON.stringify(initialData.settingsConfig) : OPENCODE_DEFAULT_CONFIG);
return config.npm || "@ai-sdk/openai-compatible";
} catch {
return "@ai-sdk/openai-compatible";
}
});
const [opencodeApiKey, setOpencodeApiKey] = useState<string>(() => {
if (appId !== "opencode") return "";
try {
const config = JSON.parse(initialData?.settingsConfig ? JSON.stringify(initialData.settingsConfig) : OPENCODE_DEFAULT_CONFIG);
return config.options?.apiKey || "";
} catch {
return "";
}
});
const [opencodeBaseUrl, setOpencodeBaseUrl] = useState<string>(() => {
if (appId !== "opencode") return "";
try {
const config = JSON.parse(initialData?.settingsConfig ? JSON.stringify(initialData.settingsConfig) : OPENCODE_DEFAULT_CONFIG);
return config.options?.baseURL || "";
} catch {
return "";
}
});
const [opencodeModels, setOpencodeModels] = useState<Record<string, OpenCodeModel>>(() => {
if (appId !== "opencode") return {};
try {
const config = JSON.parse(initialData?.settingsConfig ? JSON.stringify(initialData.settingsConfig) : OPENCODE_DEFAULT_CONFIG);
return config.models || {};
} catch {
return {};
}
});
// OpenCode extra options state (e.g., timeout, setCacheKey)
const [opencodeExtraOptions, setOpencodeExtraOptions] = useState<Record<string, string>>(() => {
if (appId !== "opencode") return {};
try {
const config = JSON.parse(initialData?.settingsConfig ? JSON.stringify(initialData.settingsConfig) : OPENCODE_DEFAULT_CONFIG);
const options = config.options || {};
const extra: Record<string, string> = {};
const knownKeys = ["baseURL", "apiKey", "headers"];
for (const [k, v] of Object.entries(options)) {
if (!knownKeys.includes(k)) {
// Convert value to string for display
extra[k] = typeof v === "string" ? v : JSON.stringify(v);
}
}
return extra;
} catch {
return {};
}
});
// OpenCode handlers - sync state to form
const handleOpencodeNpmChange = useCallback(
(npm: string) => {
setOpencodeNpm(npm);
try {
const config = JSON.parse(form.getValues("settingsConfig") || OPENCODE_DEFAULT_CONFIG);
config.npm = npm;
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
} catch {
// ignore
}
},
[form],
);
const handleOpencodeApiKeyChange = useCallback(
(apiKey: string) => {
setOpencodeApiKey(apiKey);
try {
const config = JSON.parse(form.getValues("settingsConfig") || OPENCODE_DEFAULT_CONFIG);
if (!config.options) config.options = {};
config.options.apiKey = apiKey;
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
} catch {
// ignore
}
},
[form],
);
const handleOpencodeBaseUrlChange = useCallback(
(baseUrl: string) => {
setOpencodeBaseUrl(baseUrl);
try {
const config = JSON.parse(form.getValues("settingsConfig") || OPENCODE_DEFAULT_CONFIG);
if (!config.options) config.options = {};
config.options.baseURL = baseUrl.trim().replace(/\/+$/, "");
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
} catch {
// ignore
}
},
[form],
);
const handleOpencodeModelsChange = useCallback(
(models: Record<string, OpenCodeModel>) => {
setOpencodeModels(models);
try {
const config = JSON.parse(form.getValues("settingsConfig") || OPENCODE_DEFAULT_CONFIG);
config.models = models;
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
} catch {
// ignore
}
},
[form],
);
const handleOpencodeExtraOptionsChange = useCallback(
(options: Record<string, string>) => {
setOpencodeExtraOptions(options);
try {
const config = JSON.parse(form.getValues("settingsConfig") || OPENCODE_DEFAULT_CONFIG);
if (!config.options) config.options = {};
// Remove old extra options (keep only known keys)
const knownKeys = ["baseURL", "apiKey", "headers"];
for (const k of Object.keys(config.options)) {
if (!knownKeys.includes(k)) {
delete config.options[k];
}
}
// Add new extra options (auto-parse value types)
for (const [k, v] of Object.entries(options)) {
const trimmedKey = k.trim();
if (trimmedKey && !trimmedKey.startsWith("option-")) {
try {
// Try to parse as JSON (number, boolean, object, array)
config.options[trimmedKey] = JSON.parse(v);
} catch {
// If parsing fails, keep as string
config.options[trimmedKey] = v;
}
}
}
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
} catch {
// ignore
}
},
[form],
);
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false); const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
const handleSubmit = (values: ProviderFormData) => { const handleSubmit = (values: ProviderFormData) => {
@@ -701,23 +496,6 @@ export function ProviderForm({
return; return;
} }
// OpenCode: validate provider key
if (appId === "opencode") {
const keyPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/;
if (!opencodeProviderKey.trim()) {
toast.error(t("opencode.providerKeyRequired"));
return;
}
if (!keyPattern.test(opencodeProviderKey)) {
toast.error(t("opencode.providerKeyInvalid"));
return;
}
if (!isEditMode && existingOpencodeKeys.includes(opencodeProviderKey)) {
toast.error(t("opencode.providerKeyDuplicate"));
return;
}
}
// 非官方供应商必填校验:端点和 API Key // 非官方供应商必填校验:端点和 API Key
if (category !== "official") { if (category !== "official") {
if (appId === "claude") { if (appId === "claude") {
@@ -815,11 +593,6 @@ export function ProviderForm({
settingsConfig, settingsConfig,
}; };
// OpenCode: pass provider key for ID generation
if (appId === "opencode") {
payload.providerKey = opencodeProviderKey;
}
if (activePreset) { if (activePreset) {
payload.presetId = activePreset.id; payload.presetId = activePreset.id;
if (activePreset.category) { if (activePreset.category) {
@@ -975,15 +748,6 @@ export function ProviderForm({
if (appId === "gemini") { if (appId === "gemini") {
resetGeminiConfig({}, {}); resetGeminiConfig({}, {});
} }
// OpenCode 自定义模式:重置为空配置
if (appId === "opencode") {
setOpencodeProviderKey("");
setOpencodeNpm("@ai-sdk/openai-compatible");
setOpencodeBaseUrl("");
setOpencodeApiKey("");
setOpencodeModels({});
setOpencodeExtraOptions({});
}
return; return;
} }
@@ -1037,42 +801,6 @@ export function ProviderForm({
return; return;
} }
// OpenCode preset handling
if (appId === "opencode") {
const preset = entry.preset as OpenCodeProviderPreset;
const config = preset.settingsConfig;
// Clear provider key (user must enter their own unique key)
setOpencodeProviderKey("");
// Update OpenCode-specific states
setOpencodeNpm(config.npm || "@ai-sdk/openai-compatible");
setOpencodeBaseUrl(config.options?.baseURL || "");
setOpencodeApiKey(config.options?.apiKey || "");
setOpencodeModels(config.models || {});
// Extract extra options from preset
const options = config.options || {};
const extra: Record<string, string> = {};
const knownKeys = ["baseURL", "apiKey", "headers"];
for (const [k, v] of Object.entries(options)) {
if (!knownKeys.includes(k)) {
extra[k] = typeof v === "string" ? v : JSON.stringify(v);
}
}
setOpencodeExtraOptions(extra);
// Update form fields
form.reset({
name: preset.name,
websiteUrl: preset.websiteUrl ?? "",
settingsConfig: JSON.stringify(config, null, 2),
icon: preset.icon ?? "",
iconColor: preset.iconColor ?? "",
});
return;
}
const preset = entry.preset as ProviderPreset; const preset = entry.preset as ProviderPreset;
const config = applyTemplateValues( const config = applyTemplateValues(
preset.settingsConfig, preset.settingsConfig,
@@ -1110,55 +838,14 @@ export function ProviderForm({
)} )}
{/* 基础字段 */} {/* 基础字段 */}
<BasicFormFields <BasicFormFields form={form} />
form={form}
beforeNameSlot={
appId === "opencode" ? (
<div className="space-y-2">
<Label htmlFor="opencode-key">
{t("opencode.providerKey")}
<span className="text-destructive ml-1">*</span>
</Label>
<Input
id="opencode-key"
value={opencodeProviderKey}
onChange={(e) => setOpencodeProviderKey(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""))}
placeholder={t("opencode.providerKeyPlaceholder")}
disabled={isEditMode}
className={
(existingOpencodeKeys.includes(opencodeProviderKey) && !isEditMode) ||
(opencodeProviderKey.trim() !== "" && !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(opencodeProviderKey))
? "border-destructive"
: ""
}
/>
{existingOpencodeKeys.includes(opencodeProviderKey) && !isEditMode && (
<p className="text-xs text-destructive">
{t("opencode.providerKeyDuplicate")}
</p>
)}
{opencodeProviderKey.trim() !== "" && !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(opencodeProviderKey) && (
<p className="text-xs text-destructive">
{t("opencode.providerKeyInvalid")}
</p>
)}
{!(existingOpencodeKeys.includes(opencodeProviderKey) && !isEditMode) &&
(opencodeProviderKey.trim() === "" || /^[a-z0-9]+(-[a-z0-9]+)*$/.test(opencodeProviderKey)) && (
<p className="text-xs text-muted-foreground">
{t("opencode.providerKeyHint")}
</p>
)}
</div>
) : undefined
}
/>
{/* Claude 专属字段 */} {/* Claude 专属字段 */}
{appId === "claude" && ( {appId === "claude" && (
<ClaudeFormFields <ClaudeFormFields
providerId={providerId} providerId={providerId}
shouldShowApiKey={shouldShowApiKey( shouldShowApiKey={shouldShowApiKey(
form.getValues("settingsConfig"), form.watch("settingsConfig"),
isEditMode, isEditMode,
)} )}
apiKey={apiKey} apiKey={apiKey}
@@ -1229,7 +916,7 @@ export function ProviderForm({
<GeminiFormFields <GeminiFormFields
providerId={providerId} providerId={providerId}
shouldShowApiKey={shouldShowApiKey( shouldShowApiKey={shouldShowApiKey(
form.getValues("settingsConfig"), form.watch("settingsConfig"),
isEditMode, isEditMode,
)} )}
apiKey={geminiApiKey} apiKey={geminiApiKey}
@@ -1254,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 分别使用不同的编辑器 */} {/* 配置编辑器:Codex、Claude、Gemini 分别使用不同的编辑器 */}
{appId === "codex" ? ( {appId === "codex" ? (
<> <>
@@ -1332,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 <CommonConfigEditor
value={form.getValues("settingsConfig")} value={form.watch("settingsConfig")}
onChange={(value) => form.setValue("settingsConfig", value)} onChange={(value) => form.setValue("settingsConfig", value)}
useCommonConfig={useCommonConfig} useCommonConfig={useCommonConfig}
onCommonConfigToggle={handleCommonConfigToggle} onCommonConfigToggle={handleCommonConfigToggle}
@@ -1409,5 +1047,4 @@ export type ProviderFormValues = ProviderFormData & {
presetCategory?: ProviderCategory; presetCategory?: ProviderCategory;
isPartner?: boolean; isPartner?: boolean;
meta?: ProviderMeta; meta?: ProviderMeta;
providerKey?: string; // OpenCode: user-defined provider key
}; };
@@ -6,7 +6,7 @@ import {
import type { ProviderCategory } from "@/types"; import type { ProviderCategory } from "@/types";
interface UseBaseUrlStateProps { interface UseBaseUrlStateProps {
appType: "claude" | "codex" | "gemini" | "opencode"; appType: "claude" | "codex" | "gemini";
category: ProviderCategory | undefined; category: ProviderCategory | undefined;
settingsConfig: string; settingsConfig: string;
codexConfig?: string; codexConfig?: string;
@@ -19,8 +19,6 @@ interface UseCommonConfigSnippetProps {
settingsConfig?: Record<string, unknown>; settingsConfig?: Record<string, unknown>;
}; };
selectedPresetId?: string; selectedPresetId?: string;
/** When false, the hook skips all logic and returns disabled state. Default: true */
enabled?: boolean;
} }
/** /**
@@ -32,7 +30,6 @@ export function useCommonConfigSnippet({
onConfigChange, onConfigChange,
initialData, initialData,
selectedPresetId, selectedPresetId,
enabled = true,
}: UseCommonConfigSnippetProps) { }: UseCommonConfigSnippetProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [useCommonConfig, setUseCommonConfig] = useState(false); const [useCommonConfig, setUseCommonConfig] = useState(false);
@@ -50,16 +47,11 @@ export function useCommonConfigSnippet({
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑 // 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
useEffect(() => { useEffect(() => {
if (!enabled) return;
hasInitializedNewMode.current = false; hasInitializedNewMode.current = false;
}, [selectedPresetId, enabled]); }, [selectedPresetId]);
// 初始化:从 config.json 加载,支持从 localStorage 迁移 // 初始化:从 config.json 加载,支持从 localStorage 迁移
useEffect(() => { useEffect(() => {
if (!enabled) {
setIsLoading(false);
return;
}
let mounted = true; let mounted = true;
const loadSnippet = async () => { const loadSnippet = async () => {
@@ -108,11 +100,10 @@ export function useCommonConfigSnippet({
return () => { return () => {
mounted = false; mounted = false;
}; };
}, [enabled]); }, []);
// 初始化时检查通用配置片段(编辑模式) // 初始化时检查通用配置片段(编辑模式)
useEffect(() => { useEffect(() => {
if (!enabled) return;
if (initialData && !isLoading) { if (initialData && !isLoading) {
const configString = JSON.stringify(initialData.settingsConfig, null, 2); const configString = JSON.stringify(initialData.settingsConfig, null, 2);
const hasCommon = hasCommonConfigSnippet( const hasCommon = hasCommonConfigSnippet(
@@ -121,11 +112,10 @@ export function useCommonConfigSnippet({
); );
setUseCommonConfig(hasCommon); setUseCommonConfig(hasCommon);
} }
}, [enabled, initialData, commonConfigSnippet, isLoading]); }, [initialData, commonConfigSnippet, isLoading]);
// 新建模式:如果通用配置片段存在且有效,默认启用 // 新建模式:如果通用配置片段存在且有效,默认启用
useEffect(() => { useEffect(() => {
if (!enabled) return;
// 仅新建模式、加载完成、尚未初始化过 // 仅新建模式、加载完成、尚未初始化过
if (!initialData && !isLoading && !hasInitializedNewMode.current) { if (!initialData && !isLoading && !hasInitializedNewMode.current) {
hasInitializedNewMode.current = true; hasInitializedNewMode.current = true;
@@ -155,7 +145,6 @@ export function useCommonConfigSnippet({
} }
} }
}, [ }, [
enabled,
initialData, initialData,
commonConfigSnippet, commonConfigSnippet,
isLoading, isLoading,
@@ -270,7 +259,6 @@ export function useCommonConfigSnippet({
// 当配置变化时检查是否包含通用配置(但避免在通过通用配置更新时检查) // 当配置变化时检查是否包含通用配置(但避免在通过通用配置更新时检查)
useEffect(() => { useEffect(() => {
if (!enabled) return;
if (isUpdatingFromCommonConfig.current || isLoading) { if (isUpdatingFromCommonConfig.current || isLoading) {
return; return;
} }
@@ -279,7 +267,7 @@ export function useCommonConfigSnippet({
commonConfigSnippet, commonConfigSnippet,
); );
setUseCommonConfig(hasCommon); setUseCommonConfig(hasCommon);
}, [enabled, settingsConfig, commonConfigSnippet, isLoading]); }, [settingsConfig, commonConfigSnippet, isLoading]);
// 从编辑器当前内容提取通用配置片段 // 从编辑器当前内容提取通用配置片段
const handleExtract = useCallback(async () => { const handleExtract = useCallback(async () => {
+1 -3
View File
@@ -37,9 +37,7 @@ export function ProxyToggle({ className, activeApp }: ProxyToggleProps) {
? "Claude" ? "Claude"
: activeApp === "codex" : activeApp === "codex"
? "Codex" ? "Codex"
: activeApp === "gemini" : "Gemini";
? "Gemini"
: "OpenCode";
const tooltipText = takeoverEnabled const tooltipText = takeoverEnabled
? isRunning ? isRunning
+1 -1
View File
@@ -202,7 +202,7 @@ export function SettingsPage({
}; };
return ( 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 ? ( {isBusy ? (
<div className="flex flex-1 items-center justify-center"> <div className="flex flex-1 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
+1 -1
View File
@@ -193,7 +193,7 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
}, [skills, searchQuery, filterStatus]); }, [skills, searchQuery, filterStatus]);
return ( 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="flex-1 overflow-y-auto overflow-x-hidden animate-fade-in">
<div className="py-4"> <div className="py-4">
+3 -21
View File
@@ -52,13 +52,12 @@ const UnifiedSkillsPanel = React.forwardRef<
// Count enabled skills per app // Count enabled skills per app
const enabledCounts = useMemo(() => { 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; if (!skills) return counts;
skills.forEach((skill) => { skills.forEach((skill) => {
if (skill.apps.claude) counts.claude++; if (skill.apps.claude) counts.claude++;
if (skill.apps.codex) counts.codex++; if (skill.apps.codex) counts.codex++;
if (skill.apps.gemini) counts.gemini++; if (skill.apps.gemini) counts.gemini++;
if (skill.apps.opencode) counts.opencode++;
}); });
return counts; return counts;
}, [skills]); }, [skills]);
@@ -133,15 +132,14 @@ const UnifiedSkillsPanel = React.forwardRef<
})); }));
return ( 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 */} {/* Info Section */}
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6"> <div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6">
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{t("skills.installed", { count: skills?.length || 0 })} ·{" "} {t("skills.installed", { count: skills?.length || 0 })} ·{" "}
{t("skills.apps.claude")}: {enabledCounts.claude} ·{" "} {t("skills.apps.claude")}: {enabledCounts.claude} ·{" "}
{t("skills.apps.codex")}: {enabledCounts.codex} ·{" "} {t("skills.apps.codex")}: {enabledCounts.codex} ·{" "}
{t("skills.apps.gemini")}: {enabledCounts.gemini} ·{" "} {t("skills.apps.gemini")}: {enabledCounts.gemini}
{t("skills.apps.opencode")}: {enabledCounts.opencode}
</div> </div>
</div> </div>
@@ -310,22 +308,6 @@ const InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({
} }
/> />
</div> </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> </div>
{/* 右侧:删除按钮 */} {/* 右侧:删除按钮 */}
+8 -2
View File
@@ -1,6 +1,6 @@
import * as React from "react"; import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select"; 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"; import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root; const Select = SelectPrimitive.Root;
@@ -37,7 +37,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content <SelectPrimitive.Content
ref={ref} ref={ref}
className={cn( 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, className,
)} )}
position={position} position={position}
@@ -87,6 +87,12 @@ const SelectItem = React.forwardRef<
)} )}
{...props} {...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.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item> </SelectPrimitive.Item>
)); ));
-678
View File
@@ -1,678 +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: "Z.ai GLM",
websiteUrl: "https://z.ai",
apiKeyUrl: "https://z.ai/subscribe?ic=8JVLJQFSKB",
settingsConfig: {
npm: "@ai-sdk/openai-compatible",
name: "Z.ai GLM",
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: "",
},
},
},
{
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: "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: "",
},
},
},
];
+1 -1
View File
@@ -54,7 +54,7 @@ export function useProviderActions(activeApp: AppId) {
// 添加供应商 // 添加供应商
const addProvider = useCallback( const addProvider = useCallback(
async (provider: Omit<Provider, "id"> & { providerKey?: string }) => { async (provider: Omit<Provider, "id">) => {
await addProviderMutation.mutateAsync(provider); await addProviderMutation.mutateAsync(provider);
}, },
[addProviderMutation], [addProviderMutation],
+1 -3
View File
@@ -101,9 +101,7 @@ export function useProxyStatus() {
? "Claude" ? "Claude"
: variables.appType === "codex" : variables.appType === "codex"
? "Codex" ? "Codex"
: variables.appType === "gemini" : "Gemini";
? "Gemini"
: "OpenCode";
toast.success( toast.success(
variables.enabled variables.enabled
+4 -45
View File
@@ -84,10 +84,6 @@
"addClaudeProvider": "Add Claude Code Provider", "addClaudeProvider": "Add Claude Code Provider",
"addCodexProvider": "Add Codex Provider", "addCodexProvider": "Add Codex Provider",
"addGeminiProvider": "Add Gemini Provider", "addGeminiProvider": "Add Gemini Provider",
"addOpenCodeProvider": "Add OpenCode Provider",
"addToConfig": "Add",
"removeFromConfig": "Remove",
"inConfig": "Added",
"addProviderHint": "Fill in the information to quickly switch providers in the list.", "addProviderHint": "Fill in the information to quickly switch providers in the list.",
"editClaudeProvider": "Edit Claude Code Provider", "editClaudeProvider": "Edit Claude Code Provider",
"editCodexProvider": "Edit Codex Provider", "editCodexProvider": "Edit Codex Provider",
@@ -137,8 +133,6 @@
"providerSaved": "Provider configuration saved", "providerSaved": "Provider configuration saved",
"providerDeleted": "Provider deleted successfully", "providerDeleted": "Provider deleted successfully",
"switchSuccess": "Switch successful!", "switchSuccess": "Switch successful!",
"addToConfigSuccess": "Added to config",
"removeFromConfigSuccess": "Removed from config",
"switchFailedTitle": "Switch failed", "switchFailedTitle": "Switch failed",
"switchFailed": "Switch failed: {{error}}", "switchFailed": "Switch failed: {{error}}",
"autoImported": "Default provider created from existing configuration", "autoImported": "Default provider created from existing configuration",
@@ -159,9 +153,7 @@
}, },
"confirm": { "confirm": {
"deleteProvider": "Delete Provider", "deleteProvider": "Delete Provider",
"deleteProviderMessage": "Are you sure you want to delete provider \"{{name}}\"? This action cannot be undone.", "deleteProviderMessage": "Are you sure you want to delete provider \"{{name}}\"? This action cannot be undone."
"removeProvider": "Remove Provider",
"removeProviderMessage": "Are you sure you want to remove provider \"{{name}}\" from the configuration?\n\nAfter removal, this provider will no longer be active, but the configuration data will be retained in CC Switch. You can re-add it at any time."
}, },
"settings": { "settings": {
"title": "Settings", "title": "Settings",
@@ -312,8 +304,7 @@
"apps": { "apps": {
"claude": "Claude Code", "claude": "Claude Code",
"codex": "Codex", "codex": "Codex",
"gemini": "Gemini", "gemini": "Gemini"
"opencode": "OpenCode"
}, },
"console": { "console": {
"providerSwitchReceived": "Received provider switch event:", "providerSwitchReceived": "Received provider switch event:",
@@ -464,36 +455,6 @@
"configMergeFailed": "Config merge failed: {{error}}", "configMergeFailed": "Config merge failed: {{error}}",
"configReplaceFailed": "Config replace failed: {{error}}" "configReplaceFailed": "Config replace failed: {{error}}"
}, },
"opencode": {
"npmPackage": "API Format",
"selectPackage": "Select API format",
"npmPackageHint": "Select the API format for the AI service",
"baseUrl": "Base URL",
"baseUrlHint": "Custom API endpoint URL",
"models": "Models",
"modelsHint": "Configure available models and their display names",
"addModel": "Add Model",
"modelId": "Model ID",
"modelName": "Display Name",
"noModels": "No models configured",
"providerKey": "Provider Key",
"providerKeyPlaceholder": "my-provider",
"providerKeyHint": "Unique identifier in config file. Cannot be changed after creation. Use lowercase letters, numbers, and hyphens only.",
"providerKeyRequired": "Provider key is required",
"providerKeyDuplicate": "This key is already in use",
"providerKeyInvalid": "Invalid format. Use lowercase letters, numbers, and hyphens only.",
"extraOptions": "Extra Options",
"extraOptionsHint": "Configure extra SDK options like timeout, setCacheKey, etc. Values are auto-parsed to appropriate types (number, boolean, etc.).",
"addExtraOption": "Add",
"extraOptionKey": "Key",
"extraOptionValue": "Value",
"extraOptionKeyPlaceholder": "timeout",
"extraOptionValuePlaceholder": "600000",
"noExtraOptions": "No extra options configured",
"noModelOptions": "Model options, click + to add",
"modelOptionKeyPlaceholder": "provider",
"modelOptionValuePlaceholder": "{\"order\": [\"baseten\"]}"
},
"providerPreset": { "providerPreset": {
"label": "Provider Preset", "label": "Provider Preset",
"custom": "Custom Configuration", "custom": "Custom Configuration",
@@ -673,8 +634,7 @@
"apps": { "apps": {
"claude": "Claude", "claude": "Claude",
"codex": "Codex", "codex": "Codex",
"gemini": "Gemini", "gemini": "Gemini"
"opencode": "OpenCode"
} }
}, },
"userLevelPath": "User-level MCP path", "userLevelPath": "User-level MCP path",
@@ -982,8 +942,7 @@
"apps": { "apps": {
"claude": "Claude", "claude": "Claude",
"codex": "Codex", "codex": "Codex",
"gemini": "Gemini", "gemini": "Gemini"
"opencode": "OpenCode"
} }
}, },
"deeplink": { "deeplink": {
+4 -45
View File
@@ -84,10 +84,6 @@
"addClaudeProvider": "Claude Code プロバイダーを追加", "addClaudeProvider": "Claude Code プロバイダーを追加",
"addCodexProvider": "Codex プロバイダーを追加", "addCodexProvider": "Codex プロバイダーを追加",
"addGeminiProvider": "Gemini プロバイダーを追加", "addGeminiProvider": "Gemini プロバイダーを追加",
"addOpenCodeProvider": "OpenCode プロバイダーを追加",
"addToConfig": "追加",
"removeFromConfig": "削除",
"inConfig": "追加済み",
"addProviderHint": "一覧にすばやく切り替えられるよう、ここに情報を入力してください。", "addProviderHint": "一覧にすばやく切り替えられるよう、ここに情報を入力してください。",
"editClaudeProvider": "Claude Code プロバイダーを編集", "editClaudeProvider": "Claude Code プロバイダーを編集",
"editCodexProvider": "Codex プロバイダーを編集", "editCodexProvider": "Codex プロバイダーを編集",
@@ -137,8 +133,6 @@
"providerSaved": "プロバイダー設定を保存しました", "providerSaved": "プロバイダー設定を保存しました",
"providerDeleted": "プロバイダーを削除しました", "providerDeleted": "プロバイダーを削除しました",
"switchSuccess": "切り替え成功!", "switchSuccess": "切り替え成功!",
"addToConfigSuccess": "設定に追加しました",
"removeFromConfigSuccess": "設定から削除しました",
"switchFailedTitle": "切り替えに失敗しました", "switchFailedTitle": "切り替えに失敗しました",
"switchFailed": "切り替えに失敗しました: {{error}}", "switchFailed": "切り替えに失敗しました: {{error}}",
"autoImported": "既存設定からデフォルトプロバイダーを自動作成しました", "autoImported": "既存設定からデフォルトプロバイダーを自動作成しました",
@@ -159,9 +153,7 @@
}, },
"confirm": { "confirm": {
"deleteProvider": "プロバイダーを削除", "deleteProvider": "プロバイダーを削除",
"deleteProviderMessage": "プロバイダー「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。", "deleteProviderMessage": "プロバイダー「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。"
"removeProvider": "プロバイダーを解除",
"removeProviderMessage": "プロバイダー「{{name}}」を設定から解除してもよろしいですか?\n\n解除後、このプロバイダーは無効になりますが、設定データは CC Switch に保持されます。いつでも再追加できます。"
}, },
"settings": { "settings": {
"title": "設定", "title": "設定",
@@ -312,8 +304,7 @@
"apps": { "apps": {
"claude": "Claude Code", "claude": "Claude Code",
"codex": "Codex", "codex": "Codex",
"gemini": "Gemini", "gemini": "Gemini"
"opencode": "OpenCode"
}, },
"console": { "console": {
"providerSwitchReceived": "プロバイダー切り替えイベントを受信:", "providerSwitchReceived": "プロバイダー切り替えイベントを受信:",
@@ -464,36 +455,6 @@
"configMergeFailed": "設定のマージに失敗しました: {{error}}", "configMergeFailed": "設定のマージに失敗しました: {{error}}",
"configReplaceFailed": "設定の置換に失敗しました: {{error}}" "configReplaceFailed": "設定の置換に失敗しました: {{error}}"
}, },
"opencode": {
"npmPackage": "API フォーマット",
"selectPackage": "API フォーマットを選択",
"npmPackageHint": "AI サービスの API フォーマットを選択",
"baseUrl": "Base URL",
"baseUrlHint": "カスタム API エンドポイント URL",
"models": "モデル設定",
"modelsHint": "利用可能なモデルとその表示名を設定",
"addModel": "モデルを追加",
"modelId": "モデル ID",
"modelName": "表示名",
"noModels": "モデルが設定されていません",
"providerKey": "プロバイダーキー",
"providerKeyPlaceholder": "my-provider",
"providerKeyHint": "設定ファイルの一意の識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用できます。",
"providerKeyRequired": "プロバイダーキーを入力してください",
"providerKeyDuplicate": "このキーは既に使用されています",
"providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用できます。",
"extraOptions": "追加オプション",
"extraOptionsHint": "timeout、setCacheKey などの SDK オプションを設定。値は自動的に適切な型(数値、真偽値など)に変換されます。",
"addExtraOption": "追加",
"extraOptionKey": "キー名",
"extraOptionValue": "値",
"extraOptionKeyPlaceholder": "timeout",
"extraOptionValuePlaceholder": "600000",
"noExtraOptions": "追加オプションはありません",
"noModelOptions": "モデルオプション、+ をクリックして追加",
"modelOptionKeyPlaceholder": "provider",
"modelOptionValuePlaceholder": "{\"order\": [\"baseten\"]}"
},
"providerPreset": { "providerPreset": {
"label": "プロバイダータイプ", "label": "プロバイダータイプ",
"custom": "カスタム設定", "custom": "カスタム設定",
@@ -673,8 +634,7 @@
"apps": { "apps": {
"claude": "Claude", "claude": "Claude",
"codex": "Codex", "codex": "Codex",
"gemini": "Gemini", "gemini": "Gemini"
"opencode": "OpenCode"
} }
}, },
"userLevelPath": "ユーザーレベルの MCP パス", "userLevelPath": "ユーザーレベルの MCP パス",
@@ -982,8 +942,7 @@
"apps": { "apps": {
"claude": "Claude", "claude": "Claude",
"codex": "Codex", "codex": "Codex",
"gemini": "Gemini", "gemini": "Gemini"
"opencode": "OpenCode"
} }
}, },
"deeplink": { "deeplink": {
+4 -45
View File
@@ -84,10 +84,6 @@
"addClaudeProvider": "添加 Claude Code 供应商", "addClaudeProvider": "添加 Claude Code 供应商",
"addCodexProvider": "添加 Codex 供应商", "addCodexProvider": "添加 Codex 供应商",
"addGeminiProvider": "添加 Gemini 供应商", "addGeminiProvider": "添加 Gemini 供应商",
"addOpenCodeProvider": "添加 OpenCode 供应商",
"addToConfig": "添加",
"removeFromConfig": "移除",
"inConfig": "已添加",
"addProviderHint": "填写信息后即可在列表中快速切换供应商。", "addProviderHint": "填写信息后即可在列表中快速切换供应商。",
"editClaudeProvider": "编辑 Claude Code 供应商", "editClaudeProvider": "编辑 Claude Code 供应商",
"editCodexProvider": "编辑 Codex 供应商", "editCodexProvider": "编辑 Codex 供应商",
@@ -137,8 +133,6 @@
"providerSaved": "供应商配置已保存", "providerSaved": "供应商配置已保存",
"providerDeleted": "供应商删除成功", "providerDeleted": "供应商删除成功",
"switchSuccess": "切换成功!", "switchSuccess": "切换成功!",
"addToConfigSuccess": "已添加到配置",
"removeFromConfigSuccess": "已从配置移除",
"switchFailedTitle": "切换失败", "switchFailedTitle": "切换失败",
"switchFailed": "切换失败:{{error}}", "switchFailed": "切换失败:{{error}}",
"autoImported": "已从现有配置创建默认供应商", "autoImported": "已从现有配置创建默认供应商",
@@ -159,9 +153,7 @@
}, },
"confirm": { "confirm": {
"deleteProvider": "删除供应商", "deleteProvider": "删除供应商",
"deleteProviderMessage": "确定要删除供应商 \"{{name}}\" 吗?此操作无法撤销。", "deleteProviderMessage": "确定要删除供应商 \"{{name}}\" 吗?此操作无法撤销。"
"removeProvider": "移除供应商",
"removeProviderMessage": "确定要从配置中移除供应商 \"{{name}}\" 吗?\n\n移除后该供应商将不再生效,但配置数据会保留在 CC Switch 中,您可以随时重新添加。"
}, },
"settings": { "settings": {
"title": "设置", "title": "设置",
@@ -312,8 +304,7 @@
"apps": { "apps": {
"claude": "Claude Code", "claude": "Claude Code",
"codex": "Codex", "codex": "Codex",
"gemini": "Gemini", "gemini": "Gemini"
"opencode": "OpenCode"
}, },
"console": { "console": {
"providerSwitchReceived": "收到供应商切换事件:", "providerSwitchReceived": "收到供应商切换事件:",
@@ -464,36 +455,6 @@
"configMergeFailed": "配置合并失败: {{error}}", "configMergeFailed": "配置合并失败: {{error}}",
"configReplaceFailed": "配置替换失败: {{error}}" "configReplaceFailed": "配置替换失败: {{error}}"
}, },
"opencode": {
"npmPackage": "接口格式",
"selectPackage": "选择接口格式",
"npmPackageHint": "选择 AI 服务的 API 接口格式",
"baseUrl": "Base URL",
"baseUrlHint": "自定义 API 端点地址",
"models": "模型配置",
"modelsHint": "配置可用的模型及其显示名称",
"addModel": "添加模型",
"modelId": "模型 ID",
"modelName": "显示名称",
"noModels": "暂无模型配置",
"providerKey": "供应商标识",
"providerKeyPlaceholder": "my-provider",
"providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符",
"providerKeyRequired": "请填写供应商标识",
"providerKeyDuplicate": "此标识已被使用,请更换",
"providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符",
"extraOptions": "额外选项",
"extraOptionsHint": "配置额外的 SDK 选项,如 timeout、setCacheKey 等。值会自动解析类型(数字、布尔值等)。",
"addExtraOption": "添加",
"extraOptionKey": "键名",
"extraOptionValue": "值",
"extraOptionKeyPlaceholder": "timeout",
"extraOptionValuePlaceholder": "600000",
"noExtraOptions": "暂无额外选项",
"noModelOptions": "模型选项,点击 + 添加",
"modelOptionKeyPlaceholder": "provider",
"modelOptionValuePlaceholder": "{\"order\": [\"baseten\"]}"
},
"providerPreset": { "providerPreset": {
"label": "预设供应商", "label": "预设供应商",
"custom": "自定义配置", "custom": "自定义配置",
@@ -673,8 +634,7 @@
"apps": { "apps": {
"claude": "Claude", "claude": "Claude",
"codex": "Codex", "codex": "Codex",
"gemini": "Gemini", "gemini": "Gemini"
"opencode": "OpenCode"
} }
}, },
"userLevelPath": "用户级 MCP 配置路径", "userLevelPath": "用户级 MCP 配置路径",
@@ -982,8 +942,7 @@
"apps": { "apps": {
"claude": "Claude", "claude": "Claude",
"codex": "Codex", "codex": "Codex",
"gemini": "Gemini", "gemini": "Gemini"
"opencode": "OpenCode"
} }
}, },
"deeplink": { "deeplink": {
-1
View File
@@ -53,7 +53,6 @@ export const icons: Record<string, string> = {
longcat: `<svg fill="currentColor" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>LongCat</title><path clip-rule="evenodd" d="M.507 19.883a.507.507 0 01-.489-.642L4.29 3.745a1.013 1.013 0 011.533-.578l5.622 3.687a1.013 1.013 0 001.11 0L18.2 3.165a1.013 1.013 0 011.532.58l4.25 15.497a.506.506 0 01-.49.64H18.07a6.297 6.297 0 001.53-4.115v-.177a6.09 6.09 0 00-1.513-4.017l-.697-3.495a.438.438 0 00-.694-.266L14.07 9.781a.748.748 0 01-.654.121 5.156 5.156 0 00-2.833 0 .746.746 0 01-.653-.121L7.302 7.81a.435.435 0 00-.688.269l-.675 3.652a5.36 5.36 0 00-1.539 3.76v.333c0 1.474.527 2.9 1.488 4.02l.032.038H.507z" fill="#29E154" fill-rule="evenodd"></path><path d="M9.213 16.843h1.52v-3.546h-1.29l-.23 3.546zm5.573 0h-1.52v-3.546h1.29l.23 3.546z"></path></svg>`, longcat: `<svg fill="currentColor" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>LongCat</title><path clip-rule="evenodd" d="M.507 19.883a.507.507 0 01-.489-.642L4.29 3.745a1.013 1.013 0 011.533-.578l5.622 3.687a1.013 1.013 0 001.11 0L18.2 3.165a1.013 1.013 0 011.532.58l4.25 15.497a.506.506 0 01-.49.64H18.07a6.297 6.297 0 001.53-4.115v-.177a6.09 6.09 0 00-1.513-4.017l-.697-3.495a.438.438 0 00-.694-.266L14.07 9.781a.748.748 0 01-.654.121 5.156 5.156 0 00-2.833 0 .746.746 0 01-.653-.121L7.302 7.81a.435.435 0 00-.688.269l-.675 3.652a5.36 5.36 0 00-1.539 3.76v.333c0 1.474.527 2.9 1.488 4.02l.032.038H.507z" fill="#29E154" fill-rule="evenodd"></path><path d="M9.213 16.843h1.52v-3.546h-1.29l-.23 3.546zm5.573 0h-1.52v-3.546h1.29l.23 3.546z"></path></svg>`,
modelscope: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ModelScope</title><path d="M0 7.967h2.667v2.667H0zM8 10.633h2.667V13.3H8z" fill="#36CED0"></path><path d="M0 10.633h2.667V13.3H0zM2.667 13.3h2.666v2.667H8v2.666H2.667V13.3zM2.667 5.3H8v2.667H5.333v2.666H2.667V5.3zM10.667 13.3h2.667v2.667h-2.667z" fill="#624AFF"></path><path d="M24 7.967h-2.667v2.667H24zM16 10.633h-2.667V13.3H16z" fill="#36CED0"></path><path d="M24 10.633h-2.667V13.3H24zM21.333 13.3h-2.666v2.667H16v2.666h5.333V13.3zM21.333 5.3H16v2.667h2.667v2.666h2.666V5.3z" fill="#624AFF"></path></svg>`, modelscope: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ModelScope</title><path d="M0 7.967h2.667v2.667H0zM8 10.633h2.667V13.3H8z" fill="#36CED0"></path><path d="M0 10.633h2.667V13.3H0zM2.667 13.3h2.666v2.667H8v2.666H2.667V13.3zM2.667 5.3H8v2.667H5.333v2.666H2.667V5.3zM10.667 13.3h2.667v2.667h-2.667z" fill="#624AFF"></path><path d="M24 7.967h-2.667v2.667H24zM16 10.633h-2.667V13.3H16z" fill="#36CED0"></path><path d="M24 10.633h-2.667V13.3H24zM21.333 13.3h-2.666v2.667H16v2.666h5.333V13.3zM21.333 5.3H16v2.667h2.667v2.666h2.666V5.3z" fill="#624AFF"></path></svg>`,
aihubmix: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>AiHubMix</title><path d="M12 24c6.627 0 12-5.373 12-12S18.627 0 12 0 0 5.373 0 12s5.373 12 12 12z" fill="#006FFB"></path><path clip-rule="evenodd" d="M11.24 8.393c.095-.644.302-1.47.624-2.48L12 5.496l.136.417c.322 1.01.53 1.836.624 2.48.071.472.071 1.072 0 1.8-.072.731-.072 1.336 0 1.814.106.7.426 1.281.96 1.744a2.795 2.795 0 001.89.708 2.78 2.78 0 002.034-.84c.56-.559.842-1.234.848-2.024.003-.7.075-1.472.216-2.316.069-.422.14-.775.21-1.06l.095-.384.168.356a7.862 7.862 0 01.76 3.244v.16a7.84 7.84 0 01-.624 3.089 7.952 7.952 0 01-4.228 4.228 7.841 7.841 0 01-3.089.623 7.84 7.84 0 01-3.089-.623 7.952 7.952 0 01-4.228-4.228 7.84 7.84 0 01-.623-3.09v-.159a7.862 7.862 0 01.759-3.244l.169-.356.093.385c.072.284.143.637.211 1.059.141.844.213 1.616.216 2.316.006.79.29 1.465.848 2.024.563.56 1.241.84 2.035.84.715 0 1.345-.236 1.889-.708a2.79 2.79 0 00.96-1.744c.073-.478.073-1.083 0-1.814-.071-.728-.071-1.328 0-1.8zm.76 9.694c1.097 0 2.125-.26 3.085-.778a6.379 6.379 0 001.77-1.399c.063-.07-.01-.178-.101-.153-.37.1-.75.15-1.144.15a4.236 4.236 0 01-2.18-.59 4.253 4.253 0 01-1.35-1.233.099.099 0 00-.16 0 4.253 4.253 0 01-1.35 1.232 4.236 4.236 0 01-2.18.591c-.393 0-.774-.05-1.143-.15-.091-.025-.165.083-.102.153a6.38 6.38 0 001.77 1.399c.96.518 1.988.778 3.085.778z" fill="#fff" fill-rule="evenodd"></path></svg>`, aihubmix: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>AiHubMix</title><path d="M12 24c6.627 0 12-5.373 12-12S18.627 0 12 0 0 5.373 0 12s5.373 12 12 12z" fill="#006FFB"></path><path clip-rule="evenodd" d="M11.24 8.393c.095-.644.302-1.47.624-2.48L12 5.496l.136.417c.322 1.01.53 1.836.624 2.48.071.472.071 1.072 0 1.8-.072.731-.072 1.336 0 1.814.106.7.426 1.281.96 1.744a2.795 2.795 0 001.89.708 2.78 2.78 0 002.034-.84c.56-.559.842-1.234.848-2.024.003-.7.075-1.472.216-2.316.069-.422.14-.775.21-1.06l.095-.384.168.356a7.862 7.862 0 01.76 3.244v.16a7.84 7.84 0 01-.624 3.089 7.952 7.952 0 01-4.228 4.228 7.841 7.841 0 01-3.089.623 7.84 7.84 0 01-3.089-.623 7.952 7.952 0 01-4.228-4.228 7.84 7.84 0 01-.623-3.09v-.159a7.862 7.862 0 01.759-3.244l.169-.356.093.385c.072.284.143.637.211 1.059.141.844.213 1.616.216 2.316.006.79.29 1.465.848 2.024.563.56 1.241.84 2.035.84.715 0 1.345-.236 1.889-.708a2.79 2.79 0 00.96-1.744c.073-.478.073-1.083 0-1.814-.071-.728-.071-1.328 0-1.8zm.76 9.694c1.097 0 2.125-.26 3.085-.778a6.379 6.379 0 001.77-1.399c.063-.07-.01-.178-.101-.153-.37.1-.75.15-1.144.15a4.236 4.236 0 01-2.18-.59 4.253 4.253 0 01-1.35-1.233.099.099 0 00-.16 0 4.253 4.253 0 01-1.35 1.232 4.236 4.236 0 01-2.18.591c-.393 0-.774-.05-1.143-.15-.091-.025-.165.083-.102.153a6.38 6.38 0 001.77 1.399c.96.518 1.988.778 3.085.778z" fill="#fff" fill-rule="evenodd"></path></svg>`,
opencode: `<svg height="1em" width="1em" style="flex:none;line-height:1" viewBox="0 0 240 300" xmlns="http://www.w3.org/2000/svg"><title>OpenCode</title><g clip-path="url(#clip0_1401_86274)"><mask id="mask0_1401_86274" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="240" height="300"><path d="M240 0H0V300H240V0Z" fill="white"/></mask><g mask="url(#mask0_1401_86274)"><path d="M180 240H60V120H180V240Z" fill="#CFCECD"/><path d="M180 60H60V240H180V60ZM240 300H0V0H240V300Z" fill="#211E1E"/></g></g><defs><clipPath id="clip0_1401_86274"><rect width="240" height="300" fill="white"/></clipPath></defs></svg>`,
}; };
export const iconList = Object.keys(icons); export const iconList = Object.keys(icons);
@@ -1 +0,0 @@
<svg width='240' height='300' viewBox='0 0 240 300' fill='none' xmlns='http://www.w3.org/2000/svg'><g clip-path='url(#clip0_1401_86274)'><mask id='mask0_1401_86274' style='mask-type:luminance' maskUnits='userSpaceOnUse' x='0' y='0' width='240' height='300'><path d='M240 0H0V300H240V0Z' fill='white'/></mask><g mask='url(#mask0_1401_86274)'><path d='M180 240H60V120H180V240Z' fill='#CFCECD'/><path d='M180 60H60V240H180V60ZM240 300H0V0H240V300Z' fill='#211E1E'/></g></g><defs><clipPath id='clip0_1401_86274'><rect width='240' height='300' fill='white'/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 577 B

-24
View File
@@ -38,14 +38,6 @@ export const providersApi = {
return await invoke("delete_provider", { id, app: appId }); return await invoke("delete_provider", { id, app: appId });
}, },
/**
* Remove provider from live config only (for additive mode apps like OpenCode)
* Does NOT delete from database - provider remains in the list
*/
async removeFromLiveConfig(id: string, appId: AppId): Promise<boolean> {
return await invoke("remove_provider_from_live_config", { id, app: appId });
},
async switch(id: string, appId: AppId): Promise<boolean> { async switch(id: string, appId: AppId): Promise<boolean> {
return await invoke("switch_provider", { id, app: appId }); return await invoke("switch_provider", { id, app: appId });
}, },
@@ -82,22 +74,6 @@ export const providersApi = {
async openTerminal(providerId: string, appId: AppId): Promise<boolean> { async openTerminal(providerId: string, appId: AppId): Promise<boolean> {
return await invoke("open_provider_terminal", { providerId, app: appId }); return await invoke("open_provider_terminal", { providerId, app: appId });
}, },
/**
* OpenCode live
* OpenCode opencode.json
*/
async importOpenCodeFromLive(): Promise<number> {
return await invoke("import_opencode_providers_from_live");
},
/**
* OpenCode live ID
* opencode.json
*/
async getOpenCodeLiveProviderIds(): Promise<string[]> {
return await invoke("get_opencode_live_provider_ids");
},
}; };
// ============================================================================ // ============================================================================
+1 -2
View File
@@ -2,14 +2,13 @@ import { invoke } from "@tauri-apps/api/core";
// ========== 类型定义 ========== // ========== 类型定义 ==========
export type AppType = "claude" | "codex" | "gemini" | "opencode"; export type AppType = "claude" | "codex" | "gemini";
/** Skill 应用启用状态 */ /** Skill 应用启用状态 */
export interface SkillApps { export interface SkillApps {
claude: boolean; claude: boolean;
codex: boolean; codex: boolean;
gemini: boolean; gemini: boolean;
opencode: boolean;
} }
/** 已安装的 Skillv3.10.0+ 统一结构) */ /** 已安装的 Skillv3.10.0+ 统一结构) */
+1 -1
View File
@@ -1,2 +1,2 @@
// 前端统一使用 AppId 作为应用标识(与后端命令参数 `app` 一致) // 前端统一使用 AppId 作为应用标识(与后端命令参数 `app` 一致)
export type AppId = "claude" | "codex" | "gemini" | "opencode"; export type AppId = "claude" | "codex" | "gemini"; // 新增 gemini
+11 -38
View File
@@ -11,30 +11,12 @@ export const useAddProviderMutation = (appId: AppId) => {
const { t } = useTranslation(); const { t } = useTranslation();
return useMutation({ return useMutation({
mutationFn: async ( mutationFn: async (providerInput: Omit<Provider, "id">) => {
providerInput: Omit<Provider, "id"> & { providerKey?: string }
) => {
let id: string;
if (appId === "opencode") {
// OpenCode: use user-provided providerKey as ID
if (!providerInput.providerKey) {
throw new Error("Provider key is required for OpenCode");
}
id = providerInput.providerKey;
} else {
// Other apps: use random UUID
id = generateUUID();
}
const newProvider: Provider = { const newProvider: Provider = {
...providerInput, ...providerInput,
id, id: generateUUID(),
createdAt: Date.now(), createdAt: Date.now(),
}; };
// Remove providerKey from the provider object before saving
delete (newProvider as any).providerKey;
await providersApi.add(newProvider, appId); await providersApi.add(newProvider, appId);
return newProvider; return newProvider;
}, },
@@ -154,13 +136,6 @@ export const useSwitchProviderMutation = (appId: AppId) => {
onSuccess: async () => { onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["providers", appId] }); await queryClient.invalidateQueries({ queryKey: ["providers", appId] });
// OpenCode: also invalidate live provider IDs cache to update button state
if (appId === "opencode") {
await queryClient.invalidateQueries({
queryKey: ["opencodeLiveProviderIds"],
});
}
// 更新托盘菜单(失败不影响主操作) // 更新托盘菜单(失败不影响主操作)
try { try {
await providersApi.updateTrayMenu(); await providersApi.updateTrayMenu();
@@ -171,17 +146,15 @@ export const useSwitchProviderMutation = (appId: AppId) => {
); );
} }
// OpenCode: show "added to config" message instead of "switched" toast.success(
const messageKey = t("notifications.switchSuccess", {
appId === "opencode" defaultValue: "切换供应商成功",
? "notifications.addToConfigSuccess" appName: t(`apps.${appId}`, { defaultValue: appId }),
: "notifications.switchSuccess"; }),
const defaultMessage = {
appId === "opencode" ? "已添加到配置" : "切换供应商成功"; closeButton: true,
},
toast.success(t(messageKey, { defaultValue: defaultMessage }), { );
closeButton: true,
});
}, },
onError: (error: Error) => { onError: (error: Error) => {
const detail = extractErrorMessage(error) || t("common.unknown"); const detail = extractErrorMessage(error) || t("common.unknown");
-48
View File
@@ -125,8 +125,6 @@ export interface Settings {
codexConfigDir?: string; codexConfigDir?: string;
// 覆盖 Gemini 配置目录(可选) // 覆盖 Gemini 配置目录(可选)
geminiConfigDir?: string; geminiConfigDir?: string;
// 覆盖 OpenCode 配置目录(可选)
opencodeConfigDir?: string;
// ===== 当前供应商 ID(设备级)===== // ===== 当前供应商 ID(设备级)=====
// 当前 Claude 供应商 ID(优先于数据库 is_current // 当前 Claude 供应商 ID(优先于数据库 is_current
@@ -158,7 +156,6 @@ export interface McpApps {
claude: boolean; claude: boolean;
codex: boolean; codex: boolean;
gemini: boolean; gemini: boolean;
opencode: boolean;
} }
// MCP 服务器条目(v3.7.0 统一结构) // MCP 服务器条目(v3.7.0 统一结构)
@@ -250,48 +247,3 @@ export interface UniversalProvider {
// 统一供应商映射(id -> UniversalProvider // 统一供应商映射(id -> UniversalProvider
export type UniversalProvidersMap = Record<string, UniversalProvider>; export type UniversalProvidersMap = Record<string, UniversalProvider>;
// ============================================================================
// OpenCode 专属配置(v3.9.2+
// ============================================================================
// OpenCode 模型配置
export interface OpenCodeModel {
name: string;
limit?: {
context?: number;
output?: number;
};
options?: Record<string, unknown>; // 模型级别额外选项(provider 路由等)
}
// OpenCode 供应商选项
export interface OpenCodeProviderOptions {
baseURL?: string;
apiKey?: string;
headers?: Record<string, string>;
// 支持额外选项(timeout, setCacheKey 等)
[key: string]: unknown;
}
// OpenCode 供应商配置(settings_config 结构)
export interface OpenCodeProviderConfig {
npm: string; // AI SDK 包名,如 "@ai-sdk/openai-compatible"
name?: string; // 供应商显示名称
options: OpenCodeProviderOptions;
models: Record<string, OpenCodeModel>;
}
// OpenCode MCP 服务器配置(与统一格式不同)
export interface OpenCodeMcpServerSpec {
type: "local" | "remote";
// local 类型字段
command?: string[]; // 与统一格式不同:命令和参数合并为数组
environment?: Record<string, string>; // 与统一格式不同:使用 environment 而非 env
// remote 类型字段
url?: string;
headers?: Record<string, string>;
// 通用字段
enabled?: boolean;
}
-1
View File
@@ -45,7 +45,6 @@ export interface ProxyTakeoverStatus {
claude: boolean; claude: boolean;
codex: boolean; codex: boolean;
gemini: boolean; gemini: boolean;
opencode: boolean;
} }
export interface ProviderHealth { export interface ProviderHealth {
-1
View File
@@ -432,7 +432,6 @@ type = "stdio"
claude: false, claude: false,
codex: false, codex: false,
gemini: false, gemini: false,
opencode: false,
}); });
expect(onSave).toHaveBeenCalledTimes(1); expect(onSave).toHaveBeenCalledTimes(1);
expect(toastErrorMock).not.toHaveBeenCalled(); expect(toastErrorMock).not.toHaveBeenCalled();
+4 -8
View File
@@ -57,14 +57,12 @@ const createDefaultProviders = (): ProvidersByApp => ({
createdAt: Date.now(), createdAt: Date.now(),
}, },
}, },
opencode: {},
}); });
const createDefaultCurrent = (): CurrentProviderState => ({ const createDefaultCurrent = (): CurrentProviderState => ({
claude: "claude-1", claude: "claude-1",
codex: "codex-1", codex: "codex-1",
gemini: "gemini-1", gemini: "gemini-1",
opencode: "",
}); });
let providers = createDefaultProviders(); let providers = createDefaultProviders();
@@ -84,7 +82,7 @@ let mcpConfigs: McpConfigState = {
id: "sample", id: "sample",
name: "Sample Claude Server", name: "Sample Claude Server",
enabled: true, enabled: true,
apps: { claude: true, codex: false, gemini: false, opencode: false }, apps: { claude: true, codex: false, gemini: false },
server: { server: {
type: "stdio", type: "stdio",
command: "claude-server", command: "claude-server",
@@ -96,7 +94,7 @@ let mcpConfigs: McpConfigState = {
id: "httpServer", id: "httpServer",
name: "HTTP Codex Server", name: "HTTP Codex Server",
enabled: false, enabled: false,
apps: { claude: false, codex: true, gemini: false, opencode: false }, apps: { claude: false, codex: true, gemini: false },
server: { server: {
type: "http", type: "http",
url: "http://localhost:3000", url: "http://localhost:3000",
@@ -104,7 +102,6 @@ let mcpConfigs: McpConfigState = {
}, },
}, },
gemini: {}, gemini: {},
opencode: {},
}; };
const cloneProviders = (value: ProvidersByApp) => const cloneProviders = (value: ProvidersByApp) =>
@@ -128,7 +125,7 @@ export const resetProviderState = () => {
id: "sample", id: "sample",
name: "Sample Claude Server", name: "Sample Claude Server",
enabled: true, enabled: true,
apps: { claude: true, codex: false, gemini: false, opencode: false }, apps: { claude: true, codex: false, gemini: false },
server: { server: {
type: "stdio", type: "stdio",
command: "claude-server", command: "claude-server",
@@ -140,7 +137,7 @@ export const resetProviderState = () => {
id: "httpServer", id: "httpServer",
name: "HTTP Codex Server", name: "HTTP Codex Server",
enabled: false, enabled: false,
apps: { claude: false, codex: true, gemini: false, opencode: false }, apps: { claude: false, codex: true, gemini: false },
server: { server: {
type: "http", type: "http",
url: "http://localhost:3000", url: "http://localhost:3000",
@@ -148,7 +145,6 @@ export const resetProviderState = () => {
}, },
}, },
gemini: {}, gemini: {},
opencode: {},
}; };
}; };