Merge pull request #73 from router-for-me/dev

Dev
This commit is contained in:
lzt404 authored and GitHub committed 2026-07-30 01:41:22 +08:00
commit 865af60fb0
13 files changed
+719 -343

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(
+143 -20
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>,
@@ -6433,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<String, String> {
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]
@@ -6671,6 +6783,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 +13385,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 +13474,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,
@@ -13373,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,
+16 -13
View File
@@ -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,16 +205,6 @@ 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.originalModel': '元のモデル',
@@ -336,6 +327,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 +343,6 @@ export const jaOverrides = {
'authFiles.deleted': '認証ファイルを削除しました',
'authFiles.deleteAllConfirm': 'ディスク上の認証ファイルをすべて削除しますか?実行時認証は削除されません。',
'authFiles.deletedAll': 'ディスク上の認証ファイルをすべて削除しました',
'authFiles.runtimeDownloadError': '実行時認証ファイルにはダウンロード可能なディスクファイルがありません',
'authFiles.downloaded': '認証ファイルをダウンロードしました',
'common.copyFailed': 'コピーに失敗しました',
'authFiles.title': '認証ファイル',
'authFiles.summary': 'ファイル {files} 件 · 無効 {disabled} 件',
@@ -372,7 +368,7 @@ export const jaOverrides = {
'authFiles.models.settings': '公開モデルを設定',
'authFiles.models.button': 'モデル',
'authFiles.copyName': 'ファイル名をコピー',
'common.download': 'ダウンロード',
'authFiles.openDirectory': '認証情報フォルダーを開く',
'common.enable': '有効化',
'common.disable': '無効化',
'authFiles.runtimeFootnote': '{count} 件の認証情報は実行時ストレージ由来です。対応するプロバイダーまたは認証元でのみ管理できます。',
@@ -706,6 +702,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': 'アプリ更新をインストール',
+32 -26
View File
@@ -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,16 +208,6 @@ 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.originalModel': '原模型',
@@ -339,6 +330,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 +346,6 @@ export const zhCN = {
'authFiles.deleted': '认证文件已删除',
'authFiles.deleteAllConfirm': '确定删除所有磁盘认证文件吗?运行时认证不会被删除。',
'authFiles.deletedAll': '磁盘认证文件已全部删除',
'authFiles.runtimeDownloadError': '运行时认证文件没有可下载的磁盘文件',
'authFiles.downloaded': '认证文件已下载',
'common.copyFailed': '复制失败',
'authFiles.title': '认证文件',
'authFiles.summary': '{files} 个文件 · {disabled} 个停用',
@@ -375,7 +371,7 @@ export const zhCN = {
'authFiles.models.settings': '设置开放模型',
'authFiles.models.button': '模型',
'authFiles.copyName': '复制文件名',
'common.download': '下载',
'authFiles.openDirectory': '打开凭证所在文件夹',
'common.enable': '启用',
'common.disable': '停用',
'authFiles.runtimeFootnote': '{count} 个凭据来自运行时存储,只能在对应提供商或认证源中管理。',
@@ -709,6 +705,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': '安装软件更新',
@@ -907,6 +910,7 @@ export const en: Record<MessageKey, string> = {
'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',
@@ -1051,16 +1055,6 @@ export const en: Record<MessageKey, string> = {
'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.originalModel': 'Original Model',
@@ -1183,6 +1177,13 @@ export const en: Record<MessageKey, string> = {
'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',
@@ -1192,8 +1193,6 @@ export const en: Record<MessageKey, string> = {
'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',
@@ -1219,7 +1218,7 @@ export const en: Record<MessageKey, string> = {
'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.',
@@ -1553,6 +1552,13 @@ export const en: Record<MessageKey, string> = {
'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',
+107 -16
View File
@@ -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<string, unknown>;
type PriorityEditor = {
fileName: string;
originalPriority: number;
value: string;
error: string;
};
const providerIcons: Record<string, string> = {
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<PriorityEditor | null>(null);
const [oauthModelProvider, setOauthModelProvider] = useState('');
const [oauthModelProviderLabel, setOauthModelProviderLabel] = useState('');
const [oauthModels, setOauthModels] = useState<OAuthModelDefinition[]>([]);
@@ -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 (
<article className={`real-auth-file-row ${disabled ? 'disabled' : ''}`} key={`${name}-${readString(file, 'auth_index', 'authIndex')}`}>
<img src={icon} alt="" className="provider-logo" />
@@ -500,8 +545,9 @@ export function AuthFileManagementPage() {
<div className="auth-file-actions">
{quotaProviderForFile(file) ? <button type="button" className="secondary-button compact-button" onClick={() => void refreshQuota(file)} disabled={busy || disabled || quotas[quotaKey(file)]?.status === 'loading'}>{disabled ? t('authFiles.status.disabled') : quotas[quotaKey(file)]?.status === 'loading' ? t('authFiles.quota.querying') : quotas[quotaKey(file)]?.status === 'success' ? t('authFiles.quota.refresh') : t('authFiles.quota.fetch')}</button> : null}
{providerKey(file) ? <button type="button" className="secondary-button compact-button" onClick={() => void openOauthModels(file)} disabled={busy} title={t('authFiles.models.settings')}>{t('authFiles.models.button')}</button> : null}
<button type="button" className="secondary-button compact-button auth-file-priority-button" onClick={() => openPriorityEditor(file)} disabled={busy} title={t('authFiles.priority.hint')}><Pencil size={14} />{t('authFiles.priority.button', { priority })}</button>
<button type="button" className="icon-button quiet" onClick={() => void copyName(name)} disabled={busy} title={t('authFiles.copyName')}>{copied === name ? <Check size={16} /> : <Copy size={16} />}</button>
<button type="button" className="icon-button quiet" onClick={() => void downloadFile(file)} disabled={busy || isRuntimeOnly(file)} title={t('common.download')}><Download size={16} /></button>
<button type="button" className="icon-button quiet" onClick={() => void openAuthFilesDirectory()} disabled={busy} title={t('authFiles.openDirectory')}><FolderOpen size={16} /></button>
<button type="button" className="secondary-button compact-button" onClick={() => void toggleStatus(file)} disabled={busy}>{disabled ? t('common.enable') : t('common.disable')}</button>
<button type="button" className="icon-button danger" onClick={() => void deleteFile(file)} disabled={busy || isRuntimeOnly(file)} title={t('common.delete')}><Trash2 size={16} /></button>
</div>
@@ -514,6 +560,51 @@ export function AuthFileManagementPage() {
</section>
{runtimeCount > 0 ? <p className="page-footnote">{t('authFiles.runtimeFootnote', { count: runtimeCount })}</p> : null}
{priorityEditor ? (
<div className="config-dialog-backdrop" onMouseDown={(event) => event.currentTarget === event.target && closePriorityEditor()}>
<form
className="config-dialog auth-priority-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="auth-priority-title"
onSubmit={(event) => {
event.preventDefault();
void savePriority();
}}
>
<div className="config-dialog-heading">
<div><Pencil size={19} /><h2 id="auth-priority-title">{t('authFiles.priority.title')}</h2></div>
<button type="button" className="icon-button quiet" onClick={closePriorityEditor} disabled={busy} title={t('common.close')}><X size={18} /></button>
</div>
<div className="auth-priority-target" title={priorityEditor.fileName}>{priorityEditor.fileName}</div>
<label className="auth-priority-field" htmlFor="auth-file-priority-input">
<span>{t('authFiles.priority.label')}</span>
<input
id="auth-file-priority-input"
autoFocus
type="text"
inputMode="numeric"
value={priorityEditor.value}
placeholder={t('authFiles.priority.placeholder')}
disabled={busy}
aria-invalid={Boolean(priorityEditor.error)}
onChange={(event) => setPriorityEditor((current) => current
? { ...current, value: event.currentTarget.value, error: '' }
: current)}
/>
<small>{t('authFiles.priority.hint')}</small>
</label>
<div className={`config-form-message ${priorityEditor.error ? 'error' : ''}`} role={priorityEditor.error ? 'alert' : undefined}>
{priorityEditor.error || ' '}
</div>
<div className="config-dialog-actions two-actions">
<button type="button" className="secondary-button" onClick={closePriorityEditor} disabled={busy}>{t('common.cancel')}</button>
<button type="submit" className="primary-button" disabled={busy}>{busy ? <LoaderCircle size={16} className="spin" /> : <Check size={16} />}{busy ? t('common.saving') : t('common.save')}</button>
</div>
</form>
</div>
) : null}
{oauthModelProvider ? (
<div className="model-discovery-backdrop" onMouseDown={(event) => event.currentTarget === event.target && !oauthModelSaving && closeOauthModels()}>
<section className="model-discovery-dialog" role="dialog" aria-modal="true" aria-labelledby="oauth-model-title">
+46
View File
@@ -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<MessageType>('info');
const [catalogUpdating, setCatalogUpdating] = useState(false);
const [catalogUpdateError, setCatalogUpdateError] = useState('');
const [catalogUpdateNotice, setCatalogUpdateNotice] = useState('');
const [progress, setProgress] = useState<CoreInstallTask | null>(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<CodexModelCatalogUpdateResult>('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')}
</dd>
</div>
<div className="panel-detail-row">
<dt>{t('appUpdate.catalog.label')}</dt>
<dd
className={catalogUpdateError ? 'error' : catalogUpdateNotice ? 'success' : ''}
role={catalogUpdateError ? 'alert' : undefined}
aria-live="polite"
>
{catalogUpdateError
|| catalogUpdateNotice
|| t('appUpdate.catalog.description')}
</dd>
</div>
</dl>
<div className="button-row panel-action-row software-update-actions">
@@ -804,6 +839,17 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
{t('appUpdate.openRelease')} <ExternalLink size={14} aria-hidden="true" />
</button>
)}
<button
type="button"
className="secondary-button"
disabled={catalogUpdating || appUpdateTask.running}
onClick={() => void updateCodexModelCatalog()}
title={t('appUpdate.catalog.updateHint')}
>
{catalogUpdating
? t('appUpdate.catalog.updating')
: t('appUpdate.catalog.update')}
</button>
</div>
</div>
) : null}
+41 -2
View File
@@ -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<Partial<Record<OAuthProviderId, number>>>({});
const pollingRequests = useRef<Partial<Record<OAuthProviderId, boolean>>>({});
const successResetTimers = useRef<Partial<Record<OAuthProviderId, number>>>({});
const credentialSnapshots = useRef<Partial<Record<OAuthProviderId, AuthFileSnapshot>>>({});
const noticeTimerRef = useRef<number | null>(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<OAuthStatusResult>('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<OAuthStartResult>('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),
+43 -66
View File
@@ -248,46 +248,22 @@ export function ThinkingAliasesPage() {
return (
<section className="page management-page thinking-alias-page">
<header className="management-header">
<div>
<span>Model Alias</span>
<h1>{t('aliases.title')}</h1>
</div>
</header>
<div className="thinking-alias-feedback" aria-live="polite">
{error ? <div className="management-alert error">{error}</div> : null}
{!error && notice ? <div className="management-alert success">{notice}</div> : null}
</div>
<div className="thinking-alias-guide">
<BrainCircuit size={20} />
<div className="thinking-alias-guide-copy">
<strong>{t('aliases.intro.title')}</strong>
<p>{t('aliases.intro.description')}</p>
<span>{t('aliases.intro.badge')}</span>
</div>
<div className="thinking-alias-flow" aria-label={t('aliases.flow.aria')}>
<span><small>{t('aliases.flow.original')}</small><code>deepseek-v4-pro</code></span>
<ArrowRight size={14} />
<div>
<span><small>{t('aliases.flow.direct')}</small><code>{t('aliases.flow.directValue')}</code></span>
<span><small>{t('aliases.flow.alias')}</small><code>{t('aliases.flow.aliasValue')}</code></span>
</div>
</div>
</div>
<div className="thinking-alias-workbench">
<section className="panel thinking-alias-builder">
<div className="thinking-alias-panel-heading">
<span><GitFork size={18} /></span>
<div>
<h2>{t('aliases.create.title')}</h2>
<p>{t('aliases.create.description')}</p>
<section className="panel thinking-alias-editor-panel">
<div className="thinking-alias-panel-heading">
<span><GitFork size={18} /></span>
<div>
<h2>{t('aliases.create.title')}</h2>
<p>{t('aliases.create.description')}</p>
</div>
</div>
</div>
<div className="thinking-alias-field thinking-model-field">
<div className="thinking-alias-field thinking-model-field">
<label htmlFor="thinking-model-search">{t('aliases.originalModel')}</label>
<div className="thinking-model-picker" ref={modelPickerRef}>
<div className="thinking-model-search">
@@ -379,9 +355,9 @@ export function ThinkingAliasesPage() {
) : (
<small className="thinking-model-hint">{t('aliases.sourceHint')}</small>
)}
</div>
</div>
<div className="thinking-alias-field">
<div className="thinking-alias-field">
<div className="thinking-field-heading">
<strong>{t('aliases.effort.title')}</strong>
<span>{t('aliases.effort.description')}</span>
@@ -439,42 +415,43 @@ export function ThinkingAliasesPage() {
</button>
<small>{t('aliases.customHelp')}</small>
</div> : null}
</div>
<div className="thinking-alias-field">
<div className="thinking-field-heading">
<strong>{t('aliases.aliasName.title')}</strong>
<span>{t('aliases.aliasName.description')}</span>
</div>
<input
id="thinking-alias-name"
className="thinking-alias-input"
value={alias}
onChange={(event) => setAlias(event.currentTarget.value)}
placeholder={selectedSource
? t('aliases.aliasName.example', { model: selectedSource.model, effort: normalizedEffort || 'high' })
: t('aliases.aliasName.selectFirst')}
disabled={Boolean(busyAlias)}
/>
</div>
<div className="thinking-alias-section-divider" aria-hidden="true" />
<div className="thinking-alias-preview">
<BrainCircuit size={18} />
<div>
<span>{selectedSource?.model || t('aliases.notSelected')} <ArrowRight size={13} /> {alias || t('aliases.enterAlias')}</span>
<code>{selectedSource?.protocol === 'openai' ? 'reasoning_effort' : 'reasoning.effort'} = {normalizedEffort || t('aliases.notSet')}</code>
<div className="thinking-alias-field">
<div className="thinking-field-heading">
<strong>{t('aliases.aliasName.title')}</strong>
<span>{t('aliases.aliasName.description')}</span>
</div>
<input
id="thinking-alias-name"
className="thinking-alias-input"
value={alias}
onChange={(event) => setAlias(event.currentTarget.value)}
placeholder={selectedSource
? t('aliases.aliasName.example', { model: selectedSource.model, effort: normalizedEffort || 'high' })
: t('aliases.aliasName.selectFirst')}
disabled={Boolean(busyAlias)}
/>
</div>
</div>
<button
type="button"
className="primary-button thinking-alias-create"
onClick={() => void createAlias()}
disabled={loading || Boolean(busyAlias) || !selectedSource || !effort.trim() || !alias.trim()}
>
{busyAction === 'create' ? <LoaderCircle size={16} className="spin" /> : <GitFork size={16} />}
{busyAction === 'create' ? t('aliases.creating') : t('aliases.create')}
</button>
<div className="thinking-alias-preview">
<BrainCircuit size={18} />
<div>
<span>{selectedSource?.model || t('aliases.notSelected')} <ArrowRight size={13} /> {alias || t('aliases.enterAlias')}</span>
<code>{selectedSource?.protocol === 'openai' ? 'reasoning_effort' : 'reasoning.effort'} = {normalizedEffort || t('aliases.notSet')}</code>
</div>
</div>
<button
type="button"
className="primary-button thinking-alias-create"
onClick={() => void createAlias()}
disabled={loading || Boolean(busyAlias) || !selectedSource || !effort.trim() || !alias.trim()}
>
{busyAction === 'create' ? <LoaderCircle size={16} className="spin" /> : <GitFork size={16} />}
{busyAction === 'create' ? t('aliases.creating') : t('aliases.create')}
</button>
</section>
<section className="panel thinking-alias-list-panel">
+61
View File
@@ -2,6 +2,7 @@ import { readBoolean, readString } from './managementApi';
import { getCurrentLocale, translate } from '../i18n';
export type AuthFileRecord = Record<string, unknown>;
export type AuthFileSnapshot = Map<string, string>;
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<string, string[]>();
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<string>();
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;
+1 -1
View File
@@ -58,7 +58,7 @@ export const managementApi = {
data,
});
},
downloadAuthFile: (name: string) => invoke<string>('download_auth_file', { name }),
openAuthFilesDirectory: () => invoke<void>('open_auth_files_directory'),
};
export function isRecord(value: unknown): value is Record<string, unknown> {
+6 -41
View File
@@ -540,24 +540,8 @@ export const quotaRowsFor = (provider: QuotaProvider, payload: unknown): QuotaRo
});
};
const resolveProjectId = async (file: AuthFile): Promise<string> => {
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<string, unknown>;
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<string, unknown> | null => {
if (isRecord(value)) return value;
@@ -614,18 +598,8 @@ const codexAccountIdFromRecord = (record: Record<string, unknown>): string => {
return '';
};
const resolveCodexAccountId = async (file: AuthFile): Promise<string> => {
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, unknown>): string => {
const nestedRecords = [record, record.metadata, record.attributes]
@@ -642,17 +616,8 @@ const xaiUserIdFromRecord = (record: Record<string, unknown>): string => {
return '';
};
const resolveXaiUserId = async (file: AuthFile): Promise<string> => {
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,
+136 -149
View File
@@ -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,20 @@ 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;
}
.thinking-alias-builder,
.thinking-alias-editor-panel,
.thinking-alias-list-panel {
height: clamp(420px, calc(100vh - 270px), 580px);
min-height: 420px;
height: clamp(600px, calc(100vh - 250px), 720px);
min-height: 600px;
}
.thinking-alias-builder {
.thinking-alias-editor-panel {
position: relative;
z-index: 2;
display: flex;
@@ -3566,6 +3476,13 @@ input:focus-visible {
overflow: visible;
}
.thinking-alias-section-divider {
height: 1px;
flex: none;
margin: 2px 0;
background: var(--theme-e3e1db);
}
.thinking-alias-panel-heading {
display: flex;
align-items: center;
@@ -3596,14 +3513,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 +3537,7 @@ input:focus-visible {
.thinking-alias-field > label {
color: var(--theme-6f6961);
font-size: 11px;
font-size: 14px;
font-weight: 700;
}
@@ -3632,13 +3550,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 +3587,7 @@ input:focus-visible {
outline: 0;
background: transparent;
color: var(--theme-2d2a26);
font-size: 12px;
font-size: 14px;
}
.thinking-source-kind {
@@ -3680,7 +3598,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 +3667,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 +3688,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 +3709,7 @@ input:focus-visible {
justify-content: center;
gap: 7px;
color: var(--theme-8a847c);
font-size: 11px;
font-size: 13px;
}
.thinking-model-selection {
@@ -3800,7 +3718,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 +3740,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 +3757,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 +3794,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 +3810,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 +3823,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 +3858,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 +3866,7 @@ input:focus-visible {
.thinking-alias-preview code {
color: var(--theme-8069ad);
font-size: 10px;
font-size: 13px;
}
.thinking-alias-create {
@@ -3957,7 +3876,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 +3904,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 +3917,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 +3943,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 +3965,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 +3991,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 +4004,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);
@@ -6113,6 +6051,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);
@@ -6721,8 +6721,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,
@@ -6872,16 +6872,7 @@ 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-editor-panel,
.thinking-alias-list-panel {
height: auto;
min-height: 0;
@@ -6994,8 +6985,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;
}
}
@@ -7136,11 +7127,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;
}
@@ -7255,6 +7241,7 @@ input:focus-visible {
.auth-file-actions {
grid-column: 2;
flex-wrap: wrap;
justify-content: flex-start;
}
+38 -1
View File
@@ -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']);
});
});