diff --git a/src-tauri/src/codex_catalog.rs b/src-tauri/src/codex_catalog.rs index e0553dc..4b1829e 100644 --- a/src-tauri/src/codex_catalog.rs +++ b/src-tauri/src/codex_catalog.rs @@ -1,11 +1,11 @@ use crate::AgentModelOption; use serde_json::{Map, Value}; use std::collections::{HashMap, HashSet}; -use std::sync::OnceLock; +use std::sync::{OnceLock, RwLock}; const MODEL_CATALOG_JSON: &str = include_str!("../resources/codex_models/model-catalog.json"); -static SOURCES: OnceLock> = OnceLock::new(); +static CATALOG_STATE: OnceLock, String>> = OnceLock::new(); #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct CodexRuntimeModel { @@ -33,6 +33,12 @@ struct CatalogSources { max_template_priority: i64, } +#[derive(Clone, Debug)] +struct CatalogState { + sources: CatalogSources, + json: String, +} + #[derive(Clone, Debug)] struct Template { value: Map, @@ -46,7 +52,31 @@ struct CatalogEntry { } pub(crate) fn validate_embedded_catalog() -> Result<(), String> { - sources().map(|_| ()) + catalog_state().map(|_| ()) +} + +pub(crate) fn activate_catalog_json(catalog_json: &str) -> Result { + let parsed = parse_sources(catalog_json)?; + let mut state = catalog_state()? + .write() + .map_err(|_| "Codex 模型目录内存锁已损坏".to_string())?; + if state.json == catalog_json { + return Ok(false); + } + state.sources = parsed; + state.json = catalog_json.to_string(); + Ok(true) +} + +pub(crate) fn validate_catalog_json(catalog_json: &str) -> Result<(), String> { + parse_sources(catalog_json).map(|_| ()) +} + +pub(crate) fn current_catalog_json() -> Result { + let state = catalog_state()? + .read() + .map_err(|_| "Codex 模型目录内存锁已损坏".to_string())?; + Ok(state.json.clone()) } pub(crate) fn parse_runtime_models(payload: &Value) -> Result, String> { @@ -113,12 +143,22 @@ pub(crate) fn parse_runtime_models(payload: &Value) -> Result Result { - prepare_catalog_with_sources(runtime_models, sources()?) + let state = catalog_state()? + .read() + .map_err(|_| "Codex 模型目录内存锁已损坏".to_string())?; + prepare_catalog_with_sources(runtime_models, &state.sources) } -fn sources() -> Result<&'static CatalogSources, String> { - SOURCES - .get_or_init(|| parse_sources(MODEL_CATALOG_JSON)) +fn catalog_state() -> Result<&'static RwLock, String> { + CATALOG_STATE + .get_or_init(|| { + parse_sources(MODEL_CATALOG_JSON).map(|sources| { + RwLock::new(CatalogState { + sources, + json: MODEL_CATALOG_JSON.to_string(), + }) + }) + }) .as_ref() .map_err(Clone::clone) } @@ -686,7 +726,8 @@ mod tests { #[test] fn embedded_catalog_is_valid() { validate_embedded_catalog().unwrap(); - let sources = sources().unwrap(); + let state = catalog_state().unwrap().read().unwrap(); + let sources = &state.sources; assert_eq!(sources.templates.len(), 10); let fallback_prompt = string_value(&sources.fallback, "base_instructions"); assert!(fallback_prompt.starts_with( diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index effde4b..b8ae6ff 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -51,6 +51,10 @@ const RELEASE_DOWNLOAD_PREFIX: &str = const APP_UPDATE_MANIFEST_URL: &str = "https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest/download/portable-update-windows.json"; const APP_RELEASE_DOWNLOAD_PREFIX: &str = "https://github.com/router-for-me/EasyCLIProxyAPI/releases/download/"; +const CODEX_MODEL_CATALOG_URL: &str = "https://raw.githubusercontent.com/router-for-me/EasyCLIProxyAPI/main/src-tauri/resources/codex_models/model-catalog.json"; +const CODEX_MODEL_CATALOG_OVERRIDE_DIR: &str = "codex_models"; +const CODEX_MODEL_CATALOG_SOURCE_FILE: &str = "model-catalog.json"; +const MAX_CODEX_MODEL_CATALOG_BYTES: usize = 4 * 1024 * 1024; const APP_UPDATE_PROGRESS_EVENT: &str = "app-update-progress"; const PORTABLE_APP_MANIFEST_FILE: &str = "portable-app.json"; #[cfg(windows)] @@ -610,6 +614,12 @@ struct AgentConfigActionResult { conflict_files: Vec, } +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct CodexModelCatalogUpdateResult { + outcome: String, +} + #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] struct AgentModelOption { @@ -1597,6 +1607,100 @@ async fn get_agent_models( Ok(fetch_prepared_agent_models(client, &config).await?.models) } +#[tauri::command] +async fn update_codex_model_catalog( + app: tauri::AppHandle, +) -> Result { + update_codex_model_catalog_inner(&app).await +} + +async fn update_codex_model_catalog_inner( + app: &tauri::AppHandle, +) -> Result { + let client = reqwest::Client::builder() + .redirect(github_https_redirect_policy()) + .connect_timeout(Duration::from_secs(8)) + .read_timeout(Duration::from_secs(15)) + .timeout(Duration::from_secs(25)) + .build() + .map_err(|error| format!("创建 Codex 模型目录更新客户端失败: {error}"))?; + let response = client + .get(CODEX_MODEL_CATALOG_URL) + .header(reqwest::header::ACCEPT, "application/json") + .header(reqwest::header::USER_AGENT, APP_USER_AGENT) + .send() + .await + .map_err(|error| format!("从 GitHub 读取 Codex 模型目录失败: {error}"))?; + let status = response.status(); + if !status.is_success() { + return Err(format!( + "从 GitHub 读取 Codex 模型目录失败: HTTP {}", + status.as_u16() + )); + } + if response + .content_length() + .is_some_and(|size| size > MAX_CODEX_MODEL_CATALOG_BYTES as u64) + { + return Err(format!( + "GitHub Codex 模型目录超过 {} MiB 限制", + MAX_CODEX_MODEL_CATALOG_BYTES / 1024 / 1024 + )); + } + + let mut bytes = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|error| format!("读取 GitHub Codex 模型目录失败: {error}"))?; + if bytes.len().saturating_add(chunk.len()) > MAX_CODEX_MODEL_CATALOG_BYTES { + return Err(format!( + "GitHub Codex 模型目录超过 {} MiB 限制", + MAX_CODEX_MODEL_CATALOG_BYTES / 1024 / 1024 + )); + } + bytes.extend_from_slice(&chunk); + } + let catalog_json = String::from_utf8(bytes) + .map_err(|_| "GitHub Codex 模型目录不是有效的 UTF-8 文件".to_string())?; + codex_catalog::validate_catalog_json(&catalog_json) + .map_err(|error| format!("GitHub Codex 模型目录校验失败: {error}"))?; + + if codex_catalog::current_catalog_json()? == catalog_json { + return Ok(CodexModelCatalogUpdateResult { + outcome: "unchanged".to_string(), + }); + } + + let path = codex_model_catalog_override_path(app)?; + write_bytes_atomically(&path, catalog_json.as_bytes())?; + let changed = codex_catalog::activate_catalog_json(&catalog_json)?; + Ok(CodexModelCatalogUpdateResult { + outcome: if changed { "updated" } else { "unchanged" }.to_string(), + }) +} + +fn codex_model_catalog_override_path(app: &tauri::AppHandle) -> Result { + let app_data = app + .path() + .app_data_dir() + .map_err(|error| format!("无法获取应用数据目录: {error}"))?; + Ok(app_data + .join(CODEX_MODEL_CATALOG_OVERRIDE_DIR) + .join(CODEX_MODEL_CATALOG_SOURCE_FILE)) +} + +fn load_codex_model_catalog_override(app: &tauri::AppHandle) -> Result<(), String> { + let path = codex_model_catalog_override_path(app)?; + if !path.is_file() { + return Ok(()); + } + let catalog_json = fs::read_to_string(&path) + .map_err(|error| format!("读取本地 Codex 模型目录更新文件失败: {error}"))?; + codex_catalog::activate_catalog_json(&catalog_json) + .map_err(|error| format!("本地 Codex 模型目录更新文件无效: {error}"))?; + Ok(()) +} + #[tauri::command] async fn get_thinking_aliases( gui_config_state: tauri::State<'_, GuiConfigState>, @@ -6671,6 +6775,7 @@ fn github_https_redirect_policy() -> reqwest::redirect::Policy { url.host_str(), Some( "github.com" + | "raw.githubusercontent.com" | "objects.githubusercontent.com" | "release-assets.githubusercontent.com" ) @@ -13272,6 +13377,15 @@ fn main() { if let Err(error) = codex_catalog::validate_embedded_catalog() { eprintln!("Codex 内置模型目录无效: {error}"); } + if let Err(error) = load_codex_model_catalog_override(app.handle()) { + eprintln!("加载 Codex 模型目录更新文件失败,将使用内置目录: {error}"); + } + let catalog_update_app = app.handle().clone(); + tauri::async_runtime::spawn(async move { + if let Err(error) = update_codex_model_catalog_inner(&catalog_update_app).await { + eprintln!("后台更新 Codex 模型目录失败,继续使用当前目录: {error}"); + } + }); if let Err(error) = restore_main_window_size(app.handle()) { eprintln!("{error}"); } @@ -13352,6 +13466,7 @@ fn main() { get_agent_config_statuses, refresh_agent_config_statuses, get_agent_models, + update_codex_model_catalog, get_thinking_aliases, get_thinking_alias_sources, create_thinking_alias, diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 8ebb1f7..4ab35f8 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -409,6 +409,10 @@ export const jaOverrides = { 'agents.model.search': 'モデル名またはエイリアスを検索', 'agents.model.clearSearch': '検索を消去', 'agents.model.refresh': 'モデルを更新', + 'agents.catalog.update': 'モデル設定情報を更新', + 'agents.catalog.updating': 'モデル設定を更新しています', + 'agents.catalog.updated': 'GitHub からモデル設定を更新しました', + 'agents.catalog.unchanged': 'モデル設定は最新です', 'agents.model.fetching': 'モデルを取得しています', 'agents.model.noMatch': '一致するモデルがありません', 'agents.model.unavailable': '現在利用可能なモデルはありません', diff --git a/src/i18n/resources.ts b/src/i18n/resources.ts index 8195044..66cd347 100644 --- a/src/i18n/resources.ts +++ b/src/i18n/resources.ts @@ -412,6 +412,10 @@ export const zhCN = { 'agents.model.search': '搜索模型名称或别名', 'agents.model.clearSearch': '清空搜索', 'agents.model.refresh': '刷新模型', + 'agents.catalog.update': '更新模型配置信息', + 'agents.catalog.updating': '正在更新模型配置', + 'agents.catalog.updated': '模型配置已从 GitHub 更新', + 'agents.catalog.unchanged': '模型配置已是最新版本', 'agents.model.fetching': '正在获取模型', 'agents.model.noMatch': '没有匹配的模型', 'agents.model.unavailable': '暂时没有可用模型', @@ -1256,6 +1260,10 @@ export const en: Record = { 'agents.model.search': 'Search model names or aliases', 'agents.model.clearSearch': 'Clear search', 'agents.model.refresh': 'Refresh models', + 'agents.catalog.update': 'Update model configuration', + 'agents.catalog.updating': 'Updating model configuration', + 'agents.catalog.updated': 'Model configuration updated from GitHub', + 'agents.catalog.unchanged': 'Model configuration is already up to date', 'agents.model.fetching': 'Fetching models', 'agents.model.noMatch': 'No matching models', 'agents.model.unavailable': 'No models are currently available', diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index 7c92ba9..5f22d60 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -85,6 +85,10 @@ type AgentConfigActionResult = { conflictFiles: string[]; }; +type CodexModelCatalogUpdateResult = { + outcome: 'updated' | 'unchanged'; +}; + type ChatGptCloseResult = { wasRunning: boolean; closedProcesses: number; @@ -484,11 +488,13 @@ export function AgentsPage() { const [launchTargetByClient, setLaunchTargetByClient] = useState>>({}); const [loading, setLoading] = useState(true); const [modelLoading, setModelLoading] = useState(false); - const [busyAction, setBusyAction] = useState<'apply' | 'default' | 'clear' | 'launch' | 'close-app' | null>(null); + const [busyAction, setBusyAction] = useState<'apply' | 'default' | 'clear' | 'launch' | 'close-app' | 'catalog' | null>(null); const busy = busyAction !== null; const [detectionError, setDetectionError] = useState(''); const [modelError, setModelError] = useState(''); const [modelSelectionError, setModelSelectionError] = useState(''); + const [catalogUpdateError, setCatalogUpdateError] = useState(''); + const [catalogUpdateNotice, setCatalogUpdateNotice] = useState(''); const [configurationError, setConfigurationError] = useState(''); const [launchError, setLaunchError] = useState(''); const [defaultError, setDefaultError] = useState(''); @@ -586,6 +592,8 @@ export function AgentsPage() { useEffect(() => { setActiveSubpage(DEFAULT_AGENT_SUBPAGE); setModelSelectionError(''); + setCatalogUpdateError(''); + setCatalogUpdateNotice(''); setConfigurationError(''); setLaunchError(''); setDefaultError(''); @@ -680,6 +688,23 @@ export function AgentsPage() { void loadModels(selected); }; + const updateCodexModelCatalog = async () => { + setCatalogUpdateError(''); + setCatalogUpdateNotice(''); + setBusyAction('catalog'); + try { + const result = await invoke('update_codex_model_catalog'); + setCatalogUpdateNotice(result.outcome === 'updated' + ? t('agents.catalog.updated') + : t('agents.catalog.unchanged')); + await loadModels('codex'); + } catch (requestError) { + setCatalogUpdateError(String(requestError)); + } finally { + setBusyAction(null); + } + }; + const reloadStatusesAfterAction = async () => { setDetectionError(''); try { @@ -964,6 +989,31 @@ export function AgentsPage() { {modelHint} ) : null} + {selected === 'codex' ? ( +
+ + {catalogUpdateError ? ( + + {catalogUpdateError} + + ) : null} + {catalogUpdateNotice ? ( + + {catalogUpdateNotice} + + ) : null} +
+ ) : null}
diff --git a/src/styles.css b/src/styles.css index f0242cc..0bac950 100644 --- a/src/styles.css +++ b/src/styles.css @@ -4934,6 +4934,18 @@ input:focus-visible { overflow-wrap: anywhere; } +.agent-catalog-update-control { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.agent-catalog-update-control .agent-inline-message { + min-width: 0; + overflow-wrap: anywhere; +} + .agent-pending-badge { flex: none; padding: 4px 8px; @@ -7164,6 +7176,11 @@ input:focus-visible { flex-direction: column; } + .agent-catalog-update-control { + align-items: stretch; + flex-direction: column; + } + .codex-auto-restore-toggle { width: 100%; min-width: 0;