diff --git a/docs/opencode-implementation-plan.md b/docs/opencode-implementation-plan.md new file mode 100644 index 000000000..6aecc4a7b --- /dev/null +++ b/docs/opencode-implementation-plan.md @@ -0,0 +1,485 @@ +# OpenCode 第四应用支持实现计划 + +> **范围说明**:本计划暂不包含统一供应商(UniversalProvider)对 OpenCode 的支持,以降低初期实现复杂度。 + +## 概述 + +为 CC Switch 添加 OpenCode 支持,这是第四个受管理的 CLI 应用。OpenCode 的核心差异在于采用**累加式**供应商管理(多供应商共存,应用内热切换),而非现有三应用的**替换式**管理。 + +## 关键设计决策 + +| 特性 | Claude/Codex/Gemini | OpenCode | +|------|---------------------|----------| +| 供应商模式 | 替换式(单一活跃) | 累加式(多供应商共存) | +| UI 按钮 | 启用/切换 | 添加/删除 | +| is_current | 需要 | 不需要 | +| 代理/故障转移 | 支持 | 不支持 | +| API 格式字段 | 无 | 需要(npm 包名) | +| 配置文件 | 各自独立 | `~/.config/opencode/opencode.json` | + +## 配置文件格式 + +### 供应商配置 +```json +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "provider-id": { + "npm": "@ai-sdk/openai-compatible", + "name": "Provider Name", + "options": { + "baseURL": "https://api.example.com/v1", + "apiKey": "{env:API_KEY}" + }, + "models": { + "model-id": { "name": "Model Name" } + } + } + } +} +``` + +### MCP 配置 +```json +{ + "mcp": { + "remote-server": { + "type": "remote", + "url": "https://example.com/mcp", + "enabled": true + }, + "local-server": { + "type": "local", + "command": ["npx", "-y", "my-mcp-command"], + "enabled": true, + "environment": { "KEY": "value" } + } + } +} +``` + +--- + +## 实现步骤 + +### Phase 1: 后端数据结构扩展 + +#### 1.1 AppType 枚举扩展 +**文件**: `src-tauri/src/app_config.rs` + +```rust +pub enum AppType { + Claude, + Codex, + Gemini, + OpenCode, // 新增 +} +``` + +#### 1.2 McpApps / SkillApps 扩展 +**文件**: `src-tauri/src/app_config.rs` + +```rust +pub struct McpApps { + pub claude: bool, + pub codex: bool, + pub gemini: bool, + pub opencode: bool, // 新增 +} + +pub struct SkillApps { + pub claude: bool, + pub codex: bool, + pub gemini: bool, + pub opencode: bool, // 新增 +} +``` + +#### 1.3 数据库 Schema 迁移 +**文件**: `src-tauri/src/database/schema.rs` + +- `SCHEMA_VERSION` 递增 +- 添加迁移: + ```sql + ALTER TABLE mcp_servers ADD COLUMN enabled_opencode BOOLEAN NOT NULL DEFAULT 0; + ALTER TABLE skills ADD COLUMN enabled_opencode BOOLEAN NOT NULL DEFAULT 0; + ``` + +### Phase 2: OpenCode 供应商数据结构 + +#### 2.1 OpenCode 专属配置结构 +**文件**: `src-tauri/src/provider.rs`(或新建 `opencode_provider.rs`) + +```rust +/// OpenCode 供应商的 settings_config 结构 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpenCodeProviderConfig { + /// AI SDK 包名,如 "@ai-sdk/openai-compatible" + pub npm: String, + /// 供应商选项 + pub options: OpenCodeProviderOptions, + /// 模型定义 + pub models: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpenCodeProviderOptions { + #[serde(rename = "baseURL", skip_serializing_if = "Option::is_none")] + pub base_url: Option, + #[serde(rename = "apiKey", skip_serializing_if = "Option::is_none")] + pub api_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub headers: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpenCodeModel { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpenCodeModelLimit { + pub context: Option, + pub output: Option, +} +``` + +### 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 +``` + +**格式转换**: +| 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, AppError> + +/// 添加供应商(同时写入 live 配置) +pub fn add(state: &AppState, provider: Provider) -> Result + +/// 更新供应商 +pub fn update(state: &AppState, provider: Provider) -> Result + +/// 删除供应商(同时从 live 配置移除) +pub fn delete(state: &AppState, id: &str) -> Result<(), AppError> + +/// 从 live 配置导入供应商到数据库 +pub fn import_from_live(state: &AppState) -> Result +``` + +**关键差异**: +- 不需要 `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; + }; + models: Record; +} + +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 ( + + ); +} +``` + +#### 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` - 对 OpenCode 可能无意义,但保持结构一致 +- `opencode_config_dir: Option` - 自定义配置目录 diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index 513ae5606..9b4a7b0e9 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -13,6 +13,8 @@ pub struct McpApps { pub codex: bool, #[serde(default)] pub gemini: bool, + #[serde(default)] + pub opencode: bool, } impl McpApps { @@ -22,6 +24,7 @@ impl McpApps { AppType::Claude => self.claude, AppType::Codex => self.codex, AppType::Gemini => self.gemini, + AppType::OpenCode => self.opencode, } } @@ -31,6 +34,7 @@ impl McpApps { AppType::Claude => self.claude = enabled, AppType::Codex => self.codex = enabled, AppType::Gemini => self.gemini = enabled, + AppType::OpenCode => self.opencode = enabled, } } @@ -46,12 +50,15 @@ impl McpApps { if self.gemini { apps.push(AppType::Gemini); } + if self.opencode { + apps.push(AppType::OpenCode); + } apps } /// 检查是否所有应用都未启用 pub fn is_empty(&self) -> bool { - !self.claude && !self.codex && !self.gemini + !self.claude && !self.codex && !self.gemini && !self.opencode } } @@ -64,6 +71,8 @@ pub struct SkillApps { pub codex: bool, #[serde(default)] pub gemini: bool, + #[serde(default)] + pub opencode: bool, } impl SkillApps { @@ -73,6 +82,7 @@ impl SkillApps { AppType::Claude => self.claude, AppType::Codex => self.codex, AppType::Gemini => self.gemini, + AppType::OpenCode => self.opencode, } } @@ -82,6 +92,7 @@ impl SkillApps { AppType::Claude => self.claude = enabled, AppType::Codex => self.codex = enabled, AppType::Gemini => self.gemini = enabled, + AppType::OpenCode => self.opencode = enabled, } } @@ -97,12 +108,15 @@ impl SkillApps { if self.gemini { apps.push(AppType::Gemini); } + if self.opencode { + apps.push(AppType::OpenCode); + } apps } /// 检查是否所有应用都未启用 pub fn is_empty(&self) -> bool { - !self.claude && !self.codex && !self.gemini + !self.claude && !self.codex && !self.gemini && !self.opencode } /// 仅启用指定应用(其他应用设为禁用) @@ -205,6 +219,9 @@ pub struct McpRoot { pub codex: McpConfig, #[serde(default, skip_serializing_if = "McpConfig::is_empty")] pub gemini: McpConfig, + /// OpenCode MCP 配置(v4.0.0+,实际使用 opencode.json) + #[serde(default, skip_serializing_if = "McpConfig::is_empty")] + pub opencode: McpConfig, } impl Default for McpRoot { @@ -216,6 +233,7 @@ impl Default for McpRoot { claude: McpConfig::default(), codex: McpConfig::default(), gemini: McpConfig::default(), + opencode: McpConfig::default(), } } } @@ -236,6 +254,8 @@ pub struct PromptRoot { pub codex: PromptConfig, #[serde(default)] pub gemini: PromptConfig, + #[serde(default)] + pub opencode: PromptConfig, } use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file}; @@ -249,7 +269,8 @@ use crate::provider::ProviderManager; pub enum AppType { Claude, Codex, - Gemini, // 新增 + Gemini, + OpenCode, } impl AppType { @@ -257,7 +278,8 @@ impl AppType { match self { AppType::Claude => "claude", AppType::Codex => "codex", - AppType::Gemini => "gemini", // 新增 + AppType::Gemini => "gemini", + AppType::OpenCode => "opencode", } } } @@ -270,11 +292,12 @@ impl FromStr for AppType { match normalized.as_str() { "claude" => Ok(AppType::Claude), "codex" => Ok(AppType::Codex), - "gemini" => Ok(AppType::Gemini), // 新增 + "gemini" => Ok(AppType::Gemini), + "opencode" => Ok(AppType::OpenCode), other => Err(AppError::localized( "unsupported_app", - format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini。"), - format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini."), + format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode。"), + format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode."), )), } } @@ -291,6 +314,9 @@ pub struct CommonConfigSnippets { #[serde(default, skip_serializing_if = "Option::is_none")] pub gemini: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opencode: Option, } impl CommonConfigSnippets { @@ -300,6 +326,7 @@ impl CommonConfigSnippets { AppType::Claude => self.claude.as_ref(), AppType::Codex => self.codex.as_ref(), AppType::Gemini => self.gemini.as_ref(), + AppType::OpenCode => self.opencode.as_ref(), } } @@ -309,6 +336,7 @@ impl CommonConfigSnippets { AppType::Claude => self.claude = snippet, AppType::Codex => self.codex = snippet, AppType::Gemini => self.gemini = snippet, + AppType::OpenCode => self.opencode = snippet, } } } @@ -347,7 +375,8 @@ impl Default for MultiAppConfig { let mut apps = HashMap::new(); apps.insert("claude".to_string(), ProviderManager::default()); apps.insert("codex".to_string(), ProviderManager::default()); - apps.insert("gemini".to_string(), ProviderManager::default()); // 新增 + apps.insert("gemini".to_string(), ProviderManager::default()); + apps.insert("opencode".to_string(), ProviderManager::default()); Self { version: 2, @@ -506,6 +535,7 @@ impl MultiAppConfig { AppType::Claude => &self.mcp.claude, AppType::Codex => &self.mcp.codex, AppType::Gemini => &self.mcp.gemini, + AppType::OpenCode => &self.mcp.opencode, } } @@ -515,6 +545,7 @@ impl MultiAppConfig { AppType::Claude => &mut self.mcp.claude, AppType::Codex => &mut self.mcp.codex, AppType::Gemini => &mut self.mcp.gemini, + AppType::OpenCode => &mut self.mcp.opencode, } } @@ -528,6 +559,7 @@ impl MultiAppConfig { Self::auto_import_prompt_if_exists(&mut config, AppType::Claude)?; Self::auto_import_prompt_if_exists(&mut config, AppType::Codex)?; Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?; + Self::auto_import_prompt_if_exists(&mut config, AppType::OpenCode)?; Ok(config) } @@ -547,6 +579,7 @@ impl MultiAppConfig { if !self.prompts.claude.prompts.is_empty() || !self.prompts.codex.prompts.is_empty() || !self.prompts.gemini.prompts.is_empty() + || !self.prompts.opencode.prompts.is_empty() { return Ok(false); } @@ -554,7 +587,12 @@ impl MultiAppConfig { log::info!("检测到已存在配置文件且 Prompt 列表为空,将尝试从现有提示词文件自动导入"); let mut imported = false; - for app in [AppType::Claude, AppType::Codex, AppType::Gemini] { + for app in [ + AppType::Claude, + AppType::Codex, + AppType::Gemini, + AppType::OpenCode, + ] { // 复用已有的单应用导入逻辑 if Self::auto_import_prompt_if_exists(self, app)? { imported = true; @@ -623,6 +661,7 @@ impl MultiAppConfig { AppType::Claude => &mut config.prompts.claude.prompts, AppType::Codex => &mut config.prompts.codex.prompts, AppType::Gemini => &mut config.prompts.gemini.prompts, + AppType::OpenCode => &mut config.prompts.opencode.prompts, }; prompts.insert(id, prompt); @@ -656,6 +695,7 @@ impl MultiAppConfig { AppType::Claude => &self.mcp.claude.servers, AppType::Codex => &self.mcp.codex.servers, AppType::Gemini => &self.mcp.gemini.servers, + AppType::OpenCode => &self.mcp.opencode.servers, }; for (id, entry) in old_servers { diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index c4fa8682c..f000a4d29 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -51,6 +51,15 @@ pub async fn get_config_status(app: String) -> Result { Ok(ConfigStatus { exists, path }) } + AppType::OpenCode => { + let config_path = crate::opencode_config::get_opencode_config_path(); + let exists = config_path.exists(); + let path = crate::opencode_config::get_opencode_dir() + .to_string_lossy() + .to_string(); + + Ok(ConfigStatus { exists, path }) + } } } @@ -67,6 +76,7 @@ pub async fn get_config_dir(app: String) -> Result { AppType::Claude => config::get_claude_config_dir(), AppType::Codex => codex_config::get_codex_config_dir(), AppType::Gemini => crate::gemini_config::get_gemini_dir(), + AppType::OpenCode => crate::opencode_config::get_opencode_dir(), }; Ok(dir.to_string_lossy().to_string()) @@ -79,6 +89,7 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result config::get_claude_config_dir(), AppType::Codex => codex_config::get_codex_config_dir(), AppType::Gemini => crate::gemini_config::get_gemini_dir(), + AppType::OpenCode => crate::opencode_config::get_opencode_dir(), }; if !config_dir.exists() { diff --git a/src-tauri/src/commands/mcp.rs b/src-tauri/src/commands/mcp.rs index 299fed6bf..8963584c8 100644 --- a/src-tauri/src/commands/mcp.rs +++ b/src-tauri/src/commands/mcp.rs @@ -122,6 +122,7 @@ pub async fn upsert_mcp_server_in_config( new_server.apps.claude = true; new_server.apps.codex = true; new_server.apps.gemini = true; + new_server.apps.opencode = true; } McpService::upsert_server(&state, new_server) @@ -200,5 +201,6 @@ pub async fn import_mcp_from_apps(state: State<'_, AppState>) -> Result Result { + let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; + ProviderService::remove_from_live_config(app_type, &id) + .map(|_| true) + .map_err(|e| e.to_string()) +} + /// 切换供应商 fn switch_provider_internal(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> { ProviderService::switch(state, app_type, id) @@ -325,3 +335,27 @@ pub fn sync_universal_provider( Ok(result) } + +// ============================================================================ +// OpenCode 专属命令 +// ============================================================================ + +/// 从 OpenCode live 配置导入供应商到数据库 +/// +/// 这是 OpenCode 特有的功能,因为 OpenCode 使用累加模式, +/// 用户可能已经在 opencode.json 中配置了供应商。 +#[tauri::command] +pub fn import_opencode_providers_from_live(state: State<'_, AppState>) -> Result { + 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, String> { + crate::opencode_config::get_providers() + .map(|providers| providers.keys().cloned().collect()) + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/commands/skill.rs b/src-tauri/src/commands/skill.rs index 9e3eb6076..ca6bcc38f 100644 --- a/src-tauri/src/commands/skill.rs +++ b/src-tauri/src/commands/skill.rs @@ -20,6 +20,7 @@ fn parse_app_type(app: &str) -> Result { "claude" => Ok(AppType::Claude), "codex" => Ok(AppType::Codex), "gemini" => Ok(AppType::Gemini), + "opencode" => Ok(AppType::OpenCode), _ => Err(format!("不支持的 app 类型: {app}")), } } diff --git a/src-tauri/src/database/dao/mcp.rs b/src-tauri/src/database/dao/mcp.rs index 004d9339c..d5c60163f 100644 --- a/src-tauri/src/database/dao/mcp.rs +++ b/src-tauri/src/database/dao/mcp.rs @@ -13,7 +13,7 @@ impl Database { pub fn get_all_mcp_servers(&self) -> Result, AppError> { let conn = lock_conn!(self.conn); let mut stmt = conn.prepare( - "SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini + "SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode FROM mcp_servers ORDER BY name ASC, id ASC" ).map_err(|e| AppError::Database(e.to_string()))?; @@ -30,6 +30,7 @@ impl Database { let enabled_claude: bool = row.get(7)?; let enabled_codex: bool = row.get(8)?; let enabled_gemini: bool = row.get(9)?; + let enabled_opencode: bool = row.get(10)?; let server = serde_json::from_str(&server_config_str).unwrap_or_default(); let tags = serde_json::from_str(&tags_str).unwrap_or_default(); @@ -44,6 +45,7 @@ impl Database { claude: enabled_claude, codex: enabled_codex, gemini: enabled_gemini, + opencode: enabled_opencode, }, description, homepage, @@ -68,8 +70,8 @@ impl Database { conn.execute( "INSERT OR REPLACE INTO mcp_servers ( id, name, server_config, description, homepage, docs, tags, - enabled_claude, enabled_codex, enabled_gemini - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + enabled_claude, enabled_codex, enabled_gemini, enabled_opencode + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", params![ server.id, server.name, @@ -84,6 +86,7 @@ impl Database { server.apps.claude, server.apps.codex, server.apps.gemini, + server.apps.opencode, ], ) .map_err(|e| AppError::Database(e.to_string()))?; diff --git a/src-tauri/src/database/dao/skills.rs b/src-tauri/src/database/dao/skills.rs index 269d11753..67df0d2c1 100644 --- a/src-tauri/src/database/dao/skills.rs +++ b/src-tauri/src/database/dao/skills.rs @@ -3,7 +3,7 @@ //! 提供 Skills 和 Skill Repos 的 CRUD 操作。 //! //! v3.10.0+ 统一管理架构: -//! - Skills 使用统一的 id 主键,支持三应用启用标志 +//! - Skills 使用统一的 id 主键,支持四应用启用标志 //! - 实际文件存储在 ~/.cc-switch/skills/,同步到各应用目录 use crate::app_config::{InstalledSkill, SkillApps}; @@ -22,7 +22,7 @@ impl Database { let mut stmt = conn .prepare( "SELECT id, name, description, directory, repo_owner, repo_name, repo_branch, - readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at + readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at FROM skills ORDER BY name ASC", ) .map_err(|e| AppError::Database(e.to_string()))?; @@ -42,8 +42,9 @@ impl Database { claude: row.get(8)?, codex: row.get(9)?, gemini: row.get(10)?, + opencode: row.get(11)?, }, - installed_at: row.get(11)?, + installed_at: row.get(12)?, }) }) .map_err(|e| AppError::Database(e.to_string()))?; @@ -62,7 +63,7 @@ impl Database { let mut stmt = conn .prepare( "SELECT id, name, description, directory, repo_owner, repo_name, repo_branch, - readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at + readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at FROM skills WHERE id = ?1", ) .map_err(|e| AppError::Database(e.to_string()))?; @@ -81,8 +82,9 @@ impl Database { claude: row.get(8)?, codex: row.get(9)?, gemini: row.get(10)?, + opencode: row.get(11)?, }, - installed_at: row.get(11)?, + installed_at: row.get(12)?, }) }); @@ -99,8 +101,8 @@ impl Database { conn.execute( "INSERT OR REPLACE INTO skills (id, name, description, directory, repo_owner, repo_name, repo_branch, - readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", params![ skill.id, skill.name, @@ -113,6 +115,7 @@ impl Database { skill.apps.claude, skill.apps.codex, skill.apps.gemini, + skill.apps.opencode, skill.installed_at, ], ) @@ -142,8 +145,8 @@ impl Database { let conn = lock_conn!(self.conn); let affected = conn .execute( - "UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3 WHERE id = ?4", - params![apps.claude, apps.codex, apps.gemini, id], + "UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_opencode = ?4 WHERE id = ?5", + params![apps.claude, apps.codex, apps.gemini, apps.opencode, id], ) .map_err(|e| AppError::Database(e.to_string()))?; Ok(affected > 0) diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index bf59dcd61..1c855a5c7 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -47,7 +47,7 @@ const DB_BACKUP_RETAIN: usize = 10; /// 当前 Schema 版本号 /// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑 -pub(crate) const SCHEMA_VERSION: i32 = 3; +pub(crate) const SCHEMA_VERSION: i32 = 4; /// 安全地序列化 JSON,避免 unwrap panic pub(crate) fn to_json_string(value: &T) -> Result { diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 46d74df85..4bd334fd7 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -58,7 +58,7 @@ impl Database { id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL, description TEXT, homepage TEXT, docs TEXT, tags TEXT NOT NULL DEFAULT '[]', enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0, - enabled_gemini BOOLEAN NOT NULL DEFAULT 0 + enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0 )", [], ) @@ -85,6 +85,7 @@ impl Database { enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0, enabled_gemini BOOLEAN NOT NULL DEFAULT 0, + enabled_opencode BOOLEAN NOT NULL DEFAULT 0, installed_at INTEGER NOT NULL DEFAULT 0 )", [], @@ -346,6 +347,11 @@ impl Database { Self::migrate_v2_to_v3(conn)?; Self::set_user_version(conn, 3)?; } + 3 => { + log::info!("迁移数据库从 v3 到 v4(OpenCode 支持)"); + Self::migrate_v3_to_v4(conn)?; + Self::set_user_version(conn, 4)?; + } _ => { return Err(AppError::Database(format!( "未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}" @@ -849,6 +855,30 @@ impl Database { Ok(()) } + /// v3 -> v4 迁移:添加 OpenCode 支持 + /// + /// 为 mcp_servers 和 skills 表添加 enabled_opencode 列。 + fn migrate_v3_to_v4(conn: &Connection) -> Result<(), AppError> { + // 为 mcp_servers 表添加 enabled_opencode 列 + Self::add_column_if_missing( + conn, + "mcp_servers", + "enabled_opencode", + "BOOLEAN NOT NULL DEFAULT 0", + )?; + + // 为 skills 表添加 enabled_opencode 列 + Self::add_column_if_missing( + conn, + "skills", + "enabled_opencode", + "BOOLEAN NOT NULL DEFAULT 0", + )?; + + log::info!("v3 -> v4 迁移完成:已添加 OpenCode 支持"); + Ok(()) + } + /// 插入默认模型定价数据 /// 格式: (model_id, display_name, input, output, cache_read, cache_creation) /// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致 diff --git a/src-tauri/src/deeplink/mcp.rs b/src-tauri/src/deeplink/mcp.rs index 75eb28654..04d3f11b9 100644 --- a/src-tauri/src/deeplink/mcp.rs +++ b/src-tauri/src/deeplink/mcp.rs @@ -166,6 +166,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result { claude: false, codex: false, gemini: false, + opencode: false, }; for app in apps_str.split(',') { @@ -173,6 +174,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result { "claude" => apps.claude = true, "codex" => apps.codex = true, "gemini" => apps.gemini = true, + "opencode" => apps.opencode = true, other => { return Err(AppError::InvalidInput(format!( "Invalid app in 'apps': {other}" diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs index 88f5709e9..ec75d55e5 100644 --- a/src-tauri/src/deeplink/provider.rs +++ b/src-tauri/src/deeplink/provider.rs @@ -145,6 +145,7 @@ pub(crate) fn build_provider_from_request( AppType::Claude => build_claude_settings(request), AppType::Codex => build_codex_settings(request), AppType::Gemini => build_gemini_settings(request), + AppType::OpenCode => build_opencode_settings(request), }; // Build usage script configuration if provided @@ -363,6 +364,33 @@ fn build_gemini_settings(request: &DeepLinkImportRequest) -> serde_json::Value { json!({ "env": env }) } +/// Build OpenCode settings configuration +fn build_opencode_settings(request: &DeepLinkImportRequest) -> serde_json::Value { + let endpoint = get_primary_endpoint(request); + + // Build options object + let mut options = serde_json::Map::new(); + if !endpoint.is_empty() { + options.insert("baseURL".to_string(), json!(endpoint)); + } + if let Some(api_key) = &request.api_key { + options.insert("apiKey".to_string(), json!(api_key)); + } + + // Build models object + let mut models = serde_json::Map::new(); + if let Some(model) = &request.model { + models.insert(model.clone(), json!({ "name": model })); + } + + // Default to openai-compatible npm package + json!({ + "npm": "@ai-sdk/openai-compatible", + "options": options, + "models": models + }) +} + // ============================================================================= // Config Merge Logic // ============================================================================= diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7d719f58c..48abd4f50 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13,6 +13,7 @@ mod gemini_config; mod gemini_mcp; mod init_status; mod mcp; +mod opencode_config; mod panic_hook; mod prompt; mod prompt_files; @@ -482,6 +483,17 @@ pub fn run() { } } + // 2.1 OpenCode 供应商导入(累加式模式,需特殊处理) + // OpenCode 与其他应用不同:配置文件中可同时存在多个供应商 + // 需要遍历 provider 字段下的每个供应商并导入 + match crate::services::provider::import_opencode_providers_from_live(&app_state) { + Ok(count) if count > 0 => { + log::info!("✓ Imported {count} OpenCode provider(s) from live config"); + } + Ok(_) => log::debug!("○ No OpenCode providers found to import"), + Err(e) => log::debug!("○ Failed to import OpenCode providers: {e}"), + } + // 3. 导入 MCP 服务器配置(表空时触发) if app_state.db.is_mcp_table_empty().unwrap_or(false) { log::info!("MCP table empty, importing from live configurations..."); @@ -509,6 +521,14 @@ pub fn run() { Ok(_) => log::debug!("○ No Gemini MCP servers found to import"), Err(e) => log::warn!("✗ Failed to import Gemini MCP: {e}"), } + + match crate::services::mcp::McpService::import_from_opencode(&app_state) { + Ok(count) if count > 0 => { + log::info!("✓ Imported {count} MCP server(s) from OpenCode"); + } + Ok(_) => log::debug!("○ No OpenCode MCP servers found to import"), + Err(e) => log::warn!("✗ Failed to import OpenCode MCP: {e}"), + } } // 4. 导入提示词文件(表空时触发) @@ -712,6 +732,7 @@ pub fn run() { commands::add_provider, commands::update_provider, commands::delete_provider, + commands::remove_provider_from_live_config, commands::switch_provider, commands::import_default_config, commands::get_claude_config_status, @@ -874,6 +895,9 @@ pub fn run() { commands::upsert_universal_provider, commands::delete_universal_provider, commands::sync_universal_provider, + // OpenCode specific + commands::import_opencode_providers_from_live, + commands::get_opencode_live_provider_ids, // Global upstream proxy commands::get_global_proxy_url, commands::set_global_proxy_url, diff --git a/src-tauri/src/mcp/claude.rs b/src-tauri/src/mcp/claude.rs index c2108b9b6..25d2f426a 100644 --- a/src-tauri/src/mcp/claude.rs +++ b/src-tauri/src/mcp/claude.rs @@ -91,6 +91,7 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result Result claude: false, codex: true, gemini: false, + opencode: false, }, description: None, homepage: None, diff --git a/src-tauri/src/mcp/gemini.rs b/src-tauri/src/mcp/gemini.rs index 9a1a3064f..c8a7809f3 100644 --- a/src-tauri/src/mcp/gemini.rs +++ b/src-tauri/src/mcp/gemini.rs @@ -87,6 +87,7 @@ pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result 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 { + 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 { + 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 = 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 { + 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"); + } +} diff --git a/src-tauri/src/opencode_config.rs b/src-tauri/src/opencode_config.rs new file mode 100644 index 000000000..36d4f2e95 --- /dev/null +++ b/src-tauri/src/opencode_config.rs @@ -0,0 +1,222 @@ +//! 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 { + 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, 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, AppError> { + let providers = get_providers()?; + let mut result = IndexMap::new(); + + for (id, value) in providers { + match serde_json::from_value::(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, 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) +} + diff --git a/src-tauri/src/prompt_files.rs b/src-tauri/src/prompt_files.rs index 5fe320fa7..1395599a3 100644 --- a/src-tauri/src/prompt_files.rs +++ b/src-tauri/src/prompt_files.rs @@ -5,6 +5,7 @@ use crate::codex_config::get_codex_auth_path; use crate::config::get_claude_settings_path; use crate::error::AppError; use crate::gemini_config::get_gemini_dir; +use crate::opencode_config::get_opencode_dir; /// 返回指定应用所使用的提示词文件路径。 pub fn prompt_file_path(app: &AppType) -> Result { @@ -12,12 +13,14 @@ pub fn prompt_file_path(app: &AppType) -> Result { AppType::Claude => get_base_dir_with_fallback(get_claude_settings_path(), ".claude")?, AppType::Codex => get_base_dir_with_fallback(get_codex_auth_path(), ".codex")?, AppType::Gemini => get_gemini_dir(), + AppType::OpenCode => get_opencode_dir(), }; let filename = match app { AppType::Claude => "CLAUDE.md", AppType::Codex => "AGENTS.md", AppType::Gemini => "GEMINI.md", + AppType::OpenCode => "AGENTS.md", }; Ok(base_dir.join(filename)) diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 46a6c6f92..5e9ab7328 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -462,3 +462,95 @@ 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, + + /// 供应商选项(API 密钥、基础 URL 等) + #[serde(default)] + pub options: OpenCodeProviderOptions, + + /// 模型定义映射 + #[serde(default)] + pub models: HashMap, +} + +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, + + /// API 密钥(支持环境变量引用,如 "{env:API_KEY}") + #[serde(rename = "apiKey", skip_serializing_if = "Option::is_none")] + pub api_key: Option, + + /// 自定义请求头 + #[serde(skip_serializing_if = "Option::is_none")] + pub headers: Option>, + + /// 额外选项(timeout, setCacheKey 等) + /// 使用 flatten 捕获所有未明确定义的字段 + #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")] + pub extra: HashMap, +} + +/// OpenCode 模型定义 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpenCodeModel { + /// 模型显示名称 + pub name: String, + + /// 模型限制(上下文和输出 token 数) + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + + /// 模型额外选项(provider 路由等) + #[serde(skip_serializing_if = "Option::is_none")] + pub options: Option>, +} + +/// OpenCode 模型限制 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct OpenCodeModelLimit { + /// 上下文 token 限制 + #[serde(skip_serializing_if = "Option::is_none")] + pub context: Option, + + /// 输出 token 限制 + #[serde(skip_serializing_if = "Option::is_none")] + pub output: Option, +} diff --git a/src-tauri/src/proxy/providers/mod.rs b/src-tauri/src/proxy/providers/mod.rs index 64ae8ff27..61be1087e 100644 --- a/src-tauri/src/proxy/providers/mod.rs +++ b/src-tauri/src/proxy/providers/mod.rs @@ -132,6 +132,10 @@ impl ProviderType { } ProviderType::Gemini } + AppType::OpenCode => { + // OpenCode doesn't support proxy, but return a default type for completeness + ProviderType::Codex // Fallback to Codex-like type + } } } @@ -176,6 +180,10 @@ pub fn get_adapter(app_type: &AppType) -> Box { AppType::Claude => Box::new(ClaudeAdapter::new()), AppType::Codex => Box::new(CodexAdapter::new()), AppType::Gemini => Box::new(GeminiAdapter::new()), + AppType::OpenCode => { + // OpenCode doesn't support proxy, fallback to Codex adapter + Box::new(CodexAdapter::new()) + } } } diff --git a/src-tauri/src/services/config.rs b/src-tauri/src/services/config.rs index 8a46a2b2a..229eebdfb 100644 --- a/src-tauri/src/services/config.rs +++ b/src-tauri/src/services/config.rs @@ -122,6 +122,10 @@ impl ConfigService { AppType::Codex => Self::sync_codex_live(config, ¤t_id, &provider)?, AppType::Claude => Self::sync_claude_live(config, ¤t_id, &provider)?, AppType::Gemini => Self::sync_gemini_live(config, ¤t_id, &provider)?, + AppType::OpenCode => { + // OpenCode uses additive mode, no live sync needed + // OpenCode providers are managed directly in the config file + } } Ok(()) diff --git a/src-tauri/src/services/mcp.rs b/src-tauri/src/services/mcp.rs index ea472af31..8d1211bbc 100644 --- a/src-tauri/src/services/mcp.rs +++ b/src-tauri/src/services/mcp.rs @@ -37,6 +37,9 @@ impl McpService { if prev_apps.gemini && !server.apps.gemini { Self::remove_server_from_app(state, &server.id, &AppType::Gemini)?; } + if prev_apps.opencode && !server.apps.opencode { + Self::remove_server_from_app(state, &server.id, &AppType::OpenCode)?; + } // 同步到各个启用的应用 Self::sync_server_to_apps(state, &server)?; @@ -113,6 +116,13 @@ impl McpService { AppType::Gemini => { mcp::sync_single_server_to_gemini(&Default::default(), &server.id, &server.server)?; } + AppType::OpenCode => { + mcp::sync_single_server_to_opencode( + &Default::default(), + &server.id, + &server.server, + )?; + } } Ok(()) } @@ -135,6 +145,9 @@ impl McpService { AppType::Claude => mcp::remove_server_from_claude(id)?, AppType::Codex => mcp::remove_server_from_codex(id)?, AppType::Gemini => mcp::remove_server_from_gemini(id)?, + AppType::OpenCode => { + mcp::remove_server_from_opencode(id)?; + } } Ok(()) } @@ -311,4 +324,42 @@ impl McpService { Ok(new_count) } + + /// 从 OpenCode 导入 MCP(v3.9.2+ 新增) + pub fn import_from_opencode(state: &AppState) -> Result { + // 创建临时 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) + } } diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 9854f2243..9585d3fb3 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -120,6 +120,64 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re // Delegate to write_gemini_live which handles env file writing correctly 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::(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(()) } @@ -220,6 +278,21 @@ pub fn read_live_settings(app_type: AppType) -> Result { "config": config_obj })) } + AppType::OpenCode => { + use crate::opencode_config::{get_opencode_config_path, read_opencode_config}; + + let config_path = get_opencode_config_path(); + if !config_path.exists() { + return Err(AppError::localized( + "opencode.config.missing", + "OpenCode 配置文件不存在", + "OpenCode configuration file not found", + )); + } + + let config = read_opencode_config()?; + Ok(config) + } } } @@ -295,6 +368,24 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result { + // 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( @@ -399,3 +490,84 @@ pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> { Ok(()) } + +/// Remove an OpenCode provider from the live configuration +/// +/// This is specific to OpenCode's additive mode - removing a provider +/// from the opencode.json file. +pub(crate) fn remove_opencode_provider_from_live(provider_id: &str) -> Result<(), AppError> { + use crate::opencode_config; + + // Check if OpenCode config directory exists + if !opencode_config::get_opencode_dir().exists() { + log::debug!( + "OpenCode config directory doesn't exist, skipping removal of '{}'", + provider_id + ); + return Ok(()); + } + + opencode_config::remove_provider(provider_id)?; + log::info!( + "OpenCode provider '{}' 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 { + 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) +} diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 7b45b4e6b..8080d84b5 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -20,13 +20,16 @@ use crate::settings::CustomEndpoint; use crate::store::AppState; // Re-export sub-module functions for external access -pub use live::{import_default_config, read_live_settings, sync_current_to_live}; +pub use live::{ + import_default_config, import_opencode_providers_from_live, read_live_settings, + sync_current_to_live, +}; // Internal re-exports (pub(crate)) pub(crate) use live::write_live_snapshot; // Internal re-exports -use live::write_gemini_live; +use live::{remove_opencode_provider_from_live, write_gemini_live}; use usage::validate_usage_script; /// Provider business logic service @@ -137,7 +140,13 @@ impl ProviderService { /// 使用有效的当前供应商 ID(验证过存在性)。 /// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。 /// 这确保了云同步场景下多设备可以独立选择供应商,且返回的 ID 一定有效。 + /// + /// 对于 OpenCode(累加模式),不存在"当前供应商"概念,直接返回空字符串。 pub fn current(state: &AppState, app_type: AppType) -> Result { + // OpenCode uses additive mode - no "current" provider concept + if matches!(app_type, AppType::OpenCode) { + return Ok(String::new()); + } crate::settings::get_effective_current_provider(&state.db, &app_type) .map(|opt| opt.unwrap_or_default()) } @@ -152,7 +161,13 @@ impl ProviderService { // Save to database state.db.save_provider(app_type.as_str(), &provider)?; - // Check if sync is needed (if this is current provider, or no current provider) + // OpenCode uses additive mode - always write to live config + if matches!(app_type, AppType::OpenCode) { + write_live_snapshot(&app_type, &provider)?; + return Ok(true); + } + + // For other apps: Check if sync is needed (if this is current provider, or no current provider) let current = state.db.get_current_provider(app_type.as_str())?; if current.is_none() { // No current provider, set as current and sync @@ -176,14 +191,20 @@ impl ProviderService { Self::normalize_provider_if_claude(&app_type, &mut provider); Self::validate_provider_settings(&app_type, &provider)?; - // Check if this is current provider (use effective current, not just DB) + // Save to database + state.db.save_provider(app_type.as_str(), &provider)?; + + // OpenCode uses additive mode - always update in live config + if matches!(app_type, AppType::OpenCode) { + write_live_snapshot(&app_type, &provider)?; + return Ok(true); + } + + // For other apps: Check if this is current provider (use effective current, not just DB) let effective_current = crate::settings::get_effective_current_provider(&state.db, &app_type)?; let is_current = effective_current.as_deref() == Some(provider.id.as_str()); - // Save to database - state.db.save_provider(app_type.as_str(), &provider)?; - if is_current { // 如果代理接管模式处于激活状态,并且代理服务正在运行: // - 不写 Live 配置(否则会破坏接管) @@ -216,8 +237,18 @@ impl ProviderService { /// Delete a provider /// /// 同时检查本地 settings 和数据库的当前供应商,防止删除任一端正在使用的供应商。 + /// 对于 OpenCode(累加模式),可以随时删除任意供应商,同时从 live 配置中移除。 pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> { - // Check both local settings and database + // OpenCode uses additive mode - no current provider concept + if matches!(app_type, AppType::OpenCode) { + // Remove from database + state.db.delete_provider(app_type.as_str(), id)?; + // Also remove from live config + remove_opencode_provider_from_live(id)?; + return Ok(()); + } + + // For other apps: Check both local settings and database let local_current = crate::settings::get_current_provider(&app_type); let db_current = state.db.get_current_provider(app_type.as_str())?; @@ -230,6 +261,27 @@ impl ProviderService { state.db.delete_provider(app_type.as_str(), id) } + /// Remove provider from live config only (for additive mode apps like OpenCode) + /// + /// Does NOT delete from database - provider remains in the list. + /// This is used when user wants to "remove" a provider from active config + /// but keep it available for future use. + pub fn remove_from_live_config(app_type: AppType, id: &str) -> Result<(), AppError> { + match app_type { + AppType::OpenCode => { + remove_opencode_provider_from_live(id)?; + } + // Future: add other additive mode apps here + _ => { + return Err(AppError::Message(format!( + "App {} does not support remove from live config", + app_type.as_str() + ))); + } + } + Ok(()) + } + /// Switch to a provider /// /// Switch flow: @@ -326,22 +378,29 @@ impl ProviderService { if let Some(current_id) = current_id { if current_id != id { - // Only backfill when switching to a different provider - if let Ok(live_config) = read_live_settings(app_type.clone()) { - if let Some(mut current_provider) = providers.get(¤t_id).cloned() { - current_provider.settings_config = live_config; - // Ignore backfill failure, don't affect switch flow - let _ = state.db.save_provider(app_type.as_str(), ¤t_provider); + // OpenCode uses additive mode - all providers coexist in the same file, + // no backfill needed (backfill is for exclusive mode apps like Claude/Codex/Gemini) + if !matches!(app_type, AppType::OpenCode) { + // Only backfill when switching to a different provider + if let Ok(live_config) = read_live_settings(app_type.clone()) { + if let Some(mut current_provider) = providers.get(¤t_id).cloned() { + current_provider.settings_config = live_config; + // Ignore backfill failure, don't affect switch flow + let _ = state.db.save_provider(app_type.as_str(), ¤t_provider); + } } } } } - // Update local settings (device-level, takes priority) - crate::settings::set_current_provider(&app_type, Some(id))?; + // OpenCode uses additive mode - skip setting is_current (no such concept) + if !matches!(app_type, AppType::OpenCode) { + // Update local settings (device-level, takes priority) + crate::settings::set_current_provider(&app_type, Some(id))?; - // Update database is_current (as default for new devices) - state.db.set_current_provider(app_type.as_str(), id)?; + // Update database is_current (as default for new devices) + state.db.set_current_provider(app_type.as_str(), id)?; + } // Sync to live (write_gemini_live handles security flag internally for Gemini) write_live_snapshot(&app_type, provider)?; @@ -380,6 +439,7 @@ impl ProviderService { AppType::Claude => Self::extract_claude_common_config(&provider.settings_config), AppType::Codex => Self::extract_codex_common_config(&provider.settings_config), AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config), + AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config), } } @@ -392,6 +452,7 @@ impl ProviderService { AppType::Claude => Self::extract_claude_common_config(settings_config), AppType::Codex => Self::extract_codex_common_config(settings_config), AppType::Gemini => Self::extract_gemini_common_config(settings_config), + AppType::OpenCode => Self::extract_opencode_common_config(settings_config), } } @@ -525,6 +586,29 @@ impl ProviderService { .map_err(|e| AppError::Message(format!("Serialization failed: {e}"))) } + /// Extract common config for OpenCode (JSON format) + fn extract_opencode_common_config(settings: &Value) -> Result { + // OpenCode uses a different config structure with npm, options, models + // For common config, we exclude provider-specific fields like apiKey + let mut config = settings.clone(); + + // Remove provider-specific fields + if let Some(obj) = config.as_object_mut() { + if let Some(options) = obj.get_mut("options").and_then(|v| v.as_object_mut()) { + options.remove("apiKey"); + options.remove("baseURL"); + } + // Keep npm and models as they might be common + } + + if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) { + return Ok("{}".to_string()); + } + + serde_json::to_string_pretty(&config) + .map_err(|e| AppError::Message(format!("Serialization failed: {e}"))) + } + /// Import default configuration from live files (re-export) /// /// Returns `Ok(true)` if imported, `Ok(false)` if skipped. @@ -691,6 +775,17 @@ impl ProviderService { use crate::gemini_config::validate_gemini_settings; validate_gemini_settings(&provider.settings_config)? } + AppType::OpenCode => { + // OpenCode uses a different config structure: { npm, options, models } + // Basic validation - must be an object + if !provider.settings_config.is_object() { + return Err(AppError::localized( + "provider.opencode.settings.not_object", + "OpenCode 配置必须是 JSON 对象", + "OpenCode configuration must be a JSON object", + )); + } + } } // Validate and clean UsageScript configuration (common for all app types) @@ -828,6 +923,40 @@ impl ProviderService { Ok((api_key, base_url)) } + AppType::OpenCode => { + // OpenCode uses options.apiKey and options.baseURL + let options = provider + .settings_config + .get("options") + .and_then(|v| v.as_object()) + .ok_or_else(|| { + AppError::localized( + "provider.opencode.options.missing", + "配置格式错误: 缺少 options", + "Invalid configuration: missing options section", + ) + })?; + + let api_key = options + .get("apiKey") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + AppError::localized( + "provider.opencode.api_key.missing", + "缺少 API Key", + "API key is missing", + ) + })? + .to_string(); + + let base_url = options + .get("baseURL") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + Ok((api_key, base_url)) + } } } } diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index eaa5665a5..823bbfce8 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -368,6 +368,10 @@ impl ProxyService { AppType::Claude => self.read_claude_live()?, AppType::Codex => self.read_codex_live()?, AppType::Gemini => self.read_gemini_live()?, + AppType::OpenCode => { + // OpenCode doesn't support proxy features + return Err("OpenCode 不支持代理功能".to_string()); + } }; self.sync_live_config_to_provider(app_type, &live_config) @@ -581,6 +585,9 @@ impl ProxyService { } } } + AppType::OpenCode => { + // OpenCode doesn't support proxy features, skip silently + } } Ok(()) @@ -759,6 +766,10 @@ impl ProxyService { AppType::Claude => ("claude", self.read_claude_live()?), AppType::Codex => ("codex", self.read_codex_live()?), AppType::Gemini => ("gemini", self.read_gemini_live()?), + AppType::OpenCode => { + // OpenCode doesn't support proxy features + return Err("OpenCode 不支持代理功能".to_string()); + } }; let json_str = serde_json::to_string(&config) @@ -967,6 +978,10 @@ impl ProxyService { self.write_gemini_live(&live_config)?; log::info!("Gemini Live 配置已接管,代理地址: {proxy_url}"); } + AppType::OpenCode => { + // OpenCode doesn't support proxy features + return Err("OpenCode 不支持代理功能".to_string()); + } } Ok(()) @@ -1050,6 +1065,9 @@ impl ProxyService { let _ = self.write_gemini_live(&live_config); } } + AppType::OpenCode => { + // OpenCode doesn't support proxy features, skip silently + } } Ok(()) @@ -1082,6 +1100,9 @@ impl ProxyService { log::info!("Gemini Live 配置已恢复"); } } + AppType::OpenCode => { + // OpenCode doesn't support proxy features, skip silently + } } Ok(()) @@ -1161,6 +1182,10 @@ impl ProxyService { AppType::Claude => self.write_claude_live(config), AppType::Codex => self.write_codex_live(config), AppType::Gemini => self.write_gemini_live(config), + AppType::OpenCode => { + // OpenCode doesn't support proxy features + Err("OpenCode 不支持代理功能".to_string()) + } } } @@ -1178,6 +1203,10 @@ impl ProxyService { Ok(config) => Self::is_gemini_live_taken_over(&config), Err(_) => false, }, + AppType::OpenCode => { + // OpenCode doesn't support proxy takeover + false + } } } @@ -1217,6 +1246,10 @@ impl ProxyService { AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(), AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(), AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(), + AppType::OpenCode => { + // OpenCode doesn't support proxy features + Ok(()) + } } } diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index e9aa8962e..bb215183a 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -183,6 +183,11 @@ impl SkillService { return Ok(custom.join("skills")); } } + AppType::OpenCode => { + if let Some(custom) = crate::settings::get_opencode_override_dir() { + return Ok(custom.join("skills")); + } + } } // 默认路径:回退到用户主目录下的标准位置 @@ -196,6 +201,7 @@ impl SkillService { AppType::Claude => home.join(".claude").join("skills"), AppType::Codex => home.join(".codex").join("skills"), AppType::Gemini => home.join(".gemini").join("skills"), + AppType::OpenCode => home.join(".config").join("opencode").join("skills"), }) } @@ -317,7 +323,7 @@ impl SkillService { .ok_or_else(|| anyhow!("Skill not found: {id}"))?; // 从所有应用目录删除 - for app in [AppType::Claude, AppType::Codex, AppType::Gemini] { + for app in [AppType::Claude, AppType::Codex, AppType::Gemini, AppType::OpenCode] { let _ = Self::remove_from_app(&skill.directory, &app); } @@ -376,7 +382,7 @@ impl SkillService { let mut unmanaged: HashMap = HashMap::new(); - for app in [AppType::Claude, AppType::Codex, AppType::Gemini] { + for app in [AppType::Claude, AppType::Codex, AppType::Gemini, AppType::OpenCode] { let app_dir = match Self::get_app_skills_dir(&app) { Ok(d) => d, Err(_) => continue, @@ -425,6 +431,7 @@ impl SkillService { AppType::Claude => "claude", AppType::Codex => "codex", AppType::Gemini => "gemini", + AppType::OpenCode => "opencode", }; unmanaged @@ -457,7 +464,7 @@ impl SkillService { let mut source_path: Option = None; let mut found_in: Vec = Vec::new(); - for app in [AppType::Claude, AppType::Codex, AppType::Gemini] { + for app in [AppType::Claude, AppType::Codex, AppType::Gemini, AppType::OpenCode] { if let Ok(app_dir) = Self::get_app_skills_dir(&app) { let skill_path = app_dir.join(&dir_name); if skill_path.exists() { @@ -468,6 +475,7 @@ impl SkillService { AppType::Claude => "claude", AppType::Codex => "codex", AppType::Gemini => "gemini", + AppType::OpenCode => "opencode", }; found_in.push(app_str.to_string()); } @@ -506,6 +514,7 @@ impl SkillService { "claude" => apps.claude = true, "codex" => apps.codex = true, "gemini" => apps.gemini = true, + "opencode" => apps.opencode = true, _ => {} } } @@ -976,7 +985,7 @@ pub fn migrate_skills_to_ssot(db: &Arc) -> Result { let mut discovered: HashMap = HashMap::new(); // 扫描各应用目录 - for app in [AppType::Claude, AppType::Codex, AppType::Gemini] { + for app in [AppType::Claude, AppType::Codex, AppType::Gemini, AppType::OpenCode] { let app_dir = match SkillService::get_app_skills_dir(&app) { Ok(d) => d, Err(_) => continue, diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index 900b45698..3a77fad07 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -185,6 +185,14 @@ impl StreamCheckService { ) .await } + AppType::OpenCode => { + // OpenCode doesn't support stream check yet + return Err(AppError::localized( + "opencode_no_stream_check", + "OpenCode 暂不支持健康检查", + "OpenCode does not support health check yet", + )); + } }; let response_time = start.elapsed().as_millis() as u64; @@ -477,9 +485,24 @@ impl StreamCheckService { } AppType::Gemini => Self::extract_env_model(provider, "GEMINI_MODEL") .unwrap_or_else(|| config.gemini_model.clone()), + AppType::OpenCode => { + // OpenCode uses models map in settings_config + // Try to extract first model from the models object + Self::extract_opencode_model(provider).unwrap_or_else(|| "gpt-4o".to_string()) + } } } + fn extract_opencode_model(provider: &Provider) -> Option { + 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 { provider .settings_config diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 21e15d528..88f087283 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -47,6 +47,8 @@ pub struct AppSettings { pub codex_config_dir: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub gemini_config_dir: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opencode_config_dir: Option, // ===== 当前供应商 ID(设备级)===== /// 当前 Claude 供应商 ID(本地存储,优先于数据库 is_current) @@ -58,6 +60,9 @@ pub struct AppSettings { /// 当前 Gemini 供应商 ID(本地存储,优先于数据库 is_current) #[serde(default, skip_serializing_if = "Option::is_none")] pub current_provider_gemini: Option, + /// 当前 OpenCode 供应商 ID(本地存储,对 OpenCode 可能无意义,但保持结构一致) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_provider_opencode: Option, } fn default_show_in_tray() -> bool { @@ -84,9 +89,11 @@ impl Default for AppSettings { claude_config_dir: None, codex_config_dir: None, gemini_config_dir: None, + opencode_config_dir: None, current_provider_claude: None, current_provider_codex: None, current_provider_gemini: None, + current_provider_opencode: None, } } } @@ -119,6 +126,13 @@ impl AppSettings { .filter(|s| !s.is_empty()) .map(|s| s.to_string()); + self.opencode_config_dir = self + .opencode_config_dir + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + self.language = self .language .as_ref() @@ -251,6 +265,14 @@ pub fn get_gemini_override_dir() -> Option { .map(|p| resolve_override_path(p)) } +pub fn get_opencode_override_dir() -> Option { + let settings = settings_store().read().ok()?; + settings + .opencode_config_dir + .as_ref() + .map(|p| resolve_override_path(p)) +} + // ===== 当前供应商管理函数 ===== /// 获取指定应用类型的当前供应商 ID(从本地 settings 读取) @@ -263,6 +285,7 @@ pub fn get_current_provider(app_type: &AppType) -> Option { AppType::Claude => settings.current_provider_claude.clone(), AppType::Codex => settings.current_provider_codex.clone(), AppType::Gemini => settings.current_provider_gemini.clone(), + AppType::OpenCode => settings.current_provider_opencode.clone(), } } @@ -277,6 +300,7 @@ pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(), AppType::Claude => settings.current_provider_claude = id.map(|s| s.to_string()), AppType::Codex => settings.current_provider_codex = id.map(|s| s.to_string()), AppType::Gemini => settings.current_provider_gemini = id.map(|s| s.to_string()), + AppType::OpenCode => settings.current_provider_opencode = id.map(|s| s.to_string()), } update_settings(settings) diff --git a/src-tauri/tests/import_export_sync.rs b/src-tauri/tests/import_export_sync.rs index 3b268dbbd..50fd70f3e 100644 --- a/src-tauri/tests/import_export_sync.rs +++ b/src-tauri/tests/import_export_sync.rs @@ -553,6 +553,7 @@ command = "echo" claude: false, codex: false, // 初始未启用 gemini: false, + opencode: false, }, description: None, homepage: None, @@ -680,6 +681,7 @@ fn import_from_claude_merges_into_config() { claude: false, // 初始未启用 codex: false, gemini: false, + opencode: false, }, description: None, homepage: None, diff --git a/src-tauri/tests/mcp_commands.rs b/src-tauri/tests/mcp_commands.rs index be7aa44e1..560ecdced 100644 --- a/src-tauri/tests/mcp_commands.rs +++ b/src-tauri/tests/mcp_commands.rs @@ -214,6 +214,7 @@ fn set_mcp_enabled_for_codex_writes_live_config() { claude: false, codex: false, // 初始未启用 gemini: false, + opencode: false, }, description: None, homepage: None, @@ -277,6 +278,7 @@ fn enabling_codex_mcp_skips_when_codex_dir_missing() { claude: false, codex: false, gemini: false, + opencode: false, }, description: None, homepage: None, @@ -320,6 +322,7 @@ fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() { claude: true, codex: false, gemini: false, + opencode: false, }, description: None, homepage: None, @@ -352,6 +355,7 @@ fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() { claude: false, codex: false, gemini: false, + opencode: false, }, description: None, homepage: None, @@ -483,6 +487,7 @@ fn enabling_gemini_mcp_skips_when_gemini_dir_missing() { claude: false, codex: false, gemini: false, + opencode: false, }, description: None, homepage: None, @@ -536,6 +541,7 @@ fn enabling_claude_mcp_skips_when_claude_config_absent() { claude: false, codex: false, gemini: false, + opencode: false, }, description: None, homepage: None, diff --git a/src-tauri/tests/provider_commands.rs b/src-tauri/tests/provider_commands.rs index c0828f03f..328848926 100644 --- a/src-tauri/tests/provider_commands.rs +++ b/src-tauri/tests/provider_commands.rs @@ -74,6 +74,7 @@ command = "say" claude: false, codex: true, // 启用 Codex gemini: false, + opencode: false, }, description: None, homepage: None, diff --git a/src-tauri/tests/provider_service.rs b/src-tauri/tests/provider_service.rs index 54f0700a9..6288bddd0 100644 --- a/src-tauri/tests/provider_service.rs +++ b/src-tauri/tests/provider_service.rs @@ -88,6 +88,7 @@ command = "say" claude: false, codex: true, gemini: false, + opencode: false, }, description: None, homepage: None, diff --git a/src/App.tsx b/src/App.tsx index 452afed56..17eecb645 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -77,7 +77,11 @@ function App() { const [editingProvider, setEditingProvider] = useState(null); const [usageProvider, setUsageProvider] = useState(null); - const [confirmDelete, setConfirmDelete] = useState(null); + // Confirm action state: 'remove' = remove from live config, 'delete' = delete from database + const [confirmAction, setConfirmAction] = useState<{ + provider: Provider; + action: "remove" | "delete"; + } | null>(null); const [envConflicts, setEnvConflicts] = useState([]); const [showEnvBanner, setShowEnvBanner] = useState(false); @@ -322,11 +326,46 @@ function App() { setEditingProvider(null); }; - // 确认删除供应商 - const handleConfirmDelete = async () => { - if (!confirmDelete) return; - await deleteProvider(confirmDelete.id); - setConfirmDelete(null); + // 确认删除/移除供应商 + const handleConfirmAction = async () => { + if (!confirmAction) return; + const { provider, action } = confirmAction; + + if (action === "remove") { + // Remove from live config only (for additive mode apps like OpenCode) + // Does NOT delete from database - provider remains in the list + await providersApi.removeFromLiveConfig(provider.id, activeApp); + // Invalidate queries to refresh the isInConfig state + await queryClient.invalidateQueries({ + queryKey: ["opencodeLiveProviderIds"], + }); + toast.success( + t("notifications.removeFromConfigSuccess", { + defaultValue: "已从配置移除", + }), + { closeButton: true }, + ); + } else { + // Delete from database + await deleteProvider(provider.id); + } + setConfirmAction(null); + }; + + // Generate a unique provider key for OpenCode duplication + const generateUniqueOpencodeKey = (originalKey: string, existingKeys: string[]): string => { + const baseKey = `${originalKey}-copy`; + + if (!existingKeys.includes(baseKey)) { + return baseKey; + } + + // If -copy already exists, try -copy-2, -copy-3, ... + let counter = 2; + while (existingKeys.includes(`${baseKey}-${counter}`)) { + counter++; + } + return `${baseKey}-${counter}`; }; // 复制供应商 @@ -335,7 +374,7 @@ function App() { const newSortIndex = provider.sortIndex !== undefined ? provider.sortIndex + 1 : undefined; - const duplicatedProvider: Omit = { + const duplicatedProvider: Omit & { providerKey?: string } = { name: `${provider.name} copy`, settingsConfig: JSON.parse(JSON.stringify(provider.settingsConfig)), // 深拷贝 websiteUrl: provider.websiteUrl, @@ -348,6 +387,12 @@ function App() { iconColor: provider.iconColor, }; + // OpenCode: generate unique provider key (used as ID) + if (activeApp === "opencode") { + const existingKeys = Object.keys(providers); + duplicatedProvider.providerKey = generateUniqueOpencodeKey(provider.id, existingKeys); + } + // 2️⃣ 如果原供应商有 sortIndex,需要将后续所有供应商的 sortIndex +1 if (provider.sortIndex !== undefined) { const updates = Object.values(providers) @@ -454,7 +499,7 @@ function App() { /> ); case "skillsDiscovery": - return ; + return ; case "mcp": return ( +
); default: return ( -
+
{/* 独立滚动容器 - 解决 Linux/Ubuntu 下 DndContext 与滚轮事件冲突 */}
@@ -498,7 +543,15 @@ function App() { activeProviderId={activeProviderId} onSwitch={switchProvider} onEdit={setEditingProvider} - onDelete={setConfirmDelete} + onDelete={(provider) => + setConfirmAction({ provider, action: "delete" }) + } + onRemoveFromConfig={ + activeApp === "opencode" + ? (provider) => + setConfirmAction({ provider, action: "remove" }) + : undefined + } onDuplicate={handleDuplicateProvider} onConfigureUsage={setUsageProvider} onOpenWebsite={handleOpenWebsite} @@ -580,7 +633,7 @@ function App() { } >
@@ -740,7 +793,9 @@ function App() { )} {currentView === "providers" && ( <> - + {activeApp !== "opencode" && ( + + )} @@ -845,17 +900,25 @@ function App() { )} void handleConfirmDelete()} - onCancel={() => setConfirmDelete(null)} + onConfirm={() => void handleConfirmAction()} + onCancel={() => setConfirmAction(null)} /> diff --git a/src/components/AppSwitcher.tsx b/src/components/AppSwitcher.tsx index 7f2f86d19..a724405dd 100644 --- a/src/components/AppSwitcher.tsx +++ b/src/components/AppSwitcher.tsx @@ -16,11 +16,13 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) { claude: "claude", codex: "openai", gemini: "gemini", + opencode: "opencode", }; const appDisplayName: Record = { claude: "Claude", codex: "Codex", gemini: "Gemini", + opencode: "OpenCode", }; return ( @@ -90,6 +92,28 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) { /> {appDisplayName.gemini} + +
); } diff --git a/src/components/UsageFooter.tsx b/src/components/UsageFooter.tsx index ca5b3d112..fc4b0ac2e 100644 --- a/src/components/UsageFooter.tsx +++ b/src/components/UsageFooter.tsx @@ -11,6 +11,7 @@ interface UsageFooterProps { appId: AppId; usageEnabled: boolean; // 是否启用了用量查询 isCurrent: boolean; // 是否为当前激活的供应商 + isInConfig?: boolean; // OpenCode: 是否已添加到配置 inline?: boolean; // 是否内联显示(在按钮左侧) } @@ -20,12 +21,15 @@ const UsageFooter: React.FC = ({ appId, usageEnabled, isCurrent, + isInConfig = false, inline = false, }) => { const { t } = useTranslation(); // 统一的用量查询(自动查询仅对当前激活的供应商启用) - const autoQueryInterval = isCurrent + // OpenCode(累加模式):使用 isInConfig 代替 isCurrent + const shouldAutoQuery = appId === "opencode" ? isInConfig : isCurrent; + const autoQueryInterval = shouldAutoQuery ? provider.meta?.usage_script?.autoQueryInterval || 0 : 0; diff --git a/src/components/common/FullScreenPanel.tsx b/src/components/common/FullScreenPanel.tsx index 93f0b4aa3..ecd6e37d3 100644 --- a/src/components/common/FullScreenPanel.tsx +++ b/src/components/common/FullScreenPanel.tsx @@ -72,7 +72,7 @@ export const FullScreenPanel: React.FC = ({ } >
@@ -94,7 +94,7 @@ export const FullScreenPanel: React.FC = ({ {/* Content */}
-
+
{children}
@@ -105,7 +105,7 @@ export const FullScreenPanel: React.FC = ({ className="flex-shrink-0 py-4 border-t border-border-default" style={{ backgroundColor: "hsl(var(--background))" }} > -
+
{footer}
diff --git a/src/components/mcp/McpFormModal.tsx b/src/components/mcp/McpFormModal.tsx index 5c608a4ff..5904e49d7 100644 --- a/src/components/mcp/McpFormModal.tsx +++ b/src/components/mcp/McpFormModal.tsx @@ -65,6 +65,7 @@ const McpFormModal: React.FC = ({ claude: boolean; codex: boolean; gemini: boolean; + opencode: boolean; }>(() => { if (initialData?.apps) { return { ...initialData.apps }; @@ -73,6 +74,7 @@ const McpFormModal: React.FC = ({ claude: defaultEnabledApps.includes("claude"), codex: defaultEnabledApps.includes("codex"), gemini: defaultEnabledApps.includes("gemini"), + opencode: defaultEnabledApps.includes("opencode"), }; }); diff --git a/src/components/mcp/UnifiedMcpPanel.tsx b/src/components/mcp/UnifiedMcpPanel.tsx index 19e5e22c3..19bf6e292 100644 --- a/src/components/mcp/UnifiedMcpPanel.tsx +++ b/src/components/mcp/UnifiedMcpPanel.tsx @@ -59,11 +59,12 @@ const UnifiedMcpPanel = React.forwardRef< // Count enabled servers per app const enabledCounts = useMemo(() => { - const counts = { claude: 0, codex: 0, gemini: 0 }; + const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0 }; serverEntries.forEach(([_, server]) => { if (server.apps.claude) counts.claude++; if (server.apps.codex) counts.codex++; if (server.apps.gemini) counts.gemini++; + if (server.apps.opencode) counts.opencode++; }); return counts; }, [serverEntries]); @@ -141,14 +142,15 @@ const UnifiedMcpPanel = React.forwardRef< }; return ( -
+
{/* Info Section */}
{t("mcp.serverCount", { count: serverEntries.length })} ·{" "} {t("mcp.unifiedPanel.apps.claude")}: {enabledCounts.claude} ·{" "} {t("mcp.unifiedPanel.apps.codex")}: {enabledCounts.codex} ·{" "} - {t("mcp.unifiedPanel.apps.gemini")}: {enabledCounts.gemini} + {t("mcp.unifiedPanel.apps.gemini")}: {enabledCounts.gemini} ·{" "} + {t("mcp.unifiedPanel.apps.opencode")}: {enabledCounts.opencode}
@@ -337,6 +339,22 @@ const UnifiedMcpListItem: React.FC = ({ } />
+ +
+ + + onToggleApp(id, "opencode", checked) + } + /> +
{/* 右侧:操作按钮 */} diff --git a/src/components/prompts/PromptFormModal.tsx b/src/components/prompts/PromptFormModal.tsx index 194df6868..970265d06 100644 --- a/src/components/prompts/PromptFormModal.tsx +++ b/src/components/prompts/PromptFormModal.tsx @@ -34,6 +34,7 @@ const PromptFormModal: React.FC = ({ claude: "CLAUDE.md", codex: "AGENTS.md", gemini: "GEMINI.md", + opencode: "AGENTS.md", }; const filename = filenameMap[appId]; const [name, setName] = useState(""); diff --git a/src/components/prompts/PromptFormPanel.tsx b/src/components/prompts/PromptFormPanel.tsx index bdd9efbe5..83a423209 100644 --- a/src/components/prompts/PromptFormPanel.tsx +++ b/src/components/prompts/PromptFormPanel.tsx @@ -28,6 +28,7 @@ const PromptFormPanel: React.FC = ({ claude: "CLAUDE.md", codex: "AGENTS.md", gemini: "GEMINI.md", + opencode: "AGENTS.md", }; const filename = filenameMap[appId]; const [name, setName] = useState(""); diff --git a/src/components/prompts/PromptPanel.tsx b/src/components/prompts/PromptPanel.tsx index af73626aa..aaaf67316 100644 --- a/src/components/prompts/PromptPanel.tsx +++ b/src/components/prompts/PromptPanel.tsx @@ -96,7 +96,7 @@ const PromptPanel = React.forwardRef( const enabledPrompt = promptEntries.find(([_, p]) => p.enabled); return ( -
+
{t("prompts.count", { count: promptEntries.length })} ·{" "} diff --git a/src/components/providers/AddProviderDialog.tsx b/src/components/providers/AddProviderDialog.tsx index 00cb0d1cd..c3ff9e578 100644 --- a/src/components/providers/AddProviderDialog.tsx +++ b/src/components/providers/AddProviderDialog.tsx @@ -17,13 +17,14 @@ import { UniversalProviderPanel } from "@/components/universal"; import { providerPresets } from "@/config/claudeProviderPresets"; import { codexProviderPresets } from "@/config/codexProviderPresets"; import { geminiProviderPresets } from "@/config/geminiProviderPresets"; +// Note: opencodeProviderPresets is loaded via ProviderForm, not needed here import type { UniversalProviderPreset } from "@/config/universalProviderPresets"; interface AddProviderDialogProps { open: boolean; onOpenChange: (open: boolean) => void; appId: AppId; - onSubmit: (provider: Omit) => Promise | void; + onSubmit: (provider: Omit & { providerKey?: string }) => Promise | void; } export function AddProviderDialog({ @@ -33,6 +34,8 @@ export function AddProviderDialog({ onSubmit, }: AddProviderDialogProps) { const { t } = useTranslation(); + // OpenCode doesn't support universal providers + const showUniversalTab = appId !== "opencode"; const [activeTab, setActiveTab] = useState<"app-specific" | "universal">( "app-specific", ); @@ -82,7 +85,7 @@ export function AddProviderDialog({ >; // 构造基础提交数据 - const providerData: Omit = { + const providerData: Omit & { providerKey?: string } = { name: values.name.trim(), notes: values.notes?.trim() || undefined, websiteUrl: values.websiteUrl?.trim() || undefined, @@ -93,6 +96,11 @@ export function AddProviderDialog({ ...(values.meta ? { meta: values.meta } : {}), }; + // OpenCode: pass providerKey for ID generation + if (appId === "opencode" && values.providerKey) { + providerData.providerKey = values.providerKey; + } + const hasCustomEndpoints = providerData.meta?.custom_endpoints && Object.keys(providerData.meta.custom_endpoints).length > 0; @@ -153,6 +161,7 @@ export function AddProviderDialog({ } } } + // Note: OpenCode doesn't use endpointCandidates - it handles endpoints internally } if (appId === "claude") { @@ -175,6 +184,12 @@ export function AddProviderDialog({ if (env?.GOOGLE_GEMINI_BASE_URL) { addUrl(env.GOOGLE_GEMINI_BASE_URL); } + } else if (appId === "opencode") { + // OpenCode uses options.baseURL + const options = parsedConfig.options as Record | undefined; + if (options?.baseURL) { + addUrl(options.baseURL); + } } const urls = Array.from(urlSet); @@ -204,7 +219,7 @@ export function AddProviderDialog({ // 动态 footer:根据当前 Tab 显示不同按钮 const footer = - activeTab === "app-specific" ? ( + !showUniversalTab || activeTab === "app-specific" ? ( <>
diff --git a/src/components/providers/ProviderList.tsx b/src/components/providers/ProviderList.tsx index 1203cb7d3..b9d499719 100644 --- a/src/components/providers/ProviderList.tsx +++ b/src/components/providers/ProviderList.tsx @@ -15,8 +15,10 @@ import { import { AnimatePresence, motion } from "framer-motion"; import { Search, X } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { useQuery } from "@tanstack/react-query"; import type { Provider } from "@/types"; import type { AppId } from "@/lib/api"; +import { providersApi } from "@/lib/api/providers"; import { useDragSort } from "@/hooks/useDragSort"; import { useStreamCheck } from "@/hooks/useStreamCheck"; import { ProviderCard } from "@/components/providers/ProviderCard"; @@ -38,6 +40,8 @@ interface ProviderListProps { onSwitch: (provider: Provider) => void; onEdit: (provider: Provider) => void; onDelete: (provider: Provider) => void; + /** OpenCode: remove from live config (not delete from database) */ + onRemoveFromConfig?: (provider: Provider) => void; onDuplicate: (provider: Provider) => void; onConfigureUsage?: (provider: Provider) => void; onOpenWebsite: (url: string) => void; @@ -56,6 +60,7 @@ export function ProviderList({ onSwitch, onEdit, onDelete, + onRemoveFromConfig, onDuplicate, onConfigureUsage, onOpenWebsite, @@ -72,6 +77,22 @@ export function ProviderList({ appId, ); + // OpenCode: 查询 live 配置中的供应商 ID 列表,用于判断 isInConfig + const { data: opencodeLiveIds } = useQuery({ + queryKey: ["opencodeLiveProviderIds"], + queryFn: () => providersApi.getOpenCodeLiveProviderIds(), + enabled: appId === "opencode", + }); + + // OpenCode: 判断供应商是否已添加到 opencode.json + const isProviderInConfig = useCallback( + (providerId: string): boolean => { + if (appId !== "opencode") return true; // 非 OpenCode 应用始终返回 true + return opencodeLiveIds?.includes(providerId) ?? false; + }, + [appId, opencodeLiveIds], + ); + // 流式健康检查 const { checkProvider, isChecking } = useStreamCheck(appId); @@ -199,14 +220,16 @@ export function ProviderList({ provider={provider} isCurrent={provider.id === currentProviderId} appId={appId} + isInConfig={isProviderInConfig(provider.id)} onSwitch={onSwitch} onEdit={onEdit} onDelete={onDelete} + onRemoveFromConfig={onRemoveFromConfig} onDuplicate={onDuplicate} onConfigureUsage={onConfigureUsage} onOpenWebsite={onOpenWebsite} onOpenTerminal={onOpenTerminal} - onTest={handleTest} + onTest={appId !== "opencode" ? handleTest : undefined} isTesting={isChecking(provider.id)} isProxyRunning={isProxyRunning} isProxyTakeover={isProxyTakeover} @@ -308,14 +331,17 @@ interface SortableProviderCardProps { provider: Provider; isCurrent: boolean; appId: AppId; + isInConfig: boolean; onSwitch: (provider: Provider) => void; onEdit: (provider: Provider) => void; onDelete: (provider: Provider) => void; + /** OpenCode: remove from live config (not delete from database) */ + onRemoveFromConfig?: (provider: Provider) => void; onDuplicate: (provider: Provider) => void; onConfigureUsage?: (provider: Provider) => void; onOpenWebsite: (url: string) => void; onOpenTerminal?: (provider: Provider) => void; - onTest: (provider: Provider) => void; + onTest?: (provider: Provider) => void; isTesting: boolean; isProxyRunning: boolean; isProxyTakeover: boolean; @@ -331,9 +357,11 @@ function SortableProviderCard({ provider, isCurrent, appId, + isInConfig, onSwitch, onEdit, onDelete, + onRemoveFromConfig, onDuplicate, onConfigureUsage, onOpenWebsite, @@ -368,9 +396,11 @@ function SortableProviderCard({ provider={provider} isCurrent={isCurrent} appId={appId} + isInConfig={isInConfig} onSwitch={onSwitch} onEdit={onEdit} onDelete={onDelete} + onRemoveFromConfig={onRemoveFromConfig} onDuplicate={onDuplicate} onConfigureUsage={ onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined diff --git a/src/components/providers/forms/BasicFormFields.tsx b/src/components/providers/forms/BasicFormFields.tsx index 514343474..0bdaf5b18 100644 --- a/src/components/providers/forms/BasicFormFields.tsx +++ b/src/components/providers/forms/BasicFormFields.tsx @@ -1,5 +1,6 @@ import { useTranslation } from "react-i18next"; import { useState } from "react"; +import type { ReactNode } from "react"; import { FormControl, FormField, @@ -24,9 +25,11 @@ import type { ProviderFormData } from "@/lib/schemas/provider"; interface BasicFormFieldsProps { form: UseFormReturn; + /** Slot to render content between icon and name fields */ + beforeNameSlot?: ReactNode; } -export function BasicFormFields({ form }: BasicFormFieldsProps) { +export function BasicFormFields({ form, beforeNameSlot }: BasicFormFieldsProps) { const { t } = useTranslation(); const [iconDialogOpen, setIconDialogOpen] = useState(false); @@ -78,7 +81,7 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) { >
-
+
-
+
+ {/* Slot for additional fields between icon and name */} + {beforeNameSlot} + {/* 基础信息 - 网格布局 */}
= { codex: 12, claude: 8, - gemini: 8, // 新增 gemini -} as const; + gemini: 8, + opencode: 8, +}; interface TestResult { url: string; diff --git a/src/components/providers/forms/OpenCodeFormFields.tsx b/src/components/providers/forms/OpenCodeFormFields.tsx new file mode 100644 index 000000000..b6fafc049 --- /dev/null +++ b/src/components/providers/forms/OpenCodeFormFields.tsx @@ -0,0 +1,655 @@ +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 ( + 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 ( + 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 ( + 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; + onModelsChange: (models: Record) => void; + + // Extra Options + extraOptions: Record; + onExtraOptionsChange: (options: Record) => 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>(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 = {}; + 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 = {}; + 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 = {}; + 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 */} +
+ + {t("opencode.npmPackage", { + defaultValue: "接口格式", + })} + + +

+ {t("opencode.npmPackageHint", { + defaultValue: + "Select the AI SDK package that matches your provider.", + })} +

+
+ + {/* API Key */} + + + {/* Base URL */} +
+ + {t("opencode.baseUrl", { defaultValue: "Base URL" })} + + onBaseUrlChange(e.target.value)} + placeholder="https://api.example.com/v1" + /> +

+ {t("opencode.baseUrlHint", { + defaultValue: + "The base URL for the API endpoint. Leave empty to use the default endpoint for official SDKs.", + })} +

+
+ + {/* Extra Options Editor */} +
+
+ + {t("opencode.extraOptions", { defaultValue: "额外选项" })} + + +
+ + {Object.keys(extraOptions).length === 0 ? ( +

+ {t("opencode.noExtraOptions", { + defaultValue: "暂无额外选项", + })} +

+ ) : ( +
+
+ + {t("opencode.extraOptionKey", { defaultValue: "键名" })} + + + {t("opencode.extraOptionValue", { defaultValue: "值" })} + + +
+ {Object.entries(extraOptions).map(([key, value]) => ( +
+ handleExtraOptionKeyChange(key, newKey)} + placeholder={t("opencode.extraOptionKeyPlaceholder", { + defaultValue: "timeout", + })} + /> + handleExtraOptionValueChange(key, e.target.value)} + placeholder={t("opencode.extraOptionValuePlaceholder", { + defaultValue: "600000", + })} + className="flex-1" + /> + +
+ ))} +
+ )} + +

+ {t("opencode.extraOptionsHint", { + defaultValue: + "配置额外的 SDK 选项,如 timeout、setCacheKey 等。值会自动解析类型(数字、布尔值等)。", + })} +

+
+ + {/* Models Editor */} +
+
+ + {t("opencode.models", { defaultValue: "Models" })} + + +
+ + {Object.keys(models).length === 0 ? ( +

+ {t("opencode.noModels", { + defaultValue: "No models configured. Click Add to add a model.", + })} +

+ ) : ( +
+
+ + + {t("opencode.modelId", { defaultValue: "模型 ID" })} + + + {t("opencode.modelName", { defaultValue: "显示名称" })} + + +
+ {Object.entries(models).map(([key, model]) => ( +
+ {/* Model row */} +
+ + handleModelIdChange(key, newId)} + placeholder={t("opencode.modelId", { + defaultValue: "Model ID", + })} + /> + handleModelNameChange(key, e.target.value)} + placeholder={t("opencode.modelName", { + defaultValue: "Display Name", + })} + className="flex-1" + /> + +
+ + {/* Expanded model options */} + {expandedModels.has(key) && ( +
+ {Object.keys(model.options || {}).length === 0 ? ( +
+

+ {t("opencode.noModelOptions", { + defaultValue: "模型选项,点击 + 添加", + })} +

+ +
+ ) : ( + <> + {Object.entries(model.options || {}).map( + ([optKey, optValue]) => ( +
+ + handleModelOptionKeyChange(key, optKey, newKey) + } + placeholder={t( + "opencode.modelOptionKeyPlaceholder", + { + defaultValue: "provider", + } + )} + /> + + handleModelOptionValueChange( + key, + optKey, + e.target.value + ) + } + placeholder={t( + "opencode.modelOptionValuePlaceholder", + { + defaultValue: '{"order": ["baseten"]}', + } + )} + className="flex-1" + /> + +
+ ) + )} +
+ +
+ + )} +
+ )} +
+ ))} +
+ )} + +

+ {t("opencode.modelsHint", { + defaultValue: + "Configure available models. Model ID is the API identifier, Display Name is shown in the UI.", + })} +

+
+ + ); +} diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 40cd688a0..2c366a16a 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider"; import type { AppId } from "@/lib/api"; import type { ProviderCategory, ProviderMeta } from "@/types"; @@ -20,6 +21,12 @@ import { geminiProviderPresets, type GeminiProviderPreset, } from "@/config/geminiProviderPresets"; +import { + opencodeProviderPresets, + type OpenCodeProviderPreset, +} from "@/config/opencodeProviderPresets"; +import { OpenCodeFormFields } from "./OpenCodeFormFields"; +import type { OpenCodeModel } from "@/types"; import type { UniversalProviderPreset } from "@/config/universalProviderPresets"; import { applyTemplateValues } from "@/utils/providerConfigUtils"; import { mergeProviderMeta } from "@/utils/providerMetaUtils"; @@ -27,6 +34,8 @@ import { getCodexCustomTemplate } from "@/config/codexTemplates"; import CodexConfigEditor from "./CodexConfigEditor"; import { CommonConfigEditor } from "./CommonConfigEditor"; import GeminiConfigEditor from "./GeminiConfigEditor"; +import JsonEditor from "@/components/JsonEditor"; +import { Label } from "@/components/ui/label"; import { ProviderPresetSelector } from "./ProviderPresetSelector"; import { BasicFormFields } from "./BasicFormFields"; import { ClaudeFormFields } from "./ClaudeFormFields"; @@ -47,6 +56,7 @@ import { useGeminiConfigState, useGeminiCommonConfig, } from "./hooks"; +import { useProvidersQuery } from "@/lib/query/queries"; const CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {} }, null, 2); const CODEX_DEFAULT_CONFIG = JSON.stringify({ auth: {}, config: "" }, null, 2); @@ -62,9 +72,22 @@ const GEMINI_DEFAULT_CONFIG = JSON.stringify( 2, ); +const OPENCODE_DEFAULT_CONFIG = JSON.stringify( + { + npm: "@ai-sdk/openai-compatible", + options: { + baseURL: "", + apiKey: "", + }, + models: {}, + }, + null, + 2, +); + type PresetEntry = { id: string; - preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset; + preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset | OpenCodeProviderPreset; }; interface ProviderFormProps { @@ -158,7 +181,9 @@ export function ProviderForm({ ? CODEX_DEFAULT_CONFIG : appId === "gemini" ? GEMINI_DEFAULT_CONFIG - : CLAUDE_DEFAULT_CONFIG, + : appId === "opencode" + ? OPENCODE_DEFAULT_CONFIG + : CLAUDE_DEFAULT_CONFIG, icon: initialData?.icon ?? "", iconColor: initialData?.iconColor ?? "", }), @@ -171,7 +196,7 @@ export function ProviderForm({ mode: "onSubmit", }); - const settingsConfigValue = form.watch("settingsConfig"); + const settingsConfigValue = form.getValues("settingsConfig"); // 使用 API Key hook const { @@ -179,7 +204,7 @@ export function ProviderForm({ handleApiKeyChange, showApiKey: shouldShowApiKey, } = useApiKeyState({ - initialConfig: form.watch("settingsConfig"), + initialConfig: form.getValues("settingsConfig"), onConfigChange: (config) => form.setValue("settingsConfig", config), selectedPresetId, category, @@ -190,7 +215,7 @@ export function ProviderForm({ const { baseUrl, handleClaudeBaseUrlChange } = useBaseUrlState({ appType: appId, category, - settingsConfig: form.watch("settingsConfig"), + settingsConfig: form.getValues("settingsConfig"), codexConfig: "", onSettingsConfigChange: (config) => form.setValue("settingsConfig", config), onCodexConfigChange: () => { @@ -207,7 +232,7 @@ export function ProviderForm({ defaultOpusModel, handleModelChange, } = useModelState({ - settingsConfig: form.watch("settingsConfig"), + settingsConfig: form.getValues("settingsConfig"), onConfigChange: (config) => form.setValue("settingsConfig", config), }); @@ -328,6 +353,11 @@ export function ProviderForm({ id: `gemini-${index}`, preset, })); + } else if (appId === "opencode") { + return opencodeProviderPresets.map((preset, index) => ({ + id: `opencode-${index}`, + preset, + })); } return providerPresets.map((preset, index) => ({ id: `claude-${index}`, @@ -345,7 +375,7 @@ export function ProviderForm({ } = useTemplateValues({ selectedPresetId: appId === "claude" ? selectedPresetId : null, presetEntries: appId === "claude" ? presetEntries : [], - settingsConfig: form.watch("settingsConfig"), + settingsConfig: form.getValues("settingsConfig"), onConfigChange: (config) => form.setValue("settingsConfig", config), }); @@ -359,10 +389,11 @@ export function ProviderForm({ isExtracting: isClaudeExtracting, handleExtract: handleClaudeExtract, } = useCommonConfigSnippet({ - settingsConfig: form.watch("settingsConfig"), + settingsConfig: form.getValues("settingsConfig"), onConfigChange: (config) => form.setValue("settingsConfig", config), initialData: appId === "claude" ? initialData : undefined, selectedPresetId: selectedPresetId ?? undefined, + enabled: appId === "claude", }); // 使用 Codex 通用配置片段 hook (仅 Codex 模式) @@ -408,7 +439,7 @@ export function ProviderForm({ originalHandleGeminiApiKeyChange(key); // 同步更新 settingsConfig try { - const config = JSON.parse(form.watch("settingsConfig") || "{}"); + const config = JSON.parse(form.getValues("settingsConfig") || "{}"); if (!config.env) config.env = {}; config.env.GEMINI_API_KEY = key.trim(); form.setValue("settingsConfig", JSON.stringify(config, null, 2)); @@ -424,7 +455,7 @@ export function ProviderForm({ originalHandleGeminiBaseUrlChange(url); // 同步更新 settingsConfig try { - const config = JSON.parse(form.watch("settingsConfig") || "{}"); + const config = JSON.parse(form.getValues("settingsConfig") || "{}"); if (!config.env) config.env = {}; config.env.GOOGLE_GEMINI_BASE_URL = url.trim().replace(/\/+$/, ""); form.setValue("settingsConfig", JSON.stringify(config, null, 2)); @@ -440,7 +471,7 @@ export function ProviderForm({ originalHandleGeminiModelChange(model); // 同步更新 settingsConfig try { - const config = JSON.parse(form.watch("settingsConfig") || "{}"); + const config = JSON.parse(form.getValues("settingsConfig") || "{}"); if (!config.env) config.env = {}; config.env.GEMINI_MODEL = model.trim(); form.setValue("settingsConfig", JSON.stringify(config, null, 2)); @@ -469,6 +500,180 @@ export function ProviderForm({ selectedPresetId: selectedPresetId ?? undefined, }); + // OpenCode: query existing providers for duplicate key checking + const { data: opencodeProvidersData } = useProvidersQuery("opencode"); + const existingOpencodeKeys = useMemo(() => { + if (!opencodeProvidersData?.providers) return []; + // Exclude current provider ID when in edit mode + return Object.keys(opencodeProvidersData.providers).filter( + (k) => k !== providerId + ); + }, [opencodeProvidersData?.providers, providerId]); + + // OpenCode Provider Key state + const [opencodeProviderKey, setOpencodeProviderKey] = useState(() => { + if (appId !== "opencode") return ""; + // In edit mode, use the existing provider ID as the key + return providerId || ""; + }); + + // OpenCode 配置状态 + const [opencodeNpm, setOpencodeNpm] = useState(() => { + 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(() => { + 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(() => { + 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>(() => { + 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>(() => { + 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 = {}; + 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) => { + 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) => { + setOpencodeExtraOptions(options); + try { + const config = JSON.parse(form.getValues("settingsConfig") || OPENCODE_DEFAULT_CONFIG); + if (!config.options) config.options = {}; + + // Remove old extra options (keep only known keys) + const knownKeys = ["baseURL", "apiKey", "headers"]; + for (const k of Object.keys(config.options)) { + if (!knownKeys.includes(k)) { + delete config.options[k]; + } + } + + // Add new extra options (auto-parse value types) + for (const [k, v] of Object.entries(options)) { + const trimmedKey = k.trim(); + if (trimmedKey && !trimmedKey.startsWith("option-")) { + try { + // Try to parse as JSON (number, boolean, object, array) + config.options[trimmedKey] = JSON.parse(v); + } catch { + // If parsing fails, keep as string + config.options[trimmedKey] = v; + } + } + } + + form.setValue("settingsConfig", JSON.stringify(config, null, 2)); + } catch { + // ignore + } + }, + [form], + ); + const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false); const handleSubmit = (values: ProviderFormData) => { @@ -496,6 +701,23 @@ export function ProviderForm({ return; } + // OpenCode: validate provider key + if (appId === "opencode") { + const keyPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/; + if (!opencodeProviderKey.trim()) { + toast.error(t("opencode.providerKeyRequired")); + return; + } + if (!keyPattern.test(opencodeProviderKey)) { + toast.error(t("opencode.providerKeyInvalid")); + return; + } + if (!isEditMode && existingOpencodeKeys.includes(opencodeProviderKey)) { + toast.error(t("opencode.providerKeyDuplicate")); + return; + } + } + // 非官方供应商必填校验:端点和 API Key if (category !== "official") { if (appId === "claude") { @@ -593,6 +815,11 @@ export function ProviderForm({ settingsConfig, }; + // OpenCode: pass provider key for ID generation + if (appId === "opencode") { + payload.providerKey = opencodeProviderKey; + } + if (activePreset) { payload.presetId = activePreset.id; if (activePreset.category) { @@ -748,6 +975,15 @@ export function ProviderForm({ if (appId === "gemini") { resetGeminiConfig({}, {}); } + // OpenCode 自定义模式:重置为空配置 + if (appId === "opencode") { + setOpencodeProviderKey(""); + setOpencodeNpm("@ai-sdk/openai-compatible"); + setOpencodeBaseUrl(""); + setOpencodeApiKey(""); + setOpencodeModels({}); + setOpencodeExtraOptions({}); + } return; } @@ -801,6 +1037,42 @@ export function ProviderForm({ return; } + // OpenCode preset handling + if (appId === "opencode") { + const preset = entry.preset as OpenCodeProviderPreset; + const config = preset.settingsConfig; + + // Clear provider key (user must enter their own unique key) + setOpencodeProviderKey(""); + + // Update OpenCode-specific states + setOpencodeNpm(config.npm || "@ai-sdk/openai-compatible"); + setOpencodeBaseUrl(config.options?.baseURL || ""); + setOpencodeApiKey(config.options?.apiKey || ""); + setOpencodeModels(config.models || {}); + + // Extract extra options from preset + const options = config.options || {}; + const extra: Record = {}; + const knownKeys = ["baseURL", "apiKey", "headers"]; + for (const [k, v] of Object.entries(options)) { + if (!knownKeys.includes(k)) { + extra[k] = typeof v === "string" ? v : JSON.stringify(v); + } + } + setOpencodeExtraOptions(extra); + + // Update form fields + form.reset({ + name: preset.name, + websiteUrl: preset.websiteUrl ?? "", + settingsConfig: JSON.stringify(config, null, 2), + icon: preset.icon ?? "", + iconColor: preset.iconColor ?? "", + }); + return; + } + const preset = entry.preset as ProviderPreset; const config = applyTemplateValues( preset.settingsConfig, @@ -838,14 +1110,55 @@ export function ProviderForm({ )} {/* 基础字段 */} - + + + 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 && ( +

+ {t("opencode.providerKeyDuplicate")} +

+ )} + {opencodeProviderKey.trim() !== "" && !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(opencodeProviderKey) && ( +

+ {t("opencode.providerKeyInvalid")} +

+ )} + {!(existingOpencodeKeys.includes(opencodeProviderKey) && !isEditMode) && + (opencodeProviderKey.trim() === "" || /^[a-z0-9]+(-[a-z0-9]+)*$/.test(opencodeProviderKey)) && ( +

+ {t("opencode.providerKeyHint")} +

+ )} +
+ ) : undefined + } + /> {/* Claude 专属字段 */} {appId === "claude" && ( )} + {/* OpenCode 专属字段 */} + {appId === "opencode" && ( + + )} + {/* 配置编辑器:Codex、Claude、Gemini 分别使用不同的编辑器 */} {appId === "codex" ? ( <> @@ -1000,10 +1332,40 @@ export function ProviderForm({ )} /> + ) : appId === "opencode" ? ( + <> +
+ + 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" + /> +
+ ( + + + + )} + /> + ) : ( <> form.setValue("settingsConfig", value)} useCommonConfig={useCommonConfig} onCommonConfigToggle={handleCommonConfigToggle} @@ -1047,4 +1409,5 @@ export type ProviderFormValues = ProviderFormData & { presetCategory?: ProviderCategory; isPartner?: boolean; meta?: ProviderMeta; + providerKey?: string; // OpenCode: user-defined provider key }; diff --git a/src/components/providers/forms/hooks/useBaseUrlState.ts b/src/components/providers/forms/hooks/useBaseUrlState.ts index 2deffcbac..75e39594a 100644 --- a/src/components/providers/forms/hooks/useBaseUrlState.ts +++ b/src/components/providers/forms/hooks/useBaseUrlState.ts @@ -6,7 +6,7 @@ import { import type { ProviderCategory } from "@/types"; interface UseBaseUrlStateProps { - appType: "claude" | "codex" | "gemini"; + appType: "claude" | "codex" | "gemini" | "opencode"; category: ProviderCategory | undefined; settingsConfig: string; codexConfig?: string; diff --git a/src/components/providers/forms/hooks/useCommonConfigSnippet.ts b/src/components/providers/forms/hooks/useCommonConfigSnippet.ts index f5909efeb..2f5114572 100644 --- a/src/components/providers/forms/hooks/useCommonConfigSnippet.ts +++ b/src/components/providers/forms/hooks/useCommonConfigSnippet.ts @@ -19,6 +19,8 @@ interface UseCommonConfigSnippetProps { settingsConfig?: Record; }; selectedPresetId?: string; + /** When false, the hook skips all logic and returns disabled state. Default: true */ + enabled?: boolean; } /** @@ -30,6 +32,7 @@ export function useCommonConfigSnippet({ onConfigChange, initialData, selectedPresetId, + enabled = true, }: UseCommonConfigSnippetProps) { const { t } = useTranslation(); const [useCommonConfig, setUseCommonConfig] = useState(false); @@ -47,11 +50,16 @@ export function useCommonConfigSnippet({ // 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑 useEffect(() => { + if (!enabled) return; hasInitializedNewMode.current = false; - }, [selectedPresetId]); + }, [selectedPresetId, enabled]); // 初始化:从 config.json 加载,支持从 localStorage 迁移 useEffect(() => { + if (!enabled) { + setIsLoading(false); + return; + } let mounted = true; const loadSnippet = async () => { @@ -100,10 +108,11 @@ export function useCommonConfigSnippet({ return () => { mounted = false; }; - }, []); + }, [enabled]); // 初始化时检查通用配置片段(编辑模式) useEffect(() => { + if (!enabled) return; if (initialData && !isLoading) { const configString = JSON.stringify(initialData.settingsConfig, null, 2); const hasCommon = hasCommonConfigSnippet( @@ -112,10 +121,11 @@ export function useCommonConfigSnippet({ ); setUseCommonConfig(hasCommon); } - }, [initialData, commonConfigSnippet, isLoading]); + }, [enabled, initialData, commonConfigSnippet, isLoading]); // 新建模式:如果通用配置片段存在且有效,默认启用 useEffect(() => { + if (!enabled) return; // 仅新建模式、加载完成、尚未初始化过 if (!initialData && !isLoading && !hasInitializedNewMode.current) { hasInitializedNewMode.current = true; @@ -145,6 +155,7 @@ export function useCommonConfigSnippet({ } } }, [ + enabled, initialData, commonConfigSnippet, isLoading, @@ -259,6 +270,7 @@ export function useCommonConfigSnippet({ // 当配置变化时检查是否包含通用配置(但避免在通过通用配置更新时检查) useEffect(() => { + if (!enabled) return; if (isUpdatingFromCommonConfig.current || isLoading) { return; } @@ -267,7 +279,7 @@ export function useCommonConfigSnippet({ commonConfigSnippet, ); setUseCommonConfig(hasCommon); - }, [settingsConfig, commonConfigSnippet, isLoading]); + }, [enabled, settingsConfig, commonConfigSnippet, isLoading]); // 从编辑器当前内容提取通用配置片段 const handleExtract = useCallback(async () => { diff --git a/src/components/proxy/ProxyToggle.tsx b/src/components/proxy/ProxyToggle.tsx index 2b9b8cb9d..ca526eb4b 100644 --- a/src/components/proxy/ProxyToggle.tsx +++ b/src/components/proxy/ProxyToggle.tsx @@ -37,7 +37,9 @@ export function ProxyToggle({ className, activeApp }: ProxyToggleProps) { ? "Claude" : activeApp === "codex" ? "Codex" - : "Gemini"; + : activeApp === "gemini" + ? "Gemini" + : "OpenCode"; const tooltipText = takeoverEnabled ? isRunning diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index f464f6a47..6fd63e0b5 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -202,7 +202,7 @@ export function SettingsPage({ }; return ( -
+
{isBusy ? (
diff --git a/src/components/skills/SkillsPage.tsx b/src/components/skills/SkillsPage.tsx index 721ac9665..7ab030e0b 100644 --- a/src/components/skills/SkillsPage.tsx +++ b/src/components/skills/SkillsPage.tsx @@ -193,7 +193,7 @@ export const SkillsPage = forwardRef( }, [skills, searchQuery, filterStatus]); return ( -
+
{/* 技能网格(可滚动详情区域) */}
diff --git a/src/components/skills/UnifiedSkillsPanel.tsx b/src/components/skills/UnifiedSkillsPanel.tsx index 9f8f7267f..5c6e6e79e 100644 --- a/src/components/skills/UnifiedSkillsPanel.tsx +++ b/src/components/skills/UnifiedSkillsPanel.tsx @@ -52,12 +52,13 @@ const UnifiedSkillsPanel = React.forwardRef< // Count enabled skills per app const enabledCounts = useMemo(() => { - const counts = { claude: 0, codex: 0, gemini: 0 }; + const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0 }; if (!skills) return counts; skills.forEach((skill) => { if (skill.apps.claude) counts.claude++; if (skill.apps.codex) counts.codex++; if (skill.apps.gemini) counts.gemini++; + if (skill.apps.opencode) counts.opencode++; }); return counts; }, [skills]); @@ -132,14 +133,15 @@ const UnifiedSkillsPanel = React.forwardRef< })); return ( -
+
{/* Info Section */}
{t("skills.installed", { count: skills?.length || 0 })} ·{" "} {t("skills.apps.claude")}: {enabledCounts.claude} ·{" "} {t("skills.apps.codex")}: {enabledCounts.codex} ·{" "} - {t("skills.apps.gemini")}: {enabledCounts.gemini} + {t("skills.apps.gemini")}: {enabledCounts.gemini} ·{" "} + {t("skills.apps.opencode")}: {enabledCounts.opencode}
@@ -308,6 +310,22 @@ const InstalledSkillListItem: React.FC = ({ } />
+ +
+ + + onToggleApp(skill.id, "opencode", checked) + } + /> +
{/* 右侧:删除按钮 */} diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index 9f3b88f05..09c5511e6 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import * as SelectPrimitive from "@radix-ui/react-select"; -import { Check, ChevronDown, ChevronUp } from "lucide-react"; +import { ChevronDown, ChevronUp } from "lucide-react"; import { cn } from "@/lib/utils"; const Select = SelectPrimitive.Root; @@ -37,7 +37,7 @@ const SelectContent = React.forwardRef< - - - - - - {children} )); diff --git a/src/config/opencodeProviderPresets.ts b/src/config/opencodeProviderPresets.ts new file mode 100644 index 000000000..48014a88a --- /dev/null +++ b/src/config/opencodeProviderPresets.ts @@ -0,0 +1,678 @@ +/** + * 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; + /** 视觉主题配置 */ + 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: "", + }, + }, + }, +]; diff --git a/src/hooks/useProviderActions.ts b/src/hooks/useProviderActions.ts index a8322b4ce..cc46cca89 100644 --- a/src/hooks/useProviderActions.ts +++ b/src/hooks/useProviderActions.ts @@ -54,7 +54,7 @@ export function useProviderActions(activeApp: AppId) { // 添加供应商 const addProvider = useCallback( - async (provider: Omit) => { + async (provider: Omit & { providerKey?: string }) => { await addProviderMutation.mutateAsync(provider); }, [addProviderMutation], diff --git a/src/hooks/useProxyStatus.ts b/src/hooks/useProxyStatus.ts index fb69f1915..d2faa2a8f 100644 --- a/src/hooks/useProxyStatus.ts +++ b/src/hooks/useProxyStatus.ts @@ -101,7 +101,9 @@ export function useProxyStatus() { ? "Claude" : variables.appType === "codex" ? "Codex" - : "Gemini"; + : variables.appType === "gemini" + ? "Gemini" + : "OpenCode"; toast.success( variables.enabled diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index d8cc43e0b..cf246739f 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -84,6 +84,10 @@ "addClaudeProvider": "Add Claude Code Provider", "addCodexProvider": "Add Codex 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.", "editClaudeProvider": "Edit Claude Code Provider", "editCodexProvider": "Edit Codex Provider", @@ -133,6 +137,8 @@ "providerSaved": "Provider configuration saved", "providerDeleted": "Provider deleted successfully", "switchSuccess": "Switch successful!", + "addToConfigSuccess": "Added to config", + "removeFromConfigSuccess": "Removed from config", "switchFailedTitle": "Switch failed", "switchFailed": "Switch failed: {{error}}", "autoImported": "Default provider created from existing configuration", @@ -153,7 +159,9 @@ }, "confirm": { "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": { "title": "Settings", @@ -304,7 +312,8 @@ "apps": { "claude": "Claude Code", "codex": "Codex", - "gemini": "Gemini" + "gemini": "Gemini", + "opencode": "OpenCode" }, "console": { "providerSwitchReceived": "Received provider switch event:", @@ -455,6 +464,36 @@ "configMergeFailed": "Config merge 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": { "label": "Provider Preset", "custom": "Custom Configuration", @@ -634,7 +673,8 @@ "apps": { "claude": "Claude", "codex": "Codex", - "gemini": "Gemini" + "gemini": "Gemini", + "opencode": "OpenCode" } }, "userLevelPath": "User-level MCP path", @@ -942,7 +982,8 @@ "apps": { "claude": "Claude", "codex": "Codex", - "gemini": "Gemini" + "gemini": "Gemini", + "opencode": "OpenCode" } }, "deeplink": { diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 9b726f5a7..81a19a50d 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -84,6 +84,10 @@ "addClaudeProvider": "Claude Code プロバイダーを追加", "addCodexProvider": "Codex プロバイダーを追加", "addGeminiProvider": "Gemini プロバイダーを追加", + "addOpenCodeProvider": "OpenCode プロバイダーを追加", + "addToConfig": "追加", + "removeFromConfig": "削除", + "inConfig": "追加済み", "addProviderHint": "一覧にすばやく切り替えられるよう、ここに情報を入力してください。", "editClaudeProvider": "Claude Code プロバイダーを編集", "editCodexProvider": "Codex プロバイダーを編集", @@ -133,6 +137,8 @@ "providerSaved": "プロバイダー設定を保存しました", "providerDeleted": "プロバイダーを削除しました", "switchSuccess": "切り替え成功!", + "addToConfigSuccess": "設定に追加しました", + "removeFromConfigSuccess": "設定から削除しました", "switchFailedTitle": "切り替えに失敗しました", "switchFailed": "切り替えに失敗しました: {{error}}", "autoImported": "既存設定からデフォルトプロバイダーを自動作成しました", @@ -153,7 +159,9 @@ }, "confirm": { "deleteProvider": "プロバイダーを削除", - "deleteProviderMessage": "プロバイダー「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。" + "deleteProviderMessage": "プロバイダー「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。", + "removeProvider": "プロバイダーを解除", + "removeProviderMessage": "プロバイダー「{{name}}」を設定から解除してもよろしいですか?\n\n解除後、このプロバイダーは無効になりますが、設定データは CC Switch に保持されます。いつでも再追加できます。" }, "settings": { "title": "設定", @@ -304,7 +312,8 @@ "apps": { "claude": "Claude Code", "codex": "Codex", - "gemini": "Gemini" + "gemini": "Gemini", + "opencode": "OpenCode" }, "console": { "providerSwitchReceived": "プロバイダー切り替えイベントを受信:", @@ -455,6 +464,36 @@ "configMergeFailed": "設定のマージに失敗しました: {{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": { "label": "プロバイダータイプ", "custom": "カスタム設定", @@ -634,7 +673,8 @@ "apps": { "claude": "Claude", "codex": "Codex", - "gemini": "Gemini" + "gemini": "Gemini", + "opencode": "OpenCode" } }, "userLevelPath": "ユーザーレベルの MCP パス", @@ -942,7 +982,8 @@ "apps": { "claude": "Claude", "codex": "Codex", - "gemini": "Gemini" + "gemini": "Gemini", + "opencode": "OpenCode" } }, "deeplink": { diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 21d4d5806..1f90c7217 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -84,6 +84,10 @@ "addClaudeProvider": "添加 Claude Code 供应商", "addCodexProvider": "添加 Codex 供应商", "addGeminiProvider": "添加 Gemini 供应商", + "addOpenCodeProvider": "添加 OpenCode 供应商", + "addToConfig": "添加", + "removeFromConfig": "移除", + "inConfig": "已添加", "addProviderHint": "填写信息后即可在列表中快速切换供应商。", "editClaudeProvider": "编辑 Claude Code 供应商", "editCodexProvider": "编辑 Codex 供应商", @@ -133,6 +137,8 @@ "providerSaved": "供应商配置已保存", "providerDeleted": "供应商删除成功", "switchSuccess": "切换成功!", + "addToConfigSuccess": "已添加到配置", + "removeFromConfigSuccess": "已从配置移除", "switchFailedTitle": "切换失败", "switchFailed": "切换失败:{{error}}", "autoImported": "已从现有配置创建默认供应商", @@ -153,7 +159,9 @@ }, "confirm": { "deleteProvider": "删除供应商", - "deleteProviderMessage": "确定要删除供应商 \"{{name}}\" 吗?此操作无法撤销。" + "deleteProviderMessage": "确定要删除供应商 \"{{name}}\" 吗?此操作无法撤销。", + "removeProvider": "移除供应商", + "removeProviderMessage": "确定要从配置中移除供应商 \"{{name}}\" 吗?\n\n移除后该供应商将不再生效,但配置数据会保留在 CC Switch 中,您可以随时重新添加。" }, "settings": { "title": "设置", @@ -304,7 +312,8 @@ "apps": { "claude": "Claude Code", "codex": "Codex", - "gemini": "Gemini" + "gemini": "Gemini", + "opencode": "OpenCode" }, "console": { "providerSwitchReceived": "收到供应商切换事件:", @@ -455,6 +464,36 @@ "configMergeFailed": "配置合并失败: {{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": { "label": "预设供应商", "custom": "自定义配置", @@ -634,7 +673,8 @@ "apps": { "claude": "Claude", "codex": "Codex", - "gemini": "Gemini" + "gemini": "Gemini", + "opencode": "OpenCode" } }, "userLevelPath": "用户级 MCP 配置路径", @@ -942,7 +982,8 @@ "apps": { "claude": "Claude", "codex": "Codex", - "gemini": "Gemini" + "gemini": "Gemini", + "opencode": "OpenCode" } }, "deeplink": { diff --git a/src/icons/extracted/index.ts b/src/icons/extracted/index.ts index e42634298..1545b6e1b 100644 --- a/src/icons/extracted/index.ts +++ b/src/icons/extracted/index.ts @@ -53,6 +53,7 @@ export const icons: Record = { longcat: `LongCat`, modelscope: `ModelScope`, aihubmix: `AiHubMix`, + opencode: `OpenCode`, }; export const iconList = Object.keys(icons); diff --git a/src/icons/extracted/opencode-logo-light.svg b/src/icons/extracted/opencode-logo-light.svg new file mode 100644 index 000000000..b79140a50 --- /dev/null +++ b/src/icons/extracted/opencode-logo-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/api/providers.ts b/src/lib/api/providers.ts index eb3dc9daf..dd154b4e5 100644 --- a/src/lib/api/providers.ts +++ b/src/lib/api/providers.ts @@ -38,6 +38,14 @@ export const providersApi = { 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 { + return await invoke("remove_provider_from_live_config", { id, app: appId }); + }, + async switch(id: string, appId: AppId): Promise { return await invoke("switch_provider", { id, app: appId }); }, @@ -74,6 +82,22 @@ export const providersApi = { async openTerminal(providerId: string, appId: AppId): Promise { return await invoke("open_provider_terminal", { providerId, app: appId }); }, + + /** + * 从 OpenCode live 配置导入供应商到数据库 + * OpenCode 特有功能:由于累加模式,用户可能已在 opencode.json 中配置供应商 + */ + async importOpenCodeFromLive(): Promise { + return await invoke("import_opencode_providers_from_live"); + }, + + /** + * 获取 OpenCode live 配置中的供应商 ID 列表 + * 用于前端判断供应商是否已添加到 opencode.json + */ + async getOpenCodeLiveProviderIds(): Promise { + return await invoke("get_opencode_live_provider_ids"); + }, }; // ============================================================================ diff --git a/src/lib/api/skills.ts b/src/lib/api/skills.ts index 09a4d6a45..3e15f070e 100644 --- a/src/lib/api/skills.ts +++ b/src/lib/api/skills.ts @@ -2,13 +2,14 @@ import { invoke } from "@tauri-apps/api/core"; // ========== 类型定义 ========== -export type AppType = "claude" | "codex" | "gemini"; +export type AppType = "claude" | "codex" | "gemini" | "opencode"; /** Skill 应用启用状态 */ export interface SkillApps { claude: boolean; codex: boolean; gemini: boolean; + opencode: boolean; } /** 已安装的 Skill(v3.10.0+ 统一结构) */ diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index a51390e44..2e8e2b2ac 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -1,2 +1,2 @@ // 前端统一使用 AppId 作为应用标识(与后端命令参数 `app` 一致) -export type AppId = "claude" | "codex" | "gemini"; // 新增 gemini +export type AppId = "claude" | "codex" | "gemini" | "opencode"; diff --git a/src/lib/query/mutations.ts b/src/lib/query/mutations.ts index b5118680c..73d75c854 100644 --- a/src/lib/query/mutations.ts +++ b/src/lib/query/mutations.ts @@ -11,12 +11,30 @@ export const useAddProviderMutation = (appId: AppId) => { const { t } = useTranslation(); return useMutation({ - mutationFn: async (providerInput: Omit) => { + mutationFn: async ( + providerInput: Omit & { 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 = { ...providerInput, - id: generateUUID(), + id, createdAt: Date.now(), }; + // Remove providerKey from the provider object before saving + delete (newProvider as any).providerKey; + await providersApi.add(newProvider, appId); return newProvider; }, @@ -136,6 +154,13 @@ export const useSwitchProviderMutation = (appId: AppId) => { onSuccess: async () => { 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 { await providersApi.updateTrayMenu(); @@ -146,15 +171,17 @@ export const useSwitchProviderMutation = (appId: AppId) => { ); } - toast.success( - t("notifications.switchSuccess", { - defaultValue: "切换供应商成功", - appName: t(`apps.${appId}`, { defaultValue: appId }), - }), - { - closeButton: true, - }, - ); + // OpenCode: show "added to config" message instead of "switched" + const messageKey = + appId === "opencode" + ? "notifications.addToConfigSuccess" + : "notifications.switchSuccess"; + const defaultMessage = + appId === "opencode" ? "已添加到配置" : "切换供应商成功"; + + toast.success(t(messageKey, { defaultValue: defaultMessage }), { + closeButton: true, + }); }, onError: (error: Error) => { const detail = extractErrorMessage(error) || t("common.unknown"); diff --git a/src/types.ts b/src/types.ts index a9234d0d9..dcf0b5347 100644 --- a/src/types.ts +++ b/src/types.ts @@ -125,6 +125,8 @@ export interface Settings { codexConfigDir?: string; // 覆盖 Gemini 配置目录(可选) geminiConfigDir?: string; + // 覆盖 OpenCode 配置目录(可选) + opencodeConfigDir?: string; // ===== 当前供应商 ID(设备级)===== // 当前 Claude 供应商 ID(优先于数据库 is_current) @@ -156,6 +158,7 @@ export interface McpApps { claude: boolean; codex: boolean; gemini: boolean; + opencode: boolean; } // MCP 服务器条目(v3.7.0 统一结构) @@ -247,3 +250,48 @@ export interface UniversalProvider { // 统一供应商映射(id -> UniversalProvider) export type UniversalProvidersMap = Record; + +// ============================================================================ +// OpenCode 专属配置(v3.9.2+) +// ============================================================================ + +// OpenCode 模型配置 +export interface OpenCodeModel { + name: string; + limit?: { + context?: number; + output?: number; + }; + options?: Record; // 模型级别额外选项(provider 路由等) +} + +// OpenCode 供应商选项 +export interface OpenCodeProviderOptions { + baseURL?: string; + apiKey?: string; + headers?: Record; + // 支持额外选项(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; +} + +// OpenCode MCP 服务器配置(与统一格式不同) +export interface OpenCodeMcpServerSpec { + type: "local" | "remote"; + // local 类型字段 + command?: string[]; // 与统一格式不同:命令和参数合并为数组 + environment?: Record; // 与统一格式不同:使用 environment 而非 env + // remote 类型字段 + url?: string; + headers?: Record; + // 通用字段 + enabled?: boolean; +} + diff --git a/src/types/proxy.ts b/src/types/proxy.ts index 4d11370ad..705c2288c 100644 --- a/src/types/proxy.ts +++ b/src/types/proxy.ts @@ -45,6 +45,7 @@ export interface ProxyTakeoverStatus { claude: boolean; codex: boolean; gemini: boolean; + opencode: boolean; } export interface ProviderHealth { diff --git a/tests/components/McpFormModal.test.tsx b/tests/components/McpFormModal.test.tsx index d09b7bd88..c70951186 100644 --- a/tests/components/McpFormModal.test.tsx +++ b/tests/components/McpFormModal.test.tsx @@ -432,6 +432,7 @@ type = "stdio" claude: false, codex: false, gemini: false, + opencode: false, }); expect(onSave).toHaveBeenCalledTimes(1); expect(toastErrorMock).not.toHaveBeenCalled(); diff --git a/tests/msw/state.ts b/tests/msw/state.ts index b5922a3ab..ac385a675 100644 --- a/tests/msw/state.ts +++ b/tests/msw/state.ts @@ -57,12 +57,14 @@ const createDefaultProviders = (): ProvidersByApp => ({ createdAt: Date.now(), }, }, + opencode: {}, }); const createDefaultCurrent = (): CurrentProviderState => ({ claude: "claude-1", codex: "codex-1", gemini: "gemini-1", + opencode: "", }); let providers = createDefaultProviders(); @@ -82,7 +84,7 @@ let mcpConfigs: McpConfigState = { id: "sample", name: "Sample Claude Server", enabled: true, - apps: { claude: true, codex: false, gemini: false }, + apps: { claude: true, codex: false, gemini: false, opencode: false }, server: { type: "stdio", command: "claude-server", @@ -94,7 +96,7 @@ let mcpConfigs: McpConfigState = { id: "httpServer", name: "HTTP Codex Server", enabled: false, - apps: { claude: false, codex: true, gemini: false }, + apps: { claude: false, codex: true, gemini: false, opencode: false }, server: { type: "http", url: "http://localhost:3000", @@ -102,6 +104,7 @@ let mcpConfigs: McpConfigState = { }, }, gemini: {}, + opencode: {}, }; const cloneProviders = (value: ProvidersByApp) => @@ -125,7 +128,7 @@ export const resetProviderState = () => { id: "sample", name: "Sample Claude Server", enabled: true, - apps: { claude: true, codex: false, gemini: false }, + apps: { claude: true, codex: false, gemini: false, opencode: false }, server: { type: "stdio", command: "claude-server", @@ -137,7 +140,7 @@ export const resetProviderState = () => { id: "httpServer", name: "HTTP Codex Server", enabled: false, - apps: { claude: false, codex: true, gemini: false }, + apps: { claude: false, codex: true, gemini: false, opencode: false }, server: { type: "http", url: "http://localhost:3000", @@ -145,6 +148,7 @@ export const resetProviderState = () => { }, }, gemini: {}, + opencode: {}, }; };