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
This commit is contained in:
1 parent
c370c17ff0
commit
673a322c00
13 files changed
+1671
-8
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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<PathBuf, String> {
|
||||
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<codex_catalog::CatalogEditorSnapshot, String> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
#[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<CodexCatalogEditorSaveResult, String> {
|
||||
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>,
|
||||
|
||||
@@ -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<CatalogState>, String> {
|
||||
@@ -205,6 +212,7 @@ fn catalog_state() -> Result<&'static RwLock<CatalogState>, 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<String, Value>, label: &str) -> Re
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn prepare_catalog_with_sources(
|
||||
runtime_models: &[CodexRuntimeModel],
|
||||
sources: &CatalogSources,
|
||||
) -> Result<PreparedCodexCatalog, String> {
|
||||
prepare_catalog_with_customizations(runtime_models, sources, &Default::default())
|
||||
}
|
||||
|
||||
fn prepare_catalog_with_customizations(
|
||||
runtime_models: &[CodexRuntimeModel],
|
||||
sources: &CatalogSources,
|
||||
customizations: &customizations::ModelCustomizations,
|
||||
) -> Result<PreparedCodexCatalog, String> {
|
||||
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,
|
||||
|
||||
@@ -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<String, Map<String, Value>>;
|
||||
|
||||
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<CatalogEditorModel>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CatalogEditorModel {
|
||||
slug: String,
|
||||
has_official_template: bool,
|
||||
customized: bool,
|
||||
configuration: Map<String, Value>,
|
||||
defaults: Map<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct CatalogEditorRequest {
|
||||
revision: String,
|
||||
models: Vec<CatalogEditorModelRequest>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CatalogEditorModelRequest {
|
||||
slug: String,
|
||||
configuration: Map<String, Value>,
|
||||
}
|
||||
|
||||
fn editable_configuration(model: &Map<String, Value>) -> Map<String, Value> {
|
||||
EDITABLE_FIELDS
|
||||
.iter()
|
||||
.map(|field| {
|
||||
(
|
||||
field.to_string(),
|
||||
model.get(*field).cloned().unwrap_or(Value::Null),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn validate_configuration(model: &Map<String, Value>) -> 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<String, Value>,
|
||||
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<CatalogEditorSnapshot, String> {
|
||||
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<CatalogEditorSnapshot, String> {
|
||||
let state = catalog_state()?
|
||||
.read()
|
||||
.map_err(|_| "Codex 模型目录内存锁已损坏")?;
|
||||
snapshot_for_state(runtime_models, &state)
|
||||
}
|
||||
|
||||
fn customizations_from_request(
|
||||
snapshot: &CatalogEditorSnapshot,
|
||||
request: CatalogEditorRequest,
|
||||
) -> Result<ModelCustomizations, String> {
|
||||
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<String, Value> = 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<ModelCustomizations, String> {
|
||||
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<CatalogEditorSnapshot, String> {
|
||||
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<CatalogEditorSnapshot, String> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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': 'モデル別名(任意)',
|
||||
|
||||
@@ -1,6 +1,51 @@
|
||||
import type { MessageKey } from './zh-CN';
|
||||
|
||||
export const en: Record<MessageKey, string> = {
|
||||
'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)',
|
||||
|
||||
@@ -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': '模型别名(可选)',
|
||||
|
||||
@@ -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<AgentLaunchTarget | null>(null);
|
||||
const [launchDirectoryError, setLaunchDirectoryError] = useState('');
|
||||
@@ -1944,6 +1947,15 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
|
||||
<span className="switch-track" />
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button agent-codex-catalog-button"
|
||||
onClick={() => setCodexCatalogDialogOpen(true)}
|
||||
disabled={busy}
|
||||
>
|
||||
<SlidersHorizontal size={16} />
|
||||
{t('agents.catalog.button')}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={`agent-modification-buttons ${selected === 'codex' ? 'codex' : ''}`}>
|
||||
@@ -2099,6 +2111,13 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{codexCatalogDialogOpen ? (
|
||||
<CodexModelCatalogDialog
|
||||
onClose={() => setCodexCatalogDialogOpen(false)}
|
||||
onSaved={() => loadModels('codex', selectedModel)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{launchDirectoryDialogOpen ? (
|
||||
<div className="config-dialog-backdrop" onMouseDown={(event) => {
|
||||
if (event.currentTarget === event.target && !busy) {
|
||||
|
||||
@@ -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<void>;
|
||||
};
|
||||
|
||||
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<CodexCatalogEditorSnapshot | null>(null);
|
||||
const [models, setModels] = useState<CodexCatalogEditorModel[]>([]);
|
||||
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<CodexCatalogEditorSnapshot>('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 = <Key extends keyof CodexModelConfiguration,>(
|
||||
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<CodexCatalogEditorSaveResult>('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 (
|
||||
<div className="config-dialog-backdrop codex-catalog-backdrop" onMouseDown={(event) => {
|
||||
if (event.currentTarget === event.target) requestClose();
|
||||
}}>
|
||||
<section className="config-dialog codex-catalog-dialog" role="dialog" aria-modal="true" aria-labelledby="codex-catalog-title">
|
||||
<header className="config-dialog-heading codex-catalog-heading">
|
||||
<div>
|
||||
<h2 id="codex-catalog-title">{t('agents.catalog.title')}</h2>
|
||||
<p>{t('agents.catalog.subtitle')}</p>
|
||||
</div>
|
||||
<button type="button" className="icon-button quiet" onClick={requestClose} disabled={saving} aria-label={t('common.close')}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="codex-catalog-body">
|
||||
<aside className="codex-catalog-sidebar">
|
||||
<div className="codex-catalog-search">
|
||||
<Search size={15} aria-hidden />
|
||||
<input value={search} onChange={(event) => setSearch(event.currentTarget.value)} placeholder={t('agents.catalog.search')} />
|
||||
</div>
|
||||
<div className="codex-catalog-models" role="listbox" aria-label={t('agents.catalog.title')}>
|
||||
{loading ? (
|
||||
<div className="codex-catalog-state"><LoaderCircle size={18} className="spin" />{t('agents.catalog.loading')}</div>
|
||||
) : filteredModels.length ? filteredModels.map((model) => {
|
||||
const customized = !sameCodexModelConfiguration(model.configuration, model.defaults);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={model.slug === selectedSlug}
|
||||
className={model.slug === selectedSlug ? 'active' : ''}
|
||||
key={model.slug}
|
||||
onClick={() => setSelectedSlug(model.slug)}
|
||||
>
|
||||
<strong>{model.configuration.display_name}</strong>
|
||||
<span>{model.slug}</span>
|
||||
<small>
|
||||
{model.hasOfficialTemplate ? t('agents.catalog.official') : t('agents.catalog.fallback')}
|
||||
{customized ? ` · ${t('agents.catalog.customized')}` : ''}
|
||||
</small>
|
||||
</button>
|
||||
);
|
||||
}) : <div className="codex-catalog-state">{t('agents.catalog.empty')}</div>}
|
||||
</div>
|
||||
<button type="button" className="secondary-button codex-catalog-reload" onClick={() => void load()} disabled={loading || saving || dirty}>
|
||||
<RefreshCw size={15} className={loading ? 'spin' : ''} />
|
||||
{t('agents.catalog.reload')}
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<main className="codex-catalog-editor">
|
||||
{activeModel ? (
|
||||
<>
|
||||
<div className="codex-catalog-model-heading">
|
||||
<div>
|
||||
<h3>{activeModel.slug}</h3>
|
||||
<span className={activeCustomized ? 'customized' : ''}>
|
||||
{activeModel.hasOfficialTemplate ? t('agents.catalog.official') : t('agents.catalog.fallback')}
|
||||
{activeCustomized ? ` · ${t('agents.catalog.customized')}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" className="secondary-button compact-button" onClick={restoreModel} disabled={!activeCustomized || saving}>
|
||||
<RotateCcw size={15} />{t('agents.catalog.resetModel')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="codex-catalog-form">
|
||||
<label><span>{t('agents.catalog.displayName')}</span><input value={activeModel.configuration.display_name} onChange={(event) => updateField('display_name', event.currentTarget.value)} /></label>
|
||||
<label className="wide"><span>{t('agents.catalog.description')}</span><textarea value={activeModel.configuration.description ?? ''} onChange={(event) => updateField('description', event.currentTarget.value || null)} /></label>
|
||||
<label><span>{t('agents.catalog.context')}</span><input type="number" min="1" value={Number.isFinite(activeModel.configuration.context_window) ? activeModel.configuration.context_window : ''} onChange={(event) => updateField('context_window', event.currentTarget.value ? Number(event.currentTarget.value) : Number.NaN)} /></label>
|
||||
<label><span>{t('agents.catalog.maximum')}</span><input type="number" min="1" value={Number.isFinite(activeModel.configuration.max_context_window) ? activeModel.configuration.max_context_window : ''} onChange={(event) => updateField('max_context_window', event.currentTarget.value ? Number(event.currentTarget.value) : Number.NaN)} /></label>
|
||||
<label><span>{t('agents.catalog.percent')}</span><input type="number" min="1" max="100" value={Number.isFinite(activeModel.configuration.effective_context_window_percent) ? activeModel.configuration.effective_context_window_percent : ''} onChange={(event) => updateField('effective_context_window_percent', event.currentTarget.value ? Number(event.currentTarget.value) : Number.NaN)} /></label>
|
||||
<label><span>{t('agents.catalog.compact')}</span><input type="number" min="1" placeholder={t('agents.catalog.automatic')} value={activeModel.configuration.auto_compact_token_limit ?? ''} onChange={(event) => updateField('auto_compact_token_limit', event.currentTarget.value ? Number(event.currentTarget.value) : null)} /></label>
|
||||
|
||||
<fieldset className="wide codex-catalog-choice-group">
|
||||
<legend>{t('agents.catalog.reasoning')}</legend>
|
||||
<div className="codex-catalog-chips">
|
||||
{codexReasoningEfforts.map((effort) => {
|
||||
const checked = activeModel.configuration.supported_reasoning_levels.some((level) => level.effort === effort);
|
||||
return <label key={effort} className={checked ? 'selected' : ''}><input type="checkbox" checked={checked} onChange={(event) => toggleReasoning(effort, event.currentTarget.checked, activeModel.defaults.supported_reasoning_levels)} /><span>{checked ? <Check size={13} /> : null}{effort}</span></label>;
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<label><span>{t('agents.catalog.defaultReasoning')}</span><select value={activeModel.configuration.default_reasoning_level ?? ''} onChange={(event) => updateField('default_reasoning_level', (event.currentTarget.value || null) as CodexReasoningEffort | null)}><option value="">{t('agents.catalog.clientDefault')}</option>{activeModel.configuration.supported_reasoning_levels.map((level) => <option key={level.effort} value={level.effort}>{level.effort}</option>)}</select></label>
|
||||
|
||||
<fieldset className="codex-catalog-choice-group">
|
||||
<legend>{t('agents.catalog.modalities')}</legend>
|
||||
<div className="codex-catalog-checkboxes">
|
||||
{(['text', 'image'] as const).map((modality) => <label key={modality}><input type="checkbox" checked={activeModel.configuration.input_modalities.includes(modality)} onChange={(event) => toggleModality(modality, event.currentTarget.checked)} />{t(`agents.catalog.${modality}`)}</label>)}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<label className="codex-catalog-switch"><input type="checkbox" checked={activeModel.configuration.visibility === 'list'} onChange={(event) => updateField('visibility', event.currentTarget.checked ? 'list' : 'hide')} /><span>{t('agents.catalog.visible')}</span></label>
|
||||
<label className="codex-catalog-switch"><input type="checkbox" checked={activeModel.configuration.supports_parallel_tool_calls} onChange={(event) => updateField('supports_parallel_tool_calls', event.currentTarget.checked)} /><span>{t('agents.catalog.parallel')}</span></label>
|
||||
</div>
|
||||
<p className="codex-catalog-hint">{t('agents.catalog.capabilityHint')}</p>
|
||||
</>
|
||||
) : !loading ? <div className="codex-catalog-state">{t('agents.catalog.empty')}</div> : null}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<footer className="codex-catalog-footer">
|
||||
<div>
|
||||
{error ? <span className="agent-inline-message error" role="alert">{error}</span> : null}
|
||||
{!error && notice ? <span className="agent-inline-message" role="status">{notice}</span> : null}
|
||||
{!error && !notice ? <span>{dirty ? t('agents.catalog.unsaved') : t('agents.catalog.saveHint')}</span> : null}
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" className="secondary-button" onClick={restoreAll} disabled={!models.some((model) => !sameCodexModelConfiguration(model.configuration, model.defaults)) || saving}>
|
||||
<RotateCcw size={15} />{t('agents.catalog.resetAll')}
|
||||
</button>
|
||||
<button type="button" className="secondary-button" onClick={requestClose} disabled={saving}>{t('common.cancel')}</button>
|
||||
<button type="button" className="primary-button" onClick={() => void save()} disabled={!snapshot || !dirty || saving}>
|
||||
{saving ? <LoaderCircle size={16} className="spin" /> : null}{saving ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{discardConfirmOpen ? (
|
||||
<div className="codex-catalog-confirm">
|
||||
<div role="alertdialog" aria-modal="true">
|
||||
<strong>{t('agents.catalog.unsaved')}</strong>
|
||||
<span>{t('agents.catalog.discardHint')}</span>
|
||||
<div>
|
||||
<button type="button" className="secondary-button" onClick={() => setDiscardConfirmOpen(false)}>{t('agents.catalog.keepEditing')}</button>
|
||||
<button type="button" className="danger-button" onClick={onClose}>{t('agents.catalog.discard')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { MessageKey } from '../i18n/resources';
|
||||
|
||||
export const codexReasoningEfforts = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'] as const;
|
||||
export type CodexReasoningEffort = typeof codexReasoningEfforts[number];
|
||||
|
||||
export type CodexReasoningLevel = {
|
||||
effort: CodexReasoningEffort;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type CodexModelConfiguration = {
|
||||
display_name: string;
|
||||
description: string | null;
|
||||
context_window: number;
|
||||
max_context_window: number;
|
||||
effective_context_window_percent: number;
|
||||
auto_compact_token_limit: number | null;
|
||||
default_reasoning_level: CodexReasoningEffort | null;
|
||||
supported_reasoning_levels: CodexReasoningLevel[];
|
||||
input_modalities: Array<'text' | 'image'>;
|
||||
visibility: 'list' | 'hide' | 'none';
|
||||
supports_parallel_tool_calls: boolean;
|
||||
};
|
||||
|
||||
export type CodexCatalogEditorModel = {
|
||||
slug: string;
|
||||
hasOfficialTemplate: boolean;
|
||||
customized: boolean;
|
||||
configuration: CodexModelConfiguration;
|
||||
defaults: CodexModelConfiguration;
|
||||
};
|
||||
|
||||
export type CodexCatalogEditorSnapshot = {
|
||||
revision: string;
|
||||
models: CodexCatalogEditorModel[];
|
||||
};
|
||||
|
||||
export type CodexCatalogEditorSaveResult = {
|
||||
snapshot: CodexCatalogEditorSnapshot;
|
||||
synchronizationError: string | null;
|
||||
};
|
||||
|
||||
export function cloneCodexModelConfiguration(configuration: CodexModelConfiguration): CodexModelConfiguration {
|
||||
return {
|
||||
...configuration,
|
||||
supported_reasoning_levels: configuration.supported_reasoning_levels.map((level) => ({ ...level })),
|
||||
input_modalities: [...configuration.input_modalities],
|
||||
};
|
||||
}
|
||||
|
||||
export function sameCodexModelConfiguration(left: CodexModelConfiguration, right: CodexModelConfiguration): boolean {
|
||||
return left.display_name === right.display_name
|
||||
&& left.description === right.description
|
||||
&& Object.is(left.context_window, right.context_window)
|
||||
&& Object.is(left.max_context_window, right.max_context_window)
|
||||
&& Object.is(left.effective_context_window_percent, right.effective_context_window_percent)
|
||||
&& Object.is(left.auto_compact_token_limit, right.auto_compact_token_limit)
|
||||
&& left.default_reasoning_level === right.default_reasoning_level
|
||||
&& left.visibility === right.visibility
|
||||
&& left.supports_parallel_tool_calls === right.supports_parallel_tool_calls
|
||||
&& left.input_modalities.join(',') === right.input_modalities.join(',')
|
||||
&& left.supported_reasoning_levels.length === right.supported_reasoning_levels.length
|
||||
&& left.supported_reasoning_levels.every((level, index) => level.effort === right.supported_reasoning_levels[index].effort
|
||||
&& level.description === right.supported_reasoning_levels[index].description);
|
||||
}
|
||||
|
||||
export function toggleCodexReasoningLevel(
|
||||
configuration: CodexModelConfiguration,
|
||||
effort: CodexReasoningEffort,
|
||||
enabled: boolean,
|
||||
defaults: CodexReasoningLevel[],
|
||||
): CodexModelConfiguration {
|
||||
const levels = new Map(configuration.supported_reasoning_levels.map((level) => [level.effort, level]));
|
||||
if (enabled) {
|
||||
levels.set(effort, levels.get(effort) ?? defaults.find((level) => level.effort === effort)
|
||||
?? { effort, description: `${effort} reasoning effort` });
|
||||
} else {
|
||||
levels.delete(effort);
|
||||
}
|
||||
const supported = codexReasoningEfforts.flatMap((level) => levels.has(level) ? [{ ...levels.get(level)! }] : []);
|
||||
const currentDefault = configuration.default_reasoning_level;
|
||||
const nextDefault = currentDefault && !levels.has(currentDefault)
|
||||
? levels.has('medium') ? 'medium' : supported[0]?.effort ?? null
|
||||
: currentDefault;
|
||||
return { ...configuration, supported_reasoning_levels: supported, default_reasoning_level: nextDefault };
|
||||
}
|
||||
|
||||
export function validateCodexModelConfiguration(configuration: CodexModelConfiguration): MessageKey | null {
|
||||
if (!configuration.display_name.trim() || configuration.display_name.length > 4_000
|
||||
|| (configuration.description?.length ?? 0) > 4_000) return 'agents.catalog.invalidText';
|
||||
if (![configuration.context_window, configuration.max_context_window].every((value) => Number.isSafeInteger(value) && value > 0)) {
|
||||
return 'agents.catalog.invalidContext';
|
||||
}
|
||||
if (configuration.max_context_window < configuration.context_window) return 'agents.catalog.invalidMaximum';
|
||||
const percent = configuration.effective_context_window_percent;
|
||||
if (!Number.isInteger(percent) || percent < 1 || percent > 100) return 'agents.catalog.invalidPercent';
|
||||
const compact = configuration.auto_compact_token_limit;
|
||||
if (compact !== null && (!Number.isSafeInteger(compact) || compact < 1 || compact > configuration.context_window)) {
|
||||
return 'agents.catalog.invalidCompact';
|
||||
}
|
||||
const levels = configuration.supported_reasoning_levels.map((level) => level.effort);
|
||||
if (new Set(levels).size !== levels.length || levels.some((level) => !codexReasoningEfforts.includes(level))
|
||||
|| (configuration.default_reasoning_level !== null && !levels.includes(configuration.default_reasoning_level))) {
|
||||
return 'agents.catalog.invalidReasoning';
|
||||
}
|
||||
if (!configuration.input_modalities.length) return 'agents.catalog.invalidModalities';
|
||||
return null;
|
||||
}
|
||||
+425
@@ -6647,6 +6647,431 @@ textarea:focus-visible {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.agent-codex-catalog-button {
|
||||
width: 100%;
|
||||
min-height: 54px;
|
||||
height: auto;
|
||||
padding: 10px 12px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.codex-catalog-backdrop {
|
||||
z-index: 90;
|
||||
}
|
||||
|
||||
.codex-catalog-dialog {
|
||||
position: relative;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
gap: 14px;
|
||||
width: min(1080px, calc(100vw - 40px));
|
||||
height: min(780px, calc(100vh - 40px));
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.codex-catalog-heading {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.codex-catalog-heading > div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.codex-catalog-heading p {
|
||||
margin: 0;
|
||||
color: var(--theme-716b61);
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.codex-catalog-body {
|
||||
display: grid;
|
||||
grid-template-columns: 250px minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--theme-d5d2cb);
|
||||
border-radius: 9px;
|
||||
background: var(--theme-ffffff);
|
||||
}
|
||||
|
||||
.codex-catalog-sidebar {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
border-right: 1px solid var(--theme-d5d2cb);
|
||||
background: var(--theme-faf8f4);
|
||||
}
|
||||
|
||||
.codex-catalog-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 38px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--theme-d5d2cb);
|
||||
border-radius: 7px;
|
||||
background: var(--theme-ffffff);
|
||||
color: var(--theme-716b61);
|
||||
}
|
||||
|
||||
.codex-catalog-search input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--theme-2d2a26);
|
||||
}
|
||||
|
||||
.codex-catalog-models {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 6px;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.codex-catalog-models > button {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--theme-2d2a26);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.codex-catalog-models > button:hover {
|
||||
background: var(--theme-ffffff);
|
||||
}
|
||||
|
||||
.codex-catalog-models > button.active {
|
||||
border-color: var(--theme-9db8a5);
|
||||
background: var(--theme-f3f8f4);
|
||||
}
|
||||
|
||||
.codex-catalog-models strong,
|
||||
.codex-catalog-models span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.codex-catalog-models span,
|
||||
.codex-catalog-models small {
|
||||
color: var(--theme-716b61);
|
||||
font-size: var(--font-size-meta);
|
||||
}
|
||||
|
||||
.codex-catalog-models small {
|
||||
color: var(--theme-2f6b3f);
|
||||
}
|
||||
|
||||
.codex-catalog-reload {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.codex-catalog-editor {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.codex-catalog-model-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.codex-catalog-model-heading > div {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.codex-catalog-model-heading h3 {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--theme-2d2a26);
|
||||
font-size: var(--font-size-section-title);
|
||||
}
|
||||
|
||||
.codex-catalog-model-heading span {
|
||||
color: var(--theme-716b61);
|
||||
font-size: var(--font-size-meta);
|
||||
}
|
||||
|
||||
.codex-catalog-model-heading span.customized {
|
||||
color: var(--theme-2f6b3f);
|
||||
}
|
||||
|
||||
.codex-catalog-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.codex-catalog-form > label {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 7px;
|
||||
color: var(--theme-5f5a53);
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.codex-catalog-form > label.wide,
|
||||
.codex-catalog-form > fieldset.wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.codex-catalog-form input[type='text'],
|
||||
.codex-catalog-form input[type='number'],
|
||||
.codex-catalog-form input:not([type]),
|
||||
.codex-catalog-form select,
|
||||
.codex-catalog-form textarea {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid var(--theme-d5d2cb);
|
||||
border-radius: 7px;
|
||||
outline: 0;
|
||||
background: var(--theme-ffffff);
|
||||
color: var(--theme-2d2a26);
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.codex-catalog-form input[type='text'],
|
||||
.codex-catalog-form input[type='number'],
|
||||
.codex-catalog-form input:not([type]),
|
||||
.codex-catalog-form select {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.codex-catalog-form textarea {
|
||||
min-height: 70px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.codex-catalog-form input:focus,
|
||||
.codex-catalog-form select:focus,
|
||||
.codex-catalog-form textarea:focus {
|
||||
border-color: var(--theme-8d8377);
|
||||
box-shadow: 0 0 0 3px rgba(45, 42, 38, 0.08);
|
||||
}
|
||||
|
||||
.codex-catalog-choice-group {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 11px;
|
||||
border: 1px solid var(--theme-d5d2cb);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.codex-catalog-choice-group legend {
|
||||
padding: 0 5px;
|
||||
color: var(--theme-5f5a53);
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.codex-catalog-chips,
|
||||
.codex-catalog-checkboxes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.codex-catalog-chips label,
|
||||
.codex-catalog-checkboxes label {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.codex-catalog-chips input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.codex-catalog-chips span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: 30px;
|
||||
padding: 5px 9px;
|
||||
border: 1px solid var(--theme-d5d2cb);
|
||||
border-radius: 999px;
|
||||
background: var(--theme-faf8f4);
|
||||
color: var(--theme-5f5a53);
|
||||
font-size: var(--font-size-meta);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.codex-catalog-chips label.selected span {
|
||||
border-color: var(--theme-9db8a5);
|
||||
background: var(--theme-f3f8f4);
|
||||
color: var(--theme-2f6b3f);
|
||||
}
|
||||
|
||||
.codex-catalog-checkboxes label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--theme-5f5a53);
|
||||
font-size: var(--font-size-label);
|
||||
}
|
||||
|
||||
.codex-catalog-switch {
|
||||
grid-template-columns: auto minmax(0, 1fr) !important;
|
||||
align-items: center;
|
||||
min-height: 42px;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid var(--theme-d5d2cb);
|
||||
border-radius: 8px;
|
||||
background: var(--theme-faf8f4);
|
||||
}
|
||||
|
||||
.codex-catalog-switch input {
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
}
|
||||
|
||||
.codex-catalog-hint {
|
||||
margin: 14px 0 0;
|
||||
color: var(--theme-716b61);
|
||||
font-size: var(--font-size-meta);
|
||||
line-height: var(--line-height-relaxed);
|
||||
}
|
||||
|
||||
.codex-catalog-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 90px;
|
||||
padding: 14px;
|
||||
color: var(--theme-716b61);
|
||||
font-size: var(--font-size-label);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.codex-catalog-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.codex-catalog-footer > div:first-child {
|
||||
min-width: 0;
|
||||
color: var(--theme-716b61);
|
||||
font-size: var(--font-size-meta);
|
||||
}
|
||||
|
||||
.codex-catalog-footer > div:last-child {
|
||||
display: flex;
|
||||
flex: none;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.codex-catalog-confirm {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
border-radius: inherit;
|
||||
background: rgba(45, 42, 38, 0.35);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.codex-catalog-confirm > div {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
width: min(420px, 100%);
|
||||
padding: 20px;
|
||||
border: 1px solid var(--theme-d5d2cb);
|
||||
border-radius: 9px;
|
||||
background: var(--theme-fffdf8);
|
||||
box-shadow: 0 18px 48px rgba(45, 42, 38, 0.24);
|
||||
}
|
||||
|
||||
.codex-catalog-confirm span {
|
||||
color: var(--theme-716b61);
|
||||
font-size: var(--font-size-label);
|
||||
}
|
||||
|
||||
.codex-catalog-confirm > div > div {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.codex-catalog-dialog {
|
||||
width: calc(100vw - 28px);
|
||||
height: calc(100vh - 28px);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.codex-catalog-body {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 190px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.codex-catalog-sidebar {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--theme-d5d2cb);
|
||||
}
|
||||
|
||||
.codex-catalog-models {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.codex-catalog-reload {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.codex-catalog-editor {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.codex-catalog-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.codex-catalog-form > label.wide,
|
||||
.codex-catalog-form > fieldset.wide {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.codex-catalog-footer {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.codex-catalog-footer > div:last-child {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.codex-catalog-footer > div:last-child > button:first-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.agent-codex-options .agent-section-heading strong,
|
||||
.agent-codex-options > .agent-oauth-configuration {
|
||||
font-size: var(--font-size-meta);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
cloneCodexModelConfiguration,
|
||||
sameCodexModelConfiguration,
|
||||
toggleCodexReasoningLevel,
|
||||
validateCodexModelConfiguration,
|
||||
type CodexModelConfiguration,
|
||||
} from '../src/services/codexModelCatalog';
|
||||
|
||||
const configuration = (): CodexModelConfiguration => ({
|
||||
display_name: 'Third Party',
|
||||
description: null,
|
||||
context_window: 128_000,
|
||||
max_context_window: 128_000,
|
||||
effective_context_window_percent: 95,
|
||||
auto_compact_token_limit: null,
|
||||
default_reasoning_level: 'medium',
|
||||
supported_reasoning_levels: [
|
||||
{ effort: 'low', description: 'Low' },
|
||||
{ effort: 'medium', description: 'Medium' },
|
||||
{ effort: 'high', description: 'High' },
|
||||
],
|
||||
input_modalities: ['text', 'image'],
|
||||
visibility: 'list',
|
||||
supports_parallel_tool_calls: false,
|
||||
});
|
||||
|
||||
describe('Codex 模型列表编辑', () => {
|
||||
test('克隆配置时隔离数组字段', () => {
|
||||
const original = configuration();
|
||||
const cloned = cloneCodexModelConfiguration(original);
|
||||
cloned.supported_reasoning_levels[0].description = 'Changed';
|
||||
cloned.input_modalities.pop();
|
||||
|
||||
expect(original.supported_reasoning_levels[0].description).toBe('Low');
|
||||
expect(original.input_modalities).toEqual(['text', 'image']);
|
||||
expect(sameCodexModelConfiguration(original, cloned)).toBeFalse();
|
||||
});
|
||||
|
||||
test('移除默认思考等级时选择剩余可用等级', () => {
|
||||
const updated = toggleCodexReasoningLevel(configuration(), 'medium', false, []);
|
||||
expect(updated.supported_reasoning_levels.map((level) => level.effort)).toEqual(['low', 'high']);
|
||||
expect(updated.default_reasoning_level).toBe('low');
|
||||
});
|
||||
|
||||
test('校验上下文、压缩阈值和输入类型', () => {
|
||||
expect(validateCodexModelConfiguration(configuration())).toBeNull();
|
||||
expect(validateCodexModelConfiguration({ ...configuration(), max_context_window: 64_000 }))
|
||||
.toBe('agents.catalog.invalidMaximum');
|
||||
expect(validateCodexModelConfiguration({ ...configuration(), auto_compact_token_limit: 200_000 }))
|
||||
.toBe('agents.catalog.invalidCompact');
|
||||
expect(validateCodexModelConfiguration({ ...configuration(), input_modalities: [] }))
|
||||
.toBe('agents.catalog.invalidModalities');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user