From 8db725e310212ff94e75cbf1f2f812e76051b354 Mon Sep 17 00:00:00 2001 From: mkit777 <35168767+mkit777@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:46:16 +0800 Subject: [PATCH 1/2] feat(codex): configure auto-review models --- src-tauri/src/codex_catalog/customizations.rs | 289 +++++++++++++++++- src/i18n/ja.ts | 6 + src/i18n/locales/en.ts | 6 + src/i18n/locales/zh-CN.ts | 6 + src/pages/CodexModelCatalogDialog.tsx | 36 ++- src/services/codexModelCatalog.ts | 4 + src/styles.css | 7 + tests/codexModelCatalog.test.ts | 8 + 8 files changed, 346 insertions(+), 16 deletions(-) diff --git a/src-tauri/src/codex_catalog/customizations.rs b/src-tauri/src/codex_catalog/customizations.rs index 7fbac066..e22c4b7e 100644 --- a/src-tauri/src/codex_catalog/customizations.rs +++ b/src-tauri/src/codex_catalog/customizations.rs @@ -4,9 +4,13 @@ use sha2::{Digest, Sha256}; use std::collections::BTreeMap; use std::path::Path; -pub(super) type ModelCustomizations = BTreeMap>; +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +pub(super) struct ModelCustomizations { + default_auto_review_model: Option, + models: BTreeMap>, +} -const EDITABLE_FIELDS: [&str; 11] = [ +const EDITABLE_FIELDS: [&str; 12] = [ "display_name", "description", "context_window", @@ -18,19 +22,24 @@ const EDITABLE_FIELDS: [&str; 11] = [ "input_modalities", "visibility", "supports_parallel_tool_calls", + "auto_review_model_override", ]; #[derive(Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct SavedCustomizations { version: u32, - models: ModelCustomizations, + // 老配置没有统一默认值,加载后继续沿用模型模板的审批行为。 + #[serde(default, skip_serializing_if = "Option::is_none")] + default_auto_review_model: Option, + models: BTreeMap>, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct CatalogEditorSnapshot { revision: String, + default_auto_review_model: Option, models: Vec, } @@ -49,6 +58,7 @@ struct CatalogEditorModel { #[serde(deny_unknown_fields)] pub(crate) struct CatalogEditorRequest { revision: String, + default_auto_review_model: Option, models: Vec, } @@ -85,6 +95,11 @@ fn validate_configuration(model: &Map) -> Result<(), String> { return Err(format!("不允许修改模型字段 {field}")); } } + if let Some(value) = model.get("auto_review_model_override") { + if !value.is_null() { + validate_review_model(Some(value.as_str().ok_or("审批模型 ID 必须是字符串")?))?; + } + } for field in [ "context_window", "max_context_window", @@ -193,17 +208,50 @@ fn validate_configuration(model: &Map) -> Result<(), String> { Ok(()) } +fn validate_review_model(model: Option<&str>) -> Result<(), String> { + // 模型 ID 区分大小写并允许内部空格;只拒绝空值、首尾空白和控制字符,不改写代理别名。 + if model.is_some_and(|id| { + id.is_empty() || id.len() > 4_000 || id.trim() != id || id.chars().any(char::is_control) + }) { + return Err( + "审批模型 ID 必须是有效文本,不能包含首尾空白或控制字符,且不超过 4000 字节" + .to_string(), + ); + } + Ok(()) +} + pub(super) fn apply_customizations( model: &mut Map, customizations: &ModelCustomizations, ) -> Result<(), String> { let slug = string_value(model, "slug"); - if let Some(customization) = customizations.get(&normalize_id(&slug)) { - model.extend(customization.clone()); + let customization = customizations.models.get(&normalize_id(&slug)); + if let Some(customization) = customization { + model.extend( + customization + .iter() + .filter(|(field, value)| { + field.as_str() != "auto_review_model_override" || !value.is_null() + }) + .map(|(field, value)| (field.clone(), value.clone())), + ); validate_configuration(&editable_configuration(model)) .map_err(|error| format!("模型 {slug} 的自定义配置无效: {error}"))?; enable_fast_mode(model); } + // 仅在生成目录时解析继承:单模型覆盖 > 统一默认 > 原模型模板。 + // 不把继承值写回单模型设置,后续更改统一默认时才能同时更新所有继承者。 + if let Some(review_model) = customization + .and_then(|fields| fields.get("auto_review_model_override")) + .and_then(Value::as_str) + .or(customizations.default_auto_review_model.as_deref()) + { + model.insert( + "auto_review_model_override".to_string(), + Value::from(review_model), + ); + } Ok(()) } @@ -226,8 +274,21 @@ pub(super) fn snapshot_for_state( let mut model = value.as_object().cloned().ok_or("模型目录条目必须为对象")?; let slug = string_value(&model, "slug"); let key = normalize_id(&slug); - let defaults = editable_configuration(&model); + let mut defaults = editable_configuration(&model); + defaults.insert("auto_review_model_override".to_string(), Value::Null); apply_customizations(&mut model, &state.customizations)?; + let mut configuration = editable_configuration(&model); + // 编辑器展示用户的显式选择;null 表示继承,而不是解析后的实际审批模型。 + configuration.insert( + "auto_review_model_override".to_string(), + state + .customizations + .models + .get(&key) + .and_then(|fields| fields.get("auto_review_model_override")) + .cloned() + .unwrap_or(Value::Null), + ); models.push(CatalogEditorModel { slug, has_official_template: state.sources.templates.contains_key(&key), @@ -235,8 +296,8 @@ pub(super) fn snapshot_for_state( .iter() .find(|runtime| normalize_id(&runtime.slug) == key) .map_or("template", |runtime| runtime.context_source), - customized: state.customizations.contains_key(&key), - configuration: editable_configuration(&model), + customized: state.customizations.models.contains_key(&key), + configuration, defaults, }); } @@ -245,6 +306,7 @@ pub(super) fn snapshot_for_state( digest.update(serde_json::to_vec(&state.customizations).map_err(|error| error.to_string())?); Ok(CatalogEditorSnapshot { revision: format!("{:x}", digest.finalize()), + default_auto_review_model: state.customizations.default_auto_review_model.clone(), models, }) } @@ -268,6 +330,7 @@ fn customizations_from_request( if request.models.len() != snapshot.models.len() { return Err("模型列表已变化,请重新加载后编辑".to_string()); } + validate_review_model(request.default_auto_review_model.as_deref())?; let mut seen = HashSet::new(); let mut customizations = BTreeMap::new(); for requested in request.models { @@ -310,7 +373,10 @@ fn customizations_from_request( customizations.insert(key, changes); } } - Ok(customizations) + Ok(ModelCustomizations { + default_auto_review_model: request.default_auto_review_model, + models: customizations, + }) } fn decode_customizations(content: &[u8]) -> Result { @@ -322,6 +388,7 @@ fn decode_customizations(content: &[u8]) -> Result if saved.version != 1 { return Err("不支持的 Codex 自定义模型配置版本".to_string()); } + validate_review_model(saved.default_auto_review_model.as_deref())?; let mut normalized = BTreeMap::new(); for (slug, model) in saved.models { let key = normalize_id(&slug); @@ -331,7 +398,10 @@ fn decode_customizations(content: &[u8]) -> Result validate_configuration(&model)?; normalized.insert(key, model); } - Ok(normalized) + Ok(ModelCustomizations { + default_auto_review_model: saved.default_auto_review_model, + models: normalized, + }) } pub(crate) fn load_customizations(path: &Path) -> Result<(), String> { @@ -358,14 +428,15 @@ fn save_for_state( let customizations = customizations_from_request(&snapshot, request)?; let saved = SavedCustomizations { version: 1, - models: customizations, + default_auto_review_model: customizations.default_auto_review_model.clone(), + models: customizations.models.clone(), }; let content = serde_json::to_vec_pretty(&saved).map_err(|error| error.to_string())?; if content.len() > crate::MAX_CODEX_MODEL_CATALOG_BYTES { return Err("Codex 自定义模型配置超过大小限制".to_string()); } crate::write_bytes_atomically(path, &content)?; - state.customizations = saved.models; + state.customizations = customizations; snapshot_for_state(runtime_models, state) } @@ -408,6 +479,189 @@ mod tests { )) } + #[test] + fn approval_models_inherit_override_and_persist_without_materializing_defaults() { + let mut state = CatalogState { + sources: parse_sources(MODEL_CATALOG_JSON).unwrap(), + json: MODEL_CATALOG_JSON.to_string(), + customizations: Default::default(), + }; + let mut runtime_models = vec![runtime_model("main-a"), runtime_model("main-b")]; + let path = temporary_path(); + let snapshot = snapshot_for_state(&runtime_models, &state).unwrap(); + let request = CatalogEditorRequest { + revision: snapshot.revision, + default_auto_review_model: Some("team/Review Default".to_string()), + models: snapshot + .models + .into_iter() + .map(|model| { + let mut configuration = model.configuration; + if model.slug == "main-b" { + configuration.insert( + "auto_review_model_override".to_string(), + Value::from("review-special"), + ); + } + CatalogEditorModelRequest { + slug: model.slug, + configuration, + } + }) + .collect(), + }; + let saved = save_for_state(&path, &runtime_models, request, &mut state).unwrap(); + assert_eq!( + saved.default_auto_review_model.as_deref(), + Some("team/Review Default") + ); + assert!(!state.customizations.models.contains_key("main-a")); + + // 模拟重启和新增模型;审批目标暂时不在列表中也必须原样保留。 + state.customizations = decode_customizations(&std::fs::read(&path).unwrap()).unwrap(); + runtime_models.push(runtime_model("main-c")); + let generated = prepare_catalog_with_customizations( + &runtime_models, + &state.sources, + &state.customizations, + ) + .unwrap(); + let generated: Value = serde_json::from_str(&generated.json).unwrap(); + for model in generated["models"].as_array().unwrap() { + let expected = if model["slug"] == "main-b" { + "review-special" + } else { + "team/Review Default" + }; + assert_eq!(model["auto_review_model_override"], expected); + } + + let snapshot = snapshot_for_state(&runtime_models, &state).unwrap(); + assert!(snapshot + .models + .iter() + .filter(|model| model.slug != "main-b") + .all(|model| model.configuration["auto_review_model_override"].is_null())); + let stale_revision = snapshot.revision.clone(); + let request = CatalogEditorRequest { + revision: snapshot.revision, + default_auto_review_model: Some("review-next".to_string()), + models: snapshot + .models + .into_iter() + .map(|model| CatalogEditorModelRequest { + slug: model.slug, + configuration: model.configuration, + }) + .collect(), + }; + let saved = save_for_state(&path, &runtime_models, request, &mut state).unwrap(); + assert_ne!(saved.revision, stale_revision); + let generated = prepare_catalog_with_customizations( + &runtime_models, + &state.sources, + &state.customizations, + ) + .unwrap(); + let generated: Value = serde_json::from_str(&generated.json).unwrap(); + for model in generated["models"].as_array().unwrap() { + assert_eq!( + model["auto_review_model_override"], + if model["slug"] == "main-b" { + "review-special" + } else { + "review-next" + } + ); + } + + // 清除单模型覆盖后恢复继承;编辑器不应把统一默认保存成单模型固定值。 + let request = CatalogEditorRequest { + revision: saved.revision, + default_auto_review_model: saved.default_auto_review_model, + models: saved + .models + .into_iter() + .map(|model| CatalogEditorModelRequest { + slug: model.slug, + configuration: model.defaults, + }) + .collect(), + }; + save_for_state(&path, &runtime_models, request, &mut state).unwrap(); + assert!(state.customizations.models.is_empty()); + let generated = prepare_catalog_with_customizations( + &runtime_models, + &state.sources, + &state.customizations, + ) + .unwrap(); + let generated: Value = serde_json::from_str(&generated.json).unwrap(); + assert!(generated["models"] + .as_array() + .unwrap() + .iter() + .all(|model| model["auto_review_model_override"] == "review-next")); + let _ = std::fs::remove_file(path); + } + + #[test] + fn approval_defaults_preserve_templates_and_legacy_settings() { + let legacy = decode_customizations(br#"{"version":1,"models":{}}"#).unwrap(); + assert_eq!(legacy, ModelCustomizations::default()); + let prepared = prepare_catalog_with_customizations( + &[runtime_model("main-a")], + &parse_sources(MODEL_CATALOG_JSON).unwrap(), + &legacy, + ) + .unwrap(); + let prepared: Value = serde_json::from_str(&prepared.json).unwrap(); + let mut original = prepared["models"][0].as_object().unwrap().clone(); + original.insert( + "auto_review_model_override".to_string(), + Value::from("template-review"), + ); + let mut model = original.clone(); + apply_customizations(&mut model, &legacy).unwrap(); + assert_eq!(model, original); + + // 未设置统一默认时,null 也表示保留原模板,而非删除模板自带的审批配置。 + let settings = decode_customizations( + br#"{"version":1,"models":{"main-a":{"auto_review_model_override":null}}}"#, + ) + .unwrap(); + apply_customizations(&mut model, &settings).unwrap(); + assert_eq!(model["auto_review_model_override"], "template-review"); + assert_eq!(model, original); + + let mut no_template_override = serde_json::json!({"slug":"main-b"}) + .as_object() + .unwrap() + .clone(); + apply_customizations(&mut no_template_override, &legacy).unwrap(); + assert!(!no_template_override.contains_key("auto_review_model_override")); + } + + #[test] + fn approval_model_ids_are_validated_without_requiring_catalog_membership() { + for value in [ + serde_json::json!(""), + serde_json::json!(" review"), + serde_json::json!("review\n"), + serde_json::json!(42), + ] { + let settings = + serde_json::json!({"version":1,"default_auto_review_model": value,"models":{}}); + assert!(decode_customizations(&serde_json::to_vec(&settings).unwrap()).is_err()); + let settings = serde_json::json!({"version":1,"models":{"main":{"auto_review_model_override":value}}}); + assert!(decode_customizations(&serde_json::to_vec(&settings).unwrap()).is_err()); + } + assert!(decode_customizations( + br#"{"version":1,"default_auto_review_model":"team/Codex Auto Review","models":{}}"# + ) + .is_ok()); + } + #[test] fn customizations_persist_apply_and_restore_to_the_current_template() { let sources = parse_sources(MODEL_CATALOG_JSON).unwrap(); @@ -428,6 +682,7 @@ mod tests { &runtime_models, CatalogEditorRequest { revision: snapshot.revision, + default_auto_review_model: None, models: vec![CatalogEditorModelRequest { slug: "third-party-model".to_string(), configuration, @@ -458,6 +713,7 @@ mod tests { &runtime_models, CatalogEditorRequest { revision: saved.revision, + default_auto_review_model: None, models: vec![CatalogEditorModelRequest { slug: "third-party-model".to_string(), configuration: saved.models[0].defaults.clone(), @@ -467,7 +723,7 @@ mod tests { ) .unwrap(); assert!(!restored.models[0].customized); - assert!(state.customizations.is_empty()); + assert!(state.customizations.models.is_empty()); let persisted: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); assert_eq!(persisted["models"], serde_json::json!({})); @@ -497,6 +753,7 @@ mod tests { &snapshot, CatalogEditorRequest { revision: snapshot.revision.clone(), + default_auto_review_model: None, models: vec![CatalogEditorModelRequest { slug: runtime.slug.clone(), configuration, @@ -522,6 +779,7 @@ mod tests { &updated, CatalogEditorRequest { revision: updated.revision.clone(), + default_auto_review_model: None, models: vec![CatalogEditorModelRequest { slug: runtime.slug.clone(), configuration: updated.models[0].defaults.clone(), @@ -529,7 +787,7 @@ mod tests { }, ) .unwrap(); - assert!(state.customizations.is_empty()); + assert!(state.customizations.models.is_empty()); let generated = prepare_catalog_with_customizations(&[runtime], &state.sources, &state.customizations) .unwrap(); @@ -554,6 +812,7 @@ mod tests { &runtime_models, CatalogEditorRequest { revision: "stale".to_string(), + default_auto_review_model: None, models: vec![CatalogEditorModelRequest { slug: "third-party-model".to_string(), configuration: snapshot.models[0].configuration.clone(), @@ -564,7 +823,7 @@ mod tests { .unwrap_err(); assert_eq!(error, "CODEX_MODEL_CATALOG_CHANGED"); - assert!(state.customizations.is_empty()); + assert!(state.customizations.models.is_empty()); assert!(!path.exists()); } } diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index cd5c315c..4de67ef2 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -75,6 +75,12 @@ export const jaOverrides = { 'agents.catalog.empty': '利用可能なモデルがありません。コアに接続し、モデルの提供元を設定してください。', 'agents.catalog.displayName': '表示名', 'agents.catalog.description': 'モデルの説明', + 'agents.catalog.defaultReviewModel': '全モデル共通の既定承認モデル', + 'agents.catalog.reviewTemplateDefault': '上書きせず、モデルテンプレートの既定動作を維持', + 'agents.catalog.defaultReviewHint': '承認モデルを個別指定していないモデル(今後追加するモデルを含む)に適用します。個別指定が優先されます。承認ルールや権限は変更しません。選択したモデルは現在のプロキシから呼び出せる必要があります。', + 'agents.catalog.reviewModelOverride': 'このメインモデルの承認モデル', + 'agents.catalog.reviewInherit': '共通の既定設定を継承', + 'agents.catalog.reviewModelMissing': 'モデル {model} は現在の一覧にありません。選択は保持されています。プロキシのルーティングを確認するか、別のモデルを選択してください。', 'agents.catalog.context': 'コンテキストウィンドウ(トークン)', 'agents.catalog.contextSource.definition': 'コンテキストの取得元:CPA コア API のモデル定義。', 'agents.catalog.contextSource.configuration': 'コンテキストの取得元:CPA コアでこのモデルに設定された max-context-length。', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index a22291ea..1f908470 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -25,6 +25,12 @@ export const en: Record = { 'agents.catalog.empty': 'No models are available. Connect the core and configure a model source first.', 'agents.catalog.displayName': 'Display name', 'agents.catalog.description': 'Description', + 'agents.catalog.defaultReviewModel': 'Default approval model for all models', + 'agents.catalog.reviewTemplateDefault': 'No override — preserve model template defaults', + 'agents.catalog.defaultReviewHint': 'Used by models without an individual approval model, including newly added models. Individual selections take priority. Approval rules and permissions stay unchanged; the selected model must be callable through the current proxy.', + 'agents.catalog.reviewModelOverride': 'Approval model for this primary model', + 'agents.catalog.reviewInherit': 'Inherit the shared default', + 'agents.catalog.reviewModelMissing': 'Model {model} is no longer listed. Your selection is preserved; check the proxy route or choose another model.', 'agents.catalog.context': 'Context window (tokens)', 'agents.catalog.contextSource.definition': 'Context source: model definitions from the CPA core API.', 'agents.catalog.contextSource.configuration': 'Context source: max-context-length configured for this model in the CPA core.', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 7a326280..f72934cd 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -24,6 +24,12 @@ export const zhCN = { 'agents.catalog.empty': '当前没有可用模型,请先连接内核并配置模型来源。', 'agents.catalog.displayName': '显示名称', 'agents.catalog.description': '模型说明', + 'agents.catalog.defaultReviewModel': '统一默认审批模型', + 'agents.catalog.reviewTemplateDefault': '不覆盖,保留模型模板默认行为', + 'agents.catalog.defaultReviewHint': '未单独指定审批模型的主模型(包括后续新增模型)都使用此设置;单模型指定优先。不改变审批规则和权限,目标模型需能通过当前代理调用。', + 'agents.catalog.reviewModelOverride': '此主模型的审批模型', + 'agents.catalog.reviewInherit': '继承统一默认设置', + 'agents.catalog.reviewModelMissing': '模型 {model} 已不在当前列表中,已保留原选择;请检查代理路由或重新选择。', 'agents.catalog.context': '上下文窗口(tokens)', 'agents.catalog.contextSource.definition': '上下文来源:CPA 内核的模型定义 API。', 'agents.catalog.contextSource.configuration': '上下文来源:CPA 内核中为此模型配置的 max-context-length。', diff --git a/src/pages/CodexModelCatalogDialog.tsx b/src/pages/CodexModelCatalogDialog.tsx index 6eeea9c0..0547cc51 100644 --- a/src/pages/CodexModelCatalogDialog.tsx +++ b/src/pages/CodexModelCatalogDialog.tsx @@ -33,6 +33,7 @@ export function CodexModelCatalogDialog({ onClose, onSaved }: CodexModelCatalogD const { t } = useI18n(); const [snapshot, setSnapshot] = useState(null); const [models, setModels] = useState([]); + const [defaultAutoReviewModel, setDefaultAutoReviewModel] = useState(null); const [selectedSlug, setSelectedSlug] = useState(''); const [search, setSearch] = useState(''); const [loading, setLoading] = useState(true); @@ -49,6 +50,7 @@ export function CodexModelCatalogDialog({ onClose, onSaved }: CodexModelCatalogD try { const next = await invoke('get_codex_model_catalog_editor'); setSnapshot(next); + setDefaultAutoReviewModel(restoreDefaults ? null : next.defaultAutoReviewModel); setModels(cloneModels(next).map((model) => restoreDefaults ? { ...model, configuration: cloneCodexModelConfiguration(model.defaults) } : model)); @@ -72,11 +74,12 @@ export function CodexModelCatalogDialog({ onClose, onSaved }: CodexModelCatalogD const dirty = useMemo(() => { if (defaultsRestored) return true; if (!snapshot || snapshot.models.length !== models.length) return false; + if (defaultAutoReviewModel !== snapshot.defaultAutoReviewModel) return true; return models.some((model) => { const saved = snapshot.models.find((candidate) => candidate.slug === model.slug); return !saved || !sameCodexModelConfiguration(model.configuration, saved.configuration); }); - }, [defaultsRestored, models, snapshot]); + }, [defaultsRestored, defaultAutoReviewModel, models, snapshot]); const filteredModels = useMemo(() => { const query = search.trim().toLocaleLowerCase(); @@ -86,6 +89,12 @@ export function CodexModelCatalogDialog({ onClose, onSaved }: CodexModelCatalogD }, [models, search]); const activeModel = models.find((model) => model.slug === selectedSlug) ?? null; + const reviewModelMissing = (slug: string | null) => slug !== null && !models.some((model) => model.slug === slug); + // 审批模型选项包含隐藏模型;已保存的失效 ID 仍展示,避免下拉框静默切换路由。 + const reviewModelOptions = (selected: string | null) => <> + {reviewModelMissing(selected) ? : null} + {models.map((model) => )} + ; const activeCustomized = activeModel ? !sameCodexModelConfiguration(activeModel.configuration, activeModel.defaults) : false; @@ -161,6 +170,7 @@ export function CodexModelCatalogDialog({ onClose, onSaved }: CodexModelCatalogD const result = await invoke('save_codex_model_catalog_editor', { request: { revision: snapshot.revision, + default_auto_review_model: defaultAutoReviewModel, models: models.map((model) => ({ slug: model.slug, configuration: model.configuration, @@ -168,6 +178,7 @@ export function CodexModelCatalogDialog({ onClose, onSaved }: CodexModelCatalogD }, }); setSnapshot(result.snapshot); + setDefaultAutoReviewModel(result.snapshot.defaultAutoReviewModel); setModels(cloneModels(result.snapshot)); setDefaultsRestored(false); setNotice(result.synchronizationError @@ -236,6 +247,22 @@ export function CodexModelCatalogDialog({ onClose, onSaved }: CodexModelCatalogD
+ {!loading && snapshot ?
+
+ +
+

{t('agents.catalog.defaultReviewHint')}

+ {reviewModelMissing(defaultAutoReviewModel) ?

{t('agents.catalog.reviewModelMissing', { model: defaultAutoReviewModel! })}

: null} +
: null} {activeModel && !loading ? ( <>
@@ -252,6 +279,13 @@ export function CodexModelCatalogDialog({ onClose, onSaved }: CodexModelCatalogD
+