From d5417d98902e3aa6dc97e2943c407bd92287e339 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Wed, 17 Dec 2025 13:45:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(provider):=20=E6=B7=BB=E5=8A=A0=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E5=90=8D=E7=A7=B0=E5=AD=97=E6=AE=B5=E5=B9=B6?= =?UTF-8?q?=E4=BC=98=E5=8C=96OAuth=E5=A4=84=E7=90=86=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加自定义名称字段以允许用户为节点设置个性化名称 重构OAuth处理逻辑,统一各提供商的授权流程 增加超时处理并优化错误提示 调整UI显示顺序和字段布局 --- provider_pools.json.example | 12 ++++ src/claude/claude-kiro.js | 6 +- src/common.js | 5 +- src/gemini/antigravity-core.js | 105 ++++++++++------------------ src/gemini/gemini-core.js | 103 +++++++++++----------------- src/openai/qwen-core.js | 121 +++++++++++---------------------- static/app/modal.js | 85 ++++++++++++++++++++--- static/app/utils.js | 4 +- 8 files changed, 211 insertions(+), 230 deletions(-) diff --git a/provider_pools.json.example b/provider_pools.json.example index 0a416c4..2a5e4dc 100644 --- a/provider_pools.json.example +++ b/provider_pools.json.example @@ -1,6 +1,7 @@ { "openai-custom": [ { + "customName": "OpenAI节点1", "OPENAI_API_KEY": "sk-openai-key1", "OPENAI_BASE_URL": "https://api.openai.com/v1", "checkModelName": null, @@ -15,6 +16,7 @@ "lastErrorTime": null }, { + "customName": "OpenAI节点2", "OPENAI_API_KEY": "sk-openai-key2", "OPENAI_BASE_URL": "https://api.openai.com/v1", "checkModelName": null, @@ -31,6 +33,7 @@ ], "openaiResponses-custom": [ { + "customName": "OpenAI Responses节点", "OPENAI_API_KEY": "sk-openai-key", "OPENAI_BASE_URL": "https://api.openai.com/v1", "checkModelName": null, @@ -46,6 +49,7 @@ ], "gemini-cli-oauth": [ { + "customName": "Gemini OAuth节点1", "GEMINI_OAUTH_CREDS_FILE_PATH": "./credentials1.json", "PROJECT_ID": "your-project-id-1", "checkModelName": null, @@ -59,6 +63,7 @@ "lastErrorTime": null }, { + "customName": "Gemini OAuth节点2", "GEMINI_OAUTH_CREDS_FILE_PATH": "./credentials2.json", "PROJECT_ID": "your-project-id-2", "checkModelName": null, @@ -74,6 +79,7 @@ ], "claude-custom": [ { + "customName": "Claude节点1", "CLAUDE_API_KEY": "sk-claude-key1", "CLAUDE_BASE_URL": "https://api.anthropic.com", "checkModelName": null, @@ -87,6 +93,7 @@ "lastErrorTime": null }, { + "customName": "Claude节点2", "CLAUDE_API_KEY": "sk-claude-key2", "CLAUDE_BASE_URL": "https://api.anthropic.com", "checkModelName": null, @@ -102,6 +109,7 @@ ], "claude-kiro-oauth": [ { + "customName": "Kiro OAuth节点1", "KIRO_OAUTH_CREDS_FILE_PATH": "./kiro_creds1.json", "uuid": "2c69d0ac-b86f-43d8-9d17-0d300afc5cfd", "checkModelName": null, @@ -114,6 +122,7 @@ "lastErrorTime": null }, { + "customName": "Kiro OAuth节点2", "KIRO_OAUTH_CREDS_FILE_PATH": "./kiro_creds2.json", "uuid": "7482abe6-8083-4288-bb7d-d8ecb7c461e2", "checkModelName": null, @@ -128,6 +137,7 @@ ], "openai-qwen-oauth": [ { + "customName": "Qwen OAuth节点", "QWEN_OAUTH_CREDS_FILE_PATH": "./qwen_creds.json", "uuid": "658a2114-c4c9-d713-b8d4-ceabf0e0bf18", "checkModelName": null, @@ -142,6 +152,7 @@ ], "gemini-antigravity": [ { + "customName": "Antigravity节点1", "ANTIGRAVITY_OAUTH_CREDS_FILE_PATH": "./antigravity_creds1.json", "PROJECT_ID": "antigravity-project-1", "uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", @@ -155,6 +166,7 @@ "lastErrorTime": null }, { + "customName": "Antigravity节点2", "ANTIGRAVITY_OAUTH_CREDS_FILE_PATH": "./antigravity_creds2.json", "PROJECT_ID": "antigravity-project-2", "uuid": "f0e9d8c7-b6a5-4321-fedc-ba9876543210", diff --git a/src/claude/claude-kiro.js b/src/claude/claude-kiro.js index 070ee26..1813685 100644 --- a/src/claude/claude-kiro.js +++ b/src/claude/claude-kiro.js @@ -17,7 +17,7 @@ const KIRO_CONSTANTS = { AMAZON_Q_URL: 'https://codewhisperer.{{region}}.amazonaws.com/SendMessageStreaming', USAGE_LIMITS_URL: 'https://q.{{region}}.amazonaws.com/getUsageLimits', DEFAULT_MODEL_NAME: 'claude-opus-4-5', - AXIOS_TIMEOUT: 120000, // 2 minutes timeout + AXIOS_TIMEOUT: 300000, // 5 minutes timeout (increased from 2 minutes) USER_AGENT: 'KiroIDE', KIRO_VERSION: '0.7.5', CONTENT_TYPE_JSON: 'application/json', @@ -312,13 +312,13 @@ export class KiroApiService { keepAlive: true, maxSockets: 100, // 每个主机最多 10 个连接 maxFreeSockets: 5, // 最多保留 5 个空闲连接 - timeout: 120000, // 空闲连接 60 秒后关闭 + timeout: KIRO_CONSTANTS.AXIOS_TIMEOUT, }); const httpsAgent = new https.Agent({ keepAlive: true, maxSockets: 100, maxFreeSockets: 5, - timeout: 120000, + timeout: KIRO_CONSTANTS.AXIOS_TIMEOUT, }); const axiosConfig = { diff --git a/src/common.js b/src/common.js index adcfe6a..590f8ee 100644 --- a/src/common.js +++ b/src/common.js @@ -2,8 +2,7 @@ import { promises as fs } from 'fs'; import * as path from 'path'; import * as http from 'http'; // Add http for IncomingMessage and ServerResponse types import * as crypto from 'crypto'; // Import crypto for MD5 hashing -import { ApiServiceAdapter } from './adapter.js'; // Import ApiServiceAdapter -import { convertData, getOpenAIStreamChunkStop, getOpenAIResponsesStreamChunkBegin, getOpenAIResponsesStreamChunkEnd } from './convert.js'; +import { convertData, getOpenAIStreamChunkStop } from './convert.js'; import { ProviderStrategyFactory } from './provider-strategies.js'; export const API_ACTIONS = { @@ -317,7 +316,6 @@ export async function handleUnaryRequest(res, service, model, requestBody, fromP * and sends the JSON response. * @param {http.IncomingMessage} req The HTTP request object. * @param {http.ServerResponse} res The HTTP response object. - * @param {ApiServiceAdapter} service The API service adapter. * @param {string} endpointType The type of endpoint being called (e.g., OPENAI_MODEL_LIST). * @param {Object} CONFIG - The server configuration object. */ @@ -368,7 +366,6 @@ export async function handleModelListRequest(req, res, service, endpointType, CO * logging, and dispatching to the appropriate stream or unary handler. * @param {http.IncomingMessage} req The HTTP request object. * @param {http.ServerResponse} res The HTTP response object. - * @param {ApiServiceAdapter} service The API service adapter. * @param {string} endpointType The type of endpoint being called (e.g., OPENAI_CHAT). * @param {Object} CONFIG - The server configuration object. * @param {string} PROMPT_LOG_FILENAME - The prompt log filename. diff --git a/src/gemini/antigravity-core.js b/src/gemini/antigravity-core.js index 1ed2804..0c1782e 100644 --- a/src/gemini/antigravity-core.js +++ b/src/gemini/antigravity-core.js @@ -9,6 +9,7 @@ import { v4 as uuidv4 } from 'uuid'; import open from 'open'; import { formatExpiryTime } from '../common.js'; import { getProviderModels } from '../provider-models.js'; +import { handleGeminiAntigravityOAuth } from '../oauth-handlers.js'; // 配置 HTTP/HTTPS agent 限制连接池大小,避免资源泄漏 const httpAgent = new http.Agent({ @@ -305,85 +306,51 @@ export class AntigravityApiService { } async getNewToken(credPath) { - let host = this.host; - if (!host || host === 'undefined') { - host = '127.0.0.1'; - } - const redirectUri = `http://${host}:${AUTH_REDIRECT_PORT}`; - this.authClient.redirectUri = redirectUri; + // 使用统一的 OAuth 处理方法 + const { authUrl, authInfo } = await handleGeminiAntigravityOAuth(this.config); + + console.log('\n[Antigravity Auth] 正在自动打开浏览器进行授权...'); + console.log('[Antigravity Auth] 授权链接:', authUrl, '\n'); - return new Promise(async (resolve, reject) => { - const authUrl = this.authClient.generateAuthUrl({ - access_type: 'offline', - scope: ['https://www.googleapis.com/auth/cloud-platform'] - }); + // 自动打开浏览器 + const showFallbackMessage = () => { + console.log('[Antigravity Auth] 无法自动打开浏览器,请手动复制上面的链接到浏览器中打开'); + }; - console.log('\n[Antigravity Auth] 正在自动打开浏览器进行授权...'); - console.log('[Antigravity Auth] 授权链接:', authUrl, '\n'); - - // 自动打开浏览器 - const showFallbackMessage = () => { - console.log('[Antigravity Auth] 无法自动打开浏览器,请手动复制上面的链接到浏览器中打开'); - }; - - if (this.config) { - try { - const childProcess = await open(authUrl); - if (childProcess) { - childProcess.on('error', () => showFallbackMessage()); - } - } catch (_err) { - showFallbackMessage(); + if (this.config) { + try { + const childProcess = await open(authUrl); + if (childProcess) { + childProcess.on('error', () => showFallbackMessage()); } - } else { + } catch (_err) { showFallbackMessage(); } + } else { + showFallbackMessage(); + } - const server = http.createServer(async (req, res) => { + // 等待 OAuth 回调完成并读取保存的凭据 + return new Promise((resolve, reject) => { + const checkInterval = setInterval(async () => { try { - const url = new URL(req.url, redirectUri); - const code = url.searchParams.get('code'); - const errorParam = url.searchParams.get('error'); - - if (code) { - console.log(`[Antigravity Auth] Received successful callback from Google: ${req.url}`); - res.writeHead(200, { 'Content-Type': 'text/plain' }); - res.end('Authentication successful! You can close this browser tab.'); - server.close(); - - const { tokens } = await this.authClient.getToken(code); - await fs.mkdir(path.dirname(credPath), { recursive: true }); - await fs.writeFile(credPath, JSON.stringify(tokens, null, 2)); - console.log('[Antigravity Auth] New token received and saved to file.'); - resolve(tokens); - } else if (errorParam) { - const errorMessage = `Authentication failed. Google returned an error: ${errorParam}.`; - res.writeHead(400, { 'Content-Type': 'text/plain' }); - res.end(errorMessage); - server.close(); - reject(new Error(errorMessage)); - } else { - console.log(`[Antigravity Auth] Ignoring irrelevant request: ${req.url}`); - res.writeHead(204); - res.end(); + const data = await fs.readFile(credPath, 'utf8'); + const credentials = JSON.parse(data); + if (credentials.access_token) { + clearInterval(checkInterval); + console.log('[Antigravity Auth] New token obtained successfully.'); + resolve(credentials); } - } catch (e) { - if (server.listening) server.close(); - reject(e); + } catch (error) { + // 文件尚未创建或无效,继续等待 } - }); + }, 1000); - server.on('error', (err) => { - if (err.code === 'EADDRINUSE') { - const errorMessage = `[Antigravity Auth] Port ${AUTH_REDIRECT_PORT} on ${host} is already in use.`; - console.error(errorMessage); - reject(new Error(errorMessage)); - } else { - reject(err); - } - }); - - server.listen(AUTH_REDIRECT_PORT, host); + // 设置超时(5分钟) + setTimeout(() => { + clearInterval(checkInterval); + reject(new Error('[Antigravity Auth] OAuth 授权超时')); + }, 5 * 60 * 1000); }); } diff --git a/src/gemini/gemini-core.js b/src/gemini/gemini-core.js index 2b15ee9..1778e18 100644 --- a/src/gemini/gemini-core.js +++ b/src/gemini/gemini-core.js @@ -8,6 +8,7 @@ import * as readline from 'readline'; import open from 'open'; import { API_ACTIONS, formatExpiryTime } from '../common.js'; import { getProviderModels } from '../provider-models.js'; +import { handleGeminiCliOAuth } from '../oauth-handlers.js'; // 配置 HTTP/HTTPS agent 限制连接池大小,避免资源泄漏 const httpAgent = new http.Agent({ @@ -274,75 +275,51 @@ export class GeminiApiService { } async getNewToken(credPath) { - let host = this.host; - if (!host || host === 'undefined') { - host = '127.0.0.1'; - } - const redirectUri = `http://${host}:${AUTH_REDIRECT_PORT}`; - this.authClient.redirectUri = redirectUri; - return new Promise(async (resolve, reject) => { - const authUrl = this.authClient.generateAuthUrl({ access_type: 'offline', scope: ['https://www.googleapis.com/auth/cloud-platform'] }); - console.log('\n[Gemini Auth] 正在自动打开浏览器进行授权...'); - - // 自动打开浏览器 - const showFallbackMessage = () => { - console.log('[Gemini Auth] 无法自动打开浏览器,请手动复制上面的链接到浏览器中打开'); - }; - - if (this.config) { - try { - const childProcess = await open(authUrl); - if (childProcess) { - childProcess.on('error', () => showFallbackMessage()); - } - } catch (_err) { - showFallbackMessage(); + // 使用统一的 OAuth 处理方法 + const { authUrl, authInfo } = await handleGeminiCliOAuth(this.config); + + console.log('\n[Gemini Auth] 正在自动打开浏览器进行授权...'); + console.log('[Gemini Auth] 授权链接:', authUrl, '\n'); + + // 自动打开浏览器 + const showFallbackMessage = () => { + console.log('[Gemini Auth] 无法自动打开浏览器,请手动复制上面的链接到浏览器中打开'); + }; + + if (this.config) { + try { + const childProcess = await open(authUrl); + if (childProcess) { + childProcess.on('error', () => showFallbackMessage()); } - } else { + } catch (_err) { showFallbackMessage(); } - console.log('[Gemini Auth] 授权链接:', authUrl, '\n'); - const server = http.createServer(async (req, res) => { + } else { + showFallbackMessage(); + } + + // 等待 OAuth 回调完成并读取保存的凭据 + return new Promise((resolve, reject) => { + const checkInterval = setInterval(async () => { try { - const url = new URL(req.url, redirectUri); - const code = url.searchParams.get('code'); - const errorParam = url.searchParams.get('error'); - if (code) { - console.log(`[Gemini Auth] Received successful callback from Google: ${req.url}`); - res.writeHead(200, { 'Content-Type': 'text/plain' }); - res.end('Authentication successful! You can close this browser tab.'); - server.close(); - const { tokens } = await this.authClient.getToken(code); - await fs.mkdir(path.dirname(credPath), { recursive: true }); - await fs.writeFile(credPath, JSON.stringify(tokens, null, 2)); - console.log('[Gemini Auth] New token received and saved to file.'); - resolve(tokens); - } else if (errorParam) { - const errorMessage = `Authentication failed. Google returned an error: ${errorParam}.`; - res.writeHead(400, { 'Content-Type': 'text/plain' }); - res.end(errorMessage); - server.close(); - reject(new Error(errorMessage)); - } else { - console.log(`[Gemini Auth] Ignoring irrelevant request: ${req.url}`); - res.writeHead(204); - res.end(); + const data = await fs.readFile(credPath, 'utf8'); + const credentials = JSON.parse(data); + if (credentials.access_token) { + clearInterval(checkInterval); + console.log('[Gemini Auth] New token obtained successfully.'); + resolve(credentials); } - } catch (e) { - if (server.listening) server.close(); - reject(e); + } catch (error) { + // 文件尚未创建或无效,继续等待 } - }); - server.on('error', (err) => { - if (err.code === 'EADDRINUSE') { - const errorMessage = `[Gemini Auth] Port ${AUTH_REDIRECT_PORT} on ${this.host} is already in use.`; - console.error(errorMessage); - reject(new Error(errorMessage)); - } else { - reject(err); - } - }); - server.listen(AUTH_REDIRECT_PORT, this.host); + }, 1000); + + // 设置超时(5分钟) + setTimeout(() => { + clearInterval(checkInterval); + reject(new Error('[Gemini Auth] OAuth 授权超时')); + }, 5 * 60 * 1000); }); } diff --git a/src/openai/qwen-core.js b/src/openai/qwen-core.js index 61dc65b..7f344c6 100644 --- a/src/openai/qwen-core.js +++ b/src/openai/qwen-core.js @@ -9,6 +9,7 @@ import open from 'open'; import { EventEmitter } from 'events'; import { randomUUID } from 'node:crypto'; import { getProviderModels } from '../provider-models.js'; +import { handleQwenOAuth } from '../oauth-handlers.js'; // --- Constants --- const QWEN_DIR = '.qwen'; @@ -284,39 +285,30 @@ export class QwenApiService { } async _authWithQwenDeviceFlow(client, config) { - let isCancelled = false; - const cancelHandler = () => { isCancelled = true; }; - const sigintHandler = () => { - isCancelled = true; - qwenOAuth2Events.emit(QwenOAuth2Event.AuthCancel); - }; - qwenOAuth2Events.once(QwenOAuth2Event.AuthCancel, cancelHandler); - process.once('SIGINT', sigintHandler); - try { - const { code_verifier, code_challenge } = generatePKCEPair(); - const deviceAuth = await client.requestDeviceAuthorization({ - scope: QWEN_OAUTH_SCOPE, - code_challenge, - code_challenge_method: 'S256', + // 使用统一的 OAuth 处理方法 + const { authUrl, authInfo } = await handleQwenOAuth(config); + + // 发送授权 URI 事件 + qwenOAuth2Events.emit(QwenOAuth2Event.AuthUri, { + verification_uri_complete: authUrl, + user_code: authInfo.userCode, + verification_uri: authInfo.verificationUri, + device_code: authInfo.deviceCode, + expires_in: authInfo.expiresIn, + interval: authInfo.interval }); - if (!isDeviceAuthorizationSuccess(deviceAuth)) { - throw new Error(`Device authorization failed: ${deviceAuth?.error || 'Unknown error'} - ${deviceAuth?.error_description || 'No details'}`); - } - - qwenOAuth2Events.emit(QwenOAuth2Event.AuthUri, deviceAuth); - const showFallbackMessage = () => { console.log('\n=== Qwen OAuth Device Authorization ==='); console.log('Please visit the following URL in your browser to authorize:'); - console.log(`\n${deviceAuth.verification_uri_complete}\n`); + console.log(`\n${authUrl}\n`); console.log('Waiting for authorization to complete...\n'); }; if (config) { try { - const childProcess = await open(deviceAuth.verification_uri_complete); + const childProcess = await open(authUrl); if (childProcess) { childProcess.on('error', () => showFallbackMessage()); } @@ -330,70 +322,37 @@ export class QwenApiService { qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'polling', 'Waiting for authorization...'); console.debug('Waiting for authorization...\n'); - let pollInterval = 2000; - const maxAttempts = Math.ceil(deviceAuth.expires_in / (pollInterval / 1000)); - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - if (isCancelled) { - qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', 'Authentication cancelled by user.'); - return { success: false, reason: 'cancelled' }; - } - - try { - const tokenResponse = await client.pollDeviceToken({ device_code: deviceAuth.device_code, code_verifier }); - if (isDeviceTokenSuccess(tokenResponse)) { - const credentials = { - access_token: tokenResponse.access_token, - refresh_token: tokenResponse.refresh_token || undefined, - token_type: tokenResponse.token_type, - resource_url: tokenResponse.resource_url, - expiry_date: tokenResponse.expires_in ? Date.now() + tokenResponse.expires_in * 1000 : undefined, - }; - client.setCredentials(credentials); - await this._cacheQwenCredentials(credentials); - qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'success', 'Authentication successful! Access token obtained.'); - return { success: true }; - } - - if (isDeviceTokenPending(tokenResponse)) { - if (tokenResponse.slowDown) { - pollInterval = Math.min(pollInterval * 1.5, 10000); - } else { - pollInterval = 2000; + // 等待 OAuth 回调完成并读取保存的凭据 + const credPath = this._getQwenCachedCredentialPath(); + const credentials = await new Promise((resolve, reject) => { + const checkInterval = setInterval(async () => { + try { + const data = await fs.readFile(credPath, 'utf8'); + const creds = JSON.parse(data); + if (creds.access_token) { + clearInterval(checkInterval); + console.log('[Qwen Auth] New token obtained successfully.'); + resolve(creds); } - // Fall through to wait and continue - } else if (isErrorResponse(tokenResponse)) { - console.warn(`Token polling failed with error: ${tokenResponse?.error || 'Unknown error'}`); - // Fall through to wait and continue + } catch (error) { + // 文件尚未创建或无效,继续等待 } - } catch (error) { - console.warn(`Token polling threw an exception: ${error.message}`); - // Fall through to wait for the next attempt - } - - // Wait for the polling interval before the next attempt - await new Promise(resolve => { - const timeoutId = setTimeout(resolve, pollInterval); - // If cancelled during wait, clear timeout and resolve immediately - if (isCancelled) { - clearTimeout(timeoutId); - resolve(); - } - }); - - // Check again after waiting - if (isCancelled) { - qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', 'Authentication cancelled by user.'); - return { success: false, reason: 'cancelled' }; - } - } - return { success: false, reason: 'timeout' }; + }, 1000); + + // 设置超时(5分钟) + setTimeout(() => { + clearInterval(checkInterval); + reject(new Error('[Qwen Auth] OAuth 授权超时')); + }, 5 * 60 * 1000); + }); + + client.setCredentials(credentials); + qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'success', 'Authentication successful! Access token obtained.'); + return { success: true }; } catch (error) { console.error('Device authorization flow failed:', error.message); + qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', error.message); return { success: false, reason: 'error' }; - } finally { - qwenOAuth2Events.off(QwenOAuth2Event.AuthCancel, cancelHandler); - process.off('SIGINT', sigintHandler); } } diff --git a/static/app/modal.js b/static/app/modal.js index 8a054f0..f5ef42e 100644 --- a/static/app/modal.js +++ b/static/app/modal.js @@ -381,7 +381,7 @@ function renderProviderList(providers) {
-
${provider.uuid}
+
${provider.customName || provider.uuid}
@@ -438,17 +438,29 @@ function renderProviderConfig(provider) { // 获取字段映射,确保顺序一致 const fieldOrder = getFieldOrder(provider); - // 先渲染基础配置字段(checkModelName 和 checkHealth) + // 先渲染基础配置字段(customName、checkModelName 和 checkHealth) let html = '
'; - const baseFields = ['checkModelName', 'checkHealth']; + const baseFields = ['customName', 'checkModelName', 'checkHealth']; baseFields.forEach(fieldKey => { const displayLabel = getFieldLabel(fieldKey); const value = provider[fieldKey]; const displayValue = value || ''; - // 如果是 checkHealth 字段,使用下拉选择框 - if (fieldKey === 'checkHealth') { + // 如果是 customName 字段,使用普通文本输入框 + if (fieldKey === 'customName') { + html += ` +
+ + +
+ `; + } else if (fieldKey === 'checkHealth') { // 如果没有值,默认为 false const actualValue = value !== undefined ? value : false; const isEnabled = actualValue === true || actualValue === 'true'; @@ -626,22 +638,71 @@ function renderProviderConfig(provider) { * @returns {Array} 字段键数组 */ function getFieldOrder(provider) { - const orderedFields = ['checkModelName', 'checkHealth']; + const orderedFields = ['customName', 'checkModelName', 'checkHealth']; // 需要排除的内部状态字段 const excludedFields = [ 'isHealthy', 'lastUsed', 'usageCount', 'errorCount', 'lastErrorTime', - 'uuid', 'isDisabled', 'lastHealthCheckTime', 'lastHealthCheckModel', 'lastErrorMessage' + 'uuid', 'isDisabled', 'lastHealthCheckTime', 'lastHealthCheckModel', 'lastErrorMessage', + 'notSupportedModels' ]; + // 从 getProviderTypeFields 获取字段顺序映射 + const fieldOrderMap = { + 'openai-custom': ['OPENAI_API_KEY', 'OPENAI_BASE_URL'], + 'openaiResponses-custom': ['OPENAI_API_KEY', 'OPENAI_BASE_URL'], + 'claude-custom': ['CLAUDE_API_KEY', 'CLAUDE_BASE_URL'], + 'gemini-cli-oauth': ['PROJECT_ID', 'GEMINI_OAUTH_CREDS_FILE_PATH'], + 'claude-kiro-oauth': ['KIRO_OAUTH_CREDS_FILE_PATH'], + 'openai-qwen-oauth': ['QWEN_OAUTH_CREDS_FILE_PATH'], + 'gemini-antigravity': ['PROJECT_ID', 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH'] + }; + // 获取所有其他配置项 const otherFields = Object.keys(provider).filter(key => !excludedFields.includes(key) && !orderedFields.includes(key) ); - // 按字母顺序排序其他字段 - otherFields.sort(); + // 尝试从 provider 中推断提供商类型 + let providerType = null; + if (provider.OPENAI_API_KEY && provider.OPENAI_BASE_URL) { + providerType = 'openai-custom'; // 或 openaiResponses-custom,顺序相同 + } else if (provider.CLAUDE_API_KEY && provider.CLAUDE_BASE_URL) { + providerType = 'claude-custom'; + } else if (provider.GEMINI_OAUTH_CREDS_FILE_PATH) { + providerType = 'gemini-cli-oauth'; + } else if (provider.KIRO_OAUTH_CREDS_FILE_PATH) { + providerType = 'claude-kiro-oauth'; + } else if (provider.QWEN_OAUTH_CREDS_FILE_PATH) { + providerType = 'openai-qwen-oauth'; + } else if (provider.ANTIGRAVITY_OAUTH_CREDS_FILE_PATH) { + providerType = 'gemini-antigravity'; + } + // 如果能识别提供商类型,使用预定义的顺序 + if (providerType && fieldOrderMap[providerType]) { + const predefinedOrder = fieldOrderMap[providerType]; + const orderedOtherFields = []; + const remainingFields = [...otherFields]; + + // 先按预定义顺序添加字段 + predefinedOrder.forEach(fieldKey => { + const index = remainingFields.indexOf(fieldKey); + if (index !== -1) { + orderedOtherFields.push(fieldKey); + remainingFields.splice(index, 1); + } + }); + + // 剩余字段按字母顺序添加 + remainingFields.sort(); + orderedOtherFields.push(...remainingFields); + + return [...orderedFields, ...orderedOtherFields].filter(key => provider.hasOwnProperty(key)); + } + + // 如果无法识别提供商类型,按字母顺序排序 + otherFields.sort(); return [...orderedFields, ...otherFields].filter(key => provider.hasOwnProperty(key)); } @@ -962,6 +1023,10 @@ function showAddProviderForm(providerType) { form.innerHTML = `

添加新提供商配置

+
+ + +
@@ -1140,10 +1205,12 @@ function bindAddFormPasswordToggleListeners(form) { * @param {string} providerType - 提供商类型 */ async function addProvider(providerType) { + const customName = document.getElementById('newCustomName')?.value; const checkModelName = document.getElementById('newCheckModelName')?.value; const checkHealth = document.getElementById('newCheckHealth')?.value === 'true'; const providerConfig = { + customName: customName || '', // 允许为空 checkModelName: checkModelName || '', // 允许为空 checkHealth }; diff --git a/static/app/utils.js b/static/app/utils.js index 2500d44..5bb0c2f 100644 --- a/static/app/utils.js +++ b/static/app/utils.js @@ -54,6 +54,7 @@ function showToast(message, type = 'info') { */ function getFieldLabel(key) { const labelMap = { + 'customName': '自定义名称 (选填)', 'checkModelName': '检查模型名称 (选填)', 'checkHealth': '健康检查', 'OPENAI_API_KEY': 'OpenAI API Key', @@ -63,7 +64,8 @@ function getFieldLabel(key) { 'PROJECT_ID': '项目ID', 'GEMINI_OAUTH_CREDS_FILE_PATH': 'OAuth凭据文件路径', 'KIRO_OAUTH_CREDS_FILE_PATH': 'OAuth凭据文件路径', - 'QWEN_OAUTH_CREDS_FILE_PATH': 'OAuth凭据文件路径' + 'QWEN_OAUTH_CREDS_FILE_PATH': 'OAuth凭据文件路径', + 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH': 'OAuth凭据文件路径' }; return labelMap[key] || key;