13 files changed
+719
-343
No files matched your search
@@ -1,11 +1,11 @@
|
||||
use crate::AgentModelOption;
|
||||
use serde_json::{Map, Value};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::{OnceLock, RwLock};
|
||||
|
||||
const MODEL_CATALOG_JSON: &str = include_str!("../resources/codex_models/model-catalog.json");
|
||||
|
||||
static SOURCES: OnceLock<Result<CatalogSources, String>> = OnceLock::new();
|
||||
static CATALOG_STATE: OnceLock<Result<RwLock<CatalogState>, String>> = OnceLock::new();
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct CodexRuntimeModel {
|
||||
@@ -33,6 +33,12 @@ struct CatalogSources {
|
||||
max_template_priority: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct CatalogState {
|
||||
sources: CatalogSources,
|
||||
json: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Template {
|
||||
value: Map<String, Value>,
|
||||
@@ -46,7 +52,31 @@ struct CatalogEntry {
|
||||
}
|
||||
|
||||
pub(crate) fn validate_embedded_catalog() -> Result<(), String> {
|
||||
sources().map(|_| ())
|
||||
catalog_state().map(|_| ())
|
||||
}
|
||||
|
||||
pub(crate) fn activate_catalog_json(catalog_json: &str) -> Result<bool, String> {
|
||||
let parsed = parse_sources(catalog_json)?;
|
||||
let mut state = catalog_state()?
|
||||
.write()
|
||||
.map_err(|_| "Codex 模型目录内存锁已损坏".to_string())?;
|
||||
if state.json == catalog_json {
|
||||
return Ok(false);
|
||||
}
|
||||
state.sources = parsed;
|
||||
state.json = catalog_json.to_string();
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(crate) fn validate_catalog_json(catalog_json: &str) -> Result<(), String> {
|
||||
parse_sources(catalog_json).map(|_| ())
|
||||
}
|
||||
|
||||
pub(crate) fn current_catalog_json() -> Result<String, String> {
|
||||
let state = catalog_state()?
|
||||
.read()
|
||||
.map_err(|_| "Codex 模型目录内存锁已损坏".to_string())?;
|
||||
Ok(state.json.clone())
|
||||
}
|
||||
|
||||
pub(crate) fn parse_runtime_models(payload: &Value) -> Result<Vec<CodexRuntimeModel>, String> {
|
||||
@@ -113,12 +143,22 @@ pub(crate) fn parse_runtime_models(payload: &Value) -> Result<Vec<CodexRuntimeMo
|
||||
pub(crate) fn prepare_catalog(
|
||||
runtime_models: &[CodexRuntimeModel],
|
||||
) -> Result<PreparedCodexCatalog, String> {
|
||||
prepare_catalog_with_sources(runtime_models, sources()?)
|
||||
let state = catalog_state()?
|
||||
.read()
|
||||
.map_err(|_| "Codex 模型目录内存锁已损坏".to_string())?;
|
||||
prepare_catalog_with_sources(runtime_models, &state.sources)
|
||||
}
|
||||
|
||||
fn sources() -> Result<&'static CatalogSources, String> {
|
||||
SOURCES
|
||||
.get_or_init(|| parse_sources(MODEL_CATALOG_JSON))
|
||||
fn catalog_state() -> Result<&'static RwLock<CatalogState>, String> {
|
||||
CATALOG_STATE
|
||||
.get_or_init(|| {
|
||||
parse_sources(MODEL_CATALOG_JSON).map(|sources| {
|
||||
RwLock::new(CatalogState {
|
||||
sources,
|
||||
json: MODEL_CATALOG_JSON.to_string(),
|
||||
})
|
||||
})
|
||||
})
|
||||
.as_ref()
|
||||
.map_err(Clone::clone)
|
||||
}
|
||||
@@ -686,7 +726,8 @@ mod tests {
|
||||
#[test]
|
||||
fn embedded_catalog_is_valid() {
|
||||
validate_embedded_catalog().unwrap();
|
||||
let sources = sources().unwrap();
|
||||
let state = catalog_state().unwrap().read().unwrap();
|
||||
let sources = &state.sources;
|
||||
assert_eq!(sources.templates.len(), 10);
|
||||
let fallback_prompt = string_value(&sources.fallback, "base_instructions");
|
||||
assert!(fallback_prompt.starts_with(
|
||||
|
||||
+143
-20
@@ -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
@@ -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
@@ -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',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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
@@ -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']);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user