From 673a322c00cba2a7a306d692860191daec89c3d6 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Sat, 5 Sep 2026 18:03:18 +0800 Subject: [PATCH] feat(codex): add customizable model catalog editor - add per-model context, reasoning, modality, visibility, and parallel tool settings - persist customizations separately and apply them during catalog generation - support restoring individual or all models to official fallback templates - expose full fallback reasoning levels while keeping Fast available - add localized UI, validation, documentation, and regression tests --- .../codex_models/fallback-model.json | 29 +- src-tauri/src/agents/commands.rs | 50 ++ src-tauri/src/codex_catalog.rs | 44 +- src-tauri/src/codex_catalog/customizations.rs | 492 ++++++++++++++++++ src-tauri/src/main.rs | 5 + src/i18n/ja.ts | 45 ++ src/i18n/locales/en.ts | 45 ++ src/i18n/locales/zh-CN.ts | 45 ++ src/pages/AgentsPage.tsx | 19 + src/pages/CodexModelCatalogDialog.tsx | 317 +++++++++++ src/services/codexModelCatalog.ts | 108 ++++ src/styles.css | 425 +++++++++++++++ tests/codexModelCatalog.test.ts | 55 ++ 13 files changed, 1671 insertions(+), 8 deletions(-) create mode 100644 src-tauri/src/codex_catalog/customizations.rs create mode 100644 src/pages/CodexModelCatalogDialog.tsx create mode 100644 src/services/codexModelCatalog.ts create mode 100644 tests/codexModelCatalog.test.ts diff --git a/src-tauri/resources/codex_models/fallback-model.json b/src-tauri/resources/codex_models/fallback-model.json index d712eb4..3fd3957 100644 --- a/src-tauri/resources/codex_models/fallback-model.json +++ b/src-tauri/resources/codex_models/fallback-model.json @@ -38,8 +38,33 @@ "supports_reasoning_summaries": true, "display_name": "Compatible Model", "description": null, - "default_reasoning_level": null, - "supported_reasoning_levels": [], + "default_reasoning_level": "medium", + "supported_reasoning_levels": [ + { + "effort": "low", + "description": "Low reasoning effort" + }, + { + "effort": "medium", + "description": "Medium reasoning effort" + }, + { + "effort": "high", + "description": "High reasoning effort" + }, + { + "effort": "xhigh", + "description": "Extra high reasoning effort" + }, + { + "effort": "max", + "description": "Maximum reasoning effort" + }, + { + "effort": "ultra", + "description": "Ultra reasoning effort" + } + ], "shell_type": "unified_exec", "visibility": "none", "supported_in_api": true, diff --git a/src-tauri/src/agents/commands.rs b/src-tauri/src/agents/commands.rs index 768132d..365843d 100644 --- a/src-tauri/src/agents/commands.rs +++ b/src-tauri/src/agents/commands.rs @@ -399,6 +399,56 @@ pub(crate) fn load_codex_model_catalog_override(app: &tauri::AppHandle) -> Resul Ok(()) } +fn codex_model_customizations_path(app: &tauri::AppHandle) -> Result { + Ok(codex_model_catalog_override_path(app)?.with_file_name("model-customizations.json")) +} + +pub(crate) fn load_codex_model_customizations(app: &tauri::AppHandle) -> Result<(), String> { + codex_catalog::load_customizations(&codex_model_customizations_path(app)?) +} + +#[tauri::command] +pub(crate) async fn get_codex_model_catalog_editor( + gui_config_state: tauri::State<'_, GuiConfigState>, +) -> Result { + let _sync_guard = CODEX_CATALOG_SYNC_LOCK.lock().await; + let config = gui_config_state.snapshot()?; + let runtime_models = + fetch_codex_runtime_models(config.port, effective_agent_api_key(&config)).await?; + codex_catalog::editor_snapshot(&runtime_models) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CodexCatalogEditorSaveResult { + snapshot: codex_catalog::CatalogEditorSnapshot, + synchronization_error: Option, +} + +#[tauri::command] +pub(crate) async fn save_codex_model_catalog_editor( + app: tauri::AppHandle, + gui_config_state: tauri::State<'_, GuiConfigState>, + request: codex_catalog::CatalogEditorRequest, +) -> Result { + let _sync_guard = CODEX_CATALOG_SYNC_LOCK.lock().await; + let config = gui_config_state.snapshot()?; + let runtime_models = + fetch_codex_runtime_models(config.port, effective_agent_api_key(&config)).await?; + let snapshot = codex_catalog::save_customizations( + &codex_model_customizations_path(&app)?, + &runtime_models, + request, + )?; + let synchronization_error = prepare_codex_agent_models(&runtime_models) + .and_then(|prepared| sync_prepared_codex_model_catalog(&app, &config, &prepared)) + .err(); + Ok(CodexCatalogEditorSaveResult { + snapshot, + synchronization_error, + }) +} + #[tauri::command] pub(crate) async fn get_thinking_aliases( gui_config_state: tauri::State<'_, GuiConfigState>, diff --git a/src-tauri/src/codex_catalog.rs b/src-tauri/src/codex_catalog.rs index 438186a..233b998 100644 --- a/src-tauri/src/codex_catalog.rs +++ b/src-tauri/src/codex_catalog.rs @@ -3,6 +3,12 @@ use serde_json::{Map, Value}; use std::collections::{HashMap, HashSet}; use std::sync::{OnceLock, RwLock}; +mod customizations; +pub(crate) use customizations::{ + editor_snapshot, load_customizations, save_customizations, CatalogEditorRequest, + CatalogEditorSnapshot, +}; + const MODEL_CATALOG_JSON: &str = include_str!("../resources/codex_models/model-catalog.json"); const FALLBACK_MODEL_JSON: &str = include_str!("../resources/codex_models/fallback-model.json"); @@ -38,6 +44,7 @@ struct CatalogSources { struct CatalogState { sources: CatalogSources, json: String, + customizations: customizations::ModelCustomizations, } impl CatalogState { @@ -195,7 +202,7 @@ pub(crate) fn prepare_catalog( let state = catalog_state()? .read() .map_err(|_| "Codex 模型目录内存锁已损坏".to_string())?; - prepare_catalog_with_sources(runtime_models, &state.sources) + prepare_catalog_with_customizations(runtime_models, &state.sources, &state.customizations) } fn catalog_state() -> Result<&'static RwLock, String> { @@ -205,6 +212,7 @@ fn catalog_state() -> Result<&'static RwLock, String> { RwLock::new(CatalogState { sources, json: MODEL_CATALOG_JSON.to_string(), + customizations: Default::default(), }) }) }) @@ -376,9 +384,18 @@ fn validate_required_codex_fields(model: &Map, label: &str) -> Re Ok(()) } +#[cfg(test)] fn prepare_catalog_with_sources( runtime_models: &[CodexRuntimeModel], sources: &CatalogSources, +) -> Result { + prepare_catalog_with_customizations(runtime_models, sources, &Default::default()) +} + +fn prepare_catalog_with_customizations( + runtime_models: &[CodexRuntimeModel], + sources: &CatalogSources, + customizations: &customizations::ModelCustomizations, ) -> Result { if runtime_models.is_empty() { return Err("CPA 当前没有可写入 Codex 的模型".to_string()); @@ -418,6 +435,10 @@ fn prepare_catalog_with_sources( return Err("CPA 当前没有有效的 Codex 模型 ID".to_string()); } + for entry in &mut entries { + customizations::apply_customizations(&mut entry.value, customizations)?; + } + for entry in &entries { let slug = string_value(&entry.value, "slug"); validate_required_codex_fields(&entry.value, &slug)?; @@ -821,8 +842,11 @@ mod tests { sources.fallback["input_modalities"], serde_json::json!(["text", "image"]) ); - assert_eq!(sources.fallback["default_reasoning_level"], Value::Null); - assert!(reasoning_efforts(&sources.fallback).is_empty()); + assert_eq!(sources.fallback["default_reasoning_level"], "medium"); + assert_eq!( + reasoning_efforts(&sources.fallback), + ["low", "medium", "high", "xhigh", "max", "ultra"] + ); assert_eq!(sources.fallback["shell_type"], "unified_exec"); assert_eq!(sources.fallback["visibility"], "none"); assert_eq!(sources.fallback["default_reasoning_summary"], "auto"); @@ -897,6 +921,7 @@ mod tests { let mut state = CatalogState { sources: current, json: "revision-two".to_string(), + customizations: Default::default(), }; let mut older = test_sources(); @@ -1230,7 +1255,7 @@ mod tests { } #[test] - fn generated_fallback_uses_official_defaults_with_fast_available() { + fn generated_fallback_uses_official_defaults_with_reasoning_and_fast_available() { let sources = parse_sources(MODEL_CATALOG_JSON).unwrap(); let runtime = runtime(serde_json::json!({"models":[{"id":"unknown-model"}]})); let catalog = prepare_catalog_with_sources(&runtime, &sources).unwrap(); @@ -1245,8 +1270,15 @@ mod tests { "auto_compact_token_limit": null, "comp_hash": null, "effective_context_window_percent": 95, - "default_reasoning_level": null, - "supported_reasoning_levels": [], + "default_reasoning_level": "medium", + "supported_reasoning_levels": [ + {"effort": "low", "description": "Low reasoning effort"}, + {"effort": "medium", "description": "Medium reasoning effort"}, + {"effort": "high", "description": "High reasoning effort"}, + {"effort": "xhigh", "description": "Extra high reasoning effort"}, + {"effort": "max", "description": "Maximum reasoning effort"}, + {"effort": "ultra", "description": "Ultra reasoning effort"} + ], "shell_type": "unified_exec", "input_modalities": ["text", "image"], "supports_image_detail_original": false, diff --git a/src-tauri/src/codex_catalog/customizations.rs b/src-tauri/src/codex_catalog/customizations.rs new file mode 100644 index 0000000..f0d9908 --- /dev/null +++ b/src-tauri/src/codex_catalog/customizations.rs @@ -0,0 +1,492 @@ +use super::*; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::path::Path; + +pub(super) type ModelCustomizations = BTreeMap>; + +const EDITABLE_FIELDS: [&str; 11] = [ + "display_name", + "description", + "context_window", + "max_context_window", + "effective_context_window_percent", + "auto_compact_token_limit", + "default_reasoning_level", + "supported_reasoning_levels", + "input_modalities", + "visibility", + "supports_parallel_tool_calls", +]; + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct SavedCustomizations { + version: u32, + models: ModelCustomizations, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CatalogEditorSnapshot { + revision: String, + models: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct CatalogEditorModel { + slug: String, + has_official_template: bool, + customized: bool, + configuration: Map, + defaults: Map, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct CatalogEditorRequest { + revision: String, + models: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CatalogEditorModelRequest { + slug: String, + configuration: Map, +} + +fn editable_configuration(model: &Map) -> Map { + EDITABLE_FIELDS + .iter() + .map(|field| { + ( + field.to_string(), + model.get(*field).cloned().unwrap_or(Value::Null), + ) + }) + .collect() +} + +fn validate_configuration(model: &Map) -> Result<(), String> { + for field in model.keys() { + if !EDITABLE_FIELDS.contains(&field.as_str()) { + return Err(format!("不允许修改模型字段 {field}")); + } + } + for field in [ + "context_window", + "max_context_window", + "auto_compact_token_limit", + ] { + if let Some(value) = model.get(field) { + if field == "auto_compact_token_limit" && value.is_null() { + continue; + } + if !value + .as_u64() + .is_some_and(|value| (1..=9_007_199_254_740_991).contains(&value)) + { + return Err(format!("{field} 必须是有效的正整数")); + } + } + } + if let (Some(context), Some(maximum)) = ( + model.get("context_window").and_then(Value::as_u64), + model.get("max_context_window").and_then(Value::as_u64), + ) { + if maximum < context { + return Err("最大上下文窗口不能小于上下文窗口".to_string()); + } + if model + .get("auto_compact_token_limit") + .and_then(Value::as_u64) + .is_some_and(|limit| limit > context) + { + return Err("自动压缩阈值不能大于上下文窗口".to_string()); + } + } + if let Some(value) = model.get("effective_context_window_percent") { + if !value + .as_u64() + .is_some_and(|value| (1..=100).contains(&value)) + { + return Err("有效上下文比例必须为 1 到 100 的整数".to_string()); + } + } + for field in ["display_name", "description"] { + if let Some(value) = model.get(field) { + if field == "description" && value.is_null() { + continue; + } + if !value.as_str().is_some_and(|text| { + text.len() <= 4_000 && (field == "description" || !text.trim().is_empty()) + }) { + return Err(format!("{field} 必须是有效的文本")); + } + } + } + if let Some(value) = model.get("visibility") { + if !matches!(value.as_str(), Some("list" | "hide" | "none")) { + return Err("模型显示状态无效".to_string()); + } + } + if let Some(value) = model.get("supports_parallel_tool_calls") { + if !value.is_boolean() { + return Err("并行工具调用必须为布尔值".to_string()); + } + } + if let Some(value) = model.get("input_modalities") { + let modalities = value.as_array().ok_or("输入类型必须为数组")?; + let mut seen = HashSet::new(); + if modalities.is_empty() + || modalities.iter().any(|value| { + !matches!(value.as_str(), Some("text" | "image")) || !seen.insert(value.as_str()) + }) + { + return Err("输入类型仅允许不重复的 text 和 image,且至少选择一项".to_string()); + } + } + let default = model.get("default_reasoning_level"); + if let Some(value) = default { + if !value.is_null() && !value.as_str().is_some_and(is_allowed_reasoning_level) { + return Err("默认思考等级无效".to_string()); + } + } + if let Some(value) = model.get("supported_reasoning_levels") { + let levels = value.as_array().ok_or("思考等级必须为数组")?; + let mut seen = HashSet::new(); + for level in levels { + let effort = level + .get("effort") + .and_then(Value::as_str) + .ok_or("思考等级缺少 effort")?; + if !is_allowed_reasoning_level(effort) || !seen.insert(effort) { + return Err(format!("思考等级无效或重复: {effort}")); + } + if !level + .get("description") + .and_then(Value::as_str) + .is_some_and(|text| text.len() <= 4_000) + { + return Err("思考等级说明必须是有效的文本".to_string()); + } + } + if default + .and_then(Value::as_str) + .is_some_and(|effort| !seen.contains(effort)) + { + return Err("默认思考等级必须包含在可选等级中".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()); + validate_configuration(&editable_configuration(model)) + .map_err(|error| format!("模型 {slug} 的自定义配置无效: {error}"))?; + enable_fast_mode(model); + } + Ok(()) +} + +fn snapshot_for_state( + runtime_models: &[CodexRuntimeModel], + state: &CatalogState, +) -> Result { + let baseline = if runtime_models.is_empty() { + "{\"models\":[]}".to_string() + } else { + prepare_catalog_with_customizations(runtime_models, &state.sources, &Default::default())? + .json + }; + let root: Value = serde_json::from_str(&baseline).map_err(|error| error.to_string())?; + let mut models = Vec::new(); + for value in root["models"] + .as_array() + .ok_or("模型目录缺少 models 数组")? + { + 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); + apply_customizations(&mut model, &state.customizations)?; + models.push(CatalogEditorModel { + slug, + has_official_template: state.sources.templates.contains_key(&key), + customized: state.customizations.contains_key(&key), + configuration: editable_configuration(&model), + defaults, + }); + } + let mut digest = Sha256::new(); + digest.update(baseline.as_bytes()); + digest.update(serde_json::to_vec(&state.customizations).map_err(|error| error.to_string())?); + Ok(CatalogEditorSnapshot { + revision: format!("{:x}", digest.finalize()), + models, + }) +} + +pub(crate) fn editor_snapshot( + runtime_models: &[CodexRuntimeModel], +) -> Result { + let state = catalog_state()? + .read() + .map_err(|_| "Codex 模型目录内存锁已损坏")?; + snapshot_for_state(runtime_models, &state) +} + +fn customizations_from_request( + snapshot: &CatalogEditorSnapshot, + request: CatalogEditorRequest, +) -> Result { + if request.revision != snapshot.revision { + return Err("CODEX_MODEL_CATALOG_CHANGED".to_string()); + } + if request.models.len() != snapshot.models.len() { + return Err("模型列表已变化,请重新加载后编辑".to_string()); + } + let mut seen = HashSet::new(); + let mut customizations = BTreeMap::new(); + for requested in request.models { + let key = normalize_id(&requested.slug); + let current = snapshot + .models + .iter() + .find(|model| normalize_id(&model.slug) == key) + .ok_or_else(|| format!("模型 {} 已不在当前列表中", requested.slug))?; + if !seen.insert(key.clone()) { + return Err(format!("模型 {} 重复", requested.slug)); + } + if requested.configuration.len() != EDITABLE_FIELDS.len() { + return Err(format!("模型 {} 的配置字段不完整", requested.slug)); + } + validate_configuration(&requested.configuration) + .map_err(|error| format!("模型 {}: {error}", requested.slug))?; + let mut changes: Map = requested + .configuration + .iter() + .filter(|(field, value)| current.defaults.get(*field) != Some(*value)) + .map(|(field, value)| (field.clone(), value.clone())) + .collect(); + for group in [ + &[ + "context_window", + "max_context_window", + "auto_compact_token_limit", + "effective_context_window_percent", + ][..], + &["default_reasoning_level", "supported_reasoning_levels"][..], + ] { + if group.iter().any(|field| changes.contains_key(*field)) { + for field in group { + changes.insert(field.to_string(), requested.configuration[*field].clone()); + } + } + } + if !changes.is_empty() { + customizations.insert(key, changes); + } + } + Ok(customizations) +} + +fn decode_customizations(content: &[u8]) -> Result { + if content.len() > crate::MAX_CODEX_MODEL_CATALOG_BYTES { + return Err("Codex 自定义模型配置超过大小限制".to_string()); + } + let saved: SavedCustomizations = + serde_json::from_slice(content).map_err(|error| error.to_string())?; + if saved.version != 1 { + return Err("不支持的 Codex 自定义模型配置版本".to_string()); + } + let mut normalized = BTreeMap::new(); + for (slug, model) in saved.models { + let key = normalize_id(&slug); + if key.is_empty() || normalized.contains_key(&key) { + return Err("自定义模型 ID 为空或重复".to_string()); + } + validate_configuration(&model)?; + normalized.insert(key, model); + } + Ok(normalized) +} + +pub(crate) fn load_customizations(path: &Path) -> Result<(), String> { + let content = match std::fs::read(path) { + Ok(content) => content, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(format!("读取 Codex 自定义模型配置失败: {error}")), + }; + let customizations = decode_customizations(&content)?; + let mut state = catalog_state()? + .write() + .map_err(|_| "Codex 模型目录内存锁已损坏")?; + state.customizations = customizations; + Ok(()) +} + +fn save_for_state( + path: &Path, + runtime_models: &[CodexRuntimeModel], + request: CatalogEditorRequest, + state: &mut CatalogState, +) -> Result { + let snapshot = snapshot_for_state(runtime_models, state)?; + let customizations = customizations_from_request(&snapshot, request)?; + let saved = SavedCustomizations { + version: 1, + models: customizations, + }; + 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) +} + +pub(crate) fn save_customizations( + path: &Path, + runtime_models: &[CodexRuntimeModel], + request: CatalogEditorRequest, +) -> Result { + let mut state = catalog_state()? + .write() + .map_err(|_| "Codex 模型目录内存锁已损坏")?; + save_for_state(path, runtime_models, request, &mut state) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + fn runtime_model(slug: &str) -> CodexRuntimeModel { + CodexRuntimeModel { + slug: slug.to_string(), + display_name: None, + description: None, + context_window: None, + max_context_window: None, + input_modalities: None, + default_reasoning_level: None, + hidden: false, + } + } + + fn temporary_path() -> std::path::PathBuf { + static SEQUENCE: AtomicU64 = AtomicU64::new(0); + std::env::temp_dir().join(format!( + "cpa-codex-model-customizations-{}-{}.json", + std::process::id(), + SEQUENCE.fetch_add(1, Ordering::Relaxed) + )) + } + + #[test] + fn customizations_persist_apply_and_restore_to_the_current_template() { + let sources = parse_sources(MODEL_CATALOG_JSON).unwrap(); + let mut state = CatalogState { + sources, + json: MODEL_CATALOG_JSON.to_string(), + customizations: Default::default(), + }; + let runtime_models = vec![runtime_model("third-party-model")]; + let snapshot = snapshot_for_state(&runtime_models, &state).unwrap(); + let mut configuration = snapshot.models[0].configuration.clone(); + configuration.insert("context_window".to_string(), serde_json::json!(131_072)); + configuration.insert("max_context_window".to_string(), serde_json::json!(262_144)); + let path = temporary_path(); + + let saved = save_for_state( + &path, + &runtime_models, + CatalogEditorRequest { + revision: snapshot.revision, + models: vec![CatalogEditorModelRequest { + slug: "third-party-model".to_string(), + configuration, + }], + }, + &mut state, + ) + .unwrap(); + + assert!(saved.models[0].customized); + assert_eq!(saved.models[0].configuration["context_window"], 131_072); + assert_eq!(saved.models[0].configuration["max_context_window"], 262_144); + let generated = prepare_catalog_with_customizations( + &runtime_models, + &state.sources, + &state.customizations, + ) + .unwrap(); + let generated: Value = serde_json::from_str(&generated.json).unwrap(); + assert_eq!(generated["models"][0]["context_window"], 131_072); + assert_eq!(generated["models"][0]["max_context_window"], 262_144); + + let loaded = decode_customizations(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(loaded, state.customizations); + + let restored = save_for_state( + &path, + &runtime_models, + CatalogEditorRequest { + revision: saved.revision, + models: vec![CatalogEditorModelRequest { + slug: "third-party-model".to_string(), + configuration: saved.models[0].defaults.clone(), + }], + }, + &mut state, + ) + .unwrap(); + assert!(!restored.models[0].customized); + assert!(state.customizations.is_empty()); + let persisted: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(persisted["models"], serde_json::json!({})); + + let _ = std::fs::remove_file(path); + } + + #[test] + fn stale_editor_revision_is_rejected_without_changing_state() { + let sources = parse_sources(MODEL_CATALOG_JSON).unwrap(); + let mut state = CatalogState { + sources, + json: MODEL_CATALOG_JSON.to_string(), + customizations: Default::default(), + }; + let runtime_models = vec![runtime_model("third-party-model")]; + let snapshot = snapshot_for_state(&runtime_models, &state).unwrap(); + let path = temporary_path(); + let error = save_for_state( + &path, + &runtime_models, + CatalogEditorRequest { + revision: "stale".to_string(), + models: vec![CatalogEditorModelRequest { + slug: "third-party-model".to_string(), + configuration: snapshot.models[0].configuration.clone(), + }], + }, + &mut state, + ) + .unwrap_err(); + + assert_eq!(error, "CODEX_MODEL_CATALOG_CHANGED"); + assert!(state.customizations.is_empty()); + assert!(!path.exists()); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index caf0976..6ce09da 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2354,6 +2354,9 @@ fn main() { eprintln!("加载 Codex 模型目录更新文件失败,将使用内置目录: {error}"); } let catalog_update_app = app.handle().clone(); + if let Err(error) = load_codex_model_customizations(app.handle()) { + eprintln!("加载 Codex 自定义模型配置失败: {error}"); + } tauri::async_runtime::spawn(async move { if let Err(error) = update_codex_model_catalog_inner(&catalog_update_app).await { eprintln!("后台更新 Codex 模型目录失败,继续使用当前目录: {error}"); @@ -2484,6 +2487,8 @@ fn main() { uninstall_pi_provider, check_codex_oauth_login, update_codex_model_catalog, + get_codex_model_catalog_editor, + save_codex_model_catalog_editor, get_thinking_aliases, get_model_alias_sources, get_thinking_alias_sources, diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 2ef698d..3e78c04 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -1,6 +1,51 @@ import type { MessageKey } from './locales/zh-CN'; export const jaOverrides = { + 'agents.catalog.button': 'モデル一覧の設定', + 'agents.catalog.title': 'Codex モデル一覧', + 'agents.catalog.subtitle': '利用可能なモデルのパラメーターを編集し、生成される Codex モデル一覧に保存します。', + 'agents.catalog.search': 'モデル名または ID を検索', + 'agents.catalog.official': '公式テンプレート', + 'agents.catalog.fallback': '標準テンプレート', + 'agents.catalog.customized': 'カスタマイズ済み', + 'agents.catalog.reload': '再読み込み', + 'agents.catalog.loading': 'モデル一覧を読み込み中…', + 'agents.catalog.empty': '利用可能なモデルがありません。コアに接続し、モデルの提供元を設定してください。', + 'agents.catalog.displayName': '表示名', + 'agents.catalog.description': 'モデルの説明', + 'agents.catalog.context': 'コンテキストウィンドウ(トークン)', + 'agents.catalog.maximum': '最大コンテキストウィンドウ(トークン)', + 'agents.catalog.percent': '有効コンテキスト比率(%)', + 'agents.catalog.compact': '自動圧縮のしきい値(トークン)', + 'agents.catalog.automatic': '空欄で自動設定', + 'agents.catalog.reasoning': '選択可能な推論レベル', + 'agents.catalog.defaultReasoning': '既定の推論レベル', + 'agents.catalog.clientDefault': 'クライアントに任せる', + 'agents.catalog.modalities': '入力タイプ', + 'agents.catalog.text': 'テキスト', + 'agents.catalog.image': '画像', + 'agents.catalog.visible': 'Codex のモデル一覧に表示', + 'agents.catalog.parallel': '並列ツール呼び出しを許可', + 'agents.catalog.capabilityHint': 'Fast は常に選択可能です。推論・画像・ツール機能の動作は上流モデルの対応状況によります。', + 'agents.catalog.resetModel': 'このテンプレートを復元', + 'agents.catalog.resetAll': 'すべて公式テンプレートに復元', + 'agents.catalog.resetHint': '専用テンプレートがあるモデルは公式設定に、それ以外はアプリの標準設定に戻ります。保存すると反映されます。', + 'agents.catalog.restored': '下書きをテンプレートに戻しました。保存すると反映されます。', + 'agents.catalog.saved': '保存しました。管理対象の一覧が同期されます。Codex を再起動して変更を読み込んでください。', + 'agents.catalog.syncFailed': '設定は保存しましたが、一覧の同期に失敗しました:{error}', + 'agents.catalog.changed': 'モデル一覧またはテンプレートが更新されました。再読み込みしてから編集してください。', + 'agents.catalog.unsaved': '未保存の変更があります。', + 'agents.catalog.discard': '変更を破棄', + 'agents.catalog.discardHint': '閉じると、保存していないモデルパラメーターの変更は破棄されます。', + 'agents.catalog.keepEditing': '編集を続ける', + 'agents.catalog.saveHint': '変更はこの端末に保存され、アプリの再起動後も保持されます。', + 'agents.catalog.invalidText': '表示名を入力してください。名前と説明はそれぞれ 4000 文字以内にしてください。', + 'agents.catalog.invalidContext': 'コンテキストウィンドウには有効な正の整数を入力してください。', + 'agents.catalog.invalidMaximum': '最大コンテキストはコンテキストウィンドウ以上にしてください。', + 'agents.catalog.invalidPercent': '有効コンテキスト比率は 1~100 の整数にしてください。', + 'agents.catalog.invalidCompact': '自動圧縮のしきい値はコンテキストウィンドウ以下の正の整数にしてください。', + 'agents.catalog.invalidReasoning': '既定の推論レベルは選択肢に含め、レベルを重複させないでください。', + 'agents.catalog.invalidModalities': '入力タイプを少なくとも 1 つ選択してください。', 'apiAccess.models.add': 'カスタムモデルを追加', 'apiAccess.models.namePlaceholder': 'モデル名(例: gpt-4o)', 'apiAccess.models.aliasPlaceholder': 'モデル別名(任意)', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 23c9afd..4a9f7f4 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1,6 +1,51 @@ import type { MessageKey } from './zh-CN'; export const en: Record = { + 'agents.catalog.button': 'Model list settings', + 'agents.catalog.title': 'Codex model catalog', + 'agents.catalog.subtitle': 'Customize parameters for currently available models and save them to the generated Codex catalog.', + 'agents.catalog.search': 'Search model names or IDs', + 'agents.catalog.official': 'Official template', + 'agents.catalog.fallback': 'Default template', + 'agents.catalog.customized': 'Customized', + 'agents.catalog.reload': 'Reload', + 'agents.catalog.loading': 'Loading models…', + '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.context': 'Context window (tokens)', + 'agents.catalog.maximum': 'Maximum context window (tokens)', + 'agents.catalog.percent': 'Effective context (%)', + 'agents.catalog.compact': 'Auto-compaction threshold (tokens)', + 'agents.catalog.automatic': 'Leave blank for automatic', + 'agents.catalog.reasoning': 'Available reasoning levels', + 'agents.catalog.defaultReasoning': 'Default reasoning level', + 'agents.catalog.clientDefault': 'Let the client decide', + 'agents.catalog.modalities': 'Input types', + 'agents.catalog.text': 'Text', + 'agents.catalog.image': 'Image', + 'agents.catalog.visible': 'Show in the Codex model list', + 'agents.catalog.parallel': 'Allow parallel tool calls', + 'agents.catalog.capabilityHint': 'Fast is always available. Reasoning, image and tool capabilities depend on the upstream model.', + 'agents.catalog.resetModel': 'Restore this template', + 'agents.catalog.resetAll': 'Restore all official templates', + 'agents.catalog.resetHint': 'Models with dedicated templates return to official settings; other models return to application defaults. Save to apply.', + 'agents.catalog.restored': 'Templates restored in the draft. Save to apply.', + 'agents.catalog.saved': 'Saved. Managed catalogs are synchronized; restart Codex to load the changes.', + 'agents.catalog.syncFailed': 'Settings saved, but catalog synchronization failed: {error}', + 'agents.catalog.changed': 'The model list or templates have changed. Reload before editing again.', + 'agents.catalog.unsaved': 'You have unsaved changes.', + 'agents.catalog.discard': 'Discard changes', + 'agents.catalog.discardHint': 'Closing now discards the model parameter changes that have not been saved.', + 'agents.catalog.keepEditing': 'Keep editing', + 'agents.catalog.saveHint': 'Changes are saved locally and survive application restarts.', + 'agents.catalog.invalidText': 'Enter a display name. Names and descriptions must not exceed 4,000 characters.', + 'agents.catalog.invalidContext': 'Context windows must be positive safe integers.', + 'agents.catalog.invalidMaximum': 'Maximum context must be at least the context window.', + 'agents.catalog.invalidPercent': 'Effective context must be an integer from 1 to 100.', + 'agents.catalog.invalidCompact': 'The compaction threshold must be a positive integer no greater than the context window.', + 'agents.catalog.invalidReasoning': 'The default reasoning level must be among the selected levels, with no duplicate levels.', + 'agents.catalog.invalidModalities': 'Select at least one input type.', 'apiAccess.models.add': 'Add Custom Model', 'apiAccess.models.namePlaceholder': 'Model name, for example gpt-4o', 'apiAccess.models.aliasPlaceholder': 'Model alias (optional)', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 7d71436..49188a3 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -1,4 +1,49 @@ export const zhCN = { + 'agents.catalog.button': '模型列表设置', + 'agents.catalog.title': 'Codex 模型列表', + 'agents.catalog.subtitle': '自定义当前可用模型的参数,保存后用于生成 Codex 模型列表。', + 'agents.catalog.search': '搜索模型名称或 ID', + 'agents.catalog.official': '官方模板', + 'agents.catalog.fallback': '默认模板', + 'agents.catalog.customized': '已自定义', + 'agents.catalog.reload': '重新加载', + 'agents.catalog.loading': '正在读取模型列表…', + 'agents.catalog.empty': '当前没有可用模型,请先连接内核并配置模型来源。', + 'agents.catalog.displayName': '显示名称', + 'agents.catalog.description': '模型说明', + 'agents.catalog.context': '上下文窗口(tokens)', + 'agents.catalog.maximum': '最大上下文窗口(tokens)', + 'agents.catalog.percent': '有效上下文比例(%)', + 'agents.catalog.compact': '自动压缩阈值(tokens)', + 'agents.catalog.automatic': '留空使用自动设置', + 'agents.catalog.reasoning': '可选思考等级', + 'agents.catalog.defaultReasoning': '默认思考等级', + 'agents.catalog.clientDefault': '由客户端决定', + 'agents.catalog.modalities': '输入类型', + 'agents.catalog.text': '文本', + 'agents.catalog.image': '图片', + 'agents.catalog.visible': '在 Codex 模型列表中显示', + 'agents.catalog.parallel': '允许并行工具调用', + 'agents.catalog.capabilityHint': 'Fast 始终可选;思考等级、图片与工具能力是否生效取决于上游模型。', + 'agents.catalog.resetModel': '恢复此模型模板', + 'agents.catalog.resetAll': '全部恢复官方模板', + 'agents.catalog.resetHint': '有专属模板的模型恢复官方配置,其余恢复软件默认配置。恢复操作保存后生效。', + 'agents.catalog.restored': '模板已恢复到草稿,点击保存后生效。', + 'agents.catalog.saved': '已保存。已托管的模型目录会同步更新,重启 Codex 后加载新配置。', + 'agents.catalog.syncFailed': '设置已保存,但目录同步失败:{error}', + 'agents.catalog.changed': '模型列表或模板已更新,请重新加载后再编辑。', + 'agents.catalog.unsaved': '有尚未保存的修改。', + 'agents.catalog.discard': '放弃修改', + 'agents.catalog.discardHint': '关闭后,本次尚未保存的模型参数修改会丢失。', + 'agents.catalog.keepEditing': '继续编辑', + 'agents.catalog.saveHint': '修改保存在本机,重新启动软件后仍然有效。', + 'agents.catalog.invalidText': '请填写显示名称,名称和说明均不能超过 4000 个字符。', + 'agents.catalog.invalidContext': '上下文窗口必须为有效的正整数。', + 'agents.catalog.invalidMaximum': '最大上下文窗口不能小于上下文窗口。', + 'agents.catalog.invalidPercent': '有效上下文比例必须为 1 到 100 的整数。', + 'agents.catalog.invalidCompact': '自动压缩阈值必须为正整数,且不能超过上下文窗口。', + 'agents.catalog.invalidReasoning': '默认思考等级必须位于可选等级中,且等级不能重复。', + 'agents.catalog.invalidModalities': '请至少选择一种输入类型。', 'apiAccess.models.add': '添加自定义模型', 'apiAccess.models.namePlaceholder': '模型名称,例如 gpt-4o', 'apiAccess.models.aliasPlaceholder': '模型别名(可选)', diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index 7d8686c..5bf2a6e 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -22,6 +22,7 @@ import { Play, RefreshCw, Search, + SlidersHorizontal, Trash2, Terminal, Wrench, @@ -59,6 +60,7 @@ import { import type { ModelOption } from '../services/modelService'; import { getCurrentLocale, translate, useI18n } from '../i18n'; import { CodexSessionsPanel } from './CodexSessionsPanel'; +import { CodexModelCatalogDialog } from './CodexModelCatalogDialog'; type AgentClientId = | 'claude-code' @@ -670,6 +672,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP const [clearConfirmOpen, setClearConfirmOpen] = useState(false); const [launchError, setLaunchError] = useState(''); const [launchDirectoryDialogOpen, setLaunchDirectoryDialogOpen] = useState(false); + const [codexCatalogDialogOpen, setCodexCatalogDialogOpen] = useState(false); const [launchDirectory, setLaunchDirectory] = useState(''); const [launchDirectoryTarget, setLaunchDirectoryTarget] = useState(null); const [launchDirectoryError, setLaunchDirectoryError] = useState(''); @@ -1944,6 +1947,15 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP + ) : null}
@@ -2099,6 +2111,13 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
+ {codexCatalogDialogOpen ? ( + setCodexCatalogDialogOpen(false)} + onSaved={() => loadModels('codex', selectedModel)} + /> + ) : null} + {launchDirectoryDialogOpen ? (
{ if (event.currentTarget === event.target && !busy) { diff --git a/src/pages/CodexModelCatalogDialog.tsx b/src/pages/CodexModelCatalogDialog.tsx new file mode 100644 index 0000000..f64c878 --- /dev/null +++ b/src/pages/CodexModelCatalogDialog.tsx @@ -0,0 +1,317 @@ +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 { + cloneCodexModelConfiguration, + codexReasoningEfforts, + sameCodexModelConfiguration, + toggleCodexReasoningLevel, + validateCodexModelConfiguration, + type CodexCatalogEditorModel, + type CodexCatalogEditorSaveResult, + type CodexCatalogEditorSnapshot, + type CodexModelConfiguration, + type CodexReasoningEffort, +} from '../services/codexModelCatalog'; + +type CodexModelCatalogDialogProps = { + onClose: () => void; + onSaved: () => void | Promise; +}; + +const cloneModel = (model: CodexCatalogEditorModel): CodexCatalogEditorModel => ({ + ...model, + configuration: cloneCodexModelConfiguration(model.configuration), + defaults: cloneCodexModelConfiguration(model.defaults), +}); + +const cloneModels = (snapshot: CodexCatalogEditorSnapshot) => snapshot.models.map(cloneModel); + +export function CodexModelCatalogDialog({ onClose, onSaved }: CodexModelCatalogDialogProps) { + const { t } = useI18n(); + const [snapshot, setSnapshot] = useState(null); + const [models, setModels] = useState([]); + const [selectedSlug, setSelectedSlug] = useState(''); + const [search, setSearch] = useState(''); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + const [notice, setNotice] = useState(''); + const [discardConfirmOpen, setDiscardConfirmOpen] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + setError(''); + setNotice(''); + try { + const next = await invoke('get_codex_model_catalog_editor'); + setSnapshot(next); + setModels(cloneModels(next)); + setSelectedSlug((current) => next.models.some((model) => model.slug === current) + ? current + : next.models[0]?.slug ?? ''); + } catch (requestError) { + setError(String(requestError)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const dirty = useMemo(() => { + if (!snapshot || snapshot.models.length !== models.length) return false; + return models.some((model) => { + const saved = snapshot.models.find((candidate) => candidate.slug === model.slug); + return !saved || !sameCodexModelConfiguration(model.configuration, saved.configuration); + }); + }, [models, snapshot]); + + const filteredModels = useMemo(() => { + const query = search.trim().toLocaleLowerCase(); + if (!query) return models; + return models.filter((model) => model.slug.toLocaleLowerCase().includes(query) + || model.configuration.display_name.toLocaleLowerCase().includes(query)); + }, [models, search]); + + const activeModel = models.find((model) => model.slug === selectedSlug) ?? null; + const activeCustomized = activeModel + ? !sameCodexModelConfiguration(activeModel.configuration, activeModel.defaults) + : false; + + const updateActive = (update: (configuration: CodexModelConfiguration) => CodexModelConfiguration) => { + setModels((current) => current.map((model) => model.slug === selectedSlug + ? { ...model, configuration: update(model.configuration) } + : model)); + setError(''); + setNotice(''); + }; + + const updateField = ( + field: Key, + value: CodexModelConfiguration[Key], + ) => updateActive((current) => ({ ...current, [field]: value })); + + const toggleReasoning = ( + effort: CodexReasoningEffort, + enabled: boolean, + defaults: CodexModelConfiguration['supported_reasoning_levels'], + ) => updateActive((current) => toggleCodexReasoningLevel(current, effort, enabled, defaults)); + + const toggleModality = (modality: 'text' | 'image', enabled: boolean) => updateActive((current) => ({ + ...current, + input_modalities: (['text', 'image'] as const).filter((candidate) => candidate === modality + ? enabled + : current.input_modalities.includes(candidate)), + })); + + const requestClose = () => { + if (saving) return; + if (dirty) setDiscardConfirmOpen(true); + else onClose(); + }; + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return; + if (discardConfirmOpen) setDiscardConfirmOpen(false); + else requestClose(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }); + + const restoreModel = () => { + if (!activeModel) return; + updateActive(() => cloneCodexModelConfiguration(activeModel.defaults)); + setNotice(t('agents.catalog.restored')); + }; + + const restoreAll = () => { + setModels((current) => current.map((model) => ({ + ...model, + configuration: cloneCodexModelConfiguration(model.defaults), + }))); + setError(''); + setNotice(t('agents.catalog.restored')); + }; + + const save = async () => { + if (!snapshot || saving) return; + for (const model of models) { + const validationKey = validateCodexModelConfiguration(model.configuration); + if (validationKey) { + setSelectedSlug(model.slug); + setError(t(validationKey)); + setNotice(''); + return; + } + } + setSaving(true); + setError(''); + setNotice(''); + try { + const result = await invoke('save_codex_model_catalog_editor', { + request: { + revision: snapshot.revision, + models: models.map((model) => ({ + slug: model.slug, + configuration: model.configuration, + })), + }, + }); + setSnapshot(result.snapshot); + setModels(cloneModels(result.snapshot)); + setNotice(result.synchronizationError + ? t('agents.catalog.syncFailed', { error: result.synchronizationError }) + : t('agents.catalog.saved')); + await onSaved(); + } catch (requestError) { + const message = String(requestError); + setError(message.includes('CODEX_MODEL_CATALOG_CHANGED') + ? t('agents.catalog.changed') + : message); + } finally { + setSaving(false); + } + }; + + return ( +
{ + if (event.currentTarget === event.target) requestClose(); + }}> +
+
+
+

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

+

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

+
+ +
+ +
+ + +
+ {activeModel ? ( + <> +
+
+

{activeModel.slug}

+ + {activeModel.hasOfficialTemplate ? t('agents.catalog.official') : t('agents.catalog.fallback')} + {activeCustomized ? ` · ${t('agents.catalog.customized')}` : ''} + +
+ +
+ +
+ +