From e4d24f2df967ae508e511e37d1a3c7ca5bae019b Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 15 Jan 2026 15:31:38 +0800 Subject: [PATCH 01/43] docs: add OpenCode implementation plan Add comprehensive implementation plan for OpenCode (4th app) support: - Additive provider management (vs. replacement model) - MCP format conversion (stdio<->local, sse<->remote) - Config file: ~/.config/opencode/opencode.json - No proxy/failover support (OpenCode handles internally) --- docs/opencode-implementation-plan.md | 485 +++++++++++++++++++++++++++ 1 file changed, 485 insertions(+) create mode 100644 docs/opencode-implementation-plan.md 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` - 自定义配置目录 From 5658d93924a368fec2cd49c11837d4cee08c3f30 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 15 Jan 2026 15:54:29 +0800 Subject: [PATCH 02/43] feat(opencode): Phase 1 - Backend data structure expansion for OpenCode support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add OpenCode as the 4th supported application with additive provider management: - Add OpenCode variant to AppType enum with all related match statements - Add enabled_opencode field to McpApps and SkillApps structures - Add opencode field to McpRoot and PromptRoot - Add database schema migration v3→v4 with enabled_opencode columns - Add settings.rs support for opencode_config_dir and current_provider_opencode - Create opencode_config.rs module for config file I/O operations - Update all services (proxy, mcp, skill, provider, stream_check) for OpenCode - Add OpenCode support to deeplink provider and MCP parsing - Update commands/config.rs for OpenCode config status and paths Key design decisions: - OpenCode uses additive mode (no is_current needed, no proxy support) - Config path: ~/.config/opencode/opencode.json - MCP format: stdio→local, sse/http→remote conversion planned - Stream check returns error (not yet implemented for OpenCode) --- src-tauri/src/app_config.rs | 53 ++++++-- src-tauri/src/commands/config.rs | 11 ++ src-tauri/src/database/dao/mcp.rs | 9 +- src-tauri/src/database/dao/skills.rs | 21 ++-- src-tauri/src/database/mod.rs | 2 +- src-tauri/src/database/schema.rs | 32 ++++- src-tauri/src/deeplink/mcp.rs | 2 + src-tauri/src/deeplink/provider.rs | 28 +++++ src-tauri/src/lib.rs | 1 + src-tauri/src/mcp/claude.rs | 1 + src-tauri/src/mcp/codex.rs | 1 + src-tauri/src/mcp/gemini.rs | 1 + src-tauri/src/opencode_config.rs | 154 ++++++++++++++++++++++++ src-tauri/src/prompt_files.rs | 3 + src-tauri/src/proxy/providers/mod.rs | 8 ++ src-tauri/src/services/config.rs | 4 + src-tauri/src/services/mcp.rs | 9 ++ src-tauri/src/services/provider/live.rs | 38 ++++++ src-tauri/src/services/provider/mod.rs | 70 +++++++++++ src-tauri/src/services/proxy.rs | 33 +++++ src-tauri/src/services/skill.rs | 8 ++ src-tauri/src/services/stream_check.rs | 23 ++++ src-tauri/src/settings.rs | 24 ++++ 23 files changed, 513 insertions(+), 23 deletions(-) create mode 100644 src-tauri/src/opencode_config.rs diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index 513ae5606..ea8cd076b 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,7 @@ 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 +656,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 +690,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/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..d39c83c4a 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; 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 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 配置文件 +/// +/// 返回完整的配置 JSON 对象 +pub fn read_opencode_config() -> Result { + let path = get_opencode_config_path(); + + if !path.exists() { + // Return empty config with schema + return Ok(serde_json::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: &serde_json::Value) -> Result<(), AppError> { + let path = get_opencode_config_path(); + + // Ensure directory exists + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + + // Write to temporary file first + let temp_path = path.with_extension("json.tmp"); + let content = serde_json::to_string_pretty(config) + .map_err(|e| AppError::JsonSerialize { source: e })?; + + std::fs::write(&temp_path, &content).map_err(|e| AppError::io(&temp_path, e))?; + + // Atomic rename + std::fs::rename(&temp_path, &path).map_err(|e| AppError::io(&path, e))?; + + Ok(()) +} + +/// 获取所有供应商配置 +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()) +} + +/// 设置供应商配置 +pub fn set_provider(id: &str, config: serde_json::Value) -> Result<(), AppError> { + let mut full_config = read_opencode_config()?; + + if full_config.get("provider").is_none() { + full_config["provider"] = serde_json::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) +} + +/// 获取所有 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: serde_json::Value) -> Result<(), AppError> { + let mut full_config = read_opencode_config()?; + + if full_config.get("mcp").is_none() { + full_config["mcp"] = serde_json::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..dbcb58969 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 => "OPENCODE.md", }; Ok(base_dir.join(filename)) 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..81decbac7 100644 --- a/src-tauri/src/services/mcp.rs +++ b/src-tauri/src/services/mcp.rs @@ -113,6 +113,11 @@ impl McpService { AppType::Gemini => { mcp::sync_single_server_to_gemini(&Default::default(), &server.id, &server.server)?; } + AppType::OpenCode => { + // OpenCode MCP sync will be implemented in Phase 4 + // For now, skip silently + log::debug!("OpenCode MCP sync not yet implemented, skipping"); + } } Ok(()) } @@ -135,6 +140,10 @@ 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 => { + // OpenCode MCP removal will be implemented in Phase 4 + log::debug!("OpenCode MCP removal not yet implemented, skipping"); + } } Ok(()) } diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 9854f2243..7599b0ab7 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -120,6 +120,11 @@ 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 - providers are written directly to config + // This will be fully implemented in Phase 3 + log::debug!("OpenCode live snapshot not needed (additive mode)"); + } } Ok(()) } @@ -220,6 +225,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 +315,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( diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 7b45b4e6b..0c48ed437 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -380,6 +380,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 +393,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 +527,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 +716,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 +864,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..8ea844db2 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"), }) } @@ -425,6 +431,7 @@ impl SkillService { AppType::Claude => "claude", AppType::Codex => "codex", AppType::Gemini => "gemini", + AppType::OpenCode => "opencode", }; unmanaged @@ -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()); } diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index 900b45698..33877fc1d 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) From 58ecc44ee69b2771151fef4c38f46804929dfc23 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 15 Jan 2026 15:59:44 +0800 Subject: [PATCH 03/43] feat(opencode): Phase 2 - Add OpenCode provider data structures Add OpenCode-specific configuration structures for the AI SDK format: - OpenCodeProviderConfig: Main config with npm package, options, and models - OpenCodeProviderOptions: baseURL, apiKey, and headers support - OpenCodeModel: Model definition with name and token limits - OpenCodeModelLimit: Context and output token limits Key features: - Supports environment variable references (e.g., "{env:API_KEY}") - Custom headers support for specialized providers - Model-level token limits for context management - Helper methods for parsing and validation --- src-tauri/src/provider.rs | 110 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 46a6c6f92..171d6d3c3 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -462,3 +462,113 @@ 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>, +} + +/// OpenCode 模型定义 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpenCodeModel { + /// 模型显示名称 + pub name: String, + + /// 模型限制(上下文和输出 token 数) + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: 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, +} + +impl OpenCodeProviderConfig { + /// 从 serde_json::Value 解析 + pub fn from_value(value: &Value) -> Result { + serde_json::from_value(value.clone()) + } + + /// 转换为 serde_json::Value + pub fn to_value(&self) -> Result { + serde_json::to_value(self) + } + + /// 获取第一个模型 ID(用于默认模型选择) + pub fn first_model_id(&self) -> Option { + self.models.keys().next().cloned() + } + + /// 检查是否有有效的 API 密钥配置 + pub fn has_api_key(&self) -> bool { + self.options + .api_key + .as_ref() + .map(|k| !k.trim().is_empty()) + .unwrap_or(false) + } +} + From a30d72bb68cc15c226687160ed80964a1557114a Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 15 Jan 2026 16:07:14 +0800 Subject: [PATCH 04/43] feat(opencode): complete Phase 3 - enhanced config read/write module Add typed provider functions and utilities to opencode_config.rs: - Typed provider operations (get_typed_providers, get_typed_provider, set_typed_provider, set_providers_batch) - MCP batch operations (set_mcp_servers_batch, clear_mcp_servers) - Utility functions (create_provider_config, validate_provider_config, provider_to_opencode_config, opencode_config_to_provider) - Enhanced module documentation with config file format examples The typed API layer provides type-safe access to OpenCode provider configurations while the untyped layer allows raw JSON operations when needed. --- src-tauri/src/opencode_config.rs | 232 +++++++++++++++++++++++++++++-- 1 file changed, 219 insertions(+), 13 deletions(-) diff --git a/src-tauri/src/opencode_config.rs b/src-tauri/src/opencode_config.rs index bec24b46c..8e6133791 100644 --- a/src-tauri/src/opencode_config.rs +++ b/src-tauri/src/opencode_config.rs @@ -2,11 +2,37 @@ //! //! 处理 `~/.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::error::AppError; +use crate::provider::{OpenCodeModel, OpenCodeProviderConfig, OpenCodeProviderOptions}; use crate::settings::get_opencode_override_dir; +use indexmap::IndexMap; +use serde_json::{json, Map, Value}; +use std::collections::HashMap; use std::path::PathBuf; +// ============================================================================ +// Path Functions +// ============================================================================ + /// 获取 OpenCode 配置目录 /// /// 默认路径: `~/.config/opencode/` @@ -40,15 +66,27 @@ 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 { +pub fn read_opencode_config() -> Result { let path = get_opencode_config_path(); if !path.exists() { // Return empty config with schema - return Ok(serde_json::json!({ + return Ok(json!({ "$schema": "https://opencode.ai/config.json" })); } @@ -60,7 +98,7 @@ pub fn read_opencode_config() -> Result { /// 写入 OpenCode 配置文件(原子写入) /// /// 使用临时文件 + 重命名确保原子性 -pub fn write_opencode_config(config: &serde_json::Value) -> Result<(), AppError> { +pub fn write_opencode_config(config: &Value) -> Result<(), AppError> { let path = get_opencode_config_path(); // Ensure directory exists @@ -70,19 +108,29 @@ pub fn write_opencode_config(config: &serde_json::Value) -> Result<(), AppError> // Write to temporary file first let temp_path = path.with_extension("json.tmp"); - let content = serde_json::to_string_pretty(config) - .map_err(|e| AppError::JsonSerialize { source: e })?; + let content = + serde_json::to_string_pretty(config).map_err(|e| AppError::JsonSerialize { source: e })?; std::fs::write(&temp_path, &content).map_err(|e| AppError::io(&temp_path, e))?; // Atomic rename std::fs::rename(&temp_path, &path).map_err(|e| AppError::io(&path, e))?; + log::debug!("OpenCode config written to {:?}", path); Ok(()) } -/// 获取所有供应商配置 -pub fn get_providers() -> Result, AppError> { +/// 检查 OpenCode 配置文件是否存在 +pub fn config_exists() -> bool { + get_opencode_config_path().exists() +} + +// ============================================================================ +// Provider Functions (Untyped - for raw JSON operations) +// ============================================================================ + +/// 获取所有供应商配置(原始 JSON) +pub fn get_providers() -> Result, AppError> { let config = read_opencode_config()?; Ok(config .get("provider") @@ -91,12 +139,12 @@ pub fn get_providers() -> Result, App .unwrap_or_default()) } -/// 设置供应商配置 -pub fn set_provider(id: &str, config: serde_json::Value) -> Result<(), AppError> { +/// 设置供应商配置(原始 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"] = serde_json::json!({}); + full_config["provider"] = json!({}); } if let Some(providers) = full_config.get_mut("provider").and_then(|v| v.as_object_mut()) { @@ -117,8 +165,74 @@ pub fn remove_provider(id: &str) -> Result<(), AppError> { 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 get_typed_provider(id: &str) -> Result, AppError> { + let providers = get_providers()?; + + match providers.get(id) { + Some(value) => { + let config = serde_json::from_value::(value.clone()) + .map_err(|e| AppError::JsonSerialize { source: e })?; + Ok(Some(config)) + } + None => Ok(None), + } +} + +/// 设置供应商配置(类型化) +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) +} + +/// 批量设置供应商配置 +pub fn set_providers_batch( + providers: &IndexMap, +) -> Result<(), AppError> { + let mut full_config = read_opencode_config()?; + + let mut provider_map = Map::new(); + for (id, config) in providers { + let value = + serde_json::to_value(config).map_err(|e| AppError::JsonSerialize { source: e })?; + provider_map.insert(id.clone(), value); + } + + full_config["provider"] = Value::Object(provider_map); + write_opencode_config(&full_config) +} + +// ============================================================================ +// MCP Functions +// ============================================================================ + /// 获取所有 MCP 服务器配置 -pub fn get_mcp_servers() -> Result, AppError> { +pub fn get_mcp_servers() -> Result, AppError> { let config = read_opencode_config()?; Ok(config .get("mcp") @@ -128,11 +242,11 @@ pub fn get_mcp_servers() -> Result, A } /// 设置 MCP 服务器配置 -pub fn set_mcp_server(id: &str, config: serde_json::Value) -> Result<(), AppError> { +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"] = serde_json::json!({}); + full_config["mcp"] = json!({}); } if let Some(mcp) = full_config.get_mut("mcp").and_then(|v| v.as_object_mut()) { @@ -152,3 +266,95 @@ pub fn remove_mcp_server(id: &str) -> Result<(), AppError> { write_opencode_config(&config) } + +/// 批量设置 MCP 服务器配置 +pub fn set_mcp_servers_batch(servers: &Map) -> Result<(), AppError> { + let mut full_config = read_opencode_config()?; + full_config["mcp"] = Value::Object(servers.clone()); + write_opencode_config(&full_config) +} + +/// 清空所有 MCP 服务器配置 +pub fn clear_mcp_servers() -> Result<(), AppError> { + let mut config = read_opencode_config()?; + config["mcp"] = json!({}); + write_opencode_config(&config) +} + +// ============================================================================ +// Utility Functions +// ============================================================================ + +/// 创建新的供应商配置 +/// +/// 便捷方法,用于创建 OpenAI 兼容的供应商配置 +pub fn create_provider_config( + npm_package: &str, + base_url: Option<&str>, + api_key: Option<&str>, + models: HashMap, +) -> OpenCodeProviderConfig { + let options = OpenCodeProviderOptions { + base_url: base_url.map(|s| s.to_string()), + api_key: api_key.map(|s| s.to_string()), + headers: None, + }; + + let model_map: HashMap = models + .into_iter() + .map(|(id, name)| { + ( + id, + OpenCodeModel { + name, + limit: None, + }, + ) + }) + .collect(); + + OpenCodeProviderConfig { + npm: npm_package.to_string(), + name: None, + options, + models: model_map, + } +} + +/// 验证供应商配置 +pub fn validate_provider_config(config: &OpenCodeProviderConfig) -> Result<(), AppError> { + // npm package must not be empty + if config.npm.trim().is_empty() { + return Err(AppError::localized( + "opencode.provider.npm.empty", + "npm 包名不能为空", + "npm package name cannot be empty", + )); + } + + // npm package should start with @ or be a valid package name + if !config.npm.starts_with('@') && !config.npm.chars().all(|c| c.is_alphanumeric() || c == '-') + { + log::warn!( + "Unusual npm package name: {}. Expected format like '@ai-sdk/openai'", + config.npm + ); + } + + Ok(()) +} + +/// 从通用 Provider 转换为 OpenCode 配置 +/// +/// 用于从数据库 Provider 结构转换为 OpenCode 配置格式 +pub fn provider_to_opencode_config( + settings_config: &Value, +) -> Result { + serde_json::from_value(settings_config.clone()).map_err(|e| AppError::JsonSerialize { source: e }) +} + +/// 将 OpenCode 配置转换为通用 Provider settings_config +pub fn opencode_config_to_provider(config: &OpenCodeProviderConfig) -> Result { + serde_json::to_value(config).map_err(|e| AppError::JsonSerialize { source: e }) +} + From 7ea2c3452b43e99286b2f89ac58147756a2538ff Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 15 Jan 2026 16:11:25 +0800 Subject: [PATCH 05/43] feat(opencode): complete Phase 4 - MCP sync module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add mcp/opencode.rs with format conversion between CC Switch and OpenCode: - stdio ↔ local type conversion - command+args ↔ command array format - env ↔ environment field mapping - sse/http ↔ remote type conversion Public API: - sync_enabled_to_opencode: Batch sync all enabled servers - sync_single_server_to_opencode: Sync individual server - remove_server_from_opencode: Remove from live config - import_from_opencode: Import servers from OpenCode config Also fix test files to include new opencode field in McpApps struct. All 4 unit tests pass for format conversion. --- src-tauri/src/mcp/mod.rs | 6 + src-tauri/src/mcp/opencode.rs | 412 ++++++++++++++++++++++++++ src-tauri/tests/import_export_sync.rs | 2 + src-tauri/tests/mcp_commands.rs | 6 + src-tauri/tests/provider_commands.rs | 1 + src-tauri/tests/provider_service.rs | 1 + 6 files changed, 428 insertions(+) create mode 100644 src-tauri/src/mcp/opencode.rs diff --git a/src-tauri/src/mcp/mod.rs b/src-tauri/src/mcp/mod.rs index 55fee60da..1081a2800 100644 --- a/src-tauri/src/mcp/mod.rs +++ b/src-tauri/src/mcp/mod.rs @@ -8,10 +8,12 @@ //! - `claude` - Claude MCP 同步和导入 //! - `codex` - Codex MCP 同步和导入(含 TOML 转换) //! - `gemini` - Gemini MCP 同步和导入 +//! - `opencode` - OpenCode MCP 同步和导入(含 local/remote 格式转换) mod claude; mod codex; mod gemini; +mod opencode; mod validation; // 重新导出公共 API @@ -26,3 +28,7 @@ pub use gemini::{ import_from_gemini, remove_server_from_gemini, sync_enabled_to_gemini, sync_single_server_to_gemini, }; +pub use opencode::{ + import_from_opencode, remove_server_from_opencode, sync_enabled_to_opencode, + sync_single_server_to_opencode, +}; diff --git a/src-tauri/src/mcp/opencode.rs b/src-tauri/src/mcp/opencode.rs new file mode 100644 index 000000000..45a8c42d6 --- /dev/null +++ b/src-tauri/src/mcp/opencode.rs @@ -0,0 +1,412 @@ +//! OpenCode MCP 同步和导入模块 +//! +//! 本模块处理 CC Switch 统一 MCP 格式与 OpenCode 格式之间的转换。 +//! +//! ## 格式差异 +//! +//! | CC Switch 统一格式 | OpenCode 格式 | +//! |----------------------|---------------------| +//! | `type: "stdio"` | `type: "local"` | +//! | `command` + `args` | `command: [cmd, ...args]` | +//! | `env` | `environment` | +//! | `type: "sse"/"http"` | `type: "remote"` | +//! | `url` | `url` | + +use serde_json::{json, Value}; +use std::collections::HashMap; + +use crate::app_config::{McpApps, McpConfig, McpServer, MultiAppConfig}; +use crate::error::AppError; +use crate::opencode_config; + +use super::validation::{extract_server_spec, validate_server_spec}; + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/// Check if OpenCode MCP sync should proceed +fn should_sync_opencode_mcp() -> bool { + // Skip if OpenCode config directory doesn't exist + opencode_config::get_opencode_dir().exists() +} + +/// Collect enabled MCP servers for OpenCode +fn collect_enabled_servers(cfg: &McpConfig) -> HashMap { + let mut out = HashMap::new(); + for (id, entry) in cfg.servers.iter() { + let enabled = entry + .get("enabled") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if !enabled { + continue; + } + match extract_server_spec(entry) { + Ok(spec) => { + out.insert(id.clone(), spec); + } + Err(err) => { + log::warn!("Skip invalid MCP entry '{}': {}", id, err); + } + } + } + out +} + +// ============================================================================ +// 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 all enabled_opencode=true servers to OpenCode config +pub fn sync_enabled_to_opencode(config: &MultiAppConfig) -> Result<(), AppError> { + if !should_sync_opencode_mcp() { + return Ok(()); + } + + let enabled = collect_enabled_servers(&config.mcp.opencode); + + // Convert all servers to OpenCode format + let mut opencode_servers = serde_json::Map::new(); + for (id, spec) in enabled { + match convert_to_opencode_format(&spec) { + Ok(opencode_spec) => { + opencode_servers.insert(id, opencode_spec); + } + Err(e) => { + log::warn!("Skip converting MCP server to OpenCode format: {}", e); + } + } + } + + // Write to OpenCode config + opencode_config::set_mcp_servers_batch(&opencode_servers) +} + +/// 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/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, From b8538b211d98a00075fe509a8878cd831ba8f6e7 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 15 Jan 2026 16:15:01 +0800 Subject: [PATCH 06/43] feat(opencode): complete Phase 5 - provider service layer Implement OpenCode-specific provider service logic with additive mode: - add(): Always write to live config (no is_current check needed) - update(): Always sync changes to live config - delete(): Remove from both DB and live config (no is_current check) New helper functions in live.rs: - write_live_snapshot(): Write provider to opencode.json provider section - remove_opencode_provider_from_live(): Remove provider from live config - import_opencode_providers_from_live(): Import existing providers from ~/.config/opencode/opencode.json into CC Switch database Key design: OpenCode uses additive mode where all providers coexist in the config file, unlike Claude/Codex/Gemini which use replacement mode with a single active provider. --- src-tauri/src/services/provider/live.rs | 107 +++++++++++++++++++++++- src-tauri/src/services/provider/mod.rs | 41 +++++++-- 2 files changed, 137 insertions(+), 11 deletions(-) diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 7599b0ab7..0030c12a9 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -121,9 +121,32 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re write_gemini_live(provider)?; } AppType::OpenCode => { - // OpenCode uses additive mode - providers are written directly to config - // This will be fully implemented in Phase 3 - log::debug!("OpenCode live snapshot not needed (additive mode)"); + // OpenCode uses additive mode - write provider to config + use crate::opencode_config; + use crate::provider::OpenCodeProviderConfig; + + // Convert settings_config to OpenCodeProviderConfig + let opencode_config_result = + serde_json::from_value::(provider.settings_config.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 + ); + // Still try to write as raw JSON + opencode_config::set_provider(&provider.id, provider.settings_config.clone())?; + } + } } } Ok(()) @@ -437,3 +460,81 @@ 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 0c48ed437..a749e7700 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 @@ -152,7 +155,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 +185,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 +231,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())?; From 7997b2c7b3e2a3e1c9e1bebd052d975306c496fb Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 15 Jan 2026 16:20:03 +0800 Subject: [PATCH 07/43] feat(opencode): Phase 6 - Tauri command extensions for OpenCode - Add import_opencode_providers_from_live command to provider.rs - Register new command in lib.rs invoke_handler - Update commands/mcp.rs: include OpenCode in sync_other_side logic - Add McpService::import_from_opencode to import_mcp_from_apps - Implement MCP sync/remove for OpenCode in services/mcp.rs - sync_server_to_app_no_config now calls sync_single_server_to_opencode - remove_server_from_app now calls remove_server_from_opencode --- src-tauri/src/commands/mcp.rs | 2 ++ src-tauri/src/commands/provider.rs | 14 ++++++++++ src-tauri/src/lib.rs | 2 ++ src-tauri/src/services/mcp.rs | 45 ++++++++++++++++++++++++++---- 4 files changed, 58 insertions(+), 5 deletions(-) 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 { + crate::services::provider::import_opencode_providers_from_live(state.inner()) + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d39c83c4a..ceab5e326 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -875,6 +875,8 @@ pub fn run() { commands::upsert_universal_provider, commands::delete_universal_provider, commands::sync_universal_provider, + // OpenCode specific + commands::import_opencode_providers_from_live, // Global upstream proxy commands::get_global_proxy_url, commands::set_global_proxy_url, diff --git a/src-tauri/src/services/mcp.rs b/src-tauri/src/services/mcp.rs index 81decbac7..602765d2f 100644 --- a/src-tauri/src/services/mcp.rs +++ b/src-tauri/src/services/mcp.rs @@ -114,9 +114,7 @@ impl McpService { mcp::sync_single_server_to_gemini(&Default::default(), &server.id, &server.server)?; } AppType::OpenCode => { - // OpenCode MCP sync will be implemented in Phase 4 - // For now, skip silently - log::debug!("OpenCode MCP sync not yet implemented, skipping"); + mcp::sync_single_server_to_opencode(&Default::default(), &server.id, &server.server)?; } } Ok(()) @@ -141,8 +139,7 @@ impl McpService { AppType::Codex => mcp::remove_server_from_codex(id)?, AppType::Gemini => mcp::remove_server_from_gemini(id)?, AppType::OpenCode => { - // OpenCode MCP removal will be implemented in Phase 4 - log::debug!("OpenCode MCP removal not yet implemented, skipping"); + mcp::remove_server_from_opencode(id)?; } } Ok(()) @@ -320,4 +317,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) + } } From 21754a73499c8c2eaf3f5c97f5c531e6a8f35752 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 15 Jan 2026 16:25:14 +0800 Subject: [PATCH 08/43] feat(opencode): Phase 7 - Frontend TypeScript type definitions - Add "opencode" to AppId type in lib/api/types.ts - Extend McpApps interface with opencode field in types.ts - Add OpenCode-specific types: OpenCodeModel, OpenCodeProviderOptions, OpenCodeProviderConfig, OpenCodeMcpServerSpec - Add opencodeConfigDir to Settings interface - Add importOpenCodeFromLive() to providersApi - Fix type errors across components: - AppSwitcher: add opencode to icon/name mappings - McpFormModal: add opencode to enabledApps state - PromptFormModal/Panel: add opencode filename mapping - EndpointSpeedTest: add opencode timeout config - useBaseUrlState: add opencode to appType union - ProxyToggle: add opencode label - App.tsx: handle opencode fallback for SkillsPage - Update ProxyTakeoverStatus with opencode field (always false) - Fix test mocks in tests/msw/state.ts --- src/App.tsx | 2 +- src/components/AppSwitcher.tsx | 2 + src/components/mcp/McpFormModal.tsx | 2 + src/components/prompts/PromptFormModal.tsx | 1 + src/components/prompts/PromptFormPanel.tsx | 1 + .../providers/forms/EndpointSpeedTest.tsx | 7 +-- .../providers/forms/hooks/useBaseUrlState.ts | 2 +- src/components/proxy/ProxyToggle.tsx | 4 +- src/lib/api/providers.ts | 8 ++++ src/lib/api/types.ts | 2 +- src/types.ts | 45 +++++++++++++++++++ src/types/proxy.ts | 1 + tests/msw/state.ts | 12 +++-- 13 files changed, 78 insertions(+), 11 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 452afed56..32673da69 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -454,7 +454,7 @@ function App() { /> ); case "skillsDiscovery": - return ; + return ; case "mcp": return ( = { claude: "Claude", codex: "Codex", gemini: "Gemini", + opencode: "OpenCode", }; return ( 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/prompts/PromptFormModal.tsx b/src/components/prompts/PromptFormModal.tsx index 194df6868..8eb043e7a 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: "OPENCODE.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..7f2f884cb 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: "OPENCODE.md", }; const filename = filenameMap[appId]; const [name, setName] = useState(""); diff --git a/src/components/providers/forms/EndpointSpeedTest.tsx b/src/components/providers/forms/EndpointSpeedTest.tsx index 011116dd4..5c2172512 100644 --- a/src/components/providers/forms/EndpointSpeedTest.tsx +++ b/src/components/providers/forms/EndpointSpeedTest.tsx @@ -9,11 +9,12 @@ import { FullScreenPanel } from "@/components/common/FullScreenPanel"; import type { CustomEndpoint, EndpointCandidate } from "@/types"; // 端点测速超时配置(秒) -const ENDPOINT_TIMEOUT_SECS = { +const ENDPOINT_TIMEOUT_SECS: Record = { codex: 12, claude: 8, - gemini: 8, // 新增 gemini -} as const; + gemini: 8, + opencode: 8, +}; interface TestResult { url: string; 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/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/lib/api/providers.ts b/src/lib/api/providers.ts index eb3dc9daf..3e9d371b9 100644 --- a/src/lib/api/providers.ts +++ b/src/lib/api/providers.ts @@ -74,6 +74,14 @@ 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"); + }, }; // ============================================================================ 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/types.ts b/src/types.ts index a9234d0d9..5980eaedd 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,45 @@ export interface UniversalProvider { // 统一供应商映射(id -> UniversalProvider) export type UniversalProvidersMap = Record; + +// ============================================================================ +// OpenCode 专属配置(v3.9.2+) +// ============================================================================ + +// OpenCode 模型配置 +export interface OpenCodeModel { + name: string; + limit?: { + context?: number; + output?: number; + }; +} + +// OpenCode 供应商选项 +export interface OpenCodeProviderOptions { + baseURL?: string; + apiKey?: string; + headers?: Record; +} + +// 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/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: {}, }; }; From 093ff0ba299fd936122a603c23d5be1cd51464d5 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 15 Jan 2026 16:26:46 +0800 Subject: [PATCH 09/43] feat(opencode): Phase 8 - OpenCode provider presets configuration - Create src/config/opencodeProviderPresets.ts with: - OpenCodeProviderPreset interface for preset structure - opencodeNpmPackages: AI SDK npm package options - Provider presets: OpenAI, Anthropic, Google, DeepSeek, Mistral, Groq - OpenAI Compatible custom template for third-party providers - Each preset uses OpenCodeProviderConfig with npm, options, models structure - Includes apiKeyUrl for quick access to API key pages - Template values for dynamic API key input --- src/config/opencodeProviderPresets.ts | 245 ++++++++++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 src/config/opencodeProviderPresets.ts diff --git a/src/config/opencodeProviderPresets.ts b/src/config/opencodeProviderPresets.ts new file mode 100644 index 000000000..0e1ac65b7 --- /dev/null +++ b/src/config/opencodeProviderPresets.ts @@ -0,0 +1,245 @@ +/** + * 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/anthropic", label: "Anthropic" }, + { value: "@ai-sdk/openai-compatible", label: "OpenAI Compatible" }, + { value: "@ai-sdk/google", label: "Google (Gemini)" }, + { value: "@ai-sdk/azure", label: "Azure OpenAI" }, + { value: "@ai-sdk/amazon-bedrock", label: "Amazon Bedrock" }, + { value: "@ai-sdk/cohere", label: "Cohere" }, + { value: "@ai-sdk/mistral", label: "Mistral" }, + { value: "@ai-sdk/groq", label: "Groq" }, + { value: "@ai-sdk/deepseek", label: "DeepSeek" }, +] as const; + +/** + * OpenCode 供应商预设列表 + */ +export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ + // ========== 官方供应商 ========== + { + name: "OpenAI", + websiteUrl: "https://platform.openai.com", + apiKeyUrl: "https://platform.openai.com/api-keys", + settingsConfig: { + npm: "@ai-sdk/openai", + options: { + apiKey: "{env:OPENAI_API_KEY}", + }, + models: { + "gpt-4o": { name: "GPT-4o" }, + "gpt-4o-mini": { name: "GPT-4o Mini" }, + "o1": { name: "o1" }, + "o1-mini": { name: "o1 Mini" }, + "o3-mini": { name: "o3 Mini" }, + }, + }, + isOfficial: true, + category: "official", + icon: "openai", + iconColor: "#00A67E", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "sk-...", + editorValue: "", + }, + }, + }, + { + name: "Anthropic", + websiteUrl: "https://console.anthropic.com", + apiKeyUrl: "https://console.anthropic.com/settings/keys", + settingsConfig: { + npm: "@ai-sdk/anthropic", + options: { + apiKey: "{env:ANTHROPIC_API_KEY}", + }, + models: { + "claude-sonnet-4-20250514": { name: "Claude Sonnet 4" }, + "claude-3-5-sonnet-20241022": { name: "Claude 3.5 Sonnet" }, + "claude-3-5-haiku-20241022": { name: "Claude 3.5 Haiku" }, + "claude-3-opus-20240229": { name: "Claude 3 Opus" }, + }, + }, + isOfficial: true, + category: "official", + icon: "anthropic", + iconColor: "#D4915D", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "sk-ant-...", + editorValue: "", + }, + }, + }, + { + name: "Google (Gemini)", + websiteUrl: "https://ai.google.dev", + apiKeyUrl: "https://aistudio.google.com/app/apikey", + settingsConfig: { + npm: "@ai-sdk/google", + options: { + apiKey: "{env:GOOGLE_GENERATIVE_AI_API_KEY}", + }, + models: { + "gemini-2.0-flash": { name: "Gemini 2.0 Flash" }, + "gemini-2.0-flash-lite": { name: "Gemini 2.0 Flash Lite" }, + "gemini-1.5-pro": { name: "Gemini 1.5 Pro" }, + "gemini-1.5-flash": { name: "Gemini 1.5 Flash" }, + }, + }, + isOfficial: true, + category: "official", + icon: "gemini", + iconColor: "#4285F4", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "AIza...", + editorValue: "", + }, + }, + }, + + // ========== 国产官方 ========== + { + name: "DeepSeek", + websiteUrl: "https://platform.deepseek.com", + apiKeyUrl: "https://platform.deepseek.com/api_keys", + settingsConfig: { + npm: "@ai-sdk/deepseek", + options: { + apiKey: "{env:DEEPSEEK_API_KEY}", + }, + models: { + "deepseek-chat": { name: "DeepSeek V3" }, + "deepseek-reasoner": { name: "DeepSeek R1" }, + }, + }, + category: "cn_official", + icon: "deepseek", + iconColor: "#1E88E5", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "sk-...", + editorValue: "", + }, + }, + }, + { + name: "Mistral", + websiteUrl: "https://mistral.ai", + apiKeyUrl: "https://console.mistral.ai/api-keys", + settingsConfig: { + npm: "@ai-sdk/mistral", + options: { + apiKey: "{env:MISTRAL_API_KEY}", + }, + models: { + "mistral-large-latest": { name: "Mistral Large" }, + "mistral-small-latest": { name: "Mistral Small" }, + "codestral-latest": { name: "Codestral" }, + }, + }, + category: "official", + icon: "mistral", + iconColor: "#FF7000", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "", + editorValue: "", + }, + }, + }, + { + name: "Groq", + websiteUrl: "https://groq.com", + apiKeyUrl: "https://console.groq.com/keys", + settingsConfig: { + npm: "@ai-sdk/groq", + options: { + apiKey: "{env:GROQ_API_KEY}", + }, + models: { + "llama-3.3-70b-versatile": { name: "Llama 3.3 70B" }, + "llama-3.1-8b-instant": { name: "Llama 3.1 8B" }, + "mixtral-8x7b-32768": { name: "Mixtral 8x7B" }, + }, + }, + category: "official", + icon: "groq", + iconColor: "#F55036", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "gsk_...", + 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: "", + }, + }, + }, +]; From 864884926aec06b13c3d2f54ef53e3b677f037fb Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 15 Jan 2026 16:38:26 +0800 Subject: [PATCH 10/43] feat(opencode): Phase 9 - Frontend UI components for OpenCode - Create OpenCodeFormFields.tsx with: - NPM package selector (from AI SDK ecosystem) - API Key input using shared ApiKeySection component - Base URL input (shown for openai-compatible) - Dynamic models editor (add/remove models) - Update ProviderForm.tsx: - Import OpenCode presets and form fields - Add OPENCODE_DEFAULT_CONFIG constant - Add OpenCode to PresetEntry type union - Add OpenCode preset entries in useMemo - Add OpenCode state hooks (npm, apiKey, baseUrl, models) - Add OpenCode change handlers syncing to form - Add OpenCodeFormFields rendering section - Add OpenCode config editor using CommonConfigEditor - Update ProviderActions.tsx for OpenCode additive mode: - Add appId and isInConfig props - Implement "Add to Config" / "Remove from Config" buttons - Disable failover mode for OpenCode - Update delete button logic for additive mode - Update ProviderCard.tsx: - Pass appId and isInConfig to ProviderActions - Update AddProviderDialog.tsx: - Add OpenCode base URL extraction from options.baseURL --- .../providers/AddProviderDialog.tsx | 8 + src/components/providers/ProviderActions.tsx | 56 ++++- src/components/providers/ProviderCard.tsx | 2 + .../providers/forms/OpenCodeFormFields.tsx | 221 ++++++++++++++++++ .../providers/forms/ProviderForm.tsx | 173 +++++++++++++- 5 files changed, 451 insertions(+), 9 deletions(-) create mode 100644 src/components/providers/forms/OpenCodeFormFields.tsx diff --git a/src/components/providers/AddProviderDialog.tsx b/src/components/providers/AddProviderDialog.tsx index 00cb0d1cd..f3bfc6dcd 100644 --- a/src/components/providers/AddProviderDialog.tsx +++ b/src/components/providers/AddProviderDialog.tsx @@ -17,6 +17,7 @@ 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 { @@ -153,6 +154,7 @@ export function AddProviderDialog({ } } } + // Note: OpenCode doesn't use endpointCandidates - it handles endpoints internally } if (appId === "claude") { @@ -175,6 +177,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); diff --git a/src/components/providers/ProviderActions.tsx b/src/components/providers/ProviderActions.tsx index e508167ac..a79320045 100644 --- a/src/components/providers/ProviderActions.tsx +++ b/src/components/providers/ProviderActions.tsx @@ -4,6 +4,7 @@ import { Copy, Edit, Loader2, + Minus, Play, Plus, Terminal, @@ -13,9 +14,13 @@ import { import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; +import type { AppId } from "@/lib/api"; interface ProviderActionsProps { + appId?: AppId; isCurrent: boolean; + /** OpenCode: 是否已添加到配置 */ + isInConfig?: boolean; isTesting?: boolean; isProxyTakeover?: boolean; onSwitch: () => void; @@ -32,7 +37,9 @@ interface ProviderActionsProps { } export function ProviderActions({ + appId, isCurrent, + isInConfig = false, isTesting, isProxyTakeover = false, onSwitch, @@ -50,12 +57,22 @@ export function ProviderActions({ const { t } = useTranslation(); const iconButtonClass = "h-8 w-8 p-1"; - // 故障转移模式下的按钮逻辑 - const isFailoverMode = isAutoFailoverEnabled && onToggleFailover; + // OpenCode 使用累加模式 + const isOpenCodeMode = appId === "opencode"; + + // 故障转移模式下的按钮逻辑(OpenCode 不支持故障转移) + const isFailoverMode = !isOpenCodeMode && isAutoFailoverEnabled && onToggleFailover; // 处理主按钮点击 const handleMainButtonClick = () => { - if (isFailoverMode) { + if (isOpenCodeMode) { + // OpenCode 模式:切换配置状态(添加/移除) + if (isInConfig) { + onDelete(); // 从配置移除 + } else { + onSwitch(); // 添加到配置 + } + } else if (isFailoverMode) { // 故障转移模式:切换队列状态 onToggleFailover(!isInFailoverQueue); } else { @@ -66,8 +83,30 @@ export function ProviderActions({ // 主按钮的状态和样式 const getMainButtonState = () => { + // OpenCode 累加模式 + if (isOpenCodeMode) { + if (isInConfig) { + return { + disabled: false, + variant: "secondary" as const, + className: + "bg-orange-100 text-orange-600 hover:bg-orange-200 dark:bg-orange-900/50 dark:text-orange-400 dark:hover:bg-orange-900/70", + icon: , + text: t("provider.removeFromConfig", { defaultValue: "移除" }), + }; + } + return { + disabled: false, + variant: "default" as const, + className: + "bg-emerald-500 hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700", + icon: , + text: t("provider.addToConfig", { defaultValue: "添加" }), + }; + } + + // 故障转移模式 if (isFailoverMode) { - // 故障转移模式 if (isInFailoverQueue) { return { disabled: false, @@ -113,6 +152,9 @@ export function ProviderActions({ const buttonState = getMainButtonState(); + // OpenCode 模式下删除按钮的行为不同(主按钮已处理移除功能) + const canDelete = isOpenCodeMode ? !isInConfig : !isCurrent; + return (
+
+ + {Object.keys(models).length === 0 ? ( +

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

+ ) : ( +
+ {Object.entries(models).map(([key, model]) => ( +
+ handleModelIdChange(key, e.target.value)} + placeholder={t("provider.form.opencode.modelId", { + defaultValue: "Model ID", + })} + className="flex-1" + /> + handleModelNameChange(key, e.target.value)} + placeholder={t("provider.form.opencode.modelName", { + defaultValue: "Display Name", + })} + className="flex-1" + /> + +
+ ))} +
+ )} + +

