feat(codex): add automatic model catalog updates from GitHub
This commit is contained in:
1 parent
8d8f619720
commit
0f3e5093e8
6 files changed
+244
-9
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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': '現在利用可能なモデルはありません',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' : ''}`}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user