From 25bcb5a2322d14dfe23888860f7f11c5b3e16cfc Mon Sep 17 00:00:00 2001 From: hex2077 Date: Fri, 6 Mar 2026 12:46:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(oauth):=20=E6=96=B0=E5=A2=9E=20Codex=20Tok?= =?UTF-8?q?en=20=E6=89=B9=E9=87=8F=E5=AF=BC=E5=85=A5=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E5=B9=B6=E4=BC=98=E5=8C=96=20OAuth=20=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=99=A8=E5=85=B3=E9=97=AD=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 Codex Token 批量导入功能,支持 SSE 实时进度显示 - 统一各 OAuth 服务器关闭逻辑,添加超时机制和错误处理 - 更新 Codex 支持的模型列表,添加 gpt-5.4 模型 - 优化 Codex API 版本管理,修复模型回退逻辑 - 添加前端批量导入界面及多语言支持 --- src/auth/codex-oauth.js | 203 ++++++++++++++-- src/auth/gemini-oauth.js | 73 ++++-- src/auth/iflow-oauth.js | 32 ++- src/auth/index.js | 3 +- src/auth/kiro-oauth.js | 32 ++- src/auth/oauth-handlers.js | 1 + src/providers/openai/codex-core.js | 52 ++-- src/providers/provider-models.js | 3 +- src/services/ui-manager.js | 4 + src/ui-modules/oauth-api.js | 77 ++++++ static/app/i18n.js | 32 +++ static/app/provider-manager.js | 373 +++++++++++++++++++++++++++++ 12 files changed, 810 insertions(+), 75 deletions(-) diff --git a/src/auth/codex-oauth.js b/src/auth/codex-oauth.js index 2f1c983..6e4e442 100644 --- a/src/auth/codex-oauth.js +++ b/src/auth/codex-oauth.js @@ -33,26 +33,37 @@ const activeServers = new Map(); */ async function closeActiveServer(provider, port = null) { const existing = activeServers.get(provider); + if (existing) { - await new Promise((resolve) => { - existing.server.close(() => { - activeServers.delete(provider); - logger.info(`[Codex Auth] 已关闭提供商 ${provider} 在端口 ${existing.port} 上的旧服务器`); - resolve(); + try { + // 1. 使用 Promise.race() 添加 2 秒超时 + const closePromise = new Promise((resolve, reject) => { + existing.server.close((err) => { + if (err) reject(err); + else resolve(); + }); }); - }); + + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Server close timeout after 2s')), 2000); + }); + + await Promise.race([closePromise, timeoutPromise]); + logger.info(`[Codex Auth] ${provider} server closed successfully`); + } catch (error) { + // 2. try-catch 捕获错误 + logger.warn(`[Codex Auth] Server close failed or timed out: ${error.message}`); + } finally { + // 3. finally 块强制清理,防止阻塞 + activeServers.delete(provider); + } } if (port) { for (const [p, info] of activeServers.entries()) { if (info.port === port) { - await new Promise((resolve) => { - info.server.close(() => { - activeServers.delete(p); - logger.info(`[Codex Auth] 已关闭端口 ${port} 上被占用(提供商: ${p})的旧服务器`); - resolve(); - }); - }); + // 递归调用处理端口冲突的情况 + await closeActiveServer(p); } } } @@ -594,6 +605,170 @@ class CodexAuth { return false; } } + + /** + * 检查凭据是否已存在(基于 account_id 或 refresh_token) + * @param {string} accountId + * @param {string} refreshToken + * @returns {Promise<{isDuplicate: boolean, existingPath?: string}>} + */ + async checkDuplicate(accountId, refreshToken) { + const projectDir = process.cwd(); + const targetDir = path.join(projectDir, 'configs', 'codex'); + + try { + if (!fs.existsSync(targetDir)) { + return { isDuplicate: false }; + } + + const files = await fs.promises.readdir(targetDir); + for (const file of files) { + if (file.endsWith('.json')) { + try { + const fullPath = path.join(targetDir, file); + const content = await fs.promises.readFile(fullPath, 'utf8'); + const credentials = JSON.parse(content); + + if ((accountId && credentials.account_id === accountId) || (refreshToken && credentials.refresh_token === refreshToken)) { + const relativePath = path.relative(process.cwd(), fullPath); + return { + isDuplicate: true, + existingPath: relativePath + }; + } + } catch (e) { + // 忽略解析错误 + } + } + } + return { isDuplicate: false }; + } catch (error) { + logger.warn(`${CODEX_OAUTH_CONFIG.logPrefix} Error checking duplicates:`, error.message); + return { isDuplicate: false }; + } + } +} + +/** + * 批量导入 Codex Token 并生成凭据文件(流式版本) + * @param {Object[]} tokens - Token 对象数组 + * @param {Function} onProgress - 进度回调函数 + * @param {boolean} skipDuplicateCheck - 是否跳过重复检查 + * @returns {Promise} 批量处理结果 + */ +export async function batchImportCodexTokensStream(tokens, onProgress = null, skipDuplicateCheck = false) { + const auth = new CodexAuth({}); + const results = { + total: tokens.length, + success: 0, + failed: 0, + details: [] + }; + + for (let i = 0; i < tokens.length; i++) { + const tokenData = tokens[i]; + const progressData = { + index: i + 1, + total: tokens.length, + current: null + }; + + try { + // 验证 token 数据 + if (!tokenData.access_token || !tokenData.id_token) { + throw new Error('Token 缺少必需字段 (access_token 或 id_token)'); + } + + // 解析 JWT 提取账户信息 + const claims = auth.parseJWT(tokenData.id_token); + const accountId = claims['https://api.openai.com/auth']?.chatgpt_account_id || claims.sub; + const email = claims.email; + + // 检查重复 + if (!skipDuplicateCheck) { + const duplicateCheck = await auth.checkDuplicate(accountId, tokenData.refresh_token); + if (duplicateCheck.isDuplicate) { + progressData.current = { + index: i + 1, + success: false, + error: 'duplicate', + existingPath: duplicateCheck.existingPath + }; + results.failed++; + results.details.push(progressData.current); + if (onProgress) { + onProgress({ + ...progressData, + successCount: results.success, + failedCount: results.failed + }); + } + continue; + } + } + + // 构建凭据对象 + const credentials = { + id_token: tokenData.id_token, + access_token: tokenData.access_token, + refresh_token: tokenData.refresh_token, + account_id: accountId, + last_refresh: new Date().toISOString(), + email: email, + type: 'codex', + expired: new Date(Date.now() + (tokenData.expires_in || 3600) * 1000).toISOString() + }; + + // 保存凭据 + const saveResult = await auth.saveCredentials(credentials); + const relativePath = saveResult.relativePath; + + logger.info(`${CODEX_OAUTH_CONFIG.logPrefix} Token ${i + 1} imported: ${relativePath}`); + + progressData.current = { + index: i + 1, + success: true, + path: relativePath + }; + results.success++; + + // 自动关联到 Pools + await autoLinkProviderConfigs(CONFIG, { + onlyCurrentCred: true, + credPath: relativePath + }); + + } catch (error) { + logger.error(`${CODEX_OAUTH_CONFIG.logPrefix} Token ${i + 1} import failed:`, error.message); + + progressData.current = { + index: i + 1, + success: false, + error: error.message + }; + results.failed++; + } + + results.details.push(progressData.current); + + if (onProgress) { + onProgress({ + ...progressData, + successCount: results.success, + failedCount: results.failed + }); + } + } + + if (results.success > 0) { + broadcastEvent('oauth_batch_success', { + provider: 'openai-codex-oauth', + count: results.success, + timestamp: new Date().toISOString() + }); + } + + return results; } /** @@ -668,7 +843,7 @@ export async function handleCodexOAuth(currentConfig, options = {}) { // 轮询计数器 let pollCount = 0; - const maxPollCount = 200; // 增加到约 10 分钟 (200 * 3s = 600s) + const maxPollCount = 100; // 增加到约 5 分钟 (100 * 3s = 300s) const pollInterval = 3000; // 轮询间隔(毫秒) let pollTimer = null; let isCompleted = false; diff --git a/src/auth/gemini-oauth.js b/src/auth/gemini-oauth.js index c6ede1c..e7896bb 100644 --- a/src/auth/gemini-oauth.js +++ b/src/auth/gemini-oauth.js @@ -72,26 +72,38 @@ async function closeActiveServer(provider, port = null) { // 1. 关闭该提供商之前的所有服务器 const existing = activeServers.get(provider); if (existing) { - await new Promise((resolve) => { - existing.server.close(() => { - activeServers.delete(provider); - logger.info(`[OAuth] 已关闭提供商 ${provider} 在端口 ${existing.port} 上的旧服务器`); - resolve(); + // 清理轮询定时器 + if (existing.pollTimer) { + clearInterval(existing.pollTimer); + existing.pollTimer = null; + } + + try { + const closePromise = new Promise((resolve, reject) => { + existing.server.close((err) => { + if (err) reject(err); + else resolve(); + }); }); - }); + + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Server close timeout after 2s')), 2000); + }); + + await Promise.race([closePromise, timeoutPromise]); + logger.info(`[OAuth] 已关闭提供商 ${provider} 在端口 ${existing.port} 上的旧服务器`); + } catch (error) { + logger.warn(`[OAuth] 关闭提供商 ${provider} 服务器失败或超时: ${error.message}`); + } finally { + activeServers.delete(provider); + } } // 2. 如果指定了端口,检查是否有其他提供商占用了该端口 if (port) { for (const [p, info] of activeServers.entries()) { if (info.port === port) { - await new Promise((resolve) => { - info.server.close(() => { - activeServers.delete(p); - logger.info(`[OAuth] 已关闭端口 ${port} 上被占用(提供商: ${p})的旧服务器`); - resolve(); - }); - }); + await closeActiveServer(p); } } } @@ -112,6 +124,18 @@ async function createOAuthCallbackServer(config, redirectUri, authClient, credPa await closeActiveServer(provider, port); return new Promise((resolve, reject) => { + let pollCount = 0; + const maxPollCount = 100; // 约 5 分钟 (100 * 3s = 300s) + const pollInterval = 3000; + let pollTimer = null; + + const clearPollTimer = () => { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + }; + const server = http.createServer(async (req, res) => { try { const url = new URL(req.url, redirectUri); @@ -119,6 +143,7 @@ async function createOAuthCallbackServer(config, redirectUri, authClient, credPa const errorParam = url.searchParams.get('error'); if (code) { + clearPollTimer(); logger.info(`${config.logPrefix} 收到来自 Google 的成功回调: ${req.url}`); try { @@ -167,6 +192,7 @@ async function createOAuthCallbackServer(config, redirectUri, authClient, credPa }); } } else if (errorParam) { + clearPollTimer(); const errorMessage = `授权失败。Google 返回错误: ${errorParam}`; logger.error(`${config.logPrefix}`, errorMessage); @@ -181,6 +207,7 @@ async function createOAuthCallbackServer(config, redirectUri, authClient, credPa res.end(); } } catch (error) { + clearPollTimer(); logger.error(`${config.logPrefix} 处理回调时出错:`, error); res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(generateResponsePage(false, `服务器错误: ${error.message}`)); @@ -194,6 +221,7 @@ async function createOAuthCallbackServer(config, redirectUri, authClient, credPa }); server.on('error', (err) => { + clearPollTimer(); if (err.code === 'EADDRINUSE') { logger.error(`${config.logPrefix} 端口 ${port} 已被占用`); reject(new Error(`端口 ${port} 已被占用`)); @@ -206,7 +234,24 @@ async function createOAuthCallbackServer(config, redirectUri, authClient, credPa const host = '0.0.0.0'; server.listen(port, host, () => { logger.info(`${config.logPrefix} OAuth 回调服务器已启动于 ${host}:${port}`); - activeServers.set(provider, { server, port }); + + // 启动轮询日志 + pollTimer = setInterval(() => { + pollCount++; + if (pollCount <= maxPollCount) { + logger.info(`${config.logPrefix} Waiting for callback... (${pollCount}/${maxPollCount})`); + } else { + clearPollTimer(); + logger.warn(`${config.logPrefix} Polling timeout, closing server...`); + if (server.listening) { + server.close(() => { + activeServers.delete(provider); + }); + } + } + }, pollInterval); + + activeServers.set(provider, { server, port, pollTimer }); resolve(server); }); }); diff --git a/src/auth/iflow-oauth.js b/src/auth/iflow-oauth.js index 3a5dcda..c882624 100644 --- a/src/auth/iflow-oauth.js +++ b/src/auth/iflow-oauth.js @@ -253,25 +253,31 @@ async function fetchIFlowUserInfo(accessToken) { async function closeIFlowServer(provider, port = null) { const existing = activeIFlowServers.get(provider); if (existing) { - await new Promise((resolve) => { - existing.server.close(() => { - activeIFlowServers.delete(provider); - logger.info(`${IFLOW_OAUTH_CONFIG.logPrefix} 已关闭提供商 ${provider} 在端口 ${existing.port} 上的旧服务器`); - resolve(); + try { + const closePromise = new Promise((resolve, reject) => { + existing.server.close((err) => { + if (err) reject(err); + else resolve(); + }); }); - }); + + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Server close timeout after 2s')), 2000); + }); + + await Promise.race([closePromise, timeoutPromise]); + logger.info(`${IFLOW_OAUTH_CONFIG.logPrefix} 已关闭提供商 ${provider} 在端口 ${existing.port} 上的旧服务器`); + } catch (error) { + logger.warn(`${IFLOW_OAUTH_CONFIG.logPrefix} 关闭提供商 ${provider} 服务器失败或超时: ${error.message}`); + } finally { + activeIFlowServers.delete(provider); + } } if (port) { for (const [p, info] of activeIFlowServers.entries()) { if (info.port === port) { - await new Promise((resolve) => { - info.server.close(() => { - activeIFlowServers.delete(p); - logger.info(`${IFLOW_OAUTH_CONFIG.logPrefix} 已关闭端口 ${port} 上的旧服务器`); - resolve(); - }); - }); + await closeIFlowServer(p); } } } diff --git a/src/auth/index.js b/src/auth/index.js index f0ea696..dc5cfb0 100644 --- a/src/auth/index.js +++ b/src/auth/index.js @@ -2,7 +2,8 @@ export { refreshCodexTokensWithRetry, handleCodexOAuth, - handleCodexOAuthCallback + handleCodexOAuthCallback, + batchImportCodexTokensStream } from './codex-oauth.js'; // Gemini OAuth diff --git a/src/auth/kiro-oauth.js b/src/auth/kiro-oauth.js index 584c1bf..2e1bba6 100644 --- a/src/auth/kiro-oauth.js +++ b/src/auth/kiro-oauth.js @@ -487,25 +487,31 @@ async function startKiroCallbackServer(codeVerifier, expectedState, options = {} async function closeKiroServer(provider, port = null) { const existing = activeKiroServers.get(provider); if (existing) { - await new Promise((resolve) => { - existing.server.close(() => { - activeKiroServers.delete(provider); - logger.info(`${KIRO_OAUTH_CONFIG.logPrefix} 已关闭提供商 ${provider} 在端口 ${existing.port} 上的旧服务器`); - resolve(); + try { + const closePromise = new Promise((resolve, reject) => { + existing.server.close((err) => { + if (err) reject(err); + else resolve(); + }); }); - }); + + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Server close timeout after 2s')), 2000); + }); + + await Promise.race([closePromise, timeoutPromise]); + logger.info(`${KIRO_OAUTH_CONFIG.logPrefix} 已关闭提供商 ${provider} 在端口 ${existing.port} 上的旧服务器`); + } catch (error) { + logger.warn(`${KIRO_OAUTH_CONFIG.logPrefix} 关闭提供商 ${provider} 服务器失败或超时: ${error.message}`); + } finally { + activeKiroServers.delete(provider); + } } if (port) { for (const [p, info] of activeKiroServers.entries()) { if (info.port === port) { - await new Promise((resolve) => { - info.server.close(() => { - activeKiroServers.delete(p); - logger.info(`${KIRO_OAUTH_CONFIG.logPrefix} 已关闭端口 ${port} 上的旧服务器`); - resolve(); - }); - }); + await closeKiroServer(p); } } } diff --git a/src/auth/oauth-handlers.js b/src/auth/oauth-handlers.js index 96fcedd..1183e4a 100644 --- a/src/auth/oauth-handlers.js +++ b/src/auth/oauth-handlers.js @@ -7,6 +7,7 @@ export { refreshCodexTokensWithRetry, handleCodexOAuth, handleCodexOAuthCallback, + batchImportCodexTokensStream, // Gemini OAuth handleGeminiCliOAuth, handleGeminiAntigravityOAuth, diff --git a/src/providers/openai/codex-core.js b/src/providers/openai/codex-core.js index 1280e76..26dbeb9 100644 --- a/src/providers/openai/codex-core.js +++ b/src/providers/openai/codex-core.js @@ -8,6 +8,10 @@ import { refreshCodexTokensWithRetry } from '../../auth/oauth-handlers.js'; import { getProviderPoolManager } from '../../services/service-manager.js'; import { MODEL_PROVIDER, formatExpiryLog } from '../../utils/common.js'; import { getProxyConfigForProvider } from '../../utils/proxy-utils.js'; +import { getProviderModels } from '../provider-models.js'; + +const CODEX_MODELS = getProviderModels(MODEL_PROVIDER.CODEX_API); +const CODEX_VERSION = '0.111.0'; /** * Codex API 服务类 @@ -22,6 +26,7 @@ export class CodexApiService { this.email = null; this.expiresAt = null; this.idToken = null; + this.last_refresh = null; this.credsPath = null; // 记录本次加载/使用的凭据文件路径,确保刷新后写回同一文件 this.uuid = config.uuid; // 保存 uuid 用于号池管理 this.isInitialized = false; @@ -89,6 +94,7 @@ export class CodexApiService { this.refreshToken = creds.refresh_token; this.accountId = creds.account_id; this.email = creds.email; + this.last_refresh = creds.last_refresh || this.last_refresh; this.expiresAt = new Date(creds.expired); // 注意:字段名是 expired // 检查 token 是否需要刷新 @@ -136,6 +142,13 @@ export class CodexApiService { await this.initialize(); } + let selectedModel = model; + if (!CODEX_MODELS.includes(model)) { + const defaultModel = CODEX_MODELS[0] || 'gpt-5'; + logger.warn(`[Codex] Model '${model}' not found in supported list. Falling back to default: '${defaultModel}'`); + selectedModel = defaultModel; + } + // 临时存储 monitorRequestId if (requestBody._monitorRequestId) { this.config._monitorRequestId = requestBody._monitorRequestId; @@ -157,7 +170,7 @@ export class CodexApiService { } const url = `${this.baseUrl}/responses`; - const body = this.prepareRequestBody(model, requestBody, true); + const body = this.prepareRequestBody(selectedModel, requestBody, true); const headers = this.buildHeaders(body.prompt_cache_key, true); try { @@ -210,6 +223,13 @@ export class CodexApiService { await this.initialize(); } + let selectedModel = model; + if (!CODEX_MODELS.includes(model)) { + const defaultModel = CODEX_MODELS[0] || 'gpt-5'; + logger.warn(`[Codex] Model '${model}' not found in supported list. Falling back to default: '${defaultModel}'`); + selectedModel = defaultModel; + } + // 临时存储 monitorRequestId if (requestBody._monitorRequestId) { this.config._monitorRequestId = requestBody._monitorRequestId; @@ -231,7 +251,7 @@ export class CodexApiService { } const url = `${this.baseUrl}/responses`; - const body = this.prepareRequestBody(model, requestBody, true); + const body = this.prepareRequestBody(selectedModel, requestBody, true); const headers = this.buildHeaders(body.prompt_cache_key, true); try { @@ -281,13 +301,13 @@ export class CodexApiService { */ buildHeaders(cacheId, stream = true) { const headers = { - 'version': '0.101.0', + 'version': CODEX_VERSION, 'x-codex-beta-features': 'powershell_utf8', 'x-oai-web-search-eligible': 'true', 'authorization': `Bearer ${this.accessToken}`, 'chatgpt-account-id': this.accountId, 'content-type': 'application/json', - 'user-agent': 'codex_cli_rs/0.101.0 (Windows 10.0.26100; x86_64) WindowsTerminal', + 'user-agent': `codex_cli_rs/${CODEX_VERSION} (Windows 10.0.26100; x86_64) WindowsTerminal`, 'originator': 'codex_cli_rs', 'host': 'chatgpt.com', 'Connection': 'Keep-Alive' @@ -360,6 +380,7 @@ export class CodexApiService { this.refreshToken = newTokens.refresh_token; this.accountId = newTokens.account_id; this.email = newTokens.email; + this.last_refresh = new Date().toISOString(); // 关键修复:refreshCodexTokensWithRetry 返回字段名是 `expired`(ISO string),不是 `expire` const expiredValue = newTokens.expired || newTokens.expire || newTokens.expires_at || newTokens.expiresAt; @@ -445,7 +466,7 @@ export class CodexApiService { access_token: this.accessToken, refresh_token: this.refreshToken, account_id: this.accountId, - last_refresh: new Date().toISOString(), + last_refresh: this.last_refresh || new Date().toISOString(), email: this.email, type: 'codex', expired: this.expiresAt.toISOString() @@ -552,19 +573,12 @@ export class CodexApiService { async listModels() { return { object: 'list', - data: [ - { id: 'gpt-5', object: 'model', created: Math.floor(Date.now() / 1000), owned_by: 'openai' }, - { id: 'gpt-5-codex', object: 'model', created: Math.floor(Date.now() / 1000), owned_by: 'openai' }, - { id: 'gpt-5-codex-mini', object: 'model', created: Math.floor(Date.now() / 1000), owned_by: 'openai' }, - { id: 'gpt-5.1', object: 'model', created: Math.floor(Date.now() / 1000), owned_by: 'openai' }, - { id: 'gpt-5.1-codex', object: 'model', created: Math.floor(Date.now() / 1000), owned_by: 'openai' }, - { id: 'gpt-5.1-codex-mini', object: 'model', created: Math.floor(Date.now() / 1000), owned_by: 'openai' }, - { id: 'gpt-5.1-codex-max', object: 'model', created: Math.floor(Date.now() / 1000), owned_by: 'openai' }, - { id: 'gpt-5.2', object: 'model', created: Math.floor(Date.now() / 1000), owned_by: 'openai' }, - { id: 'gpt-5.2-codex', object: 'model', created: Math.floor(Date.now() / 1000), owned_by: 'openai' }, - { id: 'gpt-5.3-codex', object: 'model', created: Math.floor(Date.now() / 1000), owned_by: 'openai' }, - { id: 'gpt-5.3-codex-spark', object: 'model', created: Math.floor(Date.now() / 1000), owned_by: 'openai' } - ] + data: CODEX_MODELS.map(id => ({ + id, + object: 'model', + created: Math.floor(Date.now() / 1000), + owned_by: 'openai' + })) }; } @@ -605,7 +619,7 @@ export class CodexApiService { try { const url = 'https://chatgpt.com/backend-api/wham/usage'; const headers = { - 'user-agent': 'codex_cli_rs/0.89.0 (Windows 10.0.26100; x86_64) WindowsTerminal', + 'user-agent': `codex_cli_rs/${CODEX_VERSION} (Windows 10.0.26100; x86_64) WindowsTerminal`, 'authorization': `Bearer ${this.accessToken}`, 'chatgpt-account-id': this.accountId, 'accept': '*/*', diff --git a/src/providers/provider-models.js b/src/providers/provider-models.js index 67389be..8d49736 100644 --- a/src/providers/provider-models.js +++ b/src/providers/provider-models.js @@ -85,7 +85,8 @@ export const PROVIDER_MODELS = { 'gpt-5.2', 'gpt-5.2-codex', 'gpt-5.3-codex', - 'gpt-5.3-codex-spark' + 'gpt-5.3-codex-spark', + 'gpt-5.4', ], 'forward-api': [], 'grok-custom': [ diff --git a/src/services/ui-manager.js b/src/services/ui-manager.js index 70f6318..8fb30fa 100644 --- a/src/services/ui-manager.js +++ b/src/services/ui-manager.js @@ -319,6 +319,10 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo return await oauthApi.handleBatchImportGeminiTokens(req, res); } + if (method === 'POST' && pathParam === '/api/codex/batch-import-tokens') { + return await oauthApi.handleBatchImportCodexTokens(req, res); + } + // Import AWS SSO credentials for Kiro if (method === 'POST' && pathParam === '/api/kiro/import-aws-credentials') { return await oauthApi.handleImportAwsCredentials(req, res); diff --git a/src/ui-modules/oauth-api.js b/src/ui-modules/oauth-api.js index 7a6bf67..91c5094 100644 --- a/src/ui-modules/oauth-api.js +++ b/src/ui-modules/oauth-api.js @@ -8,6 +8,7 @@ import { handleKiroOAuth, handleIFlowOAuth, handleCodexOAuth, + batchImportCodexTokensStream, batchImportKiroRefreshTokensStream, importAwsCredentials } from '../auth/oauth-handlers.js'; @@ -345,6 +346,82 @@ export async function handleBatchImportGeminiTokens(req, res) { } } +/** + * 批量导入 Codex Token(带实时进度 SSE) + */ +export async function handleBatchImportCodexTokens(req, res) { + try { + const body = await getRequestBody(req); + const { tokens, skipDuplicateCheck } = body; + + if (!tokens || !Array.isArray(tokens) || tokens.length === 0) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: false, + error: 'tokens array is required and must not be empty' + })); + return true; + } + + logger.info(`[Codex Batch Import] Starting batch import with ${tokens.length} tokens...`); + + // 设置 SSE 响应头 + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no' + }); + + // 发送 SSE 事件的辅助函数 + const sendSSE = (event, data) => { + res.write(`event: ${event}\n`); + res.write(`data: ${JSON.stringify(data)}\n\n`); + }; + + // 发送开始事件 + sendSSE('start', { total: tokens.length }); + + // 执行流式批量导入 + const result = await batchImportCodexTokensStream( + tokens, + (progress) => { + sendSSE('progress', progress); + }, + skipDuplicateCheck !== false // 默认为 true + ); + + logger.info(`[Codex Batch Import] Completed: ${result.success} success, ${result.failed} failed`); + + // 发送完成事件 + sendSSE('complete', { + success: true, + total: result.total, + successCount: result.success, + failedCount: result.failed, + details: result.details + }); + + res.end(); + return true; + + } catch (error) { + logger.error('[Codex Batch Import] Error:', error); + if (res.headersSent) { + res.write(`event: error\n`); + res.write(`data: ${JSON.stringify({ error: error.message })}\n\n`); + res.end(); + } else { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: false, + error: error.message + })); + } + return true; + } +} + /** * 导入 AWS SSO 凭据用于 Kiro(支持单个或批量导入) */ diff --git a/static/app/i18n.js b/static/app/i18n.js index 70bd835..934603d 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -201,6 +201,22 @@ const translations = { 'oauth.gemini.startImport': '开始导入', 'oauth.gemini.jsonExample': '查看 JSON 格式示例', 'oauth.gemini.jsonHint': '请确保 JSON 包含 access_token 和 refresh_token', + 'oauth.codex.batchImport': '批量导入 Codex Token', + 'oauth.codex.batchImportDesc': '批量导入多个 Codex Token JSON 数据', + 'oauth.codex.tokensLabel': 'Token 数据 (JSON 数组)', + 'oauth.codex.tokensPlaceholder': '请粘贴包含 access_token 和 id_token 的 JSON 数组...', + 'oauth.codex.importInstructions': '请粘贴从浏览器或 CLI 获取的 Codex Token JSON 数据。支持单个对象或对象数组。', + 'oauth.codex.noTokens': '请输入有效的 Token 数据', + 'oauth.codex.importing': '正在导入...', + 'oauth.codex.importingProgress': '正在处理: {current} / {total}', + 'oauth.codex.importSuccess': '成功导入 {count} 个凭据', + 'oauth.codex.importAllFailed': '所有 {count} 个凭据导入失败', + 'oauth.codex.importPartial': '部分导入成功: {success} 成功, {failed} 失败', + 'oauth.codex.importError': '导入过程中出错', + 'oauth.codex.tokenCount': 'Token 数量', + 'oauth.codex.startImport': '开始导入', + 'oauth.codex.jsonExample': '查看 JSON 格式示例', + 'oauth.codex.jsonHint': '请确保 JSON 包含 access_token 和 id_token', 'oauth.kiro.duplicateCredentials': '该凭据已存在,请勿重复导入', 'oauth.kiro.builderIDStartURL': 'Builder ID Start URL', 'oauth.kiro.builderIDStartURLHint': '如果您使用 AWS IAM Identity Center,请输入您的 Start URL', @@ -1024,6 +1040,22 @@ const translations = { 'oauth.gemini.startImport': 'Start Import', 'oauth.gemini.jsonExample': 'View JSON Example', 'oauth.gemini.jsonHint': 'Ensure JSON contains access_token and refresh_token', + 'oauth.codex.batchImport': 'Batch Import Codex Tokens', + 'oauth.codex.batchImportDesc': 'Import multiple Codex Token JSON objects', + 'oauth.codex.tokensLabel': 'Token Data (JSON Array)', + 'oauth.codex.tokensPlaceholder': 'Paste JSON array containing access_token and id_token...', + 'oauth.codex.importInstructions': 'Paste Codex Token JSON from browser or CLI. Supports single object or array.', + 'oauth.codex.noTokens': 'Please enter valid Token data', + 'oauth.codex.importing': 'Importing...', + 'oauth.codex.importingProgress': 'Processing: {current} / {total}', + 'oauth.codex.importSuccess': 'Successfully imported {count} credentials', + 'oauth.codex.importAllFailed': 'Failed to import all {count} credentials', + 'oauth.codex.importPartial': 'Partial success: {success} succeeded, {failed} failed', + 'oauth.codex.importError': 'Import error', + 'oauth.codex.tokenCount': 'Token Count', + 'oauth.codex.startImport': 'Start Import', + 'oauth.codex.jsonExample': 'View JSON Example', + 'oauth.codex.jsonHint': 'Ensure JSON contains access_token and id_token', 'oauth.kiro.duplicateCredentials': 'This credential already exists, please do not import duplicates', 'oauth.kiro.builderIDStartURL': 'Builder ID Start URL', 'oauth.kiro.builderIDStartURLHint': 'If you use AWS IAM Identity Center, enter your Start URL', diff --git a/static/app/provider-manager.js b/static/app/provider-manager.js index 3f82a13..165cf1e 100644 --- a/static/app/provider-manager.js +++ b/static/app/provider-manager.js @@ -509,9 +509,382 @@ async function handleGenerateAuthUrl(providerType) { return; } + // 如果是 Codex OAuth,显示认证方式选择对话框 + if (providerType === 'openai-codex-oauth') { + showCodexAuthMethodSelector(providerType); + return; + } + await executeGenerateAuthUrl(providerType, {}); } +/** + * 显示 Codex OAuth 认证方式选择对话框 + * @param {string} providerType - 提供商类型 + */ +function showCodexAuthMethodSelector(providerType) { + const modal = document.createElement('div'); + modal.className = 'modal-overlay'; + modal.style.display = 'flex'; + + modal.innerHTML = ` + + `; + + document.body.appendChild(modal); + + // 关闭按钮事件 + const closeBtn = modal.querySelector('.modal-close'); + const cancelBtn = modal.querySelector('.modal-cancel'); + [closeBtn, cancelBtn].forEach(btn => { + btn.addEventListener('click', () => { + modal.remove(); + }); + }); + + // 认证方式选择按钮事件 + const methodBtns = modal.querySelectorAll('.auth-method-btn'); + methodBtns.forEach(btn => { + btn.addEventListener('mouseenter', () => { + btn.style.borderColor = '#4285f4'; + btn.style.background = '#f8faff'; + }); + btn.addEventListener('mouseleave', () => { + btn.style.borderColor = '#e0e0e0'; + btn.style.background = 'white'; + }); + btn.addEventListener('click', async () => { + const method = btn.dataset.method; + modal.remove(); + + if (method === 'batch-import') { + showCodexBatchImportModal(providerType); + } else { + await executeGenerateAuthUrl(providerType, {}); + } + }); + }); +} + +/** + * 显示 Codex 批量导入模态框 + * @param {string} providerType - 提供商类型 + */ +function showCodexBatchImportModal(providerType) { + const modal = document.createElement('div'); + modal.className = 'modal-overlay'; + modal.style.display = 'flex'; + + modal.innerHTML = ` + + `; + + document.body.appendChild(modal); + + const textarea = modal.querySelector('#batchCodexTokens'); + const statsDiv = modal.querySelector('#codexBatchStats'); + const tokenCountValue = modal.querySelector('#codexTokenCountValue'); + const progressDiv = modal.querySelector('#codexBatchProgress'); + const progressBar = modal.querySelector('#codexImportProgressBar'); + const resultDiv = modal.querySelector('#codexBatchResult'); + const submitBtn = modal.querySelector('#codexBatchSubmit'); + const closeBtn = modal.querySelector('.modal-close'); + const cancelBtn = modal.querySelector('.modal-cancel'); + + // 实时统计 token 数量 + textarea.addEventListener('input', () => { + try { + const val = textarea.value.trim(); + if (!val) { + statsDiv.style.display = 'none'; + return; + } + const data = JSON.parse(val); + const tokens = Array.isArray(data) ? data : [data]; + statsDiv.style.display = 'block'; + tokenCountValue.textContent = tokens.length; + } catch (e) { + statsDiv.style.display = 'none'; + } + }); + + // 关闭按钮事件 + [closeBtn, cancelBtn].forEach(btn => { + btn.addEventListener('click', () => { + modal.remove(); + }); + }); + + // 提交按钮事件 + submitBtn.addEventListener('click', async () => { + let tokens = []; + try { + const val = textarea.value.trim(); + const data = JSON.parse(val); + tokens = Array.isArray(data) ? data : [data]; + } catch (e) { + showToast(t('common.error'), t('oauth.codex.noTokens'), 'error'); + return; + } + + if (tokens.length === 0) { + showToast(t('common.warning'), t('oauth.codex.noTokens'), 'warning'); + return; + } + + // 禁用输入和按钮 + textarea.disabled = true; + submitBtn.disabled = true; + cancelBtn.disabled = true; + progressDiv.style.display = 'block'; + resultDiv.style.display = 'none'; + progressBar.style.width = '0%'; + + // 创建实时结果显示区域 + resultDiv.style.cssText = 'display: block; margin-top: 16px; padding: 12px; border-radius: 8px; background: #f3f4f6; border: 1px solid #d1d5db;'; + resultDiv.innerHTML = ` +
+ + ${t('oauth.codex.importingProgress', { current: 0, total: tokens.length })} +
+
+ `; + + const progressText = resultDiv.querySelector('#codexBatchProgressText'); + const resultsList = resultDiv.querySelector('#codexBatchResultsList'); + + let importSuccess = false; // 标记是否导入成功 + + try { + const response = await fetch('/api/codex/batch-import-tokens', { + method: 'POST', + headers: window.apiClient ? window.apiClient.getAuthHeaders() : { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ tokens }) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + let eventType = ''; + let eventData = ''; + + for (const line of lines) { + if (line.startsWith('event: ')) { + eventType = line.substring(7).trim(); + } else if (line.startsWith('data: ')) { + eventData = line.substring(6).trim(); + + if (eventType && eventData) { + try { + const data = JSON.parse(eventData); + + if (eventType === 'progress') { + const { index, total, current } = data; + const percentage = Math.round((index / total) * 100); + progressBar.style.width = `${percentage}%`; + progressText.textContent = t('oauth.codex.importingProgress', { current: index, total: total }); + + const resultItem = document.createElement('div'); + resultItem.style.cssText = 'padding: 4px 0; border-bottom: 1px solid rgba(0,0,0,0.1);'; + if (current.success) { + resultItem.innerHTML = `Token ${current.index}: ✓ ${current.path}`; + } else if (current.error === 'duplicate') { + resultItem.innerHTML = `Token ${current.index}: ⚠ ${t('oauth.kiro.duplicateToken')} + ${current.existingPath ? `(${current.existingPath})` : ''}`; + } else { + resultItem.innerHTML = `Token ${current.index}: ✗ ${current.error}`; + } + resultsList.appendChild(resultItem); + resultsList.scrollTop = resultsList.scrollHeight; + } else if (eventType === 'complete') { + progressBar.style.width = '100%'; + progressDiv.style.display = 'none'; + + const isAllSuccess = data.failedCount === 0; + const isAllFailed = data.successCount === 0; + let resultClass, resultIcon, resultMessage; + + if (isAllSuccess) { + resultClass = 'background: #f0fdf4; border: 1px solid #bbf7d0; color: #166534;'; + resultIcon = 'fa-check-circle'; + resultMessage = t('oauth.codex.importSuccess', { count: data.successCount }); + } else if (isAllFailed) { + resultClass = 'background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;'; + resultIcon = 'fa-times-circle'; + resultMessage = t('oauth.codex.importAllFailed', { count: data.failedCount }); + } else { + resultClass = 'background: #fffbeb; border: 1px solid #fde68a; color: #92400e;'; + resultIcon = 'fa-exclamation-triangle'; + resultMessage = t('oauth.codex.importPartial', { success: data.successCount, failed: data.failedCount }); + } + + resultDiv.style.cssText = `display: block; margin-top: 16px; padding: 12px; border-radius: 8px; ${resultClass}`; + const headerDiv = resultDiv.querySelector('div:first-child'); + headerDiv.innerHTML = ` ${resultMessage}`; + + if (data.successCount > 0) { + importSuccess = true; + loadProviders(); + loadConfigList(); + } + } else if (eventType === 'error') { + throw new Error(data.error); + } + } catch (parseError) { + console.warn('Failed to parse SSE data:', parseError); + } + eventType = ''; + eventData = ''; + } + } + } + } + } catch (error) { + console.error('[Codex Batch Import] Failed:', error); + progressDiv.style.display = 'none'; + resultDiv.style.cssText = 'display: block; margin-top: 16px; padding: 12px; border-radius: 8px; background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;'; + resultDiv.innerHTML = ` +
+ + ${t('oauth.codex.importError')}: ${error.message} +
+ `; + } finally { + cancelBtn.disabled = false; + + if (!importSuccess) { + textarea.disabled = false; + submitBtn.disabled = false; + submitBtn.innerHTML = ` ${t('oauth.codex.startImport')}`; + } else { + submitBtn.innerHTML = ` ${t('common.success')}`; + } + } + }); +} + /** * 显示 Kiro OAuth 认证方式选择对话框 * @param {string} providerType - 提供商类型