diff --git a/VERSION b/VERSION index e261122..743af5e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.6.7 +2.6.8 diff --git a/package-lock.json b/package-lock.json index bfecc3e..8e81846 100644 --- a/package-lock.json +++ b/package-lock.json @@ -101,7 +101,6 @@ "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -2960,7 +2959,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", diff --git a/src/auth/oauth-handlers.js b/src/auth/oauth-handlers.js index c4a241f..c2e578a 100644 --- a/src/auth/oauth-handlers.js +++ b/src/auth/oauth-handlers.js @@ -665,7 +665,7 @@ export async function handleQwenOAuth(currentConfig, options = {}) { * @returns {Promise} 返回授权URL和相关信息 */ export async function handleKiroOAuth(currentConfig, options = {}) { - const method = options.method || 'google'; // 默认使用 Google + const method = options.method || options.authMethod || 'google'; // 默认使用 Google,同时支持 authMethod 参数 console.log(`${KIRO_OAUTH_CONFIG.logPrefix} Starting OAuth with method: ${method}`); @@ -740,6 +740,10 @@ async function handleKiroBuilderIDDeviceCode(currentConfig, options = {}) { } } + // 获取 Builder ID Start URL(优先使用前端传入的值,否则使用默认值) + const builderIDStartURL = options.builderIDStartURL || KIRO_OAUTH_CONFIG.builderIDStartURL; + console.log(`${KIRO_OAUTH_CONFIG.logPrefix} Using Builder ID Start URL: ${builderIDStartURL}`); + // 1. 注册 OIDC 客户端 const regResponse = await fetchWithProxy(`${KIRO_OAUTH_CONFIG.ssoOIDCEndpoint}/client/register`, { method: 'POST', @@ -771,7 +775,7 @@ async function handleKiroBuilderIDDeviceCode(currentConfig, options = {}) { body: JSON.stringify({ clientId: regData.clientId, clientSecret: regData.clientSecret, - startUrl: KIRO_OAUTH_CONFIG.builderIDStartURL + startUrl: builderIDStartURL }) }, 'claude-kiro-oauth'); diff --git a/src/services/ui-manager.js b/src/services/ui-manager.js index 496d9a5..aa3989d 100644 --- a/src/services/ui-manager.js +++ b/src/services/ui-manager.js @@ -137,7 +137,57 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo return await providerApi.handleAddProvider(req, res, currentConfig, providerPoolManager); } + // Reset all providers health status for a specific provider type + // NOTE: This must be before the generic /{providerType}/{uuid} route to avoid matching 'reset-health' as UUID + const resetHealthMatch = pathParam.match(/^\/api\/providers\/([^\/]+)\/reset-health$/); + if (method === 'POST' && resetHealthMatch) { + const providerType = decodeURIComponent(resetHealthMatch[1]); + return await providerApi.handleResetProviderHealth(req, res, currentConfig, providerPoolManager, providerType); + } + + // Perform health check for all providers of a specific type + // NOTE: This must be before the generic /{providerType}/{uuid} route to avoid matching 'health-check' as UUID + const healthCheckMatch = pathParam.match(/^\/api\/providers\/([^\/]+)\/health-check$/); + if (method === 'POST' && healthCheckMatch) { + const providerType = decodeURIComponent(healthCheckMatch[1]); + return await providerApi.handleHealthCheck(req, res, currentConfig, providerPoolManager, providerType); + } + + // Delete all unhealthy providers for a specific type + // NOTE: This must be before the generic /{providerType}/{uuid} route to avoid matching 'delete-unhealthy' as UUID + const deleteUnhealthyMatch = pathParam.match(/^\/api\/providers\/([^\/]+)\/delete-unhealthy$/); + if (method === 'DELETE' && deleteUnhealthyMatch) { + const providerType = decodeURIComponent(deleteUnhealthyMatch[1]); + return await providerApi.handleDeleteUnhealthyProviders(req, res, currentConfig, providerPoolManager, providerType); + } + + // Refresh UUIDs for all unhealthy providers of a specific type + // NOTE: This must be before the generic /{providerType}/{uuid} route to avoid matching 'refresh-unhealthy-uuids' as UUID + const refreshUnhealthyUuidsMatch = pathParam.match(/^\/api\/providers\/([^\/]+)\/refresh-unhealthy-uuids$/); + if (method === 'POST' && refreshUnhealthyUuidsMatch) { + const providerType = decodeURIComponent(refreshUnhealthyUuidsMatch[1]); + return await providerApi.handleRefreshUnhealthyUuids(req, res, currentConfig, providerPoolManager, providerType); + } + + // Disable/Enable specific provider configuration + const disableEnableProviderMatch = pathParam.match(/^\/api\/providers\/([^\/]+)\/([^\/]+)\/(disable|enable)$/); + if (disableEnableProviderMatch) { + const providerType = decodeURIComponent(disableEnableProviderMatch[1]); + const providerUuid = disableEnableProviderMatch[2]; + const action = disableEnableProviderMatch[3]; + return await providerApi.handleDisableEnableProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid, action); + } + + // Refresh UUID for specific provider configuration + const refreshUuidMatch = pathParam.match(/^\/api\/providers\/([^\/]+)\/([^\/]+)\/refresh-uuid$/); + if (method === 'POST' && refreshUuidMatch) { + const providerType = decodeURIComponent(refreshUuidMatch[1]); + const providerUuid = refreshUuidMatch[2]; + return await providerApi.handleRefreshProviderUuid(req, res, currentConfig, providerPoolManager, providerType, providerUuid); + } + // Update specific provider configuration + // NOTE: This generic route must be after all specific routes like /reset-health, /health-check, /delete-unhealthy const updateProviderMatch = pathParam.match(/^\/api\/providers\/([^\/]+)\/([^\/]+)$/); if (method === 'PUT' && updateProviderMatch) { const providerType = decodeURIComponent(updateProviderMatch[1]); @@ -152,29 +202,6 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo return await providerApi.handleDeleteProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid); } - // Disable/Enable specific provider configuration - const disableEnableProviderMatch = pathParam.match(/^\/api\/providers\/([^\/]+)\/([^\/]+)\/(disable|enable)$/); - if (disableEnableProviderMatch) { - const providerType = decodeURIComponent(disableEnableProviderMatch[1]); - const providerUuid = disableEnableProviderMatch[2]; - const action = disableEnableProviderMatch[3]; - return await providerApi.handleDisableEnableProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid, action); - } - - // Reset all providers health status for a specific provider type - const resetHealthMatch = pathParam.match(/^\/api\/providers\/([^\/]+)\/reset-health$/); - if (method === 'POST' && resetHealthMatch) { - const providerType = decodeURIComponent(resetHealthMatch[1]); - return await providerApi.handleResetProviderHealth(req, res, currentConfig, providerPoolManager, providerType); - } - - // 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]); - return await providerApi.handleHealthCheck(req, res, currentConfig, providerPoolManager, providerType); - } - // Generate OAuth authorization URL for providers const generateAuthUrlMatch = pathParam.match(/^\/api\/providers\/([^\/]+)\/generate-auth-url$/); if (method === 'POST' && generateAuthUrlMatch) { @@ -216,6 +243,11 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo return await uploadConfigApi.handleDownloadAllConfigs(req, res); } + // Delete all unbound config files + if (method === 'DELETE' && pathParam === '/api/upload-configs/delete-unbound') { + return await uploadConfigApi.handleDeleteUnboundConfigs(req, res, currentConfig, providerPoolManager); + } + // Quick link config to corresponding provider based on directory if (method === 'POST' && pathParam === '/api/quick-link-provider') { return await providerApi.handleQuickLinkProvider(req, res, currentConfig, providerPoolManager); diff --git a/src/ui-modules/provider-api.js b/src/ui-modules/provider-api.js index 66394e5..965bd9b 100644 --- a/src/ui-modules/provider-api.js +++ b/src/ui-modules/provider-api.js @@ -467,6 +467,184 @@ export async function handleResetProviderHealth(req, res, currentConfig, provide } } +/** + * 删除特定提供商类型的所有不健康节点 + */ +export async function handleDeleteUnhealthyProviders(req, res, currentConfig, providerPoolManager, providerType) { + try { + const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; + let providerPools = {}; + + // Load existing pools + if (existsSync(filePath)) { + try { + const fileContent = readFileSync(filePath, 'utf-8'); + providerPools = JSON.parse(fileContent); + } catch (readError) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Provider pools file not found' } })); + return true; + } + } + + // Find and remove unhealthy providers + const providers = providerPools[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; + } + + // Filter out unhealthy providers (keep only healthy ones) + const unhealthyProviders = providers.filter(p => !p.isHealthy); + const healthyProviders = providers.filter(p => p.isHealthy); + + if (unhealthyProviders.length === 0) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + message: 'No unhealthy providers to delete', + deletedCount: 0, + remainingCount: providers.length + })); + return true; + } + + // Update the provider pool with only healthy providers + if (healthyProviders.length === 0) { + delete providerPools[providerType]; + } else { + providerPools[providerType] = healthyProviders; + } + + // Save to file + writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); + console.log(`[UI API] Deleted ${unhealthyProviders.length} unhealthy providers from ${providerType}`); + + // Update provider pool manager if available + if (providerPoolManager) { + providerPoolManager.providerPools = providerPools; + providerPoolManager.initializeProviderStatus(); + } + + // 广播更新事件 + broadcastEvent('config_update', { + action: 'delete_unhealthy', + filePath: filePath, + providerType, + deletedCount: unhealthyProviders.length, + deletedProviders: unhealthyProviders.map(p => ({ uuid: p.uuid, customName: p.customName })), + timestamp: new Date().toISOString() + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + message: `Successfully deleted ${unhealthyProviders.length} unhealthy providers`, + deletedCount: unhealthyProviders.length, + remainingCount: healthyProviders.length, + deletedProviders: unhealthyProviders.map(p => ({ uuid: p.uuid, customName: p.customName })) + })); + return true; + } catch (error) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: error.message } })); + return true; + } +} + +/** + * 批量刷新特定提供商类型的所有不健康节点的 UUID + */ +export async function handleRefreshUnhealthyUuids(req, res, currentConfig, providerPoolManager, providerType) { + try { + const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; + let providerPools = {}; + + // Load existing pools + if (existsSync(filePath)) { + try { + const fileContent = readFileSync(filePath, 'utf-8'); + providerPools = JSON.parse(fileContent); + } catch (readError) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Provider pools file not found' } })); + return true; + } + } + + // Find unhealthy providers + const providers = providerPools[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; + } + + // Filter unhealthy providers and refresh their UUIDs + const refreshedProviders = []; + for (const provider of providers) { + if (!provider.isHealthy) { + const oldUuid = provider.uuid; + const newUuid = generateUUID(); + provider.uuid = newUuid; + refreshedProviders.push({ + oldUuid, + newUuid, + customName: provider.customName + }); + } + } + + if (refreshedProviders.length === 0) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + message: 'No unhealthy providers to refresh', + refreshedCount: 0, + totalCount: providers.length + })); + return true; + } + + // Save to file + writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); + console.log(`[UI API] Refreshed UUIDs for ${refreshedProviders.length} unhealthy providers in ${providerType}`); + + // Update provider pool manager if available + if (providerPoolManager) { + providerPoolManager.providerPools = providerPools; + providerPoolManager.initializeProviderStatus(); + } + + // 广播更新事件 + broadcastEvent('config_update', { + action: 'refresh_unhealthy_uuids', + filePath: filePath, + providerType, + refreshedCount: refreshedProviders.length, + refreshedProviders, + timestamp: new Date().toISOString() + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + message: `Successfully refreshed UUIDs for ${refreshedProviders.length} unhealthy providers`, + refreshedCount: refreshedProviders.length, + totalCount: providers.length, + refreshedProviders + })); + return true; + } catch (error) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: error.message } })); + return true; + } +} + /** * 对特定提供商类型的所有提供商执行健康检查 */ @@ -486,11 +664,27 @@ export async function handleHealthCheck(req, res, currentConfig, providerPoolMan return true; } - console.log(`[UI API] Starting health check for ${providers.length} providers in ${providerType}`); + // 只检测不健康的节点 + const unhealthyProviders = providers.filter(ps => !ps.config.isHealthy); + + if (unhealthyProviders.length === 0) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + message: 'No unhealthy providers to check', + successCount: 0, + failCount: 0, + totalCount: providers.length, + results: [] + })); + return true; + } + + console.log(`[UI API] Starting health check for ${unhealthyProviders.length} unhealthy providers in ${providerType} (total: ${providers.length})`); // 执行健康检测(强制检查,忽略 checkHealth 配置) const results = []; - for (const providerStatus of providers) { + for (const providerStatus of unhealthyProviders) { const providerConfig = providerStatus.config; // 跳过已禁用的节点 @@ -580,7 +774,7 @@ export async function handleHealthCheck(req, res, currentConfig, providerPoolMan 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`); + console.log(`[UI API] Health check completed for ${providerType}: ${successCount} recovered, ${failCount} still unhealthy (checked ${unhealthyProviders.length} unhealthy nodes)`); // 广播更新事件 broadcastEvent('config_update', { @@ -728,4 +922,77 @@ export async function handleQuickLinkProvider(req, res, currentConfig, providerP })); return true; } +} + +/** + * 刷新特定提供商的UUID + */ +export async function handleRefreshProviderUuid(req, res, currentConfig, providerPoolManager, providerType, providerUuid) { + try { + const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; + let providerPools = {}; + + // Load existing pools + if (existsSync(filePath)) { + try { + const fileContent = readFileSync(filePath, 'utf-8'); + providerPools = JSON.parse(fileContent); + } catch (readError) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Provider pools file not found' } })); + return true; + } + } + + // Find the provider + const providers = providerPools[providerType] || []; + const providerIndex = providers.findIndex(p => p.uuid === providerUuid); + + if (providerIndex === -1) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Provider not found' } })); + return true; + } + + // Generate new UUID + const oldUuid = providerUuid; + const newUuid = generateUUID(); + + // Update provider UUID + providerPools[providerType][providerIndex].uuid = newUuid; + + // Save to file + writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); + console.log(`[UI API] Refreshed UUID for provider in ${providerType}: ${oldUuid} -> ${newUuid}`); + + // Update provider pool manager if available + if (providerPoolManager) { + providerPoolManager.providerPools = providerPools; + providerPoolManager.initializeProviderStatus(); + } + + // 广播更新事件 + broadcastEvent('config_update', { + action: 'refresh_uuid', + filePath: filePath, + providerType, + oldUuid, + newUuid, + timestamp: new Date().toISOString() + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + message: 'UUID refreshed successfully', + oldUuid, + newUuid, + provider: providerPools[providerType][providerIndex] + })); + return true; + } catch (error) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: error.message } })); + return true; + } } \ No newline at end of file diff --git a/src/ui-modules/upload-config-api.js b/src/ui-modules/upload-config-api.js index aa673a4..426c323 100644 --- a/src/ui-modules/upload-config-api.js +++ b/src/ui-modules/upload-config-api.js @@ -197,4 +197,114 @@ export async function handleDownloadAllConfigs(req, res) { })); return true; } +} + +/** + * 批量删除未绑定的配置文件 + * 只删除 configs/xxx/ 子目录下的未绑定配置文件 + */ +export async function handleDeleteUnboundConfigs(req, res, currentConfig, providerPoolManager) { + try { + // 首先获取所有配置文件及其绑定状态 + const configFiles = await scanConfigFiles(currentConfig, providerPoolManager); + + // 筛选出未绑定的配置文件,并且必须在 configs/xxx/ 子目录下 + // 即路径格式为 configs/子目录名/文件名,而不是直接在 configs/ 根目录下 + const unboundConfigs = configFiles.filter(config => { + if (config.isUsed) return false; + + // 检查路径是否在 configs/xxx/ 子目录下 + // 路径格式应该是 configs/子目录/... + const normalizedPath = config.path.replace(/\\/g, '/'); + const pathParts = normalizedPath.split('/'); + + // 路径至少需要3部分:configs/子目录/文件名 + // 例如:configs/kiro/xxx.json 或 configs/gemini/xxx.json + if (pathParts.length >= 3 && pathParts[0] === 'configs') { + // 确保第二部分是子目录名(不是文件名) + return true; + } + + return false; + }); + + if (unboundConfigs.length === 0) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + message: 'No unbound config files to delete', + deletedCount: 0, + deletedFiles: [] + })); + return true; + } + + const deletedFiles = []; + const failedFiles = []; + + for (const config of unboundConfigs) { + try { + const fullPath = path.join(process.cwd(), config.path); + + // 安全检查:确保文件路径在允许的目录内 + const allowedDirs = ['configs']; + const relativePath = path.relative(process.cwd(), fullPath); + const isAllowed = allowedDirs.some(dir => relativePath.startsWith(dir + path.sep) || relativePath === dir); + + if (!isAllowed) { + failedFiles.push({ + path: config.path, + error: 'Access denied: can only delete files in configs directory' + }); + continue; + } + + if (!existsSync(fullPath)) { + failedFiles.push({ + path: config.path, + error: 'File does not exist' + }); + continue; + } + + await fs.unlink(fullPath); + deletedFiles.push(config.path); + + } catch (error) { + failedFiles.push({ + path: config.path, + error: error.message + }); + } + } + + // 广播更新事件 + if (deletedFiles.length > 0) { + broadcastEvent('config_update', { + action: 'batch_delete', + deletedFiles: deletedFiles, + timestamp: new Date().toISOString() + }); + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + message: `Deleted ${deletedFiles.length} unbound config files`, + deletedCount: deletedFiles.length, + deletedFiles: deletedFiles, + failedCount: failedFiles.length, + failedFiles: failedFiles + })); + return true; + } catch (error) { + console.error('[UI API] Failed to delete unbound configs:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'Failed to delete unbound configs: ' + error.message + } + })); + return true; + } } \ No newline at end of file diff --git a/static/app/i18n.js b/static/app/i18n.js index f72526d..cb472a9 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -28,7 +28,7 @@ const translations = { 'nav.dashboard': '仪表盘', 'nav.config': '配置管理', 'nav.providers': '提供商池管理', - 'nav.upload': '配置管理', + 'nav.upload': '凭据文件管理', 'nav.usage': '用量查询', 'nav.logs': '实时日志', 'nav.plugins': '插件管理', @@ -177,6 +177,8 @@ const translations = { 'oauth.kiro.importError': '导入出错', 'oauth.kiro.duplicateToken': '重复凭据 - 此 refreshToken 已存在', 'oauth.kiro.duplicateCredentials': '该凭据已存在,请勿重复导入', + 'oauth.kiro.builderIDStartURL': 'Builder ID Start URL', + 'oauth.kiro.builderIDStartURLHint': '如果您使用 AWS IAM Identity Center,请输入您的 Start URL', 'oauth.iflow.step1': '点击下方按钮在浏览器中打开 iFlow 授权页面', 'oauth.iflow.step2': '使用您的 iFlow 账号登录并授权', 'oauth.iflow.step3': '授权完成后,系统会自动获取 API Key', @@ -283,6 +285,8 @@ const translations = { 'config.advanced.maxErrorCount': '提供商最大错误次数', 'config.advanced.maxErrorCountPlaceholder': '默认: 3', 'config.advanced.maxErrorCountNote': '提供商连续错误达到此次数后将被标记为不健康,默认为 3 次', + 'config.advanced.credentialSwitchMaxRetries': '坏凭证切换最大重试次数', + 'config.advanced.credentialSwitchMaxRetriesNote': '认证错误(401/403)后切换凭证的最大重试次数,默认 5 次', 'config.advanced.fallbackChain': '跨类型 Fallback 链配置', 'config.advanced.fallbackChainPlaceholder': '例如:\n{\n "gemini-cli-oauth": ["gemini-antigravity"],\n "gemini-antigravity": ["gemini-cli-oauth"],\n "claude-kiro-oauth": ["claude-custom"]\n}', 'config.advanced.fallbackChainNote': '当某一 Provider Type 所有账号都不健康时,自动切换到配置的 Fallback 类型。JSON 格式,键为主类型,值为 Fallback 类型数组(按优先级排序)', @@ -308,9 +312,17 @@ const translations = { 'config.placeholder.model': '例如: gpt-3.5-turbo', // Upload Config - 'upload.title': '配置管理', + 'upload.title': '凭据文件管理', 'upload.search': '搜索配置', 'upload.searchPlaceholder': '输入文件名', + 'upload.providerFilter': '提供商类型', + 'upload.providerFilter.all': '全部提供商', + 'upload.providerFilter.kiro': 'Kiro OAuth', + 'upload.providerFilter.gemini': 'Gemini OAuth', + 'upload.providerFilter.qwen': 'Qwen OAuth', + 'upload.providerFilter.antigravity': 'Antigravity', + 'upload.providerFilter.orchids': 'Orchids OAuth', + 'upload.providerFilter.other': '其他/未识别', 'upload.statusFilter': '关联状态', 'upload.statusFilter.all': '全部状态', 'upload.statusFilter.used': '已关联', @@ -362,6 +374,13 @@ const translations = { 'upload.batchLink.processing': '正在批量关联 {count} 个配置...', 'upload.batchLink.success': '成功关联 {count} 个配置', 'upload.batchLink.partial': '关联完成: 成功 {success} 个, 失败 {fail} 个', + 'upload.deleteUnbound': '删除未关联', + 'upload.deleteUnbound.none': '没有可删除的未关联配置文件(仅删除 configs/子目录/ 下的文件)', + 'upload.deleteUnbound.confirm': '确定要删除 {count} 个未关联的配置文件吗?\n\n注意:仅删除 configs/子目录/ 下的未关联文件,configs/ 根目录下的文件不会被删除。\n\n此操作不可撤销!', + 'upload.deleteUnbound.processing': '正在删除未关联的配置文件...', + 'upload.deleteUnbound.success': '成功删除 {count} 个未关联的配置文件', + 'upload.deleteUnbound.partial': '删除完成: 成功 {success} 个, 失败 {fail} 个', + 'upload.deleteUnbound.failed': '删除未关联配置失败', // Providers 'providers.title': '提供商池管理', @@ -384,9 +403,9 @@ const translations = { 'modal.provider.healthyAccounts': '健康账户:', 'modal.provider.add': '添加新提供商', 'modal.provider.resetHealth': '重置为健康', - 'modal.provider.healthCheck': '健康检测', + 'modal.provider.healthCheck': '检测不健康', 'modal.provider.resetHealthConfirm': '确定要将 {type} 的所有节点重置为健康状态吗?\n\n这将清除所有节点的错误计数和错误时间。', - 'modal.provider.healthCheckConfirm': '确定要对 {type} 的所有节点执行健康检测吗?\n\n这将向每个节点发送测试请求来验证其可用性。', + 'modal.provider.healthCheckConfirm': '确定要对 {type} 的不健康节点执行健康检测吗?\n\n这将向不健康节点发送测试请求来验证其可用性。', 'modal.provider.deleteConfirm': '确定要删除这个提供商配置吗?此操作不可恢复。', 'modal.provider.disableConfirm': '确定要禁用这个提供商配置吗?禁用后该提供商将不会被选中使用。', 'modal.provider.enableConfirm': '确定要启用这个提供商配置吗?', @@ -417,6 +436,10 @@ const translations = { 'modal.provider.enabled': '启用', 'modal.provider.disabled': '禁用', 'modal.provider.noProviderType': '不支持的提供商类型', + 'modal.provider.refreshUuid': '刷新uuid', + 'modal.provider.refreshUuidConfirm': '确定要刷新此提供商的uuid吗?\n\n旧uuid: {oldUuid}\n\n刷新后将生成新的uuid,请确保没有其他系统依赖此uuid。', + 'modal.provider.refreshUuid.success': 'uuid刷新成功\n\n旧uuid: {oldUuid}\n新uuid: {newUuid}', + 'modal.provider.refreshUuid.failed': 'uuid刷新失败', 'modal.provider.load.failed': '加载提供商详情失败', 'modal.provider.auth.initializing': '正在初始化凭据生成...', @@ -431,6 +454,20 @@ const translations = { 'modal.provider.add.failed': '添加失败', 'modal.provider.resetHealth.success': '成功重置 {count} 个节点的健康状态', 'modal.provider.resetHealth.failed': '重置健康状态失败', + 'modal.provider.deleteUnhealthy': '删除不健康节点', + 'modal.provider.deleteUnhealthyBtn': '删除不健康', + 'modal.provider.deleteUnhealthyConfirm': '确定要删除 {count} 个不健康节点吗?此操作不可恢复。', + 'modal.provider.deleteUnhealthy.noUnhealthy': '没有不健康节点', + 'modal.provider.deleteUnhealthy.deleting': '正在删除...', + 'modal.provider.deleteUnhealthy.success': '已删除 {count} 个节点', + 'modal.provider.deleteUnhealthy.failed': '删除失败', + 'modal.provider.refreshUnhealthyUuids': '刷新不健康UUID', + 'modal.provider.refreshUnhealthyUuidsBtn': '刷新UUID', + 'modal.provider.refreshUnhealthyUuidsConfirm': '确定要刷新 {count} 个不健康节点的UUID吗?', + 'modal.provider.refreshUnhealthyUuids.noUnhealthy': '没有不健康节点', + 'modal.provider.refreshUnhealthyUuids.refreshing': '正在刷新...', + 'modal.provider.refreshUnhealthyUuids.success': '已刷新 {count} 个节点的UUID', + 'modal.provider.refreshUnhealthyUuids.failed': '刷新失败', 'modal.provider.kiroAuthHint': '使用 AWS Builder ID 登录方式时,需要 clientIdclientSecret 字段,可在同文件夹下的另一个 JSON 文件中获取', // Pagination @@ -496,6 +533,7 @@ const translations = { 'common.loading': '加载中...', 'common.upload': '上传', 'common.generate': '生成', + 'common.optional': '可选', 'common.found': '已找到', 'common.missing': '缺失', 'common.search': '搜索', @@ -552,7 +590,7 @@ const translations = { 'nav.dashboard': 'Dashboard', 'nav.config': 'Configuration', 'nav.providers': 'Provider Pools', - 'nav.upload': 'Config Management', + 'nav.upload': 'Credential Files', 'nav.usage': 'Usage Query', 'nav.logs': 'Real-time Logs', 'nav.plugins': 'Plugin Management', @@ -701,6 +739,8 @@ const translations = { 'oauth.kiro.importError': 'Import error', 'oauth.kiro.duplicateToken': 'Duplicate - this refreshToken already exists', 'oauth.kiro.duplicateCredentials': 'This credential already exists, please do not import duplicates', + 'oauth.kiro.builderIDStartURL': 'Builder ID Start URL', + 'oauth.kiro.builderIDStartURLHint': 'If you use AWS IAM Identity Center, enter your Start URL', 'oauth.iflow.step1': 'Click the button below to open the iFlow authorization page', 'oauth.iflow.step2': 'Log in with your iFlow account and authorize', 'oauth.iflow.step3': 'After authorization, the system will automatically fetch the API Key', @@ -807,6 +847,8 @@ const translations = { 'config.advanced.maxErrorCount': 'Provider Max Error Count', 'config.advanced.maxErrorCountPlaceholder': 'Default: 3', 'config.advanced.maxErrorCountNote': 'Provider will be marked as unhealthy after consecutive errors reach this count, default is 3', + 'config.advanced.credentialSwitchMaxRetries': 'Credential Switch Max Retries', + 'config.advanced.credentialSwitchMaxRetriesNote': 'Maximum retries for switching credentials after authentication errors (401/403), default is 5', 'config.advanced.fallbackChain': 'Cross-Type Fallback Chain Config', 'config.advanced.fallbackChainPlaceholder': 'Example:\n{\n "gemini-cli-oauth": ["gemini-antigravity"],\n "gemini-antigravity": ["gemini-cli-oauth"],\n "claude-kiro-oauth": ["claude-custom"]\n}', 'config.advanced.fallbackChainNote': 'When all accounts of a Provider Type are unhealthy, automatically switch to configured Fallback types. JSON format, key is primary type, value is Fallback type array (sorted by priority)', @@ -832,9 +874,17 @@ const translations = { 'config.placeholder.model': 'e.g.: gpt-3.5-turbo', // Upload Config - 'upload.title': 'Config Management', + 'upload.title': 'Credential Files Management', 'upload.search': 'Search Config', 'upload.searchPlaceholder': 'Enter filename', + 'upload.providerFilter': 'Provider Type', + 'upload.providerFilter.all': 'All Providers', + 'upload.providerFilter.kiro': 'Kiro OAuth', + 'upload.providerFilter.gemini': 'Gemini OAuth', + 'upload.providerFilter.qwen': 'Qwen OAuth', + 'upload.providerFilter.antigravity': 'Antigravity', + 'upload.providerFilter.orchids': 'Orchids OAuth', + 'upload.providerFilter.other': 'Other/Unknown', 'upload.statusFilter': 'Association Status', 'upload.statusFilter.all': 'All Status', 'upload.statusFilter.used': 'Associated', @@ -886,6 +936,13 @@ const translations = { 'upload.batchLink.processing': 'Batch linking {count} configurations...', 'upload.batchLink.success': 'Successfully linked {count} configurations', 'upload.batchLink.partial': 'Linking completed: {success} succeeded, {fail} failed', + 'upload.deleteUnbound': 'Delete Unbound', + 'upload.deleteUnbound.none': 'No unbound config files to delete (only files in configs/subdirectory/ are deleted)', + 'upload.deleteUnbound.confirm': 'Are you sure you want to delete {count} unbound config files?\n\nNote: Only unbound files in configs/subdirectory/ will be deleted. Files directly in configs/ root will not be deleted.\n\nThis action cannot be undone!', + 'upload.deleteUnbound.processing': 'Deleting unbound config files...', + 'upload.deleteUnbound.success': 'Successfully deleted {count} unbound config files', + 'upload.deleteUnbound.partial': 'Deletion completed: {success} succeeded, {fail} failed', + 'upload.deleteUnbound.failed': 'Failed to delete unbound configs', // Providers 'providers.title': 'Provider Pool Management', @@ -908,9 +965,9 @@ const translations = { 'modal.provider.healthyAccounts': 'Healthy Accounts:', 'modal.provider.add': 'Add Provider', 'modal.provider.resetHealth': 'Reset Health', - 'modal.provider.healthCheck': 'Health Check', + 'modal.provider.healthCheck': 'Check Unhealthy', 'modal.provider.resetHealthConfirm': 'Are you sure you want to reset all {type} nodes to healthy status?\n\nThis will clear error counts and timestamps for all nodes.', - 'modal.provider.healthCheckConfirm': 'Are you sure you want to perform a health check on all {type} nodes?\n\nThis will send test requests to each node to verify availability.', + 'modal.provider.healthCheckConfirm': 'Are you sure you want to perform a health check on unhealthy {type} nodes?\n\nThis will send test requests to unhealthy nodes to verify availability.', 'modal.provider.deleteConfirm': 'Are you sure you want to delete this provider config? This cannot be undone.', 'modal.provider.disableConfirm': 'Are you sure you want to disable this provider? It will no longer be selected for use.', 'modal.provider.enableConfirm': 'Are you sure you want to enable this provider?', @@ -941,6 +998,10 @@ const translations = { 'modal.provider.enabled': 'Enabled', 'modal.provider.disabled': 'Disabled', 'modal.provider.noProviderType': 'Unsupported provider type', + 'modal.provider.refreshUuid': 'Refresh uuid', + 'modal.provider.refreshUuidConfirm': 'Are you sure you want to refresh the uuid for this provider?\n\nOld uuid: {oldUuid}\n\nA new uuid will be generated. Make sure no other systems depend on this uuid.', + 'modal.provider.refreshUuid.success': 'uuid refreshed successfully\n\nOld uuid: {oldUuid}\nNew uuid: {newUuid}', + 'modal.provider.refreshUuid.failed': 'Failed to refresh uuid', 'modal.provider.load.failed': 'Failed to load provider details', 'modal.provider.auth.initializing': 'Initializing credential generation...', @@ -955,6 +1016,20 @@ const translations = { 'modal.provider.add.failed': 'Add failed', 'modal.provider.resetHealth.success': 'Successfully reset health status for {count} nodes', 'modal.provider.resetHealth.failed': 'Failed to reset health status', + 'modal.provider.deleteUnhealthy': 'Delete unhealthy nodes', + 'modal.provider.deleteUnhealthyBtn': 'Delete Unhealthy', + 'modal.provider.deleteUnhealthyConfirm': 'Delete {count} unhealthy node(s)? This cannot be undone.', + 'modal.provider.deleteUnhealthy.noUnhealthy': 'No unhealthy nodes', + 'modal.provider.deleteUnhealthy.deleting': 'Deleting...', + 'modal.provider.deleteUnhealthy.success': 'Deleted {count} node(s)', + 'modal.provider.deleteUnhealthy.failed': 'Delete failed', + 'modal.provider.refreshUnhealthyUuids': 'Refresh unhealthy UUIDs', + 'modal.provider.refreshUnhealthyUuidsBtn': 'Refresh UUIDs', + 'modal.provider.refreshUnhealthyUuidsConfirm': 'Refresh UUIDs for {count} unhealthy node(s)?', + 'modal.provider.refreshUnhealthyUuids.noUnhealthy': 'No unhealthy nodes', + 'modal.provider.refreshUnhealthyUuids.refreshing': 'Refreshing...', + 'modal.provider.refreshUnhealthyUuids.success': 'Refreshed {count} UUID(s)', + 'modal.provider.refreshUnhealthyUuids.failed': 'Refresh failed', 'modal.provider.kiroAuthHint': 'When using AWS Builder ID login, clientId and clientSecret fields are required, which can be found in another JSON file in the same folder', // Pagination @@ -1023,6 +1098,7 @@ const translations = { 'common.loading': 'Loading...', 'common.upload': 'Upload', 'common.generate': 'Generate', + 'common.optional': 'Optional', 'common.found': 'Found', 'common.missing': 'Missing', 'common.search': 'Search', diff --git a/static/app/modal.js b/static/app/modal.js index 0def12c..c30540c 100644 --- a/static/app/modal.js +++ b/static/app/modal.js @@ -65,8 +65,14 @@ function showProviderManagerModal(data) { - + + @@ -418,6 +424,9 @@ function renderProviderList(providers) { +
@@ -784,19 +793,10 @@ function editProvider(uuid, event) { // 添加编辑状态类 providerDetail.classList.add('editing'); - // 替换编辑按钮为保存和取消按钮,但保留禁用/启用按钮 + // 替换编辑按钮为保存和取消按钮,不显示禁用/启用按钮 const actionsGroup = providerDetail.querySelector('.provider-actions-group'); - const toggleButton = actionsGroup.querySelector('[onclick*="toggleProviderStatus"]'); - const currentProvider = providerDetail.closest('.provider-modal').querySelector(`[data-uuid="${uuid}"]`); - const isCurrentlyDisabled = currentProvider.classList.contains('disabled'); - const toggleButtonText = isCurrentlyDisabled ? '启用' : '禁用'; - const toggleButtonIcon = isCurrentlyDisabled ? 'fas fa-play' : 'fas fa-ban'; - const toggleButtonClass = isCurrentlyDisabled ? 'btn-success' : 'btn-warning'; actionsGroup.innerHTML = ` - @@ -852,11 +852,11 @@ function cancelEdit(uuid, event) { select.value = originalValue || ''; }); - // 恢复原来的编辑和删除按钮,但保留禁用/启用按钮 + // 恢复原来的按钮布局 const actionsGroup = providerDetail.querySelector('.provider-actions-group'); const currentProvider = providerDetail.closest('.provider-modal').querySelector(`[data-uuid="${uuid}"]`); const isCurrentlyDisabled = currentProvider.classList.contains('disabled'); - const toggleButtonText = isCurrentlyDisabled ? '启用' : '禁用'; + const toggleButtonText = isCurrentlyDisabled ? t('modal.provider.enabled') : t('modal.provider.disabled'); const toggleButtonIcon = isCurrentlyDisabled ? 'fas fa-play' : 'fas fa-ban'; const toggleButtonClass = isCurrentlyDisabled ? 'btn-success' : 'btn-warning'; @@ -865,10 +865,13 @@ function cancelEdit(uuid, event) { ${toggleButtonText} + `; } @@ -1379,6 +1382,134 @@ async function performHealthCheck(providerType) { } } +/** + * 刷新提供商UUID + * @param {string} uuid - 提供商UUID + * @param {Event} event - 事件对象 + */ +async function refreshProviderUuid(uuid, event) { + event.stopPropagation(); + + if (!confirm(t('modal.provider.refreshUuidConfirm', { oldUuid: uuid }))) { + return; + } + + const providerDetail = event.target.closest('.provider-item-detail'); + const providerType = providerDetail.closest('.provider-modal').getAttribute('data-provider-type'); + + try { + const response = await window.apiClient.post( + `/providers/${encodeURIComponent(providerType)}/${uuid}/refresh-uuid`, + {} + ); + + if (response.success) { + showToast(t('common.success'), t('modal.provider.refreshUuid.success', { oldUuid: response.oldUuid, newUuid: response.newUuid }), 'success'); + + // 重新加载配置 + await window.apiClient.post('/reload-config'); + + // 刷新提供商配置显示 + await refreshProviderConfig(providerType); + } else { + showToast(t('common.error'), t('modal.provider.refreshUuid.failed'), 'error'); + } + } catch (error) { + console.error('刷新uuid失败:', error); + showToast(t('common.error'), t('modal.provider.refreshUuid.failed') + ': ' + error.message, 'error'); + } +} + +/** + * 删除所有不健康的提供商节点 + * @param {string} providerType - 提供商类型 + */ +async function deleteUnhealthyProviders(providerType) { + // 先获取不健康节点数量 + const unhealthyCount = currentProviders.filter(p => !p.isHealthy).length; + + if (unhealthyCount === 0) { + showToast(t('common.info'), t('modal.provider.deleteUnhealthy.noUnhealthy'), 'info'); + return; + } + + if (!confirm(t('modal.provider.deleteUnhealthyConfirm', { type: providerType, count: unhealthyCount }))) { + return; + } + + try { + showToast(t('common.info'), t('modal.provider.deleteUnhealthy.deleting'), 'info'); + + const response = await window.apiClient.delete( + `/providers/${encodeURIComponent(providerType)}/delete-unhealthy` + ); + + if (response.success) { + showToast( + t('common.success'), + t('modal.provider.deleteUnhealthy.success', { count: response.deletedCount }), + 'success' + ); + + // 重新加载配置 + await window.apiClient.post('/reload-config'); + + // 刷新提供商配置显示 + await refreshProviderConfig(providerType); + } else { + showToast(t('common.error'), t('modal.provider.deleteUnhealthy.failed'), 'error'); + } + } catch (error) { + console.error('删除不健康节点失败:', error); + showToast(t('common.error'), t('modal.provider.deleteUnhealthy.failed') + ': ' + error.message, 'error'); + } +} + +/** + * 批量刷新不健康节点的UUID + * @param {string} providerType - 提供商类型 + */ +async function refreshUnhealthyUuids(providerType) { + // 先获取不健康节点数量 + const unhealthyCount = currentProviders.filter(p => !p.isHealthy).length; + + if (unhealthyCount === 0) { + showToast(t('common.info'), t('modal.provider.refreshUnhealthyUuids.noUnhealthy'), 'info'); + return; + } + + if (!confirm(t('modal.provider.refreshUnhealthyUuidsConfirm', { type: providerType, count: unhealthyCount }))) { + return; + } + + try { + showToast(t('common.info'), t('modal.provider.refreshUnhealthyUuids.refreshing'), 'info'); + + const response = await window.apiClient.post( + `/providers/${encodeURIComponent(providerType)}/refresh-unhealthy-uuids` + ); + + if (response.success) { + showToast( + t('common.success'), + t('modal.provider.refreshUnhealthyUuids.success', { count: response.refreshedCount }), + 'success' + ); + + // 重新加载配置 + await window.apiClient.post('/reload-config'); + + // 刷新提供商配置显示 + await refreshProviderConfig(providerType); + } else { + showToast(t('common.error'), t('modal.provider.refreshUnhealthyUuids.failed'), 'error'); + } + } catch (error) { + console.error('刷新不健康节点UUID失败:', error); + showToast(t('common.error'), t('modal.provider.refreshUnhealthyUuids.failed') + ': ' + error.message, 'error'); + } +} + /** * 渲染不支持的模型选择器(不调用API,直接使用传入的模型列表) * @param {string} uuid - 提供商UUID @@ -1430,9 +1561,12 @@ export { toggleProviderStatus, resetAllProvidersHealth, performHealthCheck, + deleteUnhealthyProviders, + refreshUnhealthyUuids, loadModelsForProviderType, renderNotSupportedModelsSelector, - goToProviderPage + goToProviderPage, + refreshProviderUuid }; // 将函数挂载到window对象 @@ -1447,4 +1581,7 @@ window.addProvider = addProvider; window.toggleProviderStatus = toggleProviderStatus; window.resetAllProvidersHealth = resetAllProvidersHealth; window.performHealthCheck = performHealthCheck; -window.goToProviderPage = goToProviderPage; \ No newline at end of file +window.deleteUnhealthyProviders = deleteUnhealthyProviders; +window.refreshUnhealthyUuids = refreshUnhealthyUuids; +window.goToProviderPage = goToProviderPage; +window.refreshProviderUuid = refreshProviderUuid; \ No newline at end of file diff --git a/static/app/provider-manager.js b/static/app/provider-manager.js index f27662e..779d56e 100644 --- a/static/app/provider-manager.js +++ b/static/app/provider-manager.js @@ -680,7 +680,7 @@ function showKiroAuthMethodSelector(providerType) { modal.style.display = 'flex'; modal.innerHTML = ` -
diff --git a/static/components/sidebar.html b/static/components/sidebar.html index 16276fb..e9beb2c 100644 --- a/static/components/sidebar.html +++ b/static/components/sidebar.html @@ -11,8 +11,8 @@ 提供商池管理 - - 配置管理 + + 凭据文件管理 用量查询