From b364341f67fc9dca39a12911cf3cca3cda8764d0 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Wed, 10 Dec 2025 15:18:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(provider):=20=E5=A2=9E=E5=BC=BA=E5=81=A5?= =?UTF-8?q?=E5=BA=B7=E6=A3=80=E6=B5=8B=E5=92=8C=E8=87=AA=E5=8A=A8=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=85=B3=E8=81=94=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 provider-utils.js 公共模块,提取共用工具函数 - 添加提供商健康检测 API 端点和 UI 按钮 - 实现配置文件自动关联功能(启动时和手动触发) - 支持 gemini-antigravity 新提供商类型 - 增强健康检测结果记录(时间、模型、错误信息) - 添加提供商列表分页功能 - 修复 OpenAIResponsesConverter 中 systemMessages 未定义问题 - 更新默认健康检测模型配置 --- .../strategies/OpenAIResponsesConverter.js | 4 + src/provider-pool-manager.js | 207 +++++--- src/provider-utils.js | 342 ++++++++++++++ src/service-manager.js | 152 ++++++ src/ui-manager.js | 446 +++++++++++------- static/app/file-upload.js | 1 + static/app/modal.js | 305 +++++++++++- static/app/styles.css | 247 ++++++++++ static/app/upload-config-manager.js | 168 +++++++ static/app/utils.js | 14 + static/index.html | 3 + 11 files changed, 1625 insertions(+), 264 deletions(-) create mode 100644 src/provider-utils.js diff --git a/src/converters/strategies/OpenAIResponsesConverter.js b/src/converters/strategies/OpenAIResponsesConverter.js index dfd7e06..1c635d8 100644 --- a/src/converters/strategies/OpenAIResponsesConverter.js +++ b/src/converters/strategies/OpenAIResponsesConverter.js @@ -257,6 +257,10 @@ export class OpenAIResponsesConverter extends BaseConverter { // 如果有标准的 messages 字段,也支持 if (responsesRequest.messages && Array.isArray(responsesRequest.messages)) { + const { systemMessages, otherMessages } = extractSystemMessages( + responsesRequest.messages + ); + if (!claudeRequest.system && systemMessages.length > 0) { const systemTexts = systemMessages.map(msg => extractText(msg.content)); claudeRequest.system = systemTexts.join('\n'); diff --git a/src/provider-pool-manager.js b/src/provider-pool-manager.js index 1a2ad0a..106ec84 100644 --- a/src/provider-pool-manager.js +++ b/src/provider-pool-manager.js @@ -1,19 +1,22 @@ import * as fs from 'fs'; // Import fs module import { getServiceAdapter } from './adapter.js'; import { MODEL_PROVIDER } from './common.js'; +import axios from 'axios'; /** * Manages a pool of API service providers, handling their health and selection. */ export class ProviderPoolManager { // 默认健康检查模型配置 + // 键名必须与 MODEL_PROVIDER 常量值一致 static DEFAULT_HEALTH_CHECK_MODELS = { - 'gemini-cli': 'gemini-2.5-flash', + 'gemini-cli-oauth': 'gemini-2.5-flash', + 'gemini-antigravity': 'gemini-2.5-flash', 'openai-custom': 'gpt-3.5-turbo', 'claude-custom': 'claude-3-7-sonnet-20250219', - 'kiro-api': 'claude-3-7-sonnet-20250219', - 'qwen-api': 'qwen3-coder-flash', - 'openai-custom-responses': 'gpt-5-low' + 'claude-kiro-oauth': 'claude-haiku-4-5', + 'openai-qwen-oauth': 'qwen3-coder-flash', + 'openaiResponses-custom': 'gpt-4o-mini' }; constructor(providerPools, options = {}) { @@ -81,6 +84,11 @@ export class ProviderPoolManager { providerConfig.lastErrorTime = providerConfig.lastErrorTime instanceof Date ? providerConfig.lastErrorTime.toISOString() : (providerConfig.lastErrorTime || null); + + // 健康检测相关字段 + providerConfig.lastHealthCheckTime = providerConfig.lastHealthCheckTime || null; + providerConfig.lastHealthCheckModel = providerConfig.lastHealthCheckModel || null; + providerConfig.lastErrorMessage = providerConfig.lastErrorMessage || null; this.providerStatus[providerType].push({ config: providerConfig, @@ -165,8 +173,9 @@ export class ProviderPoolManager { * Marks a provider as unhealthy (e.g., after an API error). * @param {string} providerType - The type of the provider. * @param {object} providerConfig - The configuration of the provider to mark. + * @param {string} [errorMessage] - Optional error message to store. */ - markProviderUnhealthy(providerType, providerConfig) { + markProviderUnhealthy(providerType, providerConfig, errorMessage = null) { if (!providerConfig?.uuid) { this._log('error', 'Invalid providerConfig in markProviderUnhealthy'); return; @@ -176,6 +185,11 @@ export class ProviderPoolManager { if (provider) { provider.config.errorCount++; provider.config.lastErrorTime = new Date().toISOString(); + + // 保存错误信息 + if (errorMessage) { + provider.config.lastErrorMessage = errorMessage; + } if (provider.config.errorCount >= this.maxErrorCount) { provider.config.isHealthy = false; @@ -192,9 +206,10 @@ export class ProviderPoolManager { * Marks a provider as healthy. * @param {string} providerType - The type of the provider. * @param {object} providerConfig - The configuration of the provider to mark. - * @param {boolean} isInit - Whether to reset usage count (optional, default: false). + * @param {boolean} resetUsageCount - Whether to reset usage count (optional, default: false). + * @param {string} [healthCheckModel] - Optional model name used for health check. */ - markProviderHealthy(providerType, providerConfig, resetUsageCount = false) { + markProviderHealthy(providerType, providerConfig, resetUsageCount = false, healthCheckModel = null) { if (!providerConfig?.uuid) { this._log('error', 'Invalid providerConfig in markProviderHealthy'); return; @@ -205,6 +220,14 @@ export class ProviderPoolManager { provider.config.isHealthy = true; provider.config.errorCount = 0; provider.config.lastErrorTime = null; + provider.config.lastErrorMessage = null; + + // 更新健康检测信息 + provider.config.lastHealthCheckTime = new Date().toISOString(); + if (healthCheckModel) { + provider.config.lastHealthCheckModel = healthCheckModel; + } + // 只有在明确要求重置使用计数时才重置 if (resetUsageCount) { provider.config.usageCount = 0; @@ -295,122 +318,165 @@ export class ProviderPoolManager { try { // Perform actual health check based on provider type - const isHealthy = await this._checkProviderHealth(providerType, providerConfig); + const healthResult = await this._checkProviderHealth(providerType, providerConfig); - if (isHealthy === null) { + if (healthResult === null) { this._log('debug', `Health check for ${providerConfig.uuid} (${providerType}) skipped: Check not implemented.`); this.resetProviderCounters(providerType, providerConfig); continue; } - if (isHealthy) { + if (healthResult.success) { if (!providerStatus.config.isHealthy) { // Provider was unhealthy but is now healthy // 恢复健康时不重置使用计数,保持原有值 - this.markProviderHealthy(providerType, providerConfig, true); + this.markProviderHealthy(providerType, providerConfig, true, healthResult.modelName); this._log('info', `Health check for ${providerConfig.uuid} (${providerType}): Marked Healthy (actual check)`); } else { // Provider was already healthy and still is // 只在初始化时重置使用计数 - this.markProviderHealthy(providerType, providerConfig, true); + this.markProviderHealthy(providerType, providerConfig, true, healthResult.modelName); this._log('debug', `Health check for ${providerConfig.uuid} (${providerType}): Still Healthy`); } } else { // Provider is not healthy - this._log('warn', `Health check for ${providerConfig.uuid} (${providerType}) failed: Provider is not responding correctly.`); - this.markProviderUnhealthy(providerType, providerConfig); + this._log('warn', `Health check for ${providerConfig.uuid} (${providerType}) failed: ${healthResult.errorMessage || 'Provider is not responding correctly.'}`); + this.markProviderUnhealthy(providerType, providerConfig, healthResult.errorMessage); + + // 更新健康检测时间和模型(即使失败也记录) + providerStatus.config.lastHealthCheckTime = new Date().toISOString(); + if (healthResult.modelName) { + providerStatus.config.lastHealthCheckModel = healthResult.modelName; + } } } catch (error) { this._log('error', `Health check for ${providerConfig.uuid} (${providerType}) failed: ${error.message}`); // If a health check fails, mark it unhealthy, which will update error count and lastErrorTime - this.markProviderUnhealthy(providerType, providerConfig); + this.markProviderUnhealthy(providerType, providerConfig, error.message); } } } } /** - * 构建健康检查请求 + * 构建健康检查请求(返回多种格式用于重试) * @private + * @returns {Array} 请求格式数组,按优先级排序 */ - _buildHealthCheckRequest(providerType, modelName) { - const baseMessage = { role: 'user', content: 'Hello, are you ok?' }; + _buildHealthCheckRequests(providerType, modelName) { + const baseMessage = { role: 'user', content: 'Hi' }; + const requests = []; - // Gemini 使用不同的请求格式 + // Gemini 使用 contents 格式 if (providerType.startsWith('gemini')) { - return { + requests.push({ contents: [{ role: 'user', parts: [{ text: baseMessage.content }] }] - }; + }); + return requests; + } + + // Kiro OAuth 同时支持 messages 和 contents 格式 + if (providerType.startsWith('claude-kiro')) { + // 优先使用 messages 格式 + requests.push({ + messages: [baseMessage], + model: modelName, + max_tokens: 1 + }); + // 备用 contents 格式 + requests.push({ + contents: [{ + role: 'user', + parts: [{ text: baseMessage.content }] + }], + max_tokens: 1 + }); + return requests; } // OpenAI Custom Responses 使用特殊格式 if (providerType === MODEL_PROVIDER.OPENAI_CUSTOM_RESPONSES) { - return { + requests.push({ input: [baseMessage], model: modelName - }; + }); + return requests; } - // 其他提供商(OpenAI、Claude、Kiro、Qwen)使用标准格式 - return { + // 其他提供商(OpenAI、Claude、Qwen)使用标准 messages 格式 + requests.push({ messages: [baseMessage], model: modelName - }; + }); + + return requests; } /** * Performs an actual health check for a specific provider. * @param {string} providerType - The type of the provider. * @param {object} providerConfig - The configuration of the provider to check. - * @returns {Promise} - True if healthy, false if unhealthy, null if check not implemented. + * @param {boolean} forceCheck - If true, ignore checkHealth config and force the check. + * @returns {Promise<{success: boolean, modelName: string, errorMessage: string}|null>} - Health check result object or null if check not implemented. */ - async _checkProviderHealth(providerType, providerConfig) { - try { - // 如果未启用健康检查,返回 null - if (!providerConfig.checkHealth) { - return null; - } - - // 合并全局配置和 provider 配置(简化代理配置) - const proxyKeys = ['GEMINI', 'OPENAI', 'CLAUDE', 'QWEN', 'KIRO']; - const tempConfig = { - ...providerConfig, - MODEL_PROVIDER: providerType - }; - - // 动态添加代理配置 - proxyKeys.forEach(key => { - const proxyKey = `USE_SYSTEM_PROXY_${key}`; - if (this.globalConfig[proxyKey] !== undefined) { - tempConfig[proxyKey] = this.globalConfig[proxyKey]; - } - }); - - const serviceAdapter = getServiceAdapter(tempConfig); - - // 确定健康检查使用的模型名称 - const modelName = providerConfig.checkModelName || - ProviderPoolManager.DEFAULT_HEALTH_CHECK_MODELS[providerType]; - - if (!modelName) { - this._log('warn', `Unknown provider type for health check: ${providerType}`); - return false; - } - - // 构建健康检查请求 - const healthCheckRequest = this._buildHealthCheckRequest(providerType, modelName); - - this._log('debug', `Health check request for ${modelName}: ${JSON.stringify(healthCheckRequest)}`); - await serviceAdapter.generateContent(modelName, healthCheckRequest); - return true; - } catch (error) { - this._log('error', `Health check failed for ${providerType}: ${error.message}`); - return false; + async _checkProviderHealth(providerType, providerConfig, forceCheck = false) { + // 确定健康检查使用的模型名称 + const modelName = providerConfig.checkModelName || + ProviderPoolManager.DEFAULT_HEALTH_CHECK_MODELS[providerType]; + + // 如果未启用健康检查且不是强制检查,返回 null + if (!providerConfig.checkHealth && !forceCheck) { + return null; } + + if (!modelName) { + this._log('warn', `Unknown provider type for health check: ${providerType}`); + return { success: false, modelName: null, errorMessage: 'Unknown provider type for health check' }; + } + + // 使用内部服务适配器方式进行健康检查 + const proxyKeys = ['GEMINI', 'OPENAI', 'CLAUDE', 'QWEN', 'KIRO']; + const tempConfig = { + ...providerConfig, + MODEL_PROVIDER: providerType + }; + + proxyKeys.forEach(key => { + const proxyKey = `USE_SYSTEM_PROXY_${key}`; + if (this.globalConfig[proxyKey] !== undefined) { + tempConfig[proxyKey] = this.globalConfig[proxyKey]; + } + }); + + const serviceAdapter = getServiceAdapter(tempConfig); + + // 获取所有可能的请求格式 + const healthCheckRequests = this._buildHealthCheckRequests(providerType, modelName); + + // 重试机制:尝试不同的请求格式 + const maxRetries = healthCheckRequests.length; + let lastError = null; + + for (let i = 0; i < maxRetries; i++) { + const healthCheckRequest = healthCheckRequests[i]; + try { + this._log('debug', `Health check attempt ${i + 1}/${maxRetries} for ${modelName}: ${JSON.stringify(healthCheckRequest)}`); + await serviceAdapter.generateContent(modelName, healthCheckRequest); + return { success: true, modelName, errorMessage: null }; + } catch (error) { + lastError = error; + this._log('debug', `Health check attempt ${i + 1} failed for ${providerType}: ${error.message}`); + // 继续尝试下一个格式 + } + } + + // 所有尝试都失败 + this._log('error', `Health check failed for ${providerType} after ${maxRetries} attempts: ${lastError?.message}`); + return { success: false, modelName, errorMessage: lastError?.message || 'All health check attempts failed' }; } /** @@ -472,6 +538,9 @@ export class ProviderPoolManager { if (config.lastErrorTime instanceof Date) { config.lastErrorTime = config.lastErrorTime.toISOString(); } + if (config.lastHealthCheckTime instanceof Date) { + config.lastHealthCheckTime = config.lastHealthCheckTime.toISOString(); + } return config; }); } else { diff --git a/src/provider-utils.js b/src/provider-utils.js new file mode 100644 index 0000000..380c207 --- /dev/null +++ b/src/provider-utils.js @@ -0,0 +1,342 @@ +/** + * 提供商工具模块 + * 包含 ui-manager.js 和 service-manager.js 共用的工具函数 + */ + +import * as path from 'path'; +import { promises as fs } from 'fs'; + +/** + * 提供商目录映射配置 + * 定义目录名称到提供商类型的映射关系 + */ +export const PROVIDER_MAPPINGS = [ + { + // Kiro OAuth 配置 + dirName: 'kiro', + patterns: ['configs/kiro/', '/kiro/'], + providerType: 'claude-kiro-oauth', + credPathKey: 'KIRO_OAUTH_CREDS_FILE_PATH', + defaultCheckModel: 'claude-haiku-4-5', + displayName: 'Claude Kiro OAuth', + needsProjectId: false + }, + { + // Gemini CLI OAuth 配置 + dirName: 'gemini', + patterns: ['configs/gemini/', '/gemini/', 'configs/gemini-cli/'], + providerType: 'gemini-cli-oauth', + credPathKey: 'GEMINI_OAUTH_CREDS_FILE_PATH', + defaultCheckModel: 'gemini-2.5-flash', + displayName: 'Gemini CLI OAuth', + needsProjectId: true + }, + { + // Qwen OAuth 配置 + dirName: 'qwen', + patterns: ['configs/qwen/', '/qwen/'], + providerType: 'openai-qwen-oauth', + credPathKey: 'QWEN_OAUTH_CREDS_FILE_PATH', + defaultCheckModel: 'qwen3-coder-plus', + displayName: 'Qwen OAuth', + needsProjectId: false + }, + { + // Antigravity OAuth 配置 + dirName: 'antigravity', + patterns: ['configs/antigravity/', '/antigravity/'], + providerType: 'gemini-antigravity', + credPathKey: 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH', + defaultCheckModel: 'gemini-2.5-computer-use-preview-10-2025', + displayName: 'Gemini Antigravity', + needsProjectId: true + } +]; + +/** + * 生成 UUID + * @returns {string} UUID 字符串 + */ +export function generateUUID() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + const r = Math.random() * 16 | 0; + const v = c === 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); +} + +/** + * 标准化路径,用于跨平台兼容 + * @param {string} filePath - 文件路径 + * @returns {string} 使用正斜杠的标准化路径 + */ +export function normalizePath(filePath) { + if (!filePath) return filePath; + + // 使用 path 模块标准化,然后转换为正斜杠 + const normalized = path.normalize(filePath); + return normalized.replace(/\\/g, '/'); +} + +/** + * 从路径中提取文件名 + * @param {string} filePath - 文件路径 + * @returns {string} 文件名 + */ +export function getFileName(filePath) { + return path.basename(filePath); +} + +/** + * 格式化相对路径为当前系统的路径格式 + * @param {string} relativePath - 相对路径 + * @returns {string} 格式化后的路径(带有 ./ 或 .\ 前缀) + */ +export function formatSystemPath(relativePath) { + if (!relativePath) return relativePath; + + // 根据操作系统判断使用对应的路径分隔符 + const isWindows = process.platform === 'win32'; + const separator = isWindows ? '\\' : '/'; + // 统一转换路径分隔符为当前系统的分隔符 + const systemPath = relativePath.replace(/[\/\\]/g, separator); + return systemPath.startsWith('.' + separator) ? systemPath : '.' + separator + systemPath; +} + +/** + * 检查两个路径是否指向同一文件(跨平台兼容) + * @param {string} path1 - 第一个路径 + * @param {string} path2 - 第二个路径 + * @returns {boolean} 如果路径指向同一文件则返回 true + */ +export function pathsEqual(path1, path2) { + if (!path1 || !path2) return false; + + try { + // 标准化两个路径 + const normalized1 = normalizePath(path1); + const normalized2 = normalizePath(path2); + + // 直接匹配 + if (normalized1 === normalized2) { + return true; + } + + // 移除开头的 './' 后比较 + const clean1 = normalized1.replace(/^\.\//, ''); + const clean2 = normalized2.replace(/^\.\//, ''); + + if (clean1 === clean2) { + return true; + } + + // 检查一个是否是另一个的子集(用于相对路径与绝对路径比较) + if (normalized1.endsWith('/' + clean2) || normalized2.endsWith('/' + clean1)) { + return true; + } + + return false; + } catch (error) { + console.warn(`[Path Comparison] Error comparing paths: ${path1} vs ${path2}`, error.message); + return false; + } +} + +/** + * 检查文件路径是否正在被使用(跨平台兼容) + * @param {string} relativePath - 相对路径 + * @param {string} fileName - 文件名 + * @param {Set} usedPaths - 已使用路径的集合 + * @returns {boolean} 如果文件正在被使用则返回 true + */ +export function isPathUsed(relativePath, fileName, usedPaths) { + if (!relativePath) return false; + + // 标准化相对路径 + const normalizedRelativePath = normalizePath(relativePath); + const cleanRelativePath = normalizedRelativePath.replace(/^\.\//, ''); + + // 从相对路径获取文件名 + const relativeFileName = getFileName(normalizedRelativePath); + + // 遍历所有已使用路径进行匹配 + for (const usedPath of usedPaths) { + if (!usedPath) continue; + + // 1. 直接路径匹配 + if (pathsEqual(relativePath, usedPath) || pathsEqual(relativePath, './' + usedPath)) { + return true; + } + + // 2. 标准化路径匹配 + if (pathsEqual(normalizedRelativePath, usedPath) || + pathsEqual(normalizedRelativePath, './' + usedPath)) { + return true; + } + + // 3. 清理后的路径匹配 + if (pathsEqual(cleanRelativePath, usedPath) || + pathsEqual(cleanRelativePath, './' + usedPath)) { + return true; + } + + // 4. 文件名匹配(确保不是误匹配) + const usedFileName = getFileName(usedPath); + if (usedFileName === fileName || usedFileName === relativeFileName) { + // 确保是同一个目录下的文件 + const usedDir = path.dirname(usedPath); + const relativeDir = path.dirname(normalizedRelativePath); + + if (pathsEqual(usedDir, relativeDir) || + pathsEqual(usedDir, cleanRelativePath.replace(/\/[^\/]+$/, '')) || + pathsEqual(relativeDir.replace(/^\.\//, ''), usedDir.replace(/^\.\//, ''))) { + return true; + } + } + + // 5. 绝对路径匹配(Windows 和 Unix) + try { + const resolvedUsedPath = path.resolve(usedPath); + const resolvedRelativePath = path.resolve(relativePath); + + if (resolvedUsedPath === resolvedRelativePath) { + return true; + } + } catch (error) { + // 忽略路径解析错误 + } + } + + return false; +} + +/** + * 根据文件路径检测提供商类型 + * @param {string} normalizedPath - 标准化的文件路径(小写,正斜杠) + * @returns {Object|null} 提供商映射对象,如果未检测到则返回 null + */ +export function detectProviderFromPath(normalizedPath) { + // 遍历映射关系,查找匹配的提供商 + for (const mapping of PROVIDER_MAPPINGS) { + for (const pattern of mapping.patterns) { + if (normalizedPath.includes(pattern)) { + return { + providerType: mapping.providerType, + credPathKey: mapping.credPathKey, + defaultCheckModel: mapping.defaultCheckModel, + displayName: mapping.displayName, + needsProjectId: mapping.needsProjectId + }; + } + } + } + + return null; +} + +/** + * 根据目录名获取提供商映射 + * @param {string} dirName - 目录名称 + * @returns {Object|null} 提供商映射对象,如果未找到则返回 null + */ +export function getProviderMappingByDirName(dirName) { + return PROVIDER_MAPPINGS.find(m => m.dirName === dirName) || null; +} + +/** + * 验证文件是否是有效的 OAuth 凭据文件 + * @param {string} filePath - 文件路径 + * @returns {Promise} 是否有效 + */ +export async function isValidOAuthCredentials(filePath) { + try { + const content = await fs.readFile(filePath, 'utf8'); + const jsonData = JSON.parse(content); + + // 检查是否包含 OAuth 相关字段 + // 凭据通常包含 access_token/accessToken, refresh_token/refreshToken, client_id 等字段 + // 支持下划线命名(access_token)和驼峰命名(accessToken)两种格式 + if (jsonData.access_token || jsonData.refresh_token || + jsonData.accessToken || jsonData.refreshToken || + jsonData.client_id || jsonData.client_secret || + jsonData.token || jsonData.credentials) { + return true; + } + + // 也可能是包含嵌套结构的凭据文件 + if (jsonData.installed || jsonData.web) { + return true; + } + + return false; + } catch (error) { + // 如果无法解析,认为不是有效的凭据文件 + return false; + } +} + +/** + * 创建新的提供商配置对象 + * @param {Object} options - 配置选项 + * @param {string} options.credPathKey - 凭据路径键名 + * @param {string} options.credPath - 凭据文件路径 + * @param {string} options.defaultCheckModel - 默认检测模型 + * @param {boolean} options.needsProjectId - 是否需要 PROJECT_ID + * @returns {Object} 新的提供商配置对象 + */ +export function createProviderConfig(options) { + const { credPathKey, credPath, defaultCheckModel, needsProjectId } = options; + + const newProvider = { + [credPathKey]: credPath, + uuid: generateUUID(), + checkModelName: defaultCheckModel, + checkHealth: true, + isHealthy: true, + isDisabled: false, + lastUsed: null, + usageCount: 0, + errorCount: 0, + lastErrorTime: null, + lastHealthCheckTime: null, + lastHealthCheckModel: null, + lastErrorMessage: null + }; + + // 如果需要 PROJECT_ID,添加空字符串占位 + if (needsProjectId) { + newProvider.PROJECT_ID = ''; + } + + return newProvider; +} + +/** + * 将路径添加到已使用路径集合(标准化多种格式) + * @param {Set} usedPaths - 已使用路径的集合 + * @param {string} filePath - 要添加的文件路径 + */ +export function addToUsedPaths(usedPaths, filePath) { + if (!filePath) return; + + const normalizedPath = filePath.replace(/\\/g, '/'); + usedPaths.add(filePath); + usedPaths.add(normalizedPath); + if (normalizedPath.startsWith('./')) { + usedPaths.add(normalizedPath.slice(2)); + } else { + usedPaths.add('./' + normalizedPath); + } +} + +/** + * 检查路径是否已关联(用于自动关联检测) + * @param {string} relativePath - 相对路径 + * @param {Set} linkedPaths - 已关联路径的集合 + * @returns {boolean} 是否已关联 + */ +export function isPathLinked(relativePath, linkedPaths) { + return linkedPaths.has(relativePath) || + linkedPaths.has('./' + relativePath) || + linkedPaths.has(relativePath.replace(/^\.\//, '')); +} \ No newline at end of file diff --git a/src/service-manager.js b/src/service-manager.js index 123cefc..9677cd6 100644 --- a/src/service-manager.js +++ b/src/service-manager.js @@ -1,16 +1,168 @@ import { getServiceAdapter, serviceInstances } from './adapter.js'; import { ProviderPoolManager } from './provider-pool-manager.js'; import deepmerge from 'deepmerge'; +import * as fs from 'fs'; +import { promises as pfs } from 'fs'; +import * as path from 'path'; +import { + PROVIDER_MAPPINGS, + createProviderConfig, + addToUsedPaths, + isPathUsed, + getFileName, + formatSystemPath +} from './provider-utils.js'; // 存储 ProviderPoolManager 实例 let providerPoolManager = null; +/** + * 扫描 configs 目录并自动关联未关联的配置文件到对应的提供商 + * @param {Object} config - 服务器配置对象 + * @returns {Promise} 更新后的 providerPools 对象 + */ +async function autoLinkProviderConfigs(config) { + // 确保 providerPools 对象存在 + if (!config.providerPools) { + config.providerPools = {}; + } + + let totalNewProviders = 0; + const allNewProviders = {}; + + // 遍历所有提供商映射 + for (const mapping of PROVIDER_MAPPINGS) { + const configsPath = path.join(process.cwd(), 'configs', mapping.dirName); + const { providerType, credPathKey, defaultCheckModel, displayName, needsProjectId } = mapping; + + // 确保提供商类型数组存在 + if (!config.providerPools[providerType]) { + config.providerPools[providerType] = []; + } + + // 检查目录是否存在 + if (!fs.existsSync(configsPath)) { + continue; + } + + // 获取已关联的配置文件路径集合 + const linkedPaths = new Set(); + for (const provider of config.providerPools[providerType]) { + if (provider[credPathKey]) { + // 使用公共方法添加路径的所有变体格式 + addToUsedPaths(linkedPaths, provider[credPathKey]); + } + } + + // 递归扫描目录 + const newProviders = []; + await scanProviderDirectory(configsPath, linkedPaths, newProviders, { + credPathKey, + defaultCheckModel, + needsProjectId + }); + + // 如果有新的配置文件需要关联 + if (newProviders.length > 0) { + config.providerPools[providerType].push(...newProviders); + totalNewProviders += newProviders.length; + allNewProviders[displayName] = newProviders; + } + } + + // 如果有新的配置文件需要关联,保存更新后的 provider_pools.json + if (totalNewProviders > 0) { + const filePath = config.PROVIDER_POOLS_FILE_PATH || 'provider_pools.json'; + try { + await pfs.writeFile(filePath, JSON.stringify(config.providerPools, null, 2), 'utf8'); + console.log(`[Auto-Link] Added ${totalNewProviders} new config(s) to provider pools:`); + for (const [displayName, providers] of Object.entries(allNewProviders)) { + console.log(` ${displayName}: ${providers.length} config(s)`); + providers.forEach(p => { + // 获取凭据路径键 + const credKey = Object.keys(p).find(k => k.endsWith('_CREDS_FILE_PATH')); + if (credKey) { + console.log(` - ${p[credKey]}`); + } + }); + } + } catch (error) { + console.error(`[Auto-Link] Failed to save provider_pools.json: ${error.message}`); + } + } else { + console.log('[Auto-Link] No new configs to link'); + } + + return config.providerPools; +} + +/** + * 递归扫描提供商配置目录 + * @param {string} dirPath - 目录路径 + * @param {Set} linkedPaths - 已关联的路径集合 + * @param {Array} newProviders - 新提供商配置数组 + * @param {Object} options - 配置选项 + * @param {string} options.credPathKey - 凭据路径键名 + * @param {string} options.defaultCheckModel - 默认检测模型 + * @param {boolean} options.needsProjectId - 是否需要 PROJECT_ID + */ +async function scanProviderDirectory(dirPath, linkedPaths, newProviders, options) { + const { credPathKey, defaultCheckModel, needsProjectId } = options; + + try { + const files = await pfs.readdir(dirPath, { withFileTypes: true }); + + for (const file of files) { + const fullPath = path.join(dirPath, file.name); + + if (file.isFile()) { + const ext = path.extname(file.name).toLowerCase(); + // 只处理 JSON 文件 + if (ext === '.json') { + const relativePath = path.relative(process.cwd(), fullPath); + const fileName = getFileName(fullPath); + + // 使用与 ui-manager.js 相同的 isPathUsed 函数检查是否已关联 + const isLinked = isPathUsed(relativePath, fileName, linkedPaths); + + if (!isLinked) { + // 使用公共方法创建新的提供商配置 + const newProvider = createProviderConfig({ + credPathKey, + credPath: formatSystemPath(relativePath), + defaultCheckModel, + needsProjectId + }); + + newProviders.push(newProvider); + } + } + } else if (file.isDirectory()) { + // 递归扫描子目录(限制深度为 3 层) + const relativePath = path.relative(process.cwd(), fullPath); + const depth = relativePath.split(path.sep).length; + if (depth < 5) { // configs/{provider}/subfolder/subsubfolder + await scanProviderDirectory(fullPath, linkedPaths, newProviders, options); + } + } + } + } catch (error) { + console.warn(`[Auto-Link] Failed to scan directory ${dirPath}: ${error.message}`); + } +} + +// 注意:isValidOAuthCredentials 已移至 provider-utils.js 公共模块 + /** * Initialize API services and provider pool manager * @param {Object} config - The server configuration * @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, { globalConfig: config, diff --git a/src/ui-manager.js b/src/ui-manager.js index e680593..f1213fb 100644 --- a/src/ui-manager.js +++ b/src/ui-manager.js @@ -9,6 +9,18 @@ import { CONFIG } from './config-manager.js'; import { serviceInstances } from './adapter.js'; import { initApiService } from './service-manager.js'; import { handleGeminiCliOAuth, handleGeminiAntigravityOAuth, handleQwenOAuth } from './oauth-handlers.js'; +import { + generateUUID, + normalizePath, + getFileName, + pathsEqual, + isPathUsed, + detectProviderFromPath, + isValidOAuthCredentials, + createProviderConfig, + addToUsedPaths, + formatSystemPath +} from './provider-utils.js'; // Token存储到本地文件中 const TOKEN_STORE_FILE = 'token-store.json'; @@ -706,11 +718,7 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo // Generate UUID if not provided if (!providerConfig.uuid) { - providerConfig.uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { - const r = Math.random() * 16 | 0; - const v = c == 'x' ? r : (r & 0x3 | 0x8); - return v.toString(16); - }); + providerConfig.uuid = generateUUID(); } // Set default values @@ -1091,6 +1099,118 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo } } + // Perform health check for all providers of a specific type + const healthCheckMatch = pathParam.match(/^\/api\/providers\/([^\/]+)\/health-check$/); + if (method === 'POST' && healthCheckMatch) { + const providerType = decodeURIComponent(healthCheckMatch[1]); + + try { + if (!providerPoolManager) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Provider pool manager not initialized' } })); + return true; + } + + const providers = providerPoolManager.providerStatus[providerType] || []; + + if (providers.length === 0) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'No providers found for this type' } })); + return true; + } + + console.log(`[UI API] Starting health check for ${providers.length} providers in ${providerType}`); + + // 执行健康检测(强制检查,忽略 checkHealth 配置) + const results = []; + for (const providerStatus of providers) { + const providerConfig = providerStatus.config; + try { + // 传递 forceCheck = true 强制执行健康检查,忽略 checkHealth 配置 + const healthResult = await providerPoolManager._checkProviderHealth(providerType, providerConfig, true); + + if (healthResult === null) { + results.push({ + uuid: providerConfig.uuid, + success: null, + message: '健康检测不支持此提供商类型' + }); + continue; + } + + if (healthResult.success) { + providerPoolManager.markProviderHealthy(providerType, providerConfig, false, healthResult.modelName); + results.push({ + uuid: providerConfig.uuid, + success: true, + modelName: healthResult.modelName, + message: '健康' + }); + } else { + providerPoolManager.markProviderUnhealthy(providerType, providerConfig, healthResult.errorMessage); + providerStatus.config.lastHealthCheckTime = new Date().toISOString(); + if (healthResult.modelName) { + providerStatus.config.lastHealthCheckModel = healthResult.modelName; + } + results.push({ + uuid: providerConfig.uuid, + success: false, + modelName: healthResult.modelName, + message: healthResult.errorMessage || '检测失败' + }); + } + } catch (error) { + providerPoolManager.markProviderUnhealthy(providerType, providerConfig, error.message); + results.push({ + uuid: providerConfig.uuid, + success: false, + message: error.message + }); + } + } + + // 保存更新后的状态到文件 + const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'provider_pools.json'; + + // 从 providerStatus 构建 providerPools 对象并保存 + const providerPools = {}; + for (const pType in providerPoolManager.providerStatus) { + providerPools[pType] = providerPoolManager.providerStatus[pType].map(ps => ps.config); + } + writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf8'); + + const successCount = results.filter(r => r.success === true).length; + const failCount = results.filter(r => r.success === false).length; + + console.log(`[UI API] Health check completed for ${providerType}: ${successCount} healthy, ${failCount} unhealthy`); + + // 广播更新事件 + broadcastEvent('config_update', { + action: 'health_check', + filePath: filePath, + providerType, + results, + timestamp: new Date().toISOString() + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + message: `健康检测完成: ${successCount} 个健康, ${failCount} 个异常`, + successCount, + failCount, + totalCount: providers.length, + results + })); + return true; + } catch (error) { + console.error('[UI API] Health check error:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: error.message } })); + return true; + } + } + // Generate OAuth authorization URL for providers const generateAuthUrlMatch = pathParam.match(/^\/api\/providers\/([^\/]+)\/generate-auth-url$/); if (method === 'POST' && generateAuthUrlMatch) { @@ -1309,6 +1429,125 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo } } + // Quick link config to corresponding provider based on directory + if (method === 'POST' && pathParam === '/api/quick-link-provider') { + try { + const body = await getRequestBody(req); + const { filePath } = body; + + if (!filePath) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'filePath is required' } })); + return true; + } + + const normalizedPath = filePath.replace(/\\/g, '/').toLowerCase(); + + // 根据文件路径自动识别提供商类型 + const providerMapping = detectProviderFromPath(normalizedPath); + + if (!providerMapping) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: '无法识别配置文件对应的提供商类型,请确保文件位于 configs/kiro/、configs/gemini/、configs/qwen/ 或 configs/antigravity/ 目录下' + } + })); + return true; + } + + const { providerType, credPathKey, defaultCheckModel, displayName } = providerMapping; + const poolsFilePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'provider_pools.json'; + + // Load existing pools + let providerPools = {}; + if (existsSync(poolsFilePath)) { + try { + const fileContent = readFileSync(poolsFilePath, 'utf8'); + providerPools = JSON.parse(fileContent); + } catch (readError) { + console.warn('[UI API] Failed to read existing provider pools:', readError.message); + } + } + + // Ensure provider type array exists + if (!providerPools[providerType]) { + providerPools[providerType] = []; + } + + // Check if already linked - 使用标准化路径进行比较 + const normalizedForComparison = filePath.replace(/\\/g, '/'); + const isAlreadyLinked = providerPools[providerType].some(p => { + const existingPath = p[credPathKey]; + if (!existingPath) return false; + const normalizedExistingPath = existingPath.replace(/\\/g, '/'); + return normalizedExistingPath === normalizedForComparison || + normalizedExistingPath === './' + normalizedForComparison || + './' + normalizedExistingPath === normalizedForComparison; + }); + + if (isAlreadyLinked) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: '该配置文件已关联' } })); + return true; + } + + // Create new provider config based on provider type + const newProvider = createProviderConfig({ + credPathKey, + credPath: formatSystemPath(filePath), + defaultCheckModel, + needsProjectId: providerMapping.needsProjectId + }); + + providerPools[providerType].push(newProvider); + + // Save to file + writeFileSync(poolsFilePath, JSON.stringify(providerPools, null, 2), 'utf8'); + console.log(`[UI API] Quick linked config: ${filePath} -> ${providerType}`); + + // Update provider pool manager if available + if (providerPoolManager) { + providerPoolManager.providerPools = providerPools; + providerPoolManager.initializeProviderStatus(); + } + + // Broadcast update event + broadcastEvent('config_update', { + action: 'quick_link', + filePath: poolsFilePath, + providerType, + newProvider, + timestamp: new Date().toISOString() + }); + + broadcastEvent('provider_update', { + action: 'add', + providerType, + providerConfig: newProvider, + timestamp: new Date().toISOString() + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + message: `配置已成功关联到 ${displayName}`, + provider: newProvider, + providerType: providerType + })); + return true; + } catch (error) { + console.error('[UI API] Quick link failed:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: '关联失败: ' + error.message + } + })); + return true; + } + } + // Reload configuration files if (method === 'POST' && pathParam === '/api/reload-config') { try { @@ -1431,30 +1670,9 @@ async function scanConfigFiles(currentConfig, providerPoolManager) { const usedPaths = new Set(); // 存储已使用的路径,用于判断关联状态 // 从配置中提取所有OAuth凭据文件路径 - 标准化路径格式 - if (currentConfig.GEMINI_OAUTH_CREDS_FILE_PATH) { - const normalizedPath = currentConfig.GEMINI_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/'); - usedPaths.add(currentConfig.GEMINI_OAUTH_CREDS_FILE_PATH); - usedPaths.add(normalizedPath); - if (normalizedPath.startsWith('./')) { - usedPaths.add(normalizedPath.slice(2)); - } - } - if (currentConfig.KIRO_OAUTH_CREDS_FILE_PATH) { - const normalizedPath = currentConfig.KIRO_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/'); - usedPaths.add(currentConfig.KIRO_OAUTH_CREDS_FILE_PATH); - usedPaths.add(normalizedPath); - if (normalizedPath.startsWith('./')) { - usedPaths.add(normalizedPath.slice(2)); - } - } - if (currentConfig.QWEN_OAUTH_CREDS_FILE_PATH) { - const normalizedPath = currentConfig.QWEN_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/'); - usedPaths.add(currentConfig.QWEN_OAUTH_CREDS_FILE_PATH); - usedPaths.add(normalizedPath); - if (normalizedPath.startsWith('./')) { - usedPaths.add(normalizedPath.slice(2)); - } - } + addToUsedPaths(usedPaths, currentConfig.GEMINI_OAUTH_CREDS_FILE_PATH); + addToUsedPaths(usedPaths, currentConfig.KIRO_OAUTH_CREDS_FILE_PATH); + addToUsedPaths(usedPaths, currentConfig.QWEN_OAUTH_CREDS_FILE_PATH); // 使用最新的提供商池数据 let providerPools = currentConfig.providerPools; @@ -1466,30 +1684,10 @@ async function scanConfigFiles(currentConfig, providerPoolManager) { if (providerPools) { for (const [providerType, providers] of Object.entries(providerPools)) { for (const provider of providers) { - if (provider.GEMINI_OAUTH_CREDS_FILE_PATH) { - const normalizedPath = provider.GEMINI_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/'); - usedPaths.add(provider.GEMINI_OAUTH_CREDS_FILE_PATH); - usedPaths.add(normalizedPath); - if (normalizedPath.startsWith('./')) { - usedPaths.add(normalizedPath.slice(2)); - } - } - if (provider.KIRO_OAUTH_CREDS_FILE_PATH) { - const normalizedPath = provider.KIRO_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/'); - usedPaths.add(provider.KIRO_OAUTH_CREDS_FILE_PATH); - usedPaths.add(normalizedPath); - if (normalizedPath.startsWith('./')) { - usedPaths.add(normalizedPath.slice(2)); - } - } - if (provider.QWEN_OAUTH_CREDS_FILE_PATH) { - const normalizedPath = provider.QWEN_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/'); - usedPaths.add(provider.QWEN_OAUTH_CREDS_FILE_PATH); - usedPaths.add(normalizedPath); - if (normalizedPath.startsWith('./')) { - usedPaths.add(normalizedPath.slice(2)); - } - } + addToUsedPaths(usedPaths, provider.GEMINI_OAUTH_CREDS_FILE_PATH); + addToUsedPaths(usedPaths, provider.KIRO_OAUTH_CREDS_FILE_PATH); + addToUsedPaths(usedPaths, provider.QWEN_OAUTH_CREDS_FILE_PATH); + addToUsedPaths(usedPaths, provider.ANTIGRAVITY_OAUTH_CREDS_FILE_PATH); } } } @@ -1695,6 +1893,18 @@ function getFileUsageInfo(relativePath, fileName, usedPaths, currentConfig) { configKey: 'QWEN_OAUTH_CREDS_FILE_PATH' }); } + + if (provider.ANTIGRAVITY_OAUTH_CREDS_FILE_PATH && + (pathsEqual(relativePath, provider.ANTIGRAVITY_OAUTH_CREDS_FILE_PATH) || + pathsEqual(relativePath, provider.ANTIGRAVITY_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/')))) { + providerUsages.push({ + type: '提供商池', + location: `Antigravity OAuth凭据 (节点${index + 1})`, + providerType: providerType, + providerIndex: index, + configKey: 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH' + }); + } if (providerUsages.length > 0) { usageInfo.usageType = 'provider_pool'; @@ -1754,131 +1964,5 @@ async function scanOAuthDirectory(dirPath, usedPaths, currentConfig) { } -/** - * Normalize a path for cross-platform compatibility - * @param {string} filePath - The file path to normalize - * @returns {string} Normalized path using forward slashes - */ -function normalizePath(filePath) { - if (!filePath) return filePath; - - // Use path module to normalize and then convert to forward slashes - const normalized = path.normalize(filePath); - return normalized.replace(/\\/g, '/'); -} - -/** - * Extract filename from any path format - * @param {string} filePath - The file path - * @returns {string} Filename - */ -function getFileName(filePath) { - return path.basename(filePath); -} - -/** - * Check if two paths refer to the same file (cross-platform compatible) - * @param {string} path1 - First path - * @param {string} path2 - Second path - * @returns {boolean} True if paths refer to same file - */ -function pathsEqual(path1, path2) { - if (!path1 || !path2) return false; - - try { - // Normalize both paths - const normalized1 = normalizePath(path1); - const normalized2 = normalizePath(path2); - - // Direct match - if (normalized1 === normalized2) { - return true; - } - - // Remove leading './' if present - const clean1 = normalized1.replace(/^\.\//, ''); - const clean2 = normalized2.replace(/^\.\//, ''); - - if (clean1 === clean2) { - return true; - } - - // Check if one is a subset of the other (for relative vs absolute) - if (normalized1.endsWith('/' + clean2) || normalized2.endsWith('/' + clean1)) { - return true; - } - - return false; - } catch (error) { - console.warn(`[Path Comparison] Error comparing paths: ${path1} vs ${path2}`, error.message); - return false; - } -} - -/** - * Check if a file path is being used (cross-platform compatible) - * @param {string} relativePath - Relative path - * @param {string} fileName - File name - * @param {Set} usedPaths - Set of used paths - * @returns {boolean} True if the file is being used - */ -function isPathUsed(relativePath, fileName, usedPaths) { - if (!relativePath) return false; - - // Normalize the relative path - const normalizedRelativePath = normalizePath(relativePath); - const cleanRelativePath = normalizedRelativePath.replace(/^\.\//, ''); - - // Get the filename from relative path - const relativeFileName = getFileName(normalizedRelativePath); - - // 遍历所有已使用路径进行匹配 - for (const usedPath of usedPaths) { - if (!usedPath) continue; - - // 1. 直接路径匹配 - if (pathsEqual(relativePath, usedPath) || pathsEqual(relativePath, './' + usedPath)) { - return true; - } - - // 2. 标准化路径匹配 - if (pathsEqual(normalizedRelativePath, usedPath) || - pathsEqual(normalizedRelativePath, './' + usedPath)) { - return true; - } - - // 3. 清理后的路径匹配 - if (pathsEqual(cleanRelativePath, usedPath) || - pathsEqual(cleanRelativePath, './' + usedPath)) { - return true; - } - - // 4. 文件名匹配(确保不是误匹配) - const usedFileName = getFileName(usedPath); - if (usedFileName === fileName || usedFileName === relativeFileName) { - // 确保是同一个目录下的文件 - const usedDir = path.dirname(usedPath); - const relativeDir = path.dirname(normalizedRelativePath); - - if (pathsEqual(usedDir, relativeDir) || - pathsEqual(usedDir, cleanRelativePath.replace(/\/[^\/]+$/, '')) || - pathsEqual(relativeDir.replace(/^\.\//, ''), usedDir.replace(/^\.\//, ''))) { - return true; - } - } - - // 5. 绝对路径匹配(Windows和Unix) - try { - const resolvedUsedPath = path.resolve(usedPath); - const resolvedRelativePath = path.resolve(relativePath); - - if (resolvedUsedPath === resolvedRelativePath) { - return true; - } - } catch (error) { - // Ignore path resolution errors - } - } - - return false; -} \ No newline at end of file +// 注意:normalizePath, getFileName, pathsEqual, isPathUsed, detectProviderFromPath +// 已移至 provider-utils.js 公共模块 diff --git a/static/app/file-upload.js b/static/app/file-upload.js index 4d1bedb..75d38e7 100644 --- a/static/app/file-upload.js +++ b/static/app/file-upload.js @@ -54,6 +54,7 @@ class FileUploadHandler { getProviderKey(provider) { const providerMap = { 'gemini-cli-oauth': 'gemini', + 'gemini-antigravity': 'antigravity', 'claude-kiro-oauth': 'kiro', 'openai-qwen-oauth': 'qwen' }; diff --git a/static/app/modal.js b/static/app/modal.js index f2a717b..8a054f0 100644 --- a/static/app/modal.js +++ b/static/app/modal.js @@ -3,6 +3,13 @@ import { showToast, getFieldLabel, getProviderTypeFields } from './utils.js'; import { handleProviderPasswordToggle } from './event-handlers.js'; +// 分页配置 +const PROVIDERS_PER_PAGE = 5; +let currentPage = 1; +let currentProviders = []; +let currentProviderType = ''; +let cachedModels = []; // 缓存模型列表 + /** * 显示提供商管理模态框 * @param {Object} data - 提供商数据 @@ -10,6 +17,12 @@ import { handleProviderPasswordToggle } from './event-handlers.js'; function showProviderManagerModal(data) { const { providerType, providers, totalCount, healthyCount } = data; + // 保存当前数据用于分页 + currentProviders = providers; + currentProviderType = providerType; + currentPage = 1; + cachedModels = []; + // 移除已存在的模态框 const existingModal = document.querySelector('.provider-modal'); if (existingModal) { @@ -20,6 +33,8 @@ function showProviderManagerModal(data) { existingModal.remove(); } + const totalPages = Math.ceil(providers.length / PROVIDERS_PER_PAGE); + // 创建模态框 const modal = document.createElement('div'); modal.className = 'provider-modal'; @@ -49,12 +64,19 @@ function showProviderManagerModal(data) { + + ${totalPages > 1 ? renderPagination(1, totalPages, providers.length) : ''} +
- ${renderProviderList(providers)} + ${renderProviderListPaginated(providers, 1)}
+ + ${totalPages > 1 ? renderPagination(1, totalPages, providers.length, 'bottom') : ''} `; @@ -66,20 +88,158 @@ function showProviderManagerModal(data) { addModalEventListeners(modal); // 先获取该提供商类型的模型列表(只调用一次API) - loadModelsForProviderType(providerType, providers); + const pageProviders = providers.slice(0, PROVIDERS_PER_PAGE); + loadModelsForProviderType(providerType, pageProviders); } /** - * 为提供商类型加载模型列表(优化:只调用一次API) + * 渲染分页控件 + * @param {number} currentPage - 当前页码 + * @param {number} totalPages - 总页数 + * @param {number} totalItems - 总条目数 + * @param {string} position - 位置标识 (top/bottom) + * @returns {string} HTML字符串 + */ +function renderPagination(page, totalPages, totalItems, position = 'top') { + const startItem = (page - 1) * PROVIDERS_PER_PAGE + 1; + const endItem = Math.min(page * PROVIDERS_PER_PAGE, totalItems); + + // 生成页码按钮 + let pageButtons = ''; + const maxVisiblePages = 5; + let startPage = Math.max(1, page - Math.floor(maxVisiblePages / 2)); + let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1); + + if (endPage - startPage < maxVisiblePages - 1) { + startPage = Math.max(1, endPage - maxVisiblePages + 1); + } + + if (startPage > 1) { + pageButtons += ``; + if (startPage > 2) { + pageButtons += `...`; + } + } + + for (let i = startPage; i <= endPage; i++) { + pageButtons += ``; + } + + if (endPage < totalPages) { + if (endPage < totalPages - 1) { + pageButtons += `...`; + } + pageButtons += ``; + } + + return ` +
+
+ 显示 ${startItem}-${endItem} / 共 ${totalItems} 条 +
+
+ + ${pageButtons} + +
+
+ 跳转到 + + +
+
+ `; +} + +/** + * 跳转到指定页 + * @param {number} page - 目标页码 + */ +function goToProviderPage(page) { + const totalPages = Math.ceil(currentProviders.length / PROVIDERS_PER_PAGE); + + // 验证页码范围 + if (page < 1) page = 1; + if (page > totalPages) page = totalPages; + + currentPage = page; + + // 更新提供商列表 + const providerList = document.getElementById('providerList'); + if (providerList) { + providerList.innerHTML = renderProviderListPaginated(currentProviders, page); + } + + // 更新分页控件 + const paginationContainers = document.querySelectorAll('.pagination-container'); + paginationContainers.forEach(container => { + const position = container.getAttribute('data-position'); + container.outerHTML = renderPagination(page, totalPages, currentProviders.length, position); + }); + + // 滚动到顶部 + const modalBody = document.querySelector('.provider-modal-body'); + if (modalBody) { + modalBody.scrollTop = 0; + } + + // 为当前页的提供商加载模型列表 + const startIndex = (page - 1) * PROVIDERS_PER_PAGE; + const endIndex = Math.min(startIndex + PROVIDERS_PER_PAGE, currentProviders.length); + const pageProviders = currentProviders.slice(startIndex, endIndex); + + // 如果已缓存模型列表,直接使用 + if (cachedModels.length > 0) { + pageProviders.forEach(provider => { + renderNotSupportedModelsSelector(provider.uuid, cachedModels, provider.notSupportedModels || []); + }); + } else { + loadModelsForProviderType(currentProviderType, pageProviders); + } +} + +/** + * 渲染分页后的提供商列表 + * @param {Array} providers - 提供商数组 + * @param {number} page - 当前页码 + * @returns {string} HTML字符串 + */ +function renderProviderListPaginated(providers, page) { + const startIndex = (page - 1) * PROVIDERS_PER_PAGE; + const endIndex = Math.min(startIndex + PROVIDERS_PER_PAGE, providers.length); + const pageProviders = providers.slice(startIndex, endIndex); + + return renderProviderList(pageProviders); +} + +/** + * 为提供商类型加载模型列表(优化:只调用一次API,并缓存结果) * @param {string} providerType - 提供商类型 * @param {Array} providers - 提供商列表 */ async function loadModelsForProviderType(providerType, providers) { try { + // 如果已有缓存,直接使用 + if (cachedModels.length > 0) { + providers.forEach(provider => { + renderNotSupportedModelsSelector(provider.uuid, cachedModels, provider.notSupportedModels || []); + }); + return; + } + // 只调用一次API获取模型列表 const response = await window.apiClient.get(`/provider-models/${encodeURIComponent(providerType)}`); const models = response.models || []; + // 缓存模型列表 + cachedModels = models; + // 为每个提供商渲染模型选择器 providers.forEach(provider => { renderNotSupportedModelsSelector(provider.uuid, models, provider.notSupportedModels || []); @@ -192,6 +352,8 @@ function renderProviderList(providers) { const isHealthy = provider.isHealthy; const isDisabled = provider.isDisabled || false; const lastUsed = provider.lastUsed ? new Date(provider.lastUsed).toLocaleString() : '从未使用'; + const lastHealthCheckTime = provider.lastHealthCheckTime ? new Date(provider.lastHealthCheckTime).toLocaleString() : '从未检测'; + const lastHealthCheckModel = provider.lastHealthCheckModel || '-'; const healthClass = isHealthy ? 'healthy' : 'unhealthy'; const disabledClass = isDisabled ? 'disabled' : ''; const healthIcon = isHealthy ? 'fas fa-check-circle text-success' : 'fas fa-exclamation-triangle text-warning'; @@ -202,6 +364,19 @@ function renderProviderList(providers) { const toggleButtonIcon = isDisabled ? 'fas fa-play' : 'fas fa-ban'; const toggleButtonClass = isDisabled ? 'btn-success' : 'btn-warning'; + // 构建错误信息显示 + let errorInfoHtml = ''; + if (!isHealthy && provider.lastErrorMessage) { + const escapedErrorMsg = provider.lastErrorMessage.replace(//g, '>'); + errorInfoHtml = ` +
+ + 最后错误: + ${escapedErrorMsg} +
+ `; + } + return `
@@ -220,6 +395,17 @@ function renderProviderList(providers) { 失败次数: ${provider.errorCount || 0} | 最后使用: ${lastUsed}
+
+ + + 最后检测: ${lastHealthCheckTime} + | + + + 检测模型: ${lastHealthCheckModel} + +
+ ${errorInfoHtml}
` : ''; item.innerHTML = `
@@ -90,6 +98,7 @@ function createConfigItemElement(config, index) {
${statusText} + ${quickLinkBtnHtml}
@@ -141,6 +150,15 @@ function createConfigItemElement(config, index) { }); } + // 一键关联按钮事件 + const quickLinkBtn = item.querySelector('.btn-quick-link'); + if (quickLinkBtn) { + quickLinkBtn.addEventListener('click', (e) => { + e.stopPropagation(); + quickLinkProviderConfig(config.path); + }); + } + // 添加点击事件展开/折叠详情 item.addEventListener('click', (e) => { if (!e.target.closest('.config-item-actions')) { @@ -706,6 +724,12 @@ function initUploadConfigManager() { refreshBtn.addEventListener('click', loadConfigList); } + // 批量关联配置按钮 + const batchLinkBtn = document.getElementById('batchLinkKiroBtn') || document.getElementById('batchLinkProviderBtn'); + if (batchLinkBtn) { + batchLinkBtn.addEventListener('click', batchLinkProviderConfigs); + } + // 初始加载配置列表 loadConfigList(); } @@ -738,6 +762,150 @@ async function reloadConfig() { } } +/** + * 根据文件路径检测对应的提供商类型 + * @param {string} filePath - 文件路径 + * @returns {Object|null} 提供商信息对象或null + */ +function detectProviderFromPath(filePath) { + const normalizedPath = filePath.replace(/\\/g, '/').toLowerCase(); + + // 定义目录到提供商的映射关系 + const providerMappings = [ + { + patterns: ['configs/kiro/', '/kiro/'], + providerType: 'claude-kiro-oauth', + displayName: 'Claude Kiro OAuth', + shortName: 'kiro-oauth' + }, + { + patterns: ['configs/gemini/', '/gemini/', 'configs/gemini-cli/'], + providerType: 'gemini-cli-oauth', + displayName: 'Gemini CLI OAuth', + shortName: 'gemini-oauth' + }, + { + patterns: ['configs/qwen/', '/qwen/'], + providerType: 'openai-qwen-oauth', + displayName: 'Qwen OAuth', + shortName: 'qwen-oauth' + }, + { + patterns: ['configs/antigravity/', '/antigravity/'], + providerType: 'gemini-antigravity', + displayName: 'Gemini Antigravity', + shortName: 'antigravity' + } + ]; + + // 遍历映射关系,查找匹配的提供商 + for (const mapping of providerMappings) { + for (const pattern of mapping.patterns) { + if (normalizedPath.includes(pattern)) { + return { + providerType: mapping.providerType, + displayName: mapping.displayName, + shortName: mapping.shortName + }; + } + } + } + + return null; +} + +/** + * 一键关联配置到对应的提供商 + * @param {string} filePath - 配置文件路径 + */ +async function quickLinkProviderConfig(filePath) { + try { + const providerInfo = detectProviderFromPath(filePath); + if (!providerInfo) { + showToast('无法识别配置文件对应的提供商类型', 'error'); + return; + } + + showToast(`正在关联配置到 ${providerInfo.displayName}...`, 'info'); + + const result = await window.apiClient.post('/quick-link-provider', { + filePath: filePath + }); + + showToast(result.message || '配置关联成功', 'success'); + + // 刷新配置列表 + await loadConfigList(); + } catch (error) { + console.error('一键关联失败:', error); + showToast('关联失败: ' + error.message, 'error'); + } +} + +/** + * 批量关联所有支持的提供商目录下的未关联配置 + */ +async function batchLinkProviderConfigs() { + // 筛选出所有支持的提供商目录下的未关联配置 + const unlinkedConfigs = allConfigs.filter(config => { + if (config.isUsed) return false; + const providerInfo = detectProviderFromPath(config.path); + return providerInfo !== null; + }); + + if (unlinkedConfigs.length === 0) { + showToast('没有需要关联的配置文件', 'info'); + return; + } + + // 按提供商类型分组统计 + const groupedByProvider = {}; + unlinkedConfigs.forEach(config => { + const providerInfo = detectProviderFromPath(config.path); + if (providerInfo) { + if (!groupedByProvider[providerInfo.displayName]) { + groupedByProvider[providerInfo.displayName] = 0; + } + groupedByProvider[providerInfo.displayName]++; + } + }); + + const providerSummary = Object.entries(groupedByProvider) + .map(([name, count]) => `${name}: ${count}个`) + .join(', '); + + const confirmMsg = `确定要批量关联 ${unlinkedConfigs.length} 个配置吗?\n\n${providerSummary}`; + if (!confirm(confirmMsg)) { + return; + } + + showToast(`正在批量关联 ${unlinkedConfigs.length} 个配置...`, 'info'); + + let successCount = 0; + let failCount = 0; + + for (const config of unlinkedConfigs) { + try { + await window.apiClient.post('/quick-link-provider', { + filePath: config.path + }); + successCount++; + } catch (error) { + console.error(`关联失败: ${config.path}`, error); + failCount++; + } + } + + // 刷新配置列表 + await loadConfigList(); + + if (failCount === 0) { + showToast(`成功关联 ${successCount} 个配置`, 'success'); + } else { + showToast(`关联完成: 成功 ${successCount} 个, 失败 ${failCount} 个`, 'warning'); + } +} + /** * 防抖函数 * @param {Function} func - 要防抖的函数 diff --git a/static/app/utils.js b/static/app/utils.js index e558a10..2500d44 100644 --- a/static/app/utils.js +++ b/static/app/utils.js @@ -147,6 +147,20 @@ function getProviderTypeFields(providerType) { type: 'text', placeholder: '例如: ~/.qwen/oauth_creds.json' } + ], + 'gemini-antigravity': [ + { + id: 'ProjectId', + label: '项目ID (选填)', + type: 'text', + placeholder: 'Google Cloud项目ID (留空自动发现)' + }, + { + id: 'AntigravityOauthCredsFilePath', + label: 'OAuth凭据文件路径', + type: 'text', + placeholder: '例如: ~/.antigravity/oauth_creds.json' + } ] }; diff --git a/static/index.html b/static/index.html index 7d99511..6a11099 100644 --- a/static/index.html +++ b/static/index.html @@ -768,6 +768,9 @@ 共 0 个配置文件 已关联: 0 未关联: 0 +