From 0f3e5093e8ef0beee9bd1ba45f386c9766c4cb96 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Wed, 29 Jul 2026 21:05:47 +0800 Subject: [PATCH 1/4] feat(codex): add automatic model catalog updates from GitHub --- src-tauri/src/codex_catalog.rs | 57 +++++++++++++--- src-tauri/src/main.rs | 115 +++++++++++++++++++++++++++++++++ src/i18n/ja.ts | 4 ++ src/i18n/resources.ts | 8 +++ src/pages/AgentsPage.tsx | 52 ++++++++++++++- src/styles.css | 17 +++++ 6 files changed, 244 insertions(+), 9 deletions(-) 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; From bbe22a96eba02fe6b63e6ce5fee30d82480baf2d Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Thu, 30 Jul 2026 01:21:48 +0800 Subject: [PATCH 2/4] feat: add credential priority controls and OAuth defaults --- src-tauri/src/main.rs | 48 +++-- src/i18n/ja.ts | 35 +-- src/i18n/resources.ts | 70 +++--- src/pages/AgentsPage.tsx | 52 +---- src/pages/AuthFileManagementPage.tsx | 123 +++++++++-- src/pages/Kernel.tsx | 46 ++++ src/pages/ManagementPages.tsx | 43 +++- src/pages/ThinkingAliasesPage.tsx | 122 +++++------ src/services/authFiles.ts | 61 ++++++ src/services/managementApi.ts | 2 +- src/services/quotaService.ts | 47 +--- src/styles.css | 310 +++++++++++++-------------- tests/authFiles.test.ts | 39 +++- 13 files changed, 582 insertions(+), 416 deletions(-) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index b8ae6ff..e275986 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -6537,27 +6537,35 @@ async fn upload_auth_file( } #[tauri::command] -async fn download_auth_file( +fn open_auth_files_directory( gui_config_state: tauri::State<'_, GuiConfigState>, - name: String, -) -> Result { - let name = name.trim(); - if name.is_empty() { - return Err("认证文件名不能为空".to_string()); - } - +) -> Result<(), String> { let config = gui_config_state.snapshot()?; - let client = management_http_client()?; - let mut query = std::collections::HashMap::new(); - query.insert("name".to_string(), name.to_string()); - let response = client - .get(management_endpoint(&config, "auth-files/download")?) - .header("Authorization", management_authorization(&config)?) - .query(&query) - .send() - .await - .map_err(|err| format!("下载认证文件失败: {err}"))?; - read_management_text(response).await + let install_dir = core_install_dir()?; + let auth_dir = auth_dir_path_for_core(&config.auth_dir, &install_dir); + fs::create_dir_all(&auth_dir) + .map_err(|error| format!("创建凭证目录失败 {}: {error}", path_to_string(&auth_dir)))?; + open_directory_in_file_manager(&auth_dir) +} + +fn open_directory_in_file_manager(path: &Path) -> Result<(), String> { + #[cfg(target_os = "windows")] + let mut command = Command::new(windows_explorer_executable()); + #[cfg(target_os = "macos")] + let mut command = Command::new("open"); + #[cfg(all(unix, not(target_os = "macos")))] + let mut command = Command::new("xdg-open"); + + command.arg(path); + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + configure_background_command(&mut command); + command + .spawn() + .map(|_| ()) + .map_err(|error| format!("打开凭证目录失败 {}: {error}", path_to_string(path))) } #[tauri::command] @@ -13488,7 +13496,7 @@ fn main() { clear_core_management_secret_key, management_request, upload_auth_file, - download_auth_file, + open_auth_files_directory, set_core_plugins_enabled, set_core_routing_strategy, set_core_proxy_url, diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 4ab35f8..3bfdfe6 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -60,6 +60,7 @@ export const jaOverrides = { 'quota.confirm.warning': '「確認」を押した場合のみ実行されます。「キャンセル」ではリセット回数を消費しません。', 'oauth.title': 'OAuth ログイン', 'oauth.loginSuccess': '{provider} へのログインに成功しました', + 'oauth.priorityApplyFailed': 'OAuth ログインは成功しましたが、新しい認証情報の優先度を 0 に設定できませんでした:{error}', 'oauth.authFailed': '認証に失敗しました', 'oauth.loginFailed': '{provider} へのログインに失敗しました{detail}', 'oauth.missingState': 'コアから OAuth state が返されませんでした', @@ -204,18 +205,10 @@ export const jaOverrides = { 'aliases.created': '{alias} を作成し、推論レベルを {effort} に固定しました', 'aliases.deleteConfirm': 'エイリアスモデル「{alias}」を削除しますか?\n対応する推論レベルの上書きルールも削除されます。', 'aliases.deleted': '{alias} を削除しました', - 'aliases.title': '推論エイリアス', - 'aliases.intro.title': '元のモデルに固定推論レベルのカスタムエイリアスを追加', - 'aliases.intro.description': 'エイリアス名は自由に指定できます。クライアントがエイリアスを使用した場合のみ CPA が選択した推論レベルを注入し、元のモデルを直接使用した場合は名前、設定、動作を変更しません。', - 'aliases.intro.badge': '元のモデルには影響しません', - 'aliases.flow.aria': '推論エイリアスのワークフロー', - 'aliases.flow.original': '元のモデル', - 'aliases.flow.direct': '直接呼び出し', - 'aliases.flow.directValue': 'deepseek-v4-pro · 変更なし', - 'aliases.flow.alias': 'エイリアス呼び出し', - 'aliases.flow.aliasValue': 'deepseek-v4-pro-max · max に固定', 'aliases.create.title': '推論エイリアスを作成', 'aliases.create.description': 'Codex OAuth、Codex API、OpenAI 互換接続に対応', + 'aliases.modelAlias.title': 'モデルエイリアス', + 'aliases.modelAlias.description': 'クライアントが使用する名前を入力し、最終マッピングを確認します', 'aliases.originalModel': '元のモデル', 'aliases.loadingModels': 'モデルを読み込んでいます', 'aliases.searchModel': '元のモデルを検索して選択', @@ -336,6 +329,13 @@ export const jaOverrides = { 'authFiles.quota.expiry': '最も早い有効期限 {time}', 'authFiles.models.noneForProvider': 'このプロバイダーには設定可能なモデル定義がありません', 'authFiles.models.updated': '{provider} の公開モデルを更新しました', + 'authFiles.priority.button': '優先度 {priority}', + 'authFiles.priority.title': '認証情報の優先度を変更', + 'authFiles.priority.label': '優先度', + 'authFiles.priority.placeholder': '例:10 または -1', + 'authFiles.priority.hint': '整数のみ指定できます。値が大きいほど優先度が高くなります。空欄または 0 で既定値に戻ります。', + 'authFiles.priority.invalid': '有効な整数の優先度を入力してください', + 'authFiles.priority.updated': '{name} の優先度を更新しました', 'authFiles.uploaded': '認証ファイルを {count} 件アップロードしました', 'authFiles.uploadFailed': '{count} 件のファイルをアップロードできませんでした:{errors}', 'authFiles.notice.enabled': '認証ファイルを有効にしました', @@ -345,8 +345,6 @@ export const jaOverrides = { 'authFiles.deleted': '認証ファイルを削除しました', 'authFiles.deleteAllConfirm': 'ディスク上の認証ファイルをすべて削除しますか?実行時認証は削除されません。', 'authFiles.deletedAll': 'ディスク上の認証ファイルをすべて削除しました', - 'authFiles.runtimeDownloadError': '実行時認証ファイルにはダウンロード可能なディスクファイルがありません', - 'authFiles.downloaded': '認証ファイルをダウンロードしました', 'common.copyFailed': 'コピーに失敗しました', 'authFiles.title': '認証ファイル', 'authFiles.summary': 'ファイル {files} 件 · 無効 {disabled} 件', @@ -372,7 +370,7 @@ export const jaOverrides = { 'authFiles.models.settings': '公開モデルを設定', 'authFiles.models.button': 'モデル', 'authFiles.copyName': 'ファイル名をコピー', - 'common.download': 'ダウンロード', + 'authFiles.openDirectory': '認証情報フォルダーを開く', 'common.enable': '有効化', 'common.disable': '無効化', 'authFiles.runtimeFootnote': '{count} 件の認証情報は実行時ストレージ由来です。対応するプロバイダーまたは認証元でのみ管理できます。', @@ -409,10 +407,6 @@ 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': '現在利用可能なモデルはありません', @@ -710,6 +704,13 @@ export const jaOverrides = { 'appUpdate.available': 'バージョン {version} を利用できます', 'appUpdate.check': '更新を確認', 'appUpdate.checking': '確認中', + 'appUpdate.catalog.label': 'Codex モデルカタログ', + 'appUpdate.catalog.description': '起動時に GitHub から通知なしで自動同期します', + 'appUpdate.catalog.update': 'モデルカタログを同期', + 'appUpdate.catalog.updateHint': 'GitHub を確認して Codex モデルカタログを更新します', + 'appUpdate.catalog.updating': 'モデルカタログを同期しています', + 'appUpdate.catalog.updated': 'GitHub からモデルカタログを同期しました', + 'appUpdate.catalog.unchanged': 'モデルカタログは最新です', 'appUpdate.installNow': '今すぐ更新', 'appUpdate.openRelease': 'ダウンロードページを開く', 'appUpdate.confirmTitle': 'アプリ更新をインストール', diff --git a/src/i18n/resources.ts b/src/i18n/resources.ts index 66cd347..1448f00 100644 --- a/src/i18n/resources.ts +++ b/src/i18n/resources.ts @@ -63,6 +63,7 @@ export const zhCN = { 'quota.confirm.warning': '只有点击“确定”后才会执行;点击“取消”不会消耗重置机会。', 'oauth.title': 'OAuth 登录', 'oauth.loginSuccess': '{provider} 登录成功', + 'oauth.priorityApplyFailed': 'OAuth 登录成功,但无法将新凭证的优先级设为 0:{error}', 'oauth.authFailed': '认证失败', 'oauth.loginFailed': '{provider} 登录失败{detail}', 'oauth.missingState': '内核未返回 OAuth state', @@ -207,18 +208,10 @@ export const zhCN = { 'aliases.created': '已创建 {alias},思考强度固定为 {effort}', 'aliases.deleteConfirm': '确定删除别名模型“{alias}”吗?\n将同时移除对应的思考强度覆盖规则。', 'aliases.deleted': '已删除 {alias}', - 'aliases.title': '思考别名', - 'aliases.intro.title': '为原模型新增一个固定思考强度的自定义别名', - 'aliases.intro.description': '别名名称由你填写,客户端调用它时 CPA 才会注入所选思考强度;直接调用原模型时,原有名称、配置和行为全部保持不变。', - 'aliases.intro.badge': '原模型不受影响', - 'aliases.flow.aria': '思考别名工作流程', - 'aliases.flow.original': '原模型', - 'aliases.flow.direct': '直接调用', - 'aliases.flow.directValue': 'deepseek-v4-pro · 保持不变', - 'aliases.flow.alias': '别名调用', - 'aliases.flow.aliasValue': 'deepseek-v4-pro-max · 固定 max 强度', 'aliases.create.title': '创建思考别名', 'aliases.create.description': '支持 Codex OAuth、Codex API 和 OpenAI 兼容接入', + 'aliases.modelAlias.title': '模型别名', + 'aliases.modelAlias.description': '填写客户端调用名称并确认最终映射', 'aliases.originalModel': '原模型', 'aliases.loadingModels': '正在读取模型', 'aliases.searchModel': '搜索并选择原模型', @@ -339,6 +332,13 @@ export const zhCN = { 'authFiles.quota.expiry': '最早过期 {time}', 'authFiles.models.noneForProvider': '该提供商没有可配置的模型定义', 'authFiles.models.updated': '{provider} 开放模型已更新', + 'authFiles.priority.button': '优先级 {priority}', + 'authFiles.priority.title': '修改凭证优先级', + 'authFiles.priority.label': '优先级', + 'authFiles.priority.placeholder': '例如 10 或 -1', + 'authFiles.priority.hint': '仅支持整数;数值越大优先级越高。留空或填写 0 恢复默认。', + 'authFiles.priority.invalid': '请输入有效的整数优先级', + 'authFiles.priority.updated': '{name} 的优先级已更新', 'authFiles.uploaded': '已上传 {count} 个认证文件', 'authFiles.uploadFailed': '{count} 个文件上传失败:{errors}', 'authFiles.notice.enabled': '认证文件已启用', @@ -348,8 +348,6 @@ export const zhCN = { 'authFiles.deleted': '认证文件已删除', 'authFiles.deleteAllConfirm': '确定删除所有磁盘认证文件吗?运行时认证不会被删除。', 'authFiles.deletedAll': '磁盘认证文件已全部删除', - 'authFiles.runtimeDownloadError': '运行时认证文件没有可下载的磁盘文件', - 'authFiles.downloaded': '认证文件已下载', 'common.copyFailed': '复制失败', 'authFiles.title': '认证文件', 'authFiles.summary': '{files} 个文件 · {disabled} 个停用', @@ -375,7 +373,7 @@ export const zhCN = { 'authFiles.models.settings': '设置开放模型', 'authFiles.models.button': '模型', 'authFiles.copyName': '复制文件名', - 'common.download': '下载', + 'authFiles.openDirectory': '打开凭证所在文件夹', 'common.enable': '启用', 'common.disable': '停用', 'authFiles.runtimeFootnote': '{count} 个凭据来自运行时存储,只能在对应提供商或认证源中管理。', @@ -412,10 +410,6 @@ 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': '暂时没有可用模型', @@ -713,6 +707,13 @@ export const zhCN = { 'appUpdate.available': '发现新版本 {version}', 'appUpdate.check': '检查更新', 'appUpdate.checking': '正在检查', + 'appUpdate.catalog.label': 'Codex 模型目录', + 'appUpdate.catalog.description': '启动时自动从 GitHub 静默同步', + 'appUpdate.catalog.update': '同步模型目录', + 'appUpdate.catalog.updateHint': '从 GitHub 检查并更新 Codex 模型目录', + 'appUpdate.catalog.updating': '正在同步模型目录', + 'appUpdate.catalog.updated': '模型目录已从 GitHub 同步', + 'appUpdate.catalog.unchanged': '模型目录已是最新版本', 'appUpdate.installNow': '立即升级', 'appUpdate.openRelease': '打开下载页面', 'appUpdate.confirmTitle': '安装软件更新', @@ -911,6 +912,7 @@ export const en: Record = { 'quota.confirm.warning': 'The reset runs only after you confirm. Canceling will not consume a reset credit.', 'oauth.title': 'OAuth Sign-In', 'oauth.loginSuccess': '{provider} sign-in succeeded', + 'oauth.priorityApplyFailed': 'OAuth sign-in succeeded, but the new credential priority could not be set to 0: {error}', 'oauth.authFailed': 'Authentication failed', 'oauth.loginFailed': '{provider} sign-in failed{detail}', 'oauth.missingState': 'The core did not return an OAuth state', @@ -1055,18 +1057,10 @@ export const en: Record = { 'aliases.created': 'Created {alias} with reasoning effort fixed to {effort}', 'aliases.deleteConfirm': 'Delete the model alias “{alias}”?\nIts reasoning-effort override will also be removed.', 'aliases.deleted': 'Deleted {alias}', - 'aliases.title': 'Reasoning Aliases', - 'aliases.intro.title': 'Add a custom alias with a fixed reasoning effort to an original model', - 'aliases.intro.description': 'Choose the alias name. CPA injects the selected reasoning effort only when a client calls that alias. Calls to the original model keep their existing name, configuration, and behavior.', - 'aliases.intro.badge': 'Original model is unchanged', - 'aliases.flow.aria': 'Reasoning alias workflow', - 'aliases.flow.original': 'Original model', - 'aliases.flow.direct': 'Direct call', - 'aliases.flow.directValue': 'deepseek-v4-pro · unchanged', - 'aliases.flow.alias': 'Alias call', - 'aliases.flow.aliasValue': 'deepseek-v4-pro-max · fixed max effort', 'aliases.create.title': 'Create Reasoning Alias', 'aliases.create.description': 'Supports Codex OAuth, Codex API, and OpenAI-compatible connections', + 'aliases.modelAlias.title': 'Model Alias', + 'aliases.modelAlias.description': 'Enter the client-facing name and confirm the final mapping', 'aliases.originalModel': 'Original Model', 'aliases.loadingModels': 'Loading models', 'aliases.searchModel': 'Search and select an original model', @@ -1187,6 +1181,13 @@ export const en: Record = { 'authFiles.quota.expiry': 'Earliest expiry {time}', 'authFiles.models.noneForProvider': 'This provider has no configurable model definitions', 'authFiles.models.updated': 'Open models updated for {provider}', + 'authFiles.priority.button': 'Priority {priority}', + 'authFiles.priority.title': 'Edit Credential Priority', + 'authFiles.priority.label': 'Priority', + 'authFiles.priority.placeholder': 'For example, 10 or -1', + 'authFiles.priority.hint': 'Integers only. Larger values have higher priority. Leave blank or enter 0 to restore the default.', + 'authFiles.priority.invalid': 'Enter a valid integer priority', + 'authFiles.priority.updated': 'Priority updated for {name}', 'authFiles.uploaded': 'Uploaded {count} authentication files', 'authFiles.uploadFailed': '{count} files failed to upload: {errors}', 'authFiles.notice.enabled': 'Authentication file enabled', @@ -1196,8 +1197,6 @@ export const en: Record = { 'authFiles.deleted': 'Authentication file deleted', 'authFiles.deleteAllConfirm': 'Delete all on-disk authentication files? Runtime credentials will not be deleted.', 'authFiles.deletedAll': 'All on-disk authentication files deleted', - 'authFiles.runtimeDownloadError': 'This runtime authentication file has no downloadable disk file', - 'authFiles.downloaded': 'Authentication file downloaded', 'common.copyFailed': 'Copy failed', 'authFiles.title': 'Authentication Files', 'authFiles.summary': '{files} files · {disabled} disabled', @@ -1223,7 +1222,7 @@ export const en: Record = { 'authFiles.models.settings': 'Configure open models', 'authFiles.models.button': 'Models', 'authFiles.copyName': 'Copy file name', - 'common.download': 'Download', + 'authFiles.openDirectory': 'Open credential folder', 'common.enable': 'Enable', 'common.disable': 'Disable', 'authFiles.runtimeFootnote': '{count} credentials come from runtime storage and can only be managed through their provider or authentication source.', @@ -1260,10 +1259,6 @@ 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', @@ -1561,6 +1556,13 @@ export const en: Record = { 'appUpdate.available': 'Version {version} is available', 'appUpdate.check': 'Check for Updates', 'appUpdate.checking': 'Checking', + 'appUpdate.catalog.label': 'Codex Model Catalog', + 'appUpdate.catalog.description': 'Silently syncs from GitHub when the app starts', + 'appUpdate.catalog.update': 'Sync Model Catalog', + 'appUpdate.catalog.updateHint': 'Check GitHub and update the Codex model catalog', + 'appUpdate.catalog.updating': 'Syncing Model Catalog', + 'appUpdate.catalog.updated': 'Model catalog synced from GitHub', + 'appUpdate.catalog.unchanged': 'Model catalog is already up to date', 'appUpdate.installNow': 'Update Now', 'appUpdate.openRelease': 'Open Download Page', 'appUpdate.confirmTitle': 'Install Application Update', diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index 5f22d60..7c92ba9 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -85,10 +85,6 @@ type AgentConfigActionResult = { conflictFiles: string[]; }; -type CodexModelCatalogUpdateResult = { - outcome: 'updated' | 'unchanged'; -}; - type ChatGptCloseResult = { wasRunning: boolean; closedProcesses: number; @@ -488,13 +484,11 @@ 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' | 'catalog' | null>(null); + const [busyAction, setBusyAction] = useState<'apply' | 'default' | 'clear' | 'launch' | 'close-app' | 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(''); @@ -592,8 +586,6 @@ export function AgentsPage() { useEffect(() => { setActiveSubpage(DEFAULT_AGENT_SUBPAGE); setModelSelectionError(''); - setCatalogUpdateError(''); - setCatalogUpdateNotice(''); setConfigurationError(''); setLaunchError(''); setDefaultError(''); @@ -688,23 +680,6 @@ 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 { @@ -989,31 +964,6 @@ export function AgentsPage() { {modelHint} ) : null} - {selected === 'codex' ? ( -
- - {catalogUpdateError ? ( - - {catalogUpdateError} - - ) : null} - {catalogUpdateNotice ? ( - - {catalogUpdateNotice} - - ) : null} -
- ) : null}
diff --git a/src/pages/AuthFileManagementPage.tsx b/src/pages/AuthFileManagementPage.tsx index a2e0c7a..aa80edf 100644 --- a/src/pages/AuthFileManagementPage.tsx +++ b/src/pages/AuthFileManagementPage.tsx @@ -2,9 +2,10 @@ import { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from ' import { Check, Copy, - Download, FileDown, + FolderOpen, LoaderCircle, + Pencil, RefreshCw, Search, Trash2, @@ -45,6 +46,8 @@ import { authFileName, dedupeAuthFiles, isRuntimeOnlyAuthFile, + normalizeAuthFilePriorityInput, + parseAuthFilePriority, } from '../services/authFiles'; import { exclusionsForOpenOAuthModels, @@ -57,6 +60,13 @@ import { getCurrentLocale, translate, useI18n } from '../i18n'; type AuthFile = Record; +type PriorityEditor = { + fileName: string; + originalPriority: number; + value: string; + error: string; +}; + const providerIcons: Record = { antigravity: antigravityIcon, claude: claudeIcon, @@ -144,6 +154,7 @@ export function AuthFileManagementPage() { const [error, setError] = useState(''); const [notice, setNotice] = useState(''); const [copied, setCopied] = useState(''); + const [priorityEditor, setPriorityEditor] = useState(null); const [oauthModelProvider, setOauthModelProvider] = useState(''); const [oauthModelProviderLabel, setOauthModelProviderLabel] = useState(''); const [oauthModels, setOauthModels] = useState([]); @@ -361,6 +372,51 @@ export function AuthFileManagementPage() { } }; + const openPriorityEditor = (file: AuthFile) => { + const priority = parseAuthFilePriority(file.priority); + setPriorityEditor({ + fileName: fileName(file), + originalPriority: priority ?? 0, + value: priority === undefined || priority === 0 ? '' : String(priority), + error: '', + }); + }; + + const closePriorityEditor = () => { + if (!busy) setPriorityEditor(null); + }; + + const savePriority = async () => { + if (!priorityEditor || busy) return; + const priority = normalizeAuthFilePriorityInput(priorityEditor.value); + if (priority === null) { + setPriorityEditor((current) => current + ? { ...current, error: t('authFiles.priority.invalid') } + : current); + return; + } + if (priority === priorityEditor.originalPriority) { + setPriorityEditor(null); + return; + } + + const name = priorityEditor.fileName; + setBusy(true); + setError(''); + try { + await managementApi.patch('/auth-files/fields', { name, priority }); + setPriorityEditor(null); + setNotice(t('authFiles.priority.updated', { name })); + await loadFiles(); + } catch (requestError) { + setPriorityEditor((current) => current + ? { ...current, error: String(requestError) } + : current); + } finally { + setBusy(false); + } + }; + const deleteFile = async (file: AuthFile) => { const name = fileName(file); if (isRuntimeOnly(file)) { @@ -396,23 +452,11 @@ export function AuthFileManagementPage() { } }; - const downloadFile = async (file: AuthFile) => { - const name = fileName(file); - if (isRuntimeOnly(file)) { - setError(t('authFiles.runtimeDownloadError')); - return; - } + const openAuthFilesDirectory = async () => { setBusy(true); setError(''); try { - const content = await managementApi.downloadAuthFile(name); - const url = URL.createObjectURL(new Blob([content], { type: 'application/json' })); - const anchor = document.createElement('a'); - anchor.href = url; - anchor.download = name; - anchor.click(); - URL.revokeObjectURL(url); - setNotice(t('authFiles.downloaded')); + await managementApi.openAuthFilesDirectory(); } catch (requestError) { setError(String(requestError)); } finally { @@ -485,6 +529,7 @@ export function AuthFileManagementPage() { const name = fileName(file); const icon = providerIcons[providerKey(file)] ?? geminiIcon; const disabled = readBoolean(file, 'disabled'); + const priority = parseAuthFilePriority(file.priority) ?? 0; return (
@@ -500,8 +545,9 @@ export function AuthFileManagementPage() {
{quotaProviderForFile(file) ? : null} {providerKey(file) ? : null} + - +
@@ -514,6 +560,51 @@ export function AuthFileManagementPage() {
{runtimeCount > 0 ?

{t('authFiles.runtimeFootnote', { count: runtimeCount })}

: null} + {priorityEditor ? ( +
event.currentTarget === event.target && closePriorityEditor()}> +
{ + event.preventDefault(); + void savePriority(); + }} + > +
+

{t('authFiles.priority.title')}

+ +
+
{priorityEditor.fileName}
+ +
+ {priorityEditor.error || ' '} +
+
+ + +
+
+
+ ) : null} + {oauthModelProvider ? (
event.currentTarget === event.target && !oauthModelSaving && closeOauthModels()}>
diff --git a/src/pages/Kernel.tsx b/src/pages/Kernel.tsx index 4ff762e..dc38d63 100644 --- a/src/pages/Kernel.tsx +++ b/src/pages/Kernel.tsx @@ -48,6 +48,10 @@ type CoreInstallTask = { result: CoreInstallResult | null; }; +type CodexModelCatalogUpdateResult = { + outcome: 'updated' | 'unchanged'; +}; + type MessageType = 'info' | 'success' | 'error'; type CoreProcessCommand = 'start_core_process' | 'stop_core_process' | 'restart_core_process'; @@ -131,6 +135,9 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) { } | null>(null); const [message, setMessage] = useState(''); const [messageType, setMessageType] = useState('info'); + const [catalogUpdating, setCatalogUpdating] = useState(false); + const [catalogUpdateError, setCatalogUpdateError] = useState(''); + const [catalogUpdateNotice, setCatalogUpdateNotice] = useState(''); const [progress, setProgress] = useState(null); const [installDialogOpen, setInstallDialogOpen] = useState(false); const [cancellingInstall, setCancellingInstall] = useState(false); @@ -551,6 +558,22 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) { } }; + const updateCodexModelCatalog = async () => { + setCatalogUpdating(true); + setCatalogUpdateError(''); + setCatalogUpdateNotice(''); + try { + const result = await invoke('update_codex_model_catalog'); + setCatalogUpdateNotice(result.outcome === 'updated' + ? t('appUpdate.catalog.updated') + : t('appUpdate.catalog.unchanged')); + } catch (error) { + setCatalogUpdateError(String(error)); + } finally { + setCatalogUpdating(false); + } + }; + const latestVersion = latest?.version ?? ''; const currentVersion = coreStatus?.currentVersion ?? ''; const coreInstalled = Boolean(coreStatus?.installed); @@ -779,6 +802,18 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) { : t('appUpdate.phase.checking')}
+
+
{t('appUpdate.catalog.label')}
+
+ {catalogUpdateError + || catalogUpdateNotice + || t('appUpdate.catalog.description')} +
+
@@ -804,6 +839,17 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) { {t('appUpdate.openRelease')}
) : null} diff --git a/src/pages/ManagementPages.tsx b/src/pages/ManagementPages.tsx index d3878cd..6db0fc2 100644 --- a/src/pages/ManagementPages.tsx +++ b/src/pages/ManagementPages.tsx @@ -13,6 +13,12 @@ import codexIcon from '../assets/icons/codex.svg'; import grokIcon from '../assets/icons/grok.svg'; import kimiIcon from '../assets/icons/kimi-light.svg'; import { useI18n } from '../i18n'; +import { + changedOAuthAuthFileNames, + snapshotAuthFiles, + type AuthFileSnapshot, +} from '../services/authFiles'; +import { managementApi, responseList } from '../services/managementApi'; type OAuthProviderId = 'codex' | 'claude' | 'antigravity' | 'kimi' | 'xai'; type OAuthFlowStatus = 'idle' | 'waiting' | 'success' | 'error'; @@ -69,6 +75,7 @@ export function OAuthLoginPage() { const pollingTimers = useRef>>({}); const pollingRequests = useRef>>({}); const successResetTimers = useRef>>({}); + const credentialSnapshots = useRef>>({}); const noticeTimerRef = useRef(null); const showNotice = useCallback((message: string, tone: 'success' | 'error' | 'info') => { @@ -118,6 +125,7 @@ export function OAuthLoginPage() { const resetProviderAttempt = useCallback( (provider: OAuthProviderId) => { clearProviderTimers(provider); + delete credentialSnapshots.current[provider]; setStates((current) => ({ ...current, [provider]: { status: 'idle' } })); }, [clearProviderTimers], @@ -144,6 +152,24 @@ export function OAuthLoginPage() { [clearProviderTimers, resetProviderAttempt, updateProviderState], ); + const captureCredentialSnapshot = useCallback(async (provider: OAuthProviderId) => { + const payload = await managementApi.get('/auth-files'); + credentialSnapshots.current[provider] = snapshotAuthFiles(responseList(payload, 'files')); + }, []); + + const applyDefaultCredentialPriority = useCallback(async (provider: OAuthProviderId) => { + const before = credentialSnapshots.current[provider]; + delete credentialSnapshots.current[provider]; + if (!before) return; + + const payload = await managementApi.get('/auth-files'); + const names = changedOAuthAuthFileNames(before, responseList(payload, 'files'), provider); + await Promise.all(names.map((name) => managementApi.patch('/auth-files/fields', { + name, + priority: 0, + }))); + }, []); + const startPolling = useCallback( (provider: OAuthProviderId, state: string) => { clearPollingTimer(provider); @@ -154,8 +180,19 @@ export function OAuthLoginPage() { const result = await invoke('get_oauth_status', { state }); const status = (result.status || '').toLowerCase(); if (status === 'ok') { + let priorityError = ''; + try { + await applyDefaultCredentialPriority(provider); + } catch (error) { + priorityError = String(error); + } completeProviderAuth(provider); - showNotice(t('oauth.loginSuccess', { provider: providerLabel(provider) }), 'success'); + showNotice( + priorityError + ? t('oauth.priorityApplyFailed', { error: priorityError }) + : t('oauth.loginSuccess', { provider: providerLabel(provider) }), + priorityError ? 'error' : 'success', + ); } else if (status === 'error') { updateProviderState(provider, { status: 'error', @@ -188,7 +225,7 @@ export function OAuthLoginPage() { OAUTH_POLL_INTERVAL_MS, ); }, - [clearPollingTimer, completeProviderAuth, showNotice, t, updateProviderState], + [applyDefaultCredentialPriority, clearPollingTimer, completeProviderAuth, showNotice, t, updateProviderState], ); useEffect(() => { @@ -217,6 +254,7 @@ export function OAuthLoginPage() { }); try { + await captureCredentialSnapshot(provider); const result = await invoke('start_oauth_login', { provider }); if (!result.state) { updateProviderState(provider, { @@ -247,6 +285,7 @@ export function OAuthLoginPage() { ); } } catch (error) { + delete credentialSnapshots.current[provider]; updateProviderState(provider, { status: 'error', error: String(error), diff --git a/src/pages/ThinkingAliasesPage.tsx b/src/pages/ThinkingAliasesPage.tsx index 8f5ad8a..56a10ce 100644 --- a/src/pages/ThinkingAliasesPage.tsx +++ b/src/pages/ThinkingAliasesPage.tsx @@ -248,46 +248,23 @@ export function ThinkingAliasesPage() { return (
-
-
- Model Alias -

{t('aliases.title')}

-
-
-
{error ?
{error}
: null} {!error && notice ?
{notice}
: null}
-
- -
- {t('aliases.intro.title')} -

{t('aliases.intro.description')}

- {t('aliases.intro.badge')} -
-
- {t('aliases.flow.original')}deepseek-v4-pro - -
- {t('aliases.flow.direct')}{t('aliases.flow.directValue')} - {t('aliases.flow.alias')}{t('aliases.flow.aliasValue')} -
-
-
-
-
-
- -
-

{t('aliases.create.title')}

-

{t('aliases.create.description')}

+
+
+
+ +
+

{t('aliases.create.title')}

+

{t('aliases.create.description')}

+
-
-
+
@@ -379,9 +356,9 @@ export function ThinkingAliasesPage() { ) : ( {t('aliases.sourceHint')} )} -
+
-
+
{t('aliases.effort.title')} {t('aliases.effort.description')} @@ -439,43 +416,54 @@ export function ThinkingAliasesPage() { {t('aliases.customHelp')}
: null} -
- -
-
- {t('aliases.aliasName.title')} - {t('aliases.aliasName.description')}
- setAlias(event.currentTarget.value)} - placeholder={selectedSource - ? t('aliases.aliasName.example', { model: selectedSource.model, effort: normalizedEffort || 'high' }) - : t('aliases.aliasName.selectFirst')} - disabled={Boolean(busyAlias)} - /> -
+
-
- -
- {selectedSource?.model || t('aliases.notSelected')} {alias || t('aliases.enterAlias')} - {selectedSource?.protocol === 'openai' ? 'reasoning_effort' : 'reasoning.effort'} = {normalizedEffort || t('aliases.notSet')} +
+
+ +
+

{t('aliases.modelAlias.title')}

+

{t('aliases.modelAlias.description')}

+
-
- -
+
+
+ {t('aliases.aliasName.title')} + {t('aliases.aliasName.description')} +
+ setAlias(event.currentTarget.value)} + placeholder={selectedSource + ? t('aliases.aliasName.example', { model: selectedSource.model, effort: normalizedEffort || 'high' }) + : t('aliases.aliasName.selectFirst')} + disabled={Boolean(busyAlias)} + /> +
+ +
+ +
+ {selectedSource?.model || t('aliases.notSelected')} {alias || t('aliases.enterAlias')} + {selectedSource?.protocol === 'openai' ? 'reasoning_effort' : 'reasoning.effort'} = {normalizedEffort || t('aliases.notSet')} +
+
+ + + +
diff --git a/src/services/authFiles.ts b/src/services/authFiles.ts index 37b01bb..ac147ef 100644 --- a/src/services/authFiles.ts +++ b/src/services/authFiles.ts @@ -2,6 +2,7 @@ import { readBoolean, readString } from './managementApi'; import { getCurrentLocale, translate } from '../i18n'; export type AuthFileRecord = Record; +export type AuthFileSnapshot = Map; export const authFileName = (file: AuthFileRecord) => readString(file, 'name') || translate(getCurrentLocale(), 'authFiles.unnamed'); @@ -9,6 +10,66 @@ export const authFileName = (file: AuthFileRecord) => export const isRuntimeOnlyAuthFile = (file: AuthFileRecord) => readBoolean(file, 'runtime_only', 'runtimeOnly'); +export const parseAuthFilePriority = (value: unknown): number | undefined => { + if (typeof value === 'number') { + return Number.isSafeInteger(value) ? value : undefined; + } + if (typeof value !== 'string') return undefined; + const normalized = value.trim(); + if (!/^-?\d+$/.test(normalized)) return undefined; + const priority = Number(normalized); + return Number.isSafeInteger(priority) ? priority : undefined; +}; + +export const normalizeAuthFilePriorityInput = (value: string): number | null => { + const normalized = value.trim(); + if (!normalized) return 0; + return parseAuthFilePriority(normalized) ?? null; +}; + +const normalizeOAuthProvider = (value: string) => { + const provider = value.trim().toLowerCase(); + if (provider === 'anthropic') return 'claude'; + if (provider === 'openai') return 'codex'; + return provider; +}; + +const authFileProvider = (file: AuthFileRecord) => + normalizeOAuthProvider(readString(file, 'provider', 'type')); + +export const snapshotAuthFiles = (files: AuthFileRecord[]): AuthFileSnapshot => { + const grouped = new Map(); + files.forEach((file) => { + const name = readString(file, 'name'); + if (!name) return; + const fingerprints = grouped.get(name) ?? []; + fingerprints.push(JSON.stringify(file)); + grouped.set(name, fingerprints); + }); + return new Map(Array.from(grouped, ([name, fingerprints]) => [ + name, + fingerprints.sort().join('\n'), + ])); +}; + +export const changedOAuthAuthFileNames = ( + before: AuthFileSnapshot, + files: AuthFileRecord[], + provider: string, +) => { + const expectedProvider = normalizeOAuthProvider(provider); + const after = snapshotAuthFiles(files); + const names = new Set(); + files.forEach((file) => { + const name = readString(file, 'name'); + if (!name || authFileProvider(file) !== expectedProvider) return; + const priority = parseAuthFilePriority(file.priority); + if (priority !== undefined && priority !== 0) return; + if (before.get(name) !== after.get(name)) names.add(name); + }); + return Array.from(names); +}; + const hasMeaningfulValue = (value: unknown) => { if (value === null || value === undefined) return false; if (typeof value === 'string') return value.trim().length > 0; diff --git a/src/services/managementApi.ts b/src/services/managementApi.ts index a6b85b0..99a9812 100644 --- a/src/services/managementApi.ts +++ b/src/services/managementApi.ts @@ -58,7 +58,7 @@ export const managementApi = { data, }); }, - downloadAuthFile: (name: string) => invoke('download_auth_file', { name }), + openAuthFilesDirectory: () => invoke('open_auth_files_directory'), }; export function isRecord(value: unknown): value is Record { diff --git a/src/services/quotaService.ts b/src/services/quotaService.ts index e1f2f98..3791618 100644 --- a/src/services/quotaService.ts +++ b/src/services/quotaService.ts @@ -540,24 +540,8 @@ export const quotaRowsFor = (provider: QuotaProvider, payload: unknown): QuotaRo }); }; -const resolveProjectId = async (file: AuthFile): Promise => { - const direct = readString(file, 'project_id', 'projectId'); - if (direct) return direct; - try { - const content = await managementApi.downloadAuthFile(fileName(file)); - const parsed = JSON.parse(content) as Record; - const direct = readString(parsed, 'project_id', 'projectId'); - if (direct) return direct; - for (const container of [parsed.installed, parsed.web]) { - if (!isRecord(container)) continue; - const nested = readString(container, 'project_id', 'projectId'); - if (nested) return nested; - } - return ''; - } catch { - return ''; - } -}; +const resolveProjectId = (file: AuthFile): string => + readString(file, 'project_id', 'projectId'); const decodeJwtPayload = (value: unknown): Record | null => { if (isRecord(value)) return value; @@ -614,18 +598,8 @@ const codexAccountIdFromRecord = (record: Record): string => { return ''; }; -const resolveCodexAccountId = async (file: AuthFile): Promise => { - const direct = codexAccountIdFromRecord(file); - if (direct) return direct; - - try { - const content = await managementApi.downloadAuthFile(fileName(file)); - const parsed = JSON.parse(content); - return isRecord(parsed) ? codexAccountIdFromRecord(parsed) : ''; - } catch { - return ''; - } -}; +const resolveCodexAccountId = (file: AuthFile): string => + codexAccountIdFromRecord(file); const xaiUserIdFromRecord = (record: Record): string => { const nestedRecords = [record, record.metadata, record.attributes] @@ -642,17 +616,8 @@ const xaiUserIdFromRecord = (record: Record): string => { return ''; }; -const resolveXaiUserId = async (file: AuthFile): Promise => { - const direct = xaiUserIdFromRecord(file); - if (direct) return direct; - try { - const content = await managementApi.downloadAuthFile(fileName(file)); - const parsed = JSON.parse(content); - return isRecord(parsed) ? xaiUserIdFromRecord(parsed) : ''; - } catch { - return ''; - } -}; +const resolveXaiUserId = (file: AuthFile): string => + xaiUserIdFromRecord(file); const requestQuotaPayload = async ( authIndex: string, diff --git a/src/styles.css b/src/styles.css index 0bac950..e066c7c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -858,7 +858,7 @@ input:focus-visible { grid-template-columns: minmax(0, 1.35fr) minmax(280px, 0.65fr); align-items: stretch; gap: 18px; - height: 430px; + height: 350px; } .control-panel { @@ -1493,20 +1493,20 @@ input:focus-visible { .config-keys-panel { grid-template-rows: 42px minmax(0, 1fr); gap: 16px; - height: 430px; + height: 350px; } .config-side-stack { display: grid; - grid-template-rows: repeat(2, 206px); + grid-template-rows: repeat(2, 166px); gap: 18px; - height: 430px; + height: 350px; } .config-setting-panel { grid-template-rows: 42px minmax(0, 1fr); gap: 16px; - height: 206px; + height: 166px; } .config-network-panel { @@ -3453,110 +3453,34 @@ input:focus-visible { min-height: 42px; } -.thinking-alias-guide { - display: grid; - grid-template-columns: 24px minmax(270px, 1fr) minmax(280px, auto); - align-items: center; - gap: 12px; - min-height: 86px; - padding: 12px 14px; - border: 1px solid var(--theme-d9d4c9); - border-radius: 8px; - background: var(--theme-f8f6f1); - color: var(--theme-655f57); -} - -.thinking-alias-guide > svg { - color: var(--theme-654f91); -} - -.thinking-alias-guide-copy { - display: grid; - min-width: 0; - gap: 4px; -} - -.thinking-alias-guide strong { - color: var(--theme-342f2a); - font-size: 12px; -} - -.thinking-alias-guide p { - margin: 0; - color: var(--theme-777067); - font-size: 10px; - line-height: 1.5; -} - -.thinking-alias-guide-copy > span { - width: fit-content; - padding: 2px 7px; - border: 1px solid var(--theme-b9d5bf); - border-radius: 999px; - background: var(--theme-eef7f0); - color: var(--theme-2f6b3f); - font-size: 9px; - font-weight: 800; -} - -.thinking-alias-guide code { - padding: 6px 9px; - border: 1px solid var(--theme-d8d1eb); - border-radius: 6px; - background: var(--theme-f5f1ff); - color: var(--theme-654f91); - font-size: 10px; - white-space: nowrap; -} - -.thinking-alias-flow { - display: grid; - grid-template-columns: auto 14px minmax(0, auto); - align-items: center; - gap: 6px; -} - -.thinking-alias-flow > span, -.thinking-alias-flow > div > span { - display: grid; - gap: 3px; -} - -.thinking-alias-flow > div { - display: grid; - gap: 5px; -} - -.thinking-alias-flow > div > span { - grid-template-columns: 54px minmax(0, auto); - align-items: center; -} - -.thinking-alias-flow small { - color: var(--theme-8a847c); - font-size: 8px; - font-weight: 700; - text-align: center; -} - -.thinking-alias-flow > svg { - color: var(--theme-aaa49b); -} - .thinking-alias-workbench { display: grid; - grid-template-columns: minmax(330px, 390px) minmax(0, 1fr); + grid-template-columns: minmax(480px, 1.4fr) minmax(320px, 0.6fr); gap: 16px; - align-items: stretch; + align-items: start; } -.thinking-alias-builder, -.thinking-alias-list-panel { - height: clamp(420px, calc(100vh - 270px), 580px); - min-height: 420px; +.thinking-alias-left-stack { + display: grid; + min-width: 0; + gap: 16px; } .thinking-alias-builder { + min-height: 330px; +} + +.thinking-alias-name-panel { + min-height: 250px; +} + +.thinking-alias-list-panel { + height: clamp(360px, calc(100vh - 330px), 460px); + min-height: 360px; +} + +.thinking-alias-builder, +.thinking-alias-name-panel { position: relative; z-index: 2; display: flex; @@ -3596,14 +3520,15 @@ input:focus-visible { .thinking-alias-list-heading h2 { margin: 0; color: var(--theme-2d2a26); - font-size: 15px; + font-size: 17px; } .thinking-alias-panel-heading p, .thinking-alias-list-heading span { margin: 0; color: var(--theme-8a847c); - font-size: 10px; + font-size: 14px; + line-height: 1.45; } .thinking-alias-field { @@ -3619,7 +3544,7 @@ input:focus-visible { .thinking-alias-field > label { color: var(--theme-6f6961); - font-size: 11px; + font-size: 14px; font-weight: 700; } @@ -3632,13 +3557,13 @@ input:focus-visible { .thinking-field-heading strong { color: var(--theme-6f6961); - font-size: 11px; + font-size: 14px; } .thinking-field-heading span { overflow: hidden; color: var(--theme-918a81); - font-size: 9px; + font-size: 13px; text-overflow: ellipsis; white-space: nowrap; } @@ -3669,7 +3594,7 @@ input:focus-visible { outline: 0; background: transparent; color: var(--theme-2d2a26); - font-size: 12px; + font-size: 14px; } .thinking-source-kind { @@ -3680,7 +3605,7 @@ input:focus-visible { border-radius: 999px; background: var(--theme-f5f1ff); color: var(--theme-654f91); - font-size: 8px; + font-size: 11px; font-weight: 800; text-overflow: ellipsis; white-space: nowrap; @@ -3749,12 +3674,12 @@ input:focus-visible { } .thinking-model-option-copy strong { - font-size: 11px; + font-size: 15px; } .thinking-model-option-copy > span:first-child small { color: var(--theme-8a847c); - font-size: 9px; + font-size: 13px; } .thinking-model-source { @@ -3770,14 +3695,14 @@ input:focus-visible { border-radius: 4px; background: var(--theme-eeece6); color: var(--theme-5f5a53); - font-size: 8px; + font-size: 11px; font-style: normal; font-weight: 800; } .thinking-model-source small { color: var(--theme-8a847c); - font-size: 9px; + font-size: 13px; } .thinking-model-list > button > svg { @@ -3791,7 +3716,7 @@ input:focus-visible { justify-content: center; gap: 7px; color: var(--theme-8a847c); - font-size: 11px; + font-size: 13px; } .thinking-model-selection { @@ -3800,7 +3725,7 @@ input:focus-visible { align-items: center; gap: 6px; color: var(--theme-777067); - font-size: 9px; + font-size: 13px; } .thinking-model-selection > span { @@ -3822,8 +3747,8 @@ input:focus-visible { .thinking-model-hint { color: var(--theme-918a81); - font-size: 9px; - line-height: 1.4; + font-size: 13px; + line-height: 1.5; } .thinking-effort-options { @@ -3839,7 +3764,7 @@ input:focus-visible { border-radius: 6px; background: var(--theme-fff); color: var(--theme-7a746c); - font-size: 10px; + font-size: 13px; font-weight: 700; } @@ -3876,7 +3801,7 @@ input:focus-visible { outline: 0; background: var(--theme-fff); color: var(--theme-2d2a26); - font-size: 11px; + font-size: 14px; } .thinking-effort-custom input:focus { @@ -3892,7 +3817,8 @@ input:focus-visible { .thinking-effort-custom small { grid-column: 1 / -1; color: var(--theme-918a81); - font-size: 9px; + font-size: 12px; + line-height: 1.45; } .thinking-alias-input { @@ -3904,7 +3830,7 @@ input:focus-visible { outline: 0; background: var(--theme-fff); color: var(--theme-2d2a26); - font-size: 12px; + font-size: 14px; } .thinking-alias-input:focus, @@ -3939,7 +3865,7 @@ input:focus-visible { gap: 5px; overflow: hidden; color: var(--theme-3f3550); - font-size: 11px; + font-size: 15px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; @@ -3947,7 +3873,7 @@ input:focus-visible { .thinking-alias-preview code { color: var(--theme-8069ad); - font-size: 10px; + font-size: 13px; } .thinking-alias-create { @@ -3957,7 +3883,7 @@ input:focus-visible { .thinking-alias-list-panel { display: grid; - grid-template-rows: 48px minmax(0, 1fr); + grid-template-rows: 64px minmax(0, 1fr); gap: 12px; padding: 16px; } @@ -3985,7 +3911,7 @@ input:focus-visible { border-radius: 999px; background: var(--theme-eeece6); color: var(--theme-5f5a53); - font-size: 11px; + font-size: 13px; } .thinking-alias-list { @@ -3998,8 +3924,8 @@ input:focus-visible { grid-template-columns: minmax(0, 1fr) auto 34px; align-items: center; gap: 10px; - min-height: 58px; - padding: 8px 7px 8px 10px; + min-height: 66px; + padding: 9px 7px 9px 10px; border-bottom: 1px solid var(--theme-eeeae2); } @@ -4024,7 +3950,7 @@ input:focus-visible { .thinking-alias-route-source > span { color: var(--theme-7a746c); - font-size: 11px; + font-size: 14px; } .thinking-alias-route-source { @@ -4046,19 +3972,19 @@ input:focus-visible { border-radius: 3px; background: var(--theme-eeece6); color: var(--theme-6f6961); - font-size: 7px; + font-size: 10px; font-style: normal; font-weight: 800; } .thinking-alias-route-source > small span { color: var(--theme-9a938a); - font-size: 8px; + font-size: 12px; } .thinking-alias-route strong { color: var(--theme-2d2a26); - font-size: 12px; + font-size: 15px; } .thinking-alias-route svg { @@ -4072,7 +3998,7 @@ input:focus-visible { border-radius: 999px; background: var(--theme-f5f1ff); color: var(--theme-654f91); - font-size: 10px; + font-size: 12px; font-weight: 800; text-align: center; text-transform: uppercase; @@ -4085,6 +4011,25 @@ input:focus-visible { text-transform: none; } +@media (min-width: 1081px) { + .thinking-alias-list-panel .thinking-alias-row { + grid-template-columns: minmax(0, 1fr) auto 34px; + min-height: 94px; + } + + .thinking-alias-list-panel .thinking-alias-route { + grid-column: 1 / -1; + } + + .thinking-alias-list-panel .thinking-effort-badge { + grid-column: 2; + } + + .thinking-alias-list-panel .thinking-alias-row > .icon-button { + grid-column: 3; + } +} + .agent-workbench { display: grid; grid-template-columns: 230px minmax(0, 1fr); @@ -4934,18 +4879,6 @@ 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; @@ -6125,6 +6058,68 @@ input:focus-visible { min-width: 52px; } +.real-auth-file-row .auth-file-priority-button { + min-width: 92px; + white-space: nowrap; +} + +.auth-priority-dialog { + grid-template-rows: 40px auto auto 20px 40px; + width: min(460px, 100%); + height: auto; + min-height: 0; +} + +.auth-priority-target { + overflow: hidden; + padding: 9px 11px; + border: 1px solid var(--theme-e3e1db); + border-radius: 6px; + background: var(--theme-f4f1ea); + color: var(--theme-5f5a53); + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.auth-priority-field { + display: grid; + gap: 8px; + color: var(--theme-5f5a53); + font-size: 13px; + font-weight: 700; +} + +.auth-priority-field input { + width: 100%; + height: 42px; + min-width: 0; + padding: 0 12px; + border: 1px solid var(--theme-d5d2cb); + border-radius: 6px; + outline: none; + background: var(--theme-ffffff); + color: var(--theme-2d2a26); + font: inherit; +} + +.auth-priority-field input:focus { + border-color: var(--theme-777067); + box-shadow: 0 0 0 3px rgba(119, 112, 103, 0.12); +} + +.auth-priority-field input[aria-invalid='true'] { + border-color: var(--theme-a83f32); +} + +.auth-priority-field small { + color: var(--theme-7a746c); + font-size: 12px; + font-weight: 400; + line-height: 1.5; +} + .page-footnote { margin: -6px 0 0; color: var(--theme-7a746c); @@ -6733,8 +6728,8 @@ input:focus-visible { .config-side-stack { grid-template-columns: repeat(2, minmax(0, 1fr)); - grid-template-rows: 206px; - height: 206px; + grid-template-rows: 166px; + height: 166px; } .control-panel, @@ -6884,16 +6879,8 @@ input:focus-visible { grid-template-columns: 1fr; } - .thinking-alias-guide { - grid-template-columns: 24px minmax(0, 1fr); - } - - .thinking-alias-flow { - grid-column: 2; - justify-self: start; - } - .thinking-alias-builder, + .thinking-alias-name-panel, .thinking-alias-list-panel { height: auto; min-height: 0; @@ -7006,8 +6993,8 @@ input:focus-visible { .config-side-stack { grid-template-columns: 1fr; - grid-template-rows: repeat(2, 206px); - height: 430px; + grid-template-rows: repeat(2, 166px); + height: 350px; } } @@ -7148,11 +7135,6 @@ input:focus-visible { grid-template-columns: repeat(3, minmax(0, 1fr)); } - .thinking-alias-flow { - width: 100%; - grid-template-columns: auto 14px minmax(0, 1fr); - } - .thinking-alias-row { grid-template-columns: minmax(0, 1fr) auto; } @@ -7176,11 +7158,6 @@ 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; @@ -7272,6 +7249,7 @@ input:focus-visible { .auth-file-actions { grid-column: 2; + flex-wrap: wrap; justify-content: flex-start; } diff --git a/tests/authFiles.test.ts b/tests/authFiles.test.ts index 8faba13..c782b1a 100644 --- a/tests/authFiles.test.ts +++ b/tests/authFiles.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'bun:test'; -import { dedupeAuthFiles } from '../src/services/authFiles'; +import { + changedOAuthAuthFileNames, + dedupeAuthFiles, + normalizeAuthFilePriorityInput, + parseAuthFilePriority, + snapshotAuthFiles, +} from '../src/services/authFiles'; describe('认证文件列表规范化', () => { it('合并同名的磁盘和运行时记录并优先保留磁盘状态', () => { @@ -28,3 +34,34 @@ describe('认证文件列表规范化', () => { expect(files[0].auth_index).toBe('runtime-index'); }); }); + +describe('authentication file priority', () => { + it('accepts safe integers from API values', () => { + expect(parseAuthFilePriority(10)).toBe(10); + expect(parseAuthFilePriority(' -3 ')).toBe(-3); + expect(parseAuthFilePriority(1.5)).toBeUndefined(); + expect(parseAuthFilePriority('high')).toBeUndefined(); + }); + + it('uses zero to restore the default and rejects invalid input', () => { + expect(normalizeAuthFilePriorityInput('')).toBe(0); + expect(normalizeAuthFilePriorityInput('0')).toBe(0); + expect(normalizeAuthFilePriorityInput('12')).toBe(12); + expect(normalizeAuthFilePriorityInput('1.5')).toBeNull(); + }); + + it('finds only credentials created or updated by the completed OAuth provider', () => { + const before = snapshotAuthFiles([ + { name: 'codex-old.json', provider: 'codex', modtime: 1, priority: 8 }, + { name: 'codex-custom.json', provider: 'codex', modtime: 1, priority: 5 }, + { name: 'claude-old.json', provider: 'claude', modtime: 1 }, + ]); + + expect(changedOAuthAuthFileNames(before, [ + { name: 'codex-old.json', provider: 'codex', modtime: 2 }, + { name: 'codex-custom.json', provider: 'codex', modtime: 2, priority: 5 }, + { name: 'codex-new.json', type: 'codex', modtime: 2 }, + { name: 'claude-old.json', provider: 'claude', modtime: 2 }, + ], 'codex')).toEqual(['codex-old.json', 'codex-new.json']); + }); +}); From 0b13b2de0c765a9e6db47fa1f37b779dd3f3af82 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Thu, 30 Jul 2026 01:27:06 +0800 Subject: [PATCH 3/4] feat: refine credential controls and thinking alias layout --- src/pages/ThinkingAliasesPage.tsx | 11 ++++----- src/styles.css | 38 ++++++++++++++----------------- 2 files changed, 21 insertions(+), 28 deletions(-) diff --git a/src/pages/ThinkingAliasesPage.tsx b/src/pages/ThinkingAliasesPage.tsx index 56a10ce..1a8b1e9 100644 --- a/src/pages/ThinkingAliasesPage.tsx +++ b/src/pages/ThinkingAliasesPage.tsx @@ -254,8 +254,7 @@ export function ThinkingAliasesPage() {
-
-
+
@@ -417,10 +416,9 @@ export function ThinkingAliasesPage() { {t('aliases.customHelp')}
: null}
-
+ +
diff --git a/src/styles.css b/src/styles.css index e066c7c..3bea0cf 100644 --- a/src/styles.css +++ b/src/styles.css @@ -3457,30 +3457,16 @@ input:focus-visible { display: grid; grid-template-columns: minmax(480px, 1.4fr) minmax(320px, 0.6fr); gap: 16px; - align-items: start; -} - -.thinking-alias-left-stack { - display: grid; - min-width: 0; - gap: 16px; -} - -.thinking-alias-builder { - min-height: 330px; -} - -.thinking-alias-name-panel { - min-height: 250px; + align-items: stretch; } +.thinking-alias-editor-panel, .thinking-alias-list-panel { - height: clamp(360px, calc(100vh - 330px), 460px); - min-height: 360px; + height: clamp(600px, calc(100vh - 250px), 720px); + min-height: 600px; } -.thinking-alias-builder, -.thinking-alias-name-panel { +.thinking-alias-editor-panel { position: relative; z-index: 2; display: flex; @@ -3490,6 +3476,17 @@ input:focus-visible { overflow: visible; } +.thinking-alias-section-divider { + height: 1px; + flex: none; + margin: 2px 0; + background: var(--theme-e3e1db); +} + +.thinking-alias-secondary-heading { + padding-top: 2px; +} + .thinking-alias-panel-heading { display: flex; align-items: center; @@ -6879,8 +6876,7 @@ input:focus-visible { grid-template-columns: 1fr; } - .thinking-alias-builder, - .thinking-alias-name-panel, + .thinking-alias-editor-panel, .thinking-alias-list-panel { height: auto; min-height: 0; From 9dc74abe6b8379966d410e7afeeb66ce0125689c Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Thu, 30 Jul 2026 01:40:22 +0800 Subject: [PATCH 4/4] feat: improve credential management and alias layout --- src/i18n/ja.ts | 2 -- src/i18n/resources.ts | 4 ---- src/pages/ThinkingAliasesPage.tsx | 8 -------- src/styles.css | 4 ---- 4 files changed, 18 deletions(-) diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 3bfdfe6..13ea72c 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -207,8 +207,6 @@ export const jaOverrides = { 'aliases.deleted': '{alias} を削除しました', 'aliases.create.title': '推論エイリアスを作成', 'aliases.create.description': 'Codex OAuth、Codex API、OpenAI 互換接続に対応', - 'aliases.modelAlias.title': 'モデルエイリアス', - 'aliases.modelAlias.description': 'クライアントが使用する名前を入力し、最終マッピングを確認します', 'aliases.originalModel': '元のモデル', 'aliases.loadingModels': 'モデルを読み込んでいます', 'aliases.searchModel': '元のモデルを検索して選択', diff --git a/src/i18n/resources.ts b/src/i18n/resources.ts index 1448f00..68817ec 100644 --- a/src/i18n/resources.ts +++ b/src/i18n/resources.ts @@ -210,8 +210,6 @@ export const zhCN = { 'aliases.deleted': '已删除 {alias}', 'aliases.create.title': '创建思考别名', 'aliases.create.description': '支持 Codex OAuth、Codex API 和 OpenAI 兼容接入', - 'aliases.modelAlias.title': '模型别名', - 'aliases.modelAlias.description': '填写客户端调用名称并确认最终映射', 'aliases.originalModel': '原模型', 'aliases.loadingModels': '正在读取模型', 'aliases.searchModel': '搜索并选择原模型', @@ -1059,8 +1057,6 @@ export const en: Record = { 'aliases.deleted': 'Deleted {alias}', 'aliases.create.title': 'Create Reasoning Alias', 'aliases.create.description': 'Supports Codex OAuth, Codex API, and OpenAI-compatible connections', - 'aliases.modelAlias.title': 'Model Alias', - 'aliases.modelAlias.description': 'Enter the client-facing name and confirm the final mapping', 'aliases.originalModel': 'Original Model', 'aliases.loadingModels': 'Loading models', 'aliases.searchModel': 'Search and select an original model', diff --git a/src/pages/ThinkingAliasesPage.tsx b/src/pages/ThinkingAliasesPage.tsx index 1a8b1e9..79744cb 100644 --- a/src/pages/ThinkingAliasesPage.tsx +++ b/src/pages/ThinkingAliasesPage.tsx @@ -418,14 +418,6 @@ export function ThinkingAliasesPage() {