feat: 添加OAuth授权凭据自动生成功能并优化UI

- 实现OAuth授权成功后自动生成凭据文件并填充路径
- 添加"生成凭据"按钮到各提供商配置表单
- 优化文件上传组件的样式和布局
- 将autoLinkProviderConfigs函数导出供服务初始化使用
- 新增oauth_success事件处理逻辑
- 调整授权模态框位置避免遮挡
This commit is contained in:
hex2077 2025-12-19 18:05:32 +08:00
parent e943819539
commit e87d74f517
11 changed files with 217 additions and 66 deletions

View file

@ -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);

View file

@ -107,7 +107,7 @@ async function closeActiveServer(port) {
* @param {string} provider - 提供商标识
* @returns {Promise<http.Server>} 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<Object>} 返回授权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<Object>} 返回授权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<Object>} 返回授权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<Object>} 返回令牌信息
*/
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<Object>} 返回授权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);
// 广播授权失败事件

View file

@ -296,7 +296,7 @@ export function createProviderConfig(options) {
[credPathKey]: credPath,
uuid: generateUUID(),
checkModelName: defaultCheckModel,
checkHealth: true,
checkHealth: false,
isHealthy: true,
isDisabled: false,
lastUsed: null,

View file

@ -21,7 +21,7 @@ let providerPoolManager = null;
* @param {Object} config - 服务器配置对象
* @returns {Promise<Object>} 更新后的 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<Object>} 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, {

View file

@ -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 {

View file

@ -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;

View file

@ -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 - 事件对象

View file

@ -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;

View file

@ -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) {
<h4>授权步骤</h4>
<ol>
<li>点击下方按钮在浏览器中打开授权页面</li>
<li>在授权页面输入用户码: <strong>${authInfo.userCode}</strong></li>
<li>完成授权后系统会自动获取访问令牌</li>
<li>授权有效期: ${Math.floor(authInfo.expiresIn / 60)} 分钟</li>
</ol>
@ -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
};

View file

@ -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;
}

View file

@ -527,6 +527,9 @@
<button type="button" class="btn btn-outline upload-btn" data-target="geminiOauthCredsFilePath" aria-label="上传文件">
<i class="fas fa-upload"></i>
</button>
<button type="button" class="btn btn-outline generate-creds-btn" data-target="geminiOauthCredsFilePath" data-provider="gemini-cli-oauth" title="生成凭据文件">
<i class="fas fa-magic"></i>
</button>
</div>
</div>
</div>
@ -548,6 +551,9 @@
<button type="button" class="btn btn-outline upload-btn" data-target="antigravityOauthCredsFilePath" aria-label="上传文件">
<i class="fas fa-upload"></i>
</button>
<button type="button" class="btn btn-outline generate-creds-btn" data-target="antigravityOauthCredsFilePath" data-provider="gemini-antigravity" title="生成凭据文件">
<i class="fas fa-magic"></i>
</button>
</div>
<small class="form-text">Antigravity 使用 Google OAuth 认证,需要提供凭据文件路径</small>
</div>
@ -652,6 +658,9 @@
<button type="button" class="btn btn-outline upload-btn" data-target="qwenOauthCredsFilePath" aria-label="上传文件">
<i class="fas fa-upload"></i>
</button>
<button type="button" class="btn btn-outline generate-creds-btn" data-target="qwenOauthCredsFilePath" data-provider="openai-qwen-oauth" title="生成凭据文件">
<i class="fas fa-magic"></i>
</button>
</div>
</div>
</div>
@ -929,8 +938,11 @@
<div id="toastContainer" class="toast-container"></div>
<!-- Scripts -->
<script src="app/auth.js"></script>
<script>
<script type="module" src="app/auth.js"></script>
<script type="module">
// 导入认证函数
import { initAuth, logout } from './app/auth.js';
// 页面加载时检查登录状态
(async function() {
const isAuthenticated = await initAuth();
@ -983,6 +995,10 @@
element.textContent = updatedCommand;
});
}
// 导出到 window 供其他脚本使用
window.initAuth = initAuth;
window.logout = logout;
</script>
<script type="module" src="app/app.js"></script>
</body>