feat(gemini-oauth): 添加批量导入功能和文档更新
- 在 Gemini OAuth 模块中添加批量导入 Token 功能,支持流式进度反馈 - 为 Kiro OAuth 的批量导入功能优化自动关联逻辑,改为逐个凭据关联 - 在 Web UI 中添加 Gemini 批量导入的 API 端点 - 更新多语言文档,添加新的 FAQ 条目解决常见错误 - 扩展国际化翻译支持新的界面文本
This commit is contained in:
parent
c632f184c6
commit
ede63ac229
12 changed files with 1380 additions and 151 deletions
24
README-JA.md
24
README-JA.md
|
|
@ -575,6 +575,30 @@ kill -9 <PID>
|
|||
- **リクエストヘッダー形式を確認**:リクエストに正しい形式の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認証情報が取り消されたり期限切れになっている可能性があります。認証の再生成を試してください
|
||||
- **リクエスト頻度を確認**:一部のプロバイダーはリクエスト頻度に厳しい制限があります。リクエスト頻度を下げて再試行
|
||||
- **プロバイダードキュメントを確認**:対応するプロバイダーの公式ドキュメントにアクセスして、具体的なアクセス制限と要件を理解
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
|
|
|||
24
README-ZH.md
24
README-ZH.md
|
|
@ -574,6 +574,30 @@ kill -9 <PID>
|
|||
- **检查请求头格式**:确保请求中包含正确格式的 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 凭据可能已被撤销或失效,尝试重新生成授权
|
||||
- **检查请求频率**:某些提供商对请求频率有严格限制,降低请求频率后重试
|
||||
- **查看提供商文档**:访问对应提供商的官方文档,了解具体的访问限制和要求
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
|
|
|||
24
README.md
24
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
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -290,4 +290,170 @@ export async function handleGeminiCliOAuth(currentConfig, options = {}) {
|
|||
*/
|
||||
export async function handleGeminiAntigravityOAuth(currentConfig, options = {}) {
|
||||
return handleGoogleOAuth('gemini-antigravity', currentConfig, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 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<Object>} 批量处理结果
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@ export {
|
|||
// Gemini OAuth
|
||||
export {
|
||||
handleGeminiCliOAuth,
|
||||
handleGeminiAntigravityOAuth
|
||||
handleGeminiAntigravityOAuth,
|
||||
batchImportGeminiTokensStream,
|
||||
checkGeminiCredentialsDuplicate
|
||||
} from './gemini-oauth.js';
|
||||
|
||||
// Qwen OAuth
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ export {
|
|||
// Gemini OAuth
|
||||
handleGeminiCliOAuth,
|
||||
handleGeminiAntigravityOAuth,
|
||||
batchImportGeminiTokensStream,
|
||||
checkGeminiCredentialsDuplicate,
|
||||
// Qwen OAuth
|
||||
handleQwenOAuth,
|
||||
// Kiro OAuth
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
<div style="font-size: 12px; color: #666;" data-i18n="oauth.kiro.awsBuilderDesc">${t('oauth.kiro.awsBuilderDesc')}</div>
|
||||
</div>
|
||||
</button>
|
||||
<button class="auth-method-btn" data-method="batch-import" style="display: flex; align-items: center; gap: 12px; padding: 16px; border: 2px solid #e0e0e0; border-radius: 8px; background: white; cursor: pointer; transition: all 0.2s;">
|
||||
<i class="fas fa-file-import" style="font-size: 24px; color: #10b981;"></i>
|
||||
<div style="text-align: left;">
|
||||
<div style="font-weight: 600; color: #333;" data-i18n="oauth.kiro.batchImport">${t('oauth.kiro.batchImport')}</div>
|
||||
<div style="font-size: 12px; color: #666;" data-i18n="oauth.kiro.batchImportDesc">${t('oauth.kiro.batchImportDesc')}</div>
|
||||
</div>
|
||||
</button>
|
||||
<button class="auth-method-btn" data-method="aws-import" style="display: flex; align-items: center; gap: 12px; padding: 16px; border: 2px solid #e0e0e0; border-radius: 8px; background: white; cursor: pointer; transition: all 0.2s;">
|
||||
<i class="fas fa-cloud-upload-alt" style="font-size: 24px; color: #ff9900;"></i>
|
||||
<div style="text-align: left;">
|
||||
|
|
@ -535,6 +534,13 @@ function showKiroAuthMethodSelector(providerType) {
|
|||
<div style="font-size: 12px; color: #666;" data-i18n="oauth.kiro.awsImportDesc">${t('oauth.kiro.awsImportDesc')}</div>
|
||||
</div>
|
||||
</button>
|
||||
<button class="auth-method-btn" data-method="batch-import" style="display: flex; align-items: center; gap: 12px; padding: 16px; border: 2px solid #e0e0e0; border-radius: 8px; background: white; cursor: pointer; transition: all 0.2s;">
|
||||
<i class="fas fa-file-import" style="font-size: 24px; color: #10b981;"></i>
|
||||
<div style="text-align: left;">
|
||||
<div style="font-weight: 600; color: #333;" data-i18n="oauth.kiro.batchImport">${t('oauth.kiro.batchImport')}</div>
|
||||
<div style="font-size: 12px; color: #666;" data-i18n="oauth.kiro.batchImportDesc">${t('oauth.kiro.batchImportDesc')}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
|
|
@ -580,6 +586,373 @@ function showKiroAuthMethodSelector(providerType) {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示 Gemini OAuth 认证方式选择对话框
|
||||
* @param {string} providerType - 提供商类型
|
||||
*/
|
||||
function showGeminiAuthMethodSelector(providerType) {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal-overlay';
|
||||
modal.style.display = 'flex';
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content" style="max-width: 500px;">
|
||||
<div class="modal-header">
|
||||
<h3><i class="fas fa-key"></i> <span data-i18n="oauth.gemini.selectMethod">${t('oauth.gemini.selectMethod')}</span></h3>
|
||||
<button class="modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="auth-method-options" style="display: flex; flex-direction: column; gap: 12px;">
|
||||
<button class="auth-method-btn" data-method="oauth" style="display: flex; align-items: center; gap: 12px; padding: 16px; border: 2px solid #e0e0e0; border-radius: 8px; background: white; cursor: pointer; transition: all 0.2s;">
|
||||
<i class="fab fa-google" style="font-size: 24px; color: #4285f4;"></i>
|
||||
<div style="text-align: left;">
|
||||
<div style="font-weight: 600; color: #333;" data-i18n="oauth.gemini.oauth">${t('oauth.gemini.oauth')}</div>
|
||||
<div style="font-size: 12px; color: #666;" data-i18n="oauth.gemini.oauthDesc">${t('oauth.gemini.oauthDesc')}</div>
|
||||
</div>
|
||||
</button>
|
||||
<button class="auth-method-btn" data-method="batch-import" style="display: flex; align-items: center; gap: 12px; padding: 16px; border: 2px solid #e0e0e0; border-radius: 8px; background: white; cursor: pointer; transition: all 0.2s;">
|
||||
<i class="fas fa-file-import" style="font-size: 24px; color: #10b981;"></i>
|
||||
<div style="text-align: left;">
|
||||
<div style="font-weight: 600; color: #333;" data-i18n="oauth.gemini.batchImport">${t('oauth.gemini.batchImport')}</div>
|
||||
<div style="font-size: 12px; color: #666;" data-i18n="oauth.gemini.batchImportDesc">${t('oauth.gemini.batchImportDesc')}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="modal-cancel" data-i18n="modal.provider.cancel">${t('modal.provider.cancel')}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// 关闭按钮事件
|
||||
const closeBtn = modal.querySelector('.modal-close');
|
||||
const cancelBtn = modal.querySelector('.modal-cancel');
|
||||
[closeBtn, cancelBtn].forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
modal.remove();
|
||||
});
|
||||
});
|
||||
|
||||
// 认证方式选择按钮事件
|
||||
const methodBtns = modal.querySelectorAll('.auth-method-btn');
|
||||
methodBtns.forEach(btn => {
|
||||
btn.addEventListener('mouseenter', () => {
|
||||
btn.style.borderColor = '#4285f4';
|
||||
btn.style.background = '#f8faff';
|
||||
});
|
||||
btn.addEventListener('mouseleave', () => {
|
||||
btn.style.borderColor = '#e0e0e0';
|
||||
btn.style.background = 'white';
|
||||
});
|
||||
btn.addEventListener('click', async () => {
|
||||
const method = btn.dataset.method;
|
||||
modal.remove();
|
||||
|
||||
if (method === 'batch-import') {
|
||||
showGeminiBatchImportModal(providerType);
|
||||
} else {
|
||||
await executeGenerateAuthUrl(providerType, {});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示 Gemini 批量导入模态框
|
||||
* @param {string} providerType - 提供商类型
|
||||
*/
|
||||
function showGeminiBatchImportModal(providerType) {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal-overlay';
|
||||
modal.style.display = 'flex';
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content" style="max-width: 600px;">
|
||||
<div class="modal-header">
|
||||
<h3><i class="fas fa-file-import"></i> <span data-i18n="oauth.gemini.batchImport">${t('oauth.gemini.batchImport')}</span> (${providerType})</h3>
|
||||
<button class="modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="batch-import-instructions" style="margin-bottom: 16px; padding: 12px; background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 8px;">
|
||||
<p style="margin: 0; font-size: 14px; color: #1e40af;">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<span data-i18n="oauth.gemini.importInstructions">${t('oauth.gemini.importInstructions')}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="batchGeminiTokens" style="display: block; margin-bottom: 8px; font-weight: 600; color: #374151;">
|
||||
<span data-i18n="oauth.gemini.tokensLabel">${t('oauth.gemini.tokensLabel')}</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="batchGeminiTokens"
|
||||
rows="10"
|
||||
style="width: 100%; padding: 12px; border: 1px solid #d1d5db; border-radius: 8px; font-family: monospace; font-size: 13px; resize: vertical;"
|
||||
placeholder='${t('oauth.gemini.tokensPlaceholder')}'
|
||||
data-i18n-placeholder="oauth.gemini.tokensPlaceholder"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top: 12px; margin-bottom: 16px;">
|
||||
<details style="background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px;">
|
||||
<summary style="padding: 12px; cursor: pointer; font-weight: 600; color: #374151; user-select: none;">
|
||||
<i class="fas fa-code" style="color: #4285f4; margin-right: 8px;"></i>
|
||||
<span data-i18n="oauth.gemini.jsonExample">${t('oauth.gemini.jsonExample')}</span>
|
||||
</summary>
|
||||
<div style="padding: 12px; background: #1f2937; border-radius: 0 0 8px 8px;">
|
||||
<div style="color: #10b981; font-family: monospace; font-size: 12px;">
|
||||
<div style="color: #9ca3af; margin-bottom: 8px;">// 单个凭据导入示例:</div>
|
||||
<pre style="margin: 0; white-space: pre; overflow-x: auto;">{
|
||||
"access_token": "ya29.a0A...",
|
||||
"refresh_token": "1//0...",
|
||||
"scope": "https://www.googleapis.com/auth/cloud-platform",
|
||||
"token_type": "Bearer",
|
||||
"expiry_date": 1738590000000
|
||||
}</pre>
|
||||
</div>
|
||||
<div style="color: #10b981; font-family: monospace; font-size: 12px; margin-top: 16px;">
|
||||
<div style="color: #9ca3af; margin-bottom: 8px;">// 批量导入示例(JSON数组):</div>
|
||||
<pre style="margin: 0; white-space: pre; overflow-x: auto;">[
|
||||
{
|
||||
"access_token": "ya29.a0A1...",
|
||||
"refresh_token": "1//0..."
|
||||
},
|
||||
{
|
||||
"access_token": "ya29.a0A2...",
|
||||
"refresh_token": "1//0..."
|
||||
}
|
||||
]</pre>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="batch-import-stats" id="geminiBatchStats" style="display: none; margin-top: 12px; padding: 12px; background: #f3f4f6; border-radius: 8px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span data-i18n="oauth.gemini.tokenCount">${t('oauth.gemini.tokenCount')}</span>
|
||||
<span id="geminiTokenCountValue" style="font-weight: 600;">0</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="batch-import-progress" id="geminiBatchProgress" style="display: none; margin-top: 16px;">
|
||||
<div style="display: flex; align-items: center; gap: 12px;">
|
||||
<i class="fas fa-spinner fa-spin" style="color: #4285f4;"></i>
|
||||
<span data-i18n="oauth.gemini.importing">${t('oauth.gemini.importing')}</span>
|
||||
</div>
|
||||
<div class="progress-bar" style="margin-top: 8px; height: 8px; background: #e5e7eb; border-radius: 4px; overflow: hidden;">
|
||||
<div id="geminiImportProgressBar" style="height: 100%; width: 0%; background: #4285f4; transition: width 0.3s;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="batch-import-result" id="geminiBatchResult" style="display: none; margin-top: 16px; padding: 12px; border-radius: 8px;"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="modal-cancel" data-i18n="modal.provider.cancel">${t('modal.provider.cancel')}</button>
|
||||
<button class="btn btn-primary batch-import-submit" id="geminiBatchSubmit">
|
||||
<i class="fas fa-upload"></i>
|
||||
<span data-i18n="oauth.gemini.startImport">${t('oauth.gemini.startImport')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
const textarea = modal.querySelector('#batchGeminiTokens');
|
||||
const statsDiv = modal.querySelector('#geminiBatchStats');
|
||||
const tokenCountValue = modal.querySelector('#geminiTokenCountValue');
|
||||
const progressDiv = modal.querySelector('#geminiBatchProgress');
|
||||
const progressBar = modal.querySelector('#geminiImportProgressBar');
|
||||
const resultDiv = modal.querySelector('#geminiBatchResult');
|
||||
const submitBtn = modal.querySelector('#geminiBatchSubmit');
|
||||
const closeBtn = modal.querySelector('.modal-close');
|
||||
const cancelBtn = modal.querySelector('.modal-cancel');
|
||||
|
||||
// 实时统计 token 数量
|
||||
textarea.addEventListener('input', () => {
|
||||
try {
|
||||
const val = textarea.value.trim();
|
||||
if (!val) {
|
||||
statsDiv.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
const data = JSON.parse(val);
|
||||
const tokens = Array.isArray(data) ? data : [data];
|
||||
statsDiv.style.display = 'block';
|
||||
tokenCountValue.textContent = tokens.length;
|
||||
} catch (e) {
|
||||
statsDiv.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// 关闭按钮事件
|
||||
[closeBtn, cancelBtn].forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
modal.remove();
|
||||
});
|
||||
});
|
||||
|
||||
// 提交按钮事件
|
||||
submitBtn.addEventListener('click', async () => {
|
||||
let tokens = [];
|
||||
try {
|
||||
const val = textarea.value.trim();
|
||||
const data = JSON.parse(val);
|
||||
tokens = Array.isArray(data) ? data : [data];
|
||||
} catch (e) {
|
||||
showToast(t('common.error'), t('oauth.gemini.noTokens'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (tokens.length === 0) {
|
||||
showToast(t('common.warning'), t('oauth.gemini.noTokens'), 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// 禁用输入和按钮
|
||||
textarea.disabled = true;
|
||||
submitBtn.disabled = true;
|
||||
cancelBtn.disabled = true;
|
||||
progressDiv.style.display = 'block';
|
||||
resultDiv.style.display = 'none';
|
||||
progressBar.style.width = '0%';
|
||||
|
||||
// 创建实时结果显示区域
|
||||
resultDiv.style.cssText = 'display: block; margin-top: 16px; padding: 12px; border-radius: 8px; background: #f3f4f6; border: 1px solid #d1d5db;';
|
||||
resultDiv.innerHTML = `
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
||||
<i class="fas fa-spinner fa-spin" style="color: #4285f4;"></i>
|
||||
<strong id="geminiBatchProgressText">${t('oauth.gemini.importingProgress', { current: 0, total: tokens.length })}</strong>
|
||||
</div>
|
||||
<div id="geminiBatchResultsList" style="max-height: 200px; overflow-y: auto; font-size: 12px; margin-top: 8px;"></div>
|
||||
`;
|
||||
|
||||
const progressText = resultDiv.querySelector('#geminiBatchProgressText');
|
||||
const resultsList = resultDiv.querySelector('#geminiBatchResultsList');
|
||||
|
||||
let importSuccess = false; // 标记是否导入成功
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/gemini/batch-import-tokens', {
|
||||
method: 'POST',
|
||||
headers: window.apiClient ? window.apiClient.getAuthHeaders() : {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ providerType, tokens })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
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 === 'progress') {
|
||||
const { index, total, current } = data;
|
||||
const percentage = Math.round((index / total) * 100);
|
||||
progressBar.style.width = `${percentage}%`;
|
||||
progressText.textContent = t('oauth.gemini.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 = `Token ${current.index}: <span style="color: #166534;">✓ ${current.path}</span>`;
|
||||
} else if (current.error === 'duplicate') {
|
||||
resultItem.innerHTML = `Token ${current.index}: <span style="color: #d97706;">⚠ ${t('oauth.kiro.duplicateToken')}</span>
|
||||
${current.existingPath ? `<span style="color: #666; font-size: 11px;">(${current.existingPath})</span>` : ''}`;
|
||||
} else {
|
||||
resultItem.innerHTML = `Token ${current.index}: <span style="color: #991b1b;">✗ ${current.error}</span>`;
|
||||
}
|
||||
resultsList.appendChild(resultItem);
|
||||
resultsList.scrollTop = resultsList.scrollHeight;
|
||||
} else if (eventType === 'complete') {
|
||||
progressBar.style.width = '100%';
|
||||
progressDiv.style.display = 'none';
|
||||
|
||||
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.gemini.importSuccess', { count: data.successCount });
|
||||
} else if (isAllFailed) {
|
||||
resultClass = 'background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;';
|
||||
resultIcon = 'fa-times-circle';
|
||||
resultMessage = t('oauth.gemini.importAllFailed', { count: data.failedCount });
|
||||
} else {
|
||||
resultClass = 'background: #fffbeb; border: 1px solid #fde68a; color: #92400e;';
|
||||
resultIcon = 'fa-exclamation-triangle';
|
||||
resultMessage = t('oauth.gemini.importPartial', { success: data.successCount, failed: data.failedCount });
|
||||
}
|
||||
|
||||
resultDiv.style.cssText = `display: block; margin-top: 16px; padding: 12px; border-radius: 8px; ${resultClass}`;
|
||||
const headerDiv = resultDiv.querySelector('div:first-child');
|
||||
headerDiv.innerHTML = `<i class="fas ${resultIcon}"></i> <strong>${resultMessage}</strong>`;
|
||||
|
||||
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 = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Gemini Batch Import] Failed:', error);
|
||||
progressDiv.style.display = 'none';
|
||||
resultDiv.style.cssText = 'display: block; margin-top: 16px; padding: 12px; border-radius: 8px; background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;';
|
||||
resultDiv.innerHTML = `
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<i class="fas fa-times-circle"></i>
|
||||
<strong>${t('oauth.gemini.importError')}: ${error.message}</strong>
|
||||
</div>
|
||||
`;
|
||||
} finally {
|
||||
cancelBtn.disabled = false;
|
||||
|
||||
if (!importSuccess) {
|
||||
textarea.disabled = false;
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = `<i class="fas fa-upload"></i> <span data-i18n="oauth.gemini.startImport">${t('oauth.gemini.startImport')}</span>`;
|
||||
} else {
|
||||
submitBtn.innerHTML = `<i class="fas fa-check-circle"></i> <span>${t('common.success')}</span>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示 Kiro 批量导入 refreshToken 模态框
|
||||
*/
|
||||
|
|
@ -703,6 +1076,7 @@ function showKiroBatchImportModal() {
|
|||
let successCount = 0;
|
||||
let failedCount = 0;
|
||||
const details = [];
|
||||
let importSuccess = false; // 标记是否导入成功
|
||||
|
||||
try {
|
||||
// 使用 fetch + SSE 获取流式响应(需要带认证头)
|
||||
|
|
@ -811,6 +1185,7 @@ function showKiroBatchImportModal() {
|
|||
|
||||
// 如果有成功的,刷新提供商列表
|
||||
if (data.successCount > 0) {
|
||||
importSuccess = true;
|
||||
loadProviders();
|
||||
loadConfigList();
|
||||
}
|
||||
|
|
@ -841,9 +1216,14 @@ function showKiroBatchImportModal() {
|
|||
`;
|
||||
} finally {
|
||||
// 重新启用按钮
|
||||
textarea.disabled = false;
|
||||
submitBtn.disabled = false;
|
||||
cancelBtn.disabled = false;
|
||||
if (!importSuccess) {
|
||||
textarea.disabled = false;
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = `<i class="fas fa-upload"></i> <span data-i18n="oauth.kiro.startImport">${t('oauth.kiro.startImport')}</span>`;
|
||||
} else {
|
||||
submitBtn.innerHTML = `<i class="fas fa-check-circle"></i> <span>${t('common.success')}</span>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -940,7 +1320,10 @@ function showKiroAwsImportModal() {
|
|||
<i class="fas fa-code" style="color: #ff9900; margin-right: 8px;"></i>
|
||||
<span data-i18n="oauth.kiro.awsJsonExample">${t('oauth.kiro.awsJsonExample')}</span>
|
||||
</summary>
|
||||
<pre style="margin: 0; padding: 12px; background: #1f2937; color: #10b981; border-radius: 0 0 8px 8px; font-family: monospace; font-size: 12px; overflow-x: auto; white-space: pre;">{
|
||||
<div style="padding: 12px; background: #1f2937; border-radius: 0 0 8px 8px;">
|
||||
<div style="color: #10b981; font-family: monospace; font-size: 12px; margin-bottom: 12px;">
|
||||
<div style="color: #9ca3af; margin-bottom: 8px;">// 单个凭据导入示例:</div>
|
||||
<pre style="margin: 0; white-space: pre; overflow-x: auto;">{
|
||||
"clientId": "VYZBSTx3Q7QEq1W3Wn8c5nVzLWVhc3QtMQ",
|
||||
"clientSecret": "eyJraWQi...OAMc",
|
||||
"expiresAt": "2026-01-09T04:43:18.079944400+00:00",
|
||||
|
|
@ -950,6 +1333,32 @@ function showKiroAwsImportModal() {
|
|||
"refreshToken": "aorAAAAAGn...uKw+E3",
|
||||
"region": "us-east-1"
|
||||
}</pre>
|
||||
</div>
|
||||
<div style="color: #10b981; font-family: monospace; font-size: 12px; margin-top: 16px;">
|
||||
<div style="color: #9ca3af; margin-bottom: 8px;">// 批量导入示例(JSON数组):</div>
|
||||
<pre style="margin: 0; white-space: pre; overflow-x: auto;">[
|
||||
{
|
||||
"clientId": "VYZBSTx3Q7QEq1W3Wn8c5nVzLWVhc3QtMQ",
|
||||
"clientSecret": "eyJraWQi...OAMc",
|
||||
"accessToken": "aoaAAAAAGlgghoSqRgQK...2tfhmdNZDA",
|
||||
"refreshToken": "aorAAAAAGn...uKw+E3",
|
||||
"region": "us-east-1"
|
||||
},
|
||||
{
|
||||
"clientId": "AnotherClientId123",
|
||||
"clientSecret": "eyJraWQi...xyz",
|
||||
"accessToken": "aoaAAAAAGlgghoSqRgQK...abc",
|
||||
"refreshToken": "aorAAAAAGn...def",
|
||||
"region": "us-west-2",
|
||||
"idcRegion": "us-west-2"
|
||||
}
|
||||
]</pre>
|
||||
</div>
|
||||
<div style="color: #fbbf24; font-size: 11px; margin-top: 12px; padding: 8px; background: rgba(251, 191, 36, 0.1); border-radius: 4px;">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<strong>注意:</strong>AWS企业用户需要额外添加 <code style="background: rgba(0,0,0,0.3); padding: 2px 4px; border-radius: 2px;">idcRegion</code> 字段
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
|
|
@ -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 => `
|
||||
<li>${f.key}: ${f.has
|
||||
? `<code style="background: #dcfce7; padding: 1px 4px; border-radius: 2px; color: #166534;">✓ ${t('common.found')}</code>`
|
||||
: `<code style="background: #fecaca; padding: 1px 4px; border-radius: 2px; color: #991b1b;">✗ ${t('common.missing')}</code>`
|
||||
}</li>
|
||||
`).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 = `
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
<strong data-i18n="oauth.kiro.awsValidationSuccess">${t('oauth.kiro.awsValidationSuccess')}</strong>
|
||||
</div>
|
||||
<ul style="margin: 8px 0 0 24px; font-size: 13px; list-style: none; padding: 0;">
|
||||
${fieldsHtml}
|
||||
</ul>
|
||||
`;
|
||||
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 => `
|
||||
<span style="margin-right: 8px;">${f.key}: ${f.has
|
||||
? `<code style="background: #dcfce7; padding: 1px 4px; border-radius: 2px; color: #166534;">✓</code>`
|
||||
: `<code style="background: #fecaca; padding: 1px 4px; border-radius: 2px; color: #991b1b;">✗</code>`
|
||||
}</span>
|
||||
`).join('');
|
||||
|
||||
return `
|
||||
<div style="padding: 8px; margin-bottom: 4px; background: ${cv.isValid ? '#f0fdf4' : '#fef2f2'}; border: 1px solid ${cv.isValid ? '#bbf7d0' : '#fecaca'}; border-radius: 4px;">
|
||||
<div style="font-weight: 600; color: ${statusColor}; margin-bottom: 4px;">
|
||||
${statusIcon} 凭据 ${cv.index}
|
||||
</div>
|
||||
<div style="font-size: 12px; color: #6b7280;">
|
||||
${fieldsHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).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 = `
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 12px;">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
<strong>批量验证通过 (${mergedCredentials.length} 个凭据)</strong>
|
||||
</div>
|
||||
<div style="max-height: 200px; overflow-y: auto;">
|
||||
${credentialsHtml}
|
||||
</div>
|
||||
`;
|
||||
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 = `
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 12px;">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<strong>批量验证失败</strong>
|
||||
<span style="font-weight: normal; font-size: 12px;">(${invalidCount} 个凭据缺少必需字段)</span>
|
||||
</div>
|
||||
<div style="max-height: 200px; overflow-y: auto;">
|
||||
${credentialsHtml}
|
||||
</div>
|
||||
<p style="margin: 12px 0 0 0; font-size: 12px; padding: 8px; background: #fee2e2; border-radius: 4px;">
|
||||
<i class="fas fa-lightbulb" style="color: #dc2626;"></i>
|
||||
请确保每个凭据都包含所有必需字段:clientId, clientSecret, accessToken, refreshToken
|
||||
</p>
|
||||
`;
|
||||
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 = `
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<strong>${t('oauth.kiro.awsValidationFailed')}</strong>
|
||||
<span style="font-weight: normal; font-size: 12px;">(${t('oauth.kiro.awsMissingFields', { count: missingCount })})</span>
|
||||
</div>
|
||||
<ul style="margin: 8px 0 0 24px; font-size: 13px; list-style: none; padding: 0;">
|
||||
${fieldsHtml}
|
||||
</ul>
|
||||
<p style="margin: 12px 0 0 0; font-size: 12px; padding: 8px; background: #fee2e2; border-radius: 4px;">
|
||||
<i class="fas fa-lightbulb" style="color: #dc2626;"></i>
|
||||
<span data-i18n="oauth.kiro.awsUploadMore">${t('oauth.kiro.awsUploadMore')}</span>
|
||||
</p>
|
||||
`;
|
||||
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 => `
|
||||
<li>${f.key}: ${f.has
|
||||
? `<code style="background: #dcfce7; padding: 1px 4px; border-radius: 2px; color: #166534;">✓ ${t('common.found')}</code>`
|
||||
: `<code style="background: #fecaca; padding: 1px 4px; border-radius: 2px; color: #991b1b;">✗ ${t('common.missing')}</code>`
|
||||
}</li>
|
||||
`).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 = `
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
<strong data-i18n="oauth.kiro.awsValidationSuccess">${t('oauth.kiro.awsValidationSuccess')}</strong>
|
||||
</div>
|
||||
<ul style="margin: 8px 0 0 24px; font-size: 13px; list-style: none; padding: 0;">
|
||||
${fieldsHtml}
|
||||
</ul>
|
||||
`;
|
||||
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 = `
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<strong>${t('oauth.kiro.awsValidationFailed')}</strong>
|
||||
<span style="font-weight: normal; font-size: 12px;">(${t('oauth.kiro.awsMissingFields', { count: missingCount })})</span>
|
||||
</div>
|
||||
<ul style="margin: 8px 0 0 24px; font-size: 13px; list-style: none; padding: 0;">
|
||||
${fieldsHtml}
|
||||
</ul>
|
||||
<p style="margin: 12px 0 0 0; font-size: 12px; padding: 8px; background: #fee2e2; border-radius: 4px;">
|
||||
<i class="fas fa-lightbulb" style="color: #dc2626;"></i>
|
||||
<span data-i18n="oauth.kiro.awsUploadMore">${t('oauth.kiro.awsUploadMore')}</span>
|
||||
</p>
|
||||
`;
|
||||
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 = `<i class="fas fa-spinner fa-spin"></i> <span>${t('oauth.kiro.awsImporting')}</span>`;
|
||||
|
||||
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 = `
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
||||
<i class="fas fa-spinner fa-spin" style="color: #ff9900;"></i>
|
||||
<strong id="awsBatchProgressText">${t('oauth.kiro.importingProgress', { current: 0, total: credentialsToImport.length })}</strong>
|
||||
</div>
|
||||
<div class="progress-bar" style="margin: 8px 0; height: 8px; background: #e5e7eb; border-radius: 4px; overflow: hidden;">
|
||||
<div id="awsImportProgressBar" style="height: 100%; width: 0%; background: #ff9900; transition: width 0.3s;"></div>
|
||||
</div>
|
||||
<div id="awsBatchResultsList" style="max-height: 200px; overflow-y: auto; font-size: 12px; margin-top: 8px;"></div>
|
||||
`;
|
||||
|
||||
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}: <span style="color: #166534;">✓ ${current.path}</span>`;
|
||||
} else if (current.error === 'duplicate') {
|
||||
resultItem.innerHTML = `凭据 ${current.index}: <span style="color: #d97706;">⚠ ${t('oauth.kiro.duplicateCredentials')}</span>
|
||||
${current.existingPath ? `<span style="color: #666; font-size: 11px;">(${current.existingPath})</span>` : ''}`;
|
||||
} else {
|
||||
resultItem.innerHTML = `凭据 ${current.index}: <span style="color: #991b1b;">✗ ${current.error}</span>`;
|
||||
}
|
||||
|
||||
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 = `<i class="fas ${resultIcon}"></i> <strong>${resultMessage}</strong>`;
|
||||
|
||||
// 如果有成功的,标记为成功并刷新提供商列表
|
||||
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 = `
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<i class="fas fa-times-circle"></i>
|
||||
<strong>${t('oauth.kiro.awsImportFailed')}: ${error.message}</strong>
|
||||
</div>
|
||||
`;
|
||||
|
||||
showToast(t('common.error'), t('oauth.kiro.awsImportFailed') + ': ' + error.message, 'error');
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = `<i class="fas fa-check"></i> <span data-i18n="oauth.kiro.awsConfirmImport">${t('oauth.kiro.awsConfirmImport')}</span>`;
|
||||
// 取消按钮始终可用
|
||||
cancelBtn.disabled = false;
|
||||
|
||||
// 只有在导入失败时才重新启用提交按钮
|
||||
if (!importSuccess) {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = `<i class="fas fa-check"></i> <span data-i18n="oauth.kiro.awsConfirmImport">${t('oauth.kiro.awsConfirmImport')}</span>`;
|
||||
|
||||
if (currentMode === 'json') {
|
||||
jsonInputTextarea.disabled = false;
|
||||
}
|
||||
} else {
|
||||
// 导入成功后,保持提交按钮禁用状态,并显示成功图标
|
||||
submitBtn.innerHTML = `<i class="fas fa-check-circle"></i> <span>${t('common.success')}</span>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -212,6 +212,14 @@
|
|||
<div class="faq-question" data-i18n="guide.faq.q5">Q: 流式响应中断怎么办?</div>
|
||||
<div class="faq-answer" data-i18n="guide.faq.a5">A: 检查网络稳定性,增加客户端请求超时时间。如使用代理,确保代理支持长连接。</div>
|
||||
</div>
|
||||
<div class="faq-item">
|
||||
<div class="faq-question" data-i18n="guide.faq.q6">Q: 请求返回 "No available and healthy providers" 错误怎么办?</div>
|
||||
<div class="faq-answer" data-i18n="guide.faq.a6">A: 这表示对应类型的提供商都不可用。请在"提供商池"页面检查提供商健康状态,确认 OAuth 凭据未过期,或配置 Fallback 链实现自动切换到备用提供商。</div>
|
||||
</div>
|
||||
<div class="faq-item">
|
||||
<div class="faq-question" data-i18n="guide.faq.q7">Q: 请求返回 403 Forbidden 错误怎么办?</div>
|
||||
<div class="faq-answer" data-i18n="guide.faq.a7">A: 403 表示访问被拒绝。首先检查"提供商池"页面中节点状态,如果节点健康检查正常,可以忽略此报错。其他可能原因包括:账号权限不足、API Key 权限受限、地区访问限制、凭据已失效等。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue