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();