diff --git a/src-tauri/src/agents/commands.rs b/src-tauri/src/agents/commands.rs index 2602145f..ddb26bb8 100644 --- a/src-tauri/src/agents/commands.rs +++ b/src-tauri/src/agents/commands.rs @@ -1057,8 +1057,14 @@ pub(crate) async fn apply_agent_config( oauth_configuration: bool, claude_code_model_mappings: Option, claude_desktop_model_mappings: Option, + codex_review_model: Option, ) -> Result { let client = AgentClient::parse(&client)?; + let _sync_guard = if client == AgentClient::Codex { + Some(CODEX_CATALOG_SYNC_LOCK.lock().await) + } else { + None + }; let home = app .path() .home_dir() @@ -1069,7 +1075,17 @@ pub(crate) async fn apply_agent_config( validate_codex_oauth_login(&home)?; } validate_agent_can_enable(client, &home, config.port, api_key)?; - let prepared = fetch_prepared_agent_models(client, &config).await?; + // 全局审批设置与主模型共用应用入口,生成时保留最新的单模型覆盖。 + let runtime_models = if client == AgentClient::Codex && codex_review_model.is_some() { + Some(fetch_codex_catalog_runtime_models(&config).await?) + } else { + None + }; + let prepared = if let Some(runtime_models) = &runtime_models { + prepare_codex_agent_models(runtime_models)? + } else { + fetch_prepared_agent_models(client, &config).await? + }; let model = resolve_available_agent_model(&prepared.models, &validate_agent_model(&model)?)?; let claude_code_model_mappings = resolve_claude_code_model_mappings( client, @@ -1089,7 +1105,7 @@ pub(crate) async fn apply_agent_config( let _guard = AGENT_CONFIG_FILE_LOCK .lock() .map_err(|_| "智能体配置文件锁已损坏".to_string())?; - apply_agent_configuration_with_oauth( + let apply = |catalog: Option<&str>| apply_agent_configuration_with_oauth( client, &home, config.port, @@ -1097,12 +1113,22 @@ pub(crate) async fn apply_agent_config( &model, AgentConfigurationOptions { models: &prepared.models, - codex_catalog: prepared.codex_catalog.as_deref(), + codex_catalog: catalog, oauth_configuration, claude_code_model_mappings: claude_code_model_mappings.as_ref(), claude_desktop_model_mappings: claude_desktop_model_mappings.as_ref(), }, - ) + ); + if let (Some(request), Some(runtime_models)) = (codex_review_model, runtime_models) { + codex_catalog::apply_default_review_model( + &codex_model_customizations_path(&app)?, + &runtime_models, + request, + |catalog| apply(Some(catalog)), + ) + } else { + apply(prepared.codex_catalog.as_deref()) + } } #[tauri::command] diff --git a/src-tauri/src/codex_catalog.rs b/src-tauri/src/codex_catalog.rs index ab642602..8e918c5b 100644 --- a/src-tauri/src/codex_catalog.rs +++ b/src-tauri/src/codex_catalog.rs @@ -6,8 +6,8 @@ use std::sync::{OnceLock, RwLock}; mod customizations; mod runtime_context; pub(crate) use customizations::{ - editor_snapshot, load_customizations, save_customizations, CatalogEditorRequest, - CatalogEditorSnapshot, + apply_default_review_model, editor_snapshot, load_customizations, save_customizations, + CatalogEditorRequest, CatalogEditorSnapshot, DefaultReviewModelRequest, }; pub(crate) use runtime_context::{apply_configured_context_limits, merge_context_definitions}; @@ -1371,7 +1371,6 @@ mod tests { "guardian": null, "node_repl_auto_review_required": false, "node_repl_disabled": false, - "auto_review_model_override": null, "model_specialty": null, "tool_mode": null, "multi_agent_version": null, @@ -1392,6 +1391,8 @@ mod tests { for (field, value) in expected.as_object().unwrap() { assert_eq!(model.get(field), Some(value), "field: {field}"); } + // 默认审批行为要求省略覆盖字段,而不是从模板保留 null。 + assert!(!model.contains_key("auto_review_model_override")); assert_eq!( model["model_messages"]["instructions_template"], sources.fallback["base_instructions"] diff --git a/src-tauri/src/codex_catalog/customizations.rs b/src-tauri/src/codex_catalog/customizations.rs index 7fbac066..70022da2 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, + // 未指定全局模型时使用 Codex 默认选择逻辑,不保留模板中的审批覆盖。 + #[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, } @@ -52,6 +61,13 @@ pub(crate) struct CatalogEditorRequest { models: Vec, } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct DefaultReviewModelRequest { + revision: String, + model: Option, +} + #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct CatalogEditorModelRequest { @@ -85,6 +101,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() && *value != serde_json::json!({"mode": "codex_default"}) { + validate_review_model(Some(value.as_str().ok_or("审批模型 ID 必须是字符串")?))?; + } + } for field in [ "context_window", "max_context_window", @@ -193,17 +214,51 @@ 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, _)| field.as_str() != "auto_review_model_override") + .map(|(field, value)| (field.clone(), value.clone())), + ); validate_configuration(&editable_configuration(model)) .map_err(|error| format!("模型 {slug} 的自定义配置无效: {error}"))?; enable_fast_mode(model); } + // 单模型有三态:null/缺省继承全局,明确 Codex 默认则绕过全局,字符串指定模型。 + // 先移除模板覆盖;Codex 默认必须真正交回客户端选择,不能回退到模板覆盖。 + let selection = customization.and_then(|fields| fields.get("auto_review_model_override")); + let review_model = match selection { + None | Some(Value::Null) => customizations.default_auto_review_model.as_deref(), + Some(Value::String(model)) => Some(model.as_str()), + _ => None, + }; + model.remove("auto_review_model_override"); + if let Some(review_model) = review_model { + model.insert( + "auto_review_model_override".to_string(), + Value::from(review_model), + ); + } Ok(()) } @@ -226,8 +281,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 +303,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 +313,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, }) } @@ -310,7 +379,11 @@ fn customizations_from_request( customizations.insert(key, changes); } } - Ok(customizations) + Ok(ModelCustomizations { + // 弹窗只保存单模型设置,不能覆盖主页面管理的全局选择。 + default_auto_review_model: snapshot.default_auto_review_model.clone(), + models: customizations, + }) } fn decode_customizations(content: &[u8]) -> Result { @@ -322,6 +395,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 +405,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> { @@ -356,17 +433,59 @@ fn save_for_state( ) -> Result { let snapshot = snapshot_for_state(runtime_models, state)?; let customizations = customizations_from_request(&snapshot, request)?; + persist_customizations(path, &customizations)?; + state.customizations = customizations; + snapshot_for_state(runtime_models, state) +} + +fn persist_customizations(path: &Path, customizations: &ModelCustomizations) -> Result<(), String> { 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; - snapshot_for_state(runtime_models, state) + Ok(()) +} + +// 全局选择只由现有“更新配置”入口提交。用候选配置生成目录,应用成功后才持久化。 +// 持有目录锁确保单模型保存和后台同步不会与本次提交交错。 +pub(crate) fn apply_default_review_model( + path: &Path, + runtime_models: &[CodexRuntimeModel], + request: DefaultReviewModelRequest, + apply: impl FnOnce(&str) -> Result, +) -> Result { + let mut state = catalog_state()? + .write() + .map_err(|_| "Codex 模型目录内存锁已损坏")?; + apply_default_review_model_for_state(path, runtime_models, request, &mut state, apply) +} + +fn apply_default_review_model_for_state( + path: &Path, + runtime_models: &[CodexRuntimeModel], + request: DefaultReviewModelRequest, + state: &mut CatalogState, + apply: impl FnOnce(&str) -> Result, +) -> Result { + if request.revision != snapshot_for_state(runtime_models, state)?.revision { + return Err("CODEX_MODEL_CATALOG_CHANGED".to_string()); + } + validate_review_model(request.model.as_deref())?; + let mut candidate = state.customizations.clone(); + candidate.default_auto_review_model = request.model; + let prepared = prepare_catalog_with_customizations(runtime_models, &state.sources, &candidate)?; + let result = apply(&prepared.json)?; + persist_customizations(path, &candidate).map_err(|error| { + format!("Codex 配置已应用,但审批模型设置保存失败,请重试更新配置: {error}") + })?; + state.customizations = candidate; + Ok(result) } pub(crate) fn save_customizations( @@ -408,6 +527,328 @@ mod tests { )) } + #[test] + fn approval_selection_matrix_removes_templates_without_materializing_inheritance() { + // 每种单模型状态分别覆盖全局默认/指定模型,以及有无模板覆盖两种情况。 + for global in [None, Some("review-a")] { + for selection in [ + Value::Null, + serde_json::json!({"mode":"codex_default"}), + Value::from("review-b"), + ] { + for template in [None, Some("template-review")] { + let settings = ModelCustomizations { + default_auto_review_model: global.map(str::to_string), + models: BTreeMap::from([( + "main-a".to_string(), + Map::from_iter([( + "auto_review_model_override".to_string(), + selection.clone(), + )]), + )]), + }; + let mut model = serde_json::json!({"slug":"main-a"}) + .as_object() + .unwrap() + .clone(); + if let Some(template) = template { + model.insert( + "auto_review_model_override".to_string(), + Value::from(template), + ); + } + // 使用完整模板满足其他字段的既有校验。 + let sources = parse_sources(MODEL_CATALOG_JSON).unwrap(); + let prepared = prepare_catalog_with_customizations( + &[runtime_model("main-a")], + &sources, + &Default::default(), + ) + .unwrap(); + let root: Value = serde_json::from_str(&prepared.json).unwrap(); + let mut full = root["models"][0].as_object().unwrap().clone(); + full.extend(model); + apply_customizations(&mut full, &settings).unwrap(); + let expected = if selection.is_null() { + global + } else { + selection.as_str() + }; + assert_eq!( + full.get("auto_review_model_override") + .and_then(Value::as_str), + expected + ); + if expected.is_none() { + assert!(!full.contains_key("auto_review_model_override")); + } + assert_eq!( + settings.models["main-a"]["auto_review_model_override"], + selection + ); + } + } + } + } + + #[test] + fn global_apply_preserves_model_choices_and_rejects_stale_or_failed_updates() { + let mut state = CatalogState { + sources: parse_sources(MODEL_CATALOG_JSON).unwrap(), + json: MODEL_CATALOG_JSON.to_string(), + customizations: decode_customizations(br#"{"version":1,"default_auto_review_model":"review-old","models":{"main-b":{"auto_review_model_override":{"mode":"codex_default"}}}}"#).unwrap(), + }; + let runtime = vec![runtime_model("main-a"), runtime_model("main-b")]; + let path = temporary_path(); + let before = state.customizations.clone(); + let revision = snapshot_for_state(&runtime, &state).unwrap().revision; + let failed = apply_default_review_model_for_state( + &path, + &runtime, + DefaultReviewModelRequest { + revision: revision.clone(), + model: Some("review-new".to_string()), + }, + &mut state, + |_| Err::<(), _>("apply failed".to_string()), + ); + assert!(failed.is_err()); + assert_eq!(state.customizations, before); + assert!(!path.exists()); + apply_default_review_model_for_state( + &path, + &runtime, + DefaultReviewModelRequest { + revision: revision.clone(), + model: Some("review-new".to_string()), + }, + &mut state, + |catalog| { + let root: Value = serde_json::from_str(catalog).unwrap(); + for model in root["models"].as_array().unwrap() { + if model["slug"] == "main-a" { + assert_eq!(model["auto_review_model_override"], "review-new"); + } else { + assert!(model.get("auto_review_model_override").is_none()); + } + } + Ok(()) + }, + ) + .unwrap(); + assert_eq!(state.customizations.models, before.models); + assert_eq!( + decode_customizations(&std::fs::read(&path).unwrap()).unwrap(), + state.customizations + ); + let result: Result<(), String> = apply_default_review_model_for_state( + &path, + &runtime, + DefaultReviewModelRequest { + revision, + model: None, + }, + &mut state, + |_| panic!("过期请求不能应用配置"), + ); + assert!(result.unwrap_err().contains("CODEX_MODEL_CATALOG_CHANGED")); + let _ = std::fs::remove_file(path); + } + + #[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(); + state.customizations.default_auto_review_model = Some("team/Review Default".to_string()); + let snapshot = snapshot_for_state(&runtime_models, &state).unwrap(); + let request = CatalogEditorRequest { + revision: snapshot.revision, + 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(); + apply_default_review_model_for_state( + &path, + &runtime_models, + DefaultReviewModelRequest { + revision: snapshot.revision.clone(), + model: Some("review-next".to_string()), + }, + &mut state, + |_| Ok(()), + ) + .unwrap(); + let snapshot = snapshot_for_state(&runtime_models, &state).unwrap(); + let request = CatalogEditorRequest { + revision: snapshot.revision, + 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, + 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_remove_template_overrides_and_read_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(); + original.remove("auto_review_model_override"); + assert_eq!(model, original); + + // 旧配置中的 null 仍是继承;全局未指定时清除模板覆盖,交给 Codex 默认逻辑。 + let settings = decode_customizations( + br#"{"version":1,"models":{"main-a":{"auto_review_model_override":null}}}"#, + ) + .unwrap(); + apply_customizations(&mut model, &settings).unwrap(); + assert!(!model.contains_key("auto_review_model_override")); + 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(); @@ -467,7 +908,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!({})); @@ -529,7 +970,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(); @@ -564,7 +1005,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/components/AgentModelPicker.tsx b/src/components/AgentModelPicker.tsx new file mode 100644 index 00000000..63cd565f --- /dev/null +++ b/src/components/AgentModelPicker.tsx @@ -0,0 +1,266 @@ +import { useState, useRef, useMemo, useCallback, useLayoutEffect, useEffect, useId, type KeyboardEvent } from 'react'; +import { Check, ChevronDown, LoaderCircle, RefreshCw, Search, X } from 'lucide-react'; +import { useI18n } from '../i18n'; +import { agentModelAlias, filterAgentModels, findAgentModel } from '../services/agentModelPicker'; +import type { ModelOption } from '../services/modelService'; + +// 复用主页面既有选择器,统一主模型和审批模型的交互及样式。 +type AgentModelPickerProps = { + models: ModelOption[]; + value: string; + loading: boolean; + error: string; + disabled: boolean; + onChange: (value: string) => void; + onRefresh: () => void; + specialOptions?: Array<{ value: string; label: string }>; + specialValue?: string; + onSpecialChange?: (value: string) => void; + exactModelIds?: boolean; + ariaLabel?: string; +}; + +type AgentModelDropdownLayout = { + top: number; + left: number; + width: number; + height: number; +}; + +export function AgentModelPicker({ + models, + value, + loading, + error, + disabled, + onChange, + onRefresh, + specialOptions = [], + specialValue, + onSpecialChange, + exactModelIds = false, + ariaLabel, +}: AgentModelPickerProps) { + const { t } = useI18n(); + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); + const [activeIndex, setActiveIndex] = useState(0); + const [dropdownLayout, setDropdownLayout] = useState(null); + const rootRef = useRef(null); + const searchRef = useRef(null); + const listboxId = useId(); + const visibleModels = useMemo(() => filterAgentModels(models, search), [models, search]); + // 特殊选项单独携带类型,不占用任何真实模型 ID(包括同名模型)。 + const choices = [ + ...specialOptions.filter((option) => option.label.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())) + .map((option) => ({ name: option.label, alias: '', special: option.value })), + ...visibleModels.map((model) => ({ name: model.name, alias: model.alias ?? '', special: undefined })), + ]; + const isSelected = (choice: typeof choices[number]) => choice.special !== undefined + ? choice.special === specialValue + : specialValue === undefined && (exactModelIds ? choice.name === value : choice.name.toLocaleLowerCase() === value.trim().toLocaleLowerCase()); + const selectedModel = exactModelIds ? models.find((model) => model.name === value) : findAgentModel(models, value); + const selectedName = specialOptions.find((option) => option.value === specialValue)?.label ?? selectedModel?.name ?? ''; + const selectedAlias = specialValue === undefined && selectedName ? agentModelAlias(models, selectedName) : ''; + + const updateDropdownLayout = useCallback(() => { + const root = rootRef.current; + if (!root) return; + + const rect = root.getBoundingClientRect(); + const edgeGap = 12; + const triggerGap = 6; + const preferredHeight = 282; + const minimumHeight = 150; + const spaceBelow = Math.max(0, window.innerHeight - rect.bottom - triggerGap - edgeGap); + const spaceAbove = Math.max(0, rect.top - triggerGap - edgeGap); + const placeAbove = spaceBelow < preferredHeight && spaceAbove > spaceBelow; + const availableHeight = placeAbove ? spaceAbove : spaceBelow; + const height = Math.min(preferredHeight, Math.max(minimumHeight, availableHeight)); + const width = Math.min(rect.width, window.innerWidth - edgeGap * 2); + const left = Math.min( + Math.max(edgeGap, rect.left), + Math.max(edgeGap, window.innerWidth - edgeGap - width), + ); + const desiredTop = placeAbove + ? rect.top - triggerGap - height + : rect.bottom + triggerGap; + const top = Math.min( + Math.max(edgeGap, desiredTop), + Math.max(edgeGap, window.innerHeight - edgeGap - height), + ); + + setDropdownLayout({ top, left, width, height }); + }, []); + + useLayoutEffect(() => { + if (!open) { + setDropdownLayout(null); + return undefined; + } + + updateDropdownLayout(); + window.addEventListener('resize', updateDropdownLayout); + window.addEventListener('scroll', updateDropdownLayout, true); + return () => { + window.removeEventListener('resize', updateDropdownLayout); + window.removeEventListener('scroll', updateDropdownLayout, true); + }; + }, [open, updateDropdownLayout]); + + useEffect(() => { + if (!open) return undefined; + const close = (event: MouseEvent) => { + if (!rootRef.current?.contains(event.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', close); + return () => document.removeEventListener('mousedown', close); + }, [open]); + + useEffect(() => { + if (!open) return; + setSearch(''); + const selectedIndex = choices.findIndex(isSelected); + setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0); + requestAnimationFrame(() => searchRef.current?.focus()); + }, [open]); + + useEffect(() => { + setActiveIndex((current) => Math.min(current, Math.max(choices.length - 1, 0))); + }, [choices.length]); + + const choose = (choice: typeof choices[number]) => { + if (choice.special !== undefined) onSpecialChange?.(choice.special); + else onChange(choice.name); + setOpen(false); + }; + + const moveActive = (offset: number) => { + if (choices.length === 0) return; + setActiveIndex((current) => (current + offset + choices.length) % choices.length); + }; + + const handleSearchKeyDown = (event: KeyboardEvent) => { + if (event.key === 'ArrowDown') { + event.preventDefault(); + moveActive(1); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + moveActive(-1); + } else if (event.key === 'Enter' && choices[activeIndex]) { + event.preventDefault(); + choose(choices[activeIndex]); + } else if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + setOpen(false); + } + }; + + return ( +
+ + + {open ? ( +
+
+ + { + setSearch(event.currentTarget.value); + setActiveIndex(0); + }} + onKeyDown={handleSearchKeyDown} + placeholder={t('agents.model.search')} + role="combobox" + aria-controls={listboxId} + aria-expanded="true" + /> + {search ? ( + + ) : null} + +
+ +
+ {loading && models.length === 0 ? ( +
{t('agents.model.fetching')}
+ ) : error && models.length === 0 ? ( +
{t('agents.model.loadFailed')}{error}
+ ) : choices.length === 0 ? ( +
+ {search.trim() ? t('agents.model.noMatch') : t('agents.model.unavailable')} + {search.trim() ? t('agents.model.tryKeywords') : t('agents.model.connectFirst')} +
+ ) : choices.map((choice, index) => { + const selected = isSelected(choice); + return ( + + ); + })} +
+
+ {t('agents.model.count', { count: models.length })} + {error && models.length > 0 ? {t('agents.model.stale')} : null} +
+
+ ) : null} +
+ ); +} diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index cd5c315c..c05abcee 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': 'Codex の既定', + 'agents.catalog.defaultReviewHint': '全メインモデル共通の既定値です。既存の設定ボタンで適用します。個別設定が優先され、Codex の既定では承認モデルを上書きしません。権限は変更しません。選択したモデルは現在のプロキシから呼び出せる必要があります。', + '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..c7d5f877 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': 'Approval model', + 'agents.catalog.reviewTemplateDefault': 'Codex default', + 'agents.catalog.defaultReviewHint': 'Global default for all primary models; apply using the existing configuration button. Individual settings take priority. Codex default omits the approval override. Permissions stay unchanged; the selected model must be callable through the current proxy.', + 'agents.catalog.reviewModelOverride': 'Approval model', + 'agents.catalog.reviewInherit': 'Inherit global configuration', + '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..054452d0 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': 'Codex 默认', + 'agents.catalog.defaultReviewHint': '作为所有主模型的全局默认值,点击现有配置按钮后应用。单模型设置优先;Codex 默认不写入审批覆盖。不改变审批权限,目标模型需能通过当前代理调用。', + '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/AgentsPage.tsx b/src/pages/AgentsPage.tsx index d1220051..8ebd13e5 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -1,12 +1,10 @@ import { useCallback, useEffect, - useLayoutEffect, useMemo, useRef, useState, type ComponentType, - type KeyboardEvent, } from 'react'; import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; @@ -17,11 +15,9 @@ import { BadgeCheck, Bot, Check, - ChevronDown, LoaderCircle, Play, RefreshCw, - Search, SlidersHorizontal, Square, Trash2, @@ -40,8 +36,6 @@ import opencodeIcon from '../assets/icons/opencode.svg'; import piIcon from '../assets/icons/pi-logo-on-light.svg'; import zcodeIcon from '../assets/icons/zcode.png'; import { - agentModelAlias, - filterAgentModels, filterAgentModelsByAlias, findAgentModel, resolveAgentModelForAliasMode, @@ -69,6 +63,8 @@ import type { ModelOption } from '../services/modelService'; import { getCurrentLocale, translate, useI18n } from '../i18n'; import { CodexSessionsPanel } from './CodexSessionsPanel'; import { CodexModelCatalogDialog } from './CodexModelCatalogDialog'; +import { AgentModelPicker } from '../components/AgentModelPicker'; +import { reviewModelPickerModels, type CodexCatalogEditorSnapshot } from '../services/codexModelCatalog'; type AgentClientId = | 'claude-code' @@ -403,248 +399,6 @@ const listStatusText = (status: AgentConfigStatus | undefined) => { : translate(locale, 'agents.list.installed'); }; -type AgentModelPickerProps = { - models: ModelOption[]; - value: string; - loading: boolean; - error: string; - disabled: boolean; - onChange: (value: string) => void; - onRefresh: () => void; -}; - -type AgentModelDropdownLayout = { - top: number; - left: number; - width: number; - height: number; -}; - -function AgentModelPicker({ - models, - value, - loading, - error, - disabled, - onChange, - onRefresh, -}: AgentModelPickerProps) { - const { t } = useI18n(); - const [open, setOpen] = useState(false); - const [search, setSearch] = useState(''); - const [activeIndex, setActiveIndex] = useState(0); - const [dropdownLayout, setDropdownLayout] = useState(null); - const rootRef = useRef(null); - const searchRef = useRef(null); - const visibleModels = useMemo(() => filterAgentModels(models, search), [models, search]); - const choices = useMemo( - () => visibleModels.map((model) => ({ name: model.name, alias: model.alias ?? '' })), - [visibleModels], - ); - const selectedModel = findAgentModel(models, value); - const selectedName = selectedModel?.name ?? ''; - const selectedAlias = selectedName ? agentModelAlias(models, selectedName) : ''; - - const updateDropdownLayout = useCallback(() => { - const root = rootRef.current; - if (!root) return; - - const rect = root.getBoundingClientRect(); - const edgeGap = 12; - const triggerGap = 6; - const preferredHeight = 282; - const minimumHeight = 150; - const spaceBelow = Math.max(0, window.innerHeight - rect.bottom - triggerGap - edgeGap); - const spaceAbove = Math.max(0, rect.top - triggerGap - edgeGap); - const placeAbove = spaceBelow < preferredHeight && spaceAbove > spaceBelow; - const availableHeight = placeAbove ? spaceAbove : spaceBelow; - const height = Math.min(preferredHeight, Math.max(minimumHeight, availableHeight)); - const width = Math.min(rect.width, window.innerWidth - edgeGap * 2); - const left = Math.min( - Math.max(edgeGap, rect.left), - Math.max(edgeGap, window.innerWidth - edgeGap - width), - ); - const desiredTop = placeAbove - ? rect.top - triggerGap - height - : rect.bottom + triggerGap; - const top = Math.min( - Math.max(edgeGap, desiredTop), - Math.max(edgeGap, window.innerHeight - edgeGap - height), - ); - - setDropdownLayout({ top, left, width, height }); - }, []); - - useLayoutEffect(() => { - if (!open) { - setDropdownLayout(null); - return undefined; - } - - updateDropdownLayout(); - window.addEventListener('resize', updateDropdownLayout); - window.addEventListener('scroll', updateDropdownLayout); - return () => { - window.removeEventListener('resize', updateDropdownLayout); - window.removeEventListener('scroll', updateDropdownLayout); - }; - }, [open, updateDropdownLayout]); - - useEffect(() => { - if (!open) return undefined; - const close = (event: MouseEvent) => { - if (!rootRef.current?.contains(event.target as Node)) setOpen(false); - }; - document.addEventListener('mousedown', close); - return () => document.removeEventListener('mousedown', close); - }, [open]); - - useEffect(() => { - if (!open) return; - setSearch(''); - const selectedIndex = filterAgentModels(models, '').findIndex( - (model) => model.name.toLocaleLowerCase() === value.trim().toLocaleLowerCase(), - ); - setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0); - requestAnimationFrame(() => searchRef.current?.focus()); - }, [open]); - - useEffect(() => { - setActiveIndex((current) => Math.min(current, Math.max(choices.length - 1, 0))); - }, [choices.length]); - - const choose = (name: string) => { - onChange(name); - setOpen(false); - }; - - const moveActive = (offset: number) => { - if (choices.length === 0) return; - setActiveIndex((current) => (current + offset + choices.length) % choices.length); - }; - - const handleSearchKeyDown = (event: KeyboardEvent) => { - if (event.key === 'ArrowDown') { - event.preventDefault(); - moveActive(1); - } else if (event.key === 'ArrowUp') { - event.preventDefault(); - moveActive(-1); - } else if (event.key === 'Enter' && choices[activeIndex]) { - event.preventDefault(); - choose(choices[activeIndex].name); - } else if (event.key === 'Escape') { - event.preventDefault(); - setOpen(false); - } - }; - - return ( -
- - - {open ? ( -
-
- - { - setSearch(event.currentTarget.value); - setActiveIndex(0); - }} - onKeyDown={handleSearchKeyDown} - placeholder={t('agents.model.search')} - role="combobox" - aria-controls="agent-model-listbox" - aria-expanded="true" - /> - {search ? ( - - ) : null} - -
- -
- {loading && models.length === 0 ? ( -
{t('agents.model.fetching')}
- ) : error && models.length === 0 ? ( -
{t('agents.model.loadFailed')}{error}
- ) : choices.length === 0 ? ( -
- {search.trim() ? t('agents.model.noMatch') : t('agents.model.unavailable')} - {search.trim() ? t('agents.model.tryKeywords') : t('agents.model.connectFirst')} -
- ) : choices.map((choice, index) => { - const selected = choice.name.toLocaleLowerCase() === value.trim().toLocaleLowerCase(); - return ( - - ); - })} -
-
- {t('agents.model.count', { count: models.length })} - {error && models.length > 0 ? {t('agents.model.stale')} : null} -
-
- ) : null} -
- ); -} type AgentsPageProps = { embedded?: boolean; @@ -687,6 +441,33 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP const [launchError, setLaunchError] = useState(''); const [launchDirectoryDialogOpen, setLaunchDirectoryDialogOpen] = useState(false); const [codexCatalogDialogOpen, setCodexCatalogDialogOpen] = useState(false); + const [codexReviewSnapshot, setCodexReviewSnapshot] = useState(null); + // undefined 是未编辑的草稿;null 是用户明确选择 Codex 默认,不能合并这两态。 + const [codexReviewDraft, setCodexReviewDraft] = useState(undefined); + const [codexReviewLoading, setCodexReviewLoading] = useState(false); + const [codexReviewError, setCodexReviewError] = useState(''); + const codexReviewLoadId = useRef(0); + const loadCodexReview = useCallback(async () => { + const loadId = ++codexReviewLoadId.current; + setCodexReviewLoading(true); + setCodexReviewError(''); + try { + const snapshot = await invoke('get_codex_model_catalog_editor'); + if (loadId === codexReviewLoadId.current) setCodexReviewSnapshot(snapshot); + } catch (error) { + if (loadId === codexReviewLoadId.current) setCodexReviewError(String(error)); + } finally { + if (loadId === codexReviewLoadId.current) setCodexReviewLoading(false); + } + }, []); + useEffect(() => { + if (selected === 'codex') void loadCodexReview(); + }, [selected, loadCodexReview]); + const codexReviewModel = codexReviewDraft === undefined + ? codexReviewSnapshot?.defaultAutoReviewModel ?? null + : codexReviewDraft; + const codexReviewChanged = selected === 'codex' && codexReviewSnapshot !== null + && codexReviewModel !== codexReviewSnapshot.defaultAutoReviewModel; const [launchDirectory, setLaunchDirectory] = useState(''); const [launchDirectoryTarget, setLaunchDirectoryTarget] = useState(null); const [launchDirectoryError, setLaunchDirectoryError] = useState(''); @@ -783,12 +564,13 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP setDetectionError(''); try { await loadStatuses(true); + if (selected === 'codex') await loadCodexReview(); } catch (requestError) { setDetectionError(String(requestError)); } finally { setLoading(false); } - }, [loadStatuses]); + }, [loadStatuses, selected, loadCodexReview]); useEffect(() => { setLoading(true); @@ -994,7 +776,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP ); const oauthConfigurationChanged = selected === 'codex' && oauthConfiguration !== Boolean(activeStatus?.oauthConfiguration); - const draftChanged = modelDraftChanged || claudeMappingDraftChanged || oauthConfigurationChanged; + const draftChanged = modelDraftChanged || claudeMappingDraftChanged || oauthConfigurationChanged || codexReviewChanged; const configurationAction = resolveAgentConfigurationAction({ client: selected, modificationState: activeStatus?.modificationState ?? 'unconfigured', @@ -1003,6 +785,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP appliedModel, oauthConfiguration, appliedOauthConfiguration: Boolean(activeStatus?.oauthConfiguration), + codexReviewModelChanged: codexReviewChanged, modelMappings: claudeModelMappingsDraft, appliedModelMappings: appliedClaudeModelMappings, }); @@ -1010,6 +793,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP activeStatus?.supportedPlatform && activeStatus.installed && !modelLoading + && (selected !== 'codex' || (codexReviewSnapshot !== null && !codexReviewLoading && !codexReviewError)) && (isClaudeModelMappingClient ? claudeMappingsReady && claudeCodeRuntimeSettingsReady : selectedModelOption), @@ -1056,6 +840,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP : ''; const refreshModels = () => { void loadModels(selected); + if (selected === 'codex') void loadCodexReview(); }; const runEmbeddedPrimaryAction = () => { @@ -1258,6 +1043,10 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP const applyConfigurationChanges = async () => { setConfigurationError(''); + if (selected === 'codex' && (!codexReviewSnapshot || codexReviewLoading || codexReviewError)) { + setConfigurationError(codexReviewError || t('agents.catalog.loading')); + return; + } const claudeModelMappings = requireClaudeModelMappings(); if (isClaudeModelMappingClient && !claudeModelMappings) return; const model = isClaudeModelMappingClient @@ -1272,7 +1061,15 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP oauthConfiguration, claudeCodeModelMappings: selected === 'claude-code' ? claudeModelMappings : null, claudeDesktopModelMappings: selected === 'claude-desktop' ? claudeModelMappings : null, + codexReviewModel: selected === 'codex' && codexReviewSnapshot ? { + revision: codexReviewSnapshot.revision, + model: codexReviewModel, + } : null, }); + if (selected === 'codex') { + setCodexReviewDraft(undefined); + await loadCodexReview(); + } if (isClaudeModelMappingClient) { claudeModelMappingsDirtyRef.current[selected] = false; } @@ -1845,6 +1642,27 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP {modelHint} ) : null} + {selected === 'codex' ? ( +
+
{t('agents.catalog.defaultReviewModel')}
+ setCodexReviewDraft(null)} + onChange={setCodexReviewDraft} + onRefresh={() => void loadCodexReview()} + loading={codexReviewLoading} error={codexReviewError} + disabled={busy || codexReviewLoading || !codexReviewSnapshot} + exactModelIds ariaLabel={t('agents.catalog.defaultReviewModel')} + /> + {t('agents.catalog.defaultReviewHint')} + {codexReviewError ? {codexReviewError} : null} + {codexReviewSnapshot && typeof codexReviewModel === 'string' && !codexReviewSnapshot.models.some((model) => model.slug === codexReviewModel) + ? {t('agents.catalog.reviewModelMissing', { model: codexReviewModel })} : null} +
+ ) : null} ) : null} @@ -2245,7 +2063,10 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP {codexCatalogDialogOpen ? ( setCodexCatalogDialogOpen(false)} - onSaved={() => loadModels('codex', selectedModel)} + onSaved={async () => { + await loadModels('codex', selectedModel); + await loadCodexReview(); + }} /> ) : null} diff --git a/src/pages/CodexModelCatalogDialog.tsx b/src/pages/CodexModelCatalogDialog.tsx index 6eeea9c0..c0f39cab 100644 --- a/src/pages/CodexModelCatalogDialog.tsx +++ b/src/pages/CodexModelCatalogDialog.tsx @@ -2,8 +2,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { invoke } from '@tauri-apps/api/core'; import { Check, LoaderCircle, RefreshCw, RotateCcw, Search, X } from 'lucide-react'; import { useI18n } from '../i18n'; +import { AgentModelPicker } from '../components/AgentModelPicker'; import { cloneCodexModelConfiguration, + reviewModelPickerModels, codexContextSourceHint, codexReasoningEfforts, sameCodexModelConfiguration, @@ -86,6 +88,8 @@ export function CodexModelCatalogDialog({ onClose, onSaved }: CodexModelCatalogD }, [models, search]); const activeModel = models.find((model) => model.slug === selectedSlug) ?? null; + const reviewSelection = activeModel?.configuration.auto_review_model_override ?? null; + const reviewModelMissing = typeof reviewSelection === 'string' && !models.some((model) => model.slug === reviewSelection); const activeCustomized = activeModel ? !sameCodexModelConfiguration(activeModel.configuration, activeModel.defaults) : false; @@ -253,6 +257,24 @@ export function CodexModelCatalogDialog({ onClose, onSaved }: CodexModelCatalogD
+
+ {t('agents.catalog.reviewModelOverride')} + updateField('auto_review_model_override', mode === 'inherit' ? null : { mode: 'codex_default' })} + onChange={(model) => updateField('auto_review_model_override', model)} + onRefresh={() => { if (!dirty) void load(); }} + loading={loading} error="" disabled={saving} exactModelIds + ariaLabel={t('agents.catalog.reviewModelOverride')} + /> + {reviewModelMissing ? {t('agents.catalog.reviewModelMissing', { model: reviewSelection as string })} : null} +