mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-27 08:14:33 +08:00
fix(omo): preserve custom fields and align otherFields import/validation
This commit is contained in:
@@ -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<String, Value>,
|
||||
file_path: String,
|
||||
last_modified: Option<String>,
|
||||
) -> 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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>).model as string) || "",
|
||||
sourceKey: k,
|
||||
});
|
||||
}
|
||||
}
|
||||
return customs;
|
||||
}
|
||||
|
||||
function mergeCustomModelsIntoStore(
|
||||
export function mergeCustomModelsIntoStore(
|
||||
store: Record<string, Record<string, unknown>>,
|
||||
builtinKeys: Set<string>,
|
||||
customs: CustomModelItem[],
|
||||
modelVariantsMap: Record<string, string[]>,
|
||||
): Record<string, Record<string, unknown>> {
|
||||
const updated = { ...store };
|
||||
for (const key of Object.keys(updated)) {
|
||||
if (!builtinKeys.has(key)) delete updated[key];
|
||||
const updated: Record<string, Record<string, unknown>> = {};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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", {
|
||||
|
||||
+13
-1
@@ -316,6 +316,17 @@ export const OMO_CLAUDE_CODE_PLACEHOLDER = `{
|
||||
"plugins": true
|
||||
}`;
|
||||
|
||||
export function parseOmoOtherFieldsObject(
|
||||
raw: string,
|
||||
): Record<string, unknown> | 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<string, unknown>;
|
||||
}
|
||||
|
||||
export function mergeOmoConfigPreview(
|
||||
global: OmoGlobalConfig,
|
||||
agents: Record<string, Record<string, unknown>>,
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user