mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-05-14 16:29:39 +08:00
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
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -68,10 +68,7 @@ pub fn convert_to_opencode_format(spec: &Value) -> Result<Value, AppError> {
|
||||
.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<Value, AppError> {
|
||||
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<Value, AppError> {
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -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<Value, AppError> {
|
||||
/// 使用临时文件 + 重命名确保原子性
|
||||
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<Option<OpenCodeProviderConfig>, 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<String, OpenCodeModel> = 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<OpenCodeProviderConfig, AppError> {
|
||||
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<Value, AppError> {
|
||||
serde_json::to_value(config).map_err(|e| AppError::JsonSerialize { source: e })
|
||||
}
|
||||
|
||||
|
||||
@@ -571,4 +571,3 @@ impl OpenCodeProviderConfig {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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<usize, Ap
|
||||
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);
|
||||
log::debug!(
|
||||
"OpenCode provider '{}' already exists in database, skipping",
|
||||
id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -190,7 +190,7 @@ impl StreamCheckService {
|
||||
return Err(AppError::localized(
|
||||
"opencode_no_stream_check",
|
||||
"OpenCode 暂不支持健康检查",
|
||||
"OpenCode does not support health check yet"
|
||||
"OpenCode does not support health check yet",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -93,14 +93,14 @@ export function OpenCodeFormFields({
|
||||
{/* NPM Package Selector */}
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="opencode-npm">
|
||||
{t("provider.form.opencode.npmPackage", {
|
||||
{t("opencode.npmPackage", {
|
||||
defaultValue: "AI SDK Package",
|
||||
})}
|
||||
</FormLabel>
|
||||
<Select value={npm} onValueChange={onNpmChange}>
|
||||
<SelectTrigger id="opencode-npm">
|
||||
<SelectValue
|
||||
placeholder={t("provider.form.opencode.selectPackage", {
|
||||
placeholder={t("opencode.selectPackage", {
|
||||
defaultValue: "Select a package",
|
||||
})}
|
||||
/>
|
||||
@@ -114,7 +114,7 @@ export function OpenCodeFormFields({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{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" && (
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="opencode-baseurl">
|
||||
{t("provider.form.opencode.baseUrl", { defaultValue: "Base URL" })}
|
||||
{t("opencode.baseUrl", { defaultValue: "Base URL" })}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="opencode-baseurl"
|
||||
@@ -143,7 +143,7 @@ export function OpenCodeFormFields({
|
||||
placeholder="https://api.example.com/v1"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("provider.form.opencode.baseUrlHint", {
|
||||
{t("opencode.baseUrlHint", {
|
||||
defaultValue:
|
||||
"The base URL for OpenAI-compatible API endpoints.",
|
||||
})}
|
||||
@@ -155,7 +155,7 @@ export function OpenCodeFormFields({
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>
|
||||
{t("provider.form.opencode.models", { defaultValue: "Models" })}
|
||||
{t("opencode.models", { defaultValue: "Models" })}
|
||||
</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -165,13 +165,13 @@ export function OpenCodeFormFields({
|
||||
className="h-7 gap-1"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("provider.form.opencode.addModel", { defaultValue: "Add" })}
|
||||
{t("opencode.addModel", { defaultValue: "Add" })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{Object.keys(models).length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-2">
|
||||
{t("provider.form.opencode.noModels", {
|
||||
{t("opencode.noModels", {
|
||||
defaultValue: "No models configured. Click Add to add a model.",
|
||||
})}
|
||||
</p>
|
||||
@@ -182,7 +182,7 @@ export function OpenCodeFormFields({
|
||||
<Input
|
||||
value={key}
|
||||
onChange={(e) => 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({
|
||||
<Input
|
||||
value={model.name}
|
||||
onChange={(e) => 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({
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{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.",
|
||||
})}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -462,6 +462,7 @@
|
||||
},
|
||||
"opencode": {
|
||||
"npmPackage": "AI SDK パッケージ",
|
||||
"selectPackage": "パッケージを選択",
|
||||
"npmPackageHint": "AI サービスとの通信に使用する npm パッケージを選択",
|
||||
"baseUrl": "Base URL",
|
||||
"baseUrlHint": "カスタム API エンドポイント URL",
|
||||
|
||||
@@ -462,6 +462,7 @@
|
||||
},
|
||||
"opencode": {
|
||||
"npmPackage": "AI SDK 包",
|
||||
"selectPackage": "选择一个包",
|
||||
"npmPackageHint": "选择用于与 AI 服务通信的 npm 包",
|
||||
"baseUrl": "Base URL",
|
||||
"baseUrlHint": "自定义 API 端点地址",
|
||||
|
||||
@@ -432,6 +432,7 @@ type = "stdio"
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
});
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
expect(toastErrorMock).not.toHaveBeenCalled();
|
||||
|
||||
Reference in New Issue
Block a user