diff --git a/README-JA.md b/README-JA.md index 9a15fb9..b70e237 100644 --- a/README-JA.md +++ b/README-JA.md @@ -575,6 +575,30 @@ kill -9 - **リクエストヘッダー形式を確認**:リクエストに正しい形式のAuthorizationヘッダーが含まれていることを確認、例:`Authorization: Bearer your-api-key` - **サービスログを確認**:Web UIの「リアルタイムログ」ページで詳細なエラーメッセージを確認し、具体的な原因を特定 +### 12. No available and healthy providers for type + +**問題の説明**:APIを呼び出すと `No available and healthy providers for type xxx` エラーが返されます。 + +**解決策**: +- **プロバイダー状態を確認**:Web UIの「プロバイダープール」ページで対応するタイプのプロバイダーが健全な状態にあるか確認 +- **認証情報の有効性を確認**:OAuth認証情報が期限切れでないことを確認、期限切れの場合は認証を再生成 +- **割り当て制限を確認**:一部のプロバイダーは無料割り当て上限に達している可能性があります。割り当てリセットを待つか、より多くのアカウントを追加 +- **フォールバックを有効化**:`config.json` で `providerFallbackChain` を設定し、主プロバイダーが利用できない場合に自動的にバックアッププロバイダーに切り替え +- **詳細ログを確認**:Web UIの「リアルタイムログ」ページで具体的なヘルスチェック失敗の原因を確認 + +### 13. リクエストが403 Forbiddenエラーを返す + +**問題の説明**:APIリクエストが403 Forbiddenエラーを返します。 + +**解決策**: +- **ノード状態を確認**:Web UIの「プロバイダープール」ページでノード状態が正常(ヘルスチェック合格)であれば、このエラーは無視できます。システムが自動的に処理します +- **アカウント権限を確認**:使用しているアカウントがリクエストされたモデルまたはサービスにアクセスする権限があることを確認 +- **API Key権限を確認**:一部のプロバイダーのAPI Keyにはアクセス範囲の制限がある場合があります。Keyに十分な権限があることを確認 +- **地域制限を確認**:一部のサービスには地域アクセス制限がある場合があります。プロキシまたはVPNの使用を試してください +- **認証情報の状態を確認**:OAuth認証情報が取り消されたり期限切れになっている可能性があります。認証の再生成を試してください +- **リクエスト頻度を確認**:一部のプロバイダーはリクエスト頻度に厳しい制限があります。リクエスト頻度を下げて再試行 +- **プロバイダードキュメントを確認**:対応するプロバイダーの公式ドキュメントにアクセスして、具体的なアクセス制限と要件を理解 + --- diff --git a/README-ZH.md b/README-ZH.md index 11b870e..35374c0 100644 --- a/README-ZH.md +++ b/README-ZH.md @@ -574,6 +574,30 @@ kill -9 - **检查请求头格式**:确保请求中包含正确格式的 Authorization 头,如 `Authorization: Bearer your-api-key` - **查看服务日志**:在 Web UI 的"实时日志"页面查看详细错误信息,定位具体原因 +### 12. No available and healthy providers for type + +**问题描述**:调用 API 时返回 `No available and healthy providers for type xxx` 错误。 + +**解决方案**: +- **检查提供商状态**:在 Web UI 的"提供商池"页面查看对应类型的提供商是否处于健康状态 +- **检查凭据有效性**:确认 OAuth 凭据未过期,如已过期需重新生成授权 +- **检查配额限制**:某些提供商可能已达到免费配额上限,等待配额重置或添加更多账号 +- **启用 Fallback**:在 `config.json` 中配置 `providerFallbackChain`,当主提供商不可用时自动切换到备用提供商 +- **查看详细日志**:在 Web UI 的"实时日志"页面查看具体的健康检查失败原因 + +### 13. 请求返回 403 Forbidden 错误 + +**问题描述**:API 请求返回 403 Forbidden 错误。 + +**解决方案**: +- **检查节点状态**:如果在 Web UI 的"提供商池"页面中看到节点状态正常(健康检查通过),则可以忽略此报错,系统会自动处理 +- **检查账号权限**:确认使用的账号有权限访问请求的模型或服务 +- **检查 API Key 权限**:某些提供商的 API Key 可能有访问范围限制,确保 Key 有足够权限 +- **检查地区限制**:部分服务可能有地区访问限制,尝试使用代理或 VPN +- **检查凭据状态**:OAuth 凭据可能已被撤销或失效,尝试重新生成授权 +- **检查请求频率**:某些提供商对请求频率有严格限制,降低请求频率后重试 +- **查看提供商文档**:访问对应提供商的官方文档,了解具体的访问限制和要求 + --- diff --git a/README.md b/README.md index e400776..a9e4711 100644 --- a/README.md +++ b/README.md @@ -575,6 +575,30 @@ Or modify the port configuration in `configs/config.json` to use a different por - **Check Request Header Format**: Ensure the request contains the correct Authorization header format, such as `Authorization: Bearer your-api-key` - **Check Service Logs**: View detailed error messages on the "Real-time Logs" page in Web UI to locate the specific cause +### 12. No available and healthy providers for type + +**Problem Description**: When calling API, it returns `No available and healthy providers for type xxx` error. + +**Solutions**: +- **Check Provider Status**: Check if providers of the corresponding type are in healthy status on the "Provider Pools" page in Web UI +- **Check Credential Validity**: Confirm OAuth credentials have not expired; if expired, regenerate authorization +- **Check Quota Limits**: Some providers may have reached free quota limits; wait for quota reset or add more accounts +- **Enable Fallback**: Configure `providerFallbackChain` in `config.json` to automatically switch to backup providers when the primary provider is unavailable +- **View Detailed Logs**: Check specific health check failure reasons on the "Real-time Logs" page in Web UI + +### 13. Request Returns 403 Forbidden Error + +**Problem Description**: API requests return 403 Forbidden error. + +**Solutions**: +- **Check Node Status**: If you see the node status is normal (health check passed) on the "Provider Pools" page in Web UI, you can ignore this error as the system will handle it automatically +- **Check Account Permissions**: Confirm the account has permission to access the requested model or service +- **Check API Key Permissions**: Some providers' API Keys may have access scope restrictions; ensure the Key has sufficient permissions +- **Check Regional Restrictions**: Some services may have regional access restrictions; try using a proxy or VPN +- **Check Credential Status**: OAuth credentials may have been revoked or expired; try regenerating authorization +- **Check Request Frequency**: Some providers have strict request frequency limits; reduce request frequency and retry +- **View Provider Documentation**: Visit the official documentation of the corresponding provider to understand specific access restrictions and requirements + --- diff --git a/src/auth/gemini-oauth.js b/src/auth/gemini-oauth.js index 3dd162b..c6ede1c 100644 --- a/src/auth/gemini-oauth.js +++ b/src/auth/gemini-oauth.js @@ -290,4 +290,170 @@ export async function handleGeminiCliOAuth(currentConfig, options = {}) { */ export async function handleGeminiAntigravityOAuth(currentConfig, options = {}) { return handleGoogleOAuth('gemini-antigravity', currentConfig, options); -} \ No newline at end of file +} + +/** + * 检查 Gemini 凭据是否已存在(基于 refresh_token) + * @param {string} providerType - 提供商类型 + * @param {string} refreshToken - 要检查的 refreshToken + * @returns {Promise<{isDuplicate: boolean, existingPath?: string}>} 检查结果 + */ +export async function checkGeminiCredentialsDuplicate(providerType, refreshToken) { + const config = OAUTH_PROVIDERS[providerType]; + if (!config) return { isDuplicate: false }; + + const providerDir = config.credentialsDir.replace('.', ''); + const targetDir = path.join(process.cwd(), 'configs', providerDir); + + try { + if (!fs.existsSync(targetDir)) { + return { isDuplicate: false }; + } + + const files = await fs.promises.readdir(targetDir); + for (const file of files) { + if (file.endsWith('.json')) { + try { + const fullPath = path.join(targetDir, file); + const content = await fs.promises.readFile(fullPath, 'utf8'); + const credentials = JSON.parse(content); + + if (credentials.refresh_token === refreshToken) { + const relativePath = path.relative(process.cwd(), fullPath); + return { + isDuplicate: true, + existingPath: relativePath + }; + } + } catch (e) { + // 忽略解析错误 + } + } + } + return { isDuplicate: false }; + } catch (error) { + logger.warn(`[Gemini Auth] Error checking duplicates for ${providerType}:`, error.message); + return { isDuplicate: false }; + } +} + +/** + * 批量导入 Gemini Token 并生成凭据文件(流式版本,支持实时进度回调) + * @param {string} providerType - 提供商类型 ('gemini-cli-oauth' 或 'gemini-antigravity') + * @param {Object[]} tokens - Token 对象数组 + * @param {Function} onProgress - 进度回调函数 + * @param {boolean} skipDuplicateCheck - 是否跳过重复检查 (默认: false) + * @returns {Promise} 批量处理结果 + */ +export async function batchImportGeminiTokensStream(providerType, tokens, onProgress = null, skipDuplicateCheck = false) { + const config = OAUTH_PROVIDERS[providerType]; + if (!config) { + throw new Error(`未知的提供商: ${providerType}`); + } + + const results = { + total: tokens.length, + success: 0, + failed: 0, + details: [] + }; + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + const progressData = { + index: i + 1, + total: tokens.length, + current: null + }; + + try { + // 验证 token 是否包含必需字段 (通常是 access_token 和 refresh_token) + if (!token.access_token || !token.refresh_token) { + throw new Error('Token 缺少必需字段 (access_token 或 refresh_token)'); + } + + // 检查重复 + if (!skipDuplicateCheck) { + const duplicateCheck = await checkGeminiCredentialsDuplicate(providerType, token.refresh_token); + if (duplicateCheck.isDuplicate) { + progressData.current = { + index: i + 1, + success: false, + error: 'duplicate', + existingPath: duplicateCheck.existingPath + }; + results.failed++; + results.details.push(progressData.current); + if (onProgress) { + onProgress({ + ...progressData, + successCount: results.success, + failedCount: results.failed + }); + } + continue; + } + } + + // 生成文件路径 + const timestamp = Date.now(); + const providerDir = config.credentialsDir.replace('.', ''); // 去掉开头的点 + const targetDir = path.join(process.cwd(), 'configs', providerDir); + await fs.promises.mkdir(targetDir, { recursive: true }); + + const filename = `${timestamp}_${i}_oauth_creds.json`; + const credPath = path.join(targetDir, filename); + + await fs.promises.writeFile(credPath, JSON.stringify(token, null, 2)); + + const relativePath = path.relative(process.cwd(), credPath); + + logger.info(`${config.logPrefix} Token ${i + 1} 已导入并保存: ${relativePath}`); + + progressData.current = { + index: i + 1, + success: true, + path: relativePath + }; + results.success++; + + // 自动关联新生成的凭据到 Pools + await autoLinkProviderConfigs(CONFIG, { + onlyCurrentCred: true, + credPath: relativePath + }); + + } catch (error) { + logger.error(`${config.logPrefix} Token ${i + 1} 导入失败:`, error.message); + + progressData.current = { + index: i + 1, + success: false, + error: error.message + }; + results.failed++; + } + + results.details.push(progressData.current); + + // 发送进度更新 + if (onProgress) { + onProgress({ + ...progressData, + successCount: results.success, + failedCount: results.failed + }); + } + } + + // 如果有成功的,广播事件 + if (results.success > 0) { + broadcastEvent('oauth_batch_success', { + provider: providerType, + count: results.success, + timestamp: new Date().toISOString() + }); + } + + return results; +} diff --git a/src/auth/index.js b/src/auth/index.js index 17f43cf..f0ea696 100644 --- a/src/auth/index.js +++ b/src/auth/index.js @@ -8,7 +8,9 @@ export { // Gemini OAuth export { handleGeminiCliOAuth, - handleGeminiAntigravityOAuth + handleGeminiAntigravityOAuth, + batchImportGeminiTokensStream, + checkGeminiCredentialsDuplicate } from './gemini-oauth.js'; // Qwen OAuth diff --git a/src/auth/kiro-oauth.js b/src/auth/kiro-oauth.js index 1dbc0a1..584c1bf 100644 --- a/src/auth/kiro-oauth.js +++ b/src/auth/kiro-oauth.js @@ -852,8 +852,15 @@ export async function batchImportKiroRefreshTokens(refreshTokens, region = KIRO_ timestamp: new Date().toISOString() }); - // 自动关联新生成的凭据到 Pools(批量导入时扫描所有) - await autoLinkProviderConfigs(CONFIG); + // 自动关联新生成的凭据到 Pools + for (const detail of results.details) { + if (detail.success && detail.path) { + await autoLinkProviderConfigs(CONFIG, { + onlyCurrentCred: true, + credPath: detail.path + }); + } + } } return results; @@ -985,8 +992,15 @@ export async function batchImportKiroRefreshTokensStream(refreshTokens, region = timestamp: new Date().toISOString() }); - // 自动关联新生成的凭据到 Pools(批量导入时扫描所有) - await autoLinkProviderConfigs(CONFIG); + // 自动关联新生成的凭据到 Pools + for (const detail of results.details) { + if (detail.success && detail.path) { + await autoLinkProviderConfigs(CONFIG, { + onlyCurrentCred: true, + credPath: detail.path + }); + } + } } return results; diff --git a/src/auth/oauth-handlers.js b/src/auth/oauth-handlers.js index 4ffef6b..96fcedd 100644 --- a/src/auth/oauth-handlers.js +++ b/src/auth/oauth-handlers.js @@ -10,6 +10,8 @@ export { // Gemini OAuth handleGeminiCliOAuth, handleGeminiAntigravityOAuth, + batchImportGeminiTokensStream, + checkGeminiCredentialsDuplicate, // Qwen OAuth handleQwenOAuth, // Kiro OAuth diff --git a/src/services/ui-manager.js b/src/services/ui-manager.js index 1bc5708..f01c7f0 100644 --- a/src/services/ui-manager.js +++ b/src/services/ui-manager.js @@ -310,6 +310,10 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo return await oauthApi.handleBatchImportKiroTokens(req, res); } + if (method === 'POST' && pathParam === '/api/gemini/batch-import-tokens') { + return await oauthApi.handleBatchImportGeminiTokens(req, res); + } + // Import AWS SSO credentials for Kiro if (method === 'POST' && pathParam === '/api/kiro/import-aws-credentials') { return await oauthApi.handleImportAwsCredentials(req, res); diff --git a/src/ui-modules/oauth-api.js b/src/ui-modules/oauth-api.js index a0c6a07..8485016 100644 --- a/src/ui-modules/oauth-api.js +++ b/src/ui-modules/oauth-api.js @@ -3,6 +3,7 @@ import logger from '../utils/logger.js'; import { handleGeminiCliOAuth, handleGeminiAntigravityOAuth, + batchImportGeminiTokensStream, handleQwenOAuth, handleKiroOAuth, handleIFlowOAuth, @@ -256,69 +257,288 @@ export async function handleBatchImportKiroTokens(req, res) { } /** - * 导入 AWS SSO 凭据用于 Kiro + * 批量导入 Gemini Token(带实时进度 SSE) + */ +export async function handleBatchImportGeminiTokens(req, res) { + try { + const body = await getRequestBody(req); + const { providerType, tokens, skipDuplicateCheck } = body; + + if (!providerType || !tokens || !Array.isArray(tokens) || tokens.length === 0) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: false, + error: 'providerType and tokens array are required and must not be empty' + })); + return true; + } + + logger.info(`[Gemini Batch Import] Starting batch import for ${providerType} with ${tokens.length} tokens...`); + + // 设置 SSE 响应头 + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no' + }); + + // 发送 SSE 事件的辅助函数 + const sendSSE = (event, data) => { + res.write(`event: ${event}\n`); + res.write(`data: ${JSON.stringify(data)}\n\n`); + }; + + // 发送开始事件 + sendSSE('start', { total: tokens.length }); + + // 执行流式批量导入 + const result = await batchImportGeminiTokensStream( + providerType, + tokens, + (progress) => { + sendSSE('progress', progress); + }, + skipDuplicateCheck !== false // 默认为 true + ); + + logger.info(`[Gemini Batch Import] Completed: ${result.success} success, ${result.failed} failed`); + + // 发送完成事件 + sendSSE('complete', { + success: true, + total: result.total, + successCount: result.success, + failedCount: result.failed, + details: result.details + }); + + res.end(); + return true; + + } catch (error) { + logger.error('[Gemini Batch Import] Error:', error); + if (res.headersSent) { + res.write(`event: error\n`); + res.write(`data: ${JSON.stringify({ error: error.message })}\n\n`); + res.end(); + } else { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: false, + error: error.message + })); + } + return true; + } +} + +/** + * 导入 AWS SSO 凭据用于 Kiro(支持单个或批量导入) */ export async function handleImportAwsCredentials(req, res) { try { const body = await getRequestBody(req); const { credentials } = body; - if (!credentials || typeof credentials !== 'object') { + if (!credentials) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: false, - error: 'credentials object is required' + error: 'credentials is required' })); return true; } - // 验证必需字段 - 需要四个字段都存在 - const missingFields = []; - if (!credentials.clientId) missingFields.push('clientId'); - if (!credentials.clientSecret) missingFields.push('clientSecret'); - if (!credentials.accessToken) missingFields.push('accessToken'); - if (!credentials.refreshToken) missingFields.push('refreshToken'); - - if (missingFields.length > 0) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: false, - error: `Missing required fields: ${missingFields.join(', ')}` - })); - return true; - } - - logger.info('[Kiro AWS Import] Starting AWS credentials import...'); - - const result = await importAwsCredentials(credentials); - - if (result.success) { - logger.info(`[Kiro AWS Import] Successfully imported credentials to: ${result.path}`); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ + // 检查是否为批量导入(数组) + if (Array.isArray(credentials)) { + // 批量导入模式 - 使用 SSE 流式响应 + if (credentials.length === 0) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: false, + error: 'credentials array must not be empty' + })); + return true; + } + + // 验证每个凭据对象的必需字段 + const validationErrors = []; + for (let i = 0; i < credentials.length; i++) { + const cred = credentials[i]; + const missingFields = []; + if (!cred.clientId) missingFields.push('clientId'); + if (!cred.clientSecret) missingFields.push('clientSecret'); + if (!cred.accessToken) missingFields.push('accessToken'); + if (!cred.refreshToken) missingFields.push('refreshToken'); + + if (missingFields.length > 0) { + validationErrors.push({ + index: i + 1, + missingFields: missingFields + }); + } + } + + // 如果有验证错误,返回详细信息 + if (validationErrors.length > 0) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: false, + error: `Validation failed for ${validationErrors.length} credential(s)`, + validationErrors: validationErrors + })); + return true; + } + + logger.info(`[Kiro AWS Batch Import] Starting batch import of ${credentials.length} credentials with SSE...`); + + // 设置 SSE 响应头 + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no' + }); + + // 发送 SSE 事件的辅助函数 + const sendSSE = (event, data) => { + res.write(`event: ${event}\n`); + res.write(`data: ${JSON.stringify(data)}\n\n`); + }; + + // 发送开始事件 + sendSSE('start', { total: credentials.length }); + + // 批量导入 + let successCount = 0; + let failedCount = 0; + const details = []; + + for (let i = 0; i < credentials.length; i++) { + const cred = credentials[i]; + const progressData = { + index: i + 1, + total: credentials.length, + current: null + }; + + try { + const result = await importAwsCredentials(cred); + + if (result.success) { + progressData.current = { + index: i + 1, + success: true, + path: result.path + }; + successCount++; + } else { + progressData.current = { + index: i + 1, + success: false, + error: result.error, + existingPath: result.existingPath + }; + failedCount++; + } + } catch (error) { + progressData.current = { + index: i + 1, + success: false, + error: error.message + }; + failedCount++; + } + + details.push(progressData.current); + + // 发送进度更新 + sendSSE('progress', { + ...progressData, + successCount, + failedCount + }); + } + + logger.info(`[Kiro AWS Batch Import] Completed: ${successCount} success, ${failedCount} failed`); + + // 发送完成事件 + sendSSE('complete', { success: true, - path: result.path, - message: 'AWS credentials imported successfully' - })); + total: credentials.length, + successCount, + failedCount, + details + }); + + res.end(); + return true; + + } else if (typeof credentials === 'object') { + // 单个导入模式 + // 验证必需字段 - 需要四个字段都存在 + const missingFields = []; + if (!credentials.clientId) missingFields.push('clientId'); + if (!credentials.clientSecret) missingFields.push('clientSecret'); + if (!credentials.accessToken) missingFields.push('accessToken'); + if (!credentials.refreshToken) missingFields.push('refreshToken'); + + if (missingFields.length > 0) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: false, + error: `Missing required fields: ${missingFields.join(', ')}` + })); + return true; + } + + logger.info('[Kiro AWS Import] Starting AWS credentials import...'); + + const result = await importAwsCredentials(credentials); + + if (result.success) { + logger.info(`[Kiro AWS Import] Successfully imported credentials to: ${result.path}`); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + path: result.path, + message: 'AWS credentials imported successfully' + })); + } else { + // 重复凭据返回 409 Conflict,其他错误返回 500 + const statusCode = result.error === 'duplicate' ? 409 : 500; + res.writeHead(statusCode, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: false, + error: result.error, + existingPath: result.existingPath || null + })); + } + return true; } else { - // 重复凭据返回 409 Conflict,其他错误返回 500 - const statusCode = result.error === 'duplicate' ? 409 : 500; - res.writeHead(statusCode, { 'Content-Type': 'application/json' }); + res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: false, - error: result.error, - existingPath: result.existingPath || null + error: 'credentials must be an object or array' })); + return true; } - return true; } catch (error) { logger.error('[Kiro AWS Import] Error:', error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: false, - error: error.message - })); + // 如果已经开始发送 SSE,则发送错误事件 + if (res.headersSent) { + res.write(`event: error\n`); + res.write(`data: ${JSON.stringify({ error: error.message })}\n\n`); + res.end(); + } else { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: false, + error: error.message + })); + } return true; } } diff --git a/static/app/i18n.js b/static/app/i18n.js index 29c918b..f3f11e0 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -137,7 +137,7 @@ const translations = { 'oauth.kiro.step2': '使用您的 {method} 账号登录', 'oauth.kiro.step3': '授权完成后页面会自动关闭', 'oauth.kiro.step4': '刷新本页面查看凭据文件', - 'oauth.kiro.batchImport': '批量导入 refreshToken', + 'oauth.kiro.batchImport': '批量导入 Google/Github RefreshToken', 'oauth.kiro.batchImportDesc': '批量导入已有的 refreshToken 生成凭据文件,该模式不支持 AWS 账号。', 'oauth.kiro.batchImportInstructions': '请输入 refreshToken,每行一个。系统将自动刷新并生成凭据文件。', 'oauth.kiro.awsImport': '导入 AWS 账号', @@ -168,6 +168,7 @@ const translations = { 'oauth.kiro.awsImporting': '正在导入...', 'oauth.kiro.awsImportSuccess': 'AWS 凭据导入成功!', 'oauth.kiro.awsImportFailed': 'AWS 凭据导入失败', + 'oauth.kiro.awsImportAllFailed': '导入失败!共 {count} 个凭据导入失败', 'oauth.kiro.refreshTokensLabel': 'RefreshToken 列表', 'oauth.kiro.refreshTokensPlaceholder': '每行输入一个 refreshToken\n例如:\naorAxxxxxxxx\naorAyyyyyyyy\naorAzzzzzzzz', 'oauth.kiro.tokenCount': '待导入数量:', @@ -180,6 +181,25 @@ const translations = { 'oauth.kiro.importPartial': '部分成功:{success} 个成功,{failed} 个失败', 'oauth.kiro.importError': '导入出错', 'oauth.kiro.duplicateToken': '重复凭据 - 此 refreshToken 已存在', + 'oauth.gemini.selectMethod': '选择授权方式', + 'oauth.gemini.oauth': 'OAuth 授权', + 'oauth.gemini.oauthDesc': '通过 Google 账号进行标准 OAuth 授权', + 'oauth.gemini.batchImport': '批量导入', + 'oauth.gemini.batchImportDesc': '批量导入多个 Token JSON 数据', + 'oauth.gemini.tokensLabel': 'Token 数据 (JSON 数组)', + 'oauth.gemini.tokensPlaceholder': '请粘贴包含 access_token 和 refresh_token 的 JSON 数组...', + 'oauth.gemini.importInstructions': '请粘贴从浏览器或 CLI 获取的 Token JSON 数据。支持单个对象或对象数组。', + 'oauth.gemini.noTokens': '请输入有效的 Token 数据', + 'oauth.gemini.importing': '正在导入...', + 'oauth.gemini.importingProgress': '正在处理: {current} / {total}', + 'oauth.gemini.importSuccess': '成功导入 {count} 个凭据', + 'oauth.gemini.importAllFailed': '所有 {count} 个凭据导入失败', + 'oauth.gemini.importPartial': '部分导入成功: {success} 成功, {failed} 失败', + 'oauth.gemini.importError': '导入过程中出错', + 'oauth.gemini.tokenCount': 'Token 数量', + 'oauth.gemini.startImport': '开始导入', + 'oauth.gemini.jsonExample': '查看 JSON 格式示例', + 'oauth.gemini.jsonHint': '请确保 JSON 包含 access_token 和 refresh_token', 'oauth.kiro.duplicateCredentials': '该凭据已存在,请勿重复导入', 'oauth.kiro.builderIDStartURL': 'Builder ID Start URL', 'oauth.kiro.builderIDStartURLHint': '如果您使用 AWS IAM Identity Center,请输入您的 Start URL', @@ -637,6 +657,10 @@ const translations = { 'guide.faq.a4': 'A: 在侧边栏点击"可用模型"页面,可以查看所有已配置提供商支持的模型列表。点击模型名称可直接复制。', 'guide.faq.q5': 'Q: 流式响应中断怎么办?', 'guide.faq.a5': 'A: 检查网络稳定性,增加客户端请求超时时间。如使用代理,确保代理支持长连接。', + 'guide.faq.q6': 'Q: 请求返回 "No available and healthy providers" 错误怎么办?', + 'guide.faq.a6': 'A: 这表示对应类型的提供商都不可用。请在"提供商池"页面检查提供商健康状态,确认 OAuth 凭据未过期,或配置 Fallback 链实现自动切换到备用提供商。', + 'guide.faq.q7': 'Q: 请求返回 403 Forbidden 错误怎么办?', + 'guide.faq.a7': 'A: 403 表示访问被拒绝。首先检查"提供商池"页面中节点状态,如果节点健康检查正常,可以忽略此报错。其他可能原因包括:账号权限不足、API Key 权限受限、地区访问限制、凭据已失效等。', // Guide - Flow 'guide.flow.title': '操作流程图', @@ -908,7 +932,7 @@ const translations = { 'oauth.kiro.step2': 'Log in with your {method} account', 'oauth.kiro.step3': 'The page will close automatically after authorization', 'oauth.kiro.step4': 'Refresh this page to view the credentials file', - 'oauth.kiro.batchImport': 'Batch Import refreshToken', + 'oauth.kiro.batchImport': 'Batch Import Google/Github RefreshToken', 'oauth.kiro.batchImportDesc': 'Batch import existing refresh tokens to generate credential files. This mode does not support AWS accounts.', 'oauth.kiro.batchImportInstructions': 'Enter refreshTokens, one per line. The system will automatically refresh and generate credential files.', 'oauth.kiro.awsImport': 'Import AWS Account', @@ -939,6 +963,7 @@ const translations = { 'oauth.kiro.awsImporting': 'Importing...', 'oauth.kiro.awsImportSuccess': 'AWS credentials imported successfully!', 'oauth.kiro.awsImportFailed': 'AWS credentials import failed', + 'oauth.kiro.awsImportAllFailed': 'Import failed! {count} credentials failed to import', 'oauth.kiro.refreshTokensLabel': 'RefreshToken List', 'oauth.kiro.refreshTokensPlaceholder': 'Enter one refreshToken per line\nExample:\naorAxxxxxxxx\naorAyyyyyyyy\naorAzzzzzzzz', 'oauth.kiro.tokenCount': 'Tokens to import:', @@ -951,6 +976,25 @@ const translations = { 'oauth.kiro.importPartial': 'Partial success: {success} succeeded, {failed} failed', 'oauth.kiro.importError': 'Import error', 'oauth.kiro.duplicateToken': 'Duplicate - this refreshToken already exists', + 'oauth.gemini.selectMethod': 'Select Auth Method', + 'oauth.gemini.oauth': 'OAuth Authorization', + 'oauth.gemini.oauthDesc': 'Standard OAuth via Google account', + 'oauth.gemini.batchImport': 'Batch Import', + 'oauth.gemini.batchImportDesc': 'Import multiple Token JSON objects', + 'oauth.gemini.tokensLabel': 'Token Data (JSON Array)', + 'oauth.gemini.tokensPlaceholder': 'Paste JSON array containing access_token and refresh_token...', + 'oauth.gemini.importInstructions': 'Paste Token JSON from browser or CLI. Supports single object or array.', + 'oauth.gemini.noTokens': 'Please enter valid Token data', + 'oauth.gemini.importing': 'Importing...', + 'oauth.gemini.importingProgress': 'Processing: {current} / {total}', + 'oauth.gemini.importSuccess': 'Successfully imported {count} credentials', + 'oauth.gemini.importAllFailed': 'Failed to import all {count} credentials', + 'oauth.gemini.importPartial': 'Partial success: {success} succeeded, {failed} failed', + 'oauth.gemini.importError': 'Import error', + 'oauth.gemini.tokenCount': 'Token Count', + 'oauth.gemini.startImport': 'Start Import', + 'oauth.gemini.jsonExample': 'View JSON Example', + 'oauth.gemini.jsonHint': 'Ensure JSON contains access_token and refresh_token', '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', @@ -1408,6 +1452,10 @@ const translations = { 'guide.faq.a4': 'A: Click "Available Models" in the sidebar to view all models supported by configured providers. Click model name to copy.', 'guide.faq.q5': 'Q: What to do if streaming response is interrupted?', 'guide.faq.a5': 'A: Check network stability, increase client request timeout. If using proxy, ensure it supports long connections.', + 'guide.faq.q6': 'Q: What to do if request returns "No available and healthy providers" error?', + 'guide.faq.a6': 'A: This means all providers of the corresponding type are unavailable. Check provider health status in "Provider Pools" page, confirm OAuth credentials are not expired, or configure Fallback chain for automatic switching to backup providers.', + 'guide.faq.q7': 'Q: What to do if request returns 403 Forbidden error?', + 'guide.faq.a7': 'A: 403 means access denied. First check node status in "Provider Pools" page. If node health check is normal, you can ignore this error. Other possible causes include: insufficient account permissions, limited API Key permissions, regional access restrictions, expired credentials, etc.', // Guide - Flow 'guide.flow.title': 'Operation Flowchart', diff --git a/static/app/provider-manager.js b/static/app/provider-manager.js index 96f97f6..ca40881 100644 --- a/static/app/provider-manager.js +++ b/static/app/provider-manager.js @@ -480,6 +480,12 @@ async function handleGenerateAuthUrl(providerType) { return; } + // 如果是 Gemini OAuth 或 Antigravity,显示认证方式选择对话框 + if (providerType === 'gemini-cli-oauth' || providerType === 'gemini-antigravity') { + showGeminiAuthMethodSelector(providerType); + return; + } + await executeGenerateAuthUrl(providerType, {}); } @@ -521,13 +527,6 @@ function showKiroAuthMethodSelector(providerType) {
${t('oauth.kiro.awsBuilderDesc')}
- + @@ -1242,78 +1651,180 @@ function showKiroAwsImportModal() { return; } - // 检查所有四个必需字段 - const hasClientId = !!mergedCredentials.clientId; - const hasClientSecret = !!mergedCredentials.clientSecret; - const hasAccessToken = !!mergedCredentials.accessToken; - const hasRefreshToken = !!mergedCredentials.refreshToken; + // 检查是否为批量导入(数组) + const isBatchImport = Array.isArray(mergedCredentials); - // 所有四个字段都必须存在 - const isValid = hasClientId && hasClientSecret && hasAccessToken && hasRefreshToken; - - // 构建字段状态列表 - const fieldsList = [ - { key: 'clientId', has: hasClientId }, - { key: 'clientSecret', has: hasClientSecret }, - { key: 'accessToken', has: hasAccessToken }, - { key: 'refreshToken', has: hasRefreshToken } - ]; - - const fieldsHtml = fieldsList.map(f => ` -
  • ${f.key}: ${f.has - ? `✓ ${t('common.found')}` - : `✗ ${t('common.missing')}` - }
  • - `).join(''); - - if (isValid) { - validationResult.style.cssText = 'display: block; margin-bottom: 16px; padding: 12px; border-radius: 8px; background: #f0fdf4; border: 1px solid #bbf7d0; color: #166534;'; - validationResult.innerHTML = ` -
    - - ${t('oauth.kiro.awsValidationSuccess')} -
    -
      - ${fieldsHtml} -
    - `; - submitBtn.disabled = false; + if (isBatchImport) { + // 批量导入模式:验证数组中的每个对象 + let allValid = true; + const credentialsValidation = mergedCredentials.map((cred, index) => { + const hasClientId = !!cred.clientId; + const hasClientSecret = !!cred.clientSecret; + const hasAccessToken = !!cred.accessToken; + const hasRefreshToken = !!cred.refreshToken; + const isValid = hasClientId && hasClientSecret && hasAccessToken && hasRefreshToken; + + if (!isValid) allValid = false; + + return { + index: index + 1, + isValid, + fields: [ + { key: 'clientId', has: hasClientId }, + { key: 'clientSecret', has: hasClientSecret }, + { key: 'accessToken', has: hasAccessToken }, + { key: 'refreshToken', has: hasRefreshToken } + ] + }; + }); + + // 构建批量验证结果HTML + const credentialsHtml = credentialsValidation.map(cv => { + const statusIcon = cv.isValid ? '✓' : '✗'; + const statusColor = cv.isValid ? '#166534' : '#991b1b'; + const fieldsHtml = cv.fields.map(f => ` + ${f.key}: ${f.has + ? `` + : `` + } + `).join(''); + + return ` +
    +
    + ${statusIcon} 凭据 ${cv.index} +
    +
    + ${fieldsHtml} +
    +
    + `; + }).join(''); + + if (allValid) { + validationResult.style.cssText = 'display: block; margin-bottom: 16px; padding: 12px; border-radius: 8px; background: #f0fdf4; border: 1px solid #bbf7d0; color: #166534;'; + validationResult.innerHTML = ` +
    + + 批量验证通过 (${mergedCredentials.length} 个凭据) +
    +
    + ${credentialsHtml} +
    + `; + submitBtn.disabled = false; + } else { + const validCount = credentialsValidation.filter(cv => cv.isValid).length; + const invalidCount = credentialsValidation.length - validCount; + validationResult.style.cssText = 'display: block; margin-bottom: 16px; padding: 12px; border-radius: 8px; background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;'; + validationResult.innerHTML = ` +
    + + 批量验证失败 + (${invalidCount} 个凭据缺少必需字段) +
    +
    + ${credentialsHtml} +
    +

    + + 请确保每个凭据都包含所有必需字段:clientId, clientSecret, accessToken, refreshToken +

    + `; + submitBtn.disabled = true; + } + + // 显示 JSON 预览(批量模式) + jsonPreview.style.display = 'block'; + const previewData = mergedCredentials.map(cred => { + const preview = { ...cred }; + if (preview.clientSecret) { + preview.clientSecret = preview.clientSecret.substring(0, 8) + '...' + preview.clientSecret.slice(-4); + } + if (preview.accessToken) { + preview.accessToken = preview.accessToken.substring(0, 20) + '...' + preview.accessToken.slice(-10); + } + if (preview.refreshToken) { + preview.refreshToken = preview.refreshToken.substring(0, 10) + '...' + preview.refreshToken.slice(-6); + } + return preview; + }); + jsonContent.textContent = JSON.stringify(previewData, null, 2); + } else { - const missingCount = fieldsList.filter(f => !f.has).length; - validationResult.style.cssText = 'display: block; margin-bottom: 16px; padding: 12px; border-radius: 8px; background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;'; - validationResult.innerHTML = ` -
    - - ${t('oauth.kiro.awsValidationFailed')} - (${t('oauth.kiro.awsMissingFields', { count: missingCount })}) -
    -
      - ${fieldsHtml} -
    -

    - - ${t('oauth.kiro.awsUploadMore')} -

    - `; - submitBtn.disabled = true; + // 单个导入模式:原有逻辑 + const hasClientId = !!mergedCredentials.clientId; + const hasClientSecret = !!mergedCredentials.clientSecret; + const hasAccessToken = !!mergedCredentials.accessToken; + const hasRefreshToken = !!mergedCredentials.refreshToken; + + // 所有四个字段都必须存在 + const isValid = hasClientId && hasClientSecret && hasAccessToken && hasRefreshToken; + + // 构建字段状态列表 + const fieldsList = [ + { key: 'clientId', has: hasClientId }, + { key: 'clientSecret', has: hasClientSecret }, + { key: 'accessToken', has: hasAccessToken }, + { key: 'refreshToken', has: hasRefreshToken } + ]; + + const fieldsHtml = fieldsList.map(f => ` +
  • ${f.key}: ${f.has + ? `✓ ${t('common.found')}` + : `✗ ${t('common.missing')}` + }
  • + `).join(''); + + if (isValid) { + validationResult.style.cssText = 'display: block; margin-bottom: 16px; padding: 12px; border-radius: 8px; background: #f0fdf4; border: 1px solid #bbf7d0; color: #166534;'; + validationResult.innerHTML = ` +
    + + ${t('oauth.kiro.awsValidationSuccess')} +
    +
      + ${fieldsHtml} +
    + `; + submitBtn.disabled = false; + } else { + const missingCount = fieldsList.filter(f => !f.has).length; + validationResult.style.cssText = 'display: block; margin-bottom: 16px; padding: 12px; border-radius: 8px; background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;'; + validationResult.innerHTML = ` +
    + + ${t('oauth.kiro.awsValidationFailed')} + (${t('oauth.kiro.awsMissingFields', { count: missingCount })}) +
    +
      + ${fieldsHtml} +
    +

    + + ${t('oauth.kiro.awsUploadMore')} +

    + `; + submitBtn.disabled = true; + } + + // 显示 JSON 预览(单个模式) + jsonPreview.style.display = 'block'; + + // 隐藏敏感信息的部分内容 + const previewData = { ...mergedCredentials }; + if (previewData.clientSecret) { + previewData.clientSecret = previewData.clientSecret.substring(0, 8) + '...' + previewData.clientSecret.slice(-4); + } + if (previewData.accessToken) { + previewData.accessToken = previewData.accessToken.substring(0, 20) + '...' + previewData.accessToken.slice(-10); + } + if (previewData.refreshToken) { + previewData.refreshToken = previewData.refreshToken.substring(0, 10) + '...' + previewData.refreshToken.slice(-6); + } + + jsonContent.textContent = JSON.stringify(previewData, null, 2); } - - // 显示 JSON 预览 - jsonPreview.style.display = 'block'; - - // 隐藏敏感信息的部分内容 - const previewData = { ...mergedCredentials }; - if (previewData.clientSecret) { - previewData.clientSecret = previewData.clientSecret.substring(0, 8) + '...' + previewData.clientSecret.slice(-4); - } - if (previewData.accessToken) { - previewData.accessToken = previewData.accessToken.substring(0, 20) + '...' + previewData.accessToken.slice(-10); - } - if (previewData.refreshToken) { - previewData.refreshToken = previewData.refreshToken.substring(0, 10) + '...' + previewData.refreshToken.slice(-6); - } - - jsonContent.textContent = JSON.stringify(previewData, null, 2); } // 读取文件内容 @@ -1340,40 +1851,222 @@ function showKiroAwsImportModal() { return; } - // 禁用按钮 + // 检查是否为批量导入(数组) + const isBatchImport = Array.isArray(mergedCredentials); + + // 禁用按钮和输入 submitBtn.disabled = true; + cancelBtn.disabled = true; submitBtn.innerHTML = ` ${t('oauth.kiro.awsImporting')}`; + if (currentMode === 'json') { + jsonInputTextarea.disabled = true; + } + + let importSuccess = false; // 标记是否导入成功 + try { - // 确保 authMethod 为 builder-id(AWS 账号模式) - if (!mergedCredentials.authMethod) { - mergedCredentials.authMethod = 'builder-id'; - } - - const response = await window.apiClient.post('/kiro/import-aws-credentials', { - credentials: mergedCredentials - }); - - if (response.success) { - showToast(t('common.success'), t('oauth.kiro.awsImportSuccess'), 'success'); - modal.remove(); + if (isBatchImport) { + // 批量导入模式 - 使用 SSE 流式响应 + // 确保每个凭据都有 authMethod + const credentialsToImport = mergedCredentials.map(cred => ({ + ...cred, + authMethod: cred.authMethod || 'builder-id' + })); + + // 创建进度显示区域 + validationResult.style.cssText = 'display: block; margin-top: 16px; padding: 12px; border-radius: 8px; background: #f3f4f6; border: 1px solid #d1d5db;'; + validationResult.innerHTML = ` +
    + + ${t('oauth.kiro.importingProgress', { current: 0, total: credentialsToImport.length })} +
    +
    +
    +
    +
    + `; + + const progressText = validationResult.querySelector('#awsBatchProgressText'); + const progressBar = validationResult.querySelector('#awsImportProgressBar'); + const resultsList = validationResult.querySelector('#awsBatchResultsList'); + + // 使用 fetch + SSE 获取流式响应 + const response = await fetch('/api/kiro/import-aws-credentials', { + method: 'POST', + headers: window.apiClient ? window.apiClient.getAuthHeaders() : { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ credentials: credentialsToImport }) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + let successCount = 0; + let failedCount = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + // 解析 SSE 事件 + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + let eventType = ''; + let eventData = ''; + + for (const line of lines) { + if (line.startsWith('event: ')) { + eventType = line.substring(7).trim(); + } else if (line.startsWith('data: ')) { + eventData = line.substring(6).trim(); + + if (eventType && eventData) { + try { + const data = JSON.parse(eventData); + + if (eventType === 'start') { + console.log(`[AWS Batch Import] Starting import of ${data.total} credentials`); + } else if (eventType === 'progress') { + const { index, total, current, successCount: sc, failedCount: fc } = data; + successCount = sc; + failedCount = fc; + + // 更新进度条 + const percentage = Math.round((index / total) * 100); + progressBar.style.width = `${percentage}%`; + + // 更新进度文本 + progressText.textContent = t('oauth.kiro.importingProgress', { current: index, total: total }); + + // 添加结果项 + const resultItem = document.createElement('div'); + resultItem.style.cssText = 'padding: 4px 0; border-bottom: 1px solid rgba(0,0,0,0.1);'; + + if (current.success) { + resultItem.innerHTML = `凭据 ${current.index}: ✓ ${current.path}`; + } else if (current.error === 'duplicate') { + resultItem.innerHTML = `凭据 ${current.index}: ⚠ ${t('oauth.kiro.duplicateCredentials')} + ${current.existingPath ? `(${current.existingPath})` : ''}`; + } else { + resultItem.innerHTML = `凭据 ${current.index}: ✗ ${current.error}`; + } + + resultsList.appendChild(resultItem); + resultsList.scrollTop = resultsList.scrollHeight; + + } else if (eventType === 'complete') { + progressBar.style.width = '100%'; + + const isAllSuccess = data.failedCount === 0; + const isAllFailed = data.successCount === 0; + + let resultClass, resultIcon, resultMessage; + if (isAllSuccess) { + resultClass = 'background: #f0fdf4; border: 1px solid #bbf7d0; color: #166534;'; + resultIcon = 'fa-check-circle'; + resultMessage = t('oauth.kiro.awsImportSuccess') + ` (${data.successCount})`; + } else if (isAllFailed) { + resultClass = 'background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;'; + resultIcon = 'fa-times-circle'; + resultMessage = t('oauth.kiro.awsImportAllFailed', { count: data.failedCount }); + } else { + resultClass = 'background: #fffbeb; border: 1px solid #fde68a; color: #92400e;'; + resultIcon = 'fa-exclamation-triangle'; + resultMessage = t('oauth.kiro.importPartial', { success: data.successCount, failed: data.failedCount }); + } + + validationResult.style.cssText = `display: block; margin-top: 16px; padding: 12px; border-radius: 8px; ${resultClass}`; + + const headerDiv = validationResult.querySelector('div:first-child'); + headerDiv.innerHTML = ` ${resultMessage}`; + + // 如果有成功的,标记为成功并刷新提供商列表 + if (data.successCount > 0) { + importSuccess = true; + loadProviders(); + loadConfigList(); + } + + } else if (eventType === 'error') { + throw new Error(data.error); + } + } catch (parseError) { + console.warn('Failed to parse SSE data:', parseError); + } + + eventType = ''; + eventData = ''; + } + } + } + } - // 刷新提供商列表和配置列表 - loadProviders(); - loadConfigList(); - } else if (response.error === 'duplicate') { - // 显示重复凭据警告 - const existingPath = response.existingPath || ''; - showToast(t('common.warning'), t('oauth.kiro.duplicateCredentials') + (existingPath ? ` (${existingPath})` : ''), 'warning'); } else { - showToast(t('common.error'), response.error || t('oauth.kiro.awsImportFailed'), 'error'); + // 单个导入模式 + // 确保 authMethod 为 builder-id(AWS 账号模式) + if (!mergedCredentials.authMethod) { + mergedCredentials.authMethod = 'builder-id'; + } + + const response = await window.apiClient.post('/kiro/import-aws-credentials', { + credentials: mergedCredentials + }); + + if (response.success) { + importSuccess = true; + showToast(t('common.success'), t('oauth.kiro.awsImportSuccess'), 'success'); + modal.remove(); + + // 刷新提供商列表和配置列表 + loadProviders(); + loadConfigList(); + } else if (response.error === 'duplicate') { + // 显示重复凭据警告 + const existingPath = response.existingPath || ''; + showToast(t('common.warning'), t('oauth.kiro.duplicateCredentials') + (existingPath ? ` (${existingPath})` : ''), 'warning'); + } else { + showToast(t('common.error'), response.error || t('oauth.kiro.awsImportFailed'), 'error'); + } } } catch (error) { console.error('AWS import failed:', error); + + // 更新错误显示 + validationResult.style.cssText = 'display: block; margin-top: 16px; padding: 12px; border-radius: 8px; background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;'; + validationResult.innerHTML = ` +
    + + ${t('oauth.kiro.awsImportFailed')}: ${error.message} +
    + `; + showToast(t('common.error'), t('oauth.kiro.awsImportFailed') + ': ' + error.message, 'error'); } finally { - submitBtn.disabled = false; - submitBtn.innerHTML = ` ${t('oauth.kiro.awsConfirmImport')}`; + // 取消按钮始终可用 + cancelBtn.disabled = false; + + // 只有在导入失败时才重新启用提交按钮 + if (!importSuccess) { + submitBtn.disabled = false; + submitBtn.innerHTML = ` ${t('oauth.kiro.awsConfirmImport')}`; + + if (currentMode === 'json') { + jsonInputTextarea.disabled = false; + } + } else { + // 导入成功后,保持提交按钮禁用状态,并显示成功图标 + submitBtn.innerHTML = ` ${t('common.success')}`; + } } }); } diff --git a/static/components/section-guide.html b/static/components/section-guide.html index 296e315..0ebfdf7 100644 --- a/static/components/section-guide.html +++ b/static/components/section-guide.html @@ -212,6 +212,14 @@
    Q: 流式响应中断怎么办?
    A: 检查网络稳定性,增加客户端请求超时时间。如使用代理,确保代理支持长连接。
    +
    +
    Q: 请求返回 "No available and healthy providers" 错误怎么办?
    +
    A: 这表示对应类型的提供商都不可用。请在"提供商池"页面检查提供商健康状态,确认 OAuth 凭据未过期,或配置 Fallback 链实现自动切换到备用提供商。
    +
    +
    +
    Q: 请求返回 403 Forbidden 错误怎么办?
    +
    A: 403 表示访问被拒绝。首先检查"提供商池"页面中节点状态,如果节点健康检查正常,可以忽略此报错。其他可能原因包括:账号权限不足、API Key 权限受限、地区访问限制、凭据已失效等。
    +