diff --git a/src-tauri/src/services/omo.rs b/src-tauri/src/services/omo.rs index 525ae03d4..0f2038e48 100644 --- a/src-tauri/src/services/omo.rs +++ b/src-tauri/src/services/omo.rs @@ -286,6 +286,18 @@ impl OmoService { let obj = Self::read_jsonc_object(&actual_path)?; + Ok(Self::build_local_file_data_from_obj( + &obj, + actual_path.to_string_lossy().to_string(), + last_modified, + )) + } + + fn build_local_file_data_from_obj( + obj: &Map, + file_path: String, + last_modified: Option, + ) -> OmoLocalFileData { let agents = obj.get("agents").cloned(); let categories = obj.get("categories").cloned(); @@ -298,15 +310,16 @@ impl OmoService { let mut global = OmoGlobalConfig::default(); Self::merge_global_from_obj(&obj, &mut global); + global.other_fields = other_fields.clone(); - Ok(OmoLocalFileData { + OmoLocalFileData { agents, categories, other_fields, global, - file_path: actual_path.to_string_lossy().to_string(), + file_path, last_modified, - }) + } } fn strip_jsonc_comments(input: &str) -> String { @@ -434,4 +447,58 @@ mod tests { assert!(!obj.contains_key("disabled_agents")); assert!(obj.contains_key("agents")); } + + #[test] + fn test_build_local_file_data_keeps_unknown_top_level_fields_in_global() { + let obj = serde_json::json!({ + "$schema": "https://example.com/schema.json", + "disabled_agents": ["oracle"], + "agents": { + "sisyphus": { "model": "claude-opus-4-6" } + }, + "categories": { + "code": { "model": "gpt-5.3" } + }, + "custom_top_level": { + "enabled": true + } + }); + let obj_map = obj.as_object().unwrap().clone(); + + let data = OmoService::build_local_file_data_from_obj( + &obj_map, + "/tmp/oh-my-opencode.jsonc".to_string(), + None, + ); + + assert_eq!(data.global.schema_url.as_deref(), Some("https://example.com/schema.json")); + assert_eq!(data.global.disabled_agents, vec!["oracle".to_string()]); + + assert_eq!( + data.other_fields, + Some(serde_json::json!({ + "custom_top_level": { "enabled": true } + })) + ); + assert_eq!(data.global.other_fields, data.other_fields); + } + + #[test] + fn test_merge_config_ignores_non_object_other_fields() { + let global = OmoGlobalConfig { + other_fields: Some(serde_json::json!(["global_non_object"])), + ..Default::default() + }; + let agents = None; + let categories = None; + let other_fields = Some(serde_json::json!("profile_non_object")); + let profile_data = (agents, categories, other_fields, true); + + let merged = OmoService::merge_config(&global, Some(&profile_data)); + let obj = merged.as_object().unwrap(); + + assert!(!obj.contains_key("0")); + assert!(!obj.contains_key("global_non_object")); + assert!(!obj.contains_key("profile_non_object")); + } } diff --git a/src/components/providers/forms/OmoFormFields.tsx b/src/components/providers/forms/OmoFormFields.tsx index 9ec647472..b4b859ba8 100644 --- a/src/components/providers/forms/OmoFormFields.tsx +++ b/src/components/providers/forms/OmoFormFields.tsx @@ -77,7 +77,11 @@ interface OmoFormFieldsProps { onOtherFieldsStrChange: (value: string) => void; } -type CustomModelItem = { key: string; model: string }; +export type CustomModelItem = { + key: string; + model: string; + sourceKey?: string; +}; type BuiltinModelDef = Pick< OmoAgentDef | OmoCategoryDef, "key" | "display" | "descKey" | "recommended" | "tooltipKey" @@ -236,45 +240,58 @@ function collectCustomModels( customs.push({ key: k, model: ((v as Record).model as string) || "", + sourceKey: k, }); } } return customs; } -function mergeCustomModelsIntoStore( +export function mergeCustomModelsIntoStore( store: Record>, builtinKeys: Set, customs: CustomModelItem[], modelVariantsMap: Record, ): Record> { - const updated = { ...store }; - for (const key of Object.keys(updated)) { - if (!builtinKeys.has(key)) delete updated[key]; + const updated: Record> = {}; + + for (const [key, value] of Object.entries(store)) { + if (builtinKeys.has(key)) { + updated[key] = { ...value }; + } } + for (const custom of customs) { - if (custom.key.trim()) { - const nextEntry = { ...(updated[custom.key] || {}) }; - if (custom.model.trim()) { - nextEntry.model = custom.model; - const currentVariant = - typeof nextEntry.variant === "string" ? nextEntry.variant : ""; - if (currentVariant) { - const validVariants = modelVariantsMap[custom.model] || []; - if (!validVariants.includes(currentVariant)) { - delete nextEntry.variant; - } - } - updated[custom.key] = nextEntry; - } else { - delete nextEntry.model; - delete nextEntry.variant; - if (Object.keys(nextEntry).length > 0) { - updated[custom.key] = nextEntry; - } else { - delete updated[custom.key]; + const targetKey = custom.key.trim(); + if (!targetKey) continue; + + const sourceKey = (custom.sourceKey || targetKey).trim(); + const sourceEntry = store[sourceKey] ?? store[targetKey]; + const nextEntry = { + ...(updated[targetKey] || {}), + ...(sourceEntry || {}), + }; + + if (custom.model.trim()) { + nextEntry.model = custom.model; + const currentVariant = + typeof nextEntry.variant === "string" ? nextEntry.variant : ""; + if (currentVariant) { + const validVariants = modelVariantsMap[custom.model] || []; + if (!validVariants.includes(currentVariant)) { + delete nextEntry.variant; } } + updated[targetKey] = nextEntry; + continue; + } + + delete nextEntry.model; + delete nextEntry.variant; + if (Object.keys(nextEntry).length > 0) { + updated[targetKey] = nextEntry; + } else { + delete updated[targetKey]; } } return updated; @@ -1032,11 +1049,17 @@ export function OmoFormFields({ const addCustomModel = (scope: AdvancedScope) => { if (scope === "agent") { - setCustomAgents((prev) => [...prev, { key: "", model: "" }]); + setCustomAgents((prev) => [ + ...prev, + { key: "", model: "", sourceKey: "" }, + ]); setSubAgentsOpen(true); return; } - setCustomCategories((prev) => [...prev, { key: "", model: "" }]); + setCustomCategories((prev) => [ + ...prev, + { key: "", model: "", sourceKey: "" }, + ]); setCategoriesOpen(true); }; diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index d1c909720..381a7d68c 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -54,7 +54,7 @@ import { type OmoGlobalConfigFieldsRef } from "./OmoGlobalConfigFields"; import { OmoCommonConfigEditor } from "./OmoCommonConfigEditor"; import * as configApi from "@/lib/api/config"; import type { OmoGlobalConfig } from "@/types/omo"; -import { mergeOmoConfigPreview } from "@/types/omo"; +import { mergeOmoConfigPreview, parseOmoOtherFieldsObject } from "@/types/omo"; import { ProviderAdvancedConfig, type PricingModelSourceOption, @@ -219,8 +219,10 @@ function buildOmoProfilePreview( } if (otherFieldsStr.trim()) { try { - const other = JSON.parse(otherFieldsStr); - Object.assign(profileOnly, other); + const other = parseOmoOtherFieldsObject(otherFieldsStr); + if (other) { + Object.assign(profileOnly, other); + } } catch {} } return profileOnly; @@ -1203,7 +1205,19 @@ export function ProviderForm({ } if (omoOtherFieldsStr.trim()) { try { - omoConfig.otherFields = JSON.parse(omoOtherFieldsStr); + const otherFields = parseOmoOtherFieldsObject(omoOtherFieldsStr); + if (!otherFields) { + toast.error( + t("omo.jsonMustBeObject", { + field: t("omo.otherFields", { + defaultValue: "Other Config", + }), + defaultValue: "{{field}} must be a JSON object", + }), + ); + return; + } + omoConfig.otherFields = otherFields; } catch { toast.error( t("omo.invalidJson", { diff --git a/src/types/omo.ts b/src/types/omo.ts index e450c20a1..8b6059223 100644 --- a/src/types/omo.ts +++ b/src/types/omo.ts @@ -316,6 +316,17 @@ export const OMO_CLAUDE_CODE_PLACEHOLDER = `{ "plugins": true }`; +export function parseOmoOtherFieldsObject( + raw: string, +): Record | undefined { + if (!raw.trim()) return undefined; + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return undefined; + } + return parsed as Record; +} + export function mergeOmoConfigPreview( global: OmoGlobalConfig, agents: Record>, @@ -351,7 +362,8 @@ export function mergeOmoConfigPreview( if (Object.keys(agents).length > 0) result["agents"] = agents; if (Object.keys(categories).length > 0) result["categories"] = categories; try { - const other = JSON.parse(otherFieldsStr || "{}"); + const other = parseOmoOtherFieldsObject(otherFieldsStr); + if (!other) return result; for (const [k, v] of Object.entries(other)) { result[k] = v; } diff --git a/tests/components/OmoFormFields.mergeCustomModelsIntoStore.test.ts b/tests/components/OmoFormFields.mergeCustomModelsIntoStore.test.ts new file mode 100644 index 000000000..b77b79e60 --- /dev/null +++ b/tests/components/OmoFormFields.mergeCustomModelsIntoStore.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { + mergeCustomModelsIntoStore, + type CustomModelItem, +} from "@/components/providers/forms/OmoFormFields"; + +describe("mergeCustomModelsIntoStore", () => { + it("保留自定义项高级字段,并在模型变更时仅按需清理非法 variant", () => { + const store = { + sisyphus: { model: "builtin-model" }, + "custom-agent": { + model: "model-a", + variant: "fast", + temperature: 0.2, + permission: { edit: "allow" }, + }, + }; + const customs: CustomModelItem[] = [ + { key: "custom-agent", model: "model-b", sourceKey: "custom-agent" }, + ]; + + const merged = mergeCustomModelsIntoStore( + store, + new Set(["sisyphus"]), + customs, + { "model-b": ["precise"] }, + ); + + expect(merged.sisyphus).toEqual({ model: "builtin-model" }); + expect(merged["custom-agent"]).toEqual({ + model: "model-b", + temperature: 0.2, + permission: { edit: "allow" }, + }); + }); + + it("重命名自定义 key 时迁移原有 variant 和高级字段", () => { + const store = { + sisyphus: { model: "builtin-model" }, + "custom-agent-old": { + model: "model-a", + variant: "fast", + maxTokens: 8192, + }, + }; + const customs: CustomModelItem[] = [ + { + key: "custom-agent-new", + sourceKey: "custom-agent-old", + model: "model-a", + }, + ]; + + const merged = mergeCustomModelsIntoStore( + store, + new Set(["sisyphus"]), + customs, + { "model-a": ["fast", "balanced"] }, + ); + + expect(merged["custom-agent-old"]).toBeUndefined(); + expect(merged["custom-agent-new"]).toEqual({ + model: "model-a", + variant: "fast", + maxTokens: 8192, + }); + }); + + it("custom 列表为空时移除旧自定义项但保留内置项", () => { + const store = { + sisyphus: { model: "builtin-model" }, + hephaestus: { model: "builtin-model-2" }, + "custom-agent": { model: "model-a", temperature: 0.3 }, + }; + + const merged = mergeCustomModelsIntoStore( + store, + new Set(["sisyphus", "hephaestus"]), + [], + {}, + ); + + expect(merged).toEqual({ + sisyphus: { model: "builtin-model" }, + hephaestus: { model: "builtin-model-2" }, + }); + }); + + it("清空 model 时保留高级字段并移除 model/variant", () => { + const store = { + sisyphus: { model: "builtin-model" }, + "custom-agent": { + model: "model-a", + variant: "fast", + temperature: 0.7, + }, + }; + const customs: CustomModelItem[] = [ + { key: "custom-agent", model: "", sourceKey: "custom-agent" }, + ]; + + const merged = mergeCustomModelsIntoStore( + store, + new Set(["sisyphus"]), + customs, + { "model-a": ["fast"] }, + ); + + expect(merged["custom-agent"]).toEqual({ temperature: 0.7 }); + }); +}); diff --git a/tests/utils/omoConfig.test.ts b/tests/utils/omoConfig.test.ts new file mode 100644 index 000000000..5a5de6c91 --- /dev/null +++ b/tests/utils/omoConfig.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { + mergeOmoConfigPreview, + parseOmoOtherFieldsObject, + type OmoGlobalConfig, +} from "@/types/omo"; + +const EMPTY_GLOBAL: OmoGlobalConfig = { + id: "global", + disabledAgents: [], + disabledMcps: [], + disabledHooks: [], + disabledSkills: [], + updatedAt: "2026-01-01T00:00:00.000Z", +}; + +describe("parseOmoOtherFieldsObject", () => { + it("解析对象 JSON", () => { + expect(parseOmoOtherFieldsObject('{ "foo": 1 }')).toEqual({ foo: 1 }); + }); + + it("数组/字符串返回 undefined", () => { + expect(parseOmoOtherFieldsObject('["a"]')).toBeUndefined(); + expect(parseOmoOtherFieldsObject('"hello"')).toBeUndefined(); + }); + + it("非法 JSON 抛出异常", () => { + expect(() => parseOmoOtherFieldsObject("{")).toThrow(); + }); +}); + +describe("mergeOmoConfigPreview", () => { + it("只合并 otherFields 的对象值,忽略数组", () => { + const mergedFromArray = mergeOmoConfigPreview( + EMPTY_GLOBAL, + {}, + {}, + '["a", "b"]', + ); + expect(mergedFromArray).toEqual({}); + + const mergedFromObject = mergeOmoConfigPreview( + EMPTY_GLOBAL, + {}, + {}, + '{ "foo": "bar" }', + ); + expect(mergedFromObject).toEqual({ foo: "bar" }); + }); +});