feat(codex): add automatic model catalog updates from GitHub

This commit is contained in:
lzt404 committed 2026-07-29 21:05:47 +08:00
1 parent 8d8f619720
commit 0f3e5093e8
6 files changed
+244 -9

No files matched your search

+49 -8
View File
@@ -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<Result<CatalogSources, String>> = OnceLock::new();
static CATALOG_STATE: OnceLock<Result<RwLock<CatalogState>, 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<String, Value>,
@@ -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<bool, String> {
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<String, String> {
let state = catalog_state()?
.read()
.map_err(|_| "Codex 模型目录内存锁已损坏".to_string())?;
Ok(state.json.clone())
}
pub(crate) fn parse_runtime_models(payload: &Value) -> Result<Vec<CodexRuntimeModel>, String> {
@@ -113,12 +143,22 @@ pub(crate) fn parse_runtime_models(payload: &Value) -> Result<Vec<CodexRuntimeMo
pub(crate) fn prepare_catalog(
runtime_models: &[CodexRuntimeModel],
) -> Result<PreparedCodexCatalog, String> {
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<CatalogState>, 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(
+115
View File
@@ -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<String>,
}
#[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<CodexModelCatalogUpdateResult, String> {
update_codex_model_catalog_inner(&app).await
}
async fn update_codex_model_catalog_inner(
app: &tauri::AppHandle,
) -> Result<CodexModelCatalogUpdateResult, String> {
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<PathBuf, String> {
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,
+4
View File
@@ -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': '現在利用可能なモデルはありません',
+8
View File
@@ -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<MessageKey, string> = {
'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',
+51 -1
View File
@@ -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<Partial<Record<AgentClientId, string>>>({});
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<CodexModelCatalogUpdateResult>('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}
</span>
) : null}
{selected === 'codex' ? (
<div className="agent-catalog-update-control">
<button
type="button"
className="secondary-button compact-button"
onClick={() => void updateCodexModelCatalog()}
disabled={busy}
>
<RefreshCw size={15} className={busyAction === 'catalog' ? 'spin' : ''} />
{busyAction === 'catalog'
? t('agents.catalog.updating')
: t('agents.catalog.update')}
</button>
{catalogUpdateError ? (
<span className="agent-inline-message error" role="alert" aria-live="polite">
{catalogUpdateError}
</span>
) : null}
{catalogUpdateNotice ? (
<span className="agent-inline-message" role="status" aria-live="polite">
{catalogUpdateNotice}
</span>
) : null}
</div>
) : null}
</section>
<section className={`agent-core-setting-section agent-modification-actions ${activeStatus?.modificationState === 'applied' ? 'enabled' : ''}`}>
+17
View File
@@ -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;