diff --git a/src/api-server.js b/src/api-server.js index 55a3f48..e9fca5d 100644 --- a/src/api-server.js +++ b/src/api-server.js @@ -1,6 +1,6 @@ import * as http from 'http'; import { initializeConfig, CONFIG, logProviderSpecificDetails } from './config-manager.js'; -import { initApiService } from './service-manager.js'; +import { initApiService, autoLinkProviderConfigs } from './service-manager.js'; import { initializeUIManagement } from './ui-manager.js'; import { initializeAPIManagement } from './api-manager.js'; import { createRequestHandler } from './request-handler.js'; @@ -119,6 +119,10 @@ async function startServer() { // Initialize configuration await initializeConfig(); + // 自动关联 configs 目录中的配置文件到对应的提供商 + console.log('[Initialization] Checking for unlinked provider configs...'); + await autoLinkProviderConfigs(CONFIG); + // Initialize API services const services = await initApiService(CONFIG); diff --git a/src/oauth-handlers.js b/src/oauth-handlers.js index bb9fa37..e85603e 100644 --- a/src/oauth-handlers.js +++ b/src/oauth-handlers.js @@ -107,7 +107,7 @@ async function closeActiveServer(port) { * @param {string} provider - 提供商标识 * @returns {Promise} HTTP 服务器实例 */ -async function createOAuthCallbackServer(config, redirectUri, authClient, credPath, provider) { +async function createOAuthCallbackServer(config, redirectUri, authClient, credPath, provider, options = {}) { // 先关闭该端口上的旧服务器 await closeActiveServer(config.port); @@ -123,14 +123,29 @@ async function createOAuthCallbackServer(config, redirectUri, authClient, credPa try { const { tokens } = await authClient.getToken(code); - await fs.promises.mkdir(path.dirname(credPath), { recursive: true }); - await fs.promises.writeFile(credPath, JSON.stringify(tokens, null, 2)); - console.log(`${config.logPrefix} 新令牌已接收并保存到文件`); + let finalCredPath = credPath; + // 如果指定了保存到 configs 目录 + if (options.saveToConfigs) { + const providerDir = options.providerDir; + const targetDir = path.join(process.cwd(), 'configs', providerDir); + await fs.promises.mkdir(targetDir, { recursive: true }); + const timestamp = Date.now(); + const filename = `${timestamp}_oauth_creds.json`; + finalCredPath = path.join(targetDir, filename); + } + + await fs.promises.mkdir(path.dirname(finalCredPath), { recursive: true }); + await fs.promises.writeFile(finalCredPath, JSON.stringify(tokens, null, 2)); + console.log(`${config.logPrefix} 新令牌已接收并保存到文件: ${finalCredPath}`); + + const relativePath = path.relative(process.cwd(), finalCredPath); + // 广播授权成功事件 broadcastEvent('oauth_success', { provider: provider, - credPath: credPath, + credPath: finalCredPath, + relativePath: relativePath, timestamp: new Date().toISOString() }); @@ -195,9 +210,10 @@ async function createOAuthCallbackServer(config, redirectUri, authClient, credPa * 处理 Google OAuth 授权(通用函数) * @param {string} providerKey - 提供商键名 * @param {Object} currentConfig - 当前配置对象 + * @param {Object} options - 额外选项 * @returns {Promise} 返回授权URL和相关信息 */ -async function handleGoogleOAuth(providerKey, currentConfig) { +async function handleGoogleOAuth(providerKey, currentConfig, options = {}) { const config = OAUTH_PROVIDERS[providerKey]; if (!config) { throw new Error(`未知的提供商: ${providerKey}`); @@ -211,6 +227,7 @@ async function handleGoogleOAuth(providerKey, currentConfig) { const authUrl = authClient.generateAuthUrl({ access_type: 'offline', + prompt: 'select_account', scope: config.scope }); @@ -218,7 +235,7 @@ async function handleGoogleOAuth(providerKey, currentConfig) { const credPath = path.join(os.homedir(), config.credentialsDir, config.credentialsFile); try { - await createOAuthCallbackServer(config, redirectUri, authClient, credPath, providerKey); + await createOAuthCallbackServer(config, redirectUri, authClient, credPath, providerKey, options); } catch (error) { throw new Error(`启动回调服务器失败: ${error.message}`); } @@ -237,19 +254,21 @@ async function handleGoogleOAuth(providerKey, currentConfig) { /** * 处理 Gemini CLI OAuth 授权 * @param {Object} currentConfig - 当前配置对象 + * @param {Object} options - 额外选项 * @returns {Promise} 返回授权URL和相关信息 */ -export async function handleGeminiCliOAuth(currentConfig) { - return handleGoogleOAuth('gemini-cli-oauth', currentConfig); +export async function handleGeminiCliOAuth(currentConfig, options = {}) { + return handleGoogleOAuth('gemini-cli-oauth', currentConfig, options); } /** * 处理 Gemini Antigravity OAuth 授权 * @param {Object} currentConfig - 当前配置对象 + * @param {Object} options - 额外选项 * @returns {Promise} 返回授权URL和相关信息 */ -export async function handleGeminiAntigravityOAuth(currentConfig) { - return handleGoogleOAuth('gemini-antigravity', currentConfig); +export async function handleGeminiAntigravityOAuth(currentConfig, options = {}) { + return handleGoogleOAuth('gemini-antigravity', currentConfig, options); } /** @@ -291,10 +310,11 @@ function stopPollingTask(taskId) { * @param {number} interval - 轮询间隔(秒) * @param {number} expiresIn - 过期时间(秒) * @param {string} taskId - 任务标识符 + * @param {Object} options - 额外选项 * @returns {Promise} 返回令牌信息 */ -async function pollQwenToken(deviceCode, codeVerifier, interval = 5, expiresIn = 300, taskId = 'default') { - const credPath = path.join(os.homedir(), QWEN_OAUTH_CONFIG.credentialsDir, QWEN_OAUTH_CONFIG.credentialsFile); +async function pollQwenToken(deviceCode, codeVerifier, interval = 5, expiresIn = 300, taskId = 'default', options = {}) { + let credPath = path.join(os.homedir(), QWEN_OAUTH_CONFIG.credentialsDir, QWEN_OAUTH_CONFIG.credentialsFile); const maxAttempts = Math.floor(expiresIn / interval); let attempts = 0; @@ -345,11 +365,22 @@ async function pollQwenToken(deviceCode, codeVerifier, interval = 5, expiresIn = // 成功获取令牌 console.log(`${QWEN_OAUTH_CONFIG.logPrefix} 成功获取令牌 [${taskId}]`); + // 如果指定了保存到 configs 目录 + if (options.saveToConfigs) { + const targetDir = path.join(process.cwd(), 'configs', options.providerDir); + await fs.promises.mkdir(targetDir, { recursive: true }); + const timestamp = Date.now(); + const filename = `${timestamp}_oauth_creds.json`; + credPath = path.join(targetDir, filename); + } + // 保存令牌到文件 await fs.promises.mkdir(path.dirname(credPath), { recursive: true }); await fs.promises.writeFile(credPath, JSON.stringify(data, null, 2)); console.log(`${QWEN_OAUTH_CONFIG.logPrefix} 令牌已保存到 ${credPath}`); + const relativePath = path.relative(process.cwd(), credPath); + // 清理任务 activePollingTasks.delete(taskId); @@ -357,6 +388,7 @@ async function pollQwenToken(deviceCode, codeVerifier, interval = 5, expiresIn = broadcastEvent('oauth_success', { provider: 'openai-qwen-oauth', credPath: credPath, + relativePath: relativePath, timestamp: new Date().toISOString() }); @@ -401,9 +433,10 @@ async function pollQwenToken(deviceCode, codeVerifier, interval = 5, expiresIn = /** * 处理 Qwen OAuth 授权(设备授权流程) * @param {Object} currentConfig - 当前配置对象 + * @param {Object} options - 额外选项 * @returns {Promise} 返回授权URL和相关信息 */ -export async function handleQwenOAuth(currentConfig) { +export async function handleQwenOAuth(currentConfig, options = {}) { const codeVerifier = generateCodeVerifier(); const codeChallenge = generateCodeChallenge(codeVerifier); @@ -454,7 +487,7 @@ export async function handleQwenOAuth(currentConfig) { } // 不等待轮询完成,立即返回授权信息 - pollQwenToken(deviceAuth.device_code, codeVerifier, interval, expiresIn, taskId) + pollQwenToken(deviceAuth.device_code, codeVerifier, interval, expiresIn, taskId, options) .catch(error => { console.error(`${QWEN_OAUTH_CONFIG.logPrefix} 轮询失败 [${taskId}]:`, error); // 广播授权失败事件 diff --git a/src/provider-utils.js b/src/provider-utils.js index e6edb51..b235fe4 100644 --- a/src/provider-utils.js +++ b/src/provider-utils.js @@ -296,7 +296,7 @@ export function createProviderConfig(options) { [credPathKey]: credPath, uuid: generateUUID(), checkModelName: defaultCheckModel, - checkHealth: true, + checkHealth: false, isHealthy: true, isDisabled: false, lastUsed: null, diff --git a/src/service-manager.js b/src/service-manager.js index 0440284..ea5508e 100644 --- a/src/service-manager.js +++ b/src/service-manager.js @@ -21,7 +21,7 @@ let providerPoolManager = null; * @param {Object} config - 服务器配置对象 * @returns {Promise} 更新后的 providerPools 对象 */ -async function autoLinkProviderConfigs(config) { +export async function autoLinkProviderConfigs(config) { // 确保 providerPools 对象存在 if (!config.providerPools) { config.providerPools = {}; @@ -159,9 +159,6 @@ async function scanProviderDirectory(dirPath, linkedPaths, newProviders, options * @returns {Promise} The initialized services */ export async function initApiService(config) { - // 自动关联 configs 目录中的配置文件到对应的提供商 - console.log('[Initialization] Checking for unlinked provider configs...'); - await autoLinkProviderConfigs(config); if (config.providerPools && Object.keys(config.providerPools).length > 0) { providerPoolManager = new ProviderPoolManager(config.providerPools, { diff --git a/src/ui-manager.js b/src/ui-manager.js index 3701e28..57336ca 100644 --- a/src/ui-manager.js +++ b/src/ui-manager.js @@ -1350,17 +1350,25 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo let authUrl = ''; let authInfo = {}; + // 解析 options + let options = {}; + try { + options = await getRequestBody(req); + } catch (e) { + // 如果没有请求体,使用默认空对象 + } + // 根据提供商类型生成授权链接并启动回调服务器 if (providerType === 'gemini-cli-oauth') { - const result = await handleGeminiCliOAuth(currentConfig); + const result = await handleGeminiCliOAuth(currentConfig, options); authUrl = result.authUrl; authInfo = result.authInfo; } else if (providerType === 'gemini-antigravity') { - const result = await handleGeminiAntigravityOAuth(currentConfig); + const result = await handleGeminiAntigravityOAuth(currentConfig, options); authUrl = result.authUrl; authInfo = result.authInfo; } else if (providerType === 'openai-qwen-oauth') { - const result = await handleQwenOAuth(currentConfig); + const result = await handleQwenOAuth(currentConfig, options); authUrl = result.authUrl; authInfo = result.authInfo; } else { diff --git a/static/app/app.js b/static/app/app.js index 1342336..567b35a 100644 --- a/static/app/app.js +++ b/static/app/app.js @@ -36,7 +36,8 @@ import { loadSystemInfo, updateTimeDisplay, loadProviders, - openProviderManager + openProviderManager, + showAuthModal } from './provider-manager.js'; import { @@ -141,6 +142,7 @@ window.openProviderManager = openProviderManager; window.showProviderManagerModal = showProviderManagerModal; window.refreshProviderConfig = refreshProviderConfig; window.fileUploadHandler = fileUploadHandler; +window.showAuthModal = showAuthModal; // 上传配置管理相关全局函数 window.viewConfig = viewConfig; diff --git a/static/app/event-handlers.js b/static/app/event-handlers.js index b6eba67..b608e88 100644 --- a/static/app/event-handlers.js +++ b/static/app/event-handlers.js @@ -2,6 +2,7 @@ import { elements, autoScroll, setAutoScroll, clearLogs } from './constants.js'; import { showToast } from './utils.js'; +import { fileUploadHandler } from './file-upload.js'; /** * 初始化所有事件监听器 @@ -66,6 +67,11 @@ function initEventListeners() { button.addEventListener('click', handlePasswordToggle); }); + // 生成凭据按钮监听 + document.querySelectorAll('.generate-creds-btn').forEach(button => { + button.addEventListener('click', handleGenerateCreds); + }); + // 提供商池配置监听 // const providerPoolsInput = document.getElementById('providerPoolsFilePath'); // if (providerPoolsInput) { @@ -171,6 +177,65 @@ function handlePasswordToggle(event) { } } +/** + * 处理生成凭据逻辑 + * @param {Event} event - 事件对象 + */ +async function handleGenerateCreds(event) { + const button = event.target.closest('.generate-creds-btn'); + if (!button) return; + + const providerType = button.getAttribute('data-provider'); + const targetInputId = button.getAttribute('data-target'); + + try { + showToast('正在初始化凭据生成...', 'info'); + + // 使用 fileUploadHandler 中的 getProviderKey 获取目录名称 + const providerDir = fileUploadHandler.getProviderKey(providerType); + + const response = await window.apiClient.post( + `/providers/${encodeURIComponent(providerType)}/generate-auth-url`, + { + saveToConfigs: true, + providerDir: providerDir + } + ); + + if (response.success && response.authUrl) { + // 使用自定义事件监听授权成功,以便自动填充路径 + const handleSuccess = (e) => { + const data = e.detail; + if (data.provider === providerType && data.relativePath) { + const input = document.getElementById(targetInputId); + if (input) { + input.value = data.relativePath; + input.dispatchEvent(new Event('input', { bubbles: true })); + showToast('凭据已生成并自动填充路径', 'success'); + } + window.removeEventListener('oauth_success_event', handleSuccess); + } + }; + window.addEventListener('oauth_success_event', handleSuccess); + + // 调用 provider-manager.js 中的 showAuthModal (假设已在全局作用域或通过某种方式可用) + // 如果不可用,我们需要在 app.js 中导出它 + if (window.showAuthModal) { + window.showAuthModal(response.authUrl, response.authInfo); + } else { + // 降级处理:如果在 app.js 中没导出,尝试直接打开 + window.open(response.authUrl, '_blank'); + showToast('请在打开的窗口中完成授权', 'info'); + } + } else { + showToast('初始化凭据生成失败', 'error'); + } + } catch (error) { + console.error('生成凭据失败:', error); + showToast(`生成凭据失败: ${error.message}`, 'error'); + } +} + /** * 提供商池配置变化处理 * @param {Event} event - 事件对象 diff --git a/static/app/event-stream.js b/static/app/event-stream.js index 396a4fc..11ea0f3 100644 --- a/static/app/event-stream.js +++ b/static/app/event-stream.js @@ -33,6 +33,22 @@ function initEventStream() { updateProviderStatus(data); }); + newEventSource.addEventListener('oauth_success', (event) => { + const data = JSON.parse(event.data); + showToast(`授权成功 (${data.provider})`, 'success'); + // 发送自定义事件,以便其他模块(如生成凭据逻辑)可以接收到详细信息 + window.dispatchEvent(new CustomEvent('oauth_success_event', { detail: data })); + + // 关闭授权窗口和模态框 + // 查找并关闭所有授权相关的模态框 + const modals = document.querySelectorAll('.modal-overlay'); + modals.forEach(modal => modal.remove()); + + // 授权成功后刷新配置和提供商列表 + if (loadProviders) loadProviders(); + if (loadConfigList) loadConfigList(); + }); + newEventSource.addEventListener('provider_update', (event) => { const data = JSON.parse(event.data); handleProviderUpdate(data); @@ -127,7 +143,7 @@ function handleProviderUpdate(data) { } // 导入工具函数 -import { escapeHtml } from './utils.js'; +import { escapeHtml, showToast } from './utils.js'; // 需要从其他模块导入的函数 let loadProviders; diff --git a/static/app/provider-manager.js b/static/app/provider-manager.js index 3eb0075..4d344d2 100644 --- a/static/app/provider-manager.js +++ b/static/app/provider-manager.js @@ -1,7 +1,8 @@ // 提供商管理功能模块 import { providerStats, updateProviderStats } from './constants.js'; -import { showToast } from './utils.js'; +import { showToast, formatUptime } from './utils.js'; +import { fileUploadHandler } from './file-upload.js'; // 保存初始服务器时间和运行时间 let initialServerTime = null; @@ -342,9 +343,15 @@ async function handleGenerateAuthUrl(providerType) { try { showToast('正在生成授权链接...', 'info'); + // 使用 fileUploadHandler 中的 getProviderKey 获取目录名称 + const providerDir = fileUploadHandler.getProviderKey(providerType); + const response = await window.apiClient.post( `/providers/${encodeURIComponent(providerType)}/generate-auth-url`, - {} + { + saveToConfigs: true, + providerDir: providerDir + } ); if (response.success && response.authUrl) { @@ -394,7 +401,6 @@ function showAuthModal(authUrl, authInfo) {

授权步骤:

  1. 点击下方按钮在浏览器中打开授权页面
  2. -
  3. 在授权页面输入用户码: ${authInfo.userCode}
  4. 完成授权后,系统会自动获取访问令牌
  5. 授权有效期: ${Math.floor(authInfo.expiresIn / 60)} 分钟
@@ -491,7 +497,7 @@ function showAuthModal(authUrl, authInfo) { // 使用子窗口打开,以便监听 URL 变化 const width = 600; const height = 700; - const left = (window.screen.width - width) / 2; + const left = (window.screen.width - width) / 2 + 600; const top = (window.screen.height - height) / 2; const authWindow = window.open( @@ -500,6 +506,16 @@ function showAuthModal(authUrl, authInfo) { `width=${width},height=${height},left=${left},top=${top},status=no,resizable=yes,scrollbars=yes` ); + // 监听 OAuth 成功事件,自动关闭窗口和模态框 + const handleOAuthSuccess = () => { + if (authWindow && !authWindow.closed) { + authWindow.close(); + } + modal.remove(); + window.removeEventListener('oauth_success_event', handleOAuthSuccess); + }; + window.addEventListener('oauth_success_event', handleOAuthSuccess); + if (authWindow) { showToast('已打开授权窗口,请在窗口中完成操作', 'info'); @@ -547,13 +563,6 @@ function showAuthModal(authUrl, authInfo) { img.src = localUrl.href; } - setTimeout(() => { - showToast('授权完成!', 'success'); - if (authWindow && !authWindow.closed) authWindow.close(); - modal.remove(); - // 刷新列表以显示新状态 - loadProviders(); - }, 2500); } else { showToast('该 URL 似乎不包含有效的授权代码', 'warning'); } @@ -596,14 +605,12 @@ function showAuthModal(authUrl, authInfo) { }); } -// 导入工具函数 -import { formatUptime } from './utils.js'; - export { loadSystemInfo, updateTimeDisplay, loadProviders, renderProviders, updateProviderStatsDisplay, - openProviderManager + openProviderManager, + showAuthModal }; \ No newline at end of file diff --git a/static/app/styles.css b/static/app/styles.css index 0cb36c0..c92f617 100644 --- a/static/app/styles.css +++ b/static/app/styles.css @@ -418,33 +418,38 @@ textarea.form-control { position: relative; display: flex; align-items: center; - gap: 0; + gap: 0.5rem; } .file-input-group .form-control { flex: 1; - border-top-right-radius: 0; - border-bottom-right-radius: 0; - padding-right: 3rem; + padding-right: 0.75rem; } -.upload-btn { - position: absolute; - right: 0; - top: 0; - height: 100%; - border-top-left-radius: 0; - border-bottom-left-radius: 0; - border-left: none; - padding: 0.75rem 1rem; +.file-input-group .btn-outline { + height: 38px; + width: 38px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + flex-shrink: 0; + background: var(--bg-tertiary); color: var(--text-secondary); border: 1px solid var(--border-color); - border-left: none; - cursor: pointer; - transition: var(--transition); - white-space: nowrap; - z-index: 1; - background: bottom; +} + +.file-input-group .btn-outline:hover { + background: var(--bg-secondary); + color: var(--primary-color); + border-color: var(--primary-color); +} + +/* 兼容旧的 class 名 */ +.upload-btn { + /* 移除之前的 position: absolute 等样式,改为 inline-flex 配合父级 flex 布局 */ + display: inline-flex; } .upload-btn:hover { @@ -1616,14 +1621,12 @@ input:checked + .toggle-slider:before { position: relative; display: flex; align-items: center; - gap: 0; + gap: 0.5rem; } .config-item .file-input-group input { flex: 1; - border-top-right-radius: 0; - border-bottom-right-radius: 0; - padding-right: 3rem; + padding-right: 0.75rem; box-sizing: border-box; } diff --git a/static/index.html b/static/index.html index ae89b19..2f3303d 100644 --- a/static/index.html +++ b/static/index.html @@ -527,6 +527,9 @@ + @@ -548,6 +551,9 @@ + Antigravity 使用 Google OAuth 认证,需要提供凭据文件路径 @@ -652,6 +658,9 @@ + @@ -929,8 +938,11 @@
- - +