diff --git a/src/providers/claude/claude-kiro.js b/src/providers/claude/claude-kiro.js index 766336b..79f3f11 100644 --- a/src/providers/claude/claude-kiro.js +++ b/src/providers/claude/claude-kiro.js @@ -57,8 +57,8 @@ const FULL_MODEL_MAPPING = { "claude-sonnet-4-6":"claude-sonnet-4.6", "claude-opus-4-5":"claude-opus-4.5", "claude-opus-4-5-20251101":"claude-opus-4.5", - "claude-sonnet-4-5": "CLAUDE_SONNET_4_5_20250929_V1_0", - "claude-sonnet-4-5-20250929": "CLAUDE_SONNET_4_5_20250929_V1_0" + "claude-sonnet-4-5": "claude-sonnet-4.5", + "claude-sonnet-4-5-20250929": "claude-sonnet-4.5" }; // 只保留 KIRO_MODELS 中存在的模型映射 diff --git a/src/services/ui-manager.js b/src/services/ui-manager.js index 8fb30fa..91db632 100644 --- a/src/services/ui-manager.js +++ b/src/services/ui-manager.js @@ -246,6 +246,13 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo return await uploadConfigApi.handleViewConfigFile(req, res, filePath); } + // Download specific configuration file + const downloadConfigMatch = pathParam.match(/^\/api\/upload-configs\/download\/(.+)$/); + if (method === 'GET' && downloadConfigMatch) { + const filePath = decodeURIComponent(downloadConfigMatch[1]); + return await uploadConfigApi.handleDownloadConfigFile(req, res, filePath); + } + // Delete specific configuration file const deleteConfigMatch = pathParam.match(/^\/api\/upload-configs\/delete\/(.+)$/); if (method === 'DELETE' && deleteConfigMatch) { diff --git a/src/ui-modules/config-scanner.js b/src/ui-modules/config-scanner.js index 0e95e09..ea43587 100644 --- a/src/ui-modules/config-scanner.js +++ b/src/ui-modules/config-scanner.js @@ -294,6 +294,8 @@ function getFileUsageInfo(relativePath, fileName, usedPaths, currentConfig) { providerIndex: index, nodeName: provider.customName, uuid: provider.uuid, + isHealthy: provider.isHealthy !== false, + isDisabled: provider.isDisabled === true, configKey: 'GEMINI_OAUTH_CREDS_FILE_PATH' }); } @@ -308,6 +310,8 @@ function getFileUsageInfo(relativePath, fileName, usedPaths, currentConfig) { providerIndex: index, nodeName: provider.customName, uuid: provider.uuid, + isHealthy: provider.isHealthy !== false, + isDisabled: provider.isDisabled === true, configKey: 'KIRO_OAUTH_CREDS_FILE_PATH' }); } @@ -322,6 +326,8 @@ function getFileUsageInfo(relativePath, fileName, usedPaths, currentConfig) { providerIndex: index, nodeName: provider.customName, uuid: provider.uuid, + isHealthy: provider.isHealthy !== false, + isDisabled: provider.isDisabled === true, configKey: 'QWEN_OAUTH_CREDS_FILE_PATH' }); } @@ -336,6 +342,8 @@ function getFileUsageInfo(relativePath, fileName, usedPaths, currentConfig) { providerIndex: index, nodeName: provider.customName, uuid: provider.uuid, + isHealthy: provider.isHealthy !== false, + isDisabled: provider.isDisabled === true, configKey: 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH' }); } @@ -350,6 +358,8 @@ function getFileUsageInfo(relativePath, fileName, usedPaths, currentConfig) { providerIndex: index, nodeName: provider.customName, uuid: provider.uuid, + isHealthy: provider.isHealthy !== false, + isDisabled: provider.isDisabled === true, configKey: 'IFLOW_TOKEN_FILE_PATH' }); } @@ -364,6 +374,8 @@ function getFileUsageInfo(relativePath, fileName, usedPaths, currentConfig) { providerIndex: index, nodeName: provider.customName, uuid: provider.uuid, + isHealthy: provider.isHealthy !== false, + isDisabled: provider.isDisabled === true, configKey: 'CODEX_OAUTH_CREDS_FILE_PATH' }); } diff --git a/src/ui-modules/upload-config-api.js b/src/ui-modules/upload-config-api.js index 0ca8a0b..8b92d7e 100644 --- a/src/ui-modules/upload-config-api.js +++ b/src/ui-modules/upload-config-api.js @@ -83,6 +83,60 @@ export async function handleViewConfigFile(req, res, filePath) { } } +/** + * 下载特定配置文件 + */ +export async function handleDownloadConfigFile(req, res, filePath) { + try { + const fullPath = path.join(process.cwd(), filePath); + + // 安全检查:确保文件路径在允许的目录内 + const allowedDirs = ['configs']; + const relativePath = path.relative(process.cwd(), fullPath); + const isAllowed = allowedDirs.some(dir => relativePath.startsWith(dir + path.sep) || relativePath === dir); + + if (!isAllowed) { + res.writeHead(403, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'Access denied: can only download files in configs directory' + } + })); + return true; + } + + if (!existsSync(fullPath)) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'File does not exist' + } + })); + return true; + } + + const content = await fs.readFile(fullPath); + const fileName = path.basename(fullPath); + + res.writeHead(200, { + 'Content-Type': 'application/octet-stream', + 'Content-Disposition': `attachment; filename="${fileName}"`, + 'Content-Length': content.length + }); + res.end(content); + return true; + } catch (error) { + logger.error('[UI API] Failed to download config file:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'Failed to download config file: ' + error.message + } + })); + return true; + } +} + /** * 删除特定配置文件 */ diff --git a/src/ui-modules/usage-api.js b/src/ui-modules/usage-api.js index c23a8cf..e9c6329 100644 --- a/src/ui-modules/usage-api.js +++ b/src/ui-modules/usage-api.js @@ -3,6 +3,7 @@ import logger from '../utils/logger.js'; import { serviceInstances, getServiceAdapter } from '../providers/adapter.js'; import { formatKiroUsage, formatGeminiUsage, formatAntigravityUsage, formatCodexUsage, formatGrokUsage } from '../services/usage-service.js'; import { readUsageCache, writeUsageCache, readProviderUsageCache, updateProviderUsageCache } from './usage-cache.js'; +import { PROVIDER_MAPPINGS } from '../utils/provider-utils.js'; import path from 'path'; const supportedProviders = ['claude-kiro-oauth', 'gemini-cli-oauth', 'gemini-antigravity', 'openai-codex-oauth', 'grok-custom']; @@ -82,6 +83,7 @@ async function getProviderTypeUsage(providerType, currentConfig, providerPoolMan const instanceResult = { uuid: provider.uuid || 'unknown', name: getProviderDisplayName(provider, providerType), + configFilePath: getProviderConfigFilePath(provider, providerType), isHealthy: provider.isHealthy !== false, isDisabled: provider.isDisabled === true, success: false, @@ -209,14 +211,8 @@ function getProviderDisplayName(provider, providerType) { } // 尝试从凭据文件路径提取名称 - const credPathKey = { - 'claude-kiro-oauth': 'KIRO_OAUTH_CREDS_FILE_PATH', - 'gemini-cli-oauth': 'GEMINI_OAUTH_CREDS_FILE_PATH', - 'gemini-antigravity': 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH', - 'openai-codex-oauth': 'CODEX_OAUTH_CREDS_FILE_PATH', - 'openai-qwen-oauth': 'QWEN_OAUTH_CREDS_FILE_PATH', - 'openai-iflow': 'IFLOW_TOKEN_FILE_PATH' - }[providerType]; + const mapping = PROVIDER_MAPPINGS.find(m => m.providerType === providerType); + const credPathKey = mapping ? mapping.credPathKey : null; if (credPathKey && provider[credPathKey]) { const filePath = provider[credPathKey]; @@ -228,6 +224,19 @@ function getProviderDisplayName(provider, providerType) { return 'Unnamed'; } +/** + * 获取提供商配置文件路径 + * @param {Object} provider - 提供商配置 + * @param {string} providerType - 提供商类型 + * @returns {string|null} 配置文件路径 + */ +function getProviderConfigFilePath(provider, providerType) { + const mapping = PROVIDER_MAPPINGS.find(m => m.providerType === providerType); + const credPathKey = mapping ? mapping.credPathKey : null; + + return (credPathKey && provider[credPathKey]) ? provider[credPathKey] : null; +} + /** * 获取支持用量查询的提供商列表 */ diff --git a/static/app/i18n.js b/static/app/i18n.js index 934603d..0762e37 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -397,6 +397,7 @@ const translations = { 'upload.detail.modified': '最后修改', 'upload.detail.status': '关联状态', 'upload.action.view': '查看', + 'upload.action.download': '下载', 'upload.action.delete': '删除', 'upload.usage.title': '关联详情 ({type})', 'upload.usage.mainConfig': '主要配置', @@ -585,6 +586,9 @@ const translations = { 'usage.card.status.unhealthy': '异常', 'usage.card.totalUsage': '总用量', 'usage.card.resetAt': '将在 {time} 重置', + 'usage.card.downloadConfig': '下载授权文件', + 'usage.card.downloadSuccess': '授权文件下载成功', + 'usage.card.downloadFailed': '授权文件下载失败', 'usage.card.freeTrial': '免费试用', 'usage.card.bonus': '奖励', 'usage.card.expires': '到期: {time}', @@ -1236,6 +1240,7 @@ const translations = { 'upload.detail.modified': 'Last Modified', 'upload.detail.status': 'Status', 'upload.action.view': 'View', + 'upload.action.download': 'Download', 'upload.action.delete': 'Delete', 'upload.usage.title': 'Association Details ({type})', 'upload.usage.mainConfig': 'Main Config', @@ -1424,6 +1429,9 @@ const translations = { 'usage.card.status.unhealthy': 'Abnormal', 'usage.card.totalUsage': 'Total Usage', 'usage.card.resetAt': 'Resets at {time}', + 'usage.card.downloadConfig': 'Download Config', + 'usage.card.downloadSuccess': 'Config file downloaded successfully', + 'usage.card.downloadFailed': 'Failed to download config file', 'usage.card.freeTrial': 'Free Trial', 'usage.card.bonus': 'Bonus', 'usage.card.expires': 'Expires: {time}', diff --git a/static/app/upload-config-manager.js b/static/app/upload-config-manager.js index f843fd3..967b26c 100644 --- a/static/app/upload-config-manager.js +++ b/static/app/upload-config-manager.js @@ -113,22 +113,61 @@ function createConfigItemElement(config, index) { let linkedNodesInfo = ''; if (config.isUsed && config.usageInfo && config.usageInfo.usageDetails) { const details = config.usageInfo.usageDetails; - const infoParts = details.map(d => { + + // 收集节点信息及其状态 + const nodes = details.map(d => { + let name = ''; + let isPool = false; if (d.type === 'Provider Pool' || d.type === '提供商池') { - // 严格按照优先级:自定义名称 > UUID (简短) > 默认位置描述 - if (d.nodeName) return d.nodeName; - if (d.uuid) return d.uuid.substring(0, 8); - return d.location; + isPool = true; + if (d.nodeName) name = d.nodeName; + else if (d.uuid) name = d.uuid.substring(0, 8); + else name = d.location; } else if (d.type === 'Main Config' || d.type === '主要配置') { - return t('upload.usage.mainConfig'); + name = t('upload.usage.mainConfig'); } - return null; + + if (!name) return null; + + return { + name, + isPool, + isHealthy: d.isHealthy, + isDisabled: d.isDisabled + }; }).filter(Boolean); - if (infoParts.length > 0) { - const uniqueParts = [...new Set(infoParts)]; + if (nodes.length > 0) { + // 去重,但保留状态信息(如果有多个相同名称的节点,状态可能不同,这里按名称去重以节省空间,取第一个) + const uniqueNodes = []; + const seenNames = new Set(); + for (const node of nodes) { + if (!seenNames.has(node.name)) { + uniqueNodes.push(node); + seenNames.add(node.name); + } + } + linkedNodesInfo = `
`; } } @@ -207,6 +246,9 @@ function createConfigItemElement(config, index) { + @@ -216,6 +258,7 @@ function createConfigItemElement(config, index) { // 添加按钮事件监听器 const viewBtn = item.querySelector('.btn-view'); + const downloadBtn = item.querySelector('.btn-download'); const deleteBtn = item.querySelector('.btn-delete-small'); if (viewBtn) { @@ -224,6 +267,13 @@ function createConfigItemElement(config, index) { viewConfig(config.path); }); } + + if (downloadBtn) { + downloadBtn.addEventListener('click', (e) => { + e.stopPropagation(); + downloadSingleConfig(config.path); + }); + } if (deleteBtn) { deleteBtn.addEventListener('click', (e) => { @@ -351,6 +401,18 @@ function generateUsageInfoHtml(config) { subtitle = detail.providerType || ''; } + // 生成节点状态标签 + let statusTag = ''; + if (detail.type === 'Provider Pool' || detail.type === '提供商池') { + if (detail.isDisabled) { + statusTag = `${t('modal.provider.status.disabled')}`; + } else if (!detail.isHealthy) { + statusTag = `${t('modal.provider.status.unhealthy')}`; + } else { + statusTag = `${t('modal.provider.status.healthy')}`; + } + } + detailsHtml += `