+ {t("provider.form.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..255b78945 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -20,6 +20,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"; @@ -62,9 +68,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 +177,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 ?? "", }), @@ -328,6 +349,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}`, @@ -469,6 +495,106 @@ export function ProviderForm({ selectedPresetId: selectedPresetId ?? undefined, }); + // 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 handlers - sync state to form + const handleOpencodeNpmChange = useCallback( + (npm: string) => { + setOpencodeNpm(npm); + try { + const config = JSON.parse(form.watch("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.watch("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.watch("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.watch("settingsConfig") || OPENCODE_DEFAULT_CONFIG); + config.models = models; + form.setValue("settingsConfig", JSON.stringify(config, null, 2)); + } catch { + // ignore + } + }, + [form], + ); + const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false); const handleSubmit = (values: ProviderFormData) => { @@ -941,6 +1067,23 @@ export function ProviderForm({ /> )} + {/* OpenCode 专属字段 */} + {appId === "opencode" && ( + + )} + {/* 配置编辑器:Codex、Claude、Gemini 分别使用不同的编辑器 */} {appId === "codex" ? ( <> @@ -1000,6 +1143,32 @@ export function ProviderForm({ )} /> + ) : appId === "opencode" ? ( + <> + form.setValue("settingsConfig", config)} + useCommonConfig={false} + onCommonConfigToggle={() => {}} + commonConfigSnippet="" + onCommonConfigSnippetChange={() => {}} + commonConfigError="" + onEditClick={() => {}} + isModalOpen={false} + onModalClose={() => {}} + onExtract={() => Promise.resolve()} + isExtracting={false} + /> + ( + + + + )} + /> + ) : ( <> Date: Thu, 15 Jan 2026 16:42:15 +0800 Subject: [PATCH 11/43] feat(opencode): Phase 10 - Internationalization for OpenCode Update i18n translation files (zh.json, en.json, ja.json): - Add "opencode" to apps section: "OpenCode" - Add provider section keys: - addOpenCodeProvider: Add OpenCode Provider - addToConfig: Add (for additive mode button) - removeFromConfig: Remove - inConfig: Added - Add new "opencode" section with UI labels: - npmPackage: AI SDK Package - npmPackageHint: Package selection hint - baseUrl, baseUrlHint: Base URL configuration - models, modelsHint: Model configuration - addModel, modelId, modelName: Model editor labels - noModels: Empty state message - Add "opencode" to mcp.unifiedPanel.apps section - Add "opencode" to skills.apps section --- src/i18n/locales/en.json | 25 ++++++++++++++++++++++--- src/i18n/locales/ja.json | 25 ++++++++++++++++++++++--- src/i18n/locales/zh.json | 25 ++++++++++++++++++++++--- 3 files changed, 66 insertions(+), 9 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index d8cc43e0b..62a73392c 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", @@ -304,7 +308,8 @@ "apps": { "claude": "Claude Code", "codex": "Codex", - "gemini": "Gemini" + "gemini": "Gemini", + "opencode": "OpenCode" }, "console": { "providerSwitchReceived": "Received provider switch event:", @@ -455,6 +460,18 @@ "configMergeFailed": "Config merge failed: {{error}}", "configReplaceFailed": "Config replace failed: {{error}}" }, + "opencode": { + "npmPackage": "AI SDK Package", + "npmPackageHint": "Select the npm package for communicating with 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" + }, "providerPreset": { "label": "Provider Preset", "custom": "Custom Configuration", @@ -634,7 +651,8 @@ "apps": { "claude": "Claude", "codex": "Codex", - "gemini": "Gemini" + "gemini": "Gemini", + "opencode": "OpenCode" } }, "userLevelPath": "User-level MCP path", @@ -942,7 +960,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..8edd6fee7 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 プロバイダーを編集", @@ -304,7 +308,8 @@ "apps": { "claude": "Claude Code", "codex": "Codex", - "gemini": "Gemini" + "gemini": "Gemini", + "opencode": "OpenCode" }, "console": { "providerSwitchReceived": "プロバイダー切り替えイベントを受信:", @@ -455,6 +460,18 @@ "configMergeFailed": "設定のマージに失敗しました: {{error}}", "configReplaceFailed": "設定の置換に失敗しました: {{error}}" }, + "opencode": { + "npmPackage": "AI SDK パッケージ", + "npmPackageHint": "AI サービスとの通信に使用する npm パッケージを選択", + "baseUrl": "Base URL", + "baseUrlHint": "カスタム API エンドポイント URL", + "models": "モデル設定", + "modelsHint": "利用可能なモデルとその表示名を設定", + "addModel": "モデルを追加", + "modelId": "モデル ID", + "modelName": "表示名", + "noModels": "モデルが設定されていません" + }, "providerPreset": { "label": "プロバイダータイプ", "custom": "カスタム設定", @@ -634,7 +651,8 @@ "apps": { "claude": "Claude", "codex": "Codex", - "gemini": "Gemini" + "gemini": "Gemini", + "opencode": "OpenCode" } }, "userLevelPath": "ユーザーレベルの MCP パス", @@ -942,7 +960,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..5e8cadb77 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 供应商", @@ -304,7 +308,8 @@ "apps": { "claude": "Claude Code", "codex": "Codex", - "gemini": "Gemini" + "gemini": "Gemini", + "opencode": "OpenCode" }, "console": { "providerSwitchReceived": "收到供应商切换事件:", @@ -455,6 +460,18 @@ "configMergeFailed": "配置合并失败: {{error}}", "configReplaceFailed": "配置替换失败: {{error}}" }, + "opencode": { + "npmPackage": "AI SDK 包", + "npmPackageHint": "选择用于与 AI 服务通信的 npm 包", + "baseUrl": "Base URL", + "baseUrlHint": "自定义 API 端点地址", + "models": "模型配置", + "modelsHint": "配置可用的模型及其显示名称", + "addModel": "添加模型", + "modelId": "模型 ID", + "modelName": "显示名称", + "noModels": "暂无模型配置" + }, "providerPreset": { "label": "预设供应商", "custom": "自定义配置", @@ -634,7 +651,8 @@ "apps": { "claude": "Claude", "codex": "Codex", - "gemini": "Gemini" + "gemini": "Gemini", + "opencode": "OpenCode" } }, "userLevelPath": "用户级 MCP 配置路径", @@ -942,7 +960,8 @@ "apps": { "claude": "Claude", "codex": "Codex", - "gemini": "Gemini" + "gemini": "Gemini", + "opencode": "OpenCode" } }, "deeplink": { From de3a22535d8fb95962ab2c7e9140b04f7888240f Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 15 Jan 2026 19:07:49 +0800 Subject: [PATCH 12/43] fix(opencode): address issues found during OpenCode integration review - Fix MCP server not removed from opencode.json when unchecked in edit modal - Fix Windows atomic write failure when opencode.json already exists - Fix i18n keys mismatch in OpenCodeFormFields (use opencode.* namespace) - Fix unit test missing apps.opencode field assertion --- src-tauri/src/app_config.rs | 7 +++- src-tauri/src/mcp/opencode.rs | 20 ++++------ src-tauri/src/opencode_config.rs | 40 +++++-------------- src-tauri/src/provider.rs | 1 - src-tauri/src/services/mcp.rs | 9 ++++- src-tauri/src/services/provider/live.rs | 10 ++--- src-tauri/src/services/stream_check.rs | 2 +- .../providers/forms/OpenCodeFormFields.tsx | 22 +++++----- src/i18n/locales/en.json | 1 + src/i18n/locales/ja.json | 1 + src/i18n/locales/zh.json | 1 + tests/components/McpFormModal.test.tsx | 1 + 12 files changed, 53 insertions(+), 62 deletions(-) diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index ea8cd076b..9b4a7b0e9 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -587,7 +587,12 @@ impl MultiAppConfig { log::info!("检测到已存在配置文件且 Prompt 列表为空,将尝试从现有提示词文件自动导入"); let mut imported = false; - for app in [AppType::Claude, AppType::Codex, AppType::Gemini, AppType::OpenCode] { + for app in [ + AppType::Claude, + AppType::Codex, + AppType::Gemini, + AppType::OpenCode, + ] { // 复用已有的单应用导入逻辑 if Self::auto_import_prompt_if_exists(self, app)? { imported = true; diff --git a/src-tauri/src/mcp/opencode.rs b/src-tauri/src/mcp/opencode.rs index 45a8c42d6..971d28e3f 100644 --- a/src-tauri/src/mcp/opencode.rs +++ b/src-tauri/src/mcp/opencode.rs @@ -68,10 +68,7 @@ pub fn convert_to_opencode_format(spec: &Value) -> Result { .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 typ = obj.get("type").and_then(|v| v.as_str()).unwrap_or("stdio"); let mut result = serde_json::Map::new(); @@ -81,10 +78,7 @@ pub fn convert_to_opencode_format(spec: &Value) -> Result { 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 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()) { @@ -149,10 +143,7 @@ pub fn convert_from_opencode_format(spec: &Value) -> Result { .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 typ = obj.get("type").and_then(|v| v.as_str()).unwrap_or("local"); let mut result = serde_json::Map::new(); @@ -360,7 +351,10 @@ mod tests { 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["command"][2], + "@modelcontextprotocol/server-filesystem" + ); assert_eq!(result["environment"]["HOME"], "/Users/test"); assert_eq!(result["enabled"], true); } diff --git a/src-tauri/src/opencode_config.rs b/src-tauri/src/opencode_config.rs index 8e6133791..769723f9e 100644 --- a/src-tauri/src/opencode_config.rs +++ b/src-tauri/src/opencode_config.rs @@ -21,6 +21,7 @@ //! } //! ``` +use crate::config::write_json_file; use crate::error::AppError; use crate::provider::{OpenCodeModel, OpenCodeProviderConfig, OpenCodeProviderOptions}; use crate::settings::get_opencode_override_dir; @@ -100,21 +101,8 @@ pub fn read_opencode_config() -> Result { /// 使用临时文件 + 重命名确保原子性 pub fn write_opencode_config(config: &Value) -> Result<(), AppError> { let path = get_opencode_config_path(); - - // Ensure directory exists - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; - } - - // Write to temporary file first - let temp_path = path.with_extension("json.tmp"); - let content = - serde_json::to_string_pretty(config).map_err(|e| AppError::JsonSerialize { source: e })?; - - std::fs::write(&temp_path, &content).map_err(|e| AppError::io(&temp_path, e))?; - - // Atomic rename - std::fs::rename(&temp_path, &path).map_err(|e| AppError::io(&path, e))?; + // 复用统一的原子写入逻辑(兼容 Windows 上目标文件已存在的情况) + write_json_file(&path, config)?; log::debug!("OpenCode config written to {:?}", path); Ok(()) @@ -147,7 +135,10 @@ pub fn set_provider(id: &str, config: Value) -> Result<(), AppError> { full_config["provider"] = json!({}); } - if let Some(providers) = full_config.get_mut("provider").and_then(|v| v.as_object_mut()) { + if let Some(providers) = full_config + .get_mut("provider") + .and_then(|v| v.as_object_mut()) + { providers.insert(id.to_string(), config); } @@ -205,8 +196,7 @@ pub fn get_typed_provider(id: &str) -> Result, Ap /// 设置供应商配置(类型化) 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 })?; + let value = serde_json::to_value(config).map_err(|e| AppError::JsonSerialize { source: e })?; set_provider(id, value) } @@ -302,15 +292,7 @@ pub fn create_provider_config( let model_map: HashMap = models .into_iter() - .map(|(id, name)| { - ( - id, - OpenCodeModel { - name, - limit: None, - }, - ) - }) + .map(|(id, name)| (id, OpenCodeModel { name, limit: None })) .collect(); OpenCodeProviderConfig { @@ -350,11 +332,11 @@ pub fn validate_provider_config(config: &OpenCodeProviderConfig) -> Result<(), A pub fn provider_to_opencode_config( settings_config: &Value, ) -> Result { - serde_json::from_value(settings_config.clone()).map_err(|e| AppError::JsonSerialize { source: e }) + serde_json::from_value(settings_config.clone()) + .map_err(|e| AppError::JsonSerialize { source: e }) } /// 将 OpenCode 配置转换为通用 Provider settings_config pub fn opencode_config_to_provider(config: &OpenCodeProviderConfig) -> Result { serde_json::to_value(config).map_err(|e| AppError::JsonSerialize { source: e }) } - diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 171d6d3c3..b681411ae 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -571,4 +571,3 @@ impl OpenCodeProviderConfig { .unwrap_or(false) } } - diff --git a/src-tauri/src/services/mcp.rs b/src-tauri/src/services/mcp.rs index 602765d2f..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)?; @@ -114,7 +117,11 @@ impl McpService { 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)?; + mcp::sync_single_server_to_opencode( + &Default::default(), + &server.id, + &server.server, + )?; } } Ok(()) diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 0030c12a9..9b73796ed 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -132,10 +132,7 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re match opencode_config_result { Ok(config) => { opencode_config::set_typed_provider(&provider.id, &config)?; - log::info!( - "OpenCode provider '{}' written to live config", - provider.id - ); + log::info!("OpenCode provider '{}' written to live config", provider.id); } Err(e) => { log::warn!( @@ -505,7 +502,10 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result - {t("provider.form.opencode.npmPackage", { + {t("opencode.npmPackage", { defaultValue: "AI SDK Package", })}

- {t("provider.form.opencode.npmPackageHint", { + {t("opencode.npmPackageHint", { defaultValue: "Select the AI SDK package that matches your provider.", })} @@ -134,7 +134,7 @@ export function OpenCodeFormFields({ {npm === "@ai-sdk/openai-compatible" && (

- {t("provider.form.opencode.baseUrl", { defaultValue: "Base URL" })} + {t("opencode.baseUrl", { defaultValue: "Base URL" })}

- {t("provider.form.opencode.baseUrlHint", { + {t("opencode.baseUrlHint", { defaultValue: "The base URL for OpenAI-compatible API endpoints.", })} @@ -155,7 +155,7 @@ export function OpenCodeFormFields({

- {t("provider.form.opencode.models", { defaultValue: "Models" })} + {t("opencode.models", { defaultValue: "Models" })}
{Object.keys(models).length === 0 ? (

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

@@ -182,7 +182,7 @@ export function OpenCodeFormFields({ handleModelIdChange(key, e.target.value)} - placeholder={t("provider.form.opencode.modelId", { + placeholder={t("opencode.modelId", { defaultValue: "Model ID", })} className="flex-1" @@ -190,7 +190,7 @@ export function OpenCodeFormFields({ handleModelNameChange(key, e.target.value)} - placeholder={t("provider.form.opencode.modelName", { + placeholder={t("opencode.modelName", { defaultValue: "Display Name", })} className="flex-1" @@ -210,7 +210,7 @@ export function OpenCodeFormFields({ )}

- {t("provider.form.opencode.modelsHint", { + {t("opencode.modelsHint", { defaultValue: "Configure available models. Model ID is the API identifier, Display Name is shown in the UI.", })} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 62a73392c..dadca1d6e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -462,6 +462,7 @@ }, "opencode": { "npmPackage": "AI SDK Package", + "selectPackage": "Select a package", "npmPackageHint": "Select the npm package for communicating with the AI service", "baseUrl": "Base URL", "baseUrlHint": "Custom API endpoint URL", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 8edd6fee7..eb7d4ef42 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -462,6 +462,7 @@ }, "opencode": { "npmPackage": "AI SDK パッケージ", + "selectPackage": "パッケージを選択", "npmPackageHint": "AI サービスとの通信に使用する npm パッケージを選択", "baseUrl": "Base URL", "baseUrlHint": "カスタム API エンドポイント URL", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 5e8cadb77..179dd322b 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -462,6 +462,7 @@ }, "opencode": { "npmPackage": "AI SDK 包", + "selectPackage": "选择一个包", "npmPackageHint": "选择用于与 AI 服务通信的 npm 包", "baseUrl": "Base URL", "baseUrlHint": "自定义 API 端点地址", 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(); From 45b9cf1df0b5dcb9ab580dfaaeaa6d7370e2712b Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 15 Jan 2026 19:31:44 +0800 Subject: [PATCH 13/43] feat(opencode): add OpenCode button to AppSwitcher Add the fourth tab button for OpenCode in the app switcher component, making the OpenCode providers page accessible from the main navigation. --- src/components/AppSwitcher.tsx | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/components/AppSwitcher.tsx b/src/components/AppSwitcher.tsx index cb3223c99..a724405dd 100644 --- a/src/components/AppSwitcher.tsx +++ b/src/components/AppSwitcher.tsx @@ -92,6 +92,28 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) { /> {appDisplayName.gemini} + +

); } From 2494eaaa32e50018f480fad44e408cdecc2e0828 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 15 Jan 2026 19:32:34 +0800 Subject: [PATCH 14/43] feat(opencode): hide proxy UI for OpenCode OpenCode uses additive provider management (multiple providers coexist), so proxy/failover features are not applicable. This commit: - Conditionally renders ProxyToggle only for non-OpenCode apps - Fixes toast message label to properly handle opencode app type --- src/App.tsx | 4 +++- src/hooks/useProxyStatus.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 32673da69..404e93d49 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -740,7 +740,9 @@ function App() { )} {currentView === "providers" && ( <> - + {activeApp !== "opencode" && ( + + )} 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 From e4df1a32a5391d441730c8ebc9f9975268341bc3 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 15 Jan 2026 19:37:35 +0800 Subject: [PATCH 15/43] feat(opencode): implement isInConfig semantics for additive provider management OpenCode uses additive provider management where providers can exist in the database but not necessarily in the live opencode.json config. This commit implements proper isInConfig state: Backend: - Add get_opencode_live_provider_ids command to query live config Frontend: - Add getOpenCodeLiveProviderIds API method - ProviderList queries live provider IDs and computes isInConfig - ProviderCard receives and passes isInConfig to ProviderActions - ProviderActions already handles the add/remove button logic --- src-tauri/src/commands/provider.rs | 10 ++++++++++ src-tauri/src/lib.rs | 1 + src/components/providers/ProviderCard.tsx | 4 +++- src/components/providers/ProviderList.tsx | 22 ++++++++++++++++++++++ src/lib/api/providers.ts | 8 ++++++++ 5 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index 7746318d3..30b8cfd8e 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -339,3 +339,13 @@ 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/lib.rs b/src-tauri/src/lib.rs index ceab5e326..43bdbe009 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -877,6 +877,7 @@ pub fn run() { 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/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index 2b3f9dc82..c661172a3 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -26,6 +26,7 @@ interface ProviderCardProps { provider: Provider; isCurrent: boolean; appId: AppId; + isInConfig?: boolean; // OpenCode: 是否已添加到 opencode.json onSwitch: (provider: Provider) => void; onEdit: (provider: Provider) => void; onDelete: (provider: Provider) => void; @@ -85,6 +86,7 @@ export function ProviderCard({ provider, isCurrent, appId, + isInConfig = true, onSwitch, onEdit, onDelete, @@ -362,7 +364,7 @@ export function ProviderCard({ onSwitch(provider)} diff --git a/src/components/providers/ProviderList.tsx b/src/components/providers/ProviderList.tsx index 1203cb7d3..4baed18e9 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"; @@ -72,6 +74,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,6 +217,7 @@ export function ProviderList({ provider={provider} isCurrent={provider.id === currentProviderId} appId={appId} + isInConfig={isProviderInConfig(provider.id)} onSwitch={onSwitch} onEdit={onEdit} onDelete={onDelete} @@ -308,6 +327,7 @@ interface SortableProviderCardProps { provider: Provider; isCurrent: boolean; appId: AppId; + isInConfig: boolean; onSwitch: (provider: Provider) => void; onEdit: (provider: Provider) => void; onDelete: (provider: Provider) => void; @@ -331,6 +351,7 @@ function SortableProviderCard({ provider, isCurrent, appId, + isInConfig, onSwitch, onEdit, onDelete, @@ -368,6 +389,7 @@ function SortableProviderCard({ provider={provider} isCurrent={isCurrent} appId={appId} + isInConfig={isInConfig} onSwitch={onSwitch} onEdit={onEdit} onDelete={onDelete} diff --git a/src/lib/api/providers.ts b/src/lib/api/providers.ts index 3e9d371b9..0c1b76ef4 100644 --- a/src/lib/api/providers.ts +++ b/src/lib/api/providers.ts @@ -82,6 +82,14 @@ export const providersApi = { 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"); + }, }; // ============================================================================ From cb1b45ae4e5e740f192fb7dcdcd7958c03059c9a Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 16 Jan 2026 13:03:21 +0800 Subject: [PATCH 16/43] feat(opencode): add OpenCode icon to icon system Register OpenCode SVG icon in the icons index so AppSwitcher displays the proper logo instead of fallback initials. --- src/icons/extracted/index.ts | 1 + src/icons/extracted/opencode-logo-light.svg | 1 + 2 files changed, 2 insertions(+) create mode 100644 src/icons/extracted/opencode-logo-light.svg 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 From 5c6956b6e22729bde62bfff8325c4ce766ed7edf Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 16 Jan 2026 15:40:33 +0800 Subject: [PATCH 17/43] feat(opencode): add OpenCode toggle switches to MCP and Skills panels - Add opencode to AppType and SkillApps interfaces in skills.ts - Add OpenCode Switch component to UnifiedMcpPanel list items - Add OpenCode Switch component to UnifiedSkillsPanel list items - Include OpenCode in enabled counts and header statistics for both panels --- src/components/mcp/UnifiedMcpPanel.tsx | 22 ++++++++++++++++++-- src/components/skills/UnifiedSkillsPanel.tsx | 22 ++++++++++++++++++-- src/lib/api/skills.ts | 3 ++- 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/components/mcp/UnifiedMcpPanel.tsx b/src/components/mcp/UnifiedMcpPanel.tsx index 19e5e22c3..8cf5659c6 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]); @@ -148,7 +149,8 @@ const UnifiedMcpPanel = React.forwardRef< {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/skills/UnifiedSkillsPanel.tsx b/src/components/skills/UnifiedSkillsPanel.tsx index 9f8f7267f..9887a2bc0 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]); @@ -139,7 +140,8 @@ const UnifiedSkillsPanel = React.forwardRef< {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/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+ 统一结构) */ From d765364a1881506b3ad03c4363b2821e4faa3042 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 16 Jan 2026 15:56:03 +0800 Subject: [PATCH 18/43] chore(opencode): remove unused functions and legacy code Remove dead code that was never called after v3.7.0 architecture change: - mcp/opencode.rs: sync_enabled_to_opencode, collect_enabled_servers - opencode_config.rs: 8 unused utility functions - provider.rs: OpenCodeProviderConfig impl block (4 methods) These functions were designed for batch operations but McpService uses per-server sync pattern instead. No functionality affected. --- src-tauri/src/mcp/mod.rs | 3 +- src-tauri/src/mcp/opencode.rs | 52 +------------ src-tauri/src/opencode_config.rs | 122 +------------------------------ src-tauri/src/provider.rs | 26 ------- 4 files changed, 4 insertions(+), 199 deletions(-) diff --git a/src-tauri/src/mcp/mod.rs b/src-tauri/src/mcp/mod.rs index 1081a2800..2f2b7f424 100644 --- a/src-tauri/src/mcp/mod.rs +++ b/src-tauri/src/mcp/mod.rs @@ -29,6 +29,5 @@ pub use gemini::{ sync_single_server_to_gemini, }; pub use opencode::{ - import_from_opencode, remove_server_from_opencode, sync_enabled_to_opencode, - sync_single_server_to_opencode, + import_from_opencode, remove_server_from_opencode, sync_single_server_to_opencode, }; diff --git a/src-tauri/src/mcp/opencode.rs b/src-tauri/src/mcp/opencode.rs index 971d28e3f..0b5873fbb 100644 --- a/src-tauri/src/mcp/opencode.rs +++ b/src-tauri/src/mcp/opencode.rs @@ -15,11 +15,11 @@ use serde_json::{json, Value}; use std::collections::HashMap; -use crate::app_config::{McpApps, McpConfig, McpServer, MultiAppConfig}; +use crate::app_config::{McpApps, McpServer, MultiAppConfig}; use crate::error::AppError; use crate::opencode_config; -use super::validation::{extract_server_spec, validate_server_spec}; +use super::validation::validate_server_spec; // ============================================================================ // Helper Functions @@ -31,29 +31,6 @@ fn should_sync_opencode_mcp() -> bool { opencode_config::get_opencode_dir().exists() } -/// Collect enabled MCP servers for OpenCode -fn collect_enabled_servers(cfg: &McpConfig) -> HashMap { - let mut out = HashMap::new(); - for (id, entry) in cfg.servers.iter() { - let enabled = entry - .get("enabled") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - if !enabled { - continue; - } - match extract_server_spec(entry) { - Ok(spec) => { - out.insert(id.clone(), spec); - } - Err(err) => { - log::warn!("Skip invalid MCP entry '{}': {}", id, err); - } - } - } - out -} - // ============================================================================ // Format Conversion: CC Switch → OpenCode // ============================================================================ @@ -207,31 +184,6 @@ pub fn convert_from_opencode_format(spec: &Value) -> Result { // Public API: Sync Functions // ============================================================================ -/// Sync all enabled_opencode=true servers to OpenCode config -pub fn sync_enabled_to_opencode(config: &MultiAppConfig) -> Result<(), AppError> { - if !should_sync_opencode_mcp() { - return Ok(()); - } - - let enabled = collect_enabled_servers(&config.mcp.opencode); - - // Convert all servers to OpenCode format - let mut opencode_servers = serde_json::Map::new(); - for (id, spec) in enabled { - match convert_to_opencode_format(&spec) { - Ok(opencode_spec) => { - opencode_servers.insert(id, opencode_spec); - } - Err(e) => { - log::warn!("Skip converting MCP server to OpenCode format: {}", e); - } - } - } - - // Write to OpenCode config - opencode_config::set_mcp_servers_batch(&opencode_servers) -} - /// Sync a single MCP server to OpenCode live config pub fn sync_single_server_to_opencode( _config: &MultiAppConfig, diff --git a/src-tauri/src/opencode_config.rs b/src-tauri/src/opencode_config.rs index 769723f9e..36d4f2e95 100644 --- a/src-tauri/src/opencode_config.rs +++ b/src-tauri/src/opencode_config.rs @@ -23,11 +23,10 @@ use crate::config::write_json_file; use crate::error::AppError; -use crate::provider::{OpenCodeModel, OpenCodeProviderConfig, OpenCodeProviderOptions}; +use crate::provider::OpenCodeProviderConfig; use crate::settings::get_opencode_override_dir; use indexmap::IndexMap; use serde_json::{json, Map, Value}; -use std::collections::HashMap; use std::path::PathBuf; // ============================================================================ @@ -108,11 +107,6 @@ pub fn write_opencode_config(config: &Value) -> Result<(), AppError> { Ok(()) } -/// 检查 OpenCode 配置文件是否存在 -pub fn config_exists() -> bool { - get_opencode_config_path().exists() -} - // ============================================================================ // Provider Functions (Untyped - for raw JSON operations) // ============================================================================ @@ -180,43 +174,12 @@ pub fn get_typed_providers() -> Result, Ok(result) } -/// 获取单个供应商配置(类型化) -pub fn get_typed_provider(id: &str) -> Result, AppError> { - let providers = get_providers()?; - - match providers.get(id) { - Some(value) => { - let config = serde_json::from_value::(value.clone()) - .map_err(|e| AppError::JsonSerialize { source: e })?; - Ok(Some(config)) - } - None => Ok(None), - } -} - /// 设置供应商配置(类型化) 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) } -/// 批量设置供应商配置 -pub fn set_providers_batch( - providers: &IndexMap, -) -> Result<(), AppError> { - let mut full_config = read_opencode_config()?; - - let mut provider_map = Map::new(); - for (id, config) in providers { - let value = - serde_json::to_value(config).map_err(|e| AppError::JsonSerialize { source: e })?; - provider_map.insert(id.clone(), value); - } - - full_config["provider"] = Value::Object(provider_map); - write_opencode_config(&full_config) -} - // ============================================================================ // MCP Functions // ============================================================================ @@ -257,86 +220,3 @@ pub fn remove_mcp_server(id: &str) -> Result<(), AppError> { write_opencode_config(&config) } -/// 批量设置 MCP 服务器配置 -pub fn set_mcp_servers_batch(servers: &Map) -> Result<(), AppError> { - let mut full_config = read_opencode_config()?; - full_config["mcp"] = Value::Object(servers.clone()); - write_opencode_config(&full_config) -} - -/// 清空所有 MCP 服务器配置 -pub fn clear_mcp_servers() -> Result<(), AppError> { - let mut config = read_opencode_config()?; - config["mcp"] = json!({}); - write_opencode_config(&config) -} - -// ============================================================================ -// Utility Functions -// ============================================================================ - -/// 创建新的供应商配置 -/// -/// 便捷方法,用于创建 OpenAI 兼容的供应商配置 -pub fn create_provider_config( - npm_package: &str, - base_url: Option<&str>, - api_key: Option<&str>, - models: HashMap, -) -> OpenCodeProviderConfig { - let options = OpenCodeProviderOptions { - base_url: base_url.map(|s| s.to_string()), - api_key: api_key.map(|s| s.to_string()), - headers: None, - }; - - let model_map: HashMap = models - .into_iter() - .map(|(id, name)| (id, OpenCodeModel { name, limit: None })) - .collect(); - - OpenCodeProviderConfig { - npm: npm_package.to_string(), - name: None, - options, - models: model_map, - } -} - -/// 验证供应商配置 -pub fn validate_provider_config(config: &OpenCodeProviderConfig) -> Result<(), AppError> { - // npm package must not be empty - if config.npm.trim().is_empty() { - return Err(AppError::localized( - "opencode.provider.npm.empty", - "npm 包名不能为空", - "npm package name cannot be empty", - )); - } - - // npm package should start with @ or be a valid package name - if !config.npm.starts_with('@') && !config.npm.chars().all(|c| c.is_alphanumeric() || c == '-') - { - log::warn!( - "Unusual npm package name: {}. Expected format like '@ai-sdk/openai'", - config.npm - ); - } - - Ok(()) -} - -/// 从通用 Provider 转换为 OpenCode 配置 -/// -/// 用于从数据库 Provider 结构转换为 OpenCode 配置格式 -pub fn provider_to_opencode_config( - settings_config: &Value, -) -> Result { - serde_json::from_value(settings_config.clone()) - .map_err(|e| AppError::JsonSerialize { source: e }) -} - -/// 将 OpenCode 配置转换为通用 Provider settings_config -pub fn opencode_config_to_provider(config: &OpenCodeProviderConfig) -> Result { - serde_json::to_value(config).map_err(|e| AppError::JsonSerialize { source: e }) -} diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index b681411ae..e4a032a8f 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -545,29 +545,3 @@ pub struct OpenCodeModelLimit { #[serde(skip_serializing_if = "Option::is_none")] pub output: Option, } - -impl OpenCodeProviderConfig { - /// 从 serde_json::Value 解析 - pub fn from_value(value: &Value) -> Result { - serde_json::from_value(value.clone()) - } - - /// 转换为 serde_json::Value - pub fn to_value(&self) -> Result { - serde_json::to_value(self) - } - - /// 获取第一个模型 ID(用于默认模型选择) - pub fn first_model_id(&self) -> Option { - self.models.keys().next().cloned() - } - - /// 检查是否有有效的 API 密钥配置 - pub fn has_api_key(&self) -> bool { - self.options - .api_key - .as_ref() - .map(|k| !k.trim().is_empty()) - .unwrap_or(false) - } -} From 58a13cc69a7b51600e08a2075a1fb702079b7dd6 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 16 Jan 2026 16:09:43 +0800 Subject: [PATCH 19/43] feat(provider): hide universal tab for OpenCode OpenCode doesn't support universal providers, so the tab is hidden to avoid confusion for users. --- .../providers/AddProviderDialog.tsx | 79 +++++++++++-------- 1 file changed, 47 insertions(+), 32 deletions(-) diff --git a/src/components/providers/AddProviderDialog.tsx b/src/components/providers/AddProviderDialog.tsx index f3bfc6dcd..7eae340ea 100644 --- a/src/components/providers/AddProviderDialog.tsx +++ b/src/components/providers/AddProviderDialog.tsx @@ -34,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", ); @@ -212,7 +214,7 @@ export function AddProviderDialog({ // 动态 footer:根据当前 Tab 显示不同按钮 const footer = - activeTab === "app-specific" ? ( + !showUniversalTab || activeTab === "app-specific" ? ( <> + + + {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 */}
diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index d837bfd3c..2c366a16a 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -558,6 +558,26 @@ export function ProviderForm({ } }); + // 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) => { @@ -617,6 +637,43 @@ export function ProviderForm({ [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) => { @@ -925,6 +982,7 @@ export function ProviderForm({ setOpencodeBaseUrl(""); setOpencodeApiKey(""); setOpencodeModels({}); + setOpencodeExtraOptions({}); } return; } @@ -993,6 +1051,17 @@ export function ProviderForm({ 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, @@ -1199,6 +1268,8 @@ export function ProviderForm({ onBaseUrlChange={handleOpencodeBaseUrlChange} models={opencodeModels} onModelsChange={handleOpencodeModelsChange} + extraOptions={opencodeExtraOptions} + onExtraOptionsChange={handleOpencodeExtraOptionsChange} /> )} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 4df6f3dad..1bc1c0617 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -481,7 +481,15 @@ "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." + "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" }, "providerPreset": { "label": "Provider Preset", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 03c83036b..754c030a4 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -481,7 +481,15 @@ "providerKeyHint": "設定ファイルの一意の識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用できます。", "providerKeyRequired": "プロバイダーキーを入力してください", "providerKeyDuplicate": "このキーは既に使用されています", - "providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用できます。" + "providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用できます。", + "extraOptions": "追加オプション", + "extraOptionsHint": "timeout、setCacheKey などの SDK オプションを設定。値は自動的に適切な型(数値、真偽値など)に変換されます。", + "addExtraOption": "追加", + "extraOptionKey": "キー名", + "extraOptionValue": "値", + "extraOptionKeyPlaceholder": "timeout", + "extraOptionValuePlaceholder": "600000", + "noExtraOptions": "追加オプションはありません" }, "providerPreset": { "label": "プロバイダータイプ", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 30c27457e..4f5c46be5 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -481,7 +481,15 @@ "providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符", "providerKeyRequired": "请填写供应商标识", "providerKeyDuplicate": "此标识已被使用,请更换", - "providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符" + "providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符", + "extraOptions": "额外选项", + "extraOptionsHint": "配置额外的 SDK 选项,如 timeout、setCacheKey 等。值会自动解析类型(数字、布尔值等)。", + "addExtraOption": "添加", + "extraOptionKey": "键名", + "extraOptionValue": "值", + "extraOptionKeyPlaceholder": "timeout", + "extraOptionValuePlaceholder": "600000", + "noExtraOptions": "暂无额外选项" }, "providerPreset": { "label": "预设供应商", diff --git a/src/types.ts b/src/types.ts index 5980eaedd..e389da94b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -269,6 +269,8 @@ export interface OpenCodeProviderOptions { baseURL?: string; apiKey?: string; headers?: Record; + // 支持额外选项(timeout, setCacheKey 等) + [key: string]: unknown; } // OpenCode 供应商配置(settings_config 结构) From 3bd3845ec0b3dd27bf3d4750f3f473f63838f124 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 19 Jan 2026 15:05:01 +0800 Subject: [PATCH 41/43] feat(opencode): add model-level options editor Add support for configuring per-model options like provider routing. Each model row now has an expand/collapse toggle to show a key-value editor for model-specific options (e.g., provider order, fallbacks). - Add options field to OpenCodeModel in Rust and TypeScript - Add expandable key-value editor UI for each model - Use local state pattern for option key input to prevent focus loss - Add i18n translations for zh/en/ja --- src-tauri/src/provider.rs | 4 + .../providers/forms/OpenCodeFormFields.tsx | 288 ++++++++++++++++-- src/i18n/locales/en.json | 5 +- src/i18n/locales/ja.json | 5 +- src/i18n/locales/zh.json | 5 +- src/types.ts | 1 + 6 files changed, 279 insertions(+), 29 deletions(-) diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 865ad84b8..5e9ab7328 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -537,6 +537,10 @@ pub struct OpenCodeModel { /// 模型限制(上下文和输出 token 数) #[serde(skip_serializing_if = "Option::is_none")] pub limit: Option, + + /// 模型额外选项(provider 路由等) + #[serde(skip_serializing_if = "Option::is_none")] + pub options: Option>, } /// OpenCode 模型限制 diff --git a/src/components/providers/forms/OpenCodeFormFields.tsx b/src/components/providers/forms/OpenCodeFormFields.tsx index 1aabe0fd1..71ff46516 100644 --- a/src/components/providers/forms/OpenCodeFormFields.tsx +++ b/src/components/providers/forms/OpenCodeFormFields.tsx @@ -10,9 +10,10 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Plus, Trash2 } from "lucide-react"; +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"; /** @@ -91,6 +92,42 @@ function ExtraOptionKeyInput({ ); } +/** + * 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; @@ -133,6 +170,19 @@ export function OpenCodeFormFields({ }: 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()}`; @@ -147,6 +197,12 @@ export function OpenCodeFormFields({ 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) @@ -161,6 +217,15 @@ export function OpenCodeFormFields({ } } 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 @@ -171,6 +236,71 @@ export function OpenCodeFormFields({ }); }; + // 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()}`; @@ -368,6 +498,7 @@ export function OpenCodeFormFields({ ) : (
+ {t("opencode.modelId", { defaultValue: "模型 ID" })} @@ -377,31 +508,136 @@ export function OpenCodeFormFields({
{Object.entries(models).map(([key, model]) => ( -
- 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" - /> - +
+ {/* 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" + /> + +
+ ) + )} + + )} +
+ )}
))}
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 1bc1c0617..cf246739f 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -489,7 +489,10 @@ "extraOptionValue": "Value", "extraOptionKeyPlaceholder": "timeout", "extraOptionValuePlaceholder": "600000", - "noExtraOptions": "No extra options configured" + "noExtraOptions": "No extra options configured", + "noModelOptions": "Model options, click + to add", + "modelOptionKeyPlaceholder": "provider", + "modelOptionValuePlaceholder": "{\"order\": [\"baseten\"]}" }, "providerPreset": { "label": "Provider Preset", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 754c030a4..81a19a50d 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -489,7 +489,10 @@ "extraOptionValue": "値", "extraOptionKeyPlaceholder": "timeout", "extraOptionValuePlaceholder": "600000", - "noExtraOptions": "追加オプションはありません" + "noExtraOptions": "追加オプションはありません", + "noModelOptions": "モデルオプション、+ をクリックして追加", + "modelOptionKeyPlaceholder": "provider", + "modelOptionValuePlaceholder": "{\"order\": [\"baseten\"]}" }, "providerPreset": { "label": "プロバイダータイプ", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 4f5c46be5..1f90c7217 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -489,7 +489,10 @@ "extraOptionValue": "值", "extraOptionKeyPlaceholder": "timeout", "extraOptionValuePlaceholder": "600000", - "noExtraOptions": "暂无额外选项" + "noExtraOptions": "暂无额外选项", + "noModelOptions": "模型选项,点击 + 添加", + "modelOptionKeyPlaceholder": "provider", + "modelOptionValuePlaceholder": "{\"order\": [\"baseten\"]}" }, "providerPreset": { "label": "预设供应商", diff --git a/src/types.ts b/src/types.ts index e389da94b..dcf0b5347 100644 --- a/src/types.ts +++ b/src/types.ts @@ -262,6 +262,7 @@ export interface OpenCodeModel { context?: number; output?: number; }; + options?: Record; // 模型级别额外选项(provider 路由等) } // OpenCode 供应商选项 From 1eb0a0d7ac3f6b56491796fae8853b0aba943aa1 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 19 Jan 2026 15:32:56 +0800 Subject: [PATCH 42/43] fix(opencode): move model options add button to bottom --- .../providers/forms/OpenCodeFormFields.tsx | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/components/providers/forms/OpenCodeFormFields.tsx b/src/components/providers/forms/OpenCodeFormFields.tsx index 71ff46516..b6fafc049 100644 --- a/src/components/providers/forms/OpenCodeFormFields.tsx +++ b/src/components/providers/forms/OpenCodeFormFields.tsx @@ -573,17 +573,6 @@ export function OpenCodeFormFields({
) : ( <> -
- -
{Object.entries(model.options || {}).map( ([optKey, optValue]) => (
@@ -634,6 +623,17 @@ export function OpenCodeFormFields({
) )} +
+ +
)}
From c847fff7689fc4e4ab843e2e24abc9dba659d766 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 19 Jan 2026 16:35:39 +0800 Subject: [PATCH 43/43] feat(provider): update OpenCode presets with Claude 4.5 models and SDK changes --- src/config/opencodeProviderPresets.ts | 60 ++++++++++++--------------- 1 file changed, 26 insertions(+), 34 deletions(-) diff --git a/src/config/opencodeProviderPresets.ts b/src/config/opencodeProviderPresets.ts index f262e2143..48014a88a 100644 --- a/src/config/opencodeProviderPresets.ts +++ b/src/config/opencodeProviderPresets.ts @@ -41,16 +41,16 @@ export const opencodeNpmPackages = [ * OpenCode 供应商预设列表 */ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ - // ========== 国产官方 ========== { name: "DeepSeek", websiteUrl: "https://platform.deepseek.com", apiKeyUrl: "https://platform.deepseek.com/api_keys", settingsConfig: { - npm: "@ai-sdk/deepseek", + npm: "@ai-sdk/openai-compatible", options: { - apiKey: "{env:DEEPSEEK_API_KEY}", + baseURL: "https://api.deepseek.com/v1", + apiKey: "", }, models: { "deepseek-chat": { name: "DeepSeek V3.2" }, @@ -277,16 +277,17 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ apiKey: "", }, models: { - "KAT-Coder-Pro V1": { name: "KAT-Coder Pro V1" }, - "KAT-Coder-Air V1": { name: "KAT-Coder Air V1" }, + "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", + 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: { @@ -347,7 +348,6 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, models: { "MiniMax-M2.1": { name: "MiniMax M2.1" }, - "MiniMax-Text-01": { name: "MiniMax Text 01" }, }, }, category: "cn_official", @@ -380,7 +380,6 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, models: { "MiniMax-M2.1": { name: "MiniMax M2.1" }, - "MiniMax-Text-01": { name: "MiniMax Text 01" }, }, }, category: "cn_official", @@ -476,24 +475,21 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, }, - // ========== 聚合网站 ========== { name: "AiHubMix", websiteUrl: "https://aihubmix.com", apiKeyUrl: "https://aihubmix.com", settingsConfig: { - npm: "@ai-sdk/openai-compatible", + npm: "@ai-sdk/anthropic", name: "AiHubMix", options: { baseURL: "https://aihubmix.com/v1", apiKey: "", }, models: { - "gpt-4o": { name: "GPT-4o" }, - "claude-sonnet-4-20250514": { name: "Claude Sonnet 4" }, - "gemini-2.5-pro-preview": { name: "Gemini 2.5 Pro" }, - "deepseek-chat": { name: "DeepSeek V3" }, + "claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" }, + "claude-opus-4-5-20251101": { name: "Claude Opus 4.5" }, }, }, category: "aggregator", @@ -512,16 +508,15 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ websiteUrl: "https://www.dmxapi.cn", apiKeyUrl: "https://www.dmxapi.cn", settingsConfig: { - npm: "@ai-sdk/openai-compatible", + npm: "@ai-sdk/anthropic", name: "DMXAPI", options: { baseURL: "https://www.dmxapi.cn/v1", apiKey: "", }, models: { - "gpt-4o": { name: "GPT-4o" }, - "claude-sonnet-4-20250514": { name: "Claude Sonnet 4" }, - "deepseek-chat": { name: "DeepSeek V3" }, + "claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" }, + "claude-opus-4-5-20251101": { name: "Claude Opus 4.5" }, }, }, category: "aggregator", @@ -540,17 +535,15 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ websiteUrl: "https://openrouter.ai", apiKeyUrl: "https://openrouter.ai/keys", settingsConfig: { - npm: "@ai-sdk/openai-compatible", + npm: "@ai-sdk/anthropic", name: "OpenRouter", options: { baseURL: "https://openrouter.ai/api/v1", apiKey: "", }, models: { - "anthropic/claude-sonnet-4": { name: "Claude Sonnet 4" }, - "openai/gpt-4o": { name: "GPT-4o" }, - "google/gemini-2.5-pro-preview": { name: "Gemini 2.5 Pro" }, - "deepseek/deepseek-chat": { name: "DeepSeek V3" }, + "anthropic/claude-sonnet-4.5": { name: "Claude Sonnet 4.5" }, + "anthropic/claude-opus-4.5": { name: "Claude Opus 4.5" }, }, }, category: "aggregator", @@ -571,16 +564,15 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ websiteUrl: "https://www.packyapi.com", apiKeyUrl: "https://www.packyapi.com/register?aff=cc-switch", settingsConfig: { - npm: "@ai-sdk/openai-compatible", + npm: "@ai-sdk/anthropic", name: "PackyCode", options: { baseURL: "https://www.packyapi.com/v1", apiKey: "", }, models: { - "claude-sonnet-4-20250514": { name: "Claude Sonnet 4" }, - "gpt-4o": { name: "GPT-4o" }, - "gemini-2.5-pro": { name: "Gemini 2.5 Pro" }, + "claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" }, + "claude-opus-4-5-20251101": { name: "Claude Opus 4.5" }, }, }, category: "third_party", @@ -600,15 +592,15 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ websiteUrl: "https://cubence.com", apiKeyUrl: "https://cubence.com/signup?code=CCSWITCH&source=ccs", settingsConfig: { - npm: "@ai-sdk/openai-compatible", + npm: "@ai-sdk/anthropic", name: "Cubence", options: { baseURL: "https://api.cubence.com/v1", apiKey: "", }, models: { - "claude-sonnet-4-20250514": { name: "Claude Sonnet 4" }, - "gpt-4o": { name: "GPT-4o" }, + "claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" }, + "claude-opus-4-5-20251101": { name: "Claude Opus 4.5" }, }, }, category: "third_party", @@ -629,15 +621,15 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ websiteUrl: "https://aigocode.com", apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH", settingsConfig: { - npm: "@ai-sdk/openai-compatible", + npm: "@ai-sdk/anthropic", name: "AIGoCode", options: { baseURL: "https://api.aigocode.com/v1", apiKey: "", }, models: { - "claude-sonnet-4-20250514": { name: "Claude Sonnet 4" }, - "gpt-4o": { name: "GPT-4o" }, + "claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" }, + "claude-opus-4-5-20251101": { name: "Claude Opus 4.5" }, }, }, category: "third_party",